@blackcode_sa/metaestetics-api 1.14.57 → 1.14.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -707,4 +707,61 @@ export class NotificationsAdmin {
707
707
  return null;
708
708
  }
709
709
  }
710
+
711
+ /**
712
+ * Sends a reminder push notification for a pending reschedule request.
713
+ * Used when a clinic has proposed a reschedule and the patient hasn't responded.
714
+ * @param appointment The appointment with pending reschedule.
715
+ * @param patientUserId The ID of the patient.
716
+ * @param patientExpoTokens Array of Expo push tokens for the patient.
717
+ * @param reminderCount Optional count of reminders already sent (for tracking).
718
+ */
719
+ async sendRescheduleReminderPush(
720
+ appointment: Appointment,
721
+ patientUserId: string,
722
+ patientExpoTokens: string[],
723
+ reminderCount?: number
724
+ ): Promise<string | null> {
725
+ if (!patientExpoTokens || patientExpoTokens.length === 0) {
726
+ console.log(
727
+ `[NotificationsAdmin] No expo tokens for patient ${patientUserId} for appointment ${appointment.id} reschedule reminder. Skipping push.`
728
+ );
729
+ return null;
730
+ }
731
+
732
+ const title = "Reminder: Reschedule Request Pending";
733
+ const body = `You have a pending reschedule request for your ${appointment.procedureInfo.name} appointment. Please respond in the app.`;
734
+
735
+ const notificationTimestampForDb = admin.firestore.Timestamp.now();
736
+
737
+ const notificationData: Omit<
738
+ Notification,
739
+ "id" | "createdAt" | "updatedAt" | "status" | "isRead"
740
+ > = {
741
+ userId: patientUserId,
742
+ userRole: UserRole.PATIENT,
743
+ notificationType: NotificationType.APPOINTMENT_RESCHEDULED_REMINDER,
744
+ notificationTime: notificationTimestampForDb as any,
745
+ notificationTokens: patientExpoTokens,
746
+ title,
747
+ body,
748
+ appointmentId: appointment.id,
749
+ };
750
+
751
+ try {
752
+ const notificationId = await this.createNotification(
753
+ notificationData as Notification
754
+ );
755
+ console.log(
756
+ `[NotificationsAdmin] Created APPOINTMENT_RESCHEDULED_REMINDER notification ${notificationId} for patient ${patientUserId}. Reminder count: ${reminderCount ?? 1}.`
757
+ );
758
+ return notificationId;
759
+ } catch (error) {
760
+ console.error(
761
+ `[NotificationsAdmin] Error creating APPOINTMENT_RESCHEDULED_REMINDER notification for patient ${patientUserId}:`,
762
+ error
763
+ );
764
+ return null;
765
+ }
766
+ }
710
767
  }
@@ -1505,10 +1505,98 @@ export class ProcedureService extends BaseService {
1505
1505
  }
1506
1506
  }
1507
1507
 
