fix #19 clubhouses

This commit is contained in:
Devin Zuczek
2026-07-21 12:59:47 -04:00
parent d56fe3a276
commit 7f497fcee0
7 changed files with 691 additions and 21 deletions
+90 -4
View File
@@ -519,6 +519,17 @@ export async function setHomeClub(
.run()
}
/**
* Drop the player's home club (the field is removed from their account row, not set
* to 0 — `getHomeClub` reads a missing field as "no home club"). Idempotent.
*/
export async function clearHomeClub(db: D1Database, accountId: number): Promise<void> {
await db
.prepare("UPDATE account SET data = json_remove(data, '$.homeClubId') WHERE account_id = ?1")
.bind(accountId)
.run()
}
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
export interface ClubMember {
ClubMemberId: number
@@ -659,6 +670,30 @@ export async function getClub(db: D1Database, clubId: number): Promise<Club | nu
)
}
/**
* Delete a club and everything hanging off it — its memberships and announcements —
* and clear it from the home club of anyone who'd set it. Returns false when there
* was no such club. Batched so a half-deleted club can't be left behind.
*/
export async function deleteClub(db: D1Database, clubId: number): Promise<boolean> {
if ((await getClub(db, clubId)) === null) return false
await db.batch([
db.prepare('DELETE FROM club_member WHERE club_id = ?1').bind(clubId),
db.prepare('DELETE FROM club_announcement WHERE club_id = ?1').bind(clubId),
// The account table belongs to the auth worker; a dangling homeClubId already
// reads as "no home club" (getHomeClub), but leaving it would point at whatever
// club later reuses the id.
db
.prepare(
`UPDATE account SET data = json_remove(data, '$.homeClubId')
WHERE json_extract(data, '$.homeClubId') = ?1`
)
.bind(clubId),
db.prepare('DELETE FROM club WHERE club_id = ?1').bind(clubId),
])
return true
}
/**
* 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"
@@ -740,19 +775,70 @@ export async function joinClub(
return { ...club, MemberCount: count }
}
/**
* How a request to join resolved. `joined` is an Open club (no approval needed),
* `requested` an AskToJoin club (now PendingRequested), `alreadyPending` a repeat
* request, `alreadyMember` someone who's already in. `inviteOnly` and `banned` are
* refusals — the caller can't get in this way.
*/
export type JoinRequestResult =
'joined' | 'requested' | 'alreadyPending' | 'alreadyMember' | 'inviteOnly' | 'banned'
/**
* Ask to join a club. Unlike `joinClub` this honours the club's Joinability strictly:
* an InviteOnly club can only be entered through an invite, so a request is refused
* rather than parked as pending. Returns the outcome plus the club with its refreshed
* MemberCount, or null when the club doesn't exist.
*/
export async function requestToJoinClub(
db: D1Database,
clubId: number,
accountId: number
): Promise<{ result: JoinRequestResult; club: Club } | null> {
const club = await getClub(db, clubId)
if (!club) return null
const current = await getMembership(db, clubId, accountId)
// A ban can't be shed by asking again, and existing members/requests stay as-is.
if (current === ClubMembershipType.Banned) return { result: 'banned', club }
if (current >= MEMBER_THRESHOLD) return { result: 'alreadyMember', club }
if (current === ClubMembershipType.PendingRequested) return { result: 'alreadyPending', club }
if (club.Joinability === ClubJoinability.InviteOnly) return { result: 'inviteOnly', club }
const open = club.Joinability === ClubJoinability.Open
await setMembership(
db,
clubId,
accountId,
open ? ClubMembershipType.Member : ClubMembershipType.PendingRequested
)
const count = await syncMemberCount(db, clubId)
return { result: open ? 'joined' : 'requested', club: { ...club, MemberCount: count } }
}
/**
* Remove `accountId`'s membership of a club (idempotent). A ban is preserved — you
* can't clear it by leaving — but any member/pending row is dropped. Returns the
* club with its refreshed MemberCount, or null when the club doesn't exist. The
* club itself is left in place even when the last member leaves.
* outcome plus the club with its refreshed MemberCount, or null when the club doesn't
* exist. The club itself is left in place even when the last member leaves.
*
* The creator can't leave: a club with no owner has no one who can administer it, and
* there's no ownership transfer, so they have to delete the club instead. `creator`
* reports that refusal, with the club unchanged.
*/
export async function leaveClub(
db: D1Database,
clubId: number,
accountId: number
): Promise<Club | null> {
): Promise<{ result: 'left' | 'creator'; club: Club } | null> {
const club = await getClub(db, clubId)
if (!club) return null
const current = await getMembership(db, clubId, accountId)
if (current === ClubMembershipType.Creator) return { result: 'creator', club }
await db
.prepare(
'DELETE FROM club_member WHERE club_id = ?1 AND account_id = ?2 AND membership_type <> ?3'
@@ -760,5 +846,5 @@ export async function leaveClub(
.bind(clubId, accountId, ClubMembershipType.Banned)
.run()
const count = await syncMemberCount(db, clubId)
return { ...club, MemberCount: count }
return { result: 'left', club: { ...club, MemberCount: count } }
}