Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/app/api/volunteers/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* route.ts
*
* routing for GET endpoint for volunteer profiles
* Authors: William Balz and Elijah Chandler
* Date created: 7 November 2026
*/
import { NextResponse } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { validateVolunteerFields } from "@/lib/applicant-validation";
import { prisma } from "@/lib/prisma";

type RouteContext = { params: Promise<{ id: string }> };

// The fields of a volunteer's profile. GET and PATCH both use it, so they always send back the same shape.
const VOLUNTEER_PROFILE_SELECT = {
id: true,
status: true,
firstName: true,
lastName: true,
email: true,
cellPhone: true,
homePhone: true,
photoUrl: true,
photoConsent: true,
emergencyContactName: true,
emergencyContactPhone: true,
emergencyContactRelationship: true,
positions: { select: { id: true, name: true } },
} satisfies Prisma.VolunteerSelect;

/**
* GETs volunteer if the session is a volunteer and the volunteer to GET is unique
* @param _request - the incoming request
* @param params - volunteer id
* @returns 200; response if successful with volunteer
* Erros:
* 404 if not found
*/
export async function GET(_request: Request, { params }: RouteContext) {
// TODO(natalie): require the volunteer session with requireVolunteer() and check that its volunteerId matches this id (BE-volunteer-profile)

// In Next.js 15+, params is a Promise, so you have to await it
const { id } = await params;

const volunteer = await prisma.volunteer.findUnique({
where: {
id: id,
},
select: VOLUNTEER_PROFILE_SELECT,
});

if (!volunteer) {
return Response.json({ error: "Volunteer not found" }, { status: 404 });
}

return Response.json(
{ volunteer },
{ status: 200, headers: { "Cache-Control": "no-store" } },
);
}

/**
* allow volunteers to edit editable volunteer fields
* @param request - incoming request
* @param param1 - volunteer id
* Errors:
* 400 if invalid json
* 404 if not found
* 409 if not unique
*/
export async function PATCH(request: Request, { params }: RouteContext) {
// TODO(natalie): require the volunteer session with requireVolunteer() and check that its volunteerId matches this id (BE-volunteer-profile)

const { id } = await params;

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Request body must be valid JSON" },
{ status: 400 },
);
}
const volunteerData = validateVolunteerFields(body);
if ("error" in volunteerData) {
return NextResponse.json(
{ error: String(volunteerData.error) },
{ status: 400 },
);
}
const { data } = volunteerData;
try {
const updated = await prisma.volunteer.update({
where: { id },
data: data as Parameters<typeof prisma.volunteer.update>[0]["data"],
select: VOLUNTEER_PROFILE_SELECT,
});
return Response.json(
{ volunteer: updated },
{ status: 200, headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "P2025") {
return NextResponse.json(
{ error: "Volunteer does not exist" },
{ status: 404 },
);
}
if (error instanceof Error && "code" in error && error.code === "P2002") {
return NextResponse.json(
{ error: "Email + cell phone pair belongs to another volunteer" },
{ status: 409 },
);
}
throw error;
}
}
134 changes: 134 additions & 0 deletions src/lib/applicant-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
*/

import { describe, expect, it } from "@jest/globals";

