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
192 changes: 188 additions & 4 deletions openapi/playerz-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand All @@ -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)."
}
}
}
Expand Down Expand Up @@ -19556,6 +19644,7 @@
"resource",
"venue",
"clubSlug",
"clubLogoUrl",
"venueReview",
"canReview",
"viewerRole",
Expand Down Expand Up @@ -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",
Expand All @@ -19647,13 +19736,21 @@
"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)."
}
}
},
"clubSlug": {
"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": [
{
Expand Down Expand Up @@ -21193,6 +21290,7 @@
"required": [
"id",
"kind",
"category",
"title",
"body",
"href",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -21285,7 +21386,7 @@
"NotificationPage": {
"type": "object",
"title": "NotificationPage",
"required": ["items", "nextCursor", "unreadCount"],
"required": ["items", "nextCursor", "unreadCount", "unreadByCategory"],
"properties": {
"items": {
"type": "array",
Expand All @@ -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
}
}
}
}
},
Expand Down Expand Up @@ -22779,7 +22911,7 @@
},
{
"type": "object",
"required": ["kind", "clubId", "slug", "name"],
"required": ["kind", "clubId", "slug", "name", "logoUrl"],
"properties": {
"kind": {
"type": "string",
Expand All @@ -22793,6 +22925,10 @@
},
"name": {
"type": "string"
},
"logoUrl": {
"type": ["string", "null"],
"description": "The club's logo (#668)."
}
}
},
Expand Down Expand Up @@ -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."
}
}
}
}
}
Expand Down
25 changes: 24 additions & 1 deletion src/app-layer/repositories/booking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 };
}
8 changes: 6 additions & 2 deletions src/app-layer/usecases/messaging-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
}),
]),
Expand All @@ -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 }]),
),
};
}

Expand Down
3 changes: 2 additions & 1 deletion src/app-layer/usecases/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -414,6 +414,7 @@ function counterpartOf(
clubId: c.tenantId ?? '',
slug: club?.slug ?? '',
name: club?.name ?? '',
logoUrl: club?.logoUrl ?? null,
};
}
const id = c.otherUserId ?? '';
Expand Down
Loading
Loading