mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
add club invite
This commit is contained in:
@@ -942,3 +942,24 @@ export async function leaveClub(
|
|||||||
const count = await syncMemberCount(db, clubId)
|
const count = await syncMemberCount(db, clubId)
|
||||||
return { result: 'left', club: { ...club, MemberCount: count } }
|
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,
|
searchClubs,
|
||||||
setClubAdditionalImage,
|
setClubAdditionalImage,
|
||||||
setHomeClub,
|
setHomeClub,
|
||||||
|
setMemberType,
|
||||||
updateClub,
|
updateClub,
|
||||||
} from './clubs-db'
|
} from './clubs-db'
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
form,
|
form,
|
||||||
HomeClubRequest,
|
HomeClubRequest,
|
||||||
ImageNameRequest,
|
ImageNameRequest,
|
||||||
|
InviteMemberRequest,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
MinLevelRequest,
|
MinLevelRequest,
|
||||||
@@ -102,6 +104,17 @@ const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
|
|||||||
/** Longest a club name may be (the reference's MaxNameLength). */
|
/** Longest a club name may be (the reference's MaxNameLength). */
|
||||||
const MAX_CLUB_NAME_LENGTH = 16
|
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. */
|
/** The punctuation a club name may use, on top of letters and digits. */
|
||||||
const ALLOWED_NAME_PUNCTUATION = new Set(` .,'!?-_&()#@:+`)
|
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
|
// 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
|
// 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
|
// 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'),
|
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. */
|
/** `POST /announcements/club/:clubId` form body. */
|
||||||
export const AnnouncementRequest = z.object({
|
export const AnnouncementRequest = z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
|||||||
@@ -1187,6 +1187,72 @@ describe('clubs endpoints', () => {
|
|||||||
expect((await request(99999, '830')).status).toBe(404)
|
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 () => {
|
test('members/leave drops a membership and withdraws a pending request', async () => {
|
||||||
const create = async (sub: string, fields: Record<string, string>) =>
|
const create = async (sub: string, fields: Record<string, string>) =>
|
||||||
(
|
(
|
||||||
@@ -1363,6 +1429,7 @@ describe('clubs endpoints', () => {
|
|||||||
'PUT /club/{clubId}/additionalimage/{index}',
|
'PUT /club/{clubId}/additionalimage/{index}',
|
||||||
'PUT /club/{clubId}/clubhouse',
|
'PUT /club/{clubId}/clubhouse',
|
||||||
'PUT /club/{clubId}/mainimage',
|
'PUT /club/{clubId}/mainimage',
|
||||||
|
'PUT /club/{clubId}/members/invite',
|
||||||
'PUT /club/{clubId}/members/requesttojoin',
|
'PUT /club/{clubId}/members/requesttojoin',
|
||||||
'PUT /club/{clubId}/minlevel',
|
'PUT /club/{clubId}/minlevel',
|
||||||
'PUT /club/{clubId}/modify',
|
'PUT /club/{clubId}/modify',
|
||||||
|
|||||||
Reference in New Issue
Block a user