diff --git a/package.json b/package.json index 53fa81a..2dc9f35 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "check": "npm run format:check && npm run lint && npm run typecheck", "fix": "npm run format && npm run lint:fix", "supabase:setup": "bash scripts/supabase-setup.sh", - "supabase:status": "npx supabase status" + "supabase:status": "npx supabase status", + "supabase:db:reset": "npx supabase db reset", + "supabase:migration:up": "npx supabase migration up" }, "dependencies": { "@supabase/ssr": "^0.7.0", diff --git a/scripts/supabase-setup.sh b/scripts/supabase-setup.sh index 2825913..269cc79 100755 --- a/scripts/supabase-setup.sh +++ b/scripts/supabase-setup.sh @@ -9,8 +9,14 @@ fi echo "Starting local Supabase" npx supabase start +echo "Applying migrations" +if ! npx supabase migration up; then + npx supabase db pull + npx supabase db reset + npx supabase migration up +fi + echo "Dump local Supabase env variables" npx supabase status -o env >> .env -echo "Local test Supabase ready" - +echo "Local Supabase ready (migrations applied)" diff --git a/src/lib/api/createVolunteer.ts b/src/lib/api/createVolunteer.ts new file mode 100644 index 0000000..19a7da9 --- /dev/null +++ b/src/lib/api/createVolunteer.ts @@ -0,0 +1,363 @@ +// API function to create a new volunteer in the database +import { createClient } from "@/lib/client/supabase"; +import type { Json, TablesInsert } from "@/lib/client/supabase/types"; + +// Valid role types +const VALID_ROLE_TYPES = ["prior", "current", "future_interest"] as const; +export type RoleType = (typeof VALID_ROLE_TYPES)[number]; + +// Valid cohort terms +const VALID_COHORT_TERMS = ["Fall", "Summer", "Winter", "Spring"] as const; +export type CohortTerm = (typeof VALID_COHORT_TERMS)[number]; + +// Role input type +export type RoleInput = { + name: string; + type: RoleType; +}; + +// Cohort input type +export type CohortInput = { + year: number; + term: CohortTerm; +}; + +// Type for the volunteer data we expect to receive +export type VolunteerInput = Omit< + TablesInsert<"Volunteers">, + "id" | "created_at" | "updated_at" +>; + +// Combined input type for creating a volunteer with role and cohort +export type CreateVolunteerInput = { + volunteer: VolunteerInput; + role: RoleInput; + cohort: CohortInput; +}; + +// Validation error type +export type ValidationError = { + field: string; + message: string; +}; + +// Response type for the API function +export type CreateVolunteerResponse = + | { success: true; data: { id: number } } + | { + success: false; + error: string; + validationErrors?: ValidationError[]; + dbError?: unknown; + }; + +/** + * Validates volunteer input data + * @param data - The volunteer data to validate + * @returns An array of validation errors (empty if valid) + */ +function validateVolunteerData( + data: Record +): ValidationError[] { + const errors: ValidationError[] = []; + + // name_org + if (!data["name_org"] || typeof data["name_org"] !== "string") { + errors.push({ + field: "volunteer.name_org", + message: "Name/Organization is required and must be a string", + }); + } else if ((data["name_org"] as string).trim().length === 0) { + errors.push({ + field: "volunteer.name_org", + message: "Name/Organization cannot be empty", + }); + } + + // email + if (data["email"] !== undefined && data["email"] !== null) { + if (typeof data["email"] !== "string") { + errors.push({ + field: "volunteer.email", + message: "Email must be a string", + }); + } + } + + // phone + if (data["phone"] !== undefined && data["phone"] !== null) { + if (typeof data["phone"] !== "string") { + errors.push({ + field: "volunteer.phone", + message: "Phone must be a string", + }); + } + } + + // opt_in_communication (optional; when provided must be boolean) + if ( + data["opt_in_communication"] !== undefined && + data["opt_in_communication"] !== null + ) { + if (typeof data["opt_in_communication"] !== "boolean") { + errors.push({ + field: "volunteer.opt_in_communication", + message: "opt_in_communication must be a boolean", + }); + } + } + + // optional string fields + const optionalStringFields = [ + "position", + "pronouns", + "pseudonym", + "notes", + ] as const; + for (const field of optionalStringFields) { + if (data[field] !== undefined && data[field] !== null) { + if (typeof data[field] !== "string") { + errors.push({ + field: `volunteer.${field}`, + message: `${field} must be a string`, + }); + } + } + } + + return errors; +} + +/** + * Validates role input data + * @param role - The role data to validate + * @returns An array of validation errors (empty if valid) + */ +function validateRoleInput(role: unknown): ValidationError[] { + const errors: ValidationError[] = []; + + if (!role || typeof role !== "object") { + errors.push({ + field: "role", + message: "Role is required and must be an object", + }); + return errors; + } + + const roleData = role as Record; + + // name + if (!roleData["name"] || typeof roleData["name"] !== "string") { + errors.push({ + field: "role.name", + message: "Role name is required and must be a string", + }); + } else if ((roleData["name"] as string).trim().length === 0) { + errors.push({ + field: "role.name", + message: "Role name cannot be empty", + }); + } + + // type + if (!roleData["type"] || typeof roleData["type"] !== "string") { + errors.push({ + field: "role.type", + message: "Role type is required and must be a string", + }); + } else if ( + !VALID_ROLE_TYPES.includes( + roleData["type"] as (typeof VALID_ROLE_TYPES)[number] + ) + ) { + errors.push({ + field: "role.type", + message: `Role type must be one of: ${VALID_ROLE_TYPES.join(", ")}`, + }); + } + + return errors; +} + +/** + * Validates cohort input data + * @param cohort - The cohort data to validate + * @returns An array of validation errors (empty if valid) + */ +function validateCohortInput(cohort: unknown): ValidationError[] { + const errors: ValidationError[] = []; + + if (!cohort || typeof cohort !== "object") { + errors.push({ + field: "cohort", + message: "Cohort is required and must be an object", + }); + return errors; + } + + const cohortData = cohort as Record; + + // year + if (cohortData["year"] === undefined || cohortData["year"] === null) { + errors.push({ + field: "cohort.year", + message: "Cohort year is required", + }); + } else if ( + typeof cohortData["year"] !== "number" || + !Number.isInteger(cohortData["year"]) + ) { + errors.push({ + field: "cohort.year", + message: "Cohort year must be an integer", + }); + } + + // term + if (!cohortData["term"] || typeof cohortData["term"] !== "string") { + errors.push({ + field: "cohort.term", + message: "Cohort term is required and must be a string", + }); + } else if ( + !VALID_COHORT_TERMS.includes( + cohortData["term"] as (typeof VALID_COHORT_TERMS)[number] + ) + ) { + errors.push({ + field: "cohort.term", + message: `Cohort term must be one of: ${VALID_COHORT_TERMS.join(", ")}`, + }); + } + + return errors; +} + +/** + * Validates the complete input for creating a volunteer + * @param input - The input data to validate + * @returns An array of validation errors (empty if valid) + */ +function validateInput(input: unknown): ValidationError[] { + const errors: ValidationError[] = []; + + if (!input || typeof input !== "object") { + errors.push({ + field: "general", + message: "Request body must be a valid JSON object", + }); + return errors; + } + + const data = input as Record; + + // Validate volunteer data + if (!data["volunteer"] || typeof data["volunteer"] !== "object") { + errors.push({ + field: "volunteer", + message: "Volunteer data is required and must be an object", + }); + } else { + errors.push( + ...validateVolunteerData(data["volunteer"] as Record) + ); + } + + // Validate role + errors.push(...validateRoleInput(data["role"])); + + // Validate cohort + errors.push(...validateCohortInput(data["cohort"])); + + return errors; +} + +/** + * Builds the volunteer JSON payload for the RPC (only allowed columns). + */ +function volunteerToJson(volunteer: VolunteerInput): Record { + return { + name_org: volunteer.name_org, + pseudonym: volunteer.pseudonym ?? null, + pronouns: volunteer.pronouns ?? null, + email: volunteer.email ?? null, + phone: volunteer.phone ?? null, + position: volunteer.position ?? null, + opt_in_communication: volunteer.opt_in_communication ?? true, + notes: volunteer.notes ?? null, + }; +} + +/** + * Creates a new volunteer in the database with associated role and cohort. + * Runs in a single transaction: either all tables are updated or none. + * If the role or cohort does not exist, it is created. + * + * @param input - The volunteer, role, and cohort data to insert + * @returns A response object indicating success or failure + */ +export async function createVolunteer( + input: CreateVolunteerInput +): Promise { + try { + // Validate input + const validationErrors = validateInput(input); + if (validationErrors.length > 0) { + return { + success: false, + error: "Validation failed", + validationErrors, + }; + } + + const { volunteer, role, cohort } = input; + const client = await createClient(); + + const { data: volunteerId, error } = await client.rpc( + "create_volunteer_with_role_and_cohort", + { + p_volunteer: volunteerToJson(volunteer) as Json, + p_role_name: role.name, + p_role_type: role.type, + p_cohort_year: cohort.year, + p_cohort_term: cohort.term, + } + ); + + if (error) { + console.error("Database error while creating volunteer:", error); + + if (error.code === "23505") { + return { + success: false, + error: "A volunteer with this information already exists", + dbError: error, + }; + } + + return { + success: false, + error: error.message ?? "Failed to create volunteer in database", + dbError: error, + }; + } + + if (volunteerId === null || volunteerId === undefined) { + return { + success: false, + error: "Failed to retrieve volunteer ID after insertion", + }; + } + + return { + success: true, + data: { id: Number(volunteerId) }, + }; + } catch (error) { + console.error("Unexpected error while creating volunteer:", error); + return { + success: false, + error: "An unexpected error occurred", + }; + } +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index b20078b..8139e5c 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -4,3 +4,4 @@ export { getExample } from "./getExample"; export { getVolunteersByCohorts } from "./getVolunteersByCohorts"; export { getVolunteersByMultipleColumns } from "./getVolunteersByMultipleColumns"; export { getVolunteersByRoles } from "./getVolunteersByRoles"; +export { createVolunteer } from "./createVolunteer"; diff --git a/src/lib/client/supabase/types.ts b/src/lib/client/supabase/types.ts index ec2468e..faa0f40 100644 --- a/src/lib/client/supabase/types.ts +++ b/src/lib/client/supabase/types.ts @@ -221,7 +221,16 @@ export type Database = { [_ in never]: never; }; Functions: { - [_ in never]: never; + create_volunteer_with_role_and_cohort: { + Args: { + p_volunteer: Json; + p_role_name: string; + p_role_type: string; + p_cohort_year: number; + p_cohort_term: string; + }; + Returns: number; + }; }; Enums: { [_ in never]: never; diff --git a/supabase/migrations/20260202120000_create_volunteer_with_role_and_cohort.sql b/supabase/migrations/20260202120000_create_volunteer_with_role_and_cohort.sql new file mode 100644 index 0000000..9cc2a38 --- /dev/null +++ b/supabase/migrations/20260202120000_create_volunteer_with_role_and_cohort.sql @@ -0,0 +1,88 @@ +-- Create volunteer with role and cohort in a single transaction. +-- If the role (by name) or cohort (by year, term) does not exist, they are created. +-- Returns the new volunteer id. + +CREATE OR REPLACE FUNCTION public.create_volunteer_with_role_and_cohort( + p_volunteer jsonb, + p_role_name text, + p_role_type text, + p_cohort_year smallint, + p_cohort_term text +) +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_role_id bigint; + v_cohort_id bigint; + v_volunteer_id bigint; +BEGIN + -- Get or create role (Roles has UNIQUE on name) + INSERT INTO public."Roles" (name, type) + VALUES (p_role_name, p_role_type) + ON CONFLICT (name) DO UPDATE SET type = EXCLUDED.type + RETURNING id INTO v_role_id; + + -- Get or create cohort (no unique on year+term; we select first or insert) + SELECT id INTO v_cohort_id + FROM public."Cohorts" + WHERE year = p_cohort_year AND term = p_cohort_term + LIMIT 1; + + IF v_cohort_id IS NULL THEN + INSERT INTO public."Cohorts" (year, term) + VALUES (p_cohort_year, p_cohort_term) + RETURNING id INTO v_cohort_id; + END IF; + + IF v_cohort_id IS NULL THEN + RAISE EXCEPTION 'Failed to get or create cohort'; + END IF; + + -- Insert volunteer + INSERT INTO public."Volunteers" ( + name_org, + pseudonym, + pronouns, + email, + phone, + position, + opt_in_communication, + notes + ) + VALUES ( + (p_volunteer ->> 'name_org'), + NULLIF(TRIM(p_volunteer ->> 'pseudonym'), ''), + NULLIF(TRIM(p_volunteer ->> 'pronouns'), ''), + NULLIF(TRIM(p_volunteer ->> 'email'), ''), + NULLIF(TRIM(p_volunteer ->> 'phone'), ''), + NULLIF(TRIM(p_volunteer ->> 'position'), ''), + COALESCE((p_volunteer ->> 'opt_in_communication')::boolean, true), + NULLIF(TRIM(p_volunteer ->> 'notes'), '') + ) + RETURNING id INTO v_volunteer_id; + + IF v_volunteer_id IS NULL THEN + RAISE EXCEPTION 'Failed to insert volunteer'; + END IF; + + -- Link volunteer to role + INSERT INTO public."VolunteerRoles" (volunteer_id, role_id) + VALUES (v_volunteer_id, v_role_id); + + -- Link volunteer to cohort + INSERT INTO public."VolunteerCohorts" (volunteer_id, cohort_id) + VALUES (v_volunteer_id, v_cohort_id); + + RETURN v_volunteer_id; +END; +$$; + +COMMENT ON FUNCTION public.create_volunteer_with_role_and_cohort(jsonb, text, text, smallint, text) IS + 'Creates a volunteer with role and cohort in one transaction. Creates role or cohort if they do not exist.'; + +GRANT EXECUTE ON FUNCTION public.create_volunteer_with_role_and_cohort(jsonb, text, text, smallint, text) TO anon; +GRANT EXECUTE ON FUNCTION public.create_volunteer_with_role_and_cohort(jsonb, text, text, smallint, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.create_volunteer_with_role_and_cohort(jsonb, text, text, smallint, text) TO service_role; diff --git a/tests/lib/api/createVolunteer.test.ts b/tests/lib/api/createVolunteer.test.ts new file mode 100644 index 0000000..d825a8a --- /dev/null +++ b/tests/lib/api/createVolunteer.test.ts @@ -0,0 +1,548 @@ +// Tests for createVolunteer API function + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createVolunteer } from "@/lib/api/createVolunteer"; +import type { CreateVolunteerInput } from "@/lib/api/createVolunteer"; +import { + createServiceTestClient, + deleteWhere, + deleteWhereGte, +} from "../support/helpers"; +import { + makeTestRoleInsert, + makeTestCohortInsert, + TEST_YEAR, +} from "../support/factories"; + +describe("createVolunteer", () => { + // Test validation - missing volunteer data + it("should fail when volunteer data is missing", async () => { + const input = { + role: { name: "Test Role", type: "current" }, + cohort: { year: 2024, term: "Fall" }, + } as CreateVolunteerInput; + + const result = await createVolunteer(input); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBe("Validation failed"); + expect(result.validationErrors).toBeDefined(); + expect( + result.validationErrors!.some((e) => e.field === "volunteer") + ).toBe(true); + } + }); + + // Test validation - missing name_org + it("should fail when name_org is missing", async () => { + const input: CreateVolunteerInput = { + volunteer: { + email: "test@example.com", + } as CreateVolunteerInput["volunteer"], + role: { name: "Test Role", type: "current" }, + cohort: { year: 2024, term: "Fall" }, + }; + + const result = await createVolunteer(input); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBe("Validation failed"); + expect( + result.validationErrors!.some((e) => e.field === "volunteer.name_org") + ).toBe(true); + } + }); + + // Test validation - empty name_org + it("should fail when name_org is empty", async () => { + const input: CreateVolunteerInput = { + volunteer: { name_org: " " }, + role: { name: "Test Role", type: "current" }, + cohort: { year: 2024, term: "Fall" }, + }; + + const result = await createVolunteer(input); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.validationErrors!.some( + (e) => + e.field === "volunteer.name_org" && + e.message.includes("cannot be empty") + ) + ).toBe(true); + } + }); + + // Test validation - missing role + it("should fail when role is missing", async () => { + const input = { + volunteer: { name_org: "Test Volunteer" }, + cohort: { year: 2024, term: "Fall" }, + } as CreateVolunteerInput; + + const result = await createVolunteer(input); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.validationErrors!.some((e) => e.field === "role")).toBe( + true + ); + } + }); + + // Test validation - invalid role type + it("should fail when role type is invalid", async () => { + const input = { + volunteer: { name_org: "Test Volunteer" }, + role: { name: "Test Role", type: "invalid_type" }, + cohort: { year: 2024, term: "Fall" }, + } as unknown as CreateVolunteerInput; + + const result = await createVolunteer(input); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.validationErrors!.some((e) => e.field === "role.type") + ).toBe(true); + } + }); + + // Test validation - missing cohort + it("should fail when cohort is missing", async () => { + const input = { + volunteer: { name_org: "Test Volunteer" }, + role: { name: "Test Role", type: "current" }, + } as CreateVolunteerInput; + + const result = await createVolunteer(input); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.validationErrors!.some((e) => e.field === "cohort")).toBe( + true + ); + } + }); + + // Test validation - invalid cohort term + it("should fail when cohort term is invalid", async () => { + const input = { + volunteer: { name_org: "Test Volunteer" }, + role: { name: "Test Role", type: "current" }, + cohort: { year: 2024, term: "autumn" }, + } as unknown as CreateVolunteerInput; + + const result = await createVolunteer(input); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.validationErrors!.some((e) => e.field === "cohort.term") + ).toBe(true); + } + }); + + // Test validation - cohort year must be integer + it("should fail when cohort year is not an integer", async () => { + const input: CreateVolunteerInput = { + volunteer: { name_org: "Test Volunteer" }, + role: { name: "Test Role", type: "current" }, + cohort: { year: 2024.5, term: "Fall" }, + }; + + const result = await createVolunteer(input); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.validationErrors!.some( + (e) => e.field === "cohort.year" && e.message.includes("integer") + ) + ).toBe(true); + } + }); + + // Test edge case - null input + it("should handle null input gracefully", async () => { + const result = await createVolunteer( + null as unknown as CreateVolunteerInput + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBe("Validation failed"); + } + }); + + // Test edge case - empty object + it("should handle empty object input gracefully", async () => { + const result = await createVolunteer({} as CreateVolunteerInput); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBe("Validation failed"); + expect(result.validationErrors).toBeDefined(); + } + }); + + describe("integration (requires DB)", () => { + const client = createServiceTestClient(); + + beforeEach(async () => { + await deleteWhere(client, "Volunteers", "name_org", "TEST_%"); + await deleteWhereGte(client, "Cohorts", "year", TEST_YEAR); + await deleteWhere(client, "Roles", "name", "TEST_%"); + }); + + afterEach(async () => { + await deleteWhere(client, "Volunteers", "name_org", "TEST_%"); + await deleteWhereGte(client, "Cohorts", "year", TEST_YEAR); + await deleteWhere(client, "Roles", "name", "TEST_%"); + }); + + it("creates volunteer with role and cohort when valid", async () => { + const roleInsert = makeTestRoleInsert({ name: "TEST_Integration_Role" }); + const cohortInsert = makeTestCohortInsert({ + term: "Fall", + year: TEST_YEAR, + }); + + const { data: role, error: roleError } = await client + .from("Roles") + .insert(roleInsert) + .select("id, name, type") + .single(); + + expect(roleError).toBeNull(); + expect(role).toBeTruthy(); + + const { data: cohort, error: cohortError } = await client + .from("Cohorts") + .insert(cohortInsert) + .select("id, year, term") + .single(); + + expect(cohortError).toBeNull(); + expect(cohort).toBeTruthy(); + + const input: CreateVolunteerInput = { + volunteer: { + name_org: "TEST_Integration_Volunteer", + email: "integration@example.com", + }, + role: { name: role!.name, type: role!.type as "current" }, + cohort: { year: cohort!.year, term: cohort!.term as "Fall" }, + }; + + const result = await createVolunteer(input); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveProperty("id"); + expect(typeof result.data.id).toBe("number"); + } + }); + + it("creates role when it does not exist", async () => { + const input: CreateVolunteerInput = { + volunteer: { + name_org: "TEST_Integration_Volunteer", + email: "integration@example.com", + }, + role: { name: "TEST_AutoCreated_Role", type: "current" }, + cohort: { year: TEST_YEAR, term: "Fall" }, + }; + + const result = await createVolunteer(input); + expect(result.success).toBe(true); + if (result.success) { + expect(typeof result.data.id).toBe("number"); + } + const { data: role } = await client + .from("Roles") + .select("id") + .eq("name", "TEST_AutoCreated_Role") + .single(); + expect(role).toBeTruthy(); + }); + + it("creates cohort when it does not exist", async () => { + // Use a year within smallint range and distinct from TEST_YEAR (2099) + const nonexistentYear = 2098; + const input: CreateVolunteerInput = { + volunteer: { + name_org: "TEST_Integration_Volunteer", + email: "integration@example.com", + }, + role: { name: "TEST_Integration_Role", type: "current" }, + cohort: { year: nonexistentYear, term: "Fall" }, + }; + + const result = await createVolunteer(input); + expect(result.success).toBe(true); + if (result.success) { + expect(typeof result.data.id).toBe("number"); + } + const { data: cohort } = await client + .from("Cohorts") + .select("id") + .eq("year", nonexistentYear) + .eq("term", "Fall") + .single(); + expect(cohort).toBeTruthy(); + }); + + it("successfully creates a volunteer with just name_org", async () => { + const input: CreateVolunteerInput = { + volunteer: { name_org: "TEST_Integration_Volunteer" }, + role: { name: "TEST_Integration_Role", type: "current" }, + cohort: { year: TEST_YEAR, term: "Fall" }, + }; + + const result = await createVolunteer(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveProperty("id"); + expect(typeof result.data.id).toBe("number"); + } + }); + + describe("RPC: junction tables and get-or-create", () => { + it("creates VolunteerRoles and VolunteerCohorts rows linking volunteer to role and cohort", async () => { + const input: CreateVolunteerInput = { + volunteer: { + name_org: "TEST_RPC_Junction_Volunteer", + email: "rpc-junction@example.com", + }, + role: { name: "TEST_RPC_Junction_Role", type: "prior" }, + cohort: { year: TEST_YEAR, term: "Spring" }, + }; + + const result = await createVolunteer(input); + expect(result.success).toBe(true); + if (!result.success) return; + + const volunteerId = result.data.id; + + const { data: volunteerRole, error: vrError } = await client + .from("VolunteerRoles") + .select("volunteer_id, role_id") + .eq("volunteer_id", volunteerId) + .single(); + + expect(vrError).toBeNull(); + expect(volunteerRole).toBeTruthy(); + expect(volunteerRole!.volunteer_id).toBe(volunteerId); + expect(typeof volunteerRole!.role_id).toBe("number"); + + const { data: volunteerCohort, error: vcError } = await client + .from("VolunteerCohorts") + .select("volunteer_id, cohort_id") + .eq("volunteer_id", volunteerId) + .single(); + + expect(vcError).toBeNull(); + expect(volunteerCohort).toBeTruthy(); + expect(volunteerCohort!.volunteer_id).toBe(volunteerId); + expect(typeof volunteerCohort!.cohort_id).toBe("number"); + }); + + it("reuses existing role when creating second volunteer with same role name", async () => { + const roleName = "TEST_RPC_Shared_Role"; + const input1: CreateVolunteerInput = { + volunteer: { + name_org: "TEST_RPC_Volunteer_One", + email: "one@example.com", + }, + role: { name: roleName, type: "current" }, + cohort: { year: TEST_YEAR, term: "Summer" }, + }; + const result1 = await createVolunteer(input1); + expect(result1.success).toBe(true); + if (!result1.success) return; + + const input2: CreateVolunteerInput = { + volunteer: { + name_org: "TEST_RPC_Volunteer_Two", + email: "two@example.com", + }, + role: { name: roleName, type: "current" }, + cohort: { year: TEST_YEAR, term: "Summer" }, + }; + const result2 = await createVolunteer(input2); + expect(result2.success).toBe(true); + if (!result2.success) return; + + const { data: roles } = await client + .from("Roles") + .select("id") + .eq("name", roleName); + expect(roles).toHaveLength(1); + const roleId = roles?.[0]?.id; + expect(roleId).toBeDefined(); + + const { data: vrRows } = await client + .from("VolunteerRoles") + .select("volunteer_id, role_id") + .eq("role_id", roleId!); + expect(vrRows).toHaveLength(2); + expect(vrRows!.map((r) => r.volunteer_id).sort()).toEqual( + [result1.data.id, result2.data.id].sort() + ); + }); + + it("reuses existing cohort when creating second volunteer with same year and term", async () => { + const cohortYear = 2097; + const cohortTerm = "Winter"; + const input1: CreateVolunteerInput = { + volunteer: { + name_org: "TEST_RPC_Cohort_Vol_One", + email: "cohort1@example.com", + }, + role: { name: "TEST_RPC_Cohort_Role", type: "future_interest" }, + cohort: { year: cohortYear, term: cohortTerm }, + }; + const result1 = await createVolunteer(input1); + expect(result1.success).toBe(true); + if (!result1.success) return; + + const input2: CreateVolunteerInput = { + volunteer: { + name_org: "TEST_RPC_Cohort_Vol_Two", + email: "cohort2@example.com", + }, + role: { name: "TEST_RPC_Cohort_Role", type: "future_interest" }, + cohort: { year: cohortYear, term: cohortTerm }, + }; + const result2 = await createVolunteer(input2); + expect(result2.success).toBe(true); + if (!result2.success) return; + + const { data: cohorts } = await client + .from("Cohorts") + .select("id") + .eq("year", cohortYear) + .eq("term", cohortTerm); + expect(cohorts?.length).toBeGreaterThanOrEqual(1); + const cohortId = cohorts?.[0]?.id; + expect(cohortId).toBeDefined(); + + const { data: vcRows } = await client + .from("VolunteerCohorts") + .select("volunteer_id, cohort_id") + .eq("cohort_id", cohortId!); + expect(vcRows!.length).toBeGreaterThanOrEqual(2); + const volunteerIds = vcRows!.map((r) => r.volunteer_id); + expect(volunteerIds).toContain(result1.data.id); + expect(volunteerIds).toContain(result2.data.id); + }); + + it("stores all volunteer optional fields via RPC", async () => { + const input: CreateVolunteerInput = { + volunteer: { + name_org: "TEST_RPC_Full_Volunteer", + pseudonym: "TestPseudonym", + pronouns: "they/them", + email: "full@example.com", + phone: "555-1234", + position: "volunteer", + opt_in_communication: false, + notes: "Test notes for RPC", + }, + role: { name: "TEST_RPC_Full_Role", type: "current" }, + cohort: { year: TEST_YEAR, term: "Fall" }, + }; + + const result = await createVolunteer(input); + expect(result.success).toBe(true); + if (!result.success) return; + + const { data: volunteer, error } = await client + .from("Volunteers") + .select( + "name_org, pseudonym, pronouns, email, phone, position, opt_in_communication, notes" + ) + .eq("id", result.data.id) + .single(); + + expect(error).toBeNull(); + expect(volunteer).toBeTruthy(); + expect(volunteer!.name_org).toBe("TEST_RPC_Full_Volunteer"); + expect(volunteer!.pseudonym).toBe("TestPseudonym"); + expect(volunteer!.pronouns).toBe("they/them"); + expect(volunteer!.email).toBe("full@example.com"); + expect(volunteer!.phone).toBe("555-1234"); + expect(volunteer!.position).toBe("volunteer"); + expect(volunteer!.opt_in_communication).toBe(false); + expect(volunteer!.notes).toBe("Test notes for RPC"); + }); + + it("creates volunteer with each valid cohort term (Fall, Spring, Summer, Winter)", async () => { + const terms = ["Fall", "Spring", "Summer", "Winter"] as const; + for (const term of terms) { + const input: CreateVolunteerInput = { + volunteer: { + name_org: `TEST_RPC_Term_${term}`, + email: `term-${term.toLowerCase()}@example.com`, + }, + role: { name: "TEST_RPC_Term_Role", type: "current" }, + cohort: { year: TEST_YEAR, term }, + }; + const result = await createVolunteer(input); + expect(result.success).toBe(true); + if (!result.success) return; + + const { data: vc } = await client + .from("VolunteerCohorts") + .select("cohort_id") + .eq("volunteer_id", result.data.id) + .single(); + + expect(vc).toBeTruthy(); + const { data: cohort } = await client + .from("Cohorts") + .select("term") + .eq("id", vc!.cohort_id) + .single(); + expect(cohort!.term).toBe(term); + } + }); + + it("creates volunteer with each valid role type (prior, current, future_interest)", async () => { + const types = ["prior", "current", "future_interest"] as const; + for (const type of types) { + const input: CreateVolunteerInput = { + volunteer: { + name_org: `TEST_RPC_Type_${type}`, + email: `type-${type}@example.com`, + }, + role: { name: `TEST_RPC_Type_Role_${type}`, type }, + cohort: { year: TEST_YEAR, term: "Fall" }, + }; + const result = await createVolunteer(input); + expect(result.success).toBe(true); + if (!result.success) return; + + const { data: vr } = await client + .from("VolunteerRoles") + .select("role_id") + .eq("volunteer_id", result.data.id) + .single(); + + expect(vr).toBeTruthy(); + const { data: role } = await client + .from("Roles") + .select("type") + .eq("id", vr!.role_id) + .single(); + expect(role!.type).toBe(type); + } + }); + }); + }); +});