Skip to content
35 changes: 14 additions & 21 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "trcc",
"version": "0.1.0",
"private": true,
"type": "module",
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

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

Adding "type": "module" to package.json was not mentioned in the PR description and doesn't appear to be related to the filter_by_role feature. This change affects how Node.js interprets JavaScript files throughout the entire project. Please clarify why this change is necessary for this PR, or if it should be removed or submitted as a separate PR.

Suggested change
"type": "module",

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

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

I agree with Copilot here. Is this necessary?

"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
Expand Down
38 changes: 38 additions & 0 deletions src/app/volunteers/filter_by_role/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.

No need to implement route.ts file.

Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import {
getVolunteersByRoles,
isAllStrings,
isValidOperator,
} from "@/lib/api/getVolunteersByRoles";

export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const operator = searchParams.get("operator");
const roleParams = searchParams.get("roles");

if (!operator || !roleParams) {
return NextResponse.json({
status: 400,
error: "Missing operator or role filter values",
});
}

const roles = roleParams.split(",");

if (!isValidOperator(operator)) {
return { status: 400, error: "Operator is not AND or OR" };
}

if (!isAllStrings(roles)) {
return {
status: 400,
error: "Roles to filter by are not all strings",
};
}

const response = await getVolunteersByRoles(operator as "OR" | "AND", roles);

return NextResponse.json(response, {
status: response.status,
});
}
108 changes: 108 additions & 0 deletions src/lib/api/getVolunteersByRoles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { createClient } from "../client/supabase/server";

type Volunteer = {
id: number;
created_at: string;
updated_at: string;
email: string | null;
phone: string | null;
name_org: string;
notes: string | null;
opt_in_communication: boolean | null;
position: string | null;
pronouns: string | null;
pseudonym: string | null;
};
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the Volunteer type has already been defined in the types.ts, so you can just import it instead of creating a new type:

type Volunteer = Database["public"]["Tables"]["Volunteers"]["Row"];


type Operator = "OR" | "AND";

export function isAllStrings(arr: unknown[]): arr is string[] {
return arr.every((item) => typeof item === "string");
}

export function isValidOperator(operator: string) {
return (
typeof operator === "string" &&
["OR", "AND"].includes(operator.toUpperCase())
);
}

