mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
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'),
|
||
})
|
||
|
||
/** `PUT /club/:clubId/members/invite` form body. */
|
||
export const InviteMemberRequest = z.object({
|
||
accountId: z.string().describe('The account to add to the club; a positive integer'),
|
||
membershipType: z
|
||
.string()
|
||
.optional()
|
||
.describe('The tier to grant — 10 Member, 20 Moderator, 30 Co-owner; defaults to Member'),
|
||
})
|
||
|
||
/** `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(),
|
||
})
|