diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 53e202a..8d521f2 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,3 +1,4 @@ // Add any exports from your API files here export { getExample } from "./getExample"; +export { updateVolunteer } from "./updateVolunteer"; diff --git a/src/lib/api/updateVolunteer.ts b/src/lib/api/updateVolunteer.ts new file mode 100644 index 0000000..0e19efb --- /dev/null +++ b/src/lib/api/updateVolunteer.ts @@ -0,0 +1,295 @@ +import { createClient } from "../client/supabase/server"; +import type { Tables, TablesUpdate } from "../client/supabase/types"; + +const ROLE_TYPES = ["prior", "current", "future_interest"] as const; +const COHORT_TERMS = ["fall", "summer", "winter", "spring"] as const; + +type VolunteerUpdatePayload = Pick< + TablesUpdate<"Volunteers">, + | "name_org" + | "email" + | "phone" + | "pronouns" + | "pseudonym" + | "position" + | "notes" + | "opt_in_communication" +>; + +type RoleInput = { name: string; type: (typeof ROLE_TYPES)[number] }; +type CohortInput = { year: number; term: (typeof COHORT_TERMS)[number] }; + +type UpdateVolunteerResult = + | { status: 200; body: { volunteer: Tables<"Volunteers"> } } + | { status: 400 | 404 | 500; body: { error: string } }; + +type VolunteerValidationResult = { + updates?: Partial; + role?: RoleInput; + cohort?: CohortInput; + error?: string; +}; + +// keep this in sync with allowed patch fields on the volunteers table +const ALLOWED_VOLUNTEER_FIELDS = new Set([ + "name_org", + "email", + "phone", + "pronouns", + "pseudonym", + "position", + "notes", + "opt_in_communication", +]); +const ALLOWED_TOP_LEVEL_FIELDS = new Set([ + ...ALLOWED_VOLUNTEER_FIELDS, + "role", + "cohort", +]); + +function validateVolunteerUpdateBody(body: unknown): VolunteerValidationResult { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { error: "Request body must be a JSON object" }; + } + + const payload = body as Record; + const unknownKeys = Object.keys(payload).filter( + (key) => !ALLOWED_TOP_LEVEL_FIELDS.has(key) + ); + + if (unknownKeys.length > 0) { + return { + error: `Unknown field(s): ${unknownKeys.join(", ")}`, + }; + } + + // name_org is the only required patchable field; validate it eagerly + const updates: Partial = {}; + if ("name_org" in payload) { + const value = payload["name_org"]; + if (value === null || value === undefined) { + return { error: "Field name_org must be provided as a non-empty string" }; + } + if (typeof value !== "string") { + return { error: "Field name_org must be a string" }; + } + if (value.trim().length === 0) { + return { error: "Field name_org cannot be empty" }; + } + updates.name_org = value; + } + + // optional string-ish fields can be patched with string or null + const stringFields = [ + "email", + "phone", + "pronouns", + "pseudonym", + "position", + "notes", + ] as const; + + for (const key of stringFields) { + if (key in payload) { + const value = payload[key]; + + if (value === undefined || value === null) { + updates[key] = null; + } else if (typeof value === "string") { + updates[key] = value; + } else { + return { error: `Field ${key} must be a string or null` }; + } + } + } + + if ("opt_in_communication" in payload) { + const value = payload["opt_in_communication"]; + if (value === undefined || value === null) { + updates.opt_in_communication = null; + } else if (typeof value === "boolean") { + updates.opt_in_communication = value; + } else { + return { + error: "Field opt_in_communication must be a boolean or null", + }; + } + } + + const hasFields = Object.keys(updates).length > 0; + let role: RoleInput | undefined; + let cohort: CohortInput | undefined; + + if ("role" in payload) { + const r = payload["role"]; + if (!r || typeof r !== "object" || Array.isArray(r)) { + return { error: "Field role must be an object" }; + } + const { name, type } = r as Record; + + if (typeof name !== "string" || name.trim().length === 0) { + return { error: "Field role.name must be a non-empty string" }; + } + if ( + typeof type !== "string" || + !ROLE_TYPES.includes(type as (typeof ROLE_TYPES)[number]) + ) { + return { + error: `Field role.type must be one of ${ROLE_TYPES.join(", ")}`, + }; + } + role = { name, type: type as RoleInput["type"] }; + } + + if ("cohort" in payload) { + const c = payload["cohort"]; + if (!c || typeof c !== "object" || Array.isArray(c)) { + return { error: "Field cohort must be an object" }; + } + const { year, term } = c as Record; + if (!Number.isInteger(year)) { + return { error: "Field cohort.year must be an integer" }; + } + if ( + typeof term !== "string" || + !COHORT_TERMS.includes(term as (typeof COHORT_TERMS)[number]) + ) { + return { + error: `Field cohort.term must be one of ${COHORT_TERMS.join(", ")}`, + }; + } + cohort = { year: year as number, term: term as CohortInput["term"] }; + } + + if (!hasFields && !role && !cohort) { + return { + error: + "At least one updatable field is required (volunteer fields, role, or cohort)", + }; + } + + const result: VolunteerValidationResult = { updates }; + if (role) { + result.role = role; + } + if (cohort) { + result.cohort = cohort; + } + + return result; +} + +export async function updateVolunteer( + volunteerId: unknown, + body: unknown +): Promise { + if (!Number.isInteger(volunteerId) || (volunteerId as number) <= 0) { + return { status: 400, body: { error: "Invalid volunteer id" } }; + } + + const validation = validateVolunteerUpdateBody(body); + if (!validation.updates) { + return { + status: 400, + body: { error: validation.error ?? "Invalid volunteer update payload" }, + }; + } + + const client = await createClient(); + const timestamp = new Date().toISOString(); + + const { data: volunteer, error: volunteerError } = await client + .from("Volunteers") + .update({ ...validation.updates, updated_at: timestamp }) + .eq("id", volunteerId as number) + .select() + .maybeSingle(); + + if (volunteerError) { + return { status: 500, body: { error: volunteerError.message } }; + } + + if (!volunteer) { + return { status: 404, body: { error: "Volunteer not found" } }; + } + + if (validation.role) { + const { name, type } = validation.role; + const { data: roleRow, error: roleLookupError } = await client + .from("Roles") + .select("id") + .eq("name", name) + .eq("type", type) + .maybeSingle(); + + if (roleLookupError) { + return { status: 500, body: { error: roleLookupError.message } }; + } + + if (!roleRow) { + return { status: 400, body: { error: "Role not found" } }; + } + + const { error: roleDeleteError } = await client + .from("VolunteerRoles") + .delete() + .eq("volunteer_id", volunteerId as number); + + if (roleDeleteError) { + return { status: 500, body: { error: roleDeleteError.message } }; + } + + const { error: roleInsertError } = await client + .from("VolunteerRoles") + .insert({ + volunteer_id: volunteerId as number, + role_id: roleRow.id, + created_at: timestamp, + }); + + if (roleInsertError) { + return { status: 500, body: { error: roleInsertError.message } }; + } + } + + if (validation.cohort) { + const { year, term } = validation.cohort; + const { data: cohortRow, error: cohortLookupError } = await client + .from("Cohorts") + .select("id") + .eq("year", year) + .eq("term", term) + .maybeSingle(); + + if (cohortLookupError) { + return { status: 500, body: { error: cohortLookupError.message } }; + } + + if (!cohortRow) { + return { status: 400, body: { error: "Cohort not found" } }; + } + + const { error: cohortDeleteError } = await client + .from("VolunteerCohorts") + .delete() + .eq("volunteer_id", volunteerId as number); + + if (cohortDeleteError) { + return { status: 500, body: { error: cohortDeleteError.message } }; + } + + const { error: cohortInsertError } = await client + .from("VolunteerCohorts") + .insert({ + volunteer_id: volunteerId as number, + cohort_id: cohortRow.id, + assigned_at: timestamp, + }); + + if (cohortInsertError) { + return { status: 500, body: { error: cohortInsertError.message } }; + } + } + + return { status: 200, body: { volunteer } }; +} diff --git a/tests/lib/api/updateVolunteer.test.ts b/tests/lib/api/updateVolunteer.test.ts new file mode 100644 index 0000000..367dbad --- /dev/null +++ b/tests/lib/api/updateVolunteer.test.ts @@ -0,0 +1,424 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { updateVolunteer } from "@/lib/api/updateVolunteer"; +import { createClient } from "@/lib/client/supabase/server"; +import type { Tables } from "@/lib/client/supabase/types"; + +vi.mock("@/lib/client/supabase/server", () => ({ + createClient: vi.fn(), +})); + +const FIXED_TIME = new Date("2024-01-02T03:04:05.000Z"); + +const baseVolunteerRow: Tables<"Volunteers"> = { + created_at: "2024-01-01T00:00:00.000Z", + email: null, + id: 1, + name_org: "Existing", + notes: null, + opt_in_communication: null, + phone: null, + position: null, + pronouns: null, + pseudonym: null, + updated_at: "2024-01-01T00:00:00.000Z", +}; + +type MockOptions = { + volunteerMaybeSingleData?: Tables<"Volunteers"> | null; + volunteerMaybeSingleError?: unknown; + roleMaybeSingleData?: { id: number } | null; + roleMaybeSingleError?: unknown; + cohortMaybeSingleData?: { id: number } | null; + cohortMaybeSingleError?: unknown; + roleDeleteError?: unknown; + roleInsertError?: unknown; + cohortDeleteError?: unknown; + cohortInsertError?: unknown; +}; + +type MockFn = ReturnType; + +type ClientMocks = { + client: { from: MockFn }; + spies: { + volunteerUpdate: MockFn; + volunteerEq: MockFn; + volunteerSelect: MockFn; + volunteerMaybeSingle: MockFn; + roleSelect: MockFn; + roleEqFirst: MockFn; + roleEqSecond: MockFn; + roleMaybeSingle: MockFn; + roleDelete: MockFn; + roleDeleteEq: MockFn; + roleInsert: MockFn; + cohortSelect: MockFn; + cohortEqFirst: MockFn; + cohortEqSecond: MockFn; + cohortMaybeSingle: MockFn; + cohortDelete: MockFn; + cohortDeleteEq: MockFn; + cohortInsert: MockFn; + }; + captured: { + volunteerUpdatePayload: unknown; + volunteerEqArgs: unknown; + roleInsertPayload: unknown; + cohortInsertPayload: unknown; + }; +}; + +function buildMockClient(opts: MockOptions = {}): ClientMocks { + const { + volunteerMaybeSingleData = baseVolunteerRow, + volunteerMaybeSingleError = null, + roleMaybeSingleData = { id: 10 }, + roleMaybeSingleError = null, + cohortMaybeSingleData = { id: 20 }, + cohortMaybeSingleError = null, + roleDeleteError = null, + roleInsertError = null, + cohortDeleteError = null, + cohortInsertError = null, + } = opts; + + const volunteerMaybeSingle = vi.fn().mockResolvedValue({ + data: volunteerMaybeSingleData, + error: volunteerMaybeSingleError, + }); + const volunteerSelect = vi + .fn() + .mockReturnValue({ maybeSingle: volunteerMaybeSingle }); + const volunteerEq = vi.fn().mockReturnValue({ select: volunteerSelect }); + const volunteerUpdate = vi.fn((payload) => ({ eq: volunteerEq, payload })); + + const roleMaybeSingle = vi.fn().mockResolvedValue({ + data: roleMaybeSingleData, + error: roleMaybeSingleError, + }); + const roleEqSecond = vi + .fn() + .mockReturnValue({ maybeSingle: roleMaybeSingle }); + const roleEqFirst = vi.fn().mockReturnValue({ eq: roleEqSecond }); + const roleSelect = vi.fn().mockReturnValue({ eq: roleEqFirst }); + + const roleDeleteEq = vi.fn().mockResolvedValue({ error: roleDeleteError }); + const roleDelete = vi.fn().mockReturnValue({ eq: roleDeleteEq }); + const roleInsert = vi.fn().mockResolvedValue({ error: roleInsertError }); + + const cohortMaybeSingle = vi.fn().mockResolvedValue({ + data: cohortMaybeSingleData, + error: cohortMaybeSingleError, + }); + const cohortEqSecond = vi + .fn() + .mockReturnValue({ maybeSingle: cohortMaybeSingle }); + const cohortEqFirst = vi.fn().mockReturnValue({ eq: cohortEqSecond }); + const cohortSelect = vi.fn().mockReturnValue({ eq: cohortEqFirst }); + + const cohortDeleteEq = vi + .fn() + .mockResolvedValue({ error: cohortDeleteError }); + const cohortDelete = vi.fn().mockReturnValue({ eq: cohortDeleteEq }); + const cohortInsert = vi.fn().mockResolvedValue({ error: cohortInsertError }); + + const client = { + from: vi.fn((table: string) => { + switch (table) { + case "Volunteers": + return { update: volunteerUpdate }; + case "Roles": + return { select: roleSelect }; + case "VolunteerRoles": + return { delete: roleDelete, insert: roleInsert }; + case "Cohorts": + return { select: cohortSelect }; + case "VolunteerCohorts": + return { delete: cohortDelete, insert: cohortInsert }; + default: + throw new Error(`Unexpected table ${table}`); + } + }), + }; + + return { + client, + spies: { + volunteerUpdate, + volunteerEq, + volunteerSelect, + volunteerMaybeSingle, + roleSelect, + roleEqFirst, + roleEqSecond, + roleMaybeSingle, + roleDelete, + roleDeleteEq, + roleInsert, + cohortSelect, + cohortEqFirst, + cohortEqSecond, + cohortMaybeSingle, + cohortDelete, + cohortDeleteEq, + cohortInsert, + }, + captured: { + get volunteerUpdatePayload(): unknown { + return volunteerUpdate.mock.calls.at(-1)?.[0]; + }, + get volunteerEqArgs(): unknown { + return volunteerEq.mock.calls.at(-1); + }, + get roleInsertPayload(): unknown { + return roleInsert.mock.calls.at(-1)?.[0]; + }, + get cohortInsertPayload(): unknown { + return cohortInsert.mock.calls.at(-1)?.[0]; + }, + }, + }; +} + +describe("updateVolunteer", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_TIME); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function resolveClient(mock: ClientMocks): void { + vi.mocked(createClient).mockResolvedValue( + mock.client as unknown as Awaited> + ); + } + + type UpdateResult = Awaited>; + + function getError(result: UpdateResult): string { + if ("error" in result.body) { + return result.body.error; + } + throw new Error("Expected error result but received success"); + } + + function getVolunteer(result: UpdateResult): Tables<"Volunteers"> { + if ("volunteer" in result.body) { + return result.body.volunteer; + } + throw new Error("Expected success result but received error"); + } + + it("returns 400 for invalid volunteer id without touching Supabase", async () => { + const result = await updateVolunteer("bad-id", {}); + + expect(result.status).toBe(400); + expect(getError(result)).toContain("Invalid volunteer id"); + expect(createClient).not.toHaveBeenCalled(); + }); + + it("returns 400 for unknown body fields before creating client", async () => { + const result = await updateVolunteer(1, { foo: "bar" }); + + expect(result.status).toBe(400); + expect(getError(result)).toContain("Unknown field"); + expect(createClient).not.toHaveBeenCalled(); + }); + + it("updates volunteer fields and stamps updated_at", async () => { + const updatedVolunteer = { + ...baseVolunteerRow, + name_org: "Updated Name", + phone: "123", + updated_at: FIXED_TIME.toISOString(), + }; + const mock = buildMockClient({ + volunteerMaybeSingleData: updatedVolunteer, + }); + resolveClient(mock); + + const result = await updateVolunteer(1, { + name_org: "Updated Name", + phone: "123", + }); + + expect(createClient).toHaveBeenCalled(); + expect(result).toEqual({ + status: 200, + body: { volunteer: updatedVolunteer }, + }); + expect(mock.spies.volunteerUpdate).toHaveBeenCalledTimes(1); + expect(mock.captured.volunteerUpdatePayload).toMatchObject({ + name_org: "Updated Name", + phone: "123", + updated_at: FIXED_TIME.toISOString(), + }); + }); + + it("returns 400 when role does not exist", async () => { + const mock = buildMockClient({ roleMaybeSingleData: null }); + resolveClient(mock); + + const result = await updateVolunteer(1, { + role: { name: "Advocate", type: "prior" }, + name_org: "Name", + }); + + expect(result.status).toBe(400); + expect(getError(result)).toBe("Role not found"); + expect(mock.spies.roleDelete).not.toHaveBeenCalled(); + expect(mock.spies.roleInsert).not.toHaveBeenCalled(); + }); + + it("updates role and cohort when both provided", async () => { + const updatedVolunteer = { + ...baseVolunteerRow, + updated_at: FIXED_TIME.toISOString(), + }; + const mock = buildMockClient({ + volunteerMaybeSingleData: updatedVolunteer, + roleMaybeSingleData: { id: 5 }, + cohortMaybeSingleData: { id: 7 }, + }); + resolveClient(mock); + + const result = await updateVolunteer(1, { + name_org: "Keep Name", + role: { name: "Advocate", type: "current" }, + cohort: { year: 2024, term: "fall" }, + }); + + expect(result.status).toBe(200); + expect(mock.spies.roleDelete).toHaveBeenCalledTimes(1); + expect(mock.spies.roleInsert).toHaveBeenCalledTimes(1); + expect(mock.captured.roleInsertPayload).toEqual({ + volunteer_id: 1, + role_id: 5, + created_at: FIXED_TIME.toISOString(), + }); + + expect(mock.spies.cohortDelete).toHaveBeenCalledTimes(1); + expect(mock.spies.cohortInsert).toHaveBeenCalledTimes(1); + expect(mock.captured.cohortInsertPayload).toEqual({ + volunteer_id: 1, + cohort_id: 7, + assigned_at: FIXED_TIME.toISOString(), + }); + expect(getVolunteer(result)).toEqual(updatedVolunteer); + }); + + it("returns 400 when no updatable fields, role, or cohort provided", async () => { + const result = await updateVolunteer(1, {}); + + expect(result.status).toBe(400); + expect(getError(result)).toContain("At least one updatable field"); + expect(createClient).not.toHaveBeenCalled(); + }); + + it("returns 400 for invalid role type", async () => { + const result = await updateVolunteer(1, { + name_org: "Name", + role: { name: "Advocate", type: "past" }, + }); + + expect(result.status).toBe(400); + expect(getError(result)).toContain("role.type"); + expect(createClient).not.toHaveBeenCalled(); + }); + + it("returns 400 for invalid cohort term", async () => { + const result = await updateVolunteer(1, { + cohort: { year: 2024, term: "autumn" }, + }); + + expect(result.status).toBe(400); + expect(getError(result)).toContain("cohort.term"); + expect(createClient).not.toHaveBeenCalled(); + }); + + it("returns 404 when volunteer is not found", async () => { + const mock = buildMockClient({ volunteerMaybeSingleData: null }); + resolveClient(mock); + + const result = await updateVolunteer(1, { name_org: "Name" }); + + expect(result.status).toBe(404); + expect(getError(result)).toBe("Volunteer not found"); + }); + + it("returns 500 when volunteer update fails", async () => { + const mock = buildMockClient({ + volunteerMaybeSingleError: { message: "db error" }, + }); + resolveClient(mock); + + const result = await updateVolunteer(1, { name_org: "Name" }); + + expect(result.status).toBe(500); + expect(getError(result)).toBe("db error"); + }); + + it("returns 500 when role delete fails", async () => { + const mock = buildMockClient({ + roleDeleteError: { message: "cannot delete role link" }, + }); + resolveClient(mock); + + const result = await updateVolunteer(1, { + name_org: "Name", + role: { name: "Advocate", type: "current" }, + }); + + expect(result.status).toBe(500); + expect(getError(result)).toBe("cannot delete role link"); + }); + + it("returns 500 when role insert fails", async () => { + const mock = buildMockClient({ + roleInsertError: { message: "cannot insert role link" }, + }); + resolveClient(mock); + + const result = await updateVolunteer(1, { + name_org: "Name", + role: { name: "Advocate", type: "current" }, + }); + + expect(result.status).toBe(500); + expect(getError(result)).toBe("cannot insert role link"); + }); + + it("returns 500 when cohort delete fails", async () => { + const mock = buildMockClient({ + cohortDeleteError: { message: "cannot delete cohort link" }, + }); + resolveClient(mock); + + const result = await updateVolunteer(1, { + name_org: "Name", + cohort: { year: 2024, term: "fall" }, + }); + + expect(result.status).toBe(500); + expect(getError(result)).toBe("cannot delete cohort link"); + }); + + it("returns 500 when cohort insert fails", async () => { + const mock = buildMockClient({ + cohortInsertError: { message: "cannot insert cohort link" }, + }); + resolveClient(mock); + + const result = await updateVolunteer(1, { + name_org: "Name", + cohort: { year: 2024, term: "fall" }, + }); + + expect(result.status).toBe(500); + expect(getError(result)).toBe("cannot insert cohort link"); + }); +});