Any member can destroy a room; observer sees highlighted whose turn it is
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)
This commit is contained in:
2026-09-06 20:44:37 +03:00
parent 91fc74efc1
commit 1fa7e733ba
9 changed files with 164 additions and 18 deletions
+22 -8
View File
@@ -94,33 +94,47 @@ export function computeAdvice(input: AdviceInput): string {
/**
* Room-level "whose turn is it" for the human observer.
* Priority: blocking questions first, then contract agreements, then finalize.
* Returns the human-readable hint plus the roles the room is waiting on
* (empty when nobody in particular is expected to move).
*/
export function computeTurn(
status: string,
participants: { id: string; role: string }[],
openQuestions: { addressed_to: string | null; participant_id: string; blocking: boolean; question: string }[],
contract: { version: number; agreements: { participant_id: string; version: number }[] } | null,
): string {
if (status === 'expired') return 'Room expired — all data has been deleted.';
if (status === 'agreed') return 'Done: contract agreed by everyone. Fetch final.md before the room expires.';
): { text: string; waiting_for: string[] } {
if (status === 'expired') return { text: 'Room expired — all data has been deleted.', waiting_for: [] };
if (status === 'agreed')
return { text: 'Done: contract agreed by everyone. Fetch final.md before the room expires.', waiting_for: [] };
const roleOf = (id: string) => participants.find((p) => p.id === id)?.role ?? 'unknown';
const blocking = openQuestions.filter((q) => q.blocking);
if (blocking.length > 0) {
const q = blocking[0];
const who = q.addressed_to ? roleOf(q.addressed_to) : `${participants.filter((p) => p.id !== q.participant_id).map((p) => p.role).join(', ')} (asked by ${roleOf(q.participant_id)})`;
return `Waiting for ${who} to answer/resolve the blocking question: "${q.question.slice(0, 120)}"${q.question.length > 120 ? '…' : ''}`;
const waiting = q.addressed_to
? [roleOf(q.addressed_to)]
: participants.filter((p) => p.id !== q.participant_id).map((p) => p.role);
return {
text: `Waiting for ${waiting.join(', ')} to answer/resolve the blocking question: "${q.question.slice(0, 120)}"${q.question.length > 120 ? '…' : ''}`,
waiting_for: waiting,
};
}
if (!contract || contract.version === 0) {
return 'No blocking questions. Waiting for someone to draft the Agreed Contract.';
return { text: 'No blocking questions. Waiting for someone to draft the Agreed Contract.', waiting_for: [] };
}
const pending = participants.filter(
(p) => (contract.agreements.find((a) => a.participant_id === p.id)?.version ?? 0) !== contract.version,
);
if (pending.length > 0) {
return `Contract v${contract.version} is on the table. Waiting for ${pending.map((p) => p.role).join(', ')} to agree or propose changes.`;
return {
text: `Contract v${contract.version} is on the table. Waiting for ${pending.map((p) => p.role).join(', ')} to agree or propose changes.`,
waiting_for: pending.map((p) => p.role),
};
}
return 'All questions resolved and everyone agreed — the next agree call finalizes the room.';
return {
text: 'All questions resolved and everyone agreed — the next agree call finalizes the room.',
waiting_for: [],
};
}
export function availableActions(status: string): string[] {
+2
View File
@@ -9,6 +9,7 @@ export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string
parts.push(`Status: ${o.room_status} · expires ${o.room.expires_at} (all data deleted then).`);
parts.push('');
parts.push(`**Whose turn:** ${o.turn}`);
if (o.turn_waiting_for.length) parts.push(`**Waiting on:** ${o.turn_waiting_for.map((r) => `**${r}**`).join(', ')}`);
parts.push('');
parts.push(`Participants: ${o.participants.map((p) => p.role).join(', ')}`);
parts.push('');
@@ -125,6 +126,7 @@ export function renderRoomMarkdown(i: MarkdownInput): string {
parts.push('- `PUT /api/rooms/' + i.roomId + '/contract` — {"markdown": "## Facts\\n..."}');
parts.push('- `POST /api/rooms/' + i.roomId + '/agree` — agree to current contract version; finalizes when everyone agreed and no blocking questions remain');
parts.push('- `GET /api/rooms/' + i.roomId + '/final.md` — final Markdown artifact');
parts.push('- `DELETE /api/rooms/' + i.roomId + '` — destroy the room immediately (any member; deletes everything, including this token)');
return parts.join('\n');
}
+20 -1
View File
@@ -318,6 +318,7 @@ export class RendezvousService {
: 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,
@@ -334,7 +335,8 @@ export class RendezvousService {
resolved_questions: resolvedQuestions,
current_contract: contract,
room_status: viewStatus,
turn: computeTurn(viewStatus, participants, openQuestions, contract),
turn: turn.text,
turn_waiting_for: turn.waiting_for,
};
}
@@ -539,6 +541,23 @@ export class RendezvousService {
};
}
// ---------- 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. */
+2
View File
@@ -100,6 +100,8 @@ export interface ObserverView {
room_status: 'open' | 'agreed' | 'expired';
/** Whose move it is right now, for a human watching the negotiation. */
turn: string;
/** Roles the room is currently waiting on (for UI highlighting). */
turn_waiting_for: string[];
}
export class RendezvousError extends Error {
+20
View File
@@ -145,6 +145,26 @@ test('limits: ttl > 24h rejected, oversize message rejected, 1 participant rejec
);
});
test('destroyRoom: allowed for participant and observer, rejected for strangers', () => {
const { svc } = makeService();
const created = createTwoPartyRoom(svc);
const roomId = created.room_id;
const tokenA = created.participants[0].token;
const observerToken = created.observer_url.split('/').pop()!;
// stranger
assert.throws(() => svc.destroyRoom(roomId, 'forged-token'), RendezvousError);
// observer may read and destroy
const view = svc.getObserverView(roomId, observerToken);
assert.ok(view.turn.length > 0);
svc.destroyRoom(roomId, observerToken);
assert.throws(() => svc.getRoomView(roomId, tokenA), RendezvousError, 'room is gone');
// participant may also destroy
const c2 = createTwoPartyRoom(svc);
svc.destroyRoom(c2.room_id, c2.participants[1].token);
assert.throws(() => svc.getRoomView(c2.room_id, c2.participants[0].token), RendezvousError);
});
test('only addressee or author can resolve a question', () => {
const { svc } = makeService();
const created = createTwoPartyRoom(svc);