From acff08b02bc84f10a94abaae5a78806e1620a206 Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Tue, 25 Aug 2026 13:19:13 -0400 Subject: [PATCH 01/11] feat: scope GET /opportunity to an AGENT caller's own agents --- .../routes/opportunity/opportunity.routes.ts | 24 +++ src/server/utils/data/get-caller-agent-ids.ts | 17 ++ src/server/utils/data/index.ts | 1 + .../opportunity-agent-scope.routes.test.ts | 176 ++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 src/server/utils/data/get-caller-agent-ids.ts create mode 100644 src/test/server/routes/opportunity-agent-scope.routes.test.ts diff --git a/src/server/routes/opportunity/opportunity.routes.ts b/src/server/routes/opportunity/opportunity.routes.ts index 960544d9..9d416816 100644 --- a/src/server/routes/opportunity/opportunity.routes.ts +++ b/src/server/routes/opportunity/opportunity.routes.ts @@ -13,6 +13,7 @@ import { SortOrder, UserRole, } from "need4deed-sdk"; +import { FindOptionsWhere, In } from "typeorm"; import { BadRequestError, NotFoundError, @@ -61,6 +62,7 @@ import { import { addAgentTypeServiceTranslations, addComments2Entity, + getCallerAgentIds, getCategoryToDealHandler, getDistrictToAgentHandler, getDistrictToOpportunityHandler, @@ -262,6 +264,28 @@ export default async function opportunityRoutes( : undefined; const where = getOpportunityWhere(request.query.filter, request.query); + // NGOs see only their own agent's opportunities + if (request.authUser?.role === UserRole.AGENT) { + const agentIds = await getCallerAgentIds( + fastify, + request.authUser.personId, + ); + + // An agent with no shelter must see nothing, so return here rather than + // skipping the filter, which would show everything. + if (agentIds.length === 0) { + return reply.status(200).send({ + message: `Opportunities page:${request.query.page}.`, + data: [], + count: 0, + }); + } + // Spread so this composes if getOpportunityWhere ever sets an agent filter; + where.agent = { + ...(where.agent as FindOptionsWhere | undefined), + id: In(agentIds), + }; + } logger.debug( `GET /opportunities called. options: ${JSON.stringify({ where })}`, diff --git a/src/server/utils/data/get-caller-agent-ids.ts b/src/server/utils/data/get-caller-agent-ids.ts new file mode 100644 index 00000000..cd30cc08 --- /dev/null +++ b/src/server/utils/data/get-caller-agent-ids.ts @@ -0,0 +1,17 @@ +import { FastifyInstance } from "fastify"; +import { AgentMembershipStatus } from "need4deed-sdk"; + +export async function getCallerAgentIds( + fastify: FastifyInstance, + personId: number | null | undefined, +): Promise { + if (!personId) { + return []; + } + + const memberships = await fastify.db.agentPersonRepository.find({ + where: { personId, status: AgentMembershipStatus.ACTIVE }, + }); + + return [...new Set(memberships.map((m) => m.agentId))]; +} diff --git a/src/server/utils/data/index.ts b/src/server/utils/data/index.ts index 04bee3a7..fb8c3ab9 100644 --- a/src/server/utils/data/index.ts +++ b/src/server/utils/data/index.ts @@ -8,6 +8,7 @@ export * from "./create-agent-contact"; export * from "./for-routes"; export * from "./get-agent-by-postcode"; export * from "./get-agent-where"; +export * from "./get-caller-agent-ids"; export * from "./get-language-title"; export * from "./get-opportunity-orphanage-agent"; export * from "./get-opportunity-where"; diff --git a/src/test/server/routes/opportunity-agent-scope.routes.test.ts b/src/test/server/routes/opportunity-agent-scope.routes.test.ts new file mode 100644 index 00000000..4f0f2deb --- /dev/null +++ b/src/test/server/routes/opportunity-agent-scope.routes.test.ts @@ -0,0 +1,176 @@ +import { FastifyInstance } from "fastify"; +import { AgentMembershipStatus, AgentRoleType, UserRole } from "need4deed-sdk"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { accessCookieName } from "../../../config/constants"; +import AgentPerson from "../../../data/entity/m2m/agent-person"; +import Person from "../../../data/entity/person.entity"; +import User from "../../../data/entity/user.entity"; +import { hashPassword } from "../../../data/utils"; +import { createServer } from "../../../server"; + +// GET /opportunity returns every agent's opportunities to everyone. +// AGENT caller must only receive their own agent's, resolved from the +// authenticated caller rather than anything the request supplies. +const PASSWORD = "test_password"; + +describe("GET /opportunity is scoped to an AGENT caller's own agent(s)", () => { + let fastify: FastifyInstance; + const suffix = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const coordinatorEmail = `coordinator-${suffix}@test.need4deed.org`; + const agentEmail = `agentrole-${suffix}@test.need4deed.org`; + + let coordinatorCookie: string; + let agentCookie: string; + let personId: number; + + // Chosen from seeded data + let ownAgentId: number; + let otherAgentId: number; + let ownAgentOpportunityCount: number; + let totalOpportunityCount: number; + + async function login(email: string): Promise { + const res = await fastify.inject({ + method: "POST", + url: "/auth/login", + payload: { email, password: PASSWORD }, + }); + return res.cookies.find((cookie) => cookie.name === accessCookieName)! + .value; + } + + async function listOpportunities(cookie: string, query = "") { + return fastify.inject({ + method: "GET", + url: `/opportunity/?limit=120${query}`, + cookies: { [accessCookieName]: cookie }, + }); + } + + beforeAll(async () => { + fastify = await createServer(); + await fastify.ready(); + + await fastify.db.userRepository.save( + new User({ + email: coordinatorEmail, + password: await hashPassword(PASSWORD), + role: UserRole.COORDINATOR, + isActive: true, + }), + ); + coordinatorCookie = await login(coordinatorEmail); + + const person = await fastify.db.personRepository.save( + new Person({ firstName: "Scope", lastName: `Test-${suffix}` }), + ); + personId = person.id; + + await fastify.db.userRepository.save( + new User({ + email: agentEmail, + password: await hashPassword(PASSWORD), + role: UserRole.AGENT, + isActive: true, + personId, + }), + ); + agentCookie = await login(agentEmail); + + // Pick an agent that actually owns opportunities, and a different one that + // also does, otherwise test 3 can't tell "scoped" from "empty". + const owned = await fastify.db.opportunityRepository + .createQueryBuilder("opportunity") + .select("opportunity.agent_id", "agentId") + .addSelect("COUNT(*)", "count") + .where("opportunity.agent_id IS NOT NULL") + .groupBy("opportunity.agent_id") + .orderBy("COUNT(*)", "DESC") + .getRawMany<{ agentId: number; count: string }>(); + + ownAgentId = Number(owned[0].agentId); + otherAgentId = Number(owned[1].agentId); + ownAgentOpportunityCount = Number(owned[0].count); + totalOpportunityCount = await fastify.db.opportunityRepository.count(); + + await fastify.db.agentPersonRepository.save( + new AgentPerson({ + agentId: ownAgentId, + personId, + role: AgentRoleType.SOCIAL_WORKER, + status: AgentMembershipStatus.ACTIVE, + }), + ); + }); + + afterAll(async () => { + await fastify.db.agentPersonRepository.delete({ personId }); + await fastify.db.userRepository.delete({ personId }); + await fastify.db.personRepository.delete({ id: personId }); + await fastify.db.userRepository.delete({ email: coordinatorEmail }); + await fastify.close(); + }); + + it("returns only the caller's own agent's opportunities", async () => { + const res = await listOpportunities(agentCookie); + + expect(res.statusCode).toBe(200); + const { data, count } = res.json(); + expect(count).toBe(ownAgentOpportunityCount); + expect(data.length).toBeGreaterThan(0); + expect( + data.every((o: { agentId: number }) => o.agentId === ownAgentId), + ).toBe(true); + }); + + it("leaves a COORDINATOR seeing every agent's opportunities", async () => { + const res = await listOpportunities(coordinatorCookie); + + expect(res.statusCode).toBe(200); + expect(res.json().count).toBe(totalOpportunityCount); + expect(totalOpportunityCount).toBeGreaterThan(ownAgentOpportunityCount); + }); + + it("keeps an AGENT scoped to their own agent even when filter[agentId] names another", async () => { + const res = await listOpportunities( + agentCookie, + `&filter[agentId]=${otherAgentId}`, + ); + + expect(res.statusCode).toBe(200); + const { data, count } = res.json(); + expect(count).toBe(ownAgentOpportunityCount); + expect( + data.every((o: { agentId: number }) => o.agentId !== otherAgentId), + ).toBe(true); + }); + + it("covers every agent a caller is a member of, not just the first", async () => { + await fastify.db.agentPersonRepository.save( + new AgentPerson({ + agentId: otherAgentId, + personId, + role: AgentRoleType.SOCIAL_WORKER, + status: AgentMembershipStatus.ACTIVE, + }), + ); + + const res = await listOpportunities(agentCookie); + + expect(res.statusCode).toBe(200); + const agentIds = new Set( + res.json().data.map((o: { agentId: number }) => o.agentId), + ); + expect(agentIds.has(ownAgentId)).toBe(true); + expect(agentIds.has(otherAgentId)).toBe(true); + }); + + it("returns an empty list for an AGENT with no membership at all", async () => { + await fastify.db.agentPersonRepository.delete({ personId }); + + const res = await listOpportunities(agentCookie); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ data: [], count: 0 }); + }); +}); From 456c2ba5503091da48d89be603a2cc64c1cfe3c6 Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Tue, 25 Aug 2026 16:07:21 -0400 Subject: [PATCH 02/11] add test to ignore a PENDING membership --- .../opportunity-agent-scope.routes.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/test/server/routes/opportunity-agent-scope.routes.test.ts b/src/test/server/routes/opportunity-agent-scope.routes.test.ts index 4f0f2deb..952e827c 100644 --- a/src/test/server/routes/opportunity-agent-scope.routes.test.ts +++ b/src/test/server/routes/opportunity-agent-scope.routes.test.ts @@ -173,4 +173,21 @@ describe("GET /opportunity is scoped to an AGENT caller's own agent(s)", () => { expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ data: [], count: 0 }); }); + + it("ignores a PENDING membership", async () => { + await fastify.db.agentPersonRepository.delete({ personId }); + await fastify.db.agentPersonRepository.save( + new AgentPerson({ + agentId: ownAgentId, + personId, + role: AgentRoleType.SOCIAL_WORKER, + status: AgentMembershipStatus.PENDING, + }), + ); + + const res = await listOpportunities(agentCookie); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ data: [], count: 0 }); + }); }); From 2561c7c050b24f1bae5fb8856fd1ec4b86e6af92 Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Tue, 25 Aug 2026 16:18:11 -0400 Subject: [PATCH 03/11] fix: hoist the list response message --- src/server/routes/opportunity/opportunity.routes.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/server/routes/opportunity/opportunity.routes.ts b/src/server/routes/opportunity/opportunity.routes.ts index 9d416816..03a4d8de 100644 --- a/src/server/routes/opportunity/opportunity.routes.ts +++ b/src/server/routes/opportunity/opportunity.routes.ts @@ -264,6 +264,8 @@ export default async function opportunityRoutes( : undefined; const where = getOpportunityWhere(request.query.filter, request.query); + const message = `Opportunities page:${request.query.page}.`; + // NGOs see only their own agent's opportunities if (request.authUser?.role === UserRole.AGENT) { const agentIds = await getCallerAgentIds( @@ -275,7 +277,7 @@ export default async function opportunityRoutes( // skipping the filter, which would show everything. if (agentIds.length === 0) { return reply.status(200).send({ - message: `Opportunities page:${request.query.page}.`, + message, data: [], count: 0, }); @@ -373,7 +375,7 @@ export default async function opportunityRoutes( // DTO (dtoOpportunityGetList) runs in the preSerialization hook after PII masking. return reply.status(200).send({ - message: `Opportunities page:${request.query.page}.`, + message, data: opportunitiesCategoryDistrict, count, }); From 20396f0117345ee7742f6c286a3ea028adddaafe Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Fri, 28 Aug 2026 06:23:40 -0400 Subject: [PATCH 04/11] fix: require an active membership for agent-scoped opportunity access --- src/server/routes/opportunity/opportunity.routes.ts | 3 +++ src/server/utils/data/get-caller-agent-ids.ts | 4 +++- src/server/utils/pii/visible-persons.ts | 6 ++++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/server/routes/opportunity/opportunity.routes.ts b/src/server/routes/opportunity/opportunity.routes.ts index d1bba2a7..b8bba09c 100644 --- a/src/server/routes/opportunity/opportunity.routes.ts +++ b/src/server/routes/opportunity/opportunity.routes.ts @@ -1,5 +1,6 @@ import { FastifyInstance, FastifyPluginOptions } from "fastify"; import { + AgentMembershipStatus, ApiOpportunityGet, ApiOpportunityPatch, CommunicationType, @@ -464,6 +465,7 @@ export default async function opportunityRoutes( ? await fastify.db.agentPersonRepository.findOneBy({ agentId, personId, + status: AgentMembershipStatus.ACTIVE, }) : null; if (!membership) { @@ -639,6 +641,7 @@ export default async function opportunityRoutes( ? await fastify.db.agentPersonRepository.findOneBy({ agentId: opportunity.agentId, personId, + status: AgentMembershipStatus.ACTIVE, }) : null; if (!membership) { diff --git a/src/server/utils/data/get-caller-agent-ids.ts b/src/server/utils/data/get-caller-agent-ids.ts index cd30cc08..f2f5a985 100644 --- a/src/server/utils/data/get-caller-agent-ids.ts +++ b/src/server/utils/data/get-caller-agent-ids.ts @@ -1,11 +1,13 @@ import { FastifyInstance } from "fastify"; import { AgentMembershipStatus } from "need4deed-sdk"; +// The agents this caller belongs to. Only ACTIVE memberships count. A PENDING +// one is still waiting on a coordinator to approve it, so it grants nothing. export async function getCallerAgentIds( fastify: FastifyInstance, personId: number | null | undefined, ): Promise { - if (!personId) { + if (personId === null || personId === undefined) { return []; } diff --git a/src/server/utils/pii/visible-persons.ts b/src/server/utils/pii/visible-persons.ts index c6282c1b..8eef17e4 100644 --- a/src/server/utils/pii/visible-persons.ts +++ b/src/server/utils/pii/visible-persons.ts @@ -2,6 +2,7 @@ import { FastifyInstance } from "fastify"; import { UserRole } from "need4deed-sdk"; import { In } from "typeorm"; import User from "../../../data/entity/user.entity"; +import { getCallerAgentIds } from "../data/get-caller-agent-ids"; /** * What a non COORDINATOR/ADMIN caller may see UNMASKED, resolved per request @@ -65,8 +66,9 @@ export async function resolveCallerVisibility( } const agentPersonRepository = fastify.db.agentPersonRepository; - const memberships = await agentPersonRepository.find({ where: { personId } }); - memberships.forEach((m) => agentIds.add(m.agentId)); + const callerAgentIds = await getCallerAgentIds(fastify, personId); + callerAgentIds.forEach((id) => agentIds.add(id)); + if (agentIds.size === 0) { return visibility; } From 232f1bb8f1d4bf8402434c081cbd9d7f85418d50 Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Tue, 1 Sep 2026 06:50:32 -0400 Subject: [PATCH 05/11] refactor: drop the unreachable agent-filter spread from the scoped list query --- src/server/routes/opportunity/opportunity.routes.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/server/routes/opportunity/opportunity.routes.ts b/src/server/routes/opportunity/opportunity.routes.ts index b8bba09c..aced2db4 100644 --- a/src/server/routes/opportunity/opportunity.routes.ts +++ b/src/server/routes/opportunity/opportunity.routes.ts @@ -14,7 +14,7 @@ import { SortOrder, UserRole, } from "need4deed-sdk"; -import { EntityManager, FindOptionsWhere, In } from "typeorm"; +import { EntityManager, In } from "typeorm"; import { BadRequestError, NotFoundError, @@ -306,11 +306,8 @@ export default async function opportunityRoutes( count: 0, }); } - // Spread so this composes if getOpportunityWhere ever sets an agent filter; - where.agent = { - ...(where.agent as FindOptionsWhere | undefined), - id: In(agentIds), - }; + // NGOs are scoped to their own agents, so this overwrites any agent condition. + where.agent = { id: In(agentIds) }; } logger.debug( From a33c85ff20b667124fbae9e8308450920cc7a7c5 Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Tue, 1 Sep 2026 06:57:53 -0400 Subject: [PATCH 06/11] test: fail with a clear precondition when the seed has too few agents --- .../server/routes/opportunity-agent-scope.routes.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/test/server/routes/opportunity-agent-scope.routes.test.ts b/src/test/server/routes/opportunity-agent-scope.routes.test.ts index 952e827c..6ea0bc98 100644 --- a/src/test/server/routes/opportunity-agent-scope.routes.test.ts +++ b/src/test/server/routes/opportunity-agent-scope.routes.test.ts @@ -88,6 +88,12 @@ describe("GET /opportunity is scoped to an AGENT caller's own agent(s)", () => { .orderBy("COUNT(*)", "DESC") .getRawMany<{ agentId: number; count: string }>(); + if (owned.length < 2) { + throw new Error( + `This suite needs at least 2 agents owning opportunities, found ${owned.length}.`, + ); + } + ownAgentId = Number(owned[0].agentId); otherAgentId = Number(owned[1].agentId); ownAgentOpportunityCount = Number(owned[0].count); From 2da6e9376c70cc41fe7d3f18ee9c0a7867207111 Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Tue, 1 Sep 2026 07:41:48 -0400 Subject: [PATCH 07/11] fix: scope GET /opportunity/:id to an AGENT caller's own agents --- src/server/routes/opportunity/opportunity.routes.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/server/routes/opportunity/opportunity.routes.ts b/src/server/routes/opportunity/opportunity.routes.ts index aced2db4..12b5b497 100644 --- a/src/server/routes/opportunity/opportunity.routes.ts +++ b/src/server/routes/opportunity/opportunity.routes.ts @@ -209,6 +209,15 @@ export default async function opportunityRoutes( if (!opportunity) { throw new NotFoundError(`Opportunity (id:${id}) not found.`); } + if (request.authUser?.role === UserRole.AGENT) { + const agentIds = await getCallerAgentIds( + fastify, + request.authUser.personId, + ); + if (!opportunity.agentId || !agentIds.includes(opportunity.agentId)) { + throw new NotFoundError(`Opportunity (id:${id}) not found.`); + } + } const opportunityComments: Opportunity & { comments: Comment[] } = await addComments2Entity(opportunity); From 8a392d90ce3d7b14cc8722d6e64eee379d06c45a Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Tue, 1 Sep 2026 07:49:46 -0400 Subject: [PATCH 08/11] fix: scope GET /:id/volunteer-linked to an AGENT caller's own agents --- .../opportunity-volunteer.routes.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/server/routes/opportunity/opportunity-volunteer.routes.ts b/src/server/routes/opportunity/opportunity-volunteer.routes.ts index acb81318..b334e324 100644 --- a/src/server/routes/opportunity/opportunity-volunteer.routes.ts +++ b/src/server/routes/opportunity/opportunity-volunteer.routes.ts @@ -1,9 +1,11 @@ import { FastifyInstance, FastifyPluginOptions } from "fastify"; -import { BadRequestError } from "../../../config/error/fastify"; +import { UserRole } from "need4deed-sdk"; +import { BadRequestError, NotFoundError } from "../../../config/error/fastify"; import OpportunityVolunteer from "../../../data/entity/m2m/opportunity-volunteer"; import { opportunityOpportunityVolunteerDTO } from "../../../services"; import { idParamSchema, responseSchema } from "../../schema"; import { + getCallerAgentIds, maskVolunteerIdentities, shouldMaskInactiveAgentData, } from "../../utils"; @@ -54,12 +56,25 @@ export default function opportunityOpportunityVolunteerRoutes( ], }); + const agent = volunteers[0]?.opportunity?.agent; + + if (agent && request.authUser?.role === UserRole.AGENT) { + const agentIds = await getCallerAgentIds( + fastify, + request.authUser.personId, + ); + if (!agentIds.includes(agent.id)) { + throw new NotFoundError( + `Opportunity (id:${opportunityId}) not found.`, + ); + } + } + // An INACTIVE agent's linked volunteers shouldn't read as live, // actionable data (be#885) here either — this route surfaces the same // underlying rows as GET /agent/:id/volunteer-linked, just scoped by // opportunityId instead of agentId, so it needs the same rule. All // rows share one opportunity/agent, so checking the first is enough. - const agent = volunteers[0]?.opportunity?.agent; if (agent && shouldMaskInactiveAgentData(agent, request.authUser?.role)) { maskVolunteerIdentities(volunteers); } From 5166c8c546852758c7dfe11ba460b205a146bc20 Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Tue, 1 Sep 2026 16:18:56 -0400 Subject: [PATCH 09/11] test: cover agent scoping on GET /opportunity/:id and volunteer-linked --- .../opportunity-agent-scope.routes.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/test/server/routes/opportunity-agent-scope.routes.test.ts b/src/test/server/routes/opportunity-agent-scope.routes.test.ts index 6ea0bc98..feed5fce 100644 --- a/src/test/server/routes/opportunity-agent-scope.routes.test.ts +++ b/src/test/server/routes/opportunity-agent-scope.routes.test.ts @@ -151,6 +151,59 @@ describe("GET /opportunity is scoped to an AGENT caller's own agent(s)", () => { ).toBe(true); }); + it("404s when an AGENT fetches another agent's linked volunteers", async () => { + const link = await fastify.db.opportunityVolunteerRepository.findOne({ + where: { opportunity: { agentId: otherAgentId } }, + relations: ["opportunity"], + }); + expect(link).toBeTruthy(); + + const res = await fastify.inject({ + method: "GET", + url: `/opportunity/${link!.opportunityId}/volunteer-linked`, + cookies: { [accessCookieName]: agentCookie }, + }); + expect(res.statusCode).toBe(404); + }); + + it("lets an AGENT fetch their own agent's linked volunteers", async () => { + const link = await fastify.db.opportunityVolunteerRepository.findOne({ + where: { opportunity: { agentId: ownAgentId } }, + relations: ["opportunity"], + }); + expect(link).toBeTruthy(); + + const res = await fastify.inject({ + method: "GET", + url: `/opportunity/${link!.opportunityId}/volunteer-linked`, + cookies: { [accessCookieName]: agentCookie }, + }); + expect(res.statusCode).toBe(200); + }); + + it("404s when an AGENT fetches another agent's opportunity by id", async () => { + const other = await fastify.db.opportunityRepository.findOneBy({ + agentId: otherAgentId, + }); + const res = await fastify.inject({ + method: "GET", + url: `/opportunity/${other!.id}`, + cookies: { [accessCookieName]: agentCookie }, + }); + expect(res.statusCode).toBe(404); + }); + it("lets an AGENT fetch their own agent's opportunity by id", async () => { + const own = await fastify.db.opportunityRepository.findOneBy({ + agentId: ownAgentId, + }); + const res = await fastify.inject({ + method: "GET", + url: `/opportunity/${own!.id}`, + cookies: { [accessCookieName]: agentCookie }, + }); + expect(res.statusCode).toBe(200); + }); + it("covers every agent a caller is a member of, not just the first", async () => { await fastify.db.agentPersonRepository.save( new AgentPerson({ From 258609772047b2cf0a70815b28c23d6eaccbfd49 Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Tue, 1 Sep 2026 19:10:24 -0400 Subject: [PATCH 10/11] fix: resolve the volunteer-linked scope check from the opportunity, not its volunteers --- .../opportunity-volunteer.routes.ts | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/server/routes/opportunity/opportunity-volunteer.routes.ts b/src/server/routes/opportunity/opportunity-volunteer.routes.ts index b334e324..17e932ea 100644 --- a/src/server/routes/opportunity/opportunity-volunteer.routes.ts +++ b/src/server/routes/opportunity/opportunity-volunteer.routes.ts @@ -38,6 +38,22 @@ export default function opportunityOpportunityVolunteerRoutes( throw new BadRequestError(msg400); } + if (request.authUser?.role === UserRole.AGENT) { + const opportunity = await fastify.db.opportunityRepository.findOne({ + where: { id: opportunityId }, + select: { id: true, agentId: true }, + }); + const agentIds = await getCallerAgentIds( + fastify, + request.authUser.personId, + ); + if (!opportunity?.agentId || !agentIds.includes(opportunity.agentId)) { + throw new NotFoundError( + `Opportunity (id:${opportunityId}) not found.`, + ); + } + } + const opportunityVolunteerRepository = fastify.db.opportunityVolunteerRepository; @@ -57,19 +73,6 @@ export default function opportunityOpportunityVolunteerRoutes( }); const agent = volunteers[0]?.opportunity?.agent; - - if (agent && request.authUser?.role === UserRole.AGENT) { - const agentIds = await getCallerAgentIds( - fastify, - request.authUser.personId, - ); - if (!agentIds.includes(agent.id)) { - throw new NotFoundError( - `Opportunity (id:${opportunityId}) not found.`, - ); - } - } - // An INACTIVE agent's linked volunteers shouldn't read as live, // actionable data (be#885) here either — this route surfaces the same // underlying rows as GET /agent/:id/volunteer-linked, just scoped by From 8365bc864fc10752b44fd2a4bf485413f6cd8111 Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Tue, 1 Sep 2026 19:55:56 -0400 Subject: [PATCH 11/11] fix: require an active membership for the opportunity contact person --- src/server/routes/opportunity/opportunity.routes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/routes/opportunity/opportunity.routes.ts b/src/server/routes/opportunity/opportunity.routes.ts index 12b5b497..099599b7 100644 --- a/src/server/routes/opportunity/opportunity.routes.ts +++ b/src/server/routes/opportunity/opportunity.routes.ts @@ -759,6 +759,7 @@ export default async function opportunityRoutes( await fastify.db.agentPersonRepository.findOneBy({ agentId: effectiveAgentId, personId: contactLinkId, + status: AgentMembershipStatus.ACTIVE, }); if (!agentContactMembership) { throw new NotFoundError(