import {
validateApplicantFields,
validateVolunteerFields,
findMissingFields,
ApplicantForCompleteness,
} from "./applicant-validation";
Expand Down Expand Up @@ -503,3 +505,135 @@ describe("validateApplicantFields", () => {
expect(result).toEqual({ error: expect.stringContaining("availableDays") });
});
});
describe("validateVolunteerFields", () => {
it("accepts a partial update and normalizes email and phone", () => {
const result = validateVolunteerFields({
firstName: " Pat ",
email: " New.Email@Example.com ",
cellPhone: "(617) 555-0199",
});

expect(result).toEqual({
data: {
firstName: "Pat",
email: "new.email@example.com",
cellPhone: "6175550199",
},
});
});

it("rejects a bad email", () => {
const result = validateVolunteerFields({ email: "invalid-email" });

expect(result).toEqual({
error: expect.stringContaining("email"),
});
});

it("rejects a phone number with only 9 digits", () => {
const result = validateVolunteerFields({ cellPhone: "617555012" });

expect(result).toEqual({
error: expect.stringContaining("cellPhone"),
});
});

it("rejects a required text field that's only spaces", () => {
const result = validateVolunteerFields({ firstName: " " });

expect(result).toEqual({
error: expect.stringContaining("firstName"),
});
});

it("rejects a required field that is null", () => {
const result = validateVolunteerFields({ lastName: null });

expect(result).toEqual({
error: expect.stringContaining("lastName"),
});
});

it("allows homePhone to be cleared with null", () => {
const result = validateVolunteerFields({ homePhone: null });

expect(result).toEqual({
data: { homePhone: null },
});
});

it.each(["status", "positions", "id", "unknownField"])(
"rejects a read-only or unknown field: %s",
(field) => {
const result = validateVolunteerFields({ [field]: "test" });

expect(result).toEqual({
error: expect.stringContaining(field),
});
},
);

it("rejects a body that isn't an object (null)", () => {
const result = validateVolunteerFields(null);

expect(result).toEqual({
error: expect.stringContaining("Body"),
});
});

it("rejects a body that isn't an object (an array)", () => {
const result = validateVolunteerFields([]);

expect(result).toEqual({
error: expect.stringContaining("Body"),
});
});

it("rejects a body that isn't an object (a string)", () => {
const result = validateVolunteerFields("test");

expect(result).toEqual({
error: expect.stringContaining("Body"),
});
});

it("rejects an empty object", () => {
const result = validateVolunteerFields({});

expect(result).toEqual({
error: expect.stringContaining("at least one field"),
});
});

it("rejects a photoConsent that isn't a boolean", () => {
const result = validateVolunteerFields({ photoConsent: "yes" });

expect(result).toEqual({
error: expect.stringContaining("photoConsent"),
});
});

it("accepts true or false for photoConsent", () => {
expect(validateVolunteerFields({ photoConsent: true })).toEqual({
data: { photoConsent: true },
});

expect(validateVolunteerFields({ photoConsent: false })).toEqual({
data: { photoConsent: false },
});
});

it("allows other optional fields to be cleared with null", () => {
const result = validateVolunteerFields({
photoUrl: null,
emergencyContactRelationship: null,
});

expect(result).toEqual({
data: {
photoUrl: null,
emergencyContactRelationship: null,
},
});
});
});
99 changes: 99 additions & 0 deletions src/lib/applicant-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ const ALLOWED_FIELDS = [
"availableDays",
];

// The only fields a volunteer may change about themselves
const VOLUNTEER_EDITABLE_FIELDS = [
"firstName",
"lastName",
"email",
"cellPhone",
"homePhone",
"photoUrl",
"photoConsent",
"emergencyContactName",
"emergencyContactPhone",
"emergencyContactRelationship",
];

/**
* Validates applicant fields and normalizes email and cell phone values.
*
Expand Down Expand Up @@ -254,3 +268,88 @@ export function findMissingFields(

return missing;
}

/**
* validates inputs to applicant fields for editing
*
* @param body - input of proposed edited field
* @returns - 200; cleaned data to update field with
* Errors:
* errors if rules are broken regarding input morphology
*/
export function validateVolunteerFields(body: unknown) {
if (
typeof body !== "object" ||
body === null ||
Array.isArray(body) ||
Object.getPrototypeOf(body) !== Object.prototype
) {
return { error: "Body must be an object" };
}
const input = body as Record<string, unknown>;

if (Object.keys(input).length == 0) {
return { error: "There must be at least one field provided" };
}

for (const field of Object.keys(input)) {
if (!VOLUNTEER_EDITABLE_FIELDS.includes(field)) {
return { error: `${field} is not an allowed field` };
}
}

// The cleaned-up values to return. Only fields that were sent end up here.
const data: Record<string, unknown> = {};

// Check each field that was sent, one at a time, and return at the first problem.
for (const [field, value] of Object.entries(input)) {
if (
field === "firstName" ||
field === "lastName" ||
field === "emergencyContactName" ||
field === "emergencyContactPhone"
) {
// Blank fields look "filled in" but are useless to an admin, so trim first
if (typeof value !== "string" || value.trim() === "") {
return { error: `${field} must be a non-empty string` };
}
data[field] = value.trim();
} else if (field === "email") {
if (
typeof value !== "string" ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizeEmail(value))
) {
return { error: "email must be a valid email address" };
}
data[field] = normalizeEmail(value);
} else if (field === "cellPhone") {
if (typeof value !== "string") {
return { error: "cellPhone must be a string" };
}
const normalized = normalizeCellPhone(value);
if (normalized.length < 10) {
return { error: "cellPhone must have at least 10 digits" };
}
data[field] = normalized;
} else if (field === "photoConsent") {
if (typeof value !== "boolean") {
return { error: `${field} must be a boolean` };
}
data[field] = value;
} else if (
field === "homePhone" ||
field === "emergencyContactRelationship" ||
field === "photoUrl"
) {
if (value == null) {
data[field] = null;
} else if (typeof value !== "string" || value.trim() === "") {
return { error: `${field} must be a non-empty string` };
} else {
data[field] = value.trim();
}
}
}

return { data };
}
2 changes: 2 additions & 0 deletions src/lib/time-conversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* Author: Natalie
* Date Created: 11/5/2026
*/

import { describe, expect, it } from "@jest/globals";
import {
formatMonth,
formatPantry,
Expand Down
Loading