Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 131 additions & 2 deletions openapi/playerz-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -662,6 +662,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",
Expand Down Expand Up @@ -17737,6 +17788,8 @@
"publicSlug",
"name",
"city",
"cityKey",
"cityNames",
"country",
"avgRating",
"reviewCount",
Expand Down Expand Up @@ -17770,7 +17823,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",
Expand Down Expand Up @@ -17867,11 +17928,15 @@
"publicSlug",
"name",
"city",
"cityKey",
"cityNames",
"country",
"avgRating",
"reviewCount",
"sports",
"fromPriceCents",
"currency",
"clubLogoUrl",
"coverPhotoUrl",
"cover",
"description",
Expand Down Expand Up @@ -17908,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"
},
Expand All @@ -17928,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."
Expand Down Expand Up @@ -27729,6 +27809,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."
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
5 changes: 5 additions & 0 deletions prisma/schema/venue.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`.
Expand Down
20 changes: 20 additions & 0 deletions src/app/api/v1/_lib/city-names.ts
Original file line number Diff line number Diff line change
@@ -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.<key>`).
*/
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] };
}
10 changes: 10 additions & 0 deletions src/app/api/v1/_lib/dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -121,6 +129,8 @@ export function toVenueSummary(
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,
Expand Down
33 changes: 33 additions & 0 deletions src/app/api/v1/venues/facets/route.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading