MisbahKhan0009 commited on
Commit
2d6f0b4
·
1 Parent(s): 9a1641d

feat: live queue sync — start/end/extend appointments, real-time patient notifications

Browse files
database/apply_schema.js CHANGED
@@ -272,6 +272,37 @@ async function applySchema() {
272
  console.log(`[migration] ${tableName} table created.`);
273
  }
274
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  } finally {
276
  await connection.end();
277
  }
 
272
  console.log(`[migration] ${tableName} table created.`);
273
  }
274
  }
275
+
276
+ // ── Migration: live queue columns on appointments ──────────────────────
277
+ const queueColumns = [
278
+ { columnName: 'started_at', definition: 'DATETIME NULL DEFAULT NULL', after: 'updated_at' },
279
+ { columnName: 'ended_at', definition: 'DATETIME NULL DEFAULT NULL', after: 'started_at' },
280
+ { columnName: 'extra_minutes', definition: 'INT NOT NULL DEFAULT 0', after: 'ended_at' },
281
+ ];
282
+ for (const col of queueColumns) {
283
+ await ensureColumnExists({ tableName: 'appointments', ...col });
284
+ }
285
+
286
+ // ── Migration: doctor_queue_status table ──────────────────────────────
287
+ const [queueStatusTableRows] = await connection.query(`
288
+ SELECT COUNT(*) AS total
289
+ FROM information_schema.TABLES
290
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'doctor_queue_status'
291
+ `);
292
+ if (Number(queueStatusTableRows[0].total || 0) === 0) {
293
+ await connection.query(`
294
+ CREATE TABLE IF NOT EXISTS doctor_queue_status (
295
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
296
+ doctor_id VARCHAR(10) NOT NULL,
297
+ queue_date DATE NOT NULL,
298
+ current_serial INT NOT NULL DEFAULT 0,
299
+ is_active TINYINT(1) NOT NULL DEFAULT 0,
300
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
301
+ UNIQUE KEY uq_doctor_queue (doctor_id, queue_date)
302
+ ) ENGINE=InnoDB
303
+ `);
304
+ console.log('[migration] doctor_queue_status table created.');
305
+ }
306
  } finally {
307
  await connection.end();
308
  }
src/routes/appointments.routes.js CHANGED
@@ -10,6 +10,10 @@ const {
10
  rescheduleAppointment,
11
  respondToRescheduleRequest,
12
  updateAppointmentStatus,
 
 
 
 
13
  } = require('../services/appointments.service');
14
  const { sendSuccess } = require('../utils/apiResponse');
15
 
@@ -125,4 +129,56 @@ router.patch('/:appointmentId/reschedule-request/respond', requireAuth('doctor')
125
  });
126
  });
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  module.exports = router;
 
10
  rescheduleAppointment,
11
  respondToRescheduleRequest,
12
  updateAppointmentStatus,
13
+ startAppointment,
14
+ endAppointment,
15
+ extendAppointment,
16
+ getQueueStatus,
17
  } = require('../services/appointments.service');
18
  const { sendSuccess } = require('../utils/apiResponse');
19
 
 
129
  });
130
  });
131
 
132
+ // ── Live Queue ─────────────────────────────────────────────────────────────
133
+
134
+ // Doctor starts an appointment (sets started_at, bumps queue serial, pushes patients)
135
+ router.post('/:appointmentId/start', requireAuth('doctor'), async (req, res) => {
136
+ const appointment = await startAppointment({
137
+ appointmentId: Number(req.params.appointmentId),
138
+ doctorId: req.auth.userIdentifier,
139
+ });
140
+
141
+ return sendSuccess(res, {
142
+ message: 'Appointment started.',
143
+ data: appointment,
144
+ });
145
+ });
146
+
147
+ // Doctor ends an appointment (sets ended_at, marks completed)
148
+ router.post('/:appointmentId/end', requireAuth('doctor'), async (req, res) => {
149
+ const appointment = await endAppointment({
150
+ appointmentId: Number(req.params.appointmentId),
151
+ doctorId: req.auth.userIdentifier,
152
+ });
153
+
154
+ return sendSuccess(res, {
155
+ message: 'Appointment ended.',
156
+ data: appointment,
157
+ });
158
+ });
159
+
160
+ // Doctor extends an appointment by N minutes → push all later patients
161
+ router.post('/:appointmentId/extend', requireAuth('doctor'), async (req, res) => {
162
+ const appointment = await extendAppointment({
163
+ appointmentId: Number(req.params.appointmentId),
164
+ doctorId: req.auth.userIdentifier,
165
+ addMinutes: req.body.addMinutes,
166
+ });
167
+
168
+ return sendSuccess(res, {
169
+ message: `Appointment extended by ${req.body.addMinutes} minute(s).`,
170
+ data: appointment,
171
+ });
172
+ });
173
+
174
+ // Get live queue status — usable by both patient and doctor (no auth required for polling)
175
+ router.get('/queue-status', async (req, res) => {
176
+ const { doctorId, date } = req.query;
177
+ if (!doctorId || !date) {
178
+ return sendSuccess(res, { message: 'doctorId and date are required.', data: null });
179
+ }
180
+ const status = await getQueueStatus({ doctorId: String(doctorId).trim().toUpperCase(), date: String(date) });
181
+ return sendSuccess(res, { message: 'Queue status fetched.', data: status });
182
+ });
183
+
184
  module.exports = router;
