66import { customAlphabet } from 'nanoid' ;
77import db from './db.js' ;
88import { 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).
1212const 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+
4560export 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+ */
5979export 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
82128function 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 ;
0 commit comments