Skip to content

Commit e45f030

Browse files
committed
feat: implement participant management in lobby phase and update room status handling
1 parent ce3ac76 commit e45f030

4 files changed

Lines changed: 95 additions & 15 deletions

File tree

src/db.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ db.exec(`
3434
message TEXT NOT NULL,
3535
at INTEGER NOT NULL
3636
);
37+
38+
CREATE TABLE IF NOT EXISTS participants (
39+
code TEXT NOT NULL,
40+
participant_id TEXT NOT NULL,
41+
name TEXT NOT NULL,
42+
joined_at INTEGER NOT NULL,
43+
PRIMARY KEY (code, participant_id)
44+
);
3745
`);
3846

3947
export default db;

src/rooms.ts

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { customAlphabet } from 'nanoid';
77
import db from './db.js';
88
import { divideQuran } from './quran.js';
9-
import { PartState, RoomState, FeedEntry, ExportData } from './types.js';
9+
import { PartState, RoomState, FeedEntry, ExportData, Assignee } from './types.js';
1010

1111
// Human-friendly codes (no ambiguous 0/O/1/I).
1212
const genCode = customAlphabet('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', 6);
@@ -22,7 +22,7 @@ interface RoomRow {
2222
admin_token: string;
2323
participant_count: number;
2424
dedication: string | null;
25-
status: 'active' | 'completed';
25+
status: 'lobby' | 'active' | 'completed';
2626
created_at: number;
2727
completed_at: number | null;
2828
}
@@ -42,6 +42,21 @@ function logEvent(code: string, key: string, params: Record<string, string | num
4242
db.prepare('INSERT INTO events (code, message, at) VALUES (?,?,?)').run(code, JSON.stringify({ key, params }), now());
4343
}
4444

45+
/** Register/refresh a participant in the room's roster (idempotent). */
46+
function upsertParticipant(code: string, id: string, name: string): void {
47+
db.prepare(
48+
'INSERT INTO participants (code, participant_id, name, joined_at) VALUES (?,?,?,?) ' +
49+
'ON CONFLICT(code, participant_id) DO UPDATE SET name = excluded.name'
50+
).run(code, id, name, now());
51+
}
52+
53+
function readParticipants(code: string): Assignee[] {
54+
const rows = db
55+
.prepare('SELECT participant_id, name FROM participants WHERE code = ? ORDER BY joined_at ASC')
56+
.all(code) as { participant_id: string; name: string }[];
57+
return rows.map((r) => ({ id: r.participant_id, name: r.name }));
58+
}
59+
4560
export function getRoom(code: string): RoomRow | undefined {
4661
return db.prepare('SELECT * FROM rooms WHERE code = ?').get(code) as RoomRow | undefined;
4762
}
@@ -56,27 +71,58 @@ function getPart(code: string, idx: number): PartRow | undefined {
5671
return db.prepare('SELECT * FROM parts WHERE code = ? AND idx = ?').get(code, idx) as PartRow | undefined;
5772
}
5873

74+
/**
75+
* Create a room in the 'lobby' phase. The number is just an expected target for
76+
* progress display — the Quran is NOT divided yet. Division happens later in
77+
* `startKhatmah`, based on the count the admin confirms once people have joined.
78+
*/
5979
export function createRoom(opts: { participantCount: number; dedication?: string }): { code: string; adminToken: string } {
60-
const count = Math.min(MAX_PARTICIPANTS, Math.max(1, Math.floor(Number(opts.participantCount) || 0)));
61-
if (!count) throw new Error('BAD_COUNT');
80+
const target = Math.min(MAX_PARTICIPANTS, Math.max(1, Math.floor(Number(opts.participantCount) || 0)));
81+
if (!target) throw new Error('BAD_COUNT');
6282

6383
let code = genCode();
6484
while (getRoom(code)) code = genCode();
6585
const adminToken = genToken();
6686

67-
const insertRoom = db.prepare(
87+
db.prepare(
6888
'INSERT INTO rooms (code, admin_token, participant_count, dedication, status, created_at) VALUES (?,?,?,?,?,?)'
69-
);
70-
const insertPart = db.prepare('INSERT INTO parts (code, idx, data_json, status) VALUES (?,?,?,?)');
71-
const parts = divideQuran(count);
89+
).run(code, adminToken, target, (opts.dedication || '').trim() || null, 'lobby', now());
90+
logEvent(code, 'room_created', { count: target });
91+
92+
return { code, adminToken };
93+
}
94+
95+
/** Register a participant in the lobby (idempotent on reconnect/rename). */
96+
export function joinLobby(opts: { code: string; name: string; participantId: string }): void {
97+
requireRoom(opts.code);
98+
const name = (opts.name || '').trim();
99+
const id = (opts.participantId || '').trim();
100+
if (!name) throw new Error('NO_NAME');
101+
if (!id) throw new Error('NO_ID');
102+
103+
const existing = db.prepare('SELECT 1 FROM participants WHERE code = ? AND participant_id = ?').get(opts.code, id);
104+
upsertParticipant(opts.code, id, name);
105+
if (!existing) logEvent(opts.code, 'lobby_joined', { name });
106+
}
107+
108+
/**
109+
* Admin-only: divide the Quran into `count` parts and move the room from 'lobby'
110+
* to 'active'. `count` is what the admin confirms (defaults on the client to the
111+
* number of joined participants). Parts are created open for claiming.
112+
*/
113+
export function startKhatmah(opts: { code: string; adminToken?: string; count: number }): void {
114+
const room = assertAdmin(opts.code, opts.adminToken);
115+
if (room.status !== 'lobby') throw new Error('ALREADY_STARTED');
116+
const count = Math.min(MAX_PARTICIPANTS, Math.max(1, Math.floor(Number(opts.count) || 0)));
117+
if (!count) throw new Error('BAD_COUNT');
72118

119+
const parts = divideQuran(count);
120+
const insertPart = db.prepare('INSERT INTO parts (code, idx, data_json, status) VALUES (?,?,?,?)');
73121
db.transaction(() => {
74-
insertRoom.run(code, adminToken, count, (opts.dedication || '').trim() || null, 'active', now());
75-
for (const p of parts) insertPart.run(code, p.index, JSON.stringify(p), 'open');
122+
for (const p of parts) insertPart.run(opts.code, p.index, JSON.stringify(p), 'open');
123+
db.prepare('UPDATE rooms SET status = ?, participant_count = ? WHERE code = ?').run('active', count, opts.code);
76124
})();
77-
logEvent(code, 'room_created', { count });
78-
79-
return { code, adminToken };
125+
logEvent(opts.code, 'khatmah_started', { count });
80126
}
81127

