some work on clubs schema

This commit is contained in:
Devin Zuczek
2026-07-09 22:28:09 -04:00
parent 7686bf7bf6
commit 879b5d5905
6 changed files with 584 additions and 12 deletions
+35
View File
@@ -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);
+344
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
} }
+113 -2
View File
@@ -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 callers 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)
})
}) })
+13
View File
@@ -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"