mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
some work on clubs schema
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
-- Club storage. A club is a single JSON blob in the `data` column (the
|
||||||
|
-- client-facing Club DTO); queryable fields are SQLite generated (virtual) columns
|
||||||
|
-- extracted from that JSON and indexed — the same JSON-blob pattern the
|
||||||
|
-- rooms/accounts tables use. Mirror of the Go/GORM `Club` model. Owned by the
|
||||||
|
-- `clubs` worker; generated from src/clubs-db.ts (SCHEMA_DDL) — keep in sync.
|
||||||
|
--
|
||||||
|
-- Membership lives in `club_member` (one row per club/account); the club's
|
||||||
|
-- MemberCount is denormalized and kept in sync from those rows.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club (
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
||||||
|
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
||||||
|
category TEXT GENERATED ALWAYS AS (json_extract(data, '$.Category')) VIRTUAL,
|
||||||
|
visibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Visibility')) VIRTUAL,
|
||||||
|
state INTEGER GENERATED ALWAYS AS (json_extract(data, '$.State')) VIRTUAL,
|
||||||
|
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_club_club_id ON club (club_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_name_lower ON club (name_lower);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_category ON club (category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_creator ON club (creator_account_id);
|
||||||
|
|
||||||
|
-- `membership_type` (ClubMembershipType) encodes bans, pending request/invite
|
||||||
|
-- states, and role tiers in one field. Surrogate PK mirrors the Go model; the
|
||||||
|
-- UNIQUE (club_id, account_id) index enforces one membership per pair.
|
||||||
|
CREATE TABLE IF NOT EXISTS club_member (
|
||||||
|
club_member_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
club_id INTEGER NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
membership_type INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_club_member_pair ON club_member (club_id, account_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_member_account ON club_member (account_id);
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
/**
|
||||||
|
* Club storage on the shared `recflare` D1 database. A club is a single JSON blob
|
||||||
|
* in the `data` column (the client-facing Club DTO); queryable fields (ClubId,
|
||||||
|
* Name, Category, Visibility, State, CreatorAccountId) are SQLite generated
|
||||||
|
* (virtual) columns extracted from that JSON and indexed — the same JSON-blob
|
||||||
|
* pattern the rooms/accounts tables use. Mirrors the Go/GORM `Club` model.
|
||||||
|
*
|
||||||
|
* Membership lives in a separate `club_member` table (one row per club/account);
|
||||||
|
* the club's `MemberCount` is a denormalized field kept in sync from those rows.
|
||||||
|
*
|
||||||
|
* The `clubs` worker owns this schema/migration (migrations/0001_club.sql, applied
|
||||||
|
* under its own `migrations_table` so it doesn't clash with the other workers'
|
||||||
|
* migrations that share the database). `SCHEMA_DDL` mirrors that migration so tests
|
||||||
|
* can build the tables directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of migrations/0001_club.sql, sans seed rows). */
|
||||||
|
export const SCHEMA_DDL: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS club (
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
||||||
|
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
||||||
|
category TEXT GENERATED ALWAYS AS (json_extract(data, '$.Category')) VIRTUAL,
|
||||||
|
visibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Visibility')) VIRTUAL,
|
||||||
|
state INTEGER GENERATED ALWAYS AS (json_extract(data, '$.State')) VIRTUAL,
|
||||||
|
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_club_club_id ON club (club_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_name_lower ON club (name_lower)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_category ON club (category)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_creator ON club (creator_account_id)`,
|
||||||
|
// Club membership — one row per (club, account); `membership_type` (see
|
||||||
|
// ClubMembershipType) encodes bans, pending requests/invites, and roles in a
|
||||||
|
// single field. Surrogate PK mirrors the Go model; the UNIQUE (club_id,
|
||||||
|
// account_id) index enforces one membership per pair (and backs the upsert). The
|
||||||
|
// club's MemberCount is kept in sync from the rows that count as real members.
|
||||||
|
`CREATE TABLE IF NOT EXISTS club_member (
|
||||||
|
club_member_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
club_id INTEGER NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
membership_type INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_club_member_pair ON club_member (club_id, account_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_club_member_account ON club_member (account_id)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's membership state in a club (mirror of the Go `ClubMembershipType`).
|
||||||
|
* The single field spans bans, the pending request/invite states, and the member
|
||||||
|
* role tiers; `Member` (10) is the threshold at/above which someone is an actual
|
||||||
|
* member (below it is pending/none/banned).
|
||||||
|
*/
|
||||||
|
export enum ClubMembershipType {
|
||||||
|
Banned = -1,
|
||||||
|
None = 0,
|
||||||
|
PendingRequested = 1,
|
||||||
|
PendingInvited = 2,
|
||||||
|
PendingDenied = 3,
|
||||||
|
Member = 10,
|
||||||
|
Moderator = 20,
|
||||||
|
Coowner = 30,
|
||||||
|
Creator = 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A club's visibility (mirror of the Go `ClubVisibility`). */
|
||||||
|
export enum ClubVisibility {
|
||||||
|
Private = 0,
|
||||||
|
Public = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a player may join a club (mirror of the Go `ClubJoinability`). */
|
||||||
|
export enum ClubJoinability {
|
||||||
|
Open = 0,
|
||||||
|
InviteOnly = 1,
|
||||||
|
AskToJoin = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Membership types at/above which a row counts as an actual member (not pending/banned). */
|
||||||
|
const MEMBER_THRESHOLD = ClubMembershipType.Member
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-facing club shape (PascalCase, mirror of the Go `Club` JSON tags). The
|
||||||
|
* Go model's `CreatedAt` is `json:"-"` — stored but never serialized — so it lives
|
||||||
|
* in the blob (see StoredClub) but is dropped from this DTO.
|
||||||
|
*/
|
||||||
|
export interface Club {
|
||||||
|
ClubId: number
|
||||||
|
Name: string
|
||||||
|
Description: string
|
||||||
|
Category: string
|
||||||
|
Visibility: number
|
||||||
|
Joinability: number
|
||||||
|
AllowJuniors: boolean
|
||||||
|
MainImageName: string
|
||||||
|
ClubType: number
|
||||||
|
ClubhouseRoomId: number | null
|
||||||
|
CreatorAccountId: number
|
||||||
|
IsRRO: boolean
|
||||||
|
MinLevel: number
|
||||||
|
State: number
|
||||||
|
MemberCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored club — the DTO plus `CreatedAt` (kept in the blob, `json:"-"` in Go). */
|
||||||
|
interface StoredClub extends Club {
|
||||||
|
CreatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClubRow {
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a stored club to the client DTO (drops the non-serialized CreatedAt). */
|
||||||
|
function toDto(s: StoredClub): Club {
|
||||||
|
return {
|
||||||
|
ClubId: s.ClubId,
|
||||||
|
Name: s.Name,
|
||||||
|
Description: s.Description,
|
||||||
|
Category: s.Category,
|
||||||
|
Visibility: s.Visibility,
|
||||||
|
Joinability: s.Joinability,
|
||||||
|
AllowJuniors: s.AllowJuniors,
|
||||||
|
MainImageName: s.MainImageName,
|
||||||
|
ClubType: s.ClubType,
|
||||||
|
ClubhouseRoomId: s.ClubhouseRoomId,
|
||||||
|
CreatorAccountId: s.CreatorAccountId,
|
||||||
|
IsRRO: s.IsRRO,
|
||||||
|
MinLevel: s.MinLevel,
|
||||||
|
State: s.State,
|
||||||
|
MemberCount: s.MemberCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseOne = (row: ClubRow | null): Club | null =>
|
||||||
|
row ? toDto(JSON.parse(row.data) as StoredClub) : null
|
||||||
|
const parseAll = (rows: ClubRow[]): Club[] => rows.map((r) => toDto(JSON.parse(r.data) as StoredClub))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute a club's `MemberCount` from the `club_member` rows and write it back
|
||||||
|
* into the blob (the generated column follows). Returns the fresh count. Keeping
|
||||||
|
* the count derived avoids drift from concurrent joins/leaves.
|
||||||
|
*/
|
||||||
|
async function syncMemberCount(db: D1Database, clubId: number): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT COUNT(*) AS n FROM club_member WHERE club_id = ?1 AND membership_type >= ?2')
|
||||||
|
.bind(clubId, MEMBER_THRESHOLD)
|
||||||
|
.first<{ n: number }>()
|
||||||
|
const count = row?.n ?? 0
|
||||||
|
await db
|
||||||
|
.prepare("UPDATE club SET data = json_set(data, '$.MemberCount', ?2) WHERE club_id = ?1")
|
||||||
|
.bind(clubId, count)
|
||||||
|
.run()
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a player's membership type in a club (None when there's no row). */
|
||||||
|
export async function getMembership(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<ClubMembershipType> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT membership_type AS t FROM club_member WHERE club_id = ?1 AND account_id = ?2')
|
||||||
|
.bind(clubId, accountId)
|
||||||
|
.first<{ t: number }>()
|
||||||
|
return (row?.t ?? ClubMembershipType.None) as ClubMembershipType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a player's membership type for a club (one row per pair). `created_at` is
|
||||||
|
* stamped on first insert and preserved on later type changes.
|
||||||
|
*/
|
||||||
|
async function setMembership(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number,
|
||||||
|
type: ClubMembershipType
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO club_member (club_id, account_id, membership_type, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(club_id, account_id) DO UPDATE SET membership_type = ?3`
|
||||||
|
)
|
||||||
|
.bind(clubId, accountId, type, new Date().toISOString())
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields a caller may supply when creating a club; everything else takes the Go defaults. */
|
||||||
|
export interface NewClub {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
category?: string
|
||||||
|
visibility?: number
|
||||||
|
joinability?: number
|
||||||
|
allowJuniors?: boolean
|
||||||
|
mainImageName?: string
|
||||||
|
clubType?: number
|
||||||
|
clubhouseRoomId?: number | null
|
||||||
|
isRRO?: boolean
|
||||||
|
minLevel?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a club owned by `creatorAccountId`. The id is the next free integer (the
|
||||||
|
* Go model uses `autoIncrement:false`, i.e. an app-assigned id). Unset fields fall
|
||||||
|
* back to the Go model's column defaults. The creator is added as the club's first
|
||||||
|
* member (Owner), so the returned club has MemberCount 1.
|
||||||
|
*/
|
||||||
|
export async function createClub(
|
||||||
|
db: D1Database,
|
||||||
|
creatorAccountId: number,
|
||||||
|
input: NewClub
|
||||||
|
): Promise<Club> {
|
||||||
|
const idRow = await db
|
||||||
|
.prepare('SELECT COALESCE(MAX(club_id), 0) + 1 AS next FROM club')
|
||||||
|
.first<{ next: number }>()
|
||||||
|
const clubId = idRow?.next ?? 1
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
const stored: StoredClub = {
|
||||||
|
ClubId: clubId,
|
||||||
|
Name: input.name,
|
||||||
|
Description: input.description ?? '',
|
||||||
|
Category: input.category ?? '',
|
||||||
|
Visibility: input.visibility ?? ClubVisibility.Public,
|
||||||
|
Joinability: input.joinability ?? ClubJoinability.Open,
|
||||||
|
AllowJuniors: input.allowJuniors ?? true,
|
||||||
|
MainImageName: input.mainImageName ?? 'DefaultImgPurple',
|
||||||
|
ClubType: input.clubType ?? 0,
|
||||||
|
ClubhouseRoomId: input.clubhouseRoomId ?? null,
|
||||||
|
CreatorAccountId: creatorAccountId,
|
||||||
|
IsRRO: input.isRRO ?? false,
|
||||||
|
MinLevel: input.minLevel ?? 0,
|
||||||
|
State: 0,
|
||||||
|
MemberCount: 0,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
await db.prepare('INSERT INTO club (data) VALUES (?1)').bind(JSON.stringify(stored)).run()
|
||||||
|
|
||||||
|
// The creator is the club's first member, joining as its Creator.
|
||||||
|
await setMembership(db, clubId, creatorAccountId, ClubMembershipType.Creator)
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { ...toDto(stored), MemberCount: count }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a single club by its ClubId. */
|
||||||
|
export async function getClub(db: D1Database, clubId: number): Promise<Club | null> {
|
||||||
|
return parseOne(
|
||||||
|
await db.prepare('SELECT data FROM club WHERE club_id = ?1').bind(clubId).first<ClubRow>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All clubs created by an account (GetMyCreatedClubs). */
|
||||||
|
export async function getClubsByCreator(db: D1Database, accountId: number): Promise<Club[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare('SELECT data FROM club WHERE creator_account_id = ?1')
|
||||||
|
.bind(accountId)
|
||||||
|
.all<ClubRow>()
|
||||||
|
return parseAll(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All clubs an account is an actual member of (GetMyMembershipClubs), most recently
|
||||||
|
* joined first. Only memberships at/above `Member` count — pending requests, denied
|
||||||
|
* requests, and bans are excluded. Joins `club_member` to `club`, so a membership
|
||||||
|
* whose club is gone is simply absent.
|
||||||
|
*/
|
||||||
|
export async function getClubsByMember(db: D1Database, accountId: number): Promise<Club[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT c.data AS data
|
||||||
|
FROM club_member m
|
||||||
|
JOIN club c ON c.club_id = m.club_id
|
||||||
|
WHERE m.account_id = ?1 AND m.membership_type >= ?2
|
||||||
|
ORDER BY m.created_at DESC`
|
||||||
|
)
|
||||||
|
.bind(accountId, MEMBER_THRESHOLD)
|
||||||
|
.all<ClubRow>()
|
||||||
|
return parseAll(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an account is an actual member of a club (Member tier or above). */
|
||||||
|
export async function isClubMember(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
return (await getMembership(db, clubId, accountId)) >= MEMBER_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Have `accountId` join a club. On an Open club they become a `Member` immediately;
|
||||||
|
* on an InviteOnly/AskToJoin club the join is recorded as `PendingRequested` (an
|
||||||
|
* approval flow, not yet a member). Idempotent for anyone already a member, and a
|
||||||
|
* no-op for a banned account. Returns the club with its refreshed MemberCount, or
|
||||||
|
* null when the club doesn't exist.
|
||||||
|
*/
|
||||||
|
export async function joinClub(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<Club | null> {
|
||||||
|
const club = await getClub(db, clubId)
|
||||||
|
if (!club) return null
|
||||||
|
|
||||||
|
const current = await getMembership(db, clubId, accountId)
|
||||||
|
// A ban can't be shed by re-joining, and an existing member/pending stays as-is.
|
||||||
|
if (current === ClubMembershipType.Banned || current >= MEMBER_THRESHOLD) {
|
||||||
|
return club
|
||||||
|
}
|
||||||
|
const next =
|
||||||
|
club.Joinability === ClubJoinability.Open
|
||||||
|
? ClubMembershipType.Member
|
||||||
|
: ClubMembershipType.PendingRequested
|
||||||
|
await setMembership(db, clubId, accountId, next)
|
||||||
|
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { ...club, MemberCount: count }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove `accountId`'s membership of a club (idempotent). A ban is preserved — you
|
||||||
|
* can't clear it by leaving — but any member/pending row is dropped. Returns the
|
||||||
|
* club with its refreshed MemberCount, or null when the club doesn't exist. The
|
||||||
|
* club itself is left in place even when the last member leaves.
|
||||||
|
*/
|
||||||
|
export async function leaveClub(
|
||||||
|
db: D1Database,
|
||||||
|
clubId: number,
|
||||||
|
accountId: number
|
||||||
|
): Promise<Club | null> {
|
||||||
|
const club = await getClub(db, clubId)
|
||||||
|
if (!club) return null
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM club_member WHERE club_id = ?1 AND account_id = ?2 AND membership_type <> ?3'
|
||||||
|
)
|
||||||
|
.bind(clubId, accountId, ClubMembershipType.Banned)
|
||||||
|
.run()
|
||||||
|
const count = await syncMemberCount(db, clubId)
|
||||||
|
return { ...club, MemberCount: count }
|
||||||
|
}
|
||||||
+77
-10
@@ -4,14 +4,18 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createClub,
|
||||||
|
getClub,
|
||||||
|
getClubsByCreator,
|
||||||
|
getClubsByMember,
|
||||||
|
joinClub,
|
||||||
|
leaveClub,
|
||||||
|
} from './clubs-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
|
||||||
/**
|
|
||||||
* The only endpoint is auth-gated and returns 404 unconditionally — no DB
|
|
||||||
* binding involved.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the account id from a Bearer token. Returns `null` when the header is
|
* 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.
|
* missing, the token is invalid, or the `sub` claim isn't an integer.
|
||||||
@@ -73,18 +77,81 @@ const app = new Hono<App>()
|
|||||||
.get('/subscription/subscriberCount/:accountId{[0-9]+}', (c) => c.json(0))
|
.get('/subscription/subscriberCount/:accountId{[0-9]+}', (c) => c.json(0))
|
||||||
|
|
||||||
// The player's clubs that have unread announcements (MyClubsWithUnread-
|
// The player's clubs that have unread announcements (MyClubsWithUnread-
|
||||||
// Announcements). No DB → empty list.
|
// Announcements). No announcements backing yet → empty list.
|
||||||
.get('/announcements/v2/mine/unread', (c) => c.json([]))
|
.get('/announcements/v2/mine/unread', (c) => c.json([]))
|
||||||
|
|
||||||
// The clubs the player is a member of (GetMyMembershipClubs). No DB → empty.
|
// The clubs the player is a member of (GetMyMembershipClubs). Auth-gated; reads
|
||||||
.get('/club/mine/member', (c) => c.json([]))
|
// the caller's memberships from `club_member`.
|
||||||
|
.get('/club/mine/member', async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return c.body(null, 401)
|
||||||
|
return c.json(await getClubsByMember(c.env.DB, id))
|
||||||
|
})
|
||||||
|
|
||||||
// The clubs the player created (GetMyCreatedClubs). No DB → empty.
|
// The clubs the player created (GetMyCreatedClubs). Auth-gated.
|
||||||
.get('/club/mine/created', (c) => c.json([]))
|
.get('/club/mine/created', async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return c.body(null, 401)
|
||||||
|
return c.json(await getClubsByCreator(c.env.DB, id))
|
||||||
|
})
|
||||||
|
|
||||||
// The set of club category tags a club can be filed under — a fixed list.
|
// The set of club category tags a club can be filed under — a fixed list.
|
||||||
.get('/club/categoryTags', (c) =>
|
.get('/club/categoryTags', (c) =>
|
||||||
c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
|
c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Create a club owned by the caller. Auth-gated (401). Body carries the club
|
||||||
|
// fields (Name required; the rest fall back to the model defaults). The creator
|
||||||
|
// is auto-joined as the owner. Returns the new Club.
|
||||||
|
.post('/club', async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return c.body(null, 401)
|
||||||
|
|
||||||
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||||
|
const int = (v: unknown): number | undefined => {
|
||||||
|
const n = typeof v === 'string' ? Number.parseInt(v, 10) : Number.NaN
|
||||||
|
return Number.isNaN(n) ? undefined : n
|
||||||
|
}
|
||||||
|
const bool = (v: unknown): boolean | undefined =>
|
||||||
|
typeof v === 'string' ? v.toLowerCase() === 'true' : undefined
|
||||||
|
|
||||||
|
const name = str(body.Name)?.trim() ?? ''
|
||||||
|
if (name === '') return c.json({ error: 'You must enter a name for your club.' }, 400)
|
||||||
|
|
||||||
|
const club = await createClub(c.env.DB, id, {
|
||||||
|
name,
|
||||||
|
description: str(body.Description),
|
||||||
|
category: str(body.Category),
|
||||||
|
visibility: int(body.Visibility),
|
||||||
|
joinability: int(body.Joinability),
|
||||||
|
allowJuniors: bool(body.AllowJuniors),
|
||||||
|
mainImageName: str(body.MainImageName),
|
||||||
|
clubType: int(body.ClubType),
|
||||||
|
minLevel: int(body.MinLevel),
|
||||||
|
})
|
||||||
|
return c.json(club)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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]+}/leave', async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return c.body(null, 401)
|
||||||
|
const club = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
|
||||||
|
return club ? c.json(club) : c.notFound()
|
||||||
|
})
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type Env = SharedHonoEnv & {
|
|||||||
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
|
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
|
||||||
// signed by `auth` verify here.
|
// signed by `auth` verify here.
|
||||||
JWT_SECRET: SecretsStoreSecret
|
JWT_SECRET: SecretsStoreSecret
|
||||||
|
// Shared `recflare` D1 database holding the club / club_member tables. See clubs-db.ts.
|
||||||
|
DB: D1Database
|
||||||
// add additional Bindings here
|
// add additional Bindings here
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
|||||||
|
|
||||||
import '../../clubs.app'
|
import '../../clubs.app'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL } from '../../clubs-db'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
declare module 'cloudflare:test' {
|
declare module 'cloudflare:test' {
|
||||||
@@ -15,6 +17,8 @@ const ORIGIN = 'https://example.com'
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||||
|
// Build the club / club_member tables (mirrors the migration).
|
||||||
|
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store.
|
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store.
|
||||||
@@ -86,8 +90,11 @@ describe('clubs endpoints', () => {
|
|||||||
expect(await res.json()).toEqual([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /club/mine/member returns []', async () => {
|
test('GET /club/mine/member is auth-gated and lists the caller’s clubs', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/club/mine/member`)
|
expect((await exports.default.fetch(`${ORIGIN}/club/mine/member`)).status).toBe(401)
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/club/mine/member`, {
|
||||||
|
headers: await bearer('4242'),
|
||||||
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
@@ -96,4 +103,108 @@ describe('clubs endpoints', () => {
|
|||||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Full club lifecycle: create → get → other player joins/leaves, with the
|
||||||
|
// creator auto-membership and MemberCount upkeep, plus the mine/* lists.
|
||||||
|
test('create → get → join → leave a club, with MemberCount and my-clubs lists', async () => {
|
||||||
|
type Club = {
|
||||||
|
ClubId: number
|
||||||
|
Name: string
|
||||||
|
Category: string
|
||||||
|
Visibility: number
|
||||||
|
AllowJuniors: boolean
|
||||||
|
MainImageName: string
|
||||||
|
CreatorAccountId: number
|
||||||
|
MemberCount: number
|
||||||
|
}
|
||||||
|
const post = async (path: string, sub: string | null, fields: Record<string, string> = {}) =>
|
||||||
|
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(sub ? await bearer(sub) : {}),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(fields).toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create requires auth.
|
||||||
|
expect((await post('/club', null, { Name: 'Nope' })).status).toBe(401)
|
||||||
|
// Create requires a name.
|
||||||
|
expect((await post('/club', '800', { Name: ' ' })).status).toBe(400)
|
||||||
|
|
||||||
|
// 800 creates a club → defaults applied, creator auto-joined (MemberCount 1).
|
||||||
|
const created = (await (
|
||||||
|
await post('/club', '800', { Name: 'Speedrunners', Category: 'Competitive' })
|
||||||
|
).json()) as Club
|
||||||
|
expect(created).toMatchObject({
|
||||||
|
Name: 'Speedrunners',
|
||||||
|
Category: 'Competitive',
|
||||||
|
Visibility: 1, // default
|
||||||
|
AllowJuniors: true, // default
|
||||||
|
MainImageName: 'DefaultImgPurple', // default
|
||||||
|
CreatorAccountId: 800,
|
||||||
|
MemberCount: 1,
|
||||||
|
})
|
||||||
|
const clubId = created.ClubId
|
||||||
|
|
||||||
|
// Public get by id returns it; unknown id 404s.
|
||||||
|
const fetched = (await (await exports.default.fetch(`${ORIGIN}/club/${clubId}`)).json()) as Club
|
||||||
|
expect(fetched.ClubId).toBe(clubId)
|
||||||
|
expect((await exports.default.fetch(`${ORIGIN}/club/99999`)).status).toBe(404)
|
||||||
|
|
||||||
|
// It shows in the creator's created + member lists.
|
||||||
|
const created800 = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/club/mine/created`, { headers: await bearer('800') })
|
||||||
|
).json()) as Club[]
|
||||||
|
expect(created800.map((c) => c.ClubId)).toContain(clubId)
|
||||||
|
|
||||||
|
// 801 joins → MemberCount 2, and the club appears in 801's member list (not created).
|
||||||
|
const joined = (await (await post(`/club/${clubId}/join`, '801')).json()) as Club
|
||||||
|
expect(joined.MemberCount).toBe(2)
|
||||||
|
const member801 = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('801') })
|
||||||
|
).json()) as Club[]
|
||||||
|
expect(member801.map((c) => c.ClubId)).toContain(clubId)
|
||||||
|
const createdBy801 = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/club/mine/created`, { headers: await bearer('801') })
|
||||||
|
).json()) as Club[]
|
||||||
|
expect(createdBy801).toEqual([])
|
||||||
|
|
||||||
|
// Joining again is idempotent (still 2).
|
||||||
|
expect(((await (await post(`/club/${clubId}/join`, '801')).json()) as Club).MemberCount).toBe(2)
|
||||||
|
|
||||||
|
// 801 leaves → back to 1, and it drops out of their member list.
|
||||||
|
expect(((await (await post(`/club/${clubId}/leave`, '801')).json()) as Club).MemberCount).toBe(1)
|
||||||
|
const afterLeave = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('801') })
|
||||||
|
).json()) as Club[]
|
||||||
|
expect(afterLeave.map((c) => c.ClubId)).not.toContain(clubId)
|
||||||
|
|
||||||
|
// Join/leave on a missing club 404s.
|
||||||
|
expect((await post('/club/99999/join', '801')).status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('joining a non-open club records a pending request, not a membership', async () => {
|
||||||
|
const post = async (path: string, sub: string, fields: Record<string, string> = {}) =>
|
||||||
|
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(fields).toString(),
|
||||||
|
})
|
||||||
|
type Club = { ClubId: number; Joinability: number; MemberCount: number }
|
||||||
|
|
||||||
|
// 810 creates an ask-to-join club (Joinability 2) → only the creator counts.
|
||||||
|
const club = (await (
|
||||||
|
await post('/club', '810', { Name: 'Inner Circle', Joinability: '2' })
|
||||||
|
).json()) as Club
|
||||||
|
expect(club).toMatchObject({ Joinability: 2, MemberCount: 1 })
|
||||||
|
|
||||||
|
// 811 asks to join → pending, so MemberCount is unchanged and it isn't a membership.
|
||||||
|
const joined = (await (await post(`/club/${club.ClubId}/join`, '811')).json()) as Club
|
||||||
|
expect(joined.MemberCount).toBe(1)
|
||||||
|
const member811 = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('811') })
|
||||||
|
).json()) as Club[]
|
||||||
|
expect(member811.map((c) => c.ClubId)).not.toContain(club.ClubId)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,19 @@
|
|||||||
"main": "src/clubs.app.ts",
|
"main": "src/clubs.app.ts",
|
||||||
"compatibility_date": "2025-09-20",
|
"compatibility_date": "2025-09-20",
|
||||||
"compatibility_flags": ["nodejs_compat"],
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
|
// Shared `recflare` DB (created manually with `wrangler d1 create recflare`; the
|
||||||
|
// "local" placeholder is spliced out at deploy time). The `clubs` worker owns the
|
||||||
|
// `club` / `club_member` tables (schema/migration here); its own migrations_table
|
||||||
|
// keeps history separate from the other workers' migrations on the shared database.
|
||||||
|
"d1_databases": [
|
||||||
|
{
|
||||||
|
"binding": "DB",
|
||||||
|
"database_name": "recflare",
|
||||||
|
"database_id": "local",
|
||||||
|
"migrations_dir": "migrations",
|
||||||
|
"migrations_table": "d1_migrations_clubs"
|
||||||
|
}
|
||||||
|
],
|
||||||
"logpush": false,
|
"logpush": false,
|
||||||
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
||||||
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
||||||
|
|||||||
Reference in New Issue
Block a user