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
41 changes: 41 additions & 0 deletions src/app/api/volunteers/filter/multiple/route.ts
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't need to implement route.ts file.

Original file line number Diff line number Diff line change
@@ -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 });
}
}
221 changes: 221 additions & 0 deletions src/lib/api/filterMultipleColumns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { createClient } from "../client/supabase/server";
import type { Database } from "../client/supabase/types";

export type FilterTuple = {
mini_op: string;
field: string;
values: 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.

Type definition mismatch: FilterTuple.values is typed as string[], but for cohorts it actually contains arrays [term, year]. The type should be string[] | [string, string][] or use a union type to properly represent both cases.

Suggested change
values: string[];
values: string[] | [string, string][];

Copilot uses AI. Check for mistakes.
};

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" };
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.

Error message inconsistency: the validation function returns "'filter' must be an array" (line 39) but the parameter is actually named 'filters_list' in the API route. This could confuse API consumers. Consider using the actual parameter name in the error message.

Suggested change
return { valid: false, error: "'filter' must be an array" };
return { valid: false, error: "'filtersList' must be an array" };

Copilot uses AI. Check for mistakes.

// Allow filter to be an empty array
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.

[nitpick] Unnecessary empty comment or unclear intention. If this comment is meant to explain why empty arrays are allowed, it should be more explicit (e.g., "Empty filter arrays are allowed and will return all volunteers"). Otherwise, remove the empty comment.

Suggested change
// Allow filter to be an empty array
// Empty filter arrays are allowed and will return all volunteers

Copilot uses AI. Check for mistakes.

for (const f of filtersList) {
if (!f.mini_op || !(f.mini_op == "AND" || f.mini_op == "OR"))
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.

[nitpick] Case sensitivity inconsistency: The validation converts field names to lowercase (line 47) and the filtering logic also uses lowercase (line 96), but mini_op is validated case-sensitively (line 44). Consider normalizing mini_op to uppercase before validation for consistency and better user experience.

Suggested change
if (!f.mini_op || !(f.mini_op == "AND" || f.mini_op == "OR"))
if (
!f.mini_op ||
!(f.mini_op.toUpperCase() === "AND" || f.mini_op.toUpperCase() === "OR")
)

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of checking f.mini_op == "AND" every time, you can create operation constants to improve consistency and reduce typos:

export const MINI_OP = {
  AND: "AND",
  OR: "OR",
} as const;

Then do f.mini_op == MINI_OP.AND. Same goes to 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]))
Comment on lines +56 to +62
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.

[nitpick] The cohort filter values use a string year that must be parseable as an integer, but the database query expects an integer. While parseInt(v[1]) is used in the query (line 160), the validation should ensure the parsed value is within a reasonable range (e.g., 1900-2100) to prevent potential issues with edge cases or malformed data.

Suggested change
(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]))
(v) => {
if (
!Array.isArray(v) ||
v.length !== 2 ||
typeof v[0] !== "string" ||
!/^(Fall|Spring|Summer|Winter)$/i.test(v[0]) ||
typeof v[1] !== "string"
) {
return true;
}
const year = parseInt(v[1]);
if (isNaN(year) || year < 1900 || year > 2100) {
return true;
}
return false;
}

Copilot uses AI. Check for mistakes.
);

if (invalid)
return { valid: false, error: "Invalid cohort filter values" };
}
}

if (!op || !(op === "AND" || op === "OR"))
return { valid: false, error: "Invalid global operation" };
Comment on lines +70 to +71
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.

[nitpick] Missing input validation for the op parameter. The validation only checks if op exists and is "AND" or "OR", but doesn't validate the case sensitivity. Consider normalizing the case (e.g., converting to uppercase) before validation, or document that the parameter is case-sensitive.

Copilot uses AI. Check for mistakes.

return { valid: true };
}

export async function filterMultipleColumns(
filtersList: FilterTuple[],
op: string
): Promise<VolunteerFilterResponse> {
Comment on lines +76 to +79
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.

[nitpick] Missing documentation for complex filter logic. The function handles three different filtering strategies (general, roles, cohorts) with different query patterns. Add JSDoc comments explaining the expected input format, behavior of mini_op vs global op, and provide examples of valid filter inputs.

Copilot uses AI. Check for mistakes.
const client = await createClient();

const querySets: Set<number>[] = [];

// 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
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 {
const roleVolunteerIds: Set<number>[] = [];

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);
Copy link
Contributor

Choose a reason for hiding this comment

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

cohortRows should be roleRows instead, because it contains row data.


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);
}),
];
}
Comment on lines +112 to +132
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.

N+1 query problem: When mini_op is "AND" for roles, a separate database query is executed for each value in the array (lines 114-124). For large value arrays, this creates a performance bottleneck. Consider refactoring to use a single query with aggregation/grouping to identify volunteers who have all specified roles.

Suggested change
const roleVolunteerIds: Set<number>[] = [];
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);
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);
}),
];
}
// Fetch all volunteer-role pairs for the specified roles in a single query
const { data: roleRows, error } = await client
.from("VolunteerRoles")
.select("volunteer_id, Roles!inner(name)")
.in("Roles.name", values);
if (error) return { error: error.message };
// Group by volunteer_id and count the number of matching roles
const volunteerRoleCount: Record<number, number> = {};
for (const row of roleRows) {
if (volunteerRoleCount[row.volunteer_id]) {
volunteerRoleCount[row.volunteer_id]++;
} else {
volunteerRoleCount[row.volunteer_id] = 1;
}
}
// Only include volunteers who have all specified roles
volunteerIds = Object.entries(volunteerRoleCount)
.filter(([_, count]) => count === values.length)
.map(([volunteer_id, _]) => Number(volunteer_id));

