Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
acff08b
feat: scope GET /opportunity to an AGENT caller's own agents
ivannissimrch Aug 25, 2026
4623c88
Merge remote-tracking branch 'origin/develop' into 934-scope-ngo-oppo…
ivannissimrch Aug 25, 2026
456c2ba
add test to ignore a PENDING membership
ivannissimrch Aug 25, 2026
2561c7c
fix: hoist the list response message
ivannissimrch Aug 25, 2026
fbdb8b8
Merge remote-tracking branch 'upstream/develop' into scope-opportunit…
ivannissimrch Aug 27, 2026
20396f0
fix: require an active membership for agent-scoped opportunity access
ivannissimrch Aug 28, 2026
50cfd88
Merge branch 'develop' into scope-opportunity-list-to-agent
arturasmckwcz Aug 28, 2026
4f2e022
Merge branch 'develop' into scope-opportunity-list-to-agent
arturasmckwcz Aug 31, 2026
232f1bb
refactor: drop the unreachable agent-filter spread from the scoped li…
ivannissimrch Sep 1, 2026
a33c85f
test: fail with a clear precondition when the seed has too few agents
ivannissimrch Sep 1, 2026
2da6e93
fix: scope GET /opportunity/:id to an AGENT caller's own agents
ivannissimrch Sep 1, 2026
8a392d9
fix: scope GET /:id/volunteer-linked to an AGENT caller's own agents
ivannissimrch Sep 1, 2026
913c6f3
Merge remote-tracking branch 'origin/scope-opportunity-list-to-agent'…
ivannissimrch Sep 1, 2026
7599a31
Merge remote-tracking branch 'upstream/develop' into scope-opportunit…
ivannissimrch Sep 1, 2026
5166c8c
test: cover agent scoping on GET /opportunity/:id and volunteer-linked
ivannissimrch Sep 1, 2026
2586097
fix: resolve the volunteer-linked scope check from the opportunity, n…
ivannissimrch Sep 1, 2026
8365bc8
fix: require an active membership for the opportunity contact person
ivannissimrch Sep 1, 2026
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
22 changes: 20 additions & 2 deletions src/server/routes/opportunity/opportunity-volunteer.routes.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -36,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;

Expand All @@ -54,12 +72,12 @@ export default function opportunityOpportunityVolunteerRoutes(
],
});

const agent = volunteers[0]?.opportunity?.agent;
// 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);
}
Expand Down
39 changes: 37 additions & 2 deletions src/server/routes/opportunity/opportunity.routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FastifyInstance, FastifyPluginOptions } from "fastify";
import {
AgentMembershipStatus,
ApiOpportunityGet,
ApiOpportunityPatch,
CommunicationType,
Expand All @@ -13,7 +14,7 @@ import {
SortOrder,
UserRole,
} from "need4deed-sdk";
import { EntityManager } from "typeorm";
import { EntityManager, In } from "typeorm";
import {
BadRequestError,
NotFoundError,
Expand Down Expand Up @@ -62,6 +63,7 @@ import {
import {
addAgentTypeServiceTranslations,
addComments2Entity,
getCallerAgentIds,
getCategoryToDealHandler,
getDistrictToAgentHandler,
getDistrictToOpportunityHandler,
Expand Down Expand Up @@ -207,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);
Expand Down Expand Up @@ -286,6 +297,27 @@ 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(
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,
data: [],
count: 0,
});
}
// NGOs are scoped to their own agents, so this overwrites any agent condition.
where.agent = { id: In(agentIds) };
}

logger.debug(
`GET /opportunities called. options: ${JSON.stringify({ where })}`,
Expand Down Expand Up @@ -373,7 +405,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,
});
Expand Down Expand Up @@ -439,6 +471,7 @@ export default async function opportunityRoutes(
? await fastify.db.agentPersonRepository.findOneBy({
agentId,
personId,
status: AgentMembershipStatus.ACTIVE,
})
: null;
if (!membership) {
Expand Down Expand Up @@ -614,6 +647,7 @@ export default async function opportunityRoutes(
? await fastify.db.agentPersonRepository.findOneBy({
agentId: opportunity.agentId,
personId,
status: AgentMembershipStatus.ACTIVE,
})
: null;
if (!membership) {
Expand Down Expand Up @@ -725,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(
Expand Down
19 changes: 19 additions & 0 deletions src/server/utils/data/get-caller-agent-ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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<number[]> {
if (personId === null || personId === undefined) {
return [];
}

const memberships = await fastify.db.agentPersonRepository.find({
where: { personId, status: AgentMembershipStatus.ACTIVE },
});

return [...new Set(memberships.map((m) => m.agentId))];
}
1 change: 1 addition & 0 deletions src/server/utils/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
6 changes: 4 additions & 2 deletions src/server/utils/pii/visible-persons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
Loading