mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add room invites
This commit is contained in:
+24
-9
@@ -97,6 +97,16 @@ export const BareString = z.string()
|
|||||||
/** The `{ error }` body the 400 / 403 branches return. */
|
/** The `{ error }` body the 400 / 403 branches return. */
|
||||||
export const ErrorResponse = z.object({ error: z.string() })
|
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 ----------------------------------------------------------------
|
// ---- Config ----------------------------------------------------------------
|
||||||
|
|
||||||
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
|
/** `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'),
|
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. */
|
/** The `{ Success, Message }` ack the flag toggles answer with. */
|
||||||
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
|
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'),
|
.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
|
* `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
|
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
ModerationBlockDetails,
|
ModerationBlockDetails,
|
||||||
ReportCreateResponse,
|
SuccessErrorEnvelope,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
import { createReport } from '../reports-db'
|
import { createReport } from '../reports-db'
|
||||||
@@ -136,8 +136,8 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(CreateReportRequest, 'The report'),
|
requestBody: form(CreateReportRequest, 'The report'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(ReportCreateResponse, '`{ success: true, error: "" }`'),
|
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||||
400: json(ReportCreateResponse, 'No `PlayerIdReported` in the request'),
|
400: json(SuccessErrorEnvelope, 'No `PlayerIdReported` in the request'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -194,10 +194,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(CreateWarningRequest, 'The warning'),
|
requestBody: form(CreateWarningRequest, 'The warning'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(ReportCreateResponse, '`{ success: true, error: "" }`'),
|
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||||
400: json(ReportCreateResponse, 'No `WarnedPlayerId` in the request'),
|
400: json(SuccessErrorEnvelope, 'No `WarnedPlayerId` in the request'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
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) => {
|
async (c) => {
|
||||||
|
|||||||
@@ -4,16 +4,22 @@ import { describeRoute } from 'hono-openapi'
|
|||||||
import { getAccountsByIds } from '@repo/domain'
|
import { getAccountsByIds } from '@repo/domain'
|
||||||
import { logger } from '@repo/hono-helpers'
|
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 { authedId, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
AckResponse,
|
AckResponse,
|
||||||
AUTHED,
|
AUTHED,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
|
form,
|
||||||
intQuery,
|
intQuery,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
MutualFriendDto,
|
MutualFriendDto,
|
||||||
RelationshipDto,
|
RelationshipDto,
|
||||||
|
SendMessageRequest,
|
||||||
|
SuccessErrorEnvelope,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
import {
|
import {
|
||||||
@@ -38,9 +44,6 @@ import type {
|
|||||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
const HUB_INSTANCE = 'global'
|
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
|
* 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
|
* logged and swallowed — the DB write has already committed, so a hub hiccup must not fail
|
||||||
@@ -54,7 +57,7 @@ async function notifyRelationship(
|
|||||||
try {
|
try {
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
playerId,
|
playerId,
|
||||||
RELATIONSHIP_CHANGED,
|
NotificationType.RelationshipChanged,
|
||||||
{ ...rel }
|
{ ...rel }
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -254,6 +257,77 @@ export const socialRoutes = new Hono<App>({ 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<string, unknown>
|
||||||
|
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
|
// 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
|
// 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
|
// matched any method). Auth-gated. Returns the resulting relationship from the
|
||||||
|
|||||||
@@ -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<Sent[]> =>
|
||||||
|
(await (await hub().fetch('http://do/all')).json()) as Sent[]
|
||||||
|
|
||||||
|
const send = async (fields: Record<string, string>, headers?: Record<string, string>) => {
|
||||||
|
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', () => {
|
describe('mutual friends', () => {
|
||||||
// High, distinct ids so the friendships seeded here don't collide with the
|
// High, distinct ids so the friendships seeded here don't collide with the
|
||||||
// relationship tests above.
|
// relationship tests above.
|
||||||
@@ -2301,6 +2365,7 @@ describe('openapi', () => {
|
|||||||
'POST /api/inventions/v1/settags',
|
'POST /api/inventions/v1/settags',
|
||||||
'POST /api/inventions/v1/updateprice',
|
'POST /api/inventions/v1/updateprice',
|
||||||
'POST /api/inventions/v6/save',
|
'POST /api/inventions/v6/save',
|
||||||
|
'POST /api/messages/v2/send',
|
||||||
'POST /api/playerReputation/v1/bulk',
|
'POST /api/playerReputation/v1/bulk',
|
||||||
'POST /api/playerReputation/v2/bulk',
|
'POST /api/playerReputation/v2/bulk',
|
||||||
'POST /api/players/v1/progression/bulk',
|
'POST /api/players/v1/progression/bulk',
|
||||||
|
|||||||
Reference in New Issue
Block a user