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.
|
|
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
|
-
|
|
6
|
+
/** Escape HTML special characters to avoid injection. */
|
|
7
|
+
static _escapeHtml(str) {
|
|
8
|
+
if (typeof str !== 'string') return '';
|
|
9
|
+
return str
|
|
10
|
+
.replace(/&/g, '&')
|
|
11
|
+
.replace(/</g, '<')
|
|
12
|
+
.replace(/>/g, '>')
|
|
13
|
+
.replace(/"/g, '"')
|
|
14
|
+
.replace(/'/g, ''');
|
|
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} ${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
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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',
|
|
103
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
const
|
|
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
|
-
|
|
116
|
-
|
|
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)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
129
|
-
|
|
152
|
+
|
|
153
|
+
if (dayMap[scheduledWeekday] === undefined) {
|
|
154
|
+
return null;
|
|
130
155
|
}
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
}
|