Copilot uses AI. Check for mistakes.
}
} else if (filterField === "cohorts") {
// Cohort-specific filtering
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);
Comment on lines +137 to +149
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.

Potential SQL injection vulnerability in the cohort OR statement construction. The values from user input (v[0] and v[1]) are directly interpolated into the query string without proper escaping. While validation exists, this pattern is risky. Consider using Supabase's query builder methods more safely, or ensure values are properly sanitized.

Suggested change
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);
// Safely query for each cohort value and union the results
const cohortVolunteerIds: Set<number>[] = [];
for (const v of values) {
const query = client
.from("VolunteerCohorts")
.select("volunteer_id, Cohorts!inner(term, year)")
.eq("Cohorts.term", v[0])
.eq("Cohorts.year", parseInt(v[1]));
const { data: cohortRows, error } = await query;
if (error) return { error: error.message };
cohortVolunteerIds.push(
new Set(cohortRows.map((r) => r.volunteer_id))
);
}
// Union all sets
if (cohortVolunteerIds.length > 0) {
volunteerIds = [
...cohortVolunteerIds.reduce((acc, cur) => {
return new Set([...acc, ...cur]);
}),
];
}

Copilot uses AI. Check for mistakes.
} else {
const cohortVolunteerIds: Set<number>[] = [];

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]));

if (error) return { error: error.message };

cohortVolunteerIds.push(
new Set(cohortRows.map((r) => r.volunteer_id))
);
}

if (cohortVolunteerIds.length > 0) {
volunteerIds = [
...cohortVolunteerIds.reduce((acc, cur) => {
return acc.intersection(cur);
}),
];
}
Comment on lines +112 to +175
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.

[nitpick] Code duplication in role and cohort filtering logic. Both the role filtering (lines 112-132) and cohort filtering (lines 151-175) blocks implement nearly identical AND operation logic. Consider extracting this into a reusable helper function to improve maintainability.

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +175
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.

N+1 query problem: When mini_op is "AND" for cohorts, a separate database query is executed for each value in the array (lines 153-167). For large value arrays, this creates a performance bottleneck. Consider refactoring to use a single query with aggregation/grouping to identify volunteers who are in all specified cohorts.

Suggested change
const cohortVolunteerIds: Set<number>[] = [];
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]));
if (error) return { error: error.message };
cohortVolunteerIds.push(
new Set(cohortRows.map((r) => r.volunteer_id))
);
}
if (cohortVolunteerIds.length > 0) {
volunteerIds = [
...cohortVolunteerIds.reduce((acc, cur) => {
return acc.intersection(cur);
}),
];
}
// Refactored: Single query for all cohort values, then group by volunteer_id
// Build filter for all cohort values
const orStatement = values
.map((v) => `and(term.eq.${v[0]},year.eq.${v[1]})`)
.join(",");
let query = client
.from("VolunteerCohorts")
.select("volunteer_id, Cohorts!inner(term, year)");
query = query.or(orStatement, { referencedTable: "Cohorts" });
const { data: cohortRows, error } = await query;
if (error) return { error: error.message };
// Group by volunteer_id and count matches
const volunteerCount: Record<number, number> = {};
for (const row of cohortRows) {
volunteerCount[row.volunteer_id] = (volunteerCount[row.volunteer_id] || 0) + 1;
}
// Only volunteers present in all cohorts
volunteerIds = Object.entries(volunteerCount)
.filter(([_, count]) => count === values.length)
.map(([volunteer_id, _]) => Number(volunteer_id));

Copilot uses AI. Check for mistakes.
}
} 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);
Copy link
Contributor

Choose a reason for hiding this comment

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

I agree with Copilot, AND is not correct here, the loop you are doing will act as: query.eq(field, a).eq(field, b).eq(field, c)... which is incorrect.

});
}

const { data: volunteerRows, error } = await query;
if (error) return { error: error.message };

volunteerIds = volunteerRows.map((r) => r.id);
Comment on lines +184 to +192
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 AND operation for general volunteer filtering is incorrect. Chaining multiple .eq() calls on the same field will result in only the last condition being applied, not an AND of all values. This logic doesn't make semantic sense either - a field can't equal multiple different values simultaneously. Consider if this should be OR operation instead, or clarify the intended behavior.

Suggested change
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);
// For AND, get volunteers matching each value, then intersect
const valueVolunteerIds: Set<number>[] = [];
for (const v of values) {
const { data: volunteerRows, error } = await client
.from("Volunteers")
.select("id")
.eq(filterField, v);
if (error) return { error: error.message };
valueVolunteerIds.push(new Set(volunteerRows.map((r) => r.id)));
}
if (valueVolunteerIds.length > 0) {
volunteerIds = [
...valueVolunteerIds.reduce((acc, cur) => {
return acc.intersection(cur);
}),
];
}
}
// Only needed for OR branch
if (mini_op === "OR") {
const { data: volunteerRows, error } = await query;
if (error) return { error: error.message };
volunteerIds = volunteerRows.map((r) => r.id);
}

Copilot uses AI. Check for mistakes.
}

querySets.push(new Set(volunteerIds));
}

let finalIds: Set<number>;

if (querySets.length === 0) return { data: [] };
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.

[nitpick] The function can return early with an empty array (line 200) when querySets.length === 0, but this scenario should be impossible given the code flow. If filtersList.length === 0, the function returns early at line 90. Otherwise, at least one set is added to querySets. This check appears to be dead code and should be removed, or clarify if there's an edge case being handled.

Suggested change
if (querySets.length === 0) return { data: [] };

Copilot uses AI. Check for mistakes.

// 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 };
}
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 { filterMultipleColumns, validateFilter } from "./filterMultipleColumns";
Loading