From 05b56e698e30c4dc1ea4c538e6959734b679a4b0 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 4 Aug 2026 12:45:11 -0400 Subject: [PATCH] add room invites --- apps/api/src/openapi.ts | 33 ++++++--- apps/api/src/routes/moderation.ts | 12 ++-- apps/api/src/routes/social.ts | 82 +++++++++++++++++++++-- apps/api/src/test/integration/api.test.ts | 65 ++++++++++++++++++ 4 files changed, 173 insertions(+), 19 deletions(-) diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 245580e..9889e4d 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -97,6 +97,16 @@ export const BareString = z.string() /** The `{ error }` body the 400 / 403 branches return. */ export const ErrorResponse = z.object({ error: z.string() }) +/** + * The `{ success, error }` envelope the report / warning writes and the message send + * answer with — `error` is an empty string on success, never null, and the rejected + * branches use the same shape so there is only one thing to parse. + */ +export const SuccessErrorEnvelope = z.object({ + success: z.boolean(), + error: z.string().describe('Empty string when the call succeeded'), +}) + // ---- Config ---------------------------------------------------------------- /** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */ @@ -160,6 +170,20 @@ export const RelationshipDto = z.object({ Muted: z.int().describe('0/1 — the caller‘s own flag'), }) +/** + * `POST /api/messages/v2/send` form body — a message sent to another player. Everything + * is a string on the wire (it's form-encoded). The sender is NOT in the body — it's + * taken from the bearer token. + */ +export const SendMessageRequest = z.object({ + ToPlayerId: z.string().describe('Account id of the recipient'), + Type: z + .string() + .optional() + .describe('The Message-model type, e.g. `10`. Passed through unmapped; defaults to 0'), + Data: z.string().optional().describe('The message payload; often empty'), +}) + /** The `{ Success, Message }` ack the flag toggles answer with. */ export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() }) @@ -446,15 +470,6 @@ export const CreateReportRequest = z.object({ .describe('Instance type name, e.g. `Public`. Stored verbatim'), }) -/** - * The `{ success, error }` envelope the report / warning writes answer with — `error` - * is an empty string on success, never null. - */ -export const ReportCreateResponse = z.object({ - success: z.boolean(), - error: z.string().describe('Empty string when the record was written'), -}) - /** * `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything * is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index f166cde..3a00acd 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -12,7 +12,7 @@ import { json, JsonArray, ModerationBlockDetails, - ReportCreateResponse, + SuccessErrorEnvelope, UNAUTHORIZED_RESPONSE, } from '../openapi' import { createReport } from '../reports-db' @@ -136,8 +136,8 @@ export const moderationRoutes = new Hono({ strict: false }) security: AUTHED, requestBody: form(CreateReportRequest, 'The report'), responses: { - 200: json(ReportCreateResponse, '`{ success: true, error: "" }`'), - 400: json(ReportCreateResponse, 'No `PlayerIdReported` in the request'), + 200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'), + 400: json(SuccessErrorEnvelope, 'No `PlayerIdReported` in the request'), 401: UNAUTHORIZED_RESPONSE, }, }), @@ -194,10 +194,10 @@ export const moderationRoutes = new Hono({ strict: false }) security: AUTHED, requestBody: form(CreateWarningRequest, 'The warning'), responses: { - 200: json(ReportCreateResponse, '`{ success: true, error: "" }`'), - 400: json(ReportCreateResponse, 'No `WarnedPlayerId` in the request'), + 200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'), + 400: json(SuccessErrorEnvelope, 'No `WarnedPlayerId` in the request'), 401: UNAUTHORIZED_RESPONSE, - 403: json(ReportCreateResponse, 'A valid token with neither staff role'), + 403: json(SuccessErrorEnvelope, 'A valid token with neither staff role'), }, }), async (c) => { diff --git a/apps/api/src/routes/social.ts b/apps/api/src/routes/social.ts index fbb9a18..7ba86c0 100644 --- a/apps/api/src/routes/social.ts +++ b/apps/api/src/routes/social.ts @@ -4,16 +4,22 @@ import { describeRoute } from 'hono-openapi' import { getAccountsByIds } from '@repo/domain' import { logger } from '@repo/hono-helpers' +// The notification-type ids the hub carries (owned by the `notify` worker). Imported +// as a value — the enum has no runtime dependencies. +import { NotificationType } from '../../../notify/src/notification-types' import { authedId, unauthorized } from '../http' import { AckResponse, AUTHED, ErrorResponse, + form, intQuery, json, JsonArray, MutualFriendDto, RelationshipDto, + SendMessageRequest, + SuccessErrorEnvelope, UNAUTHORIZED_RESPONSE, } from '../openapi' import { @@ -38,9 +44,6 @@ import type { /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' -/** NotificationType.RelationshipChanged (see apps/notify/src/notification-types.ts). */ -const RELATIONSHIP_CHANGED = 1 - /** * Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are * logged and swallowed — the DB write has already committed, so a hub hiccup must not fail @@ -54,7 +57,7 @@ async function notifyRelationship( try { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( playerId, - RELATIONSHIP_CHANGED, + NotificationType.RelationshipChanged, { ...rel } ) } catch (err) { @@ -254,6 +257,77 @@ export const socialRoutes = new Hono({ strict: false }) } ) + // A message from one player to another — the "invite me!" style prompts the client + // sends. Nothing is stored: the message IS the notification, pushed to the + // recipient's hub connection (and queued by the hub if they're offline). + .post( + '/api/messages/v2/send', + describeRoute({ + tags: ['Social'], + summary: 'Send a message to another player', + description: + 'Pushes a `MessageReceived` notification to `ToPlayerId` carrying the message — ' + + 'the same frame the Coach broadcast sends (see the `notify` worker’s ' + + '`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' + + 'account and it goes to one player. The hub queues it when the recipient is ' + + 'offline, so it arrives on their next connect.\n\n' + + 'Nothing is persisted here — there is no message store, the notification is the ' + + 'whole delivery. The sender is the caller (from the bearer token), NOT a body ' + + 'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' + + 'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' + + 'empty.\n\n' + + 'Answers the same `{ success, error }` envelope as the report / warning writes, ' + + '`error` an empty string on success. A hub failure is reported honestly as a 500 ' + + 'with `success: false` — with no store behind it, a swallowed error would be a ' + + 'silently dropped message.', + security: AUTHED, + requestBody: form(SendMessageRequest, 'The message'), + responses: { + 200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'), + 400: json(SuccessErrorEnvelope, 'No `ToPlayerId` in the request'), + 401: UNAUTHORIZED_RESPONSE, + 500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'), + }, + }), + async (c) => { + const fromPlayerId = await authedId(c) + if (fromPlayerId === null) return unauthorized(c) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) + const toPlayerId = Number.parseInt(str(body.ToPlayerId) ?? '', 10) + if (Number.isNaN(toPlayerId)) { + return c.json({ success: false, error: 'ToPlayerId is required' }, 400) + } + + // The Message the notification carries. Mirrors the coach message's shape with + // a real sender and recipient; `Data` stays a string, empty included (the hub + // drops only null/undefined from the frame). + const message = { + FromPlayerId: fromPlayerId, + ToPlayerId: toPlayerId, + Type: Number.parseInt(str(body.Type) ?? '', 10) || 0, + Data: str(body.Data) ?? '', + } + + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + toPlayerId, + NotificationType.MessageReceived, + message + ) + } catch (err) { + logger.error('failed to push MessageReceived notification', { + toPlayerId, + error: err instanceof Error ? err.message : String(err), + }) + return c.json({ success: false, error: 'Failed to deliver message' }, 500) + } + + return c.json({ success: true, error: '' }) + } + ) + // Send a friend request to another player (the target arrives as `?id=`). The // client calls this as a GET; the mutations accept GET or POST (the Go handlers // matched any method). Auth-gated. Returns the resulting relationship from the diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 23b4e20..74adb02 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -2103,6 +2103,70 @@ describe('relationships', () => { }) }) +describe('messages', () => { + // The notify DO is stubbed to record every notifyPlayer call (see vitest.config). + type Sent = { + playerId: number + notificationType: number + data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string } + } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + const pushed = async (): Promise => + (await (await hub().fetch('http://do/all')).json()) as Sent[] + + const send = async (fields: Record, headers?: Record) => { + await hub().fetch('http://do/all', { method: 'DELETE' }) + return exports.default.fetch(`${ORIGIN}/api/messages/v2/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, + body: new URLSearchParams(fields), + }) + } + + // NotificationType.MessageReceived — the same frame the Coach broadcast uses. + const MESSAGE_RECEIVED = 2 + + test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => { + const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42')) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, error: '' }) + + expect(await pushed()).toEqual([ + { + // Delivered to the recipient, not the sender. + playerId: 2, + notificationType: MESSAGE_RECEIVED, + // FromPlayerId is the token's subject, not a body field. + data: { FromPlayerId: 42, ToPlayerId: 2, Type: 10, Data: '' }, + }, + ]) + }) + + test('POST /api/messages/v2/send defaults Type and Data when omitted', async () => { + const res = await send({ ToPlayerId: '2' }, await bearer('42')) + expect(res.status).toBe(200) + expect((await pushed())[0]?.data).toEqual({ + FromPlayerId: 42, + ToPlayerId: 2, + Type: 0, + Data: '', + }) + }) + + test('POST /api/messages/v2/send 400s without a recipient, pushing nothing', async () => { + const res = await send({ Type: '10' }, await bearer('42')) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ success: false, error: 'ToPlayerId is required' }) + expect(await pushed()).toEqual([]) + }) + + test('POST /api/messages/v2/send is auth-gated', async () => { + const res = await send({ ToPlayerId: '2' }) + expect(res.status).toBe(401) + expect(await pushed()).toEqual([]) + }) +}) + describe('mutual friends', () => { // High, distinct ids so the friendships seeded here don't collide with the // relationship tests above. @@ -2301,6 +2365,7 @@ describe('openapi', () => { 'POST /api/inventions/v1/settags', 'POST /api/inventions/v1/updateprice', 'POST /api/inventions/v6/save', + 'POST /api/messages/v2/send', 'POST /api/playerReputation/v1/bulk', 'POST /api/playerReputation/v2/bulk', 'POST /api/players/v1/progression/bulk',