[clubs] subscribers WIP

This commit is contained in:
Devin Zuczek
2026-09-24 14:09:42 -04:00
parent 13b80f9748
commit a1e89d6948
4 changed files with 614 additions and 54 deletions
+144 -27
View File
@@ -8,6 +8,7 @@ import {
ClubMembershipType, ClubMembershipType,
ClubVisibility, ClubVisibility,
countClubsByCreator, countClubsByCreator,
countClubSubscribers,
createClub, createClub,
createClubAnnouncement, createClubAnnouncement,
deleteClub, deleteClub,
@@ -19,6 +20,9 @@ import {
getClubsByMember, getClubsByMember,
getHomeClub, getHomeClub,
getMembership, getMembership,
getOrCreateSubscriptionClub,
getSubscriptionClub,
getSubscriptionMemberships,
glyphLength, glyphLength,
joinClub, joinClub,
leaveClub, leaveClub,
@@ -30,6 +34,7 @@ import {
setClubAdditionalImage, setClubAdditionalImage,
setHomeClub, setHomeClub,
setMemberType, setMemberType,
subscribeToClub,
updateClub, updateClub,
} from '@repo/domain' } from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
@@ -62,7 +67,11 @@ import {
ModifyClubRequest, ModifyClubRequest,
NullEnvelope, NullEnvelope,
SubscriberCountResponse, SubscriberCountResponse,
SubscribeRequest,
SubscriptionDetailsResponse, SubscriptionDetailsResponse,
SubscriptionErrorEnvelope,
SubscriptionIdEnvelope,
SubscriptionMembershipsResponse,
UNAUTHORIZED_RESPONSE, UNAUTHORIZED_RESPONSE,
} from './openapi' } 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: * 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), * 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 * 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 * home club, plus subscriptions. A creator's subscribers are the members of their
* `/subscription/*` routes are stubs, since there are no subscription clubs yet. * 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. * Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
*/ */
@@ -104,7 +116,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
*/ */
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10 const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
/** /**
* The tiers `members/invite` may grant — the real member roles only. Creator (100) is * 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 * excluded so an invite can't mint a second owner, and the pending/none/banned states
@@ -310,33 +321,56 @@ const app = new Hono<App>()
} }
) )
// A real Rec Room client endpoint with no backing implementation yet. The // The caller's subscriptions — the subscription clubs they're a member of, one row
// client calls it on the clubs host at /subscription/mine/member (no /club // each with the creator, the club and its subscriber count; the subscription-club
// prefix) and sends no auth header, so it isn't gated. Returns an empty // counterpart of `/club/mine/member`. The client calls it on the clubs host with no
// array = no club subscription memberships (the client chokes on null). // /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( .get(
'/subscription/mine/member', '/subscription/mine/member',
describeRoute({ describeRoute({
tags: ['Subscriptions'], tags: ['Subscriptions'],
summary: 'The caller’s club-subscription memberships', summary: 'The caller’s subscriptions',
description: [ description: [
'A real client endpoint with no backing implementation yet. The client calls it on', 'The subscription clubs the caller is a member of (`POST /subscription/{accountId}`),',
'the clubs host at `/subscription/mine/member` (no `/club` prefix) and sends no auth', 'oldest first — the creator, the club and its subscriber count; the subscription-club',
'header, so it isn’t gated. Always `[]` — no subscription memberships (the client', 'counterpart of `/club/mine/member`. A creator is a member of their own subscription',
'chokes on null).', '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(' '), ].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( .get(
'/subscription/details/:accountId{[0-9]+}', '/subscription/details/:accountId{[0-9]+}',
describeRoute({ describeRoute({
tags: ['Subscriptions'], tags: ['Subscriptions'],
summary: 'Subscription details for an account', 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: [ parameters: [
{ {
name: 'accountId', name: 'accountId',
@@ -346,14 +380,17 @@ const app = new Hono<App>()
schema: { type: 'string' }, schema: { type: 'string' },
}, },
], ],
responses: { 200: json(SubscriptionDetailsResponse, 'Zeroed subscription details') }, responses: { 200: json(SubscriptionDetailsResponse, 'The account’s subscription details') },
}), }),
(c) => async (c) => {
c.json({ const accountId = Number.parseInt(c.req.param('accountId'), 10)
accountId: Number.parseInt(c.req.param('accountId'), 10), const club = await getOrCreateSubscriptionClub(c.env.DB, accountId)
clubId: 0, return c.json({
subscriberCount: 0, 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 // Details for a named subscription (e.g. `rrplus`). The client deserializes this
@@ -381,13 +418,85 @@ const app = new Hono<App>()
(c) => c.json({}) (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<string, string | undefined> = {
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( .get(
'/subscription/subscriberCount/:accountId{[0-9]+}', '/subscription/subscriberCount/:accountId{[0-9]+}',
describeRoute({ describeRoute({
tags: ['Subscriptions'], tags: ['Subscriptions'],
summary: 'Subscriber count for an account', 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: [ parameters: [
{ {
name: 'accountId', name: 'accountId',
@@ -397,9 +506,15 @@ const app = new Hono<App>()
schema: { type: 'string' }, 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- // 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),', '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', 'search, announcements, the club gallery and clubhouse room, and each player’s home',
'club. Everything is D1-backed on the shared `recflare` database; the', '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', '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', 'ones the client re-renders a club screen from carry the club’s FULL details as',
+55 -4
View File
@@ -245,11 +245,52 @@ export const ClubSearchResponse = z.object({
TotalClubs: z.int().describe('How many clubs matched, not the page size'), 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({ export const SubscriptionDetailsResponse = z.object({
accountId: z.int(), accountId: z.int(),
clubId: z.int().describe('Always 0 — no subscription clubs yet'), clubId: z
subscriberCount: z.int().describe('Always 0'), .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. */ /** 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. */ /** `GET /subscription/subscriberCount/:accountId` — a bare JSON integer. */
export const SubscriberCountResponse = z export const SubscriberCountResponse = z
.int() .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 * `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'), 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. */ /** `PUT /club/:clubId/minlevel` form body. */
export const MinLevelRequest = z.object({ export const MinLevelRequest = z.object({
minLevel: z.string().describe('The minimum player level to join; negative/NaN is 400'), minLevel: z.string().describe('The minimum player level to join; negative/NaN is 400'),
+244 -16
View File
@@ -84,29 +84,256 @@ describe('clubs endpoints', () => {
expect(res.status).toBe(404) 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 () => { test('GET /subscription/details/:subscription returns an empty object', async () => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/rrplus`) const res = await exports.default.fetch(`${ORIGIN}/subscription/details/rrplus`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({}) expect(await res.json()).toEqual({})
}) })
test('GET /subscription/details/:accountId returns simulated details', async () => { describe('subscriptions', () => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/2`) // A creator's subscribers are the members of their SUBSCRIPTION CLUB (ClubType 1).
expect(res.status).toBe(200) // Nothing provisions those yet, so the tests seed them the way the eventual
expect(await res.json()).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 }) // 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 () => { type Envelope = { value: number | null; success: boolean; errorId: null; error: string | null }
const res = await exports.default.fetch(`${ORIGIN}/subscription/subscriberCount/2`) type Membership = { AccountId: number; ClubId: number; SubscriberCount: number }
expect(res.status).toBe(200) type Details = { accountId: number; clubId: number; subscriberCount: number }
expect(await res.json()).toBe(0)
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<Details> => {
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<number> => {
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<string, string> = {}): Promise<Membership[]> => {
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<void> => {
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 () => { test('GET /announcements/v2/mine/unread returns []', async () => {
@@ -1441,6 +1668,7 @@ describe('clubs endpoints', () => {
'POST /club/{clubId}/join', 'POST /club/{clubId}/join',
'POST /club/{clubId}/leave', 'POST /club/{clubId}/leave',
'POST /club/{clubId}/members/leave', 'POST /club/{clubId}/members/leave',
'POST /subscription/{accountId}',
'PUT /club/home/me', 'PUT /club/home/me',
'PUT /club/{clubId}/additionalimage/{index}', 'PUT /club/{clubId}/additionalimage/{index}',
'PUT /club/{clubId}/clubhouse', 'PUT /club/{clubId}/clubhouse',
+171 -7
View File
@@ -16,6 +16,7 @@
* {@link getClubSummary} and gates entry on {@link isClubMember}. * {@link getClubSummary} and gates entry on {@link isClubMember}.
*/ */
import { getAccount } from './accounts-db'
import { getSavedImagesByNames, placeholderSavedImage } from './images-db' import { getSavedImagesByNames, placeholderSavedImage } from './images-db'
import type { SavedImage } 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). */ /** Membership types at/above which a row counts as an actual member (not pending/banned). */
const MEMBER_THRESHOLD = ClubMembershipType.Member 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 * 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 * 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 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<Club | null> {
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<ClubRow>()
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<Club | null> {
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<number> {
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<SubscribeResult | null> {
const club = await getClub(db, clubId)
if (!club) return null
const membershipId = async (): Promise<number | null> => {
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<SubscriptionMembership[]> {
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. */ /** What club search answers: the page of clubs plus the total that matched. */
export interface ClubSearchResult { export interface ClubSearchResult {
Clubs: Club[] Clubs: Club[]
@@ -856,13 +1027,6 @@ export async function getMostActiveClubhouses(
return results 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 * 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 * clubs don't count — they're provisioned for a creator's subscribers rather than