82128
function refreshStatus(code: string): void {
@@ -97,6 +143,7 @@ export function joinRoom(opts: { code: string; name: string; participantId: stri
97143
const id = (opts.participantId || '').trim();
98144
if (!name) throw new Error('NO_NAME');
99145
if (!id) throw new Error('NO_ID');
146+
upsertParticipant(opts.code, id, name); // keep the roster complete for late/active joiners
100147

101148
const existing = db.prepare('SELECT idx FROM parts WHERE code = ? AND assignee_id = ?').get(opts.code, id) as
102149
| { idx: number }
@@ -245,6 +292,7 @@ export function getState(code: string): RoomState {
245292
doneCount: parts.filter((p) => p.status === 'done').length,
246293
totalParts: parts.length,
247294
parts,
295+
participants: readParticipants(code),
248296
feed,
249297
};
250298
}
@@ -282,6 +330,7 @@ export function closeKhatmah(opts: { code: string; adminToken?: string }): Expor
282330
db.transaction(() => {
283331
db.prepare('DELETE FROM parts WHERE code = ?').run(opts.code);
284332
db.prepare('DELETE FROM events WHERE code = ?').run(opts.code);
333+
db.prepare('DELETE FROM participants WHERE code = ?').run(opts.code);
285334
db.prepare('DELETE FROM rooms WHERE code = ?').run(opts.code);
286335
})();
287336
return data;

src/server.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,27 @@ io.on("connection", (socket: Socket) => {
8181
}),
8282
);
8383

84+
// Lobby phase: register a participant (before the Quran is divided).
85+
socket.on(
86+
"joinLobby",
87+
handle((p) => {
88+
rooms.joinLobby(p);
89+
socket.join(p.code);
90+
socket.data.code = p.code;
91+
broadcast(p.code);
92+
return { state: rooms.getState(p.code) };
93+
}),
94+
);
95+
96+
// Admin divides the Quran by the confirmed count and starts the khatmah.
97+
socket.on(
98+
"startKhatmah",
99+
handle((p) => {
100+
rooms.startKhatmah(p);
101+
broadcast(p.code);
102+
}),
103+
);
104+
84105
socket.on(
85106
"startPart",
86107
handle((p) => {

src/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,21 @@ export interface FeedEntry {
5656
at: number;
5757
}
5858

59-
export type RoomStatus = 'active' | 'completed';
59+
// 'lobby' = created, people joining, not yet divided; 'active' = divided & reading.
60+
export type RoomStatus = 'lobby' | 'active' | 'completed';
6061

6162
export interface RoomState {
6263
code: string;
6364
status: RoomStatus;
64-
participantCount: number;
65+
participantCount: number; // the admin's expected target (display only)
6566
dedication: string | null;
6667
createdAt: number;
6768
completedAt: number | null;
6869
assignedCount: number;
6970
doneCount: number;
7071
totalParts: number;
7172
parts: PartState[];
73+
participants: Assignee[]; // people who joined the lobby (id + name)
7274
feed: FeedEntry[];
7375
}
7476

0 commit comments

Comments
 (0)