mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[rooms] roles
This commit is contained in:
@@ -203,8 +203,13 @@ export enum MatchmakingErrorCode {
|
||||
* and Creator are the owner-level tiers that may manage the room (see
|
||||
* {@link canManageRoom}). Creator is the room's owner (its `CreatorAccountId`) —
|
||||
* the max byte.
|
||||
*
|
||||
* `None` is a real value on the wire, not a placeholder: it is the `Role` on an entry that
|
||||
* holds only a pending `InvitedRole` (invited, nothing granted yet), the `InvitedRole` on an
|
||||
* entry with nothing pending, and what the invited player posts back to DECLINE an invite.
|
||||
*/
|
||||
export enum Role {
|
||||
None = 0,
|
||||
Host = 10,
|
||||
Moderator = 20,
|
||||
CoOwner = 30,
|
||||
|
||||
@@ -19,6 +19,7 @@ export * from './stats-db'
|
||||
export * from './gifts-db'
|
||||
export * from './inventory-invention-db'
|
||||
export * from './lists-db'
|
||||
export * from './notifications-db'
|
||||
export * from './outfits-db'
|
||||
export * from './progression-db'
|
||||
export * from './relationships-db'
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Stored notifications on the shared `recflare` D1 database — one row per notification a
|
||||
* player has been sent, as `GET /api/messages/v2/get` serves them back on login.
|
||||
*
|
||||
* A notification is a WebSocket push, and this table is the delayed half of one. The live
|
||||
* half is the `MessageReceived` frame the hub pushes, and for a long time it was the ONLY
|
||||
* half: the inbox endpoint answered an empty list unconditionally, so anything sent to an
|
||||
* offline player existed solely as a queued frame in the hub's Durable Object. That queue is
|
||||
* best-effort by construction — dropped once sent rather than acked, bounded per player, and
|
||||
* skipped entirely whenever the hub believes it delivered (a socket the client has already
|
||||
* abandoned still counts as a delivery) — so a push could be lost with nothing left to show
|
||||
* it ever existed. A row here outlives all of that: the player reads it on next login
|
||||
* whether or not the socket ever carried it.
|
||||
*
|
||||
* The two halves are tied together by the ID. A stored notification's `Id` is the id carried
|
||||
* on the frame that delivers it, so the client can recognise the push it received as the one
|
||||
* it then reads from the inbox, and `POST /api/messages/v3/delete` has an id to match. A
|
||||
* sender therefore WRITES THE ROW FIRST and builds its frame from the returned record —
|
||||
* never the other way round, and never with an id of its own invention.
|
||||
*
|
||||
* Not everything pushed belongs here. Only what is still worth reading LATER: a direct
|
||||
* message, a room-role invite, a coach message to one player. A broadcast (a maintenance
|
||||
* notice to everyone online at once) would be a row per player for something nobody re-reads;
|
||||
* a vote-to-kick prompt is about a session that has since ended; a cheer already has its
|
||||
* durable record in the `reputation` counters.
|
||||
*
|
||||
* The table is `notification`, NOT `message`: the `chat` worker already owns a `message`
|
||||
* table on this same shared database (its thread posts, a different thing entirely). The two
|
||||
* were distinguishable only by name, and `CREATE TABLE IF NOT EXISTS` against a name that is
|
||||
* already taken does nothing and says nothing — so the first version of this migration
|
||||
* quietly adopted chat's table and failed only one statement later, on an index naming a
|
||||
* column that table doesn't have. Every worker here shares one database; check a table name
|
||||
* against the whole repo before taking it.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (`apps/api/migrations/0019_notification.sql`,
|
||||
* applied under its own `migrations_table` so it doesn't clash with the other workers'
|
||||
* migrations that share the database). `NOTIFICATION_SCHEMA_DDL` mirrors that migration so
|
||||
* tests can build the table directly.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How many stored notifications one player keeps, newest-wins.
|
||||
*
|
||||
* Bounded for the same reason the hub's pending queue is: an inbox nothing ever deletes grows
|
||||
* with every notification the server sends, and the read then loads the lot. The client shows
|
||||
* an inbox rather than an archive, and a player returning to a screenful of them is served no
|
||||
* better by the one below it.
|
||||
*/
|
||||
export const MAX_NOTIFICATIONS_PER_PLAYER = 20
|
||||
|
||||
/** Schema DDL (mirror of apps/api/migrations/0019_notification.sql). */
|
||||
export const NOTIFICATION_SCHEMA_DDL: string[] = [
|
||||
// `notification_id` is AUTOINCREMENT rather than a bare rowid alias: it is handed to the
|
||||
// client (on the frame and in the inbox) and deleting a notification frees its rowid, so a
|
||||
// reused id would point a client's stale row at somebody else's.
|
||||
//
|
||||
// `data` is nullable because a Message's `Data` is: the types that carry no payload of
|
||||
// their own send null, distinct from the empty string a DM sends. Where it is set it is
|
||||
// always a STRING, whatever it means to that type (a room-role invite puts the offered
|
||||
// role tier in it). `room_id`/`player_event_id` are the optional context a notification
|
||||
// can name.
|
||||
//
|
||||
// `sent_time` is an ISO-8601 UTC string, which is fixed-width and so orders correctly
|
||||
// under SQLite's plain string comparison — though reads order by `notification_id`, which
|
||||
// is monotonic and doesn't tie when two land in the same millisecond.
|
||||
`CREATE TABLE IF NOT EXISTS notification (
|
||||
notification_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_player_id INTEGER NOT NULL,
|
||||
to_player_id INTEGER NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
data TEXT,
|
||||
room_id INTEGER,
|
||||
player_event_id INTEGER,
|
||||
sent_time TEXT NOT NULL
|
||||
)`,
|
||||
// The inbox read and the newest-wins trim are both "this player's notifications, newest
|
||||
// first", which is the whole access pattern.
|
||||
`CREATE INDEX IF NOT EXISTS idx_notification_recipient ON notification (to_player_id, notification_id DESC)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* One stored notification, in the shape the client reads it — the client's Message model,
|
||||
* which is also what the `MessageReceived` frame carries (see the `notify` worker's
|
||||
* `MessageReceivedPayload`). Same shape in the inbox and on the wire, deliberately: they are
|
||||
* one notification delivered two ways, and the client parses it with the same decoder either
|
||||
* way. Hence `Id` here rather than a spelled-out `NotificationId` — the field names belong to
|
||||
* the client, not to this table.
|
||||
*/
|
||||
export interface StoredNotification {
|
||||
Id: number
|
||||
FromPlayerId: number
|
||||
ToPlayerId: number
|
||||
/** ISO-8601 UTC. */
|
||||
SentTime: string
|
||||
Type: number
|
||||
Data: string | null
|
||||
RoomId: number | null
|
||||
PlayerEventId: number | null
|
||||
}
|
||||
|
||||
/** What a sender supplies; the rest of a {@link StoredNotification} is minted here. */
|
||||
export interface NewNotification {
|
||||
FromPlayerId: number
|
||||
ToPlayerId: number
|
||||
Type: number
|
||||
Data?: string | null
|
||||
RoomId?: number | null
|
||||
PlayerEventId?: number | null
|
||||
}
|
||||
|
||||
interface NotificationRow {
|
||||
notification_id: number
|
||||
from_player_id: number
|
||||
to_player_id: number
|
||||
type: number
|
||||
data: string | null
|
||||
room_id: number | null
|
||||
player_event_id: number | null
|
||||
sent_time: string
|
||||
}
|
||||
|
||||
const SELECT_COLUMNS = `notification_id, from_player_id, to_player_id, type, data, room_id, player_event_id, sent_time`
|
||||
|
||||
const toNotification = (row: NotificationRow): StoredNotification => ({
|
||||
Id: row.notification_id,
|
||||
FromPlayerId: row.from_player_id,
|
||||
ToPlayerId: row.to_player_id,
|
||||
SentTime: row.sent_time,
|
||||
Type: row.type,
|
||||
Data: row.data,
|
||||
RoomId: row.room_id,
|
||||
PlayerEventId: row.player_event_id,
|
||||
})
|
||||
|
||||
/**
|
||||
* Store one notification and return it as the client reads it — including the `Id` the caller
|
||||
* must then put on the `MessageReceived` frame it pushes.
|
||||
*
|
||||
* Trims the recipient's inbox to {@link MAX_NOTIFICATIONS_PER_PLAYER} on the way, oldest
|
||||
* first, so the bound holds no matter how the inbox got long. Both statements go in one
|
||||
* batch: they are one logical write, and an insert whose trim didn't run would leave the
|
||||
* inbox over its bound until the next notification happened along.
|
||||
*/
|
||||
export async function createNotification(
|
||||
db: D1Database,
|
||||
notification: NewNotification
|
||||
): Promise<StoredNotification> {
|
||||
const [inserted] = await db.batch<NotificationRow>([
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO notification (from_player_id, to_player_id, type, data, room_id, player_event_id, sent_time)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
RETURNING ${SELECT_COLUMNS}`
|
||||
)
|
||||
.bind(
|
||||
notification.FromPlayerId,
|
||||
notification.ToPlayerId,
|
||||
notification.Type,
|
||||
notification.Data ?? null,
|
||||
notification.RoomId ?? null,
|
||||
notification.PlayerEventId ?? null,
|
||||
new Date().toISOString()
|
||||
),
|
||||
db
|
||||
.prepare(
|
||||
`DELETE FROM notification
|
||||
WHERE to_player_id = ?1 AND notification_id NOT IN (
|
||||
SELECT notification_id FROM notification WHERE to_player_id = ?1
|
||||
ORDER BY notification_id DESC LIMIT ?2
|
||||
)`
|
||||
)
|
||||
.bind(notification.ToPlayerId, MAX_NOTIFICATIONS_PER_PLAYER),
|
||||
])
|
||||
|
||||
return toNotification(inserted.results[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's inbox, newest first — everything sent TO them that they haven't deleted.
|
||||
*
|
||||
* Newest first because the client renders the list in the order it is given and the recent
|
||||
* ones are what is still worth acting on. Ordered by `notification_id` rather than
|
||||
* `sent_time`: ids are monotonic, so two that land in the same millisecond still have a
|
||||
* definite order.
|
||||
*/
|
||||
export async function getNotificationsForPlayer(
|
||||
db: D1Database,
|
||||
playerId: number
|
||||
): Promise<StoredNotification[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT ${SELECT_COLUMNS} FROM notification
|
||||
WHERE to_player_id = ?1 ORDER BY notification_id DESC LIMIT ?2`
|
||||
)
|
||||
.bind(playerId, MAX_NOTIFICATIONS_PER_PLAYER)
|
||||
.all<NotificationRow>()
|
||||
|
||||
return results.map(toNotification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete notifications from one player's inbox, returning how many rows went.
|
||||
*
|
||||
* Scoped to the recipient, not just the ids: an id names a notification globally, so deleting
|
||||
* on id alone would let anyone holding one clear somebody else's inbox. An id that isn't
|
||||
* theirs (or is already gone) simply matches nothing — the client removes its rows locally
|
||||
* and re-reads the list either way, so a partial match is not an error.
|
||||
*/
|
||||
export async function deleteNotifications(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
notificationIds: number[]
|
||||
): Promise<number> {
|
||||
if (notificationIds.length === 0) return 0
|
||||
|
||||
const placeholders = notificationIds.map((_, i) => `?${i + 2}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`DELETE FROM notification
|
||||
WHERE to_player_id = ?1 AND notification_id IN (${placeholders})
|
||||
RETURNING notification_id`
|
||||
)
|
||||
.bind(playerId, ...notificationIds)
|
||||
.all<{ notification_id: number }>()
|
||||
|
||||
return results.length
|
||||
}
|
||||
@@ -204,6 +204,33 @@ export async function getPlayerIdsInInstance(
|
||||
return results.map((r) => r.accountId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone standing in a ROOM right now, across all of its instances — the fan-out set for
|
||||
* something that changes the room itself rather than one session of it, such as a role
|
||||
* being accepted: every client in the room re-renders it, whichever instance they are in.
|
||||
*
|
||||
* The room-wide counterpart to {@link getPlayerIdsInInstance}, which is the narrower set
|
||||
* for something that happens in front of one session (a cheer). Reads only unexpired
|
||||
* presence, so it is LIVE occupancy — a player who crashed out drops off it when their row
|
||||
* lapses — and excludes lobby (null-instance) presence. Ordered by account id, and DISTINCT:
|
||||
* a stale row in another instance of the same room must not earn a second push.
|
||||
*/
|
||||
export async function getPlayerIdsInRoom(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
now = nowSeconds()
|
||||
): Promise<number[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT DISTINCT account_id AS accountId FROM presence
|
||||
WHERE room_id = ?1 AND expires_at > ?2 AND room_instance_id IS NOT NULL
|
||||
ORDER BY account_id`
|
||||
)
|
||||
.bind(roomId, now)
|
||||
.all<{ accountId: number }>()
|
||||
return results.map((r) => r.accountId)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many players are online right now, anywhere — one row per account, so this is
|
||||
* the player count a status page means. Counts unexpired presence only: rows outlive
|
||||
|
||||
+182
-14
@@ -184,13 +184,30 @@ export const SUBROOM_SCHEMA_DDL: string[] = [
|
||||
export type Room = Record<string, unknown>
|
||||
|
||||
/** A room role assignment (the client's RoomRole shape). */
|
||||
interface RoomRole {
|
||||
export interface RoomRole {
|
||||
AccountId: number
|
||||
Role: number
|
||||
LastChangedByAccountId: number | null
|
||||
InvitedRole: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A room's `Roles`, always as whole {@link RoomRole} records. Roles live inside the room
|
||||
* blob, and the older rooms either have no `Roles` key at all or carry entries written
|
||||
* before `LastChangedByAccountId`/`InvitedRole` existed; every reader wants the complete
|
||||
* shape, so the missing keys are filled in here rather than at each call site. Returns
|
||||
* copies, so a caller that edits an entry must write the returned array back to the room.
|
||||
*/
|
||||
export function roomRoles(room: Room): RoomRole[] {
|
||||
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Partial<RoomRole>>) : []
|
||||
return roles.map((r) => ({
|
||||
AccountId: Number(r.AccountId ?? 0),
|
||||
Role: Number(r.Role ?? Role.None),
|
||||
LastChangedByAccountId: r.LastChangedByAccountId ?? null,
|
||||
InvitedRole: Number(r.InvitedRole ?? Role.None),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Room roles that confer owner-level management of a room: Creator (255) and
|
||||
* CoOwner (30). The reference gates its room-admin actions on this set. (Host and
|
||||
@@ -207,8 +224,7 @@ const MANAGE_ROLES: ReadonlySet<number> = new Set([Role.Creator, Role.CoOwner])
|
||||
*/
|
||||
export function canManageRoom(room: Room, accountId: number): boolean {
|
||||
if (room.CreatorAccountId === accountId) return true
|
||||
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
|
||||
return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
|
||||
return roomRoles(room).some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,8 +239,19 @@ export function canManageRoom(room: Room, accountId: number): boolean {
|
||||
*/
|
||||
export function canModerateRoom(room: Room, accountId: number): boolean {
|
||||
if (room.CreatorAccountId === accountId) return true
|
||||
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
|
||||
return roles.some((r) => r.AccountId === accountId && r.Role >= Role.Moderator)
|
||||
return roomRoles(room).some((r) => r.AccountId === accountId && r.Role >= Role.Moderator)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account OWNS a room — its `CreatorAccountId`, or the holder of the Creator
|
||||
* (255) role. The narrowest of the three room gates: {@link canManageRoom} lets a CoOwner
|
||||
* through and {@link canModerateRoom} a Moderator, but handing out co-ownership is the
|
||||
* owner's alone, or a co-owner could quietly grow the set of people who can change the
|
||||
* room, an offer the invited player then accepts themselves.
|
||||
*/
|
||||
export function isRoomOwner(room: Room, accountId: number): boolean {
|
||||
if (room.CreatorAccountId === accountId) return true
|
||||
return roomRoles(room).some((r) => r.AccountId === accountId && r.Role === Role.Creator)
|
||||
}
|
||||
|
||||
/** A player banned from a room (a `room_ban` row). */
|
||||
@@ -414,7 +441,12 @@ export async function cloneRoom(
|
||||
// any co-owners, e.g. the seeded base-room roles for accounts 1/2) must NOT
|
||||
// carry over, or the clone would still list the template's owner as owner.
|
||||
const roles: RoomRole[] = [
|
||||
{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
{
|
||||
AccountId: accountId,
|
||||
Role: Role.Creator,
|
||||
LastChangedByAccountId: null,
|
||||
InvitedRole: Role.None,
|
||||
},
|
||||
]
|
||||
|
||||
const cloned: Room = {
|
||||
@@ -509,10 +541,37 @@ export async function updateRoomFields(
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a target account's room `Role` — updating their existing `Roles` entry or
|
||||
* appending a new one — and stamp `LastChangedByAccountId` with the editor. The
|
||||
* caller supplies the already-loaded room (after its owner/co-owner check) to avoid
|
||||
* a re-read; the whole room JSON is rewritten. Returns the updated room.
|
||||
* Whether a role tier can only be taken by INVITATION — offered with
|
||||
* {@link inviteRoomRole} and accepted by the player themselves via
|
||||
* {@link answerRoomRoleInvite} — rather than granted outright.
|
||||
*
|
||||
* {@link Role.CoOwner} is the tier this rule exists for: nobody can force-add a co-owner,
|
||||
* it is always an invite the player accepts. {@link Role.Creator} is included because it is
|
||||
* strictly more than co-ownership — it would make no sense for the bigger grant to be the
|
||||
* one that skips the ceremony. Expressed as `>=` so a tier added between or above them is
|
||||
* covered by default: a new high-privilege role should have to be accepted until someone
|
||||
* decides otherwise, not be grantable because nobody updated this list.
|
||||
*/
|
||||
export function roleRequiresInvite(role: number): boolean {
|
||||
return role >= Role.CoOwner
|
||||
}
|
||||
|
||||
/**
|
||||
* GRANT a room role outright — updating the target account's existing `Roles` entry or
|
||||
* appending one — and stamp `LastChangedByAccountId` with the editor. This is the owner's
|
||||
* half of role management, and it takes effect immediately: the helper tiers
|
||||
* ({@link Role.Host}, {@link Role.Moderator} and whatever else the client hands out) are the
|
||||
* owner's to give.
|
||||
*
|
||||
* Returns null, writing nothing, for a tier that {@link roleRequiresInvite} — nobody can
|
||||
* force-add a co-owner, whoever is asking. The rule lives HERE rather than in the route that
|
||||
* currently calls this, because it is an invariant of the room's `Roles` and not a policy of
|
||||
* one endpoint: the only way a `Role` of {@link Role.CoOwner} may ever appear on an entry is
|
||||
* {@link answerRoomRoleInvite} promoting an `InvitedRole` the room's owner offered.
|
||||
*
|
||||
* The caller supplies the already-loaded room (after its gate) to avoid a re-read; the whole
|
||||
* room JSON is rewritten. Any pending `InvitedRole` on the entry is left standing — a granted
|
||||
* Host with a co-owner invite open still has it to answer.
|
||||
*/
|
||||
export async function setRoomRole(
|
||||
db: D1Database,
|
||||
@@ -521,8 +580,10 @@ export async function setRoomRole(
|
||||
role: number,
|
||||
changedByAccountId: number,
|
||||
room: Room
|
||||
): Promise<Room> {
|
||||
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
|
||||
): Promise<Room | null> {
|
||||
if (roleRequiresInvite(role)) return null
|
||||
|
||||
const roles = roomRoles(room)
|
||||
const existing = roles.find((r) => r.AccountId === targetAccountId)
|
||||
if (existing) {
|
||||
existing.Role = role
|
||||
@@ -532,7 +593,109 @@ export async function setRoomRole(
|
||||
AccountId: targetAccountId,
|
||||
Role: role,
|
||||
LastChangedByAccountId: changedByAccountId,
|
||||
InvitedRole: 0,
|
||||
InvitedRole: Role.None,
|
||||
})
|
||||
}
|
||||
|
||||
const updated: Room = { ...room, Roles: roles }
|
||||
await db
|
||||
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
|
||||
.bind(roomId, serializeRoom(updated))
|
||||
.run()
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* ANSWER a standing room-role invite — accept it, or decline it with `role` 0.
|
||||
*
|
||||
* Accepting promotes the account's `Roles` entry from its pending `InvitedRole` to the real
|
||||
* `Role` and clears the pending one, so an accepted entry reads `Role: 30, InvitedRole: 0`
|
||||
* and the invite cannot be answered twice. `role` is what the ACCEPTING PLAYER asked for, and
|
||||
* it must equal the `InvitedRole` already on their entry. That equality is the whole security
|
||||
* of this call: the answer is made by the invited player themselves, so without it they could
|
||||
* answer an invite to Host by asking for Creator and award themselves the room. The invited
|
||||
* role is the server's own record of what the owner offered, written by
|
||||
* {@link inviteRoomRole}, and nothing the caller sends can move it.
|
||||
*
|
||||
* Declining (`role` 0 — not a real tier, so it can't be confused for one) REMOVES the entry
|
||||
* outright rather than just clearing `InvitedRole`: a declined invite should leave the room's
|
||||
* `Roles` as it was before the offer. Note that this takes any role the player already held
|
||||
* with it, so someone re-invited to a different tier who declines is left with nothing at
|
||||
* all. That is deliberate — an invite is answered as a whole — but it means a decline is not
|
||||
* a no-op for an existing member.
|
||||
*
|
||||
* Returns null — changing nothing — when there is no entry for the account, no invite
|
||||
* standing on it, or (when accepting) the requested role isn't the one offered. The three are
|
||||
* deliberately one answer: a caller probing for a higher role learns only that it didn't work.
|
||||
*
|
||||
* `LastChangedByAccountId` is left alone rather than restamped with the accepter: it names
|
||||
* who put this role on the entry, and that is the owner who offered it. The caller supplies
|
||||
* the already-loaded room to avoid a re-read; the whole room JSON is rewritten.
|
||||
*/
|
||||
export async function answerRoomRoleInvite(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
accountId: number,
|
||||
role: number,
|
||||
room: Room
|
||||
): Promise<Room | null> {
|
||||
const roles = roomRoles(room)
|
||||
const index = roles.findIndex((r) => r.AccountId === accountId)
|
||||
if (index === -1) return null
|
||||
|
||||
const entry = roles[index]
|
||||
// `Role.None` is "nothing pending" — the resting state of every entry, and what an
|
||||
// already-answered invite leaves behind, so neither answer can be replayed.
|
||||
if (entry.InvitedRole === Role.None) return null
|
||||
|
||||
if (role === Role.None) {
|
||||
roles.splice(index, 1)
|
||||
} else if (entry.InvitedRole !== role) {
|
||||
return null
|
||||
} else {
|
||||
entry.Role = entry.InvitedRole
|
||||
entry.InvitedRole = Role.None
|
||||
}
|
||||
|
||||
const updated: Room = { ...room, Roles: roles }
|
||||
await db
|
||||
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
|
||||
.bind(roomId, serializeRoom(updated))
|
||||
.run()
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* INVITE an account to a room role — setting `InvitedRole` on their `Roles` entry (adding
|
||||
* the entry when they have none) and stamping `LastChangedByAccountId` with the inviter.
|
||||
*
|
||||
* `InvitedRole` is the PENDING half of a role entry: an invited account holds it until
|
||||
* they accept, at which point {@link answerRoomRoleInvite} promotes it to the real `Role`.
|
||||
* So an entry added here starts at {@link Role.None} — no role yet, only an offer — and an
|
||||
* account who already holds a role keeps it while a higher one is pending.
|
||||
*
|
||||
* Like {@link answerRoomRoleInvite}, the caller supplies the already-loaded room to
|
||||
* avoid a re-read, and the whole room JSON is rewritten. Returns the updated room.
|
||||
*/
|
||||
export async function inviteRoomRole(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
targetAccountId: number,
|
||||
invitedRole: number,
|
||||
changedByAccountId: number,
|
||||
room: Room
|
||||
): Promise<Room> {
|
||||
const roles = roomRoles(room)
|
||||
const existing = roles.find((r) => r.AccountId === targetAccountId)
|
||||
if (existing) {
|
||||
existing.InvitedRole = invitedRole
|
||||
existing.LastChangedByAccountId = changedByAccountId
|
||||
} else {
|
||||
roles.push({
|
||||
AccountId: targetAccountId,
|
||||
Role: Role.None,
|
||||
LastChangedByAccountId: changedByAccountId,
|
||||
InvitedRole: invitedRole,
|
||||
})
|
||||
}
|
||||
const updated: Room = { ...room, Roles: roles }
|
||||
@@ -2915,7 +3078,12 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr
|
||||
CreatorAccountId: accountId,
|
||||
IsDorm: true,
|
||||
Roles: [
|
||||
{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
{
|
||||
AccountId: accountId,
|
||||
Role: Role.Creator,
|
||||
LastChangedByAccountId: null,
|
||||
InvitedRole: Role.None,
|
||||
},
|
||||
],
|
||||
// Counters start at zero rather than inheriting the template dorm's (see cloneRoom).
|
||||
Stats: storedStats(template?.Stats),
|
||||
|
||||
Reference in New Issue
Block a user