From 7f497fcee0582fc2f91687d81353dd2872ec156a Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 21 Jul 2026 12:59:47 -0400 Subject: [PATCH] fix #19 clubhouses --- apps/clubs/src/clubs-db.ts | 94 +++++++- apps/clubs/src/clubs.app.ts | 150 +++++++++++- apps/clubs/src/test/integration/api.test.ts | 255 +++++++++++++++++++- apps/match/src/match.app.ts | 61 +++++ apps/match/src/test/integration/api.test.ts | 87 +++++++ packages/domain/src/clubs-db.ts | 64 +++++ packages/domain/src/index.ts | 1 + 7 files changed, 691 insertions(+), 21 deletions(-) create mode 100644 packages/domain/src/clubs-db.ts diff --git a/apps/clubs/src/clubs-db.ts b/apps/clubs/src/clubs-db.ts index 6ca5f78..fa3e92f 100644 --- a/apps/clubs/src/clubs-db.ts +++ b/apps/clubs/src/clubs-db.ts @@ -519,6 +519,17 @@ export async function setHomeClub( .run() } +/** + * Drop the player's home club (the field is removed from their account row, not set + * to 0 — `getHomeClub` reads a missing field as "no home club"). Idempotent. + */ +export async function clearHomeClub(db: D1Database, accountId: number): Promise { + await db + .prepare("UPDATE account SET data = json_remove(data, '$.homeClubId') WHERE account_id = ?1") + .bind(accountId) + .run() +} + /** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */ export interface ClubMember { ClubMemberId: number @@ -659,6 +670,30 @@ export async function getClub(db: D1Database, clubId: number): Promise { + if ((await getClub(db, clubId)) === null) return false + await db.batch([ + db.prepare('DELETE FROM club_member WHERE club_id = ?1').bind(clubId), + db.prepare('DELETE FROM club_announcement WHERE club_id = ?1').bind(clubId), + // The account table belongs to the auth worker; a dangling homeClubId already + // reads as "no home club" (getHomeClub), but leaving it would point at whatever + // club later reuses the id. + db + .prepare( + `UPDATE account SET data = json_remove(data, '$.homeClubId') + WHERE json_extract(data, '$.homeClubId') = ?1` + ) + .bind(clubId), + db.prepare('DELETE FROM club WHERE club_id = ?1').bind(clubId), + ]) + return true +} + /** * Subscription clubs (`ClubType` 1) are a creator's paid-subscriber club, not a * club you browse or list among your own — they're excluded from the "my clubs" @@ -740,19 +775,70 @@ export async function joinClub( return { ...club, MemberCount: count } } +/** + * How a request to join resolved. `joined` is an Open club (no approval needed), + * `requested` an AskToJoin club (now PendingRequested), `alreadyPending` a repeat + * request, `alreadyMember` someone who's already in. `inviteOnly` and `banned` are + * refusals — the caller can't get in this way. + */ +export type JoinRequestResult = + 'joined' | 'requested' | 'alreadyPending' | 'alreadyMember' | 'inviteOnly' | 'banned' + +/** + * Ask to join a club. Unlike `joinClub` this honours the club's Joinability strictly: + * an InviteOnly club can only be entered through an invite, so a request is refused + * rather than parked as pending. Returns the outcome plus the club with its refreshed + * MemberCount, or null when the club doesn't exist. + */ +export async function requestToJoinClub( + db: D1Database, + clubId: number, + accountId: number +): Promise<{ result: JoinRequestResult; club: 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 asking again, and existing members/requests stay as-is. + if (current === ClubMembershipType.Banned) return { result: 'banned', club } + if (current >= MEMBER_THRESHOLD) return { result: 'alreadyMember', club } + if (current === ClubMembershipType.PendingRequested) return { result: 'alreadyPending', club } + + if (club.Joinability === ClubJoinability.InviteOnly) return { result: 'inviteOnly', club } + + const open = club.Joinability === ClubJoinability.Open + await setMembership( + db, + clubId, + accountId, + open ? ClubMembershipType.Member : ClubMembershipType.PendingRequested + ) + + const count = await syncMemberCount(db, clubId) + return { result: open ? 'joined' : 'requested', club: { ...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. + * outcome plus 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. + * + * The creator can't leave: a club with no owner has no one who can administer it, and + * there's no ownership transfer, so they have to delete the club instead. `creator` + * reports that refusal, with the club unchanged. */ export async function leaveClub( db: D1Database, clubId: number, accountId: number -): Promise { +): Promise<{ result: 'left' | 'creator'; club: Club } | null> { const club = await getClub(db, clubId) if (!club) return null + + const current = await getMembership(db, clubId, accountId) + if (current === ClubMembershipType.Creator) return { result: 'creator', club } + await db .prepare( 'DELETE FROM club_member WHERE club_id = ?1 AND account_id = ?2 AND membership_type <> ?3' @@ -760,5 +846,5 @@ export async function leaveClub( .bind(clubId, accountId, ClubMembershipType.Banned) .run() const count = await syncMemberCount(db, clubId) - return { ...club, MemberCount: count } + return { result: 'left', club: { ...club, MemberCount: count } } } diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index 02d1ed7..778ee0d 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -5,11 +5,13 @@ import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' import { + clearHomeClub, ClubJoinability, ClubMembershipType, ClubVisibility, createClub, createClubAnnouncement, + deleteClub, getClub, getClubAnnouncements, getClubDetails, @@ -20,6 +22,7 @@ import { getMembership, joinClub, leaveClub, + requestToJoinClub, searchClubs, setHomeClub, updateClub, @@ -164,6 +167,17 @@ const app = new Hono() return c.json({ error: '', success: true, value: club }) }) + // Clear the player's home club — they spawn into the default hub again instead of a + // clubhouse. No body, idempotent (clearing when there's none set is a no-op, not a + // 404), and it doesn't touch their membership of the club. The envelope's value is + // null because there's no home club left to describe; GET goes back to 404ing. + .delete('/club/home/me', async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + await clearHomeClub(c.env.DB, id) + return c.json({ error: '', success: true, value: null }) + }) + // A real Rec Room client endpoint with no backing implementation yet. The // client calls it on the clubs host at /subscription/mine/member (no /club // prefix) and sends no auth header, so it isn't gated. Returns an empty @@ -334,7 +348,10 @@ const app = new Hono() // and absent fields keep their stored value. `customTags` may repeat; when present // it replaces the club's tag set wholesale. Co-owner or above only. Answers the // same `{ error, success, value }` envelope create does. - .put('/club/:clubId{[0-9]+}/modifydetails', async (c) => { + // + // `/modify` is the same endpoint under the shorter name the client also PUTs to + // (`name=…&description=…&category=…`); one handler, so the two can't drift. + .on('PUT', ['/club/:clubId{[0-9]+}/modifydetails', '/club/:clubId{[0-9]+}/modify'], async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) @@ -472,8 +489,14 @@ const app = new Hono() // Set (or clear) the club's clubhouse room — the room players spawn into when the // club is their home. `roomId` sets it; omitting it clears the clubhouse. Co-owner - // or above only. Answers the envelope with a null value, as the reference does. - .put('/club/:clubId{[0-9]+}/clubhouse', async (c) => { + // or above only. Answers the details envelope (the reference returns a null value + // here, but the client re-renders from the response and leaves the old clubhouse + // on screen unless it gets the updated club back). + // + // DELETE is the same thing with the clearing spelled out — it ignores any body and + // always unsets the room, so "remove the clubhouse" doesn't depend on the client + // remembering to send an empty PUT. + .on(['PUT', 'DELETE'], '/club/:clubId{[0-9]+}/clubhouse', async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) @@ -486,17 +509,24 @@ const app = new Hono() return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) } - const body = (await c.req.parseBody().catch(() => ({}))) as Record - const key = Object.keys(body).find((k) => k.toLowerCase() === 'roomid') - const raw = typeof body[key ?? ''] === 'string' ? String(body[key ?? '']).trim() : '' - if (raw !== '' && Number.isNaN(Number.parseInt(raw, 10))) { - return clubError(c, 'Invalid roomId.') + let roomId: number | null = null + if (c.req.method !== 'DELETE') { + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const key = Object.keys(body).find((k) => k.toLowerCase() === 'roomid') + const raw = typeof body[key ?? ''] === 'string' ? String(body[key ?? '']).trim() : '' + if (raw !== '' && Number.isNaN(Number.parseInt(raw, 10))) { + return clubError(c, 'Invalid roomId.') + } + roomId = raw === '' ? null : Number.parseInt(raw, 10) } - await updateClub(c.env.DB, clubId, { - clubhouseRoomId: raw === '' ? null : Number.parseInt(raw, 10), + const updated = await updateClub(c.env.DB, clubId, { clubhouseRoomId: roomId }) + if (updated === null) return c.notFound() + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, updated, id), }) - return c.json({ error: '', success: true, value: null }) }) // The club's main image. PUT sets it from an uploaded image's `imageName` (the @@ -548,6 +578,87 @@ const app = new Hono() return club ? c.json(club) : c.notFound() }) + // Delete a club, along with its memberships and announcements. The creator only — + // not co-owners, who can edit a club but can't destroy one — which is also the way + // out for a creator, since they aren't allowed to leave (see /members/leave). + // Answers the envelope with a null value; the club is gone, so there are no details + // left to return. + .delete('/club/:clubId{[0-9]+}', async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Creator) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + await deleteClub(c.env.DB, clubId) + return c.json({ error: '', success: true, value: null }) + }) + + // Ask to join a club. No body — the club id and the Bearer token are the whole + // request. What it does depends on the club's Joinability: an Open club takes the + // caller straight in as a Member, an AskToJoin club records a PendingRequested row + // for a co-owner to approve, and an InviteOnly club refuses (you can only get in + // through an invite). Repeats are idempotent; a banned account stays out. Answers + // the details envelope so the client can read its new `MyMembershipType`. + .put('/club/:clubId{[0-9]+}/members/requesttojoin', async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const outcome = await requestToJoinClub(c.env.DB, clubId, id) + if (outcome === null) return c.notFound() + + if (outcome.result === 'inviteOnly') { + return clubError(c, 'This club is invite only.') + } + if (outcome.result === 'banned') { + return c.json({ error: 'You are banned from this club.', success: false, value: null }, 403) + } + + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, outcome.club, id), + }) + }) + + // Leave a club. No body, like requesttojoin — the club id and the Bearer token are + // the whole request. Idempotent (leaving a club you're not in is a no-op), and it + // also withdraws a pending request; a ban is preserved, since you can't clear one + // by leaving. The creator is refused — they'd leave the club ownerless, so they + // have to delete it instead. Answers the details envelope so the client sees + // `MyMembershipType` drop to 0 (or stay at -1 for a banned account). + .post('/club/:clubId{[0-9]+}/members/leave', async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const outcome = await leaveClub(c.env.DB, clubId, id) + if (outcome === null) return c.notFound() + if (outcome.result === 'creator') { + return c.json( + { + error: 'You created this club — delete it instead of leaving.', + success: false, + value: null, + }, + 403 + ) + } + + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, outcome.club, id), + }) + }) + // Join / leave a club (auth-gated, idempotent). Both return the club with its // refreshed MemberCount; 404 when the club doesn't exist. .post('/club/:clubId{[0-9]+}/join', async (c) => { @@ -556,11 +667,24 @@ const app = new Hono() const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id) return club ? c.json(club) : c.notFound() }) + // Leaving is refused for the creator here too (see /members/leave), so the two + // routes can't disagree about who's still in the club. .post('/club/:clubId{[0-9]+}/leave', async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) - const club = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id) - return club ? c.json(club) : c.notFound() + const outcome = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id) + if (outcome === null) return c.notFound() + if (outcome.result === 'creator') { + return c.json( + { + error: 'You created this club — delete it instead of leaving.', + success: false, + value: null, + }, + 403 + ) + } + return c.json(outcome.club) }) export default app diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index 3c07010..234253f 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -325,6 +325,25 @@ describe('clubs endpoints', () => { body: 'name=Ghost', }) expect(missing.status).toBe(404) + + // /modify is the same endpoint under the client's shorter name. + const short = async (sub: string) => + exports.default.fetch(`${ORIGIN}/club/${clubId}/modify`, { + method: 'PUT', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=my%20club&description=rock%20out&category=Casual', + }) + const renamed = (await (await short('6000')).json()) as Details + expect(renamed.value.Club).toMatchObject({ + Name: 'my club', + Description: 'rock out', + Category: 'Casual', + }) + // ...and it's gated the same way. + expect((await short('6001')).status).toBe(403) + expect( + (await exports.default.fetch(`${ORIGIN}/club/${clubId}/modify`, { method: 'PUT' })).status + ).toBe(401) }) test('GET/PUT /club/:id/mainimage reads and sets the club image, co-owner only', async () => { @@ -473,22 +492,77 @@ describe('clubs endpoints', () => { // Give it a clubhouse → the home club now resolves. const clubhouse = await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100') expect(clubhouse.status).toBe(200) - expect(await clubhouse.json()).toEqual({ error: '', success: true, value: null }) + // The details envelope carries the new room back — the client re-renders from it. + const setRoom = (await clubhouse.json()) as { + error: string + success: boolean + value: { Club: { ClubhouseRoomId: number | null } } + } + expect(setRoom).toMatchObject({ error: '', success: true }) + expect(setRoom.value.Club.ClubhouseRoomId).toBe(77) const home = await exports.default.fetch(`${ORIGIN}/club/home/me`, { headers: await bearer('9100'), }) expect((await home.json()) as Club).toMatchObject({ ClubId: clubId, ClubhouseRoomId: 77 }) - // Clearing the clubhouse takes the home club away again. - await form(`/club/${clubId}/clubhouse`, 'PUT', '', '9100') + // Clearing the clubhouse takes the home club away again — and reports the cleared + // room, so the client doesn't keep showing the old one. + const clear = (await (await form(`/club/${clubId}/clubhouse`, 'PUT', '', '9100')).json()) as { + value: { Club: { ClubhouseRoomId: number | null } } + } + expect(clear.value.Club.ClubhouseRoomId).toBeNull() const cleared = await exports.default.fetch(`${ORIGIN}/club/home/me`, { headers: await bearer('9100'), }) expect(cleared.status).toBe(404) - // Only co-owners may set the clubhouse; signed out is a 401 on both. + // DELETE clears it too, ignoring any body it's sent. + await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=88', '9100') + const deleted = (await ( + await form(`/club/${clubId}/clubhouse`, 'DELETE', 'roomId=99', '9100') + ).json()) as { success: boolean; value: { Club: { ClubhouseRoomId: number | null } } } + expect(deleted.success).toBe(true) + expect(deleted.value.Club.ClubhouseRoomId).toBeNull() + + // DELETE /club/home/me drops the home club without touching the membership, and + // is idempotent when there's none set. + await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100') + await form('/club/home/me', 'PUT', `clubId=${clubId}`, '9100') + const dropped = await exports.default.fetch(`${ORIGIN}/club/home/me`, { + method: 'DELETE', + headers: await bearer('9100'), + }) + expect(dropped.status).toBe(200) + expect(await dropped.json()).toEqual({ error: '', success: true, value: null }) + expect( + (await exports.default.fetch(`${ORIGIN}/club/home/me`, { headers: await bearer('9100') })) + .status + ).toBe(404) + // Still a member of the club they'd made their home. + const mine = (await ( + await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('9100') }) + ).json()) as Club[] + expect(mine.map((c) => c.ClubId)).toContain(clubId) + // Clearing again, and clearing when nothing was set, both succeed. + for (const sub of ['9100', '9101']) { + const again = await exports.default.fetch(`${ORIGIN}/club/home/me`, { + method: 'DELETE', + headers: await bearer(sub), + }) + expect(again.status).toBe(200) + } + expect( + (await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'DELETE' })).status + ).toBe(401) + + // Only co-owners may set or clear the clubhouse; signed out is a 401 on both. expect((await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=1', '9101')).status).toBe(403) + expect((await form(`/club/${clubId}/clubhouse`, 'DELETE', '', '9101')).status).toBe(403) + expect( + (await exports.default.fetch(`${ORIGIN}/club/${clubId}/clubhouse`, { method: 'DELETE' })) + .status + ).toBe(401) const anon = await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'PUT' }) expect(anon.status).toBe(401) }) @@ -875,4 +949,177 @@ describe('clubs endpoints', () => { ).json()) as Club[] expect(member811.map((c) => c.ClubId)).not.toContain(club.ClubId) }) + + test('requesttojoin follows the club joinability', async () => { + const create = async (sub: string, fields: Record) => + ( + (await ( + await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { + ...(await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(fields).toString(), + }) + ).json()) as { value: { Club: { ClubId: number } } } + ).value.Club.ClubId + const request = async (clubId: number, sub: string) => + exports.default.fetch(`${ORIGIN}/club/${clubId}/members/requesttojoin`, { + method: 'PUT', + headers: await bearer(sub), + }) + type Details = { error: string; success: boolean; value: { MyMembershipType: number } | null } + + const open = await create('820', { name: 'Open Doors', joinability: 'Open' }) + const ask = await create('821', { name: 'Ask First', joinability: 'AskToJoin' }) + const invite = await create('822', { name: 'Invite Only', joinability: 'InviteOnly' }) + + // Open → straight in as a Member (10). + const joined = (await (await request(open, '830')).json()) as Details + expect(joined).toMatchObject({ success: true }) + expect(joined.value?.MyMembershipType).toBe(10) + + // AskToJoin → PendingRequested (1), and a repeat request leaves it there. + const asked = (await (await request(ask, '830')).json()) as Details + expect(asked.value?.MyMembershipType).toBe(1) + expect( + (((await (await request(ask, '830')).json()) as Details).value ?? {}).MyMembershipType + ).toBe(1) + + // InviteOnly → refused, with no membership row created. + const refused = await request(invite, '830') + expect(refused.status).toBe(400) + expect(((await refused.json()) as Details).success).toBe(false) + + // No token, and an unknown club. + expect( + ( + await exports.default.fetch(`${ORIGIN}/club/${open}/members/requesttojoin`, { + method: 'PUT', + }) + ).status + ).toBe(401) + expect((await request(99999, '830')).status).toBe(404) + }) + + test('members/leave drops a membership and withdraws a pending request', async () => { + const create = async (sub: string, fields: Record) => + ( + (await ( + await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { + ...(await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(fields).toString(), + }) + ).json()) as { value: { Club: { ClubId: number } } } + ).value.Club.ClubId + const call = async (clubId: number, sub: string, action: 'requesttojoin' | 'leave') => + exports.default.fetch(`${ORIGIN}/club/${clubId}/members/${action}`, { + method: action === 'leave' ? 'POST' : 'PUT', + headers: await bearer(sub), + }) + type Details = { + success: boolean + value: { Club: { MemberCount: number }; MyMembershipType: number } | null + } + + const open = await create('840', { name: 'Revolving', joinability: 'Open' }) + const ask = await create('841', { name: 'Waitlist', joinability: 'AskToJoin' }) + + // A member leaves → membership 0, and the count drops back to the creator alone. + await call(open, '850', 'requesttojoin') + const left = (await (await call(open, '850', 'leave')).json()) as Details + expect(left.success).toBe(true) + expect(left.value?.MyMembershipType).toBe(0) + expect(left.value?.Club.MemberCount).toBe(1) + + // Leaving again is a no-op, not an error. + expect( + (((await (await call(open, '850', 'leave')).json()) as Details).value ?? {}).MyMembershipType + ).toBe(0) + + // Leaving withdraws a pending request too. + expect( + (((await (await call(ask, '850', 'requesttojoin')).json()) as Details).value ?? {}) + .MyMembershipType + ).toBe(1) + expect( + (((await (await call(ask, '850', 'leave')).json()) as Details).value ?? {}).MyMembershipType + ).toBe(0) + + // The creator can't leave their own club — they'd leave it ownerless. + for (const path of [`/club/${open}/members/leave`, `/club/${open}/leave`]) { + const res = await exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: await bearer('840'), + }) + expect(res.status).toBe(403) + } + // ...and they're still the creator afterwards. + const stillIn = (await ( + await exports.default.fetch(`${ORIGIN}/club/${open}/details`, { + headers: await bearer('840'), + }) + ).json()) as { MyMembershipType: number } + expect(stillIn.MyMembershipType).toBe(100) + + // No token, and an unknown club. + expect( + (await exports.default.fetch(`${ORIGIN}/club/${open}/members/leave`, { method: 'POST' })) + .status + ).toBe(401) + expect((await call(99999, '850', 'leave')).status).toBe(404) + }) + + test('DELETE /club/:id is the creator’s only, and takes the memberships with it', async () => { + const create = async (sub: string, fields: Record) => + ( + (await ( + await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { + ...(await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(fields).toString(), + }) + ).json()) as { value: { Club: { ClubId: number } } } + ).value.Club.ClubId + const del = async (clubId: number, sub?: string) => + exports.default.fetch(`${ORIGIN}/club/${clubId}`, { + method: 'DELETE', + ...(sub === undefined ? {} : { headers: await bearer(sub) }), + }) + + const clubId = await create('860', { name: 'Doomed', joinability: 'Open' }) + await exports.default.fetch(`${ORIGIN}/club/${clubId}/members/requesttojoin`, { + method: 'PUT', + headers: await bearer('861'), + }) + + // Signed out, a plain member, and an unknown club. + expect((await del(clubId)).status).toBe(401) + expect((await del(clubId, '861')).status).toBe(403) + expect((await del(99999, '860')).status).toBe(404) + + // The creator can. The club, and its members, are gone. + const res = await del(clubId, '860') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ error: '', success: true, value: null }) + expect((await exports.default.fetch(`${ORIGIN}/club/${clubId}`)).status).toBe(404) + expect( + await (await exports.default.fetch(`${ORIGIN}/club/${clubId}/members`)).json() + ).toMatchObject({ value: [] }) + const member861 = (await ( + await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('861') }) + ).json()) as Array<{ ClubId: number }> + expect(member861.map((c) => c.ClubId)).not.toContain(clubId) + + // Deleting twice 404s rather than reporting success. + expect((await del(clubId, '860')).status).toBe(404) + }) }) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index a5ea0a9..71289ab 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -8,6 +8,7 @@ import { deleteExpiredPresence, deletePresence, getAccount, + getClubSummary, getExpiredPresenceInstanceIds, getJoinableInstance, getOrCreateDormRoom, @@ -16,6 +17,7 @@ import { getRoomById, getRoomByName, getRoomInstancesByRoom, + isClubMember, refreshInstanceFullness, RoomInstanceType, setPresence, @@ -718,6 +720,65 @@ const app = new Hono() return c.json({ errorCode: 0, roomInstance: instance }) } ) + // Matchmake into a club's clubhouse (`/matchmake/club/{clubId}`). Registered before + // the single-segment `/matchmake/:room` route so `club` isn't read as a room name. + // Members only: the clubhouse is the club's private space, so a non-member (or + // someone with a pending request, or banned) is refused rather than let in. + .post( + '/matchmake/club/:clubId{[0-9]+}', + describeRoute({ + tags: ['Navigation'], + summary: 'Matchmake into a club’s clubhouse', + description: + 'Looks the club up, checks the caller is a member of it, and places them into an ' + + 'instance of its clubhouse room. Returns errorCode 20 with a null instance when the ' + + 'club is unknown, has no clubhouse set, or the caller isn’t a member.', + security: AUTHED, + requestBody: form(JoinModeRequest, 'Optional JoinMode'), + parameters: [ + { + name: 'clubId', + in: 'path', + required: true, + description: 'Club id (digits only)', + schema: { type: 'string', pattern: '^[0-9]+$' }, + }, + ], + responses: { + 200: json( + MatchmakeResponse, + 'The clubhouse instance (or errorCode 20 with null when it can’t be entered)' + ), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClubSummary(c.env.DB, clubId) + // One response for "no such club", "no clubhouse", and "not a member": the + // client only needs "you're not going there", and a distinct code for the last + // case would tell a non-member which clubs exist and have a clubhouse. + if (!club?.clubhouseRoomId) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + if (!(await isClubMember(c.env.DB, clubId, id))) { + return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + } + + const joinMode = await readJoinMode(c) + const instance = await resolveRoomInstance( + c, + String(club.clubhouseRoomId), + joinMode === 2, + id + ) + if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + await enterRoom(c, id, instance) + return c.json({ errorCode: 0, roomInstance: instance }) + } + ) + // Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}` // — the client uses this to enter a room's other scenes). The subroom decides the // scene the client loads and which instances are joinable, so it must be carried diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index e724239..1581057 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -108,6 +108,40 @@ beforeAll(async () => { insertAccount.bind(JSON.stringify({ accountId: 42, username: 'Tester' })), insertAccount.bind(JSON.stringify({ accountId: 43, username: 'Roomie' })), ]) + + // Club tables (owned by the clubs worker) — matchmake/club reads the clubhouse + // room and the caller's membership from them. + await env.DB.prepare( + `CREATE TABLE IF NOT EXISTS club ( + data TEXT NOT NULL, + club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL + )` + ).run() + await env.DB.prepare( + `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 + )` + ).run() + const insertClub = env.DB.prepare('INSERT OR IGNORE INTO club (data) VALUES (?1)') + await env.DB.batch([ + // Club 4 has room 2 as its clubhouse; club 5 has none set. + insertClub.bind(JSON.stringify({ ClubId: 4, Name: 'Clubbers', ClubhouseRoomId: 2 })), + insertClub.bind(JSON.stringify({ ClubId: 5, Name: 'Homeless', ClubhouseRoomId: null })), + ]) + const insertMember = env.DB.prepare( + 'INSERT INTO club_member (club_id, account_id, membership_type) VALUES (?1, ?2, ?3)' + ) + await env.DB.batch([ + insertMember.bind(4, 120, 100), // creator + insertMember.bind(4, 121, 10), // member + insertMember.bind(4, 122, 1), // pending request — not a member yet + insertMember.bind(4, 123, -1), // banned + insertMember.bind(5, 120, 100), + ]) }) // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the @@ -309,6 +343,58 @@ describe('public endpoints', () => { expect(unknown).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE }) }) + test('POST /matchmake/club/:clubId places members into the clubhouse', async () => { + const matchmake = async (path: string, sub?: string) => + exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { + ...(sub === undefined ? {} : await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'JoinMode=0', + }) + type Body = { + errorCode: number + roomInstance: { roomId: number; location: string; roomInstanceId: number } | null + } + + // A member lands in an instance of the club's clubhouse (room 2)... + const res = await matchmake('/matchmake/club/4', '121') + expect(res.status).toBe(200) + const body = (await res.json()) as Body + expect(body.errorCode).toBe(0) + expect(body.roomInstance).toMatchObject({ roomId: 2, location: RECCENTER_SCENE }) + + // ...and it's recorded as their presence, like any other matchmake. + const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1') + .bind(121) + .first<{ data: string }>() + const presence = JSON.parse(row!.data) as { roomInstance: { roomInstanceId: number } } + expect(presence.roomInstance.roomInstanceId).toBe(body.roomInstance!.roomInstanceId) + + // The creator is a member too, and joins the same public instance. + const creator = (await (await matchmake('/matchmake/club/4', '120')).json()) as Body + expect(creator.roomInstance?.roomInstanceId).toBe(body.roomInstance!.roomInstanceId) + + // Everyone who isn't a member is turned away with the same answer: a non-member, + // a pending request, a banned account, a club with no clubhouse, an unknown club. + for (const [path, sub] of [ + ['/matchmake/club/4', '199'], + ['/matchmake/club/4', '122'], + ['/matchmake/club/4', '123'], + ['/matchmake/club/5', '120'], + ['/matchmake/club/9999', '120'], + ] as const) { + expect(await (await matchmake(path, sub)).json()).toEqual({ + errorCode: 20, + roomInstance: null, + }) + } + + // Signed out is a 401, not a matchmaking error. + expect((await matchmake('/matchmake/club/4')).status).toBe(401) + }) + test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => { const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, { method: 'POST', @@ -896,6 +982,7 @@ describe('auth-gated endpoints', () => { 'GET /rooms/requiring/rrplus', 'POST /goto/none', 'POST /goto/room/{room}', + 'POST /matchmake/club/{clubId}', 'POST /matchmake/none', 'POST /matchmake/room/{roomId}', 'POST /matchmake/room/{roomId}/{subRoomId}', diff --git a/packages/domain/src/clubs-db.ts b/packages/domain/src/clubs-db.ts new file mode 100644 index 0000000..9a0bd24 --- /dev/null +++ b/packages/domain/src/clubs-db.ts @@ -0,0 +1,64 @@ +/** + * Cross-worker *reads* of the club tables. The `clubs` worker owns the schema and + * every write (see apps/clubs/src/clubs-db.ts); this is the narrow view other + * workers need — right now `match`, which has to know a club's clubhouse room and + * whether the player asking for it is actually a member. + * + * Deliberately read-only, and deliberately small: clubs are a JSON blob in + * `club.data`, so anything that needs the whole DTO should go through the clubs + * worker's API rather than growing this file into a second copy of its model. + */ + +/** + * A player's membership state in a club (mirror of the clubs worker's + * `ClubMembershipType`). Only the values other workers reason about are named here; + * the tiers between are just higher numbers. + */ +export const CLUB_MEMBERSHIP_BANNED = -1 +export const CLUB_MEMBERSHIP_NONE = 0 +/** At/above this, a row is an actual member rather than pending/banned. */ +export const CLUB_MEMBERSHIP_MEMBER = 10 + +/** The club fields other workers read. */ +export interface ClubSummary { + clubId: number + name: string + /** The room players spawn into for this club; null when it has no clubhouse. */ + clubhouseRoomId: number | null +} + +/** Look up a club's name and clubhouse room. Null when there's no such club. */ +export async function getClubSummary(db: D1Database, clubId: number): Promise { + const row = await db + .prepare( + `SELECT json_extract(data, '$.Name') AS name, + json_extract(data, '$.ClubhouseRoomId') AS clubhouseRoomId + FROM club WHERE club_id = ?1` + ) + .bind(clubId) + .first<{ name: string | null; clubhouseRoomId: number | null }>() + if (row === null) return null + return { clubId, name: row.name ?? '', clubhouseRoomId: row.clubhouseRoomId } +} + +/** A player's membership type in a club (0 = no row, i.e. not a member). */ +export async function getClubMembership( + 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 ?? CLUB_MEMBERSHIP_NONE +} + +/** 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 getClubMembership(db, clubId, accountId)) >= CLUB_MEMBERSHIP_MEMBER +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index f29375a..40dbd36 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,5 +1,6 @@ export { RoomInstanceType, Accessibility, Role } from './enums' export * from './accounts-db' +export * from './clubs-db' export * from './password' export * from './rooms-db' export * from './room-instance-db'