1508
+ /**
1509
+ * Creates a serializable cursor from a DocumentSnapshot or returns the cursor values.
1510
+ * This format can be passed through React Native state/Redux without losing data.
1511
+ *
1512
+ * @param doc - The Firestore DocumentSnapshot
1513
+ * @param orderByField - The field used in orderBy clause
1514
+ * @returns Serializable cursor object with values needed for startAfter
1515
+ */
1516
+ private createSerializableCursor(
1517
+ doc: any,
1518
+ orderByField: string = 'createdAt',
1519
+ ): { __cursor: true; values: any[]; id: string; orderByField: string } | null {
1520
+ if (!doc) return null;
1521
+
1522
+ const data = typeof doc.data === 'function' ? doc.data() : doc;
1523
+ const docId = doc.id || data?.id;
1524
+
1525
+ if (!docId) return null;
1526
+
1527
+ // Get the value of the orderBy field
1528
+ let orderByValue = data?.[orderByField];
1529
+
1530
+ // Handle Firestore Timestamp
1531
+ if (orderByValue && typeof orderByValue.toDate === 'function') {
1532
+ orderByValue = orderByValue.toMillis();
1533
+ } else if (orderByValue && orderByValue.seconds) {
1534
+ // Serialized Timestamp
1535
+ orderByValue = orderByValue.seconds * 1000 + (orderByValue.nanoseconds || 0) / 1000000;
1536
+ }
1537
+
1538
+ return {
1539
+ __cursor: true,
1540
+ values: [orderByValue],
1541
+ id: docId,
1542
+ orderByField,
1543
+ };
1544
+ }
1545
+
1546
+ /**
1547
+ * Converts a serializable cursor back to values for startAfter.
1548
+ * Handles both native DocumentSnapshots and serialized cursor objects.
1549
+ *
1550
+ * @param lastDoc - Either a DocumentSnapshot or a serializable cursor object
1551
+ * @param orderByField - The field used in orderBy clause (for validation)
1552
+ * @returns Values to spread into startAfter, or null if invalid
1553
+ */
1554
+ private getCursorValuesForStartAfter(
1555
+ lastDoc: any,
1556
+ orderByField: string = 'createdAt',
1557
+ ): any[] | null {
1558
+ if (!lastDoc) return null;
1559
+
1560
+ // If it's a native DocumentSnapshot with data() method
1561
+ if (typeof lastDoc.data === 'function') {
1562
+ return [lastDoc];
1563
+ }
1564
+
1565
+ // If it's our serializable cursor format
1566
+ if (lastDoc.__cursor && Array.isArray(lastDoc.values)) {
1567
+ // Reconstruct Timestamp if needed for createdAt
1568
+ if (orderByField === 'createdAt' && typeof lastDoc.values[0] === 'number') {
1569
+ const timestamp = Timestamp.fromMillis(lastDoc.values[0]);
1570
+ return [timestamp];
1571
+ }
1572
+ return lastDoc.values;
1573
+ }
1574
+
1575
+ // If it's an array of values directly
1576
+ if (Array.isArray(lastDoc)) {
1577
+ return lastDoc;
1578
+ }
1579
+
1580
+ // Fallback: try to use the object's orderByField value
1581
+ if (lastDoc[orderByField]) {
1582
+ let value = lastDoc[orderByField];
1583
+ if (typeof value === 'number' && orderByField === 'createdAt') {
1584
+ value = Timestamp.fromMillis(value);
1585
+ } else if (value.seconds && orderByField === 'createdAt') {
1586
+ value = new Timestamp(value.seconds, value.nanoseconds || 0);
1587
+ }
1588
+ return [value];
1589
+ }
1590
+
1591
+ console.warn('[PROCEDURE_SERVICE] Could not parse lastDoc cursor:', typeof lastDoc);
1592
+ return null;
1593
+ }
1594
+
1508
1595
  /**
1509
1596
  * Searches and filters procedures based on multiple criteria
1510
1597
  *
1511
- * @note Frontend MORA da šalje ceo snapshot (ili barem sva polja po kojima sortiraš, npr. nameLower) kao lastDoc za paginaciju, a ne samo id!
1598
+ * @note Frontend can now send either a DocumentSnapshot or a serializable cursor object.
1599
+ * The serializable cursor format is: { __cursor: true, values: [...], id: string, orderByField: string }
1512
1600
  *
1513
1601
  * @param filters - Various filters to apply
1514
1602
  * @param filters.nameSearch - Optional search text for procedure name
@@ -1645,13 +1733,12 @@ export class ProcedureService extends BaseService {
1645
1733
  constraints.push(where('nameLower', '<=', searchTerm + '\uf8ff'));
1646
1734
  constraints.push(orderBy('nameLower'));
1647
1735
 
1736
+ // Handle lastDoc cursor - supports both native DocumentSnapshot and serializable cursor
1648
1737
  if (filters.lastDoc) {
1649
- if (typeof filters.lastDoc.data === 'function') {
1650
- constraints.push(startAfter(filters.lastDoc));
1651
- } else if (Array.isArray(filters.lastDoc)) {
1652
- constraints.push(startAfter(...filters.lastDoc));
1653
- } else {
1654
- constraints.push(startAfter(filters.lastDoc));
1738
+ const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, 'nameLower');
1739
+ if (cursorValues) {
1740
+ constraints.push(startAfter(...cursorValues));
1741
+ console.log('[PROCEDURE_SERVICE] Strategy 1: Using cursor for pagination');
1655
1742
  }
1656
1743
  }
1657
1744
  constraints.push(limit(filters.pagination || 10));
@@ -1679,14 +1766,13 @@ export class ProcedureService extends BaseService {
1679
1766
  return { procedures, lastDoc: null };
1680
1767
  }
1681
1768
 
1682
- // If we filtered out some procedures but got full query results, use last doc for pagination
1683
- // This allows fetching more pages to get enough filtered results
1684
- const lastDoc =
1685
- querySnapshot.docs.length > 0
1686
- ? querySnapshot.docs[querySnapshot.docs.length - 1]
1687
- : null;
1769
+ // Return serializable cursor for pagination
1770
+ const lastDocSnapshot = querySnapshot.docs.length > 0
1771
+ ? querySnapshot.docs[querySnapshot.docs.length - 1]
1772
+ : null;
1773
+ const serializableCursor = this.createSerializableCursor(lastDocSnapshot, 'nameLower');
1688
1774
 
1689
- return { procedures, lastDoc };
1775
+ return { procedures, lastDoc: serializableCursor };
1690
1776
  } catch (error) {
1691
1777
  console.log('[PROCEDURE_SERVICE] Strategy 1 failed:', error);
1692
1778
  }
@@ -1710,13 +1796,12 @@ export class ProcedureService extends BaseService {
1710
1796
  constraints.push(where('name', '<=', searchTerm + '\uf8ff'));
1711
1797
  constraints.push(orderBy('name'));
1712
1798
 
1799
+ // Handle lastDoc cursor - supports both native DocumentSnapshot and serializable cursor
1713
1800
  if (filters.lastDoc) {
1714
- if (typeof filters.lastDoc.data === 'function') {
1715
- constraints.push(startAfter(filters.lastDoc));
1716
- } else if (Array.isArray(filters.lastDoc)) {
1717
- constraints.push(startAfter(...filters.lastDoc));
1718
- } else {
1719
- constraints.push(startAfter(filters.lastDoc));
1801
+ const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, 'name');
1802
+ if (cursorValues) {
1803
+ constraints.push(startAfter(...cursorValues));
1804
+ console.log('[PROCEDURE_SERVICE] Strategy 2: Using cursor for pagination');
1720
1805
  }
1721
1806
  }
1722
1807
  constraints.push(limit(filters.pagination || 10));
@@ -1744,13 +1829,13 @@ export class ProcedureService extends BaseService {
1744
1829
  return { procedures, lastDoc: null };
1745
1830
  }
1746
1831
 
1747
- // If we filtered out some procedures but got full query results, use last doc for pagination
1748
- const lastDoc =
1749
- querySnapshot.docs.length > 0
1750
- ? querySnapshot.docs[querySnapshot.docs.length - 1]
1751
- : null;
1832
+ // Return serializable cursor for pagination
1833
+ const lastDocSnapshot = querySnapshot.docs.length > 0
1834
+ ? querySnapshot.docs[querySnapshot.docs.length - 1]
1835
+ : null;
1836
+ const serializableCursor = this.createSerializableCursor(lastDocSnapshot, 'name');
1752
1837
 
1753
- return { procedures, lastDoc };
1838
+ return { procedures, lastDoc: serializableCursor };
1754
1839
  } catch (error) {
1755
1840
  console.log('[PROCEDURE_SERVICE] Strategy 2 failed:', error);
1756
1841
  }
@@ -1819,13 +1904,12 @@ export class ProcedureService extends BaseService {
1819
1904
  );
1820
1905
  constraints.push(orderBy('createdAt', 'desc'));
1821
1906
 
1907
+ // Handle lastDoc cursor - supports both native DocumentSnapshot and serializable cursor
1822
1908
  if (filters.lastDoc) {
1823
- if (typeof filters.lastDoc.data === 'function') {
1824
- constraints.push(startAfter(filters.lastDoc));
1825
- } else if (Array.isArray(filters.lastDoc)) {
1826
- constraints.push(startAfter(...filters.lastDoc));
1827
- } else {
1828
- constraints.push(startAfter(filters.lastDoc));
1909
+ const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, 'createdAt');
1910
+ if (cursorValues) {
1911
+ constraints.push(startAfter(...cursorValues));
1912
+ console.log('[PROCEDURE_SERVICE] Strategy 3: Using cursor for pagination');
1829
1913
  }
1830
1914
  }
1831
1915
  constraints.push(limit(filters.pagination || 10));
@@ -1866,11 +1950,13 @@ export class ProcedureService extends BaseService {
1866
1950
  return { procedures, lastDoc: null };
1867
1951
  }
1868
1952
 
1869
- // If we filtered out some procedures but got full query results, use last doc for pagination
1870
- const lastDoc =
1871
- querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
1953
+ // Return serializable cursor for pagination
1954
+ const lastDocSnapshot = querySnapshot.docs.length > 0
1955
+ ? querySnapshot.docs[querySnapshot.docs.length - 1]
1956
+ : null;
1957
+ const serializableCursor = this.createSerializableCursor(lastDocSnapshot, 'createdAt');
1872
1958
 
1873
- return { procedures, lastDoc };
1959
+ return { procedures, lastDoc: serializableCursor };
1874
1960
  } catch (error) {
1875
1961
  console.log('[PROCEDURE_SERVICE] Strategy 3 failed:', error);
1876
1962
  }
@@ -1888,6 +1974,15 @@ export class ProcedureService extends BaseService {
1888
1974
  if (filters.clinicId) {
1889
1975
  constraints.push(where('clinicBranchId', '==', filters.clinicId));
1890
1976
  }
1977
+
1978
+ // Handle lastDoc cursor - supports both native DocumentSnapshot and serializable cursor
1979
+ if (filters.lastDoc) {
1980
+ const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, 'createdAt');
1981
+ if (cursorValues) {
1982
+ constraints.push(startAfter(...cursorValues));
1983
+ console.log('[PROCEDURE_SERVICE] Strategy 4: Using cursor for pagination');
1984
+ }
1985
+ }
1891
1986
  constraints.push(limit(filters.pagination || 10));
1892
1987
 
1893
1988
  const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
@@ -1911,11 +2006,13 @@ export class ProcedureService extends BaseService {
1911
2006
  return { procedures, lastDoc: null };
1912
2007
  }
1913
2008
 
1914
- // If we filtered out some procedures but got full query results, use last doc for pagination
1915
- const lastDoc =
1916
- querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
2009
+ // Return serializable cursor for pagination
2010
+ const lastDocSnapshot = querySnapshot.docs.length > 0
2011
+ ? querySnapshot.docs[querySnapshot.docs.length - 1]
2012
+ : null;
2013
+ const serializableCursor = this.createSerializableCursor(lastDocSnapshot, 'createdAt');
1917
2014
 
1918
- return { procedures, lastDoc };
2015
+ return { procedures, lastDoc: serializableCursor };
1919
2016
  } catch (error) {
1920
2017
  console.log('[PROCEDURE_SERVICE] Strategy 4 failed:', error);
1921
2018
  }
@@ -319,6 +319,9 @@ export interface Appointment {
319
319
  confirmationTime?: Timestamp | null;
320
320
  cancellationTime?: Timestamp | null;
321
321
  rescheduleTime?: Timestamp | null;
322
+ /** Reschedule reminder tracking */
323
+ rescheduleReminderSentAt?: Timestamp | null;
324
+ rescheduleReminderCount?: number;
322
325
  appointmentStartTime: Timestamp;
