From 99bd6d26dc0b0dadf5525d11c653ddc6d3728951 Mon Sep 17 00:00:00 2001 From: Alan Su Date: Fri, 21 Nov 2025 17:08:26 -0500 Subject: [PATCH 1/2] Implement filter multiple columns API function --- .../api/volunteers/filter/multiple/route.ts | 41 ++++ src/lib/api/filterMultipleColumns.ts | 183 ++++++++++++++++++ src/lib/api/index.ts | 1 + 3 files changed, 225 insertions(+) create mode 100644 src/app/api/volunteers/filter/multiple/route.ts create mode 100644 src/lib/api/filterMultipleColumns.ts diff --git a/src/app/api/volunteers/filter/multiple/route.ts b/src/app/api/volunteers/filter/multiple/route.ts new file mode 100644 index 0000000..f617969 --- /dev/null +++ b/src/app/api/volunteers/filter/multiple/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + filterMultipleColumns, + validateFilter +} from "@/lib/api/filterMultipleColumns"; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + + const filtersListParameter = searchParams.get("filters_list"); + const op = searchParams.get("op"); + + if (!filtersListParameter || !op) + return NextResponse.json({ error: "Missing parameter" }, { status: 400 }); + + let filters; + try { + filters = JSON.parse(filtersListParameter); + } catch { + return NextResponse.json( + { error: "Invalid JSON for 'filters_list' parameter" }, + { status: 400 } + ); + } + + const validation = validateFilter(filters, op); + if (!validation.valid) + return NextResponse.json({ error: validation.error }, { status: 400 }); + + const { data, error } = await filterMultipleColumns(filters, op); + + if (error) return NextResponse.json({ error }, { status: 500 }); + + return NextResponse.json({ data }, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/lib/api/filterMultipleColumns.ts b/src/lib/api/filterMultipleColumns.ts new file mode 100644 index 0000000..cf154ed --- /dev/null +++ b/src/lib/api/filterMultipleColumns.ts @@ -0,0 +1,183 @@ +import { createClient } from "../client/supabase/server"; +import type { Database } from "../client/supabase/types"; + +export type FilterTuple = { + mini_op: string; + field: string; + values: string[]; +}; + +export type VolunteerRow = Database["public"]["Tables"]["Volunteers"]["Row"]; + +export type VolunteerFilterResponse = + | { data: VolunteerRow[]; error?: string } + | { data?: VolunteerRow[]; error: string }; + +const VOLUNTEER_COLUMNS = [ + "name_org", + "pseudonym", + "pronouns", + "email", + "phone", + "position", + "opt_in_communication", + "notes", + "created_at", + "updated_at", + "id", +]; +const ALLOWED_FIELDS = [...VOLUNTEER_COLUMNS, "roles", "cohorts"]; + +export function validateFilter( + filtersList: FilterTuple[], + op: string +): { + valid: boolean; + error?: string; +} { + if (!Array.isArray(filtersList)) + return { valid: false, error: "'filter' must be an array" }; + + // Allow filter to be an empty array + + for (const f of filtersList) { + if (!f.mini_op || !(f.mini_op == "AND" || f.mini_op == "OR")) + return { valid: false, error: "Invalid filter mini-operation" }; + + if (!f.field || !ALLOWED_FIELDS.includes(f.field.toLowerCase())) + return { valid: false, error: "Invalid filter field" }; + + if (!Array.isArray(f.values) || f.values.length === 0) + return { valid: false, error: "Invalid filter values" }; + + // Cohort values must be in the form (term, year) + if (f.field.toLowerCase() === "cohorts") { + const invalid = f.values.some( + (v) => + !Array.isArray(v) || + v.length !== 2 || + typeof v[0] !== "string" || + !/^(Fall|Spring|Summer|Winter)$/i.test(v[0]) || + typeof v[1] !== "string" || + isNaN(parseInt(v[1])) + ); + + if (invalid) + return { valid: false, error: "Invalid cohort filter values" }; + } + } + + if (!op || !(op === "AND" || op === "OR")) + return { valid: false, error: "Invalid global operation" }; + + return { valid: true }; +} + +export async function filterMultipleColumns( + filtersList: FilterTuple[], + op: string +): Promise { + const client = await createClient(); + + const querySets: Set[] = []; + + // Return all volunteers when no filters inputted + if (filtersList.length === 0) { + const { data, error } = await client.from("Volunteers").select("*"); + + if (error) return { error: error.message }; + + return { data }; + } + + for (const { field, values, mini_op } of filtersList) { + let volunteerIds: number[] = []; + + const filterField = field.toLowerCase(); + + if (filterField === "roles") { + // Role-specific filtering + let query = client + .from("VolunteerRoles") + .select("volunteer_id, Roles!inner(name)"); + + if (mini_op === "OR") { + query = query.in("Roles.name", values); + } else { + values.forEach((v) => { + query = query.eq("Roles.name", v); + }); + } + + const { data: roleRows, error } = await query; + if (error) return { error: error.message }; + + volunteerIds = roleRows.map((r) => r.volunteer_id); + } else if (filterField === "cohorts") { + // Cohort-specific filtering + let query = client + .from("VolunteerCohorts") + .select("volunteer_id, Cohorts!inner(term, year)"); + + if (mini_op === "OR") { + const orStatement = values + .map((v) => `and(term.eq.${v[0]},year.eq.${v[1]})`) + .join(","); + query = query.or(orStatement, { referencedTable: "Cohorts" }); + } else { + values.forEach((v) => { + query = query + .eq("Cohorts.term", v[0]) + .eq("Cohorts.year", parseInt(v[1])); + }); + } + + const { data: cohortRows, error } = await query; + if (error) return { error: error.message }; + + volunteerIds = cohortRows.map((r) => r.volunteer_id); + } else { + // General filtering + let query = client.from("Volunteers").select("id"); + + if (mini_op === "OR") { + query = query.in(filterField, values); + } else { + values.forEach((v) => { + query = query.eq(filterField, v); + }); + } + + const { data: volunteerRows, error } = await query; + if (error) return { error: error.message }; + + volunteerIds = volunteerRows.map((r) => r.id); + } + + querySets.push(new Set(volunteerIds)); + } + + let finalIds: Set; + + if (querySets.length === 0) return { data: [] }; + + // Combined queried volunteer ids based on global op + if (op === "AND") { + finalIds = querySets.reduce((acc, cur) => { + return acc.intersection(cur); + }); + } else { + finalIds = querySets.reduce((acc, cur) => { + return acc.union(cur); + }); + } + + const { data, error } = await client + .from("Volunteers") + .select("*") + .in("id", [...finalIds]); + + if (error) return { error: error.message }; + + return { data }; +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 53e202a..89f3f18 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 { filterMultipleColumns, validateFilter } from "./filterMultipleColumns"; From 0b45ddd9d86a654990d288b4ccf44b74c2e4fcf0 Mon Sep 17 00:00:00 2001 From: Alan Su Date: Fri, 21 Nov 2025 17:50:37 -0500 Subject: [PATCH 2/2] Fix role and cohort filtering --- src/lib/api/filterMultipleColumns.ts | 82 ++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/src/lib/api/filterMultipleColumns.ts b/src/lib/api/filterMultipleColumns.ts index cf154ed..4b649c5 100644 --- a/src/lib/api/filterMultipleColumns.ts +++ b/src/lib/api/filterMultipleColumns.ts @@ -97,45 +97,83 @@ export async function filterMultipleColumns( if (filterField === "roles") { // Role-specific filtering - let query = client - .from("VolunteerRoles") - .select("volunteer_id, Roles!inner(name)"); - if (mini_op === "OR") { + let query = client + .from("VolunteerRoles") + .select("volunteer_id, Roles!inner(name)"); + query = query.in("Roles.name", values); + + const { data: roleRows, error } = await query; + if (error) return { error: error.message }; + + volunteerIds = roleRows.map((r) => r.volunteer_id); } else { - values.forEach((v) => { - query = query.eq("Roles.name", v); - }); - } + const roleVolunteerIds: Set[] = []; - const { data: roleRows, error } = await query; - if (error) return { error: error.message }; + for (const v of values) { + const query = client + .from("VolunteerRoles") + .select("volunteer_id, Roles!inner(name)"); + + const { data: cohortRows, error } = await query.eq("Roles.name", v); - volunteerIds = roleRows.map((r) => r.volunteer_id); + if (error) return { error: error.message }; + + roleVolunteerIds.push(new Set(cohortRows.map((r) => r.volunteer_id))); + } + + if (roleVolunteerIds.length > 0) { + volunteerIds = [ + ...roleVolunteerIds.reduce((acc, cur) => { + return acc.intersection(cur); + }), + ]; + } + } } else if (filterField === "cohorts") { // Cohort-specific filtering - let query = client - .from("VolunteerCohorts") - .select("volunteer_id, Cohorts!inner(term, year)"); - if (mini_op === "OR") { + let query = client + .from("VolunteerCohorts") + .select("volunteer_id, Cohorts!inner(term, year)"); + const orStatement = values .map((v) => `and(term.eq.${v[0]},year.eq.${v[1]})`) .join(","); query = query.or(orStatement, { referencedTable: "Cohorts" }); + + const { data: cohortRows, error } = await query; + if (error) return { error: error.message }; + + volunteerIds = cohortRows.map((r) => r.volunteer_id); } else { - values.forEach((v) => { - query = query + const cohortVolunteerIds: Set[] = []; + + for (const v of values) { + const query = client + .from("VolunteerCohorts") + .select("volunteer_id, Cohorts!inner(term, year)"); + + const { data: cohortRows, error } = await query .eq("Cohorts.term", v[0]) .eq("Cohorts.year", parseInt(v[1])); - }); - } - const { data: cohortRows, error } = await query; - if (error) return { error: error.message }; + if (error) return { error: error.message }; + + cohortVolunteerIds.push( + new Set(cohortRows.map((r) => r.volunteer_id)) + ); + } - volunteerIds = cohortRows.map((r) => r.volunteer_id); + if (cohortVolunteerIds.length > 0) { + volunteerIds = [ + ...cohortVolunteerIds.reduce((acc, cur) => { + return acc.intersection(cur); + }), + ]; + } + } } else { // General filtering let query = client.from("Volunteers").select("id");