mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
getting clubs semiworking
This commit is contained in:
@@ -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);
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"deploy": "run-wrangler-deploy",
|
"deploy": "run-wrangler-deploy",
|
||||||
"dev": "run-wrangler-dev",
|
"dev": "run-wrangler-dev",
|
||||||
"fix:workers-types": "run-wrangler-types",
|
"fix:workers-types": "run-wrangler-types",
|
||||||
|
"migrate": "run-wrangler-migrate",
|
||||||
"test": "run-vitest"
|
"test": "run-vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+421
-9
@@ -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 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)`,
|
`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
|
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 {
|
interface StoredClub extends Club {
|
||||||
CreatedAt: string
|
CreatedAt: string
|
||||||
|
CustomTags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClubRow {
|
interface ClubRow {
|
||||||
@@ -134,7 +154,8 @@ function toDto(s: StoredClub): Club {
|
|||||||
|
|
||||||
const parseOne = (row: ClubRow | null): Club | null =>
|
const parseOne = (row: ClubRow | null): Club | null =>
|
||||||
row ? toDto(JSON.parse(row.data) as StoredClub) : 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
|
* 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 }
|
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. */
|
/** Look up a single club by its ClubId. */
|
||||||
export async function getClub(db: D1Database, clubId: number): Promise<Club | null> {
|
export async function getClub(db: D1Database, clubId: number): Promise<Club | null> {
|
||||||
return parseOne(
|
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[]> {
|
export async function getClubsByCreator(db: D1Database, accountId: number): Promise<Club[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare('SELECT data FROM club WHERE creator_account_id = ?1')
|
.prepare(
|
||||||
.bind(accountId)
|
`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>()
|
.all<ClubRow>()
|
||||||
return parseAll(results)
|
return parseAll(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All clubs an account is an actual member of (GetMyMembershipClubs), most recently
|
* All clubs an account is an actual member of (GetMyMembershipClubs), oldest club
|
||||||
* joined first. Only memberships at/above `Member` count — pending requests, denied
|
* first. Only memberships at/above `Member` count — pending requests, denied
|
||||||
* requests, and bans are excluded. Joins `club_member` to `club`, so a membership
|
* requests, and bans are excluded. Joins `club_member` to `club`, so a membership
|
||||||
* whose club is gone is simply absent.
|
* whose club is gone is simply absent.
|
||||||
*/
|
*/
|
||||||
@@ -274,9 +685,10 @@ export async function getClubsByMember(db: D1Database, accountId: number): Promi
|
|||||||
FROM club_member m
|
FROM club_member m
|
||||||
JOIN club c ON c.club_id = m.club_id
|
JOIN club c ON c.club_id = m.club_id
|
||||||
WHERE m.account_id = ?1 AND m.membership_type >= ?2
|
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>()
|
.all<ClubRow>()
|
||||||
return parseAll(results)
|
return parseAll(results)
|
||||||
}
|
}
|
||||||
|
|||||||
+446
-29
@@ -5,12 +5,24 @@ import { withNotFound, withOnError } from '@repo/hono-helpers'
|
|||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ClubJoinability,
|
||||||
|
ClubMembershipType,
|
||||||
|
ClubVisibility,
|
||||||
createClub,
|
createClub,
|
||||||
|
createClubAnnouncement,
|
||||||
getClub,
|
getClub,
|
||||||
|
getClubAnnouncements,
|
||||||
|
getClubDetails,
|
||||||
|
getClubMembers,
|
||||||
getClubsByCreator,
|
getClubsByCreator,
|
||||||
getClubsByMember,
|
getClubsByMember,
|
||||||
|
getHomeClub,
|
||||||
|
getMembership,
|
||||||
joinClub,
|
joinClub,
|
||||||
leaveClub,
|
leaveClub,
|
||||||
|
searchClubs,
|
||||||
|
setHomeClub,
|
||||||
|
updateClub,
|
||||||
} from './clubs-db'
|
} from './clubs-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
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())
|
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>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -38,12 +124,44 @@ const app = new Hono<App>()
|
|||||||
.onError(withOnError())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
// Auth-gated → 401 without a valid token. A bare 404 here makes the client
|
// The player's home club — the one whose clubhouse they spawn into, stored on their
|
||||||
// treat it as an error, so we return an empty object stub.
|
// 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) => {
|
.get('/club/home/me', async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return c.body(null, 401)
|
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
|
// 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))
|
.get('/subscription/subscriberCount/:accountId{[0-9]+}', (c) => c.json(0))
|
||||||
|
|
||||||
// The player's clubs that have unread announcements (MyClubsWithUnread-
|
// 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([]))
|
.get('/announcements/v2/mine/unread', (c) => c.json([]))
|
||||||
|
|
||||||
// The clubs the player is a member of (GetMyMembershipClubs). Auth-gated; reads
|
// A club's announcements — its noticeboard, newest first. Public. Answers the
|
||||||
// the caller's memberships from `club_member`.
|
// envelope, with `LastAnnouncementId` the newest one (null when there are none)
|
||||||
.get('/club/mine/member', async (c) => {
|
// 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)
|
const id = await authedId(c)
|
||||||
if (id === null) return c.body(null, 401)
|
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))
|
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) => {
|
.get('/club/mine/created', async (c) => {
|
||||||
const id = await authedId(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))
|
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.
|
// The set of club category tags a club can be filed under — a fixed list.
|
||||||
.get('/club/categoryTags', (c) =>
|
.get('/club/categoryTags', (c) =>
|
||||||
c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
|
c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create a club owned by the caller. Auth-gated (401). Body carries the club
|
// Create a club. The client posts a form to `/club/create` with lowercase fields
|
||||||
// fields (Name required; the rest fall back to the model defaults). The creator
|
// (`name`, `description`, `category`). Auth-gated. Answers the `{ error, success,
|
||||||
// is auto-joined as the owner. Returns the new Club.
|
// value }` envelope carrying the new club's details — not a bare club.
|
||||||
.post('/club', async (c) => {
|
.post('/club/create', async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return c.body(null, 401)
|
if (id === null) return c.body(null, 401)
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
// The client sends lowercase field names; accept either casing.
|
||||||
const int = (v: unknown): number | undefined => {
|
const field = (name: string): string | undefined => {
|
||||||
const n = typeof v === 'string' ? Number.parseInt(v, 10) : Number.NaN
|
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
|
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() ?? ''
|
const name = field('name')?.trim() ?? ''
|
||||||
if (name === '') return c.json({ error: 'You must enter a name for your club.' }, 400)
|
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, {
|
const club = await createClub(c.env.DB, id, {
|
||||||
name,
|
name,
|
||||||
description: str(body.Description),
|
description,
|
||||||
category: str(body.Category),
|
// An unset category files the club under Social, as the reference does.
|
||||||
visibility: int(body.Visibility),
|
category: field('category')?.trim() || 'Social',
|
||||||
joinability: int(body.Joinability),
|
visibility: parseVisibility(field('visibility')),
|
||||||
allowJuniors: bool(body.AllowJuniors),
|
joinability: parseJoinability(field('joinability')),
|
||||||
mainImageName: str(body.MainImageName),
|
allowJuniors: parseFormBool(field('allowJuniors')),
|
||||||
clubType: int(body.ClubType),
|
mainImageName: field('mainImageName'),
|
||||||
minLevel: int(body.MinLevel),
|
// 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.
|
// A single club by id. 404 when the club isn't in the DB. Public.
|
||||||
|
|||||||
@@ -19,6 +19,21 @@ beforeAll(async () => {
|
|||||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||||
// Build the club / club_member tables (mirrors the migration).
|
// Build the club / club_member tables (mirrors the migration).
|
||||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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.
|
// 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)
|
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() })
|
const res = await exports.default.fetch(`${ORIGIN}/club/home/me`, { headers: await bearer() })
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(404)
|
||||||
expect(await res.json()).toEqual({})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /subscription/mine/member returns an empty array without a token', async () => {
|
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([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /club/mine/member is auth-gated and lists the caller’s clubs', async () => {
|
test('GET /club/mine/member lists the caller’s clubs; signed out is an empty list', async () => {
|
||||||
expect((await exports.default.fetch(`${ORIGIN}/club/mine/member`)).status).toBe(401)
|
// 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`, {
|
const res = await exports.default.fetch(`${ORIGIN}/club/mine/member`, {
|
||||||
headers: await bearer('4242'),
|
headers: await bearer('4242'),
|
||||||
})
|
})
|
||||||
@@ -99,6 +118,643 @@ describe('clubs endpoints', () => {
|
|||||||
expect(await res.json()).toEqual([])
|
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<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 () => {
|
test('unknown routes 404', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
@@ -128,14 +784,20 @@ describe('clubs endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Create requires auth.
|
// 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.
|
// 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).
|
// 800 creates a club → defaults applied, creator auto-joined (MemberCount 1).
|
||||||
const created = (await (
|
const createRes = (await (
|
||||||
await post('/club', '800', { Name: 'Speedrunners', Category: 'Competitive' })
|
await post('/club/create', '800', { name: 'Speedrunners', category: 'Competitive' })
|
||||||
).json()) as Club
|
).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({
|
expect(created).toMatchObject({
|
||||||
Name: 'Speedrunners',
|
Name: 'Speedrunners',
|
||||||
Category: 'Competitive',
|
Category: 'Competitive',
|
||||||
@@ -145,6 +807,8 @@ describe('clubs endpoints', () => {
|
|||||||
CreatorAccountId: 800,
|
CreatorAccountId: 800,
|
||||||
MemberCount: 1,
|
MemberCount: 1,
|
||||||
})
|
})
|
||||||
|
// The creator's own membership comes back on the details.
|
||||||
|
expect(createRes.value.MyMembershipType).toBe(100)
|
||||||
const clubId = created.ClubId
|
const clubId = created.ClubId
|
||||||
|
|
||||||
// Public get by id returns it; unknown id 404s.
|
// 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)
|
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.
|
// 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 (
|
const afterLeave = (await (
|
||||||
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('801') })
|
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('801') })
|
||||||
).json()) as Club[]
|
).json()) as Club[]
|
||||||
@@ -194,9 +860,11 @@ describe('clubs endpoints', () => {
|
|||||||
type Club = { ClubId: number; Joinability: number; MemberCount: number }
|
type Club = { ClubId: number; Joinability: number; MemberCount: number }
|
||||||
|
|
||||||
// 810 creates an ask-to-join club (Joinability 2) → only the creator counts.
|
// 810 creates an ask-to-join club (Joinability 2) → only the creator counts.
|
||||||
const club = (await (
|
const club = (
|
||||||
await post('/club', '810', { Name: 'Inner Circle', Joinability: '2' })
|
(await (
|
||||||
).json()) as Club
|
await post('/club/create', '810', { name: 'Inner Circle', joinability: '2' })
|
||||||
|
).json()) as { value: { Club: Club } }
|
||||||
|
).value.Club
|
||||||
expect(club).toMatchObject({ Joinability: 2, MemberCount: 1 })
|
expect(club).toMatchObject({ Joinability: 2, MemberCount: 1 })
|
||||||
|
|
||||||
// 811 asks to join → pending, so MemberCount is unchanged and it isn't a membership.
|
// 811 asks to join → pending, so MemberCount is unchanged and it isn't a membership.
|
||||||
|
|||||||
Reference in New Issue
Block a user