diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index 1cf7f3a..44f943b 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -21,6 +21,7 @@ import { getHomeClub, getMembership, getOrCreateSubscriptionClub, + getPublicClubsByMember, getSubscriptionClub, getSubscriptionMemberships, glyphLength, @@ -685,6 +686,35 @@ const app = new Hono() } ) + // 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 diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index 44c3ad3..6a912be 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -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', diff --git a/packages/domain/src/clubs-db.ts b/packages/domain/src/clubs-db.ts index 0199576..84db877 100644 --- a/packages/domain/src/clubs-db.ts +++ b/packages/domain/src/clubs-db.ts @@ -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 { + 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() + return parseAll(results) +} + /** Whether an account is an actual member of a club (Member tier or above). */ export async function isClubMember( db: D1Database,