From 9806b5b2dc53b5b436423c3cb3654d038b0613e1 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 12 Nov 2026 14:41:44 +0200 Subject: [PATCH 1/3] feat(venues): canonical city keys; GET /venues/facets (#667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decision 2026-11-12: a canonical Bulgarian city list, venues reference a city id. `venue.cityKey` (P86) is filled from the typed city by a trigger on every write, backfilled, and is a key of CITY_SPELLINGS (src/lib/geo/cities.ts), whose names already live in the catalogues. A guardrail holds the SQL function to the TypeScript list. Every venue card carries cityKey and cityNames {bg, en} (the v1 API has no request language); `?city=` takes the key; GET /api/v1/venues/facets offers one city per key and the live sports, so the phone stops showing «Sofia» and «София» as two cities and stops missing cities past its first page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DfeuWUNfBvCav72pHsXdHa --- openapi/playerz-v1.json | 114 +++++++++++++++++- .../migration.sql | 91 ++++++++++++++ prisma/schema/venue.prisma | 5 + src/app/api/v1/_lib/city-names.ts | 20 +++ src/app/api/v1/_lib/dto.ts | 10 ++ src/app/api/v1/venues/facets/route.ts | 33 +++++ src/lib/geo/cities.ts | 7 +- tests/guardrails/city-key-sql.test.ts | 36 ++++++ tests/guardrails/superuser-call-sites.test.ts | 2 + tests/integration/venue-city-key.test.ts | 101 ++++++++++++++++ tests/rendered/venues-filters.test.tsx | 2 + tests/rendered/venues-list.test.tsx | 2 + tests/unit/app/venues-page-binding.test.tsx | 3 + 13 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20261112120000_p86_venue_city_key/migration.sql create mode 100644 src/app/api/v1/_lib/city-names.ts create mode 100644 src/app/api/v1/venues/facets/route.ts create mode 100644 tests/guardrails/city-key-sql.test.ts create mode 100644 tests/integration/venue-city-key.test.ts diff --git a/openapi/playerz-v1.json b/openapi/playerz-v1.json index c4402372..dd0c4442 100644 --- a/openapi/playerz-v1.json +++ b/openapi/playerz-v1.json @@ -398,7 +398,7 @@ "name": "city", "in": "query", "required": false, - "description": "Case-insensitive EXACT match on city (not a substring match — use `q` for that). A known Bulgarian city matches every spelling a club may have stored, Latin or Cyrillic: `Sofia` also finds venues stored as `София`.", + "description": "Case-insensitive EXACT match on city (not a substring match — use `q` for that). A known Bulgarian city matches every spelling a club may have stored, Latin or Cyrillic: `Sofia` also finds venues stored as `София`. A canonical key (`sofia`, from `cityKey` or `GET /venues/facets`) or any spelling of the city (#667).", "schema": { "type": "string" } @@ -625,6 +625,57 @@ } } }, + "/venues/facets": { + "get": { + "operationId": "listVenueFacets", + "summary": "What the venue filters can offer", + "description": "The cities with a live venue and the sports with a live court (#667), from the query the web's /venues filters use. Public.", + "tags": ["Venues"], + "security": [], + "responses": { + "200": { + "description": "The cities and sports.", + "headers": { + "X-API-Version": { + "description": "Contract version marker.", + "schema": { + "type": "string" + } + }, + "x-request-id": { + "description": "Correlation id.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/VenueFacets" + } + } + } + } + } + }, + "500": { + "description": "`INTERNAL`: the database could not be read.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/venues/{id}": { "get": { "operationId": "getVenue", @@ -17700,6 +17751,8 @@ "publicSlug", "name", "city", + "cityKey", + "cityNames", "country", "avgRating", "reviewCount", @@ -17731,7 +17784,15 @@ "type": "string" }, "city": { - "type": "string" + "type": "string", + "description": "As the club typed it. Prefer `cityKey` / `cityNames` (#667)." + }, + "cityKey": { + "type": ["string", "null"], + "description": "The canonical city (#667), a key such as `sofia` or `staraZagora`: what `?city=` takes. Null for a city outside the canonical list." + }, + "cityNames": { + "$ref": "#/components/schemas/CityNames" }, "country": { "type": "string", @@ -27705,6 +27766,55 @@ "$ref": "#/components/schemas/VenuePlan" } } + }, + "CityNames": { + "type": "object", + "description": "A city's name in Bulgarian and English (#667). The API has no request language, so both go out; for a city outside the canonical list both are the text as typed.", + "required": ["bg", "en"], + "properties": { + "bg": { + "type": "string" + }, + "en": { + "type": "string" + } + } + }, + "CityFacet": { + "type": "object", + "required": ["value", "key", "names"], + "properties": { + "value": { + "type": "string", + "description": "What `?city=` takes: the key, or the typed text for a city outside the list." + }, + "key": { + "type": ["string", "null"] + }, + "names": { + "$ref": "#/components/schemas/CityNames" + } + } + }, + "VenueFacets": { + "type": "object", + "required": ["cities", "sports"], + "properties": { + "cities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CityFacet" + }, + "description": "Cities with a live venue, one per canonical city, at most 100." + }, + "sports": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SportType" + }, + "description": "Sports with a live court at a live venue." + } + } } } } diff --git a/prisma/migrations/20261112120000_p86_venue_city_key/migration.sql b/prisma/migrations/20261112120000_p86_venue_city_key/migration.sql new file mode 100644 index 00000000..3252f0c7 --- /dev/null +++ b/prisma/migrations/20261112120000_p86_venue_city_key/migration.sql @@ -0,0 +1,91 @@ +-- P86: every venue names its city by a canonical key (#667). +-- +-- ═══ WHY ═══ +-- +-- `venue.city` is free text a club typed, so the same city came back as +-- `Sofia` and `София`, and the phone's filters offered both (owner decision +-- 2026-11-12: a canonical city list, venues reference a city id). The key is +-- one of src/lib/geo/cities.ts (`CITY_SPELLINGS`), whose names live in the +-- message catalogues; `tests/guardrails/city-key-sql.test.ts` holds this +-- function to that list. +-- +-- ═══ ADDITIVE ONLY ═══ +-- +-- Nullable column, filled by a trigger on every write of `city`, so the +-- previous image (which never names it) keeps working and its writes are +-- keyed too. A city outside the list keeps a null key and its raw text. + +ALTER TABLE "venue" ADD COLUMN "cityKey" TEXT; + +-- Lower-cased comparison, as `cityKey()` in TypeScript does. IMMUTABLE: it +-- reads nothing but its argument. +CREATE OR REPLACE FUNCTION venue_city_key_for(p_city text) RETURNS text AS $$ + SELECT CASE lower(btrim(coalesce(p_city, ''))) + WHEN 'sofia' THEN 'sofia' + WHEN 'софия' THEN 'sofia' + WHEN 'plovdiv' THEN 'plovdiv' + WHEN 'пловдив' THEN 'plovdiv' + WHEN 'varna' THEN 'varna' + WHEN 'варна' THEN 'varna' + WHEN 'burgas' THEN 'burgas' + WHEN 'bourgas' THEN 'burgas' + WHEN 'бургас' THEN 'burgas' + WHEN 'ruse' THEN 'ruse' + WHEN 'rousse' THEN 'ruse' + WHEN 'русе' THEN 'ruse' + WHEN 'stara zagora' THEN 'staraZagora' + WHEN 'стара загора' THEN 'staraZagora' + WHEN 'pleven' THEN 'pleven' + WHEN 'плевен' THEN 'pleven' + WHEN 'sliven' THEN 'sliven' + WHEN 'сливен' THEN 'sliven' + WHEN 'dobrich' THEN 'dobrich' + WHEN 'добрич' THEN 'dobrich' + WHEN 'shumen' THEN 'shumen' + WHEN 'шумен' THEN 'shumen' + WHEN 'pernik' THEN 'pernik' + WHEN 'перник' THEN 'pernik' + WHEN 'haskovo' THEN 'haskovo' + WHEN 'хасково' THEN 'haskovo' + WHEN 'yambol' THEN 'yambol' + WHEN 'ямбол' THEN 'yambol' + WHEN 'pazardzhik' THEN 'pazardzhik' + WHEN 'пазарджик' THEN 'pazardzhik' + WHEN 'blagoevgrad' THEN 'blagoevgrad' + WHEN 'благоевград' THEN 'blagoevgrad' + WHEN 'veliko tarnovo' THEN 'velikoTarnovo' + WHEN 'veliko turnovo' THEN 'velikoTarnovo' + WHEN 'велико търново' THEN 'velikoTarnovo' + WHEN 'vratsa' THEN 'vratsa' + WHEN 'враца' THEN 'vratsa' + WHEN 'gabrovo' THEN 'gabrovo' + WHEN 'габрово' THEN 'gabrovo' + WHEN 'bansko' THEN 'bansko' + WHEN 'банско' THEN 'bansko' + WHEN 'samokov' THEN 'samokov' + WHEN 'самоков' THEN 'samokov' + ELSE NULL + END; +$$ LANGUAGE sql IMMUTABLE SET search_path = public, pg_temp; + +CREATE OR REPLACE FUNCTION venue_fill_city_key() RETURNS trigger AS $$ +BEGIN + NEW."cityKey" := venue_city_key_for(NEW."city"); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SET search_path = public, pg_temp; + +DROP TRIGGER IF EXISTS venue_fill_city_key_trg ON "venue"; +CREATE TRIGGER venue_fill_city_key_trg + BEFORE INSERT OR UPDATE OF "city" ON "venue" + FOR EACH ROW EXECUTE FUNCTION venue_fill_city_key(); + +-- ═══ BACKFILL ═══ +-- +-- As app_superuser: under FORCE row security the migration's own role would +-- otherwise see no venue at all (see P41). +SET ROLE app_superuser; +UPDATE "venue" SET "cityKey" = venue_city_key_for("city"); +RESET ROLE; + +CREATE INDEX "venue_cityKey_idx" ON "venue"("cityKey"); diff --git a/prisma/schema/venue.prisma b/prisma/schema/venue.prisma index 1110096d..97269bc2 100644 --- a/prisma/schema/venue.prisma +++ b/prisma/schema/venue.prisma @@ -15,6 +15,10 @@ model Venue { addressLine String city String + /// The canonical city (#667): a key of CITY_SPELLINGS (src/lib/geo/cities.ts), + /// filled from `city` by a trigger on every write (P86). Null: a city + /// outside the list, shown as typed. + cityKey String? country String @default("BG") lat Decimal @db.Decimal(10, 7) lng Decimal @db.Decimal(10, 7) @@ -79,6 +83,7 @@ model Venue { // migrations before this line existed. @@index([geog], map: "venue_geog_idx", type: Gist) @@map("venue") + @@index([cityKey]) } /// Formerly `Court`. diff --git a/src/app/api/v1/_lib/city-names.ts b/src/app/api/v1/_lib/city-names.ts new file mode 100644 index 00000000..f904e3e0 --- /dev/null +++ b/src/app/api/v1/_lib/city-names.ts @@ -0,0 +1,20 @@ +import bg from '../../../../../messages/bg.json'; +import en from '../../../../../messages/en.json'; + +import { cityKey, type CityKey } from '@/lib/geo/cities'; + +/** A city's name in both of the app's languages (#667). */ +export interface CityNames { + bg: string; + en: string; +} + +/** + * The v1 API has no request language, so a city goes out in both: the + * catalogue's names for a key of the canonical list, else the text as the + * club typed it in both slots. One source with the web (`cities.`). + */ +export function cityNames(raw: string, key: CityKey | null = cityKey(raw)): CityNames { + if (!key) return { bg: raw, en: raw }; + return { bg: bg.cities[key], en: en.cities[key] }; +} diff --git a/src/app/api/v1/_lib/dto.ts b/src/app/api/v1/_lib/dto.ts index 6380f1b7..252ce405 100644 --- a/src/app/api/v1/_lib/dto.ts +++ b/src/app/api/v1/_lib/dto.ts @@ -6,9 +6,12 @@ import type { MyNotification, NotificationSettings } from '@/app-layer/usecases/ import { bookingCutoffHours, playerCancellableUntil } from '@/lib/booking/cutoff'; import { coachFeeCents } from '@/lib/coaches/lessons'; +import type { CityKey } from '@/lib/geo/cities'; import { splitPhotos, type PhotoRow, type PhotoView } from '@/lib/media/photo-view'; import type { QueueItem } from '@/app-layer/usecases/moderation-queue'; +import { cityNames, type CityNames } from './city-names'; + /** * Wire shapes for v1. * @@ -78,7 +81,12 @@ export interface VenueSummary { */ publicSlug: string | null; name: string; + /** As the club typed it. Prefer `cityKey` and `cityNames` (#667). */ city: string; + /** The canonical city (#667): what `?city=` takes; null for a city outside the list. */ + cityKey: string | null; + /** The city's name in Bulgarian and English; the typed text in both when `cityKey` is null. */ + cityNames: CityNames; country: string; avgRating: number; reviewCount: number; @@ -106,6 +114,8 @@ export function toVenueSummary(v: VenueWithResources, clubSlug: string): VenueSu publicSlug: v.publicSlug, name: v.name, city: v.city, + cityKey: v.cityKey, + cityNames: cityNames(v.city, v.cityKey as CityKey | null), country: v.country, avgRating: Number(v.avgRating), reviewCount: v.reviewCount, diff --git a/src/app/api/v1/venues/facets/route.ts b/src/app/api/v1/venues/facets/route.ts new file mode 100644 index 00000000..26a9eb3d --- /dev/null +++ b/src/app/api/v1/venues/facets/route.ts @@ -0,0 +1,33 @@ +import { type NextRequest } from 'next/server'; + +import { listVenueFacets } from '@/app-layer/repositories/venue'; +import { asSuperuser } from '@/app/api/v1/_lib/bind'; +import { cityNames } from '@/app/api/v1/_lib/city-names'; +import { contextFromRequest } from '@/app/api/v1/_lib/context'; +import { defineV1Route } from '@/app/api/v1/_lib/define-route'; +import { ok } from '@/app/api/v1/_lib/envelope'; +import { cityKey } from '@/lib/geo/cities'; +import { getRequestId } from '@/lib/observability/context'; + +/** + * GET /api/v1/venues/facets — what the venue filters can offer (#667): the + * cities with a live venue and the sports with a live court, from the same + * query /venues uses (#357). Public, like the list. + * + * Without it the phone built its city chips from the venue pages it had + * loaded, so a city on a later page was missing and `Sofia` / `София` came + * back as two cities. Each city carries `value`, what `?city=` takes: the + * canonical key, or the typed text for a city outside the list. + */ +async function handler(req: NextRequest) { + const ctx = await contextFromRequest(req, { slug: null, requestId: getRequestId() }); + const facets = await asSuperuser(ctx, (db) => listVenueFacets(db)); + + const cities = facets.cities.map((city) => { + const key = cityKey(city); + return { value: key ?? city, key, names: cityNames(city, key) }; + }); + return ok({ cities, sports: facets.sports }); +} + +export const GET = defineV1Route(handler); diff --git a/src/lib/geo/cities.ts b/src/lib/geo/cities.ts index 79fe73ba..7b871e04 100644 --- a/src/lib/geo/cities.ts +++ b/src/lib/geo/cities.ts @@ -60,8 +60,13 @@ const BY_SPELLING = new Map( ), ); -/** The catalogue key for a stored or typed city, or null for one not in the table. */ +/** + * The catalogue key for a stored or typed city, or null for one not in the + * table. The key itself is accepted too (`staraZagora`), as the v1 API's + * `?city=` carries it since #667. + */ export function cityKey(raw: string): CityKey | null { + if (Object.hasOwn(CITY_SPELLINGS, raw)) return raw as CityKey; return BY_SPELLING.get(norm(raw)) ?? null; } diff --git a/tests/guardrails/city-key-sql.test.ts b/tests/guardrails/city-key-sql.test.ts new file mode 100644 index 00000000..1ce57f8c --- /dev/null +++ b/tests/guardrails/city-key-sql.test.ts @@ -0,0 +1,36 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { CITY_SPELLINGS } from '@/lib/geo/cities'; + +/** + * `venue.cityKey` is filled by `venue_city_key_for` (P86, #667), a SQL copy of + * CITY_SPELLINGS. A spelling added to the TypeScript list and not to SQL + * would leave new venues of that city without a key, and the phone would + * show them under the typed text. This holds the LATEST definition of the + * function to the list: adding a city means a migration that replaces it. + */ +describe('venue_city_key_for matches CITY_SPELLINGS', () => { + const dir = join(process.cwd(), 'prisma/migrations'); + const latest = readdirSync(dir) + .sort() + .map((m) => readFileSync(join(dir, m, 'migration.sql'), 'utf8')) + .filter((sql) => sql.includes('FUNCTION venue_city_key_for')) + .at(-1); + + it('is defined by a migration', () => { + expect(latest).toBeDefined(); + }); + + it('maps exactly every spelling, lower-cased, to its key', () => { + const fn = latest!.slice(latest!.indexOf('FUNCTION venue_city_key_for')); + const sql = [...fn.matchAll(/WHEN '((?:[^']|'')+)' THEN '(\w+)'/g)].map( + ([, spelling, key]) => [spelling!.replace(/''/g, "'"), key] as const, + ); + const ts = Object.entries(CITY_SPELLINGS).flatMap(([key, names]) => + names.map((n) => [n.toLocaleLowerCase('bg'), key] as const), + ); + expect(new Map(sql)).toEqual(new Map(ts)); + expect(sql).toHaveLength(ts.length); + }); +}); diff --git a/tests/guardrails/superuser-call-sites.test.ts b/tests/guardrails/superuser-call-sites.test.ts index c9934791..1b328c09 100644 --- a/tests/guardrails/superuser-call-sites.test.ts +++ b/tests/guardrails/superuser-call-sites.test.ts @@ -111,6 +111,8 @@ const ALLOWED: Record = { 'and each venue’s free times today as the public availability route reads them', 'src/app/api/v1/venues/route.ts': 'public venue list', 'src/app/api/v1/venues/near/route.ts': 'public geo search', + 'src/app/api/v1/venues/facets/route.ts': + 'public venue filters (#667): distinct cities and sports, through publicVenueFilter like /venues', 'src/app/api/v1/venues/[id]/route.ts': 'public venue detail', 'src/app/api/v1/venues/[id]/plan/route.ts': 'public venue plan (#623): the courts’ outlines, through publicVenueFilter', diff --git a/tests/integration/venue-city-key.test.ts b/tests/integration/venue-city-key.test.ts new file mode 100644 index 00000000..3349634a --- /dev/null +++ b/tests/integration/venue-city-key.test.ts @@ -0,0 +1,101 @@ +import { NextRequest } from 'next/server'; + +import { GET as getFacets } from '@/app/api/v1/venues/facets/route'; +import { GET as listVenuesRoute } from '@/app/api/v1/venues/route'; + +import { prismaTestClient, seedTenant } from '../helpers/db'; +import { asAppSuperuser } from '../helpers/rls'; + +/** + * Canonical cities (#667, P86): `venue.cityKey` is filled from the typed + * `city` by a trigger, so `Sofia` and `София` are one city to the phone. + */ +describe('venue.cityKey', () => { + const db = prismaTestClient(); + + async function venue(city: string) { + const tenant = await seedTenant({}, db); + return asAppSuperuser(db, async (tx) => { + const v = await tx.venue.create({ + data: { + tenantId: tenant.tenantId, + slug: `v-${Math.random().toString(36).slice(2, 8)}`, + name: `Venue in ${city}`, + addressLine: '1 Court St', + city, + email: 'internal@club.test', + lat: 42.7, + lng: 23.3, + }, + }); + await tx.resource.create({ + data: { + tenantId: tenant.tenantId, + venueId: v.id, + name: 'Court 1', + sport: 'PADEL', + surface: 'HARD', + basePriceCents: 2000, + }, + }); + return v.id; + }); + } + + const key = (id: string) => + asAppSuperuser(db, (tx) => + tx.venue.findUniqueOrThrow({ where: { id }, select: { cityKey: true } }), + ); + + it('is filled on insert, in any spelling, and null for a city outside the list', async () => { + expect(await key(await venue('Sofia'))).toEqual({ cityKey: 'sofia' }); + expect(await key(await venue(' софия '))).toEqual({ cityKey: 'sofia' }); + expect(await key(await venue('Стара Загора'))).toEqual({ cityKey: 'staraZagora' }); + expect(await key(await venue('Tryavna'))).toEqual({ cityKey: null }); + }); + + it('follows an edit of the city', async () => { + const id = await venue('Sofia'); + await asAppSuperuser(db, (tx) => tx.venue.update({ where: { id }, data: { city: 'Пловдив' } })); + expect(await key(id)).toEqual({ cityKey: 'plovdiv' }); + }); + + it('goes out on every card with both names, and `?city=` takes the key', async () => { + const latin = await venue('Sofia'); + const cyrillic = await venue('София'); + await venue('Plovdiv'); + + const res = await listVenuesRoute( + new NextRequest('http://t/api/v1/venues?city=sofia'), + undefined, + ); + const items = ((await res.json()) as { data: { items: Array> } }).data + .items; + expect(items.map((i) => i.id).sort()).toEqual([latin, cyrillic].sort()); + for (const i of items) { + expect(i).toMatchObject({ cityKey: 'sofia', cityNames: { bg: 'София', en: 'Sofia' } }); + } + }); + + it('facets offer one city per key, with what `?city=` takes', async () => { + await venue('Sofia'); + await venue('София'); + await venue('Tryavna'); + + const res = await getFacets(new NextRequest('http://t/api/v1/venues/facets'), undefined); + expect(res.status).toBe(200); + const { cities, sports } = ( + (await res.json()) as { + data: { cities: Array<{ value: string; key: string | null }>; sports: string[] }; + } + ).data; + expect(cities).toEqual( + expect.arrayContaining([ + { value: 'sofia', key: 'sofia', names: { bg: 'София', en: 'Sofia' } }, + { value: 'Tryavna', key: null, names: { bg: 'Tryavna', en: 'Tryavna' } }, + ]), + ); + expect(cities.filter((c) => c.key === 'sofia')).toHaveLength(1); + expect(sports).toEqual(['PADEL']); + }); +}); diff --git a/tests/rendered/venues-filters.test.tsx b/tests/rendered/venues-filters.test.tsx index 7854300c..0f69b7f8 100644 --- a/tests/rendered/venues-filters.test.tsx +++ b/tests/rendered/venues-filters.test.tsx @@ -45,6 +45,8 @@ const venue = (over: Partial = {}): VenueSummary => ({ publicSlug: 'alfa', name: 'Алфа Кортове', city: 'Sofia', + cityKey: 'sofia', + cityNames: { bg: 'София', en: 'Sofia' }, country: 'BG', avgRating: 4.5, reviewCount: 12, diff --git a/tests/rendered/venues-list.test.tsx b/tests/rendered/venues-list.test.tsx index 445c0b03..eaa108e1 100644 --- a/tests/rendered/venues-list.test.tsx +++ b/tests/rendered/venues-list.test.tsx @@ -38,6 +38,8 @@ const venue = (over: Partial = {}): VenueSummary => ({ publicSlug: 'alfa', name: 'Алфа Кортове', city: 'Sofia', + cityKey: 'sofia', + cityNames: { bg: 'София', en: 'Sofia' }, country: 'BG', avgRating: 4.5, reviewCount: 12, diff --git a/tests/unit/app/venues-page-binding.test.tsx b/tests/unit/app/venues-page-binding.test.tsx index 602d7547..59554f86 100644 --- a/tests/unit/app/venues-page-binding.test.tsx +++ b/tests/unit/app/venues-page-binding.test.tsx @@ -71,6 +71,7 @@ const row = (id: string, tenantId: string): Row => slug: `venue-${id}`, name: `Venue ${id}`, city: 'Sofia', + cityKey: 'sofia', country: 'BG', avgRating: 4.5, reviewCount: 3, @@ -164,6 +165,8 @@ describe('public venues page database binding', () => { clubSlug: 'club-t1', name: 'Venue v1', city: 'Sofia', + cityKey: 'sofia', + cityNames: { bg: 'София', en: 'Sofia' }, country: 'BG', avgRating: 4.5, reviewCount: 3, From 69ba2b99139930e807818a596226374509f80bfc Mon Sep 17 00:00:00 2001 From: user Date: Thu, 12 Nov 2026 14:48:03 +0200 Subject: [PATCH 2/3] fix(test): the city-key guardrail reads migration folders only `migration_lock.toml` sits beside them, and reading it as a folder failed the whole suite to load. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DfeuWUNfBvCav72pHsXdHa --- tests/guardrails/city-key-sql.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/guardrails/city-key-sql.test.ts b/tests/guardrails/city-key-sql.test.ts index 1ce57f8c..262dffc1 100644 --- a/tests/guardrails/city-key-sql.test.ts +++ b/tests/guardrails/city-key-sql.test.ts @@ -12,7 +12,10 @@ import { CITY_SPELLINGS } from '@/lib/geo/cities'; */ describe('venue_city_key_for matches CITY_SPELLINGS', () => { const dir = join(process.cwd(), 'prisma/migrations'); - const latest = readdirSync(dir) + const latest = readdirSync(dir, { withFileTypes: true }) + // Folders only: `migration_lock.toml` sits beside them. + .filter((e) => e.isDirectory()) + .map((e) => e.name) .sort() .map((m) => readFileSync(join(dir, m, 'migration.sql'), 'utf8')) .filter((sql) => sql.includes('FUNCTION venue_city_key_for')) From 5b9058ec0ee8760b7e3f68b3cb58bf40e22255cd Mon Sep 17 00:00:00 2001 From: user Date: Thu, 12 Nov 2026 15:14:24 +0200 Subject: [PATCH 3/3] fix(spec): VenueDetail lists the card fields it inherits (#667) `toVenueDetail` spreads `toVenueSummary`, so a venue's detail carries currency, clubLogoUrl, cityKey and cityNames, but the flat VenueDetail schema only named them on VenueSummary: the iOS client could not read them on the venue screen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DfeuWUNfBvCav72pHsXdHa --- openapi/playerz-v1.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openapi/playerz-v1.json b/openapi/playerz-v1.json index 3510eb79..67d70081 100644 --- a/openapi/playerz-v1.json +++ b/openapi/playerz-v1.json @@ -17928,11 +17928,15 @@ "publicSlug", "name", "city", + "cityKey", + "cityNames", "country", "avgRating", "reviewCount", "sports", "fromPriceCents", + "currency", + "clubLogoUrl", "coverPhotoUrl", "cover", "description", @@ -17969,6 +17973,13 @@ "city": { "type": "string" }, + "cityKey": { + "type": ["string", "null"], + "description": "The canonical city (#667), a key such as `sofia` or `staraZagora`: what `?city=` takes. Null for a city outside the canonical list." + }, + "cityNames": { + "$ref": "#/components/schemas/CityNames" + }, "country": { "type": "string" }, @@ -17989,6 +18000,14 @@ "type": ["integer", "null"], "description": "Lowest `basePriceCents` among ACTIVE resources, integer CENTS; null when there are none." }, + "currency": { + "type": ["string", "null"], + "description": "ISO 4217 code of `fromPriceCents` (#667): the cheapest active resource's currency. Null exactly when `fromPriceCents` is." + }, + "clubLogoUrl": { + "type": ["string", "null"], + "description": "The owning club's logo (#667), the same image as `ClubPublic.logoUrl`; null when the club has none." + }, "coverPhotoUrl": { "type": ["string", "null"], "description": "The cover's default URL (`cover.url`), kept for clients that predate `cover`. Prefer `cover`, which carries every width and the alt text."