@blackcode_sa/metaestetics-api 1.14.57 → 1.14.58
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 +5 -0
- package/dist/admin/index.d.ts +5 -0
- package/dist/admin/index.js +366 -2
- package/dist/admin/index.mjs +366 -2
- package/dist/index.d.mts +20 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +94 -27
- package/dist/index.mjs +95 -27
- package/package.json +1 -1
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +383 -2
- package/src/services/procedure/procedure.service.ts +137 -40
package/dist/index.mjs
CHANGED
|
@@ -21583,6 +21583,7 @@ import {
|
|
|
21583
21583
|
updateDoc as updateDoc36,
|
|
21584
21584
|
setDoc as setDoc27,
|
|
21585
21585
|
deleteDoc as deleteDoc19,
|
|
21586
|
+
Timestamp as Timestamp36,
|
|
21586
21587
|
serverTimestamp as serverTimestamp31,
|
|
21587
21588
|
writeBatch as writeBatch6,
|
|
21588
21589
|
orderBy as orderBy18,
|
|
@@ -22838,10 +22839,72 @@ var ProcedureService = class extends BaseService {
|
|
|
22838
22839
|
throw error;
|
|
22839
22840
|
}
|
|
22840
22841
|
}
|
|
22842
|
+
/**
|
|
22843
|
+
* Creates a serializable cursor from a DocumentSnapshot or returns the cursor values.
|
|
22844
|
+
* This format can be passed through React Native state/Redux without losing data.
|
|
22845
|
+
*
|
|
22846
|
+
* @param doc - The Firestore DocumentSnapshot
|
|
22847
|
+
* @param orderByField - The field used in orderBy clause
|
|
22848
|
+
* @returns Serializable cursor object with values needed for startAfter
|
|
22849
|
+
*/
|
|
22850
|
+
createSerializableCursor(doc47, orderByField = "createdAt") {
|
|
22851
|
+
if (!doc47) return null;
|
|
22852
|
+
const data = typeof doc47.data === "function" ? doc47.data() : doc47;
|
|
22853
|
+
const docId = doc47.id || (data == null ? void 0 : data.id);
|
|
22854
|
+
if (!docId) return null;
|
|
22855
|
+
let orderByValue = data == null ? void 0 : data[orderByField];
|
|
22856
|
+
if (orderByValue && typeof orderByValue.toDate === "function") {
|
|
22857
|
+
orderByValue = orderByValue.toMillis();
|
|
22858
|
+
} else if (orderByValue && orderByValue.seconds) {
|
|
22859
|
+
orderByValue = orderByValue.seconds * 1e3 + (orderByValue.nanoseconds || 0) / 1e6;
|
|
22860
|
+
}
|
|
22861
|
+
return {
|
|
22862
|
+
__cursor: true,
|
|
22863
|
+
values: [orderByValue],
|
|
22864
|
+
id: docId,
|
|
22865
|
+
orderByField
|
|
22866
|
+
};
|
|
22867
|
+
}
|
|
22868
|
+
/**
|
|
22869
|
+
* Converts a serializable cursor back to values for startAfter.
|
|
22870
|
+
* Handles both native DocumentSnapshots and serialized cursor objects.
|
|
22871
|
+
*
|
|
22872
|
+
* @param lastDoc - Either a DocumentSnapshot or a serializable cursor object
|
|
22873
|
+
* @param orderByField - The field used in orderBy clause (for validation)
|
|
22874
|
+
* @returns Values to spread into startAfter, or null if invalid
|
|
22875
|
+
*/
|
|
22876
|
+
getCursorValuesForStartAfter(lastDoc, orderByField = "createdAt") {
|
|
22877
|
+
if (!lastDoc) return null;
|
|
22878
|
+
if (typeof lastDoc.data === "function") {
|
|
22879
|
+
return [lastDoc];
|
|
22880
|
+
}
|
|
22881
|
+
if (lastDoc.__cursor && Array.isArray(lastDoc.values)) {
|
|
22882
|
+
if (orderByField === "createdAt" && typeof lastDoc.values[0] === "number") {
|
|
22883
|
+
const timestamp = Timestamp36.fromMillis(lastDoc.values[0]);
|
|
22884
|
+
return [timestamp];
|
|
22885
|
+
}
|
|
22886
|
+
return lastDoc.values;
|
|
22887
|
+
}
|
|
22888
|
+
if (Array.isArray(lastDoc)) {
|
|
22889
|
+
return lastDoc;
|
|
22890
|
+
}
|
|
22891
|
+
if (lastDoc[orderByField]) {
|
|
22892
|
+
let value = lastDoc[orderByField];
|
|
22893
|
+
if (typeof value === "number" && orderByField === "createdAt") {
|
|
22894
|
+
value = Timestamp36.fromMillis(value);
|
|
22895
|
+
} else if (value.seconds && orderByField === "createdAt") {
|
|
22896
|
+
value = new Timestamp36(value.seconds, value.nanoseconds || 0);
|
|
22897
|
+
}
|
|
22898
|
+
return [value];
|
|
22899
|
+
}
|
|
22900
|
+
console.warn("[PROCEDURE_SERVICE] Could not parse lastDoc cursor:", typeof lastDoc);
|
|
22901
|
+
return null;
|
|
22902
|
+
}
|
|
22841
22903
|
/**
|
|
22842
22904
|
* Searches and filters procedures based on multiple criteria
|
|
22843
22905
|
*
|
|
22844
|
-
* @note Frontend
|
|
22906
|
+
* @note Frontend can now send either a DocumentSnapshot or a serializable cursor object.
|
|
22907
|
+
* The serializable cursor format is: { __cursor: true, values: [...], id: string, orderByField: string }
|
|
22845
22908
|
*
|
|
22846
22909
|
* @param filters - Various filters to apply
|
|
22847
22910
|
* @param filters.nameSearch - Optional search text for procedure name
|
|
@@ -22937,12 +23000,10 @@ var ProcedureService = class extends BaseService {
|
|
|
22937
23000
|
constraints.push(where33("nameLower", "<=", searchTerm + "\uF8FF"));
|
|
22938
23001
|
constraints.push(orderBy18("nameLower"));
|
|
22939
23002
|
if (filters.lastDoc) {
|
|
22940
|
-
|
|
22941
|
-
|
|
22942
|
-
|
|
22943
|
-
|
|
22944
|
-
} else {
|
|
22945
|
-
constraints.push(startAfter14(filters.lastDoc));
|
|
23003
|
+
const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, "nameLower");
|
|
23004
|
+
if (cursorValues) {
|
|
23005
|
+
constraints.push(startAfter14(...cursorValues));
|
|
23006
|
+
console.log("[PROCEDURE_SERVICE] Strategy 1: Using cursor for pagination");
|
|
22946
23007
|
}
|
|
22947
23008
|
}
|
|
22948
23009
|
constraints.push(limit16(filters.pagination || 10));
|
|
@@ -22961,8 +23022,9 @@ var ProcedureService = class extends BaseService {
|
|
|
22961
23022
|
if (querySnapshot.docs.length < (filters.pagination || 10)) {
|
|
22962
23023
|
return { procedures, lastDoc: null };
|
|
22963
23024
|
}
|
|
22964
|
-
const
|
|
22965
|
-
|
|
23025
|
+
const lastDocSnapshot = querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
23026
|
+
const serializableCursor = this.createSerializableCursor(lastDocSnapshot, "nameLower");
|
|
23027
|
+
return { procedures, lastDoc: serializableCursor };
|
|
22966
23028
|
} catch (error) {
|
|
22967
23029
|
console.log("[PROCEDURE_SERVICE] Strategy 1 failed:", error);
|
|
22968
23030
|
}
|
|
@@ -22980,12 +23042,10 @@ var ProcedureService = class extends BaseService {
|
|
|
22980
23042
|
constraints.push(where33("name", "<=", searchTerm + "\uF8FF"));
|
|
22981
23043
|
constraints.push(orderBy18("name"));
|
|
22982
23044
|
if (filters.lastDoc) {
|
|
22983
|
-
|
|
22984
|
-
|
|
22985
|
-
|
|
22986
|
-
|
|
22987
|
-
} else {
|
|
22988
|
-
constraints.push(startAfter14(filters.lastDoc));
|
|
23045
|
+
const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, "name");
|
|
23046
|
+
if (cursorValues) {
|
|
23047
|
+
constraints.push(startAfter14(...cursorValues));
|
|
23048
|
+
console.log("[PROCEDURE_SERVICE] Strategy 2: Using cursor for pagination");
|
|
22989
23049
|
}
|
|
22990
23050
|
}
|
|
22991
23051
|
constraints.push(limit16(filters.pagination || 10));
|
|
@@ -23004,8 +23064,9 @@ var ProcedureService = class extends BaseService {
|
|
|
23004
23064
|
if (querySnapshot.docs.length < (filters.pagination || 10)) {
|
|
23005
23065
|
return { procedures, lastDoc: null };
|
|
23006
23066
|
}
|
|
23007
|
-
const
|
|
23008
|
-
|
|
23067
|
+
const lastDocSnapshot = querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
23068
|
+
const serializableCursor = this.createSerializableCursor(lastDocSnapshot, "name");
|
|
23069
|
+
return { procedures, lastDoc: serializableCursor };
|
|
23009
23070
|
} catch (error) {
|
|
23010
23071
|
console.log("[PROCEDURE_SERVICE] Strategy 2 failed:", error);
|
|
23011
23072
|
}
|
|
@@ -23055,12 +23116,10 @@ var ProcedureService = class extends BaseService {
|
|
|
23055
23116
|
);
|
|
23056
23117
|
constraints.push(orderBy18("createdAt", "desc"));
|
|
23057
23118
|
if (filters.lastDoc) {
|
|
23058
|
-
|
|
23059
|
-
|
|
23060
|
-
|
|
23061
|
-
|
|
23062
|
-
} else {
|
|
23063
|
-
constraints.push(startAfter14(filters.lastDoc));
|
|
23119
|
+
const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, "createdAt");
|
|
23120
|
+
if (cursorValues) {
|
|
23121
|
+
constraints.push(startAfter14(...cursorValues));
|
|
23122
|
+
console.log("[PROCEDURE_SERVICE] Strategy 3: Using cursor for pagination");
|
|
23064
23123
|
}
|
|
23065
23124
|
}
|
|
23066
23125
|
constraints.push(limit16(filters.pagination || 10));
|
|
@@ -23091,8 +23150,9 @@ var ProcedureService = class extends BaseService {
|
|
|
23091
23150
|
if (querySnapshot.docs.length < (filters.pagination || 10)) {
|
|
23092
23151
|
return { procedures, lastDoc: null };
|
|
23093
23152
|
}
|
|
23094
|
-
const
|
|
23095
|
-
|
|
23153
|
+
const lastDocSnapshot = querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
23154
|
+
const serializableCursor = this.createSerializableCursor(lastDocSnapshot, "createdAt");
|
|
23155
|
+
return { procedures, lastDoc: serializableCursor };
|
|
23096
23156
|
} catch (error) {
|
|
23097
23157
|
console.log("[PROCEDURE_SERVICE] Strategy 3 failed:", error);
|
|
23098
23158
|
}
|
|
@@ -23108,6 +23168,13 @@ var ProcedureService = class extends BaseService {
|
|
|
23108
23168
|
if (filters.clinicId) {
|
|
23109
23169
|
constraints.push(where33("clinicBranchId", "==", filters.clinicId));
|
|
23110
23170
|
}
|
|
23171
|
+
if (filters.lastDoc) {
|
|
23172
|
+
const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, "createdAt");
|
|
23173
|
+
if (cursorValues) {
|
|
23174
|
+
constraints.push(startAfter14(...cursorValues));
|
|
23175
|
+
console.log("[PROCEDURE_SERVICE] Strategy 4: Using cursor for pagination");
|
|
23176
|
+
}
|
|
23177
|
+
}
|
|
23111
23178
|
constraints.push(limit16(filters.pagination || 10));
|
|
23112
23179
|
const q = query33(collection33(this.db, PROCEDURES_COLLECTION), ...constraints);
|
|
23113
23180
|
const querySnapshot = await getDocs33(q);
|
|
@@ -23122,8 +23189,9 @@ var ProcedureService = class extends BaseService {
|
|
|
23122
23189
|
if (querySnapshot.docs.length < (filters.pagination || 10)) {
|
|
23123
23190
|
return { procedures, lastDoc: null };
|
|
23124
23191
|
}
|
|
23125
|
-
const
|
|
23126
|
-
|
|
23192
|
+
const lastDocSnapshot = querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
23193
|
+
const serializableCursor = this.createSerializableCursor(lastDocSnapshot, "createdAt");
|
|
23194
|
+
return { procedures, lastDoc: serializableCursor };
|
|
23127
23195
|
} catch (error) {
|
|
23128
23196
|
console.log("[PROCEDURE_SERVICE] Strategy 4 failed:", error);
|
|
23129
23197
|
}
|
package/package.json
CHANGED
|
@@ -411,6 +411,285 @@ const clinicAppointmentRequestedTemplate = `
|
|
|
411
411
|
</html>
|
|
412
412
|
`;
|
|
413
413
|
|
|
414
|
+
const appointmentRescheduledProposalTemplate = `
|
|
415
|
+
<!DOCTYPE html>
|
|
416
|
+
<html lang="en">
|
|
417
|
+
<head>
|
|
418
|
+
<meta charset="UTF-8">
|
|
419
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
420
|
+
<title>Appointment Reschedule Proposal</title>
|
|
421
|
+
<style>
|
|
422
|
+
body {
|
|
423
|
+
margin: 0;
|
|
424
|
+
padding: 0;
|
|
425
|
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
426
|
+
background: linear-gradient(135deg, #a48a76 0%, #67574A 100%);
|
|
427
|
+
min-height: 100vh;
|
|
428
|
+
}
|
|
429
|
+
.email-container {
|
|
430
|
+
max-width: 600px;
|
|
431
|
+
margin: 0 auto;
|
|
432
|
+
background: #ffffff;
|
|
433
|
+
border-radius: 20px;
|
|
434
|
+
overflow: hidden;
|
|
435
|
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
|
436
|
+
margin-top: 40px;
|
|
437
|
+
margin-bottom: 40px;
|
|
438
|
+
}
|
|
439
|
+
.header {
|
|
440
|
+
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
|
|
441
|
+
padding: 40px 30px;
|
|
442
|
+
text-align: center;
|
|
443
|
+
color: white;
|
|
444
|
+
}
|
|
445
|
+
.header h1 {
|
|
446
|
+
margin: 0;
|
|
447
|
+
font-size: 28px;
|
|
448
|
+
font-weight: 300;
|
|
449
|
+
letter-spacing: 1px;
|
|
450
|
+
}
|
|
451
|
+
.header .subtitle {
|
|
452
|
+
margin: 10px 0 0 0;
|
|
453
|
+
font-size: 16px;
|
|
454
|
+
opacity: 0.9;
|
|
455
|
+
font-weight: 300;
|
|
456
|
+
}
|
|
457
|
+
.content {
|
|
458
|
+
padding: 40px 30px;
|
|
459
|
+
}
|
|
460
|
+
.greeting {
|
|
461
|
+
font-size: 18px;
|
|
462
|
+
color: #333;
|
|
463
|
+
margin-bottom: 25px;
|
|
464
|
+
font-weight: 400;
|
|
465
|
+
}
|
|
466
|
+
.info-box {
|
|
467
|
+
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
|
|
468
|
+
border-radius: 15px;
|
|
469
|
+
padding: 25px;
|
|
470
|
+
margin: 25px 0;
|
|
471
|
+
border-left: 5px solid #ff9800;
|
|
472
|
+
}
|
|
473
|
+
.info-box p {
|
|
474
|
+
margin: 0;
|
|
475
|
+
color: #e65100;
|
|
476
|
+
font-size: 15px;
|
|
477
|
+
font-weight: 500;
|
|
478
|
+
line-height: 1.6;
|
|
479
|
+
}
|
|
480
|
+
.time-comparison {
|
|
481
|
+
display: grid;
|
|
482
|
+
gap: 20px;
|
|
483
|
+
margin: 25px 0;
|
|
484
|
+
}
|
|
485
|
+
.time-card {
|
|
486
|
+
background: linear-gradient(135deg, #f8f6f5 0%, #f5f3f2 100%);
|
|
487
|
+
border-radius: 15px;
|
|
488
|
+
padding: 25px;
|
|
489
|
+
border-left: 5px solid #a48a76;
|
|
490
|
+
}
|
|
491
|
+
.time-card.old-time {
|
|
492
|
+
border-left-color: #9e9e9e;
|
|
493
|
+
opacity: 0.8;
|
|
494
|
+
}
|
|
495
|
+
.time-card.new-time {
|
|
496
|
+
border-left-color: #ff9800;
|
|
497
|
+
background: linear-gradient(135deg, #fff8e1 0%, #ffe0b2 100%);
|
|
498
|
+
}
|
|
499
|
+
.time-label {
|
|
500
|
+
font-size: 14px;
|
|
501
|
+
font-weight: 600;
|
|
502
|
+
color: #666;
|
|
503
|
+
text-transform: uppercase;
|
|
504
|
+
letter-spacing: 0.5px;
|
|
505
|
+
margin-bottom: 10px;
|
|
506
|
+
}
|
|
507
|
+
.time-label.old {
|
|
508
|
+
color: #757575;
|
|
509
|
+
}
|
|
510
|
+
.time-label.new {
|
|
511
|
+
color: #f57c00;
|
|
512
|
+
}
|
|
513
|
+
.appointment-card {
|
|
514
|
+
background: linear-gradient(135deg, #f8f6f5 0%, #f5f3f2 100%);
|
|
515
|
+
border-radius: 15px;
|
|
516
|
+
padding: 30px;
|
|
517
|
+
margin: 25px 0;
|
|
518
|
+
border-left: 5px solid #a48a76;
|
|
519
|
+
}
|
|
520
|
+
.appointment-title {
|
|
521
|
+
font-size: 20px;
|
|
522
|
+
color: #a48a76;
|
|
523
|
+
margin-bottom: 20px;
|
|
524
|
+
font-weight: 600;
|
|
525
|
+
}
|
|
526
|
+
.appointment-details {
|
|
527
|
+
display: grid;
|
|
528
|
+
gap: 15px;
|
|
529
|
+
}
|
|
530
|
+
.detail-row {
|
|
531
|
+
display: flex;
|
|
532
|
+
align-items: center;
|
|
533
|
+
padding: 8px 0;
|
|
534
|
+
}
|
|
535
|
+
.detail-label {
|
|
536
|
+
font-weight: 600;
|
|
537
|
+
color: #555;
|
|
538
|
+
min-width: 120px;
|
|
539
|
+
font-size: 14px;
|
|
540
|
+
}
|
|
541
|
+
.detail-value {
|
|
542
|
+
color: #333;
|
|
543
|
+
font-size: 16px;
|
|
544
|
+
font-weight: 500;
|
|
545
|
+
}
|
|
546
|
+
.procedure-name {
|
|
547
|
+
color: #67574A;
|
|
548
|
+
font-weight: 600;
|
|
549
|
+
}
|
|
550
|
+
.clinic-name {
|
|
551
|
+
color: #a48a76;
|
|
552
|
+
font-weight: 600;
|
|
553
|
+
}
|
|
554
|
+
.action-section {
|
|
555
|
+
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
|
|
556
|
+
border-radius: 15px;
|
|
557
|
+
padding: 30px;
|
|
558
|
+
margin: 30px 0;
|
|
559
|
+
text-align: center;
|
|
560
|
+
border-left: 5px solid #4caf50;
|
|
561
|
+
}
|
|
562
|
+
.action-section h3 {
|
|
563
|
+
margin: 0 0 15px 0;
|
|
564
|
+
color: #2e7d32;
|
|
565
|
+
font-weight: 600;
|
|
566
|
+
font-size: 18px;
|
|
567
|
+
}
|
|
568
|
+
.action-section p {
|
|
569
|
+
margin: 0 0 20px 0;
|
|
570
|
+
color: #555;
|
|
571
|
+
font-size: 15px;
|
|
572
|
+
line-height: 1.6;
|
|
573
|
+
}
|
|
574
|
+
.footer {
|
|
575
|
+
background: #f8f9fa;
|
|
576
|
+
padding: 25px 30px;
|
|
577
|
+
text-align: center;
|
|
578
|
+
color: #666;
|
|
579
|
+
font-size: 14px;
|
|
580
|
+
border-top: 1px solid #eee;
|
|
581
|
+
}
|
|
582
|
+
.logo {
|
|
583
|
+
font-size: 24px;
|
|
584
|
+
font-weight: 700;
|
|
585
|
+
color: white;
|
|
586
|
+
margin-bottom: 5px;
|
|
587
|
+
}
|
|
588
|
+
.divider {
|
|
589
|
+
height: 2px;
|
|
590
|
+
background: linear-gradient(90deg, #a48a76, #67574A);
|
|
591
|
+
margin: 25px 0;
|
|
592
|
+
border-radius: 1px;
|
|
593
|
+
}
|
|
594
|
+
.icon {
|
|
595
|
+
text-align: center;
|
|
596
|
+
margin: 20px 0;
|
|
597
|
+
font-size: 48px;
|
|
598
|
+
}
|
|
599
|
+
.arrow {
|
|
600
|
+
text-align: center;
|
|
601
|
+
font-size: 32px;
|
|
602
|
+
color: #ff9800;
|
|
603
|
+
margin: 10px 0;
|
|
604
|
+
}
|
|
605
|
+
</style>
|
|
606
|
+
</head>
|
|
607
|
+
<body>
|
|
608
|
+
<div class="email-container">
|
|
609
|
+
<div class="header">
|
|
610
|
+
<div class="logo">MetaEstetics</div>
|
|
611
|
+
<h1>Appointment Reschedule Proposal</h1>
|
|
612
|
+
<div class="subtitle">Action Required</div>
|
|
613
|
+
</div>
|
|
614
|
+
|
|
615
|
+
<div class="content">
|
|
616
|
+
<div class="icon">📅</div>
|
|
617
|
+
|
|
618
|
+
<div class="greeting">
|
|
619
|
+
Dear <strong>{{patientName}}</strong>,
|
|
620
|
+
</div>
|
|
621
|
+
|
|
622
|
+
<p style="color: #555; font-size: 16px; line-height: 1.6; margin-bottom: 25px;">
|
|
623
|
+
We hope this message finds you well. We need to propose a new time for your upcoming appointment. Please review the details below and confirm if the new time works for you.
|
|
624
|
+
</p>
|
|
625
|
+
|
|
626
|
+
<div class="info-box">
|
|
627
|
+
<p><strong>⚠️ Important:</strong> Please respond to this reschedule proposal as soon as possible. Your appointment will remain pending until you confirm or reject the new time.</p>
|
|
628
|
+
</div>
|
|
629
|
+
|
|
630
|
+
<div class="appointment-card">
|
|
631
|
+
<div class="appointment-title">📋 Appointment Details</div>
|
|
632
|
+
<div class="appointment-details">
|
|
633
|
+
<div class="detail-row">
|
|
634
|
+
<div class="detail-label">Procedure:</div>
|
|
635
|
+
<div class="detail-value procedure-name">{{procedureName}}</div>
|
|
636
|
+
</div>
|
|
637
|
+
<div class="detail-row">
|
|
638
|
+
<div class="detail-label">Practitioner:</div>
|
|
639
|
+
<div class="detail-value">{{practitionerName}}</div>
|
|
640
|
+
</div>
|
|
641
|
+
<div class="detail-row">
|
|
642
|
+
<div class="detail-label">Location:</div>
|
|
643
|
+
<div class="detail-value clinic-name">{{clinicName}}</div>
|
|
644
|
+
</div>
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
|
|
648
|
+
<div class="time-comparison">
|
|
649
|
+
<div class="time-card old-time">
|
|
650
|
+
<div class="time-label old">Previous Time</div>
|
|
651
|
+
<div style="font-size: 18px; font-weight: 600; color: #424242; margin-bottom: 8px;">{{previousDate}}</div>
|
|
652
|
+
<div style="font-size: 16px; color: #616161;">{{previousTime}}</div>
|
|
653
|
+
</div>
|
|
654
|
+
|
|
655
|
+
<div class="arrow">↓</div>
|
|
656
|
+
|
|
657
|
+
<div class="time-card new-time">
|
|
658
|
+
<div class="time-label new">Proposed New Time</div>
|
|
659
|
+
<div style="font-size: 18px; font-weight: 600; color: #e65100; margin-bottom: 8px;">{{newDate}}</div>
|
|
660
|
+
<div style="font-size: 16px; color: #f57c00; font-weight: 500;">{{newTime}}</div>
|
|
661
|
+
</div>
|
|
662
|
+
</div>
|
|
663
|
+
|
|
664
|
+
<div class="divider"></div>
|
|
665
|
+
|
|
666
|
+
<div class="action-section">
|
|
667
|
+
<h3>What's Next?</h3>
|
|
668
|
+
<p>
|
|
669
|
+
Please open the MetaEstetics app to accept or reject this reschedule proposal.
|
|
670
|
+
If the new time works for you, simply tap "Accept Reschedule".
|
|
671
|
+
If not, you can reject it and we'll work with you to find an alternative time.
|
|
672
|
+
</p>
|
|
673
|
+
</div>
|
|
674
|
+
|
|
675
|
+
<p style="color: #555; font-size: 14px; line-height: 1.6; margin-top: 25px;">
|
|
676
|
+
<strong>Need Help?</strong> If you have any questions or concerns about this reschedule, please contact us directly through the app or reach out to {{clinicName}}.
|
|
677
|
+
</p>
|
|
678
|
+
</div>
|
|
679
|
+
|
|
680
|
+
<div class="footer">
|
|
681
|
+
<p style="margin: 0 0 10px 0;">
|
|
682
|
+
<strong>MetaEstetics</strong> - Premium Aesthetic Services
|
|
683
|
+
</p>
|
|
684
|
+
<p style="margin: 0; font-size: 12px; color: #999;">
|
|
685
|
+
This is an automated message. Please do not reply to this email.
|
|
686
|
+
</p>
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
689
|
+
</body>
|
|
690
|
+
</html>
|
|
691
|
+
`;
|
|
692
|
+
|
|
414
693
|
// --- Interface Definitions for Email Data ---
|
|
415
694
|
|
|
416
695
|
export interface AppointmentEmailDataBase {
|
|
@@ -707,13 +986,115 @@ export class AppointmentMailingService extends BaseMailingService {
|
|
|
707
986
|
return Promise.resolve();
|
|
708
987
|
}
|
|
709
988
|
|
|
989
|
+
/**
|
|
990
|
+
* Sends a reschedule proposal email to the patient
|
|
991
|
+
* @param data - Appointment reschedule proposal email data
|
|
992
|
+
* @returns Promise with the sending result
|
|
993
|
+
*/
|
|
710
994
|
async sendAppointmentRescheduledProposalEmail(
|
|
711
995
|
data: AppointmentRescheduledProposalEmailData,
|
|
712
996
|
): Promise<any> {
|
|
713
997
|
Logger.info(
|
|
714
|
-
`[AppointmentMailingService]
|
|
998
|
+
`[AppointmentMailingService] Preparing to send reschedule proposal email to patient: ${data.patientProfile.id}`,
|
|
715
999
|
);
|
|
716
|
-
|
|
1000
|
+
|
|
1001
|
+
const recipientEmail = data.patientProfile.email;
|
|
1002
|
+
|
|
1003
|
+
if (!recipientEmail) {
|
|
1004
|
+
Logger.error('[AppointmentMailingService] Patient email not found for reschedule proposal.', {
|
|
1005
|
+
patientId: data.patientProfile.id,
|
|
1006
|
+
});
|
|
1007
|
+
throw new Error('Patient email address is missing.');
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Get clinic timezone from appointment data, default to UTC if not available
|
|
1011
|
+
const clinicTimezone = data.appointment.clinic_tz || 'UTC';
|
|
1012
|
+
|
|
1013
|
+
Logger.debug('[AppointmentMailingService] Formatting appointment times for reschedule', {
|
|
1014
|
+
clinicTimezone,
|
|
1015
|
+
previousTime: data.previousStartTime.toDate().toISOString(),
|
|
1016
|
+
newTime: data.appointment.appointmentStartTime.toDate().toISOString(),
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
// Format previous time
|
|
1020
|
+
const previousFormattedTime = this.formatTimestampInClinicTimezone(
|
|
1021
|
+
data.previousStartTime,
|
|
1022
|
+
clinicTimezone,
|
|
1023
|
+
'time',
|
|
1024
|
+
);
|
|
1025
|
+
const previousFormattedDate = this.formatTimestampInClinicTimezone(
|
|
1026
|
+
data.previousStartTime,
|
|
1027
|
+
clinicTimezone,
|
|
1028
|
+
'date',
|
|
1029
|
+
);
|
|
1030
|
+
const previousTimezoneName = this.getTimezoneDisplayName(clinicTimezone);
|
|
1031
|
+
|
|
1032
|
+
// Format new proposed time
|
|
1033
|
+
const newFormattedTime = this.formatTimestampInClinicTimezone(
|
|
1034
|
+
data.appointment.appointmentStartTime,
|
|
1035
|
+
clinicTimezone,
|
|
1036
|
+
'time',
|
|
1037
|
+
);
|
|
1038
|
+
const newFormattedDate = this.formatTimestampInClinicTimezone(
|
|
1039
|
+
data.appointment.appointmentStartTime,
|
|
1040
|
+
clinicTimezone,
|
|
1041
|
+
'date',
|
|
1042
|
+
);
|
|
1043
|
+
const newTimezoneName = this.getTimezoneDisplayName(clinicTimezone);
|
|
1044
|
+
|
|
1045
|
+
const templateVariables = {
|
|
1046
|
+
patientName: data.appointment.patientInfo.fullName,
|
|
1047
|
+
procedureName: data.appointment.procedureInfo.name,
|
|
1048
|
+
practitionerName: data.appointment.practitionerInfo.name,
|
|
1049
|
+
clinicName: data.appointment.clinicInfo.name,
|
|
1050
|
+
previousDate: previousFormattedDate,
|
|
1051
|
+
previousTime: `${previousFormattedTime} (${previousTimezoneName})`,
|
|
1052
|
+
newDate: newFormattedDate,
|
|
1053
|
+
newTime: `${newFormattedTime} (${newTimezoneName})`,
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
const html = this.renderTemplate(appointmentRescheduledProposalTemplate, templateVariables);
|
|
1057
|
+
const subject =
|
|
1058
|
+
data.options?.customSubject ||
|
|
1059
|
+
`Action Required: Reschedule Proposal for Your ${data.appointment.procedureInfo.name} Appointment`;
|
|
1060
|
+
const fromAddress =
|
|
1061
|
+
data.options?.fromAddress ||
|
|
1062
|
+
`MetaEstetics <no-reply@${data.options?.mailgunDomain || this.DEFAULT_MAILGUN_DOMAIN}>`;
|
|
1063
|
+
const domainToSendFrom = data.options?.mailgunDomain || this.DEFAULT_MAILGUN_DOMAIN;
|
|
1064
|
+
|
|
1065
|
+
const mailgunSendData = {
|
|
1066
|
+
to: recipientEmail,
|
|
1067
|
+
from: fromAddress,
|
|
1068
|
+
subject,
|
|
1069
|
+
html,
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
try {
|
|
1073
|
+
const result = await this.sendEmail(domainToSendFrom, mailgunSendData);
|
|
1074
|
+
await this.logEmailAttempt(
|
|
1075
|
+
{ to: recipientEmail, subject, templateName: 'appointment_rescheduled_proposal' },
|
|
1076
|
+
true,
|
|
1077
|
+
);
|
|
1078
|
+
Logger.info(
|
|
1079
|
+
`[AppointmentMailingService] Successfully sent reschedule proposal email to ${recipientEmail}`,
|
|
1080
|
+
);
|
|
1081
|
+
return result;
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
await this.logEmailAttempt(
|
|
1084
|
+
{
|
|
1085
|
+
to: recipientEmail,
|
|
1086
|
+
subject,
|
|
1087
|
+
templateName: 'appointment_rescheduled_proposal',
|
|
1088
|
+
},
|
|
1089
|
+
false,
|
|
1090
|
+
error,
|
|
1091
|
+
);
|
|
1092
|
+
Logger.error(
|
|
1093
|
+
`[AppointmentMailingService] Error sending reschedule proposal email to ${recipientEmail}:`,
|
|
1094
|
+
error,
|
|
1095
|
+
);
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
717
1098
|
}
|
|
718
1099
|
|
|
719
1100
|
async sendReviewRequestEmail(data: ReviewRequestEmailData): Promise<any> {
|