From 40c434c296094eaaae7e7987061a2cbf13ac6082 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 15 Sep 2026 22:08:37 -0400 Subject: [PATCH] [api] fix 2025 friends notification --- CLAUDE.md | 6 +++ apps/api/src/routes/social.ts | 56 +++++++++++++++++++++-- apps/api/src/test/integration/api.test.ts | 50 +++++++++++++++++--- 3 files changed, 102 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2bf07fb..5db10c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -216,6 +216,12 @@ inconsistency here without checking the client first. `GiftPackage` null — no box is minted. `GET /econ/customAvatarItems/v1/owned` is that table joined to the item record PLUS everything the caller created (drafts included): a creator owns their own through `CreatorAccountId`, and the bag refuses to sell it to them. +- A friend request is SHOWN to the target as a Message of type 4 `FriendInvite` (`api`: + `/api/relationships/v2/sendfriendrequest`), and an acceptance to the requester as type 40 + `FriendRequestAccepted` (`acceptfriendrequest`, and the crossing-request auto-accept), + each stored and pushed as `MessageReceived`. The `RelationshipChanged` frames only + refresh relationship state: sent alone, the request "worked" server-side and the target + saw nothing. Same pattern as the cheer. - Accessibility is sent as the `RoomAccessibility` enum NAME on `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members diff --git a/apps/api/src/routes/social.ts b/apps/api/src/routes/social.ts index b32300e..1ea9b42 100644 --- a/apps/api/src/routes/social.ts +++ b/apps/api/src/routes/social.ts @@ -11,7 +11,9 @@ import { getMutualFriendIds, getNotificationsForPlayer, getRelationshipsForPlayer, + MessageType, MUTUAL_FRIENDS_LIMIT, + RelationshipType, removeFriend, sendFriendRequest, setRelationshipFlag, @@ -122,6 +124,34 @@ async function notifyBoth( await notifyRelationship(c, otherId, change.other) } +/** + * Store and push the MESSAGE that a friend-graph change shows the other player. The + * `RelationshipChanged` frames above only refresh relationship state; what the client + * actually surfaces — the request in their inbox with accept/decline, the "accepted your + * request" line — is a Message of the right {@link MessageType} delivered as + * `MessageReceived`, and stored so it's still there in `GET /api/messages/v2/get` for a + * player who was offline. `Data` is NULL for both — the client's Message DTO carries + * `"Data": null` on a FriendInvite (a payload-less type, unlike a DM's empty string); the + * sender id is the whole payload. + * Delivery failure is logged by `pushMessage`; the row is committed either way. + */ +async function pushFriendMessage( + c: Context, + fromPlayerId: number, + toPlayerId: number, + type: MessageType.FriendInvite | MessageType.FriendRequestAccepted +): Promise { + await pushMessage( + c, + await createNotification(c.env.DB, { + FromPlayerId: fromPlayerId, + ToPlayerId: toPlayerId, + Type: type, + Data: null, + }) + ) +} + /** * Apply a per-player relationship flag toggle (favorited/ignored/muted). The flags are * private to the caller's own side of the row, so only the caller is notified. The @@ -454,8 +484,10 @@ export const socialRoutes = new Hono({ strict: false }) '/api/relationships/v2/sendfriendrequest', friendMutation( 'Send a friend request', - 'Offer friendship to another player. Re-sending an outstanding request is a no-op ' + - 'and notifies nobody.' + 'Offer friendship to another player. A new request also lands in the target’s inbox ' + + 'as a `FriendInvite` message (type 4), pushed as `MessageReceived` — that message, ' + + 'not the `RelationshipChanged` frame, is what the client shows them. Re-sending an ' + + 'outstanding request is a no-op and notifies nobody.' ), async (c) => { const id = await authedId(c) @@ -464,6 +496,17 @@ export const socialRoutes = new Hono({ strict: false }) if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) const change = await sendFriendRequest(c.env.DB, id, target) await notifyBoth(c, id, target, change) + // A NEW request is what the target sees as a FriendInvite message (the reference + // sends it on exactly this outcome). A request that crosses one the target already + // had out auto-accepts instead — so the target hears their own request was accepted, + // as if they'd been accepted the ordinary way. A no-op re-send says nothing. + if (change.changed) { + if (change.self.RelationshipType === RelationshipType.FriendRequestSent) { + await pushFriendMessage(c, id, target, MessageType.FriendInvite) + } else if (change.self.RelationshipType === RelationshipType.Friend) { + await pushFriendMessage(c, id, target, MessageType.FriendRequestAccepted) + } + } return c.json(change.self) } ) @@ -474,8 +517,9 @@ export const socialRoutes = new Hono({ strict: false }) '/api/relationships/v2/acceptfriendrequest', friendMutation( 'Accept a friend request', - 'Turn a pending incoming request into a friendship. Accepting nothing pending is a ' + - 'no-op and notifies nobody.' + 'Turn a pending incoming request into a friendship. The requester is told with a ' + + '`FriendRequestAccepted` message (type 40) pushed as `MessageReceived`. Accepting ' + + 'nothing pending is a no-op and notifies nobody.' ), async (c) => { const id = await authedId(c) @@ -484,6 +528,10 @@ export const socialRoutes = new Hono({ strict: false }) if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) const change = await acceptFriendRequest(c.env.DB, id, target) await notifyBoth(c, id, target, change) + // The requester is told, as a FriendRequestAccepted message from the accepter. + if (change.changed) { + await pushFriendMessage(c, id, target, MessageType.FriendRequestAccepted) + } return c.json(change.self) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index d66c2b3..190bf43 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -6470,8 +6470,11 @@ describe('relationships', () => { await mutate('/api/relationships/v2/sendfriendrequest', '770', 771) // Both sides hear about it, each seeing the other player and their own side's - // type: the sender Sent (1), the recipient Received (2). - expect(await sentNotifications()).toEqual([ + // type: the sender Sent (1), the recipient Received (2). The target ALSO gets the + // request itself as a FriendInvite (4) message — that is what the client shows. + const sent = await sentNotifications() + expect(sent).toHaveLength(3) + expect(sent.slice(0, 2)).toEqual([ { playerId: 770, notificationType: 1, @@ -6483,6 +6486,34 @@ describe('relationships', () => { data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 }, }, ]) + expect(sent[2]?.playerId).toBe(771) + expect(sent[2]?.notificationType).toBe(2) // NotificationType.MessageReceived + expect(sent[2]?.data).toMatchObject({ FromPlayerId: 770, ToPlayerId: 771, Type: 4, Data: null }) + + // The message is stored under the same id, so an offline target finds it on login. + const frameId = (sent[2]?.data as unknown as { Id: number }).Id + expect(frameId).toBeGreaterThan(0) + const inbox = (await ( + await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, { headers: await bearer('771') }) + ).json()) as Array<{ Id: number; FromPlayerId: number; Type: number }> + expect(inbox.find((m) => m.Id === frameId)).toMatchObject({ FromPlayerId: 770, Type: 4 }) + }) + + test('a re-sent request says nothing; a crossing request tells the requester it was accepted', async () => { + await mutate('/api/relationships/v2/sendfriendrequest', '772', 773) + await resetNotifications() + // Re-sending an outstanding request is a no-op: nothing is pushed at all. + await mutate('/api/relationships/v2/sendfriendrequest', '772', 773) + expect(await sentNotifications()).toEqual([]) + // The crossing request auto-accepts: both hear Friend, and the ORIGINAL requester + // (772) is told their request was accepted rather than asked anything. + await mutate('/api/relationships/v2/sendfriendrequest', '773', 772) + const sent = await sentNotifications() + expect(sent).toHaveLength(3) + expect(sent.slice(0, 2).every((n) => n.notificationType === 1)).toBe(true) + expect(sent[2]?.playerId).toBe(772) + expect(sent[2]?.notificationType).toBe(2) // NotificationType.MessageReceived + expect(sent[2]?.data).toMatchObject({ FromPlayerId: 773, ToPlayerId: 772, Type: 40 }) }) test('accepting notifies both players as Friend', async () => { @@ -6490,8 +6521,13 @@ describe('relationships', () => { await resetNotifications() await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780) - const sent = await sentNotifications() - expect(sent).toHaveLength(2) + const all = await sentNotifications() + expect(all).toHaveLength(3) + // The requester also gets the acceptance as a FriendRequestAccepted (40) message. + expect(all[2]?.playerId).toBe(780) + expect(all[2]?.notificationType).toBe(2) // NotificationType.MessageReceived + expect(all[2]?.data).toMatchObject({ FromPlayerId: 781, ToPlayerId: 780, Type: 40, Data: null }) + const sent = all.slice(0, 2) // Friend (3) is symmetric, so both sides see the same type, each pointing at the other. expect(sent).toEqual( expect.arrayContaining([ @@ -6538,8 +6574,10 @@ describe('relationships', () => { await mutate('/api/relationships/v2/sendfriendrequest', '821', 820) const sent = await sentNotifications() - expect(sent).toHaveLength(2) - for (const n of sent) expect(n.data.RelationshipType).toBe(3) + expect(sent).toHaveLength(3) + for (const n of sent.slice(0, 2)) expect(n.data.RelationshipType).toBe(3) + // ...and 820, whose request this crossed, is told it was accepted. + expect(sent[2]).toMatchObject({ playerId: 820, notificationType: 2, data: { Type: 40 } }) }) })