323
326
  appointmentEndTime: Timestamp;
324
327
  procedureActualStartTime?: Timestamp | null; // NEW: Actual start time of the procedure
@@ -418,6 +421,9 @@ export interface UpdateAppointmentData {
418
421
  confirmationTime?: Timestamp | FieldValue | null;
419
422
  cancellationTime?: Timestamp | FieldValue | null;
420
423
  rescheduleTime?: Timestamp | FieldValue | null;
424
+ /** Reschedule reminder tracking */
425
+ rescheduleReminderSentAt?: Timestamp | FieldValue | null;
426
+ rescheduleReminderCount?: number | FieldValue;
421
427
  procedureActualStartTime?: Timestamp | FieldValue | null; // NEW
422
428
  actualDurationMinutes?: number;
423
429
  cancellationReason?: string | null;
@@ -8,6 +8,7 @@ export enum NotificationType {
8
8
  APPOINTMENT_REMINDER = "appointmentReminder", // For upcoming appointments
9
9
  APPOINTMENT_STATUS_CHANGE = "appointmentStatusChange", // Generic for status changes like confirmed, checked-in etc.
10
10
  APPOINTMENT_RESCHEDULED_PROPOSAL = "appointmentRescheduledProposal", // When clinic proposes a new time
11
+ APPOINTMENT_RESCHEDULED_REMINDER = "appointmentRescheduledReminder", // Reminder for pending reschedule request
11
12
  APPOINTMENT_CANCELLED = "appointmentCancelled", // When an appointment is cancelled
12
13
 
13
14
  // --- Requirement-Driven Instructions ---
@@ -185,6 +186,18 @@ export interface AppointmentRescheduledProposalNotification
185
186
  // May include a deep link to confirm/reject reschedule.
186
187
  }
187
188
 
189
+ /**
190
+ * Notification reminding the patient about a pending reschedule request they haven't responded to.
191
+ * Example: "Reminder: You have a pending reschedule request for your [Procedure Name] appointment."
192
+ */
193
+ export interface AppointmentRescheduledReminderNotification
194
+ extends BaseNotification {
195
+ notificationType: NotificationType.APPOINTMENT_RESCHEDULED_REMINDER;
196
+ appointmentId: string; // Mandatory
197
+ procedureName?: string;
198
+ reminderCount?: number; // Number of reminders sent so far
199
+ }
200
+
188
201
  /**
189
202
  * Notification informing about a cancelled appointment.
190
203
  * Example: "Your appointment for [Procedure Name] on [Date] has been cancelled."
@@ -277,6 +290,7 @@ export type Notification =
277
290
  | AppointmentReminderNotification
278
291
  | AppointmentStatusChangeNotification
279
292
  | AppointmentRescheduledProposalNotification
293
+ | AppointmentRescheduledReminderNotification
280
294
  | AppointmentCancelledNotification
281
295
  | FormReminderNotification
282
296
  | FormSubmissionConfirmationNotification