Skip to content
Open
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
1 change: 1 addition & 0 deletions src/lib/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Add any exports from your API files here

export { getExample } from "./getExample";
export { updateVolunteer } from "./updateVolunteer";
295 changes: 295 additions & 0 deletions src/lib/api/updateVolunteer.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When updating a row in the Volunteers table, we need to set the updated_at field to the timestamp of the update.

Original file line number Diff line number Diff line change
@@ -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<VolunteerUpdatePayload>;
role?: RoleInput;
cohort?: CohortInput;
error?: string;
};

// keep this in sync with allowed patch fields on the volunteers table
const ALLOWED_VOLUNTEER_FIELDS = new Set<keyof VolunteerUpdatePayload>([
"name_org",
"email",
"phone",
"pronouns",
"pseudonym",
"position",
"notes",
"opt_in_communication",
]);
const ALLOWED_TOP_LEVEL_FIELDS = new Set<string>([
...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<string, unknown>;
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<VolunteerUpdatePayload> = {};
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" };
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message is inconsistent with the validation logic. The message says "must be provided as a non-empty string" but the actual validation checks happen across three separate conditions (lines 57, 60, 63). Additionally, this error is returned for both null and undefined, but neither of those cases means the field was "provided". Consider making the error message more specific:

  • Line 58: "Field name_org cannot be null or undefined"
  • Line 61: "Field name_org must be a string"
  • Line 64: "Field name_org cannot be empty"

This provides clearer feedback about what exactly is wrong with the input.

Suggested change
return { error: "Field name_org must be provided as a non-empty string" };
return { error: "Field name_org cannot be null or undefined" };

Copilot uses AI. Check for mistakes.
}
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<string, unknown>;

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<string, unknown>;
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<UpdateVolunteerResult> {
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 } };
}
Loading