From 64637104aedcb29f41114d75398f50ccac39d98a Mon Sep 17 00:00:00 2001 From: SURYANSH-RAI Date: Sat, 17 Oct 2026 18:55:42 +0530 Subject: [PATCH] Enforce candidate eligibility on every results path (R05) Hidden, incomplete, removed, and other-event participants no longer appear in fresh retrieval, cached search results, or saved matches. - One SQL rule (visible, named, profile saved) shared by listCandidates, cached-id hydration, and saved-match reads; saved matches are filtered on read, so a participant who becomes eligible again reappears - The pipeline filter also requires a completed profile - Viewers must have saved their event profile to search or compute matches; hidden viewers can still browse - Intro requests require a visible recipient with a completed profile - Unit tests for the rules and a real-Postgres test for all three paths --- .../application/use-cases/request-intro.ts | 2 +- apps/web/src/domain/matching/pipeline.ts | 3 +- .../specifications/complete-profile.spec.ts | 9 ++ .../matching/stages/intent-filter.stage.ts | 5 +- .../src/infrastructure/postgres/match.repo.ts | 3 +- .../postgres/participant.repo.ts | 10 +- .../integration/candidate-eligibility.test.ts | 112 ++++++++++++++++++ tests/integration/match-rank.test.ts | 3 +- tests/unit/candidate-eligibility.test.ts | 83 +++++++++++++ tests/unit/matching-pipeline.test.ts | 2 +- 10 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/domain/matching/specifications/complete-profile.spec.ts create mode 100644 tests/integration/candidate-eligibility.test.ts create mode 100644 tests/unit/candidate-eligibility.test.ts diff --git a/apps/web/src/application/use-cases/request-intro.ts b/apps/web/src/application/use-cases/request-intro.ts index b536da5..16b6ee3 100644 --- a/apps/web/src/application/use-cases/request-intro.ts +++ b/apps/web/src/application/use-cases/request-intro.ts @@ -12,7 +12,7 @@ export async function requestIntro( dependencies.participants.findProfile(input.eventId, input.requesterId), dependencies.participants.findProfile(input.eventId, input.recipientId), ]); - if (!requester || !recipient || !recipient.visible) throw new Error("This participant is unavailable"); + if (!requester || !recipient || !recipient.visible || !recipient.profileComplete) throw new Error("This participant is unavailable"); const intro = await dependencies.intros.create(input); if (recipient.email && dependencies.notifications) { await dependencies.notifications.introRequested({ diff --git a/apps/web/src/domain/matching/pipeline.ts b/apps/web/src/domain/matching/pipeline.ts index 941c786..0a4351a 100644 --- a/apps/web/src/domain/matching/pipeline.ts +++ b/apps/web/src/domain/matching/pipeline.ts @@ -62,7 +62,8 @@ export class MatchingPipeline { async run(input: MatchingInput) { if (input.mode === "search" && !input.query?.trim()) throw new Error("Enter a search query"); const storedProfile = await this.participants.findProfile(input.eventId, input.participantId); - if (!storedProfile) throw new Error("Complete your event profile before continuing"); + // Hidden viewers may still browse; only a saved profile is required. + if (!storedProfile?.profileComplete) throw new Error("Complete your event profile before continuing"); const embeddingText = input.mode === "search" ? input.query!.trim() : profileText(storedProfile); const embedding = input.mode === "profile" && storedProfile.embedding diff --git a/apps/web/src/domain/matching/specifications/complete-profile.spec.ts b/apps/web/src/domain/matching/specifications/complete-profile.spec.ts new file mode 100644 index 0000000..26f8746 --- /dev/null +++ b/apps/web/src/domain/matching/specifications/complete-profile.spec.ts @@ -0,0 +1,9 @@ +import type { ParticipantProfile } from "../../participant/participant.entity"; +import type { Specification } from "./specification"; + +/** A profile counts as complete once the participant has saved it for the event. */ +export class CompleteProfileSpecification implements Specification { + isSatisfiedBy(candidate: ParticipantProfile): boolean { + return candidate.profileComplete === true; + } +} diff --git a/apps/web/src/domain/matching/stages/intent-filter.stage.ts b/apps/web/src/domain/matching/stages/intent-filter.stage.ts index 1d4087f..9215c48 100644 --- a/apps/web/src/domain/matching/stages/intent-filter.stage.ts +++ b/apps/web/src/domain/matching/stages/intent-filter.stage.ts @@ -1,4 +1,5 @@ import type { ParticipantProfile } from "../../participant/participant.entity"; +import { CompleteProfileSpecification } from "../specifications/complete-profile.spec"; import { IntentCompatibleSpecification } from "../specifications/intent-compatible.spec"; import { VisibleSpecification } from "../specifications/visible.spec"; @@ -6,6 +7,8 @@ export class IntentFilterStage { execute(viewer: ParticipantProfile, candidates: ParticipantProfile[]): ParticipantProfile[] { const intent = new IntentCompatibleSpecification(viewer.intent); const visible = new VisibleSpecification(); - return candidates.filter((candidate) => intent.isSatisfiedBy(candidate) && visible.isSatisfiedBy(candidate)); + const complete = new CompleteProfileSpecification(); + return candidates.filter((candidate) => + intent.isSatisfiedBy(candidate) && visible.isSatisfiedBy(candidate) && complete.isSatisfiedBy(candidate)); } } diff --git a/apps/web/src/infrastructure/postgres/match.repo.ts b/apps/web/src/infrastructure/postgres/match.repo.ts index 869a3f5..f491c75 100644 --- a/apps/web/src/infrastructure/postgres/match.repo.ts +++ b/apps/web/src/infrastructure/postgres/match.repo.ts @@ -3,6 +3,7 @@ import type { MatchRepository } from "../../application/ports/match-repository"; import type { MatchCandidate } from "../../domain/matching/match-strategy"; import type { ParticipantProfile } from "../../domain/participant/participant.entity"; import { Intent } from "../../domain/participant/intent"; +import { ELIGIBLE_CANDIDATE } from "./participant.repo"; interface MatchRow { event_id: string; @@ -61,7 +62,7 @@ export class PostgresMatchRepository implements MatchRepository { from event_matches m join event_participants p on p.event_id = m.event_id and p.attendee_id = m.candidate_id join registered_attendees a on a.id = p.attendee_id - where m.event_id = $1 and m.participant_id = $2 + where m.event_id = $1 and m.participant_id = $2 and ${ELIGIBLE_CANDIDATE} order by m.rank, m.candidate_id limit $3`, [eventId, participantId, Math.max(1, Math.min(100, limit))] ); diff --git a/apps/web/src/infrastructure/postgres/participant.repo.ts b/apps/web/src/infrastructure/postgres/participant.repo.ts index 7c9fb29..c93ed7a 100644 --- a/apps/web/src/infrastructure/postgres/participant.repo.ts +++ b/apps/web/src/infrastructure/postgres/participant.repo.ts @@ -34,6 +34,12 @@ const PROFILE_COLUMNS = `p.event_id, a.id as attendee_id, a.name, p.venue_zone, p.visible, p.embedding::text as embedding, p.profile_updated_at`; const PROFILE_FROM = `from event_participants p join registered_attendees a on a.id = p.attendee_id`; +/** + * Who other participants may see as a candidate: on this event's roster (the + * join), visible, named, and with a saved event profile. Every results path — + * fresh retrieval, cached ids, saved matches — applies this same rule. + */ +export const ELIGIBLE_CANDIDATE = `p.visible = true and a.name is not null and p.profile_updated_at is not null`; export class PostgresParticipantRepository implements ParticipantRepository { async findProfile(eventId: string, attendeeId: string): Promise { @@ -72,7 +78,7 @@ export class PostgresParticipantRepository implements ParticipantRepository { const vector = toVector(input.embedding ?? null); const { rows } = await query( `select ${PROFILE_COLUMNS} ${PROFILE_FROM} - where p.event_id = $1 and ($2::uuid is null or a.id <> $2::uuid) and p.visible = true and a.name is not null + where p.event_id = $1 and ($2::uuid is null or a.id <> $2::uuid) and ${ELIGIBLE_CANDIDATE} order by case when $3::vector is not null and p.embedding is not null then 0 else 1 end, p.embedding <=> $3::vector nulls last, a.name asc limit $4`, @@ -85,7 +91,7 @@ export class PostgresParticipantRepository implements ParticipantRepository { if (!ids.length) return []; const { rows } = await query( `select ${PROFILE_COLUMNS} ${PROFILE_FROM} - where p.event_id = $1 and a.id = any($2::uuid[]) and p.visible = true + where p.event_id = $1 and a.id = any($2::uuid[]) and ${ELIGIBLE_CANDIDATE} order by array_position($2::uuid[], a.id)`, [eventId, ids] ); diff --git a/tests/integration/candidate-eligibility.test.ts b/tests/integration/candidate-eligibility.test.ts new file mode 100644 index 0000000..261ee55 --- /dev/null +++ b/tests/integration/candidate-eligibility.test.ts @@ -0,0 +1,112 @@ +/** + * Hidden, incomplete, removed, and other-event participants must not appear + * through any results path: fresh retrieval, cached search ids, or saved + * matches. Runs against a real Postgres and is skipped when DATABASE_URL is unset. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { query, closePool, attendees } from "@match-maker/db"; +import { migrate } from "../../packages/db/src/migrate"; +import { PostgresParticipantRepository } from "../../apps/web/src/infrastructure/postgres/participant.repo"; +import { PostgresMatchRepository } from "../../apps/web/src/infrastructure/postgres/match.repo"; +import { RetrieveStage } from "../../apps/web/src/domain/matching/stages/retrieve.stage"; +import { Intent } from "../../apps/web/src/domain/participant/intent"; +import type { MatchCandidate } from "../../apps/web/src/domain/matching/match-strategy"; + +const hasDatabase = Boolean(process.env.DATABASE_URL); +const describeDb = hasDatabase ? describe : describe.skip; + +const ORG_SLUG = "integration-eligibility"; +const KEYS = ["viewer", "eligible", "hidden", "incomplete", "removed", "other"] as const; +type Key = (typeof KEYS)[number]; +const emailOf = (key: Key) => `eligibility-${key}@example.com`; + +describeDb("candidate eligibility", () => { + const participants = new PostgresParticipantRepository(); + const matches = new PostgresMatchRepository(); + let eventId: string; + let otherEventId: string; + let ids: Record; + + beforeAll(async () => { + await migrate(); + await attendees.upsertMany(KEYS.map((key) => ({ name: key, email: emailOf(key) }))); + const found = await attendees.findIdsByEmails(KEYS.map(emailOf)); + ids = Object.fromEntries(KEYS.map((key) => [key, found.find((row) => row.email === emailOf(key))!.id])) as Record; + + const { rows: [org] } = await query<{ id: string }>( + `insert into organizations (name, slug) values ('Eligibility test', $1) + on conflict (slug) do update set name = excluded.name returning id`, [ORG_SLUG] + ); + const event = async (slug: string) => (await query<{ id: string }>( + `insert into events (organization_id, name, slug, status) values ($1, $2, $2, 'open') + on conflict (organization_id, slug) do update set status = 'open' returning id`, [org.id, slug] + )).rows[0].id; + eventId = await event(ORG_SLUG); + otherEventId = await event(`${ORG_SLUG}-other`); + + const join = (event: string, key: Key, saved: boolean, visible = true) => query( + `insert into event_participants (event_id, attendee_id, visible, profile_updated_at) + values ($1, $2, $3, case when $4 then now() end) + on conflict (event_id, attendee_id) do update + set visible = excluded.visible, profile_updated_at = excluded.profile_updated_at`, + [event, ids[key], visible, saved] + ); + await join(eventId, "viewer", true); + await join(eventId, "eligible", true); + await join(eventId, "hidden", true, false); + await join(eventId, "incomplete", false); + await join(eventId, "removed", true); + await join(otherEventId, "other", true); + + // Saved before "removed" left the roster; "hidden"/"incomplete" changed after matching. + await matches.save(eventId, ids.viewer, + (["hidden", "eligible", "incomplete", "removed"] as const).map((key) => candidate(key)), "v1+score"); + await query(`delete from event_participants where event_id = $1 and attendee_id = $2`, [eventId, ids.removed]); + }); + + afterAll(async () => { + await query(`delete from organizations where slug = $1`, [ORG_SLUG]); + await query(`delete from registered_attendees where email = any($1::text[])`, [KEYS.map(emailOf)]); + await closePool(); + }); + + function candidate(key: Key): MatchCandidate { + return { + participant: { + eventId, attendeeId: ids[key], name: key, designation: null, domain: null, aboutMe: null, + selfDescription: null, yearsOfExperience: null, mostImpressiveBuild: null, + intent: Intent.NETWORKING, interests: [], venueZone: null, visible: true, + }, + score: 50, scoreBreakdown: {}, mutualInterest: false, reason: `Reason for ${key}`, + }; + } + const keysOf = (rows: Array<{ attendeeId: string }>) => + rows.map((row) => KEYS.find((key) => ids[key] === row.attendeeId) ?? row.attendeeId); + + it("returns only eligible candidates from fresh retrieval", async () => { + const rows = await participants.listCandidates({ eventId, excludeAttendeeId: ids.viewer }); + expect(keysOf(rows)).toEqual(["eligible"]); + }); + + it("re-checks eligibility when hydrating cached ids", async () => { + const allIds = KEYS.map((key) => ids[key]); + expect(keysOf(await participants.findCandidatesByIds(eventId, allIds)).sort()).toEqual(["eligible", "viewer"]); + + // A cache entry written before people became ineligible still yields only current candidates. + const cache = { get: async () => allIds, set: async () => {} }; + const retrieved = await new RetrieveStage(participants, cache) + .execute({ eventId, viewerId: ids.viewer, embedding: null, query: "anything" }); + expect(keysOf(retrieved)).toEqual(["eligible"]); + }); + + it("filters saved matches that are no longer eligible", async () => { + const saved = await matches.listForParticipant(eventId, ids.viewer); + expect(keysOf(saved.map((match) => match.participant))).toEqual(["eligible"]); + }); + + it("keeps a hidden viewer's own matches readable", async () => { + await query(`update event_participants set visible = false where event_id = $1 and attendee_id = $2`, [eventId, ids.viewer]); + const saved = await matches.listForParticipant(eventId, ids.viewer); + expect(keysOf(saved.map((match) => match.participant))).toEqual(["eligible"]); + }); +}); diff --git a/tests/integration/match-rank.test.ts b/tests/integration/match-rank.test.ts index 4f21f9a..00e9a3e 100644 --- a/tests/integration/match-rank.test.ts +++ b/tests/integration/match-rank.test.ts @@ -36,7 +36,8 @@ describeDb("persisted match order", () => { ); eventId = event.id; for (const id of Object.values(ids)) { - await query(`insert into event_participants (event_id, attendee_id) values ($1, $2) on conflict do nothing`, [eventId, id]); + await query(`insert into event_participants (event_id, attendee_id, profile_updated_at) values ($1, $2, now()) + on conflict (event_id, attendee_id) do update set profile_updated_at = now()`, [eventId, id]); } }); diff --git a/tests/unit/candidate-eligibility.test.ts b/tests/unit/candidate-eligibility.test.ts new file mode 100644 index 0000000..5ac19bc --- /dev/null +++ b/tests/unit/candidate-eligibility.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import { MatchingPipeline } from "@/src/domain/matching/pipeline"; +import { IntentFilterStage } from "@/src/domain/matching/stages/intent-filter.stage"; +import { requestIntro } from "@/src/application/use-cases/request-intro"; +import type { IntroRepository } from "@/src/application/ports/intro-repository"; +import type { MatchRepository } from "@/src/application/ports/match-repository"; +import type { ParticipantRepository } from "@/src/application/ports/participant-repository"; +import { Intent } from "@/src/domain/participant/intent"; +import type { ParticipantProfile } from "@/src/domain/participant/participant.entity"; + +const profile = (attendeeId: string, overrides: Partial = {}): ParticipantProfile => ({ + eventId: "event", attendeeId, name: attendeeId, designation: null, domain: null, aboutMe: null, + selfDescription: null, yearsOfExperience: null, mostImpressiveBuild: null, + intent: Intent.NETWORKING, interests: [], venueZone: null, visible: true, profileComplete: true, + embedding: null, ...overrides, +}); + +const eligible = profile("eligible"); +const hidden = profile("hidden", { visible: false }); +const incomplete = profile("incomplete", { profileComplete: false }); + +describe("candidate eligibility filter", () => { + it("drops hidden and incomplete candidates", () => { + const kept = new IntentFilterStage().execute(profile("viewer"), [eligible, hidden, incomplete]); + expect(kept.map((candidate) => candidate.attendeeId)).toEqual(["eligible"]); + }); +}); + +describe("matching pipeline viewer", () => { + function setup(viewer: ParticipantProfile | null) { + const participants = { + findProfile: vi.fn(async () => viewer), + listCandidates: vi.fn(async () => [eligible, hidden, incomplete]), + setEmbedding: vi.fn(), + } as unknown as ParticipantRepository; + const matches = { save: vi.fn(), listForParticipant: vi.fn() } satisfies MatchRepository; + const embeddings = { embed: vi.fn(async () => null) }; + const cache = { get: vi.fn(), set: vi.fn() }; + return { pipeline: new MatchingPipeline(participants, matches, embeddings, null, cache), matches }; + } + const run = (pipeline: MatchingPipeline, mode: "profile" | "search") => + pipeline.run({ eventId: "event", participantId: "viewer", mode, query: "rag" }); + + it.each(["profile", "search"] as const)("refuses an unsaved profile in %s mode", async (mode) => { + const { pipeline, matches } = setup(profile("viewer", { profileComplete: false })); + await expect(run(pipeline, mode)).rejects.toThrow("Complete your event profile"); + expect(matches.save).not.toHaveBeenCalled(); + }); + + it("refuses a participant with no event profile", async () => { + await expect(run(setup(null).pipeline, "search")).rejects.toThrow("Complete your event profile"); + }); + + it("lets a hidden viewer browse eligible candidates only", async () => { + const { pipeline } = setup(profile("viewer", { visible: false })); + const result = await run(pipeline, "search"); + expect(result.map((match) => match.participant.attendeeId)).toEqual(["eligible"]); + }); +}); + +describe("intro request recipient", () => { + function setup(recipient: ParticipantProfile) { + const participants = { + hasEventAccess: vi.fn(async () => true), + findProfile: vi.fn(async (_eventId: string, id: string) => (id === "requester" ? profile("requester") : recipient)), + } as unknown as ParticipantRepository; + const intros = { create: vi.fn(async () => ({ id: "intro" })) } as unknown as IntroRepository; + return { participants, intros }; + } + const send = (dependencies: ReturnType, recipientId: string) => + requestIntro({ eventId: "event", requesterId: "requester", recipientId }, dependencies); + + it.each([hidden, incomplete])("refuses an unavailable recipient ($attendeeId)", async (recipient) => { + const dependencies = setup(recipient); + await expect(send(dependencies, recipient.attendeeId)).rejects.toThrow("unavailable"); + expect(dependencies.intros.create).not.toHaveBeenCalled(); + }); + + it("creates the request for an eligible recipient", async () => { + const dependencies = setup(eligible); + await expect(send(dependencies, "eligible")).resolves.toEqual({ id: "intro" }); + }); +}); diff --git a/tests/unit/matching-pipeline.test.ts b/tests/unit/matching-pipeline.test.ts index 31975dd..e85aec8 100644 --- a/tests/unit/matching-pipeline.test.ts +++ b/tests/unit/matching-pipeline.test.ts @@ -9,7 +9,7 @@ import type { ParticipantProfile } from "@/src/domain/participant/participant.en const profile = (attendeeId: string, overrides: Partial = {}): ParticipantProfile => ({ eventId: "event", attendeeId, name: attendeeId, designation: null, domain: null, aboutMe: null, selfDescription: null, yearsOfExperience: null, mostImpressiveBuild: null, - intent: Intent.NETWORKING, interests: [], venueZone: null, visible: true, embedding: null, ...overrides, + intent: Intent.NETWORKING, interests: [], venueZone: null, visible: true, profileComplete: true, embedding: null, ...overrides, }); // "strong" outscores "weak" on domain and shared interests, so score order is strong → weak.