mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
relationship notifications (maybe)
This commit is contained in:
@@ -37,10 +37,10 @@ export const SCHEMA_DDL: string[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saved-image categories from the C# `SavedImageType` enum — the value of a stored
|
* Saved-image categories from the reference's `SavedImageType` enum — the value of a
|
||||||
* image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives here in
|
* stored image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives
|
||||||
* the image data layer so both the upload route and the slideshow query share one
|
* here in the image data layer so both the upload route and the slideshow query share
|
||||||
* definition.
|
* one definition.
|
||||||
*/
|
*/
|
||||||
export const SavedImageType = {
|
export const SavedImageType = {
|
||||||
None: 0,
|
None: 0,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* workers' migrations that share the database).
|
* workers' migrations that share the database).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Relationship state from the perspective of the player asking (mirror of the C# enum). */
|
/** Relationship state from the perspective of the player asking (mirrors the reference). */
|
||||||
export enum RelationshipType {
|
export enum RelationshipType {
|
||||||
None = 0,
|
None = 0,
|
||||||
FriendRequestSent = 1,
|
FriendRequestSent = 1,
|
||||||
@@ -53,7 +53,7 @@ interface RelationshipRow {
|
|||||||
target_muted: number
|
target_muted: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The per-player relationship projection returned to the client (the C# RelationshipResponse). */
|
/** The per-player relationship projection returned to the client (RelationshipResponse). */
|
||||||
export interface RelationshipResponse {
|
export interface RelationshipResponse {
|
||||||
Favorited: number
|
Favorited: number
|
||||||
Ignored: number
|
Ignored: number
|
||||||
@@ -62,6 +62,34 @@ export interface RelationshipResponse {
|
|||||||
RelationshipType: RelationshipType
|
RelationshipType: RelationshipType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of a friend-graph mutation. These changes are visible to BOTH players, and
|
||||||
|
* each sees a different projection of the same row (the target of a request sees
|
||||||
|
* `FriendRequestReceived` where the sender sees `Sent`), so callers get both — `self` for
|
||||||
|
* the HTTP response and the acting player's notification, `other` for the target's.
|
||||||
|
*
|
||||||
|
* `changed` is false when the mutation was a no-op: re-sending a request that's already
|
||||||
|
* outstanding, befriending someone you're already friends with, accepting something that
|
||||||
|
* isn't pending. Nothing was written, so no RelationshipChanged notification should go out
|
||||||
|
* (the reference server is likewise silent on its no-change branch).
|
||||||
|
*/
|
||||||
|
export interface RelationshipChange {
|
||||||
|
self: RelationshipResponse
|
||||||
|
other: RelationshipResponse
|
||||||
|
changed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The projection reported for a pair with no stored relationship. */
|
||||||
|
function noneResponse(otherId: number): RelationshipResponse {
|
||||||
|
return {
|
||||||
|
PlayerID: otherId,
|
||||||
|
RelationshipType: RelationshipType.None,
|
||||||
|
Favorited: 0,
|
||||||
|
Ignored: 0,
|
||||||
|
Muted: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Flip a pending request to the other side's point of view; Friend/None are symmetric. */
|
/** Flip a pending request to the other side's point of view; Friend/None are symmetric. */
|
||||||
function flipType(type: number): RelationshipType {
|
function flipType(type: number): RelationshipType {
|
||||||
if (type === RelationshipType.FriendRequestSent) return RelationshipType.FriendRequestReceived
|
if (type === RelationshipType.FriendRequestSent) return RelationshipType.FriendRequestReceived
|
||||||
@@ -85,6 +113,16 @@ function toResponse(row: RelationshipRow, playerId: number): RelationshipRespons
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Project a written row for both players in the pair. */
|
||||||
|
function toChange(
|
||||||
|
row: RelationshipRow,
|
||||||
|
playerId: number,
|
||||||
|
otherId: number,
|
||||||
|
changed: boolean
|
||||||
|
): RelationshipChange {
|
||||||
|
return { self: toResponse(row, playerId), other: toResponse(row, otherId), changed }
|
||||||
|
}
|
||||||
|
|
||||||
/** Find the single row for an unordered pair (either direction), or null. */
|
/** Find the single row for an unordered pair (either direction), or null. */
|
||||||
async function findPair(db: D1Database, a: number, b: number): Promise<RelationshipRow | null> {
|
async function findPair(db: D1Database, a: number, b: number): Promise<RelationshipRow | null> {
|
||||||
return db
|
return db
|
||||||
@@ -98,7 +136,10 @@ async function findPair(db: D1Database, a: number, b: number): Promise<Relations
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* All of a player's relationships, projected from that player's point of view.
|
* All of a player's relationships, projected from that player's point of view.
|
||||||
* `None` rows are omitted (a removed friend leaves no relationship to report).
|
*
|
||||||
|
* `None` rows are included: they are how an unfriending, or an ignore/mute of someone you
|
||||||
|
* were never friends with, is recorded, and they still carry that player's
|
||||||
|
* favorited/ignored/muted flags. Dropping them would lose the flags on the client.
|
||||||
*/
|
*/
|
||||||
export async function getRelationshipsForPlayer(
|
export async function getRelationshipsForPlayer(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -111,9 +152,7 @@ export async function getRelationshipsForPlayer(
|
|||||||
)
|
)
|
||||||
.bind(playerId)
|
.bind(playerId)
|
||||||
.all<RelationshipRow>()
|
.all<RelationshipRow>()
|
||||||
return results
|
return results.map((row) => toResponse(row, playerId))
|
||||||
.filter((row) => row.relationship_type !== RelationshipType.None)
|
|
||||||
.map((row) => toResponse(row, playerId))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,14 +160,14 @@ export async function getRelationshipsForPlayer(
|
|||||||
* requester. Inserts a new row or, if one already exists for the pair (either
|
* requester. Inserts a new row or, if one already exists for the pair (either
|
||||||
* direction), rewrites it so the requester is normalized to `requesterId` and
|
* direction), rewrites it so the requester is normalized to `requesterId` and
|
||||||
* the flags are preserved for whichever side each player is on. Returns the
|
* the flags are preserved for whichever side each player is on. Returns the
|
||||||
* relationship from `requesterId`'s point of view.
|
* row as written, for the caller to project onto whichever side it needs.
|
||||||
*/
|
*/
|
||||||
async function upsertPair(
|
async function upsertPair(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
requesterId: number,
|
requesterId: number,
|
||||||
targetId: number,
|
targetId: number,
|
||||||
type: RelationshipType
|
type: RelationshipType
|
||||||
): Promise<RelationshipResponse> {
|
): Promise<RelationshipRow> {
|
||||||
const existing = await findPair(db, requesterId, targetId)
|
const existing = await findPair(db, requesterId, targetId)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
await db
|
await db
|
||||||
@@ -138,7 +177,17 @@ async function upsertPair(
|
|||||||
)
|
)
|
||||||
.bind(requesterId, targetId, type)
|
.bind(requesterId, targetId, type)
|
||||||
.run()
|
.run()
|
||||||
return { PlayerID: targetId, RelationshipType: type, Favorited: 0, Ignored: 0, Muted: 0 }
|
return {
|
||||||
|
requester_id: requesterId,
|
||||||
|
target_id: targetId,
|
||||||
|
relationship_type: type,
|
||||||
|
requester_favorited: 0,
|
||||||
|
requester_ignored: 0,
|
||||||
|
requester_muted: 0,
|
||||||
|
target_favorited: 0,
|
||||||
|
target_ignored: 0,
|
||||||
|
target_muted: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep each player's flags with that player as the row is normalized to
|
// Keep each player's flags with that player as the row is normalized to
|
||||||
@@ -175,93 +224,117 @@ async function upsertPair(
|
|||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
return {
|
return {
|
||||||
PlayerID: targetId,
|
requester_id: requesterId,
|
||||||
RelationshipType: type,
|
target_id: targetId,
|
||||||
Favorited: reqFlags.favorited,
|
relationship_type: type,
|
||||||
Ignored: reqFlags.ignored,
|
requester_favorited: reqFlags.favorited,
|
||||||
Muted: reqFlags.muted,
|
requester_ignored: reqFlags.ignored,
|
||||||
|
requester_muted: reqFlags.muted,
|
||||||
|
target_favorited: tgtFlags.favorited,
|
||||||
|
target_ignored: tgtFlags.ignored,
|
||||||
|
target_muted: tgtFlags.muted,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a friend request from `requesterId` to `targetId`. If the target already
|
* Send a friend request from `requesterId` to `targetId`. If the target already
|
||||||
* has a pending request out to the requester, the two become friends instead
|
* has a pending request out to the requester, the two become friends instead
|
||||||
* (the request crosses an existing one). Already-friends is left unchanged.
|
* (the request crosses an existing one). Already-friends, and re-sending a request
|
||||||
* Returns the relationship from the requester's point of view.
|
* that's already outstanding, are no-ops.
|
||||||
*/
|
*/
|
||||||
export async function sendFriendRequest(
|
export async function sendFriendRequest(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
requesterId: number,
|
requesterId: number,
|
||||||
targetId: number
|
targetId: number
|
||||||
): Promise<RelationshipResponse> {
|
): Promise<RelationshipChange> {
|
||||||
const existing = await findPair(db, requesterId, targetId)
|
const existing = await findPair(db, requesterId, targetId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.relationship_type === RelationshipType.Friend) {
|
// Already friends, or we already have a request out to them — nothing to write.
|
||||||
return toResponse(existing, requesterId)
|
if (
|
||||||
|
existing.relationship_type === RelationshipType.Friend ||
|
||||||
|
(existing.requester_id === requesterId &&
|
||||||
|
existing.relationship_type === RelationshipType.FriendRequestSent)
|
||||||
|
) {
|
||||||
|
return toChange(existing, requesterId, targetId, false)
|
||||||
}
|
}
|
||||||
// The target already requested us → crossing requests become a friendship.
|
// The target already requested us → crossing requests become a friendship.
|
||||||
if (
|
if (
|
||||||
existing.requester_id === targetId &&
|
existing.requester_id === targetId &&
|
||||||
existing.relationship_type === RelationshipType.FriendRequestSent
|
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||||
) {
|
) {
|
||||||
return upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||||
|
return toChange(row, requesterId, targetId, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
|
const row = await upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
|
||||||
|
return toChange(row, requesterId, targetId, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `accepterId` accepts a pending friend request from `otherId`. Only upgrades to
|
* `accepterId` accepts a pending friend request from `otherId`. Only upgrades to
|
||||||
* Friend when a request from `otherId` is actually pending; otherwise the
|
* Friend when a request from `otherId` is actually pending; otherwise the current
|
||||||
* current state is returned unchanged. Returns the relationship from the
|
* state is returned as a no-op. (The reference server answers 403 there instead;
|
||||||
* accepter's point of view.
|
* we stay lenient, but either way nothing changed.)
|
||||||
*/
|
*/
|
||||||
export async function acceptFriendRequest(
|
export async function acceptFriendRequest(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
accepterId: number,
|
accepterId: number,
|
||||||
otherId: number
|
otherId: number
|
||||||
): Promise<RelationshipResponse> {
|
): Promise<RelationshipChange> {
|
||||||
const existing = await findPair(db, accepterId, otherId)
|
const existing = await findPair(db, accepterId, otherId)
|
||||||
if (
|
if (
|
||||||
existing &&
|
existing &&
|
||||||
existing.requester_id === otherId &&
|
existing.requester_id === otherId &&
|
||||||
existing.relationship_type === RelationshipType.FriendRequestSent
|
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||||
) {
|
) {
|
||||||
// upsertPair projects for the requester (otherId); the accepter is the target,
|
const row = await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
|
||||||
// so re-project the written row from the accepter's point of view.
|
return toChange(row, accepterId, otherId, true)
|
||||||
await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
|
|
||||||
const updated = await findPair(db, accepterId, otherId)
|
|
||||||
if (updated) return toResponse(updated, accepterId)
|
|
||||||
}
|
}
|
||||||
return existing
|
return existing
|
||||||
? toResponse(existing, accepterId)
|
? toChange(existing, accepterId, otherId, false)
|
||||||
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
|
: { self: noneResponse(otherId), other: noneResponse(accepterId), changed: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Directly make `requesterId` and `targetId` friends (no pending request step).
|
* Directly make `requesterId` and `targetId` friends (no pending request step).
|
||||||
* Returns the relationship from the requester's point of view.
|
|
||||||
*/
|
*/
|
||||||
export async function addFriend(
|
export async function addFriend(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
requesterId: number,
|
requesterId: number,
|
||||||
targetId: number
|
targetId: number
|
||||||
): Promise<RelationshipResponse> {
|
): Promise<RelationshipChange> {
|
||||||
return upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
const existing = await findPair(db, requesterId, targetId)
|
||||||
|
if (existing && existing.relationship_type === RelationshipType.Friend) {
|
||||||
|
return toChange(existing, requesterId, targetId, false)
|
||||||
|
}
|
||||||
|
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||||
|
return toChange(row, requesterId, targetId, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove any relationship between the two players (unfriend / cancel request /
|
* Remove any relationship between the two players (unfriend / cancel request /
|
||||||
* decline). Deletes the row entirely so neither side reports a relationship.
|
* decline).
|
||||||
|
*
|
||||||
|
* The row is set to `None` rather than deleted, matching the reference server: the
|
||||||
|
* per-player favorited/ignored/muted flags live on that row and must survive an
|
||||||
|
* unfriending (someone you ignored stays ignored after you drop them as a friend).
|
||||||
*/
|
*/
|
||||||
export async function removeFriend(db: D1Database, a: number, b: number): Promise<void> {
|
export async function removeFriend(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
otherId: number
|
||||||
|
): Promise<RelationshipChange> {
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`DELETE FROM relationship
|
`UPDATE relationship SET relationship_type = ?3
|
||||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||||
)
|
)
|
||||||
.bind(a, b)
|
.bind(playerId, otherId, RelationshipType.None)
|
||||||
.run()
|
.run()
|
||||||
|
const updated = await findPair(db, playerId, otherId)
|
||||||
|
return updated
|
||||||
|
? toChange(updated, playerId, otherId, true)
|
||||||
|
: { self: noneResponse(otherId), other: noneResponse(playerId), changed: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A per-player relationship flag — each is stored on the player's own side of the row. */
|
/** A per-player relationship flag — each is stored on the player's own side of the row. */
|
||||||
@@ -308,7 +381,5 @@ export async function setRelationshipFlag(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await findPair(db, playerId, otherId)
|
const updated = await findPair(db, playerId, otherId)
|
||||||
return updated
|
return updated ? toResponse(updated, playerId) : noneResponse(otherId)
|
||||||
? toResponse(updated, playerId)
|
|
||||||
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
|
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
|
||||||
const file = candidate
|
const file = candidate
|
||||||
|
|
||||||
// `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`),
|
// `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`),
|
||||||
// posted as a multipart field. It carries the metadata we record on the image
|
// posted as a multipart field. It carries the metadata we record on the image
|
||||||
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
|
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
|
||||||
let meta: Record<string, unknown> = {}
|
let meta: Record<string, unknown> = {}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
import type { RelationshipFlag } from '../relationships-db'
|
import type { RelationshipChange, RelationshipFlag, RelationshipResponse } from '../relationships-db'
|
||||||
|
|
||||||
/** 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'
|
||||||
@@ -23,20 +23,15 @@ const HUB_INSTANCE = 'global'
|
|||||||
const RELATIONSHIP_CHANGED = 1
|
const RELATIONSHIP_CHANGED = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a per-player relationship flag toggle (favorited/ignored/muted) and hand the
|
* Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are
|
||||||
* result to the client the way the Go server does: the resulting relationship rides a
|
* logged and swallowed — the DB write has already committed, so a hub hiccup must not fail
|
||||||
* `RelationshipChanged` hub notification to the caller, and the HTTP body is just the
|
* the request.
|
||||||
* `{ 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(
|
async function notifyRelationship(
|
||||||
c: Context<App>,
|
c: Context<App>,
|
||||||
playerId: number,
|
playerId: number,
|
||||||
otherId: number,
|
rel: RelationshipResponse
|
||||||
flag: RelationshipFlag,
|
): Promise<void> {
|
||||||
value: boolean
|
|
||||||
): Promise<Response> {
|
|
||||||
const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value)
|
|
||||||
try {
|
try {
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
playerId,
|
playerId,
|
||||||
@@ -49,6 +44,39 @@ async function applyFlag(
|
|||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify both players of a friend-graph change, each with the relationship projected from
|
||||||
|
* their own point of view — the target of a request sees `FriendRequestReceived` where the
|
||||||
|
* sender sees `Sent`, so the two payloads differ. A no-op mutation notifies nobody.
|
||||||
|
*/
|
||||||
|
async function notifyBoth(
|
||||||
|
c: Context<App>,
|
||||||
|
playerId: number,
|
||||||
|
otherId: number,
|
||||||
|
change: RelationshipChange
|
||||||
|
): Promise<void> {
|
||||||
|
if (!change.changed) return
|
||||||
|
await notifyRelationship(c, playerId, change.self)
|
||||||
|
await notifyRelationship(c, otherId, change.other)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* resulting relationship rides the notification and the HTTP body is just the
|
||||||
|
* `{ Success, Message }` ack.
|
||||||
|
*/
|
||||||
|
async function applyFlag(
|
||||||
|
c: Context<App>,
|
||||||
|
playerId: number,
|
||||||
|
otherId: number,
|
||||||
|
flag: RelationshipFlag,
|
||||||
|
value: boolean
|
||||||
|
): Promise<Response> {
|
||||||
|
const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value)
|
||||||
|
await notifyRelationship(c, playerId, rel)
|
||||||
return c.json({ Success: true, Message: '' })
|
return c.json({ Success: true, Message: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,12 +120,19 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
|||||||
// 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
|
||||||
// caller's point of view.
|
// caller's point of view.
|
||||||
|
//
|
||||||
|
// The four friend-graph mutations below change state both players can see, so each
|
||||||
|
// notifies BOTH sides with their own projection (see notifyBoth) on top of the HTTP
|
||||||
|
// response. A no-op — re-sending an outstanding request, accepting nothing pending —
|
||||||
|
// notifies nobody.
|
||||||
.on(['GET', 'POST'], '/api/relationships/v2/sendfriendrequest', async (c) => {
|
.on(['GET', 'POST'], '/api/relationships/v2/sendfriendrequest', async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const target = await targetPlayerId(c)
|
const target = await targetPlayerId(c)
|
||||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||||
return c.json(await sendFriendRequest(c.env.DB, id, target))
|
const change = await sendFriendRequest(c.env.DB, id, target)
|
||||||
|
await notifyBoth(c, id, target, change)
|
||||||
|
return c.json(change.self)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Accept a pending friend request from another player (`?id=`). Auth-gated.
|
// Accept a pending friend request from another player (`?id=`). Auth-gated.
|
||||||
@@ -106,17 +141,21 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
|||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const target = await targetPlayerId(c)
|
const target = await targetPlayerId(c)
|
||||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||||
return c.json(await acceptFriendRequest(c.env.DB, id, target))
|
const change = await acceptFriendRequest(c.env.DB, id, target)
|
||||||
|
await notifyBoth(c, id, target, change)
|
||||||
|
return c.json(change.self)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Remove a friend / cancel a request / decline a request (`?id=`). Auth-gated.
|
// Remove a friend / cancel a request / decline a request (`?id=`). The row is kept as
|
||||||
|
// a None relationship so the per-side flags survive (see removeFriend). Auth-gated.
|
||||||
.on(['GET', 'POST'], '/api/relationships/v2/removefriend', async (c) => {
|
.on(['GET', 'POST'], '/api/relationships/v2/removefriend', async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const target = await targetPlayerId(c)
|
const target = await targetPlayerId(c)
|
||||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||||
await removeFriend(c.env.DB, id, target)
|
const change = await removeFriend(c.env.DB, id, target)
|
||||||
return c.json({ success: true })
|
await notifyBoth(c, id, target, change)
|
||||||
|
return c.json(change.self)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Directly add another player as a friend, no pending-request step (`?id=`). Auth-gated.
|
// Directly add another player as a friend, no pending-request step (`?id=`). Auth-gated.
|
||||||
@@ -125,7 +164,9 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
|||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const target = await targetPlayerId(c)
|
const target = await targetPlayerId(c)
|
||||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||||
return c.json(await addFriend(c.env.DB, id, target))
|
const change = await addFriend(c.env.DB, id, target)
|
||||||
|
await notifyBoth(c, id, target, change)
|
||||||
|
return c.json(change.self)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Ignore / mute another player, and their inverses unignore / unmute (target
|
// Ignore / mute another player, and their inverses unignore / unmute (target
|
||||||
|
|||||||
@@ -1517,6 +1517,24 @@ describe('relationships', () => {
|
|||||||
// the relationship detail rides a RelationshipChanged hub notification instead.
|
// the relationship detail rides a RelationshipChanged hub notification instead.
|
||||||
const ACK = { Success: true, Message: '' }
|
const ACK = { Success: true, Message: '' }
|
||||||
|
|
||||||
|
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
||||||
|
type Notification = {
|
||||||
|
playerId: number
|
||||||
|
notificationType: number
|
||||||
|
data: { PlayerID: number; RelationshipType: number; Favorited: number; Ignored: number }
|
||||||
|
}
|
||||||
|
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||||
|
|
||||||
|
/** Drop everything the hub stub has recorded so far. */
|
||||||
|
async function resetNotifications() {
|
||||||
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every notification pushed since the last reset, in order. */
|
||||||
|
async function sentNotifications(): Promise<Notification[]> {
|
||||||
|
return (await (await hub().fetch('http://do/all')).json()) as Notification[]
|
||||||
|
}
|
||||||
|
|
||||||
// POST a flag mutation the real client way (form body `PlayerId=<id>`), returning
|
// POST a flag mutation the real client way (form body `PlayerId=<id>`), returning
|
||||||
// the parsed ack body.
|
// the parsed ack body.
|
||||||
async function ackFlag(path: string, sub: string, playerId: number) {
|
async function ackFlag(path: string, sub: string, playerId: number) {
|
||||||
@@ -1529,9 +1547,8 @@ describe('relationships', () => {
|
|||||||
).json()) as { Success: boolean; Message: string }
|
).json()) as { Success: boolean; Message: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
// A player's own-side flags read straight from the relationship row. The None row a
|
// A player's own-side flags read straight from the relationship row — the flag
|
||||||
// flag can create for an otherwise-unrelated pair isn't reported by v2/get, so the
|
// endpoints return only an ack, so the effect is verified against the row itself.
|
||||||
// flag effect is verified here instead of through the (now ack-only) response.
|
|
||||||
async function ownFlags(playerId: number, otherId: number) {
|
async function ownFlags(playerId: number, otherId: number) {
|
||||||
const row = (await env.DB.prepare(
|
const row = (await env.DB.prepare(
|
||||||
`SELECT requester_id, requester_favorited, requester_ignored, requester_muted,
|
`SELECT requester_id, requester_favorited, requester_ignored, requester_muted,
|
||||||
@@ -1597,10 +1614,27 @@ describe('relationships', () => {
|
|||||||
{ PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
{ PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||||
])
|
])
|
||||||
|
|
||||||
// 500 removes → neither side has a relationship.
|
// 500 removes → both sides drop to None. The row is kept (that's where the
|
||||||
|
// per-side flags live), so v2/get still reports the pair, now as None (0).
|
||||||
expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200)
|
expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200)
|
||||||
expect(await relationships('500')).toEqual([])
|
expect(await relationships('500')).toEqual([
|
||||||
expect(await relationships('501')).toEqual([])
|
{ PlayerID: 501, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||||
|
])
|
||||||
|
expect(await relationships('501')).toEqual([
|
||||||
|
{ PlayerID: 500, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removefriend keeps the caller’s ignore flag', async () => {
|
||||||
|
// 760 befriends 761 then ignores them; dropping the friendship must not
|
||||||
|
// un-ignore them (the flag lives on the row the removal downgrades to None).
|
||||||
|
await mutate('/api/relationships/v2/addfriend', '760', 761)
|
||||||
|
await ackFlag('/api/relationships/v1/ignore', '760', 761)
|
||||||
|
await mutate('/api/relationships/v2/removefriend', '760', 761)
|
||||||
|
expect(await ownFlags(760, 761)).toMatchObject({ Ignored: 1 })
|
||||||
|
expect(await relationships('760')).toEqual([
|
||||||
|
{ PlayerID: 761, RelationshipType: 0, Favorited: 0, Ignored: 1, Muted: 0 },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('addfriend makes them friends directly', async () => {
|
test('addfriend makes them friends directly', async () => {
|
||||||
@@ -1689,8 +1723,10 @@ describe('relationships', () => {
|
|||||||
// Mirrors ignore/mute: a bare None row is created with the caller's side flagged.
|
// 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()).toEqual(ACK)
|
expect(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()).toEqual(ACK)
|
||||||
expect(await ownFlags(730, 731)).toMatchObject({ Favorited: 1 })
|
expect(await ownFlags(730, 731)).toMatchObject({ Favorited: 1 })
|
||||||
// A None row is not reported as a relationship by v2/get.
|
// The bare None row is reported by v2/get — it carries the flag.
|
||||||
expect(await relationships('730')).toEqual([])
|
expect(await relationships('730')).toEqual([
|
||||||
|
{ PlayerID: 731, RelationshipType: 0, Favorited: 1, Ignored: 0, Muted: 0 },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a self-targeted favorite is rejected', async () => {
|
test('a self-targeted favorite is rejected', async () => {
|
||||||
@@ -1711,4 +1747,81 @@ describe('relationships', () => {
|
|||||||
expect(last.notificationType).toBe(1) // NotificationType.RelationshipChanged
|
expect(last.notificationType).toBe(1) // NotificationType.RelationshipChanged
|
||||||
expect(last.data).toMatchObject({ PlayerID: 751, Favorited: 1, RelationshipType: 0 })
|
expect(last.data).toMatchObject({ PlayerID: 751, Favorited: 1, RelationshipType: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('sendfriendrequest notifies both players with their own projection', async () => {
|
||||||
|
await resetNotifications()
|
||||||
|
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([
|
||||||
|
{
|
||||||
|
playerId: 770,
|
||||||
|
notificationType: 1,
|
||||||
|
data: { PlayerID: 771, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
playerId: 771,
|
||||||
|
notificationType: 1,
|
||||||
|
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepting notifies both players as Friend', async () => {
|
||||||
|
await mutate('/api/relationships/v2/sendfriendrequest', '780', 781)
|
||||||
|
await resetNotifications()
|
||||||
|
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
|
||||||
|
|
||||||
|
const sent = await sentNotifications()
|
||||||
|
expect(sent).toHaveLength(2)
|
||||||
|
// Friend (3) is symmetric, so both sides see the same type, each pointing at the other.
|
||||||
|
expect(sent).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
playerId: 780,
|
||||||
|
data: expect.objectContaining({ PlayerID: 781, RelationshipType: 3 }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
playerId: 781,
|
||||||
|
data: expect.objectContaining({ PlayerID: 780, RelationshipType: 3 }),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removefriend notifies both players with None', async () => {
|
||||||
|
await mutate('/api/relationships/v2/addfriend', '790', 791)
|
||||||
|
await resetNotifications()
|
||||||
|
await mutate('/api/relationships/v2/removefriend', '790', 791)
|
||||||
|
|
||||||
|
const sent = await sentNotifications()
|
||||||
|
expect(sent).toHaveLength(2)
|
||||||
|
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([790, 791])
|
||||||
|
for (const n of sent) expect(n.data.RelationshipType).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a no-op friend request notifies nobody', async () => {
|
||||||
|
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
|
||||||
|
await resetNotifications()
|
||||||
|
|
||||||
|
// Re-sending an already-outstanding request writes nothing, so nothing is pushed.
|
||||||
|
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
|
||||||
|
expect(await sentNotifications()).toEqual([])
|
||||||
|
|
||||||
|
// Likewise accepting something that isn't pending (810 has no request to accept).
|
||||||
|
await mutate('/api/relationships/v2/acceptfriendrequest', '810', 811)
|
||||||
|
expect(await sentNotifications()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('crossing requests notify both players as Friend', async () => {
|
||||||
|
await mutate('/api/relationships/v2/sendfriendrequest', '820', 821)
|
||||||
|
await resetNotifications()
|
||||||
|
// 821's request crosses 820's → an immediate friendship, both sides told.
|
||||||
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,17 +21,27 @@ export default defineConfig({
|
|||||||
compatibilityDate: '2026-06-16',
|
compatibilityDate: '2026-06-16',
|
||||||
compatibilityFlags: ['nodejs_compat'],
|
compatibilityFlags: ['nodejs_compat'],
|
||||||
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
|
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
|
||||||
// notifyPlayer records its last call so tests can assert the notification
|
// notifyPlayer records every call so tests can assert the notifications the
|
||||||
// the worker pushed (type + payload); GET the DO to read it back.
|
// worker pushed (type + payload). GET the DO for the most recent one,
|
||||||
|
// GET /all for the whole list (friend-graph changes notify both players),
|
||||||
|
// DELETE to reset it between assertions.
|
||||||
script: `
|
script: `
|
||||||
import { DurableObject } from 'cloudflare:workers'
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
export class NotificationsHub extends DurableObject {
|
export class NotificationsHub extends DurableObject {
|
||||||
|
sent = []
|
||||||
async notifyPlayer(playerId, notificationType, data) {
|
async notifyPlayer(playerId, notificationType, data) {
|
||||||
this.last = { playerId, notificationType, data }
|
this.sent.push({ playerId, notificationType, data })
|
||||||
return { delivered: 0, queued: true }
|
return { delivered: 0, queued: true }
|
||||||
}
|
}
|
||||||
async broadcast() { return { delivered: 0 } }
|
async broadcast() { return { delivered: 0 } }
|
||||||
async fetch() { return Response.json(this.last ?? null) }
|
async fetch(request) {
|
||||||
|
if (request.method === 'DELETE') {
|
||||||
|
this.sent = []
|
||||||
|
return new Response(null, { status: 204 })
|
||||||
|
}
|
||||||
|
if (new URL(request.url).pathname === '/all') return Response.json(this.sent)
|
||||||
|
return Response.json(this.sent.at(-1) ?? null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export default { fetch() { return new Response('ok') } }
|
export default { fetch() { return new Response('ok') } }
|
||||||
`,
|
`,
|
||||||
|
|||||||
Reference in New Issue
Block a user