@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.
- package/dist/admin/index.d.mts +41 -1
- package/dist/admin/index.d.ts +41 -1
- package/dist/admin/index.js +1104 -227
- package/dist/admin/index.mjs +1104 -227
- package/dist/index.d.mts +39 -3
- package/dist/index.d.ts +39 -3
- package/dist/index.js +95 -27
- package/dist/index.mjs +96 -27
- package/package.json +1 -1
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +12 -0
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +876 -4
- package/src/admin/notifications/notifications.admin.ts +57 -0
- package/src/services/procedure/procedure.service.ts +137 -40
- package/src/types/appointment/index.ts +6 -0
- package/src/types/notifications/index.ts +14 -0
|
@@ -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
|
|
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
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
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
|
-
//
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
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
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
-
//
|
|
1748
|
-
const
|
|
1749
|
-
querySnapshot.docs.length
|
|
1750
|
-
|
|
1751
|
-
|
|
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
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
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
|
-
//
|
|
1870
|
-
const
|
|
1871
|
-
|
|
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
|
-
//
|
|
1915
|
-
const
|
|
1916
|
-
|
|
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
|