diff --git a/openapi/playerz-v1.json b/openapi/playerz-v1.json index c3424a36..31814b14 100644 --- a/openapi/playerz-v1.json +++ b/openapi/playerz-v1.json @@ -6596,6 +6596,15 @@ "schema": { "type": "string" } + }, + { + "name": "category", + "in": "query", + "required": false, + "description": "One group only (#668); the cursor pages within it. Any other value is 400 with `details.field: \"category\"`.", + "schema": { + "$ref": "#/components/schemas/NotificationCategory" + } } ], "responses": { @@ -17698,6 +17707,81 @@ } } } + }, + "/me/summary": { + "get": { + "operationId": "getMySummary", + "summary": "Counts for the profile tiles and the next booking", + "description": "One call for the phone's «Профил» tiles and «Начало»'s next booking (#668).", + "tags": ["Me"], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "The counts and the next booking.", + "headers": { + "X-API-Version": { + "description": "Contract version marker. Stamped on every v1 response, success included.", + "schema": { + "type": "string" + } + }, + "x-request-id": { + "description": "Correlation id; matches `error.requestId` on failures and the id in the server logs.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MySummary" + } + } + } + } + } + }, + "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": { @@ -18399,7 +18483,7 @@ "venue": { "type": "object", "description": "The club/venue, lifted out of the resource so the wire shape survives a schema change that moves the relation.", - "required": ["id", "name", "timezone"], + "required": ["id", "name", "timezone", "city"], "properties": { "id": { "type": "string", @@ -18412,6 +18496,10 @@ "timezone": { "type": "string", "description": "IANA zone, e.g. `Europe/Sofia`. The client needs it to render `startTs` as local wall-clock time rather than doing UTC arithmetic." + }, + "city": { + "type": "string", + "description": "As the club typed it (#668)." } } } @@ -19556,6 +19644,7 @@ "resource", "venue", "clubSlug", + "clubLogoUrl", "venueReview", "canReview", "viewerRole", @@ -19634,7 +19723,7 @@ "venue": { "type": "object", "description": "The club/venue, lifted out of the resource so the wire shape survives a schema change that moves the relation.", - "required": ["id", "name", "timezone"], + "required": ["id", "name", "timezone", "city"], "properties": { "id": { "type": "string", @@ -19647,6 +19736,10 @@ "timezone": { "type": "string", "description": "IANA zone, e.g. `Europe/Sofia`. The client needs it to render `startTs` as local wall-clock time rather than doing UTC arithmetic." + }, + "city": { + "type": "string", + "description": "As the club typed it (#668)." } } }, @@ -19654,6 +19747,10 @@ "type": ["string", "null"], "description": "The club's slug — what `POST /t/{slug}/bookings/{id}/cancel`, `/checkout` and `/review` take for this booking. Null only if the club row is gone; such a booking can be shown and not acted on." }, + "clubLogoUrl": { + "type": ["string", "null"], + "description": "The club's logo (#668); null when it has none or the club is gone." + }, "venueReview": { "oneOf": [ { @@ -21193,6 +21290,7 @@ "required": [ "id", "kind", + "category", "title", "body", "href", @@ -21250,6 +21348,9 @@ "description": "Treat an unknown value as a generic notification: kinds are added over time.", "x-open-enum": true }, + "category": { + "$ref": "#/components/schemas/NotificationCategory" + }, "title": { "type": "string", "maxLength": 80 @@ -21285,7 +21386,7 @@ "NotificationPage": { "type": "object", "title": "NotificationPage", - "required": ["items", "nextCursor", "unreadCount"], + "required": ["items", "nextCursor", "unreadCount", "unreadByCategory"], "properties": { "items": { "type": "array", @@ -21300,6 +21401,37 @@ "unreadCount": { "type": "integer", "minimum": 0 + }, + "unreadByCategory": { + "type": "object", + "description": "Unread per group (#668), every group present; they add up to `unreadCount`.", + "required": ["bookings", "games", "messages", "lessons", "tournaments", "other"], + "properties": { + "bookings": { + "type": "integer", + "minimum": 0 + }, + "games": { + "type": "integer", + "minimum": 0 + }, + "messages": { + "type": "integer", + "minimum": 0 + }, + "lessons": { + "type": "integer", + "minimum": 0 + }, + "tournaments": { + "type": "integer", + "minimum": 0 + }, + "other": { + "type": "integer", + "minimum": 0 + } + } } } }, @@ -22779,7 +22911,7 @@ }, { "type": "object", - "required": ["kind", "clubId", "slug", "name"], + "required": ["kind", "clubId", "slug", "name", "logoUrl"], "properties": { "kind": { "type": "string", @@ -22793,6 +22925,10 @@ }, "name": { "type": "string" + }, + "logoUrl": { + "type": ["string", "null"], + "description": "The club's logo (#668)." } } }, @@ -27973,6 +28109,54 @@ "type": "boolean" } } + }, + "NotificationCategory": { + "type": "string", + "enum": ["bookings", "games", "messages", "lessons", "tournaments", "other"], + "description": "The bell's group (#668), decided by the server from `kind`; `other` has no chip." + }, + "MySummary": { + "type": "object", + "description": "The phone's «Профил» tiles and «Начало»'s next booking (#668). Each figure comes from the use case behind the list it opens.", + "required": [ + "upcomingBookings", + "upcomingGames", + "pendingGameRequests", + "unreadNotifications", + "nextBooking" + ], + "properties": { + "upcomingBookings": { + "type": "integer", + "minimum": 0, + "description": "Резервации → Предстоящи." + }, + "upcomingGames": { + "type": "integer", + "minimum": 0, + "description": "Of those, the ones with an open game on them." + }, + "pendingGameRequests": { + "type": "integer", + "minimum": 0, + "description": "The caller's requests to join games still awaiting an answer (capped at 100)." + }, + "unreadNotifications": { + "type": "integer", + "minimum": 0 + }, + "nextBooking": { + "oneOf": [ + { + "$ref": "#/components/schemas/MyBooking" + }, + { + "type": "null" + } + ], + "description": "The first of Предстоящи, or null." + } + } } } } diff --git a/src/app-layer/repositories/booking.ts b/src/app-layer/repositories/booking.ts index 84694de2..23ff038d 100644 --- a/src/app-layer/repositories/booking.ts +++ b/src/app-layer/repositories/booking.ts @@ -89,7 +89,9 @@ const BOOKING_FIELDS = { resourceType: true, // The cutoff rides along so every booking a player is shown can say // until when they may cancel it (`cancellableUntil`, #354). - venue: { select: { id: true, name: true, timezone: true, cancellationCutoffHours: true } }, + venue: { + select: { id: true, name: true, city: true, timezone: true, cancellationCutoffHours: true }, + }, }, }, // The lesson on it, if it is one (#548): the coach's fee and the coach's own @@ -487,3 +489,24 @@ export async function readBookingPlayers( } return players; } + +/** + * How many bookings this person still has to play, and how many of those are + * open games (#668): the counts on the phone's «Профил» tiles. The same `mine` + * and `upcomingWhere` as Резервации → Предстоящи, so a tile never disagrees + * with the list it opens. + */ +export async function countUpcomingForUser( + db: PrismaClient, + input: { userId: string; now: Date }, +): Promise<{ bookings: number; games: number }> { + const where = { ...mine(input.userId), ...upcomingWhere(input.now) }; + const [bookings, games] = await Promise.all([ + // guardrail-allow: cross-tenant — a person's own bookings span every club, + // and the filter is their session-derived id, not a request parameter. + db.booking.count({ where }), + // guardrail-allow: cross-tenant — as above, those with an open game on them. + db.booking.count({ where: { ...where, openGame: { isNot: null } } }), + ]); + return { bookings, games }; +} diff --git a/src/app-layer/usecases/messaging-directory.ts b/src/app-layer/usecases/messaging-directory.ts index f2fbc976..b50cad06 100644 --- a/src/app-layer/usecases/messaging-directory.ts +++ b/src/app-layer/usecases/messaging-directory.ts @@ -70,6 +70,8 @@ export interface ClubName { tenantId: string; slug: string; name: string; + /** The club's logo (#668), for the inbox row; absent where only the name was read. */ + logoUrl?: string | null; } /** `%`, `_` and `\` are LIKE syntax; a search for "50%" means the characters. */ @@ -275,7 +277,7 @@ export async function namesFor(input: { ? Promise.resolve([]) : db.venueOrg.findMany({ where: { id: { in: tenantIds } }, - select: { id: true, slug: true, name: true }, + select: { id: true, slug: true, name: true, logoUrl: true }, take: tenantIds.length, }), ]), @@ -292,7 +294,9 @@ export async function namesFor(input: { }, ]), ), - clubs: new Map(clubs.map((c) => [c.id, { tenantId: c.id, slug: c.slug, name: c.name }])), + clubs: new Map( + clubs.map((c) => [c.id, { tenantId: c.id, slug: c.slug, name: c.name, logoUrl: c.logoUrl }]), + ), }; } diff --git a/src/app-layer/usecases/messaging.ts b/src/app-layer/usecases/messaging.ts index 9728b350..508534de 100644 --- a/src/app-layer/usecases/messaging.ts +++ b/src/app-layer/usecases/messaging.ts @@ -187,7 +187,7 @@ export type Counterpart = avatarUrl: string | null; deleted: boolean; } - | { kind: 'club'; clubId: string; slug: string; name: string } + | { kind: 'club'; clubId: string; slug: string; name: string; logoUrl: string | null } | ({ kind: 'game'; members: number } & GameChatFacts); export interface ConversationSummary { @@ -414,6 +414,7 @@ function counterpartOf( clubId: c.tenantId ?? '', slug: club?.slug ?? '', name: club?.name ?? '', + logoUrl: club?.logoUrl ?? null, }; } const id = c.otherUserId ?? ''; diff --git a/src/app-layer/usecases/my-bookings.ts b/src/app-layer/usecases/my-bookings.ts index 86a5eb17..da53f3c3 100644 --- a/src/app-layer/usecases/my-bookings.ts +++ b/src/app-layer/usecases/my-bookings.ts @@ -1,6 +1,7 @@ import type { PrismaClient, ReviewStatus } from '@prisma/client'; import { + countUpcomingForUser, getBookingForUserAcrossClubs, listBookingsForUserAcrossClubs, readBookingPlayers, @@ -9,6 +10,7 @@ import { } from '@/app-layer/repositories/booking'; import { isOpenForPlayers } from '@/app-layer/usecases/booking-players'; import { lessonCoaches } from '@/app-layer/usecases/coach-directory'; +import { playerMayCancel } from '@/lib/booking/cutoff'; import { runAsSuperuser } from '@/lib/db/rls-middleware'; /** @@ -121,7 +123,7 @@ export async function listMyBookings(input: { // the club is addressed by slug everywhere else. db.venueOrg.findMany({ where: { id: { in: tenantIds } }, - select: { id: true, slug: true }, + select: { id: true, slug: true, logoUrl: true }, take: tenantIds.length, }), // guardrail-allow: cross-tenant — the caller's OWN reviews, by the @@ -139,6 +141,7 @@ export async function listMyBookings(input: { ]); const slugByTenant = new Map(clubs.map((c) => [c.id, c.slug])); + const logoByTenant = new Map(clubs.map((c) => [c.id, c.logoUrl])); const reviewByVenue = new Map( reviews.map((r) => [ r.venueId, @@ -162,6 +165,7 @@ export async function listMyBookings(input: { coachReview, }), clubSlug: slugByTenant.get(b.tenantId) ?? null, + clubLogoUrl: logoByTenant.get(b.tenantId) ?? null, venueReview, viewerRole, // Decided HERE, by the rule below, so `GET /api/v1/me/bookings` and @@ -213,7 +217,7 @@ export async function getMyBooking(input: { userId: string; bookingId: string }) const [club, review, players, coaches, coachReviews] = await Promise.all([ db.venueOrg.findUnique({ where: { id: b.tenantId }, - select: { slug: true, onlinePaymentEnabled: true }, + select: { slug: true, logoUrl: true, onlinePaymentEnabled: true }, }), // guardrail-allow: cross-tenant — the caller's OWN review of this // booking's venue, by the session-derived authorUserId. @@ -247,6 +251,7 @@ export async function getMyBooking(input: { userId: string; bookingId: string }) coachReview, }), clubSlug: club?.slug ?? null, + clubLogoUrl: club?.logoUrl ?? null, // A club that takes no money online is paid at the club, which is every // pilot club (#354). A club gone from under its booking is treated the // same: there is nothing online to point at. @@ -286,3 +291,58 @@ export function canReview(b: { }): boolean { return b.status === 'COMPLETED' && b.venueReview === null; } + +/** The phone's «Профил» tiles and «Начало»'s next game in one read (#668). */ +export interface MySummary { + upcomingBookings: number; + /** Of those, the ones with an open game on them. */ + upcomingGames: number; + /** The caller's requests to join games that still await an answer. */ + pendingGameRequests: number; +} + +export async function mySummaryCounts(input: { userId: string; now?: Date }): Promise { + const now = input.now ?? new Date(); + return runAsSuperuser(async (db) => { + const [upcoming, requests] = await Promise.all([ + countUpcomingForUser(db, { userId: input.userId, now }), + // guardrail-allow: cross-tenant — the caller's OWN requests, by the + // session-derived userId; a request past its game's cutoff has lapsed. + db.openGameRequest.findMany({ + where: { + userId: input.userId, + status: 'PENDING', + game: { booking: { startTs: { gt: now } } }, + }, + select: { + game: { + select: { + booking: { + select: { + startTs: true, + resource: { select: { venue: { select: { cancellationCutoffHours: true } } } }, + }, + }, + }, + }, + }, + take: SUMMARY_REQUESTS_MAX, + }), + ]); + const pending = requests.filter((r) => + playerMayCancel( + r.game.booking.startTs, + r.game.booking.resource.venue.cancellationCutoffHours, + now, + ), + ).length; + return { + upcomingBookings: upcoming.bookings, + upcomingGames: upcoming.games, + pendingGameRequests: pending, + }; + }); +} + +/** A count past this is shown as this; nobody waits on a hundred games at once. */ +const SUMMARY_REQUESTS_MAX = 100; diff --git a/src/app-layer/usecases/my-notifications.ts b/src/app-layer/usecases/my-notifications.ts index 369e7917..9ad5c63d 100644 --- a/src/app-layer/usecases/my-notifications.ts +++ b/src/app-layer/usecases/my-notifications.ts @@ -1,6 +1,12 @@ import type { NotificationKind } from '@prisma/client'; import { runAsUserOnly } from '@/lib/db/rls-middleware'; +import { + CATEGORY_OF, + kindsInCategory, + NOTIFICATION_CATEGORIES, + type NotificationCategory, +} from '@/lib/notifications/category'; /** * The caller's own bell (#367): `GET /api/v1/me/notifications`, @@ -59,7 +65,15 @@ export async function listMyNotifications(input: { userId: string; cursor?: string | null; limit?: number; -}): Promise<{ items: MyNotification[]; nextCursor: string | null; unreadCount: number }> { + /** One group only (#668); the cursor pages within it. */ + category?: NotificationCategory; +}): Promise<{ + items: MyNotification[]; + nextCursor: string | null; + unreadCount: number; + /** Unread per group (#668), every group present; they add up to `unreadCount`. */ + unreadByCategory: Record; +}> { const take = clampNotificationLimit(input.limit); return runAsUserOnly(input.userId, async (db) => { let after: { createdAt: Date; id: string } | null = null; @@ -77,6 +91,7 @@ export async function listMyNotifications(input: { db.notification.findMany({ where: { userId: input.userId, + ...(input.category ? { kind: { in: kindsInCategory(input.category) } } : {}), ...(after ? { OR: [ @@ -104,12 +119,23 @@ export async function listMyNotifications(input: { // club: owner-only RLS on app.user_id is the boundary, and userId is the session's. db.notification.count({ where: { userId: input.userId, readAt: null } }), ]); + // guardrail-allow: cross-tenant — as above: the session's own unread rows, by kind. + const unreadKinds = await db.notification.groupBy({ + by: ['kind'], + where: { userId: input.userId, readAt: null }, + _count: { _all: true }, + }); + const unreadByCategory = Object.fromEntries( + NOTIFICATION_CATEGORIES.map((c) => [c, 0]), + ) as Record; + for (const k of unreadKinds) unreadByCategory[CATEGORY_OF[k.kind]] += k._count._all; const items = rows.slice(0, take); return { items, nextCursor: rows.length > take ? (items.at(-1)?.id ?? null) : null, unreadCount, + unreadByCategory, }; }); } @@ -277,3 +303,12 @@ export async function updateMyNotificationSettings( return toSettings(u, await db.deviceToken.count({ where: { userId } })); }); } + +/** The caller's unread count alone (#668), for `GET /me/summary`. */ +export async function countMyUnread(userId: string): Promise { + return runAsUserOnly(userId, (db) => + // guardrail-allow: cross-tenant — a notification belongs to a PERSON, not a + // club: owner-only RLS on app.user_id is the boundary, and userId is the session's. + db.notification.count({ where: { userId, readAt: null } }), + ); +} diff --git a/src/app/api/v1/_lib/dto.ts b/src/app/api/v1/_lib/dto.ts index 3be4cc86..84d248df 100644 --- a/src/app/api/v1/_lib/dto.ts +++ b/src/app/api/v1/_lib/dto.ts @@ -8,6 +8,7 @@ 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 { CATEGORY_OF, type NotificationCategory } from '@/lib/notifications/category'; import type { QueueItem } from '@/app-layer/usecases/moderation-queue'; import { cityNames, type CityNames } from './city-names'; @@ -390,6 +391,8 @@ export interface BookingDto { venue: { id: string; name: string; + /** As the club typed it (#668), for a card that says where. */ + city: string; timezone: string; }; } @@ -409,7 +412,13 @@ type BookingRow = { name: string; sport: string; resourceType: string; - venue: { id: string; name: string; timezone: string; cancellationCutoffHours: number }; + venue: { + id: string; + name: string; + city: string; + timezone: string; + cancellationCutoffHours: number; + }; }; /** The lesson on it, when it is one; a lesson has the coach's cutoff too. */ lesson?: { @@ -517,6 +526,7 @@ export function toBooking(b: BookingRow): BookingDto { venue: { id: b.resource.venue.id, name: b.resource.venue.name, + city: b.resource.venue.city, timezone: b.resource.venue.timezone, }, }; @@ -543,6 +553,8 @@ export interface MyBookingDto extends BookingDto { * foreign key); such a booking can be shown and not acted on. */ clubSlug: string | null; + /** The club's logo (#668), for the booking card; null when it has none or is gone. */ + clubLogoUrl: string | null; /** * The caller's review of this booking's VENUE, if they wrote one — at most * one per venue, so it may have been left against a different booking @@ -577,6 +589,7 @@ export function toMyBookingDto( coachReview?: { id: string; rating: number; status: string } | null; canReviewCoach?: boolean; clubSlug: string | null; + clubLogoUrl?: string | null; venueReview: { id: string; bookingId: string | null; rating: number; status: string } | null; canReview: boolean; viewerRole: 'BOOKER' | 'PARTICIPANT'; @@ -591,6 +604,7 @@ export function toMyBookingDto( lesson: toMyBookingLesson(b), viewerRole: b.viewerRole, clubSlug: b.clubSlug, + clubLogoUrl: b.clubLogoUrl ?? null, // Rebuilt field by field rather than passed through, for the reason at the // top of this file: a spread publishes whatever the use case adds next. venueReview: b.venueReview @@ -985,6 +999,8 @@ export type MeDto = Me; export interface NotificationDto { id: string; kind: string; + /** The bell's group (#668): what `?category=` filters by. */ + category: NotificationCategory; title: string; body: string; /** A path in the web app (`/me/bookings/{id}`); the iOS app maps `refType`/`refId`. */ @@ -1000,6 +1016,7 @@ export function toNotificationDto(n: MyNotification): NotificationDto { return { id: n.id, kind: n.kind, + category: CATEGORY_OF[n.kind], title: n.title, body: n.body, href: n.href, diff --git a/src/app/api/v1/me/notifications/route.ts b/src/app/api/v1/me/notifications/route.ts index 936969b6..8120391a 100644 --- a/src/app/api/v1/me/notifications/route.ts +++ b/src/app/api/v1/me/notifications/route.ts @@ -10,8 +10,12 @@ import { defineV1Route } from '@/app/api/v1/_lib/define-route'; import { toNotificationDto } from '@/app/api/v1/_lib/dto'; import { ok } from '@/app/api/v1/_lib/envelope'; import { UnauthorizedError, ValidationError } from '@/lib/errors/types'; +import { NOTIFICATION_CATEGORIES, type NotificationCategory } from '@/lib/notifications/category'; import { getRequestId } from '@/lib/observability/context'; +const isCategory = (s: string): s is NotificationCategory => + (NOTIFICATION_CATEGORIES as readonly string[]).includes(s); + /** * GET /api/v1/me/notifications — the caller's bell (#367), newest first. * @@ -33,13 +37,22 @@ async function handler(req: NextRequest) { throw new ValidationError('Invalid cursor', { field: 'cursor' }); } + const rawCategory = sp.get('category'); + if (rawCategory !== null && !isCategory(rawCategory)) { + throw new ValidationError(`\`category\` is one of ${NOTIFICATION_CATEGORIES.join(', ')}`, { + field: 'category', + }); + } + const category = rawCategory ?? undefined; + try { - const { items, nextCursor, unreadCount } = await listMyNotifications({ + const { items, nextCursor, unreadCount, unreadByCategory } = await listMyNotifications({ userId: ctx.userId, cursor, limit: sp.has('limit') ? Number(sp.get('limit')) : undefined, + category, }); - return ok({ items: items.map(toNotificationDto), nextCursor, unreadCount }); + return ok({ items: items.map(toNotificationDto), nextCursor, unreadCount, unreadByCategory }); } catch (err) { if (err instanceof NotificationCursorError) { throw new ValidationError('Unknown cursor', { field: 'cursor' }); diff --git a/src/app/api/v1/me/summary/route.ts b/src/app/api/v1/me/summary/route.ts new file mode 100644 index 00000000..26adfeae --- /dev/null +++ b/src/app/api/v1/me/summary/route.ts @@ -0,0 +1,41 @@ +import { type NextRequest } from 'next/server'; + +import { listMyBookings, mySummaryCounts } from '@/app-layer/usecases/my-bookings'; +import { countMyUnread } from '@/app-layer/usecases/my-notifications'; +import { contextFromRequest } from '@/app/api/v1/_lib/context'; +import { defineV1Route } from '@/app/api/v1/_lib/define-route'; +import { toMyBookingDto } from '@/app/api/v1/_lib/dto'; +import { ok } from '@/app/api/v1/_lib/envelope'; +import { UnauthorizedError } from '@/lib/errors/types'; +import { getRequestId } from '@/lib/observability/context'; + +/** + * GET /api/v1/me/summary — the phone's «Профил» tiles and «Начало»'s next + * booking in one call (#668): before it the home tab spent a bookings call on + * every visit for one card, and the tiles had no numbers at all. + * + * Each figure comes from the use case behind the list it opens, so a tile + * never says 3 over a list of 2: `upcomingBookings` is Резервации → + * Предстоящи, `nextBooking` is its first row, `unreadNotifications` is the + * bell's count. + */ +async function handler(req: NextRequest) { + const ctx = await contextFromRequest(req, { slug: null, requestId: getRequestId() }); + if (!ctx.userId) throw new UnauthorizedError('Authentication required'); + const userId = ctx.userId; + + const [counts, next, unreadNotifications] = await Promise.all([ + mySummaryCounts({ userId }), + listMyBookings({ userId, when: 'upcoming', limit: 1 }), + countMyUnread(userId), + ]); + const first = next.items[0]; + + return ok({ + ...counts, + unreadNotifications, + nextBooking: first ? toMyBookingDto(first) : null, + }); +} + +export const GET = defineV1Route(handler); diff --git a/src/lib/notifications/category.ts b/src/lib/notifications/category.ts new file mode 100644 index 00000000..11d0404c --- /dev/null +++ b/src/lib/notifications/category.ts @@ -0,0 +1,64 @@ +import type { NotificationKind } from '@prisma/client'; + +/** + * The bell's groups (#668): what the phone's «Известия» chips filter by. The + * map is exhaustive over the enum, so a kind added later does not compile + * until it is given a group, instead of landing silently under «Всички». + */ +export const NOTIFICATION_CATEGORIES = [ + 'bookings', + 'games', + 'messages', + 'lessons', + 'tournaments', + 'other', +] as const; + +export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number]; + +export const CATEGORY_OF: Record = { + BOOKING_CONFIRMED: 'bookings', + BOOKING_REMINDER: 'bookings', + BOOKING_CANCELLED: 'bookings', + SESSION_FULL: 'bookings', + PAYMENT_RECEIVED: 'bookings', + SPLIT_REQUESTED: 'bookings', + BOOKING_PLAYER_JOINED: 'bookings', + BOOKING_PLAYER_LEFT: 'bookings', + BOOKING_PLAYER_ADDED: 'bookings', + BOOKING_PLAYER_REMOVED: 'bookings', + MESSAGE_RECEIVED: 'messages', + CONTACT_REQUEST: 'messages', + OPEN_PLAY_REQUESTED: 'games', + OPEN_PLAY_ACCEPTED: 'games', + OPEN_PLAY_DECLINED: 'games', + OPEN_PLAY_LAPSED: 'games', + OPEN_PLAY_CLOSED: 'games', + MATCH_RESULT: 'games', + RESULT_REPORTED: 'games', + RESULT_DISPUTED: 'games', + RESULT_DECIDED: 'games', + COACH_PROFILE_SUBMITTED: 'lessons', + COACH_PROFILE_APPROVED: 'lessons', + COACH_PROFILE_DECLINED: 'lessons', + LESSON_BOOKED: 'lessons', + LESSON_CANCELLED: 'lessons', + LESSON_REMINDER: 'lessons', + COACH_LINK_REQUESTED: 'lessons', + COACH_LINK_ACCEPTED: 'lessons', + COACH_LINK_DECLINED: 'lessons', + COACH_LINK_ENDED: 'lessons', + TOURNAMENT_ROUND: 'tournaments', + TOURNAMENT_ENTRY_RECEIVED: 'tournaments', + TOURNAMENT_PARTNER_NAMED: 'tournaments', + TOURNAMENT_ENTRY_DECIDED: 'tournaments', + TOURNAMENT_ENTRY_WITHDRAWN: 'tournaments', + ACHIEVEMENT_UNLOCKED: 'other', +}; + +/** The kinds of one group, for a `kind IN (…)` filter. */ +export function kindsInCategory(category: NotificationCategory): NotificationKind[] { + return (Object.entries(CATEGORY_OF) as Array<[NotificationKind, NotificationCategory]>) + .filter(([, c]) => c === category) + .map(([k]) => k); +} diff --git a/tests/integration/api-v1-me-summary.test.ts b/tests/integration/api-v1-me-summary.test.ts new file mode 100644 index 00000000..0500d597 --- /dev/null +++ b/tests/integration/api-v1-me-summary.test.ts @@ -0,0 +1,183 @@ +import { NextRequest } from 'next/server'; + +import { GET as listRoute } from '@/app/api/v1/me/bookings/route'; +import { GET as notificationsRoute } from '@/app/api/v1/me/notifications/route'; +import { GET as summaryRoute } from '@/app/api/v1/me/summary/route'; + +import { seedPlayer, signInAs, type TestIdentity } from '../helpers/auth'; +import { prismaTestClient, seedTenant, seedVenue, type SeededTenant } from '../helpers/db'; +import { asAppSuperuser } from '../helpers/rls'; + +/** + * What the restyled phone reads in one go (#668): the «Профил» tiles' counts + * and «Начало»'s next booking, the bell's groups, and the club's logo and + * city on a booking card. + */ +const db = prismaTestClient(); +const HOUR = 3_600_000; + +const get = async ( + route: (req: NextRequest, ctx: { params: Promise }) => Promise, + who: TestIdentity | null, + path: string, +) => { + const res = await route( + new NextRequest(`http://localhost:3000/api/v1${path}`, { + headers: who ? { authorization: `Bearer ${who.bearer}` } : {}, + }), + { params: Promise.resolve({}) }, + ); + return { res, body: (await res.json()) as { data: T; error?: { details?: { field?: string } } } }; +}; + +interface Summary { + upcomingBookings: number; + upcomingGames: number; + pendingGameRequests: number; + unreadNotifications: number; + nextBooking: { id: string; clubLogoUrl: string | null; venue: { city: string } } | null; +} + +describe('the phone home and profile reads (#668)', () => { + let club: SeededTenant; + let court: { venueId: string; resourceId: string }; + let player: TestIdentity; + let rival: TestIdentity; + + const book = (who: TestIdentity, startsInHours: number, status: 'CONFIRMED' | 'CANCELLED' = 'CONFIRMED') => { + const start = new Date(Date.now() + startsInHours * HOUR); + return asAppSuperuser(db, (tx) => + tx.booking.create({ + data: { + tenantId: club.tenantId, + resourceId: court.resourceId, + startTs: start, + endTs: new Date(start.getTime() + HOUR), + bookedByUserId: who.userId, + totalCents: 2400, + status, + idempotencyKey: `k-${Math.random()}`, + }, + select: { id: true }, + }), + ).then((b) => b.id); + }; + + const notify = (userId: string, kind: string, read = false) => + asAppSuperuser(db, (tx) => + tx.notification.create({ + data: { + userId, + kind: kind as never, + title: 't', + body: 'b', + readAt: read ? new Date() : null, + }, + }), + ); + + beforeEach(async () => { + club = await seedTenant({}); + court = await seedVenue(club.tenantId, { name: 'Алфа Кортове' }); + await asAppSuperuser(db, (tx) => + tx.venueOrg.update({ + where: { id: club.tenantId }, + data: { logoUrl: 'https://cdn.test/l.webp' }, + }), + ); + 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'); + rival = await sign('rival'); + }); + + it('401s signed out', async () => { + expect((await get(summaryRoute, null, '/me/summary')).res.status).toBe(401); + }); + + it('counts what the lists hold, and names the next booking with its club', async () => { + const later = await book(player, 72); + const soon = await book(player, 24); + await book(player, 30, 'CANCELLED'); + await book(player, -24); + await book(rival, 5); + await asAppSuperuser(db, (tx) => + tx.openGame.create({ + data: { tenantId: club.tenantId, bookingId: later, spotsWanted: 1 }, + }), + ); + await notify(player.userId, 'BOOKING_CONFIRMED'); + await notify(player.userId, 'OPEN_PLAY_ACCEPTED'); + await notify(player.userId, 'MESSAGE_RECEIVED', true); + + const { res, body } = await get(summaryRoute, player, '/me/summary'); + expect(res.status).toBe(200); + expect(body.data).toMatchObject({ + upcomingBookings: 2, + upcomingGames: 1, + pendingGameRequests: 0, + unreadNotifications: 2, + nextBooking: { + id: soon, + clubLogoUrl: 'https://cdn.test/l.webp', + venue: { city: expect.any(String) }, + }, + }); + + // The tile matches the list it opens. + const upcoming = await get<{ items: unknown[] }>( + listRoute, + player, + '/me/bookings?when=upcoming', + ); + expect(upcoming.body.data.items).toHaveLength(2); + }); + + it('the bell: a group per notification, a filter, and unread per group', async () => { + await notify(player.userId, 'BOOKING_CONFIRMED'); + await notify(player.userId, 'BOOKING_REMINDER', true); + await notify(player.userId, 'OPEN_PLAY_ACCEPTED'); + await notify(player.userId, 'TOURNAMENT_ROUND'); + + type Page = { + items: Array<{ kind: string; category: string }>; + unreadCount: number; + unreadByCategory: Record; + }; + const all = await get(notificationsRoute, player, '/me/notifications'); + expect(all.body.data.items.map((n) => n.category).sort()).toEqual([ + 'bookings', + 'bookings', + 'games', + 'tournaments', + ]); + expect(all.body.data.unreadByCategory).toEqual({ + bookings: 1, + games: 1, + messages: 0, + lessons: 0, + tournaments: 1, + other: 0, + }); + expect(all.body.data.unreadCount).toBe(3); + + const bookings = await get( + notificationsRoute, + player, + '/me/notifications?category=bookings', + ); + expect(bookings.body.data.items.map((n) => n.kind).sort()).toEqual([ + 'BOOKING_CONFIRMED', + 'BOOKING_REMINDER', + ]); + + const bad = await get(notificationsRoute, player, '/me/notifications?category=nope'); + expect(bad.res.status).toBe(400); + expect(bad.body.error?.details?.field).toBe('category'); + }); +}); diff --git a/tests/rendered/booking-detail.test.tsx b/tests/rendered/booking-detail.test.tsx index 17a27c72..b1d69500 100644 --- a/tests/rendered/booking-detail.test.tsx +++ b/tests/rendered/booking-detail.test.tsx @@ -56,6 +56,7 @@ const detail = (over: Partial = {}): MyBookingDetailDto => ( phone: '+359 2 123 4567', }, clubSlug: 'alpha', + clubLogoUrl: null, venueReview: null, canReview: false, payAtClub: true, @@ -362,7 +363,7 @@ describe('the Предстоящи / Минали tabs', () => { const { players: _p, payAtClub: _a, venue, ...rest } = detail(); return { ...rest, - venue: { id: venue.id, name: venue.name, timezone: venue.timezone }, + venue: { id: venue.id, name: venue.name, city: venue.city, timezone: venue.timezone }, ...over, }; }; diff --git a/tests/rendered/booking-players.test.tsx b/tests/rendered/booking-players.test.tsx index 2827e012..3a8d3b73 100644 --- a/tests/rendered/booking-players.test.tsx +++ b/tests/rendered/booking-players.test.tsx @@ -62,6 +62,7 @@ const booking = (over: Partial = {}): MyBookingDetailDto => phone: null, }, clubSlug: 'alpha', + clubLogoUrl: null, venueReview: null, canReview: false, viewerRole: 'BOOKER', diff --git a/tests/rendered/messaging.test.tsx b/tests/rendered/messaging.test.tsx index cf28a09a..9c074450 100644 --- a/tests/rendered/messaging.test.tsx +++ b/tests/rendered/messaging.test.tsx @@ -76,7 +76,7 @@ describe('ConversationView', () => { it('names every sender, and a staff reply with its club', async () => { const seed = conversation({ kind: 'club', - counterpart: { kind: 'club', clubId: 't1', slug: 'levski', name: 'Тенис клуб Левски' }, + counterpart: { kind: 'club', clubId: 't1', slug: 'levski', name: 'Тенис клуб Левски', logoUrl: null }, messages: [ msg({ id: 'm1', mine: true, sender: { name: 'Аз', deleted: false, clubName: null } }), msg({ diff --git a/tests/rendered/my-bookings.test.tsx b/tests/rendered/my-bookings.test.tsx index e4ca1d30..c2490671 100644 --- a/tests/rendered/my-bookings.test.tsx +++ b/tests/rendered/my-bookings.test.tsx @@ -54,8 +54,9 @@ const booking = (over: Partial = {}): MyBookingDto => ({ createdAt: '2026-09-20T10:00:00.000Z', cancellableUntil: null, resource: { id: 'r1', name: 'Корт 1', sport: 'PADEL', resourceType: 'COURT' }, - venue: { id: 'v1', name: 'Алфа Кортове', timezone: 'Europe/Sofia' }, + venue: { id: 'v1', name: 'Алфа Кортове', city: 'Sofia', timezone: 'Europe/Sofia' }, clubSlug: 'alpha', + clubLogoUrl: null, venueReview: null, canReview: true, viewerRole: 'BOOKER', @@ -167,7 +168,7 @@ describe('the server seed', () => { booking(), booking({ id: 'bk2', - venue: { id: 'v2', name: 'Лондон', timezone: 'Europe/London' }, + venue: { id: 'v2', name: 'Лондон', city: 'Sofia', timezone: 'Europe/London' }, }), ]), ); @@ -343,7 +344,7 @@ describe('reviewing a venue, optimistically', () => { page([ booking(), booking({ id: 'bk-again', canReview: true }), - booking({ id: 'bk-other', venue: { id: 'v2', name: 'Друг', timezone: 'Europe/Sofia' } }), + booking({ id: 'bk-other', venue: { id: 'v2', name: 'Друг', city: 'Sofia', timezone: 'Europe/Sofia' } }), ]), ); diff --git a/tests/rendered/notification-bell.test.tsx b/tests/rendered/notification-bell.test.tsx index 149bbfe1..310cbf38 100644 --- a/tests/rendered/notification-bell.test.tsx +++ b/tests/rendered/notification-bell.test.tsx @@ -22,6 +22,7 @@ const n = messages.common.nav; const row = (id: string, over: Partial = {}): NotificationDto => ({ id, kind: 'BOOKING_CONFIRMED', + category: 'bookings', title: 'Резервацията е потвърдена', body: 'Sofia Padel, Корт 1 · пт, 9 окт., 19:00–20:30', href: `/me/bookings/b_${id}`, diff --git a/tests/unit/api-v1/dto-my-booking.test.ts b/tests/unit/api-v1/dto-my-booking.test.ts index be952c0a..58715214 100644 --- a/tests/unit/api-v1/dto-my-booking.test.ts +++ b/tests/unit/api-v1/dto-my-booking.test.ts @@ -25,6 +25,7 @@ describe('toMyBookingDto', () => { venue: { id: 'v1', name: 'Padel Palace', + city: 'Sofia', timezone: 'Europe/Sofia', cancellationCutoffHours: 24, }, @@ -38,7 +39,7 @@ describe('toMyBookingDto', () => { resourceId: 'r1', }; - it('is BookingDto plus clubSlug, venueReview, canReview, viewerRole and the lesson — and nothing else', () => { + it('is BookingDto plus clubSlug, clubLogoUrl, venueReview, canReview, viewerRole and the lesson — and nothing else', () => { expect(toMyBookingDto(row)).toEqual({ id: 'b1', status: 'COMPLETED', @@ -54,8 +55,9 @@ describe('toMyBookingDto', () => { cancellableUntil: null, // `resourceType` (P51): what the copy calls it — a TRACK is a "писта". resource: { id: 'r1', name: 'Court 1', sport: 'PADEL', resourceType: 'COURT' }, - venue: { id: 'v1', name: 'Padel Palace', timezone: 'Europe/Sofia' }, + venue: { id: 'v1', name: 'Padel Palace', city: 'Sofia', timezone: 'Europe/Sofia' }, clubSlug: 'slot-club-sofia', + clubLogoUrl: null, venueReview: null, canReview: true, viewerRole: 'BOOKER', @@ -127,7 +129,7 @@ describe('toMyBookingDto', () => { it('says until when the PLAYER may cancel a live booking: the cutoff before the start', () => { const dto = toMyBookingDto({ ...row, status: 'CONFIRMED' }); expect(dto.cancellableUntil).toBe('2026-07-14T06:00:00Z'); - expect(dto.venue).toEqual({ id: 'v1', name: 'Padel Palace', timezone: 'Europe/Sofia' }); + expect(dto.venue).toEqual({ id: 'v1', name: 'Padel Palace', city: 'Sofia', timezone: 'Europe/Sofia' }); }); it('tells an added player there is nothing for them to cancel (#358)', () => {