mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
637 lines
23 KiB
TypeScript
637 lines
23 KiB
TypeScript
import { Hono } from 'hono'
|
||
import { describeRoute } from 'hono-openapi'
|
||
|
||
import {
|
||
acceptFriendRequest,
|
||
addFriend,
|
||
getAccountsByIds,
|
||
getMutualFriendIds,
|
||
getRelationshipsForPlayer,
|
||
MUTUAL_FRIENDS_LIMIT,
|
||
removeFriend,
|
||
sendFriendRequest,
|
||
setRelationshipFlag,
|
||
} 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,
|
||
jsonBody,
|
||
MutualFriendDto,
|
||
RelationshipDto,
|
||
SendMessageRequest,
|
||
SendMultipleMessagesRequest,
|
||
SuccessErrorEnvelope,
|
||
UNAUTHORIZED_RESPONSE,
|
||
} from '../openapi'
|
||
|
||
import type { Context } from 'hono'
|
||
import type {
|
||
RelationshipChange,
|
||
RelationshipFlag,
|
||
RelationshipResponse,
|
||
} from '@repo/domain'
|
||
import type { App } from '../context'
|
||
|
||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||
const HUB_INSTANCE = 'global'
|
||
|
||
/**
|
||
* The Message a `MessageReceived` frame carries. A type alias rather than an interface:
|
||
* `notifyPlayer` takes an index-signature record, which only aliases satisfy implicitly.
|
||
*/
|
||
type Message = {
|
||
FromPlayerId: number
|
||
ToPlayerId: number
|
||
Type: number
|
||
Data: string
|
||
}
|
||
|
||
/**
|
||
* Push one `MessageReceived` frame, resolving false when the hub could not be reached.
|
||
* Unlike the relationship pushes, a failure here is NOT swallowed by the caller: there
|
||
* is no message store behind this, so the notification is the whole delivery.
|
||
*/
|
||
async function pushMessage(c: Context<App>, message: Message): Promise<boolean> {
|
||
try {
|
||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||
message.ToPlayerId,
|
||
NotificationType.MessageReceived,
|
||
message
|
||
)
|
||
return true
|
||
} catch (err) {
|
||
logger.error('failed to push MessageReceived notification', {
|
||
toPlayerId: message.ToPlayerId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
* the request.
|
||
*/
|
||
async function notifyRelationship(
|
||
c: Context<App>,
|
||
playerId: number,
|
||
rel: RelationshipResponse
|
||
): Promise<void> {
|
||
try {
|
||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||
playerId,
|
||
NotificationType.RelationshipChanged,
|
||
{ ...rel }
|
||
)
|
||
} catch (err) {
|
||
logger.error('failed to push RelationshipChanged notification', {
|
||
playerId,
|
||
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: '' })
|
||
}
|
||
|
||
/**
|
||
* Read the other player's id from a relationship-mutation request. The exact wire
|
||
* shape is still TBD, so this is liberal: it accepts `playerId`/`id` as a query
|
||
* param and `PlayerId`/`playerId`/`Id` from a JSON or form body. Returns null when
|
||
* no integer id is present.
|
||
*/
|
||
async function targetPlayerId(c: Context<App>): Promise<number | null> {
|
||
const fromQuery = c.req.query('playerId') ?? c.req.query('id')
|
||
if (fromQuery !== undefined) {
|
||
const n = Number.parseInt(fromQuery, 10)
|
||
if (!Number.isNaN(n)) return n
|
||
}
|
||
// Body may be JSON or form-encoded; Hono's parseBody only handles the latter.
|
||
const contentType = c.req.header('content-type') ?? ''
|
||
const body = contentType.includes('application/json')
|
||
? await c.req.json<Record<string, unknown>>().catch(() => ({}) as Record<string, unknown>)
|
||
: ((await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>)
|
||
const raw = body.PlayerId ?? body.playerId ?? body.Id
|
||
if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw
|
||
if (typeof raw === 'string') {
|
||
const n = Number.parseInt(raw, 10)
|
||
if (!Number.isNaN(n)) return n
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* How every relationship mutation names its target. The handler is liberal — it also
|
||
* accepts `PlayerId`/`playerId`/`Id` from a JSON or form body — but the client sends the
|
||
* query param, so that's what the spec documents.
|
||
*/
|
||
const TARGET_PARAMS = [
|
||
intQuery('id', 'The other player. The client uses this form.'),
|
||
intQuery('playerId', 'Accepted as an alias for `id`'),
|
||
]
|
||
|
||
/**
|
||
* A `describeRoute` spec for one of the four friend-graph mutations. These change state
|
||
* both players can see, so each also pushes a RelationshipChanged notification to both
|
||
* sides; the HTTP body is the caller's own projection.
|
||
*/
|
||
function friendMutation(summary: string, description: string) {
|
||
return describeRoute({
|
||
tags: ['Social'],
|
||
summary,
|
||
description,
|
||
security: AUTHED,
|
||
parameters: TARGET_PARAMS,
|
||
responses: {
|
||
200: json(RelationshipDto, 'The relationship, from the caller’s point of view'),
|
||
400: json(ErrorResponse, 'No target id, or the caller targeting themselves'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* A `describeRoute` spec for a per-side flag toggle (favorite / ignore / mute and their
|
||
* inverses). The write lands on the caller's own side of the row, so only the caller is
|
||
* notified — and the resulting relationship rides that notification, not the response,
|
||
* which is just the ack.
|
||
*/
|
||
function flagToggle(summary: string, description: string) {
|
||
return describeRoute({
|
||
tags: ['Social'],
|
||
summary,
|
||
description,
|
||
security: AUTHED,
|
||
parameters: TARGET_PARAMS,
|
||
responses: {
|
||
200: json(AckResponse, 'The ack; the relationship arrives over the notification hub'),
|
||
400: json(ErrorResponse, 'No target id, or the caller targeting themselves'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
})
|
||
}
|
||
|
||
// ---- Social ----------------------------------------------------------------
|
||
export const socialRoutes = new Hono<App>({ strict: false })
|
||
// The authed player's relationships, projected from their point of view — a bare
|
||
// array of RelationshipResponse. Auth-gated.
|
||
.get(
|
||
'/api/relationships/v2/get',
|
||
describeRoute({
|
||
tags: ['Social'],
|
||
summary: 'The caller’s relationships',
|
||
description:
|
||
'Every relationship the signed-in player has, projected from their point of view — ' +
|
||
'a bare array. `None` rows are included: that is how an unfriending, or an ' +
|
||
'ignore/mute of someone you were never friends with, is recorded, and they still ' +
|
||
'carry the caller’s favorited/ignored/muted flags.',
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(RelationshipDto.array(), 'The caller’s relationships'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json(await getRelationshipsForPlayer(c.env.DB, id))
|
||
}
|
||
)
|
||
|
||
// The friends the caller and another player have in common. Unlike the other
|
||
// relationship routes this answers account cards, not relationships — it's what the
|
||
// client shows on someone else's profile.
|
||
.get(
|
||
'/api/relationships/mutualfriends',
|
||
describeRoute({
|
||
tags: ['Social'],
|
||
summary: 'Friends in common with another player',
|
||
description:
|
||
'The accounts the caller and `id` are both friends with — a bare array, ascending ' +
|
||
`by account id and capped at ${MUTUAL_FRIENDS_LIMIT}. Only real friendships count; ` +
|
||
'pending requests on either side are ignored.\n\n' +
|
||
'Answers an empty array rather than an error for the degenerate cases: no target ' +
|
||
'id, an id of 0 or below, or the caller asking for mutuals with themselves. ' +
|
||
'Mutual ids with no account row are dropped, so the list can be shorter than the ' +
|
||
'intersection.\n\n' +
|
||
'Each entry is a trimmed account card. `ProfileImage` is an empty string, never ' +
|
||
'null, when the account has no image.',
|
||
security: AUTHED,
|
||
parameters: [intQuery('id', 'The other player')],
|
||
responses: {
|
||
200: json(MutualFriendDto.array(), 'The shared friends; empty when there are none'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
|
||
const raw = c.req.query('id')
|
||
const otherId = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
|
||
// Nothing to intersect: no/garbage id, a non-positive one, or the caller
|
||
// themselves. An empty list, not an error — this feeds a profile panel.
|
||
if (Number.isNaN(otherId) || otherId <= 0 || otherId === id) return c.json([])
|
||
|
||
const mutualIds = await getMutualFriendIds(c.env.DB, id, otherId)
|
||
const accounts = await getAccountsByIds(c.env.DB, mutualIds)
|
||
return c.json(
|
||
accounts
|
||
.map((a) => ({
|
||
AccountId: a.accountId,
|
||
Username: a.username,
|
||
DisplayName: a.displayName,
|
||
ProfileImage: a.profileImage ?? '',
|
||
}))
|
||
// getAccountsByIds doesn't promise an order; keep the ascending one.
|
||
.sort((a, b) => a.AccountId - b.AccountId)
|
||
)
|
||
}
|
||
)
|
||
|
||
// 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 delivered = await pushMessage(c, {
|
||
FromPlayerId: fromPlayerId,
|
||
ToPlayerId: toPlayerId,
|
||
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
|
||
Data: str(body.Data) ?? '',
|
||
})
|
||
if (!delivered) {
|
||
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
|
||
}
|
||
|
||
return c.json({ success: true, error: '' })
|
||
}
|
||
)
|
||
|
||
// The bulk form of the send above: one message, several recipients. Posted as JSON
|
||
// (`{"ToPlayerIds":[205],"Type":20,"Data":""}`), not the form encoding the single
|
||
// send uses, so `Type` arrives as a number here.
|
||
.post(
|
||
'/api/messages/v1/sendMultiple',
|
||
describeRoute({
|
||
tags: ['Social'],
|
||
summary: 'Send one message to several players',
|
||
description:
|
||
'The bulk form of `POST /api/messages/v2/send`: pushes the same ' +
|
||
'`MessageReceived` frame to every id in `ToPlayerIds`, each addressed to its own ' +
|
||
'recipient (`ToPlayerId` differs per frame — the payload is not shared). Same ' +
|
||
'sender rule: the caller’s bearer token, never a body field. Same non-store: the ' +
|
||
'notification is the whole delivery, queued by the hub for whoever is offline.\n\n' +
|
||
'The body is JSON rather than the single send’s form encoding, so `Type` is a ' +
|
||
'number (still an unmapped Message-model type, defaulting to 0) and `Data` a ' +
|
||
'string, commonly empty. Repeated ids are delivered once.\n\n' +
|
||
'Answers the same `{ success, error }` envelope. Delivery is attempted for every ' +
|
||
'recipient even after one fails, but a hub failure for ANY of them is reported ' +
|
||
'honestly as a 500 — the envelope has no room to say which, and with no store ' +
|
||
'behind it a swallowed error would be a silently dropped message.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(SendMultipleMessagesRequest, 'The message and its recipients'),
|
||
responses: {
|
||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||
400: json(SuccessErrorEnvelope, 'No usable id in `ToPlayerIds`'),
|
||
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.json<Record<string, unknown>>().catch(() => ({}))) as Record<
|
||
string,
|
||
unknown
|
||
>
|
||
// Ids may arrive as numbers or as numeric strings; drop anything that isn't an
|
||
// id and de-duplicate, so a repeated id doesn't deliver the message twice.
|
||
const toPlayerIds = [
|
||
...new Set(
|
||
(Array.isArray(body.ToPlayerIds) ? body.ToPlayerIds : [])
|
||
.map((v) => (typeof v === 'number' ? v : Number.parseInt(String(v), 10)))
|
||
.filter((n) => Number.isInteger(n) && n > 0)
|
||
),
|
||
]
|
||
if (toPlayerIds.length === 0) {
|
||
return c.json({ success: false, error: 'ToPlayerIds is required' }, 400)
|
||
}
|
||
|
||
const type = typeof body.Type === 'number' ? body.Type : Number(body.Type) || 0
|
||
const data = typeof body.Data === 'string' ? body.Data : ''
|
||
|
||
// Every recipient is attempted even if an earlier one fails — the reachable
|
||
// players get their message either way.
|
||
const results = await Promise.all(
|
||
toPlayerIds.map((toPlayerId) =>
|
||
pushMessage(c, {
|
||
FromPlayerId: fromPlayerId,
|
||
ToPlayerId: toPlayerId,
|
||
Type: type,
|
||
Data: data,
|
||
})
|
||
)
|
||
)
|
||
if (results.includes(false)) {
|
||
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
|
||
// 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',
|
||
friendMutation(
|
||
'Send a friend request',
|
||
'Offer friendship to another player. Re-sending an outstanding request is a no-op ' +
|
||
'and notifies nobody.'
|
||
),
|
||
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)
|
||
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.
|
||
.on(
|
||
['GET', 'POST'],
|
||
'/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.'
|
||
),
|
||
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)
|
||
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=`). 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',
|
||
friendMutation(
|
||
'Unfriend, or cancel/decline a request',
|
||
'All three are the same operation. The row is kept as a `None` relationship so the ' +
|
||
'per-side favorited/ignored/muted flags survive.'
|
||
),
|
||
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)
|
||
const change = await removeFriend(c.env.DB, id, target)
|
||
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.
|
||
.on(
|
||
['GET', 'POST'],
|
||
'/api/relationships/v2/addfriend',
|
||
friendMutation(
|
||
'Befriend directly',
|
||
'Become friends with no pending-request step. Already being friends is a no-op.'
|
||
),
|
||
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)
|
||
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
|
||
// 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',
|
||
flagToggle(
|
||
'Ignore a player',
|
||
'Sets the caller’s `ignored` flag. Ignoring someone you have no relationship with ' +
|
||
'creates a bare (`None`) row to hold the flag.'
|
||
),
|
||
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', true)
|
||
}
|
||
)
|
||
.on(
|
||
['GET', 'POST'],
|
||
'/api/relationships/v1/unignore',
|
||
flagToggle('Stop ignoring a player', 'Clears the caller’s `ignored` flag.'),
|
||
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',
|
||
flagToggle(
|
||
'Mute a player',
|
||
'Sets the caller’s `muted` flag. Like ignore, this works on a player you have no ' +
|
||
'relationship with.'
|
||
),
|
||
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', true)
|
||
}
|
||
)
|
||
.on(
|
||
['GET', 'POST'],
|
||
'/api/relationships/v1/unmute',
|
||
flagToggle('Unmute a player', 'Clears the caller’s `muted` flag.'),
|
||
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. Result rides a
|
||
// RelationshipChanged notification; the body is the { Success, Message } ack.
|
||
.on(
|
||
['GET', 'POST'],
|
||
'/api/relationships/v1/favorite',
|
||
flagToggle(
|
||
'Favorite a player',
|
||
'Sets the caller’s `favorited` flag — what pins a player to the top of their friends ' +
|
||
'list. Works on a player you have no relationship with.'
|
||
),
|
||
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, 'favorited', true)
|
||
}
|
||
)
|
||
.on(
|
||
['GET', 'POST'],
|
||
'/api/relationships/v1/unfavorite',
|
||
flagToggle('Unfavorite a player', 'Clears the caller’s `favorited` flag.'),
|
||
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, 'favorited', false)
|
||
}
|
||
)
|
||
|
||
.get(
|
||
'/api/messages/v2/get',
|
||
describeRoute({
|
||
tags: ['Social'],
|
||
summary: 'Direct messages',
|
||
description: 'There is no message store yet, so this is always an empty list.',
|
||
responses: { 200: json(JsonArray, 'An empty list') },
|
||
}),
|
||
(c) => c.json([])
|
||
)
|
||
.get(
|
||
'/api/messages/v1/favoriteFriendOnlineStatus',
|
||
describeRoute({
|
||
tags: ['Social'],
|
||
summary: 'Online status of favorited friends',
|
||
description:
|
||
'Presence for the caller’s favorited friends. Presence lives in the `match` ' +
|
||
'worker and is not joined in here yet, so this is an empty list.',
|
||
responses: { 200: json(JsonArray, 'An empty list') },
|
||
}),
|
||
(c) => c.json([])
|
||
)
|