mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add mutual friends endpoint
This commit is contained in:
@@ -163,6 +163,17 @@ export const RelationshipDto = z.object({
|
||||
/** The `{ Success, Message }` ack the flag toggles answer with. */
|
||||
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
|
||||
|
||||
/**
|
||||
* One entry of `GET /api/relationships/mutualfriends` — a friend both players share.
|
||||
* A trimmed account card, not a relationship: no relationship type or flags.
|
||||
*/
|
||||
export const MutualFriendDto = z.object({
|
||||
AccountId: z.int(),
|
||||
Username: z.string(),
|
||||
DisplayName: z.string(),
|
||||
ProfileImage: z.string().describe('The image name; an empty string when the account has none'),
|
||||
})
|
||||
|
||||
// ---- Progression -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -155,6 +155,48 @@ export async function getRelationshipsForPlayer(
|
||||
return results.map((row) => toResponse(row, playerId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of everyone a player is actually friends with — `Friend` rows only, from
|
||||
* either side of the pair (the row records one direction, the friendship is mutual).
|
||||
* Pending requests and `None` rows are excluded, unlike
|
||||
* {@link getRelationshipsForPlayer}, which reports the whole graph.
|
||||
*/
|
||||
export async function getFriendIds(db: D1Database, playerId: number): Promise<number[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT CASE WHEN requester_id = ?1 THEN target_id ELSE requester_id END AS id
|
||||
FROM relationship
|
||||
WHERE relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)`
|
||||
)
|
||||
.bind(playerId, RelationshipType.Friend)
|
||||
.all<{ id: number }>()
|
||||
return results.map((r) => r.id)
|
||||
}
|
||||
|
||||
/** How many mutual friends the mutual-friends lookup will return at most. */
|
||||
export const MUTUAL_FRIENDS_LIMIT = 100
|
||||
|
||||
/**
|
||||
* The ids two players are both friends with — the intersection of their friend lists,
|
||||
* ascending and capped at {@link MUTUAL_FRIENDS_LIMIT}. Each player's friends are a
|
||||
* small set, so the intersection is done in memory rather than as a SQL INTERSECT.
|
||||
*/
|
||||
export async function getMutualFriendIds(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
otherId: number
|
||||
): Promise<number[]> {
|
||||
const [mine, theirs] = await Promise.all([
|
||||
getFriendIds(db, playerId),
|
||||
getFriendIds(db, otherId),
|
||||
])
|
||||
const ours = new Set(theirs)
|
||||
return mine
|
||||
.filter((id) => ours.has(id))
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, MUTUAL_FRIENDS_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist `type` for the pair, with `requesterId` recorded as the row's
|
||||
* requester. Inserts a new row or, if one already exists for the pair (either
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { getAccountsByIds } from '@repo/domain'
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
@@ -11,13 +12,16 @@ import {
|
||||
intQuery,
|
||||
json,
|
||||
JsonArray,
|
||||
MutualFriendDto,
|
||||
RelationshipDto,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
import {
|
||||
acceptFriendRequest,
|
||||
addFriend,
|
||||
getMutualFriendIds,
|
||||
getRelationshipsForPlayer,
|
||||
MUTUAL_FRIENDS_LIMIT,
|
||||
removeFriend,
|
||||
sendFriendRequest,
|
||||
setRelationshipFlag,
|
||||
@@ -199,6 +203,57 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
@@ -2103,6 +2103,98 @@ describe('relationships', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutual friends', () => {
|
||||
// High, distinct ids so the friendships seeded here don't collide with the
|
||||
// relationship tests above.
|
||||
const CALLER = 800
|
||||
const OTHER = 801
|
||||
|
||||
type Card = { AccountId: number; Username: string; DisplayName: string; ProfileImage: string }
|
||||
|
||||
const mutuals = async (query: string, sub = String(CALLER)): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}/api/relationships/mutualfriends${query}`, {
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
const rel = (a: number, b: number, type = 3) =>
|
||||
env.DB.prepare(
|
||||
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
|
||||
).bind(a, b, type)
|
||||
// 804 has no profileImage key at all — the projection must still answer a
|
||||
// string. 806 is deliberately given no account row.
|
||||
const account = (id: number, extra: Record<string, unknown>) =>
|
||||
env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)').bind(
|
||||
JSON.stringify({ accountId: id, username: `P${id}`, displayName: `Player ${id}`, ...extra })
|
||||
)
|
||||
|
||||
await env.DB.batch([
|
||||
account(CALLER, { profileImage: 'p800.jpg' }),
|
||||
account(OTHER, { profileImage: 'p801.jpg' }),
|
||||
account(802, { profileImage: 'p802.jpg' }),
|
||||
account(803, { profileImage: 'p803.jpg' }),
|
||||
account(804, {}),
|
||||
// Seeded 804-first so the ascending order of the answer is the code's doing,
|
||||
// not the insertion order's.
|
||||
rel(CALLER, 804),
|
||||
rel(802, CALLER), // friendship recorded from the other direction
|
||||
rel(CALLER, 803),
|
||||
rel(CALLER, 806),
|
||||
rel(OTHER, 804), // shared → in the answer
|
||||
rel(OTHER, 802), // shared → in the answer
|
||||
rel(803, OTHER, 1), // only a pending request → NOT a friend of OTHER
|
||||
rel(OTHER, 806), // shared, but 806 has no account row → dropped
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /api/relationships/mutualfriends returns the shared friends', async () => {
|
||||
const res = await mutuals(`?id=${OTHER}`)
|
||||
expect(res.status).toBe(200)
|
||||
const cards = (await res.json()) as Card[]
|
||||
// 803 is only a pending request on OTHER's side, and 806 has no account row.
|
||||
expect(cards.map((p) => p.AccountId)).toEqual([802, 804])
|
||||
expect(cards[0]).toEqual({
|
||||
AccountId: 802,
|
||||
Username: 'P802',
|
||||
DisplayName: 'Player 802',
|
||||
ProfileImage: 'p802.jpg',
|
||||
})
|
||||
// No stored image → an empty string, never null/undefined.
|
||||
expect(cards[1]?.ProfileImage).toBe('')
|
||||
})
|
||||
|
||||
// The degenerate cases answer an empty list rather than an error — this feeds a
|
||||
// profile panel, which would otherwise have nothing to render.
|
||||
// `?id=` is the only accepted form — `?playerId=` reads as no id at all.
|
||||
test('GET /api/relationships/mutualfriends answers [] for a missing/self/bad id', async () => {
|
||||
for (const query of ['', '?id=0', '?id=-5', '?id=abc', `?id=${CALLER}`, `?playerId=${OTHER}`]) {
|
||||
const res = await mutuals(query)
|
||||
expect(res.status, query).toBe(200)
|
||||
expect(await res.json(), query).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
// Symmetric: 802 and 803 aren't friends with each other, but both are friends with
|
||||
// 800, so 800 is what they have in common.
|
||||
test('GET /api/relationships/mutualfriends works between two other players', async () => {
|
||||
const cards = (await (await mutuals('?id=803', '802')).json()) as Card[]
|
||||
expect(cards.map((p) => p.AccountId)).toEqual([CALLER])
|
||||
})
|
||||
|
||||
test('GET /api/relationships/mutualfriends answers [] with nothing in common', async () => {
|
||||
// 809 has no relationships at all.
|
||||
const cards = (await (await mutuals('?id=809', '802')).json()) as Card[]
|
||||
expect(cards).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/relationships/mutualfriends is auth-gated', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/relationships/mutualfriends?id=${OTHER}`
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openapi', () => {
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
@@ -2181,6 +2273,7 @@ describe('openapi', () => {
|
||||
'GET /api/players/v1/progression/{id}',
|
||||
'GET /api/players/v2/progression/bulk',
|
||||
'GET /api/quickPlay/v1/getandclear',
|
||||
'GET /api/relationships/mutualfriends',
|
||||
'GET /api/relationships/v1/favorite',
|
||||
'GET /api/relationships/v1/ignore',
|
||||
'GET /api/relationships/v1/mute',
|
||||
|
||||
Reference in New Issue
Block a user