deploy / deploy (push) Canceled after 0s
- DELETE /api/rooms/:id + destroy button in the header of human pages (participant and observer views), with confirmation - turn indicator now names and highlights the expected participant(s)
595 lines
22 KiB
TypeScript
595 lines
22 KiB
TypeScript
import { randomBytes, randomUUID } from 'node:crypto';
|
|
import type { DatabaseSync } from 'node:sqlite';
|
|
import {
|
|
AgreementRow,
|
|
ContractRevisionRow,
|
|
MessageRow,
|
|
ParticipantRow,
|
|
QuestionRow,
|
|
RoomRow,
|
|
Store,
|
|
} from './store.js';
|
|
import { LIMITS } from './limits.js';
|
|
import { availableActions, computeAdvice, computeTurn } from './advice.js';
|
|
import {
|
|
CreateRoomInput,
|
|
CreatedRoom,
|
|
MessageView,
|
|
ObserverView,
|
|
ParticipantView,
|
|
QuestionView,
|
|
RendezvousError,
|
|
RoomView,
|
|
} from './types.js';
|
|
|
|
function nowIso(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function newId(prefix: string): string {
|
|
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 20)}`;
|
|
}
|
|
|
|
/** Cryptographically random invite token; the token IS the authorization. */
|
|
export function newToken(): string {
|
|
return randomBytes(24).toString('base64url'); // 32 chars, 192 bits
|
|
}
|
|
|
|
export class RendezvousService {
|
|
private db: DatabaseSync;
|
|
|
|
constructor(readonly store: Store) {
|
|
this.db = store.db;
|
|
}
|
|
|
|
// ---------- room lifecycle ----------
|
|
|
|
createRoom(input: CreateRoomInput, baseUrl: string): CreatedRoom {
|
|
const title = (input.title ?? '').trim();
|
|
if (!title) throw new RendezvousError('validation', 'title is required');
|
|
if (Buffer.byteLength(title) > 2000) {
|
|
throw new RendezvousError('validation', 'title too long (max 2000 bytes)');
|
|
}
|
|
const ps = input.participants ?? [];
|
|
if (ps.length < LIMITS.minParticipants) {
|
|
throw new RendezvousError('validation', `at least ${LIMITS.minParticipants} participants required`);
|
|
}
|
|
if (ps.length > LIMITS.maxParticipants) {
|
|
throw new RendezvousError('validation', `at most ${LIMITS.maxParticipants} participants allowed`);
|
|
}
|
|
const roles = new Set<string>();
|
|
for (const p of ps) {
|
|
const role = (p.role ?? '').trim();
|
|
if (!role) throw new RendezvousError('validation', 'each participant needs a role');
|
|
if (Buffer.byteLength(role) > 200) {
|
|
throw new RendezvousError('validation', 'participant role too long');
|
|
}
|
|
if (roles.has(role)) {
|
|
throw new RendezvousError('validation', `duplicate participant role: ${role}`);
|
|
}
|
|
roles.add(role);
|
|
}
|
|
|
|
const ttl = input.ttl_hours ?? LIMITS.defaultTtlHours;
|
|
if (!Number.isFinite(ttl) || ttl <= 0 || ttl > LIMITS.maxTtlHours) {
|
|
throw new RendezvousError('validation', `ttl_hours must be between 1 and ${LIMITS.maxTtlHours}`);
|
|
}
|
|
|
|
const roomId = randomBytes(9).toString('base64url'); // 12 chars
|
|
const createdAt = nowIso();
|
|
const expiresAt = new Date(Date.now() + ttl * 3600_000).toISOString();
|
|
|
|
const created: { id: string; role: string; token: string }[] = [];
|
|
const insertRoom = this.db.prepare(
|
|
`INSERT INTO rooms (id, title, brief, goal, status, created_at, expires_at, observer_token) VALUES (?, ?, ?, ?, 'open', ?, ?, ?)`,
|
|
);
|
|
const insertParticipant = this.db.prepare(
|
|
`INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
);
|
|
|
|
const observerToken = newToken();
|
|
this.db.exec('BEGIN');
|
|
try {
|
|
insertRoom.run(roomId, title, input.brief ?? '', input.goal ?? '', createdAt, expiresAt, observerToken);
|
|
for (const p of ps) {
|
|
const pid = newId('prt');
|
|
const token = newToken();
|
|
insertParticipant.run(
|
|
pid,
|
|
roomId,
|
|
p.role.trim(),
|
|
(p.display_name ?? p.role).trim().slice(0, 200),
|
|
token,
|
|
JSON.stringify((p.knows ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))),
|
|
JSON.stringify((p.needs_to_determine ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))),
|
|
(p.instructions ?? '').slice(0, 20000),
|
|
createdAt,
|
|
);
|
|
created.push({ id: pid, role: p.role.trim(), token });
|
|
}
|
|
this.db.exec('COMMIT');
|
|
} catch (e) {
|
|
this.db.exec('ROLLBACK');
|
|
throw e;
|
|
}
|
|
|
|
return {
|
|
room_id: roomId,
|
|
status: 'open',
|
|
expires_at: expiresAt,
|
|
participants: created,
|
|
invite_urls: created.map((p) => `${baseUrl}/r/${roomId}/${p.token}`),
|
|
observer_url: `${baseUrl}/o/${roomId}/${observerToken}`,
|
|
};
|
|
}
|
|
|
|
// ---------- auth ----------
|
|
|
|
authenticate(roomId: string, token: string): { room: RoomRow; participant: ParticipantRow } {
|
|
const room = this.getRoomRow(roomId);
|
|
const row = this.db
|
|
.prepare('SELECT * FROM participants WHERE room_id = ? AND token = ?')
|
|
.get(roomId, token) as ParticipantRow | undefined;
|
|
if (!row) {
|
|
throw new RendezvousError('forbidden', 'invalid token for this room');
|
|
}
|
|
return { room, participant: row };
|
|
}
|
|
|
|
authenticateByToken(token: string): { room: RoomRow; participant: ParticipantRow } {
|
|
const row = this.db.prepare('SELECT * FROM participants WHERE token = ?').get(token) as
|
|
| ParticipantRow
|
|
| undefined;
|
|
if (!row) throw new RendezvousError('not_found', 'unknown token');
|
|
const room = this.getRoomRow(row.room_id);
|
|
return { room, participant: row };
|
|
}
|
|
|
|
private getRoomRow(roomId: string): RoomRow {
|
|
const room = this.db.prepare('SELECT * FROM rooms WHERE id = ?').get(roomId) as RoomRow | undefined;
|
|
if (!room) throw new RendezvousError('not_found', 'room not found');
|
|
return room;
|
|
}
|
|
|
|
private ensureOpen(room: RoomRow): void {
|
|
if (new Date(room.expires_at).getTime() < Date.now()) {
|
|
this.cleanupExpired();
|
|
throw new RendezvousError('not_found', 'room not found');
|
|
}
|
|
if (room.status !== 'open') {
|
|
throw new RendezvousError('conflict', `room status is '${room.status}'; no further changes allowed`);
|
|
}
|
|
}
|
|
|
|
// ---------- reads ----------
|
|
|
|
listParticipants(roomId: string): ParticipantView[] {
|
|
return (this.db
|
|
.prepare('SELECT * FROM participants WHERE room_id = ? ORDER BY created_at, id')
|
|
.all(roomId) as unknown as ParticipantRow[]).map((p) => this.toParticipantView(p));
|
|
}
|
|
|
|
private toParticipantView(p: ParticipantRow): ParticipantView {
|
|
return {
|
|
id: p.id,
|
|
role: p.role,
|
|
display_name: p.display_name,
|
|
knows: JSON.parse(p.knows) as string[],
|
|
needs_to_determine: JSON.parse(p.needs_to_determine) as string[],
|
|
instructions: p.instructions,
|
|
};
|
|
}
|
|
|
|
listMessages(roomId: string): MessageView[] {
|
|
const rows = this.db
|
|
.prepare(
|
|
`SELECT m.*, p.role FROM messages m JOIN participants p ON p.id = m.participant_id
|
|
WHERE m.room_id = ? ORDER BY m.created_at, m.id`,
|
|
)
|
|
.all(roomId) as unknown as (MessageRow & { role: string })[];
|
|
return rows.map((m) => ({
|
|
id: m.id,
|
|
participant_id: m.participant_id,
|
|
role: m.role,
|
|
content: m.content,
|
|
created_at: m.created_at,
|
|
}));
|
|
}
|
|
|
|
listQuestions(roomId: string): QuestionView[] {
|
|
const rows = this.db
|
|
.prepare(
|
|
`SELECT q.*, pa.role AS author_role, pt.role AS to_role FROM questions q
|
|
JOIN participants pa ON pa.id = q.participant_id
|
|
LEFT JOIN participants pt ON pt.id = q.addressed_to
|
|
WHERE q.room_id = ? ORDER BY q.created_at, q.id`,
|
|
)
|
|
.all(roomId) as unknown as (QuestionRow & { author_role: string; to_role: string | null })[];
|
|
return rows.map((q) => ({
|
|
id: q.id,
|
|
participant_id: q.participant_id,
|
|
author_role: q.author_role,
|
|
addressed_to: q.addressed_to,
|
|
addressed_to_role: q.to_role,
|
|
question: q.question,
|
|
blocking: q.blocking === 1,
|
|
status: q.status,
|
|
resolution: q.resolution,
|
|
created_at: q.created_at,
|
|
resolved_at: q.resolved_at,
|
|
}));
|
|
}
|
|
|
|
getRoomView(roomId: string, token: string): RoomView {
|
|
const { room, participant } = this.authenticate(roomId, token);
|
|
const participants = this.listParticipants(roomId);
|
|
const conversation = this.listMessages(roomId);
|
|
const questions = this.listQuestions(roomId);
|
|
const openQuestions = questions.filter((q) => q.status === 'open');
|
|
const resolvedQuestions = questions.filter((q) => q.status === 'resolved');
|
|
|
|
const agreements = this.db
|
|
.prepare(
|
|
`SELECT a.participant_id, a.contract_version, p.role FROM agreements a
|
|
JOIN participants p ON p.id = a.participant_id WHERE a.room_id = ?`,
|
|
)
|
|
.all(roomId) as { participant_id: string; contract_version: number; role: string }[];
|
|
|
|
const contract =
|
|
room.contract_version > 0
|
|
? {
|
|
version: room.contract_version,
|
|
markdown: room.contract_markdown,
|
|
updated_at: room.contract_updated_at,
|
|
agreements: agreements.map((a) => ({
|
|
participant_id: a.participant_id,
|
|
role: a.role,
|
|
version: a.contract_version,
|
|
})),
|
|
}
|
|
: null;
|
|
|
|
const mine = agreements.find((a) => a.participant_id === participant.id);
|
|
const status = room.status as 'open' | 'agreed' | 'expired';
|
|
const viewStatus = new Date(room.expires_at).getTime() < Date.now() ? 'expired' : status;
|
|
|
|
return {
|
|
room: {
|
|
id: room.id,
|
|
title: room.title,
|
|
brief: room.brief,
|
|
goal: room.goal,
|
|
status,
|
|
created_at: room.created_at,
|
|
expires_at: room.expires_at,
|
|
},
|
|
your_role: participant.role,
|
|
your_participant_id: participant.id,
|
|
participants,
|
|
conversation,
|
|
open_questions: openQuestions,
|
|
resolved_questions: resolvedQuestions,
|
|
current_contract: contract,
|
|
room_status: viewStatus,
|
|
what_you_should_do_next: computeAdvice({
|
|
status: viewStatus,
|
|
you: this.toParticipantView(participant),
|
|
participants,
|
|
openQuestions,
|
|
resolvedQuestions,
|
|
conversation,
|
|
contract,
|
|
yourAgreedVersion: mine ? mine.contract_version : null,
|
|
}),
|
|
available_actions: availableActions(viewStatus),
|
|
};
|
|
}
|
|
|
|
/** Read-only view for the human observer: room state + whose turn it is. */
|
|
getObserverView(roomId: string, observerToken: string): ObserverView {
|
|
const room = this.getRoomRow(roomId);
|
|
if (room.observer_token !== observerToken) {
|
|
throw new RendezvousError('forbidden', 'invalid observer token');
|
|
}
|
|
const participants = this.listParticipants(roomId);
|
|
const conversation = this.listMessages(roomId);
|
|
const questions = this.listQuestions(roomId);
|
|
const openQuestions = questions.filter((q) => q.status === 'open');
|
|
const resolvedQuestions = questions.filter((q) => q.status === 'resolved');
|
|
const agreements = this.db
|
|
.prepare(
|
|
`SELECT a.participant_id, a.contract_version FROM agreements a
|
|
JOIN participants p ON p.id = a.participant_id WHERE a.room_id = ?`,
|
|
)
|
|
.all(roomId) as { participant_id: string; contract_version: number }[];
|
|
const contract =
|
|
room.contract_version > 0
|
|
? {
|
|
version: room.contract_version,
|
|
markdown: room.contract_markdown,
|
|
updated_at: room.contract_updated_at,
|
|
agreements: agreements.map((a) => ({
|
|
participant_id: a.participant_id,
|
|
role: participants.find((p) => p.id === a.participant_id)?.role ?? '?',
|
|
version: a.contract_version,
|
|
})),
|
|
}
|
|
: null;
|
|
const status = room.status as 'open' | 'agreed' | 'expired';
|
|
const viewStatus = new Date(room.expires_at).getTime() < Date.now() ? 'expired' : status;
|
|
const turn = computeTurn(viewStatus, participants, openQuestions, contract);
|
|
return {
|
|
room: {
|
|
id: room.id,
|
|
title: room.title,
|
|
brief: room.brief,
|
|
goal: room.goal,
|
|
status,
|
|
created_at: room.created_at,
|
|
expires_at: room.expires_at,
|
|
},
|
|
participants,
|
|
conversation,
|
|
open_questions: openQuestions,
|
|
resolved_questions: resolvedQuestions,
|
|
current_contract: contract,
|
|
room_status: viewStatus,
|
|
turn: turn.text,
|
|
turn_waiting_for: turn.waiting_for,
|
|
};
|
|
}
|
|
|
|
// ---------- writes ----------
|
|
|
|
postMessage(roomId: string, token: string, content: string): MessageView {
|
|
const { room, participant } = this.authenticate(roomId, token);
|
|
this.ensureOpen(room);
|
|
const body = typeof content === 'string' ? content : '';
|
|
if (!body.trim()) throw new RendezvousError('validation', 'content is required');
|
|
if (Buffer.byteLength(body) > LIMITS.maxMessageBytes) {
|
|
throw new RendezvousError('limit', `message too large (max ${LIMITS.maxMessageBytes} bytes)`);
|
|
}
|
|
const count = this.db.prepare('SELECT COUNT(*) AS c FROM messages WHERE room_id = ?').get(roomId) as {
|
|
c: number;
|
|
};
|
|
if (count.c >= LIMITS.maxMessagesPerRoom) {
|
|
throw new RendezvousError('limit', `message limit reached (${LIMITS.maxMessagesPerRoom})`);
|
|
}
|
|
const totalRow = this.db
|
|
.prepare(
|
|
`SELECT
|
|
(SELECT COALESCE(SUM(LENGTH(content)),0) FROM messages WHERE room_id = ?)
|
|
+ (SELECT COALESCE(SUM(LENGTH(question) + LENGTH(resolution)),0) FROM questions WHERE room_id = ?)
|
|
+ (SELECT COALESCE(SUM(LENGTH(markdown)),0) FROM contract_revisions WHERE room_id = ?) AS s`,
|
|
)
|
|
.get(roomId, roomId, roomId) as { s: number };
|
|
if (totalRow.s + Buffer.byteLength(body) > LIMITS.maxRoomTotalBytes) {
|
|
throw new RendezvousError('limit', 'room total size limit reached');
|
|
}
|
|
const id = newId('msg');
|
|
this.db
|
|
.prepare('INSERT INTO messages (id, room_id, participant_id, content, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
.run(id, roomId, participant.id, body, nowIso());
|
|
return {
|
|
id,
|
|
participant_id: participant.id,
|
|
role: participant.role,
|
|
content: body,
|
|
created_at: nowIso(),
|
|
};
|
|
}
|
|
|
|
openQuestion(
|
|
roomId: string,
|
|
token: string,
|
|
question: string,
|
|
blocking: boolean,
|
|
addressedToParticipantId?: string | null,
|
|
): QuestionView {
|
|
const { room, participant } = this.authenticate(roomId, token);
|
|
this.ensureOpen(room);
|
|
const body = typeof question === 'string' ? question.trim() : '';
|
|
if (!body) throw new RendezvousError('validation', 'question is required');
|
|
if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) {
|
|
throw new RendezvousError('limit', `question too large (max ${LIMITS.maxQuestionBytes} bytes)`);
|
|
}
|
|
let addressedTo: string | null = null;
|
|
if (addressedToParticipantId) {
|
|
const target = this.db
|
|
.prepare('SELECT id FROM participants WHERE id = ? AND room_id = ?')
|
|
.get(addressedToParticipantId, roomId);
|
|
if (!target) throw new RendezvousError('validation', 'addressed_to_participant_id not found in this room');
|
|
if (addressedToParticipantId === participant.id) {
|
|
throw new RendezvousError('validation', 'cannot address a question to yourself');
|
|
}
|
|
addressedTo = addressedToParticipantId;
|
|
}
|
|
const count = this.db.prepare('SELECT COUNT(*) AS c FROM questions WHERE room_id = ?').get(roomId) as {
|
|
c: number;
|
|
};
|
|
if (count.c >= LIMITS.maxQuestionsPerRoom) {
|
|
throw new RendezvousError('limit', `question limit reached (${LIMITS.maxQuestionsPerRoom})`);
|
|
}
|
|
const id = newId('q');
|
|
this.db
|
|
.prepare(
|
|
`INSERT INTO questions (id, room_id, participant_id, addressed_to, question, blocking, status, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'open', ?)`,
|
|
)
|
|
.run(id, roomId, participant.id, addressedTo, body, blocking ? 1 : 0, nowIso());
|
|
return {
|
|
id,
|
|
participant_id: participant.id,
|
|
author_role: participant.role,
|
|
addressed_to: addressedTo,
|
|
addressed_to_role: addressedTo
|
|
? ((this.db.prepare('SELECT role FROM participants WHERE id = ?').get(addressedTo) as { role: string }).role)
|
|
: null,
|
|
question: body,
|
|
blocking,
|
|
status: 'open',
|
|
resolution: null,
|
|
created_at: nowIso(),
|
|
resolved_at: null,
|
|
};
|
|
}
|
|
|
|
resolveQuestion(roomId: string, token: string, questionId: string, resolution: string): QuestionView {
|
|
const { room, participant } = this.authenticate(roomId, token);
|
|
this.ensureOpen(room);
|
|
const q = this.db
|
|
.prepare('SELECT * FROM questions WHERE id = ? AND room_id = ?')
|
|
.get(questionId, roomId) as QuestionRow | undefined;
|
|
if (!q) throw new RendezvousError('not_found', 'question not found');
|
|
if (q.status === 'resolved') {
|
|
throw new RendezvousError('conflict', 'question is already resolved');
|
|
}
|
|
const body = typeof resolution === 'string' ? resolution.trim() : '';
|
|
if (!body) throw new RendezvousError('validation', 'resolution text is required');
|
|
if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) {
|
|
throw new RendezvousError('limit', 'resolution too large');
|
|
}
|
|
// The addressee or the author may resolve; anyone may resolve a question
|
|
// addressed to "anyone" only if they are the addressee in practice. Keep
|
|
// it simple: the addressee (or author) resolves.
|
|
const isAddressee = q.addressed_to === participant.id;
|
|
const isAuthor = q.participant_id === participant.id;
|
|
if (!isAddressee && !isAuthor) {
|
|
throw new RendezvousError('forbidden', 'only the addressee or the author can resolve a question');
|
|
}
|
|
this.db
|
|
.prepare(
|
|
`UPDATE questions SET status = 'resolved', resolution = ?, resolved_by = ?, resolved_at = ? WHERE id = ?`,
|
|
)
|
|
.run(body, participant.id, nowIso(), questionId);
|
|
return this.listQuestions(roomId).find((x) => x.id === questionId)!;
|
|
}
|
|
|
|
proposeContract(roomId: string, token: string, markdown: string): { version: number; markdown: string } {
|
|
const { room, participant } = this.authenticate(roomId, token);
|
|
this.ensureOpen(room);
|
|
const body = typeof markdown === 'string' ? markdown : '';
|
|
if (!body.trim()) throw new RendezvousError('validation', 'markdown is required');
|
|
if (Buffer.byteLength(body) > LIMITS.maxContractBytes) {
|
|
throw new RendezvousError('limit', `contract too large (max ${LIMITS.maxContractBytes} bytes)`);
|
|
}
|
|
const version = room.contract_version + 1;
|
|
this.db.exec('BEGIN');
|
|
try {
|
|
this.db
|
|
.prepare(
|
|
`INSERT INTO contract_revisions (id, room_id, version, markdown, proposed_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(newId('rev'), roomId, version, body, participant.id, nowIso());
|
|
this.db
|
|
.prepare('UPDATE rooms SET contract_markdown = ?, contract_version = ?, contract_updated_at = ? WHERE id = ?')
|
|
.run(body, version, nowIso(), roomId);
|
|
this.db.exec('COMMIT');
|
|
} catch (e) {
|
|
this.db.exec('ROLLBACK');
|
|
throw e;
|
|
}
|
|
return { version, markdown: body };
|
|
}
|
|
|
|
listContractRevisions(roomId: string): ContractRevisionRow[] {
|
|
return this.db
|
|
.prepare('SELECT * FROM contract_revisions WHERE room_id = ? ORDER BY version')
|
|
.all(roomId) as unknown as ContractRevisionRow[];
|
|
}
|
|
|
|
agree(roomId: string, token: string): {
|
|
agreed_contract_version: number;
|
|
room_status: string;
|
|
everyone_agreed: boolean;
|
|
} {
|
|
const { room, participant } = this.authenticate(roomId, token);
|
|
this.ensureOpen(room);
|
|
if (room.contract_version === 0 || room.contract_markdown.trim() === '') {
|
|
throw new RendezvousError('conflict', 'no contract has been proposed yet');
|
|
}
|
|
this.db
|
|
.prepare(
|
|
`INSERT INTO agreements (room_id, participant_id, contract_version, created_at) VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(room_id, participant_id) DO UPDATE SET contract_version = excluded.contract_version, created_at = excluded.created_at`,
|
|
)
|
|
.run(roomId, participant.id, room.contract_version, nowIso());
|
|
|
|
const participants = this.listParticipants(roomId);
|
|
const agreements = this.db
|
|
.prepare('SELECT participant_id, contract_version FROM agreements WHERE room_id = ?')
|
|
.all(roomId) as { participant_id: string; contract_version: number }[];
|
|
const everyone = participants.every((p) => {
|
|
const a = agreements.find((x) => x.participant_id === p.id);
|
|
return a && a.contract_version === room.contract_version;
|
|
});
|
|
|
|
const blocking = this.db
|
|
.prepare(`SELECT COUNT(*) AS c FROM questions WHERE room_id = ? AND status = 'open' AND blocking = 1`)
|
|
.get(roomId) as { c: number };
|
|
|
|
if (everyone && blocking.c === 0) {
|
|
this.db.prepare(`UPDATE rooms SET status = 'agreed' WHERE id = ?`).run(roomId);
|
|
return { agreed_contract_version: room.contract_version, room_status: 'agreed', everyone_agreed: true };
|
|
}
|
|
return {
|
|
agreed_contract_version: room.contract_version,
|
|
room_status: 'open',
|
|
everyone_agreed: everyone,
|
|
};
|
|
}
|
|
|
|
// ---------- destroy ----------
|
|
|
|
/**
|
|
* Immediately and irreversibly delete a room. Allowed for any room member:
|
|
* a participant (invite token) or the observer (observer token).
|
|
*/
|
|
destroyRoom(roomId: string, token: string): void {
|
|
const room = this.getRoomRow(roomId);
|
|
const isObserver = room.observer_token !== null && room.observer_token === token;
|
|
const isParticipant =
|
|
!!this.db.prepare('SELECT id FROM participants WHERE room_id = ? AND token = ?').get(roomId, token);
|
|
if (!isObserver && !isParticipant) {
|
|
throw new RendezvousError('forbidden', 'invalid token for this room');
|
|
}
|
|
this.db.prepare('DELETE FROM rooms WHERE id = ?').run(roomId); // cascade wipes everything
|
|
}
|
|
|
|
// ---------- TTL cleanup ----------
|
|
|
|
/** Deletes everything belonging to expired rooms. Returns number of rooms removed. */
|
|
cleanupExpired(): number {
|
|
const expired = this.db
|
|
.prepare(`SELECT id FROM rooms WHERE expires_at < ? AND status != 'expired'`)
|
|
.all(nowIso()) as { id: string }[];
|
|
if (expired.length === 0) return 0;
|
|
const delRoom = this.db.prepare('DELETE FROM rooms WHERE id = ?');
|
|
this.db.exec('BEGIN');
|
|
try {
|
|
for (const r of expired) delRoom.run(r.id); // ON DELETE CASCADE removes the rest
|
|
this.db.exec('COMMIT');
|
|
} catch (e) {
|
|
this.db.exec('ROLLBACK');
|
|
throw e;
|
|
}
|
|
return expired.length;
|
|
}
|
|
|
|
startCleanupTimer(intervalMs = 10 * 60_000): NodeJS.Timeout {
|
|
return setInterval(() => {
|
|
try {
|
|
this.cleanupExpired();
|
|
} catch (e) {
|
|
console.error('[cleanup] failed:', e);
|
|
}
|
|
}, intervalMs);
|
|
}
|
|
|
|
close(): void {
|
|
this.store.close();
|
|
}
|
|
}
|