From 0c4e6c49c3800612861dac7a01ddfc88371c169e Mon Sep 17 00:00:00 2001 From: user Date: Thu, 12 Nov 2026 15:24:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(favourites):=20heart=20a=20club,=20=C2=AB?= =?UTF-8?q?=D0=9B=D1=8E=D0=B1=D0=B8=D0=BC=D0=B8=C2=BB=20on=20the=20web,=20?= =?UTF-8?q?API=20for=20the=20phone=20(#672)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decision 2026-11-12: players can mark clubs as favourites, phone and web. - P87: `favourite_venue`, owner-only RLS on app.user_id like the bell, the P69 deleted-account trigger, and removal in account deletion. - GET /me/favourites/venues (ids + cards, newest heart first, public venues only), PUT and DELETE /me/favourites/venues/{venueId} (idempotent; a non-public venue is 404; at most 200). - Web: a heart on every venue card and on the venue page (signed in only), and a «Любими» toggle on /venues. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DfeuWUNfBvCav72pHsXdHa --- messages/bg.json | 8 + messages/en.json | 8 + openapi/playerz-v1.json | 229 ++++++++++++++++++ .../migration.sql | 33 +++ prisma/schema/venue.prisma | 15 ++ src/app-layer/usecases/account-deletion.ts | 5 + src/app-layer/usecases/favourites.ts | 94 +++++++ src/app/(public)/venues/VenueList.tsx | 54 ++++- .../(public)/venues/[slug]/VenueHeader.tsx | 9 +- src/app/(public)/venues/[slug]/page.tsx | 1 + .../me/favourites/venues/[venueId]/route.ts | 34 +++ src/app/api/v1/me/favourites/venues/route.ts | 21 ++ src/components/venues/FavouriteButton.tsx | 75 ++++++ src/lib/account/deletion-plan.ts | 6 + src/lib/data/keys.ts | 4 + tests/guardrails/superuser-call-sites.test.ts | 2 + tests/integration/favourite-venues.test.ts | 110 +++++++++ tests/rendered/favourite-button.test.tsx | 62 +++++ 18 files changed, 767 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20261112130000_p87_favourite_venue/migration.sql create mode 100644 src/app-layer/usecases/favourites.ts create mode 100644 src/app/api/v1/me/favourites/venues/[venueId]/route.ts create mode 100644 src/app/api/v1/me/favourites/venues/route.ts create mode 100644 src/components/venues/FavouriteButton.tsx create mode 100644 tests/integration/favourite-venues.test.ts create mode 100644 tests/rendered/favourite-button.test.tsx diff --git a/messages/bg.json b/messages/bg.json index 40c188b5..75fff6ea 100644 --- a/messages/bg.json +++ b/messages/bg.json @@ -244,6 +244,14 @@ "title": "Няма обекти, отговарящи на търсенето", "description": "Опитайте с друг град или спорт." }, + "favourite": { + "add": "Добави в любими", + "remove": "Премахни от любими", + "filter": "Любими", + "failed": "Не успяхме да запазим. Опитайте отново.", + "emptyTitle": "Още нямате любими обекти", + "emptyDescription": "Натиснете сърцето на обект, за да го намирате тук." + }, "priceFrom": "от {price} / час", "filters": { "label": "Филтри", diff --git a/messages/en.json b/messages/en.json index 85179377..c400c271 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/openapi/playerz-v1.json b/openapi/playerz-v1.json index 58a936fa..8e728265 100644 --- a/openapi/playerz-v1.json +++ b/openapi/playerz-v1.json @@ -6513,6 +6513,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" + } + } + } + } + } + } + }, "/me/notifications": { "get": { "operationId": "listMyNotifications", @@ -27729,6 +27930,34 @@ "$ref": "#/components/schemas/VenuePlan" } } + }, + "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" + } + } } } } diff --git a/prisma/migrations/20261112130000_p87_favourite_venue/migration.sql b/prisma/migrations/20261112130000_p87_favourite_venue/migration.sql new file mode 100644 index 00000000..96bbda0c --- /dev/null +++ b/prisma/migrations/20261112130000_p87_favourite_venue/migration.sql @@ -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'); diff --git a/prisma/schema/venue.prisma b/prisma/schema/venue.prisma index 1110096d..0219d339 100644 --- a/prisma/schema/venue.prisma +++ b/prisma/schema/venue.prisma @@ -337,3 +337,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") +} diff --git a/src/app-layer/usecases/account-deletion.ts b/src/app-layer/usecases/account-deletion.ts index 4ed8162e..ca82a515 100644 --- a/src/app-layer/usecases/account-deletion.ts +++ b/src/app-layer/usecases/account-deletion.ts @@ -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', diff --git a/src/app-layer/usecases/favourites.ts b/src/app-layer/usecases/favourites.ts new file mode 100644 index 00000000..8142f0f3 --- /dev/null +++ b/src/app-layer/usecases/favourites.ts @@ -0,0 +1,94 @@ +import { listVenuesByIds } from '@/app-layer/repositories/venue'; +import { toSummaries } from '@/app/api/v1/_lib/venue-summaries'; +import type { VenueSummary } from '@/app/api/v1/_lib/dto'; +import { runAsSuperuser, runAsUserOnly } from '@/lib/db/rls-middleware'; + +/** + * Favourite clubs (#672, owner decision 2026-11-12): the heart on a venue + * card and «Любими» on the phone and the web. + * + * The rows are the person's own (owner-only RLS, P87), read and written as + * them. The cards are public venue data and go through the public venue + * filter, as /venues does, so a club that is suspended or gone drops out of + * «Любими» without anything to clean up. + */ + +/** Enough for anyone; a cap so a script cannot grow one person's list without end. */ +export const MAX_FAVOURITE_VENUES = 200; + +export class FavouriteVenueNotFoundError extends Error { + constructor() { + super('Venue not found'); + this.name = 'FavouriteVenueNotFoundError'; + } +} + +export class TooManyFavouritesError extends Error { + constructor() { + super(`At most ${MAX_FAVOURITE_VENUES} favourite venues`); + this.name = 'TooManyFavouritesError'; + } +} + +/** The person's favourite venue ids, newest first. */ +export async function myFavouriteVenueIds(userId: string): Promise { + const rows = await runAsUserOnly(userId, (db) => + // guardrail-allow: cross-tenant — the person's own hearts, by their session id. + db.favouriteVenue.findMany({ + where: { userId }, + select: { venueId: true }, + orderBy: [{ createdAt: 'desc' }, { venueId: 'asc' }], + take: MAX_FAVOURITE_VENUES, + }), + ); + return rows.map((r) => r.venueId); +} + +/** Their cards, in the order the hearts were given; a venue no longer public is left out. */ +export async function myFavouriteVenues( + userId: string, +): Promise<{ ids: string[]; items: VenueSummary[] }> { + const ids = await myFavouriteVenueIds(userId); + // guardrail-allow: cross-tenant — public venue cards for ids the person chose, + // through publicVenueFilter like the venue index. + const cards = await runAsSuperuser(async (db) => + toSummaries(db, await listVenuesByIds(db, ids, {})), + ); + const order = new Map(ids.map((id, i) => [id, i])); + const items = cards.sort((a, z) => (order.get(a.id) ?? 0) - (order.get(z.id) ?? 0)); + return { ids: items.map((v) => v.id), items }; +} + +/** Heart on or off. Idempotent; only a public venue can be hearted. */ +export async function setFavouriteVenue(input: { + userId: string; + venueId: string; + favourite: boolean; +}): Promise<{ favourite: boolean }> { + if (input.favourite) { + // guardrail-allow: cross-tenant — is this a public venue: the venue index's filter. + const found = await runAsSuperuser((db) => listVenuesByIds(db, [input.venueId], {})); + if (found.length === 0) throw new FavouriteVenueNotFoundError(); + } + return runAsUserOnly(input.userId, async (db) => { + if (!input.favourite) { + // guardrail-allow: cross-tenant — the person's own heart, by their session id. + await db.favouriteVenue.deleteMany({ + where: { userId: input.userId, venueId: input.venueId }, + }); + return { favourite: false }; + } + // guardrail-allow: cross-tenant — the person's own hearts, by their session id. + const count = await db.favouriteVenue.count({ where: { userId: input.userId } }); + // guardrail-allow: cross-tenant — as above. + const exists = await db.favouriteVenue.findUnique({ + where: { userId_venueId: { userId: input.userId, venueId: input.venueId } }, + select: { venueId: true }, + }); + if (exists) return { favourite: true }; + if (count >= MAX_FAVOURITE_VENUES) throw new TooManyFavouritesError(); + // guardrail-allow: cross-tenant — as above. + await db.favouriteVenue.create({ data: { userId: input.userId, venueId: input.venueId } }); + return { favourite: true }; + }); +} diff --git a/src/app/(public)/venues/VenueList.tsx b/src/app/(public)/venues/VenueList.tsx index d5233254..3f66666d 100644 --- a/src/app/(public)/venues/VenueList.tsx +++ b/src/app/(public)/venues/VenueList.tsx @@ -19,7 +19,9 @@ import { Input } from '@/components/ui/input'; import { StatusBadge } from '@/components/ui/status-badge'; import { ToggleGroup } from '@/components/ui/toggle-group'; import { KEYS, type V1Page } from '@/lib/data/keys'; -import { useV1SWRInfinite } from '@/lib/data/use-v1-swr'; +import { useViewerId } from '@/lib/data/provider'; +import { useV1SWR, useV1SWRInfinite } from '@/lib/data/use-v1-swr'; +import { FavouriteButton, type FavouriteVenues } from '@/components/venues/FavouriteButton'; import { canonicalCity, cityLabel } from '@/lib/geo/cities'; import { FILTER_NAMES, filtersFromParams, type VenueFilters } from './filters'; @@ -126,6 +128,12 @@ export function VenueList({ }) { const t = useTranslations('venues'); const params = useSearchParams(); + const viewerId = useViewerId(); + // «Любими» (#672): the caller's hearted venues instead of the search. + const [onlyFavourites, setOnlyFavourites] = useState(false); + const favourites = useV1SWR & { items: VenueSummary[] }>( + viewerId && onlyFavourites ? KEYS.myFavouriteVenues() : null, + ); // Cleaned as the server page cleans them (filters.ts), then joined into one // string so the memo below changes only when a VALUE does, not on every new @@ -174,12 +182,53 @@ export function VenueList({ <> + {viewerId && ( +
+ +
+ )} + {/* Pull down to refresh, jump back to the top of a long list. The pull re-reads the list through SWR — the same GET a focus would make — rather than re-rendering the server component. */} - {items === undefined ? ( + {onlyFavourites ? ( + favourites.data === undefined ? ( + favourites.error ? ( +
+ void favourites.mutate()} + /> +
+ ) : ( + + ) + ) : favourites.data.items.length === 0 ? ( +
+ +
+ ) : ( +
    + {favourites.data.items.map((v) => ( + + ))} +
+ ) + ) : items === undefined ? ( error ? (
+ {v.reviewCount > 0 && ( // icon={null}: the badge's default status glyph is a check or an // ⓘ, which says "state" on what is a fact. The ★ is the glyph here. diff --git a/src/app/(public)/venues/[slug]/VenueHeader.tsx b/src/app/(public)/venues/[slug]/VenueHeader.tsx index 587b9c4b..acdc285c 100644 --- a/src/app/(public)/venues/[slug]/VenueHeader.tsx +++ b/src/app/(public)/venues/[slug]/VenueHeader.tsx @@ -1,3 +1,4 @@ +import { FavouriteButton } from '@/components/venues/FavouriteButton'; import { getTranslations } from 'next-intl/server'; import { VenuePhotoImg } from '@/components/media/venue-photo-img'; @@ -26,6 +27,7 @@ import { VenueBackLink } from './VenueBackLink'; * dials, and its description in the club's own words, line breaks kept. */ export async function VenueHeader({ + venueId, name, addressLine, city, @@ -35,6 +37,8 @@ export async function VenueHeader({ description = null, phone = null, }: { + /** For the heart (#672); none, none shown. */ + venueId?: string; name: string; addressLine: string; city: string; @@ -66,7 +70,10 @@ export async function VenueHeader({
- {name} +
+ {name} + {venueId && } +
{club && ( {t.rich('club', { diff --git a/src/app/(public)/venues/[slug]/page.tsx b/src/app/(public)/venues/[slug]/page.tsx index 1951103e..af13a7ee 100644 --- a/src/app/(public)/venues/[slug]/page.tsx +++ b/src/app/(public)/venues/[slug]/page.tsx @@ -229,6 +229,7 @@ export default async function VenuePage({ Играй there, and a second one would say it twice. */} {me ? : null} }) => { + const ctx = await contextFromRequest(req, { slug: null, requestId: getRequestId() }); + if (!ctx.userId) throw new UnauthorizedError('Authentication required'); + const { venueId } = await params; + try { + return ok(await setFavouriteVenue({ userId: ctx.userId, venueId, favourite })); + } catch (err) { + if (err instanceof FavouriteVenueNotFoundError) throw new NotFoundError('Venue not found'); + if (err instanceof TooManyFavouritesError) throw new ConflictError(err.message); + throw err; + } + }; +} + +export const PUT = defineV1Route(handler(true)); +export const DELETE = defineV1Route(handler(false)); diff --git a/src/app/api/v1/me/favourites/venues/route.ts b/src/app/api/v1/me/favourites/venues/route.ts new file mode 100644 index 00000000..4cf37ceb --- /dev/null +++ b/src/app/api/v1/me/favourites/venues/route.ts @@ -0,0 +1,21 @@ +import { type NextRequest } from 'next/server'; + +import { myFavouriteVenues } from '@/app-layer/usecases/favourites'; +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 { UnauthorizedError } from '@/lib/errors/types'; +import { getRequestId } from '@/lib/observability/context'; + +/** + * GET /api/v1/me/favourites/venues — the caller's favourite clubs (#672): + * `ids` for the hearts on any list, `items` (venue cards, newest heart first) + * for «Любими». One list, at most 200, so not paged. + */ +async function handler(req: NextRequest) { + const ctx = await contextFromRequest(req, { slug: null, requestId: getRequestId() }); + if (!ctx.userId) throw new UnauthorizedError('Authentication required'); + return ok(await myFavouriteVenues(ctx.userId)); +} + +export const GET = defineV1Route(handler); diff --git a/src/components/venues/FavouriteButton.tsx b/src/components/venues/FavouriteButton.tsx new file mode 100644 index 00000000..ef910797 --- /dev/null +++ b/src/components/venues/FavouriteButton.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { Heart } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; + +import { cn } from '@/lib/cn'; +import { KEYS, V1 } from '@/lib/data/keys'; +import { useViewerId } from '@/lib/data/provider'; +import { useV1Mutation } from '@/lib/data/use-v1-mutation'; +import { useV1SWR } from '@/lib/data/use-v1-swr'; + +/** `GET /api/v1/me/favourites/venues` (#672). */ +export interface FavouriteVenues { + ids: string[]; + items: unknown[]; +} + +/** + * The heart on a venue (#672, owner 2026-11-12): on and off at once, saved + * behind it. Signed out there is nothing to save to, so there is no heart. + * + * It sits above a card's stretched link (`relative z-10`), so a click on the + * heart is not a click on the card. + */ +export function FavouriteButton({ venueId, className }: { venueId: string; className?: string }) { + const t = useTranslations('venues.favourite'); + const viewerId = useViewerId(); + const key = viewerId ? KEYS.myFavouriteVenues() : null; + const { data } = useV1SWR(key); + const [failed, setFailed] = useState(false); + + const toggle = useV1Mutation({ + url: () => V1.favouriteVenue(venueId), + method: 'PUT', + target: key ? { key } : undefined, + update: (visible, on) => ({ + ...visible, + ids: on + ? [venueId, ...visible.ids.filter((id) => id !== venueId)] + : visible.ids.filter((id) => id !== venueId), + }), + fallback: { ids: [], items: [] }, + }); + const unheart = useV1Mutation({ + url: () => V1.favouriteVenue(venueId), + method: 'DELETE', + target: key ? { key } : undefined, + update: (visible) => ({ ...visible, ids: visible.ids.filter((id) => id !== venueId) }), + fallback: { ids: [], items: [] }, + }); + + if (!viewerId) return null; + const on = data?.ids.includes(venueId) ?? false; + + return ( + + ); +} diff --git a/src/lib/account/deletion-plan.ts b/src/lib/account/deletion-plan.ts index 7e2f2e7d..c1e23bd9 100644 --- a/src/lib/account/deletion-plan.ts +++ b/src/lib/account/deletion-plan.ts @@ -593,6 +593,11 @@ export const DELETION_PLAN: readonly DeletionPlanEntry[] = [ action: 'delete', reason: 'The account’s email, sent or not, with the copy it carries.', }, + { + model: 'FavouriteVenue', + action: 'delete', + reason: 'The account’s favourite clubs (#672).', + }, { model: 'PushSubscription', action: 'delete', @@ -1047,6 +1052,7 @@ export const NEW_ROWS: Readonly> = { 'MfaRecoveryCode.userId': TRIGGER, 'DeviceToken.userId': TRIGGER, 'PushSubscription.userId': TRIGGER, + 'FavouriteVenue.userId': TRIGGER, 'PlayerSportLevel.userId': TRIGGER, // ── What a club holds about a player ──────────────────────────────────── diff --git a/src/lib/data/keys.ts b/src/lib/data/keys.ts index 1a225eb2..b35eedd3 100644 --- a/src/lib/data/keys.ts +++ b/src/lib/data/keys.ts @@ -103,6 +103,8 @@ export const KEYS = { notifications: () => `${BASE}/me/notifications${query({ limit: 20 })}`, /** Which emails the caller gets (#367). `PATCH` of the same URL writes it. */ notificationSettings: () => `${BASE}/me/notification-settings`, + /** The caller's favourite clubs (#672): ids for the hearts, cards for «Любими». */ + myFavouriteVenues: () => `${BASE}/me/favourites/venues`, /** The caller's own bookings at a club, newest first, by cursor. */ myBookings: (slug: string, params: { limit?: number } = {}) => paged(`${BASE}/t/${seg(slug)}/bookings`, params), @@ -313,6 +315,8 @@ export const V1 = { `${BASE}/platform/coaches/${seg(userId)}/certificate${query({ reason })}`, /** `POST { ids }`: the bell's rows the caller has now seen (#367). */ markNotificationsRead: () => `${BASE}/me/notifications/read`, + /** `PUT` / `DELETE`: the heart on a venue (#672). */ + favouriteVenue: (venueId: string) => `${BASE}/me/favourites/venues/${seg(venueId)}`, /** `PATCH { email: { … } }`: switch an email category (#367). */ updateNotificationSettings: () => `${BASE}/me/notification-settings`, /** `POST` a new invite link; `DELETE` stops every live one (#358). */ diff --git a/tests/guardrails/superuser-call-sites.test.ts b/tests/guardrails/superuser-call-sites.test.ts index c9934791..22fed5df 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-layer/usecases/favourites.ts': + 'favourite clubs (#672): public venue cards for the ids the person chose, through publicVenueFilter', '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/favourite-venues.test.ts b/tests/integration/favourite-venues.test.ts new file mode 100644 index 00000000..841c4631 --- /dev/null +++ b/tests/integration/favourite-venues.test.ts @@ -0,0 +1,110 @@ +import { NextRequest } from 'next/server'; + +import { DELETE as unheart, PUT as heart } from '@/app/api/v1/me/favourites/venues/[venueId]/route'; +import { GET as listFavourites } from '@/app/api/v1/me/favourites/venues/route'; + +import { seedPlayer, signInAs, type TestIdentity } from '../helpers/auth'; +import { prismaTestClient, seedTenant, seedVenue, type SeededTenant } from '../helpers/db'; +import { asAppSuperuser } from '../helpers/rls'; + +/** Favourite clubs (#672): the heart, «Любими», and whose rows are whose. */ +const db = prismaTestClient(); + +const call = async ( + + route: (req: NextRequest, ctx: { params: Promise }) => Promise, + method: string, + who: TestIdentity | null, + path: string, + params: Record = {}, +) => { + const res = await route( + new NextRequest(`http://localhost:3000/api/v1${path}`, { + method, + headers: who ? { authorization: `Bearer ${who.bearer}` } : {}, + }), + { params: Promise.resolve(params) }, + ); + return { res, body: (await res.json()) as { data: T } }; +}; + +describe('favourite venues (#672)', () => { + let club: SeededTenant; + let a: { venueId: string }; + let b: { venueId: string }; + let player: TestIdentity; + let other: TestIdentity; + + const set = (who: TestIdentity, venueId: string, on: boolean) => + call<{ favourite: boolean }>( + on ? heart : unheart, + on ? 'PUT' : 'DELETE', + who, + `/me/favourites/venues/${venueId}`, + { + venueId, + }, + ); + const list = (who: TestIdentity | null) => + call<{ ids: string[]; items: Array<{ id: string }> }>( + listFavourites, + 'GET', + who, + '/me/favourites/venues', + ); + + beforeEach(async () => { + club = await seedTenant({}); + a = await seedVenue(club.tenantId, { name: 'Алфа' }); + b = await seedVenue(club.tenantId, { name: 'Бета' }); + const sign = async (label: string) => { + const id = await seedPlayer(db, club.tenantId, label); + return signInAs(db, { + userId: id, + memberships: [{ tenantId: club.tenantId, tenantSlug: club.tenantSlug, role: 'PLAYER' }], + }); + }; + player = await sign('player'); + other = await sign('other'); + }); + + it('401s signed out', async () => { + expect((await list(null)).res.status).toBe(401); + }); + + it('hearts and unhearts, idempotently, newest heart first', async () => { + expect((await set(player, a.venueId, true)).body.data).toEqual({ favourite: true }); + expect((await set(player, a.venueId, true)).body.data).toEqual({ favourite: true }); + await set(player, b.venueId, true); + + const both = await list(player); + expect(both.res.status).toBe(200); + expect(both.body.data.ids).toEqual([b.venueId, a.venueId]); + expect(both.body.data.items.map((v) => v.id)).toEqual([b.venueId, a.venueId]); + + expect((await set(player, a.venueId, false)).body.data).toEqual({ favourite: false }); + expect((await set(player, a.venueId, false)).body.data).toEqual({ favourite: false }); + expect((await list(player)).body.data.ids).toEqual([b.venueId]); + }); + + it("is the person's own: another player sees none of it", async () => { + await set(player, a.venueId, true); + expect((await list(other)).body.data.ids).toEqual([]); + // Owner-only RLS as well, not just the where clause. + const seen = await db.$transaction(async (tx) => { + await tx.$executeRawUnsafe(`SELECT set_config('app.user_id', $1, true)`, other.userId); + await tx.$executeRawUnsafe(`SET LOCAL ROLE app_user`); + return tx.favouriteVenue.count(); + }); + expect(seen).toBe(0); + }); + + it('only a public venue can be hearted; one that stops being public drops out', async () => { + expect((await set(player, 'nope', true)).res.status).toBe(404); + await set(player, a.venueId, true); + await asAppSuperuser(db, (tx) => + tx.venue.update({ where: { id: a.venueId }, data: { status: 'SUSPENDED' } }), + ); + expect((await list(player)).body.data.items).toEqual([]); + }); +}); diff --git a/tests/rendered/favourite-button.test.tsx b/tests/rendered/favourite-button.test.tsx new file mode 100644 index 00000000..4e0ba64a --- /dev/null +++ b/tests/rendered/favourite-button.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { SWRConfig } from 'swr'; + +import { FavouriteButton } from '@/components/venues/FavouriteButton'; +import { __resetSessionExpiryForTests } from '@/lib/auth/session-expiry'; +import { DataProvider, ViewerScope } from '@/lib/data/provider'; +import { __resetViewerForTests } from '@/lib/data/viewer'; + +import { messages, withIntl } from '../helpers/intl'; +import { installFakeFetch, ok } from '../unit/data/fake-v1'; + +/** The heart on a venue (#672): none signed out; on and off, saved behind it. */ +const f = messages.venues.favourite; + +function mount(viewerId: string | null) { + const button = ; + return render( + withIntl( + + new Map(), dedupingInterval: 0 }}> + {viewerId ? {button} : button} + + , + ), + ); +} + +beforeEach(() => { + __resetSessionExpiryForTests(); + __resetViewerForTests(); +}); + +it('is not there signed out', () => { + installFakeFetch(() => ok({ ids: [], items: [] })); + mount(null); + expect(screen.queryByRole('button')).toBeNull(); +}); + +it('hearts with PUT and unhearts with DELETE, pressed state following', async () => { + let ids: string[] = []; + const calls = installFakeFetch((c) => { + if (c.method === 'PUT') ids = ['v1']; + if (c.method === 'DELETE') ids = []; + return ok(c.method === 'GET' ? { ids, items: [] } : { favourite: ids.length > 0 }); + }); + mount('usr_player'); + + const add = await screen.findByRole('button', { name: f.add }); + expect(add).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(add); + const remove = await screen.findByRole('button', { name: f.remove }); + expect(remove).toHaveAttribute('aria-pressed', 'true'); + await waitFor(() => + expect( + calls.some((c) => c.method === 'PUT' && c.url.endsWith('/me/favourites/venues/v1')), + ).toBe(true), + ); + + fireEvent.click(remove); + await screen.findByRole('button', { name: f.add }); + await waitFor(() => expect(calls.some((c) => c.method === 'DELETE')).toBe(true)); +});