Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion apps/web/src/application/use-cases/request-intro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/domain/matching/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ParticipantProfile> {
isSatisfiedBy(candidate: ParticipantProfile): boolean {
return candidate.profileComplete === true;
}
}
5 changes: 4 additions & 1 deletion apps/web/src/domain/matching/stages/intent-filter.stage.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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";

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));
}
}
3 changes: 2 additions & 1 deletion apps/web/src/infrastructure/postgres/match.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))]
);
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/infrastructure/postgres/participant.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ParticipantProfile | null> {
Expand Down Expand Up @@ -72,7 +78,7 @@ export class PostgresParticipantRepository implements ParticipantRepository {
const vector = toVector(input.embedding ?? null);
const { rows } = await query<ProfileRow>(
`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`,
Expand All @@ -85,7 +91,7 @@ export class PostgresParticipantRepository implements ParticipantRepository {
if (!ids.length) return [];
const { rows } = await query<ProfileRow>(
`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]
);
Expand Down
112 changes: 112 additions & 0 deletions tests/integration/candidate-eligibility.test.ts
Original file line number Diff line number Diff line change
@@ -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<Key, string>;

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<Key, string>;

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"]);
});
});
3 changes: 2 additions & 1 deletion tests/integration/match-rank.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
});

Expand Down
83 changes: 83 additions & 0 deletions tests/unit/candidate-eligibility.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<typeof setup>, 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" });
});
});
2 changes: 1 addition & 1 deletion tests/unit/matching-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { ParticipantProfile } from "@/src/domain/participant/participant.en
const profile = (attendeeId: string, overrides: Partial<ParticipantProfile> = {}): 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.
Expand Down
Loading