getting clubs semiworking

This commit is contained in:
Devin Zuczek
2026-07-12 22:42:56 -04:00
parent ae85bedea1
commit 0902e084e6
5 changed files with 1567 additions and 52 deletions
@@ -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);
+1
View File
@@ -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": {
+421 -9
View File
@@ -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<Omit<ClubPermission, 'ClubId' | 'Type'>> = {}
): 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<ClubDetails> {
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<ClubAnnouncement[]> {
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<number> {
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<ClubSearchResult> {
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<ClubRow>()
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<Club | null> {
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<void> {
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<ClubMember[]> {
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<Club | null> {
const row = await db
.prepare('SELECT data FROM club WHERE club_id = ?1')
.bind(clubId)
.first<ClubRow>()
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<string>()
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<string[]> {
const row = await db
.prepare('SELECT data FROM club WHERE club_id = ?1')
.bind(clubId)
.first<ClubRow>()
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<Club | null> {
return parseOne(
@@ -252,18 +651,30 @@ export async function getClub(db: D1Database, clubId: number): Promise<Club | nu
)
}
/** All clubs created by an account (GetMyCreatedClubs). */
/**
* Subscription clubs (`ClubType` 1) are a creator's paid-subscriber club, not a
* club you browse or list among your own — they're excluded from the "my clubs"
* lists (the client reaches them through the `/subscription/*` endpoints instead).
*/
const SUBSCRIPTION_CLUB_TYPE = 1
/** All clubs created by an account (GetMyCreatedClubs), oldest first. */
export async function getClubsByCreator(db: D1Database, accountId: number): Promise<Club[]> {
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<ClubRow>()
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<ClubRow>()
return parseAll(results)
}
+446 -29
View File
@@ -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<App>): Promise<number | null> {
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<App>, 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<App>()
.use(
'*',
@@ -38,12 +124,44 @@ const app = new Hono<App>()
.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<string, unknown>
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<App>()
.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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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.
+682 -14
View File
@@ -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 callers clubs', async () => {
expect((await exports.default.fetch(`${ORIGIN}/club/mine/member`)).status).toBe(401)
test('GET /club/mine/member lists the callers 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 clients 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<string, string>): Promise<Response> =>
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<Response> =>
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<Response> =>
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<Body> => {
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<string, string>, sub: string): Promise<number> => {
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<Result> => {
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<Board> => {
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<Response> =>
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<Response> =>
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.