src/services/appointments.service.js CHANGED
@@ -20,6 +20,9 @@ const APPOINTMENT_SELECT = `
20
  a.serial_number,
21
  a.status,
22
  a.notes,
 
 
 
23
  a.created_at,
24
  a.updated_at,
25
  rr.id AS reschedule_request_id,
@@ -545,6 +548,252 @@ async function respondToRescheduleRequest({ appointmentId, doctorId, response })
545
  return appointment;
546
  }
547
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  module.exports = {
549
  createAppointment,
550
  getAppointmentById,
@@ -555,4 +804,8 @@ module.exports = {
555
  rescheduleAppointment,
556
  respondToRescheduleRequest,
557
  updateAppointmentStatus,
 
 
 
 
558
  };
 
20
  a.serial_number,
21
  a.status,
22
  a.notes,
23
+ a.started_at,
24
+ a.ended_at,
25
+ a.extra_minutes,
26
  a.created_at,
27
  a.updated_at,
28
  rr.id AS reschedule_request_id,
 
548
  return appointment;
549
  }
550
 
551
+ // ── Parse "HH:MM AM/PM" → minutes since midnight ─────────────────────────────
552
+ function timeSlotToMinutes(slot) {
553
+ const match = String(slot || '').match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
554
+ if (!match) return 0;
555
+ let h = parseInt(match[1], 10);
556
+ const m = parseInt(match[2], 10);
557
+ const meridiem = match[3].toUpperCase();
558
+ if (meridiem === 'PM' && h !== 12) h += 12;
559
+ if (meridiem === 'AM' && h === 12) h = 0;
560
+ return h * 60 + m;
561
+ }
562
+
563
+ function minutesToTimeSlot(minutes) {
564
+ const h = Math.floor(minutes / 60);
565
+ const m = minutes % 60;
566
+ const meridiem = h < 12 ? 'AM' : 'PM';
567
+ const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
568
+ return `${String(displayH).padStart(2, '0')}:${String(m).padStart(2, '0')} ${meridiem}`;
569
+ }
570
+
571
+ // ── Start an appointment (doctor marks it "in progress") ─────────────────────
572
+ async function startAppointment({ appointmentId, doctorId }) {
573
+ const { createInAppNotification } = require('./notifications.service');
574
+ const today = new Date().toISOString().slice(0, 10);
575
+
576
+ const info = await withTransaction(async (connection) => {
577
+ const [apptRows] = await connection.execute(
578
+ `SELECT id, doctor_id, patient_phone, appointment_date, time_slot, serial_number, status
579
+ FROM appointments WHERE id = ? AND doctor_id = ? LIMIT 1 FOR UPDATE`,
580
+ [appointmentId, doctorId],
581
+ );
582
+ if (apptRows.length === 0) throw new HttpError(404, 'Appointment not found.', 'APPOINTMENT_NOT_FOUND');
583
+
584
+ const appt = apptRows[0];
585
+ if (appt.status === 'cancelled') throw new HttpError(400, 'Cannot start a cancelled appointment.', 'APPOINTMENT_CANCELLED');
586
+ if (appt.status === 'completed') throw new HttpError(400, 'Appointment already completed.', 'APPOINTMENT_COMPLETED');
587
+
588
+ // Mark started
589
+ await connection.execute(
590
+ `UPDATE appointments SET started_at = NOW(), status = 'confirmed', updated_at = NOW() WHERE id = ?`,
591
+ [appointmentId],
592
+ );
593
+
594
+ // Upsert queue status
595
+ await connection.execute(
596
+ `INSERT INTO doctor_queue_status (doctor_id, queue_date, current_serial, is_active)
597
+ VALUES (?, ?, ?, 1)
598
+ ON DUPLICATE KEY UPDATE current_serial = VALUES(current_serial), is_active = 1, updated_at = NOW()`,
599
+ [doctorId, appt.appointment_date, appt.serial_number],
600
+ );
601
+
602
+ return {
603
+ patientPhone: appt.patient_phone,
604
+ serialNumber: appt.serial_number,
605
+ appointmentDate: appt.appointment_date,
606
+ timeSlot: appt.time_slot,
607
+ };
608
+ });
609
+
610
+ // Fetch ALL today's appointments for this doctor (to compute expected times)
611
+ const todayAppts = await query(
612
+ `SELECT id, patient_phone, serial_number, time_slot, extra_minutes, status
613
+ FROM appointments
614
+ WHERE doctor_id = :doctorId AND appointment_date = :date AND status <> 'cancelled'
615
+ ORDER BY serial_number ASC`,
616
+ { doctorId, date: info.appointmentDate },
617
+ );
618
+
619
+ // Compute cumulative extra delay so far (all serials < current)
620
+ let cumulativeExtra = 0;
621
+ for (const row of todayAppts) {
622
+ if (Number(row.serial_number) < info.serialNumber) {
623
+ cumulativeExtra += Number(row.extra_minutes || 0);
624
+ }
625
+ }
626
+
627
+ // Notify patients whose serial > current
628
+ const doctorRows = await query(`SELECT name FROM doctors WHERE id = :doctorId LIMIT 1`, { doctorId });
629
+ const doctorName = doctorRows[0]?.name || 'Your doctor';
630
+
631
+ for (const row of todayAppts) {
632
+ if (Number(row.serial_number) <= info.serialNumber) continue;
633
+ const baseMinutes = timeSlotToMinutes(row.time_slot);
634
+ const expectedTime = minutesToTimeSlot(baseMinutes + cumulativeExtra);
635
+
636
+ createInAppNotification({
637
+ userType: 'patient',
638
+ userIdentifier: row.patient_phone,
639
+ category: 'appointment_queue_update',
640
+ title: 'Doctor Started Session',
641
+ body: `Dr. ${doctorName} is now seeing Serial #${info.serialNumber}. Your Serial: #${row.serial_number}. Est. time: ${expectedTime}.`,
642
+ metadata: {
643
+ type: 'appointment_started',
644
+ doctorId,
645
+ doctorName,
646
+ currentSerial: info.serialNumber,
647
+ yourSerial: Number(row.serial_number),
648
+ expectedTime,
649
+ },
650
+ }).catch(() => null);
651
+ }
652
+
653
+ return getAppointmentById(appointmentId);
654
+ }
655
+
656
+ // ── End an appointment ────────────────────────────────────────────────────────
657
+ async function endAppointment({ appointmentId, doctorId }) {
658
+ await withTransaction(async (connection) => {
659
+ const [apptRows] = await connection.execute(
660
+ `SELECT id, doctor_id, status FROM appointments WHERE id = ? AND doctor_id = ? LIMIT 1 FOR UPDATE`,
661
+ [appointmentId, doctorId],
662
+ );
663
+ if (apptRows.length === 0) throw new HttpError(404, 'Appointment not found.', 'APPOINTMENT_NOT_FOUND');
664
+
665
+ await connection.execute(
666
+ `UPDATE appointments SET ended_at = NOW(), status = 'completed', updated_at = NOW() WHERE id = ?`,
667
+ [appointmentId],
668
+ );
669
+ });
670
+
671
+ return getAppointmentById(appointmentId);
672
+ }
673
+
674
+ // ── Extend an appointment (add N minutes, push affected patients) ─────────────
675
+ async function extendAppointment({ appointmentId, doctorId, addMinutes }) {
676
+ const { createInAppNotification } = require('./notifications.service');
677
+ const mins = parseInt(addMinutes, 10);
678
+ if (!mins || mins < 1 || mins > 60) throw new HttpError(400, 'addMinutes must be 1–60.', 'INVALID_MINUTES');
679
+
680
+ const info = await withTransaction(async (connection) => {
681
+ const [apptRows] = await connection.execute(
682
+ `SELECT id, doctor_id, patient_phone, appointment_date, time_slot, serial_number, extra_minutes, status
683
+ FROM appointments WHERE id = ? AND doctor_id = ? LIMIT 1 FOR UPDATE`,
684
+ [appointmentId, doctorId],
685
+ );
686
+ if (apptRows.length === 0) throw new HttpError(404, 'Appointment not found.', 'APPOINTMENT_NOT_FOUND');
687
+ const appt = apptRows[0];
688
+
689
+ const newExtra = Number(appt.extra_minutes || 0) + mins;
690
+ await connection.execute(
691
+ `UPDATE appointments SET extra_minutes = ?, updated_at = NOW() WHERE id = ?`,
692
+ [newExtra, appointmentId],
693
+ );
694
+
695
+ return {
696
+ appointmentDate: appt.appointment_date,
697
+ serialNumber: Number(appt.serial_number),
698
+ };
699
+ });
700
+
701
+ // Fetch ALL today's appointments for this doctor
702
+ const todayAppts = await query(
703
+ `SELECT id, patient_phone, serial_number, time_slot, extra_minutes, status
704
+ FROM appointments
705
+ WHERE doctor_id = :doctorId AND appointment_date = :date AND status <> 'cancelled'
706
+ ORDER BY serial_number ASC`,
707
+ { doctorId, date: info.appointmentDate },
708
+ );
709
+
710
+ // Re-compute cumulative delay for each serial > current
711
+ let cumulativeExtra = 0;
712
+ for (const row of todayAppts) {
713
+ if (Number(row.serial_number) <= info.serialNumber) {
714
+ cumulativeExtra += Number(row.extra_minutes || 0);
715
+ }
716
+ }
717
+
718
+ const doctorRows = await query(`SELECT name FROM doctors WHERE id = :doctorId LIMIT 1`, { doctorId });
719
+ const doctorName = doctorRows[0]?.name || 'Your doctor';
720
+
721
+ for (const row of todayAppts) {
722
+ if (Number(row.serial_number) <= info.serialNumber) continue;
723
+ const baseMinutes = timeSlotToMinutes(row.time_slot);
724
+ const expectedTime = minutesToTimeSlot(baseMinutes + cumulativeExtra);
725
+
726
+ createInAppNotification({
727
+ userType: 'patient',
728
+ userIdentifier: row.patient_phone,
729
+ category: 'appointment_queue_update',
730
+ title: 'Appointment Delayed',
731
+ body: `Dr. ${doctorName} added ${mins} min. Your Serial #${row.serial_number} — new est. time: ${expectedTime}.`,
732
+ metadata: {
733
+ type: 'appointment_extended',
734
+ doctorId,
735
+ doctorName,
736
+ addedMinutes: mins,
737
+ currentSerial: info.serialNumber,
738
+ yourSerial: Number(row.serial_number),
739
+ expectedTime,
740
+ },
741
+ }).catch(() => null);
742
+ }
743
+
744
+ return getAppointmentById(appointmentId);
745
+ }
746
+
747
+ // ── Get live queue status for a doctor on a given date ───────────────────────
748
+ async function getQueueStatus({ doctorId, date }) {
749
+ const normalizedDate = normalizeDate(date, 'date');
750
+
751
+ const [queueRows] = await Promise.all([
752
+ query(
753
+ `SELECT current_serial, is_active
754
+ FROM doctor_queue_status
755
+ WHERE doctor_id = :doctorId AND queue_date = :date LIMIT 1`,
756
+ { doctorId, date: normalizedDate },
757
+ ),
758
+ ]);
759
+
760
+ const currentSerial = Number(queueRows[0]?.current_serial || 0);
761
+ const isActive = Boolean(queueRows[0]?.is_active);
762
+
763
+ const todayAppts = await query(
764
+ `SELECT id, patient_phone, serial_number, time_slot, extra_minutes, status
765
+ FROM appointments
766
+ WHERE doctor_id = :doctorId AND appointment_date = :date AND status <> 'cancelled'
767
+ ORDER BY serial_number ASC`,
768
+ { doctorId, date: normalizedDate },
769
+ );
770
+
771
+ // Compute cumulative extra for each appointment
772
+ let cumulativeExtra = 0;
773
+ const items = todayAppts.map((row) => {
774
+ if (Number(row.serial_number) < currentSerial) {
775
+ cumulativeExtra += Number(row.extra_minutes || 0);
776
+ }
777
+ const baseMinutes = timeSlotToMinutes(row.time_slot);
778
+ return {
779
+ serialNumber: Number(row.serial_number),
780
+ patientPhone: row.patient_phone,
781
+ timeSlot: row.time_slot,
782
+ expectedTime: minutesToTimeSlot(baseMinutes + cumulativeExtra),
783
+ extraMinutes: Number(row.extra_minutes || 0),
784
+ status: row.status,
785
+ };
786
+ });
787
+
788
+ return {
789
+ doctorId,
790
+ date: normalizedDate,
791
+ currentSerial,
792
+ isActive,
793
+ appointments: items,
794
+ };
795
+ }
796
+
797
  module.exports = {
798
  createAppointment,
799
  getAppointmentById,
 
804
  rescheduleAppointment,
805
  respondToRescheduleRequest,
806
  updateAppointmentStatus,
807
+ startAppointment,
808
+ endAppointment,
809
+ extendAppointment,
810
+ getQueueStatus,
811
  };
src/utils/serializers.js CHANGED
@@ -106,6 +106,9 @@ function mapAppointment(row) {
106
  status: row.reschedule_status,
107
  }
108
  : null,
 
 
 
109
  };
110
  }
111
 
 
106
  status: row.reschedule_status,
107
  }
108
  : null,
109
+ startedAt: row.started_at || null,
110
+ endedAt: row.ended_at || null,
111
+ extraMinutes: Number(row.extra_minutes || 0),
112
  };
113
  }
114