mongodb-backup-service 1.0.1 → 1.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mongodb-backup-service",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Reusable Node.js service for automated MongoDB backups with scheduling, startup recovery, Excel export, retention, email notifications, and backup locking.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -61,15 +61,15 @@ class EmailNotifier {
61
61
  return;
62
62
  }
63
63
 
64
- const { subject, text } = EmailTemplates.getBackupReport(metadata);
65
-
66
- const mailOptions = {
67
- from: this.config.from || `"Database Backup Service" <${this.config.user}>`,
68
- to: this.config.to,
69
- subject,
70
- text
71
- };
64
+ const { subject, text, html } = EmailTemplates.getBackupReport(metadata);
72
65
 
66
+ const mailOptions = {
67
+ from: this.config.from || `"Database Backup Service" <${this.config.user}>`,
68
+ to: this.config.to,
69
+ subject,
70
+ text,
71
+ html
72
+ };
73
73
  logger.info(`Sending email notification to: ${this.config.to} | Subject: ${subject}`);
74
74
 
75
75
  try {
@@ -1,33 +1,596 @@
1
+ // src/notifications/emailTemplates.js
2
+ /**
3
+ * EmailTemplates – Generates email subject, plain-text and HTML bodies for backup reports.
4
+ */
1
5
  class EmailTemplates {
2
- static getBackupReport(metadata) {
6
+ /** Escape HTML special characters to avoid injection. */
7
+ static _escapeHtml(str) {
8
+ if (typeof str !== 'string') return '';
9
+ return str
10
+ .replace(/&/g, '&amp;')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/'/g, '&#39;');
15
+ }
16
+
17
+ /** Build the email subject line. */
18
+ static _buildSubject(metadata) {
19
+ const statusWord = metadata.status === 'success' ? 'Successful' : 'Failed';
20
+ return `Database Backup ${statusWord} — ${metadata.projectName} | ${metadata.backupDate}`;
21
+ }
22
+
23
+ /** Format duration from milliseconds into a human-readable string. */
24
+ static _formatDuration(ms) {
25
+ const mins = Math.floor(ms / 60000);
26
+ const secs = Math.floor((ms % 60000) / 1000);
27
+
28
+ if (mins === 0) {
29
+ return `${secs} seconds`;
30
+ }
31
+
32
+ return `${mins} minutes ${secs} seconds`;
33
+ }
34
+
35
+ /** Render a component status badge. */
36
+ static _renderStatusBadge(status) {
37
+ const normalized = (status || 'skipped').toLowerCase();
38
+
39
+ const map = {
40
+ success: {
41
+ icon: '✓',
42
+ label: 'Completed',
43
+ bg: '#ecfdf3',
44
+ color: '#067647'
45
+ },
46
+ failed: {
47
+ icon: '✕',
48
+ label: 'Failed',
49
+ bg: '#fef3f2',
50
+ color: '#b42318'
51
+ },
52
+ skipped: {
53
+ icon: '—',
54
+ label: 'Skipped',
55
+ bg: '#f2f4f7',
56
+ color: '#667085'
57
+ }
58
+ };
59
+
60
+ const item = map[normalized] || map.skipped;
61
+
62
+ return `
63
+ <span style="
64
+ display:inline-block;
65
+ background:${item.bg};
66
+ color:${item.color};
67
+ padding:5px 10px;
68
+ border-radius:999px;
69
+ font-size:12px;
70
+ font-weight:700;
71
+ white-space:nowrap;
72
+ ">
73
+ ${item.icon}&nbsp; ${item.label}
74
+ </span>
75
+ `;
76
+ }
77
+
78
+ /** Build the full HTML body. */
79
+ static _buildHtml(metadata) {
3
80
  const isSuccess = metadata.status === 'success';
4
- const subjectStatus = isSuccess ? 'Successful' : 'Failed';
5
-
6
- const durationMins = Math.floor(metadata.durationMs / 60000);
7
- const durationSecs = Math.floor((metadata.durationMs % 60000) / 1000);
8
- const durationStr = `${durationMins} minutes ${durationSecs} seconds`;
9
-
10
- const subject = `Database Backup ${subjectStatus} - ${metadata.projectName}`;
11
-
12
- let text = `Subject:\nDatabase Backup ${subjectStatus} - ${metadata.projectName}\n\n`;
13
- text += `Project:\n${metadata.projectName}\n\n`;
14
- text += `Backup Date:\n${metadata.backupDate}\n\n`;
15
- text += `Overall Status:\n${metadata.status.toUpperCase()}\n\n`;
16
-
17
- text += `MongoDB Dump:\n${metadata.mongoDump.status.toUpperCase()}\n`;
18
- if (metadata.mongoDump.file) text += `File: ${metadata.mongoDump.file}\n`;
19
- if (metadata.mongoDump.error) text += `Error: ${metadata.mongoDump.error}\n`;
20
- text += `\n`;
21
-
22
- text += `Excel Export:\n${metadata.excel.status.toUpperCase()}\n`;
23
- if (metadata.excel.file) text += `File: ${metadata.excel.file}\n`;
24
- if (metadata.excel.error) text += `Error: ${metadata.excel.error}\n`;
25
- text += `\n`;
26
-
27
- text += `Duration:\n${durationStr}\n`;
28
-
29
- return { subject, text };
81
+ const esc = EmailTemplates._escapeHtml;
82
+
83
+ const projectName = esc(metadata.projectName || 'Database');
84
+ const backupDate = esc(metadata.backupDate || 'N/A');
85
+ const startTime = esc(metadata.startTime || metadata.startedAt || 'N/A');
86
+ const endTime = esc(metadata.endTime || metadata.completedAt || 'N/A');
87
+ const duration = EmailTemplates._formatDuration(metadata.durationMs || 0);
88
+
89
+ const scheduleType = esc(
90
+ (metadata.schedule && metadata.schedule.type) || 'N/A'
91
+ );
92
+
93
+ const timezone = esc(
94
+ (metadata.schedule && metadata.schedule.timezone) || 'N/A'
95
+ );
96
+
97
+ const statusText = isSuccess ? 'SUCCESS' : 'FAILED';
98
+
99
+ const theme = isSuccess
100
+ ? {
101
+ accent: '#16a34a',
102
+ accentDark: '#15803d',
103
+ light: '#f0fdf4',
104
+ border: '#bbf7d0',
105
+ icon: '✓',
106
+ title: 'Backup Completed Successfully',
107
+ description: 'Your scheduled database backup completed successfully.'
108
+ }
109
+ : {
110
+ accent: '#dc2626',
111
+ accentDark: '#b91c1c',
112
+ light: '#fef2f2',
113
+ border: '#fecaca',
114
+ icon: '✕',
115
+ title: 'Backup Failed',
116
+ description: 'The database backup could not be completed successfully.'
117
+ };
118
+
119
+ const components = [
120
+ { name: 'MongoDB Dump', data: metadata.mongoDump },
121
+ { name: 'Excel Export', data: metadata.excel },
122
+ { name: 'Metadata Generation', data: metadata.metadata },
123
+ { name: 'Retention Cleanup', data: metadata.retention }
124
+ ];
125
+
126
+ const files = [
127
+ metadata.mongoDump && metadata.mongoDump.file,
128
+ metadata.excel && metadata.excel.file,
129
+ metadata.metadata && metadata.metadata.file
130
+ ].filter(Boolean);
131
+
132
+ const componentRows = components.map(component => {
133
+ const status =
134
+ component.data && component.data.status
135
+ ? component.data.status.toLowerCase()
136
+ : 'skipped';
137
+
138
+ return `
139
+ <tr>
140
+ <td style="
141
+ padding:13px 0;
142
+ border-bottom:1px solid #eaecf0;
143
+ font-size:14px;
144
+ color:#344054;
145
+ ">
146
+ ${esc(component.name)}
147
+ </td>
148
+
149
+ <td style="
150
+ padding:13px 0;
151
+ border-bottom:1px solid #eaecf0;
152
+ text-align:right;
153
+ ">
154
+ ${EmailTemplates._renderStatusBadge(status)}
155
+ </td>
156
+ </tr>
157
+ `;
158
+ }).join('');
159
+
160
+ const filesHtml = files.length
161
+ ? files.map(file => `
162
+ <div style="
163
+ padding:10px 12px;
164
+ margin-bottom:8px;
165
+ background:#f8fafc;
166
+ border:1px solid #eaecf0;
167
+ border-radius:8px;
168
+ font-size:13px;
169
+ color:#475467;
170
+ word-break:break-word;
171
+ ">
172
+ ${esc(file)}
173
+ </div>
174
+ `).join('')
175
+ : `
176
+ <div style="color:#667085;font-size:13px;">
177
+ No backup files were generated.
178
+ </div>
179
+ `;
180
+
181
+ let errorSection = '';
182
+
183
+ if (!isSuccess) {
184
+ const errors = components
185
+ .filter(component => component.data && component.data.error)
186
+ .map(component => `
187
+ <div style="
188
+ margin-bottom:10px;
189
+ padding:12px;
190
+ background:#fffafa;
191
+ border:1px solid #fecdca;
192
+ border-radius:8px;
193
+ font-size:13px;
194
+ line-height:1.5;
195
+ color:#475467;
196
+ ">
197
+ <strong style="color:#b42318;">
198
+ ${esc(component.name)}
199
+ </strong>
200
+ <br>
201
+ ${esc(component.data.error)}
202
+ </div>
203
+ `)
204
+ .join('');
205
+
206
+ errorSection = `
207
+ <tr>
208
+ <td style="padding-top:20px;">
209
+
210
+ <div style="
211
+ font-size:15px;
212
+ font-weight:700;
213
+ color:#101828;
214
+ margin-bottom:12px;
215
+ ">
216
+ Error Details
217
+ </div>
218
+
219
+ ${errors || `
220
+ <div style="
221
+ padding:12px;
222
+ background:#f8fafc;
223
+ border-radius:8px;
224
+ font-size:13px;
225
+ color:#667085;
226
+ ">
227
+ No detailed error information was provided.
228
+ </div>
229
+ `}
230
+
231
+ <div style="
232
+ margin-top:18px;
233
+ padding:14px;
234
+ background:#fffbeb;
235
+ border:1px solid #fedf89;
236
+ border-radius:8px;
237
+ font-size:13px;
238
+ line-height:1.5;
239
+ color:#475467;
240
+ ">
241
+ <strong style="color:#92400e;">
242
+ Action Required
243
+ </strong>
244
+ <br>
245
+ Please review the application logs and investigate
246
+ the failed backup component before the next scheduled run.
247
+ </div>
248
+
249
+ </td>
250
+ </tr>
251
+ `;
252
+ }
253
+
254
+ return `<!DOCTYPE html>
255
+ <html lang="en">
256
+ <head>
257
+ <meta charset="UTF-8">
258
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
259
+ <title>Database Backup Report</title>
260
+ </head>
261
+
262
+ <body style="
263
+ margin:0;
264
+ padding:0;
265
+ background:#f2f4f7;
266
+ font-family:Arial,Helvetica,sans-serif;
267
+ color:#101828;
268
+ ">
269
+
270
+ <table width="100%" cellpadding="0" cellspacing="0" border="0">
271
+ <tr>
272
+ <td align="center" style="padding:32px 16px;">
273
+
274
+ <table width="100%" cellpadding="0" cellspacing="0" border="0"
275
+ style="
276
+ max-width:620px;
277
+ background:#ffffff;
278
+ border:1px solid #eaecf0;
279
+ border-radius:14px;
280
+ overflow:hidden;
281
+ ">
282
+
283
+ <!-- HEADER -->
284
+ <tr>
285
+ <td style="
286
+ padding:26px 28px;
287
+ border-bottom:1px solid #eaecf0;
288
+ ">
289
+
290
+ <div style="
291
+ font-size:12px;
292
+ font-weight:700;
293
+ letter-spacing:1px;
294
+ color:#667085;
295
+ text-transform:uppercase;
296
+ margin-bottom:7px;
297
+ ">
298
+ Automated Database Backup
299
+ </div>
300
+
301
+ <div style="
302
+ font-size:24px;
303
+ font-weight:700;
304
+ color:#101828;
305
+ ">
306
+ Backup Report
307
+ </div>
308
+
309
+ <div style="
310
+ margin-top:6px;
311
+ font-size:14px;
312
+ color:#667085;
313
+ ">
314
+ ${projectName}
315
+ </div>
316
+
317
+ </td>
318
+ </tr>
319
+
320
+ <!-- STATUS -->
321
+ <tr>
322
+ <td style="padding:24px 28px 10px;">
323
+
324
+ <div style="
325
+ background:${theme.light};
326
+ border:1px solid ${theme.border};
327
+ border-radius:12px;
328
+ padding:18px;
329
+ ">
330
+
331
+ <table width="100%" cellpadding="0" cellspacing="0">
332
+ <tr>
333
+
334
+ <td width="48" valign="top">
335
+ <div style="
336
+ width:38px;
337
+ height:38px;
338
+ line-height:38px;
339
+ text-align:center;
340
+ background:${theme.accent};
341
+ color:#ffffff;
342
+ border-radius:50%;
343
+ font-size:20px;
344
+ font-weight:bold;
345
+ ">
346
+ ${theme.icon}
347
+ </div>
348
+ </td>
349
+
350
+ <td valign="middle">
351
+
352
+ <div style="
353
+ font-size:16px;
354
+ font-weight:700;
355
+ color:${theme.accentDark};
356
+ ">
357
+ ${theme.title}
358
+ </div>
359
+
360
+ <div style="
361
+ margin-top:4px;
362
+ font-size:13px;
363
+ line-height:1.5;
364
+ color:#475467;
365
+ ">
366
+ ${theme.description}
367
+ </div>
368
+
369
+ </td>
370
+
371
+ </tr>
372
+ </table>
373
+
374
+ </div>
375
+
376
+ </td>
377
+ </tr>
378
+
379
+ <!-- SUMMARY -->
380
+ <tr>
381
+ <td style="padding:14px 28px;">
382
+
383
+ <div style="
384
+ font-size:15px;
385
+ font-weight:700;
386
+ color:#101828;
387
+ margin-bottom:12px;
388
+ ">
389
+ Backup Summary
390
+ </div>
391
+
392
+ <table width="100%" cellpadding="0" cellspacing="0">
393
+
394
+ <tr>
395
+ <td style="padding:8px 0;color:#667085;font-size:13px;">
396
+ Backup Date
397
+ </td>
398
+ <td align="right" style="padding:8px 0;font-size:13px;font-weight:600;">
399
+ ${backupDate}
400
+ </td>
401
+ </tr>
402
+
403
+ <tr>
404
+ <td style="padding:8px 0;color:#667085;font-size:13px;">
405
+ Start Time
406
+ </td>
407
+ <td align="right" style="padding:8px 0;font-size:13px;">
408
+ ${startTime}
409
+ </td>
410
+ </tr>
411
+
412
+ <tr>
413
+ <td style="padding:8px 0;color:#667085;font-size:13px;">
414
+ End Time
415
+ </td>
416
+ <td align="right" style="padding:8px 0;font-size:13px;">
417
+ ${endTime}
418
+ </td>
419
+ </tr>
420
+
421
+ <tr>
422
+ <td style="padding:8px 0;color:#667085;font-size:13px;">
423
+ Duration
424
+ </td>
425
+ <td align="right" style="padding:8px 0;font-size:13px;font-weight:600;">
426
+ ${duration}
427
+ </td>
428
+ </tr>
429
+
430
+ <tr>
431
+ <td style="padding:8px 0;color:#667085;font-size:13px;">
432
+ Schedule
433
+ </td>
434
+ <td align="right" style="padding:8px 0;font-size:13px;">
435
+ ${scheduleType}
436
+ </td>
437
+ </tr>
438
+
439
+ <tr>
440
+ <td style="padding:8px 0;color:#667085;font-size:13px;">
441
+ Timezone
442
+ </td>
443
+ <td align="right" style="padding:8px 0;font-size:13px;">
444
+ ${timezone}
445
+ </td>
446
+ </tr>
447
+
448
+ <tr>
449
+ <td style="padding:8px 0;color:#667085;font-size:13px;">
450
+ Overall Status
451
+ </td>
452
+ <td align="right" style="
453
+ padding:8px 0;
454
+ font-size:13px;
455
+ font-weight:700;
456
+ color:${theme.accentDark};
457
+ ">
458
+ ${statusText}
459
+ </td>
460
+ </tr>
461
+
462
+ </table>
463
+
464
+ </td>
465
+ </tr>
466
+
467
+ <!-- COMPONENTS -->
468
+ <tr>
469
+ <td style="padding:14px 28px;">
470
+
471
+ <div style="
472
+ font-size:15px;
473
+ font-weight:700;
474
+ color:#101828;
475
+ margin-bottom:10px;
476
+ ">
477
+ Backup Components
478
+ </div>
479
+
480
+ <table width="100%" cellpadding="0" cellspacing="0">
481
+ <tbody>
482
+ ${componentRows}
483
+ </tbody>
484
+ </table>
485
+
486
+ </td>
487
+ </tr>
488
+
489
+ <!-- FILES -->
490
+ <tr>
491
+ <td style="padding:14px 28px;">
492
+
493
+ <div style="
494
+ font-size:15px;
495
+ font-weight:700;
496
+ color:#101828;
497
+ margin-bottom:12px;
498
+ ">
499
+ Generated Files
500
+ </div>
501
+
502
+ ${filesHtml}
503
+
504
+ </td>
505
+ </tr>
506
+
507
+ <!-- ERRORS -->
508
+ ${errorSection}
509
+
510
+ <!-- FOOTER -->
511
+ <tr>
512
+ <td style="
513
+ padding:24px 28px;
514
+ background:#f8fafc;
515
+ border-top:1px solid #eaecf0;
516
+ ">
517
+
518
+ <div style="
519
+ font-size:12px;
520
+ line-height:1.6;
521
+ color:#667085;
522
+ text-align:center;
523
+ ">
524
+ This is an automated notification from
525
+ <strong>MongoDB Backup Service</strong>.
526
+ <br>
527
+ Please do not reply to this email.
528
+ </div>
529
+
530
+ </td>
531
+ </tr>
532
+
533
+ </table>
534
+
535
+ </td>
536
+ </tr>
537
+ </table>
538
+
539
+ </body>
540
+ </html>`;
541
+ }
542
+
543
+ /** Generate subject, plain-text and HTML payloads. */
544
+ static getBackupReport(metadata) {
545
+ const subject = EmailTemplates._buildSubject(metadata);
546
+
547
+ let text =
548
+ `Database Backup Report\n` +
549
+ `======================\n\n` +
550
+ `Project: ${metadata.projectName || ''}\n` +
551
+ `Backup Date: ${metadata.backupDate || ''}\n` +
552
+ `Start Time: ${metadata.startTime || metadata.startedAt || ''}\n` +
553
+ `End Time: ${metadata.endTime || metadata.completedAt || ''}\n` +
554
+ `Duration: ${EmailTemplates._formatDuration(metadata.durationMs || 0)}\n` +
555
+ `Overall Status: ${(metadata.status || '').toUpperCase()}\n\n` +
556
+
557
+ `Backup Components\n` +
558
+ `-----------------\n` +
559
+ `MongoDB Dump: ${(metadata.mongoDump?.status || 'skipped').toUpperCase()}\n` +
560
+ `Excel Export: ${(metadata.excel?.status || 'skipped').toUpperCase()}\n` +
561
+ `Metadata Generation: ${(metadata.metadata?.status || 'skipped').toUpperCase()}\n` +
562
+ `Retention Cleanup: ${(metadata.retention?.status || 'skipped').toUpperCase()}\n\n` +
563
+
564
+ `Backup Files\n` +
565
+ `------------\n` +
566
+ `${[
567
+ metadata.mongoDump?.file,
568
+ metadata.excel?.file,
569
+ metadata.metadata?.file
570
+ ].filter(Boolean).join('\n') || 'None'}\n`;
571
+
572
+ if (metadata.status !== 'success') {
573
+ text +=
574
+ `\nError Details\n` +
575
+ `-------------\n` +
576
+ `${[
577
+ metadata.mongoDump,
578
+ metadata.excel,
579
+ metadata.metadata,
580
+ metadata.retention
581
+ ]
582
+ .filter(item => item?.error)
583
+ .map(item => item.error)
584
+ .join('\n') || 'No detailed error information provided.'}\n\n` +
585
+ `Action Required\n` +
586
+ `---------------\n` +
587
+ `Please review the application logs and investigate the failed backup component.\n`;
588
+ }
589
+
590
+ const html = EmailTemplates._buildHtml(metadata);
591
+
592
+ return { subject, text, html };
30
593
  }
31
594
  }
32
595
 
33
- module.exports = EmailTemplates;
596
+ module.exports = EmailTemplates;
@@ -91,70 +91,162 @@ class Scheduler {
91
91
  getMostRecentExpectedBackupDate() {
92
92
  const tz = this.config.schedule.timezone;
93
93
  const { type, time, day } = this.config.schedule;
94
+
94
95
  const [hourStr, minuteStr] = time.split(':');
95
96
  const schedHour = parseInt(hourStr, 10);
96
97
  const schedMin = parseInt(minuteStr, 10);
97
-
98
- // Get the current time parts in the configured timezone (no moment.js needed)
98
+
99
99
  const now = new Date();
100
+
100
101
  const tzParts = new Intl.DateTimeFormat('en-US', {
101
102
  timeZone: tz,
102
- hour: 'numeric', minute: 'numeric', hour12: false,
103
- year: 'numeric', month: '2-digit', day: '2-digit',
103
+ hour: 'numeric',
104
+ minute: 'numeric',
105
+ hour12: false,
106
+ year: 'numeric',
107
+ month: '2-digit',
108
+ day: '2-digit',
104
109
  weekday: 'long'
105
110
  }).formatToParts(now);
106
-
107
- const get = (type) => tzParts.find(p => p.type === type);
108
- const tzHour = parseInt(get('hour').value, 10);
109
- const tzMinute = parseInt(get('minute').value, 10);
110
- const tzDay = parseInt(get('day').value, 10);
111
- const tzWeekday = get('weekday').value.toLowerCase(); // e.g. 'wednesday'
112
-
111
+
112
+ const getPart = (type) =>
113
+ tzParts.find((part) => part.type === type)?.value;
114
+
115
+ const tzHour = parseInt(getPart('hour'), 10);
116
+ const tzMinute = parseInt(getPart('minute'), 10);
117
+ const tzDay = parseInt(getPart('day'), 10);
118
+ const tzWeekday = getPart('weekday').toLowerCase();
119
+
113
120
  const currentDateStr = DateUtils.getCurrentDateString(tz);
114
-
115
- // Has the scheduled time already passed today (in configured timezone)?
116
- const passedToday = (tzHour > schedHour) || (tzHour === schedHour && tzMinute >= schedMin);
117
-
121
+
122
+ const passedToday =
123
+ tzHour > schedHour ||
124
+ (tzHour === schedHour && tzMinute >= schedMin);
125
+
126
+ const dayMap = {
127
+ sunday: 0,
128
+ monday: 1,
129
+ tuesday: 2,
130
+ wednesday: 3,
131
+ thursday: 4,
132
+ friday: 5,
133
+ saturday: 6
134
+ };
135
+
136
+ // ---------------------------------------------------------
137
+ // DAILY
138
+ // ---------------------------------------------------------
118
139
  if (type.toLowerCase() === 'daily') {
119
- if (passedToday) return currentDateStr;
120
- // Scheduled time hasn't arrived yet today — yesterday's backup is the expected one
121
- const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
122
- return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(yesterday);
140
+ if (!passedToday) {
141
+ return null;
142
+ }
143
+
144
+ return currentDateStr;
123
145
  }
124
-
146
+
147
+ // ---------------------------------------------------------
148
+ // WEEKLY
149
+ // ---------------------------------------------------------
125
150
  if (type.toLowerCase() === 'weekly') {
126
- // Check if today is the scheduled weekday AND the time has passed
127
151
  const scheduledWeekday = (day || '').toLowerCase();
128
- if (tzWeekday === scheduledWeekday && passedToday) {
129
- return currentDateStr;
152
+
153
+ if (dayMap[scheduledWeekday] === undefined) {
154
+ return null;
130
155
  }
131
- // Otherwise: find the most recent occurrence of that weekday
132
- const dayMap = { 'sunday': 0, 'monday': 1, 'tuesday': 2, 'wednesday': 3, 'thursday': 4, 'friday': 5, 'saturday': 6 };
133
- const targetDow = dayMap[scheduledWeekday];
156
+
134
157
  const currentDow = dayMap[tzWeekday];
135
- if (targetDow === undefined) return null;
136
- let daysBack = (currentDow - targetDow + 7) % 7;
137
- if (daysBack === 0) daysBack = 7; // same weekday but time hasn't passed
138
- const prevDate = new Date(now.getTime() - daysBack * 24 * 60 * 60 * 1000);
139
- return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(prevDate);
158
+ const targetDow = dayMap[scheduledWeekday];
159
+
160
+ // Today is the scheduled weekday
161
+ if (currentDow === targetDow) {
162
+ // Scheduled time has not arrived yet.
163
+ // Therefore there is no missed backup yet.
164
+ if (!passedToday) {
165
+ return null;
166
+ }
167
+
168
+ // Scheduled time has passed today.
169
+ return currentDateStr;
170
+ }
171
+
172
+ // Today is after the scheduled weekday.
173
+ // Find the most recent scheduled weekday.
174
+ const daysBack = (currentDow - targetDow + 7) % 7;
175
+
176
+ const previousOccurrence = new Date(
177
+ now.getTime() - daysBack * 24 * 60 * 60 * 1000
178
+ );
179
+
180
+ return new Intl.DateTimeFormat('en-CA', {
181
+ timeZone: tz,
182
+ year: 'numeric',
183
+ month: '2-digit',
184
+ day: '2-digit'
185
+ }).format(previousOccurrence);
140
186
  }
141
-
187
+
188
+ // ---------------------------------------------------------
189
+ // MONTHLY
190
+ // ---------------------------------------------------------
142
191
  if (type.toLowerCase() === 'monthly') {
143
- const scheduledDayOfMonth = parseInt(day, 10);
144
- if (tzDay === scheduledDayOfMonth && passedToday) {
192
+ const scheduledDay = parseInt(day, 10);
193
+
194
+ if (
195
+ Number.isNaN(scheduledDay) ||
196
+ scheduledDay < 1 ||
197
+ scheduledDay > 28
198
+ ) {
199
+ return null;
200
+ }
201
+
202
+ // Today is the scheduled day.
203
+ if (tzDay === scheduledDay) {
204
+ // Scheduled time has not arrived yet.
205
+ if (!passedToday) {
206
+ return null;
207
+ }
208
+
145
209
  return currentDateStr;
146
210
  }
147
- // Otherwise: check the previous month's occurrence
148
- // Go back to the 1st of this month, then subtract one day to get last month, then format day
149
- const prevMonthDate = new Date(now);
150
- prevMonthDate.setDate(0); // last day of previous month
151
- const prevMonthParts = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit' }).formatToParts(prevMonthDate);
152
- const prevYear = prevMonthParts.find(p => p.type === 'year').value;
153
- const prevMonth = prevMonthParts.find(p => p.type === 'month').value;
154
- const dayPadded = String(scheduledDayOfMonth).padStart(2, '0');
155
- return `${prevYear}-${prevMonth}-${dayPadded}`;
211
+
212
+ // Today is after the scheduled day.
213
+ if (tzDay > scheduledDay) {
214
+ const currentYear = parseInt(
215
+ getPart('year'),
216
+ 10
217
+ );
218
+
219
+ const currentMonth = parseInt(
220
+ getPart('month'),
221
+ 10
222
+ );
223
+
224
+ return `${currentYear}-${String(currentMonth).padStart(2, '0')}-${String(scheduledDay).padStart(2, '0')}`;
225
+ }
226
+
227
+ // Today is before the scheduled day.
228
+ // Therefore the most recent occurrence was last month.
229
+ const currentYear = parseInt(
230
+ getPart('year'),
231
+ 10
232
+ );
233
+
234
+ const currentMonth = parseInt(
235
+ getPart('month'),
236
+ 10
237
+ );
238
+
239
+ let previousYear = currentYear;
240
+ let previousMonth = currentMonth - 1;
241
+
242
+ if (previousMonth === 0) {
243
+ previousMonth = 12;
244
+ previousYear--;
245
+ }
246
+
247
+ return `${previousYear}-${String(previousMonth).padStart(2, '0')}-${String(scheduledDay).padStart(2, '0')}`;
156
248
  }
157
-
249
+
158
250
  return null;
159
251
  }
160
252
  }