domma-cms 0.3.0 → 0.5.2

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 (150) hide show
  1. package/README.md +3 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/dist/domma/domma-tools.css +2313 -0
  4. package/admin/dist/domma/domma-tools.min.js +10 -0
  5. package/admin/index.html +4 -0
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +8 -4
  8. package/admin/js/config/sidebar-config.js +1 -1
  9. package/admin/js/lib/markdown-toolbar.js +18 -10
  10. package/admin/js/templates/action-editor.html +171 -0
  11. package/admin/js/templates/actions-list.html +19 -0
  12. package/admin/js/templates/api-reference.html +1411 -0
  13. package/admin/js/templates/block-editor.html +158 -0
  14. package/admin/js/templates/blocks.html +8 -0
  15. package/admin/js/templates/collection-editor.html +47 -0
  16. package/admin/js/templates/collection-entries.html +3 -0
  17. package/admin/js/templates/collections.html +51 -4
  18. package/admin/js/templates/documentation.html +258 -0
  19. package/{plugins/form-builder/admin → admin/js}/templates/form-editor.html +238 -199
  20. package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
  21. package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
  22. package/admin/js/templates/login.html +29 -4
  23. package/admin/js/templates/my-profile.html +17 -0
  24. package/admin/js/templates/page-editor.html +39 -0
  25. package/admin/js/templates/pages.html +6 -1
  26. package/admin/js/templates/pro-docs.html +259 -0
  27. package/admin/js/templates/role-editor.html +59 -0
  28. package/admin/js/templates/roles.html +10 -0
  29. package/admin/js/templates/settings.html +167 -23
  30. package/admin/js/templates/tutorials.html +81 -0
  31. package/admin/js/templates/user-editor.html +7 -0
  32. package/admin/js/templates/users.html +3 -26
  33. package/admin/js/templates/view-editor.html +201 -0
  34. package/admin/js/templates/view-preview.html +51 -0
  35. package/admin/js/templates/views-list.html +19 -0
  36. package/admin/js/views/action-editor.js +1 -0
  37. package/admin/js/views/actions-list.js +1 -0
  38. package/admin/js/views/api-reference.js +1 -0
  39. package/admin/js/views/block-editor.js +8 -0
  40. package/admin/js/views/blocks.js +4 -0
  41. package/admin/js/views/collection-editor.js +3 -3
  42. package/admin/js/views/collection-entries.js +1 -1
  43. package/admin/js/views/collections.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +8 -0
  46. package/admin/js/views/form-submissions.js +1 -0
  47. package/admin/js/views/forms.js +1 -0
  48. package/admin/js/views/index.js +1 -1
  49. package/admin/js/views/login.js +2 -2
  50. package/admin/js/views/media.js +1 -1
  51. package/admin/js/views/my-profile.js +1 -0
  52. package/admin/js/views/page-editor.js +34 -15
  53. package/admin/js/views/pages.js +5 -5
  54. package/admin/js/views/plugins.js +10 -10
  55. package/admin/js/views/pro-docs.js +1 -0
  56. package/admin/js/views/role-editor.js +1 -0
  57. package/admin/js/views/roles.js +4 -0
  58. package/admin/js/views/settings.js +3 -1
  59. package/admin/js/views/user-editor.js +1 -1
  60. package/admin/js/views/users.js +4 -7
  61. package/admin/js/views/view-editor.js +1 -0
  62. package/admin/js/views/view-preview.js +1 -0
  63. package/admin/js/views/views-list.js +1 -0
  64. package/bin/cli.js +1 -1
  65. package/config/auth.json +1 -0
  66. package/config/connections.json.bak +9 -0
  67. package/config/connections.json.example +9 -0
  68. package/config/navigation.json +5 -15
  69. package/config/plugins.json +19 -29
  70. package/config/server.json +6 -6
  71. package/config/site.json +16 -6
  72. package/package.json +25 -10
  73. package/plugins/example-analytics/stats.json +17 -12
  74. package/plugins/form-builder/data/forms/contacts.json +62 -62
  75. package/plugins/form-builder/data/forms/enquiries.json +103 -0
  76. package/plugins/form-builder/data/forms/feedback.json +17 -16
  77. package/plugins/form-builder/data/forms/notes.json +79 -0
  78. package/plugins/form-builder/data/forms/to-do.json +100 -0
  79. package/plugins/form-builder/data/submissions/contacts.json +1 -26
  80. package/plugins/form-builder/data/submissions/notes.json +1 -0
  81. package/plugins/form-builder/data/submissions/to-do.json +1 -0
  82. package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
  83. package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
  84. package/plugins/theme-roller/config.js +1 -0
  85. package/plugins/theme-roller/plugin.js +233 -0
  86. package/plugins/theme-roller/plugin.json +31 -0
  87. package/plugins/theme-roller/public/active-theme.css +0 -0
  88. package/plugins/theme-roller/public/inject-head-late.html +1 -0
  89. package/public/css/forms.css +1 -0
  90. package/public/css/site.css +1 -1
  91. package/public/js/forms.js +1 -0
  92. package/public/js/site.js +1 -1
  93. package/scripts/build.js +194 -129
  94. package/scripts/pro.js +254 -0
  95. package/scripts/reset.js +33 -8
  96. package/scripts/seed.js +677 -128
  97. package/scripts/setup.js +1 -0
  98. package/server/middleware/auth.js +136 -120
  99. package/server/routes/api/actions.js +200 -0
  100. package/server/routes/api/auth.js +292 -146
  101. package/server/routes/api/blocks.js +84 -0
  102. package/server/routes/api/collections.js +79 -27
  103. package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +491 -505
  104. package/server/routes/api/layouts.js +49 -39
  105. package/server/routes/api/media.js +118 -92
  106. package/server/routes/api/navigation.js +40 -36
  107. package/server/routes/api/pages.js +132 -118
  108. package/server/routes/api/plugins.js +6 -3
  109. package/server/routes/api/settings.js +104 -88
  110. package/server/routes/api/users.js +27 -19
  111. package/server/routes/api/views.js +148 -0
  112. package/server/routes/public.js +124 -108
  113. package/server/server.js +269 -181
  114. package/server/services/actions.js +387 -0
  115. package/server/services/adapterRegistry.js +98 -0
  116. package/server/services/adapters/FileAdapter.js +192 -0
  117. package/server/services/adapters/MongoAdapter.js +220 -0
  118. package/server/services/blocks.js +162 -0
  119. package/server/services/collections.js +74 -86
  120. package/server/services/connectionManager.js +102 -0
  121. package/server/services/content.js +312 -307
  122. package/server/services/email.js +126 -0
  123. package/server/services/forms.js +173 -0
  124. package/server/services/markdown.js +1378 -747
  125. package/server/services/permissionRegistry.js +173 -0
  126. package/server/services/presetCollections.js +251 -0
  127. package/server/services/renderer.js +98 -2
  128. package/server/services/roles.js +227 -0
  129. package/server/services/rowAccess.js +104 -0
  130. package/server/services/userProfiles.js +199 -0
  131. package/server/services/users.js +281 -212
  132. package/server/services/views.js +280 -0
  133. package/server/templates/page.html +124 -113
  134. package/plugins/form-builder/admin/templates/form-settings.html +0 -29
  135. package/plugins/form-builder/admin/views/form-editor.js +0 -1444
  136. package/plugins/form-builder/admin/views/form-settings.js +0 -38
  137. package/plugins/form-builder/admin/views/form-submissions.js +0 -295
  138. package/plugins/form-builder/admin/views/forms-list.js +0 -164
  139. package/plugins/form-builder/config.js +0 -9
  140. package/plugins/form-builder/data/forms/consent.json +0 -104
  141. package/plugins/form-builder/data/forms/contact-details.json +0 -99
  142. package/plugins/form-builder/data/submissions/consent.json +0 -13
  143. package/plugins/form-builder/plugin.json +0 -52
  144. package/plugins/form-builder/public/inject-body.html +0 -352
  145. package/plugins/form-builder/public/inject-head.html +0 -58
  146. package/plugins/form-builder/public/package.json +0 -1
  147. package/scripts/copy-domma.js +0 -48
  148. package/server/services/userTypes.js +0 -167
  149. /package/plugins/form-builder/data/submissions/{contact-details.json → enquiries.json} +0 -0
  150. /package/{plugins/form-builder/public → public/js}/form-logic-engine.js +0 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Roles Service
