diff --git a/apps/clubs/src/clubs-db.ts b/apps/clubs/src/clubs-db.ts index 2fff3ca..c0c4d0f 100644 --- a/apps/clubs/src/clubs-db.ts +++ b/apps/clubs/src/clubs-db.ts @@ -942,3 +942,24 @@ export async function leaveClub( const count = await syncMemberCount(db, clubId) return { result: 'left', club: { ...club, MemberCount: count } } } + +/** + * Set an account's membership tier in a club — the invite / role-assignment write + * behind `PUT /club/:id/members/invite`. Upserts the `club_member` row to + * `membershipType` (adding the account when it wasn't a member, and overriding a prior + * tier or ban), then refreshes the club's MemberCount. Returns the club with its fresh + * count, or null when the club is gone. The caller is responsible for checking that the + * tier is one it may grant and that the target isn't the club's Creator. + */ +export async function setMemberType( + db: D1Database, + clubId: number, + accountId: number, + membershipType: ClubMembershipType +): Promise { + const club = await getClub(db, clubId) + if (!club) return null + await setMembership(db, clubId, accountId, membershipType) + const count = await syncMemberCount(db, clubId) + return { ...club, MemberCount: count } +} diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index 0d7b8e8..6adc848 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -29,6 +29,7 @@ import { searchClubs, setClubAdditionalImage, setHomeClub, + setMemberType, updateClub, } from './clubs-db' import { @@ -51,6 +52,7 @@ import { form, HomeClubRequest, ImageNameRequest, + InviteMemberRequest, json, JsonArray, MinLevelRequest, @@ -102,6 +104,17 @@ const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10 /** Longest a club name may be (the reference's MaxNameLength). */ const MAX_CLUB_NAME_LENGTH = 16 +/** + * 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 + * aren't something you "invite" someone to. + */ +const INVITABLE_TIERS: ReadonlySet = new Set([ + ClubMembershipType.Member, + ClubMembershipType.Moderator, + ClubMembershipType.Coowner, +]) + /** The punctuation a club name may use, on top of letters and digits. */ const ALLOWED_NAME_PUNCTUATION = new Set(` .,'!?-_&()#@:+`) @@ -1287,6 +1300,87 @@ const app = new Hono() } ) + // Invite an account into the club at a given tier — the co-owner's "add member" / + // role-assignment write. `accountId` is who to add and `membershipType` the tier they + // get (10 Member, 20 Moderator, 30 Co-owner); the client sends both as form fields. + // Co-owner or above only. The membership is upserted, so this also promotes/demotes an + // existing member and overrides a ban — but it can't mint another Creator (100) and it + // can't touch the club's own Creator. Answers the details envelope, like the other + // membership writes. + .put( + '/club/:clubId{[0-9]+}/members/invite', + describeRoute({ + tags: ['Membership'], + summary: 'Invite an account into the club', + description: [ + 'Adds `accountId` to the club at `membershipType` (10 Member, 20 Moderator, 30', + 'Co-owner) — the co-owner’s “add member” / role-assignment write; both arrive as form', + 'fields, and an absent `membershipType` defaults to Member. Co-owner or above only. The', + 'membership is upserted, so this also promotes/demotes an existing member and overrides', + 'a ban; it can’t mint another Creator (100) or change the club’s own Creator. Answers the', + 'details envelope, like the other membership writes.', + ].join(' '), + security: AUTHED, + parameters: [CLUB_ID_PARAM], + requestBody: form(InviteMemberRequest, 'The account to add and the tier to grant'), + responses: { + 200: json(ClubDetailsEnvelope, 'The club’s details after the invite'), + 400: json( + ErrorEnvelope, + 'Missing/invalid accountId, a tier outside Member/Moderator/Co-owner, or targeting the creator' + ), + 401: UNAUTHORIZED_RESPONSE, + 403: json(ErrorEnvelope, 'Below co-owner'), + 404: { description: 'No such club' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const field = (name: string): string | undefined => { + const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) + const v = key === undefined ? undefined : body[key] + return typeof v === 'string' ? v : undefined + } + + const accountId = Number.parseInt(field('accountId') ?? '', 10) + if (Number.isNaN(accountId) || accountId <= 0) return clubError(c, 'Invalid accountId.') + + // An absent tier means "add as a plain Member"; anything present must be one of the + // grantable roles (in particular not Creator), so an invite can't mint a second owner. + const rawType = field('membershipType') + const membershipType = + rawType === undefined || rawType.trim() === '' + ? ClubMembershipType.Member + : Number.parseInt(rawType, 10) + if (!INVITABLE_TIERS.has(membershipType)) return clubError(c, 'Invalid membershipType.') + + // The Creator is fixed — you can't demote them or promote someone over them. + if (accountId === club.CreatorAccountId) { + return clubError(c, 'You can’t change the club’s creator.') + } + + const updated = await setMemberType(c.env.DB, clubId, accountId, membershipType) + if (updated === null) return c.notFound() + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, updated, id), + }) + } + ) + // Leave a club. No body, like requesttojoin — the club id and the Bearer token are // the whole request. Idempotent (leaving a club you're not in is a no-op), and it // also withdraws a pending request; a ban is preserved, since you can't clear one diff --git a/apps/clubs/src/openapi.ts b/apps/clubs/src/openapi.ts index af989f7..2500998 100644 --- a/apps/clubs/src/openapi.ts +++ b/apps/clubs/src/openapi.ts @@ -329,6 +329,15 @@ export const ImageNameRequest = z.object({ imageName: z.string().describe('The image name the `storage` worker handed back'), }) +/** `PUT /club/:clubId/members/invite` form body. */ +export const InviteMemberRequest = z.object({ + accountId: z.string().describe('The account to add to the club; a positive integer'), + membershipType: z + .string() + .optional() + .describe('The tier to grant — 10 Member, 20 Moderator, 30 Co-owner; defaults to Member'), +}) + /** `POST /announcements/club/:clubId` form body. */ export const AnnouncementRequest = z.object({ title: z.string().optional(), diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index 2b95595..980d55a 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -1187,6 +1187,72 @@ describe('clubs endpoints', () => { expect((await request(99999, '830')).status).toBe(404) }) + test('PUT /club/:id/members/invite adds and promotes members, co-owner only', async () => { + type Member = { AccountId: number; MembershipType: number } + type Details = { + error: string + success: boolean + value: { Club: { MemberCount: number } } | null + } + const create = await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { ...(await bearer('870')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=Invitational', + }) + const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId + + const invite = async (fields: Record, sub = '870'): Promise => + exports.default.fetch(`${ORIGIN}/club/${clubId}/members/invite`, { + method: 'PUT', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(fields).toString(), + }) + const tiers = async (): Promise> => { + const res = await exports.default.fetch(`${ORIGIN}/club/${clubId}/members`) + const body = (await res.json()) as { value: Member[] } + return new Map(body.value.map((m) => [m.AccountId, m.MembershipType])) + } + + // The client's exact request: add account 872 as a Member (10). + const added = await invite({ accountId: '872', membershipType: '10' }) + expect(added.status).toBe(200) + const addedBody = (await added.json()) as Details + expect(addedBody).toMatchObject({ error: '', success: true }) + expect(addedBody.value?.Club.MemberCount).toBe(2) // creator + 872 + expect((await tiers()).get(872)).toBe(10) + + // Inviting an existing member at a higher tier promotes them in place. + expect((await invite({ accountId: '872', membershipType: '20' })).status).toBe(200) + expect((await tiers()).get(872)).toBe(20) + + // membershipType defaults to Member when omitted. + await invite({ accountId: '873' }) + expect((await tiers()).get(873)).toBe(10) + + // Can't mint another creator, can't touch the creator, needs a valid accountId — and + // a rejected invite writes nothing. + expect((await invite({ accountId: '874', membershipType: '100' })).status).toBe(400) + expect((await invite({ accountId: '870', membershipType: '30' })).status).toBe(400) + expect((await invite({ accountId: 'abc' })).status).toBe(400) + expect((await tiers()).has(874)).toBe(false) + + // A non-co-owner can't invite (872 is a moderator now, still below co-owner); signed + // out is a 401; an unknown club 404s. + expect((await invite({ accountId: '875' }, '872')).status).toBe(403) + const anon = await exports.default.fetch(`${ORIGIN}/club/${clubId}/members/invite`, { + method: 'PUT', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'accountId=875', + }) + expect(anon.status).toBe(401) + const missing = await exports.default.fetch(`${ORIGIN}/club/99999/members/invite`, { + method: 'PUT', + headers: { ...(await bearer('870')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'accountId=875', + }) + expect(missing.status).toBe(404) + }) + test('members/leave drops a membership and withdraws a pending request', async () => { const create = async (sub: string, fields: Record) => ( @@ -1363,6 +1429,7 @@ describe('clubs endpoints', () => { 'PUT /club/{clubId}/additionalimage/{index}', 'PUT /club/{clubId}/clubhouse', 'PUT /club/{clubId}/mainimage', + 'PUT /club/{clubId}/members/invite', 'PUT /club/{clubId}/members/requesttojoin', 'PUT /club/{clubId}/minlevel', 'PUT /club/{clubId}/modify',