add mutual friends endpoint

This commit is contained in:
Devin Zuczek
2026-08-04 12:26:44 -04:00
parent 12f6d7ab61
commit dee7497fe5
4 changed files with 201 additions and 0 deletions
+11
View File
@@ -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 -----------------------------------------------------------
/**
+42
View File
@@ -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
+55
View File
@@ -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
+93
View File
@@ -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',