From 179ceecaa758be621331b972d29a362196b83fcc Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 13 Sep 2026 11:10:15 +0100 Subject: [PATCH] Implement dimension one integrity fixes --- .github/workflows/quality.yml | 21 ++ audit/dimension-1/domain.audit.test.ts | 36 ++- audit/dimension-1/identity.audit.test.ts | 6 +- audit/dimension-1/mutations.audit.test.ts | 17 +- audit/dimension-1/session.audit.test.tsx | 19 +- package.json | 3 + .../migration.sql | 23 ++ prisma/schema.prisma | 20 ++ scripts/report-game-duplicates.ts | 43 +++ scripts/test-dimension1-integration.ts | 163 ++++++++++ src/app/account/actions.ts | 12 +- src/app/account/library/page.test.tsx | 14 + src/app/account/library/page.tsx | 7 +- src/app/account/page.tsx | 2 +- src/app/actions.availability.test.ts | 107 ++++++ src/app/actions.ts | 304 ++++++++++++------ src/app/discord/handoff/[token]/route.ts | 11 + src/app/discover/[slug]/page.tsx | 24 +- src/app/discover/discover-pages.test.tsx | 9 +- src/app/games/[slug]/page.tsx | 23 +- src/app/games/id/[gameId]/page.tsx | 104 ++++++ src/app/s/[shareToken]/page.tsx | 214 ++++++------ src/app/s/[shareToken]/session-page.test.tsx | 130 ++++++-- src/app/sessions/pick/page.test.tsx | 34 ++ src/app/sessions/pick/page.tsx | 13 +- src/app/users/[username]/page.tsx | 59 ++-- src/components/app-navigation.test.tsx | 11 + src/components/app-navigation.tsx | 18 +- src/components/availability-form.test.tsx | 95 +++++- src/components/availability-form.tsx | 48 ++- src/components/game-signal-controls.test.tsx | 74 +++++ src/components/game-signal-controls.tsx | 113 ++++++- src/components/pick-panel.test.tsx | 85 ++++- src/components/pick-panel.tsx | 136 +++++++- src/components/player-count-filter.test.tsx | 2 + src/components/post-import-status.test.tsx | 49 +++ src/components/post-import-status.tsx | 13 +- src/components/ranked-match-list.test.tsx | 61 ++++ src/components/ranked-match-list.tsx | 59 +++- src/components/recent-session-action.tsx | 8 +- src/lib/account-merge.ts | 81 +++-- src/lib/auth.ts | 56 ++-- src/lib/discord-handoff.test.ts | 59 ++++ src/lib/discord-handoff.ts | 62 ++++ src/lib/discord.test.ts | 40 ++- src/lib/discord.ts | 31 +- src/lib/games.test.ts | 4 +- src/lib/games.ts | 34 +- src/lib/group-buy.ts | 31 +- src/lib/match-scoring.test.ts | 15 +- src/lib/match-scoring.ts | 163 +++++++--- src/lib/pick/access.test.ts | 25 ++ src/lib/pick/access.ts | 31 ++ src/lib/pick/contracts.ts | 45 +++ src/lib/pick/query.test.ts | 51 +++ src/lib/pick/query.ts | 165 ++++++++++ src/lib/player-count.ts | 2 +- src/lib/scheduling.ts | 47 ++- src/lib/session/authorization.ts | 70 ++++ src/lib/session/contracts.ts | 33 ++ src/lib/session/membership.ts | 32 ++ 61 files changed, 2764 insertions(+), 503 deletions(-) create mode 100644 .github/workflows/quality.yml create mode 100644 prisma/migrations/20260910093000_dimension_1_availability_revision/migration.sql create mode 100644 scripts/report-game-duplicates.ts create mode 100644 scripts/test-dimension1-integration.ts create mode 100644 src/app/account/library/page.test.tsx create mode 100644 src/app/actions.availability.test.ts create mode 100644 src/app/discord/handoff/[token]/route.ts create mode 100644 src/app/games/id/[gameId]/page.tsx create mode 100644 src/app/sessions/pick/page.test.tsx create mode 100644 src/components/game-signal-controls.test.tsx create mode 100644 src/components/post-import-status.test.tsx create mode 100644 src/components/ranked-match-list.test.tsx create mode 100644 src/lib/discord-handoff.test.ts create mode 100644 src/lib/discord-handoff.ts create mode 100644 src/lib/pick/access.test.ts create mode 100644 src/lib/pick/access.ts create mode 100644 src/lib/pick/contracts.ts create mode 100644 src/lib/pick/query.test.ts create mode 100644 src/lib/pick/query.ts create mode 100644 src/lib/session/authorization.ts create mode 100644 src/lib/session/contracts.ts create mode 100644 src/lib/session/membership.ts diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..312ed1b --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,21 @@ +name: Quality + +on: + pull_request: + push: + branches: [main] + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.12.0 + cache: npm + - run: npm ci + - run: npm test + - run: npm run lint + - run: npm run build diff --git a/audit/dimension-1/domain.audit.test.ts b/audit/dimension-1/domain.audit.test.ts index 51fadfe..681443f 100644 --- a/audit/dimension-1/domain.audit.test.ts +++ b/audit/dimension-1/domain.audit.test.ts @@ -30,13 +30,13 @@ function fixture(): ScoreInput { } describe("dimension 1: known domain defects at audit baseline", () => { - it.fails("F04: persistent ownership remains authoritative after shortlisting", () => { + it("F04: persistent ownership remains authoritative after shortlisting", () => { const input = fixture(); input.sessionGames[0].signals = []; expect(scoreSessionGames(input)[0].ownership.have).toBe(2); }); - it.fails("F07: a veto prevents the perfect category", () => { + it("F07: a veto prevents the perfect category", () => { const input = fixture(); input.sessionGames[0].interests = [{ participantId: "p2", interest: "NOT_TONIGHT" }]; const result = scoreSessionGames(input)[0]; @@ -44,14 +44,14 @@ describe("dimension 1: known domain defects at audit baseline", () => { expect(result.categories).not.toContain("perfect"); }); - it.fails("F06: an unknown upper player limit cannot confirm a 50-player fit", () => { + it("F06: an unknown upper player limit cannot confirm a 50-player fit", () => { const input = fixture(); input.playerCount = 50; input.sessionGames[0].game.maxPlayers = null; expect(scoreSessionGames(input)[0].playerCountStatus).toBe("uncertain"); }); - it.fails("F07: unknown platform for one selected owner prevents confirmed same-platform fit", () => { + it("F07: unknown platform for one selected owner prevents confirmed same-platform fit", () => { const input = fixture(); input.participants.push({ id: "p3", userId: "u3" }); input.selectedParticipantIds!.push("p3"); @@ -61,7 +61,7 @@ describe("dimension 1: known domain defects at audit baseline", () => { expect(scoreSessionGames(input)[0].platformFit).toBe("unknown"); }); - it.fails("F07: a 2-player game is not perfect for four selected participants", () => { + it("F07: a 2-player game is not perfect for four selected participants", () => { const input = fixture(); input.sessionGames[0].game.maxPlayers = 2; for (const n of [3, 4]) { @@ -72,16 +72,17 @@ describe("dimension 1: known domain defects at audit baseline", () => { expect(scoreSessionGames(input).some((game) => game.categories.includes("perfect"))).toBe(false); }); - it.fails("F06: Discovery and Pick agree on the same modded 5-player candidate", () => { + it("F06: Discovery and Pick agree on the same modded 5-player candidate", () => { const curated = getCuratedGame("subnautica")!; expect(supportsAtLeast(curated, 5)).toBe(true); const input = fixture(); input.playerCount = 5; input.sessionGames[0].game = { ...curated, id: "g1" }; + input.setup = "modded"; expect(scoreSessionGames(input)).toHaveLength(1); }); - it.fails("F07: tied scores have an identity-based total order independent of input order", () => { + it("F07: tied scores have an identity-based total order independent of input order", () => { const input = fixture(); input.sessionGames.push({ ...input.sessionGames[0], id: "sg2", gameId: "g2", game: { ...input.sessionGames[0].game, id: "g2" } }); input.userGames = []; @@ -90,23 +91,36 @@ describe("dimension 1: known domain defects at audit baseline", () => { expect(first).toEqual(second); }); - it.fails("F10: a budget constraint excludes known over-budget group buys", () => { + it("F10: a budget constraint excludes known over-budget group buys", () => { const deals = new Map(curatedGames.map((game) => [game.title, { currentPrice: 5000, currency: "GBP", discountPercent: 0 }])); const result = scoreGroupBuyCandidates({ filters: { ...defaultGroupBuyFilters(2), budget: 100, avoidOwned: false }, ownedTitles: [], deals }); expect(result.filter((r) => r.price !== null && r.price !== undefined && r.price > 100)).toHaveLength(0); }); - it.fails("F10: a one-night-only request cannot manufacture a long-term section", () => { + it("F10: a one-night-only request cannot manufacture a long-term section", () => { const result = scoreGroupBuyCandidates({ filters: { ...defaultGroupBuyFilters(2), sessionLength: "one-night", avoidOwned: false }, ownedTitles: [], deals: new Map() }); expect(result.filter((r) => r.section === "longTerm")).toHaveLength(0); }); - it.fails("F11: spring DST produces unique real hourly instants", () => { + it("F10: multi-token group-buy genres use an explicit all-token match", () => { + const result = scoreGroupBuyCandidates({ + filters: { ...defaultGroupBuyFilters(4), genre: "co-op survival", avoidOwned: false }, + ownedTitles: [], + deals: new Map(), + }); + expect(result.length).toBeGreaterThan(0); + expect(result.every((recommendation) => { + const tags = recommendation.game.tags.map((tag) => tag.replaceAll("co-op", "coop")); + return tags.some((tag) => tag.includes("coop")) && tags.some((tag) => tag.includes("survival")); + })).toBe(true); + }); + + it("F11: spring DST produces unique real hourly instants", () => { const slots = generateHourlySlots({ dateRangeStart: "2026-03-29", dateRangeEnd: "2026-03-29", dailyStartHour: 0, dailyEndHour: 4, requiredDuration: 2, timezone: "Europe/London" }); expect(new Set(slots.map((s) => s.startsAt.toISOString())).size).toBe(slots.length); }); - it.fails("F11: autumn DST candidates contain adjacent real hours", () => { + it("F11: autumn DST candidates contain adjacent real hours", () => { const windows = generateCandidateWindows({ dateRangeStart: "2026-10-25", dateRangeEnd: "2026-10-25", dailyStartHour: 0, dailyEndHour: 4, requiredDuration: 2, timezone: "Europe/London" }); expect(windows.every((w) => w.endsAt.getTime() - w.startsAt.getTime() === 2 * 60 * 60 * 1000)).toBe(true); }); diff --git a/audit/dimension-1/identity.audit.test.ts b/audit/dimension-1/identity.audit.test.ts index 1b7d2d0..dcf29e8 100644 --- a/audit/dimension-1/identity.audit.test.ts +++ b/audit/dimension-1/identity.audit.test.ts @@ -21,7 +21,7 @@ vi.mock("@/lib/prisma", () => ({ prisma: { beforeEach(() => { mocks.values.clear(); vi.clearAllMocks(); }); describe("dimension 1: known identity defects at audit baseline", () => { - it.fails("F01: ordinary participant refresh preserves an anonymous host grant", async () => { + it("F01: ordinary participant refresh preserves an anonymous host grant", async () => { await setParticipantIdentity("s1", "host1", { isHost: true }); expect(await getHostParticipantId("s1")).toBe("host1"); // submitAvailabilityAction calls this without options after saving the name. @@ -29,7 +29,7 @@ describe("dimension 1: known identity defects at audit baseline", () => { expect(await getHostParticipantId("s1")).toBe("host1"); }); - it.fails("F02: stale cookie cannot override the signed-in account's supplied membership", async () => { + it("F02: stale cookie cannot override the signed-in account's supplied membership", async () => { await setParticipantIdentity("s1", "old-user-participant"); mocks.values.set("lpg_session", signValue("new-user-session-token")); mocks.userSessionFindFirst.mockResolvedValue({ user: { id: "new-user" } }); @@ -45,7 +45,7 @@ describe("dimension 1: known identity defects at audit baseline", () => { expect(await getHostParticipantId("s1")).toBe("host1"); }); - it.fails("F15: redirects remain same-origin after browser URL normalization", () => { + it("F15: redirects remain same-origin after browser URL normalization", () => { const destination = safeInternalRedirect("/\\example.invalid/path"); expect(new URL(destination, "https://app.invalid").origin).toBe("https://app.invalid"); }); diff --git a/audit/dimension-1/mutations.audit.test.ts b/audit/dimension-1/mutations.audit.test.ts index 55ce768..44d7b97 100644 --- a/audit/dimension-1/mutations.audit.test.ts +++ b/audit/dimension-1/mutations.audit.test.ts @@ -41,7 +41,7 @@ beforeEach(() => { }); describe("dimension 1: known mutation defects at audit baseline", () => { - it.fails("F05: suggesting a game does not write persistent ownership", async () => { + it("F05: suggesting a game does not write persistent ownership", async () => { const form = new FormData(); Object.entries({ shareToken: "share", participantId: "p1", title: "Suggested title", gameId: "g1", source: "MANUAL" }).forEach(([k, v]) => form.set(k, v)); await addSessionGameAction(form); @@ -49,19 +49,19 @@ describe("dimension 1: known mutation defects at audit baseline", () => { expect(mocks.userGameUpsert).not.toHaveBeenCalled(); }); - it.fails("F12: a title-only add does not overwrite known co-op metadata with false", async () => { + it("F12: a title-only add does not overwrite known co-op metadata with false", async () => { await upsertGame({ title: "Known title" }); expect(mocks.gameUpdate).toHaveBeenCalledOnce(); expect(mocks.gameUpdate.mock.calls[0][0].data.onlineCoop).toBeUndefined(); }); - it.fails("F12: generic multiplayer metadata is insufficient to assert co-op", async () => { + it("F12: generic multiplayer metadata is insufficient to assert co-op", async () => { await upsertGame({ title: "Synthetic versus game", steamAppId: 123, gameModes: ["Multiplayer"] }); expect(mocks.gameUpsert).toHaveBeenCalledOnce(); expect(mocks.gameUpsert.mock.calls[0][0].create.onlineCoop).not.toBe(true); }); - it.fails("F14: clearing a rating writes null, not an omitted update", async () => { + it("F14: clearing a rating writes null, not an omitted update", async () => { const form = new FormData(); Object.entries({ gameId: "g1", ownership: "HAVE", rating: "", notes: "", interest: "NEUTRAL", playedStatus: "UNPLAYED" }).forEach(([k, v]) => form.set(k, v)); await updateLibraryGameAction(form); @@ -69,10 +69,11 @@ describe("dimension 1: known mutation defects at audit baseline", () => { expect(mocks.userGameUpsert.mock.calls[0][0].update.rating).toBeNull(); }); - it("F05 evidence: an absent curated Steam row is rejected before curated fallback", async () => { + it("F05: a curated Steam game can be shortlisted before it has been imported", async () => { const form = new FormData(); - Object.entries({ shareToken: "share", participantId: "p1", title: "Portal 2", steamAppId: "620", source: "COMMON" }).forEach(([k, v]) => form.set(k, v)); - await expect(addSessionGameAction(form)).rejects.toThrow("Import or search for that Steam game before adding it."); - expect(mocks.sessionGameUpsert).not.toHaveBeenCalled(); + Object.entries({ shareToken: "share", participantId: "p1", title: "PEAK", steamAppId: "3527290", source: "COMMON" }).forEach(([k, v]) => form.set(k, v)); + await expect(addSessionGameAction(form)).resolves.toBeUndefined(); + expect(mocks.gameUpsert).toHaveBeenCalledOnce(); + expect(mocks.sessionGameUpsert).toHaveBeenCalledOnce(); }); }); diff --git a/audit/dimension-1/session.audit.test.tsx b/audit/dimension-1/session.audit.test.tsx index fafcf42..8143439 100644 --- a/audit/dimension-1/session.audit.test.tsx +++ b/audit/dimension-1/session.audit.test.tsx @@ -16,12 +16,17 @@ vi.mock("@/lib/prisma", () => ({ prisma: { userFriend: { findMany: async () => [] }, friendGroup: { findMany: async () => [] }, game: { findMany: async () => [] }, + userBlock: { findMany: async () => [] }, } })); vi.mock("@/lib/auth", () => ({ - getCurrentUser: async () => ({ id: "viewer" }), + getCurrentUser: async () => ({ id: "u1" }), getParticipantId: async () => null, })); -vi.mock("@/lib/accounts", () => ({ requireActivePickUser: async () => ({ id: "viewer" }) })); +vi.mock("@/lib/accounts", () => ({ + isActivePickUser: (user: { id: string } | null) => Boolean(user), + onboardingUrl: (returnTo: string) => `/account/onboarding?returnTo=${encodeURIComponent(returnTo)}`, + signInUrl: (returnTo: string) => `/account/sign-in?returnTo=${encodeURIComponent(returnTo)}`, +})); vi.mock("@/lib/app-url", () => ({ getAppUrl: async () => "https://audit.invalid" })); vi.mock("@/lib/igdb", () => ({ getPopularIgdbGames: async () => [], getTrendingIgdbGames: async () => [], mapIgdbGame: vi.fn() })); vi.mock("@/components/pick-panel", () => ({ PickPanel: () => null })); @@ -58,26 +63,26 @@ async function compose(query: Record = {}) { } describe("dimension 1: real session page composition", () => { - it.fails("F04: ownership uses complete rows even when a candidate owner's discovery row is cut off", async () => { + it("F04: ownership uses complete rows even when a candidate owner's discovery row is cut off", async () => { const panel = await compose(); expect(mocks.userGames).toHaveBeenCalledTimes(2); expect(panel.scoredGames).toHaveLength(1); expect(panel.scoredGames[0].ownership.have).toBe(2); }); - it.fails("F09: repeated and foreign selected participant IDs are canonicalized", async () => { + it("F09: repeated and foreign selected participant IDs are canonicalized", async () => { const panel = await compose({ selectedParticipants: ["p1", "p1", "foreign"] }); expect(panel.selectedParticipantIds).toEqual(["p1"]); }); - it.fails("F09: non-finite player counts do not enter matching", async () => { + it("F09: non-finite player counts do not enter matching", async () => { const panel = await compose({ playerCount: "Infinity" }); expect(Number.isFinite(panel.selectedPlayerCount)).toBe(true); }); - it("F03/F16 evidence: a signed-in non-member receives full matching output without joining", async () => { + it("F03/F16: private matching is available only to joined account members", async () => { const panel = await compose(); - expect(panel.participantId).toBeUndefined(); + expect(panel.participantId).toBe("p1"); expect(panel.scoredGames[0].title).toBe(game.title); }); }); diff --git a/package.json b/package.json index 54b55c5..78aed19 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,13 @@ "lint": "eslint .", "test": "vitest run", "test:watch": "vitest", + "test:integration:dimension1": "tsx scripts/test-dimension1-integration.ts", + "verify": "npm test && npm run lint && npm run build", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "discord:commands": "tsx scripts/register-discord-commands.ts", + "catalog:duplicate-report": "tsx scripts/report-game-duplicates.ts", "challenges:seed": "tsx scripts/seed-challenges.ts", "db:setup": "powershell -ExecutionPolicy Bypass -File scripts/setup-local-db.ps1", "test:all": "powershell -ExecutionPolicy Bypass -File scripts/test-all.ps1" diff --git a/prisma/migrations/20260910093000_dimension_1_availability_revision/migration.sql b/prisma/migrations/20260910093000_dimension_1_availability_revision/migration.sql new file mode 100644 index 0000000..7438e98 --- /dev/null +++ b/prisma/migrations/20260910093000_dimension_1_availability_revision/migration.sql @@ -0,0 +1,23 @@ +ALTER TABLE "Participant" +ADD COLUMN "availabilityRevision" INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE "Participant" +ADD COLUMN "historyVisible" BOOLEAN NOT NULL DEFAULT true; + +CREATE TABLE "DiscordHostHandoff" ( + "id" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "discordUserId" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "participantId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "redeemedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "DiscordHostHandoff_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "DiscordHostHandoff_tokenHash_key" ON "DiscordHostHandoff"("tokenHash"); +CREATE INDEX "DiscordHostHandoff_discordUserId_expiresAt_idx" ON "DiscordHostHandoff"("discordUserId", "expiresAt"); +CREATE INDEX "DiscordHostHandoff_sessionId_participantId_idx" ON "DiscordHostHandoff"("sessionId", "participantId"); +ALTER TABLE "DiscordHostHandoff" ADD CONSTRAINT "DiscordHostHandoff_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "DiscordHostHandoff" ADD CONSTRAINT "DiscordHostHandoff_participantId_fkey" FOREIGN KEY ("participantId") REFERENCES "Participant"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4e614e7..231b8da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -178,6 +178,7 @@ model Session { discordIntegrations DiscordIntegration[] discordNotifications DiscordNotificationLog[] discordAttendance DiscordAttendance[] + discordHostHandoffs DiscordHostHandoff[] @@unique([gameNightId, workspaceType]) @@index([gameNightId]) @@ -194,6 +195,8 @@ model Participant { discordUserId String? discordUsername String? isHost Boolean @default(false) + availabilityRevision Int @default(0) + historyVisible Boolean @default(true) createdAt DateTime @default(now()) responses AvailabilityResponse[] addedGames SessionGame[] @@ -202,6 +205,7 @@ model Participant { preference ParticipantPreference? preferenceNudgeDismissedAt DateTime? discordAttendance DiscordAttendance[] + discordHostHandoffs DiscordHostHandoff[] @@unique([sessionId, userId]) @@unique([sessionId, discordUserId]) @@ -239,6 +243,22 @@ model DiscordIntegration { @@index([guildId, channelId]) } +model DiscordHostHandoff { + id String @id @default(cuid()) + tokenHash String @unique + discordUserId String + sessionId String + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + participantId String + participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade) + expiresAt DateTime + redeemedAt DateTime? + createdAt DateTime @default(now()) + + @@index([discordUserId, expiresAt]) + @@index([sessionId, participantId]) +} + model DiscordNotificationLog { id String @id @default(cuid()) sessionId String diff --git a/scripts/report-game-duplicates.ts b/scripts/report-game-duplicates.ts new file mode 100644 index 0000000..3f2b2db --- /dev/null +++ b/scripts/report-game-duplicates.ts @@ -0,0 +1,43 @@ +import { prisma } from "@/lib/prisma"; + +async function main() { +const games = await prisma.game.findMany({ + select: { + id: true, + title: true, + normalizedTitle: true, + slug: true, + steamAppId: true, + igdbId: true, + itadId: true, + }, + orderBy: [{ normalizedTitle: "asc" }, { id: "asc" }], +}); + +const byTitle = new Map(); +for (const game of games) { + const group = byTitle.get(game.normalizedTitle) ?? []; + group.push(game); + byTitle.set(game.normalizedTitle, group); +} + +const possibleDuplicates = [...byTitle.entries()] + .filter(([, group]) => group.length > 1) + .map(([normalizedTitle, group]) => ({ normalizedTitle, games: group })); + +console.log(JSON.stringify({ + generatedAt: new Date().toISOString(), + gameCount: games.length, + possibleDuplicateTitleGroups: possibleDuplicates.length, + note: "Title equality is advisory only. Merge only when stable provider identity or reviewed edition evidence proves equivalence.", + possibleDuplicates, +}, null, 2)); + +await prisma.$disconnect(); +} + +main().catch(async (error) => { + console.error(error instanceof Error ? error.message : error); + await prisma.$disconnect(); + process.exitCode = 1; +}); diff --git a/scripts/test-dimension1-integration.ts b/scripts/test-dimension1-integration.ts new file mode 100644 index 0000000..c461713 --- /dev/null +++ b/scripts/test-dimension1-integration.ts @@ -0,0 +1,163 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { loadEnvFile } from "node:process"; +import { PrismaClient } from "@prisma/client"; + +function loadLocalDatabaseUrl() { + if (!process.env.DATABASE_URL) { + try { + loadEnvFile(".env"); + } catch { + // Fall through to the BOM-compatible parser below. + } + if (!process.env.DATABASE_URL) { + const source = readFileSync(".env", "utf8").replace(/^\uFEFF/, ""); + const line = source.split(/\r?\n/).find((entry) => /^\s*DATABASE_URL\s*=/.test(entry)); + if (line) process.env.DATABASE_URL = line.slice(line.indexOf("=") + 1).trim().replace(/^['"]|['"]$/g, ""); + } + } + if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for the isolated integration suite."); + const url = new URL(process.env.DATABASE_URL); + if (!new Set(["localhost", "127.0.0.1", "::1"]).has(url.hostname)) { + throw new Error(`Refusing integration target ${url.hostname}; only local PostgreSQL is allowed.`); + } + return url; +} + +async function main() { + const baseUrl = loadLocalDatabaseUrl(); + const databaseName = `wsplay_dimension1_test_${process.pid}_${Date.now()}`; + if (!/^wsplay_dimension1_test_[0-9_]+$/.test(databaseName)) throw new Error("Unsafe integration database name."); + + const adminUrl = new URL(baseUrl); + adminUrl.pathname = "/postgres"; + adminUrl.searchParams.set("schema", "public"); + const testUrl = new URL(baseUrl); + testUrl.pathname = `/${databaseName}`; + testUrl.searchParams.set("schema", "public"); + const admin = new PrismaClient({ datasources: { db: { url: adminUrl.toString() } } }); + let prisma: PrismaClient | null = null; + let appPrisma: PrismaClient | null = null; + + try { + await admin.$executeRawUnsafe(`CREATE DATABASE "${databaseName}"`); + const migration = spawnSync("npx", ["prisma", "migrate", "deploy"], { + env: { ...process.env, DATABASE_URL: testUrl.toString() }, + encoding: "utf8", + shell: process.platform === "win32", + }); + if (migration.status !== 0) { + throw new Error(`Isolated migration failed.\n${migration.error?.message ?? ""}\n${migration.stdout ?? ""}\n${migration.stderr ?? ""}`); + } + + prisma = new PrismaClient({ datasources: { db: { url: testUrl.toString() } } }); + const user = await prisma.user.create({ data: { displayName: "Dimension 1 fixture" } }); + const session = await prisma.session.create({ + data: { + shareToken: `integration-${Date.now()}`, + title: "Dimension 1 isolated fixture", + mode: "ONLINE", + requiredDuration: 2, + minimumPlayerCount: 2, + dateRangeStart: new Date("2026-10-25T00:00:00.000Z"), + dateRangeEnd: new Date("2026-10-25T00:00:00.000Z"), + dailyStartHour: 0, + dailyEndHour: 4, + timezone: "Europe/London", + reminderPreferences: [], + workspaceType: "PLAN", + }, + }); + + const joins = await Promise.allSettled([ + prisma.participant.create({ data: { sessionId: session.id, userId: user.id, name: "Fixture" } }), + prisma.participant.create({ data: { sessionId: session.id, userId: user.id, name: "Fixture retry" } }), + ]); + const participant = await prisma.participant.findUniqueOrThrow({ + where: { sessionId_userId: { sessionId: session.id, userId: user.id } }, + }); + if (joins.filter((result) => result.status === "fulfilled").length !== 1) { + throw new Error("Concurrent membership uniqueness did not produce exactly one row."); + } + + const revisions = await Promise.all([ + prisma.participant.updateMany({ + where: { id: participant.id, availabilityRevision: 0 }, + data: { availabilityRevision: { increment: 1 } }, + }), + prisma.participant.updateMany({ + where: { id: participant.id, availabilityRevision: 0 }, + data: { availabilityRevision: { increment: 1 } }, + }), + ]); + if (revisions.reduce((total, result) => total + result.count, 0) !== 1) { + throw new Error("Availability compare-and-set allowed more than one writer."); + } + + await prisma.participant.update({ where: { id: participant.id }, data: { historyVisible: false } }); + const hidden = await prisma.participant.findUniqueOrThrow({ where: { id: participant.id } }); + if (hidden.userId !== user.id || hidden.historyVisible) { + throw new Error("Hiding history changed membership or failed to hide the entry."); + } + + process.env.DATABASE_URL = testUrl.toString(); + const { createAccountMergeIntent, mergeAccounts } = await import("../src/lib/account-merge"); + appPrisma = (await import("../src/lib/prisma")).prisma; + const destinationUser = await prisma.user.create({ data: { displayName: "Merge destination" } }); + const sourceUser = await prisma.user.create({ data: { displayName: "Merge source" } }); + const mergeSession = await prisma.session.create({ + data: { + shareToken: `merge-${Date.now()}`, + title: "Merge fixture", + mode: "ONLINE", + requiredDuration: 2, + minimumPlayerCount: 2, + dateRangeStart: new Date("2026-09-11T00:00:00.000Z"), + dateRangeEnd: new Date("2026-09-11T00:00:00.000Z"), + dailyStartHour: 18, + dailyEndHour: 22, + timezone: "Europe/London", + reminderPreferences: [], + participants: { + create: [ + { name: "Destination", userId: destinationUser.id, isHost: false }, + { name: "Source host", userId: sourceUser.id, isHost: true }, + ], + }, + }, + }); + const mergeToken = await createAccountMergeIntent(destinationUser.id, sourceUser.id, "GOOGLE"); + await mergeAccounts(destinationUser.id, mergeToken); + const mergedParticipant = await prisma.participant.findUniqueOrThrow({ + where: { sessionId_userId: { sessionId: mergeSession.id, userId: destinationUser.id } }, + }); + if (!mergedParticipant.isHost) throw new Error("Account merge discarded host authority."); + await mergeAccounts(destinationUser.id, mergeToken).then( + () => { throw new Error("Account merge intent was applied twice."); }, + () => undefined, + ); + + console.log(JSON.stringify({ + database: databaseName, + migrations: "applied", + concurrentMembershipRows: 1, + availabilityRevisionWinners: 1, + hiddenHistoryPreservedMembership: true, + mergePreservedHost: true, + mergeIntentSingleUse: true, + })); + } finally { + await appPrisma?.$disconnect(); + await prisma?.$disconnect(); + await admin.$executeRawUnsafe( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${databaseName}' AND pid <> pg_backend_pid()`, + ); + await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS "${databaseName}"`); + await admin.$disconnect(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/src/app/account/actions.ts b/src/app/account/actions.ts index 5e9799d..31cebb2 100644 --- a/src/app/account/actions.ts +++ b/src/app/account/actions.ts @@ -246,7 +246,7 @@ export async function removeRecentSessionAction(formData: FormData) { } else { await prisma.participant.update({ where: { id: participant.id }, - data: { userId: null }, + data: { historyVisible: false }, }); } @@ -258,11 +258,11 @@ const libraryGameSchema = z.object({ ownership: z.enum(["UNKNOWN", "HAVE", "DONT_HAVE"]), wishlist: z.enum(["true", "false"]).default("false"), favourite: z.enum(["true", "false"]).default("false"), - rating: z.coerce.number().int().min(1).max(10).optional(), + rating: z.coerce.number().int().min(1).max(10).nullable().optional(), interest: z.enum(["WANT_TO_PLAY", "NEUTRAL", "NOT_INTERESTED"]).default("NEUTRAL"), playedStatus: z.enum(["UNPLAYED", "PLAYING", "PLAYED", "COMPLETED", "DROPPED"]).default("UNPLAYED"), platforms: z.array(z.enum(gamingPlatforms)).max(gamingPlatforms.length).default([]), - notes: z.string().trim().max(1000).optional(), + notes: z.string().trim().max(1000).nullable().optional(), }); export async function updateLibraryGameAction(formData: FormData) { @@ -272,16 +272,18 @@ export async function updateLibraryGameAction(formData: FormData) { redirect("/account"); } + const ratingValue = formData.get("rating"); + const notesValue = formData.get("notes"); const parsed = libraryGameSchema.safeParse({ gameId: formData.get("gameId"), ownership: formData.get("ownership"), wishlist: formData.get("wishlist") ? "true" : "false", favourite: formData.get("favourite") ? "true" : "false", - rating: formData.get("rating") || undefined, + rating: ratingValue === null ? undefined : String(ratingValue).trim() === "" ? null : ratingValue, interest: formData.get("interest") || "NEUTRAL", playedStatus: formData.get("playedStatus") || "UNPLAYED", platforms: formData.getAll("platforms"), - notes: formData.get("notes") || undefined, + notes: notesValue === null ? undefined : String(notesValue).trim() === "" ? null : notesValue, }); if (!parsed.success) { diff --git a/src/app/account/library/page.test.tsx b/src/app/account/library/page.test.tsx new file mode 100644 index 0000000..693c945 --- /dev/null +++ b/src/app/account/library/page.test.tsx @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { parseLibraryPage } from "@/app/account/library/page"; + +describe("library pagination", () => { + it("only permits bounded positive integer pages", () => { + expect(parseLibraryPage("1")).toBe(1); + expect(parseLibraryPage("250")).toBe(250); + expect(parseLibraryPage("0")).toBe(1); + expect(parseLibraryPage("-1")).toBe(1); + expect(parseLibraryPage("2.5")).toBe(1); + expect(parseLibraryPage("Infinity")).toBe(1); + expect(parseLibraryPage("10001")).toBe(1); + }); +}); diff --git a/src/app/account/library/page.tsx b/src/app/account/library/page.tsx index 3df3198..031bf0a 100644 --- a/src/app/account/library/page.tsx +++ b/src/app/account/library/page.tsx @@ -25,7 +25,7 @@ export default async function AccountLibraryPage({ searchParams }: PageProps) { const query = await searchParams; const user = await requireActivePickUser("/account/library"); const search = query?.q?.trim() ?? ""; - const page = Math.max(1, Number(query?.page) || 1); + const page = parseLibraryPage(query?.page); const pageSize = 50; const view = ["recent", "unrated", "wishlist", "unknown"].includes(query?.view ?? "") ? query?.view : undefined; const ownership = ["HAVE", "DONT_HAVE", "UNKNOWN"].includes(query?.ownership ?? "") @@ -241,6 +241,11 @@ export default async function AccountLibraryPage({ searchParams }: PageProps) { ); } +export function parseLibraryPage(value?: string) { + const page = Number(value); + return Number.isSafeInteger(page) && page >= 1 && page <= 10_000 ? page : 1; +} + function libraryHref({ q, ownership, view, page }: { q?: string; ownership?: string; view?: string; page: number }) { const params = new URLSearchParams(); if (q) params.set("q", q); diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 0ee9520..91f85d6 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -80,7 +80,7 @@ export default async function AccountPage({ searchParams }: PageProps) { const microsoftConfigured = isMicrosoftAuthConfigured(); const recentParticipants = user ? await prisma.participant.findMany({ - where: { userId: user.id }, + where: { userId: user.id, historyVisible: true }, include: { session: true }, orderBy: { session: { updatedAt: "desc" } }, take: 8, diff --git a/src/app/actions.availability.test.ts b/src/app/actions.availability.test.ts new file mode 100644 index 0000000..9a9f498 --- /dev/null +++ b/src/app/actions.availability.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { submitAvailabilityAction } from "@/app/actions"; + +const mocks = vi.hoisted(() => ({ + sessionFindUnique: vi.fn(), + participantFindFirst: vi.fn(), + participantUpdateMany: vi.fn(), + participantCreate: vi.fn(), + responseDeleteMany: vi.fn(), + responseCreateMany: vi.fn(), +})); + +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: vi.fn() })); +vi.mock("@/lib/auth", () => ({ + getCurrentUser: async () => ({ id: "user-1" }), + resolveActingParticipantId: async () => "participant-1", + safeInternalRedirect: (path: string) => path, + setParticipantIdentity: vi.fn(), +})); +vi.mock("@/lib/scheduling", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateHourlySlots: () => [{ + startsAt: new Date("2026-09-11T18:00:00.000Z"), + endsAt: new Date("2026-09-11T19:00:00.000Z"), + }], + }; +}); +vi.mock("@/lib/prisma", () => { + const tx = { + participant: { updateMany: mocks.participantUpdateMany, create: mocks.participantCreate }, + availabilityResponse: { deleteMany: mocks.responseDeleteMany, createMany: mocks.responseCreateMany }, + }; + return { + prisma: { + session: { findUnique: mocks.sessionFindUnique }, + participant: { findFirst: mocks.participantFindFirst }, + gameNight: { updateMany: vi.fn() }, + $transaction: async (work: (client: typeof tx) => unknown) => work(tx), + }, + }; +}); + +const session = { + id: "session-1", + shareToken: "share", + workspaceType: "PLAN", + dateRangeStart: new Date("2026-09-11T00:00:00.000Z"), + dateRangeEnd: new Date("2026-09-11T00:00:00.000Z"), + dailyStartHour: 18, + dailyEndHour: 22, + weekendStartHour: null, + weekendEndHour: null, + requiredDuration: 2, + minimumPlayerCount: 2, + timezone: "Europe/London", +}; + +describe("availability action integrity", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.sessionFindUnique.mockResolvedValue(session); + mocks.participantFindFirst.mockResolvedValue({ + id: "participant-1", + sessionId: "session-1", + userId: "user-1", + availabilityRevision: 2, + }); + }); + + it("validates every slot before creating or updating participant state", async () => { + const form = baseForm(); + form.set("status:2026-09-11T20:00:00.000Z", "AVAILABLE"); + + await expect(submitAvailabilityAction(null, form)).resolves.toMatchObject({ + ok: false, + code: "VALIDATION", + }); + expect(mocks.participantFindFirst).not.toHaveBeenCalled(); + expect(mocks.participantUpdateMany).not.toHaveBeenCalled(); + expect(mocks.participantCreate).not.toHaveBeenCalled(); + }); + + it("returns a recoverable conflict before replacing responses from a stale revision", async () => { + mocks.participantUpdateMany.mockResolvedValue({ count: 0 }); + const form = baseForm(); + form.set("status:2026-09-11T18:00:00.000Z", "MAYBE"); + + await expect(submitAvailabilityAction(null, form)).resolves.toMatchObject({ + ok: false, + code: "CONFLICT", + }); + expect(mocks.responseDeleteMany).not.toHaveBeenCalled(); + expect(mocks.responseCreateMany).not.toHaveBeenCalled(); + }); +}); + +function baseForm() { + const form = new FormData(); + form.set("shareToken", "share"); + form.set("participantId", "participant-1"); + form.set("participantName", "Player One"); + form.set("revision", "2"); + return form; +} diff --git a/src/app/actions.ts b/src/app/actions.ts index 5cf0438..ac360ff 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -7,16 +7,20 @@ import { fromZonedTime } from "date-fns-tz"; import { z } from "zod"; import { getCurrentUser, - getHostParticipantId, resolveActingParticipantId, + safeInternalRedirect, setParticipantIdentity, } from "@/lib/auth"; +import { requireActivePickUser } from "@/lib/accounts"; import { announceLockedSessionToDiscord, normalizeReminderPreferences } from "@/lib/discord"; import { curatedGames, getCuratedGame } from "@/lib/curated-games"; import { mergeCuratedMetadata } from "@/lib/curated-metadata"; import { addGameToSession, addGamesToSessionBulk, importSteamGamesForUser, upsertGame } from "@/lib/games"; import { getIgdbGameById, mapIgdbGame } from "@/lib/igdb"; import { prisma } from "@/lib/prisma"; +import { actorCanManageSession, actorParticipantId, resolveSessionActor } from "@/lib/session/authorization"; +import { actionFailure, type ActionResult } from "@/lib/session/contracts"; +import { joinPickWorkspace } from "@/lib/session/membership"; import { dateRangeFromPreset, generateCandidateWindows, generateHourlySlots, type DatePreset } from "@/lib/scheduling"; import { getOwnedSteamGames, getRecentlyPlayedSteamGames } from "@/lib/steam"; import { createShareToken } from "@/lib/tokens"; @@ -41,32 +45,16 @@ const timezoneSchema = z // share token is not enough to perform host-only actions (lock, remove game, // deal settings, price alerts) — otherwise anyone the link reaches could do them. async function requireHostParticipant(sessionId: string) { - const hostParticipantId = await getHostParticipantId(sessionId); + const actor = await resolveSessionActor(sessionId); + if (actorCanManageSession(actor)) { + const participantId = actorParticipantId(actor); + if (participantId) return participantId; - if (hostParticipantId) { const host = await prisma.participant.findFirst({ - where: { id: hostParticipantId, sessionId, isHost: true }, + where: { sessionId, isHost: true }, select: { id: true }, }); - - if (host) { - return host.id; - } - } - - const currentUser = await getCurrentUser(); - if (currentUser) { - const session = await prisma.session.findFirst({ - where: { - id: sessionId, - OR: [ - { participants: { some: { userId: currentUser.id, isHost: true } } }, - { gameNight: { ownerUserId: currentUser.id } }, - ], - }, - select: { participants: { where: { isHost: true }, select: { id: true }, take: 1 } }, - }); - if (session?.participants[0]) return session.participants[0].id; + if (host) return host.id; } throw new Error( @@ -244,6 +232,8 @@ const createPickSessionSchema = z.object({ timezone: timezoneSchema.default("Europe/London"), initialGameSlug: z.string().trim().max(120).optional(), gameNightId: z.string().min(1).optional(), + playerCount: z.coerce.number().int().min(1).max(50).default(2), + setup: z.enum(["native", "modded", "either"]).default("native"), }); export async function createPickSessionAction(formData: FormData) { @@ -259,6 +249,8 @@ export async function createPickSessionAction(formData: FormData) { timezone: formData.get("timezone") || "Europe/London", initialGameSlug: formData.get("initialGameSlug") || undefined, gameNightId: formData.get("gameNightId") || undefined, + playerCount: formData.get("playerCount") || 2, + setup: formData.get("setup") || "native", }); if (!parsed.success) { @@ -282,7 +274,7 @@ export async function createPickSessionAction(formData: FormData) { shareToken: createShareToken(), mode: "ONLINE", requiredDuration: 2, - minimumPlayerCount: 2, + minimumPlayerCount: values.playerCount, dateRangeStart: fromZonedTime(`${dateRange.startsOn}T00:00:00`, values.timezone), dateRangeEnd: fromZonedTime(`${dateRange.endsOn}T00:00:00`, values.timezone), dailyStartHour: 18, @@ -326,29 +318,47 @@ export async function createPickSessionAction(formData: FormData) { participantId: host.id, userId: currentUser.id, source: "COMMON", - signal: "OWNED", }); } - redirect(`/s/${session.shareToken}?tab=pick&participant=${host.id}`); + const pickParams = new URLSearchParams({ + tab: "pick", + participant: host.id, + playerCount: String(values.playerCount), + setup: values.setup, + }); + redirect(`/s/${session.shareToken}?${pickParams.toString()}`); } const submitAvailabilitySchema = z.object({ shareToken: z.string().min(1), participantId: z.string().optional(), participantName: z.string().trim().min(1).max(80), + revision: z.coerce.number().int().min(0), }); -export async function submitAvailabilityAction(formData: FormData) { +type AvailabilitySaveData = { participantId: string; revision: number }; + +class AvailabilityConflictError extends Error {} + +export async function submitAvailabilityAction( + _previousState: ActionResult | null, + formData: FormData, +): Promise> { const currentUser = await getCurrentUser(); const parsed = submitAvailabilitySchema.safeParse({ shareToken: formData.get("shareToken"), participantId: formData.get("participantId") || undefined, participantName: formData.get("participantName"), + revision: formData.get("revision") ?? 0, }); if (!parsed.success) { - throw new Error(parsed.error.issues[0]?.message ?? "Could not save availability."); + return actionFailure( + "VALIDATION", + parsed.error.issues[0]?.message ?? "Could not save availability.", + Object.fromEntries(parsed.error.issues.map((issue) => [String(issue.path[0] ?? "form"), issue.message])), + ); } const session = await prisma.session.findUnique({ @@ -370,89 +380,122 @@ export async function submitAvailabilityAction(formData: FormData) { }); if (!session) { - throw new Error("Session not found."); + return actionFailure("NOT_FOUND", "Session not found."); } // Only let the caller edit a participant they actually own (proven by the // signed per-session cookie). A form-supplied id without a matching cookie is // ignored so a link-holder cannot overwrite someone else's availability. if (session.workspaceType !== "PLAN") { - throw new Error("Availability can only be saved in a Plan workspace."); + return actionFailure("FORBIDDEN", "Availability can only be saved in a Plan workspace."); } - const actingParticipantId = await resolveActingParticipantId(session.id, parsed.data.participantId); - const existingParticipant = actingParticipantId - ? await prisma.participant.findFirst({ - where: { id: actingParticipantId, sessionId: session.id }, - }) - : null; - - const participant = existingParticipant - ? await prisma.participant.update({ - where: { id: existingParticipant.id }, - data: { - name: parsed.data.participantName, - userId: existingParticipant.userId ?? currentUser?.id, - }, - }) - : await prisma.participant.create({ - data: { - sessionId: session.id, - name: parsed.data.participantName, - userId: currentUser?.id, - }, - }); - - await setParticipantIdentity(session.id, participant.id); - const allowedSlots = new Set(generateHourlySlots(session).map((slot) => slot.startsAt.toISOString())); const rawResponses = Array.from(formData.entries()).filter(([key]) => key.startsWith("status:")); if (rawResponses.length > allowedSlots.size || rawResponses.length > 800) { - throw new Error("Too many availability slots were submitted."); + return actionFailure("VALIDATION", "Too many availability slots were submitted."); } const seenSlots = new Set(); - const responses = rawResponses.map(([key, value]) => { + const responses: Array<{ + slotStart: Date; + slotEnd: Date; + status: "AVAILABLE" | "MAYBE" | "UNAVAILABLE"; + }> = []; + for (const [key, value] of rawResponses) { const slotKey = key.replace("status:", ""); if (!allowedSlots.has(slotKey) || seenSlots.has(slotKey)) { - throw new Error("Availability contained an invalid or duplicate time slot."); + return actionFailure("VALIDATION", "Availability contained an invalid or duplicate time slot."); } seenSlots.add(slotKey); const status = String(value); if (!["AVAILABLE", "MAYBE", "UNAVAILABLE"].includes(status)) { - throw new Error("Availability contained an invalid status."); + return actionFailure("VALIDATION", "Availability contained an invalid status."); } const slotStart = new Date(slotKey); - return { + responses.push({ slotStart, slotEnd: new Date(slotStart.getTime() + 60 * 60 * 1000), status: status as "AVAILABLE" | "MAYBE" | "UNAVAILABLE", - }; - }); + }); + } - const writes = [ - prisma.availabilityResponse.deleteMany({ - where: { participantId: participant.id }, - }), - ]; + const actingParticipantId = await resolveActingParticipantId(session.id, parsed.data.participantId); + const existingParticipant = actingParticipantId + ? await prisma.participant.findFirst({ + where: { id: actingParticipantId, sessionId: session.id }, + }) + : null; - if (responses.length > 0) { - writes.push( - prisma.availabilityResponse.createMany({ - data: responses.map((response) => ({ - participantId: participant.id, - slotStart: response.slotStart, - slotEnd: response.slotEnd, - status: response.status, - })), - }), - ); + let participant: AvailabilitySaveData; + try { + participant = await prisma.$transaction(async (tx) => { + let savedParticipant: AvailabilitySaveData; + if (existingParticipant) { + const updated = await tx.participant.updateMany({ + where: { + id: existingParticipant.id, + sessionId: session.id, + availabilityRevision: parsed.data.revision, + }, + data: { + name: parsed.data.participantName, + userId: existingParticipant.userId ?? currentUser?.id, + availabilityRevision: { increment: 1 }, + }, + }); + if (updated.count !== 1) { + throw new AvailabilityConflictError(); + } + savedParticipant = { + participantId: existingParticipant.id, + revision: parsed.data.revision + 1, + }; + } else { + const created = await tx.participant.create({ + data: { + sessionId: session.id, + name: parsed.data.participantName, + userId: currentUser?.id, + availabilityRevision: 1, + }, + select: { id: true, availabilityRevision: true }, + }); + savedParticipant = { + participantId: created.id, + revision: created.availabilityRevision, + }; + } + + await tx.availabilityResponse.deleteMany({ + where: { participantId: savedParticipant.participantId }, + }); + if (responses.length > 0) { + await tx.availabilityResponse.createMany({ + data: responses.map((response) => ({ + participantId: savedParticipant.participantId, + slotStart: response.slotStart, + slotEnd: response.slotEnd, + status: response.status, + })), + }); + } + return savedParticipant; + }); + } catch (error) { + if (error instanceof AvailabilityConflictError) { + return actionFailure( + "CONFLICT", + "This availability was updated in another tab. Your draft is still here; refresh to compare before saving again.", + ); + } + throw error; } - await prisma.$transaction(writes); + await setParticipantIdentity(session.id, participant.participantId); await touchGameNight(session.id); revalidatePath(`/s/${session.shareToken}`); - redirect(`/s/${session.shareToken}?participant=${participant.id}`); + redirect(`/s/${session.shareToken}?participant=${participant.participantId}`); } const lockSessionSchema = z.object({ @@ -615,25 +658,51 @@ export async function addSessionGameAction(formData: FormData) { participantId: participant?.id, userId: currentUser.id, source: parsed.data.source, - signal: "OWNED", }); - if (currentUser) { - await prisma.userGame.upsert({ - where: { userId_gameId: { userId: currentUser.id, gameId: game.id } }, - create: { - userId: currentUser.id, - gameId: game.id, - source: parsed.data.source === "IGDB_SEARCH" ? "IGDB" : "MANUAL", - ownership: "HAVE", - }, - update: { ownership: "HAVE" }, - }); - } await touchGameNight(session.id); revalidatePath(`/s/${session.shareToken}`); } +const joinPickWorkspaceSchema = z.object({ + shareToken: z.string().min(1), + returnTo: z.string().optional(), +}); + +export async function joinPickWorkspaceAction(formData: FormData) { + const parsed = joinPickWorkspaceSchema.safeParse({ + shareToken: formData.get("shareToken"), + returnTo: formData.get("returnTo") || undefined, + }); + if (!parsed.success) { + throw new Error("This Pick workspace link is invalid."); + } + + const fallback = `/s/${parsed.data.shareToken}?tab=pick`; + const returnTo = safeInternalRedirect(parsed.data.returnTo ?? fallback); + const currentUser = await requireActivePickUser(returnTo); + const session = await prisma.session.findUnique({ + where: { shareToken: parsed.data.shareToken }, + select: { id: true, shareToken: true, workspaceType: true }, + }); + if (!session || session.workspaceType !== "PICK") { + throw new Error("Pick workspace not found."); + } + + const participant = await joinPickWorkspace({ + sessionId: session.id, + userId: currentUser.id, + displayName: currentUser.displayName, + }); + await setParticipantIdentity(session.id, participant.id, { isHost: participant.isHost }); + await touchGameNight(session.id); + revalidatePath(`/s/${session.shareToken}`); + + const destination = new URL(returnTo, "https://internal.invalid"); + destination.searchParams.set("participant", participant.id); + redirect(`${destination.pathname}${destination.search}${destination.hash}`); +} + async function resolveCanonicalGame(input: z.infer) { if (input.igdbId) { const result = await getIgdbGameById(input.igdbId); @@ -646,8 +715,13 @@ async function resolveCanonicalGame(input: z.infer) where: { steamAppId: input.steamAppId }, select: { id: true }, }); - if (!existing) throw new Error("Import or search for that Steam game before adding it."); - return existing; + if (existing) return existing; + + const curated = curatedGames.find((candidate) => ( + candidate.steamAppId === input.steamAppId || candidate.title === input.title + )); + if (!curated) throw new Error("Choose a verified catalog game."); + return upsertGame(mergeCuratedMetadata(curated)); } if (input.source !== "MANUAL") { @@ -1319,7 +1393,32 @@ export async function startPickSessionFromFriendGroupAction(formData: FormData) const dateRange = dateRangeFromPreset("this_week", parsed.data.timezone); await prisma.user.update({ where: { id: currentUser.id }, data: { timezone: parsed.data.timezone } }); - const acceptedMembers = group.members.filter((member) => member.status === "ACCEPTED" && member.userId && member.userId !== currentUser.id); + const acceptedMemberCandidates = group.members.filter( + (member) => member.status === "ACCEPTED" && member.userId && member.userId !== currentUser.id, + ); + const candidateUserIds = acceptedMemberCandidates + .map((member) => member.userId) + .filter((userId): userId is string => Boolean(userId)); + const blockedMemberships = candidateUserIds.length > 0 + ? await prisma.userBlock.findMany({ + where: { + OR: [ + { blockerId: currentUser.id, blockedId: { in: candidateUserIds } }, + { blockedId: currentUser.id, blockerId: { in: candidateUserIds } }, + ], + }, + select: { blockerId: true, blockedId: true }, + }) + : []; + const blockedUserIds = new Set( + blockedMemberships.flatMap((block) => [block.blockerId, block.blockedId]), + ); + const acceptedMembers = acceptedMemberCandidates.filter( + (member) => member.userId && !blockedUserIds.has(member.userId), + ); + if (acceptedMembers.length + 1 > 50) { + throw new Error("Saved groups can include at most 50 people in Pick."); + } const gameNightShareToken = createShareToken(); const session = await prisma.session.create({ data: { @@ -1632,7 +1731,7 @@ export async function addFriendToGroupAction(formData: FormData) { throw new Error(parsed.error.issues[0]?.message ?? "Could not add friend to group."); } - const [group, friendship] = await Promise.all([ + const [group, friendship, block] = await Promise.all([ prisma.friendGroup.findFirst({ where: { id: parsed.data.groupId, ownerId: currentUser.id }, select: { id: true }, @@ -1641,9 +1740,18 @@ export async function addFriendToGroupAction(formData: FormData) { where: { userId_friendId: { userId: currentUser.id, friendId: parsed.data.friendId } }, include: { friend: true }, }), + prisma.userBlock.findFirst({ + where: { + OR: [ + { blockerId: currentUser.id, blockedId: parsed.data.friendId }, + { blockerId: parsed.data.friendId, blockedId: currentUser.id }, + ], + }, + select: { id: true }, + }), ]); - if (!group || !friendship) { + if (!group || !friendship || block) { throw new Error("Friend group or friend not found."); } diff --git a/src/app/discord/handoff/[token]/route.ts b/src/app/discord/handoff/[token]/route.ts new file mode 100644 index 0000000..e361b5c --- /dev/null +++ b/src/app/discord/handoff/[token]/route.ts @@ -0,0 +1,11 @@ +import { redirect } from "next/navigation"; +import { redeemDiscordHostHandoff } from "@/lib/discord-handoff"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ token: string }> }, +) { + const { token } = await params; + const destination = await redeemDiscordHostHandoff(token); + redirect(destination ?? "/?handoff=invalid"); +} diff --git a/src/app/discover/[slug]/page.tsx b/src/app/discover/[slug]/page.tsx index a929002..2ae8bd7 100644 --- a/src/app/discover/[slug]/page.tsx +++ b/src/app/discover/[slug]/page.tsx @@ -8,9 +8,11 @@ import { curatedGamesForList, curatedPlayerLabel, getCuratedList } from "@/lib/c import { curatedPriceLabel, curatedSaleLabel, enrichCuratedGamesWithDeals, sortCuratedGamesForDiscovery } from "@/lib/curated-deals"; import { parseMinimumPlayers } from "@/lib/player-count"; +type PickSetup = "native" | "modded" | "either"; + type PageProps = { params: Promise<{ slug: string }>; - searchParams?: Promise<{ minPlayers?: string }>; + searchParams?: Promise<{ minPlayers?: string; setup?: string }>; }; export default async function DiscoverListPage({ params, searchParams }: PageProps) { @@ -23,6 +25,8 @@ export default async function DiscoverListPage({ params, searchParams }: PagePro notFound(); } + const setup = parseSetup(query?.setup, slug === "with-mods" ? "modded" : "native"); + const games = sortCuratedGamesForDiscovery(await enrichCuratedGamesWithDeals(curatedGamesForList(slug, minimumPlayers))); return ( @@ -34,17 +38,17 @@ export default async function DiscoverListPage({ params, searchParams }: PagePro Discover - Start Pick + Start Pick

Curated list

{list.title}

{list.description}

- +
{games.length > 0 ? ( games.map((game) => ( - +
@@ -97,3 +101,15 @@ export default async function DiscoverListPage({ params, searchParams }: PagePro ); } + +function parseSetup(value: string | undefined, fallback: PickSetup): PickSetup { + return value === "native" || value === "modded" || value === "either" ? value : fallback; +} + +function setupForGame(game: { moddedPlayersLabel?: string }, setup: PickSetup): PickSetup { + return setup === "modded" && game.moddedPlayersLabel ? "modded" : "native"; +} + +function pickHref({ playerCount, setup }: { playerCount: number; setup: PickSetup }) { + return `/sessions/pick?playerCount=${playerCount}&setup=${setup}`; +} diff --git a/src/app/discover/discover-pages.test.tsx b/src/app/discover/discover-pages.test.tsx index 67ea2fe..8e3f68a 100644 --- a/src/app/discover/discover-pages.test.tsx +++ b/src/app/discover/discover-pages.test.tsx @@ -89,6 +89,7 @@ describe("curated discovery pages", () => { expect(screen.getByRole("heading", { name: "Meccha Chameleon" })).toBeInTheDocument(); expect(screen.getByText("£4.99")).toBeInTheDocument(); expect(screen.getByText("25% off")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Project Zomboid/ })).toHaveAttribute("href", "/games/project-zomboid?playerCount=16&setup=native"); }); it("renders modded multiplayer caveats in the with-mods list", async () => { @@ -99,14 +100,18 @@ describe("curated discovery pages", () => { expect(screen.getAllByText(/1 player, 2-8 with mods/)[0]).toBeInTheDocument(); expect(screen.getAllByText("Modded setup")[0]).toBeInTheDocument(); expect(screen.getAllByText(/Solo by default/)[0]).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /RimWorld/ })).toHaveAttribute("href", "/games/rimworld?playerCount=8&setup=modded"); }); it("renders a curated game detail page", async () => { - render(await GameDetailPage({ params: Promise.resolve({ slug: "deep-rock-galactic" }) })); + render(await GameDetailPage({ + params: Promise.resolve({ slug: "deep-rock-galactic" }), + searchParams: Promise.resolve({ playerCount: "5", setup: "modded", gameNight: "night-1" }), + })); expect(screen.getByRole("heading", { level: 1, name: "Deep Rock Galactic" })).toBeInTheDocument(); expect(screen.getByText("£7.49")).toBeInTheDocument(); expect(screen.getByText("50% off")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Put this on the shortlist" })).toHaveAttribute("href", "/sessions/pick?game=deep-rock-galactic"); + expect(screen.getByRole("link", { name: "Put this on the shortlist" })).toHaveAttribute("href", "/sessions/pick?game=deep-rock-galactic&playerCount=5&setup=modded&gameNight=night-1"); }); }); diff --git a/src/app/games/[slug]/page.tsx b/src/app/games/[slug]/page.tsx index 207c32f..ba253a0 100644 --- a/src/app/games/[slug]/page.tsx +++ b/src/app/games/[slug]/page.tsx @@ -5,19 +5,26 @@ import { curatedPriceLabel, curatedSaleLabel, enrichedCuratedGame } from "@/lib/ import { curatedPlayerLabel } from "@/lib/curated-games"; import { LocalSetupBadge } from "@/components/local-setup-badge"; import { GameArtwork } from "@/components/game-artwork"; +import { parseMinimumPlayers } from "@/lib/player-count"; type PageProps = { params: Promise<{ slug: string }>; + searchParams?: Promise<{ playerCount?: string; setup?: string; gameNight?: string }>; }; -export default async function GameDetailPage({ params }: PageProps) { +export default async function GameDetailPage({ params, searchParams }: PageProps) { const { slug } = await params; + const query = await searchParams; const game = await enrichedCuratedGame(slug); if (!game) { notFound(); } + const playerCount = parseMinimumPlayers(query?.playerCount); + const setup = parseSetup(query?.setup); + const pickHref = createPickHref({ game: game.slug, playerCount, setup, gameNight: query?.gameNight }); + return (
@@ -96,13 +103,23 @@ export default async function GameDetailPage({ params }: PageProps) {

{game.caveat}

) : null} - Put this on the shortlist + Put this on the shortlist
); } +function parseSetup(value: string | undefined) { + return value === "modded" || value === "either" || value === "native" ? value : "native"; +} + +function createPickHref({ game, playerCount, setup, gameNight }: { game: string; playerCount: number; setup: string; gameNight?: string }) { + const params = new URLSearchParams({ game, playerCount: String(playerCount), setup }); + if (gameNight) params.set("gameNight", gameNight); + return `/sessions/pick?${params.toString()}`; +} + function commitmentLabel(tier?: string | null) { return { ONE_SESSION: "One session", diff --git a/src/app/games/id/[gameId]/page.tsx b/src/app/games/id/[gameId]/page.tsx new file mode 100644 index 0000000..c70b289 --- /dev/null +++ b/src/app/games/id/[gameId]/page.tsx @@ -0,0 +1,104 @@ +import Link from "next/link"; +import { ArrowLeft, Gamepad2, UsersRound } from "lucide-react"; +import { notFound } from "next/navigation"; +import { GameArtwork } from "@/components/game-artwork"; +import { prisma } from "@/lib/prisma"; + +export const metadata = { robots: { index: false, follow: false } }; + +export default async function CanonicalGamePage({ + params, +}: { + params: Promise<{ gameId: string }>; +}) { + const { gameId } = await params; + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { + id: true, + title: true, + coverUrl: true, + steamAppId: true, + summary: true, + genres: true, + platforms: true, + minPlayers: true, + maxPlayers: true, + onlineCoop: true, + localCoop: true, + onlineMultiplayer: true, + localMultiplayer: true, + minimumSessionMinutes: true, + commitmentTier: true, + capabilitySource: true, + }, + }); + if (!game) notFound(); + + const genres = jsonStrings(game.genres); + const platforms = jsonStrings(game.platforms); + const modes = [ + game.onlineCoop ? "Online co-op" : null, + game.localCoop ? "Local co-op" : null, + game.onlineMultiplayer ? "Online multiplayer" : null, + game.localMultiplayer ? "Local multiplayer" : null, + ].filter((mode): mode is string => Boolean(mode)); + + return ( +
+ + +
+ +
+

Game details

+

{game.title}

+

{game.summary ?? "No catalog summary is available yet."}

+ +
+ } label="Players" value={playerRange(game.minPlayers, game.maxPlayers)} /> + } label="Modes" value={modes.join(", ") || "Unknown"} /> + + + + +
+

+ Capability source: {game.capabilitySource ?? "not recorded"}. Missing values remain unknown rather than assumed. +

+
+
+
+ ); +} + +function Detail({ label, value, icon }: { label: string; value: string; icon?: React.ReactNode }) { + return ( +
+
{icon}{label}
+
{value}
+
+ ); +} + +function jsonStrings(value: unknown) { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; +} + +function playerRange(minimum: number | null, maximum: number | null) { + if (minimum === null || maximum === null) return "Unknown"; + return minimum === maximum ? String(minimum) : `${minimum}-${maximum}`; +} diff --git a/src/app/s/[shareToken]/page.tsx b/src/app/s/[shareToken]/page.tsx index b998493..300581e 100644 --- a/src/app/s/[shareToken]/page.tsx +++ b/src/app/s/[shareToken]/page.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; import { CalendarCheck, Download, Gamepad2, Lock, UsersRound } from "lucide-react"; -import { lockSessionAction, submitAvailabilityAction } from "@/app/actions"; +import { joinPickWorkspaceAction, lockSessionAction, submitAvailabilityAction } from "@/app/actions"; import { AvailabilityForm } from "@/components/availability-form"; import { PickPanel } from "@/components/pick-panel"; import { PendingSubmitButton } from "@/components/pending-submit-button"; @@ -11,13 +11,15 @@ import { RecommendationsDisclosure } from "@/components/recommendations-disclosu import { SessionTabs } from "@/components/session-tabs"; import { SharePanel } from "@/components/share-panel"; import { getAppUrl } from "@/lib/app-url"; -import { requireActivePickUser } from "@/lib/accounts"; +import { isActivePickUser, onboardingUrl, signInUrl } from "@/lib/accounts"; import { getCurrentUser, getParticipantId } from "@/lib/auth"; import { curatedGames } from "@/lib/curated-games"; import { commonMultiplayerGames, excludeExistingGames, rankSessionGames, searchGamesCatalog } from "@/lib/games"; import { defaultGroupBuyFilters, scoreGroupBuyCandidates } from "@/lib/group-buy"; import { getPopularIgdbGames, getTrendingIgdbGames, mapIgdbGame } from "@/lib/igdb"; -import { scoreSessionGames, type CommitmentFilter, type ScoreMode } from "@/lib/match-scoring"; +import { scoreSessionGames } from "@/lib/match-scoring"; +import { permittedPickParticipantIds } from "@/lib/pick/access"; +import { parsePickQuery, serializePickQuery, type PickSearchParams } from "@/lib/pick/query"; import { prisma } from "@/lib/prisma"; import { type BestTime, @@ -35,10 +37,11 @@ export const metadata = { robots: { index: false, follow: false } }; type PageProps = { params: Promise<{ shareToken: string }>; - searchParams: Promise<{ + searchParams: Promise; }; @@ -61,11 +66,10 @@ type RecommendationTime = BestTime & { export default async function SessionPage({ params, searchParams }: PageProps) { const { shareToken } = await params; + const queryParams = await searchParams; const { - gameSearch, - scoreMode, - playerCount, - selectedParticipants, + gameSearch: legacyGameSearch, + search, groupBudget, groupGenre, groupMode, @@ -74,9 +78,8 @@ export default async function SessionPage({ params, searchParams }: PageProps) { avoidOwned, saleOnly, imported, - sessionMinutes, - commitment, - } = await searchParams; + } = queryParams; + const gameSearch = search ?? legacyGameSearch; const justImportedCount = imported ? Math.max(0, Number(imported) || 0) : null; const session = await prisma.session.findUnique({ where: { shareToken }, @@ -104,16 +107,14 @@ export default async function SessionPage({ params, searchParams }: PageProps) { } const activeTab = session.workspaceType === "PICK" ? "pick" : "plan"; - const activeScoreMode = parseScoreMode(scoreMode); - const pickReturnTo = `/s/${shareToken}?tab=pick`; - const currentUser = - activeTab === "pick" - ? await requireActivePickUser(pickReturnTo) - : await getCurrentUser(); + const currentUser = await getCurrentUser(); + const activePickUser = isActivePickUser(currentUser); const cookieParticipantId = await getParticipantId(session.id); - const participantId = - session.participants.find((participant) => participant.userId === currentUser?.id)?.id ?? - session.participants.find((participant) => participant.id === cookieParticipantId)?.id; + const accountParticipantId = session.participants.find((participant) => participant.userId === currentUser?.id)?.id; + const participantId = activeTab === "pick" + ? activePickUser ? accountParticipantId : undefined + : accountParticipantId ?? session.participants.find((participant) => participant.id === cookieParticipantId)?.id; + const canAccessPrivatePick = activeTab === "pick" && Boolean(currentUser && participantId && activePickUser); const appUrl = await getAppUrl(); const shareUrl = session.gameNight ? `${appUrl}/n/${session.gameNight.shareToken}` @@ -205,12 +206,38 @@ export default async function SessionPage({ params, searchParams }: PageProps) { const possibleResponses = Math.max(session.participants.length * slots.length, 1); const responsePercent = Math.round((responseTotal / possibleResponses) * 100); const bestMatchLabel = submittedPeople >= session.minimumPlayerCount ? "Best match" : submittedPeople > 0 ? "Best so far" : "Waiting for responses"; - const selectedParticipantIds = normalizeSelectedParticipants(selectedParticipants, session.participants.map((participant) => participant.id)); - const selectedPlayerCount = Math.max(1, Number(playerCount ?? session.minimumPlayerCount) || session.minimumPlayerCount); - const selectedSessionMinutes = Math.max(30, Math.min(480, Number(sessionMinutes ?? session.requiredDuration * 60) || session.requiredDuration * 60)); - const selectedCommitment = parseCommitmentFilter(commitment); + const permittedParticipantIdList = canAccessPrivatePick && currentUser + ? await permittedPickParticipantIds({ viewerUserId: currentUser.id, participants: session.participants }) + : []; + const parsedPickQuery = parsePickQuery(queryParams, { + allowedParticipantIds: permittedParticipantIdList, + defaultSelectedParticipantIds: permittedParticipantIdList, + defaultPlayerCount: session.minimumPlayerCount, + defaultSessionMinutes: session.requiredDuration * 60, + }); + const selectedParticipantIds = parsedPickQuery.selectedParticipantIds; + const selectedPlayerCount = parsedPickQuery.playerCount; + const selectedSessionMinutes = parsedPickQuery.sessionMinutes; + const selectedCommitment = parsedPickQuery.commitment; + const activeScoreMode = parsedPickQuery.scoreMode; + const defaultBuyFilters = defaultGroupBuyFilters(selectedPlayerCount); + const groupBuyFilters = { + ...parsedPickQuery.groupBuy, + playerCount: selectedPlayerCount, + budget: groupBudget === undefined ? defaultBuyFilters.budget : parsedPickQuery.groupBuy.budget, + genre: groupGenre === undefined ? defaultBuyFilters.genre : parsedPickQuery.groupBuy.genre, + mode: groupMode === undefined ? defaultBuyFilters.mode : parsedPickQuery.groupBuy.mode, + sessionLength: groupLength === undefined ? defaultBuyFilters.sessionLength : parsedPickQuery.groupBuy.sessionLength, + platform: groupPlatform === undefined ? defaultBuyFilters.platform : parsedPickQuery.groupBuy.platform, + avoidOwned: avoidOwned === undefined ? defaultBuyFilters.avoidOwned : parsedPickQuery.groupBuy.avoidOwned, + saleOnly: saleOnly === undefined ? defaultBuyFilters.saleOnly : parsedPickQuery.groupBuy.saleOnly, + }; + const pickQuery = { ...parsedPickQuery, groupBuy: groupBuyFilters }; + const preservedPickParams = serializePickQuery(pickQuery); + preservedPickParams.set("tab", "pick"); + const pickDestination = `/s/${shareToken}?${preservedPickParams.toString()}`; const [initialSessionGameRows, searchResults, popularGames, trendingGames] = - activeTab === "pick" + canAccessPrivatePick ? await Promise.all([ prisma.sessionGame.findMany({ where: { sessionId: session.id }, @@ -236,7 +263,9 @@ export default async function SessionPage({ params, searchParams }: PageProps) { activeTab === "pick" ? rankSessionGames(initialSessionGames) : []; + const permittedParticipantIdsSet = new Set(permittedParticipantIdList); const participantUserIds = session.participants + .filter((participant) => permittedParticipantIdsSet.has(participant.id)) .map((participant) => participant.userId) .filter((userId): userId is string => Boolean(userId)); const selectedUserIdSet = new Set( @@ -245,12 +274,9 @@ export default async function SessionPage({ params, searchParams }: PageProps) { .map((participant) => participant.userId) .filter((userId): userId is string => Boolean(userId)), ); - const sessionGameUserIds = sessionGames - .map((sessionGame) => sessionGame.addedByUserId) - .filter((userId): userId is string => Boolean(userId)); - const playtimeUserIds = [...new Set([...participantUserIds, ...sessionGameUserIds])]; + const playtimeUserIds = [...new Set(participantUserIds)]; const profileCandidateDbRows = - activeTab === "pick" && selectedUserIdSet.size > 0 + canAccessPrivatePick && selectedUserIdSet.size > 0 ? await prisma.userGame.findMany({ where: { userId: { in: [...selectedUserIdSet] }, @@ -271,7 +297,6 @@ export default async function SessionPage({ params, searchParams }: PageProps) { { recentlyPlayedAt: "desc" }, { playtimeMinutes: "desc" }, ], - take: 500, }) : []; const profileCandidateRows = profileCandidateDbRows.map((row) => ({ @@ -281,7 +306,7 @@ export default async function SessionPage({ params, searchParams }: PageProps) { const existingSessionGameIds = new Set(sessionGames.map((sessionGame) => sessionGame.gameId)); const candidateGameIds = [...new Set(profileCandidateRows.map((userGame) => userGame.gameId))]; const userGames = - activeTab === "pick" + canAccessPrivatePick ? await prisma.userGame.findMany({ where: { userId: { in: playtimeUserIds }, @@ -348,10 +373,12 @@ export default async function SessionPage({ params, searchParams }: PageProps) { sessionMinutes: selectedSessionMinutes, commitment: selectedCommitment, mode: activeScoreMode, + setup: parsedPickQuery.setup, + selectionExplicit: parsedPickQuery.selectionExplicit, }) : []; const [priceAlertEvents, latestFriendInvite, savedFriends, friendGroups] = - activeTab === "pick" + canAccessPrivatePick ? await Promise.all([ prisma.priceAlertEvent.findMany({ where: { sessionId: session.id }, @@ -393,19 +420,9 @@ export default async function SessionPage({ params, searchParams }: PageProps) { : Promise.resolve([]), ]) : [[], null, [], []]; - const groupBuyFilters = parseGroupBuyFilters({ - groupBudget, - groupGenre, - groupMode, - groupLength, - groupPlatform, - avoidOwned, - saleOnly, - selectedPlayerCount, - }); const curatedSteamAppIds = curatedGames.map((game) => game.steamAppId).filter((steamAppId): steamAppId is number => Boolean(steamAppId)); const curatedDbGames = - activeTab === "pick" + canAccessPrivatePick ? await prisma.game.findMany({ where: { steamAppId: { in: curatedSteamAppIds } }, include: { deals: { where: { country: session.dealCountry }, take: 1 } }, @@ -423,11 +440,12 @@ export default async function SessionPage({ params, searchParams }: PageProps) { }, ]), ); - const ownedTitles = sessionGames - .filter((sessionGame) => sessionGame.signals.some((signal) => selectedParticipantIds.includes(signal.participantId) && (signal.signal === "OWNED" || signal.signal === "AVAILABLE_TO_PLAY"))) - .map((sessionGame) => sessionGame.game.title); + const ownedGameIds = new Set(userGames.filter((userGame) => userGame.ownership === "HAVE").map((userGame) => userGame.gameId)); + const ownedTitles = [...profileCandidateRows, ...sessionGames.map((sessionGame) => ({ gameId: sessionGame.gameId, game: sessionGame.game }))] + .filter((row) => ownedGameIds.has(row.gameId)) + .map((row) => row.game.title); const groupBuyRecommendations = - activeTab === "pick" + canAccessPrivatePick ? scoreGroupBuyCandidates({ filters: groupBuyFilters, ownedTitles, @@ -544,11 +562,15 @@ export default async function SessionPage({ params, searchParams }: PageProps) {

{currentParticipant ? ( <>Responding as {currentParticipant.name} · Saved to this Game Night + ) : activeTab === "pick" ? ( + currentUser + ? <>Join this Pick workspace to see and contribute private group matches. + : <>Sign in to join this Pick workspace. Steam is optional. ) : ( <>Choose your name when you make your first response. )}

- {currentParticipant ? Switch participant : null} + {currentParticipant && activeTab === "plan" ? Switch participant : null} {activeTab === "pick" && justImportedCount !== null ? ( @@ -556,7 +578,7 @@ export default async function SessionPage({ params, searchParams }: PageProps) { ) : null} {activeTab === "pick" ? ( - + query={pickQuery} + /> : ( +
+

Private group matching

+

Join before viewing the group's games

+

+ Membership keeps ownership, ratings, playtime and recommendations inside this Pick workspace. You can join without connecting Steam. +

+
+ {!currentUser ? ( + Sign in to join + ) : !activePickUser ? ( + Finish account setup + ) : ( +
+ + + Join this Pick +
+ )} +
+
+ ) ) : (
@@ -630,6 +674,7 @@ export default async function SessionPage({ params, searchParams }: PageProps) { shareToken={session.shareToken} participantId={currentParticipant?.id} participantName={currentParticipant?.name} + revision={currentParticipant?.availabilityRevision ?? 0} groupedSlots={groupedSlots} currentResponses={currentResponseRecord} compact={compactAvailability} @@ -684,73 +729,6 @@ export default async function SessionPage({ params, searchParams }: PageProps) { ); } -function parseGroupBuyFilters({ - groupBudget, - groupGenre, - groupMode, - groupLength, - groupPlatform, - avoidOwned, - saleOnly, - selectedPlayerCount, -}: { - groupBudget?: string; - groupGenre?: string; - groupMode?: string; - groupLength?: string; - groupPlatform?: string; - avoidOwned?: string; - saleOnly?: string; - selectedPlayerCount: number; -}) { - const defaults = defaultGroupBuyFilters(selectedPlayerCount); - - return { - budget: groupBudget ? Math.max(0, Math.round(Number(groupBudget) * 100)) : defaults.budget, - genre: groupGenre ?? defaults.genre, - playerCount: selectedPlayerCount, - mode: groupMode === "local" || groupMode === "either" ? groupMode : defaults.mode, - sessionLength: groupLength === "one-night" || groupLength === "long-term" || groupLength === "campaign" ? groupLength : defaults.sessionLength, - platform: groupPlatform ?? defaults.platform, - avoidOwned: avoidOwned !== "off", - saleOnly: saleOnly === "on", - }; -} - -function parseScoreMode(value?: string): ScoreMode { - if (value === "coop" || value === "backlog" || value === "cheap" || value === "familiar" || value === "fresh") { - return value; - } - - return "balanced"; -} - -function parseCommitmentFilter(value?: string): CommitmentFilter { - const supported: CommitmentFilter[] = [ - "any", - "one-session", - "under-10", - "10-30", - "30-100", - "100-1000", - "1000-plus", - "endless", - ]; - - return supported.includes(value as CommitmentFilter) ? (value as CommitmentFilter) : "any"; -} - -function normalizeSelectedParticipants(value: string | string[] | undefined, fallback: string[]) { - if (!value) { - return fallback; - } - - const values = Array.isArray(value) ? value : value.split(","); - const selected = values.flatMap((candidate) => candidate.split(",")).filter(Boolean); - - return selected.length > 0 ? selected : fallback; -} - function RecommendationList({ title, empty, diff --git a/src/app/s/[shareToken]/session-page.test.tsx b/src/app/s/[shareToken]/session-page.test.tsx index 695c418..aba6362 100644 --- a/src/app/s/[shareToken]/session-page.test.tsx +++ b/src/app/s/[shareToken]/session-page.test.tsx @@ -1,31 +1,117 @@ import React from "react"; import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import Home from "@/app/page"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +const mocks = vi.hoisted(() => ({ + findSession: vi.fn(), + findSessionGames: vi.fn(), + findUserGames: vi.fn(), + findPriceAlertEvents: vi.fn(), + findFriendInvite: vi.fn(), + findFriends: vi.fn(), + findFriendGroups: vi.fn(), + findGames: vi.fn(), + getCurrentUser: vi.fn(), + getParticipantId: vi.fn(), + getAppUrl: vi.fn(), +})); + +vi.mock("next/image", () => ({ + default: ({ fill: _fill, priority: _priority, ...props }: React.ImgHTMLAttributes & { fill?: boolean; priority?: boolean }) => , +})); vi.mock("next/link", () => ({ - default: ({ - children, - href, - className, - }: { - children: React.ReactNode; - href: string; - className?: string; - }) => ( - - {children} - - ), + default: ({ children, href, ...props }: React.AnchorHTMLAttributes) => {children}, +})); +vi.mock("next/navigation", () => ({ notFound: vi.fn(() => { throw new Error("not found"); }) })); +vi.mock("@/app/actions", () => ({ joinPickWorkspaceAction: vi.fn(), lockSessionAction: vi.fn(), submitAvailabilityAction: vi.fn() })); +vi.mock("@/lib/prisma", () => ({ + prisma: { + session: { findUnique: mocks.findSession }, sessionGame: { findMany: mocks.findSessionGames }, userGame: { findMany: mocks.findUserGames }, + priceAlertEvent: { findMany: mocks.findPriceAlertEvents }, friendInvite: { findFirst: mocks.findFriendInvite }, userFriend: { findMany: mocks.findFriends }, + friendGroup: { findMany: mocks.findFriendGroups }, game: { findMany: mocks.findGames }, userBlock: { findMany: vi.fn(async () => []) }, + }, })); +vi.mock("@/lib/auth", () => ({ getCurrentUser: mocks.getCurrentUser, getParticipantId: mocks.getParticipantId })); +vi.mock("@/lib/accounts", () => ({ + isActivePickUser: (user: { id: string } | null) => Boolean(user), + onboardingUrl: (returnTo: string) => `/account/onboarding?returnTo=${encodeURIComponent(returnTo)}`, + signInUrl: (returnTo: string) => `/account/sign-in?returnTo=${encodeURIComponent(returnTo)}`, +})); +vi.mock("@/lib/app-url", () => ({ getAppUrl: mocks.getAppUrl })); +vi.mock("@/lib/curated-games", () => ({ curatedGames: [] })); +vi.mock("@/lib/games", () => ({ commonMultiplayerGames: [], excludeExistingGames: vi.fn(() => []), rankSessionGames: vi.fn((games) => games), searchGamesCatalog: vi.fn(async () => []) })); +vi.mock("@/lib/group-buy", () => ({ defaultGroupBuyFilters: vi.fn((playerCount) => ({ budget: 0, genre: "all", playerCount, mode: "either", sessionLength: "one-night", platform: "all", avoidOwned: true, saleOnly: false })), scoreGroupBuyCandidates: vi.fn(() => []) })); +vi.mock("@/lib/igdb", () => ({ getPopularIgdbGames: vi.fn(async () => []), getTrendingIgdbGames: vi.fn(async () => []), mapIgdbGame: vi.fn() })); +vi.mock("@/lib/match-scoring", () => ({ scoreSessionGames: vi.fn(() => []) })); +vi.mock("@/lib/scheduling", () => ({ + formatSlotDay: vi.fn(), formatSlotRange: vi.fn(), formatSlotTime: vi.fn(), generateHourlySlots: vi.fn(() => []), isWeekendSlot: vi.fn(), + rankBestTimes: vi.fn(() => []), rankMaybeTimes: vi.fn(() => []), responseMap: vi.fn(() => new Map()), +})); +vi.mock("@/components/availability-form", () => ({ AvailabilityForm: ({ participantId, participantName }: { participantId?: string; participantName?: string }) => {participantId ?? "visitor"}:{participantName ?? ""} })); +vi.mock("@/components/pick-panel", () => ({ PickPanel: ({ currentUser, participantId, selectedParticipantIds }: { currentUser: { id: string } | null; participantId?: string; selectedParticipantIds: string[] }) => {currentUser?.id ?? "visitor"}:{participantId}:{selectedParticipantIds.join(",")} })); +vi.mock("@/components/post-import-status", () => ({ PostImportStatus: () => null })); +vi.mock("@/components/recommendations-disclosure", () => ({ RecommendationsDisclosure: ({ children }: { children: React.ReactNode }) => <>{children} })); +vi.mock("@/components/session-tabs", () => ({ SessionTabs: ({ activeTab }: { activeTab: string }) => {activeTab} })); +vi.mock("@/components/share-panel", () => ({ SharePanel: () => null })); +vi.mock("@/components/pending-submit-button", () => ({ PendingSubmitButton: ({ children }: { children: React.ReactNode }) => })); + +import SessionPage from "./page"; + +const planSession = { + id: "plan-session", shareToken: "plan-token", title: "Friday co-op", workspaceType: "PLAN", mode: "ONLINE", requiredDuration: 2, minimumPlayerCount: 2, + dateRangeStart: new Date("2026-09-11T00:00:00.000Z"), dateRangeEnd: new Date("2026-09-12T00:00:00.000Z"), dailyStartHour: 18, dailyEndHour: 22, + timezone: "Europe/London", dealCountry: "GB", dealCurrency: "GBP", lockedStartTime: null, lockedEndTime: null, discordChannel: null, gameNight: null, + participants: [{ id: "guest", userId: null, name: "Guest", isHost: true, createdAt: new Date(), responses: [], preference: null, user: null }], +}; + +const pickSession = { + ...planSession, id: "pick-session", shareToken: "pick-token", title: "Library night", workspaceType: "PICK", + gameNight: { shareToken: "night-token", selectedSessionGameId: null, workspaces: [{ shareToken: "plan-token", workspaceType: "PLAN" }, { shareToken: "pick-token", workspaceType: "PICK" }] }, + participants: [ + { id: "member", userId: "member-user", name: "Mina", isHost: true, createdAt: new Date(), responses: [], preference: null, user: { preference: null, steamAccount: null } }, + { id: "guest", userId: null, name: "Guest", isHost: false, createdAt: new Date(), responses: [], preference: null, user: null }, + ], +}; + +describe("shared session route", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getAppUrl.mockResolvedValue("https://example.test"); mocks.getCurrentUser.mockResolvedValue(null); mocks.getParticipantId.mockResolvedValue(undefined); + mocks.findSessionGames.mockResolvedValue([]); mocks.findUserGames.mockResolvedValue([]); + mocks.findPriceAlertEvents.mockResolvedValue([]); mocks.findFriendInvite.mockResolvedValue(null); mocks.findFriends.mockResolvedValue([]); mocks.findFriendGroups.mockResolvedValue([]); mocks.findGames.mockResolvedValue([]); + }); + + it("composes the plan workspace for a cookie-identified visitor from an explicit session query", async () => { + mocks.findSession.mockResolvedValue(planSession); mocks.getParticipantId.mockResolvedValue("guest"); + render(await SessionPage({ params: Promise.resolve({ shareToken: "plan-token" }), searchParams: Promise.resolve({}) })); + expect(screen.getByRole("heading", { name: "Friday co-op" })).toBeInTheDocument(); + expect(screen.getByTestId("session-tabs")).toHaveTextContent("plan"); + expect(screen.getByTestId("availability-form")).toHaveTextContent("guest:Guest"); + expect(mocks.getCurrentUser).toHaveBeenCalledOnce(); + expect(mocks.findSession).toHaveBeenCalledWith(expect.objectContaining({ where: { shareToken: "plan-token" } })); + }); + + it("composes the pick workspace for the signed-in member and loads its explicit data dependencies", async () => { + mocks.findSession.mockResolvedValue(pickSession); mocks.getParticipantId.mockResolvedValue("guest"); + mocks.getCurrentUser.mockResolvedValue({ id: "member-user" }); + render(await SessionPage({ params: Promise.resolve({ shareToken: "pick-token" }), searchParams: Promise.resolve({ gameSearch: "spelunky" }) })); + expect(screen.getByRole("heading", { name: "Library night" })).toBeInTheDocument(); expect(screen.getByTestId("session-tabs")).toHaveTextContent("pick"); + expect(screen.getByTestId("pick-panel")).toHaveTextContent("member-user:member:member,guest"); + expect(mocks.findSessionGames).toHaveBeenCalledWith(expect.objectContaining({ where: { sessionId: "pick-session" } })); + expect(mocks.findUserGames).toHaveBeenCalled(); expect(mocks.findGames).toHaveBeenCalled(); + }); + + it("shows a join gate without loading private Pick data for a signed-in nonmember", async () => { + mocks.findSession.mockResolvedValue(pickSession); + mocks.getCurrentUser.mockResolvedValue({ id: "outside-user" }); -describe("planner entry", () => { - it("puts the no-login planner first", () => { - render(); + render(await SessionPage({ params: Promise.resolve({ shareToken: "pick-token" }), searchParams: Promise.resolve({}) })); - expect(screen.getAllByText("Plan a night").length).toBeGreaterThan(0); - expect(screen.getByText("Guests can answer without an account")).toBeInTheDocument(); - expect(screen.getByText("When can everyone make it?")).toBeInTheDocument(); - expect(screen.getByText("Deep Rock Galactic")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Join before viewing the group's games" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Join this Pick" })).toBeInTheDocument(); + expect(screen.queryByTestId("pick-panel")).not.toBeInTheDocument(); + expect(mocks.findSessionGames).not.toHaveBeenCalled(); + expect(mocks.findUserGames).not.toHaveBeenCalled(); + expect(mocks.findGames).not.toHaveBeenCalled(); }); }); diff --git a/src/app/sessions/pick/page.test.tsx b/src/app/sessions/pick/page.test.tsx new file mode 100644 index 0000000..b830f79 --- /dev/null +++ b/src/app/sessions/pick/page.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import NewPickSessionPage from "@/app/sessions/pick/page"; +import { requireActivePickUser } from "@/lib/accounts"; + +vi.mock("@/app/actions", () => ({ + createPickSessionAction: vi.fn(), + startPickSessionFromFriendGroupAction: vi.fn(), +})); + +vi.mock("@/lib/accounts", () => ({ + requireActivePickUser: vi.fn(async () => ({ id: "user-1", displayName: "Player", timezone: "Europe/London" })), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { friendGroup: { findMany: vi.fn(async () => []) } }, +})); + +describe("new Pick session query handoff", () => { + it("keeps bounded player count and setup through the authentication return path and create form", async () => { + render(await NewPickSessionPage({ + searchParams: Promise.resolve({ game: "rimworld", gameNight: "night-1", playerCount: "5", setup: "modded" }), + })); + + const form = screen.getByRole("button", { name: "Create shortlist" }).closest("form"); + expect(form).not.toBeNull(); + const data = new FormData(form!); + expect(data.get("initialGameSlug")).toBe("rimworld"); + expect(data.get("gameNightId")).toBe("night-1"); + expect(data.get("playerCount")).toBe("5"); + expect(data.get("setup")).toBe("modded"); + expect(requireActivePickUser).toHaveBeenCalledWith("/sessions/pick?game=rimworld&gameNight=night-1&playerCount=5&setup=modded"); + }); +}); diff --git a/src/app/sessions/pick/page.tsx b/src/app/sessions/pick/page.tsx index 346d19f..74e7e81 100644 --- a/src/app/sessions/pick/page.tsx +++ b/src/app/sessions/pick/page.tsx @@ -6,17 +6,22 @@ import { TimezoneInput } from "@/components/timezone-input"; import { requireActivePickUser } from "@/lib/accounts"; import { getCuratedGame } from "@/lib/curated-games"; import { prisma } from "@/lib/prisma"; +import { parseMinimumPlayers } from "@/lib/player-count"; type PageProps = { - searchParams?: Promise<{ game?: string; gameNight?: string }>; + searchParams?: Promise<{ game?: string; gameNight?: string; playerCount?: string; setup?: string }>; }; export default async function NewPickSessionPage({ searchParams }: PageProps) { const query = await searchParams; const initialGame = query?.game ? getCuratedGame(query.game) : null; + const playerCount = parseMinimumPlayers(query?.playerCount); + const setup = parseSetup(query?.setup); const returnParams = new URLSearchParams(); if (query?.game) returnParams.set("game", query.game); if (query?.gameNight) returnParams.set("gameNight", query.gameNight); + returnParams.set("playerCount", String(playerCount)); + returnParams.set("setup", setup); const returnTo = `/sessions/pick${returnParams.size ? `?${returnParams.toString()}` : ""}`; const currentUser = await requireActivePickUser(returnTo); const friendGroups = await prisma.friendGroup.findMany({ @@ -64,6 +69,8 @@ export default async function NewPickSessionPage({ searchParams }: PageProps) {
{query?.gameNight ? : null} {initialGame ? : null} + + {initialGame ? (

Starting shortlist with {initialGame.title}

@@ -114,6 +121,10 @@ export default async function NewPickSessionPage({ searchParams }: PageProps) { ); } +function parseSetup(value: string | undefined) { + return value === "modded" || value === "either" || value === "native" ? value : "native"; +} + function Step({ number, icon, label }: { number: string; icon: React.ReactNode; label: string }) { return (
diff --git a/src/app/users/[username]/page.tsx b/src/app/users/[username]/page.tsx index 326faac..50b663d 100644 --- a/src/app/users/[username]/page.tsx +++ b/src/app/users/[username]/page.tsx @@ -50,20 +50,35 @@ export default async function UserGamingProfilePage({ params }: PageProps) { notFound(); } - const games = await prisma.userGame.findMany({ - where: { - userId: profile.id, - ...(friendship ? {} : { favourite: true }), - }, - include: { game: true }, - orderBy: [ - { favourite: "desc" }, - { rating: "desc" }, - { playtimeMinutes: "desc" }, - { game: { title: "asc" } }, - ], - take: friendship ? 100 : 6, - }); + const games = friendship + ? (await prisma.userGame.findMany({ + where: { userId: profile.id }, + select: { + id: true, + ownership: true, + rating: true, + playtimeMinutes: true, + game: true, + }, + orderBy: [ + { favourite: "desc" }, + { rating: "desc" }, + { playtimeMinutes: "desc" }, + { game: { title: "asc" } }, + ], + take: 100, + })).map((entry) => ({ ...entry, privateDetails: true as const })) + : (await prisma.userGame.findMany({ + where: { userId: profile.id, favourite: true }, + select: { + id: true, + game: { + select: { title: true, steamAppId: true, coverUrl: true }, + }, + }, + orderBy: [{ game: { title: "asc" } }], + take: 6, + })).map((entry) => ({ ...entry, privateDetails: false as const })); return (
@@ -114,13 +129,15 @@ export default async function UserGamingProfilePage({ params }: PageProps) {

{userGame.game.title}

-

- {userGame.ownership === "HAVE" ? "Has it" : userGame.ownership === "DONT_HAVE" ? "Doesn't have it" : "Unknown"} - {userGame.rating ? ` · ${userGame.rating}/10` : ""} -

- {userGame.playtimeMinutes ? ( -

{Math.round(userGame.playtimeMinutes / 60).toLocaleString()}h played

- ) : null} + {userGame.privateDetails ? <> +

+ {userGame.ownership === "HAVE" ? "Has it" : userGame.ownership === "DONT_HAVE" ? "Doesn't have it" : "Unknown"} + {userGame.rating ? ` · ${userGame.rating}/10` : ""} +

+ {userGame.playtimeMinutes ? ( +

{Math.round(userGame.playtimeMinutes / 60).toLocaleString()}h played

+ ) : null} + : null}
))} diff --git a/src/components/app-navigation.test.tsx b/src/components/app-navigation.test.tsx index c2612ad..c678380 100644 --- a/src/components/app-navigation.test.tsx +++ b/src/components/app-navigation.test.tsx @@ -32,4 +32,15 @@ describe("AppNavigation", () => { expect(document.documentElement).toHaveAttribute("data-theme", "dark"); expect(window.localStorage.getItem("theme-mode")).toBe("dark"); }); + + it("cancels its account request when it unmounts", () => { + const fetchMock = vi.fn(() => new Promise(() => undefined)); + vi.stubGlobal("fetch", fetchMock); + + const view = render(); + view.unmount(); + + const [, request] = fetchMock.mock.calls[0] as unknown as [unknown, RequestInit]; + expect(request.signal?.aborted).toBe(true); + }); }); diff --git a/src/components/app-navigation.tsx b/src/components/app-navigation.tsx index 3d0658b..f92c358 100644 --- a/src/components/app-navigation.tsx +++ b/src/components/app-navigation.tsx @@ -19,10 +19,22 @@ export function AppNavigation() { const pathname = usePathname(); useEffect(() => { - fetch("/api/auth/me", { credentials: "same-origin" }) + const controller = new AbortController(); + + fetch("/api/auth/me", { credentials: "same-origin", signal: controller.signal }) .then((response) => response.ok ? response.json() : null) - .then((value) => setUser(value?.user ?? null)) - .catch(() => setUser(null)); + .then((value) => { + if (!controller.signal.aborted) { + setUser(value?.user ?? null); + } + }) + .catch(() => { + if (!controller.signal.aborted) { + setUser(null); + } + }); + + return () => controller.abort(); }, []); return ( diff --git a/src/components/availability-form.test.tsx b/src/components/availability-form.test.tsx index e6db044..8b8ecfb 100644 --- a/src/components/availability-form.test.tsx +++ b/src/components/availability-form.test.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { AvailabilityForm } from "@/components/availability-form"; const groupedSlots = [ @@ -25,6 +25,10 @@ const groupedSlots = [ ]; describe("AvailabilityForm", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("renders day-by-day wizard controls", () => { renderForm(); @@ -46,15 +50,96 @@ describe("AvailabilityForm", () => { expect(statusInput(container, "2026-06-19T19:00:00.000Z")).toHaveValue("AVAILABLE"); expect(statusInput(container, "2026-06-20T18:00:00.000Z")).toBeNull(); }); + + it("does not submit a queued fill-blanks request after unmounting", async () => { + vi.useFakeTimers(); + const requestSubmit = vi.spyOn(HTMLFormElement.prototype, "requestSubmit"); + const view = renderForm(); + + fireEvent.submit(view.container.querySelector("form")!); + fireEvent.click(screen.getByRole("button", { name: "Fill blanks as Maybe" })); + view.unmount(); + + vi.runAllTimers(); + expect(requestSubmit).not.toHaveBeenCalled(); + requestSubmit.mockRestore(); + }); + + it("keeps an unsaved availability draft when server responses refresh", async () => { + const user = userEvent.setup(); + const view = renderForm(); + const wizard = screen.getByLabelText("Availability day wizard"); + + await user.click(within(wizard).getByRole("button", { name: /All in/i })); + view.rerender( + , + ); + + expect(statusInput(view.container, "2026-06-19T18:00:00.000Z")).toHaveValue("AVAILABLE"); + expect(view.container.querySelector('input[name="revision"]')).toHaveValue("0"); + }); + + it("submits the revision and retains the draft when the server reports a conflict", async () => { + const conflictAction = vi.fn(async () => ({ + ok: false as const, + code: "CONFLICT" as const, + message: "This availability was updated in another tab. Your draft is still here; refresh to compare before saving again.", + })); + const user = userEvent.setup(); + const view = renderForm({ action: conflictAction, revision: 7 }); + + await user.click(within(screen.getByLabelText("Availability day wizard")).getByRole("button", { name: /All in/i })); + await user.click(screen.getByRole("button", { name: "Next day" })); + await user.click(within(screen.getByLabelText("Availability day wizard")).getByRole("button", { name: /All in/i })); + await user.click(screen.getByRole("button", { name: "Save availability" })); + + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("updated in another tab")); + const [, submittedData] = conflictAction.mock.calls[0] as unknown as [unknown, FormData]; + expect(submittedData.get("revision")).toBe("7"); + expect(submittedData.get("status:2026-06-19T18:00:00.000Z")).toBe("AVAILABLE"); + expect(statusInput(view.container, "2026-06-19T18:00:00.000Z")).toHaveValue("AVAILABLE"); + }); + + it("keeps repeated DST wall-hour labels distinct when a zone is supplied", () => { + renderForm({ + groupedSlots: [{ + ...groupedSlots[0], + slots: [ + { ...groupedSlots[0].slots[0], time: "01:00-01:00 BST" }, + { ...groupedSlots[0].slots[1], time: "01:00-01:00 GMT" }, + ], + }], + }); + + expect(screen.getByLabelText("Time 01:00-01:00 BST")).toBeInTheDocument(); + expect(screen.getByLabelText("Time 01:00-01:00 GMT")).toBeInTheDocument(); + }); }); -function renderForm() { +function renderForm({ + action = vi.fn(), + revision = 0, + groupedSlots: slots = groupedSlots, +}: { + action?: Parameters[0]["action"]; + revision?: number; + groupedSlots?: typeof groupedSlots; +} = {}) { return render( , diff --git a/src/components/availability-form.tsx b/src/components/availability-form.tsx index 8c6c15d..31774eb 100644 --- a/src/components/availability-form.tsx +++ b/src/components/availability-form.tsx @@ -1,11 +1,12 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useActionState, useEffect, useMemo, useRef, useState } from "react"; import type { MutableRefObject } from "react"; import type { FormEvent } from "react"; import { Check, ChevronDown, ChevronLeft, ChevronRight, HelpCircle, X, type LucideIcon } from "lucide-react"; import { PendingSubmitButton } from "@/components/pending-submit-button"; import type { AvailabilityStatus } from "@/lib/scheduling"; +import type { ActionResult } from "@/lib/session/contracts"; type SlotView = { key: string; @@ -23,10 +24,14 @@ type DayView = { }; type AvailabilityFormProps = { - action: (formData: FormData) => void | Promise; + action: ( + state: ActionResult<{ participantId: string; revision: number }> | null, + formData: FormData, + ) => Promise>; shareToken: string; participantId?: string; participantName?: string; + revision: number; groupedSlots: DayView[]; currentResponses: Record; compact: boolean; @@ -78,16 +83,21 @@ export function AvailabilityForm({ shareToken, participantId, participantName, + revision, groupedSlots, currentResponses, compact, }: AvailabilityFormProps) { + const [saveState, formAction] = useActionState(action, null); const [responses, setResponses] = useState>(currentResponses); + const [draftRevision, setDraftRevision] = useState(revision); const [activeDayIndex, setActiveDayIndex] = useState(0); const [incompletePromptOpen, setIncompletePromptOpen] = useState(false); const [allowIncompleteSubmit, setAllowIncompleteSubmit] = useState(false); const formRef = useRef(null); const paintingStatus = useRef(null); + const fillSubmitTimer = useRef(null); + const hasUnsavedChanges = useRef(false); const allSlots = useMemo(() => groupedSlots.flatMap((group) => group.slots), [groupedSlots]); const allSlotKeys = allSlots.map((slot) => slot.key); const timeColumns = Array.from(new Set(allSlots.map((slot) => slot.time))).sort( @@ -115,7 +125,23 @@ export function AvailabilityForm({ return () => window.removeEventListener("pointerup", clearPaint); }, []); + useEffect(() => { + if (!hasUnsavedChanges.current) { + setResponses(currentResponses); + setDraftRevision(revision); + } + }, [currentResponses, revision]); + + useEffect(() => { + return () => { + if (fillSubmitTimer.current !== null) { + window.clearTimeout(fillSubmitTimer.current); + } + }; + }, []); + function setSlots(slotKeys: string[], status: AvailabilityStatus) { + hasUnsavedChanges.current = true; setResponses((current) => { const next = { ...current }; for (const slotKey of slotKeys) { @@ -126,6 +152,7 @@ export function AvailabilityForm({ } function cycleSlot(slotKey: string) { + hasUnsavedChanges.current = true; setResponses((current) => ({ ...current, [slotKey]: nextStatus[current[slotKey] ?? "EMPTY"], @@ -164,16 +191,25 @@ export function AvailabilityForm({ setSlots(missingSlotKeys, "MAYBE"); setAllowIncompleteSubmit(true); setIncompletePromptOpen(false); - window.setTimeout(() => formRef.current?.requestSubmit(), 0); + fillSubmitTimer.current = window.setTimeout(() => { + fillSubmitTimer.current = null; + formRef.current?.requestSubmit(); + }, 0); } return ( -
+ + {participantId ? : null} {Object.entries(responses).map(([slotKey, status]) => ( ))} + {saveState && !saveState.ok ? ( +

+ {saveState.message} +

+ ) : null}
@@ -307,8 +343,8 @@ export function AvailabilityForm({
{timeColumns.map((time) => ( -
- {time.split("-")[0]} +
+ {time}
))} diff --git a/src/components/game-signal-controls.test.tsx b/src/components/game-signal-controls.test.tsx new file mode 100644 index 0000000..e30b9a8 --- /dev/null +++ b/src/components/game-signal-controls.test.tsx @@ -0,0 +1,74 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GameSignalControls } from "@/components/game-signal-controls"; + +const actionMocks = vi.hoisted(() => ({ + markGameAvailableAction: vi.fn(), + markGameInterestAction: vi.fn(), +})); + +vi.mock("@/app/actions", () => actionMocks); + +describe("GameSignalControls", () => { + beforeEach(() => { + actionMocks.markGameAvailableAction.mockReset(); + actionMocks.markGameInterestAction.mockReset(); + }); + + it("serializes conflicting signal writes until the active mutation settles", async () => { + let resolveSave: () => void = () => undefined; + actionMocks.markGameAvailableAction.mockImplementationOnce( + () => new Promise((resolve) => { + resolveSave = resolve; + }), + ); + const user = userEvent.setup(); + renderControls(); + + await user.click(screen.getByRole("button", { name: "Have" })); + + expect(screen.getByRole("button", { name: "Don't have" })).toBeDisabled(); + expect(screen.getByText("Saving...")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Don't have" })); + expect(actionMocks.markGameAvailableAction).toHaveBeenCalledTimes(1); + + resolveSave(); + await waitFor(() => expect(screen.getByText("Saved")).toBeInTheDocument()); + expect(screen.getByRole("button", { name: "Have" })).not.toBeDisabled(); + }); + + it("does not let an old server prop overwrite a confirmed optimistic signal", async () => { + actionMocks.markGameAvailableAction.mockResolvedValueOnce(undefined); + const user = userEvent.setup(); + const view = renderControls(); + + await user.click(screen.getByRole("button", { name: "Have" })); + await waitFor(() => expect(screen.getByText("Saved")).toBeInTheDocument()); + + view.rerender( + , + ); + + expect(screen.getByRole("button", { name: "Have" })).toHaveClass("bg-moss"); + }); +}); + +function renderControls() { + return render( + , + ); +} diff --git a/src/components/game-signal-controls.tsx b/src/components/game-signal-controls.tsx index d5bfeb3..5e44024 100644 --- a/src/components/game-signal-controls.tsx +++ b/src/components/game-signal-controls.tsx @@ -1,7 +1,7 @@ "use client"; import { Check, Heart, X } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { markGameAvailableAction, markGameInterestAction } from "@/app/actions"; export function GameSignalControls({ @@ -20,32 +20,107 @@ export function GameSignalControls({ const [signal, setSignal] = useState(initialSignal); const [interest, setInterest] = useState(initialInterest); const [status, setStatus] = useState(""); + const [isPending, setIsPending] = useState(false); + const latestMutation = useRef(0); + const mutationPending = useRef(false); + const isMounted = useRef(true); + const lastServerValues = useRef({ signal: initialSignal, interest: initialInterest }); + const hasOptimisticSignal = useRef(false); + const hasOptimisticInterest = useRef(false); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + useEffect(() => { + const serverValuesChanged = + lastServerValues.current.signal !== initialSignal || + lastServerValues.current.interest !== initialInterest; + + const signalMatchesOptimistic = signal === initialSignal; + const interestMatchesOptimistic = interest === initialInterest; + + if (!mutationPending.current && serverValuesChanged) { + lastServerValues.current = { signal: initialSignal, interest: initialInterest }; + if (!hasOptimisticSignal.current || signalMatchesOptimistic) { + hasOptimisticSignal.current = false; + setSignal(initialSignal); + } + if (!hasOptimisticInterest.current || interestMatchesOptimistic) { + hasOptimisticInterest.current = false; + setInterest(initialInterest); + } + } + }, [initialInterest, initialSignal, interest, signal]); + + function beginMutation() { + latestMutation.current += 1; + mutationPending.current = true; + setIsPending(true); + setStatus("Saving..."); + return latestMutation.current; + } + + function isCurrentMutation(mutationId: number) { + return isMounted.current && latestMutation.current === mutationId; + } async function saveSignal(formData: FormData) { + if (mutationPending.current) { + return; + } + const next = String(formData.get("signal")); const previous = signal; + const mutationId = beginMutation(); + hasOptimisticSignal.current = true; setSignal(next); - setStatus("Saving..."); try { await markGameAvailableAction(formData); - setStatus("Saved"); + if (isCurrentMutation(mutationId)) { + setStatus("Saved"); + } } catch { - setSignal(previous); - setStatus("Could not save. Try again."); + if (isCurrentMutation(mutationId)) { + setSignal(previous); + setStatus("Could not save. Try again."); + } + } finally { + if (isCurrentMutation(mutationId)) { + mutationPending.current = false; + setIsPending(false); + } } } async function saveInterest(formData: FormData) { + if (mutationPending.current) { + return; + } + const next = String(formData.get("interest")) as typeof interest; const previous = interest; + const mutationId = beginMutation(); + hasOptimisticInterest.current = true; setInterest(next); - setStatus("Saving..."); try { await markGameInterestAction(formData); - setStatus("Saved"); + if (isCurrentMutation(mutationId)) { + setStatus("Saved"); + } } catch { - setInterest(previous); - setStatus("Could not save. Try again."); + if (isCurrentMutation(mutationId)) { + setInterest(previous); + setStatus("Could not save. Try again."); + } + } finally { + if (isCurrentMutation(mutationId)) { + mutationPending.current = false; + setIsPending(false); + } } } @@ -56,12 +131,18 @@ export function GameSignalControls({ { value: "OWNED", label: "Have", icon: Check }, { value: "NOT_AVAILABLE", label: "Don't have", icon: X }, ].map(({ value, label, icon: Icon }) => ( - + { + event.preventDefault(); + void saveSignal(new FormData(event.currentTarget)); + }} + > - @@ -74,12 +155,18 @@ export function GameSignalControls({ ["NEUTRAL", "Neutral"], ["NOT_TONIGHT", "Not tonight"], ].map(([value, label]) => ( -
+ { + event.preventDefault(); + void saveInterest(new FormData(event.currentTarget)); + }} + > -
diff --git a/src/components/pick-panel.test.tsx b/src/components/pick-panel.test.tsx index 454449a..d1c790c 100644 --- a/src/components/pick-panel.test.tsx +++ b/src/components/pick-panel.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { PickPanel, type ParticipantView, type SessionGameView } from "@/components/pick-panel"; +import { parsePickQuery } from "@/lib/pick/query"; vi.mock("@/app/actions", () => ({ addSessionParticipantsAsFriendsAction: vi.fn(), @@ -70,7 +71,7 @@ const baseProps = { { key: "playerCount" as const, label: "Player count", value: 95, weight: 0.12, points: 11.4 }, { key: "onlineCoop" as const, label: "Online co-op", value: 90, weight: 0.08, points: 7.2 }, ], - ownership: { have: 2, missing: 0, selected: 2 }, + ownership: { have: 2, dontHave: 0, unknown: 0, missing: 0, selected: 2 }, playtimeMinutes: 40, discountPercent: 0, currentPrice: null, @@ -103,6 +104,58 @@ const baseProps = { }; describe("PickPanel", () => { + it("keeps canonical Pick state when match, search, or group-buy forms submit", () => { + const query = parsePickQuery({ + selectedParticipantIds: ["p1", "p2"], + selectionExplicit: "true", + playerCount: "5", + mode: "local", + setup: "modded", + scoreMode: "coop", + sessionMinutes: "180", + commitment: "10-30", + search: "wizards", + groupBudget: "12.34", + groupGenre: "rpg", + groupMode: "online", + groupLength: "campaign", + groupPlatform: "PC", + avoidOwned: "false", + saleOnly: "true", + }, { allowedParticipantIds: ["p1", "p2"] }); + + const { container } = render( + , + ); + + const matchForm = screen.getByRole("button", { name: "Update match" }).closest("form"); + const searchForm = screen.getByPlaceholderText("Search games...").closest("form"); + const groupBuyForm = screen.getByRole("button", { name: "Update group buy" }).closest("form"); + expect(matchForm).not.toBeNull(); + expect(searchForm).not.toBeNull(); + expect(groupBuyForm).not.toBeNull(); + + const matchData = new FormData(matchForm!); + expect(matchData.get("selectionExplicit")).toBe("true"); + expect(matchData.getAll("selectedParticipantIds")).toEqual(["p1", "p2"]); + expect(matchData.get("mode")).toBe("local"); + expect(matchData.get("groupBudget")).toBe("12.34"); + expect(matchData.get("avoidOwned")).toBe("false"); + expect(container.querySelector('input[name="playerCount"]')).toHaveAttribute("max", "50"); + expect(screen.getByRole("combobox", { name: "Setup" })).toHaveValue("modded"); + + const searchData = new FormData(searchForm!); + expect(searchData.get("setup")).toBe("modded"); + expect(searchData.get("groupMode")).toBe("online"); + expect(searchData.get("search")).toBe("wizards"); + + const groupBuyData = new FormData(groupBuyForm!); + expect(groupBuyData.getAll("selectedParticipantIds")).toEqual(["p1", "p2"]); + expect(groupBuyData.get("search")).toBe("wizards"); + expect(groupBuyData.get("avoidOwned")).toBe("false"); + expect(groupBuyData.get("saleOnly")).toBe("true"); + }); + it("collapses existing games for a new participant", () => { render( { scoredGames={[{ ...baseProps.scoredGames[0], categories: [], - ownership: { have: 1, missing: 0, selected: 1 }, + ownership: { have: 1, dontHave: 0, unknown: 0, missing: 0, selected: 1 }, }]} sessionGames={[sessionGame([{ participantId: "p1", signal: "OWNED" }])]} currentParticipantHasPickSignals @@ -194,6 +247,18 @@ describe("PickPanel", () => { expect(screen.queryByText("Perfect matches")).not.toBeInTheDocument(); }); + it("keeps selections and explains a player-count conflict", () => { + render( + , + ); + expect(screen.getByRole("alert")).toHaveTextContent("cannot be smaller than the 2 selected people"); + }); + it("shows Steam library coverage guidance", () => { render( { sessionGameId: "sg-close", title: "Almost Shared", categories: ["almostReady" as const], - ownership: { have: 1, missing: 1, selected: 2 }, + ownership: { have: 1, dontHave: 0, unknown: 1, missing: 1, selected: 2 }, reasons: ["1/2 selected players have it"], }, ]} @@ -242,6 +307,20 @@ describe("PickPanel", () => { expect(screen.getByRole("button", { name: /Almost ready/ })).toBeInTheDocument(); }); + it("gives a profile-only match an inspect link and shortlist control", () => { + render( + , + ); + + expect(screen.getByRole("link", { name: "Inspect details" })).toHaveAttribute("href", "/games/id/g1"); + expect(screen.getByRole("button", { name: "Add to shortlist" })).toBeInTheDocument(); + }); + it("separates games with uncertain player-count metadata from compatible recommendations", () => { render( sessionGame.id === selectedSessionGameId); + const pickQuery = query ?? { + selectedParticipantIds, + selectionExplicit: true, + playerCount: selectedPlayerCount, + mode: "either", + setup: "native", + scoreMode, + sessionMinutes: selectedSessionMinutes, + commitment: selectedCommitment, + search: searchQuery, + page: 1, + pageSize: 24, + platforms: [], + genres: [], + tags: [], + groupBuy: groupBuyFilters, + } satisfies PickQuery; return (
@@ -223,13 +243,16 @@ export function PickPanel({ shareToken={shareToken} participantId={participantId} participants={participants} - selectedParticipantIds={selectedParticipantIds} - selectedPlayerCount={selectedPlayerCount} - selectedSessionMinutes={selectedSessionMinutes} - selectedCommitment={selectedCommitment} - scoreMode={scoreMode} + selectedParticipantIds={pickQuery.selectedParticipantIds} + selectedPlayerCount={pickQuery.playerCount} + selectedSessionMinutes={pickQuery.sessionMinutes} + selectedCommitment={pickQuery.commitment} + scoreMode={pickQuery.scoreMode} + query={pickQuery} scoredGames={scoredGames} ownershipProfileCount={ownershipProfileCount} + isHost={isHost} + selectedSessionGameId={selectedSessionGameId} />
@@ -259,10 +282,11 @@ export function PickPanel({
@@ -323,7 +347,8 @@ export function PickPanel({
{participantId ? : null} - + + Search @@ -439,8 +464,11 @@ function MatchDashboard({ selectedSessionMinutes, selectedCommitment, scoreMode, + query, scoredGames, ownershipProfileCount, + isHost, + selectedSessionGameId, }: { id?: string; shareToken: string; @@ -451,13 +479,18 @@ function MatchDashboard({ selectedSessionMinutes: number; selectedCommitment: CommitmentFilter; scoreMode: ScoreMode; + query: PickQuery; scoredGames: ScoredGame[]; ownershipProfileCount: number; + isHost: boolean; + selectedSessionGameId?: string | null; }) { const compatibleGames = scoredGames.filter((game) => game.playerCountStatus === "supported"); const uncertainGames = scoredGames.filter((game) => game.playerCountStatus === "uncertain"); const selectedProfileCount = selectedParticipantIds.length; const waitingForPlayers = selectedProfileCount < selectedPlayerCount; + const selectionConflict = selectedProfileCount > selectedPlayerCount; + const emptyExplicitSelection = query.selectionExplicit && selectedProfileCount === 0; const provisional = ownershipProfileCount < 2 || waitingForPlayers; const playersNeeded = Math.max(0, selectedPlayerCount - selectedProfileCount); const filters = [ @@ -470,6 +503,38 @@ function MatchDashboard({ ["Barely played", compatibleGames.filter((game) => isBarelyPlayedGroupPick(game.playtimeMinutes)).length], ] as const; + function renderMatchActions(game: ScoredGame) { + const isShortlisted = !game.sessionGameId.startsWith("profile:"); + + if (!isShortlisted) { + return ( + + + + + + {participantId ? : null} + Add to shortlist + + ); + } + + return ( + <> + On shortlist + {isHost ? ( +
+ + + + {game.sessionGameId === selectedSessionGameId ? "Final choice" : "Choose as final game"} + +
+ ) : null} + + ); + } + return (
@@ -485,14 +550,16 @@ function MatchDashboard({
{participantId ? : null} +
Players +
{participants.map((participant) => (
) : null} + {selectionConflict ? ( +
+ Player count cannot be smaller than the {selectedProfileCount} selected people. Increase it or deselect someone; your choices have been kept. +
+ ) : null} + {emptyExplicitSelection ? ( +
+ Choose at least one person to calculate private group matches. Your other filters have been kept. +
+ ) : null}
{filters.map(([label, count]) => ( @@ -570,6 +655,8 @@ function MatchDashboard({ provisional={provisional} selectedProfiles={selectedProfileCount} requestedPlayers={selectedPlayerCount} + getDetailHref={(game) => `/games/id/${encodeURIComponent(game.gameId)}`} + renderActions={renderMatchActions} /> {uncertainGames.length > 0 ? (
@@ -577,9 +664,14 @@ function MatchDashboard({

These games are not hidden because their player limit is unknown. Add curated metadata or pick a known-capacity game for stronger recommendations.

-
- {uncertainGames.slice(0, 4).map((game) => )} -
+ `/games/id/${encodeURIComponent(game.gameId)}`} + renderActions={renderMatchActions} + />
) : null}
@@ -826,6 +918,7 @@ function GroupBuyPanel({ recommendations, currency, dealLookupConfigured, + query, }: { shareToken: string; participantId?: string; @@ -833,6 +926,7 @@ function GroupBuyPanel({ recommendations: GroupBuyRecommendation[]; currency: string; dealLookupConfigured: boolean; + query: PickQuery; }) { const labels: Record = { bestOverall: "Best overall group buy", @@ -854,6 +948,7 @@ function GroupBuyPanel({ {participantId ? : null} +
@@ -927,6 +1024,17 @@ function GroupBuyPanel({ ); } +function PickQueryFields({ query, exclude = [] }: { query: PickQuery; exclude?: string[] }) { + const excluded = new Set(exclude); + return ( + <> + {[...serializePickQuery(query)].filter(([name]) => !excluded.has(name)).map(([name, value], index) => ( + + ))} + + ); +} + function PreferencePanel({ shareToken, participantId, diff --git a/src/components/player-count-filter.test.tsx b/src/components/player-count-filter.test.tsx index 2fcc6db..e80f023 100644 --- a/src/components/player-count-filter.test.tsx +++ b/src/components/player-count-filter.test.tsx @@ -19,5 +19,7 @@ describe("PlayerCountFilter", () => { expect(parseMinimumPlayers("0")).toBe(1); expect(parseMinimumPlayers("17")).toBe(17); expect(parseMinimumPlayers("99")).toBe(50); + expect(parseMinimumPlayers("2.5")).toBe(5); + expect(parseMinimumPlayers("Infinity")).toBe(5); }); }); diff --git a/src/components/post-import-status.test.tsx b/src/components/post-import-status.test.tsx new file mode 100644 index 0000000..408cac3 --- /dev/null +++ b/src/components/post-import-status.test.tsx @@ -0,0 +1,49 @@ +import { StrictMode } from "react"; +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PostImportStatus } from "@/components/post-import-status"; + +const router = vi.hoisted(() => ({ refresh: vi.fn(), replace: vi.fn() })); + +vi.mock("next/navigation", () => ({ useRouter: () => router })); + +describe("PostImportStatus", () => { + beforeEach(() => { + vi.useFakeTimers(); + router.refresh.mockReset(); + router.replace.mockReset(); + window.history.replaceState({}, "", "/s/share?tab=pick&imported=2"); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps its one-shot refresh functional in Strict Mode and cleans it up", () => { + render( + + + , + ); + + act(() => { + vi.advanceTimersByTime(1200); + }); + expect(router.refresh).toHaveBeenCalledTimes(1); + expect(screen.getByText(/Recommendations are ready below/)).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(router.replace).toHaveBeenCalledWith("/s/share?tab=pick", { scroll: false }); + }); + + it("does not leave refresh timers behind after unmount", () => { + const view = render(); + view.unmount(); + + vi.runAllTimers(); + expect(router.refresh).not.toHaveBeenCalled(); + expect(router.replace).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/post-import-status.tsx b/src/components/post-import-status.tsx index faca873..4e8e863 100644 --- a/src/components/post-import-status.tsx +++ b/src/components/post-import-status.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { CheckCircle2 } from "lucide-react"; @@ -13,18 +13,16 @@ import { CheckCircle2 } from "lucide-react"; export function PostImportStatus({ importedCount }: { importedCount: number }) { const router = useRouter(); const [phase, setPhase] = useState<"calculating" | "ready">("calculating"); - const started = useRef(false); useEffect(() => { - if (started.current) { - return; - } - started.current = true; + let active = true; // Pull fresh server data, then clear the flag from the URL once scores show. const refreshTimer = setTimeout(() => { router.refresh(); - setPhase("ready"); + if (active) { + setPhase("ready"); + } }, 1200); const cleanupTimer = setTimeout(() => { @@ -34,6 +32,7 @@ export function PostImportStatus({ importedCount }: { importedCount: number }) { }, 4200); return () => { + active = false; clearTimeout(refreshTimer); clearTimeout(cleanupTimer); }; diff --git a/src/components/ranked-match-list.test.tsx b/src/components/ranked-match-list.test.tsx new file mode 100644 index 0000000..7bc3675 --- /dev/null +++ b/src/components/ranked-match-list.test.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { RankedMatchList } from "@/components/ranked-match-list"; +import type { ScoredGame } from "@/lib/match-scoring"; + +describe("RankedMatchList", () => { + it("keeps the full score and ownership evidence available while revealing later matches", () => { + const games = Array.from({ length: 17 }, (_, index) => scoredGame(index + 1)); + + render( + `/games/${game.gameId}`} + renderActions={(game) => } + />, + ); + + expect(screen.getByText("Game 16")).toBeInTheDocument(); + expect(screen.queryByText("Game 17")).not.toBeInTheDocument(); + expect(screen.getAllByLabelText("Ownership breakdown")[0]).toHaveTextContent("1 have, 0 don't have, 1 unknown, 1 missing profile"); + expect(screen.getAllByText("Historical low")).toHaveLength(16); + expect(screen.getAllByRole("link", { name: "Inspect details" })[0]).toHaveAttribute("href", "/games/g1"); + expect(screen.getByRole("button", { name: "Shortlist Game 1" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Show 1 more matches" })); + + expect(screen.getByText("Game 17")).toBeInTheDocument(); + }); +}); + +function scoredGame(number: number): ScoredGame { + return { + sessionGameId: `sg${number}`, + gameId: `g${number}`, + title: `Game ${number}`, + score: 90, + alignment: "High", + reasons: ["A complete explanation", "No hidden reason"], + alignmentReasons: ["No selected player has a veto or strong mismatch"], + categories: ["perfect"], + factors: { + ownership: 100, playerCount: 95, genreFit: 80, availability: 100, onlineCoop: 90, localCoop: 90, playtime: 60, freshness: 80, + interest: 60, price: 35, historicalLow: 40, popularity: 80, multiplayerFit: 95, durationFit: 90, personalRating: 85, + }, + factorBreakdown: [ + { key: "ownership", label: "Ownership", value: 100, weight: 0.2, points: 20 }, + { key: "historicalLow", label: "Historical low", value: 40, weight: 0.1, points: 4 }, + { key: "personalRating", label: "Personal rating", value: 85, weight: 0.1, points: 8.5 }, + ], + ownership: { have: 1, dontHave: 0, unknown: 1, missing: 1, selected: 2 }, + playtimeMinutes: 40, + discountPercent: 0, + currentPrice: null, + historicalLow: null, + playerCountStatus: "supported", + }; +} diff --git a/src/components/ranked-match-list.tsx b/src/components/ranked-match-list.tsx index 45aaab3..39d618e 100644 --- a/src/components/ranked-match-list.tsx +++ b/src/components/ranked-match-list.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import type { MatchCategory, ScoredGame } from "@/lib/match-scoring"; const filters: Array<{ value: "all" | MatchCategory; label: string }> = [ @@ -12,22 +12,40 @@ const filters: Array<{ value: "all" | MatchCategory; label: string }> = [ { value: "saleOpportunity", label: "On sale" }, ]; +const pageSize = 16; + +const factorOrder = [ + "ownership", "playerCount", "genreFit", "availability", "onlineCoop", "localCoop", "playtime", "freshness", + "interest", "price", "historicalLow", "popularity", "multiplayerFit", "durationFit", "personalRating", +]; + export function RankedMatchList({ games, provisional, selectedProfiles, requestedPlayers, + getDetailHref, + renderActions, }: { games: ScoredGame[]; provisional: boolean; selectedProfiles: number; requestedPlayers: number; + getDetailHref?: (game: ScoredGame) => string | undefined; + renderActions?: (game: ScoredGame) => ReactNode; }) { const [filter, setFilter] = useState<(typeof filters)[number]["value"]>("all"); - const visibleGames = useMemo( - () => games.filter((game) => filter === "all" || game.categories.includes(filter)).slice(0, 16), + const [visibleCount, setVisibleCount] = useState(pageSize); + const filteredGames = useMemo( + () => games.filter((game) => filter === "all" || game.categories.includes(filter)), [filter, games], ); + const visibleGames = filteredGames.slice(0, visibleCount); + + function selectFilter(value: (typeof filters)[number]["value"]) { + setFilter(value); + setVisibleCount(pageSize); + } return (
@@ -40,7 +58,7 @@ export function RankedMatchList({
    - {game.reasons.slice(0, 2).map((reason) =>
  • {reason}
  • )} + {game.reasons.map((reason) =>
  • {reason}
  • )}
+
+ Ownership: {game.ownership.have} have, {game.ownership.dontHave} don't have, {game.ownership.unknown} unknown, {game.ownership.missing} missing profile{game.ownership.missing === 1 ? "" : "s"}. +
+ {game.alignmentReasons.length > 0 ? ( +
+

Alignment notes

+
    + {game.alignmentReasons.map((reason) =>
  • {reason}
  • )} +
+
+ ) : null}
How this score was calculated
- {game.factorBreakdown.slice(0, 8).map((factor) => ( + {[...game.factorBreakdown].sort((a, b) => factorIndex(a.key) - factorIndex(b.key)).map((factor) => (
{factor.label} @@ -95,14 +124,32 @@ export function RankedMatchList({ ))}
+ {(getDetailHref?.(game) || renderActions) ? ( +
+ {getDetailHref?.(game) ? Inspect details : null} + {renderActions?.(game)} +
+ ) : null} ))} {visibleGames.length === 0 ?

No games match this filter yet.

: null}
+ {visibleGames.length < filteredGames.length ? ( +
+ +
+ ) : null}
); } +function factorIndex(key: string) { + const index = factorOrder.indexOf(key); + return index === -1 ? factorOrder.length : index; +} + function categoryLabel(category: MatchCategory) { return { perfect: "Perfect", diff --git a/src/components/recent-session-action.tsx b/src/components/recent-session-action.tsx index 66b0035..c60720e 100644 --- a/src/components/recent-session-action.tsx +++ b/src/components/recent-session-action.tsx @@ -25,7 +25,7 @@ export function RecentSessionAction({ type="button" onClick={() => setOpen(true)} className="focus-ring grid h-8 w-8 place-items-center rounded-md text-ink/45 hover:bg-red-50 hover:text-red-800" - aria-label={`${isHost ? "Delete" : "Remove"} ${title}`} + aria-label={`${isHost ? "Delete" : "Hide"} ${title}`} > @@ -36,12 +36,12 @@ export function RecentSessionAction({

- {isHost ? "Delete this session?" : "Remove from recent sessions?"} + {isHost ? "Delete this session?" : "Hide from recent sessions?"}

{isHost ? "This permanently deletes the shared session for everyone." - : "The shared session remains available, but it will no longer be linked to your account."} + : "This only hides the item from your account history. Your membership and matching permissions stay unchanged."}

- {isHost ? "Delete session" : "Remove from recents"} + {isHost ? "Delete session" : "Hide from recents"} diff --git a/src/lib/account-merge.ts b/src/lib/account-merge.ts index 2307708..611931c 100644 --- a/src/lib/account-merge.ts +++ b/src/lib/account-merge.ts @@ -22,20 +22,28 @@ export async function createAccountMergeIntent( } export async function mergeAccounts(currentUserId: string, token: string) { - const intent = await prisma.accountMergeIntent.findFirst({ - where: { - tokenHash: hashSessionToken(token), - currentUserId, - expiresAt: { gt: new Date() }, - confirmedAt: null, - }, - }); - - if (!intent || intent.currentUserId === intent.otherUserId) { - throw new Error("This merge request is invalid or has expired."); - } - + const tokenHash = hashSessionToken(token); await prisma.$transaction(async (transaction) => { + const now = new Date(); + const intent = await transaction.accountMergeIntent.findFirst({ + where: { + tokenHash, + currentUserId, + expiresAt: { gt: now }, + confirmedAt: null, + }, + }); + if (!intent || intent.currentUserId === intent.otherUserId) { + throw new Error("This merge request is invalid or has expired."); + } + const consumed = await transaction.accountMergeIntent.updateMany({ + where: { id: intent.id, confirmedAt: null, expiresAt: { gt: now } }, + data: { confirmedAt: now }, + }); + if (consumed.count !== 1) { + throw new Error("This merge request has already been used."); + } + const [current, other, currentGames, otherGames] = await Promise.all([ transaction.user.findUniqueOrThrow({ where: { id: currentUserId }, @@ -142,21 +150,18 @@ export async function mergeAccounts(currentUserId: string, token: string) { where: { inviterId: intent.otherUserId }, data: { inviterId: currentUserId }, }); + const retainedEmail = current.email ?? other.email; await transaction.user.update({ where: { id: currentUserId }, data: { - email: current.email ?? other.email, - emailVerified: current.emailVerified || other.emailVerified, + email: retainedEmail, + emailVerified: retainedEmail === current.email ? current.emailVerified : other.emailVerified, avatarUrl: current.avatarUrl ?? rewriteAvatarUrl(other.avatarUrl, intent.otherUserId, currentUserId), timezone: current.timezone ?? other.timezone, role: current.role === "METADATA_ADMIN" || other.role === "METADATA_ADMIN" ? "METADATA_ADMIN" : "USER", favouriteGenres: mergeJsonStringLists(current.favouriteGenres, other.favouriteGenres), }, }); - await transaction.accountMergeIntent.update({ - where: { id: intent.id }, - data: { confirmedAt: new Date() }, - }); await transaction.user.delete({ where: { id: intent.otherUserId } }); }, { timeout: 20_000 }); } @@ -170,32 +175,40 @@ async function mergeParticipants(transaction: Prisma.TransactionClient, currentU for (const participant of otherParticipants) { const existing = await transaction.participant.findUnique({ where: { sessionId_userId: { sessionId: participant.sessionId, userId: currentUserId } }, - include: { preference: true }, + include: { responses: true, gameSignals: true, gameInterests: true, preference: true }, }); if (!existing) { await transaction.participant.update({ where: { id: participant.id }, data: { userId: currentUserId } }); continue; } + const existingResponses = new Map(existing.responses.map((response) => [response.slotStart.toISOString(), response])); for (const response of participant.responses) { + const retained = existingResponses.get(response.slotStart.toISOString()); await transaction.availabilityResponse.upsert({ where: { participantId_slotStart: { participantId: existing.id, slotStart: response.slotStart } }, create: { participantId: existing.id, slotStart: response.slotStart, slotEnd: response.slotEnd, status: response.status }, - update: { slotEnd: response.slotEnd, status: response.status }, + update: response.updatedAt > (retained?.updatedAt ?? new Date(0)) + ? { slotEnd: response.slotEnd, status: response.status } + : {}, }); } + const existingSignals = new Map(existing.gameSignals.map((signal) => [signal.sessionGameId, signal])); for (const signal of participant.gameSignals) { + const retained = existingSignals.get(signal.sessionGameId); await transaction.sessionGameSignal.upsert({ where: { sessionGameId_participantId: { sessionGameId: signal.sessionGameId, participantId: existing.id } }, create: { sessionGameId: signal.sessionGameId, participantId: existing.id, signal: signal.signal }, - update: { signal: signal.signal }, + update: signal.updatedAt > (retained?.updatedAt ?? new Date(0)) ? { signal: signal.signal } : {}, }); } + const existingInterests = new Map(existing.gameInterests.map((interest) => [interest.sessionGameId, interest])); for (const interest of participant.gameInterests) { + const retained = existingInterests.get(interest.sessionGameId); await transaction.sessionGameInterest.upsert({ where: { sessionGameId_participantId: { sessionGameId: interest.sessionGameId, participantId: existing.id } }, create: { sessionGameId: interest.sessionGameId, participantId: existing.id, interest: interest.interest }, - update: { interest: interest.interest }, + update: interest.updatedAt > (retained?.updatedAt ?? new Date(0)) ? { interest: interest.interest } : {}, }); } if (!existing.preference && participant.preference) { @@ -206,6 +219,14 @@ async function mergeParticipants(transaction: Prisma.TransactionClient, currentU } await transaction.sessionGame.updateMany({ where: { addedByParticipantId: participant.id }, data: { addedByParticipantId: existing.id } }); await transaction.discordAttendance.updateMany({ where: { participantId: participant.id }, data: { participantId: existing.id } }); + await transaction.participant.update({ + where: { id: existing.id }, + data: { + isHost: existing.isHost || participant.isHost, + availabilityRevision: Math.max(existing.availabilityRevision, participant.availabilityRevision), + historyVisible: existing.historyVisible || participant.historyVisible, + }, + }); await transaction.participant.delete({ where: { id: participant.id } }); } } @@ -226,6 +247,20 @@ async function mergeBlocks(transaction: Prisma.TransactionClient, currentUserId: } } await transaction.userBlock.deleteMany({ where: { OR: [{ blockerId: otherUserId }, { blockedId: otherUserId }] } }); + const currentBlocks = await transaction.userBlock.findMany({ + where: { OR: [{ blockerId: currentUserId }, { blockedId: currentUserId }] }, + }); + for (const block of currentBlocks) { + const counterpartId = block.blockerId === currentUserId ? block.blockedId : block.blockerId; + await transaction.userFriend.deleteMany({ + where: { + OR: [ + { userId: currentUserId, friendId: counterpartId }, + { userId: counterpartId, friendId: currentUserId }, + ], + }, + }); + } } function rewriteAvatarUrl(value: string | null, otherUserId: string, currentUserId: string) { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 9b0b5e4..514e10a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -152,7 +152,11 @@ export async function setParticipantIdentity( }; const identities = readParticipantIdentities(cookieStore.get(participantIdentityCookie)?.value); - identities[sessionId] = { participantId, isHost: Boolean(options.isHost), seenAt: Date.now() }; + const previous = identities[sessionId]; + const isHost = options.isHost ?? ( + previous?.participantId === participantId ? previous.isHost : false + ); + identities[sessionId] = { participantId, isHost, seenAt: Date.now() }; const compact = Object.fromEntries( Object.entries(identities).sort((a, b) => b[1].seenAt - a[1].seenAt).slice(0, 20), ); @@ -206,33 +210,33 @@ function readParticipantIdentities(signed?: string): ParticipantIdentityMap { // one. Without a participant cookie, only the signed-in user's membership is // accepted. export async function resolveActingParticipantId(sessionId: string, suppliedId?: string | null) { - const cookieId = await getParticipantId(sessionId); + const currentUser = await getCurrentUser(); + if (currentUser) { + const membership = await prisma.participant.findUnique({ + where: { sessionId_userId: { sessionId, userId: currentUser.id } }, + select: { id: true }, + }); - if (cookieId) { - if (suppliedId && suppliedId !== cookieId) { + if (!membership || (suppliedId && suppliedId !== membership.id)) { return null; } - const participant = await prisma.participant.findFirst({ - where: { id: cookieId, sessionId }, - select: { id: true }, - }); - - return participant?.id ?? null; + return membership.id; } - const currentUser = await getCurrentUser(); - - if (!currentUser) { + const cookieId = await getParticipantId(sessionId); + if (!cookieId || (suppliedId && suppliedId !== cookieId)) { return null; } - const participant = await prisma.participant.findUnique({ - where: { sessionId_userId: { sessionId, userId: currentUser.id } }, - select: { id: true }, + const guest = await prisma.participant.findFirst({ + where: { id: cookieId, sessionId }, + select: { id: true, userId: true }, }); - return participant?.id ?? null; + // A participant linked to an account is never an anonymous browser + // capability. This keeps account switching/logout from retaining authority. + return guest && !guest.userId ? guest.id : null; } export type OAuthState = { @@ -341,9 +345,23 @@ export function parseOAuthState(signedState: string | null) { } export function safeInternalRedirect(value?: string | null) { - if (!value || !value.startsWith("/") || value.startsWith("//")) { + if ( + !value || + !value.startsWith("/") || + value.startsWith("//") || + /[\\\u0000-\u001f\u007f]/.test(value) + ) { return "/"; } - return value; + try { + const base = new URL("https://internal.invalid"); + const destination = new URL(value, base); + if (destination.origin !== base.origin) { + return "/"; + } + return `${destination.pathname}${destination.search}${destination.hash}`; + } catch { + return "/"; + } } diff --git a/src/lib/discord-handoff.test.ts b/src/lib/discord-handoff.test.ts new file mode 100644 index 0000000..3966b39 --- /dev/null +++ b/src/lib/discord-handoff.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { redeemDiscordHostHandoff } from "@/lib/discord-handoff"; + +const mocks = vi.hoisted(() => ({ + findUnique: vi.fn(), + updateMany: vi.fn(), + setParticipantIdentity: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + hashSessionToken: (token: string) => `hash:${token}`, + setParticipantIdentity: mocks.setParticipantIdentity, +})); +vi.mock("@/lib/prisma", () => ({ + prisma: { + $transaction: async (work: (tx: unknown) => unknown) => work({ + discordHostHandoff: { findUnique: mocks.findUnique, updateMany: mocks.updateMany }, + }), + }, +})); + +describe("Discord host handoff", () => { + beforeEach(() => vi.clearAllMocks()); + + it("atomically consumes a valid handoff before granting the host cookie", async () => { + mocks.findUnique.mockResolvedValue({ + id: "handoff-1", + discordUserId: "discord-1", + sessionId: "session-1", + expiresAt: new Date(Date.now() + 60_000), + redeemedAt: null, + session: { id: "session-1", shareToken: "share" }, + participant: { id: "host-1", sessionId: "session-1", isHost: true, discordUserId: "discord-1" }, + }); + mocks.updateMany.mockResolvedValue({ count: 1 }); + + await expect(redeemDiscordHostHandoff("secret")).resolves.toBe("/s/share?participant=host-1"); + expect(mocks.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ id: "handoff-1", redeemedAt: null }), + })); + expect(mocks.setParticipantIdentity).toHaveBeenCalledWith("session-1", "host-1", { isHost: true }); + }); + + it("does not grant identity when the handoff has expired or lost its host binding", async () => { + mocks.findUnique.mockResolvedValue({ + id: "handoff-1", + discordUserId: "discord-1", + sessionId: "session-1", + expiresAt: new Date(0), + redeemedAt: null, + session: { id: "session-1", shareToken: "share" }, + participant: { id: "host-1", sessionId: "session-1", isHost: true, discordUserId: "discord-1" }, + }); + + await expect(redeemDiscordHostHandoff("expired")).resolves.toBeNull(); + expect(mocks.updateMany).not.toHaveBeenCalled(); + expect(mocks.setParticipantIdentity).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/discord-handoff.ts b/src/lib/discord-handoff.ts new file mode 100644 index 0000000..834ce92 --- /dev/null +++ b/src/lib/discord-handoff.ts @@ -0,0 +1,62 @@ +import { randomBytes } from "node:crypto"; +import type { Prisma } from "@prisma/client"; +import { hashSessionToken, setParticipantIdentity } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +const HANDOFF_LIFETIME_MS = 10 * 60 * 1000; + +export async function createDiscordHostHandoff({ + discordUserId, + sessionId, + participantId, + client = prisma, +}: { + discordUserId: string; + sessionId: string; + participantId: string; + client?: Pick; +}) { + const token = randomBytes(32).toString("base64url"); + await client.discordHostHandoff.create({ + data: { + tokenHash: hashSessionToken(token), + discordUserId, + sessionId, + participantId, + expiresAt: new Date(Date.now() + HANDOFF_LIFETIME_MS), + }, + }); + return token; +} + +export async function redeemDiscordHostHandoff(token: string) { + const tokenHash = hashSessionToken(token); + const redeemed = await prisma.$transaction(async (tx) => { + const handoff = await tx.discordHostHandoff.findUnique({ + where: { tokenHash }, + include: { + session: { select: { id: true, shareToken: true } }, + participant: { select: { id: true, sessionId: true, isHost: true, discordUserId: true } }, + }, + }); + if ( + !handoff || + handoff.redeemedAt || + handoff.expiresAt <= new Date() || + handoff.participant.sessionId !== handoff.sessionId || + !handoff.participant.isHost || + handoff.participant.discordUserId !== handoff.discordUserId + ) { + return null; + } + const consumed = await tx.discordHostHandoff.updateMany({ + where: { id: handoff.id, redeemedAt: null, expiresAt: { gt: new Date() } }, + data: { redeemedAt: new Date() }, + }); + return consumed.count === 1 ? handoff : null; + }); + if (!redeemed) return null; + + await setParticipantIdentity(redeemed.session.id, redeemed.participant.id, { isHost: true }); + return `/s/${redeemed.session.shareToken}?participant=${redeemed.participant.id}`; +} diff --git a/src/lib/discord.test.ts b/src/lib/discord.test.ts index 281fba2..899c625 100644 --- a/src/lib/discord.test.ts +++ b/src/lib/discord.test.ts @@ -1,6 +1,18 @@ import nacl from "tweetnacl"; -import { describe, expect, it } from "vitest"; -import { discordCommandPayload, normalizeReminderPreferences, reminderDueAt, verifyDiscordRequest } from "@/lib/discord"; +import { describe, expect, it, vi } from "vitest"; +import { discordCommandPayload, handleDiscordInteraction, normalizeReminderPreferences, reminderDueAt, verifyDiscordRequest } from "@/lib/discord"; + +const createSession = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/prisma", () => ({ + prisma: { + $transaction: async (work: (tx: unknown) => unknown) => work({ + session: { create: createSession }, + discordHostHandoff: { create: vi.fn() }, + }), + }, +})); +vi.mock("@/lib/app-url", () => ({ getAppUrl: async () => "https://games.example" })); +vi.mock("@/lib/discord-handoff", () => ({ createDiscordHostHandoff: async () => "creator-secret" })); describe("discord helpers", () => { it("verifies valid Discord request signatures and rejects invalid ones", () => { @@ -45,6 +57,30 @@ describe("discord helpers", () => { expect(discordCommandPayload.name).toBe("letsplay"); expect(discordCommandPayload.options.map((option) => option.name)).toEqual(["create", "status", "remind", "games"]); }); + + it("returns a Discord-created host handoff only in an ephemeral creator response", async () => { + createSession.mockResolvedValue({ + id: "session-1", + shareToken: "public-session", + title: "Friday games", + requiredDuration: 2, + minimumPlayerCount: 4, + participants: [{ id: "host-1", isHost: true, discordUserId: "discord-1" }], + discordIntegrations: [], + }); + + const response = await handleDiscordInteraction({ + id: "interaction-1", + type: 2, + user: { id: "discord-1", username: "host" }, + data: { name: "letsplay", options: [{ name: "create", type: 1, options: [] }] }, + }); + const payload = await response.json() as { data: { flags?: number; content?: string } }; + + expect(payload.data.flags).toBe(64); + expect(payload.data.content).toContain("/s/public-session"); + expect(payload.data.content).toContain("/discord/handoff/creator-secret"); + }); }); function toHex(bytes: Uint8Array) { diff --git a/src/lib/discord.ts b/src/lib/discord.ts index 29636c7..af9b4c0 100644 --- a/src/lib/discord.ts +++ b/src/lib/discord.ts @@ -3,6 +3,7 @@ import { fromZonedTime } from "date-fns-tz"; import { fetchWithTimeout } from "@/lib/fetch-with-timeout"; import { getAppUrl } from "@/lib/app-url"; import { prisma } from "@/lib/prisma"; +import { createDiscordHostHandoff } from "@/lib/discord-handoff"; import { dateRangeFromPreset, formatSlotRange, rankBestTimes, responseMap, type DatePreset } from "@/lib/scheduling"; import { createShareToken } from "@/lib/tokens"; @@ -147,7 +148,10 @@ export async function handleDiscordInteraction(interaction: DiscordInteraction) if (subcommand.name === "create") { const result = await createDiscordSessionFromInteraction(interaction, subcommand.options ?? []); - return discordJson({ type: responseTypes.channelMessageWithSource, data: sessionDiscordMessage(result) }); + return discordJson({ + type: responseTypes.channelMessageWithSource, + data: { ...sessionDiscordMessage(result), flags: 64 }, + }); } const integration = await latestDiscordIntegration(interaction); @@ -183,8 +187,9 @@ export async function createDiscordSessionFromInteraction(interaction: DiscordIn const reminder = stringOption(options, "reminder") ?? "24 hours before"; const dateRange = dateRangeFromPreset(datePreset, timezone); - const session = await prisma.session.create({ - data: { + const { session, handoffToken } = await prisma.$transaction(async (tx) => { + const createdSession = await tx.session.create({ + data: { title, shareToken: createShareToken(), mode, @@ -222,8 +227,18 @@ export async function createDiscordSessionFromInteraction(interaction: DiscordIn }, } : undefined, - }, - include: { participants: true, discordIntegrations: true }, + }, + include: { participants: true, discordIntegrations: true }, + }); + const createdHandoffToken = discordUser?.id && createdSession.participants[0] + ? await createDiscordHostHandoff({ + discordUserId: discordUser.id, + sessionId: createdSession.id, + participantId: createdSession.participants[0].id, + client: tx, + }) + : null; + return { session: createdSession, handoffToken: createdHandoffToken }; }); return { @@ -231,6 +246,7 @@ export async function createDiscordSessionFromInteraction(interaction: DiscordIn host: session.participants[0], integration: session.discordIntegrations[0] ?? null, appUrl: await getAppUrl(), + handoffToken, }; } @@ -572,12 +588,15 @@ export async function logDiscordNotification(input: { function sessionDiscordMessage({ session, appUrl, + handoffToken, }: Awaited>) { const planUrl = `${appUrl}/s/${session.shareToken}`; const pickUrl = `${appUrl}/s/${session.shareToken}?tab=pick`; return { - content: `**${session.title}** is ready. Fill in your availability here:\n${planUrl}`, + content: `**${session.title}** is ready. Fill in your availability here:\n${planUrl}${ + handoffToken ? `\n\nCreator host access (single use, expires in 10 minutes):\n${appUrl}/discord/handoff/${handoffToken}` : "" + }`, embeds: [ { title: "Current best time", diff --git a/src/lib/games.test.ts b/src/lib/games.test.ts index 56a5c1d..3ce6e9a 100644 --- a/src/lib/games.test.ts +++ b/src/lib/games.test.ts @@ -28,8 +28,8 @@ describe("game helpers", () => { expect(countHaveSignals({ signals: [{ signal: "AVAILABLE_TO_PLAY" }, { signal: "OWNED" }] })).toBe(2); }); - it("defaults added games to owned", () => { - expect(defaultAddedGameSignal).toBe("OWNED"); + it("does not infer ownership from shortlisting", () => { + expect(defaultAddedGameSignal).toBeUndefined(); }); it("excludes games already present in the session", () => { diff --git a/src/lib/games.ts b/src/lib/games.ts index 2ea9312..c13f2a5 100644 --- a/src/lib/games.ts +++ b/src/lib/games.ts @@ -34,7 +34,7 @@ export type GameInput = { qualitySource?: string | null; }; -export const defaultAddedGameSignal = "OWNED"; +export const defaultAddedGameSignal = undefined; export const commonMultiplayerGames: GameInput[] = [ { title: "Minecraft", platforms: ["PC", "Xbox", "PlayStation", "Switch", "Mobile"], crossplay: true, crossplayPlatforms: ["PC", "Xbox", "PlayStation", "Nintendo Switch", "Mobile"], gameModes: ["Multiplayer", "Co-op"], minPlayers: 1, maxPlayers: 8, onlineCoop: true, localCoop: true, capabilitySource: "curated", capabilityConfidence: 0.8 }, @@ -243,7 +243,7 @@ export async function addGameToSession({ }); } - if (participantId) { + if (participantId && signal) { await prisma.sessionGameSignal.upsert({ where: { sessionGameId_participantId: { sessionGameId: sessionGame.id, participantId } }, create: { @@ -264,7 +264,7 @@ export async function addGamesToSessionBulk({ participantId, userId, source, - signal = defaultAddedGameSignal, + signal = "OWNED", }: { sessionId: string; gameIds: string[]; @@ -498,11 +498,11 @@ function gameInputToData(input: GameInput, mode: "create" | "update") { qualitySource: input.qualitySource ?? absent, qualityFetchedAt: input.qualitySource ? new Date() : absent, minPlayers: input.minPlayers ?? absent, - maxPlayers: input.maxPlayers ?? inferMaxPlayers(input) ?? absent, - onlineCoop: input.onlineCoop ?? inferOnlineCoop(input) ?? absent, - localCoop: input.localCoop ?? inferLocalCoop(input) ?? absent, - onlineMultiplayer: input.onlineMultiplayer ?? inferOnlineMultiplayer(input) ?? absent, - localMultiplayer: input.localMultiplayer ?? inferLocalMultiplayer(input) ?? absent, + maxPlayers: input.maxPlayers === undefined ? inferMaxPlayers(input) ?? absent : input.maxPlayers, + onlineCoop: input.onlineCoop === undefined ? inferOnlineCoop(input) ?? absent : input.onlineCoop, + localCoop: input.localCoop === undefined ? inferLocalCoop(input) ?? absent : input.localCoop, + onlineMultiplayer: input.onlineMultiplayer === undefined ? inferOnlineMultiplayer(input) ?? absent : input.onlineMultiplayer, + localMultiplayer: input.localMultiplayer === undefined ? inferLocalMultiplayer(input) ?? absent : input.localMultiplayer, campaignCoop: input.campaignCoop ?? absent, minimumSessionMinutes: input.minimumSessionMinutes ?? absent, commitmentTier: input.commitmentTier ?? absent, @@ -513,36 +513,28 @@ function gameInputToData(input: GameInput, mode: "create" | "update") { function inferOnlineCoop(input: GameInput) { const modes = (input.gameModes ?? []).join(" ").toLocaleLowerCase(); - return modes.includes("co-op") || modes.includes("cooperative") || modes.includes("multiplayer") || modes.includes("massively"); + return modes.includes("co-op") || modes.includes("cooperative") ? true : undefined; } function inferLocalCoop(input: GameInput) { const modes = (input.gameModes ?? []).join(" ").toLocaleLowerCase(); - return modes.includes("split screen") || modes.includes("local"); + return modes.includes("split screen") || modes.includes("local") ? true : undefined; } function inferOnlineMultiplayer(input: GameInput) { const modes = (input.gameModes ?? []).join(" ").toLocaleLowerCase(); - return modes.includes("multiplayer") || modes.includes("co-op") || modes.includes("massively"); + return modes.includes("multiplayer") || modes.includes("co-op") || modes.includes("massively") ? true : undefined; } function inferLocalMultiplayer(input: GameInput) { const modes = (input.gameModes ?? []).join(" ").toLocaleLowerCase(); - return modes.includes("split screen") || modes.includes("local"); + return modes.includes("split screen") || modes.includes("local") ? true : undefined; } function inferMaxPlayers(input: GameInput) { const modes = (input.gameModes ?? []).join(" ").toLocaleLowerCase(); - if (modes.includes("massively")) { - return 8; - } - - if (modes.includes("multiplayer") || modes.includes("co-op")) { - return 4; - } - - return null; + return modes.length > 0 ? undefined : null; } function inferCapabilitySource(input: GameInput) { diff --git a/src/lib/group-buy.ts b/src/lib/group-buy.ts index e3156fd..7553376 100644 --- a/src/lib/group-buy.ts +++ b/src/lib/group-buy.ts @@ -33,17 +33,22 @@ export function scoreGroupBuyCandidates({ deals: Map>; }) { const owned = new Set(ownedTitles.map(normalizeGameTitle)); + const requiredGenreTokens = facetTokens(filters.genre); const candidates = curatedGames .filter((game) => !filters.avoidOwned || !owned.has(normalizeGameTitle(game.title))) .filter((game) => playerCountFits(game, filters.playerCount)) .filter((game) => modeFits(game, filters.mode)) .filter((game) => platformFits(game, filters.platform)) .filter((game) => filters.sessionLength === "any" || game.sessionLength === filters.sessionLength) - .filter((game) => filters.genre.trim().length === 0 || game.tags.some((tag) => tag.includes(filters.genre.toLocaleLowerCase()))) + .filter((game) => { + if (requiredGenreTokens.length === 0) return true; + const gameTokens = new Set(game.tags.flatMap(facetTokens)); + return requiredGenreTokens.every((token) => gameTokens.has(token)); + }) .map((game) => { const deal = deals.get(game.title); const price = deal?.currentPrice ?? null; - const withinBudget = !price || price <= filters.budget; + const withinBudget = price !== null && price <= filters.budget; const onSale = (deal?.discountPercent ?? 0) > 0; const reasons = [ `${filters.playerCount}-player ${filters.mode === "either" ? "group" : filters.mode} fit`, @@ -55,7 +60,9 @@ export function scoreGroupBuyCandidates({ reasons.push(`${deal?.discountPercent}% off right now`); } - if (!withinBudget) { + if (price === null) { + reasons.push("Price unavailable; budget fit is unverified"); + } else if (!withinBudget) { reasons.push("Over the selected budget"); } @@ -75,6 +82,7 @@ export function scoreGroupBuyCandidates({ reasons, }; }) + .filter((candidate) => candidate.price === null || candidate.price <= filters.budget) .filter((candidate) => !filters.saleOnly || (candidate.discountPercent ?? 0) > 0) .sort((a, b) => b.baseScore - a.baseScore); @@ -97,8 +105,12 @@ export function scoreGroupBuyCandidates({ add("bestOverall", candidates[0]); add("cheapest", [...candidates].filter((candidate) => candidate.price !== null).sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity))[0]); - add("longTerm", candidates.find((candidate) => candidate.game.sessionLength === "long-term" || candidate.game.sessionLength === "campaign")); - add("oneNight", candidates.find((candidate) => candidate.game.sessionLength === "one-night")); + if (filters.sessionLength === "any" || filters.sessionLength === "long-term" || filters.sessionLength === "campaign") { + add("longTerm", candidates.find((candidate) => candidate.game.sessionLength === "long-term" || candidate.game.sessionLength === "campaign")); + } + if (filters.sessionLength === "any" || filters.sessionLength === "one-night") { + add("oneNight", candidates.find((candidate) => candidate.game.sessionLength === "one-night")); + } add("trending", candidates.find((candidate) => candidate.game.trending)); return recommendations; @@ -121,6 +133,15 @@ function playerCountFits(game: CuratedGame, playerCount: number) { return (game.minPlayers ?? 1) <= playerCount && (game.maxPlayers ?? playerCount) >= playerCount; } +function facetTokens(value: string) { + return value + .toLocaleLowerCase() + .replaceAll("co-op", "coop") + .split(/[\s,;/|]+/) + .map((token) => token.replace(/[^a-z0-9+#-]/g, "")) + .filter(Boolean); +} + function modeFits(game: CuratedGame, mode: GroupBuyFilters["mode"]) { if (mode === "online") { return game.onlineCoop === true; diff --git a/src/lib/match-scoring.test.ts b/src/lib/match-scoring.test.ts index c91ee46..494bc47 100644 --- a/src/lib/match-scoring.test.ts +++ b/src/lib/match-scoring.test.ts @@ -59,8 +59,12 @@ describe("match scoring", () => { selectedParticipantIds: ["p1", "p2"], playerCount: 2, userGames: [ - { userId: "u1", gameId: "g-old", playtimeMinutes: 900 }, - { userId: "u2", gameId: "g-old", playtimeMinutes: 900 }, + { userId: "u1", gameId: "g-backlog", ownership: "HAVE", playtimeMinutes: 0 }, + { userId: "u2", gameId: "g-backlog", ownership: "HAVE", playtimeMinutes: 0 }, + { userId: "u1", gameId: "g-old", ownership: "HAVE", playtimeMinutes: 900 }, + { userId: "u2", gameId: "g-old", ownership: "HAVE", playtimeMinutes: 900 }, + { userId: "u1", gameId: "g-sale", ownership: "HAVE", playtimeMinutes: 0 }, + { userId: "u2", gameId: "g-sale", ownership: "DONT_HAVE", playtimeMinutes: 0 }, ], sessionGames: [ sessionGame("sg-backlog", "Backlog", [{ participantId: "p1", signal: "OWNED" }, { participantId: "p2", signal: "OWNED" }], 4, "g-backlog"), @@ -79,7 +83,7 @@ describe("match scoring", () => { participants: [{ id: "p1", userId: "u1", preference: null, user: { preference: null } }], selectedParticipantIds: ["p1"], playerCount: 2, - userGames: [{ userId: "u1", gameId: "g-cs", playtimeMinutes: 60_000 }], + userGames: [{ userId: "u1", gameId: "g-cs", ownership: "HAVE", playtimeMinutes: 60_000 }], sessionGames: [sessionGame("sg-cs", "Counter-Strike 2", [{ participantId: "p1", signal: "OWNED" }], 20, "g-cs")], }); @@ -224,7 +228,10 @@ describe("match scoring", () => { ], selectedParticipantIds: ["p1", "p2"], playerCount: 2, - userGames: [], + userGames: [ + { userId: "u1", gameId: "g-sg-horror", ownership: "HAVE", rating: 10 }, + { userId: "u2", gameId: "g-sg-horror", ownership: "HAVE", rating: 10 }, + ], sessionGames: [ { ...sessionGame("sg-horror", "Horror Co-op", [{ participantId: "p1", signal: "OWNED" }, { participantId: "p2", signal: "OWNED" }], 4), diff --git a/src/lib/match-scoring.ts b/src/lib/match-scoring.ts index 1962af7..828241f 100644 --- a/src/lib/match-scoring.ts +++ b/src/lib/match-scoring.ts @@ -9,6 +9,7 @@ export type AlignmentLevel = "High" | "Medium" | "Low"; export type MatchCategory = "perfect" | "hiddenBacklog" | "oldFavourites" | "almostReady" | "saleOpportunity"; export type PlayerCountStatus = "supported" | "unsupported" | "uncertain"; export type PlatformFit = "same-platform" | "crossplay" | "unknown" | "mismatch"; +export type PickSetup = "native" | "modded" | "either"; type FactorKey = keyof ScoredGame["factors"]; export type PreferenceProfile = { @@ -134,6 +135,8 @@ export type ScoredGame = { }>; ownership: { have: number; + dontHave: number; + unknown: number; missing: number; selected: number; }; @@ -187,6 +190,9 @@ export function scoreSessionGames({ sessionMinutes = 120, commitment = "any", mode = "balanced", + setup = "native", + selectionExplicit = false, + asOf = new Date(), }: { sessionGames: GameScoreInput[]; participants: ParticipantInput[]; @@ -196,12 +202,20 @@ export function scoreSessionGames({ sessionMinutes?: number; commitment?: CommitmentFilter; mode?: ScoreMode; + setup?: PickSetup; + selectionExplicit?: boolean; + asOf?: Date; }) { - const selectedIds = selectedParticipantIds?.length ? selectedParticipantIds : participants.map((participant) => participant.id); + const participantIds = new Set(participants.map((participant) => participant.id)); + const selectedIds = [...new Set( + selectedParticipantIds?.length || selectionExplicit + ? selectedParticipantIds ?? [] + : participants.map((participant) => participant.id), + )].filter((participantId) => participantIds.has(participantId)); const selectedIdSet = new Set(selectedIds); const selectedParticipants = participants.filter((participant) => selectedIds.includes(participant.id)); const selectedUserIds = new Set(selectedParticipants.map((participant) => participant.userId).filter((userId): userId is string => Boolean(userId))); - const selectedCount = Math.max(selectedParticipants.length, 1); + const selectedCount = selectedParticipants.length; const baseWeights = modeWeights[mode]; const preference = averagePreference(selectedParticipants); const favouriteGenres = selectedParticipants.flatMap((participant) => @@ -212,38 +226,63 @@ export function scoreSessionGames({ const weights = preferenceAdjustedWeights(baseWeights, preference); const userGamesByGameId = groupUserGamesByGameId(userGames); + if (selectedCount === 0 || playerCount < selectedCount) { + return []; + } + return sessionGames .map((sessionGame) => { - const game = withCuratedCapabilityFallback(sessionGame.game); - const haveParticipantIds = new Set( + const game = withCuratedCapabilityFallback(sessionGame.game, setup); + const gameUserRows = userGamesByGameId.get(sessionGame.gameId) ?? []; + const userGameByUserId = new Map(gameUserRows.map((userGame) => [userGame.userId, userGame])); + const signalByParticipantId = new Map( sessionGame.signals - .filter((signal) => selectedIdSet.has(signal.participantId) && signalMeansHave(signal.signal)) - .map((signal) => signal.participantId), - ); - const notAvailableIds = new Set( - sessionGame.signals - .filter((signal) => selectedIdSet.has(signal.participantId) && signal.signal === "NOT_AVAILABLE") - .map((signal) => signal.participantId), + .filter((signal) => selectedIdSet.has(signal.participantId)) + .map((signal) => [signal.participantId, signal.signal]), ); + const ownershipByParticipant = selectedParticipants.map((participant) => { + if (participant.userId) { + const ownership = userGameByUserId.get(participant.userId)?.ownership; + return { + participantId: participant.id, + state: ownership === "HAVE" ? "have" as const : ownership === "DONT_HAVE" ? "dontHave" as const : "unknown" as const, + }; + } + + const signal = signalByParticipantId.get(participant.id); + return { + participantId: participant.id, + state: signalMeansHave(signal) ? "have" as const : signal === "NOT_AVAILABLE" ? "dontHave" as const : "unknown" as const, + }; + }); + const haveParticipantIds = new Set(ownershipByParticipant.filter((entry) => entry.state === "have").map((entry) => entry.participantId)); + const notAvailableIds = new Set(ownershipByParticipant.filter((entry) => entry.state === "dontHave").map((entry) => entry.participantId)); const interests = sessionGame.interests ?? []; const wantCount = interests.filter((interest) => selectedIdSet.has(interest.participantId) && interest.interest === "WANT_TO_PLAY").length; const notTonightCount = interests.filter((interest) => selectedIdSet.has(interest.participantId) && interest.interest === "NOT_TONIGHT").length; const have = haveParticipantIds.size; - const missing = Math.max(selectedCount - have, 0); - const ownership = have / selectedCount; + const dontHave = notAvailableIds.size; + const unknown = Math.max(selectedCount - have - dontHave, 0); + const missing = dontHave + unknown; + const ownership = have / Math.max(selectedCount, 1); const playerCountStatus = playerCountStatusFor(game, playerCount); const playerCountFit = playerCountFits(game, playerCount); const onlineCoop = coOpFit(game.onlineCoop, preference.coOpVsCompetitive); const localCoop = coOpFit(game.localCoop, preference.coOpVsCompetitive); const relevantUserIds = selectedUserIdsForSessionGame(sessionGame, selectedIds, selectedUserIds); const relevantUserGames = (userGamesByGameId.get(sessionGame.gameId) ?? []).filter((userGame) => relevantUserIds.has(userGame.userId)); - const platformCompatibility = platformCompatibilityFor(game, relevantUserGames); + const ownerUserGames = relevantUserGames.filter((userGame) => userGame.ownership === "HAVE"); + const anonymousOwnerCount = ownershipByParticipant.filter((entry) => { + const participant = selectedParticipants.find((candidate) => candidate.id === entry.participantId); + return entry.state === "have" && !participant?.userId; + }).length; + const platformCompatibility = platformCompatibilityFor(game, ownerUserGames, have, anonymousOwnerCount); const persistentVetoCount = relevantUserGames.filter((userGame) => userGame.interest === "NOT_INTERESTED").length; const totalPlaytime = totalPlaytimeMinutes(relevantUserGames); - const recentPlayCount = recentlyPlayedCount(relevantUserGames); + const recentPlayCount = recentlyPlayedCount(relevantUserGames, asOf); const averagePlaytime = selectedCount > 0 ? totalPlaytime / selectedCount : 0; const playtime = mode === "backlog" || preference.backlogImportance >= 60 ? lowPlaytimeScore(averagePlaytime) : familiarPlaytimeScore(averagePlaytime); - const freshness = freshnessScore(relevantUserGames); + const freshness = freshnessScore(relevantUserGames, asOf); const persistentInterest = persistentInterestScore(relevantUserGames); const interest = clampScore(55 + wantCount * 15 - notTonightCount * 45 + persistentInterest); const discountPercent = game.deal?.discountPercent ?? 0; @@ -303,6 +342,7 @@ export function scoreSessionGames({ platforms: platformCompatibility.platforms, }); const categories = categoriesFor({ + alignment, ownership, missing, selectedCount, @@ -311,6 +351,8 @@ export function scoreSessionGames({ totalPlaytime, discountPercent, platformFit: platformCompatibility.fit, + playerCountStatus, + vetoCount: notTonightCount + persistentVetoCount, }); const reasons = reasonsFor({ have, @@ -347,7 +389,7 @@ export function scoreSessionGames({ categories, factors, factorBreakdown, - ownership: { have, missing, selected: selectedCount }, + ownership: { have, dontHave, unknown, missing, selected: selectedCount }, playtimeMinutes: totalPlaytime, discountPercent, currentPrice, @@ -370,7 +412,8 @@ export function scoreSessionGames({ return alignmentRank(b.alignment) - alignmentRank(a.alignment); } - return a.title.localeCompare(b.title); + const titleOrder = a.title.localeCompare(b.title); + return titleOrder !== 0 ? titleOrder : a.gameId.localeCompare(b.gameId); }); } @@ -471,15 +514,15 @@ function averagePreference(participants: ParticipantInput[]) { } function playerCountStatusFor(game: GameScoreInput["game"], playerCount: number): PlayerCountStatus { - if (game.maxPlayers && game.maxPlayers < playerCount) { + if (game.maxPlayers !== null && game.maxPlayers !== undefined && game.maxPlayers < playerCount) { return "unsupported"; } - if (game.minPlayers && game.minPlayers > playerCount) { + if (game.minPlayers !== null && game.minPlayers !== undefined && game.minPlayers > playerCount) { return "unsupported"; } - if (game.maxPlayers || game.minPlayers) { + if (game.maxPlayers !== null && game.maxPlayers !== undefined && game.minPlayers !== null && game.minPlayers !== undefined) { return "supported"; } @@ -647,14 +690,22 @@ function platformAdjustedMultiplayerFit(game: GameScoreInput["game"], platformFi return base; } -function platformCompatibilityFor(game: GameScoreInput["game"], userGames: UserGameInput[]) { - const knownPlatformSets = userGames - .filter((userGame) => !userGame.ownership || userGame.ownership === "HAVE") - .map((userGame) => parseGamingPlatforms(userGame.platforms)) - .filter((platforms) => platforms.length > 0); - const platforms = [...new Set(knownPlatformSets.flat())]; - - if (knownPlatformSets.length < 2) { +function platformCompatibilityFor( + game: GameScoreInput["game"], + userGames: UserGameInput[], + requiredOwnerCount: number, + anonymousOwnerCount: number, +) { + const platformSets = userGames.map((userGame) => parseGamingPlatforms(userGame.platforms)); + const knownPlatformSets = platformSets.filter((platforms) => platforms.length > 0); + const platforms = [...new Set(platformSets.flat())]; + + if ( + requiredOwnerCount < 2 || + anonymousOwnerCount > 0 || + platformSets.length !== requiredOwnerCount || + knownPlatformSets.length !== requiredOwnerCount + ) { return { fit: "unknown" as const, platforms }; } @@ -762,22 +813,22 @@ function lowPlaytimeScore(averagePlaytime: number) { return 45; } -function freshnessScore(userGames: UserGameInput[]) { - const recentlyPlayed = userGames.some(wasRecentlyPlayed); +function freshnessScore(userGames: UserGameInput[], asOf: Date) { + const recentlyPlayed = userGames.some((userGame) => wasRecentlyPlayed(userGame, asOf)); return recentlyPlayed ? 45 : 80; } -function recentlyPlayedCount(userGames: UserGameInput[]) { - return userGames.filter(wasRecentlyPlayed).length; +function recentlyPlayedCount(userGames: UserGameInput[], asOf: Date) { + return userGames.filter((userGame) => wasRecentlyPlayed(userGame, asOf)).length; } -function wasRecentlyPlayed(userGame: UserGameInput) { +function wasRecentlyPlayed(userGame: UserGameInput, asOf: Date) { if (!userGame.recentlyPlayedAt) { return false; } - return Date.now() - new Date(userGame.recentlyPlayedAt).getTime() < 1000 * 60 * 60 * 24 * 21; + return asOf.getTime() - new Date(userGame.recentlyPlayedAt).getTime() < 1000 * 60 * 60 * 24 * 21; } function formatPlaytime(minutes: number) { @@ -953,6 +1004,7 @@ function alignmentReasonsFor({ } function categoriesFor({ + alignment, ownership, missing, selectedCount, @@ -961,7 +1013,10 @@ function categoriesFor({ totalPlaytime, discountPercent, platformFit, + playerCountStatus, + vetoCount, }: { + alignment: AlignmentLevel; ownership: number; missing: number; selectedCount: number; @@ -970,11 +1025,21 @@ function categoriesFor({ totalPlaytime: number; discountPercent: number; platformFit: PlatformFit; + playerCountStatus: PlayerCountStatus; + vetoCount: number; }) { const categories: MatchCategory[] = []; const groupIsFilled = selectedCount >= requestedPlayerCount; - if (groupIsFilled && ownership === 1 && playerCountFit >= 70 && platformFit !== "mismatch") { + if ( + alignment === "High" && + vetoCount === 0 && + groupIsFilled && + ownership === 1 && + playerCountStatus === "supported" && + playerCountFit >= 70 && + (platformFit === "same-platform" || platformFit === "crossplay") + ) { categories.push("perfect"); } if (ownership === 1 && isBarelyPlayedGroupPick(totalPlaytime)) { @@ -1097,28 +1162,30 @@ function alignmentRank(alignment: AlignmentLevel) { } function clampScore(value: number) { - return Math.max(0, Math.min(100, value)); + return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0; } -function withCuratedCapabilityFallback(game: GameScoreInput["game"]): GameScoreInput["game"] { +function withCuratedCapabilityFallback(game: GameScoreInput["game"], setup: PickSetup): GameScoreInput["game"] { const curated = curatedGames.find((candidate) => normalizeGameTitle(candidate.title) === normalizeGameTitle(game.title)); if (!curated) { return game; } + const usesModdedSetup = setup === "modded" && curated.moddedMaxPlayers !== undefined; + return { ...game, - minPlayers: curated.minPlayers ?? game.minPlayers, - maxPlayers: curated.maxPlayers ?? game.maxPlayers, - onlineCoop: curated.onlineCoop ?? game.onlineCoop, - localCoop: curated.localCoop ?? game.localCoop, - onlineMultiplayer: curated.onlineMultiplayer ?? game.onlineMultiplayer, - localMultiplayer: curated.localMultiplayer ?? game.localMultiplayer, - campaignCoop: curated.campaignCoop ?? game.campaignCoop, - minimumSessionMinutes: curated.minimumSessionMinutes ?? game.minimumSessionMinutes, - commitmentTier: curated.commitmentTier ?? game.commitmentTier, + minPlayers: usesModdedSetup ? Math.max(2, game.minPlayers ?? curated.minPlayers ?? 1) : game.minPlayers ?? curated.minPlayers, + maxPlayers: usesModdedSetup ? curated.moddedMaxPlayers : game.maxPlayers ?? curated.maxPlayers, + onlineCoop: usesModdedSetup ? true : game.onlineCoop ?? curated.onlineCoop, + localCoop: usesModdedSetup ? false : game.localCoop ?? curated.localCoop, + onlineMultiplayer: usesModdedSetup ? true : game.onlineMultiplayer ?? curated.onlineMultiplayer, + localMultiplayer: usesModdedSetup ? false : game.localMultiplayer ?? curated.localMultiplayer, + campaignCoop: game.campaignCoop ?? curated.campaignCoop, + minimumSessionMinutes: game.minimumSessionMinutes ?? curated.minimumSessionMinutes, + commitmentTier: game.commitmentTier ?? curated.commitmentTier, genres: Array.isArray(game.genres) && game.genres.length > 0 ? game.genres : curated.genres, - capabilitySource: "curated", + capabilitySource: game.capabilitySource ?? (usesModdedSetup ? `curated:${curated.moddedSourceName ?? "modded"}` : "curated"), }; } diff --git a/src/lib/pick/access.test.ts b/src/lib/pick/access.test.ts new file mode 100644 index 0000000..993e742 --- /dev/null +++ b/src/lib/pick/access.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { permittedPickParticipantIds } from "@/lib/pick/access"; + +const findBlocks = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/prisma", () => ({ + prisma: { userBlock: { findMany: findBlocks } }, +})); + +describe("Pick participant access", () => { + beforeEach(() => findBlocks.mockReset()); + + it("keeps the viewer and anonymous fallback while excluding blocked counterparts", async () => { + findBlocks.mockResolvedValue([{ blockerId: "blocked-user", blockedId: "viewer" }]); + await expect(permittedPickParticipantIds({ + viewerUserId: "viewer", + participants: [ + { id: "self", userId: "viewer" }, + { id: "friend", userId: "friend-user" }, + { id: "blocked", userId: "blocked-user" }, + { id: "guest", userId: null }, + ], + })).resolves.toEqual(["self", "friend", "guest"]); + }); +}); diff --git a/src/lib/pick/access.ts b/src/lib/pick/access.ts new file mode 100644 index 0000000..0b3665e --- /dev/null +++ b/src/lib/pick/access.ts @@ -0,0 +1,31 @@ +import { prisma } from "@/lib/prisma"; + +export async function permittedPickParticipantIds({ + viewerUserId, + participants, +}: { + viewerUserId: string; + participants: Array<{ id: string; userId?: string | null }>; +}) { + const counterpartIds = participants + .map((participant) => participant.userId) + .filter((userId): userId is string => Boolean(userId) && userId !== viewerUserId); + const blocks = counterpartIds.length > 0 + ? await prisma.userBlock.findMany({ + where: { + OR: [ + { blockerId: viewerUserId, blockedId: { in: counterpartIds } }, + { blockedId: viewerUserId, blockerId: { in: counterpartIds } }, + ], + }, + select: { blockerId: true, blockedId: true }, + }) + : []; + const blockedUserIds = new Set( + blocks.flatMap((block) => [block.blockerId, block.blockedId]).filter((userId) => userId !== viewerUserId), + ); + + return participants + .filter((participant) => !participant.userId || !blockedUserIds.has(participant.userId)) + .map((participant) => participant.id); +} diff --git a/src/lib/pick/contracts.ts b/src/lib/pick/contracts.ts new file mode 100644 index 0000000..0f62f2e --- /dev/null +++ b/src/lib/pick/contracts.ts @@ -0,0 +1,45 @@ +import type { GamingPlatform } from "@/lib/platforms"; +import type { CommitmentFilter, PickSetup } from "@/lib/match-scoring"; + +export type Eligibility = { + status: "supported" | "unsupported" | "uncertain"; + reasons: Array<{ code: string; message: string }>; + setupId?: string; +}; + +export type PickConstraints = { + selectedParticipantIds: string[]; + selectionExplicit: boolean; + playerCount: number; + mode: "online" | "local" | "either"; + setup: PickSetup; + sessionMinutes: number; + commitment: CommitmentFilter; + platforms: GamingPlatform[]; + genres: string[]; + tags: string[]; +}; + +export type OwnershipState = "have" | "dontHave" | "unknown"; + +export type OwnershipEvidence = { + state: OwnershipState; + source: "user-library" | "legacy-session" | "none"; +}; + +export type OwnershipMatrix = Map>; + +export type MatchSnapshot = { + asOf: Date; + complete: boolean; + participants: TParticipant[]; + candidates: TCandidate[]; + ownership: OwnershipMatrix; + constraints: PickConstraints; +}; + +export type RecommendationActionPolicy = { + canShortlist: boolean; + canSignal: boolean; + canChooseFinal: boolean; +}; diff --git a/src/lib/pick/query.test.ts b/src/lib/pick/query.test.ts new file mode 100644 index 0000000..c7726b2 --- /dev/null +++ b/src/lib/pick/query.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { parsePickQuery, serializePickQuery } from "@/lib/pick/query"; + +const allowedParticipantIds = ["host", "friend", "guest"]; + +describe("Pick query contract", () => { + it("deduplicates selected members, filters foreign IDs, and keeps an explicit empty selection", () => { + const selected = parsePickQuery({ selectedParticipants: ["host", "foreign", "host", "friend"] }, { allowedParticipantIds }); + expect(selected.selectedParticipantIds).toEqual(["host", "friend"]); + expect(selected.selectionExplicit).toBe(true); + + const empty = parsePickQuery({ selectionExplicit: "true" }, { allowedParticipantIds, defaultSelectedParticipantIds: ["host"] }); + expect(empty).toMatchObject({ selectionExplicit: true, selectedParticipantIds: [] }); + }); + + it("uses defaults only when selection was omitted and bounds integer input", () => { + const query = parsePickQuery({ playerCount: "2.5", sessionMinutes: "Infinity", page: "0", pageSize: "101" }, { + allowedParticipantIds, + defaultSelectedParticipantIds: ["host", "foreign"], + defaultPlayerCount: 4, + defaultSessionMinutes: 90, + }); + expect(query.selectedParticipantIds).toEqual(["host"]); + expect(query.selectionExplicit).toBe(false); + expect(query).toMatchObject({ playerCount: 4, sessionMinutes: 90, page: 1, pageSize: 24 }); + expect(parsePickQuery({ playerCount: "50", sessionMinutes: "1440" }, { allowedParticipantIds })).toMatchObject({ playerCount: 50, sessionMinutes: 1440 }); + }); + + it("normalizes enums, bounded monetary values, facets, and strict booleans", () => { + const query = parsePickQuery({ + setup: "modded", mode: "local", scoreMode: "coop", commitment: "one-session", + groupBudget: "12.34", groupMode: "online", groupLength: "campaign", + avoidOwned: "false", saleOnly: "true", platform: ["PC", "steam", "unknown"], + genre: ["co-op", "co-op"], tag: ["tactical", "tactical"], + }, { allowedParticipantIds }); + expect(query).toMatchObject({ setup: "modded", mode: "local", scoreMode: "coop", commitment: "one-session", platforms: ["PC"], genres: ["co-op"], tags: ["tactical"] }); + expect(query.groupBuy).toMatchObject({ budget: 1234, mode: "online", sessionLength: "campaign", avoidOwned: false, saleOnly: true }); + expect(parsePickQuery({ groupBudget: "NaN", avoidOwned: "off", saleOnly: "1" }, { allowedParticipantIds }).groupBuy).toMatchObject({ budget: 0, avoidOwned: false, saleOnly: false }); + }); + + it("serializes every filter, including false booleans and explicit empty selection", () => { + const query = parsePickQuery({ selectionExplicit: "true", playerCount: "5", setup: "modded", search: "wizards", groupBudget: "9.99", avoidOwned: "false", saleOnly: "true", platform: "PC", genre: "rpg", tag: "co-op" }, { allowedParticipantIds }); + const serialized = serializePickQuery(query); + expect(serialized.get("selectionExplicit")).toBe("true"); + expect(serialized.getAll("selectedParticipantIds")).toEqual([]); + expect(serialized.get("setup")).toBe("modded"); + expect(serialized.get("avoidOwned")).toBe("false"); + expect(serialized.get("saleOnly")).toBe("true"); + expect(parsePickQuery(Object.fromEntries(serialized), { allowedParticipantIds })).toEqual(query); + }); +}); diff --git a/src/lib/pick/query.ts b/src/lib/pick/query.ts new file mode 100644 index 0000000..7c40a43 --- /dev/null +++ b/src/lib/pick/query.ts @@ -0,0 +1,165 @@ +import type { CommitmentFilter, ScoreMode } from "@/lib/match-scoring"; +import { normalizeGamingPlatform, type GamingPlatform } from "@/lib/platforms"; + +type QueryValue = string | string[] | undefined; +export type PickSearchParams = Record; + +export type PickQuery = { + selectedParticipantIds: string[]; + selectionExplicit: boolean; + playerCount: number; + mode: "online" | "local" | "either"; + setup: "native" | "modded" | "either"; + scoreMode: ScoreMode; + sessionMinutes: number; + commitment: CommitmentFilter; + search: string; + page: number; + pageSize: number; + platforms: GamingPlatform[]; + genres: string[]; + tags: string[]; + groupBuy: { + budget: number; + genre: string; + mode: "online" | "local" | "either"; + sessionLength: "one-night" | "long-term" | "campaign" | "any"; + platform: string; + avoidOwned: boolean; + saleOnly: boolean; + }; +}; + +export type ParsePickQueryOptions = { + allowedParticipantIds: Iterable; + defaultSelectedParticipantIds?: Iterable; + defaultPlayerCount?: number; + defaultSessionMinutes?: number; +}; + +const SCORE_MODES = ["balanced", "coop", "backlog", "cheap", "familiar", "fresh"] as const; +const COMMITMENTS = ["any", "one-session", "under-10", "10-30", "30-100", "100-1000", "1000-plus", "endless"] as const; +const SETUPS = ["native", "modded", "either"] as const; +const MODES = ["online", "local", "either"] as const; +const SESSION_LENGTHS = ["one-night", "long-term", "campaign", "any"] as const; + +const MAX_PLAYER_COUNT = 50; +const MAX_SESSION_MINUTES = 1_440; +const MAX_PAGE = 10_000; +const MAX_PAGE_SIZE = 100; +const MAX_BUDGET_MINOR = 10_000_000; +const MAX_TEXT_LENGTH = 120; +const MAX_FACET_VALUES = 20; + +export function parsePickQuery(params: PickSearchParams, options: ParsePickQueryOptions): PickQuery { + const allowedParticipantIds = new Set(options.allowedParticipantIds); + // `selectedParticipants` is accepted for the existing GET form; new callers use + // the contract name so the client and snapshot DTO describe the same thing. + const selectedValues = valuesFor(params, "selectedParticipantIds", "selectedParticipants"); + const selectionExplicit = parseBoolean(first(params, "selectionExplicit")) ?? selectedValues.length > 0; + const defaults = uniqueAllowed(options.defaultSelectedParticipantIds ?? allowedParticipantIds, allowedParticipantIds); + const selectedParticipantIds = selectionExplicit + ? uniqueAllowed(selectedValues, allowedParticipantIds) + : defaults; + + return { + selectedParticipantIds, + selectionExplicit, + playerCount: parseInteger(first(params, "playerCount"), options.defaultPlayerCount ?? 1, 1, MAX_PLAYER_COUNT), + mode: parseEnum(first(params, "mode"), MODES, "either"), + setup: parseEnum(first(params, "setup"), SETUPS, "native"), + scoreMode: parseEnum(first(params, "scoreMode"), SCORE_MODES, "balanced"), + sessionMinutes: parseInteger(first(params, "sessionMinutes"), options.defaultSessionMinutes ?? 60, 0, MAX_SESSION_MINUTES), + commitment: parseEnum(first(params, "commitment"), COMMITMENTS, "any"), + search: parseText(first(params, "search")), + page: parseInteger(first(params, "page"), 1, 1, MAX_PAGE), + pageSize: parseInteger(first(params, "pageSize"), 24, 1, MAX_PAGE_SIZE), + platforms: uniquePlatforms(valuesFor(params, "platforms", "platform")), + genres: parseFacet(valuesFor(params, "genres", "genre")), + tags: parseFacet(valuesFor(params, "tags", "tag")), + groupBuy: { + budget: parseMoneyMinor(first(params, "groupBudget"), 0), + genre: parseText(first(params, "groupGenre")), + mode: parseEnum(first(params, "groupMode"), MODES, "either"), + sessionLength: parseEnum(first(params, "groupLength"), SESSION_LENGTHS, "any"), + platform: parseText(first(params, "groupPlatform")), + avoidOwned: parseBoolean(first(params, "avoidOwned")) ?? false, + saleOnly: parseBoolean(first(params, "saleOnly")) ?? false, + }, + }; +} + +export function serializePickQuery(query: PickQuery): URLSearchParams { + const params = new URLSearchParams(); + params.set("selectionExplicit", String(query.selectionExplicit)); + for (const participantId of query.selectedParticipantIds) params.append("selectedParticipantIds", participantId); + params.set("playerCount", String(query.playerCount)); + params.set("mode", query.mode); + params.set("setup", query.setup); + params.set("scoreMode", query.scoreMode); + params.set("sessionMinutes", String(query.sessionMinutes)); + params.set("commitment", query.commitment); + params.set("search", query.search); + params.set("page", String(query.page)); + params.set("pageSize", String(query.pageSize)); + for (const platform of query.platforms) params.append("platforms", platform); + for (const genre of query.genres) params.append("genres", genre); + for (const tag of query.tags) params.append("tags", tag); + params.set("groupBudget", (query.groupBuy.budget / 100).toFixed(2)); + params.set("groupGenre", query.groupBuy.genre); + params.set("groupMode", query.groupBuy.mode); + params.set("groupLength", query.groupBuy.sessionLength); + params.set("groupPlatform", query.groupBuy.platform); + params.set("avoidOwned", String(query.groupBuy.avoidOwned)); + params.set("saleOnly", String(query.groupBuy.saleOnly)); + return params; +} + +function first(params: PickSearchParams, key: string) { + const value = params[key]; + return Array.isArray(value) ? value[0] : value; +} + +function valuesFor(params: PickSearchParams, ...keys: string[]) { + return keys.flatMap((key) => { + const value = params[key]; + return (Array.isArray(value) ? value : value === undefined ? [] : [value]).filter((item) => item.length > 0); + }); +} + +function parseInteger(value: string | undefined, fallback: number, minimum: number, maximum: number) { + if (!value || !/^(?:0|[1-9]\d*)$/.test(value)) return fallback; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback; +} + +function parseMoneyMinor(value: string | undefined, fallback: number) { + if (!value || !/^\d+(?:\.\d{1,2})?$/.test(value)) return fallback; + const [whole, fraction = ""] = value.split("."); + const minor = Number(whole) * 100 + Number(fraction.padEnd(2, "0")); + return Number.isSafeInteger(minor) && minor <= MAX_BUDGET_MINOR ? minor : fallback; +} + +function parseEnum(value: string | undefined, values: T, fallback: T[number]): T[number] { + return values.includes(value as T[number]) ? value as T[number] : fallback; +} + +function parseBoolean(value: string | undefined) { + return value === "true" ? true : value === "false" ? false : undefined; +} + +function parseText(value: string | undefined) { + return value ? value.trim().slice(0, MAX_TEXT_LENGTH) : ""; +} + +function parseFacet(values: string[]) { + return [...new Set(values.map(parseText).filter(Boolean))].slice(0, MAX_FACET_VALUES); +} + +function uniquePlatforms(values: string[]) { + return [...new Set(values.map(normalizeGamingPlatform).filter((value): value is GamingPlatform => value !== null))]; +} + +function uniqueAllowed(values: Iterable, allowed: Set) { + return [...new Set([...values].filter((value) => allowed.has(value)))]; +} diff --git a/src/lib/player-count.ts b/src/lib/player-count.ts index 99f15b4..ec65745 100644 --- a/src/lib/player-count.ts +++ b/src/lib/player-count.ts @@ -1,6 +1,6 @@ export function parseMinimumPlayers(value?: string) { const parsed = Number(value ?? 5); - const fallback = Number.isFinite(parsed) ? parsed : 5; + const fallback = Number.isFinite(parsed) && Number.isInteger(parsed) ? parsed : 5; return Math.max(1, Math.min(50, fallback)); } diff --git a/src/lib/scheduling.ts b/src/lib/scheduling.ts index 10a68ab..a976f2a 100644 --- a/src/lib/scheduling.ts +++ b/src/lib/scheduling.ts @@ -92,17 +92,40 @@ export function generateHourlySlots(input: WindowInput): HourSlot[] { for (let hour = startHour; hour < endHour; hour += 1) { const localStart = `${dayKey}T${String(hour).padStart(2, "0")}:00:00`; - const startsAt = fromZonedTime(localStart, input.timezone); - slots.push({ + slots.push(...realInstantsForLocalHour(localStart, input.timezone).map((startsAt) => ({ startsAt, endsAt: addHours(startsAt, 1), - }); + }))); } cursor = addDays(cursor, 1); } - return slots; + return [...new Map( + slots + .sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime()) + .map((slot) => [slot.startsAt.toISOString(), slot]), + ).values()]; +} + +function realInstantsForLocalHour(localStart: string, timezone: string) { + const expected = localStart.slice(0, 16); + const estimate = fromZonedTime(localStart, timezone); + const matches: Date[] = []; + + // fromZonedTime returns one side of an ambiguous wall time. Searching a + // narrow offset window also finds the repeated autumn hour, while a missing + // spring hour produces no matching instant at all. Fifteen-minute steps + // cover current IANA offsets, including half/quarter-hour zones. + for (let offsetMinutes = -180; offsetMinutes <= 180; offsetMinutes += 15) { + const candidate = new Date(estimate.getTime() + offsetMinutes * 60_000); + if (formatInTimeZone(candidate, timezone, "yyyy-MM-dd'T'HH:mm") === expected) { + matches.push(candidate); + } + } + + return [...new Map(matches.map((date) => [date.toISOString(), date])).values()] + .sort((a, b) => a.getTime() - b.getTime()); } export function generateCandidateWindows(input: WindowInput): CandidateWindow[] { @@ -120,9 +143,21 @@ export function generateCandidateWindows(input: WindowInput): CandidateWindow[] for (let index = 0; index <= sortedSlots.length - input.requiredDuration; index += 1) { const windowSlots = sortedSlots.slice(index, index + input.requiredDuration); + const adjacent = windowSlots.every((slot, slotIndex) => ( + slotIndex === 0 || + slot.startsAt.getTime() - windowSlots[slotIndex - 1].startsAt.getTime() === 60 * 60 * 1000 + )); + if (!adjacent) { + continue; + } + const startsAt = windowSlots[0].startsAt; + const endsAt = windowSlots[windowSlots.length - 1].endsAt; + if (endsAt.getTime() - startsAt.getTime() !== input.requiredDuration * 60 * 60 * 1000) { + continue; + } windows.push({ - startsAt: windowSlots[0].startsAt, - endsAt: windowSlots[windowSlots.length - 1].endsAt, + startsAt, + endsAt, slots: windowSlots, }); } diff --git a/src/lib/session/authorization.ts b/src/lib/session/authorization.ts new file mode 100644 index 0000000..ba6b139 --- /dev/null +++ b/src/lib/session/authorization.ts @@ -0,0 +1,70 @@ +import { getCurrentUser, getHostParticipantId, getParticipantId } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import type { SessionActor } from "@/lib/session/contracts"; + +export async function resolveSessionActor(sessionId: string): Promise { + const currentUser = await getCurrentUser(); + + if (currentUser) { + const [participant, ownedSession] = await Promise.all([ + prisma.participant.findUnique({ + where: { sessionId_userId: { sessionId, userId: currentUser.id } }, + select: { id: true, isHost: true }, + }), + prisma.session.findFirst({ + where: { id: sessionId, gameNight: { ownerUserId: currentUser.id } }, + select: { id: true }, + }), + ]); + + if (ownedSession) { + return { + kind: "owner", + userId: currentUser.id, + sessionId, + participantId: participant?.id ?? null, + }; + } + + if (participant) { + return { + kind: "member", + userId: currentUser.id, + participantId: participant.id, + sessionId, + isHost: participant.isHost, + }; + } + + return { kind: "visitor" }; + } + + const participantId = await getParticipantId(sessionId); + if (!participantId) { + return { kind: "visitor" }; + } + + const participant = await prisma.participant.findFirst({ + where: { id: participantId, sessionId, userId: null }, + select: { id: true, isHost: true }, + }); + if (!participant) { + return { kind: "visitor" }; + } + + const signedHostId = await getHostParticipantId(sessionId); + return { + kind: "guest", + participantId: participant.id, + sessionId, + isHost: participant.isHost && signedHostId === participant.id, + }; +} + +export function actorParticipantId(actor: SessionActor) { + return actor.kind === "visitor" ? null : actor.participantId; +} + +export function actorCanManageSession(actor: SessionActor) { + return actor.kind === "owner" || (actor.kind !== "visitor" && actor.isHost); +} diff --git a/src/lib/session/contracts.ts b/src/lib/session/contracts.ts new file mode 100644 index 0000000..f42775b --- /dev/null +++ b/src/lib/session/contracts.ts @@ -0,0 +1,33 @@ +export type SessionActor = + | { kind: "visitor" } + | { kind: "guest"; participantId: string; sessionId: string; isHost: boolean } + | { kind: "member"; userId: string; participantId: string; sessionId: string; isHost: boolean } + | { kind: "owner"; userId: string; sessionId: string; participantId: string | null }; + +export type ActionErrorCode = + | "VALIDATION" + | "UNAUTHENTICATED" + | "FORBIDDEN" + | "NOT_FOUND" + | "CONFLICT"; + +export type ActionResult = + | { ok: true; data: T } + | { + ok: false; + code: ActionErrorCode; + message: string; + fieldErrors?: Record; + }; + +export function actionSuccess(data: T): ActionResult { + return { ok: true, data }; +} + +export function actionFailure( + code: ActionErrorCode, + message: string, + fieldErrors?: Record, +): ActionResult { + return { ok: false, code, message, fieldErrors }; +} diff --git a/src/lib/session/membership.ts b/src/lib/session/membership.ts new file mode 100644 index 0000000..992f0b8 --- /dev/null +++ b/src/lib/session/membership.ts @@ -0,0 +1,32 @@ +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; + +export async function joinPickWorkspace({ + sessionId, + userId, + displayName, +}: { + sessionId: string; + userId: string; + displayName: string; +}) { + try { + return await prisma.participant.upsert({ + where: { sessionId_userId: { sessionId, userId } }, + create: { sessionId, userId, name: displayName }, + update: {}, + select: { id: true, isHost: true }, + }); + } catch (error) { + if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== "P2002") { + throw error; + } + + const existing = await prisma.participant.findUnique({ + where: { sessionId_userId: { sessionId, userId } }, + select: { id: true, isHost: true }, + }); + if (!existing) throw error; + return existing; + } +}