From 8def0fd74f5bcce6d40ef6913732d00466411bb2 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 12 Nov 2026 14:56:35 +0200 Subject: [PATCH] feat(games): players for those who asked, court total, logo, more filters (#668) For the restyled iPhone game card (playerz-ios#53), with the owner's decisions of 2026-11-12: - `players` (first name, picture, shown level, booker first) only for a viewer who books, plays, or has a pending or accepted request; anyone else browsing gets null and keeps counts and empty seats. - `courtPrice`: the booking's total, not a share per player. - `isIndoor`, `venue.logoUrl`. - GET /games filters: dateFrom/dateTo, fromHour/toHour (start hour at the venue), indoor, level (the wish includes it, or there is none). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DfeuWUNfBvCav72pHsXdHa --- openapi/playerz-v1.json | 117 +++++++++++++++++++- src/app-layer/usecases/open-play.ts | 155 +++++++++++++++++++++++++-- src/app/api/v1/_lib/open-play-dto.ts | 10 +- src/app/api/v1/games/route.ts | 39 ++++++- tests/integration/open-play.test.ts | 82 ++++++++++++++ tests/rendered/open-play.test.tsx | 5 +- 6 files changed, 396 insertions(+), 12 deletions(-) diff --git a/openapi/playerz-v1.json b/openapi/playerz-v1.json index c4402372..12660b33 100644 --- a/openapi/playerz-v1.json +++ b/openapi/playerz-v1.json @@ -10519,6 +10519,68 @@ "format": "date" } }, + { + "name": "dateFrom", + "in": "query", + "required": false, + "description": "First day, `YYYY-MM-DD` at the venue, included (#668).", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "dateTo", + "in": "query", + "required": false, + "description": "Last day, `YYYY-MM-DD` at the venue, included (#668).", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "fromHour", + "in": "query", + "required": false, + "description": "Games starting at or after this hour at the venue (#668).", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 23 + } + }, + { + "name": "toHour", + "in": "query", + "required": false, + "description": "Games starting before this hour at the venue (#668).", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 24 + } + }, + { + "name": "indoor", + "in": "query", + "required": false, + "description": "Indoor (`true`) or outdoor (`false`) courts only (#668).", + "schema": { + "type": "boolean" + } + }, + { + "name": "level", + "in": "query", + "required": false, + "description": "Games whose level wish includes this whole level, or that wish none (#668).", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 7 + } + }, { "name": "city", "in": "query", @@ -23504,7 +23566,10 @@ "sport", "resourceType", "courtName", + "isIndoor", "venue", + "courtPrice", + "players", "startTs", "endTs", "timezone", @@ -23532,9 +23597,13 @@ "courtName": { "type": "string" }, + "isIndoor": { + "type": "boolean", + "description": "The court is indoors (#668)." + }, "venue": { "type": "object", - "required": ["id", "slug", "name", "city"], + "required": ["id", "slug", "name", "city", "logoUrl"], "properties": { "id": { "type": "string" @@ -23547,9 +23616,35 @@ }, "city": { "type": "string" + }, + "logoUrl": { + "type": ["string", "null"], + "description": "The club's logo (#668)." + } + } + }, + "courtPrice": { + "type": "object", + "description": "The booking's total in integer cents (#668). The card shows the court total, not a share per player; it is paid at the club as before.", + "required": ["totalCents", "currency"], + "properties": { + "totalCents": { + "type": "integer", + "minimum": 0 + }, + "currency": { + "type": "string", + "description": "ISO 4217." } } }, + "players": { + "type": ["array", "null"], + "items": { + "$ref": "#/components/schemas/OpenGamePlayer" + }, + "description": "Who is on the game, booker first (#668). Null unless the viewer is the booker, plays in it, or has a PENDING or ACCEPTED request: anyone else browsing sees `playersCount` and empty seats." + }, "startTs": { "type": "string", "format": "date-time" @@ -27705,6 +27800,26 @@ "$ref": "#/components/schemas/VenuePlan" } } + }, + "OpenGamePlayer": { + "type": "object", + "description": "One person on a game (#668). Never carries a user id.", + "required": ["firstName", "avatarUrl", "level", "isBooker"], + "properties": { + "firstName": { + "type": ["string", "null"] + }, + "avatarUrl": { + "type": ["string", "null"] + }, + "level": { + "type": ["number", "null"], + "description": "Their level in the game's sport (1–7, one decimal) as they let others see it; null when hidden, none, or ranking is off." + }, + "isBooker": { + "type": "boolean" + } + } } } } diff --git a/src/app-layer/usecases/open-play.ts b/src/app-layer/usecases/open-play.ts index 92dc2f0a..9044d7b6 100644 --- a/src/app-layer/usecases/open-play.ts +++ b/src/app-layer/usecases/open-play.ts @@ -155,12 +155,41 @@ function checkLevels(levelMin: number | null, levelMax: number | null): void { // ─── What everyone sees of a game ─────────────────────────────────────── +/** One person on a game (#668): what a card draws in a seat. */ +export interface OpenGamePlayer { + firstName: string | null; + avatarUrl: string | null; + /** Their level in the game's sport, as they let others see it; null when hidden or none. */ + level: number | null; + isBooker: boolean; +} + export interface OpenGameSummary { id: string; sport: SportType; resourceType: string; courtName: string; - venue: { id: string; slug: string; name: string; city: string }; + /** The court is indoors (#668). */ + isIndoor: boolean; + venue: { + id: string; + slug: string; + name: string; + city: string; + /** The club's logo (#668), or null. */ + logoUrl: string | null; + }; + /** + * The booking's total (#668, owner 2026-11-12: the card shows the court + * total, not a share), in `currency`. Paid at the club as before. + */ + courtPrice: { totalCents: number; currency: string }; + /** + * Who is on it, booker first (#668). Owner decision 2026-11-12: only for a + * viewer who is the booker, plays, or has asked (pending or accepted); + * anyone else browsing sees counts and empty seats, so this is null. + */ + players: OpenGamePlayer[] | null; startTs: Date; endTs: Date; timezone: string; @@ -208,16 +237,23 @@ const GAME_SELECT = { startTs: true, endTs: true, bookedByUserId: true, - participants: { select: { id: true, userId: true } }, + totalCents: true, + currency: true, + participants: { + select: { id: true, userId: true, guestName: true, position: true }, + orderBy: { position: 'asc' as const }, + }, resource: { select: { name: true, sport: true, resourceType: true, capacity: true, + isIndoor: true, venue: { select: { id: true, + tenantId: true, slug: true, name: true, city: true, @@ -264,14 +300,22 @@ async function summarise( now: Date, ): Promise { if (rows.length === 0) return []; - const bookerIds = [ - ...new Set(rows.flatMap((g) => (g.booking.bookedByUserId ? [g.booking.bookedByUserId] : []))), + // Bookers always (the card names the organiser); everyone on the booking, + // for the games whose players this viewer may see (#668). + const people = [ + ...new Set( + rows.flatMap((g) => [ + ...(g.booking.bookedByUserId ? [g.booking.bookedByUserId] : []), + ...g.booking.participants.flatMap((p) => (p.userId ? [p.userId] : [])), + ]), + ), ]; - const [bookers, mine, myLevels] = await Promise.all([ + const tenantIds = [...new Set(rows.map((g) => g.booking.resource.venue.tenantId))]; + const [bookers, mine, myLevels, clubs] = await Promise.all([ db.user.findMany({ - where: { id: { in: bookerIds } }, + where: { id: { in: people } }, select: { id: true, name: true, avatarUrl: true, deletedAt: true }, - take: bookerIds.length, + take: people.length, }), viewerId ? // guardrail-allow: cross-tenant — the viewer's own requests, on games already read. @@ -284,7 +328,13 @@ async function summarise( viewerId ? shownRatings(db, [viewerId], viewerId) : Promise.resolve(new Map()), + db.venueOrg.findMany({ + where: { id: { in: tenantIds } }, + select: { id: true, logoUrl: true }, + take: tenantIds.length, + }), ]); + const logos = new Map(clubs.map((c) => [c.id, c.logoUrl])); const byId = new Map(bookers.map((u) => [u.id, u])); const fits = (g: GameRow): boolean | null => { if (!viewerId || (g.levelMin === null && g.levelMax === null)) return null; @@ -298,6 +348,61 @@ async function summarise( }; const myRequest = new Map(mine.map((r) => [r.gameId, r])); + // Whose players this viewer sees (#668): they book it, play in it, or asked. + const seesPlayers = (g: GameRow): boolean => { + if (!viewerId) return false; + const b = g.booking; + if (b.bookedByUserId === viewerId || b.participants.some((p) => p.userId === viewerId)) { + return true; + } + const r = myRequest.get(g.id); + if (!r) return false; + const status = effectiveStatus( + r.status, + b.startTs, + b.resource.venue.cancellationCutoffHours, + now, + ); + return status === 'PENDING' || status === 'ACCEPTED'; + }; + const visible = rows.filter(seesPlayers); + const theirIds = [ + ...new Set( + visible.flatMap((g) => [ + ...(g.booking.bookedByUserId ? [g.booking.bookedByUserId] : []), + ...g.booking.participants.flatMap((p) => (p.userId ? [p.userId] : [])), + ]), + ), + ]; + // Their levels as each lets others see them; one read for the page. + const theirLevels = theirIds.length + ? await shownRatings(db, theirIds, viewerId, now) + : new Map(); + const person = (userId: string, sport: SportType, isBooker: boolean): OpenGamePlayer => { + const u = byId.get(userId); + const live = u && !u.deletedAt ? u : undefined; + const rating = live ? ratingIn(theirLevels, userId, sport) : null; + return { + firstName: firstName(live?.name), + avatarUrl: live ? avatarUrlOf(live.avatarUrl) : null, + level: rating ? rating.level : null, + isBooker, + }; + }; + const playersOf = (g: GameRow): OpenGamePlayer[] | null => { + if (!seesPlayers(g)) return null; + const b = g.booking; + const sport = b.resource.sport; + return [ + ...(b.bookedByUserId ? [person(b.bookedByUserId, sport, true)] : []), + ...b.participants.map((p) => + p.userId + ? person(p.userId, sport, false) + : { firstName: firstName(p.guestName), avatarUrl: null, level: null, isBooker: false }, + ), + ]; + }; + return rows.map((g): OpenGameSummary => { const b = g.booking; const v = b.resource.venue; @@ -312,7 +417,16 @@ async function summarise( sport: b.resource.sport, resourceType: b.resource.resourceType, courtName: b.resource.name, - venue: { id: v.id, slug: v.slug, name: v.name, city: v.city }, + isIndoor: b.resource.isIndoor, + venue: { + id: v.id, + slug: v.slug, + name: v.name, + city: v.city, + logoUrl: logos.get(v.tenantId) ?? null, + }, + courtPrice: { totalCents: b.totalCents, currency: b.currency }, + players: playersOf(g), startTs: b.startTs, endTs: b.endTs, timezone: v.timezone, @@ -357,6 +471,16 @@ export interface OpenGamesFilter { sport?: SportType; /** A calendar day at the venue, `YYYY-MM-DD`. */ date?: string; + /** A range of days at the venue, `YYYY-MM-DD`, both ends included (#668). */ + dateFrom?: string; + dateTo?: string; + /** Start hour at the venue, from `fromHour` (0–23) up to before `toHour` (1–24) (#668). */ + fromHour?: number; + toHour?: number; + /** Indoor or outdoor courts only (#668). */ + indoor?: boolean; + /** Games whose level wish includes this whole level, or that wish none (#668). */ + level?: number; city?: string; venueId?: string; } @@ -420,6 +544,21 @@ export async function listOpenGames( if (day) { where.push(Prisma.sql`to_char(b."startTs" AT TIME ZONE v.timezone, 'YYYY-MM-DD') = ${day}`); } + const localDay = Prisma.sql`to_char(b."startTs" AT TIME ZONE v.timezone, 'YYYY-MM-DD')`; + if (filter.dateFrom && ISO_DAY.test(filter.dateFrom)) { + where.push(Prisma.sql`${localDay} >= ${filter.dateFrom}`); + } + if (filter.dateTo && ISO_DAY.test(filter.dateTo)) { + where.push(Prisma.sql`${localDay} <= ${filter.dateTo}`); + } + const localHour = Prisma.sql`extract(hour from b."startTs" AT TIME ZONE v.timezone)`; + if (filter.fromHour !== undefined) where.push(Prisma.sql`${localHour} >= ${filter.fromHour}`); + if (filter.toHour !== undefined) where.push(Prisma.sql`${localHour} < ${filter.toHour}`); + if (filter.indoor !== undefined) where.push(Prisma.sql`r."isIndoor" = ${filter.indoor}`); + if (filter.level !== undefined) { + where.push(Prisma.sql`(g."levelMin" IS NULL OR g."levelMin" <= ${filter.level})`); + where.push(Prisma.sql`(g."levelMax" IS NULL OR g."levelMax" >= ${filter.level})`); + } if (blocked.length > 0) { where.push(Prisma.sql`b."bookedByUserId" NOT IN (${Prisma.join(blocked)})`); } diff --git a/src/app/api/v1/_lib/open-play-dto.ts b/src/app/api/v1/_lib/open-play-dto.ts index 7e6b2e47..db92adda 100644 --- a/src/app/api/v1/_lib/open-play-dto.ts +++ b/src/app/api/v1/_lib/open-play-dto.ts @@ -3,6 +3,7 @@ import type { OpenGameRequestStatus, SportType } from '@prisma/client'; import type { BookingGameView, MyGameRequest, + OpenGamePlayer, OpenGameSummary, RequestCard, } from '@/app-layer/usecases/open-play'; @@ -21,7 +22,11 @@ export interface OpenGameDto { sport: SportType; resourceType: string; courtName: string; - venue: { id: string; slug: string; name: string; city: string }; + isIndoor: boolean; + venue: { id: string; slug: string; name: string; city: string; logoUrl: string | null }; + courtPrice: { totalCents: number; currency: string }; + /** Booker first; null unless the viewer books, plays or has asked (#668). */ + players: OpenGamePlayer[] | null; startTs: string; endTs: string; timezone: string; @@ -50,7 +55,10 @@ export function toOpenGameDto(g: OpenGameSummary): OpenGameDto { sport: g.sport, resourceType: g.resourceType, courtName: g.courtName, + isIndoor: g.isIndoor, venue: g.venue, + courtPrice: g.courtPrice, + players: g.players, startTs: g.startTs.toISOString(), endTs: g.endTs.toISOString(), timezone: g.timezone, diff --git a/src/app/api/v1/games/route.ts b/src/app/api/v1/games/route.ts index f9e5f288..7da148b0 100644 --- a/src/app/api/v1/games/route.ts +++ b/src/app/api/v1/games/route.ts @@ -7,16 +7,35 @@ import { page } from '@/app/api/v1/_lib/envelope'; import { toOpenGameDto } from '@/app/api/v1/_lib/open-play-dto'; import { sportParam } from '@/app/api/v1/_lib/sport-param'; import { ValidationError } from '@/lib/errors/types'; +import { MAX_SPORT_LEVEL, MIN_SPORT_LEVEL } from '@/lib/profile/limits'; import { getRequestId } from '@/lib/observability/context'; const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/; +/** A whole number in [min, max], or a 400 naming the field; absent is undefined. */ +function intParam( + sp: URLSearchParams, + field: string, + min: number, + max: number, +): number | undefined { + const raw = sp.get(field); + if (raw === null) return undefined; + const n = Number(raw); + if (raw.trim() === '' || !Number.isInteger(n) || n < min || n > max) { + throw new ValidationError(`\`${field}\` is a whole number from ${min} to ${max}`, { field }); + } + return n; +} + /** * GET /api/v1/games — open games (#376), soonest first: open, before the * club's cutoff, within the next two weeks, with a spot left, at a public * venue. Anyone may read it, signed in or not; a signed-in player also gets * their own part in each (`viewer`). Filters: `sport`, `date` (YYYY-MM-DD at - * the venue), `city`, `venue` (a venue id). Keyset-paged by `cursor`. + * the venue), `dateFrom`/`dateTo`, `fromHour`/`toHour` (start hour at the + * venue), `indoor`, `level` (#668), `city`, `venue` (a venue id). Keyset-paged + * by `cursor`. */ async function handler(req: NextRequest) { const sp = req.nextUrl.searchParams; @@ -25,11 +44,29 @@ async function handler(req: NextRequest) { if (date !== undefined && !ISO_DAY.test(date)) { throw new ValidationError('`date` is YYYY-MM-DD', { field: 'date' }); } + for (const field of ['dateFrom', 'dateTo'] as const) { + const v = sp.get(field); + if (v !== null && !ISO_DAY.test(v)) + throw new ValidationError(`\`${field}\` is YYYY-MM-DD`, { field }); + } + const fromHour = intParam(sp, 'fromHour', 0, 23); + const toHour = intParam(sp, 'toHour', 1, 24); + const level = intParam(sp, 'level', MIN_SPORT_LEVEL, MAX_SPORT_LEVEL); + const indoorRaw = sp.get('indoor'); + if (indoorRaw !== null && indoorRaw !== 'true' && indoorRaw !== 'false') { + throw new ValidationError('`indoor` is true or false', { field: 'indoor' }); + } const limit = sp.has('limit') ? Number(sp.get('limit')) : undefined; const result = await listOpenGames( { sport: sportParam(sp.get('sport')), date, + dateFrom: sp.get('dateFrom') ?? undefined, + dateTo: sp.get('dateTo') ?? undefined, + fromHour, + toHour, + indoor: indoorRaw === null ? undefined : indoorRaw === 'true', + level, city: sp.get('city')?.trim() || undefined, venueId: sp.get('venue')?.trim() || undefined, }, diff --git a/tests/integration/open-play.test.ts b/tests/integration/open-play.test.ts index c281fe3c..44b6b4fd 100644 --- a/tests/integration/open-play.test.ts +++ b/tests/integration/open-play.test.ts @@ -210,6 +210,88 @@ describe('open play (#376)', () => { ); }); + describe('what the restyled card shows (#668)', () => { + type Card = Game & { + players: Array<{ firstName: string | null; isBooker: boolean; level: number | null }> | null; + courtPrice: { totalCents: number; currency: string }; + isIndoor: boolean; + venue: { logoUrl: string | null }; + }; + const card = async (who: TestIdentity | null) => + (await games(who)).body.data.items[0] as unknown as Card; + + it('players only for the booker, the players, and whoever asked (owner 2026-11-12)', async () => { + const { id, gameId } = await opened(2); + + expect((await card(null)).players).toBeNull(); + expect((await card(other)).players).toBeNull(); + expect((await card(booker)).players).toEqual([ + { firstName: 'Иван', avatarUrl: null, level: null, isBooker: true }, + ]); + + // Asking shows who is on it; still only the booker. + const asked = await ask(asker, gameId); + expect(asked.res.status).toBe(201); + expect((await card(asker)).players).toEqual([expect.objectContaining({ firstName: 'Иван' })]); + + // Accepted: on the booking, and seen by both, with the level they show. + const requests = (await bookingGame(booker, id)).body.data.requests; + await answer(booker, id, requests[0]!.id, 'accept'); + const seen = (await card(booker)).players!; + expect(seen.map((p) => p.firstName)).toEqual(['Иван', 'Мария']); + expect((await card(other)).players).toBeNull(); + + // Declined: back to counts only. + await ask(third, gameId); + const again = (await bookingGame(booker, id)).body.data.requests.find( + (q) => q.status === 'PENDING', + )!; + await answer(booker, id, again.id, 'decline'); + expect((await card(third)).players).toBeNull(); + }); + + it('the court total, indoor or out, and the club logo', async () => { + await asAppSuperuser(db, (tx) => + tx.venueOrg.update({ + where: { id: club.tenantId }, + data: { logoUrl: 'https://cdn.test/l.webp' }, + }), + ); + await opened(2); + expect(await card(null)).toMatchObject({ + courtPrice: { totalCents: 2400, currency: 'EUR' }, + isIndoor: false, + venue: { logoUrl: 'https://cdn.test/l.webp' }, + }); + }); + + it('filters by days, start hour, indoor and level', async () => { + const { id } = await opened(2); + const start = ( + await asAppSuperuser(db, (tx) => tx.booking.findUniqueOrThrow({ where: { id } })) + ).startTs; + const at = (o: Intl.DateTimeFormatOptions) => + new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Sofia', ...o }).format(start); + const day = at({ year: 'numeric', month: '2-digit', day: '2-digit' }); + const hour = Number(at({ hour: '2-digit', hourCycle: 'h23' })); + const count = async (q: string) => (await games(null, q)).body.data.items.length; + + expect(await count(`?dateFrom=${day}&dateTo=${day}`)).toBe(1); + expect(await count('?dateTo=2000-01-01')).toBe(0); + expect(await count(`?fromHour=${hour}&toHour=${hour + 1}`)).toBe(1); + expect(await count(`?toHour=${Math.max(hour, 1)}`)).toBe(hour === 0 ? 1 : 0); + expect(await count('?indoor=false')).toBe(1); + expect(await count('?indoor=true')).toBe(0); + // The wish is 3–4. + expect(await count('?level=3')).toBe(1); + expect(await count('?level=6')).toBe(0); + + for (const q of ['?level=9', '?fromHour=x', '?indoor=yes', '?dateFrom=nope']) { + expect((await games(null, q)).res.status).toBe(400); + } + }); + }); + describe('opening and listing', () => { it('lists an opened booking for everyone, without user ids or the booking id', async () => { const { id, gameId } = await opened(2); diff --git a/tests/rendered/open-play.test.tsx b/tests/rendered/open-play.test.tsx index b7e9a66b..c31f1c2d 100644 --- a/tests/rendered/open-play.test.tsx +++ b/tests/rendered/open-play.test.tsx @@ -25,7 +25,10 @@ const GAME: OpenGameDto = { sport: 'PADEL', resourceType: 'COURT', courtName: 'Корт 1', - venue: { id: 'v1', slug: 'alfa', name: 'Алфа Кортове', city: 'Sofia' }, + isIndoor: false, + venue: { id: 'v1', slug: 'alfa', name: 'Алфа Кортове', city: 'Sofia', logoUrl: null }, + courtPrice: { totalCents: 4000, currency: 'EUR' }, + players: null, startTs: '2026-10-20T16:00:00.000Z', endTs: '2026-10-20T17:30:00.000Z', timezone: 'Europe/Sofia',