From 0902e084e6a636aec85922d7138f812835453f6e Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sun, 12 Jul 2026 22:42:56 -0400 Subject: [PATCH] getting clubs semiworking --- .../migrations/0002_club_announcement.sql | 17 + apps/clubs/package.json | 1 + apps/clubs/src/clubs-db.ts | 430 ++++++++++- apps/clubs/src/clubs.app.ts | 475 +++++++++++- apps/clubs/src/test/integration/api.test.ts | 696 +++++++++++++++++- 5 files changed, 1567 insertions(+), 52 deletions(-) create mode 100644 apps/clubs/migrations/0002_club_announcement.sql diff --git a/apps/clubs/migrations/0002_club_announcement.sql b/apps/clubs/migrations/0002_club_announcement.sql new file mode 100644 index 0000000..d191a6f --- /dev/null +++ b/apps/clubs/migrations/0002_club_announcement.sql @@ -0,0 +1,17 @@ +-- Club announcements — the club's noticeboard (`/announcements/club/:clubId`), +-- served newest first. Unlike the club itself this isn't a JSON blob: the Go model +-- is plain columns and nothing here is client-shaped beyond the fields themselves. +-- Owned by the `clubs` worker; generated from src/clubs-db.ts (SCHEMA_DDL) — keep +-- in sync. + +CREATE TABLE IF NOT EXISTS club_announcement ( + announcement_id INTEGER PRIMARY KEY AUTOINCREMENT, + club_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + image_name TEXT NOT NULL DEFAULT '', + meta TEXT NOT NULL DEFAULT '', + created_at TEXT + ); +CREATE INDEX IF NOT EXISTS idx_club_announcement_club ON club_announcement (club_id); diff --git a/apps/clubs/package.json b/apps/clubs/package.json index 522ffe9..5a8e29b 100644 --- a/apps/clubs/package.json +++ b/apps/clubs/package.json @@ -12,6 +12,7 @@ "deploy": "run-wrangler-deploy", "dev": "run-wrangler-dev", "fix:workers-types": "run-wrangler-types", + "migrate": "run-wrangler-migrate", "test": "run-vitest" }, "dependencies": { diff --git a/apps/clubs/src/clubs-db.ts b/apps/clubs/src/clubs-db.ts index 6b18b95..05bb886 100644 --- a/apps/clubs/src/clubs-db.ts +++ b/apps/clubs/src/clubs-db.ts @@ -43,6 +43,20 @@ export const SCHEMA_DDL: string[] = [ )`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_club_member_pair ON club_member (club_id, account_id)`, `CREATE INDEX IF NOT EXISTS idx_club_member_account ON club_member (account_id)`, + // Club announcements — the club's noticeboard, newest first. Columns rather than a + // JSON blob (mirroring the Go model), since nothing here is client-shaped beyond + // the fields themselves. + `CREATE TABLE IF NOT EXISTS club_announcement ( + announcement_id INTEGER PRIMARY KEY AUTOINCREMENT, + club_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + image_name TEXT NOT NULL DEFAULT '', + meta TEXT NOT NULL DEFAULT '', + created_at TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_club_announcement_club ON club_announcement (club_id)`, ] /** @@ -102,9 +116,15 @@ export interface Club { MemberCount: number } -/** The stored club — the DTO plus `CreatedAt` (kept in the blob, `json:"-"` in Go). */ +/** + * The stored club — the DTO plus fields the client never sees on the Club object + * itself: `CreatedAt` (`json:"-"` in Go) and the club's custom tags, which the Go + * server keeps in a `club_custom_tags` table but which we keep on the blob, since + * they're only ever read and written with the club. + */ interface StoredClub extends Club { CreatedAt: string + CustomTags?: string[] } interface ClubRow { @@ -134,7 +154,8 @@ function toDto(s: StoredClub): Club { const parseOne = (row: ClubRow | null): Club | null => row ? toDto(JSON.parse(row.data) as StoredClub) : null -const parseAll = (rows: ClubRow[]): Club[] => rows.map((r) => toDto(JSON.parse(r.data) as StoredClub)) +const parseAll = (rows: ClubRow[]): Club[] => + rows.map((r) => toDto(JSON.parse(r.data) as StoredClub)) /** * Recompute a club's `MemberCount` from the `club_member` rows and write it back @@ -245,6 +266,384 @@ export async function createClub( return { ...toDto(stored), MemberCount: count } } +/** + * What each membership tier is allowed to do in a club. These are the defaults every + * new club gets (co-owners can do everything, moderators can approve/ban, plain + * members can do none of it); nothing edits them yet, so they're derived per club + * rather than stored. + */ +export interface ClubPermission { + ClubId: number + Type: number + ApproveMember: boolean + BanUnban: boolean + CreateEvent: boolean + EditDetails: boolean + EditPermissionSettings: boolean + PostAnnouncement: boolean +} + +function clubPermission( + clubId: number, + type: ClubMembershipType, + granted: Partial> = {} +): ClubPermission { + return { + ClubId: clubId, + Type: type, + ApproveMember: false, + BanUnban: false, + CreateEvent: false, + EditDetails: false, + EditPermissionSettings: false, + PostAnnouncement: false, + ...granted, + } +} + +/** The club-details payload the client reads from create/details. */ +export interface ClubDetails { + AdditionalImages: unknown[] + Club: Club + ClubId: number + CoownerPermissions: ClubPermission + CustomTags: string[] + MemberPermissions: ClubPermission + ModeratorPermissions: ClubPermission + MyMembershipType: number +} + +/** + * Build the club-details view for a caller. `MyMembershipType` is the caller's own + * membership (0 = none, e.g. a signed-out viewer). Additional images have no storage + * yet, so they're empty; custom tags come from the club (set via `modifydetails`). + */ +export async function getClubDetails( + db: D1Database, + club: Club, + accountId: number | null +): Promise { + return { + AdditionalImages: [], + Club: club, + ClubId: club.ClubId, + CoownerPermissions: clubPermission(club.ClubId, ClubMembershipType.Coowner, { + ApproveMember: true, + BanUnban: true, + CreateEvent: true, + EditDetails: true, + EditPermissionSettings: true, + PostAnnouncement: true, + }), + CustomTags: await getClubCustomTags(db, club.ClubId), + MemberPermissions: clubPermission(club.ClubId, ClubMembershipType.Member), + ModeratorPermissions: clubPermission(club.ClubId, ClubMembershipType.Moderator, { + ApproveMember: true, + BanUnban: true, + }), + MyMembershipType: accountId === null ? 0 : await getMembership(db, club.ClubId, accountId), + } +} + +/** A club announcement (mirror of the Go `ClubAnnouncement`). */ +export interface ClubAnnouncement { + AnnouncementId: number + ClubId: number + AccountId: number + Title: string + Body: string + ImageName: string + Meta: string + CreatedAt: string | null +} + +/** A club's announcements, newest first. An unknown club simply has none. */ +export async function getClubAnnouncements( + db: D1Database, + clubId: number +): Promise { + const { results } = await db + .prepare( + `SELECT announcement_id, club_id, account_id, title, body, image_name, meta, created_at + FROM club_announcement + WHERE club_id = ?1 + ORDER BY created_at DESC, announcement_id DESC` + ) + .bind(clubId) + .all<{ + announcement_id: number + club_id: number + account_id: number + title: string + body: string + image_name: string + meta: string + created_at: string | null + }>() + + return results.map((r) => ({ + AnnouncementId: r.announcement_id, + ClubId: r.club_id, + AccountId: r.account_id, + Title: r.title, + Body: r.body, + ImageName: r.image_name, + Meta: r.meta, + CreatedAt: r.created_at, + })) +} + +/** Post an announcement to a club, returning its new id. */ +export async function createClubAnnouncement( + db: D1Database, + clubId: number, + accountId: number, + fields: { title?: string; body?: string; imageName?: string; meta?: string } +): Promise { + const row = await db + .prepare( + `INSERT INTO club_announcement (club_id, account_id, title, body, image_name, meta, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + RETURNING announcement_id` + ) + .bind( + clubId, + accountId, + fields.title ?? '', + fields.body ?? '', + fields.imageName ?? '', + fields.meta ?? '', + new Date().toISOString() + ) + .first<{ announcement_id: number }>() + return row?.announcement_id ?? 0 +} + +/** What club search answers: the page of clubs plus the total that matched. */ +export interface ClubSearchResult { + Clubs: Club[] + ContinuationToken: null + TotalClubs: number +} + +/** + * Club search (`/club/search`). Public, non-subscription clubs only. `category` is an + * exact (case-insensitive) match, `query` a substring of the name or description. + * `sort`: 1 = newest first, 2 = by name, anything else (including the client's 0) = + * biggest first, then newest. `count` caps the page — out-of-range values fall back to + * 30, as the reference does. `TotalClubs` is the full match count, not the page size. + */ +export async function searchClubs( + db: D1Database, + category: string, + query: string, + sort: string | undefined, + count: number +): Promise { + const { results } = await db + .prepare( + `SELECT data FROM club + WHERE visibility = ?1 + AND json_extract(data, '$.ClubType') != ?2` + ) + .bind(ClubVisibility.Public, SUBSCRIPTION_CLUB_TYPE) + .all() + + const stored = results.map((r) => JSON.parse(r.data) as StoredClub) + const term = query.trim().toLowerCase() + const wanted = category.trim().toLowerCase() + + const matched = stored.filter((club) => { + if (wanted !== '' && club.Category.toLowerCase() !== wanted) return false + if (term === '') return true + return club.Name.toLowerCase().includes(term) || club.Description.toLowerCase().includes(term) + }) + + const byNewest = (a: StoredClub, b: StoredClub) => b.CreatedAt.localeCompare(a.CreatedAt) + matched.sort((a, b) => { + if (sort === '1') return byNewest(a, b) + if (sort === '2') return a.Name.localeCompare(b.Name) + return b.MemberCount - a.MemberCount || byNewest(a, b) + }) + + return { + Clubs: matched.slice(0, count).map(toDto), + ContinuationToken: null, + TotalClubs: matched.length, + } +} + +/** + * The player's "home club" — the one whose clubhouse they spawn into. It's a field + * on the *account* row (owned by the `auth` worker, on the same shared database, the + * way the `api` worker writes the account's profile image), not on the club: one + * home club per player. + * + * Returns null when they haven't set one, when the club is gone, or when it has no + * clubhouse room — a home club with nowhere to go isn't usable, and the reference + * 404s all three cases identically. + */ +export async function getHomeClub(db: D1Database, accountId: number): Promise { + const row = await db + .prepare( + "SELECT json_extract(data, '$.homeClubId') AS clubId FROM account WHERE account_id = ?1" + ) + .bind(accountId) + .first<{ clubId: number | null }>() + if (row?.clubId == null) return null + + const club = await getClub(db, row.clubId) + // `== null` catches a club row that predates the field (undefined), not just an + // explicit null — either way it has no clubhouse to spawn into. + if (club === null || club.ClubhouseRoomId == null) return null + return club +} + +/** Point the player's home club at `clubId` (stored on their account row). */ +export async function setHomeClub( + db: D1Database, + accountId: number, + clubId: number +): Promise { + await db + .prepare("UPDATE account SET data = json_set(data, '$.homeClubId', ?2) WHERE account_id = ?1") + .bind(accountId, clubId) + .run() +} + +/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */ +export interface ClubMember { + ClubMemberId: number + ClubId: number + AccountId: number + MembershipType: number + CreatedAt: string | null +} + +/** + * A club's members (`/club/:id/members`). `membershipType` filters to exactly that + * tier when given — note it's an exact match, not a threshold, so `30` lists only + * co-owners (not the creator above them). `sortBy` picks the order: 1 = by account + * id, 2 = oldest membership first, anything else = the default, highest tier first + * then oldest. An unknown club has no members, so it's an empty list, not a 404. + */ +export async function getClubMembers( + db: D1Database, + clubId: number, + membershipType: number | undefined, + sortBy: string | undefined +): Promise { + const order = + sortBy === '1' + ? 'account_id ASC' + : sortBy === '2' + ? 'created_at ASC' + : 'membership_type DESC, created_at ASC' + const filter = membershipType === undefined ? '' : 'AND membership_type = ?2' + + const { results } = await db + .prepare( + `SELECT club_member_id, club_id, account_id, membership_type, created_at + FROM club_member + WHERE club_id = ?1 ${filter} + ORDER BY ${order}` + ) + .bind(...(membershipType === undefined ? [clubId] : [clubId, membershipType])) + .all<{ + club_member_id: number + club_id: number + account_id: number + membership_type: number + created_at: string | null + }>() + + return results.map((r) => ({ + ClubMemberId: r.club_member_id, + ClubId: r.club_id, + AccountId: r.account_id, + MembershipType: r.membership_type, + CreatedAt: r.created_at, + })) +} + +/** Fields `modifydetails` can change. Anything left undefined keeps its stored value. */ +export interface ClubPatch { + name?: string + description?: string + category?: string + visibility?: number + joinability?: number + allowJuniors?: boolean + mainImageName?: string + minLevel?: number + /** Replaces the club's tags wholesale when present; absent leaves them alone. */ + customTags?: string[] + /** The club's clubhouse room; `null` clears it (undefined leaves it alone). */ + clubhouseRoomId?: number | null +} + +/** + * Apply an edit to a club's details (`modifydetails`). Only the keys present on the + * patch change. Custom tags are replaced as a set — trimmed, de-duplicated + * case-insensitively, first spelling wins. Returns the updated club, or null when + * there's no such club. + */ +export async function updateClub( + db: D1Database, + clubId: number, + patch: ClubPatch +): Promise { + const row = await db + .prepare('SELECT data FROM club WHERE club_id = ?1') + .bind(clubId) + .first() + if (row === null) return null + const stored = JSON.parse(row.data) as StoredClub + + const updated: StoredClub = { + ...stored, + Name: patch.name ?? stored.Name, + Description: patch.description ?? stored.Description, + Category: patch.category ?? stored.Category, + Visibility: patch.visibility ?? stored.Visibility, + Joinability: patch.joinability ?? stored.Joinability, + AllowJuniors: patch.allowJuniors ?? stored.AllowJuniors, + MainImageName: patch.mainImageName ?? stored.MainImageName, + MinLevel: patch.minLevel ?? stored.MinLevel, + CustomTags: patch.customTags === undefined ? stored.CustomTags : dedupeTags(patch.customTags), + // `null` clears the clubhouse, so this can't collapse to `??`. + ClubhouseRoomId: + patch.clubhouseRoomId === undefined ? stored.ClubhouseRoomId : patch.clubhouseRoomId, + } + await db + .prepare('UPDATE club SET data = ?1 WHERE club_id = ?2') + .bind(JSON.stringify(updated), clubId) + .run() + return toDto(updated) +} + +/** Trim, drop blanks, and de-duplicate tags case-insensitively (first spelling wins). */ +function dedupeTags(tags: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const raw of tags) { + const tag = raw.trim() + if (tag === '' || seen.has(tag.toLowerCase())) continue + seen.add(tag.toLowerCase()) + out.push(tag) + } + return out +} + +/** A club's custom tags (stored on the blob; empty when it has none). */ +export async function getClubCustomTags(db: D1Database, clubId: number): Promise { + const row = await db + .prepare('SELECT data FROM club WHERE club_id = ?1') + .bind(clubId) + .first() + return row === null ? [] : ((JSON.parse(row.data) as StoredClub).CustomTags ?? []) +} + /** Look up a single club by its ClubId. */ export async function getClub(db: D1Database, clubId: number): Promise { return parseOne( @@ -252,18 +651,30 @@ export async function getClub(db: D1Database, clubId: number): Promise { const { results } = await db - .prepare('SELECT data FROM club WHERE creator_account_id = ?1') - .bind(accountId) + .prepare( + `SELECT data FROM club + WHERE creator_account_id = ?1 + AND json_extract(data, '$.ClubType') != ?2 + ORDER BY json_extract(data, '$.CreatedAt') ASC` + ) + .bind(accountId, SUBSCRIPTION_CLUB_TYPE) .all() return parseAll(results) } /** - * All clubs an account is an actual member of (GetMyMembershipClubs), most recently - * joined first. Only memberships at/above `Member` count — pending requests, denied + * All clubs an account is an actual member of (GetMyMembershipClubs), oldest club + * first. Only memberships at/above `Member` count — pending requests, denied * requests, and bans are excluded. Joins `club_member` to `club`, so a membership * whose club is gone is simply absent. */ @@ -274,9 +685,10 @@ export async function getClubsByMember(db: D1Database, accountId: number): Promi FROM club_member m JOIN club c ON c.club_id = m.club_id WHERE m.account_id = ?1 AND m.membership_type >= ?2 - ORDER BY m.created_at DESC` + AND json_extract(c.data, '$.ClubType') != ?3 + ORDER BY json_extract(c.data, '$.CreatedAt') ASC` ) - .bind(accountId, MEMBER_THRESHOLD) + .bind(accountId, MEMBER_THRESHOLD, SUBSCRIPTION_CLUB_TYPE) .all() return parseAll(results) } diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index 60c93c6..02d1ed7 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -5,12 +5,24 @@ import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' import { + ClubJoinability, + ClubMembershipType, + ClubVisibility, createClub, + createClubAnnouncement, getClub, + getClubAnnouncements, + getClubDetails, + getClubMembers, getClubsByCreator, getClubsByMember, + getHomeClub, + getMembership, joinClub, leaveClub, + searchClubs, + setHomeClub, + updateClub, } from './clubs-db' import type { Context } from 'hono' @@ -24,6 +36,80 @@ async function authedId(c: Context): Promise { return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } +/** Longest a club name may be (the reference's MaxNameLength). */ +const MAX_CLUB_NAME_LENGTH = 16 + +/** The punctuation a club name may use, on top of letters and digits. */ +const ALLOWED_NAME_PUNCTUATION = new Set([...` .,'!?-_&()#@:+`]) + +/** + * Club names are letters (any Latin script), digits, and basic punctuation — the + * reference's IsValidName. Anything else (emoji, other scripts, control chars) is + * rejected rather than stored. + */ +function isValidClubName(name: string): boolean { + return [...name.normalize('NFC')].every( + (ch) => /\p{Script=Latin}|\p{Nd}/u.test(ch) || ALLOWED_NAME_PUNCTUATION.has(ch) + ) +} + +/** A rejected club action: the same envelope as success, carrying the message. */ +function clubError(c: Context, message: string) { + return c.json({ error: message, success: false, value: null }, 400) +} + +/** + * The client sends enums by *name* (`visibility=Public`, `joinability=Open`), not by + * number — though the numbers are accepted too. An unrecognized value is undefined, + * and leaves the field unchanged rather than resetting it. + */ +function parseVisibility(value: string | undefined): number | undefined { + switch (value?.trim().toLowerCase()) { + case 'private': + case '0': + return ClubVisibility.Private + case 'public': + case '1': + return ClubVisibility.Public + default: + return undefined + } +} + +function parseJoinability(value: string | undefined): number | undefined { + switch (value?.trim().toLowerCase().replace(/_/g, '')) { + case 'open': + case '0': + return ClubJoinability.Open + case 'inviteonly': + case '1': + return ClubJoinability.InviteOnly + // The client calls this AskToJoin; the reference parses it as RequestToJoin. + case 'asktojoin': + case 'requesttojoin': + case '2': + return ClubJoinability.AskToJoin + default: + return undefined + } +} + +/** Form booleans arrive as `True`/`false`/`1`/`yes`. */ +function parseFormBool(value: string | undefined): boolean | undefined { + switch (value?.trim().toLowerCase()) { + case 'true': + case '1': + case 'yes': + return true + case 'false': + case '0': + case 'no': + return false + default: + return undefined + } +} + const app = new Hono() .use( '*', @@ -38,12 +124,44 @@ const app = new Hono() .onError(withOnError()) .notFound(withNotFound()) - // Auth-gated → 401 without a valid token. A bare 404 here makes the client - // treat it as an error, so we return an empty object stub. + // The player's home club — the one whose clubhouse they spawn into, stored on their + // account. Auth-gated. 404 when they have no home club, the club is gone, or it has + // no clubhouse room: the client expects a 404 for "no home club" and errors on an + // empty object. Returns the bare club (not the envelope), as the reference does. .get('/club/home/me', async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) - return c.json({}) + const club = await getHomeClub(c.env.DB, id) + return club === null ? c.notFound() : c.json(club) + }) + + // Set the player's home club (`clubId` form field). They must be a member of it — + // you can't make a club you don't belong to your home. Answers the envelope. + .put('/club/home/me', async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const key = Object.keys(body).find((k) => k.toLowerCase() === 'clubid') + const clubId = Number.parseInt( + typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '', + 10 + ) + if (Number.isNaN(clubId) || clubId === 0) return clubError(c, 'Invalid clubId.') + + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Member) { + return c.json( + { error: 'You are not a member of that club.', success: false, value: null }, + 403 + ) + } + + await setHomeClub(c.env.DB, id, clubId) + return c.json({ error: '', success: true, value: club }) }) // A real Rec Room client endpoint with no backing implementation yet. The @@ -69,60 +187,359 @@ const app = new Hono() .get('/subscription/subscriberCount/:accountId{[0-9]+}', (c) => c.json(0)) // The player's clubs that have unread announcements (MyClubsWithUnread- - // Announcements). No announcements backing yet → empty list. + // Announcements). Nothing tracks what a player has read yet → nothing is unread. .get('/announcements/v2/mine/unread', (c) => c.json([])) - // The clubs the player is a member of (GetMyMembershipClubs). Auth-gated; reads - // the caller's memberships from `club_member`. - .get('/club/mine/member', async (c) => { + // A club's announcements — its noticeboard, newest first. Public. Answers the + // envelope, with `LastAnnouncementId` the newest one (null when there are none) + // and `LastReadAnnouncementId` 0: nothing tracks read state yet. + .get('/announcements/club/:clubId{[0-9]+}', async (c) => { + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const announcements = await getClubAnnouncements(c.env.DB, clubId) + return c.json({ + error: '', + success: true, + value: { + Announcements: announcements, + ClubId: clubId, + LastAnnouncementId: announcements[0]?.AnnouncementId ?? null, + LastReadAnnouncementId: 0, + }, + }) + }) + + // Post an announcement to a club. Co-owner or above only. The envelope's value is + // the new announcement's id. + .post('/announcements/club/:clubId{[0-9]+}', async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) + + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const field = (name: string): string | undefined => { + const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) + const v = key === undefined ? undefined : body[key] + return typeof v === 'string' ? v : undefined + } + + const announcementId = await createClubAnnouncement(c.env.DB, clubId, id, { + title: field('title'), + body: field('body'), + imageName: field('imageName'), + meta: field('meta'), + }) + return c.json({ error: '', success: true, value: announcementId }) + }) + + // The clubs the player is a member of (GetMyMembershipClubs). Reads the caller's + // memberships from `club_member`. A caller with no valid token has no clubs, so + // this answers an empty list rather than 401ing — the client shows the "my clubs" + // shelf either way, and an error there breaks the screen. + .get('/club/mine/member', async (c) => { + const id = await authedId(c) + if (id === null) return c.json([]) return c.json(await getClubsByMember(c.env.DB, id)) }) - // The clubs the player created (GetMyCreatedClubs). Auth-gated. + // The clubs the player created (GetMyCreatedClubs). Empty list when signed out, + // like mine/member. .get('/club/mine/created', async (c) => { const id = await authedId(c) - if (id === null) return c.body(null, 401) + if (id === null) return c.json([]) return c.json(await getClubsByCreator(c.env.DB, id)) }) + // 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 + // (out of range → 30). Public. Answers `{ Clubs, ContinuationToken, TotalClubs }`. + .get('/club/search', async (c) => { + const count = Number.parseInt(c.req.query('count') ?? '', 10) + return c.json( + await searchClubs( + c.env.DB, + c.req.query('category') ?? '', + c.req.query('query') ?? '', + c.req.query('sort'), + Number.isNaN(count) || count <= 0 || count > 100 ? 30 : count + ) + ) + }) + // The set of club category tags a club can be filed under — a fixed list. .get('/club/categoryTags', (c) => c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment']) ) - // Create a club owned by the caller. Auth-gated (401). Body carries the club - // fields (Name required; the rest fall back to the model defaults). The creator - // is auto-joined as the owner. Returns the new Club. - .post('/club', async (c) => { + // Create a club. The client posts a form to `/club/create` with lowercase fields + // (`name`, `description`, `category`). Auth-gated. Answers the `{ error, success, + // value }` envelope carrying the new club's details — not a bare club. + .post('/club/create', async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) const body = (await c.req.parseBody().catch(() => ({}))) as Record - const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) - const int = (v: unknown): number | undefined => { - const n = typeof v === 'string' ? Number.parseInt(v, 10) : Number.NaN + // The client sends lowercase field names; accept either casing. + const field = (name: string): string | undefined => { + const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) + const v = key === undefined ? undefined : body[key] + return typeof v === 'string' ? v : undefined + } + const int = (v: string | undefined): number | undefined => { + const n = v === undefined ? Number.NaN : Number.parseInt(v, 10) return Number.isNaN(n) ? undefined : n } - const bool = (v: unknown): boolean | undefined => - typeof v === 'string' ? v.toLowerCase() === 'true' : undefined - const name = str(body.Name)?.trim() ?? '' - if (name === '') return c.json({ error: 'You must enter a name for your club.' }, 400) + const name = field('name')?.trim() ?? '' + const description = field('description') ?? '' + if (name === '') return clubError(c, 'You must enter a name for your club.') + if (!isValidClubName(name)) { + return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.') + } + if ([...name].length > MAX_CLUB_NAME_LENGTH) { + return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`) + } const club = await createClub(c.env.DB, id, { name, - description: str(body.Description), - category: str(body.Category), - visibility: int(body.Visibility), - joinability: int(body.Joinability), - allowJuniors: bool(body.AllowJuniors), - mainImageName: str(body.MainImageName), - clubType: int(body.ClubType), - minLevel: int(body.MinLevel), + description, + // An unset category files the club under Social, as the reference does. + category: field('category')?.trim() || 'Social', + visibility: parseVisibility(field('visibility')), + joinability: parseJoinability(field('joinability')), + allowJuniors: parseFormBool(field('allowJuniors')), + mainImageName: field('mainImageName'), + // ClubType is deliberately not taken from the client: a player-created club + // is always a regular one. Letting the client pick would let it mint a + // subscription club (type 1), which is excluded from every club listing. + minLevel: int(field('minLevel')), + }) + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, club, id), + }) + }) + + // Edit a club's details. The client PUTs a form of the fields it's changing — + // enums by name (`visibility=Public`, `joinability=Open`, `allowJuniors=True`) — + // and absent fields keep their stored value. `customTags` may repeat; when present + // it replaces the club's tag set wholesale. Co-owner or above only. Answers the + // same `{ error, success, value }` envelope create does. + .put('/club/:clubId{[0-9]+}/modifydetails', async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + + // Editing details is a co-owner power — plain members and moderators can't. + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + // `all: true` so a repeated `customTags` field arrives as a list. + const body = (await c.req.parseBody({ all: true }).catch(() => ({}))) as Record + const field = (name: string): string | undefined => { + const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) + const v = key === undefined ? undefined : body[key] + const first = Array.isArray(v) ? v[0] : v + return typeof first === 'string' ? first : undefined + } + const list = (name: string): string[] | undefined => { + const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) + if (key === undefined) return undefined + const v = body[key] + const values = Array.isArray(v) ? v : [v] + return values.filter((t): t is string => typeof t === 'string') + } + const int = (v: string | undefined): number | undefined => { + const n = v === undefined ? Number.NaN : Number.parseInt(v, 10) + return Number.isNaN(n) ? undefined : n + } + + // An empty name/description means "unchanged", not "clear it" — the reference + // only applies these when non-empty. + const name = field('name')?.trim() || undefined + if (name !== undefined) { + if (!isValidClubName(name)) { + return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.') + } + if ([...name].length > MAX_CLUB_NAME_LENGTH) { + return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`) + } + } + + const updated = await updateClub(c.env.DB, clubId, { + name, + description: field('description') || undefined, + category: field('category')?.trim() || undefined, + visibility: parseVisibility(field('visibility')), + joinability: parseJoinability(field('joinability')), + allowJuniors: parseFormBool(field('allowJuniors')), + mainImageName: field('mainImageName') || undefined, + minLevel: int(field('minLevel')), + customTags: list('customTags'), + }) + if (updated === null) return c.notFound() + + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, updated, id), + }) + }) + + // A club's full details — the club plus its tags, the per-tier permissions, and the + // caller's own membership. Public (a signed-out viewer just gets MyMembershipType + // 0). Unlike create/modifydetails this one is *not* enveloped: the reference writes + // the details object straight out. + .get('/club/:clubId{[0-9]+}/details', async (c) => { + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + const id = await authedId(c) + return c.json(await getClubDetails(c.env.DB, club, id)) + }) + + // Whether a club has turned its club chat off. Nothing can disable club chat yet + // (no setting, no storage), so chat is always on → `false`. A bare JSON boolean, + // like the other `is…`/`has…` gates the client polls; not in the reference, so if + // the client chokes on this it likely wants the `{ error, success, value }` + // envelope the other club endpoints use. + .get('/club/:clubId{[0-9]+}/hasDisabledClubChat', (c) => c.json(false)) + + // A club's members. `membershipType` filters to exactly that tier (an exact match, + // not a threshold — `30` lists co-owners only, not the creator above them), and + // `sortBy` picks the order (1 = account id, 2 = oldest first, default = highest + // tier first). Public, and an unknown club is an empty list. Answers the envelope. + .get('/club/:clubId{[0-9]+}/members', async (c) => { + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const raw = c.req.query('membershipType') + const membershipType = raw === undefined ? Number.NaN : Number.parseInt(raw, 10) + + const members = await getClubMembers( + c.env.DB, + clubId, + Number.isNaN(membershipType) ? undefined : membershipType, + c.req.query('sortBy') + ) + return c.json({ error: '', success: true, value: members }) + }) + + // Set the minimum player level required to join the club. The reference has no such + // route (it only takes `minLevel` on modifydetails), but the client PUTs it here. + // Same rules as the other club edits: co-owner or above, and the details envelope. + .put('/club/:clubId{[0-9]+}/minlevel', async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const key = Object.keys(body).find((k) => k.toLowerCase() === 'minlevel') + const minLevel = Number.parseInt( + typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '', + 10 + ) + if (Number.isNaN(minLevel) || minLevel < 0) return clubError(c, 'Invalid minLevel.') + + const updated = await updateClub(c.env.DB, clubId, { minLevel }) + if (updated === null) return c.notFound() + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, updated, id), + }) + }) + + // Set (or clear) the club's clubhouse room — the room players spawn into when the + // club is their home. `roomId` sets it; omitting it clears the clubhouse. Co-owner + // or above only. Answers the envelope with a null value, as the reference does. + .put('/club/:clubId{[0-9]+}/clubhouse', async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const key = Object.keys(body).find((k) => k.toLowerCase() === 'roomid') + const raw = typeof body[key ?? ''] === 'string' ? String(body[key ?? '']).trim() : '' + if (raw !== '' && Number.isNaN(Number.parseInt(raw, 10))) { + return clubError(c, 'Invalid roomId.') + } + + await updateClub(c.env.DB, clubId, { + clubhouseRoomId: raw === '' ? null : Number.parseInt(raw, 10), + }) + return c.json({ error: '', success: true, value: null }) + }) + + // The club's main image. PUT sets it from an uploaded image's `imageName` (the + // name the `storage` worker handed back); co-owner or above only. GET reads it — + // the reference has no GET here (it 404s), but the client asks for it, so this + // answers the same details envelope rather than erroring; the image name is on + // `value.Club.MainImageName`. + .get('/club/:clubId{[0-9]+}/mainimage', async (c) => { + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + const id = await authedId(c) + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, club, id), + }) + }) + .put('/club/:clubId{[0-9]+}/mainimage', async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename') + const imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : '' + if (imageName === '') return clubError(c, 'imageName is required.') + + const updated = await updateClub(c.env.DB, clubId, { mainImageName: imageName }) + if (updated === null) return c.notFound() + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, updated, id), }) - return c.json(club) }) // A single club by id. 404 when the club isn't in the DB. Public. diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index 6456ee9..3c07010 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -19,6 +19,21 @@ beforeAll(async () => { await adminSecretsStore(env.JWT_SECRET).create('test-signing-key') // Build the club / club_member tables (mirrors the migration). for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() + + // Accounts table (owned by the auth worker) — a player's home club is a field on + // their account row, so /club/home/me reads and writes it here. + await env.DB.prepare( + `CREATE TABLE IF NOT EXISTS account ( + data TEXT NOT NULL, + account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL + )` + ).run() + const insertAccount = env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + await env.DB.batch( + [42, 9100, 9101].map((accountId) => + insertAccount.bind(JSON.stringify({ accountId, username: `Player${accountId}` })) + ) + ) }) // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store. @@ -53,10 +68,10 @@ describe('clubs endpoints', () => { expect(res.status).toBe(401) }) - test('GET /club/home/me returns an empty object with a valid token', async () => { + test('GET /club/home/me 404s when the player has no home club', async () => { + // The client expects a 404 here, and errors on an empty object. const res = await exports.default.fetch(`${ORIGIN}/club/home/me`, { headers: await bearer() }) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({}) + expect(res.status).toBe(404) }) test('GET /subscription/mine/member returns an empty array without a token', async () => { @@ -90,8 +105,12 @@ describe('clubs endpoints', () => { expect(await res.json()).toEqual([]) }) - test('GET /club/mine/member is auth-gated and lists the caller’s clubs', async () => { - expect((await exports.default.fetch(`${ORIGIN}/club/mine/member`)).status).toBe(401) + test('GET /club/mine/member lists the caller’s clubs; signed out is an empty list', async () => { + // Signed out is "no clubs", not an error — a 401 here breaks the client's shelf. + const anon = await exports.default.fetch(`${ORIGIN}/club/mine/member`) + expect(anon.status).toBe(200) + expect(await anon.json()).toEqual([]) + const res = await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('4242'), }) @@ -99,6 +118,643 @@ describe('clubs endpoints', () => { expect(await res.json()).toEqual([]) }) + test('GET /club/mine/created returns an empty list when signed out', async () => { + const res = await exports.default.fetch(`${ORIGIN}/club/mine/created`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + + 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. + const club = { + ClubId: 9001, + Name: 'Subscribers', + Description: '', + Category: '', + Visibility: 1, + Joinability: 0, + AllowJuniors: true, + MainImageName: '', + ClubType: 1, + ClubhouseRoomId: null, + CreatorAccountId: 4343, + IsRRO: false, + MinLevel: 0, + State: 0, + MemberCount: 1, + CreatedAt: '2026-07-01T00:00:00Z', + } + await env.DB.prepare('INSERT INTO club (data) VALUES (?1)').bind(JSON.stringify(club)).run() + await env.DB.prepare( + 'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, ?2, ?3, ?4)' + ) + .bind(9001, 4343, 100, '2026-07-01T00:00:00Z') + .run() + + const created = await exports.default.fetch(`${ORIGIN}/club/mine/created`, { + headers: await bearer('4343'), + }) + expect(await created.json()).toEqual([]) + const member = await exports.default.fetch(`${ORIGIN}/club/mine/member`, { + headers: await bearer('4343'), + }) + expect(await member.json()).toEqual([]) + }) + + test('POST /club/create takes the client’s lowercase form and answers the envelope', async () => { + // The exact request the client sends: lowercase name/description/category. + const res = await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { + ...(await bearer('5000')), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'name=clubz&description=da%20best%20club&category=Creative', + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + error: string + success: boolean + value: { + Club: { Name: string; Description: string; Category: string; CreatorAccountId: number } + ClubId: number + CustomTags: string[] + AdditionalImages: unknown[] + MyMembershipType: number + CoownerPermissions: { Type: number; EditDetails: boolean } + ModeratorPermissions: { Type: number; BanUnban: boolean; EditDetails: boolean } + MemberPermissions: { Type: number; BanUnban: boolean } + } + } + expect(body.error).toBe('') + expect(body.success).toBe(true) + expect(body.value.Club).toMatchObject({ + Name: 'clubz', + Description: 'da best club', + Category: 'Creative', + CreatorAccountId: 5000, + }) + expect(body.value.ClubId).toBeGreaterThan(0) + expect(body.value.MyMembershipType).toBe(100) // creator + expect(body.value.CustomTags).toEqual([]) + expect(body.value.AdditionalImages).toEqual([]) + // Co-owners get everything, moderators approve/ban only, members nothing. + expect(body.value.CoownerPermissions).toMatchObject({ Type: 30, EditDetails: true }) + expect(body.value.ModeratorPermissions).toMatchObject({ + Type: 20, + BanUnban: true, + EditDetails: false, + }) + expect(body.value.MemberPermissions).toMatchObject({ Type: 10, BanUnban: false }) + }) + + test('POST /club/create defaults the category and rejects bad names', async () => { + const create = async (fields: Record): Promise => + exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { + ...(await bearer('5001')), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(fields).toString(), + }) + + // No category → Social. + const defaulted = (await (await create({ name: 'Catless' })).json()) as { + value: { Club: { Category: string } } + } + expect(defaulted.value.Club.Category).toBe('Social') + + // Emoji and other non-Latin scripts are rejected; the error rides the envelope. + const emoji = await create({ name: 'club 🎉' }) + expect(emoji.status).toBe(400) + expect(await emoji.json()).toMatchObject({ success: false, value: null }) + + // Names cap at 16 characters. + expect((await create({ name: 'a'.repeat(17) })).status).toBe(400) + expect((await create({ name: 'a'.repeat(16) })).status).toBe(200) + + // Basic punctuation is allowed. + expect((await create({ name: "Bob's Club (2)" })).status).toBe(200) + }) + + test('PUT /club/:id/modifydetails edits the club, co-owner only', async () => { + type Details = { + error: string + success: boolean + value: { + Club: { + Visibility: number + Joinability: number + AllowJuniors: boolean + Name: string + Description: string + } + CustomTags: string[] + } + } + const create = await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { ...(await bearer('6000')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=Editable&description=before', + }) + const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId + + const modify = async (body: string, sub = '6000'): Promise => + exports.default.fetch(`${ORIGIN}/club/${clubId}/modifydetails`, { + method: 'PUT', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + + // The client's exact request: enums by name, and a custom tag. + const res = await modify('visibility=Public&joinability=Open&allowJuniors=True&customTags=devi') + expect(res.status).toBe(200) + const body = (await res.json()) as Details + expect(body).toMatchObject({ error: '', success: true }) + expect(body.value.Club).toMatchObject({ + Visibility: 1, // Public + Joinability: 0, // Open + AllowJuniors: true, + // Fields the request didn't mention are untouched. + Name: 'Editable', + Description: 'before', + }) + expect(body.value.CustomTags).toEqual(['devi']) + + // The other enum spellings land on the right numbers. + const priv = (await ( + await modify('visibility=Private&joinability=AskToJoin&allowJuniors=False') + ).json()) as Details + expect(priv.value.Club).toMatchObject({ + Visibility: 0, + Joinability: 2, + AllowJuniors: false, + }) + const invite = (await (await modify('joinability=InviteOnly')).json()) as Details + expect(invite.value.Club.Joinability).toBe(1) + + // customTags replaces the set wholesale, de-duplicated case-insensitively. + const retagged = (await ( + await modify('customTags=Alpha&customTags=alpha&customTags=Beta') + ).json()) as Details + expect(retagged.value.CustomTags).toEqual(['Alpha', 'Beta']) + // Omitting customTags leaves the existing tags alone. + const untouched = (await (await modify('category=Social')).json()) as Details + expect(untouched.value.CustomTags).toEqual(['Alpha', 'Beta']) + + // A plain member can't edit; a non-member can't either; signed out is a 401. + await exports.default.fetch(`${ORIGIN}/club/${clubId}/join`, { + method: 'POST', + headers: await bearer('6001'), + }) + expect((await modify('name=Hijacked', '6001')).status).toBe(403) + expect((await modify('name=Hijacked', '6002')).status).toBe(403) + const anon = await exports.default.fetch(`${ORIGIN}/club/${clubId}/modifydetails`, { + method: 'PUT', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=Hijacked', + }) + expect(anon.status).toBe(401) + + // Editing a club that doesn't exist 404s. + const missing = await exports.default.fetch(`${ORIGIN}/club/99999/modifydetails`, { + method: 'PUT', + headers: { ...(await bearer('6000')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=Ghost', + }) + expect(missing.status).toBe(404) + }) + + test('GET/PUT /club/:id/mainimage reads and sets the club image, co-owner only', async () => { + type Details = { error: string; success: boolean; value: { Club: { MainImageName: string } } } + const create = await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { ...(await bearer('7000')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=Picturesque', + }) + const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId + + // GET reads the current image (the create default) without auth. + const read = await exports.default.fetch(`${ORIGIN}/club/${clubId}/mainimage`) + expect(read.status).toBe(200) + expect(((await read.json()) as Details).value.Club.MainImageName).toBe('DefaultImgPurple') + + // PUT sets it from an uploaded image's name. + const put = async (body: string, sub = '7000'): Promise => + exports.default.fetch(`${ORIGIN}/club/${clubId}/mainimage`, { + method: 'PUT', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + const set = await put('imageName=2026-07-12%2Fclub.jpg') + expect(set.status).toBe(200) + const body = (await set.json()) as Details + expect(body).toMatchObject({ error: '', success: true }) + expect(body.value.Club.MainImageName).toBe('2026-07-12/club.jpg') + + // It sticks, and the GET reflects it. + const reread = await exports.default.fetch(`${ORIGIN}/club/${clubId}/mainimage`) + expect(((await reread.json()) as Details).value.Club.MainImageName).toBe('2026-07-12/club.jpg') + + // imageName is required; non-co-owners can't set it; signed out is a 401. + expect((await put('')).status).toBe(400) + expect((await put('imageName=x.jpg', '7001')).status).toBe(403) + const anon = await exports.default.fetch(`${ORIGIN}/club/${clubId}/mainimage`, { + method: 'PUT', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'imageName=x.jpg', + }) + expect(anon.status).toBe(401) + + // An unknown club 404s on both verbs. + expect((await exports.default.fetch(`${ORIGIN}/club/99999/mainimage`)).status).toBe(404) + }) + + test('GET /club/:id/members filters by membershipType and sorts', async () => { + type Member = { + ClubMemberId: number + ClubId: number + AccountId: number + MembershipType: number + } + type Body = { error: string; success: boolean; value: Member[] } + + const create = await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { ...(await bearer('8000')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=Crowded', + }) + const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId + + // 8002 then 8001 join as plain members (Member = 10); 8000 is the Creator (100). + for (const sub of ['8002', '8001']) { + await exports.default.fetch(`${ORIGIN}/club/${clubId}/join`, { + method: 'POST', + headers: await bearer(sub), + }) + } + const members = async (query = ''): Promise => { + const res = await exports.default.fetch(`${ORIGIN}/club/${clubId}/members${query}`) + expect(res.status).toBe(200) + return (await res.json()) as Body + } + + // Default order: highest tier first, then oldest membership. + const all = await members() + expect(all).toMatchObject({ error: '', success: true }) + expect(all.value.map((m) => m.AccountId)).toEqual([8000, 8002, 8001]) + expect(all.value[0]).toMatchObject({ ClubId: clubId, MembershipType: 100 }) + + // sortBy=1 orders by account id; sortBy=2 by join time. + expect((await members('?sortBy=1')).value.map((m) => m.AccountId)).toEqual([8000, 8001, 8002]) + expect((await members('?sortBy=2')).value.map((m) => m.AccountId)).toEqual([8000, 8002, 8001]) + + // membershipType is an exact match, not a threshold. + expect((await members('?membershipType=10')).value.map((m) => m.AccountId)).toEqual([ + 8002, 8001, + ]) + expect((await members('?membershipType=100')).value.map((m) => m.AccountId)).toEqual([8000]) + // The client's request: co-owners only — nobody holds that tier here. + expect((await members('?membershipType=30&sortBy=0')).value).toEqual([]) + + // An unknown club is an empty list, not a 404. + const ghost = await exports.default.fetch(`${ORIGIN}/club/99999/members`) + expect(ghost.status).toBe(200) + expect(((await ghost.json()) as Body).value).toEqual([]) + }) + + test('GET /club/:id/hasDisabledClubChat reports chat enabled', async () => { + // Nothing can disable club chat yet → always false, for any club. + const res = await exports.default.fetch(`${ORIGIN}/club/1/hasDisabledClubChat`) + expect(res.status).toBe(200) + expect(await res.json()).toBe(false) + }) + + test('PUT /club/home/me sets the home club; GET serves it once it has a clubhouse', async () => { + type Club = { ClubId: number; ClubhouseRoomId: number | null } + const form = async (path: string, method: string, body: string, sub: string) => + exports.default.fetch(`${ORIGIN}${path}`, { + method, + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + + const create = await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { ...(await bearer('9100')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=Homely', + }) + const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId + + // No home club yet → 404. + const before = await exports.default.fetch(`${ORIGIN}/club/home/me`, { + headers: await bearer('9100'), + }) + expect(before.status).toBe(404) + + // You must be a member of the club you're making your home. + expect((await form('/club/home/me', 'PUT', `clubId=${clubId}`, '9101')).status).toBe(403) + // A missing/zero clubId is a 400, an unknown club a 404. + expect((await form('/club/home/me', 'PUT', 'clubId=0', '9100')).status).toBe(400) + expect((await form('/club/home/me', 'PUT', 'clubId=99999', '9100')).status).toBe(404) + + const set = await form('/club/home/me', 'PUT', `clubId=${clubId}`, '9100') + expect(set.status).toBe(200) + expect(await set.json()).toMatchObject({ error: '', success: true }) + + // The club has no clubhouse room yet, so there's still nowhere to spawn → 404. + const noClubhouse = await exports.default.fetch(`${ORIGIN}/club/home/me`, { + headers: await bearer('9100'), + }) + expect(noClubhouse.status).toBe(404) + + // Give it a clubhouse → the home club now resolves. + const clubhouse = await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100') + expect(clubhouse.status).toBe(200) + expect(await clubhouse.json()).toEqual({ error: '', success: true, value: null }) + + const home = await exports.default.fetch(`${ORIGIN}/club/home/me`, { + headers: await bearer('9100'), + }) + expect((await home.json()) as Club).toMatchObject({ ClubId: clubId, ClubhouseRoomId: 77 }) + + // Clearing the clubhouse takes the home club away again. + await form(`/club/${clubId}/clubhouse`, 'PUT', '', '9100') + const cleared = await exports.default.fetch(`${ORIGIN}/club/home/me`, { + headers: await bearer('9100'), + }) + expect(cleared.status).toBe(404) + + // Only co-owners may set the clubhouse; signed out is a 401 on both. + expect((await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=1', '9101')).status).toBe(403) + const anon = await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'PUT' }) + expect(anon.status).toBe(401) + }) + + test('GET /club/search filters by category/query and sorts', async () => { + type Result = { + Clubs: Array<{ ClubId: number; Name: string; Category: string }> + ContinuationToken: null + TotalClubs: number + } + const create = async (fields: Record, sub: string): Promise => { + const res = await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(fields).toString(), + }) + return ((await res.json()) as { value: { ClubId: number } }).value.ClubId + } + const search = async (query: string): Promise => { + const res = await exports.default.fetch(`${ORIGIN}/club/search?${query}`) + expect(res.status).toBe(200) + return (await res.json()) as Result + } + + // Own category, so clubs the other tests created (which default to Social) don't + // wander into these assertions. The second club gains a member, so it outranks + // the first on the default sort. + const alpha = await create({ name: 'Chess Fans', category: 'Boardgames' }, '9200') + const beta = await create( + { name: 'Board Gamers', description: 'we love chess', category: 'Boardgames' }, + '9201' + ) + await create({ name: 'Painters', category: 'Creative' }, '9202') + await exports.default.fetch(`${ORIGIN}/club/${beta}/join`, { + method: 'POST', + headers: await bearer('9203'), + }) + + // Default sort → most members first. Category filters out the Creative club. + const listed = await search('sort=0&category=Boardgames&count=32') + expect(listed.ContinuationToken).toBeNull() + expect(listed.Clubs.map((c) => c.ClubId)).toEqual([beta, alpha]) + expect(listed.TotalClubs).toBe(2) + expect(listed.Clubs.every((c) => c.Category === 'Boardgames')).toBe(true) + + // sort=2 orders by name. + expect((await search('sort=2&category=Boardgames')).Clubs.map((c) => c.Name)).toEqual([ + 'Board Gamers', + 'Chess Fans', + ]) + + // `query` matches the name or the description. + const chess = await search('query=chess&category=Boardgames') + expect(chess.Clubs.map((c) => c.ClubId).sort()).toEqual([alpha, beta].sort()) + expect((await search('query=nomatch')).Clubs).toEqual([]) + + // `count` caps the page, but TotalClubs reports the full match count. + const capped = await search('category=Boardgames&count=1') + expect(capped.Clubs).toHaveLength(1) + expect(capped.TotalClubs).toBe(2) + + // Private clubs never show up in search. + await exports.default.fetch(`${ORIGIN}/club/${alpha}/modifydetails`, { + method: 'PUT', + headers: { ...(await bearer('9200')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'visibility=Private', + }) + expect((await search('category=Boardgames')).Clubs.map((c) => c.ClubId)).toEqual([beta]) + + // The client's own request shape still answers cleanly. + const clientRequest = await search('sort=0&category=Social&count=32') + expect(clientRequest.ContinuationToken).toBeNull() + expect(clientRequest.TotalClubs).toBeGreaterThanOrEqual(clientRequest.Clubs.length) + expect(clientRequest.Clubs.every((c) => c.Category === 'Social')).toBe(true) + }) + + test('GET /club/:id/details serves the bare details object', async () => { + type Details = { + Club: { ClubId: number; Name: string } + ClubId: number + CustomTags: string[] + AdditionalImages: unknown[] + MyMembershipType: number + CoownerPermissions: { Type: number } + ModeratorPermissions: { Type: number } + MemberPermissions: { Type: number } + } + const create = await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { ...(await bearer('9300')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=Detailed', + }) + const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId + await exports.default.fetch(`${ORIGIN}/club/${clubId}/modifydetails`, { + method: 'PUT', + headers: { ...(await bearer('9300')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'customTags=tagged', + }) + + // Not enveloped — the details object is the whole body. + const res = await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`, { + headers: await bearer('9300'), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as Details & { success?: boolean; value?: unknown } + expect(body.success).toBeUndefined() + expect(body.value).toBeUndefined() + expect(body.Club).toMatchObject({ ClubId: clubId, Name: 'Detailed' }) + expect(body.ClubId).toBe(clubId) + expect(body.CustomTags).toEqual(['tagged']) + expect(body.AdditionalImages).toEqual([]) + expect(body.MyMembershipType).toBe(100) // the creator + expect(body.CoownerPermissions.Type).toBe(30) + expect(body.ModeratorPermissions.Type).toBe(20) + expect(body.MemberPermissions.Type).toBe(10) + + // Public: a signed-out viewer sees the club with no membership of their own. + const anon = await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`) + expect(((await anon.json()) as Details).MyMembershipType).toBe(0) + + // An unknown club 404s. + expect((await exports.default.fetch(`${ORIGIN}/club/99999/details`)).status).toBe(404) + }) + + test('GET/POST /announcements/club/:id serves and posts the noticeboard', async () => { + type Announcement = { + AnnouncementId: number + Title: string + Body: string + AccountId: number + ImageName: string + Meta: string + } + type Board = { + error: string + success: boolean + value: { + Announcements: Announcement[] + ClubId: number + LastAnnouncementId: number | null + LastReadAnnouncementId: number + } + } + const create = await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { ...(await bearer('9400')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=Newsy', + }) + const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId + const board = async (): Promise => { + const res = await exports.default.fetch(`${ORIGIN}/announcements/club/${clubId}`) + expect(res.status).toBe(200) + return (await res.json()) as Board + } + const post = async (body: string, sub = '9400'): Promise => + exports.default.fetch(`${ORIGIN}/announcements/club/${clubId}`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + + // An empty board: no announcements, and no "last" one. + const empty = await board() + expect(empty).toMatchObject({ error: '', success: true }) + expect(empty.value).toMatchObject({ + Announcements: [], + ClubId: clubId, + LastAnnouncementId: null, + LastReadAnnouncementId: 0, + }) + + // Posting returns the new announcement's id in the envelope. + const first = await post('title=Hello&body=First+post') + expect(first.status).toBe(200) + const firstId = ((await first.json()) as { value: number }).value + expect(firstId).toBeGreaterThan(0) + const secondId = ((await (await post('title=Again&body=Second')).json()) as { value: number }) + .value + + // Newest first, and LastAnnouncementId points at it. + const posted = await board() + expect(posted.value.Announcements.map((a) => a.AnnouncementId)).toEqual([secondId, firstId]) + expect(posted.value.LastAnnouncementId).toBe(secondId) + expect(posted.value.Announcements[0]).toMatchObject({ + Title: 'Again', + Body: 'Second', + AccountId: 9400, + }) + + // The client's exact post: an image name plus a `meta` JSON string, which is + // stored verbatim (it's an opaque blob to us, not something we re-serialize). + const real = await post( + 'title=wooo&body=test&imageName=DefaultClubImage2k.jpg&meta=%7B%22Type%22%3A0%2C%22JsonData%22%3A%22%22%7D' + ) + expect(real.status).toBe(200) + const realId = ((await real.json()) as { value: number }).value + const withMeta = (await board()).value.Announcements.find((a) => a.AnnouncementId === realId) + expect(withMeta).toMatchObject({ + Title: 'wooo', + Body: 'test', + ImageName: 'DefaultClubImage2k.jpg', + Meta: '{"Type":0,"JsonData":""}', + }) + + // Only co-owners may post; signed out is a 401; an unknown club 404s. + expect((await post('title=Nope', '9401')).status).toBe(403) + const anon = await exports.default.fetch(`${ORIGIN}/announcements/club/${clubId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'title=Nope', + }) + expect(anon.status).toBe(401) + const missing = await exports.default.fetch(`${ORIGIN}/announcements/club/99999`, { + method: 'POST', + headers: { ...(await bearer('9400')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'title=Ghost', + }) + expect(missing.status).toBe(404) + }) + + test('PUT /club/:id/minlevel sets the join level, co-owner only', async () => { + type Details = { error: string; success: boolean; value: { Club: { MinLevel: number } } } + const create = await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { ...(await bearer('9500')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=Gated', + }) + const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId + const put = async (body: string, sub = '9500'): Promise => + exports.default.fetch(`${ORIGIN}/club/${clubId}/minlevel`, { + method: 'PUT', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + + const res = await put('minLevel=5') + expect(res.status).toBe(200) + const body = (await res.json()) as Details + expect(body).toMatchObject({ error: '', success: true }) + expect(body.value.Club.MinLevel).toBe(5) + + // It sticks on the club. + const details = await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`) + expect(((await details.json()) as { Club: { MinLevel: number } }).Club.MinLevel).toBe(5) + + // Zero is valid (no level requirement); garbage and negatives are not. + expect((await put('minLevel=0')).status).toBe(200) + expect((await put('minLevel=abc')).status).toBe(400) + expect((await put('minLevel=-1')).status).toBe(400) + + // Non-co-owners can't; signed out is a 401; an unknown club 404s. + expect((await put('minLevel=5', '9501')).status).toBe(403) + const anon = await exports.default.fetch(`${ORIGIN}/club/${clubId}/minlevel`, { + method: 'PUT', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'minLevel=5', + }) + expect(anon.status).toBe(401) + const missing = await exports.default.fetch(`${ORIGIN}/club/99999/minlevel`, { + method: 'PUT', + headers: { ...(await bearer('9500')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'minLevel=5', + }) + expect(missing.status).toBe(404) + }) + test('unknown routes 404', async () => { const res = await exports.default.fetch(`${ORIGIN}/nope`) expect(res.status).toBe(404) @@ -128,14 +784,20 @@ describe('clubs endpoints', () => { }) // Create requires auth. - expect((await post('/club', null, { Name: 'Nope' })).status).toBe(401) + expect((await post('/club/create', null, { name: 'Nope' })).status).toBe(401) // Create requires a name. - expect((await post('/club', '800', { Name: ' ' })).status).toBe(400) + expect((await post('/club/create', '800', { name: ' ' })).status).toBe(400) // 800 creates a club → defaults applied, creator auto-joined (MemberCount 1). - const created = (await ( - await post('/club', '800', { Name: 'Speedrunners', Category: 'Competitive' }) - ).json()) as Club + const createRes = (await ( + await post('/club/create', '800', { name: 'Speedrunners', category: 'Competitive' }) + ).json()) as { + error: string + success: boolean + value: { Club: Club; MyMembershipType: number } + } + expect(createRes).toMatchObject({ error: '', success: true }) + const created = createRes.value.Club expect(created).toMatchObject({ Name: 'Speedrunners', Category: 'Competitive', @@ -145,6 +807,8 @@ describe('clubs endpoints', () => { CreatorAccountId: 800, MemberCount: 1, }) + // The creator's own membership comes back on the details. + expect(createRes.value.MyMembershipType).toBe(100) const clubId = created.ClubId // Public get by id returns it; unknown id 404s. @@ -174,7 +838,9 @@ describe('clubs endpoints', () => { expect(((await (await post(`/club/${clubId}/join`, '801')).json()) as Club).MemberCount).toBe(2) // 801 leaves → back to 1, and it drops out of their member list. - expect(((await (await post(`/club/${clubId}/leave`, '801')).json()) as Club).MemberCount).toBe(1) + expect(((await (await post(`/club/${clubId}/leave`, '801')).json()) as Club).MemberCount).toBe( + 1 + ) const afterLeave = (await ( await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('801') }) ).json()) as Club[] @@ -194,9 +860,11 @@ describe('clubs endpoints', () => { type Club = { ClubId: number; Joinability: number; MemberCount: number } // 810 creates an ask-to-join club (Joinability 2) → only the creator counts. - const club = (await ( - await post('/club', '810', { Name: 'Inner Circle', Joinability: '2' }) - ).json()) as Club + const club = ( + (await ( + await post('/club/create', '810', { name: 'Inner Circle', joinability: '2' }) + ).json()) as { value: { Club: Club } } + ).value.Club expect(club).toMatchObject({ Joinability: 2, MemberCount: 1 }) // 811 asks to join → pending, so MemberCount is unchanged and it isn't a membership.