@blackcode_sa/metaestetics-api 1.6.15 → 1.6.17

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.
@@ -9,6 +9,7 @@ import { Appointment, PaymentStatus } from "../../types/appointment";
9
9
  import { UserRole } from "../../types";
10
10
  import { Timestamp as FirebaseClientTimestamp } from "@firebase/firestore";
11
11
  import { TimestampUtils } from "../../utils/TimestampUtils";
12
+ import { Logger } from "../logger";
12
13
 
13
14
  export class NotificationsAdmin {
14
15
  private expo: Expo;
@@ -46,19 +47,31 @@ export class NotificationsAdmin {
46
47
  * Priprema Expo poruku za slanje
47
48
  */
48
49
  private prepareExpoMessage(notification: Notification): ExpoPushMessage[] {
49
- return notification.notificationTokens
50
- .filter((token) => Expo.isExpoPushToken(token))
51
- .map((token) => ({
52
- to: token,
53
- sound: "default",
54
- title: notification.title,
55
- body: notification.body,
56
- data: {
57
- notificationId: notification.id,
58
- notificationType: notification.notificationType,
59
- userId: notification.userId,
60
- },
61
- }));
50
+ const validTokens = notification.notificationTokens.filter((token) =>
51
+ Expo.isExpoPushToken(token)
52
+ );
53
+
54
+ Logger.info(
55
+ `[NotificationsAdmin] Preparing Expo messages for notification ${notification.id}`,
56
+ {
57
+ totalTokens: notification.notificationTokens.length,
58
+ validTokens: validTokens.length,
59
+ invalidTokensCount:
60
+ notification.notificationTokens.length - validTokens.length,
61
+ }
62
+ );
63
+
64
+ return validTokens.map((token) => ({
65
+ to: token,
66
+ sound: "default",
67
+ title: notification.title,
68
+ body: notification.body,
69
+ data: {
70
+ notificationId: notification.id,
71
+ notificationType: notification.notificationType,
72
+ userId: notification.userId,
73
+ },
74
+ }));
62
75
  }
63
76
 
64
77
  /**
@@ -93,27 +106,58 @@ export class NotificationsAdmin {
93
106
  */
94
107
  async sendPushNotification(notification: Notification): Promise<boolean> {
95
108
  try {
109
+ Logger.info(
110
+ `[NotificationsAdmin] Processing notification ${notification.id} for sending`,
111
+ {
112
+ userId: notification.userId,
113
+ tokenCount: notification.notificationTokens?.length || 0,
114
+ type: notification.notificationType,
115
+ }
116
+ );
117
+
96
118
  const messages = this.prepareExpoMessage(notification);
97
119
 
98
120
  if (messages.length === 0) {
121
+ const errorMsg = "No valid notification tokens found";
122
+ Logger.error(
123
+ `[NotificationsAdmin] ${errorMsg} for notification ${notification.id}`
124
+ );
99
125
  await this.updateNotificationStatus(
100
126
  notification.id!,
101
127
  NotificationStatus.FAILED,
102
- "No valid notification tokens found"
128
+ errorMsg
103
129
  );
104
130
  return false;
105
131
  }
106
132
 
107
133
  const chunks = this.expo.chunkPushNotifications(messages);
134
+ Logger.info(
135
+ `[NotificationsAdmin] Sending ${messages.length} messages in ${chunks.length} chunks for notification ${notification.id}`
136
+ );
137
+
108
138
  const tickets: ExpoPushTicket[][] = [];
109
139
 
110
140
  // Šaljemo sve chunks
111
- for (const chunk of chunks) {
141
+ for (let i = 0; i < chunks.length; i++) {
142
+ const chunk = chunks[i];
112
143
  try {
144
+ Logger.info(
145
+ `[NotificationsAdmin] Sending chunk ${i + 1}/${
146
+ chunks.length
147
+ } with ${chunk.length} messages`
148
+ );
113
149
  const ticketChunk = await this.expo.sendPushNotificationsAsync(chunk);
150
+ Logger.info(
151
+ `[NotificationsAdmin] Received ${
152
+ ticketChunk.length
153
+ } tickets for chunk ${i + 1}`
154
+ );
114
155
  tickets.push(ticketChunk);
115
156
  } catch (error) {
116
- console.error(`Chunk sending error:`, error);
157
+ Logger.error(
158
+ `[NotificationsAdmin] Chunk ${i + 1} sending error:`,
159
+ error
160
+ );
117
161
  throw error;
118
162
  }
119
163
  }
@@ -121,25 +165,62 @@ export class NotificationsAdmin {
121
165
  // Proveravamo rezultate
122
166
  let hasErrors = false;
123
167
  const errors: string[] = [];
168
+ const ticketsFlat = tickets.flat();
124
169
 
125
- tickets.flat().forEach((ticket, index) => {
170
+ // Log detailed ticket information
171
+ const ticketResults = {
172
+ total: ticketsFlat.length,
173
+ success: 0,
174
+ error: 0,
175
+ errorDetails: {} as Record<string, number>,
176
+ };
177
+
178
+ ticketsFlat.forEach((ticket, index) => {
126
179
  if (ticket.status === "error") {
127
180
  hasErrors = true;
128
- errors.push(
129
- `Token ${notification.notificationTokens[index]}: ${ticket.message}`
130
- );
181
+ ticketResults.error++;
182
+
183
+ // Count each error type
184
+ const errorMessage = ticket.message || "Unknown error";
185
+ ticketResults.errorDetails[errorMessage] =
186
+ (ticketResults.errorDetails[errorMessage] || 0) + 1;
187
+
188
+ const tokenInfo =
189
+ index < notification.notificationTokens.length
190
+ ? `Token ${notification.notificationTokens[index]}`
191
+ : `Token at index ${index}`;
192
+
193
+ errors.push(`${tokenInfo}: ${errorMessage}`);
194
+ } else {
195
+ ticketResults.success++;
131
196
  }
132
197
  });
133
198
 
199
+ Logger.info(
200
+ `[NotificationsAdmin] Ticket results for notification ${notification.id}`,
201
+ ticketResults
202
+ );
203
+
134
204
  if (hasErrors) {
205
+ const errorSummary = errors.join("; ");
206
+ Logger.warn(
207
+ `[NotificationsAdmin] Partial success or errors in notification ${notification.id}`,
208
+ { errorCount: errors.length, errorSummary }
209
+ );
210
+
135
211
  await this.updateNotificationStatus(
136
212
  notification.id!,
137
- NotificationStatus.PARTIAL_SUCCESS,
138
- errors.join("; ")
213
+ ticketResults.success > 0
214
+ ? NotificationStatus.PARTIAL_SUCCESS
215
+ : NotificationStatus.FAILED,
216
+ errorSummary
139
217
  );
140
- return false;
218
+ return ticketResults.success > 0;
141
219
  }
142
220
 
221
+ Logger.info(
222
+ `[NotificationsAdmin] Successfully sent notification ${notification.id} to all recipients`
223
+ );
143
224
  await this.updateNotificationStatus(
144
225
  notification.id!,
145
226
  NotificationStatus.SENT
@@ -148,7 +229,10 @@ export class NotificationsAdmin {
148
229
  } catch (error) {
149
230
  const errorMessage =
150
231
  error instanceof Error ? error.message : "Unknown error";
151
- console.error("Greška pri slanju notifikacije:", error);
232
+ Logger.error(
233
+ `[NotificationsAdmin] Critical error sending notification ${notification.id}:`,
234
+ error
235
+ );
152
236
  await this.updateNotificationStatus(
153
237
  notification.id!,
154
238
  NotificationStatus.FAILED,
@@ -164,6 +248,10 @@ export class NotificationsAdmin {
164
248
  async processPendingNotifications(batchSize: number = 100): Promise<void> {
165
249
  const now = admin.firestore.Timestamp.now();
166
250
 
251
+ Logger.info(
252
+ `[NotificationsAdmin] Starting to process pending notifications with batch size ${batchSize}`
253
+ );
254
+
167
255
  const pendingNotifications = await this.db
168
256
  .collection("notifications")
169
257
  .where("status", "==", NotificationStatus.PENDING)
@@ -171,12 +259,27 @@ export class NotificationsAdmin {
171
259
  .limit(batchSize)
172
260
  .get();
173
261
 
262
+ Logger.info(
263
+ `[NotificationsAdmin] Found ${pendingNotifications.size} pending notifications to process`
264
+ );
265
+
266
+ if (pendingNotifications.empty) {
267
+ Logger.info(
268
+ "[NotificationsAdmin] No pending notifications found to process"
269
+ );
270
+ return;
271
+ }
272
+
174
273
  const results = await Promise.allSettled(
175
274
  pendingNotifications.docs.map(async (doc) => {
176
275
  const notification = {
177
276
  id: doc.id,
178
277
  ...doc.data(),
179
278
  } as Notification;
279
+
280
+ Logger.info(
281
+ `[NotificationsAdmin] Processing notification ${notification.id} of type ${notification.notificationType}`
282
+ );
180
283
  return this.sendPushNotification(notification);
181
284
  })
182
285
  );
@@ -189,8 +292,8 @@ export class NotificationsAdmin {
189
292
  (r) => r.status === "rejected" || (r.status === "fulfilled" && !r.value)
190
293
  ).length;
191
294
 
192
- console.log(
193
- `Processed ${results.length} notifications: ${successful} successful, ${failed} failed`
295
+ Logger.info(
296
+ `[NotificationsAdmin] Processed ${results.length} notifications: ${successful} successful, ${failed} failed`
194
297
  );
195
298
  }
196
299
 
@@ -204,6 +307,11 @@ export class NotificationsAdmin {
204
307
  const cutoffDate = new Date();
205
308
  cutoffDate.setDate(cutoffDate.getDate() - daysOld);
206
309
 
310
+ Logger.info(
311
+ `[NotificationsAdmin] Starting cleanup of notifications older than ${daysOld} days`
312
+ );
313
+
314
+ let totalDeleted = 0;
207
315
  while (true) {
208
316
  const oldNotifications = await this.db
209
317
  .collection("notifications")
@@ -216,6 +324,9 @@ export class NotificationsAdmin {
216
324
  .get();
217
325
 
218
326
  if (oldNotifications.empty) {
327
+ Logger.info(
328
+ `[NotificationsAdmin] No more old notifications to delete. Total deleted: ${totalDeleted}`
329
+ );
219
330
  break;
220
331
  }
221
332
 
@@ -225,8 +336,9 @@ export class NotificationsAdmin {
225
336
  });
226
337
 
227
338
  await batch.commit();
228
- console.log(
229
- `Deleted batch of ${oldNotifications.size} old notifications`
339
+ totalDeleted += oldNotifications.size;
340
+ Logger.info(
341
+ `[NotificationsAdmin] Deleted batch of ${oldNotifications.size} old notifications. Running total: ${totalDeleted}`
230
342
  );
231
343
  }
232
344
  }
@@ -5,6 +5,14 @@ import {
5
5
  serverTimestamp,
6
6
  arrayUnion,
7
7
  arrayRemove,
8
+ QueryConstraint,
9
+ where,
10
+ orderBy,
11
+ collection,
12
+ query,
13
+ limit,
14
+ startAfter,
15
+ getDocs,
8
16
  } from "firebase/firestore";
9
17
  import { Auth } from "firebase/auth";
10
18
  import { FirebaseApp } from "firebase/app";
@@ -21,6 +29,7 @@ import {
21
29
  PatientReviewInfo,
22
30
  LinkedFormInfo,
23
31
  type CreateAppointmentHttpData,
32
+ APPOINTMENTS_COLLECTION,
24
33
  } from "../../types/appointment";
25
34
  import {
26
35
  createAppointmentSchema,
@@ -1002,4 +1011,233 @@ export class AppointmentService extends BaseService {
1002
1011
 
1003
1012
  return this.updateAppointment(appointmentId, updateData);
1004
1013
  }
1014
+
1015
+ /**
1016
+ * Gets upcoming appointments for a specific patient.
1017
+ * These include appointments with statuses: PENDING, CONFIRMED, CHECKED_IN, IN_PROGRESS
1018
+ *
1019
+ * @param patientId ID of the patient
1020
+ * @param options Optional parameters for filtering and pagination
1021
+ * @returns Found appointments and the last document for pagination
1022
+ */
1023
+ async getUpcomingPatientAppointments(
1024
+ patientId: string,
1025
+ options?: {
1026
+ startDate?: Date; // Optional starting date (defaults to now)
1027
+ endDate?: Date;
1028
+ limit?: number;
1029
+ startAfter?: DocumentSnapshot;
1030
+ }
1031
+ ): Promise<{
1032
+ appointments: Appointment[];
1033
+ lastDoc: DocumentSnapshot | null;
1034
+ }> {
1035
+ try {
1036
+ console.log(
1037
+ `[APPOINTMENT_SERVICE] Getting upcoming appointments for patient: ${patientId}`
1038
+ );
1039
+
1040
+ // Default to current date/time if no startDate provided
1041
+ const effectiveStartDate = options?.startDate || new Date();
1042
+
1043
+ // Define the statuses considered as "upcoming"
1044
+ const upcomingStatuses = [
1045
+ AppointmentStatus.PENDING,
1046
+ AppointmentStatus.CONFIRMED,
1047
+ AppointmentStatus.CHECKED_IN,
1048
+ AppointmentStatus.IN_PROGRESS,
1049
+ AppointmentStatus.RESCHEDULED_BY_CLINIC,
1050
+ ];
1051
+
1052
+ // Build query constraints
1053
+ const constraints: QueryConstraint[] = [];
1054
+
1055
+ // Patient ID filter
1056
+ constraints.push(where("patientId", "==", patientId));
1057
+
1058
+ // Status filter - multiple statuses
1059
+ constraints.push(where("status", "in", upcomingStatuses));
1060
+
1061
+ // Date range filters
1062
+ constraints.push(
1063
+ where(
1064
+ "appointmentStartTime",
1065
+ ">=",
1066
+ Timestamp.fromDate(effectiveStartDate)
1067
+ )
1068
+ );
1069
+
1070
+ if (options?.endDate) {
1071
+ constraints.push(
1072
+ where(
1073
+ "appointmentStartTime",
1074
+ "<=",
1075
+ Timestamp.fromDate(options.endDate)
1076
+ )
1077
+ );
1078
+ }
1079
+
1080
+ // Order by appointment start time (ascending for upcoming - closest first)
1081
+ constraints.push(orderBy("appointmentStartTime", "asc"));
1082
+
1083
+ // Add pagination if specified
1084
+ if (options?.limit) {
1085
+ constraints.push(limit(options.limit));
1086
+ }
1087
+
1088
+ if (options?.startAfter) {
1089
+ constraints.push(startAfter(options.startAfter));
1090
+ }
1091
+
1092
+ // Execute query
1093
+ const q = query(
1094
+ collection(this.db, APPOINTMENTS_COLLECTION),
1095
+ ...constraints
1096
+ );
1097
+ const querySnapshot = await getDocs(q);
1098
+
1099
+ // Extract results
1100
+ const appointments = querySnapshot.docs.map(
1101
+ (doc) => doc.data() as Appointment
1102
+ );
1103
+
1104
+ // Get last document for pagination
1105
+ const lastDoc =
1106
+ querySnapshot.docs.length > 0
1107
+ ? querySnapshot.docs[querySnapshot.docs.length - 1]
1108
+ : null;
1109
+
1110
+ console.log(
1111
+ `[APPOINTMENT_SERVICE] Found ${appointments.length} upcoming appointments for patient ${patientId}`
1112
+ );
1113
+
1114
+ return { appointments, lastDoc };
1115
+ } catch (error) {
1116
+ console.error(
1117
+ `[APPOINTMENT_SERVICE] Error getting upcoming appointments for patient ${patientId}:`,
1118
+ error
1119
+ );
1120
+ throw error;
1121
+ }
1122
+ }
1123
+
1124
+ /**
1125
+ * Gets past appointments for a specific patient.
1126
+ * These include appointments with statuses: COMPLETED, CANCELED_PATIENT,
1127
+ * CANCELED_PATIENT_RESCHEDULED, CANCELED_CLINIC, NO_SHOW
1128
+ *
1129
+ * @param patientId ID of the patient
1130
+ * @param options Optional parameters for filtering and pagination
1131
+ * @returns Found appointments and the last document for pagination
1132
+ */
1133
+ async getPastPatientAppointments(
1134
+ patientId: string,
1135
+ options?: {
1136
+ startDate?: Date;
1137
+ endDate?: Date; // Optional end date (defaults to now)
1138
+ showCanceled?: boolean; // Whether to include canceled appointments
1139
+ showNoShow?: boolean; // Whether to include no-show appointments
1140
+ limit?: number;
1141
+ startAfter?: DocumentSnapshot;
1142
+ }
1143
+ ): Promise<{
1144
+ appointments: Appointment[];
1145
+ lastDoc: DocumentSnapshot | null;
1146
+ }> {
1147
+ try {
1148
+ console.log(
1149
+ `[APPOINTMENT_SERVICE] Getting past appointments for patient: ${patientId}`
1150
+ );
1151
+
1152
+ // Default to current date/time if no endDate provided
1153
+ const effectiveEndDate = options?.endDate || new Date();
1154
+
1155
+ // Define the base status for past appointments
1156
+ const pastStatuses: AppointmentStatus[] = [AppointmentStatus.COMPLETED];
1157
+
1158
+ // Add canceled statuses if requested
1159
+ if (options?.showCanceled) {
1160
+ pastStatuses.push(
1161
+ AppointmentStatus.CANCELED_PATIENT,
1162
+ AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
1163
+ AppointmentStatus.CANCELED_CLINIC
1164
+ );
1165
+ }
1166
+
1167
+ // Add no-show status if requested
1168
+ if (options?.showNoShow) {
1169
+ pastStatuses.push(AppointmentStatus.NO_SHOW);
1170
+ }
1171
+
1172
+ // Build query constraints
1173
+ const constraints: QueryConstraint[] = [];
1174
+
1175
+ // Patient ID filter
1176
+ constraints.push(where("patientId", "==", patientId));
1177
+
1178
+ // Status filter - multiple statuses
1179
+ constraints.push(where("status", "in", pastStatuses));
1180
+
1181
+ // Date range filters
1182
+ if (options?.startDate) {
1183
+ constraints.push(
1184
+ where(
1185
+ "appointmentStartTime",
1186
+ ">=",
1187
+ Timestamp.fromDate(options.startDate)
1188
+ )
1189
+ );
1190
+ }
1191
+
1192
+ constraints.push(
1193
+ where(
1194
+ "appointmentStartTime",
1195
+ "<=",
1196
+ Timestamp.fromDate(effectiveEndDate)
1197
+ )
1198
+ );
1199
+
1200
+ // Order by appointment start time (descending for past - most recent first)
1201
+ constraints.push(orderBy("appointmentStartTime", "desc"));
1202
+
1203
+ // Add pagination if specified
1204
+ if (options?.limit) {
1205
+ constraints.push(limit(options.limit));
1206
+ }
1207
+
1208
+ if (options?.startAfter) {
1209
+ constraints.push(startAfter(options.startAfter));
1210
+ }
1211
+
1212
+ // Execute query
1213
+ const q = query(
1214
+ collection(this.db, APPOINTMENTS_COLLECTION),
1215
+ ...constraints
1216
+ );
1217
+ const querySnapshot = await getDocs(q);
1218
+
1219
+ // Extract results
1220
+ const appointments = querySnapshot.docs.map(
1221
+ (doc) => doc.data() as Appointment
1222
+ );
1223
+
1224
+ // Get last document for pagination
1225
+ const lastDoc =
1226
+ querySnapshot.docs.length > 0
1227
+ ? querySnapshot.docs[querySnapshot.docs.length - 1]
1228
+ : null;
1229
+
1230
+ console.log(
1231
+ `[APPOINTMENT_SERVICE] Found ${appointments.length} past appointments for patient ${patientId}`
1232
+ );
1233
+
1234
+ return { appointments, lastDoc };
1235
+ } catch (error) {
1236
+ console.error(
1237
+ `[APPOINTMENT_SERVICE] Error getting past appointments for patient ${patientId}:`,
1238
+ error
1239
+ );
1240
+ throw error;
1241
+ }
1242
+ }
1005
1243
  }