diff --git a/apps/api/src/context.ts b/apps/api/src/context.ts index a8a68d3..7b4a3ab 100644 --- a/apps/api/src/context.ts +++ b/apps/api/src/context.ts @@ -1,5 +1,8 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' +// Type-only import (erased at build) of the DO class owned by the `notify` +// worker, so the cross-worker RPC stub is fully typed. +import type { NotificationsHub } from '../../notify/src/notifications-hub' export type Env = SharedHonoEnv & { // Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value @@ -18,6 +21,9 @@ export type Env = SharedHonoEnv & { // Image bucket (shared with the `img` worker, which serves objects back by // key). Uploaded saved images are written here. IMAGES: R2Bucket + // SignalR notifications hub (DO owned by the `notify` worker). Bound here to + // push RelationshipChanged notifications when a player's relationship changes. + RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace } /** Variables can be extended */ diff --git a/apps/api/src/routes/social.ts b/apps/api/src/routes/social.ts index 3a711ff..7ab1551 100644 --- a/apps/api/src/routes/social.ts +++ b/apps/api/src/routes/social.ts @@ -1,5 +1,7 @@ import { Hono } from 'hono' +import { logger } from '@repo/hono-helpers' + import { authedId, unauthorized } from '../http' import { acceptFriendRequest, @@ -12,6 +14,43 @@ import { import type { Context } from 'hono' import type { App } from '../context' +import type { RelationshipFlag } from '../relationships-db' + +/** 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 + +/** + * Apply a per-player relationship flag toggle (favorited/ignored/muted) and hand the + * result to the client the way the Go server does: the resulting relationship rides a + * `RelationshipChanged` hub notification to the caller, and the HTTP body is just the + * `{ Success, Message }` ack. Hub failures are logged and swallowed — the DB write has + * already committed, so a hub hiccup must not fail the request. + */ +async function applyFlag( + c: Context, + playerId: number, + otherId: number, + flag: RelationshipFlag, + value: boolean +): Promise { + const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value) + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + playerId, + RELATIONSHIP_CHANGED, + { ...rel } + ) + } catch (err) { + logger.error('failed to push RelationshipChanged notification', { + playerId, + error: err instanceof Error ? err.message : String(err), + }) + } + return c.json({ Success: true, Message: '' }) +} /** * Read the other player's id from a relationship-mutation request. The exact wire @@ -89,43 +128,60 @@ export const socialRoutes = new Hono({ strict: false }) return c.json(await addFriend(c.env.DB, id, target)) }) - // Ignore / mute another player (target arrives as `PlayerId` in the POST body). - // These set a per-player flag on the *caller's* side of the relationship row, - // creating a bare (None) row when the pair aren't otherwise related — so you can - // ignore/mute someone you've never friended. Auth-gated. Returns the resulting - // relationship from the caller's point of view. + // Ignore / mute another player, and their inverses unignore / unmute (target + // arrives as `PlayerId` in the POST body). These set a per-player flag on the + // *caller's* side of the relationship row, creating a bare (None) row when the + // pair aren't otherwise related — so you can ignore/mute someone you've never + // friended. The un- variants just clear the same flag. Auth-gated. The resulting + // relationship is delivered via a RelationshipChanged hub notification (see + // applyFlag); the HTTP body is just the { Success, Message } ack. .on(['GET', 'POST'], '/api/relationships/v1/ignore', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const target = await targetPlayerId(c) if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return c.json(await setRelationshipFlag(c.env.DB, id, target, 'ignored', true)) + return applyFlag(c, id, target, 'ignored', true) + }) + .on(['GET', 'POST'], '/api/relationships/v1/unignore', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const target = await targetPlayerId(c) + if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) + return applyFlag(c, id, target, 'ignored', false) }) .on(['GET', 'POST'], '/api/relationships/v1/mute', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const target = await targetPlayerId(c) if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return c.json(await setRelationshipFlag(c.env.DB, id, target, 'muted', true)) + return applyFlag(c, id, target, 'muted', true) + }) + .on(['GET', 'POST'], '/api/relationships/v1/unmute', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const target = await targetPlayerId(c) + if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) + return applyFlag(c, id, target, 'muted', false) }) // Favorite / unfavorite another player (the client calls these as a GET with the // target in `?id=`). Same per-side flag mechanics as ignore/mute above: the write // lands on the *caller's* side of the row, and favoriting someone you have no - // relationship with creates a bare (None) row. Auth-gated. + // relationship with creates a bare (None) row. Auth-gated. Result rides a + // RelationshipChanged notification; the body is the { Success, Message } ack. .on(['GET', 'POST'], '/api/relationships/v1/favorite', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const target = await targetPlayerId(c) if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return c.json(await setRelationshipFlag(c.env.DB, id, target, 'favorited', true)) + return applyFlag(c, id, target, 'favorited', true) }) .on(['GET', 'POST'], '/api/relationships/v1/unfavorite', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const target = await targetPlayerId(c) if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return c.json(await setRelationshipFlag(c.env.DB, id, target, 'favorited', false)) + return applyFlag(c, id, target, 'favorited', false) }) .get('/api/messages/v2/get', (c) => c.json([])) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index ffe0579..979773d 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1458,6 +1458,43 @@ describe('relationships', () => { return (await res.json()) as Rel[] } + // Standard ack the flag endpoints (favorite/ignore/mute + inverses) now return — + // the relationship detail rides a RelationshipChanged hub notification instead. + const ACK = { Success: true, Message: '' } + + // POST a flag mutation the real client way (form body `PlayerId=`), returning + // the parsed ack body. + async function ackFlag(path: string, sub: string, playerId: number) { + return (await ( + await exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `PlayerId=${playerId}`, + }) + ).json()) as { Success: boolean; Message: string } + } + + // A player's own-side flags read straight from the relationship row. The None row a + // flag can create for an otherwise-unrelated pair isn't reported by v2/get, so the + // flag effect is verified here instead of through the (now ack-only) response. + async function ownFlags(playerId: number, otherId: number) { + const row = (await env.DB.prepare( + `SELECT requester_id, requester_favorited, requester_ignored, requester_muted, + target_favorited, target_ignored, target_muted + FROM relationship + WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)` + ) + .bind(playerId, otherId) + .first()) as Record | null + if (!row) return null + const isRequester = row.requester_id === playerId + return { + Favorited: isRequester ? row.requester_favorited : row.target_favorited, + Ignored: isRequester ? row.requester_ignored : row.target_ignored, + Muted: isRequester ? row.requester_muted : row.target_muted, + } + } + test('GET /api/relationships/v2/get is auth-gated', async () => { expect((await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`)).status).toBe(401) }) @@ -1537,64 +1574,57 @@ describe('relationships', () => { test('v1 ignore/mute set the caller’s own side of the relationship', async () => { type FullRel = { PlayerID: number; RelationshipType: number; Ignored: number; Muted: number } - // POST the real client shape: form body `PlayerId=`. - const flag = async (path: string, sub: string, playerId: number) => - (await ( - await exports.default.fetch(`${ORIGIN}${path}`, { - method: 'POST', - headers: { - ...(await bearer(sub)), - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: `PlayerId=${playerId}`, - }) - ).json()) as FullRel - // 700 ignores 701 with no prior relationship → a bare None row, the caller's side flagged. - expect(await flag('/api/relationships/v1/ignore', '700', 701)).toMatchObject({ - PlayerID: 701, - RelationshipType: 0, - Ignored: 1, - Muted: 0, - }) + // 700 ignores 701 with no prior relationship → a bare None row, the caller's side + // flagged. The response is now just the ack; the flag is verified on the row. + expect(await ackFlag('/api/relationships/v1/ignore', '700', 701)).toEqual(ACK) + expect(await ownFlags(700, 701)).toMatchObject({ Ignored: 1, Muted: 0 }) // 700 then mutes 701 → same row, mute added, the earlier ignore preserved. - expect(await flag('/api/relationships/v1/mute', '700', 701)).toMatchObject({ - PlayerID: 701, - Ignored: 1, - Muted: 1, - }) + expect(await ackFlag('/api/relationships/v1/mute', '700', 701)).toEqual(ACK) + expect(await ownFlags(700, 701)).toMatchObject({ Ignored: 1, Muted: 1 }) // The tricky case: the caller is the row's TARGET. 710 sends 711 a request // (710 = requester); 711 ignoring 710 must flag the target side, not the requester's. await mutate('/api/relationships/v2/sendfriendrequest', '710', 711) - expect(await flag('/api/relationships/v1/ignore', '711', 710)).toMatchObject({ - PlayerID: 710, - RelationshipType: 2, // 711 sees 710's request as Received - Ignored: 1, - }) + expect(await ackFlag('/api/relationships/v1/ignore', '711', 710)).toEqual(ACK) + // 711 sees 710's request as Received (2) with their own Ignored set. + expect((await relationships('711')) as unknown as FullRel[]).toEqual([ + expect.objectContaining({ PlayerID: 710, RelationshipType: 2, Ignored: 1 }), + ]) // 710's own side is untouched — the requester never ignored anyone. - const view710 = (await relationships('710')) as unknown as FullRel[] - expect(view710).toEqual([ + expect((await relationships('710')) as unknown as FullRel[]).toEqual([ expect.objectContaining({ PlayerID: 711, RelationshipType: 1, Ignored: 0 }), ]) }) + test('v1 unignore/unmute clear the caller’s own flags independently', async () => { + // 800 ignores and mutes 801 (bare None row, both flags on the caller's side). + await ackFlag('/api/relationships/v1/ignore', '800', 801) + await ackFlag('/api/relationships/v1/mute', '800', 801) + expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 1, Muted: 1 }) + // unignore clears only Ignored; the mute is left in place. + expect(await ackFlag('/api/relationships/v1/unignore', '800', 801)).toEqual(ACK) + expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 0, Muted: 1 }) + // unmute then clears Muted too. + expect(await ackFlag('/api/relationships/v1/unmute', '800', 801)).toEqual(ACK) + expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 0, Muted: 0 }) + }) + test('v1 favorite/unfavorite toggle the caller’s own side, leaving the friendship intact', async () => { // 720 and 721 are friends; 720 favorites 721 — the real client shape, a GET with `?id=`. await mutate('/api/relationships/v2/addfriend', '720', 721) - expect( - (await (await mutate('/api/relationships/v1/favorite', '720', 721)).json()) as Rel - ).toMatchObject({ PlayerID: 721, RelationshipType: 3, Favorited: 1 }) - + expect(await (await mutate('/api/relationships/v1/favorite', '720', 721)).json()).toEqual(ACK) + // 720's own side is favorited; the friendship is intact. + expect(await relationships('720')).toEqual([ + { PlayerID: 721, RelationshipType: 3, Favorited: 1, Ignored: 0, Muted: 0 }, + ]) // Favoriting is one-sided: 721 does not see themselves as having favorited 720. expect(await relationships('721')).toEqual([ { PlayerID: 720, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }, ]) // Unfavorite clears the flag but keeps the friendship. - expect( - (await (await mutate('/api/relationships/v1/unfavorite', '720', 721)).json()) as Rel - ).toMatchObject({ PlayerID: 721, RelationshipType: 3, Favorited: 0 }) + expect(await (await mutate('/api/relationships/v1/unfavorite', '720', 721)).json()).toEqual(ACK) expect(await relationships('720')).toEqual([ { PlayerID: 721, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }, ]) @@ -1602,9 +1632,8 @@ describe('relationships', () => { test('favoriting a player you have no relationship with is allowed', async () => { // Mirrors ignore/mute: a bare None row is created with the caller's side flagged. - expect( - (await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()) as Rel - ).toMatchObject({ PlayerID: 731, RelationshipType: 0, Favorited: 1 }) + expect(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()).toEqual(ACK) + expect(await ownFlags(730, 731)).toMatchObject({ Favorited: 1 }) // A None row is not reported as a relationship by v2/get. expect(await relationships('730')).toEqual([]) }) @@ -1612,4 +1641,19 @@ describe('relationships', () => { test('a self-targeted favorite is rejected', async () => { expect((await mutate('/api/relationships/v1/favorite', '740', 740)).status).toBe(400) }) + + test('a flag change pushes a RelationshipChanged notification with the relationship', async () => { + // The relationship detail now rides a hub notification instead of the response. + // The notify DO is stubbed to record its last notifyPlayer call (see vitest.config). + await ackFlag('/api/relationships/v1/favorite', '750', 751) + const res = await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/last') + const last = (await res.json()) as { + playerId: number + notificationType: number + data: { PlayerID: number; Favorited: number; RelationshipType: number } + } + expect(last.playerId).toBe(750) // sent to the caller + expect(last.notificationType).toBe(1) // NotificationType.RelationshipChanged + expect(last.data).toMatchObject({ PlayerID: 751, Favorited: 1, RelationshipType: 0 }) + }) }) diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index de0d903..29b394b 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -9,6 +9,34 @@ export default defineConfig({ bindings: { ENVIRONMENT: 'VITEST', }, + // The worker's RECFLARE_NOTIFICATIONS_HUB binding points at the `notify` + // worker's DO (script_name: "notify"). That worker isn't part of this + // isolated test, so provide a minimal stub service exposing the same + // NotificationsHub RPC surface — enough for the runtime to start and for + // notification sends to no-op. + workers: [ + { + name: 'notify', + modules: true, + compatibilityDate: '2026-06-16', + compatibilityFlags: ['nodejs_compat'], + durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' }, + // notifyPlayer records its last call so tests can assert the notification + // the worker pushed (type + payload); GET the DO to read it back. + script: ` + import { DurableObject } from 'cloudflare:workers' + export class NotificationsHub extends DurableObject { + async notifyPlayer(playerId, notificationType, data) { + this.last = { playerId, notificationType, data } + return { delivered: 0, queued: true } + } + async broadcast() { return { delivered: 0 } } + async fetch() { return Response.json(this.last ?? null) } + } + export default { fetch() { return new Response('ok') } } + `, + }, + ], }, }), ], diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index ed9e80e..34a022d 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -26,6 +26,17 @@ "bucket_name": "recflare-img" } ], + // Cross-worker binding to the SignalR notifications hub DO (owned/migrated by + // the `notify` worker). We only invoke its RPC methods; no migration here. + "durable_objects": { + "bindings": [ + { + "name": "RECFLARE_NOTIFICATIONS_HUB", + "class_name": "NotificationsHub", + "script_name": "notify" + } + ] + }, "logpush": false, // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 9f1c314..6fc5fc6 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -426,6 +426,11 @@ const app = new Hono() return c.body(null, 200) }) + // Fire-and-forget disconnect notification (form body `PlayerId`/`RoomInstanceId`). + // The client posts this when it drops a room; we don't act on it — presence is + // cleared by logout and otherwise expires on its own TTL — so just ack with 200. + .post('/player/notifydisconnect', (c) => c.body(null, 200)) + .get('/player', async (c) => { // Returns each requested player's presence. Reads the `id` query param(s); // with none it serves the static getplayer.json default. diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 3f04d47..0671ede 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -150,6 +150,15 @@ describe('public endpoints', () => { expect(await res.json()).toEqual({ errorCode: 0 }) }) + test('POST /player/notifydisconnect returns 200', async () => { + const res = await exports.default.fetch(`${ORIGIN}/player/notifydisconnect`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'PlayerId=155&RoomInstanceId=1000001', + }) + expect(res.status).toBe(200) + }) + test('GET /player?id=N synthesizes a player payload for that id', async () => { const res = await exports.default.fetch(`${ORIGIN}/player?id=99`) expect(res.status).toBe(200)