domma-cms 1.8.32 → 1.8.34

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Garage Plugin — Backend
3
+ *
4
+ * Proxies DVLA vehicle lookups and stores per-user vehicle history via the
5
+ * CMS collections service (garage-vehicles collection). Uses MongoDB when
6
+ * configured, falls back to file-based storage automatically.
7
+ * All routes are mounted at /api/plugins/garage/ by the plugin system.
8
+ *
9
+ * Routes:
10
+ * POST /lookup — Proxy DVLA lookup, upsert into history
11
+ * GET /vehicles — Saved vehicles for the authenticated user
12
+ * GET /history — Full lookup history for the authenticated user
13
+ * PATCH /vehicles/:id/save — Set ({isSaved}) or toggle isSaved on a vehicle entry
14
+ * DELETE /vehicles/:id — Remove a vehicle entry from history
15
+ * GET /count — The sidebar badge: saved vehicles whose MOT or tax has run out or is due
16
+ */
17
+ import {
18
+ createEntry,
19
+ deleteEntry,
20
+ getEntry,
21
+ listEntries,
22
+ updateEntry
23
+ } from '../../server/services/collections.js';
24
+ import defaultConfig from './config.js';
25
+ import {badgeFor, isoDay, shortDay, vehicleNotices} from './admin/lib/garage.js';
26
+ import {getPluginSettings} from '../../server/services/plugins.js';
27
+
28
+ const COLLECTION = 'garage-vehicles';
29
+
30
+ /** In-memory rate limit tracker: Map<userId, { count: number, windowStart: number }> */
31
+ const rateLimits = new Map();
32
+
33
+
34
+ /**
35
+ * Flatten a collection entry into the vehicle model the frontend expects.
36
+ *
37
+ * @param {{ id: string, data: object }} entry
38
+ * @returns {object}
39
+ */
40
+ function toVehicle(entry) {
41
+ return {
42
+ id: entry.id,
43
+ ...entry.data,
44
+ isSaved: entry.data.isSaved === true || entry.data.isSaved === 'true'
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Check and increment the per-user rate limit.
50
+ *
51
+ * @param {string} userId
52
+ * @param {{ max: number, windowSeconds: number }} config
53
+ * @returns {{ allowed: boolean, retryAfter: number }} retryAfter is seconds until reset (0 if allowed)
54
+ */
55
+ function checkRateLimit(userId, config) {
56
+ const now = Date.now();
57
+ const windowMs = config.windowSeconds * 1000;
58
+ const entry = rateLimits.get(userId);
59
+
60
+ if (!entry || now - entry.windowStart >= windowMs) {
61
+ rateLimits.set(userId, {count: 1, windowStart: now});
62
+ return {allowed: true, retryAfter: 0};
63
+ }
64
+
65
+ if (entry.count >= config.max) {
66
+ const retryAfter = Math.ceil((entry.windowStart + windowMs - now) / 1000);
67
+ return {allowed: false, retryAfter};
68
+ }
69
+
70
+ entry.count += 1;
71
+ return {allowed: true, retryAfter: 0};
72
+ }
73
+
74
+ /**
75
+ * Normalise a registration number: uppercase and strip all spaces.
76
+ *
77
+ * @param {string} reg
78
+ * @returns {string}
79
+ */
80
+ function normaliseReg(reg) {
81
+ return String(reg).toUpperCase().replace(/\s+/g, '');
82
+ }
83
+
84
+ /**
85
+ * Map a DVLA API response to our internal vehicle data shape.
86
+ *
87
+ * @param {string} userId
88
+ * @param {object} dvlaData - Raw response from DVLA API
89
+ * @returns {object}
90
+ */
91
+ function mapDvlaResponse(userId, dvlaData) {
92
+ return {
93
+ userId,
94
+ registrationNumber: dvlaData.registrationNumber ?? '',
95
+ make: dvlaData.make ?? '',
96
+ colour: dvlaData.colour ?? '',
97
+ yearOfManufacture: dvlaData.yearOfManufacture ?? null,
98
+ fuelType: dvlaData.fuelType ?? '',
99
+ engineCapacity: dvlaData.engineCapacity ?? null,
100
+ co2Emissions: dvlaData.co2Emissions ?? null,
101
+ motStatus: dvlaData.motStatus ?? '',
102
+ motExpiryDate: dvlaData.motExpiryDate ?? null,
103
+ taxStatus: dvlaData.taxStatus ?? '',
104
+ taxDueDate: dvlaData.taxDueDate ?? null,
105
+ isSaved: false,
106
+ lookupDate: new Date().toISOString()
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Escape HTML special characters for safe server-side rendering.
112
+ *
113
+ * @param {*} str
114
+ * @returns {string}
115
+ */
116
+ function escHtml(str) {
117
+ return String(str ?? '')
118
+ .replace(/&/g, '&amp;')
119
+ .replace(/</g, '&lt;')
120
+ .replace(/>/g, '&gt;')
121
+ .replace(/"/g, '&quot;');
122
+ }
123
+
124
+ /**
125
+ * Render a list of saved vehicle objects as a static HTML card grid.
126
+ * Used by the [garage-vehicles /] shortcode for front-end display.
127
+ *
128
+ * @param {object[]} vehicles
129
+ * @returns {string} HTML string
130
+ */
131
+ function renderVehiclesHtml(vehicles) {
132
+ const cards = vehicles.map(v => {
133
+ const motLv = (v.motStatus || '').toLowerCase();
134
+ const motCls = motLv === 'valid' ? 'badge-success' : motLv ? 'badge-danger' : 'badge-secondary';
135
+ const taxLv = (v.taxStatus || '').toLowerCase();
136
+ const taxCls = taxLv === 'taxed' ? 'badge-success' : taxLv === 'sorn' ? 'badge-warning' : taxLv ? 'badge-danger' : 'badge-secondary';
137
+
138
+ const details = [
139
+ v.colour,
140
+ v.fuelType,
141
+ v.engineCapacity ? `${v.engineCapacity}cc` : null,
142
+ v.co2Emissions ? `${v.co2Emissions}g/km` : null
143
+ ].filter(Boolean).join(' · ');
144
+
145
+ const motExpiry = v.motExpiryDate
146
+ ? `<span style="font-size:0.78rem;color:var(--dm-text-muted,#888);">Exp ${escHtml(shortDay(v.motExpiryDate))}</span>` : '';
147
+ const taxDue = v.taxDueDate
148
+ ? `<span style="font-size:0.78rem;color:var(--dm-text-muted,#888);">Due ${escHtml(shortDay(v.taxDueDate))}</span>` : '';
149
+
150
+ return `<div class="card" style="flex:1;min-width:260px;max-width:340px;">
151
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;">
152
+ <span style="background:#FFD900;color:#000;font-family:'Arial Black',Arial,sans-serif;font-weight:900;font-size:1rem;letter-spacing:3px;padding:0.15rem 0.5rem;border-radius:4px;border:2px solid #000;">${escHtml(v.registrationNumber || '')}</span>
153
+ <span style="font-size:0.85rem;color:var(--dm-text-muted,#888);">${escHtml(v.make || '')}${v.yearOfManufacture ? ` · ${escHtml(String(v.yearOfManufacture))}` : ''}</span>
154
+ </div>
155
+ <div class="card-body">
156
+ ${details ? `<p style="font-size:0.85rem;color:var(--dm-text-muted,#888);margin:0 0 0.6rem;">${escHtml(details)}</p>` : ''}
157
+ <div style="display:flex;gap:0.4rem;flex-wrap:wrap;align-items:center;">
158
+ <span class="badge ${motCls}">MOT: ${escHtml(v.motStatus || 'Unknown')}</span>
159
+ ${motExpiry}
160
+ <span class="badge ${taxCls}">Tax: ${escHtml(v.taxStatus || 'Unknown')}</span>
161
+ ${taxDue}
162
+ </div>
163
+ </div>
164
+ </div>`;
165
+ }).join('\n');
166
+
167
+ return `<div style="display:flex;flex-wrap:wrap;gap:1rem;">\n${cards}\n</div>`;
168
+ }
169
+
170
+ export default async function garagePlugin(fastify, options) {
171
+ const {authenticate, requirePermission} = options.auth;
172
+ // The Garage permission (1.3): granted to admin and user; take it from a role
173
+ // under Roles and that role has no Garage (a lookup spends the site's DVLA key).
174
+ const need = (action) => (typeof requirePermission === 'function' ? requirePermission('garage', action) : async () => {});
175
+
176
+ // Notifications (core 0.80+; feature-detected): each morning, a saved
177
+ // vehicle whose MOT or tax has run out (critical) or is due soon (warning)
178
+ // - to its OWNER. A garage is personal. Checks that pass resolve.
179
+ const hooks = options.hooks || {};
180
+ if (typeof hooks.registerNotificationSource === 'function') {
181
+ try {
182
+ hooks.registerNotificationSource({
183
+ id: 'due', label: 'MOT and tax due', severity: 'warning', every: 'daily', atBoot: true,
184
+ description: 'Each morning: a saved vehicle whose MOT or road tax has run out, or is due within 30 days - told to its owner.',
185
+ check: async (ctx) => {
186
+ const {entries} = await listEntries(COLLECTION, {limit: 10000});
187
+ const saved = entries.map(toVehicle).filter(v => v.isSaved);
188
+ const {raise, resolve} = vehicleNotices(saved, isoDay(ctx.now || new Date()));
189
+ for (const key of resolve) await ctx.resolve(key);
190
+ for (const p of raise) await ctx.notify(p);
191
+ }
192
+ });
193
+ } catch { /* older engine */ }
194
+ }
195
+ const {registerShortcode} = options.hooks;
196
+ const cfg = {...defaultConfig, ...(options.settings || {})};
197
+ cfg.dvlaApiKey = process.env.DVLA_API_KEY || cfg.dvlaApiKey;
198
+
199
+ // --- [garage-vehicles /] shortcode --------------------------------------
200
+
201
+ /**
202
+ * Renders saved vehicles as a static HTML card grid on public pages.
203
+ *
204
+ * Reads through the collections service (listEntries -> the storage adapter),
205
+ * exactly as the API routes do, so file and MongoDB storage render the same.
206
+ * Until 1.4.1 it read content/collections/garage-vehicles/data.json directly,
207
+ * and on a site whose vehicles live in MongoDB it rendered nothing.
208
+ * Shortcode handlers may be async (processPluginShortcodes awaits them).
209
+ *
210
+ * Usage:
211
+ * [garage-vehicles user="uuid" /] — one user's saved vehicles
212
+ *
213
+ * A user must be named: until 1.3 the bare shortcode put EVERY user's saved
214
+ * vehicles on a public page, and a user's garage is theirs.
215
+ */
216
+ registerShortcode('garage-vehicles', async (attrStr, _body, ctx) => {
217
+ const attrs = ctx.parseShortcodeAttrs(attrStr);
218
+ const userId = attrs.user || null;
219
+ if (!userId) return '';
220
+
221
+ let entries;
222
+ try {
223
+ ({entries} = await listEntries(COLLECTION, {limit: 10000}));
224
+ } catch {
225
+ return '';
226
+ }
227
+
228
+ const vehicles = (entries || [])
229
+ .filter(e => e?.data && e.data.userId === userId)
230
+ .map(toVehicle)
231
+ .filter(v => v.isSaved);
232
+ if (!vehicles.length) return '';
233
+
234
+ return renderVehiclesHtml(vehicles);
235
+ });
236
+
237
+ // --- POST /lookup -------------------------------------------------------
238
+
239
+ /**
240
+ * Proxy a DVLA vehicle lookup and upsert the result into the user's history.
241
+ * Returns 503 if no DVLA API key is configured.
242
+ * Returns 429 if the user has exceeded the rate limit.
243
+ */
244
+ fastify.post('/lookup', {preHandler: [authenticate, need('create')]}, async (request, reply) => {
245
+ const userId = request.user.id;
246
+ const {registrationNumber} = request.body || {};
247
+
248
+ if (!registrationNumber || typeof registrationNumber !== 'string') {
249
+ return reply.status(400).send({error: 'registrationNumber is required'});
250
+ }
251
+
252
+ const reg = normaliseReg(registrationNumber);
253
+ if (!reg) {
254
+ return reply.status(400).send({error: 'Invalid registration number'});
255
+ }
256
+
257
+ const rl = checkRateLimit(userId, cfg.rateLimit);
258
+ if (!rl.allowed) {
259
+ return reply.status(429).send({
260
+ error: `Rate limit reached. Try again in ${rl.retryAfter} seconds.`,
261
+ retryAfter: rl.retryAfter
262
+ });
263
+ }
264
+
265
+ // Read the key now, not at boot: one saved from the settings panel works at once,
266
+ // without a restart (1.1 needed one). The environment variable still wins.
267
+ let apiKey = process.env.DVLA_API_KEY || cfg.dvlaApiKey;
268
+ try { apiKey = process.env.DVLA_API_KEY || (await getPluginSettings('garage'))?.dvlaApiKey || apiKey; } catch { /* keep the boot value */ }
269
+ if (!apiKey) {
270
+ return reply.status(503).send({
271
+ error: 'No DVLA API key yet - add one in Garage settings (the cog in the banner).'
272
+ });
273
+ }
274
+
275
+ let dvlaData;
276
+ try {
277
+ const dvlaRes = await fetch(cfg.dvlaApiUrl, {
278
+ method: 'POST',
279
+ headers: {
280
+ 'x-api-key': apiKey,
281
+ 'Content-Type': 'application/json'
282
+ },
283
+ body: JSON.stringify({registrationNumber: reg})
284
+ });
285
+
286
+ if (dvlaRes.status === 404) {
287
+ return reply.status(404).send({error: 'Vehicle not found. Check the registration number.'});
288
+ }
289
+ if (!dvlaRes.ok) {
290
+ const body = await dvlaRes.text().catch(() => '');
291
+ fastify.log.warn({status: dvlaRes.status, body}, '[garage] DVLA API error');
292
+ return reply.status(502).send({error: 'DVLA API returned an error. Please try again.'});
293
+ }
294
+
295
+ dvlaData = await dvlaRes.json();
296
+ } catch (err) {
297
+ fastify.log.error({err}, '[garage] Failed to reach DVLA API');
298
+ return reply.status(502).send({error: 'Could not reach the DVLA API. Check your network connection.'});
299
+ }
300
+
301
+ // Upsert: update existing entry if same reg+user, otherwise create
302
+ const {entries: all} = await listEntries(COLLECTION, {limit: 10000});
303
+ const existing = all.find(e => e.data.userId === userId && e.data.registrationNumber === reg);
304
+
305
+ let vehicle;
306
+ if (existing) {
307
+ const updated = await updateEntry(COLLECTION, existing.id, {
308
+ ...mapDvlaResponse(userId, dvlaData),
309
+ isSaved: existing.data.isSaved
310
+ });
311
+ vehicle = toVehicle(updated);
312
+ } else {
313
+ const created = await createEntry(COLLECTION, mapDvlaResponse(userId, dvlaData), {createdBy: userId});
314
+ vehicle = toVehicle(created);
315
+
316
+ // Prune oldest unsaved entries if over the history limit
317
+ const {entries: fresh} = await listEntries(COLLECTION, {limit: 10000});
318
+ const userEntries = fresh.filter(e => e.data.userId === userId);
319
+ if (userEntries.length > cfg.maxHistoryItems) {
320
+ const unsaved = userEntries
321
+ .filter(e => !e.data.isSaved)
322
+ .sort((a, b) => new Date(a.data.lookupDate) - new Date(b.data.lookupDate));
323
+ const toDelete = unsaved.slice(0, userEntries.length - cfg.maxHistoryItems);
324
+ for (const entry of toDelete) {
325
+ await deleteEntry(COLLECTION, entry.id);
326
+ }
327
+ }
328
+ }
329
+
330
+ return vehicle;
331
+ });
332
+
333
+ // --- GET /vehicles (saved) ----------------------------------------------
334
+
335
+ /**
336
+ * Return all vehicles the authenticated user has saved to their garage.
337
+ * Supports optional ?q= for server-side search across reg/make/colour/year/fuel.
338
+ */
339
+ fastify.get('/vehicles', {preHandler: [authenticate, need('read')]}, async (request) => {
340
+ const userId = request.user.id;
341
+ const q = (request.query.q || '').toLowerCase().trim();
342
+
343
+ const {entries} = await listEntries(COLLECTION, {limit: 10000});
344
+ let results = entries
345
+ .filter(e => e.data.userId === userId && (e.data.isSaved === true || e.data.isSaved === 'true'))
346
+ .map(toVehicle);
347
+
348
+ if (q) {
349
+ results = results.filter(v => {
350
+ const haystack = [
351
+ v.registrationNumber, v.make, v.colour,
352
+ v.yearOfManufacture, v.fuelType, v.taxStatus, v.motStatus
353
+ ].join(' ').toLowerCase();
354
+ return haystack.includes(q);
355
+ });
356
+ }
357
+
358
+ return results;
359
+ });
360
+
361
+ // --- GET /history -------------------------------------------------------
362
+
363
+ /**
364
+ * Return the authenticated user's full lookup history, newest first.
365
+ * Supports optional ?q= search.
366
+ */
367
+ fastify.get('/history', {preHandler: [authenticate, need('read')]}, async (request) => {
368
+ const userId = request.user.id;
369
+ const q = (request.query.q || '').toLowerCase().trim();
370
+
371
+ const {entries} = await listEntries(COLLECTION, {limit: 10000});
372
+ let results = entries
373
+ .filter(e => e.data.userId === userId)
374
+ .map(toVehicle)
375
+ .sort((a, b) => new Date(b.lookupDate) - new Date(a.lookupDate));
376
+
377
+ if (q) {
378
+ results = results.filter(v => {
379
+ const haystack = [
380
+ v.registrationNumber, v.make, v.colour,
381
+ v.yearOfManufacture, v.fuelType, v.taxStatus, v.motStatus
382
+ ].join(' ').toLowerCase();
383
+ return haystack.includes(q);
384
+ });
385
+ }
386
+
387
+ return results;
388
+ });
389
+
390
+ // --- PATCH /vehicles/:id/save -------------------------------------------
391
+
392
+ /**
393
+ * Save a vehicle to My Garage or take it out. `{isSaved: true|false}` in the
394
+ * body sets it - what the screen sends, so a screen that is out of date (a
395
+ * change in another tab) still does what the person asked; with no body it
396
+ * toggles, as before 1.5. A body whose isSaved is not a boolean is refused
397
+ * (400): reading "false" as "toggle" would save an unsaved vehicle.
398
+ * Returns 404 if the entry does not exist or belongs to another user.
399
+ */
400
+ fastify.patch('/vehicles/:id/save', {preHandler: [authenticate, need('create')]}, async (request, reply) => {
401
+ const userId = request.user.id;
402
+ const {id} = request.params;
403
+
404
+ const hasBody = request.body !== undefined && request.body !== null;
405
+ const wanted = hasBody ? request.body.isSaved : undefined;
406
+ if (hasBody && typeof wanted !== 'boolean') {
407
+ return reply.status(400).send({error: 'isSaved must be true or false'});
408
+ }
409
+
410
+ const entry = await getEntry(COLLECTION, id);
411
+ if (!entry || entry.data.userId !== userId) {
412
+ return reply.status(404).send({error: 'Vehicle not found'});
413
+ }
414
+
415
+ const updated = await updateEntry(COLLECTION, id, {
416
+ ...entry.data,
417
+ isSaved: typeof wanted === 'boolean' ? wanted : !(entry.data.isSaved === true || entry.data.isSaved === 'true')
418
+ });
419
+ return toVehicle(updated);
420
+ });
421
+
422
+ // --- GET /count (sidebar badge) -----------------------------------------
423
+
424
+ /**
425
+ * The badge on Garage in the sidebar (plugin.json countUrl), for the reader's
426
+ * own saved vehicles. `?today=YYYY-MM-DD` is the browser's day (the sidebar
427
+ * sends it) - MOT and tax dates are calendar days. Polled every minute from
428
+ * every admin tab, so each answer is kept for a minute per user and day.
429
+ */
430
+ const badgeCache = new Map();
431
+ fastify.get('/count', {preHandler: [authenticate, need('read')]}, async (request) => {
432
+ const userId = request.user.id;
433
+ const given = String(request.query?.today ?? '');
434
+ const today = /^\d{4}-\d{2}-\d{2}$/.test(given) ? given : isoDay();
435
+ const key = `${userId}|${today}`;
436
+ const hit = badgeCache.get(key);
437
+ if (hit && Date.now() - hit.at < 60_000) return hit.value;
438
+ const {entries} = await listEntries(COLLECTION, {limit: 10000});
439
+ const saved = entries
440
+ .filter(e => e.data.userId === userId && (e.data.isSaved === true || e.data.isSaved === 'true'))
441
+ .map(toVehicle);
442
+ const value = badgeFor(saved, today);
443
+ badgeCache.set(key, {at: Date.now(), value});
444
+ return value;
445
+ });
446
+
447
+ /** A change to a user's vehicles: their next badge is worked out afresh. */
448
+ const forget = (userId) => { for (const k of badgeCache.keys()) if (k.startsWith(`${userId}|`)) badgeCache.delete(k); };
449
+ fastify.addHook('onResponse', async (request) => {
450
+ if (request.method !== 'GET' && request.user?.id) forget(request.user.id);
451
+ });
452
+
453
+ // --- DELETE /vehicles/:id -----------------------------------------------
454
+
455
+ /**
456
+ * Permanently remove a vehicle entry from the user's history.
457
+ * Returns 404 if the entry does not exist or belongs to another user.
458
+ */
459
+ fastify.delete('/vehicles/:id', {preHandler: [authenticate, need('delete')]}, async (request, reply) => {
460
+ const userId = request.user.id;
461
+ const {id} = request.params;
462
+
463
+ const entry = await getEntry(COLLECTION, id);
464
+ if (!entry || entry.data.userId !== userId) {
465
+ return reply.status(404).send({error: 'Vehicle not found'});
466
+ }
467
+
468
+ await deleteEntry(COLLECTION, id);
469
+ return {ok: true};
470
+ });
471
+ }
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "garage",
3
+ "displayName": "Garage",
4
+ "version": "1.6.0",
5
+ "tier": "free",
6
+ "description": "UK vehicle management with DVLA API lookup, save vehicles, and search history.",
7
+ "author": "Darryl Waterhouse",
8
+ "date": "2026-10-21",
9
+ "icon": "truck",
10
+ "admin": {
11
+ "sidebar": [
12
+ {
13
+ "id": "garage",
14
+ "text": "Garage",
15
+ "icon": "truck",
16
+ "url": "#/plugins/garage",
17
+ "section": "#/plugins/garage",
18
+ "countUrl": "/api/plugins/garage/count",
19
+ "permission": "garage"
20
+ }
21
+ ],
22
+ "routes": [
23
+ {
24
+ "path": "/plugins/garage",
25
+ "view": "plugin-garage",
26
+ "title": "Garage - Domma CMS"
27
+ }
28
+ ],
29
+ "views": {
30
+ "plugin-garage": {
31
+ "entry": "garage/admin/views/garage.js",
32
+ "exportName": "garageView"
33
+ }
34
+ },
35
+ "css": [
36
+ "admin/css/garage.css"
37
+ ]
38
+ },
39
+ "permissions": [
40
+ {
41
+ "key": "garage",
42
+ "label": "Garage",
43
+ "group": "Plugins",
44
+ "description": "Your own vehicle lookups and saved vehicles.",
45
+ "actions": [
46
+ {
47
+ "key": "read",
48
+ "label": "View",
49
+ "description": "Open My Garage and your lookup history"
50
+ },
51
+ {
52
+ "key": "create",
53
+ "label": "Look up",
54
+ "description": "Look vehicles up (uses the site's DVLA key) and save them"
55
+ },
56
+ {
57
+ "key": "delete",
58
+ "label": "Delete",
59
+ "description": "Remove your saved vehicles and lookups"
60
+ }
61
+ ],
62
+ "grant": [
63
+ "admin",
64
+ "user"
65
+ ]
66
+ }
67
+ ]
68
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Garage 1.3: the Garage permission on every route, and a shortcode that shows
3
+ * one named user's vehicles - never everyone's.
4
+ *
5
+ * 1.4.1: the shortcode reads through the collections service and its storage
6
+ * adapter, like every route, not the data file - so it is tested on file
7
+ * storage (a real garage-vehicles collection in the domma-cms checkout, made
8
+ * for the run and removed again, or put back if one was there) and on a
9
+ * non-file adapter (the registry's adapter for the slug, stubbed).
10
+ */
11
+ import {test, before, after, mock} from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import Fastify from 'fastify';
16
+ import garagePlugin from '../plugin.js';
17
+ import {getAdapter, invalidate} from '../../../server/services/adapterRegistry.js';
18
+
19
+ const SLUG = 'garage-vehicles';
20
+ const HERE = path.dirname(new URL(import.meta.url).pathname);
21
+ // Where the collections service keeps it: `make test` runs with cwd = the CMS root.
22
+ const DIR = path.resolve('content/collections', SLUG);
23
+ const KEEP = ['schema.json', 'data.json'].map(f => path.join(DIR, f));
24
+ const saved = new Map();
25
+ let dirWas = false;
26
+ let app;
27
+ let shortcode;
28
+
29
+ before(async () => {
30
+ dirWas = await fs.stat(DIR).then(() => true, () => false);
31
+ for (const f of KEEP) saved.set(f, await fs.readFile(f, 'utf8').catch(() => null));
32
+ await fs.mkdir(DIR, {recursive: true});
33
+ await fs.copyFile(path.join(HERE, '..', 'collections', SLUG, 'schema.json'), path.join(DIR, 'schema.json'));
34
+ await fs.writeFile(path.join(DIR, 'data.json'), JSON.stringify([
35
+ {id: 'v1', data: {userId: 'u-sam', registrationNumber: 'AB12CDE', make: 'FORD', isSaved: true}},
36
+ {id: 'v2', data: {userId: 'u-jo', registrationNumber: 'XY99ZZZ', make: 'VOLVO', isSaved: true}},
37
+ {id: 'v3', data: {userId: 'u-sam', registrationNumber: 'LK07ABC', make: 'SKODA', isSaved: false}}
38
+ ]));
39
+ invalidate(SLUG);
40
+ app = Fastify();
41
+ const authenticate = async (req) => { req.user = {id: req.headers['x-id'] || 'u-sam', role: req.headers['x-role'] || 'user'}; };
42
+ // The Garage permission: held unless x-no-garage is sent.
43
+ const requirePermission = () => async (req, reply) => {
44
+ if (req.headers['x-no-garage']) return reply.code(403).send({error: 'Insufficient permissions'});
45
+ };
46
+ await app.register(garagePlugin, {auth: {authenticate, requirePermission}, settings: {},
47
+ hooks: {registerShortcode: (name, fn) => { if (name === 'garage-vehicles') shortcode = fn; }}});
48
+ await app.ready();
49
+ });
50
+
51
+ after(async () => {
52
+ await app?.close();
53
+ if (!dirWas) await fs.rm(DIR, {recursive: true, force: true});
54
+ else for (const [f, body] of saved) {
55
+ if (body === null) await fs.rm(f, {force: true});
56
+ else await fs.writeFile(f, body);
57
+ }
58
+ invalidate(SLUG);
59
+ });
60
+
61
+ const ctx = {parseShortcodeAttrs: (s) => Object.fromEntries([...String(s).matchAll(/(\w+)="([^"]*)"/g)].map(m => [m[1], m[2]]))};
62
+
63
+ test('the bare shortcode shows nobody\'s vehicles; a named user, only theirs (file storage)', async () => {
64
+ assert.equal(await shortcode('', null, ctx), '', 'every user\'s garage on a public page - no more');
65
+ const sams = await shortcode(' user="u-sam"', null, ctx);
66
+ assert.match(sams, /FORD/);
67
+ assert.doesNotMatch(sams, /VOLVO/, 'another user\'s vehicle');
68
+ assert.doesNotMatch(sams, /SKODA/, 'looked up but not saved');
69
+ assert.equal(await shortcode(' user="u-nobody"', null, ctx), '');
70
+ });
71
+
72
+ test('the shortcode reads through the storage adapter, so a non-file store renders the same', async () => {
73
+ // What a MongoDB-backed collection hands back: nothing in data.json, and
74
+ // isSaved as the schema's text field may store it.
75
+ const adapter = await getAdapter(SLUG);
76
+ const listed = [];
77
+ const list = mock.method(adapter, 'list', async (slug, opts) => {
78
+ listed.push(slug);
79
+ return {entries: [
80
+ {id: 'm1', data: {userId: 'u-sam', registrationNumber: 'MO55NGO', make: 'TESLA', isSaved: 'true', motStatus: 'Valid'}},
81
+ {id: 'm2', data: {userId: 'u-jo', registrationNumber: 'JO11JJJ', make: 'VOLVO', isSaved: true}},
82
+ {id: 'm3', data: {userId: 'u-sam', registrationNumber: 'UN54VED', make: 'SKODA', isSaved: false}}
83
+ ], total: 3, page: 1, limit: opts?.limit ?? 50};
84
+ });
85
+ try {
86
+ const sams = await shortcode(' user="u-sam"', null, ctx);
87
+ assert.deepEqual(listed, [SLUG], 'asked the adapter, not the file');
88
+ assert.match(sams, /MO55NGO/);
89
+ assert.match(sams, /TESLA/);
90
+ assert.match(sams, /MOT: Valid/);
91
+ assert.doesNotMatch(sams, /FORD/, 'the data file was not read');
92
+ assert.doesNotMatch(sams, /VOLVO|SKODA/, 'still only the named user\'s saved vehicles');
93
+ assert.equal(await shortcode('', null, ctx), '', 'and still nobody\'s without a user');
94
+ // The API's own read gives the same vehicles through the same adapter.
95
+ const api = (await app.inject({method: 'GET', url: '/vehicles', headers: {'x-id': 'u-sam'}})).json();
96
+ assert.deepEqual(api.map(v => v.registrationNumber), ['MO55NGO']);
97
+ } finally {
98
+ list.mock.restore();
99
+ }
100
+ });
101
+
102
+ test('a store that fails renders nothing rather than breaking the page', async () => {
103
+ const adapter = await getAdapter(SLUG);
104
+ const list = mock.method(adapter, 'list', async () => { throw new Error('connection lost'); });
105
+ try {
106
+ assert.equal(await shortcode(' user="u-sam"', null, ctx), '');
107
+ } finally {
108
+ list.mock.restore();
109
+ }
110
+ });
111
+
112
+ test('every route needs the Garage permission', async () => {
113
+ for (const [method, url] of [['GET', '/vehicles'], ['GET', '/history'], ['GET', '/count'], ['POST', '/lookup'],
114
+ ['PATCH', '/vehicles/v1/save'], ['DELETE', '/vehicles/v1']]) {
115
+ const res = await app.inject({method, url, headers: {'x-no-garage': '1'}, payload: method === 'GET' ? undefined : {}});
116
+ assert.equal(res.statusCode, 403, `${method} ${url}`);
117
+ }
118
+ assert.notEqual((await app.inject({method: 'GET', url: '/count'})).statusCode, 403, 'with it, as before');
119
+ });
120
+
121
+ test('saving sets the state asked for; with no body it toggles as before; anything else is refused', async () => {
122
+ const send = (payload) => app.inject({method: 'PATCH', url: '/vehicles/v3/save', headers: {'x-id': 'u-sam'}, payload});
123
+ const save = async (payload) => (await send(payload)).json().isSaved;
124
+ assert.equal(await save({isSaved: true}), true);
125
+ assert.equal(await save({isSaved: true}), true, 'asked twice (a screen out of date) - still saved, not flipped back');
126
+ assert.equal(await save({isSaved: false}), false);
127
+ assert.equal(await save(undefined), true, 'no body: a toggle');
128
+ for (const bad of [{isSaved: 'false'}, {isSaved: 'yes'}, {isSaved: 0}, {}]) {
129
+ assert.equal((await send(bad)).statusCode, 400, `refused: ${JSON.stringify(bad)}`);
130
+ }
131
+ assert.equal(await save({isSaved: false}), false, 'the refused ones changed nothing: it was still saved');
132
+ const other = await app.inject({method: 'PATCH', url: '/vehicles/v3/save', headers: {'x-id': 'u-jo'}, payload: {isSaved: true}});
133
+ assert.equal(other.statusCode, 404, 'another user\'s vehicle');
134
+ });