mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
updating api docs
This commit is contained in:
+1136
-436
@@ -1,7 +1,8 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
@@ -30,10 +31,58 @@ import {
|
||||
setHomeClub,
|
||||
updateClub,
|
||||
} from './clubs-db'
|
||||
import {
|
||||
AnnouncementIdEnvelope,
|
||||
AnnouncementRequest,
|
||||
AUTHED,
|
||||
CategoryTags,
|
||||
ChatDisabledResponse,
|
||||
ClubAnnouncementsEnvelope,
|
||||
ClubDetailsDto,
|
||||
ClubDetailsEnvelope,
|
||||
ClubDto,
|
||||
ClubEnvelope,
|
||||
ClubhouseRequest,
|
||||
ClubMembersEnvelope,
|
||||
ClubSearchResponse,
|
||||
CreateClubRequest,
|
||||
EmptyObject,
|
||||
ErrorEnvelope,
|
||||
form,
|
||||
HomeClubRequest,
|
||||
ImageNameRequest,
|
||||
json,
|
||||
JsonArray,
|
||||
MinLevelRequest,
|
||||
ModifyClubRequest,
|
||||
NullEnvelope,
|
||||
SubscriberCountResponse,
|
||||
SubscriptionDetailsResponse,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from './openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
* Clubs Worker. Hosts the club endpoints the game client calls on the `clubs` host:
|
||||
* club creation and editing, membership (join / ask-to-join / leave / ban tiers),
|
||||
* search, announcements, the club gallery, a club's clubhouse room, and each player's
|
||||
* home club. Everything is D1-backed (the shared `recflare` database); the
|
||||
* `/subscription/*` routes are stubs, since there are no subscription clubs yet.
|
||||
*
|
||||
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
|
||||
*/
|
||||
|
||||
/** The `clubId` path parameter, shared by every per-club route. */
|
||||
const CLUB_ID_PARAM = {
|
||||
name: 'clubId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The club’s id (digits only — a non-numeric id doesn’t match the route)',
|
||||
schema: { type: 'string' },
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Resolve the account id from a Bearer token. Returns `null` when the header is
|
||||
* missing, the token is invalid, or the `sub` claim isn't an integer.
|
||||
@@ -54,7 +103,7 @@ const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
|
||||
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([...` .,'!?-_&()#@:+`])
|
||||
const ALLOWED_NAME_PUNCTUATION = new Set(` .,'!?-_&()#@:+`)
|
||||
|
||||
/**
|
||||
* Club names are letters (any Latin script), digits, and basic punctuation — the
|
||||
@@ -142,224 +191,496 @@ const app = new Hono<App>()
|
||||
// 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)
|
||||
const club = await getHomeClub(c.env.DB, id)
|
||||
return club === null ? c.notFound() : c.json(club)
|
||||
})
|
||||
.get(
|
||||
'/club/home/me',
|
||||
describeRoute({
|
||||
tags: ['Home club'],
|
||||
summary: 'The player’s home club',
|
||||
description: [
|
||||
'The club whose clubhouse the player spawns into (a field on their account row).',
|
||||
'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.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(ClubDto, 'The player’s home club'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: { description: 'No home club, or it has no clubhouse room' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
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)
|
||||
.put(
|
||||
'/club/home/me',
|
||||
describeRoute({
|
||||
tags: ['Home club'],
|
||||
summary: 'Set the player’s home club',
|
||||
description: [
|
||||
'Points the player’s home club at the posted `clubId`. They must already be a member',
|
||||
'of it — you can’t make a club you don’t belong to your home. Answers the envelope',
|
||||
'carrying the bare club.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(HomeClubRequest, 'The club to make home'),
|
||||
responses: {
|
||||
200: json(ClubEnvelope, 'The envelope carrying the new home club'),
|
||||
400: json(ErrorEnvelope, 'Missing, non-numeric or zero clubId'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'The caller isn’t a member of that club'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
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
|
||||
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.')
|
||||
|
||||
await setHomeClub(c.env.DB, id, clubId)
|
||||
return c.json({ error: '', success: true, value: club })
|
||||
})
|
||||
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 })
|
||||
}
|
||||
)
|
||||
|
||||
// Clear the player's home club — they spawn into the default hub again instead of a
|
||||
// clubhouse. No body, idempotent (clearing when there's none set is a no-op, not a
|
||||
// 404), and it doesn't touch their membership of the club. The envelope's value is
|
||||
// null because there's no home club left to describe; GET goes back to 404ing.
|
||||
.delete('/club/home/me', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
await clearHomeClub(c.env.DB, id)
|
||||
return c.json({ error: '', success: true, value: null })
|
||||
})
|
||||
.delete(
|
||||
'/club/home/me',
|
||||
describeRoute({
|
||||
tags: ['Home club'],
|
||||
summary: 'Clear the player’s home club',
|
||||
description: [
|
||||
'The player spawns into the default hub again instead of a clubhouse. No body,',
|
||||
'idempotent (clearing when none is set is a no-op, not a 404), and it doesn’t touch',
|
||||
'their membership of the club. The envelope’s `value` is null because there’s no home',
|
||||
'club left to describe; GET goes back to 404ing.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(NullEnvelope, 'Cleared (value null)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
await clearHomeClub(c.env.DB, id)
|
||||
return c.json({ error: '', success: true, value: null })
|
||||
}
|
||||
)
|
||||
|
||||
// A real Rec Room client endpoint with no backing implementation yet. The
|
||||
// client calls it on the clubs host at /subscription/mine/member (no /club
|
||||
// prefix) and sends no auth header, so it isn't gated. Returns an empty
|
||||
// array = no club subscription memberships (the client chokes on null).
|
||||
.get('/subscription/mine/member', (c) => c.json([]))
|
||||
.get(
|
||||
'/subscription/mine/member',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'The caller’s club-subscription memberships',
|
||||
description: [
|
||||
'A real client endpoint with no backing implementation yet. The client calls it on',
|
||||
'the clubs host at `/subscription/mine/member` (no `/club` prefix) and sends no auth',
|
||||
'header, so it isn’t gated. Always `[]` — no subscription memberships (the client',
|
||||
'chokes on null).',
|
||||
].join(' '),
|
||||
responses: { 200: json(JsonArray, 'Always empty for now') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Subscription details for an account (numeric id) — simulated: no club, no subs.
|
||||
.get('/subscription/details/:accountId{[0-9]+}', (c) =>
|
||||
c.json({
|
||||
accountId: Number.parseInt(c.req.param('accountId'), 10),
|
||||
clubId: 0,
|
||||
subscriberCount: 0,
|
||||
})
|
||||
.get(
|
||||
'/subscription/details/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'Subscription details for an account',
|
||||
description: 'Simulated — no subscription club, no subscribers.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Account id (digits only)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(SubscriptionDetailsResponse, 'Zeroed subscription details') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
accountId: Number.parseInt(c.req.param('accountId'), 10),
|
||||
clubId: 0,
|
||||
subscriberCount: 0,
|
||||
})
|
||||
)
|
||||
|
||||
// Details for a named subscription (e.g. `rrplus`). The client deserializes this
|
||||
// into an object, so it must return `{}` (not `[]`).
|
||||
.get('/subscription/details/:subscription', (c) => c.json({}))
|
||||
.get(
|
||||
'/subscription/details/:subscription',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'Details for a named subscription',
|
||||
description: [
|
||||
'A named subscription (e.g. `rrplus`). The client deserializes this into an object,',
|
||||
'so it must return `{}` — not `[]`.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'subscription',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The subscription name, e.g. `rrplus`',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(EmptyObject, 'Always an empty object') },
|
||||
}),
|
||||
(c) => c.json({})
|
||||
)
|
||||
|
||||
// Subscriber count for an account. No club subscriptions yet → 0.
|
||||
.get('/subscription/subscriberCount/:accountId{[0-9]+}', (c) => c.json(0))
|
||||
.get(
|
||||
'/subscription/subscriberCount/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'Subscriber count for an account',
|
||||
description: 'No club subscriptions yet, so this is always 0. A bare JSON integer.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Account id (digits only)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(SubscriberCountResponse, 'Always 0') },
|
||||
}),
|
||||
(c) => c.json(0)
|
||||
)
|
||||
|
||||
// The player's clubs that have unread announcements (MyClubsWithUnread-
|
||||
// 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',
|
||||
describeRoute({
|
||||
tags: ['Announcements'],
|
||||
summary: 'The player’s clubs with unread announcements',
|
||||
description: [
|
||||
'MyClubsWithUnreadAnnouncements. Nothing tracks what a player has read yet, so',
|
||||
'nothing is unread → always `[]`.',
|
||||
].join(' '),
|
||||
responses: { 200: json(JsonArray, 'Always empty for now') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
.get(
|
||||
'/announcements/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Announcements'],
|
||||
summary: 'A club’s announcements',
|
||||
description: [
|
||||
'The club’s 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. An unknown club simply',
|
||||
'has no announcements.',
|
||||
].join(' '),
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: { 200: json(ClubAnnouncementsEnvelope, 'The club’s noticeboard') },
|
||||
}),
|
||||
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)
|
||||
.post(
|
||||
'/announcements/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Announcements'],
|
||||
summary: 'Post an announcement to a club',
|
||||
description: 'Co-owner or above only. The envelope’s `value` is the new announcement’s id.',
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
requestBody: form(AnnouncementRequest, 'The announcement fields'),
|
||||
responses: {
|
||||
200: json(AnnouncementIdEnvelope, 'The new announcement’s id'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
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 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 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 })
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
.get(
|
||||
'/club/mine/member',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'The clubs the player is a member of',
|
||||
description: [
|
||||
'GetMyMembershipClubs — the caller’s memberships from `club_member`, oldest club',
|
||||
'first (pending/denied/banned rows excluded). A caller with no valid token has no',
|
||||
'clubs, so this answers `[]` rather than 401ing: the client shows the “my clubs”',
|
||||
'shelf either way, and an error there breaks the screen.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: { 200: json(ClubDto.array(), 'The caller’s clubs (empty when signed out)') },
|
||||
}),
|
||||
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). Empty list when signed out,
|
||||
// like mine/member.
|
||||
.get('/club/mine/created', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.json([])
|
||||
return c.json(await getClubsByCreator(c.env.DB, id))
|
||||
})
|
||||
.get(
|
||||
'/club/mine/created',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'The clubs the player created',
|
||||
description: 'GetMyCreatedClubs, oldest first. Empty list when signed out, like mine/member.',
|
||||
security: AUTHED,
|
||||
responses: { 200: json(ClubDto.array(), 'The clubs the caller created') },
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
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
|
||||
.get(
|
||||
'/club/search',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Club search / browse',
|
||||
description: [
|
||||
'Public, non-subscription clubs. Public (no auth). `TotalClubs` is the full match',
|
||||
'count, not the page size.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'category',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Filter to one category (exact, case-insensitive)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'query',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Substring of the club name or description',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'sort',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: '1 = newest first, 2 = by name, anything else = most members first',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Page size; out of range (or absent) falls back to 30',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(ClubSearchResponse, 'The matching page of clubs') },
|
||||
}),
|
||||
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'])
|
||||
.get(
|
||||
'/club/categoryTags',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Club category tags',
|
||||
description: 'The fixed set of categories a club can be filed under.',
|
||||
responses: { 200: json(CategoryTags, 'The category list') },
|
||||
}),
|
||||
(c) => c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
|
||||
)
|
||||
|
||||
// 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)
|
||||
.post(
|
||||
'/club/create',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Create a club',
|
||||
description: [
|
||||
'The client posts a form with lowercase fields (`name`, `description`, `category`);',
|
||||
'either casing is accepted. Enums arrive by name (`visibility=Public`,',
|
||||
'`joinability=Open`). `ClubType` is never taken from the client — a player-created',
|
||||
'club is always a regular one, since letting the client pick would let it mint a',
|
||||
'subscription club (type 1), which is excluded from every listing. The caller becomes',
|
||||
'the club’s Creator. Answers the `{ error, success, value }` envelope carrying the new',
|
||||
'club’s full details — not a bare club.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateClubRequest, 'The new club’s fields'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The new club’s details'),
|
||||
400: json(
|
||||
ErrorEnvelope,
|
||||
'Missing/invalid/too-long name, or the per-account club limit is reached'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
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>
|
||||
// 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 body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
// 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 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.`)
|
||||
}
|
||||
// The per-account cap, checked after the cheap validations so a rejected name
|
||||
// costs no extra D1 read.
|
||||
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
|
||||
if (maxClubs > 0 && (await countClubsByCreator(c.env.DB, id)) >= maxClubs) {
|
||||
logger.info('club create rejected: per-account club limit', { accountId: id })
|
||||
return clubError(c, `You can only have ${maxClubs} clubs.`)
|
||||
}
|
||||
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.`)
|
||||
}
|
||||
// The per-account cap, checked after the cheap validations so a rejected name
|
||||
// costs no extra D1 read.
|
||||
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
|
||||
if (maxClubs > 0 && (await countClubsByCreator(c.env.DB, id)) >= maxClubs) {
|
||||
logger.info('club create rejected: per-account club limit', { accountId: id })
|
||||
return clubError(c, `You can only have ${maxClubs} clubs.`)
|
||||
}
|
||||
|
||||
const club = await createClub(c.env.DB, id, {
|
||||
name,
|
||||
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),
|
||||
})
|
||||
})
|
||||
const club = await createClub(c.env.DB, id, {
|
||||
name,
|
||||
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`) —
|
||||
@@ -369,141 +690,256 @@ const app = new Hono<App>()
|
||||
//
|
||||
// `/modify` is the same endpoint under the shorter name the client also PUTs to
|
||||
// (`name=…&description=…&category=…`); one handler, so the two can't drift.
|
||||
.on('PUT', ['/club/:clubId{[0-9]+}/modifydetails', '/club/:clubId{[0-9]+}/modify'], async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.on(
|
||||
'PUT',
|
||||
['/club/:clubId{[0-9]+}/modifydetails', '/club/:clubId{[0-9]+}/modify'],
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Edit a club’s details',
|
||||
description: [
|
||||
'The client PUTs a form of just the fields it’s changing — enums by name',
|
||||
'(`visibility=Public`, `joinability=Open`, `allowJuniors=True`) — and absent fields',
|
||||
'keep their stored value (an empty `name`/`description` means “unchanged”, not',
|
||||
'“clear it”). `customTags` may repeat; when present it replaces the club’s tag set',
|
||||
'wholesale. Co-owner or above only. `/modify` is the same endpoint under the shorter',
|
||||
'name the client also PUTs to — one handler, so the two can’t drift. Answers the same',
|
||||
'details envelope create does, since the client re-renders the club screen from it.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
requestBody: form(ModifyClubRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The updated club’s details'),
|
||||
400: json(ErrorEnvelope, 'Invalid or too-long name'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
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 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.')
|
||||
// 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)
|
||||
}
|
||||
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
|
||||
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
|
||||
|
||||
// `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),
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
.get(
|
||||
'/club/:clubId{[0-9]+}/details',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'A club’s full details',
|
||||
description: [
|
||||
'The club plus its custom tags, the per-tier permissions, its gallery, and the',
|
||||
'caller’s own membership. Public — a signed-out viewer just gets `MyMembershipType` 0.',
|
||||
'Unlike create/modifydetails this one is NOT enveloped: the details object is written',
|
||||
'straight out, as the reference does.',
|
||||
].join(' '),
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDetailsDto, 'The club’s details (not enveloped)'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
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))
|
||||
.get(
|
||||
'/club/:clubId{[0-9]+}/hasDisabledClubChat',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Whether the club has turned club chat off',
|
||||
description: [
|
||||
'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.',
|
||||
].join(' '),
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: { 200: json(ChatDisabledResponse, 'Always false') },
|
||||
}),
|
||||
(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)
|
||||
.get(
|
||||
'/club/:clubId{[0-9]+}/members',
|
||||
describeRoute({
|
||||
tags: ['Membership'],
|
||||
summary: 'A club’s members',
|
||||
description:
|
||||
'Public, and an unknown club is an empty list rather than a 404. Answers the envelope.',
|
||||
parameters: [
|
||||
CLUB_ID_PARAM,
|
||||
{
|
||||
name: 'membershipType',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: [
|
||||
'Filter to exactly that tier — an exact match, not a threshold, so `30` lists',
|
||||
'co-owners only, not the creator above them',
|
||||
].join(' '),
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'sortBy',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: '1 = account id, 2 = oldest first, anything else = highest tier first',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(ClubMembersEnvelope, 'The club’s membership rows') },
|
||||
}),
|
||||
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 })
|
||||
})
|
||||
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)
|
||||
.put(
|
||||
'/club/:clubId{[0-9]+}/minlevel',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Set the club’s minimum join level',
|
||||
description: [
|
||||
'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 back.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
requestBody: form(MinLevelRequest, 'The new minimum level'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The updated club’s details'),
|
||||
400: json(ErrorEnvelope, 'Missing, non-numeric or negative minLevel'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
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 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 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),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
@@ -514,81 +950,147 @@ const app = new Hono<App>()
|
||||
// DELETE is the same thing with the clearing spelled out — it ignores any body and
|
||||
// always unsets the room, so "remove the clubhouse" doesn't depend on the client
|
||||
// remembering to send an empty PUT.
|
||||
.on(['PUT', 'DELETE'], '/club/:clubId{[0-9]+}/clubhouse', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.on(
|
||||
['PUT', 'DELETE'],
|
||||
'/club/:clubId{[0-9]+}/clubhouse',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Set or clear the club’s clubhouse room',
|
||||
description: [
|
||||
'The clubhouse is the room players spawn into when the club is their home. PUT with',
|
||||
'`roomId` sets it; omitting `roomId` clears it. DELETE is the same thing with the',
|
||||
'clearing spelled out — it ignores any body and always unsets the room, so “remove the',
|
||||
'clubhouse” doesn’t depend on the client remembering to send an empty PUT. Co-owner or',
|
||||
'above only. Answers the full details envelope: the reference returns a null value',
|
||||
'here, but the client re-renders from the response and leaves the old clubhouse on',
|
||||
'screen unless it gets the updated club back.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
requestBody: form(ClubhouseRequest, 'The clubhouse room (PUT only; DELETE ignores the body)'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The updated club’s details'),
|
||||
400: json(ErrorEnvelope, 'Non-numeric roomId'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
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 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)
|
||||
}
|
||||
|
||||
let roomId: number | null = null
|
||||
if (c.req.method !== 'DELETE') {
|
||||
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.')
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
}
|
||||
roomId = raw === '' ? null : Number.parseInt(raw, 10)
|
||||
}
|
||||
|
||||
const updated = await updateClub(c.env.DB, clubId, { clubhouseRoomId: roomId })
|
||||
if (updated === null) return c.notFound()
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
})
|
||||
let roomId: number | null = null
|
||||
if (c.req.method !== 'DELETE') {
|
||||
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.')
|
||||
}
|
||||
roomId = raw === '' ? null : Number.parseInt(raw, 10)
|
||||
}
|
||||
|
||||
const updated = await updateClub(c.env.DB, clubId, { clubhouseRoomId: roomId })
|
||||
if (updated === null) return c.notFound()
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 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)
|
||||
.get(
|
||||
'/club/:clubId{[0-9]+}/mainimage',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Read the club’s main image',
|
||||
description: [
|
||||
'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`. Public.',
|
||||
].join(' '),
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The club’s details, carrying MainImageName'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
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',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Set the club’s main image',
|
||||
description: [
|
||||
'Sets the main image from an uploaded image’s `imageName` (the name the `storage`',
|
||||
'worker handed back). Co-owner or above only. Answers the details envelope.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
requestBody: form(ImageNameRequest, 'The uploaded image’s name'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The updated club’s details'),
|
||||
400: json(ErrorEnvelope, 'Missing imageName'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
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() === 'imagename')
|
||||
const imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
|
||||
if (imageName === '') return clubError(c, 'imageName is required.')
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
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),
|
||||
})
|
||||
})
|
||||
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),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// One of the club's gallery images, by position (`/additionalimage/{index}`, 0-based
|
||||
// — the client PUTs the first image to 0, the second to 1). Takes the same
|
||||
@@ -599,67 +1101,139 @@ const app = new Hono<App>()
|
||||
// DELETE removes that position's image and shifts the rest up, so there's never a
|
||||
// blank slot in the gallery. It ignores any body, so it can't accidentally set an
|
||||
// image instead, and deleting a position that holds nothing is a no-op.
|
||||
.on(['PUT', 'DELETE'], '/club/:clubId{[0-9]+}/additionalimage/:index{[0-9]+}', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.on(
|
||||
['PUT', 'DELETE'],
|
||||
'/club/:clubId{[0-9]+}/additionalimage/:index{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Set or remove one of the club’s gallery images',
|
||||
description: [
|
||||
'One gallery image by position (0-based — the client PUTs the first image to 0, the',
|
||||
'second to 1), taking the same `imageName` the `storage` worker handed back. Co-owner',
|
||||
'or above, like the main image. The list is PACKED, never sparse: a PUT past the end',
|
||||
'appends rather than leaving a gap, and DELETE removes that position and shifts the',
|
||||
'rest up, so there’s never a blank slot. DELETE ignores any body (so it can’t',
|
||||
'accidentally set an image instead) and deleting an empty position is a no-op. The',
|
||||
'images come back on `value.AdditionalImages` as whole image records, in order — a',
|
||||
'bare array of names fails the client’s parser.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
CLUB_ID_PARAM,
|
||||
{
|
||||
name: 'index',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The 0-based gallery slot; a club has 3 slots (0–2)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
requestBody: form(ImageNameRequest, 'The uploaded image’s name (PUT only)'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The updated club’s details'),
|
||||
400: json(ErrorEnvelope, 'The index is past the club’s gallery slots'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
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 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 membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
const index = Number.parseInt(c.req.param('index'), 10)
|
||||
if (index >= MAX_ADDITIONAL_IMAGES) {
|
||||
return clubError(c, `A club has ${MAX_ADDITIONAL_IMAGES} additional image slots (0-based).`)
|
||||
}
|
||||
|
||||
let imageName = ''
|
||||
if (c.req.method !== 'DELETE') {
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename')
|
||||
imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
|
||||
}
|
||||
|
||||
const updated = await setClubAdditionalImage(c.env.DB, clubId, index, imageName)
|
||||
if (updated === null) return c.notFound()
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
}
|
||||
|
||||
const index = Number.parseInt(c.req.param('index'), 10)
|
||||
if (index >= MAX_ADDITIONAL_IMAGES) {
|
||||
return clubError(c, `A club has ${MAX_ADDITIONAL_IMAGES} additional image slots (0-based).`)
|
||||
}
|
||||
|
||||
let imageName = ''
|
||||
if (c.req.method !== 'DELETE') {
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename')
|
||||
imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
|
||||
}
|
||||
|
||||
const updated = await setClubAdditionalImage(c.env.DB, clubId, index, imageName)
|
||||
if (updated === null) return c.notFound()
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// A single club by id. 404 when the club isn't in the DB. Public.
|
||||
.get('/club/:clubId{[0-9]+}', async (c) => {
|
||||
const club = await getClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10))
|
||||
return club ? c.json(club) : c.notFound()
|
||||
})
|
||||
.get(
|
||||
'/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'A single club by id',
|
||||
description: 'The bare club (not the details view, not enveloped). Public.',
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDto, 'The club'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const club = await getClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10))
|
||||
return club ? c.json(club) : c.notFound()
|
||||
}
|
||||
)
|
||||
|
||||
// Delete a club, along with its memberships and announcements. The creator only —
|
||||
// not co-owners, who can edit a club but can't destroy one — which is also the way
|
||||
// out for a creator, since they aren't allowed to leave (see /members/leave).
|
||||
// Answers the envelope with a null value; the club is gone, so there are no details
|
||||
// left to return.
|
||||
.delete('/club/:clubId{[0-9]+}', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.delete(
|
||||
'/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Delete a club',
|
||||
description: [
|
||||
'Deletes the club along with its memberships and announcements, and clears it from the',
|
||||
'home club of anyone who’d set it. The creator only — not co-owners, who can edit a',
|
||||
'club but can’t destroy one — which is also the way out for a creator, since they',
|
||||
'aren’t allowed to leave. The envelope’s `value` is null: the club is gone, so there',
|
||||
'are no details left to return.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(NullEnvelope, 'Deleted (value null)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Not the club’s creator'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
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 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.Creator) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Creator) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
await deleteClub(c.env.DB, clubId)
|
||||
return c.json({ error: '', success: true, value: null })
|
||||
}
|
||||
|
||||
await deleteClub(c.env.DB, clubId)
|
||||
return c.json({ error: '', success: true, value: null })
|
||||
})
|
||||
)
|
||||
|
||||
// Ask to join a club. No body — the club id and the Bearer token are the whole
|
||||
// request. What it does depends on the club's Joinability: an Open club takes the
|
||||
@@ -667,27 +1241,51 @@ const app = new Hono<App>()
|
||||
// for a co-owner to approve, and an InviteOnly club refuses (you can only get in
|
||||
// through an invite). Repeats are idempotent; a banned account stays out. Answers
|
||||
// the details envelope so the client can read its new `MyMembershipType`.
|
||||
.put('/club/:clubId{[0-9]+}/members/requesttojoin', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.put(
|
||||
'/club/:clubId{[0-9]+}/members/requesttojoin',
|
||||
describeRoute({
|
||||
tags: ['Membership'],
|
||||
summary: 'Ask to join a club',
|
||||
description: [
|
||||
'No body — the club id and the Bearer token are the whole request. What it does',
|
||||
'depends on the club’s Joinability: an Open club takes the caller straight in as a',
|
||||
'Member, an AskToJoin club records a PendingRequested row for a co-owner to approve,',
|
||||
'and an InviteOnly club refuses (you can only get in through an invite). Repeats are',
|
||||
'idempotent; a banned account stays out. Answers the details envelope so the client',
|
||||
'can read its new `MyMembershipType`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The club’s details, with the caller’s new membership'),
|
||||
400: json(ErrorEnvelope, 'The club is invite only'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'The caller is banned from the club'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
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 outcome = await requestToJoinClub(c.env.DB, clubId, id)
|
||||
if (outcome === null) return c.notFound()
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const outcome = await requestToJoinClub(c.env.DB, clubId, id)
|
||||
if (outcome === null) return c.notFound()
|
||||
|
||||
if (outcome.result === 'inviteOnly') {
|
||||
return clubError(c, 'This club is invite only.')
|
||||
if (outcome.result === 'inviteOnly') {
|
||||
return clubError(c, 'This club is invite only.')
|
||||
}
|
||||
if (outcome.result === 'banned') {
|
||||
return c.json({ error: 'You are banned from this club.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, outcome.club, id),
|
||||
})
|
||||
}
|
||||
if (outcome.result === 'banned') {
|
||||
return c.json({ error: 'You are banned from this club.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, outcome.club, id),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Leave a club. No body, like requesttojoin — the club id and the Bearer token are
|
||||
// the whole request. Idempotent (leaving a club you're not in is a no-op), and it
|
||||
@@ -695,57 +1293,159 @@ const app = new Hono<App>()
|
||||
// by leaving. The creator is refused — they'd leave the club ownerless, so they
|
||||
// have to delete it instead. Answers the details envelope so the client sees
|
||||
// `MyMembershipType` drop to 0 (or stay at -1 for a banned account).
|
||||
.post('/club/:clubId{[0-9]+}/members/leave', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.post(
|
||||
'/club/:clubId{[0-9]+}/members/leave',
|
||||
describeRoute({
|
||||
tags: ['Membership'],
|
||||
summary: 'Leave a club',
|
||||
description: [
|
||||
'No body, like requesttojoin. Idempotent (leaving a club you’re not in is a no-op),',
|
||||
'and it also withdraws a pending request; a ban is preserved, since you can’t clear',
|
||||
'one by leaving. The creator is refused — they’d leave the club ownerless, so they',
|
||||
'have to delete it instead. Answers the details envelope so the client sees',
|
||||
'`MyMembershipType` drop to 0 (or stay at -1 for a banned account).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The club’s details, with the caller’s membership gone'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'The creator can’t leave — delete the club instead'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
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 outcome = await leaveClub(c.env.DB, clubId, id)
|
||||
if (outcome === null) return c.notFound()
|
||||
if (outcome.result === 'creator') {
|
||||
return c.json(
|
||||
{
|
||||
error: 'You created this club — delete it instead of leaving.',
|
||||
success: false,
|
||||
value: null,
|
||||
},
|
||||
403
|
||||
)
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const outcome = await leaveClub(c.env.DB, clubId, id)
|
||||
if (outcome === null) return c.notFound()
|
||||
if (outcome.result === 'creator') {
|
||||
return c.json(
|
||||
{
|
||||
error: 'You created this club — delete it instead of leaving.',
|
||||
success: false,
|
||||
value: null,
|
||||
},
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, outcome.club, id),
|
||||
})
|
||||
}
|
||||
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, outcome.club, id),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Join / leave a club (auth-gated, idempotent). Both return the club with its
|
||||
// refreshed MemberCount; 404 when the club doesn't exist.
|
||||
.post('/club/:clubId{[0-9]+}/join', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
|
||||
return club ? c.json(club) : c.notFound()
|
||||
})
|
||||
.post(
|
||||
'/club/:clubId{[0-9]+}/join',
|
||||
describeRoute({
|
||||
tags: ['Membership'],
|
||||
summary: 'Join a club',
|
||||
description: [
|
||||
'Auth-gated and idempotent. On an Open club the caller becomes a Member immediately;',
|
||||
'on an InviteOnly/AskToJoin club the join is recorded as PendingRequested, and a ban',
|
||||
'can’t be shed by re-joining. Returns the bare club with its refreshed MemberCount',
|
||||
'(not the details envelope — see members/requesttojoin for that).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDto, 'The club, with its refreshed MemberCount'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
|
||||
return club ? c.json(club) : c.notFound()
|
||||
}
|
||||
)
|
||||
// Leaving is refused for the creator here too (see /members/leave), so the two
|
||||
// routes can't disagree about who's still in the club.
|
||||
.post('/club/:clubId{[0-9]+}/leave', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const outcome = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
|
||||
if (outcome === null) return c.notFound()
|
||||
if (outcome.result === 'creator') {
|
||||
return c.json(
|
||||
{
|
||||
error: 'You created this club — delete it instead of leaving.',
|
||||
success: false,
|
||||
value: null,
|
||||
},
|
||||
403
|
||||
)
|
||||
.post(
|
||||
'/club/:clubId{[0-9]+}/leave',
|
||||
describeRoute({
|
||||
tags: ['Membership'],
|
||||
summary: 'Leave a club (bare-club form)',
|
||||
description: [
|
||||
'The counterpart to `/join`: returns the bare club with its refreshed MemberCount',
|
||||
'rather than the details envelope. Leaving is refused for the creator here too (see',
|
||||
'`/members/leave`), so the two routes can’t disagree about who’s still in the club.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDto, 'The club, with its refreshed MemberCount'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'The creator can’t leave — delete the club instead'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const outcome = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
|
||||
if (outcome === null) return c.notFound()
|
||||
if (outcome.result === 'creator') {
|
||||
return c.json(
|
||||
{
|
||||
error: 'You created this club — delete it instead of leaving.',
|
||||
success: false,
|
||||
value: null,
|
||||
},
|
||||
403
|
||||
)
|
||||
}
|
||||
return c.json(outcome.club)
|
||||
}
|
||||
return c.json(outcome.club)
|
||||
})
|
||||
)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
app.get(
|
||||
'/openapi.json',
|
||||
describeRoute({ hide: true }),
|
||||
withCleanSpec(
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'recflare clubs',
|
||||
version: '1.0.0',
|
||||
description: [
|
||||
'Club endpoints for recflare, a private-server reimplementation of the Rec Room',
|
||||
'backend. The client calls these on the `clubs` host: club creation and editing,',
|
||||
'membership (join / ask-to-join / leave, with the ban and pending tiers),',
|
||||
'search, announcements, the club gallery and clubhouse room, and each player’s home',
|
||||
'club. Everything is D1-backed on the shared `recflare` database; the',
|
||||
'`/subscription/*` routes are stubs, since there are no subscription clubs yet.',
|
||||
'',
|
||||
'Most writes answer the `{ error, success, value }` envelope with HTTP 200, and the',
|
||||
'ones the client re-renders a club screen from carry the club’s FULL details as',
|
||||
'`value` rather than null.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://clubs.recflare.net', description: 'Production' }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the clubs worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
|
||||
* rationale as the auth/accounts/econ/match workers: a reverse-engineered protocol,
|
||||
* lenient handlers, no runtime validation.
|
||||
*
|
||||
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
|
||||
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
|
||||
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
|
||||
* schema inline, which renders correctly in any tool.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||
return jsonSchema as OpenAPIV3_1.SchemaObject
|
||||
}
|
||||
|
||||
/** A form-urlencoded / multipart request body (the client posts both). */
|
||||
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
const s = toOpenApiSchema(schema)
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
'application/x-www-form-urlencoded': { schema: s },
|
||||
'multipart/form-data': { schema: s },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** An `application/json` request body. */
|
||||
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
|
||||
}
|
||||
|
||||
/** The empty-body 401 the auth-gated routes return. */
|
||||
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
|
||||
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
export const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
/** An opaque JSON array — the empty-list stubs (`[]`) the client still expects. */
|
||||
export const JsonArray = z.array(z.unknown())
|
||||
|
||||
/** An empty JSON object — the stub shape the client deserializes into an object. */
|
||||
export const EmptyObject = z.object({})
|
||||
|
||||
// ---- Core entities ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The client-facing club DTO (mirror of the Go `Club` JSON tags). The stored blob also
|
||||
* carries `CreatedAt`, `CustomTags` and `AdditionalImages`, none of which are on this
|
||||
* object — the tags and gallery are served on the details view instead.
|
||||
*/
|
||||
export const ClubDto = z.object({
|
||||
ClubId: z.int(),
|
||||
Name: z.string().describe('At most 16 characters; letters, digits and basic punctuation'),
|
||||
Description: z.string(),
|
||||
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
|
||||
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
|
||||
Joinability: z.int().describe('ClubJoinability: 0 = Open, 1 = InviteOnly, 2 = AskToJoin'),
|
||||
AllowJuniors: z.boolean(),
|
||||
MainImageName: z.string().describe('An image name from the storage worker; DefaultImgPurple'),
|
||||
ClubType: z.int().describe('0 = a regular club; 1 = a subscription club (never listed)'),
|
||||
ClubhouseRoomId: z.int().nullable().describe('The room a home-club member spawns into'),
|
||||
CreatorAccountId: z.int(),
|
||||
IsRRO: z.boolean(),
|
||||
MinLevel: z.int(),
|
||||
State: z.int(),
|
||||
MemberCount: z.int().describe('Derived from the club_member rows at/above Member (10)'),
|
||||
})
|
||||
|
||||
/**
|
||||
* An image record as every image on the site is served (`SavedImage`). A club's gallery
|
||||
* serves these whole — see AdditionalImages on the details view.
|
||||
*/
|
||||
export const SavedImageDto = z.object({
|
||||
Id: z.int(),
|
||||
Type: z.int().describe('SavedImageType: 1 = share camera, 3 = room, 4 = profile, …'),
|
||||
Accessibility: z.int(),
|
||||
AccessibilityLocked: z.boolean(),
|
||||
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
|
||||
Description: z.string().nullable(),
|
||||
PlayerId: z.int(),
|
||||
TaggedPlayerIds: z.array(z.int()),
|
||||
RoomId: z.int().nullable(),
|
||||
PlayerEventId: z.int().nullable(),
|
||||
CreatedAt: z.string(),
|
||||
CheerCount: z.int(),
|
||||
CommentCount: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* What a membership tier may do in a club. These are the defaults every club gets
|
||||
* (co-owners everything, moderators approve/ban, members none); nothing edits them yet,
|
||||
* so they're derived per club rather than stored.
|
||||
*/
|
||||
export const ClubPermissionDto = z.object({
|
||||
ClubId: z.int(),
|
||||
Type: z.int().describe('The ClubMembershipType tier these permissions describe'),
|
||||
ApproveMember: z.boolean(),
|
||||
BanUnban: z.boolean(),
|
||||
CreateEvent: z.boolean(),
|
||||
EditDetails: z.boolean(),
|
||||
EditPermissionSettings: z.boolean(),
|
||||
PostAnnouncement: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The club-details payload the client renders a club screen from: the club, its tags,
|
||||
* the per-tier permissions, its gallery, and the caller's own membership.
|
||||
*/
|
||||
export const ClubDetailsDto = z.object({
|
||||
AdditionalImages: z
|
||||
.array(SavedImageDto)
|
||||
.describe(
|
||||
[
|
||||
'The club’s gallery as WHOLE image records, not image names — the client',
|
||||
'deserializes each entry into an object, so a bare array of names fails its parser',
|
||||
'("expected \'{\'"). The list is packed and in order: removing an image shifts the',
|
||||
'rest up, never leaving a blank slot.',
|
||||
].join(' ')
|
||||
),
|
||||
Club: ClubDto,
|
||||
ClubId: z.int(),
|
||||
CoownerPermissions: ClubPermissionDto,
|
||||
CustomTags: z.array(z.string()).describe('Set wholesale by modifydetails’ repeated customTags'),
|
||||
MemberPermissions: ClubPermissionDto,
|
||||
ModeratorPermissions: ClubPermissionDto,
|
||||
MyMembershipType: z
|
||||
.int()
|
||||
.describe(
|
||||
[
|
||||
'The caller’s own ClubMembershipType: -1 banned, 0 none (also a signed-out viewer),',
|
||||
'1 pending request, 2 pending invite, 3 denied, 10 member, 20 moderator, 30 co-owner,',
|
||||
'100 creator',
|
||||
].join(' ')
|
||||
),
|
||||
})
|
||||
|
||||
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
|
||||
export const ClubMemberDto = z.object({
|
||||
ClubMemberId: z.int(),
|
||||
ClubId: z.int(),
|
||||
AccountId: z.int(),
|
||||
MembershipType: z.int().describe('See MyMembershipType for the tiers'),
|
||||
CreatedAt: z.string().nullable().describe('When the membership row was first written'),
|
||||
})
|
||||
|
||||
/** One entry on a club's noticeboard (mirror of the Go `ClubAnnouncement`). */
|
||||
export const ClubAnnouncementDto = z.object({
|
||||
AnnouncementId: z.int(),
|
||||
ClubId: z.int(),
|
||||
AccountId: z.int().describe('Who posted it'),
|
||||
Title: z.string(),
|
||||
Body: z.string(),
|
||||
ImageName: z.string(),
|
||||
Meta: z.string(),
|
||||
CreatedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
// ---- Envelopes -------------------------------------------------------------
|
||||
//
|
||||
// Most club writes answer the `{ error, success, value }` envelope with HTTP 200 (or
|
||||
// 400/403 carrying the same shape with `success: false`). The envelope's `value` is the
|
||||
// entity the client re-renders from, so routes that change a club return the FULL
|
||||
// details view rather than a null value — `PUT /club/:id/clubhouse` left the old
|
||||
// clubhouse on screen until it answered the details envelope.
|
||||
|
||||
/** The success envelope carrying a club's full details. */
|
||||
export const ClubDetailsEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: ClubDetailsDto,
|
||||
})
|
||||
|
||||
/** The success envelope carrying a bare club (`PUT /club/home/me`). */
|
||||
export const ClubEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: ClubDto,
|
||||
})
|
||||
|
||||
/**
|
||||
* The envelope with nothing left to describe — clearing the home club, deleting a club.
|
||||
* Only used where the entity is genuinely gone; anything the client re-renders from
|
||||
* returns the details envelope instead.
|
||||
*/
|
||||
export const NullEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: z.null(),
|
||||
})
|
||||
|
||||
/** A rejected action: the same envelope, carrying the message the client shows. */
|
||||
export const ErrorEnvelope = z.object({
|
||||
error: z.string().describe('The message shown to the player'),
|
||||
success: z.boolean().describe('Always false'),
|
||||
value: z.null(),
|
||||
})
|
||||
|
||||
/** The envelope carrying a club's members (`GET /club/:clubId/members`). */
|
||||
export const ClubMembersEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: z.array(ClubMemberDto),
|
||||
})
|
||||
|
||||
/** The envelope carrying a club's noticeboard (`GET /announcements/club/:clubId`). */
|
||||
export const ClubAnnouncementsEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: z.object({
|
||||
Announcements: z.array(ClubAnnouncementDto).describe('Newest first'),
|
||||
ClubId: z.int(),
|
||||
LastAnnouncementId: z.int().nullable().describe('The newest one; null when there are none'),
|
||||
LastReadAnnouncementId: z.int().describe('Always 0 — nothing tracks read state yet'),
|
||||
}),
|
||||
})
|
||||
|
||||
/** The envelope carrying a new announcement's id (`POST /announcements/club/:clubId`). */
|
||||
export const AnnouncementIdEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: z.int().describe('The new announcement’s id'),
|
||||
})
|
||||
|
||||
// ---- Other response shapes -------------------------------------------------
|
||||
|
||||
/** `GET /club/search` — a page of clubs plus the full match count. */
|
||||
export const ClubSearchResponse = z.object({
|
||||
Clubs: z.array(ClubDto),
|
||||
ContinuationToken: z.null().describe('Always null — the whole page is served at once'),
|
||||
TotalClubs: z.int().describe('How many clubs matched, not the page size'),
|
||||
})
|
||||
|
||||
/** `GET /subscription/details/:accountId` — simulated: no club, no subscribers. */
|
||||
export const SubscriptionDetailsResponse = z.object({
|
||||
accountId: z.int(),
|
||||
clubId: z.int().describe('Always 0 — no subscription clubs yet'),
|
||||
subscriberCount: z.int().describe('Always 0'),
|
||||
})
|
||||
|
||||
/** The set of category tags a club can be filed under — a fixed list. */
|
||||
export const CategoryTags = z.array(z.string())
|
||||
|
||||
/** `GET /subscription/subscriberCount/:accountId` — a bare JSON integer. */
|
||||
export const SubscriberCountResponse = z
|
||||
.int()
|
||||
.describe('Always 0 — there are no club subscriptions yet')
|
||||
|
||||
/**
|
||||
* `GET /club/:clubId/hasDisabledClubChat` — a bare JSON boolean, like the other
|
||||
* `is…`/`has…` gates the client polls. Nothing can turn club chat off yet, so it's
|
||||
* always false; not in the reference, so if the client chokes on this it likely wants
|
||||
* the `{ error, success, value }` envelope the other club endpoints use.
|
||||
*/
|
||||
export const ChatDisabledResponse = z.boolean()
|
||||
|
||||
// ---- Request schemas -------------------------------------------------------
|
||||
//
|
||||
// Every write takes a form body (urlencoded or multipart — the client posts both) with
|
||||
// lowercase field names; the handlers match field names case-insensitively.
|
||||
|
||||
/** `POST /club/create` form body. */
|
||||
export const CreateClubRequest = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.describe('Required; at most 16 characters, letters/digits/basic punctuation only'),
|
||||
description: z.string().optional(),
|
||||
category: z.string().optional().describe('Defaults to Social when unset'),
|
||||
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
||||
joinability: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('By name (`Open`/`InviteOnly`/`AskToJoin`) or number'),
|
||||
allowJuniors: z.string().optional().describe('`True`/`false`/`1`/`yes`'),
|
||||
mainImageName: z.string().optional(),
|
||||
minLevel: z.string().optional(),
|
||||
})
|
||||
|
||||
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
|
||||
export const ModifyClubRequest = z.object({
|
||||
name: z.string().optional().describe('Empty means unchanged, not "clear it"'),
|
||||
description: z.string().optional().describe('Empty means unchanged'),
|
||||
category: z.string().optional(),
|
||||
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
||||
joinability: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('By name (`Open`/`InviteOnly`/`AskToJoin`) or number'),
|
||||
allowJuniors: z.string().optional().describe('`True`/`false`/`1`/`yes`'),
|
||||
mainImageName: z.string().optional(),
|
||||
minLevel: z.string().optional(),
|
||||
customTags: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('May repeat; when present it replaces the club’s tag set wholesale'),
|
||||
})
|
||||
|
||||
/** `PUT /club/home/me` form body. */
|
||||
export const HomeClubRequest = z.object({
|
||||
clubId: z.string().describe('The club to make home; the caller must be a member of it'),
|
||||
})
|
||||
|
||||
/** `PUT /club/:clubId/minlevel` form body. */
|
||||
export const MinLevelRequest = z.object({
|
||||
minLevel: z.string().describe('The minimum player level to join; negative/NaN is 400'),
|
||||
})
|
||||
|
||||
/** `PUT /club/:clubId/clubhouse` form body. */
|
||||
export const ClubhouseRequest = z.object({
|
||||
roomId: z.string().optional().describe('The clubhouse room; omitting it clears the clubhouse'),
|
||||
})
|
||||
|
||||
/** `PUT /club/:clubId/mainimage` and `/additionalimage/:index` form body. */
|
||||
export const ImageNameRequest = z.object({
|
||||
imageName: z.string().describe('The image name the `storage` worker handed back'),
|
||||
})
|
||||
|
||||
/** `POST /announcements/club/:clubId` form body. */
|
||||
export const AnnouncementRequest = z.object({
|
||||
title: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
imageName: z.string().optional(),
|
||||
meta: z.string().optional(),
|
||||
})
|
||||
@@ -1306,4 +1306,73 @@ describe('clubs endpoints', () => {
|
||||
// Deleting twice 404s rather than reporting success.
|
||||
expect((await del(clubId, '860')).status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
const spec = (await res.json()) as {
|
||||
openapi: string
|
||||
paths: Record<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every schema inlines: a `.meta({ id })` on any of them would emit a $ref this
|
||||
// setup doesn't always hoist into components.schemas, leaving it dangling.
|
||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec. Hono's `:param` syntax (regex constraints and all) becomes
|
||||
// OpenAPI's `{param}`; the `.on([...])` clubhouse and additionalimage routes
|
||||
// contribute both their methods, and modifydetails/modify both their paths.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /club/home/me',
|
||||
'DELETE /club/{clubId}',
|
||||
'DELETE /club/{clubId}/additionalimage/{index}',
|
||||
'DELETE /club/{clubId}/clubhouse',
|
||||
'GET /announcements/club/{clubId}',
|
||||
'GET /announcements/v2/mine/unread',
|
||||
'GET /club/categoryTags',
|
||||
'GET /club/home/me',
|
||||
'GET /club/mine/created',
|
||||
'GET /club/mine/member',
|
||||
'GET /club/search',
|
||||
'GET /club/{clubId}',
|
||||
'GET /club/{clubId}/details',
|
||||
'GET /club/{clubId}/hasDisabledClubChat',
|
||||
'GET /club/{clubId}/mainimage',
|
||||
'GET /club/{clubId}/members',
|
||||
'GET /subscription/details/{accountId}',
|
||||
'GET /subscription/details/{subscription}',
|
||||
'GET /subscription/mine/member',
|
||||
'GET /subscription/subscriberCount/{accountId}',
|
||||
'POST /announcements/club/{clubId}',
|
||||
'POST /club/create',
|
||||
'POST /club/{clubId}/join',
|
||||
'POST /club/{clubId}/leave',
|
||||
'POST /club/{clubId}/members/leave',
|
||||
'PUT /club/home/me',
|
||||
'PUT /club/{clubId}/additionalimage/{index}',
|
||||
'PUT /club/{clubId}/clubhouse',
|
||||
'PUT /club/{clubId}/mainimage',
|
||||
'PUT /club/{clubId}/members/requesttojoin',
|
||||
'PUT /club/{clubId}/minlevel',
|
||||
'PUT /club/{clubId}/modify',
|
||||
'PUT /club/{clubId}/modifydetails',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user