[clubs] add account clubs endpoint

This commit is contained in:
Devin Zuczek
2026-09-30 23:45:08 -04:00
parent e00103c6b3
commit 4319f2942b
3 changed files with 109 additions and 0 deletions
+30
View File
@@ -21,6 +21,7 @@ import {
getHomeClub,
getMembership,
getOrCreateSubscriptionClub,
getPublicClubsByMember,
getSubscriptionClub,
getSubscriptionMemberships,
glyphLength,
@@ -685,6 +686,35 @@ const app = new Hono<App>()
}
)
// The clubs shown on another player's profile: the PUBLIC clubs the account is a
// member of. Public — a private club is left out for everyone, the account's own
// owner included (they read theirs from /club/mine/member). An unknown account
// simply has no clubs.
.get(
'/account/:accountId{[0-9]+}/clubs',
describeRoute({
tags: ['Clubs'],
summary: 'The public clubs an account is a member of',
description: [
'The account’s memberships from `club_member`, oldest club first, PUBLIC clubs only',
'(private clubs, subscription clubs and pending/denied/banned rows excluded). Public',
'(no auth). An unknown account has `[]`.',
].join(' '),
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'Account id (digits only)',
schema: { type: 'string' },
},
],
responses: { 200: json(ClubDto.array(), 'The account’s public clubs') },
}),
async (c) =>
c.json(await getPublicClubsByMember(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
)
// Club search / browse. Public, non-subscription clubs; `category` filters to that
// category, `query` matches the name or description, `sort` picks the order (1 =
// newest, 2 = by name, default = most members first), and `count` caps the page
@@ -369,6 +369,64 @@ describe('clubs endpoints', () => {
expect(await res.json()).toEqual([])
})
test('GET /account/:id/clubs lists only the PUBLIC clubs the account is a member of', async () => {
const club = (ClubId: number, Visibility: number, ClubType = 0) => ({
ClubId,
Name: `Club ${ClubId}`,
Description: '',
Category: 'Social',
Visibility,
Joinability: 0,
AllowJuniors: true,
MainImageName: '',
ClubType,
ClubhouseRoomId: null,
CreatorAccountId: 4500,
IsRRO: false,
MinLevel: 0,
State: 0,
MemberCount: 2,
CreatedAt: `2026-07-0${ClubId - 9100}T00:00:00Z`,
})
// 9101 public, 9102 private, 9103 public but only a pending request, 9104 a
// public subscription club, 9105 public.
const clubs = [club(9101, 1), club(9102, 0), club(9103, 1), club(9104, 1, 1), club(9105, 1)]
const insertClub = env.DB.prepare('INSERT INTO club (data) VALUES (?1)')
const insertMember = env.DB.prepare(
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, 4501, ?2, ?3)'
)
await env.DB.batch([
...clubs.map((c) => insertClub.bind(JSON.stringify(c))),
...clubs.map((c) =>
insertMember.bind(c.ClubId, c.ClubId === 9103 ? 1 : 10, '2026-07-10T00:00:00Z')
),
])
const pending = await env.DB.prepare(
'SELECT membership_type FROM club_member WHERE club_id = 9103 AND account_id = 4501'
).first<{ membership_type: number }>()
expect(pending?.membership_type).toBeLessThan(10)
// No auth needed; the private, pending and subscription clubs are left out.
const res = await exports.default.fetch(`${ORIGIN}/account/4501/clubs`)
expect(res.status).toBe(200)
const listed = (await res.json()) as Array<{ ClubId: number; Visibility: number }>
expect(listed.map((c) => c.ClubId)).toEqual([9101, 9105])
expect(listed[0]).toMatchObject({ Name: 'Club 9101', Visibility: 1, MemberCount: 2 })
// The member themself gets the same public-only list here.
const own = await exports.default.fetch(`${ORIGIN}/account/4501/clubs`, {
headers: await bearer('4501'),
})
expect(((await own.json()) as Array<{ ClubId: number }>).map((c) => c.ClubId)).toEqual([
9101, 9105,
])
// An account with no memberships has no clubs; a non-numeric id doesn't match.
const none = await exports.default.fetch(`${ORIGIN}/account/4599/clubs`)
expect(await none.json()).toEqual([])
expect((await exports.default.fetch(`${ORIGIN}/account/abc/clubs`)).status).toBe(404)
})
test('the my-clubs lists exclude subscription clubs (ClubType 1)', async () => {
// A subscription club the player both created and is a member of. It's reached
// through /subscription/*, so it must not show up among their clubs.
@@ -1646,6 +1704,7 @@ describe('clubs endpoints', () => {
'DELETE /club/{clubId}',
'DELETE /club/{clubId}/additionalimage/{index}',
'DELETE /club/{clubId}/clubhouse',
'GET /account/{accountId}/clubs',
'GET /announcements/club/{clubId}',
'GET /announcements/v2/mine/unread',
'GET /announcements/v2/subscription/mine/unread',
+20
View File
@@ -1079,6 +1079,26 @@ export async function getClubsByMember(db: D1Database, accountId: number): Promi
return parseAll(results)
}
/**
* The PUBLIC clubs an account is an actual member of — what anyone may see of another
* player's clubs — oldest club first. {@link getClubsByMember} minus the private ones.
*/
export async function getPublicClubsByMember(db: D1Database, accountId: number): Promise<Club[]> {
const { results } = await db
.prepare(
`SELECT c.data AS data
FROM club_member m
JOIN club c ON c.club_id = m.club_id
WHERE m.account_id = ?1 AND m.membership_type >= ?2
AND json_extract(c.data, '$.ClubType') != ?3
AND c.visibility = ?4
ORDER BY json_extract(c.data, '$.CreatedAt') ASC`
)
.bind(accountId, MEMBER_THRESHOLD, SUBSCRIPTION_CLUB_TYPE, ClubVisibility.Public)
.all<ClubRow>()
return parseAll(results)
}
/** Whether an account is an actual member of a club (Member tier or above). */
export async function isClubMember(
db: D1Database,