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
8 changes: 8 additions & 0 deletions messages/bg.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,14 @@
"title": "Няма обекти, отговарящи на търсенето",
"description": "Опитайте с друг град или спорт."
},
"favourite": {
"add": "Добави в любими",
"remove": "Премахни от любими",
"filter": "Любими",
"failed": "Не успяхме да запазим. Опитайте отново.",
"emptyTitle": "Още нямате любими обекти",
"emptyDescription": "Натиснете сърцето на обект, за да го намирате тук."
},
"priceFrom": "от {price} / час",
"filters": {
"label": "Филтри",
Expand Down
8 changes: 8 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,14 @@
"title": "No venues match your search",
"description": "Try a different city or sport."
},
"favourite": {
"add": "Add to favourites",
"remove": "Remove from favourites",
"filter": "Favourites",
"failed": "We couldn't save that. Try again.",
"emptyTitle": "No favourite venues yet",
"emptyDescription": "Tap the heart on a venue to find it here."
},
"priceFrom": "from {price} / hour",
"filters": {
"label": "Filters",
Expand Down
229 changes: 229 additions & 0 deletions openapi/playerz-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -17782,6 +17782,207 @@
}
}
}
},
"/me/favourites/venues": {
"get": {
"operationId": "listMyFavouriteVenues",
"summary": "Favourite clubs",
"description": "The caller's favourite clubs (#672, owner 2026-11-12).",
"tags": ["Me"],
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "OK.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["data"],
"properties": {
"data": {
"$ref": "#/components/schemas/FavouriteVenues"
}
}
}
}
}
},
"401": {
"description": "`UNAUTHORIZED` — no token, or a revoked or superseded session. The edge lets this path through, because it names no club, and the handler refuses it.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "`VIEWER_CHANGED` — web only: the `x-playerz-viewer` header names a different user from the live session's. Nothing was read or written. A native client never sends the header and never sees this; see openapi/NOTES.md.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "`INTERNAL`. The message is generic; the detail is in the server log under `x-request-id`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/me/favourites/venues/{venueId}": {
"parameters": [
{
"name": "venueId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"put": {
"operationId": "addFavouriteVenue",
"summary": "Heart a club",
"description": "Marks a public venue as a favourite (#672). Idempotent.",
"tags": ["Me"],
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "OK.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["data"],
"properties": {
"data": {
"$ref": "#/components/schemas/FavouriteState"
}
}
}
}
}
},
"401": {
"description": "`UNAUTHORIZED` — no token, or a revoked or superseded session. The edge lets this path through, because it names no club, and the handler refuses it.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "`CONFLICT`: already 200 favourites.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "`INTERNAL`. The message is generic; the detail is in the server log under `x-request-id`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "`NOT_FOUND`: no public venue with that id.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"delete": {
"operationId": "removeFavouriteVenue",
"summary": "Unheart a club",
"description": "Removes the heart (#672). Idempotent; a venue never hearted answers the same.",
"tags": ["Me"],
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "OK.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["data"],
"properties": {
"data": {
"$ref": "#/components/schemas/FavouriteState"
}
}
}
}
}
},
"401": {
"description": "`UNAUTHORIZED` — no token, or a revoked or superseded session. The edge lets this path through, because it names no club, and the handler refuses it.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "`VIEWER_CHANGED` — web only: the `x-playerz-viewer` header names a different user from the live session's. Nothing was read or written. A native client never sends the header and never sees this; see openapi/NOTES.md.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "`INTERNAL`. The message is generic; the detail is in the server log under `x-request-id`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
},
"components": {
Expand Down Expand Up @@ -28157,6 +28358,34 @@
"description": "The first of Предстоящи, or null."
}
}
},
"FavouriteVenues": {
"type": "object",
"description": "The caller's favourite clubs (#672): `ids` for the hearts on any list, `items` for «Любими», newest heart first. At most 200; a venue no longer public is left out.",
"required": ["ids", "items"],
"properties": {
"ids": {
"type": "array",
"items": {
"type": "string"
}
},
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/VenueSummary"
}
}
}
},
"FavouriteState": {
"type": "object",
"required": ["favourite"],
"properties": {
"favourite": {
"type": "boolean"
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-- P87: favourite clubs (#672, owner decision 2026-11-12).
--
-- A player's heart on a venue, for «Любими» on the phone and the web. The
-- person's own row at every club, so the boundary is the person, as for the
-- bell (P22): owner-only on app.user_id, failing closed when it is unset.
--
-- Additive: a new table the previous image never names.

CREATE TABLE "favourite_venue" (
"userId" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "favourite_venue_pkey" PRIMARY KEY ("userId","venueId")
);

CREATE INDEX "favourite_venue_venueId_idx" ON "favourite_venue"("venueId");

ALTER TABLE "favourite_venue" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "favourite_venue" FORCE ROW LEVEL SECURITY;

CREATE POLICY favourite_venue_owner_only ON "favourite_venue"
USING ("userId" = current_setting('app.user_id', true))
WITH CHECK ("userId" = current_setting('app.user_id', true));

GRANT SELECT, INSERT, DELETE ON "favourite_venue" TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON "favourite_venue" TO app_superuser;

-- A deleted account gains nothing new (P69, #465): no heart is written for it.
DROP TRIGGER IF EXISTS favourite_venue_account_not_deleted_trg ON "favourite_venue";
CREATE TRIGGER favourite_venue_account_not_deleted_trg
BEFORE INSERT OR UPDATE OF "userId" ON "favourite_venue"
FOR EACH ROW EXECUTE FUNCTION account_not_deleted('userId');
15 changes: 15 additions & 0 deletions prisma/schema/venue.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,18 @@ model VenuePlanFeature {
@@index([tenantId])
@@map("venue_plan_feature")
}

/// A club a player marked with the heart (#672, owner 2026-11-12): the phone's
/// and the web's «Любими». The person's own, at every club: owner-only RLS on
/// app.user_id (P87), like the bell. No foreign keys, as `PushSubscription`:
/// a venue that is gone simply stops being listed (the read goes through the
/// public venue filter), and account deletion removes the person's rows.
model FavouriteVenue {
userId String
venueId String
createdAt DateTime @default(now())

@@id([userId, venueId])
@@index([venueId])
@@map("favourite_venue")
}
5 changes: 5 additions & 0 deletions src/app-layer/usecases/account-deletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,11 @@ export async function deleteAccount(
count('PlayerProfile', (await db.playerProfile.deleteMany({ where: { userId } })).count);
count('PlayerSportLevel', (await db.playerSportLevel.deleteMany({ where: { userId } })).count);
count('DeviceToken', (await db.deviceToken.deleteMany({ where: { userId } })).count);
count(
// guardrail-allow: cross-tenant — the person's own hearts (#672), by their id.
'FavouriteVenue',
(await db.favouriteVenue.deleteMany({ where: { userId } })).count,
);
count(
// guardrail-allow: cross-tenant — the person's own browsers, by their id.
'PushSubscription',
Expand Down
Loading
Loading