domma-cms 0.17.0 → 0.21.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/CLAUDE.md +39 -3
- package/admin/css/admin.css +1 -1
- package/admin/css/dashboard.css +1 -1
- package/admin/index.html +2 -2
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/card-builder.js +3 -3
- package/admin/js/lib/crud-tutorial.js +1 -0
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +6 -6
- package/admin/js/lib/project-context.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -0
- package/admin/js/templates/action-editor.html +7 -0
- package/admin/js/templates/block-editor.html +7 -0
- package/admin/js/templates/collection-editor.html +9 -0
- package/admin/js/templates/dashboard/cache.html +32 -0
- package/admin/js/templates/dashboard.html +4 -0
- package/admin/js/templates/form-editor.html +9 -0
- package/admin/js/templates/menu-editor.html +98 -0
- package/admin/js/templates/menu-locations.html +14 -0
- package/admin/js/templates/menus.html +14 -0
- package/admin/js/templates/page-editor.html +9 -2
- package/admin/js/templates/project-detail.html +50 -0
- package/admin/js/templates/project-editor.html +45 -0
- package/admin/js/templates/project-settings.html +60 -0
- package/admin/js/templates/projects.html +13 -0
- package/admin/js/templates/role-editor.html +11 -0
- package/admin/js/templates/settings.html +26 -0
- package/admin/js/templates/tutorials.html +335 -2
- package/admin/js/templates/view-editor.html +7 -0
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/actions-list.js +1 -1
- package/admin/js/views/block-editor-enhance.js +1 -1
- package/admin/js/views/block-editor.js +8 -8
- package/admin/js/views/blocks.js +2 -2
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/cache.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -1
- package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +19 -0
- package/admin/js/views/menu-locations.js +1 -0
- package/admin/js/views/menus.js +5 -0
- package/admin/js/views/page-editor.js +41 -36
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/project-detail.js +4 -0
- package/admin/js/views/project-editor.js +1 -0
- package/admin/js/views/project-settings.js +1 -0
- package/admin/js/views/projects.js +7 -0
- package/admin/js/views/role-editor.js +1 -1
- package/admin/js/views/roles.js +3 -3
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/tutorials.js +1 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -3
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/views-list.js +1 -1
- package/config/cache.json +4 -0
- package/config/cache.json.example +12 -0
- package/config/menu-locations.json +5 -0
- package/config/menus/admin-sidebar.json +185 -0
- package/config/menus/footer.json +33 -0
- package/config/menus/main.json +35 -0
- package/config/menus/sproj-1779696558011-menu.json +17 -0
- package/config/menus/sproj-1779696960337-menu.json +18 -0
- package/config/menus/sproj-1779696985353-menu.json +18 -0
- package/config/site.json +6 -22
- package/package.json +4 -3
- package/plugins/analytics/daily.json +3 -0
- package/plugins/analytics/journeys.json +8 -0
- package/plugins/analytics/lifetime.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-browser.js +4 -0
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/config.js +12 -1
- package/server/middleware/auth.js +88 -22
- package/server/routes/api/actions.js +58 -5
- package/server/routes/api/auth.js +2 -2
- package/server/routes/api/blocks.js +18 -3
- package/server/routes/api/cache.js +57 -0
- package/server/routes/api/collections.js +201 -8
- package/server/routes/api/forms.js +266 -21
- package/server/routes/api/menu-locations.js +46 -0
- package/server/routes/api/menus.js +115 -0
- package/server/routes/api/navigation.js +2 -0
- package/server/routes/api/pages.js +1 -1
- package/server/routes/api/projects.js +107 -0
- package/server/routes/api/scaffold.js +86 -0
- package/server/routes/api/settings.js +3 -0
- package/server/routes/api/sidebar.js +23 -0
- package/server/routes/api/users.js +32 -7
- package/server/routes/api/views.js +10 -2
- package/server/routes/public.js +88 -7
- package/server/server.js +54 -3
- package/server/services/actions.js +137 -8
- package/server/services/adapters/FileAdapter.js +23 -8
- package/server/services/adapters/MongoAdapter.js +36 -18
- package/server/services/blocks.js +23 -8
- package/server/services/cache/drivers/MemoryDriver.js +118 -0
- package/server/services/cache/drivers/NoneDriver.js +12 -0
- package/server/services/cache/index.js +229 -0
- package/server/services/cache/lru.js +61 -0
- package/server/services/collections.js +102 -12
- package/server/services/content.js +25 -6
- package/server/services/filterEngine.js +281 -0
- package/server/services/forms.js +3 -0
- package/server/services/hooks.js +48 -0
- package/server/services/markdown.js +711 -124
- package/server/services/menus-migration.js +107 -0
- package/server/services/menus.js +422 -0
- package/server/services/permissionRegistry.js +26 -0
- package/server/services/plugins.js +9 -2
- package/server/services/presetCollections.js +22 -0
- package/server/services/projects.js +429 -0
- package/server/services/recipes/contact-list.json +78 -0
- package/server/services/recipes/onboarding.json +426 -0
- package/server/services/references.js +174 -0
- package/server/services/renderer.js +237 -40
- package/server/services/roles.js +6 -1
- package/server/services/rowAccess.js +86 -13
- package/server/services/scaffolder.js +465 -0
- package/server/services/sidebar-migration.js +117 -0
- package/server/services/sitemap.js +112 -0
- package/server/services/userRoles.js +86 -0
- package/server/services/users.js +23 -2
- package/server/services/views.js +19 -4
- package/server/templates/page.html +135 -130
- /package/config/{navigation.json → navigation.json.bak} +0 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects service.
|
|
3
|
+
*
|
|
4
|
+
* Projects group related artefacts (Pages, Collections, Forms, Actions,
|
|
5
|
+
* Menus, Blocks, Views, Roles, Users) under a named slug. Membership is
|
|
6
|
+
* a metadata tag (`meta.project: '<slug>'`) on each artefact — no
|
|
7
|
+
* filesystem isolation. Projects also act as a permission boundary:
|
|
8
|
+
* users with `projects: []` set only see their projects' artefacts plus
|
|
9
|
+
* untagged site-wide artefacts.
|
|
10
|
+
*
|
|
11
|
+
* Storage: a preset collection at `content/collections/projects/`.
|
|
12
|
+
* Access: dedicated `/api/projects/*` routes wrap this service.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {createEntry, deleteEntry, listEntries, updateEntry} from './collections.js';
|
|
16
|
+
import {getRoleLevel} from './roles.js';
|
|
17
|
+
|
|
18
|
+
/** Slug of the preset collection that stores project records. */
|
|
19
|
+
export const PROJECTS_COLLECTION_SLUG = 'projects';
|
|
20
|
+
|
|
21
|
+
const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validate a project slug (also used for any artefact's `meta.project`
|
|
25
|
+
* value). Same rules as collection / menu slugs.
|
|
26
|
+
*
|
|
27
|
+
* @param {unknown} slug
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function validateSlug(slug) {
|
|
31
|
+
return typeof slug === 'string' && SLUG_RE.test(slug);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* List all projects. Returns a flat array of project records, sorted by
|
|
36
|
+
* sortOrder ascending then name alphabetical.
|
|
37
|
+
*
|
|
38
|
+
* @returns {Promise<Array<object>>}
|
|
39
|
+
* @throws {Error} If the projects collection schema is missing.
|
|
40
|
+
*/
|
|
41
|
+
export async function listProjects() {
|
|
42
|
+
const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
|
|
43
|
+
const projects = entries.map(e => normalise(e));
|
|
44
|
+
return projects.sort((a, b) => {
|
|
45
|
+
const so = (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
|
|
46
|
+
if (so !== 0) return so;
|
|
47
|
+
return (a.name || '').localeCompare(b.name || '');
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fetch a single project by slug. Returns null if missing or slug invalid.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} slug
|
|
55
|
+
* @returns {Promise<object|null>}
|
|
56
|
+
* @throws {Error} If the projects collection schema is missing.
|
|
57
|
+
*/
|
|
58
|
+
export async function getProject(slug) {
|
|
59
|
+
if (!validateSlug(slug)) return null;
|
|
60
|
+
const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
|
|
61
|
+
const entry = entries.find(e => e.data?.slug === slug);
|
|
62
|
+
return entry ? normalise(entry) : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a new project. Refuses on slug collision or validation failure.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} input
|
|
69
|
+
* @returns {Promise<object>} The persisted project record.
|
|
70
|
+
* @throws {Error} If validation fails or the slug already exists.
|
|
71
|
+
*/
|
|
72
|
+
export async function createProject(input) {
|
|
73
|
+
const errors = validateProject(input);
|
|
74
|
+
if (errors.length) throw new Error('Validation failed: ' + errors.join('; '));
|
|
75
|
+
if (await getProject(input.slug)) {
|
|
76
|
+
throw new Error(`Project "${input.slug}" already exists`);
|
|
77
|
+
}
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
const data = {
|
|
80
|
+
slug: input.slug,
|
|
81
|
+
name: input.name,
|
|
82
|
+
description: input.description || '',
|
|
83
|
+
icon: input.icon || 'folder',
|
|
84
|
+
...(input.rootUrl != null && {rootUrl: input.rootUrl}),
|
|
85
|
+
sortOrder: Number.isFinite(input.sortOrder) ? input.sortOrder : 0,
|
|
86
|
+
createdAt: now,
|
|
87
|
+
updatedAt: now
|
|
88
|
+
};
|
|
89
|
+
await createEntry(PROJECTS_COLLECTION_SLUG, data);
|
|
90
|
+
return data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Update a project. Slug is immutable. Preserves createdAt; bumps updatedAt.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} slug
|
|
97
|
+
* @param {object} input
|
|
98
|
+
* @returns {Promise<object>} The updated project record.
|
|
99
|
+
* @throws {Error} If the project is missing or validation fails.
|
|
100
|
+
*/
|
|
101
|
+
export async function updateProject(slug, input) {
|
|
102
|
+
const existing = await getProject(slug);
|
|
103
|
+
if (!existing) throw new Error(`Project "${slug}" not found`);
|
|
104
|
+
const merged = {
|
|
105
|
+
...existing,
|
|
106
|
+
...(input.name != null && {name: input.name}),
|
|
107
|
+
...(input.description != null && {description: input.description}),
|
|
108
|
+
...(input.icon != null && {icon: input.icon}),
|
|
109
|
+
...(input.rootUrl != null && {rootUrl: input.rootUrl}),
|
|
110
|
+
...(Number.isFinite(input.sortOrder) && {sortOrder: input.sortOrder}),
|
|
111
|
+
slug,
|
|
112
|
+
createdAt: existing.createdAt,
|
|
113
|
+
updatedAt: new Date().toISOString()
|
|
114
|
+
};
|
|
115
|
+
const errors = validateProject(merged);
|
|
116
|
+
if (errors.length) throw new Error('Validation failed: ' + errors.join('; '));
|
|
117
|
+
const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
|
|
118
|
+
const entry = entries.find(e => e.data?.slug === slug);
|
|
119
|
+
if (!entry) throw new Error(`Project "${slug}" not found`);
|
|
120
|
+
await updateEntry(PROJECTS_COLLECTION_SLUG, entry.id, merged);
|
|
121
|
+
return merged;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Delete a project. Returns true if removed, false if not found.
|
|
126
|
+
* Does NOT check for tagged artefacts — caller (API route) must do that.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} slug
|
|
129
|
+
* @returns {Promise<boolean>}
|
|
130
|
+
* @throws {Error} If the projects collection schema is missing.
|
|
131
|
+
*/
|
|
132
|
+
export async function deleteProject(slug) {
|
|
133
|
+
if (!validateSlug(slug)) return false;
|
|
134
|
+
const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
|
|
135
|
+
const entry = entries.find(e => e.data?.slug === slug);
|
|
136
|
+
if (!entry) return false;
|
|
137
|
+
await deleteEntry(PROJECTS_COLLECTION_SLUG, entry.id);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate a project object. Returns an array of error strings (empty = OK).
|
|
143
|
+
*
|
|
144
|
+
* @param {object} project
|
|
145
|
+
* @returns {string[]}
|
|
146
|
+
*/
|
|
147
|
+
export function validateProject(project) {
|
|
148
|
+
const errors = [];
|
|
149
|
+
if (!project || typeof project !== 'object') return ['Project must be an object'];
|
|
150
|
+
if (!validateSlug(project.slug)) {
|
|
151
|
+
errors.push(`Invalid slug "${project.slug}" — lowercase alphanumeric + hyphen only`);
|
|
152
|
+
}
|
|
153
|
+
if (!project.name || typeof project.name !== 'string') {
|
|
154
|
+
errors.push('name is required');
|
|
155
|
+
}
|
|
156
|
+
if (project.rootUrl != null && project.rootUrl !== '') {
|
|
157
|
+
if (typeof project.rootUrl !== 'string' || !project.rootUrl.startsWith('/')) {
|
|
158
|
+
errors.push('rootUrl must start with /');
|
|
159
|
+
} else if (project.rootUrl === '/') {
|
|
160
|
+
errors.push('rootUrl cannot be exactly "/" (would auto-claim every page)');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (project.icon != null && (typeof project.icon !== 'string' || !project.icon.trim())) {
|
|
164
|
+
errors.push('icon must be a non-empty string when set');
|
|
165
|
+
}
|
|
166
|
+
return errors;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function normalise(entry) {
|
|
170
|
+
return {...(entry.data || {})};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* List projects the given user is allowed to see. Super-admin sees all;
|
|
175
|
+
* users with empty `projects: []` see all; scoped users see their listed
|
|
176
|
+
* projects only.
|
|
177
|
+
*
|
|
178
|
+
* @param {object|null} user
|
|
179
|
+
* @returns {Promise<Array<object>>}
|
|
180
|
+
*/
|
|
181
|
+
export async function listProjectsForUser(user) {
|
|
182
|
+
const all = await listProjects();
|
|
183
|
+
if (!user) return [];
|
|
184
|
+
if (getRoleLevel(user.role) === 0) return all;
|
|
185
|
+
if (!Array.isArray(user.projects) || user.projects.length === 0) return all;
|
|
186
|
+
return all.filter(p => user.projects.includes(p.slug));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Permission gate: can `user` see / edit `artefact` given its project tag
|
|
191
|
+
* and the user's `projects: []` access scope?
|
|
192
|
+
*
|
|
193
|
+
* Rules:
|
|
194
|
+
* - Super-admin (role level 0) bypasses everything.
|
|
195
|
+
* - Untagged artefacts (no `meta.project`) are visible to all.
|
|
196
|
+
* - Users with no scope set (`projects: []` or missing) have no restriction.
|
|
197
|
+
* - Otherwise: artefact's project must be in user's scope.
|
|
198
|
+
*
|
|
199
|
+
* @param {object} user - Authenticated user; must have `role`.
|
|
200
|
+
* @param {object} artefact - Anything with optional `meta.project`.
|
|
201
|
+
* @returns {boolean}
|
|
202
|
+
*/
|
|
203
|
+
export function canSeeArtefact(user, artefact) {
|
|
204
|
+
if (!user) return false;
|
|
205
|
+
if (getRoleLevel(user.role) === 0) return true;
|
|
206
|
+
const project = artefact?.meta?.project;
|
|
207
|
+
if (!project) return true;
|
|
208
|
+
if (!Array.isArray(user.projects) || user.projects.length === 0) return true;
|
|
209
|
+
return user.projects.includes(project);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Resolve the project a page belongs to using the inheritance rules:
|
|
214
|
+
* 1. Explicit `project: null` in frontmatter → untagged
|
|
215
|
+
* 2. Explicit `project: '<slug>'` → use it
|
|
216
|
+
* 3. Missing field → longest matching `rootUrl` prefix wins
|
|
217
|
+
* 4. No match → untagged
|
|
218
|
+
*
|
|
219
|
+
* @param {string} urlPath
|
|
220
|
+
* @param {string|null|undefined} explicitProject
|
|
221
|
+
* @returns {Promise<string|null>}
|
|
222
|
+
*/
|
|
223
|
+
export async function getProjectForPage(urlPath, explicitProject) {
|
|
224
|
+
if (explicitProject === null) return null;
|
|
225
|
+
if (typeof explicitProject === 'string' && explicitProject) return explicitProject;
|
|
226
|
+
const projects = await listProjects();
|
|
227
|
+
let best = null;
|
|
228
|
+
let bestLen = -1;
|
|
229
|
+
for (const p of projects) {
|
|
230
|
+
if (!p.rootUrl) continue;
|
|
231
|
+
if (urlPath === p.rootUrl || urlPath.startsWith(p.rootUrl + '/')) {
|
|
232
|
+
if (p.rootUrl.length > bestLen) {
|
|
233
|
+
best = p.slug;
|
|
234
|
+
bestLen = p.rootUrl.length;
|
|
235
|
+
} else if (p.rootUrl.length === bestLen && p.slug < best) {
|
|
236
|
+
best = p.slug;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return best;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Enumerate every artefact tagged with the given project. Returns a flat
|
|
245
|
+
* grouping by type — each value is an array of artefact summary objects.
|
|
246
|
+
* Used by the project detail page (counts + per-type tabs).
|
|
247
|
+
*
|
|
248
|
+
* @param {string} projectSlug
|
|
249
|
+
* @returns {Promise<{pages:object[], collections:object[], forms:object[], actions:object[], menus:object[], blocks:object[], views:object[], roles:object[], users:object[]}>}
|
|
250
|
+
*/
|
|
251
|
+
export async function getArtefactsForProject(projectSlug) {
|
|
252
|
+
const out = {
|
|
253
|
+
pages: [], collections: [], forms: [], actions: [],
|
|
254
|
+
menus: [], blocks: [], views: [], roles: [], users: []
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const {listMenus, getMenu} = await import('./menus.js');
|
|
259
|
+
for (const m of await listMenus()) {
|
|
260
|
+
const full = await getMenu(m.slug);
|
|
261
|
+
if (full?.meta?.project === projectSlug) out.menus.push(m);
|
|
262
|
+
}
|
|
263
|
+
} catch { /* service unavailable */ }
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const {listForms, readForm} = await import('./forms.js');
|
|
267
|
+
for (const f of await listForms()) {
|
|
268
|
+
const full = await readForm(f.slug);
|
|
269
|
+
if (full?.meta?.project === projectSlug) out.forms.push(f);
|
|
270
|
+
}
|
|
271
|
+
} catch { /* skip */ }
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const {listCollections, getCollection} = await import('./collections.js');
|
|
275
|
+
for (const c of await listCollections()) {
|
|
276
|
+
const full = await getCollection(c.slug);
|
|
277
|
+
if (full?.meta?.project === projectSlug) out.collections.push(c);
|
|
278
|
+
}
|
|
279
|
+
} catch { /* skip */ }
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const {listActions} = await import('./actions.js');
|
|
283
|
+
for (const a of await listActions()) {
|
|
284
|
+
if (a?.meta?.project === projectSlug) out.actions.push(a);
|
|
285
|
+
}
|
|
286
|
+
} catch { /* skip */ }
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const {listBlocks, readBlock} = await import('./blocks.js');
|
|
290
|
+
for (const b of await listBlocks()) {
|
|
291
|
+
const full = (typeof readBlock === 'function') ? await readBlock(b.slug) : b;
|
|
292
|
+
if (full?.meta?.project === projectSlug) out.blocks.push(b);
|
|
293
|
+
}
|
|
294
|
+
} catch { /* skip */ }
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const {listViews} = await import('./views.js');
|
|
298
|
+
for (const v of await listViews()) {
|
|
299
|
+
if (v?.meta?.project === projectSlug) out.views.push(v);
|
|
300
|
+
}
|
|
301
|
+
} catch { /* skip */ }
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const {getRoleMap} = await import('./roles.js');
|
|
305
|
+
for (const role of getRoleMap().values()) {
|
|
306
|
+
if (role?.meta?.project === projectSlug) out.roles.push(role);
|
|
307
|
+
}
|
|
308
|
+
} catch { /* skip */ }
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const {listUsers} = await import('./users.js');
|
|
312
|
+
for (const u of await listUsers()) {
|
|
313
|
+
if (u?.meta?.project === projectSlug) out.users.push(u);
|
|
314
|
+
}
|
|
315
|
+
} catch { /* skip */ }
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const {listPages} = await import('./content.js');
|
|
319
|
+
for (const p of await listPages()) {
|
|
320
|
+
const url = p.url || p.urlPath || '/';
|
|
321
|
+
const explicit = (p.project === undefined) ? undefined : p.project;
|
|
322
|
+
const pageProject = await getProjectForPage(url, explicit);
|
|
323
|
+
if (pageProject === projectSlug) out.pages.push(p);
|
|
324
|
+
}
|
|
325
|
+
} catch { /* skip */ }
|
|
326
|
+
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Set `meta.project: null` on every artefact tagged with this project.
|
|
332
|
+
* Returns a count map of how many of each type were untagged. Used as the
|
|
333
|
+
* "Untag all" escape hatch before delete.
|
|
334
|
+
*
|
|
335
|
+
* Per-service plumbing (Tasks 9–16) is required for non-menu artefacts to
|
|
336
|
+
* carry meta.project through their update path; without that plumbing, this
|
|
337
|
+
* function simply returns zero counts for those types.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} projectSlug
|
|
340
|
+
* @returns {Promise<Record<string, number>>}
|
|
341
|
+
*/
|
|
342
|
+
export async function untagAllForProject(projectSlug) {
|
|
343
|
+
const counts = {
|
|
344
|
+
pages: 0, collections: 0, forms: 0, actions: 0,
|
|
345
|
+
menus: 0, blocks: 0, views: 0, roles: 0, users: 0
|
|
346
|
+
};
|
|
347
|
+
const grouped = await getArtefactsForProject(projectSlug);
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const {getMenu, updateMenu} = await import('./menus.js');
|
|
351
|
+
for (const m of grouped.menus) {
|
|
352
|
+
const full = await getMenu(m.slug);
|
|
353
|
+
if (!full) continue;
|
|
354
|
+
const meta = {...(full.meta || {})};
|
|
355
|
+
delete meta.project;
|
|
356
|
+
await updateMenu(m.slug, {...full, meta});
|
|
357
|
+
counts.menus++;
|
|
358
|
+
}
|
|
359
|
+
} catch { /* skip */ }
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const {readForm, writeForm} = await import('./forms.js');
|
|
363
|
+
for (const f of grouped.forms) {
|
|
364
|
+
const full = await readForm(f.slug);
|
|
365
|
+
if (!full) continue;
|
|
366
|
+
const meta = {...(full.meta || {})};
|
|
367
|
+
delete meta.project;
|
|
368
|
+
await writeForm(f.slug, {...full, meta});
|
|
369
|
+
counts.forms++;
|
|
370
|
+
}
|
|
371
|
+
} catch { /* skip */ }
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const {getCollection, updateCollection} = await import('./collections.js');
|
|
375
|
+
for (const c of grouped.collections) {
|
|
376
|
+
const full = await getCollection(c.slug);
|
|
377
|
+
if (!full) continue;
|
|
378
|
+
const meta = {...(full.meta || {})};
|
|
379
|
+
delete meta.project;
|
|
380
|
+
await updateCollection(c.slug, {...full, meta});
|
|
381
|
+
counts.collections++;
|
|
382
|
+
}
|
|
383
|
+
} catch { /* skip */ }
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const {updateAction} = await import('./actions.js');
|
|
387
|
+
for (const a of grouped.actions) {
|
|
388
|
+
const meta = {...(a.meta || {})};
|
|
389
|
+
delete meta.project;
|
|
390
|
+
await updateAction(a.slug, {...a, meta});
|
|
391
|
+
counts.actions++;
|
|
392
|
+
}
|
|
393
|
+
} catch { /* skip */ }
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const {readBlock, writeBlock} = await import('./blocks.js');
|
|
397
|
+
for (const b of grouped.blocks) {
|
|
398
|
+
const full = (typeof readBlock === 'function') ? await readBlock(b.slug) : b;
|
|
399
|
+
if (!full) continue;
|
|
400
|
+
const meta = {...(full.meta || {})};
|
|
401
|
+
delete meta.project;
|
|
402
|
+
if (typeof writeBlock === 'function') {
|
|
403
|
+
await writeBlock(b.slug, {...full, meta});
|
|
404
|
+
counts.blocks++;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} catch { /* skip */ }
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const {readView, writeView} = await import('./views.js');
|
|
411
|
+
for (const v of grouped.views) {
|
|
412
|
+
const full = (typeof readView === 'function') ? await readView(v.slug) : v;
|
|
413
|
+
if (!full) continue;
|
|
414
|
+
const meta = {...(full.meta || {})};
|
|
415
|
+
delete meta.project;
|
|
416
|
+
if (typeof writeView === 'function') {
|
|
417
|
+
await writeView(v.slug, {...full, meta});
|
|
418
|
+
counts.views++;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
} catch { /* skip */ }
|
|
422
|
+
|
|
423
|
+
// Roles, Users, Pages: deferred. Roles/Users would need the per-service
|
|
424
|
+
// plumbing to land first; pages need frontmatter rewriting which is a
|
|
425
|
+
// separate concern. Counts remain 0 for those types in this task; later
|
|
426
|
+
// tasks may revisit.
|
|
427
|
+
|
|
428
|
+
return counts;
|
|
429
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "contact-list",
|
|
3
|
+
"name": "Contact list (CRM-lite)",
|
|
4
|
+
"description": "A simple address book — contacts collection with email/phone/company/tags, a quick-add form, and a single status transition for follow-ups.",
|
|
5
|
+
"icon": "users",
|
|
6
|
+
"options": [
|
|
7
|
+
{ "name": "collectionSlug", "label": "Collection slug", "default": "contacts", "hint": "Where contact records are stored" },
|
|
8
|
+
{ "name": "formSlug", "label": "Form slug", "default": "contact-quick-add", "hint": "Embedded via [form name=\"...\" /]" },
|
|
9
|
+
{ "name": "actionPrefix", "label": "Action slug prefix", "default": "contact", "hint": "Per-action slug: <prefix>-followup" }
|
|
10
|
+
],
|
|
11
|
+
"collection": {
|
|
12
|
+
"title": "Contacts",
|
|
13
|
+
"description": "Address-book entries",
|
|
14
|
+
"fields": [
|
|
15
|
+
{ "name": "fullName", "label": "Full name", "type": "text", "required": true },
|
|
16
|
+
{ "name": "email", "label": "Email", "type": "email" },
|
|
17
|
+
{ "name": "phone", "label": "Phone", "type": "text" },
|
|
18
|
+
{ "name": "company", "label": "Company", "type": "text" },
|
|
19
|
+
{
|
|
20
|
+
"name": "tags", "label": "Tags", "type": "multiselect",
|
|
21
|
+
"options": [
|
|
22
|
+
{ "value": "lead", "label": "Lead" },
|
|
23
|
+
{ "value": "customer", "label": "Customer" },
|
|
24
|
+
{ "value": "partner", "label": "Partner" },
|
|
25
|
+
{ "value": "vendor", "label": "Vendor" }
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
{ "name": "notes", "label": "Notes", "type": "textarea" },
|
|
29
|
+
{
|
|
30
|
+
"name": "status", "label": "Status", "type": "select",
|
|
31
|
+
"options": [
|
|
32
|
+
{ "value": "active", "label": "Active" },
|
|
33
|
+
{ "value": "followup", "label": "Needs follow-up" },
|
|
34
|
+
{ "value": "archived", "label": "Archived" }
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"api": {
|
|
39
|
+
"read": { "enabled": false },
|
|
40
|
+
"create": { "enabled": false },
|
|
41
|
+
"update": { "enabled": false },
|
|
42
|
+
"delete": { "enabled": false }
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"form": {
|
|
46
|
+
"title": "Add a contact",
|
|
47
|
+
"fields": [
|
|
48
|
+
{ "name": "fullName", "label": "Full name", "type": "text", "required": true },
|
|
49
|
+
{ "name": "email", "label": "Email", "type": "email" },
|
|
50
|
+
{ "name": "phone", "label": "Phone", "type": "text" },
|
|
51
|
+
{ "name": "company", "label": "Company", "type": "text" },
|
|
52
|
+
{ "name": "notes", "label": "Notes", "type": "textarea" }
|
|
53
|
+
],
|
|
54
|
+
"settings": {
|
|
55
|
+
"submitText": "Add contact",
|
|
56
|
+
"successMessage": "Contact added.",
|
|
57
|
+
"honeypot": true,
|
|
58
|
+
"rateLimitPerMinute": 10
|
|
59
|
+
},
|
|
60
|
+
"actions": { "collection": { "enabled": true, "slug": "" } }
|
|
61
|
+
},
|
|
62
|
+
"actions": [
|
|
63
|
+
{
|
|
64
|
+
"slug": "{{actionPrefix}}-followup",
|
|
65
|
+
"title": "Mark for follow-up",
|
|
66
|
+
"description": "Flag this contact as needing follow-up.",
|
|
67
|
+
"collection": "{{collectionSlug}}",
|
|
68
|
+
"trigger": { "label": "Flag follow-up", "icon": "flag", "style": "warning" },
|
|
69
|
+
"transition": { "field": "status", "from": ["active"], "to": "followup" },
|
|
70
|
+
"access": { "roles": ["admin", "super-admin"] },
|
|
71
|
+
"steps": [
|
|
72
|
+
{ "type": "updateField", "config": { "field": "status", "value": "followup" } }
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
"wireForm": true,
|
|
77
|
+
"snippet": "[form name=\"{{formSlug}}\" /]\n\n## Contacts\n\n[collection slug=\"{{collectionSlug}}\" display=\"table\" searchable filterable=\"status,tags\" sortable transitions /]"
|
|
78
|
+
}
|