diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index 1f2b80a..1cf7f3a 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -8,6 +8,7 @@ import { ClubMembershipType, ClubVisibility, countClubsByCreator, + countClubSubscribers, createClub, createClubAnnouncement, deleteClub, @@ -19,6 +20,9 @@ import { getClubsByMember, getHomeClub, getMembership, + getOrCreateSubscriptionClub, + getSubscriptionClub, + getSubscriptionMemberships, glyphLength, joinClub, leaveClub, @@ -30,6 +34,7 @@ import { setClubAdditionalImage, setHomeClub, setMemberType, + subscribeToClub, updateClub, } from '@repo/domain' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' @@ -62,7 +67,11 @@ import { ModifyClubRequest, NullEnvelope, SubscriberCountResponse, + SubscribeRequest, SubscriptionDetailsResponse, + SubscriptionErrorEnvelope, + SubscriptionIdEnvelope, + SubscriptionMembershipsResponse, UNAUTHORIZED_RESPONSE, } from './openapi' @@ -73,8 +82,11 @@ import type { App } from './context' * Clubs Worker. Hosts the club endpoints the game client calls on the `clubs` host: * club creation and editing, membership (join / ask-to-join / leave / ban tiers), * search, announcements, the club gallery, a club's clubhouse room, and each player's - * home club. Everything is D1-backed (the shared `recflare` database); the - * `/subscription/*` routes are stubs, since there are no subscription clubs yet. + * home club, plus subscriptions. A creator's subscribers are the members of their + * SUBSCRIPTION CLUB (`ClubType` 1, one per account, reached only through + * `/subscription/*`): subscribing is joining it. Everything is D1-backed (the shared + * `recflare` database). A subscription club is provisioned by the first + * `/subscription/details/:accountId` read or `POST /subscription/:accountId` for that account. * * Auth-gated routes validate the Bearer JWT issued by the `auth` worker. */ @@ -104,7 +116,6 @@ async function authedId(c: Context): Promise { */ const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10 - /** * The tiers `members/invite` may grant — the real member roles only. Creator (100) is * excluded so an invite can't mint a second owner, and the pending/none/banned states @@ -310,33 +321,56 @@ const app = new Hono() } ) - // 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 - // array = no club subscription memberships (the client chokes on null). + // The caller's subscriptions — the subscription clubs they're a member of, one row + // each with the creator, the club and its subscriber count; the subscription-club + // counterpart of `/club/mine/member`. The client calls it on the clubs host with no + // /club prefix, and has been seen calling it WITHOUT an auth header: a missing or + // invalid token answers `[]` rather than 401, since the client chokes on null and + // this is not worth failing a launch over. .get( '/subscription/mine/member', describeRoute({ tags: ['Subscriptions'], - summary: 'The caller’s club-subscription memberships', + summary: 'The caller’s subscriptions', description: [ - 'A real 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. Always `[]` — no subscription memberships (the client', - 'chokes on null).', + 'The subscription clubs the caller is a member of (`POST /subscription/{accountId}`),', + 'oldest first — the creator, the club and its subscriber count; the subscription-club', + 'counterpart of `/club/mine/member`. A creator is a member of their own subscription', + 'club, so it is listed for them too. The client has been seen calling this without', + 'an auth header, so a missing or invalid token answers `[]` rather than 401 (the', + 'client chokes on null).', ].join(' '), - responses: { 200: json(JsonArray, 'Always empty for now') }, + security: AUTHED, + responses: { + 200: json( + SubscriptionMembershipsResponse, + 'The caller’s subscriptions; `[]` when unauthenticated' + ), + }, }), - (c) => c.json([]) + async (c) => { + const id = await authedId(c) + if (id === null) return c.json([]) + return c.json(await getSubscriptionMemberships(c.env.DB, id)) + } ) - // Subscription details for an account (numeric id) — simulated: no club, no subs. + // Subscription details for an account: their subscription club and its subscriber + // count. This is where subscription clubs come from — the first read for an account + // that has none PROVISIONS it (named after the creator, with them as its Creator-tier + // member), so the client's "subscribe" button always has a club to post to. Only a + // real account gets one; an unknown id reads as club 0. Public. .get( '/subscription/details/:accountId{[0-9]+}', describeRoute({ tags: ['Subscriptions'], summary: 'Subscription details for an account', - description: 'Simulated — no subscription club, no subscribers.', + description: [ + 'The account’s subscription club and its subscriber count (the creator not counted).', + 'The first read for an account with no subscription club PROVISIONS one — a', + '`ClubType` 1 club named after the creator — so `clubId` is only 0 for an id that', + 'isn’t an account. Public.', + ].join(' '), parameters: [ { name: 'accountId', @@ -346,14 +380,17 @@ const app = new Hono() schema: { type: 'string' }, }, ], - responses: { 200: json(SubscriptionDetailsResponse, 'Zeroed subscription details') }, + responses: { 200: json(SubscriptionDetailsResponse, 'The account’s subscription details') }, }), - (c) => - c.json({ - accountId: Number.parseInt(c.req.param('accountId'), 10), - clubId: 0, - subscriberCount: 0, + async (c) => { + const accountId = Number.parseInt(c.req.param('accountId'), 10) + const club = await getOrCreateSubscriptionClub(c.env.DB, accountId) + return c.json({ + accountId, + clubId: club?.ClubId ?? 0, + subscriberCount: club ? await countClubSubscribers(c.env.DB, club.ClubId) : 0, }) + } ) // Details for a named subscription (e.g. `rrplus`). The client deserializes this @@ -381,13 +418,85 @@ const app = new Hono() (c) => c.json({}) ) - // Subscriber count for an account. No club subscriptions yet → 0. + // Subscribe to a creator: the caller joins the SUBSCRIPTION CLUB of the account + // `accountId` — the `ClubType` 1 club provisioned for them (here too, if nobody has + // read their `details` yet) — becoming a Member of it outright. Idempotent: + // resubscribing answers the existing membership's id. Refused for one's own club and + // for a banned account; an id that isn't an account is a 404. The `roomId` form field + // (the room the subscribe was posted from) is accepted and unused. The envelope is the + // reference's, with `error` NULL on success rather than the club routes' `""`; `value` + // is the membership row's id, which is a guess at what the reference's opaque id was. + .post( + '/subscription/:accountId{[0-9]+}', + describeRoute({ + tags: ['Subscriptions'], + summary: 'Subscribe to a creator', + description: [ + 'The caller joins the subscription club of the account `accountId` as a Member,', + 'provisioning the club first if that account has none yet. Idempotent: subscribing', + 'again answers the existing membership’s id. Refused for the caller’s own club and', + 'for a banned account; an id that isn’t an account is a 404. `roomId` is accepted', + 'and unused. Note the envelope is the reference’s — `error` and `errorId` are null', + 'on success, not `""`.', + ].join(' '), + security: AUTHED, + parameters: [ + { + name: 'accountId', + in: 'path', + required: true, + description: 'The creator to subscribe to (digits only)', + schema: { type: 'string' }, + }, + ], + requestBody: form(SubscribeRequest, 'The room the subscribe was posted from'), + responses: { + 200: json(SubscriptionIdEnvelope, 'The subscription’s id'), + 400: json( + SubscriptionErrorEnvelope, + 'The caller’s own club, or the caller is banned from it' + ), + 401: UNAUTHORIZED_RESPONSE, + 404: { description: 'No such account' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const club = await getOrCreateSubscriptionClub( + c.env.DB, + Number.parseInt(c.req.param('accountId'), 10) + ) + if (club === null) return c.notFound() + const result = await subscribeToClub(c.env.DB, club.ClubId, id) + if (result === null) return c.notFound() + + const refusal: Record = { + notSubscriptionClub: 'That is not a subscription club.', + own: 'You cannot subscribe to yourself.', + banned: 'You cannot subscribe to this creator.', + } + const message = refusal[result.outcome] + if (message !== undefined) { + return c.json({ value: null, success: false, errorId: null, error: message }, 400) + } + return c.json({ value: result.membershipId ?? 0, success: true, errorId: null, error: null }) + } + ) + + // Subscriber count for an account: their subscription club's members, the creator + // not counted. Public, a bare JSON integer; an account with no subscription club yet + // (nobody has read its `details` or subscribed) has 0 — this read doesn't provision one. .get( '/subscription/subscriberCount/:accountId{[0-9]+}', describeRoute({ tags: ['Subscriptions'], summary: 'Subscriber count for an account', - description: 'No club subscriptions yet, so this is always 0. A bare JSON integer.', + description: [ + 'How many subscribers the account’s subscription club has, its creator not counted.', + 'Public. A bare JSON integer; an account with no subscription club has 0.', + ].join(' '), parameters: [ { name: 'accountId', @@ -397,9 +506,15 @@ const app = new Hono() schema: { type: 'string' }, }, ], - responses: { 200: json(SubscriberCountResponse, 'Always 0') }, + responses: { 200: json(SubscriberCountResponse, 'The subscriber count') }, }), - (c) => c.json(0) + async (c) => { + const club = await getSubscriptionClub( + c.env.DB, + Number.parseInt(c.req.param('accountId'), 10) + ) + return c.json(club ? await countClubSubscribers(c.env.DB, club.ClubId) : 0) + } ) // The player's clubs that have unread announcements (MyClubsWithUnread- @@ -1566,7 +1681,9 @@ app.get( 'membership (join / ask-to-join / leave, with the ban and pending tiers),', 'search, announcements, the club gallery and clubhouse room, and each player’s home', 'club. Everything is D1-backed on the shared `recflare` database; the', - '`/subscription/*` routes are stubs, since there are no subscription clubs yet.', + 'A creator’s subscribers are the members of their subscription club (`ClubType` 1,', + 'one per account, provisioned by the first `details` read or subscribe for that account);', + 'the `/subscription/*` routes read and join those.', '', 'Most writes answer the `{ error, success, value }` envelope with HTTP 200, and the', 'ones the client re-renders a club screen from carry the club’s FULL details as', diff --git a/apps/clubs/src/openapi.ts b/apps/clubs/src/openapi.ts index c88c3e2..c00ad95 100644 --- a/apps/clubs/src/openapi.ts +++ b/apps/clubs/src/openapi.ts @@ -245,11 +245,52 @@ export const ClubSearchResponse = z.object({ TotalClubs: z.int().describe('How many clubs matched, not the page size'), }) -/** `GET /subscription/details/:accountId` — simulated: no club, no subscribers. */ +/** + * `GET /subscription/mine/member` — one of the caller's subscriptions (recovered from + * the client's decoder: `AccountId` int, `ClubId` long, `SubscriberCount` int). + */ +export const SubscriptionMembershipDto = z.object({ + AccountId: z.int().describe('The creator subscribed to (the subscription club’s creator)'), + ClubId: z.int().describe('Their subscription club'), + SubscriberCount: z.int().describe('How many subscribers that club has, its creator not counted'), +}) + +/** `GET /subscription/mine/member` — the caller's subscriptions, oldest first. */ +export const SubscriptionMembershipsResponse = z.array(SubscriptionMembershipDto) + +/** `GET /subscription/details/:accountId` — an account's subscription club and its size. */ export const SubscriptionDetailsResponse = z.object({ accountId: z.int(), - clubId: z.int().describe('Always 0 — no subscription clubs yet'), - subscriberCount: z.int().describe('Always 0'), + clubId: z + .int() + .describe( + 'The account’s subscription club, provisioned by this read or a subscribe; 0 only for a non-account' + ), + subscriberCount: z.int().describe('Its subscribers, the creator not counted'), +}) + +/** + * `POST /subscription/:accountId` — the reference's envelope, which differs from the club + * one: `error` (and `errorId`) are NULL on success rather than `""`. `value` is the + * subscription's id. + */ +export const SubscriptionIdEnvelope = z.object({ + value: z + .int() + .describe( + 'The subscription’s id — the club membership row (the existing one when already subscribed)' + ), + success: z.boolean(), + errorId: z.null(), + error: z.null(), +}) + +/** A rejected subscribe: the same envelope, carrying the message. */ +export const SubscriptionErrorEnvelope = z.object({ + value: z.null(), + success: z.boolean().describe('Always false'), + errorId: z.null(), + error: z.string().describe('The message shown to the player'), }) /** The set of category tags a club can be filed under — a fixed list. */ @@ -258,7 +299,9 @@ export const CategoryTags = z.array(z.string()) /** `GET /subscription/subscriberCount/:accountId` — a bare JSON integer. */ export const SubscriberCountResponse = z .int() - .describe('Always 0 — there are no club subscriptions yet') + .describe( + 'The account’s subscription club’s subscribers, its creator not counted; 0 without a club' + ) /** * `GET /club/:clubId/hasDisabledClubChat` — a bare JSON boolean, like the other @@ -317,6 +360,14 @@ export const HomeClubRequest = z.object({ clubId: z.string().describe('The club to make home; the caller must be a member of it'), }) +/** `POST /subscription/:accountId` — the room the player is subscribing from. */ +export const SubscribeRequest = z.object({ + roomId: z + .string() + .optional() + .describe('The room the subscribe was posted from. Accepted and unused — nothing stores it'), +}) + /** `PUT /club/:clubId/minlevel` form body. */ export const MinLevelRequest = z.object({ minLevel: z.string().describe('The minimum player level to join; negative/NaN is 400'), diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index 3f1f98e..44c3ad3 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -84,29 +84,256 @@ describe('clubs endpoints', () => { expect(res.status).toBe(404) }) - test('GET /subscription/mine/member returns an empty array without a token', async () => { - // The client calls this on the clubs host with no /club prefix and no auth. - const res = await exports.default.fetch(`${ORIGIN}/subscription/mine/member`) - expect(res.status).toBe(200) - expect(await res.json()).toEqual([]) - }) - test('GET /subscription/details/:subscription returns an empty object', async () => { const res = await exports.default.fetch(`${ORIGIN}/subscription/details/rrplus`) expect(res.status).toBe(200) expect(await res.json()).toEqual({}) }) - test('GET /subscription/details/:accountId returns simulated details', async () => { - const res = await exports.default.fetch(`${ORIGIN}/subscription/details/2`) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 }) - }) + describe('subscriptions', () => { + // A creator's subscribers are the members of their SUBSCRIPTION CLUB (ClubType 1). + // Nothing provisions those yet, so the tests seed them the way the eventual + // provisioner will: a ClubType 1 club with the creator as its Creator-tier member. + let clubId = 0 + const CREATOR = 8800 + beforeAll(async () => { + const idRow = await env.DB.prepare( + 'SELECT COALESCE(MAX(club_id), 0) + 1 AS next FROM club' + ).first<{ + next: number + }>() + clubId = idRow?.next ?? 1 + await env.DB.prepare('INSERT INTO club (data) VALUES (?1)') + .bind( + JSON.stringify({ + ClubId: clubId, + Name: 'Player8800 subscribers', + Description: '', + Category: '', + Visibility: 1, + Joinability: 0, + AllowJuniors: true, + MainImageName: '', + ClubType: 1, + ClubhouseRoomId: null, + CreatorAccountId: CREATOR, + IsRRO: false, + MinLevel: 0, + State: 0, + MemberCount: 1, + CreatedAt: '2026-07-01T00:00:00Z', + }) + ) + .run() + await env.DB.prepare( + 'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, ?2, ?3, ?4)' + ) + .bind(clubId, CREATOR, 100, '2026-07-01T00:00:00Z') + .run() + }) - test('GET /subscription/subscriberCount/:id returns 0', async () => { - const res = await exports.default.fetch(`${ORIGIN}/subscription/subscriberCount/2`) - expect(res.status).toBe(200) - expect(await res.json()).toBe(0) + type Envelope = { value: number | null; success: boolean; errorId: null; error: string | null } + type Membership = { AccountId: number; ClubId: number; SubscriberCount: number } + type Details = { accountId: number; clubId: number; subscriberCount: number } + + const subscribe = async (sub: string, id: number | string = CREATOR, body = 'roomId=78') => + exports.default.fetch(`${ORIGIN}/subscription/${id}`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + const details = async (accountId: number): Promise
=> { + const res = await exports.default.fetch(`${ORIGIN}/subscription/details/${accountId}`) + expect(res.status).toBe(200) + return (await res.json()) as Details + } + const count = async (accountId: number): Promise => { + const res = await exports.default.fetch(`${ORIGIN}/subscription/subscriberCount/${accountId}`) + expect(res.status).toBe(200) + return (await res.json()) as number + } + const mine = async (headers: Record = {}): Promise => { + const res = await exports.default.fetch(`${ORIGIN}/subscription/mine/member`, { headers }) + expect(res.status).toBe(200) + return (await res.json()) as Membership[] + } + + test('an id that isn’t an account reads as club 0 and never gets one', async () => { + expect(await details(2)).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 }) + expect(await details(2)).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 }) + expect(await count(2)).toBe(0) + const { results } = await env.DB.prepare( + 'SELECT club_id FROM club WHERE creator_account_id = 2' + ).all() + expect(results).toEqual([]) + }) + + test('the first details read for an account provisions its subscription club', async () => { + // Account 9101 exists (seeded) but has no subscription club: the count read + // doesn't make one … + expect(await count(9101)).toBe(0) + expect( + await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 9101').first() + ).toBeNull() + + // … the details read does, and every read after agrees on it. + const first = await details(9101) + expect(first.clubId).toBeGreaterThan(0) + expect(first).toEqual({ accountId: 9101, clubId: first.clubId, subscriberCount: 0 }) + expect(await details(9101)).toEqual(first) + expect(await count(9101)).toBe(0) + + // A ClubType 1 club named after the creator, Private, Open, with the creator as its + // Creator-tier (and only) member; out of the creator's own club lists and search. + const club = (await env.DB.prepare('SELECT data FROM club WHERE club_id = ?1') + .bind(first.clubId) + .first<{ data: string }>())! + expect(JSON.parse(club.data)).toMatchObject({ + ClubId: first.clubId, + Name: 'Player9101', + ClubType: 1, + Visibility: 0, + Joinability: 0, + CreatorAccountId: 9101, + MemberCount: 1, + }) + const { results: members } = await env.DB.prepare( + 'SELECT account_id, membership_type FROM club_member WHERE club_id = ?1' + ) + .bind(first.clubId) + .all() + expect(members).toEqual([{ account_id: 9101, membership_type: 100 }]) + expect(await mine(await bearer('9101'))).toEqual([ + { AccountId: 9101, ClubId: first.clubId, SubscriberCount: 0 }, + ]) + const created = await exports.default.fetch(`${ORIGIN}/club/mine/created`, { + headers: await bearer('9101'), + }) + expect(await created.json()).toEqual([]) + const search = await exports.default.fetch(`${ORIGIN}/club/search?query=Player9101`) + expect(((await search.json()) as { Clubs: unknown[] }).Clubs).toEqual([]) + + // Subscribing to the creator lands in the provisioned club. + expect((await subscribe('9100', 9101)).status).toBe(200) + expect(await details(9101)).toEqual({ + accountId: 9101, + clubId: first.clubId, + subscriberCount: 1, + }) + }) + + test('a fresh subscription club has its creator as a member but no subscribers', async () => { + expect(await details(CREATOR)).toEqual({ accountId: CREATOR, clubId, subscriberCount: 0 }) + expect(await count(CREATOR)).toBe(0) + // The creator is a Creator-tier member of their own club, so it's listed for them. + expect(await mine(await bearer(String(CREATOR)))).toEqual([ + { AccountId: CREATOR, ClubId: clubId, SubscriberCount: 0 }, + ]) + }) + + test('GET /subscription/mine/member answers [] without a token', async () => { + // The client has been seen calling this with no auth header: `[]`, not 401 (it + // chokes on null). A garbage token is the same; so is a player with none. + expect(await mine()).toEqual([]) + expect(await mine({ Authorization: 'Bearer nope' })).toEqual([]) + expect(await mine(await bearer('8801'))).toEqual([]) + }) + + test('POST /subscription/:accountId joins the creator’s subscription club and the reads follow', async () => { + // Auth-gated. + const anon = await exports.default.fetch(`${ORIGIN}/subscription/${CREATOR}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'roomId=78', + }) + expect(anon.status).toBe(401) + + // The reference envelope: `error`/`errorId` null on success, `value` an id. + const first = await subscribe('8801') + expect(first.status).toBe(200) + const created = (await first.json()) as Envelope + expect(created).toMatchObject({ success: true, errorId: null, error: null }) + expect(created.value).toBeGreaterThan(0) + + // The subscriber is a plain Member of the club; the club's count moved. + const row = await env.DB.prepare( + 'SELECT club_member_id, membership_type FROM club_member WHERE club_id = ?1 AND account_id = 8801' + ) + .bind(clubId) + .first<{ club_member_id: number; membership_type: number }>() + expect(row).toEqual({ club_member_id: created.value, membership_type: 10 }) + + // Idempotent: subscribing again (from another room, in the other casing) answers + // the same id and nothing doubles. + const again = await subscribe('8801', CREATOR, 'RoomId=99') + expect(again.status).toBe(200) + expect(((await again.json()) as Envelope).value).toBe(created.value) + + // A second subscriber; every read agrees, and the creator isn't counted. + expect((await subscribe('8802', CREATOR, '')).status).toBe(200) + expect(await count(CREATOR)).toBe(2) + expect(await details(CREATOR)).toEqual({ accountId: CREATOR, clubId, subscriberCount: 2 }) + expect(await mine(await bearer('8801'))).toEqual([ + { AccountId: CREATOR, ClubId: clubId, SubscriberCount: 2 }, + ]) + expect(await mine(await bearer('8802'))).toEqual([ + { AccountId: CREATOR, ClubId: clubId, SubscriberCount: 2 }, + ]) + + // The club's MemberCount counts the creator too, and the subscription club stays + // out of the ordinary my-clubs lists. + const club = await env.DB.prepare('SELECT data FROM club WHERE club_id = ?1') + .bind(clubId) + .first<{ data: string }>() + expect((JSON.parse(club?.data ?? '{}') as { MemberCount: number }).MemberCount).toBe(3) + const listed = await exports.default.fetch(`${ORIGIN}/club/mine/member`, { + headers: await bearer('8801'), + }) + expect(await listed.json()).toEqual([]) + }) + + test('POST /subscription/:accountId refuses the creator and the banned, 404s a non-account', async () => { + const refused = async (res: Response, error: string): Promise => { + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ value: null, success: false, errorId: null, error }) + } + await refused(await subscribe(String(CREATOR)), 'You cannot subscribe to yourself.') + + // A ban sticks. + await env.DB.prepare( + 'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, 8804, -1, ?2)' + ) + .bind(clubId, '2026-07-01T00:00:00Z') + .run() + await refused(await subscribe('8804'), 'You cannot subscribe to this creator.') + + // An id that isn't an account is a 404 and mints no club; a non-numeric id + // doesn't match the route. + expect((await subscribe('8801', 999999)).status).toBe(404) + expect( + await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 999999').first() + ).toBeNull() + expect((await subscribe('8801', 'abc')).status).toBe(404) + }) + + test('subscribing to an account with no club yet provisions it', async () => { + // Account 9100 exists (seeded) and nobody has read its details. + expect( + await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 9100').first() + ).toBeNull() + const res = await subscribe('8801', 9100) + expect(res.status).toBe(200) + expect(((await res.json()) as Envelope).success).toBe(true) + + const d = await details(9100) + expect(d.clubId).toBeGreaterThan(0) + expect(d).toEqual({ accountId: 9100, clubId: d.clubId, subscriberCount: 1 }) + expect(await mine(await bearer('8801'))).toContainEqual({ + AccountId: 9100, + ClubId: d.clubId, + SubscriberCount: 1, + }) + }) }) test('GET /announcements/v2/mine/unread returns []', async () => { @@ -1441,6 +1668,7 @@ describe('clubs endpoints', () => { 'POST /club/{clubId}/join', 'POST /club/{clubId}/leave', 'POST /club/{clubId}/members/leave', + 'POST /subscription/{accountId}', 'PUT /club/home/me', 'PUT /club/{clubId}/additionalimage/{index}', 'PUT /club/{clubId}/clubhouse', diff --git a/packages/domain/src/clubs-db.ts b/packages/domain/src/clubs-db.ts index f7e60f0..0199576 100644 --- a/packages/domain/src/clubs-db.ts +++ b/packages/domain/src/clubs-db.ts @@ -16,6 +16,7 @@ * {@link getClubSummary} and gates entry on {@link isClubMember}. */ +import { getAccount } from './accounts-db' import { getSavedImagesByNames, placeholderSavedImage } from './images-db' import type { SavedImage } from './images-db' @@ -99,6 +100,13 @@ export enum ClubJoinability { /** Membership types at/above which a row counts as an actual member (not pending/banned). */ const MEMBER_THRESHOLD = ClubMembershipType.Member +/** + * 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" + * lists (the client reaches them through the `/subscription/*` endpoints instead). + */ +const SUBSCRIPTION_CLUB_TYPE = 1 + /** * 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 @@ -444,6 +452,169 @@ export async function createClubAnnouncement( return row?.announcement_id ?? 0 } +// ---- Subscriptions --------------------------------------------------------- +// +// Rec Room had no per-player "follow": a creator's subscribers are the members of a +// SUBSCRIPTION CLUB (`ClubType` 1) provisioned for that creator, one per account, which +// the client reaches only through `/subscription/*`. Subscribing is joining that club; +// the subscriber count is its membership minus the creator, who is its Creator-tier +// member but not their own subscriber. + +/** + * A creator's subscription club — the `ClubType` 1 club they created — or null when + * none has been provisioned for them (nothing creates them yet). + */ +export async function getSubscriptionClub(db: D1Database, accountId: number): Promise { + const row = await db + .prepare( + `SELECT data FROM club + WHERE creator_account_id = ?1 + AND json_extract(data, '$.ClubType') = ?2 + ORDER BY json_extract(data, '$.CreatedAt') ASC + LIMIT 1` + ) + .bind(accountId, SUBSCRIPTION_CLUB_TYPE) + .first() + return row ? toDto(JSON.parse(row.data) as StoredClub) : null +} + +/** + * The account's subscription club, provisioned on first sight: when `accountId` has + * none, one is created for them — a `ClubType` 1 club named after the creator, Private + * (it is reached only through `/subscription/*`; search excludes the type anyway) and + * Open (subscribers join outright), with the creator as its Creator-tier member. Null + * when no such account exists, so an unknown id can't mint clubs. Two simultaneous first + * reads can create two clubs; {@link getSubscriptionClub} then always serves the oldest, + * so the second is inert. + */ +export async function getOrCreateSubscriptionClub( + db: D1Database, + accountId: number +): Promise { + const existing = await getSubscriptionClub(db, accountId) + if (existing) return existing + const account = await getAccount(db, accountId) + if (!account) return null + return createClub(db, accountId, { + name: account.username, + clubType: SUBSCRIPTION_CLUB_TYPE, + visibility: ClubVisibility.Private, + joinability: ClubJoinability.Open, + }) +} + +/** + * How many subscribers a subscription club has: its members at/above `Member`, not + * counting the club's creator. Any club id works; one with no members simply has 0. + */ +export async function countClubSubscribers(db: D1Database, clubId: number): Promise { + const row = await db + .prepare( + `SELECT COUNT(*) AS n + FROM club_member m + JOIN club c ON c.club_id = m.club_id + WHERE m.club_id = ?1 AND m.membership_type >= ?2 + AND m.account_id != c.creator_account_id` + ) + .bind(clubId, MEMBER_THRESHOLD) + .first<{ n: number }>() + return row?.n ?? 0 +} + +/** + * How subscribing resolved. `subscribed` is a new membership, `alreadySubscribed` a + * repeat (idempotent); `notSubscriptionClub` is a club id that isn't a subscription + * club, `own` the creator subscribing to themselves, `banned` a banned account. + * `membershipId` is the `club_member` row's id when there is one. + */ +export interface SubscribeResult { + outcome: 'subscribed' | 'alreadySubscribed' | 'notSubscriptionClub' | 'own' | 'banned' + club: Club + membershipId: number | null +} + +/** + * Subscribe `accountId` to a subscription club: they become a `Member` of it outright + * (a subscription club's Joinability is not consulted — there is no approval flow for + * subscribers). Idempotent for an existing subscriber, refused for the club's creator + * and for a banned account. Null when the club doesn't exist. + */ +export async function subscribeToClub( + db: D1Database, + clubId: number, + accountId: number +): Promise { + const club = await getClub(db, clubId) + if (!club) return null + const membershipId = async (): Promise => { + const row = await db + .prepare('SELECT club_member_id FROM club_member WHERE club_id = ?1 AND account_id = ?2') + .bind(clubId, accountId) + .first<{ club_member_id: number }>() + return row?.club_member_id ?? null + } + + if (club.ClubType !== SUBSCRIPTION_CLUB_TYPE) { + return { outcome: 'notSubscriptionClub', club, membershipId: null } + } + if (club.CreatorAccountId === accountId) return { outcome: 'own', club, membershipId: null } + + const current = await getMembership(db, clubId, accountId) + if (current === ClubMembershipType.Banned) return { outcome: 'banned', club, membershipId: null } + if (current >= MEMBER_THRESHOLD) { + return { outcome: 'alreadySubscribed', club, membershipId: await membershipId() } + } + + await setMembership(db, clubId, accountId, ClubMembershipType.Member) + const count = await syncMemberCount(db, clubId) + return { + outcome: 'subscribed', + club: { ...club, MemberCount: count }, + membershipId: await membershipId(), + } +} + +/** + * One of the caller's subscriptions as `GET /subscription/mine/member` serves it: the + * creator subscribed to, their subscription club, and its subscriber count. + */ +export interface SubscriptionMembership { + AccountId: number + ClubId: number + SubscriberCount: number +} + +/** + * The subscription clubs `accountId` is a member of (Member tier or above — the + * subscription-club counterpart of {@link getClubsByMember}), oldest club first, each + * with its current subscriber count. A creator is a Creator-tier member of their own + * subscription club, so it is listed for them too. + */ +export async function getSubscriptionMemberships( + db: D1Database, + accountId: number +): Promise { + const { results } = await db + .prepare( + `SELECT c.club_id AS club_id, c.creator_account_id AS creator_account_id, + (SELECT COUNT(*) FROM club_member s + WHERE s.club_id = c.club_id AND s.membership_type >= ?2 + AND s.account_id != c.creator_account_id) AS subscriber_count + FROM club_member m + JOIN club c ON c.club_id = m.club_id + WHERE m.account_id = ?1 AND m.membership_type >= ?2 + AND json_extract(c.data, '$.ClubType') = ?3 + ORDER BY json_extract(c.data, '$.CreatedAt') ASC, c.club_id ASC` + ) + .bind(accountId, MEMBER_THRESHOLD, SUBSCRIPTION_CLUB_TYPE) + .all<{ club_id: number; creator_account_id: number; subscriber_count: number }>() + return results.map((r) => ({ + AccountId: r.creator_account_id, + ClubId: r.club_id, + SubscriberCount: r.subscriber_count, + })) +} + /** What club search answers: the page of clubs plus the total that matched. */ export interface ClubSearchResult { Clubs: Club[] @@ -856,13 +1027,6 @@ export async function getMostActiveClubhouses( return results } -/** - * 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" - * lists (the client reaches them through the `/subscription/*` endpoints instead). - */ -const SUBSCRIPTION_CLUB_TYPE = 1 - /** * How many clubs an account has made, for the per-account club cap. Subscription * clubs don't count — they're provisioned for a creator's subscribers rather than