domma-cms 0.15.0 → 0.17.0
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/admin/css/dashboard.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +4 -4
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +1 -1
- package/admin/js/templates/dashboard/activity-feed.html +3 -0
- package/admin/js/templates/dashboard/health-detail.html +2 -0
- package/admin/js/templates/dashboard/journeys.html +17 -0
- package/admin/js/templates/dashboard/kpi-strip.html +34 -0
- package/admin/js/templates/dashboard/spike-feed.html +3 -0
- package/admin/js/templates/dashboard/top-pages.html +3 -0
- package/admin/js/templates/dashboard/traffic-chart.html +3 -0
- package/admin/js/templates/dashboard.html +22 -44
- package/admin/js/views/dashboard/lib/escape.js +1 -0
- package/admin/js/views/dashboard/widgets/activity-feed.js +1 -0
- package/admin/js/views/dashboard/widgets/health-detail.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -0
- package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -0
- package/admin/js/views/dashboard/widgets/spike-feed.js +6 -0
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -0
- package/admin/js/views/dashboard/widgets/traffic-chart.js +1 -0
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/config/plugins.json +3 -0
- package/package.json +2 -2
- package/plugins/analytics/admin/templates/analytics.html +52 -1
- package/plugins/analytics/admin/views/analytics.js +157 -32
- package/plugins/analytics/config.js +10 -2
- package/plugins/analytics/daily.json +5 -0
- package/plugins/analytics/journeys.json +10 -0
- package/plugins/analytics/lifetime.json +25 -0
- package/plugins/analytics/plugin.js +429 -25
- package/plugins/analytics/plugin.json +9 -5
- package/plugins/analytics/public/inject-body.html +49 -7
- package/plugins/blog/admin/templates/blog.html +25 -2
- package/plugins/blog/admin/views/blog.js +72 -56
- package/plugins/blog/admin/views/post-editor.js +98 -79
- package/plugins/blog/plugin.js +133 -0
- package/plugins/blog/plugin.json +3 -3
- package/plugins/blog/templates/post.html +2 -1
- package/plugins/invoice/admin/templates/editor.html +129 -0
- package/plugins/invoice/admin/templates/index.html +43 -0
- package/plugins/invoice/admin/templates/issuers.html +5 -0
- package/plugins/invoice/admin/templates/receivers.html +5 -0
- package/plugins/invoice/admin/views/editor.js +267 -0
- package/plugins/invoice/admin/views/index.js +155 -0
- package/plugins/invoice/admin/views/issuers.js +23 -0
- package/plugins/invoice/admin/views/party-view.js +148 -0
- package/plugins/invoice/admin/views/receivers.js +22 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
- package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
- package/plugins/invoice/collections/invoices/schema.json +27 -0
- package/plugins/invoice/config.js +16 -0
- package/plugins/invoice/plugin.js +283 -0
- package/plugins/invoice/plugin.json +85 -0
- package/plugins/invoice/templates/invoice-print.html +213 -0
- package/server/routes/api/dashboard.js +239 -0
- package/server/server.js +2 -0
- package/server/services/email.js +60 -20
- package/server/services/health.js +282 -0
- package/server/services/markdown.js +24 -4
- package/server/services/plugins.js +37 -5
- package/server/services/renderer.js +9 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Invoice {{number}}</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { color-scheme: light; }
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
11
|
+
color: #222;
|
|
12
|
+
background: #f3f3f3;
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 24px;
|
|
15
|
+
font-size: 14px;
|
|
16
|
+
line-height: 1.5;
|
|
17
|
+
}
|
|
18
|
+
.invoice {
|
|
19
|
+
max-width: 800px;
|
|
20
|
+
margin: 0 auto;
|
|
21
|
+
background: #fff;
|
|
22
|
+
padding: 48px 56px;
|
|
23
|
+
box-shadow: 0 4px 20px rgba(0,0,0,.08);
|
|
24
|
+
}
|
|
25
|
+
.top {
|
|
26
|
+
display: flex;
|
|
27
|
+
justify-content: space-between;
|
|
28
|
+
align-items: flex-start;
|
|
29
|
+
gap: 24px;
|
|
30
|
+
border-bottom: 2px solid #e5e5e5;
|
|
31
|
+
padding-bottom: 24px;
|
|
32
|
+
margin-bottom: 32px;
|
|
33
|
+
}
|
|
34
|
+
.logo { max-height: 80px; max-width: 240px; display: block; margin-bottom: 12px; }
|
|
35
|
+
.meta { text-align: right; }
|
|
36
|
+
.meta h1 { margin: 0; font-size: 28px; letter-spacing: 1px; color: #000; }
|
|
37
|
+
.meta .number { font-size: 18px; color: #555; margin-top: 4px; }
|
|
38
|
+
.status {
|
|
39
|
+
display: inline-block;
|
|
40
|
+
padding: 4px 10px;
|
|
41
|
+
border-radius: 4px;
|
|
42
|
+
font-size: 11px;
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
letter-spacing: .5px;
|
|
45
|
+
background: #eee;
|
|
46
|
+
color: #444;
|
|
47
|
+
margin-top: 8px;
|
|
48
|
+
}
|
|
49
|
+
.parties {
|
|
50
|
+
display: grid;
|
|
51
|
+
grid-template-columns: 1fr 1fr;
|
|
52
|
+
gap: 32px;
|
|
53
|
+
margin-bottom: 32px;
|
|
54
|
+
}
|
|
55
|
+
.party h3 {
|
|
56
|
+
margin: 0 0 8px;
|
|
57
|
+
font-size: 11px;
|
|
58
|
+
text-transform: uppercase;
|
|
59
|
+
letter-spacing: 1px;
|
|
60
|
+
color: #888;
|
|
61
|
+
}
|
|
62
|
+
.party .name { font-weight: 700; font-size: 15px; margin-bottom: 4px; }
|
|
63
|
+
.party .lines { color: #555; font-size: 13px; }
|
|
64
|
+
table.items {
|
|
65
|
+
width: 100%;
|
|
66
|
+
border-collapse: collapse;
|
|
67
|
+
margin-bottom: 24px;
|
|
68
|
+
}
|
|
69
|
+
table.items th {
|
|
70
|
+
text-align: left;
|
|
71
|
+
font-size: 11px;
|
|
72
|
+
text-transform: uppercase;
|
|
73
|
+
letter-spacing: 1px;
|
|
74
|
+
color: #888;
|
|
75
|
+
padding: 8px 10px;
|
|
76
|
+
border-bottom: 2px solid #e5e5e5;
|
|
77
|
+
}
|
|
78
|
+
table.items td {
|
|
79
|
+
padding: 10px;
|
|
80
|
+
border-bottom: 1px solid #f0f0f0;
|
|
81
|
+
}
|
|
82
|
+
table.items td.num, table.items th.num { text-align: right; }
|
|
83
|
+
.totals {
|
|
84
|
+
width: 320px;
|
|
85
|
+
margin-left: auto;
|
|
86
|
+
margin-bottom: 32px;
|
|
87
|
+
}
|
|
88
|
+
.totals table { width: 100%; border-collapse: collapse; }
|
|
89
|
+
.totals th {
|
|
90
|
+
text-align: left;
|
|
91
|
+
padding: 8px 10px;
|
|
92
|
+
font-weight: 500;
|
|
93
|
+
color: #555;
|
|
94
|
+
}
|
|
95
|
+
.totals td.num {
|
|
96
|
+
text-align: right;
|
|
97
|
+
padding: 8px 10px;
|
|
98
|
+
font-variant-numeric: tabular-nums;
|
|
99
|
+
}
|
|
100
|
+
.totals .grand th, .totals .grand td {
|
|
101
|
+
font-size: 17px;
|
|
102
|
+
font-weight: 700;
|
|
103
|
+
color: #000;
|
|
104
|
+
border-top: 2px solid #222;
|
|
105
|
+
padding-top: 12px;
|
|
106
|
+
}
|
|
107
|
+
.footer {
|
|
108
|
+
margin-top: 32px;
|
|
109
|
+
padding-top: 16px;
|
|
110
|
+
border-top: 1px solid #e5e5e5;
|
|
111
|
+
font-size: 12px;
|
|
112
|
+
color: #888;
|
|
113
|
+
}
|
|
114
|
+
.notes, .bank {
|
|
115
|
+
margin-top: 24px;
|
|
116
|
+
padding: 12px 16px;
|
|
117
|
+
background: #fafafa;
|
|
118
|
+
border-radius: 4px;
|
|
119
|
+
font-size: 13px;
|
|
120
|
+
}
|
|
121
|
+
.muted { color: #999; font-style: italic; text-align: center; }
|
|
122
|
+
.actions {
|
|
123
|
+
max-width: 800px;
|
|
124
|
+
margin: 0 auto 16px;
|
|
125
|
+
display: flex;
|
|
126
|
+
justify-content: flex-end;
|
|
127
|
+
gap: 8px;
|
|
128
|
+
}
|
|
129
|
+
.actions button {
|
|
130
|
+
background: #222;
|
|
131
|
+
color: #fff;
|
|
132
|
+
border: 0;
|
|
133
|
+
padding: 8px 16px;
|
|
134
|
+
font-size: 13px;
|
|
135
|
+
border-radius: 4px;
|
|
136
|
+
cursor: pointer;
|
|
137
|
+
}
|
|
138
|
+
@media print {
|
|
139
|
+
body { background: #fff; padding: 0; }
|
|
140
|
+
.invoice { box-shadow: none; padding: 24px; }
|
|
141
|
+
.actions { display: none; }
|
|
142
|
+
}
|
|
143
|
+
</style>
|
|
144
|
+
</head>
|
|
145
|
+
<body>
|
|
146
|
+
|
|
147
|
+
<div class="actions">
|
|
148
|
+
<button onclick="window.print()">Print / Save as PDF</button>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div class="invoice">
|
|
152
|
+
<div class="top">
|
|
153
|
+
<div>
|
|
154
|
+
{{issuerLogo}}
|
|
155
|
+
<div class="party">
|
|
156
|
+
<div class="name">{{issuerName}}</div>
|
|
157
|
+
<div class="lines">{{issuerAddress}}</div>
|
|
158
|
+
<div class="lines">{{issuerEmail}}</div>
|
|
159
|
+
<div class="lines">{{issuerPhone}}</div>
|
|
160
|
+
{{issuerVat}}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
<div class="meta">
|
|
164
|
+
<h1>INVOICE</h1>
|
|
165
|
+
<div class="number">{{number}}</div>
|
|
166
|
+
<div>{{issueDate}}</div>
|
|
167
|
+
{{dueDate}}
|
|
168
|
+
<div class="status">{{status}}</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div class="parties">
|
|
173
|
+
<div class="party">
|
|
174
|
+
<h3>Billed To</h3>
|
|
175
|
+
<div class="name">{{receiverName}}</div>
|
|
176
|
+
<div class="lines">{{receiverAddress}}</div>
|
|
177
|
+
<div class="lines">{{receiverEmail}}</div>
|
|
178
|
+
{{receiverVat}}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<table class="items">
|
|
183
|
+
<thead>
|
|
184
|
+
<tr>
|
|
185
|
+
<th>Description</th>
|
|
186
|
+
<th class="num">Qty</th>
|
|
187
|
+
<th class="num">Unit</th>
|
|
188
|
+
<th class="num">Total</th>
|
|
189
|
+
</tr>
|
|
190
|
+
</thead>
|
|
191
|
+
<tbody>
|
|
192
|
+
{{items}}
|
|
193
|
+
</tbody>
|
|
194
|
+
</table>
|
|
195
|
+
|
|
196
|
+
<div class="totals">
|
|
197
|
+
<table>
|
|
198
|
+
<tr><th>Subtotal</th><td class="num">{{subtotal}}</td></tr>
|
|
199
|
+
{{vatRow}}
|
|
200
|
+
<tr class="grand"><th>Total</th><td class="num">{{total}}</td></tr>
|
|
201
|
+
</table>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{{notes}}
|
|
205
|
+
{{bankDetails}}
|
|
206
|
+
|
|
207
|
+
<div class="footer">{{footerNote}}</div>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{{autoPrint}}
|
|
211
|
+
|
|
212
|
+
</body>
|
|
213
|
+
</html>
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard API
|
|
3
|
+
* Aggregates traffic, journeys, spikes, health, and activity for the admin dashboard.
|
|
4
|
+
*
|
|
5
|
+
* GET /api/dashboard/summary full payload
|
|
6
|
+
* GET /api/dashboard/summary?lite=1 KPI strip + spikes + health.status only
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import {fileURLToPath} from 'url';
|
|
11
|
+
import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
|
|
12
|
+
import {getHealth} from '../../services/health.js';
|
|
13
|
+
import {listVersions} from '../../services/versions.js';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
17
|
+
|
|
18
|
+
const ACTIVITY_LIMIT = 10;
|
|
19
|
+
const VERSIONS_PER_PAGE = 3;
|
|
20
|
+
const ENTRIES_PER_COLLECTION = 3;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {Date} [d]
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function todayKey(d = new Date()) {
|
|
27
|
+
return d.toISOString().slice(0, 10);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {number} n
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function isoDaysAgo(n) {
|
|
35
|
+
const d = new Date();
|
|
36
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
37
|
+
d.setUTCDate(d.getUTCDate() - n);
|
|
38
|
+
return todayKey(d);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} daily
|
|
43
|
+
* @param {string} key
|
|
44
|
+
* @returns {number}
|
|
45
|
+
*/
|
|
46
|
+
function sumDay(daily, key) {
|
|
47
|
+
return Object.values(daily[key] || {}).reduce((a, n) => a + n, 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} daily
|
|
52
|
+
* @param {string} fromKey
|
|
53
|
+
* @param {string} toKey
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
function sumRange(daily, fromKey, toKey) {
|
|
57
|
+
let total = 0;
|
|
58
|
+
for (const [day, urls] of Object.entries(daily)) {
|
|
59
|
+
if (day < fromKey || day > toKey) continue;
|
|
60
|
+
for (const n of Object.values(urls)) total += n;
|
|
61
|
+
}
|
|
62
|
+
return total;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the top-pages widget data, including a per-day sparkline and a
|
|
67
|
+
* percentage delta versus the previous comparable window.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} daily
|
|
70
|
+
* @param {number} [days]
|
|
71
|
+
* @returns {Array<{url: string, hits: number, deltaPct: number|null, spark: number[]}>}
|
|
72
|
+
*/
|
|
73
|
+
function buildTopPages(daily, days = 7) {
|
|
74
|
+
const totals = {};
|
|
75
|
+
const perDay = {};
|
|
76
|
+
for (let i = days - 1; i >= 0; i -= 1) {
|
|
77
|
+
const k = isoDaysAgo(i);
|
|
78
|
+
for (const [url, n] of Object.entries(daily[k] || {})) {
|
|
79
|
+
totals[url] = (totals[url] || 0) + n;
|
|
80
|
+
if (!perDay[url]) perDay[url] = new Array(days).fill(0);
|
|
81
|
+
perDay[url][days - 1 - i] = n;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const prevTotals = {};
|
|
85
|
+
for (let i = 2 * days - 1; i >= days; i -= 1) {
|
|
86
|
+
const k = isoDaysAgo(i);
|
|
87
|
+
for (const [url, n] of Object.entries(daily[k] || {})) {
|
|
88
|
+
prevTotals[url] = (prevTotals[url] || 0) + n;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return Object.entries(totals)
|
|
92
|
+
.map(([url, hits]) => {
|
|
93
|
+
const prev = prevTotals[url] || 0;
|
|
94
|
+
const deltaPct = prev === 0 ? null : +(((hits - prev) / prev) * 100).toFixed(1);
|
|
95
|
+
return { url, hits, deltaPct, spark: perDay[url] };
|
|
96
|
+
})
|
|
97
|
+
.sort((a, b) => b.hits - a.hits)
|
|
98
|
+
.slice(0, 5);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Recent activity feed — combines page version events with recent collection
|
|
103
|
+
* entries (form submissions). Returns at most 10 items, newest first.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Promise<Array<object>>}
|
|
106
|
+
*/
|
|
107
|
+
async function buildActivity() {
|
|
108
|
+
const items = [];
|
|
109
|
+
|
|
110
|
+
// Versions — read each page's _meta.json via the service so the schema
|
|
111
|
+
// stays in one place. listVersions returns newest-first.
|
|
112
|
+
const versionsDir = path.join(ROOT, 'content', 'versions');
|
|
113
|
+
try {
|
|
114
|
+
const dirs = await fs.readdir(versionsDir, { withFileTypes: true });
|
|
115
|
+
for (const d of dirs) {
|
|
116
|
+
if (!d.isDirectory()) continue;
|
|
117
|
+
const urlPath = '/' + (d.name === '_index' ? '' : d.name);
|
|
118
|
+
let versions = [];
|
|
119
|
+
try { versions = await listVersions(urlPath); }
|
|
120
|
+
catch { continue; }
|
|
121
|
+
for (const v of versions.slice(0, VERSIONS_PER_PAGE)) {
|
|
122
|
+
items.push({
|
|
123
|
+
type: v.type === 'manual' ? 'publish' : 'edit',
|
|
124
|
+
at: v.createdAt,
|
|
125
|
+
actor: v.author || 'unknown',
|
|
126
|
+
target: urlPath
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch { /* no versions dir */ }
|
|
131
|
+
|
|
132
|
+
// Collection entries — each slug has a single data.json with an array of
|
|
133
|
+
// entries. Take the newest ENTRIES_PER_COLLECTION by meta.createdAt.
|
|
134
|
+
const collectionsDir = path.join(ROOT, 'content', 'collections');
|
|
135
|
+
try {
|
|
136
|
+
const slugs = await fs.readdir(collectionsDir, { withFileTypes: true });
|
|
137
|
+
for (const slug of slugs) {
|
|
138
|
+
if (!slug.isDirectory()) continue;
|
|
139
|
+
const dataFile = path.join(collectionsDir, slug.name, 'data.json');
|
|
140
|
+
let entries = [];
|
|
141
|
+
try { entries = JSON.parse(await fs.readFile(dataFile, 'utf8')); }
|
|
142
|
+
catch { continue; }
|
|
143
|
+
if (!Array.isArray(entries)) continue;
|
|
144
|
+
|
|
145
|
+
const sorted = entries
|
|
146
|
+
.filter(e => e && e.meta && e.meta.createdAt)
|
|
147
|
+
.sort((a, b) => (b.meta.createdAt || '').localeCompare(a.meta.createdAt || ''))
|
|
148
|
+
.slice(0, ENTRIES_PER_COLLECTION);
|
|
149
|
+
|
|
150
|
+
for (const e of sorted) {
|
|
151
|
+
items.push({
|
|
152
|
+
type: 'form',
|
|
153
|
+
at: e.meta.createdAt,
|
|
154
|
+
form: slug.name
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch { /* no collections dir */ }
|
|
159
|
+
|
|
160
|
+
return items
|
|
161
|
+
.filter(i => i.at)
|
|
162
|
+
.sort((a, b) => (b.at || '').localeCompare(a.at || ''))
|
|
163
|
+
.slice(0, ACTIVITY_LIMIT);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Register the dashboard routes.
|
|
168
|
+
*
|
|
169
|
+
* Auth middlewares are accepted as options so tests can supply no-ops without
|
|
170
|
+
* needing to register `@fastify/jwt` on the test instance. In production the
|
|
171
|
+
* defaults are the real `authenticate` / `requireAdmin` from middleware/auth.js.
|
|
172
|
+
*
|
|
173
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
174
|
+
* @param {{authenticate?: Function, requireAdmin?: Function}} [opts]
|
|
175
|
+
* @returns {Promise<void>}
|
|
176
|
+
*/
|
|
177
|
+
export async function dashboardRoutes(fastify, opts = {}) {
|
|
178
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
179
|
+
const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
|
|
180
|
+
const guard = { preHandler: [authenticate, requireAdmin] };
|
|
181
|
+
|
|
182
|
+
fastify.get('/dashboard/summary', guard, async (request) => {
|
|
183
|
+
const lite = request.query.lite === '1' || request.query.lite === 'true';
|
|
184
|
+
const warnings = [];
|
|
185
|
+
const safe = async (label, fn) => {
|
|
186
|
+
try { return await fn(); }
|
|
187
|
+
catch (e) {
|
|
188
|
+
// Strip absolute paths from error messages so we don't disclose deployment layout.
|
|
189
|
+
const msg = String(e.message || e).replace(/(\/|[A-Za-z]:\\)[^\s'"]+/g, '<path>');
|
|
190
|
+
warnings.push(`${label}: ${msg}`);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const analytics = fastify.analytics;
|
|
196
|
+
const daily = analytics ? (await safe('analytics.daily', () => analytics.getDaily())) || {} : {};
|
|
197
|
+
|
|
198
|
+
const today = todayKey();
|
|
199
|
+
const yesterday = isoDaysAgo(1);
|
|
200
|
+
const todayHits = sumDay(daily, today);
|
|
201
|
+
const yesterdayHits = sumDay(daily, yesterday);
|
|
202
|
+
const deltaPct = yesterdayHits === 0
|
|
203
|
+
? null
|
|
204
|
+
: +(((todayHits - yesterdayHits) / yesterdayHits) * 100).toFixed(1);
|
|
205
|
+
const traffic = {
|
|
206
|
+
today: todayHits,
|
|
207
|
+
yesterday: yesterdayHits,
|
|
208
|
+
deltaPct,
|
|
209
|
+
weekToDate: sumRange(daily, isoDaysAgo(6), today),
|
|
210
|
+
previousWeek: sumRange(daily, isoDaysAgo(13), isoDaysAgo(7))
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const realtime = analytics
|
|
214
|
+
? (await safe('analytics.realtime', () => analytics.getRealtime())) || { activeSessions: 0 }
|
|
215
|
+
: { activeSessions: 0 };
|
|
216
|
+
const spikes = analytics
|
|
217
|
+
? ((await safe('analytics.spikes', () => analytics.getSpikes())) || { items: [] }).items
|
|
218
|
+
: [];
|
|
219
|
+
const health = await safe('health', () => getHealth()) || { status: 'ok', checks: [] };
|
|
220
|
+
|
|
221
|
+
if (lite) {
|
|
222
|
+
return {
|
|
223
|
+
traffic: { today: traffic.today, yesterday: traffic.yesterday, deltaPct: traffic.deltaPct },
|
|
224
|
+
realtime,
|
|
225
|
+
spikes,
|
|
226
|
+
health: { status: health.status },
|
|
227
|
+
warnings
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const topPages = buildTopPages(daily);
|
|
232
|
+
const journeys = analytics
|
|
233
|
+
? (await safe('analytics.journeys', () => analytics.getJourneys({ range: '7d' }))) || null
|
|
234
|
+
: null;
|
|
235
|
+
const activity = (await safe('activity', () => buildActivity())) || [];
|
|
236
|
+
|
|
237
|
+
return { traffic, topPages, journeys, spikes, realtime, health, activity, warnings };
|
|
238
|
+
});
|
|
239
|
+
}
|
package/server/server.js
CHANGED
|
@@ -249,6 +249,7 @@ const {componentsRoutes} = await import('./routes/api/components.js');
|
|
|
249
249
|
const {versionsRoutes} = await import('./routes/api/versions.js');
|
|
250
250
|
const {effectsRoutes} = await import('./routes/api/effects.js');
|
|
251
251
|
const {notificationsRoutes} = await import('./routes/api/notifications.js');
|
|
252
|
+
const {dashboardRoutes} = await import('./routes/api/dashboard.js');
|
|
252
253
|
|
|
253
254
|
await app.register(pagesRoutes, { prefix: '/api' });
|
|
254
255
|
await app.register(settingsRoutes, { prefix: '/api' });
|
|
@@ -267,6 +268,7 @@ await app.register(componentsRoutes, {prefix: '/api'});
|
|
|
267
268
|
await app.register(versionsRoutes, {prefix: '/api'});
|
|
268
269
|
await app.register(effectsRoutes, {prefix: '/api'});
|
|
269
270
|
await app.register(notificationsRoutes, {prefix: '/api'});
|
|
271
|
+
await app.register(dashboardRoutes, {prefix: '/api'});
|
|
270
272
|
|
|
271
273
|
// ---------------------------------------------------------------------------
|
|
272
274
|
// CMS Plugins (server-side Fastify plugins from plugins/ directory)
|
package/server/services/email.js
CHANGED
|
@@ -4,6 +4,32 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import nodemailer from 'nodemailer';
|
|
6
6
|
|
|
7
|
+
let lastSendResult = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Return the most recent email send result, or null if no send has occurred.
|
|
11
|
+
*
|
|
12
|
+
* @returns {{ ok: boolean, at: string, info: string|null } | null}
|
|
13
|
+
*/
|
|
14
|
+
export function getLastSendResult() {
|
|
15
|
+
return lastSendResult;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Record the outcome of an email send for health reporting.
|
|
20
|
+
*
|
|
21
|
+
* @param {boolean} ok
|
|
22
|
+
* @param {string|null} info
|
|
23
|
+
* @returns {void}
|
|
24
|
+
*/
|
|
25
|
+
function recordSendResult(ok, info) {
|
|
26
|
+
lastSendResult = {
|
|
27
|
+
ok,
|
|
28
|
+
at: new Date().toISOString(),
|
|
29
|
+
info: info || null
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
7
33
|
/**
|
|
8
34
|
* Escape HTML special characters for safe use in email bodies.
|
|
9
35
|
*
|
|
@@ -58,17 +84,24 @@ export async function createTransport(smtp) {
|
|
|
58
84
|
* @throws {Error} If sending the email fails.
|
|
59
85
|
*/
|
|
60
86
|
export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
try {
|
|
88
|
+
const info = await transport.sendMail({
|
|
89
|
+
from: `"${fromName}" <${from}>`,
|
|
90
|
+
to,
|
|
91
|
+
subject,
|
|
92
|
+
text,
|
|
93
|
+
html
|
|
94
|
+
});
|
|
95
|
+
recordSendResult(true, info.messageId);
|
|
68
96
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
97
|
+
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
98
|
+
if (previewUrl) {
|
|
99
|
+
console.log('[email] Preview URL:', previewUrl);
|
|
100
|
+
}
|
|
101
|
+
return info;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
recordSendResult(false, err.message);
|
|
104
|
+
throw err;
|
|
72
105
|
}
|
|
73
106
|
}
|
|
74
107
|
|
|
@@ -112,16 +145,23 @@ export async function sendFormEmail(transport, { from, fromName, to, subject, fo
|
|
|
112
145
|
|
|
113
146
|
const text = `New form submission: ${formTitle}\n\n${plainRows}`;
|
|
114
147
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
148
|
+
try {
|
|
149
|
+
const info = await transport.sendMail({
|
|
150
|
+
from: `"${fromName}" <${from}>`,
|
|
151
|
+
to,
|
|
152
|
+
subject,
|
|
153
|
+
text,
|
|
154
|
+
html
|
|
155
|
+
});
|
|
156
|
+
recordSendResult(true, info.messageId);
|
|
122
157
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
158
|
+
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
159
|
+
if (previewUrl) {
|
|
160
|
+
console.log('[email] Preview URL:', previewUrl);
|
|
161
|
+
}
|
|
162
|
+
return info;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
recordSendResult(false, err.message);
|
|
165
|
+
throw err;
|
|
126
166
|
}
|
|
127
167
|
}
|