export async function getVolunteersByRoles(
operator: Operator,
filters: string[]
Copy link
Contributor

Choose a reason for hiding this comment

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

You should also check for the case that filters is an empty array.

Copy link
Author

Choose a reason for hiding this comment

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

In the case that filters is empty, should it return an error or an empty array? Right now, when an empty array is passed, Supabase returns an empty list of rows since there are no filters to match rows by

Copy link
Member

Choose a reason for hiding this comment

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

Returning an empty array should be good

) {
if (!isValidOperator(operator)) {
return { status: 400, error: "Operator is not AND or OR" };
}

if (!isAllStrings(filters)) {
return {
status: 400,
error: "Roles to filter by are not all strings",
};
}

Comment on lines +34 to +44
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

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

This validation is redundant. The route handler already validates the operator and filters before calling getVolunteersByRoles. Since the function signature requires operator: Operator (which is "OR" | "AND"), and filters: string[], these checks are unnecessary in the API function. Consider removing them or removing the validation from the route handler to avoid duplication.

Suggested change
if (!isValidOperator(operator)) {
return { status: 400, error: "Operator is not AND or OR" };
}
if (!isAllStrings(filters)) {
return {
status: 400,
error: "Roles to filter by are not all strings",
};
}

Copilot uses AI. Check for mistakes.
const client = await createClient();
const { data: allRows, error } = await client
.from("VolunteerRoles")
.select(
`
Roles!inner (name),
Volunteers!inner (*)
`
)
.in("Roles.name", filters);

if (error) {
// console.error("Supabase error:", error.message);
// console.error("Details:", error.details);
// console.error("Hint:", error.hint);

return {
status: 500,
error: `Failed to query Supabase database: ${error.message}`,
};
}

const volunteerRoleMap = new Map<
number,
{
row: Volunteer;
roleNames: Set<string>;
}
>();

for (const row of allRows) {
const volunteerId = row.Volunteers.id;
const roleName = row.Roles.name;

let volunteerData = volunteerRoleMap.get(volunteerId);
if (!volunteerData) {
volunteerData = { row: row.Volunteers, roleNames: new Set<string>() };
volunteerRoleMap.set(volunteerId, volunteerData);
}

volunteerData.roleNames.add(roleName);
}

const filteredVolunteers = [];
if (operator.toUpperCase() == "OR") {
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

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

Using loose equality (==) instead of strict equality (===). JavaScript best practices recommend using === to avoid type coercion issues.

Suggested change
if (operator.toUpperCase() == "OR") {
if (operator.toUpperCase() === "OR") {

Copilot uses AI. Check for mistakes.
for (const volunteer of volunteerRoleMap.values()) {
filteredVolunteers.push({
...volunteer.row,
filtered_roles: Array.from(volunteer.roleNames),
});
}
} else {
for (const volunteer of volunteerRoleMap.values()) {
if (volunteer.roleNames.size == filters.length) {
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

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

The AND operator logic is incorrect. This checks if the number of matched roles equals the filter count, but doesn't verify that ALL requested roles are present. For example, if filters are ["Role 1", "Role 2"] and a volunteer has ["Role 1", "Role 1"] (counted once in the Set), they would incorrectly match since size is 1 but filters.length is 2 and they don't match. More critically, if a volunteer has ["Role 1", "Role 2", "Role 3"] and you filter for ["Role 1", "Role 2"], they would match (size 3 ≠ filters.length 2) incorrectly.

The correct logic should verify that all filter roles are present in the volunteer's roles:

if (filters.every(filter => volunteer.roleNames.has(filter))) {
Suggested change
if (volunteer.roleNames.size == filters.length) {
if (filters.every(filter => volunteer.roleNames.has(filter))) {

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.

I agree with Copilot on this, you are only checking the count, not the actual content itself.

Copy link
Author

@1michhu1 1michhu1 Nov 29, 2025

Choose a reason for hiding this comment

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

Right now, I have the select from the db set up so that I join Roles to Volunteers, and I filter for rows such that the role name is in the filters.

More critically, if a volunteer has ["Role 1", "Role 2", "Role 3"] and you filter for ["Role 1", "Role 2"], they would match (size 3 ≠ filters.length 2) incorrectly.

I don't think a case like this would occur since the rows I would get from the db would consist of rows where the role name is in the filters (ie. either Role 1 or Role 2).

For example, if filters are ["Role 1", "Role 2"] and a volunteer has ["Role 1", "Role 1"] (counted once in the Set), they would incorrectly match since size is 1 but filters.length is 2 and they don't match

And for this case, if the operator was OR, then the volunteer would be matched. If the operator was AND, the set of role names would only contain Role 1 and the length of the set would be 1, so that volunteer would not be matched.

I might be wrong about this though and I can add logic to check for the contents of the roles. Also, if the logic for the filtering is too confusing, I can edit the code to make it clearer and add comments?

Copy link
Member

Choose a reason for hiding this comment

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

I think you're right. Let's create tests for these cases to be sure

filteredVolunteers.push({
...volunteer.row,
filtered_roles: Array.from(volunteer.roleNames),
});
}
}
}

return { data: filteredVolunteers, status: 200 };
}
5 changes: 5 additions & 0 deletions src/lib/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
// Add any exports from your API files here

export { getExample } from "./getExample";
export {
getVolunteersByRoles,
isAllStrings,
isValidOperator,
Comment on lines +6 to +7
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

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

Exporting internal validation helper functions (isAllStrings, isValidOperator) in the public API index is unusual. These are implementation details that consumers of the API module shouldn't need. Consider keeping them as internal utilities or moving them to a separate utilities file if they need to be shared.

Suggested change
isAllStrings,
isValidOperator,

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

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

Agree with Copilot here. We don't need to export isAllStrings and isValidOperator here

} from "./getVolunteersByRoles";
103 changes: 103 additions & 0 deletions tests/lib/api/getVolunteersByRoles.test.ts
Copy link
Member

Choose a reason for hiding this comment

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

Good job doing the tests ahead of time! We will be running tests against an instance of the official database, so the test syntax will likely need to change a bit.

Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Example test for getExample function
// This test is not meaningful as is, but serves as a template
// You should modify it to fit your actual implementation and testing needs

import { describe, it, expect, vi, beforeEach } from "vitest";
import { getVolunteersByRoles } from "@/lib/api/getVolunteersByRoles";
import { createClient } from "@/lib/client/supabase/server";

const Roles1 = [
{
Roles: { name: "Role 1" },
Volunteers: {
id: 1,
},
},
{
Roles: { name: "Role 1" },
Volunteers: {
id: 3,
},
},
];

const Roles2 = [
{
Roles: { name: "Role 2" },
Volunteers: {
id: 2,
},
},
{
Roles: { name: "Role 2" },
Volunteers: {
id: 3,
},
},
];

// Mock the Supabase client
vi.mock("@/lib/client/supabase/server", () => ({
createClient: vi.fn(),
}));

describe("getVolunteersByRoles", () => {
const mockIn = vi.fn();
const mockSelect = vi.fn(() => ({ in: mockIn }));
const mockFrom = vi.fn(() => ({ select: mockSelect }));
const mockClient = { from: mockFrom };

beforeEach(() => {
vi.clearAllMocks();
// @ts-expect-error - Partial mock of SupabaseClient for testing
vi.mocked(createClient).mockResolvedValue(mockClient);
});

it("returns error response for an invalid operator", async () => {
const result = await getVolunteersByRoles("INVALID", ["Role 1"]);
expect(result.status).toBe(400);
expect(result).toEqual({ status: 400, error: "Operator is not AND or OR" });
});

it("returns error response if the filters array is malformed", async () => {
const result = await getVolunteersByRoles("OR", [1]);
expect(result.status).toBe(400);
expect(result).toEqual({
status: 400,
error: "Roles to filter by are not all strings",
});
});

it("returns all volunteers with Role 1 or Role 2", async () => {
mockIn.mockResolvedValueOnce({ data: [...Roles1, ...Roles2], error: null });

const result = await getVolunteersByRoles("OR", ["Role 1", "Role 2"]);
expect(result.status).toBe(200);
expect(result).toHaveProperty("data");

const ids = result.data?.map((volunteer) => volunteer.id).sort();
expect(ids).toEqual([1, 2, 3]);
});

it("returns all volunteers with Role 1", async () => {
mockIn.mockResolvedValueOnce({ data: Roles1, error: null });

const result = await getVolunteersByRoles("OR", ["Role 1"]);
expect(result.status).toBe(200);
expect(result).toHaveProperty("data");

const ids = result.data?.map((volunteer) => volunteer.id).sort();
expect(ids).toEqual([1, 3]);
});

it("returns all volunteers with Role 1 AND Role2", async () => {
mockIn.mockResolvedValueOnce({ data: [...Roles1, ...Roles2], error: null });

const result = await getVolunteersByRoles("AND", ["Role 1", "Role 2"]);
expect(result.status).toBe(200);
expect(result).toHaveProperty("data");

const ids = result.data?.map((volunteer) => volunteer.id).sort();
expect(ids).toEqual([3]);
});
});