domma-cms 0.9.10 → 0.12.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.
Files changed (125) hide show
  1. package/CLAUDE.md +248 -159
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +7 -3
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/http-interceptor.js +1 -0
  7. package/admin/js/lib/card-builder.js +2 -2
  8. package/admin/js/lib/markdown-toolbar.js +5 -5
  9. package/admin/js/lib/safe-html.js +1 -0
  10. package/admin/js/lib/shortcode-modal.js +1 -0
  11. package/admin/js/templates/layouts.html +5 -4
  12. package/admin/js/templates/notifications.html +14 -0
  13. package/admin/js/templates/plugin-marketplace.html +16 -0
  14. package/admin/js/templates/plugins.html +17 -5
  15. package/admin/js/views/index.js +1 -1
  16. package/admin/js/views/layouts.js +1 -16
  17. package/admin/js/views/notifications.js +1 -0
  18. package/admin/js/views/page-editor.js +37 -33
  19. package/admin/js/views/plugin-marketplace.js +1 -0
  20. package/admin/js/views/plugins.js +16 -16
  21. package/config/navigation.json +5 -72
  22. package/config/plugins.json +10 -14
  23. package/config/presets.json +50 -13
  24. package/config/site.json +11 -63
  25. package/package.json +2 -1
  26. package/plugins/_template/admin/templates/index.html +17 -0
  27. package/plugins/_template/admin/views/index.js +19 -0
  28. package/plugins/_template/config.js +8 -0
  29. package/plugins/_template/plugin.js +23 -0
  30. package/plugins/_template/plugin.json +34 -0
  31. package/plugins/analytics/plugin.json +41 -31
  32. package/plugins/blog/admin/templates/blog.html +22 -0
  33. package/plugins/blog/admin/templates/categories.html +7 -0
  34. package/plugins/blog/admin/templates/comments.html +11 -0
  35. package/plugins/blog/admin/templates/post-editor.html +97 -0
  36. package/plugins/blog/admin/templates/settings.html +11 -0
  37. package/plugins/blog/admin/views/blog.js +183 -0
  38. package/plugins/blog/admin/views/categories.js +235 -0
  39. package/plugins/blog/admin/views/comments.js +187 -0
  40. package/plugins/blog/admin/views/post-editor.js +291 -0
  41. package/plugins/blog/admin/views/settings.js +100 -0
  42. package/plugins/blog/collections/categories/schema.json +12 -0
  43. package/plugins/blog/collections/comments/schema.json +16 -0
  44. package/plugins/blog/collections/posts/schema.json +19 -0
  45. package/plugins/blog/config.js +8 -0
  46. package/plugins/blog/plugin.js +352 -0
  47. package/plugins/blog/plugin.json +96 -0
  48. package/plugins/blog/roles/blog-author.json +10 -0
  49. package/plugins/blog/roles/blog-editor.json +12 -0
  50. package/plugins/blog/templates/author.html +9 -0
  51. package/plugins/blog/templates/category.html +9 -0
  52. package/plugins/blog/templates/index.html +9 -0
  53. package/plugins/blog/templates/post.html +17 -0
  54. package/plugins/blog/templates/tag.html +9 -0
  55. package/plugins/contacts/collections/user-contact-groups/schema.json +1 -1
  56. package/plugins/contacts/collections/user-contacts/schema.json +1 -1
  57. package/plugins/contacts/plugin.js +4 -10
  58. package/plugins/contacts/plugin.json +13 -3
  59. package/plugins/notes/collections/user-notes/schema.json +1 -1
  60. package/plugins/notes/plugin.js +3 -9
  61. package/plugins/notes/plugin.json +13 -3
  62. package/plugins/site-search/plugin.json +5 -2
  63. package/plugins/theme-switcher/plugin.json +1 -1
  64. package/plugins/todo/collections/todos/schema.json +1 -1
  65. package/plugins/todo/plugin.js +3 -9
  66. package/plugins/todo/plugin.json +13 -3
  67. package/public/css/site.css +1 -1
  68. package/public/js/site.js +1 -1
  69. package/scripts/build.js +48 -0
  70. package/scripts/create-plugin.js +113 -0
  71. package/scripts/fresh.js +6 -7
  72. package/scripts/gen-instance-secret.js +46 -0
  73. package/scripts/reset.js +3 -3
  74. package/scripts/setup.js +31 -13
  75. package/server/middleware/auth.js +48 -0
  76. package/server/middleware/managerAuth.js +36 -0
  77. package/server/routes/api/actions.js +1 -1
  78. package/server/routes/api/auth.js +4 -3
  79. package/server/routes/api/layouts.js +173 -49
  80. package/server/routes/api/notifications.js +155 -0
  81. package/server/routes/api/plugin-marketplace.js +75 -0
  82. package/server/routes/api/users.js +1 -1
  83. package/server/routes/api/views.js +1 -1
  84. package/server/routes/public.js +4 -9
  85. package/server/server.js +32 -3
  86. package/server/services/actions.js +1 -1
  87. package/server/services/managerClient.js +182 -0
  88. package/server/services/markdown.js +76 -9
  89. package/server/services/permissionRegistry.js +245 -173
  90. package/server/services/pluginInstaller.js +301 -0
  91. package/server/services/plugins.js +117 -10
  92. package/server/services/presetCollections.js +66 -251
  93. package/server/services/renderer.js +99 -0
  94. package/server/services/roles.js +191 -39
  95. package/server/services/users.js +1 -1
  96. package/server/services/views.js +1 -1
  97. package/server/templates/page.html +2 -2
  98. package/plugins/docs/admin/templates/docs.html +0 -69
  99. package/plugins/docs/admin/views/docs.js +0 -276
  100. package/plugins/docs/config.js +0 -8
  101. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +0 -11
  102. package/plugins/docs/data/folders.json +0 -9
  103. package/plugins/docs/data/templates.json +0 -1
  104. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +0 -5
  105. package/plugins/docs/plugin.js +0 -375
  106. package/plugins/docs/plugin.json +0 -23
  107. package/plugins/form-builder/data/forms/contacts.json +0 -66
  108. package/plugins/form-builder/data/forms/enquiries.json +0 -103
  109. package/plugins/form-builder/data/forms/feedback.json +0 -131
  110. package/plugins/form-builder/data/forms/notes.json +0 -79
  111. package/plugins/form-builder/data/forms/to-do.json +0 -100
  112. package/plugins/form-builder/data/submissions/contacts.json +0 -1
  113. package/plugins/form-builder/data/submissions/enquiries.json +0 -1
  114. package/plugins/form-builder/data/submissions/feedback.json +0 -1
  115. package/plugins/form-builder/data/submissions/notes.json +0 -1
  116. package/plugins/form-builder/data/submissions/to-do.json +0 -1
  117. package/plugins/garage/admin/templates/garage.html +0 -111
  118. package/plugins/garage/admin/views/garage.js +0 -622
  119. package/plugins/garage/collections/garage-vehicles/schema.json +0 -101
  120. package/plugins/garage/config.js +0 -18
  121. package/plugins/garage/data/vehicles.json +0 -70
  122. package/plugins/garage/plugin.js +0 -398
  123. package/plugins/garage/plugin.json +0 -33
  124. package/scripts/seed.js +0 -1996
  125. package/server/services/userTypes.js +0 -227
