mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[clubs] subscribers WIP
This commit is contained in:
+144
-27
@@ -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<App>): Promise<number | null> {
|
||||
*/
|
||||
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<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// 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<App>()
|
||||
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<App>()
|
||||
(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(
|
||||
'/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<App>()
|
||||
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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<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 () => {
|
||||
@@ -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',
|
||||
|
||||
@@ -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<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. */
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user