3
+ * Preset Collection — seeds roles on first startup, caches them in memory.
4
+ * Auth middleware calls getRoleMap() / getPermissionsFor() at request time.
5
+ */
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import {v4 as uuidv4} from 'uuid';
9
+ import {config} from '../config.js';
10
+ import {ACTIONS, getActionsForResource, RESOURCES} from './permissionRegistry.js';
11
+
12
+ const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
13
+ const SLUG = 'roles';
14
+ const DIR = path.join(COLLECTIONS_DIR, SLUG);
15
+ const SCHEMA_PATH = path.join(DIR, 'schema.json');
16
+ const DATA_PATH = path.join(DIR, 'data.json');
17
+
18
+ export {RESOURCES, ACTIONS};
19
+
20
+ const PRESET_SCHEMA = {
21
+ slug: SLUG,
22
+ title: 'Roles',
23
+ description: 'CMS role definitions — managed by the system.',
24
+ preset: true,
25
+ fields: [
26
+ {name: 'name', label: 'Name (slug)', type: 'text', required: true},
27
+ {name: 'label', label: 'Label', type: 'text', required: true},
28
+ {name: 'level', label: 'Level', type: 'number', required: true},
29
+ {
30
+ name: 'permissions',
31
+ label: 'Permissions',
32
+ type: 'multi-select',
33
+ options: RESOURCES.flatMap(r => [r, ...ACTIONS.map(a => `${r}.${a}`)])
34
+ },
35
+ {
36
+ name: 'badgeClass', label: 'Badge Class', type: 'select',
37
+ options: ['badge-danger', 'badge-warning', 'badge-info', 'badge-secondary', 'badge-success', 'badge-primary']
38
+ }
39
+ ],
40
+ api: {
41
+ create: {enabled: false, access: 'admin'},
42
+ read: {enabled: false, access: 'admin'},
43
+ update: {enabled: false, access: 'admin'},
44
+ delete: {enabled: false, access: 'admin'}
45
+ }
46
+ };
47
+
48
+ const SEED_ENTRIES = [
49
+ {name: 'admin', label: 'Admin', level: 0, permissions: RESOURCES, badgeClass: 'badge-danger'},
50
+ {
51
+ name: 'manager',
52
+ label: 'Manager',
53
+ level: 1,
54
+ permissions: ['pages', 'settings', 'navigation', 'layouts', 'media', 'users', 'collections', 'views', 'actions'],
55
+ badgeClass: 'badge-warning'
56
+ },
57
+ {
58
+ name: 'editor',
59
+ label: 'Editor',
60
+ level: 2,
61
+ permissions: ['pages.read', 'pages.create', 'pages.update', 'media'],
62
+ badgeClass: 'badge-info'
63
+ },
64
+ {name: 'subscriber', label: 'Subscriber', level: 3, permissions: [], badgeClass: 'badge-secondary'}
65
+ ];
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // In-memory cache
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /** @type {Map<string,{label:string,level:number,badgeClass:string}>} */
72
+ let roleMap = new Map();
73
+
74
+ /** @type {Map<string,string[]>} resource (and resource.action) → role names */
75
+ let permissionsMap = new Map();
76
+
77
+ /** @type {Map<string,string[]>} role name → raw permissions array */
78
+ let rawPermissionsMap = new Map();
79
+
80
+ /**
81
+ * Build in-memory maps from an array of data entries.
82
+ * Supports both bare resource names ('pages') and dotted action strings ('pages.read').
83
+ * Bare names expand to all four actions for backward compatibility.
84
+ *
85
+ * @param {object[]} entries
86
+ */
87
+ function buildCache(entries) {
88
+ roleMap = new Map();
89
+ permissionsMap = new Map();
90
+ rawPermissionsMap = new Map();
91
+
92
+ const addTo = (key, role) => {
93
+ if (!permissionsMap.has(key)) permissionsMap.set(key, []);
94
+ if (!permissionsMap.get(key).includes(role)) permissionsMap.get(key).push(role);
95
+ };
96
+
97
+ for (const entry of entries) {
98
+ const d = entry.data;
99
+ roleMap.set(d.name, {label: d.label, level: d.level, badgeClass: d.badgeClass || ''});
100
+ rawPermissionsMap.set(d.name, d.permissions || []);
101
+ for (const perm of (d.permissions || [])) {
102
+ if (perm.includes('.')) {
103
+ const [res] = perm.split('.');
104
+ addTo(perm, d.name); // 'pages.read' → [role]
105
+ addTo(res, d.name); // 'pages' → [role] (any-action union)
106
+ } else {
107
+ addTo(perm, d.name); // 'pages' (bare) → [role]
108
+ for (const action of getActionsForResource(perm)) {
109
+ addTo(`${perm}.${action}`, d.name); // expand to resource's actual actions
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Public API
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Seed the preset collection on first startup (no-op if data.json already exists).
122
+ *
123
+ * @returns {Promise<void>}
124
+ */
125
+ export async function seed() {
126
+ await fs.mkdir(DIR, {recursive: true});
127
+
128
+ // Always write schema (overwrite to keep in sync with code)
129
+ await fs.writeFile(SCHEMA_PATH, JSON.stringify(PRESET_SCHEMA, null, 2) + '\n', 'utf8');
130
+
131
+ // Only write data if it doesn't exist yet
132
+ try {
133
+ await fs.access(DATA_PATH);
134
+ } catch {
135
+ const entries = SEED_ENTRIES.map(data => ({
136
+ id: uuidv4(),
137
+ data,
138
+ createdAt: new Date().toISOString(),
139
+ updatedAt: new Date().toISOString()
140
+ }));
141
+ await fs.writeFile(DATA_PATH, JSON.stringify(entries, null, 2) + '\n', 'utf8');
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Load the collection from disk into the in-memory cache.
147
+ *
148
+ * @returns {Promise<void>}
149
+ */
150
+ export async function load() {
151
+ try {
152
+ const raw = await fs.readFile(DATA_PATH, 'utf8');
153
+ const entries = JSON.parse(raw);
154
+ buildCache(entries);
155
+ } catch (err) {
156
+ console.warn('[roles] Failed to load roles collection:', err.message);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Reload from disk — call after any CRUD on the roles collection.
162
+ *
163
+ * @returns {Promise<void>}
164
+ */
165
+ export async function invalidate() {
166
+ await load();
167
+ }
168
+
169
+ /**
170
+ * Return the full role map.
171
+ *
172
+ * @returns {Map<string,{label:string,level:number,badgeClass:string}>}
173
+ */
174
+ export function getRoleMap() {
175
+ return roleMap;
176
+ }
177
+
178
+ /**
179
+ * Return the level for a named role, or Infinity if not found.
180
+ *
181
+ * @param {string} roleName
182
+ * @returns {number}
183
+ */
184
+ export function getRoleLevel(roleName) {
185
+ return roleMap.get(roleName)?.level ?? Infinity;
186
+ }
187
+
188
+ /**
189
+ * Return the role names allowed to access a resource (and optional action).
190
+ * - getPermissionsFor('pages') → roles with ANY action on pages (backward compat)
191
+ * - getPermissionsFor('pages', 'delete')→ roles with delete on pages
192
+ * - getPermissionsFor('pages.delete') → same as above (dot-notation shorthand)
193
+ *
194
+ * @param {string} resource - Resource key, or 'resource.action' dot notation
195
+ * @param {string} [action] - Optional action (read | create | update | delete)
196
+ * @returns {string[]}
197
+ */
198
+ export function getPermissionsFor(resource, action) {
199
+ if (action) {
200
+ return permissionsMap.get(`${resource}.${action}`) ?? [];
201
+ }
202
+ if (resource.includes('.')) {
203
+ return permissionsMap.get(resource) ?? [];
204
+ }
205
+ return permissionsMap.get(resource) ?? [];
206
+ }
207
+
208
+ /**
209
+ * Return the raw permissions array for a role — used by the /api/auth/permissions endpoint.
210
+ *
211
+ * @param {string} roleName
212
+ * @returns {string[]}
213
+ */
214
+ export function getPermissionsForRole(roleName) {
215
+ return rawPermissionsMap.get(roleName) ?? [];
216
+ }
217
+
218
+ /**
219
+ * Return role names ordered from most to least privileged.
220
+ *
221
+ * @returns {string[]}
222
+ */
223
+ export function getRoleHierarchy() {
224
+ return [...roleMap.entries()]
225
+ .sort((a, b) => a[1].level - b[1].level)
226
+ .map(([key]) => key);
227
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Row-Level Access Control
3
+ *
4
+ * Shared helpers for scoping Actions and Views to specific entries based on
5
+ * ownership or a foreign-key field match.
6
+ *
7
+ * rowLevel shape (stored in action/view access config):
8
+ * {
9
+ * mode: 'owner' | 'field',
10
+ * field: 'assigned_to', // required when mode === 'field'
11
+ * userKey: 'id' // user property to match (default: 'id')
12
+ * }
13
+ *
14
+ * Admin users (role level 0) always bypass row-level checks.
15
+ */
16
+ import {getRoleLevel} from './roles.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Resolve the user value to match against.
24
+ *
25
+ * @param {object} user
26
+ * @param {string} [userKey='id']
27
+ * @returns {string|undefined}
28
+ */
29
+ function resolveUserValue(user, userKey = 'id') {
30
+ return user?.[userKey];
31
+ }
32
+
33
+ /**
34
+ * Whether the user is an admin (bypasses all row-level restrictions).
35
+ *
36
+ * @param {object|null} user
37
+ * @returns {boolean}
38
+ */
39
+ function isAdmin(user) {
40
+ if (!user?.role) return false;
41
+ return getRoleLevel(user.role) === 0;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Exports
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Check whether a user may access a specific entry under row-level rules.
50
+ * Returns `true` when: rowLevel is absent, user is admin, or the entry
51
+ * satisfies the configured mode.
52
+ *
53
+ * @param {object} entry - Full entry object ({ id, data, meta })
54
+ * @param {object|null} user - Authenticated user object
55
+ * @param {object|null} rowLevel - rowLevel config from action/view access
56
+ * @returns {boolean}
57
+ */
58
+ export function checkEntryAccess(entry, user, rowLevel) {
59
+ if (!rowLevel) return true;
60
+ if (isAdmin(user)) return true;
61
+
62
+ const userVal = resolveUserValue(user, rowLevel.userKey);
63
+
64
+ if (rowLevel.mode === 'owner') {
65
+ return entry?.meta?.createdBy === userVal;
66
+ }
67
+
68
+ if (rowLevel.mode === 'field') {
69
+ const field = rowLevel.field;
70
+ if (!field) return true; // misconfigured — be permissive
71
+ return entry?.data?.[field] === userVal;
72
+ }
73
+
74
+ return true; // unknown mode — be permissive
75
+ }
76
+
77
+ /**
78
+ * Build a MongoDB `$match` filter for row-level access, or return `null` when
79
+ * no filtering is required (admin, no rowLevel config, or invalid config).
80
+ *
81
+ * The returned object is ready to use as `{ $match: buildRowLevelMatch(...) }`.
82
+ *
83
+ * @param {object|null} user
84
+ * @param {object|null} rowLevel
85
+ * @returns {object|null}
86
+ */
87
+ export function buildRowLevelMatch(user, rowLevel) {
88
+ if (!rowLevel) return null;
89
+ if (isAdmin(user)) return null;
90
+
91
+ const userVal = resolveUserValue(user, rowLevel.userKey);
92
+
93
+ if (rowLevel.mode === 'owner') {
94
+ return {'meta.createdBy': userVal};
95
+ }
96
+
97
+ if (rowLevel.mode === 'field') {
98
+ const field = rowLevel.field;
99
+ if (!field) return null;
100
+ return {[`data.${field}`]: userVal};
101
+ }
102
+
103
+ return null;
104
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * User Profiles Service
3
+ * Preset Collection — stores extended profile data for each user,
4
+ * keyed by user UUID. Seeded on startup; entries auto-managed on user lifecycle.
5
+ */
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import {config} from '../config.js';
9
+
10
+ const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
11
+ const SLUG = 'user-profiles';
12
+ const DIR = path.join(COLLECTIONS_DIR, SLUG);
13
+ const SCHEMA_PATH = path.join(DIR, 'schema.json');
14
+ const DATA_PATH = path.join(DIR, 'data.json');
15
+
16
+ const USERS_DIR = path.resolve(config.content.usersDir);
17
+
18
+ const PRESET_SCHEMA = {
19
+ slug: SLUG,
20
+ title: 'User Profiles',
21
+ description: 'Extended profile data linked to users by ID.',
22
+ preset: true,
23
+ fields: [],
24
+ api: {
25
+ create: {enabled: false, access: 'admin'},
26
+ read: {enabled: false, access: 'admin'},
27
+ update: {enabled: false, access: 'admin'},
28
+ delete: {enabled: false, access: 'admin'}
29
+ }
30
+ };
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Read data.json, returning an empty array on any error.
38
+ *
39
+ * @returns {Promise<object[]>}
40
+ */
41
+ async function readData() {
42
+ try {
43
+ const raw = await fs.readFile(DATA_PATH, 'utf8');
44
+ return JSON.parse(raw);
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Write entries array to data.json.
52
+ *
53
+ * @param {object[]} entries
54
+ * @returns {Promise<void>}
55
+ */
56
+ async function writeData(entries) {
57
+ await fs.writeFile(DATA_PATH, JSON.stringify(entries, null, 2) + '\n', 'utf8');
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Public API
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Seed the preset collection on boot.
66
+ * Schema is always overwritten; data.json is only created if absent.
67
+ *
68
+ * @returns {Promise<void>}
69
+ */
70
+ export async function seed() {
71
+ await fs.mkdir(DIR, {recursive: true});
72
+
73
+ // Only write schema if absent — admin edits to fields must be preserved across restarts
74
+ try {
75
+ await fs.access(SCHEMA_PATH);
76
+ } catch {
77
+ await fs.writeFile(SCHEMA_PATH, JSON.stringify(PRESET_SCHEMA, null, 2) + '\n', 'utf8');
78
+ }
79
+
80
+ // Only create data file if absent
81
+ try {
82
+ await fs.access(DATA_PATH);
83
+ } catch {
84
+ await writeData([]);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Ensure a profile entry exists for the given user ID.
90
+ * Creates an empty profile if one does not already exist.
91
+ *
92
+ * @param {string} userId
93
+ * @returns {Promise<void>}
94
+ */
95
+ export async function ensureProfile(userId) {
96
+ const entries = await readData();
97
+ const exists = entries.some(e => e.id === userId);
98
+ if (!exists) {
99
+ entries.push({
100
+ id: userId,
101
+ data: {},
102
+ meta: {createdAt: new Date().toISOString(), updatedAt: new Date().toISOString()}
103
+ });
104
+ await writeData(entries);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Get the profile entry for a user, or null if not found.
110
+ *
111
+ * @param {string} userId
112
+ * @returns {Promise<object|null>}
113
+ */
114
+ export async function getProfile(userId) {
115
+ const entries = await readData();
116
+ return entries.find(e => e.id === userId) || null;
117
+ }
118
+
119
+ /**
120
+ * Merge new data into a user's profile.
121
+ * Creates the profile entry if it does not exist.
122
+ *
123
+ * @param {string} userId
124
+ * @param {object} data - Flat key/value pairs to merge into profile data
125
+ * @returns {Promise<object>} Updated profile entry
126
+ */
127
+ export async function updateProfile(userId, data) {
128
+ const entries = await readData();
129
+ const index = entries.findIndex(e => e.id === userId);
130
+
131
+ if (index === -1) {
132
+ const entry = {
133
+ id: userId,
134
+ data: {...data},
135
+ meta: {createdAt: new Date().toISOString(), updatedAt: new Date().toISOString()}
136
+ };
137
+ entries.push(entry);
138
+ await writeData(entries);
139
+ return entry;
140
+ }
141
+
142
+ entries[index] = {
143
+ ...entries[index],
144
+ data: {...entries[index].data, ...data},
145
+ meta: {...entries[index].meta, updatedAt: new Date().toISOString()}
146
+ };
147
+ await writeData(entries);
148
+ return entries[index];
149
+ }
150
+
151
+ /**
152
+ * Remove a user's profile entry. Silent if not found.
153
+ *
154
+ * @param {string} userId
155
+ * @returns {Promise<void>}
156
+ */
157
+ export async function deleteProfile(userId) {
158
+ const entries = await readData();
159
+ const filtered = entries.filter(e => e.id !== userId);
160
+ if (filtered.length !== entries.length) {
161
+ await writeData(filtered);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Return a Map of userId → profile data object.
167
+ * Reads data.json once — use this for list endpoints to avoid N+1 reads.
168
+ *
169
+ * @returns {Promise<Map<string, object>>}
170
+ */
171
+ export async function listProfiles() {
172
+ const entries = await readData();
173
+ const map = new Map();
174
+ for (const entry of entries) {
175
+ map.set(entry.id, entry.data || {});
176
+ }
177
+ return map;
178
+ }
179
+
180
+ /**
181
+ * Migration helper — ensure every existing user has a profile entry.
182
+ * Called once on startup after seed().
183
+ *
184
+ * @returns {Promise<void>}
185
+ */
186
+ export async function ensureAllProfiles() {
187
+ let userFiles;
188
+ try {
189
+ userFiles = await fs.readdir(USERS_DIR);
190
+ } catch {
191
+ return;
192
+ }
193
+
194
+ const jsonFiles = userFiles.filter(f => f.endsWith('.json'));
195
+ for (const file of jsonFiles) {
196
+ const userId = file.replace(/\.json$/, '');
197
+ await ensureProfile(userId);
198
+ }
199
+ }