@@ -1,101 +0,0 @@
1
- {
2
- "slug": "garage-vehicles",
3
- "title": "Garage Vehicles",
4
- "description": "Vehicle registrations managed by the Garage plugin.",
5
- "plugin": "garage",
6
- "fields": [
7
- {
8
- "name": "userId",
9
- "label": "User ID",
10
- "type": "text",
11
- "required": true
12
- },
13
- {
14
- "name": "registrationNumber",
15
- "label": "Registration",
16
- "type": "text",
17
- "required": true
18
- },
19
- {
20
- "name": "make",
21
- "label": "Make",
22
- "type": "text"
23
- },
24
- {
25
- "name": "colour",
26
- "label": "Colour",
27
- "type": "text"
28
- },
29
- {
30
- "name": "yearOfManufacture",
31
- "label": "Year",
32
- "type": "text"
33
- },
34
- {
35
- "name": "fuelType",
36
- "label": "Fuel Type",
37
- "type": "text"
38
- },
39
- {
40
- "name": "engineCapacity",
41
- "label": "Engine Capacity",
42
- "type": "text"
43
- },
44
- {
45
- "name": "co2Emissions",
46
- "label": "CO2 Emissions",
47
- "type": "text"
48
- },
49
- {
50
- "name": "motStatus",
51
- "label": "MOT Status",
52
- "type": "text"
53
- },
54
- {
55
- "name": "motExpiryDate",
56
- "label": "MOT Expiry",
57
- "type": "text"
58
- },
59
- {
60
- "name": "taxStatus",
61
- "label": "Tax Status",
62
- "type": "text"
63
- },
64
- {
65
- "name": "taxDueDate",
66
- "label": "Tax Due",
67
- "type": "text"
68
- },
69
- {
70
- "name": "isSaved",
71
- "label": "Saved",
72
- "type": "text"
73
- },
74
- {
75
- "name": "lookupDate",
76
- "label": "Lookup Date",
77
- "type": "text"
78
- }
79
- ],
80
- "api": {
81
- "create": {
82
- "enabled": false,
83
- "access": "admin"
84
- },
85
- "read": {
86
- "enabled": false,
87
- "access": "admin"
88
- },
89
- "update": {
90
- "enabled": false,
91
- "access": "admin"
92
- },
93
- "delete": {
94
- "enabled": false,
95
- "access": "admin"
96
- }
97
- },
98
- "storage": {
99
- "adapter": "file"
100
- }
101
- }
@@ -1,18 +0,0 @@
1
- /**
2
- * Garage Plugin — Default Configuration
3
- *
4
- * Override any of these via config/plugins.json under "garage.settings".
5
- */
6
- export default {
7
- /** DVLA Vehicle Enquiry Service API key. Obtain from developer-portal.driver-vehicle-licensing.api.gov.uk */
8
- dvlaApiKey: '',
9
- /** DVLA API endpoint — do not change unless DVLA updates their URL. */
10
- dvlaApiUrl: 'https://driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles',
11
- /** Per-user rate limiting for the /lookup endpoint. */
12
- rateLimit: {
13
- max: 10,
14
- windowSeconds: 60
15
- },
16
- /** Maximum lookup history entries to retain per user (oldest unsaved entries are pruned first). */
17
- maxHistoryItems: 100
18
- };
@@ -1,70 +0,0 @@
1
- [
2
- {
3
- "id": "d1757874-3a2d-4894-99ae-57fd934a9a8b",
4
- "userId": "2421ad8e-060d-4548-8878-af7011d5e08b",
5
- "registrationNumber": "GIL7698",
6
- "make": "BMW",
7
- "colour": "GREY",
8
- "yearOfManufacture": 2017,
9
- "fuelType": "DIESEL",
10
- "engineCapacity": 1995,
11
- "co2Emissions": 134,
12
- "motStatus": "Valid",
13
- "motExpiryDate": "2027-03-26",
14
- "taxStatus": "Taxed",
15
- "taxDueDate": "2027-03-01",
16
- "isSaved": true,
17
- "lookupDate": "2026-03-28T21:32:31.725Z"
18
- },
19
- {
20
- "id": "3503abbc-5bb9-4f96-9b13-1ab389f557b4",
21
- "userId": "2421ad8e-060d-4548-8878-af7011d5e08b",
22
- "registrationNumber": "LF16BMV",
23
- "make": "TESLA",
24
- "colour": "BLUE",
25
- "yearOfManufacture": 2016,
26
- "fuelType": "ELECTRICITY",
27
- "engineCapacity": 0,
28
- "co2Emissions": 0,
29
- "motStatus": "Valid",
30
- "motExpiryDate": "2026-12-16",
31
- "taxStatus": "Taxed",
32
- "taxDueDate": "2026-12-01",
33
- "isSaved": true,
34
- "lookupDate": "2026-03-28T21:32:48.814Z"
35
- },
36
- {
37
- "id": "c50c6b66-bc48-4bd1-a8fc-7dbc669f61b5",
38
- "userId": "2421ad8e-060d-4548-8878-af7011d5e08b",
39
- "registrationNumber": "CN64NFR",
40
- "make": "FORD",
41
- "colour": "RED",
42
- "yearOfManufacture": 2014,
43
- "fuelType": "PETROL",
44
- "engineCapacity": 1242,
45
- "co2Emissions": 115,
46
- "motStatus": "Valid",
47
- "motExpiryDate": "2027-01-25",
48
- "taxStatus": "Taxed",
49
- "taxDueDate": "2027-01-01",
50
- "isSaved": true,
51
- "lookupDate": "2026-03-28T21:32:55.731Z"
52
- },
53
- {
54
- "id": "cfd54f2a-7bab-469d-b929-d682a1313154",
55
- "userId": "2421ad8e-060d-4548-8878-af7011d5e08b",
56
- "registrationNumber": "PK75SFO",
57
- "make": "VOLKSWAGEN",
58
- "colour": "BLUE",
59
- "yearOfManufacture": 2025,
60
- "fuelType": "PETROL",
61
- "engineCapacity": 999,
62
- "co2Emissions": 121,
63
- "motStatus": "No details held by DVLA",
64
- "motExpiryDate": null,
65
- "taxStatus": "Taxed",
66
- "taxDueDate": "2026-10-01",
67
- "isSaved": true,
68
- "lookupDate": "2026-03-28T21:49:25.814Z"
69
- }
70
- ]
@@ -1,398 +0,0 @@
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 — Toggle isSaved on a vehicle entry
14
- * DELETE /vehicles/:id — Remove a vehicle entry from history
15
- */
16
- import fs from 'fs';
17
- import path from 'path';
18
- import {fileURLToPath} from 'url';
19
- import {
20
- createEntry,
21
- deleteEntry,
22
- getEntry,
23
- listEntries,
24
- updateEntry
25
- } from '../../server/services/collections.js';
26
- import defaultConfig from './config.js';
27
-
28
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
- const COLLECTION = 'garage-vehicles';
30
- const VEHICLES_DATA = path.join(__dirname, '../../content/collections/garage-vehicles/data.json');
31
-
32
- /** In-memory rate limit tracker: Map<userId, { count: number, windowStart: number }> */
33
- const rateLimits = new Map();
34
-
35
-
36
- /**
37
- * Flatten a collection entry into the vehicle model the frontend expects.
38
- *
39
- * @param {{ id: string, data: object }} entry
40
- * @returns {object}
41
- */
42
- function toVehicle(entry) {
43
- return {
44
- id: entry.id,
45
- ...entry.data,
46
- isSaved: entry.data.isSaved === true || entry.data.isSaved === 'true'
47
- };
48
- }
49
-
50
- /**
51
- * Check and increment the per-user rate limit.
52
- *
53
- * @param {string} userId
54
- * @param {{ max: number, windowSeconds: number }} config
55
- * @returns {{ allowed: boolean, retryAfter: number }} retryAfter is seconds until reset (0 if allowed)
56
- */
57
- function checkRateLimit(userId, config) {
58
- const now = Date.now();
59
- const windowMs = config.windowSeconds * 1000;
60
- const entry = rateLimits.get(userId);
61
-
62
- if (!entry || now - entry.windowStart >= windowMs) {
63
- rateLimits.set(userId, {count: 1, windowStart: now});
64
- return {allowed: true, retryAfter: 0};
65
- }
66
-
67
- if (entry.count >= config.max) {
68
- const retryAfter = Math.ceil((entry.windowStart + windowMs - now) / 1000);
69
- return {allowed: false, retryAfter};
70
- }
71
-
72
- entry.count += 1;
73
- return {allowed: true, retryAfter: 0};
74
- }
75
-
76
- /**
77
- * Normalise a registration number: uppercase and strip all spaces.
78
- *
79
- * @param {string} reg
80
- * @returns {string}
81
- */
82
- function normaliseReg(reg) {
83
- return String(reg).toUpperCase().replace(/\s+/g, '');
84
- }
85
-
86
- /**
87
- * Map a DVLA API response to our internal vehicle data shape.
88
- *
89
- * @param {string} userId
90
- * @param {object} dvlaData - Raw response from DVLA API
91
- * @returns {object}
92
- */
93
- function mapDvlaResponse(userId, dvlaData) {
94
- return {
95
- userId,
96
- registrationNumber: dvlaData.registrationNumber ?? '',
97
- make: dvlaData.make ?? '',
98
- colour: dvlaData.colour ?? '',
99
- yearOfManufacture: dvlaData.yearOfManufacture ?? null,
100
- fuelType: dvlaData.fuelType ?? '',
101
- engineCapacity: dvlaData.engineCapacity ?? null,
102
- co2Emissions: dvlaData.co2Emissions ?? null,
103
- motStatus: dvlaData.motStatus ?? '',
104
- motExpiryDate: dvlaData.motExpiryDate ?? null,
105
- taxStatus: dvlaData.taxStatus ?? '',
106
- taxDueDate: dvlaData.taxDueDate ?? null,
107
- isSaved: false,
108
- lookupDate: new Date().toISOString()
109
- };
110
- }
111
-
112
- /**
113
- * Escape HTML special characters for safe server-side rendering.
114
- *
115
- * @param {*} str
116
- * @returns {string}
117
- */
118
- function escHtml(str) {
119
- return String(str ?? '')
120
- .replace(/&/g, '&amp;')
121
- .replace(/</g, '&lt;')
122
- .replace(/>/g, '&gt;')
123
- .replace(/"/g, '&quot;');
124
- }
125
-
126
- /**
127
- * Render a list of saved vehicle objects as a static HTML card grid.
128
- * Used by the [garage-vehicles /] shortcode for front-end display.
129
- *
130
- * @param {object[]} vehicles
131
- * @returns {string} HTML string
132
- */
133
- function renderVehiclesHtml(vehicles) {
134
- const cards = vehicles.map(v => {
135
- const motLv = (v.motStatus || '').toLowerCase();
136
- const motCls = motLv === 'valid' ? 'badge-success' : motLv ? 'badge-danger' : 'badge-secondary';
137
- const taxLv = (v.taxStatus || '').toLowerCase();
138
- const taxCls = taxLv === 'taxed' ? 'badge-success' : taxLv === 'sorn' ? 'badge-warning' : taxLv ? 'badge-danger' : 'badge-secondary';
139
-
140
- const details = [
141
- v.colour,
142
- v.fuelType,
143
- v.engineCapacity ? `${v.engineCapacity}cc` : null,
144
- v.co2Emissions ? `${v.co2Emissions}g/km` : null
145
- ].filter(Boolean).join(' · ');
146
-
147
- const motExpiry = v.motExpiryDate
148
- ? `<span style="font-size:0.78rem;color:var(--dm-text-muted,#888);">Exp ${escHtml(v.motExpiryDate)}</span>` : '';
149
- const taxDue = v.taxDueDate
150
- ? `<span style="font-size:0.78rem;color:var(--dm-text-muted,#888);">Due ${escHtml(v.taxDueDate)}</span>` : '';
151
-
152
- return `<div class="card" style="flex:1;min-width:260px;max-width:340px;">
153
- <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;">
154
- <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>
155
- <span style="font-size:0.85rem;color:var(--dm-text-muted,#888);">${escHtml(v.make || '')}${v.yearOfManufacture ? ` · ${escHtml(String(v.yearOfManufacture))}` : ''}</span>
156
- </div>
157
- <div class="card-body">
158
- ${details ? `<p style="font-size:0.85rem;color:var(--dm-text-muted,#888);margin:0 0 0.6rem;">${escHtml(details)}</p>` : ''}
159
- <div style="display:flex;gap:0.4rem;flex-wrap:wrap;align-items:center;">
160
- <span class="badge ${motCls}">MOT: ${escHtml(v.motStatus || 'Unknown')}</span>
161
- ${motExpiry}
162
- <span class="badge ${taxCls}">Tax: ${escHtml(v.taxStatus || 'Unknown')}</span>
163
- ${taxDue}
164
- </div>
165
- </div>
166
- </div>`;
167
- }).join('\n');
168
-
169
- return `<div style="display:flex;flex-wrap:wrap;gap:1rem;">\n${cards}\n</div>`;
170
- }
171
-
172
- export default async function garagePlugin(fastify, options) {
173
- const {authenticate} = options.auth;
174
- const {registerShortcode} = options.hooks;
175
- const cfg = {...defaultConfig, ...(options.settings || {})};
176
- cfg.dvlaApiKey = process.env.DVLA_API_KEY || cfg.dvlaApiKey;
177
-
178
- // --- [garage-vehicles /] shortcode --------------------------------------
179
-
180
- /**
181
- * Renders saved vehicles as a static HTML card grid on public pages.
182
- * Reads data synchronously so it fits the markdown pipeline.
183
- *
184
- * Usage:
185
- * [garage-vehicles /] — all saved vehicles
186
- * [garage-vehicles user="uuid" /] — one user's saved vehicles
187
- */
188
- registerShortcode('garage-vehicles', (attrStr, _body, ctx) => {
189
- const attrs = ctx.parseShortcodeAttrs(attrStr);
190
- const userId = attrs.user || null;
191
-
192
- let entries;
193
- try {
194
- entries = JSON.parse(fs.readFileSync(VEHICLES_DATA, 'utf8'));
195
- } catch {
196
- return '';
197
- }
198
-
199
- let vehicles = entries
200
- .filter(e => e.data && (e.data.isSaved === true || e.data.isSaved === 'true'))
201
- .map(e => ({id: e.id, ...e.data, isSaved: true}));
202
-
203
- if (userId) vehicles = vehicles.filter(v => v.userId === userId);
204
- if (!vehicles.length) return '';
205
-
206
- return renderVehiclesHtml(vehicles);
207
- });
208
-
209
- // --- POST /lookup -------------------------------------------------------
210
-
211
- /**
212
- * Proxy a DVLA vehicle lookup and upsert the result into the user's history.
213
- * Returns 503 if no DVLA API key is configured.
214
- * Returns 429 if the user has exceeded the rate limit.
215
- */
216
- fastify.post('/lookup', {preHandler: [authenticate]}, async (request, reply) => {
217
- const userId = request.user.id;
218
- const {registrationNumber} = request.body || {};
219
-
220
- if (!registrationNumber || typeof registrationNumber !== 'string') {
221
- return reply.status(400).send({error: 'registrationNumber is required'});
222
- }
223
-
224
- const reg = normaliseReg(registrationNumber);
225
- if (!reg) {
226
- return reply.status(400).send({error: 'Invalid registration number'});
227
- }
228
-
229
- const rl = checkRateLimit(userId, cfg.rateLimit);
230
- if (!rl.allowed) {
231
- return reply.status(429).send({
232
- error: `Rate limit reached. Try again in ${rl.retryAfter} seconds.`,
233
- retryAfter: rl.retryAfter
234
- });
235
- }
236
-
237
- if (!cfg.dvlaApiKey) {
238
- return reply.status(503).send({
239
- error: 'DVLA API key not configured. Add dvlaApiKey to the Garage plugin settings in config/plugins.json.'
240
- });
241
- }
242
-
243
- let dvlaData;
244
- try {
245
- const dvlaRes = await fetch(cfg.dvlaApiUrl, {
246
- method: 'POST',
247
- headers: {
248
- 'x-api-key': cfg.dvlaApiKey,
249
- 'Content-Type': 'application/json'
250
- },
251
- body: JSON.stringify({registrationNumber: reg})
252
- });
253
-
254
- if (dvlaRes.status === 404) {
255
- return reply.status(404).send({error: 'Vehicle not found. Check the registration number.'});
256
- }
257
- if (!dvlaRes.ok) {
258
- const body = await dvlaRes.text().catch(() => '');
259
- fastify.log.warn({status: dvlaRes.status, body}, '[garage] DVLA API error');
260
- return reply.status(502).send({error: 'DVLA API returned an error. Please try again.'});
261
- }
262
-
263
- dvlaData = await dvlaRes.json();
264
- } catch (err) {
265
- fastify.log.error({err}, '[garage] Failed to reach DVLA API');
266
- return reply.status(502).send({error: 'Could not reach the DVLA API. Check your network connection.'});
267
- }
268
-
269
- // Upsert: update existing entry if same reg+user, otherwise create
270
- const {entries: all} = await listEntries(COLLECTION, {limit: 10000});
271
- const existing = all.find(e => e.data.userId === userId && e.data.registrationNumber === reg);
272
-
273
- let vehicle;
274
- if (existing) {
275
- const updated = await updateEntry(COLLECTION, existing.id, {
276
- ...mapDvlaResponse(userId, dvlaData),
277
- isSaved: existing.data.isSaved
278
- });
279
- vehicle = toVehicle(updated);
280
- } else {
281
- const created = await createEntry(COLLECTION, mapDvlaResponse(userId, dvlaData), {createdBy: userId});
282
- vehicle = toVehicle(created);
283
-
284
- // Prune oldest unsaved entries if over the history limit
285
- const {entries: fresh} = await listEntries(COLLECTION, {limit: 10000});
286
- const userEntries = fresh.filter(e => e.data.userId === userId);
287
- if (userEntries.length > cfg.maxHistoryItems) {
288
- const unsaved = userEntries
289
- .filter(e => !e.data.isSaved)
290
- .sort((a, b) => new Date(a.data.lookupDate) - new Date(b.data.lookupDate));
291
- const toDelete = unsaved.slice(0, userEntries.length - cfg.maxHistoryItems);
292
- for (const entry of toDelete) {
293
- await deleteEntry(COLLECTION, entry.id);
294
- }
295
- }
296
- }
297
-
298
- return vehicle;
299
- });
300
-
301
- // --- GET /vehicles (saved) ----------------------------------------------
302
-
303
- /**
304
- * Return all vehicles the authenticated user has saved to their garage.
305
- * Supports optional ?q= for server-side search across reg/make/colour/year/fuel.
306
- */
307
- fastify.get('/vehicles', {preHandler: [authenticate]}, async (request) => {
308
- const userId = request.user.id;
309
- const q = (request.query.q || '').toLowerCase().trim();
310
-
311
- const {entries} = await listEntries(COLLECTION, {limit: 10000});
312
- let results = entries
313
- .filter(e => e.data.userId === userId && (e.data.isSaved === true || e.data.isSaved === 'true'))
314
- .map(toVehicle);
315
-
316
- if (q) {
317
- results = results.filter(v => {
318
- const haystack = [
319
- v.registrationNumber, v.make, v.colour,
320
- v.yearOfManufacture, v.fuelType, v.taxStatus, v.motStatus
321
- ].join(' ').toLowerCase();
322
- return haystack.includes(q);
323
- });
324
- }
325
-
326
- return results;
327
- });
328
-
329
- // --- GET /history -------------------------------------------------------
330
-
331
- /**
332
- * Return the authenticated user's full lookup history, newest first.
333
- * Supports optional ?q= search.
334
- */
335
- fastify.get('/history', {preHandler: [authenticate]}, async (request) => {
336
- const userId = request.user.id;
337
- const q = (request.query.q || '').toLowerCase().trim();
338
-
339
- const {entries} = await listEntries(COLLECTION, {limit: 10000});
340
- let results = entries
341
- .filter(e => e.data.userId === userId)
342
- .map(toVehicle)
343
- .sort((a, b) => new Date(b.lookupDate) - new Date(a.lookupDate));
344
-
345
- if (q) {
346
- results = results.filter(v => {
347
- const haystack = [
348
- v.registrationNumber, v.make, v.colour,
349
- v.yearOfManufacture, v.fuelType, v.taxStatus, v.motStatus
350
- ].join(' ').toLowerCase();
351
- return haystack.includes(q);
352
- });
353
- }
354
-
355
- return results;
356
- });
357
-
358
- // --- PATCH /vehicles/:id/save -------------------------------------------
359
-
360
- /**
361
- * Toggle the isSaved flag on a vehicle entry.
362
- * Returns 404 if the entry does not exist or belongs to another user.
363
- */
364
- fastify.patch('/vehicles/:id/save', {preHandler: [authenticate]}, async (request, reply) => {
365
- const userId = request.user.id;
366
- const {id} = request.params;
367
-
368
- const entry = await getEntry(COLLECTION, id);
369
- if (!entry || entry.data.userId !== userId) {
370
- return reply.status(404).send({error: 'Vehicle not found'});
371
- }
372
-
373
- const updated = await updateEntry(COLLECTION, id, {
374
- ...entry.data,
375
- isSaved: !(entry.data.isSaved === true || entry.data.isSaved === 'true')
376
- });
377
- return toVehicle(updated);
378
- });
379
-
380
- // --- DELETE /vehicles/:id -----------------------------------------------
381
-
382
- /**
383
- * Permanently remove a vehicle entry from the user's history.
384
- * Returns 404 if the entry does not exist or belongs to another user.
385
- */
386
- fastify.delete('/vehicles/:id', {preHandler: [authenticate]}, async (request, reply) => {
387
- const userId = request.user.id;
388
- const {id} = request.params;
389
-
390
- const entry = await getEntry(COLLECTION, id);
391
- if (!entry || entry.data.userId !== userId) {
392
- return reply.status(404).send({error: 'Vehicle not found'});
393
- }
394
-
395
- await deleteEntry(COLLECTION, id);
396
- return {ok: true};
397
- });
398
- }
@@ -1,33 +0,0 @@
1
- {
2
- "name": "garage",
3
- "displayName": "Garage",
4
- "version": "1.0.0",
5
- "description": "UK vehicle management with DVLA API lookup, save vehicles, and search history.",
6
- "author": "Darryl Waterhouse",
7
- "date": "2026-03-28",
8
- "icon": "truck",
9
- "admin": {
10
- "sidebar": [
11
- {
12
- "id": "garage",
13
- "text": "Garage",
14
- "icon": "truck",
15
- "url": "#/plugins/garage",
16
- "section": "#/plugins/garage"
17
- }
18
- ],
19
- "routes": [
20
- {
21
- "path": "/plugins/garage",
22
- "view": "plugin-garage",
23
- "title": "Garage - Domma CMS"
24
- }
25
- ],
26
- "views": {
27
- "plugin-garage": {
28
- "entry": "garage/admin/views/garage.js?v=4",
29
- "exportName": "garageView"
30
- }
31
- }
32
- }
33
- }