mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add club invite
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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-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<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 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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user