-
Notifications
You must be signed in to change notification settings - Fork 0
Implement filter multiple columns API #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements a multi-column filtering API for volunteers at GET /api/volunteers/filter/multiple. The endpoint supports filtering by volunteer attributes, cohort (term/year), and role names with configurable AND/OR operations both globally and within individual filters.
Key Changes:
- New filtering endpoint supporting complex query combinations with nested AND/OR logic
- Validation function to ensure filter structure and field names are valid
- Support for three filtering strategies: general volunteer fields, role-based filtering, and cohort-based filtering
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 13 comments.
| File | Description |
|---|---|
src/lib/api/index.ts |
Exports the new filterMultipleColumns and validateFilter functions |
src/lib/api/filterMultipleColumns.ts |
Core filtering logic with validation, type definitions, and database queries for volunteers, roles, and cohorts |
src/app/api/volunteers/filter/multiple/route.ts |
Next.js API route handler that parses query parameters, validates input, and returns filtered volunteer data |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export type FilterTuple = { | ||
| mini_op: string; | ||
| field: string; | ||
| values: string[]; |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
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.
| values: string[]; | |
| values: string[] | [string, string][]; |
| 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); | ||
| }), | ||
| ]; | ||
| } | ||
| } | ||
| } 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); | ||
| } 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); | ||
| }), | ||
| ]; | ||
| } |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
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.
| 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); | ||
| }), | ||
| ]; | ||
| } |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
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.
| 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)); |
| // Allow filter to be an empty array | ||
|
|
||
| for (const f of filtersList) { | ||
| if (!f.mini_op || !(f.mini_op == "AND" || f.mini_op == "OR")) |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
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.
| 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") | |
| ) |
|
|
||
| let finalIds: Set<number>; | ||
|
|
||
| if (querySets.length === 0) return { data: [] }; |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
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.
| if (querySets.length === 0) return { data: [] }; |
| export async function filterMultipleColumns( | ||
| filtersList: FilterTuple[], | ||
| op: string | ||
| ): Promise<VolunteerFilterResponse> { |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
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.
| error?: string; | ||
| } { | ||
| if (!Array.isArray(filtersList)) | ||
| return { valid: false, error: "'filter' must be an array" }; |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
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.
| return { valid: false, error: "'filter' must be an array" }; | |
| return { valid: false, error: "'filtersList' must be an array" }; |
| 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); | ||
| }), | ||
| ]; | ||
| } |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
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.
| 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)); |
| (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])) |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
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.
| (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; | |
| } |
| if (!Array.isArray(filtersList)) | ||
| return { valid: false, error: "'filter' must be an array" }; | ||
|
|
||
| // Allow filter to be an empty array |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
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.
| // Allow filter to be an empty array | |
| // Empty filter arrays are allowed and will return all volunteers |
There was a problem hiding this comment.
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.
| // Allow filter to be an empty array | ||
|
|
||
| for (const f of filtersList) { | ||
| if (!f.mini_op || !(f.mini_op == "AND" || f.mini_op == "OR")) |
There was a problem hiding this comment.
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.
| query = query.in(filterField, values); | ||
| } else { | ||
| values.forEach((v) => { | ||
| query = query.eq(filterField, v); |
There was a problem hiding this comment.
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.
| .from("VolunteerRoles") | ||
| .select("volunteer_id, Roles!inner(name)"); | ||
|
|
||
| const { data: cohortRows, error } = await query.eq("Roles.name", v); |
There was a problem hiding this comment.
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.
Implements the API to filter volunteers by multiple columns, including by general volunteer attributes, by cohort year and term, and by role name, in the
GET /api/volunteers/filter/multipleendpoint.Initial Database Setup

Volunteers
Roles

VolunteerRoles

Cohorts

VolunteerCohorts

Sample Queries

Global OR Operation
Global AND Operation

Invalid Input
