diff --git a/apps/clubs/migrations/0001_club.sql b/apps/clubs/migrations/0001_club.sql new file mode 100644 index 0000000..3567d93 --- /dev/null +++ b/apps/clubs/migrations/0001_club.sql @@ -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); diff --git a/apps/clubs/src/clubs-db.ts b/apps/clubs/src/clubs-db.ts new file mode 100644 index 0000000..6b18b95 --- /dev/null +++ b/apps/clubs/src/clubs-db.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + return parseOne( + await db.prepare('SELECT data FROM club WHERE club_id = ?1').bind(clubId).first() + ) +} + +/** All clubs created by an account (GetMyCreatedClubs). */ +export async function getClubsByCreator(db: D1Database, accountId: number): Promise { + const { results } = await db + .prepare('SELECT data FROM club WHERE creator_account_id = ?1') + .bind(accountId) + .all() + 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 { + 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() + 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 { + 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 { + 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 { + 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 } +} diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index 049edf3..24b96a2 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -4,14 +4,18 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' +import { + createClub, + getClub, + getClubsByCreator, + getClubsByMember, + joinClub, + leaveClub, +} from './clubs-db' + import type { Context } from 'hono' 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 * missing, the token is invalid, or the `sub` claim isn't an integer. @@ -73,18 +77,81 @@ const app = new Hono() .get('/subscription/subscriberCount/:accountId{[0-9]+}', (c) => c.json(0)) // 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([])) - // The clubs the player is a member of (GetMyMembershipClubs). No DB → empty. - .get('/club/mine/member', (c) => c.json([])) + // The clubs the player is a member of (GetMyMembershipClubs). Auth-gated; reads + // 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. - .get('/club/mine/created', (c) => c.json([])) + // The clubs the player created (GetMyCreatedClubs). Auth-gated. + .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. .get('/club/categoryTags', (c) => 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 + 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 diff --git a/apps/clubs/src/context.ts b/apps/clubs/src/context.ts index 5affecc..5061140 100644 --- a/apps/clubs/src/context.ts +++ b/apps/clubs/src/context.ts @@ -6,6 +6,8 @@ export type Env = SharedHonoEnv & { // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens // signed by `auth` verify here. JWT_SECRET: SecretsStoreSecret + // Shared `recflare` D1 database holding the club / club_member tables. See clubs-db.ts. + DB: D1Database // add additional Bindings here } diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index 294f7c6..6456ee9 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../clubs.app' +import { SCHEMA_DDL } from '../../clubs-db' + import type { Env } from '../../context' declare module 'cloudflare:test' { @@ -15,6 +17,8 @@ const ORIGIN = 'https://example.com' beforeAll(async () => { // Seed the shared JWT signing key into the local Secrets Store so .get() resolves. 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. @@ -86,8 +90,11 @@ describe('clubs endpoints', () => { expect(await res.json()).toEqual([]) }) - test('GET /club/mine/member returns []', async () => { - const res = await exports.default.fetch(`${ORIGIN}/club/mine/member`) + test('GET /club/mine/member is auth-gated and lists the caller’s clubs', async () => { + 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(await res.json()).toEqual([]) }) @@ -96,4 +103,108 @@ describe('clubs endpoints', () => { const res = await exports.default.fetch(`${ORIGIN}/nope`) 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 = {}) => + 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 = {}) => + 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) + }) }) diff --git a/apps/clubs/wrangler.jsonc b/apps/clubs/wrangler.jsonc index 649efcb..6ae675e 100644 --- a/apps/clubs/wrangler.jsonc +++ b/apps/clubs/wrangler.jsonc @@ -4,6 +4,19 @@ "main": "src/clubs.app.ts", "compatibility_date": "2025-09-20", "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, // 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"