add club invite

This commit is contained in:
Devin Zuczek
2026-07-23 15:50:56 -04:00
parent a400de4a8b
commit 95386c5de3
4 changed files with 191 additions and 0 deletions
+21
View File
@@ -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<Club | null> {
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 }
}
+94
View File
@@ -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<number> = 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<App>()
}
)
// 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-owners “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 cant mint another Creator (100) or change the clubs 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 clubs 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<string, unknown>
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 cant change the clubs 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
+9
View File
@@ -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(),
@@ -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<string, string>, sub = '870'): Promise<Response> =>
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<Map<number, number>> => {
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<string, string>) =>
(
@@ -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',