domma-cms 1.6.60 → 1.7.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/CHANGELOG.md +4344 -0
- package/README.md +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +2 -2
- package/admin/js/lib/page-editor-reference.js +5 -5
- package/admin/js/lib/shortcode-tags.js +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +39 -0
- package/docs/DommaFeatures.md +3005 -0
- package/docs/MultiTenant.md +176 -0
- package/docs/api-reference.md +1667 -0
- package/docs/cache.md +96 -0
- package/docs/configuration.md +1811 -0
- package/docs/editions-and-licences.md +190 -0
- package/docs/getting-started.md +201 -0
- package/docs/guides/form-triggers.md +260 -0
- package/docs/guides/mongodb-storage-adapter.md +282 -0
- package/docs/guides/multi-page-forms.md +121 -0
- package/docs/images/scribe-composer.png +0 -0
- package/docs/markdown-shortcodes.md +1738 -0
- package/docs/pentest-report-2026-04-03.md +181 -0
- package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
- package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
- package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
- package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
- package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
- package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
- package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
- package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
- package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
- package/docs/plans/2026-03-05-user-types-design.md +139 -0
- package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
- package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
- package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
- package/docs/plans/2026-06-10-core-project-design.md +240 -0
- package/docs/plugin-development.md +827 -0
- package/docs/recruitment-recipe.md +501 -0
- package/docs/scaffolding.md +312 -0
- package/docs/screenshots/calculator-floating.png +0 -0
- package/docs/screenshots/calculator-standard.png +0 -0
- package/docs/screenshots/email-settings.png +0 -0
- package/docs/screenshots/messages-count.png +0 -0
- package/docs/structure/user-types.md +107 -0
- package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
- package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
- package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
- package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
- package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
- package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
- package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
- package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
- package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
- package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
- package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
- package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
- package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
- package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
- package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
- package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
- package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
- package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
- package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
- package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
- package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
- package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
- package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
- package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
- package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
- package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
- package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
- package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
- package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
- package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
- package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
- package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
- package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
- package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
- package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
- package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
- package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
- package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
- package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
- package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
- package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
- package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
- package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
- package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
- package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
- package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
- package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
- package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
- package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
- package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
- package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
- package/docs/theming.md +320 -0
- package/package.json +2 -2
- package/public/js/celebrations/core/canvas.js +1 -1
- package/public/js/celebrations/index.js +1 -1
- package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
- package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
- package/public/js/celebrations/message-attrs.mjs +1 -0
- package/public/js/celebrations/themes/anniversary.js +1 -0
- package/public/js/celebrations/themes/autumn.js +1 -0
- package/public/js/celebrations/themes/birthday.js +1 -0
- package/public/js/celebrations/themes/butterfly-flight.js +1 -0
- package/public/js/celebrations/themes/calendar.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year.js +1 -0
- package/public/js/celebrations/themes/christmas.js +1 -1
- package/public/js/celebrations/themes/easter.js +1 -0
- package/public/js/celebrations/themes/garden-drawing.js +1 -0
- package/public/js/celebrations/themes/garden.js +1 -0
- package/public/js/celebrations/themes/graduation.js +1 -0
- package/public/js/celebrations/themes/greeting.js +1 -0
- package/public/js/celebrations/themes/make-it-rain.js +1 -0
- package/public/js/celebrations/themes/money-rain.js +1 -0
- package/public/js/celebrations/themes/new-year.js +1 -0
- package/public/js/celebrations/themes/occasion-drawing.js +1 -0
- package/public/js/celebrations/themes/occasions.js +1 -0
- package/public/js/celebrations/themes/rain-control.js +1 -0
- package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
- package/public/js/celebrations/themes/sparkler-font.js +1 -0
- package/public/js/celebrations/themes/sparkler-writing.js +1 -0
- package/public/js/celebrations/themes/summer-garden.js +1 -0
- package/public/js/celebrations/themes/winter.js +1 -0
- package/public/js/celebrations/trait-data.mjs +1 -1
- package/public/js/effects.js +1 -1
- package/scripts/build.js +4 -0
- package/server/services/markdown.js +5 -2
- package/server/templates/page.html +1 -1
|
@@ -0,0 +1,2869 @@
|
|
|
1
|
+
# Projects Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Introduce Projects - a lightweight grouping of related artefacts (Pages, Collections, Forms, Actions, Menus, Blocks, Views, Roles, Users) with sidebar navigation, per-user permission scoping, and tight scaffolder integration.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Projects live in a preset collection (`content/collections/projects/`). Nine artefact types gain an optional `meta.project: '<slug>'` field. Users gain a `projects: []` access-scope array. The scaffolder auto-creates a project (and stamps `meta.project` on every artefact it produces) when a recipe declares a `project` block. The admin sidebar gains a new Projects section above Content. Untagged artefacts stay site-wide.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Fastify 5 (ESM), Domma SPA admin (`$`/`E`/`F`/`T`/`I`/`H`/`S`), `node:test` runner, JSON file storage.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-05-25-projects-design.md`
|
|
12
|
+
|
|
13
|
+
**Critical project rules** (from `CLAUDE.md` + memory):
|
|
14
|
+
|
|
15
|
+
- Server runs on port **4096** and is **always running** - never start/stop. Test via `curl` only.
|
|
16
|
+
- Domma admin uses `S.set/get`, `H.get/post/put/delete`, `E.toast/confirm/modal/tabs`, `T.create`, `I.scan`, `F.create`. Never vanilla equivalents.
|
|
17
|
+
- `.get(0)` to unwrap Domma collections, not `[0]`.
|
|
18
|
+
- Tabs class names: `.tabs / .tab-list / button.tab-item / .tab-panel`
|
|
19
|
+
- Admin JS/MJS/CSS uses `Cache-Control: no-store` - no `?v=N` cache-busting query strings
|
|
20
|
+
- T.create render functions return HTML strings (memory: `feedback_tcreate_render_strings.md`)
|
|
21
|
+
- UI changes must be browser-tested (memory: `feedback_no_verified_without_browser.md`)
|
|
22
|
+
- Do NOT commit during implementation - user commits separately (memory: `feedback_no_commits.md`)
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## File Map
|
|
27
|
+
|
|
28
|
+
| Path | Purpose | Action |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `server/services/projects.js` | New service: CRUD + scope helpers (`canSeeArtefact`, `getProjectForPage`) | Create |
|
|
31
|
+
| `server/services/presetCollections.js` | Add `projects` to PRESETS array | Modify |
|
|
32
|
+
| `server/services/permissionRegistry.js` | Add `projects.{read,create,update,delete}` family | Modify |
|
|
33
|
+
| `server/services/scaffolder.js` | Project auto-creation + stamp `meta.project` on all artefacts | Modify |
|
|
34
|
+
| `server/services/collections.js` | `createCollection` accepts `meta.project`; `listCollections` filter | Modify |
|
|
35
|
+
| `server/services/forms.js` | `meta.project` flow + `listForms` filter | Modify |
|
|
36
|
+
| `server/services/actions.js` | `createAction` accepts `meta.project`; `listActions` filter | Modify |
|
|
37
|
+
| `server/services/menus.js` | Accept `meta.project` (already has `meta` block); `listMenus` filter | Modify |
|
|
38
|
+
| `server/services/blocks.js` | `meta.project` flow + `listBlocks` filter | Modify |
|
|
39
|
+
| `server/services/views.js` | `meta.project` flow + `listViews` filter | Modify |
|
|
40
|
+
| `server/services/roles.js` | Role entries' `data.meta.project`; filter | Modify |
|
|
41
|
+
| `server/services/users.js` | User `meta.project` + access-scope `projects: []`; filter; cache invalidate on `projects` change | Modify |
|
|
42
|
+
| `server/services/content.js` | Page list filter + URL inheritance via `getProjectForPage` | Modify |
|
|
43
|
+
| `server/routes/api/projects.js` | New: REST API + `/artefacts` + `/untag-all` | Create |
|
|
44
|
+
| `server/server.js` | Register projects routes; seed projects preset on boot | Modify |
|
|
45
|
+
| `admin/js/api.js` | Add `api.projects.*` client | Modify |
|
|
46
|
+
| `admin/js/config/sidebar-config.js` | Add Projects section (lazy expansion, scoped to user) | Modify |
|
|
47
|
+
| `admin/js/app.js` | New routes: `/projects`, `/projects/new`, `/projects/edit/:slug`, `/projects/:slug`, `/projects/:slug/:tab` | Modify |
|
|
48
|
+
| `admin/js/views/index.js` | Export new views | Modify |
|
|
49
|
+
| `admin/js/views/projects.js` | New: list view | Create |
|
|
50
|
+
| `admin/js/views/project-editor.js` | New: create/edit form | Create |
|
|
51
|
+
| `admin/js/views/project-detail.js` | New: tabbed detail view (Overview + 9 type tabs + Settings) | Create |
|
|
52
|
+
| `admin/js/views/page-editor.js` | Add Project select (URL-inheritance pre-fill) | Modify |
|
|
53
|
+
| `admin/js/views/collection-editor.js` | Add Project select to Meta tab | Modify |
|
|
54
|
+
| `admin/js/views/form-editor.js` | Add Project select to Meta tab | Modify |
|
|
55
|
+
| `admin/js/views/action-editor.js` | Add Project select | Modify |
|
|
56
|
+
| `admin/js/views/menu-editor.js` | Add Project select to Meta tab | Modify |
|
|
57
|
+
| `admin/js/views/block-editor.js` | Add Project select | Modify |
|
|
58
|
+
| `admin/js/views/view-editor.js` | Add Project select | Modify |
|
|
59
|
+
| `admin/js/views/role-editor.js` | Add Project select | Modify |
|
|
60
|
+
| `admin/js/views/user-editor.js` | Add `projects: []` multi-select + `meta.project` single-select | Modify |
|
|
61
|
+
| `admin/js/templates/projects.html` | New: list template | Create |
|
|
62
|
+
| `admin/js/templates/project-editor.html` | New: editor template | Create |
|
|
63
|
+
| `admin/js/templates/project-detail.html` | New: detail template | Create |
|
|
64
|
+
| `admin/css/admin.css` | Sidebar Projects section styles | Modify |
|
|
65
|
+
| `tests/services/projects.test.js` | New: service tests | Create |
|
|
66
|
+
| `tests/services/projects-scope.test.js` | New: `canSeeArtefact` + cross-service scope filtering | Create |
|
|
67
|
+
| `tests/services/projects-page-inheritance.test.js` | New: `getProjectForPage` URL rules | Create |
|
|
68
|
+
| `tests/services/projects-scaffolder.test.js` | New: scaffolder project block | Create |
|
|
69
|
+
| `tests/routes/projects.test.js` | New: API smoke tests | Create |
|
|
70
|
+
| `docs/configuration.md` | Add Projects section | Modify |
|
|
71
|
+
| `docs/scaffolding.md` | Document `project` recipe branch | Modify |
|
|
72
|
+
| `docs/api-reference.md` | Add `/api/projects/*` endpoints | Modify |
|
|
73
|
+
| `CLAUDE.md` | Add Projects summary | Modify |
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Task 1: Projects service skeleton - slug validation + paths
|
|
78
|
+
|
|
79
|
+
**Files:**
|
|
80
|
+
|
|
81
|
+
- Create: `server/services/projects.js`
|
|
82
|
+
- Create: `tests/services/projects.test.js`
|
|
83
|
+
|
|
84
|
+
- [ ] **Step 1.1: Write the slug + path test**
|
|
85
|
+
|
|
86
|
+
Create `tests/services/projects.test.js`:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
/**
|
|
90
|
+
* Projects service tests. Each test uses a unique slug per run to keep
|
|
91
|
+
* runs independent and self-cleaning.
|
|
92
|
+
*/
|
|
93
|
+
import {test} from 'node:test';
|
|
94
|
+
import assert from 'node:assert/strict';
|
|
95
|
+
import {validateSlug, PROJECTS_COLLECTION_SLUG} from '../../server/services/projects.js';
|
|
96
|
+
|
|
97
|
+
test('validateSlug - accepts lowercase alphanumeric + hyphen', () => {
|
|
98
|
+
assert.equal(validateSlug('members'), true);
|
|
99
|
+
assert.equal(validateSlug('members-area'), true);
|
|
100
|
+
assert.equal(validateSlug('1site'), true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('validateSlug - rejects empty, capitals, trailing hyphen, traversal', () => {
|
|
104
|
+
assert.equal(validateSlug(''), false);
|
|
105
|
+
assert.equal(validateSlug('Members'), false);
|
|
106
|
+
assert.equal(validateSlug('members-'), false);
|
|
107
|
+
assert.equal(validateSlug('../etc'), false);
|
|
108
|
+
assert.equal(validateSlug('members/intranet'), false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('PROJECTS_COLLECTION_SLUG - exported constant', () => {
|
|
112
|
+
assert.equal(PROJECTS_COLLECTION_SLUG, 'projects');
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- [ ] **Step 1.2: Run to confirm failure**
|
|
117
|
+
|
|
118
|
+
Run: `node --test tests/services/projects.test.js`
|
|
119
|
+
|
|
120
|
+
Expected: `ERR_MODULE_NOT_FOUND` for `projects.js`.
|
|
121
|
+
|
|
122
|
+
- [ ] **Step 1.3: Create the service skeleton**
|
|
123
|
+
|
|
124
|
+
Create `server/services/projects.js`:
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
/**
|
|
128
|
+
* Projects service.
|
|
129
|
+
*
|
|
130
|
+
* Projects group related artefacts (Pages, Collections, Forms, Actions,
|
|
131
|
+
* Menus, Blocks, Views, Roles, Users) under a named slug. Membership is
|
|
132
|
+
* a metadata tag (`meta.project: '<slug>'`) on each artefact - no
|
|
133
|
+
* filesystem isolation. Projects also act as a permission boundary:
|
|
134
|
+
* users with `projects: []` set only see their projects' artefacts plus
|
|
135
|
+
* untagged site-wide artefacts.
|
|
136
|
+
*
|
|
137
|
+
* Storage: a preset collection at `content/collections/projects/`.
|
|
138
|
+
* Access: dedicated `/api/projects/*` routes wrap this service.
|
|
139
|
+
*/
|
|
140
|
+
|
|
141
|
+
/** Slug of the preset collection that stores project records. */
|
|
142
|
+
export const PROJECTS_COLLECTION_SLUG = 'projects';
|
|
143
|
+
|
|
144
|
+
const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Validate a project slug (also used for any artefact's `meta.project`
|
|
148
|
+
* value). Same rules as collection / menu slugs.
|
|
149
|
+
*
|
|
150
|
+
* @param {unknown} slug
|
|
151
|
+
* @returns {boolean}
|
|
152
|
+
*/
|
|
153
|
+
export function validateSlug(slug) {
|
|
154
|
+
return typeof slug === 'string' && SLUG_RE.test(slug);
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- [ ] **Step 1.4: Verify tests pass**
|
|
159
|
+
|
|
160
|
+
Run: `node --test tests/services/projects.test.js`
|
|
161
|
+
|
|
162
|
+
Expected: 3/3 pass.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Task 2: Register `projects` as a preset collection
|
|
167
|
+
|
|
168
|
+
**Files:**
|
|
169
|
+
|
|
170
|
+
- Modify: `server/services/presetCollections.js`
|
|
171
|
+
|
|
172
|
+
- [ ] **Step 2.1: Add the projects preset**
|
|
173
|
+
|
|
174
|
+
In `server/services/presetCollections.js`, inside the `PRESETS` array (after the `notifications` entry, around line 39), add:
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
,
|
|
178
|
+
{
|
|
179
|
+
slug: 'projects',
|
|
180
|
+
title: 'Projects',
|
|
181
|
+
description: 'Named groupings of related artefacts.',
|
|
182
|
+
preset: true,
|
|
183
|
+
systemManaged: true,
|
|
184
|
+
fields: [
|
|
185
|
+
{name: 'slug', label: 'Slug', type: 'text', required: true},
|
|
186
|
+
{name: 'name', label: 'Name', type: 'text', required: true},
|
|
187
|
+
{name: 'description', label: 'Description', type: 'textarea'},
|
|
188
|
+
{name: 'icon', label: 'Icon', type: 'text', default: 'folder'},
|
|
189
|
+
{name: 'rootUrl', label: 'Root URL', type: 'text'},
|
|
190
|
+
{name: 'sortOrder', label: 'Sort Order', type: 'number', default: 0}
|
|
191
|
+
],
|
|
192
|
+
api: {
|
|
193
|
+
create: {enabled: false, access: 'admin'},
|
|
194
|
+
read: {enabled: false, access: 'admin'},
|
|
195
|
+
update: {enabled: false, access: 'admin'},
|
|
196
|
+
delete: {enabled: false, access: 'admin'}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
(Generic collection API for `projects` is disabled - the dedicated `/api/projects/*` routes from Task 7 are the access path.)
|
|
202
|
+
|
|
203
|
+
- [ ] **Step 2.2: Verify the preset is registered**
|
|
204
|
+
|
|
205
|
+
Run:
|
|
206
|
+
```bash
|
|
207
|
+
node -e "import('./server/services/presetCollections.js').then(m => console.log('slugs:', m.PRESET_COLLECTION_SLUGS))"
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Expected: prints `slugs: [ 'notifications', 'projects' ]`.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Task 3: Projects CRUD service
|
|
215
|
+
|
|
216
|
+
**Files:**
|
|
217
|
+
|
|
218
|
+
- Modify: `server/services/projects.js`
|
|
219
|
+
- Modify: `tests/services/projects.test.js`
|
|
220
|
+
|
|
221
|
+
The service stores projects as entries in the preset collection, using the existing `server/services/collections.js` entry CRUD as the storage layer.
|
|
222
|
+
|
|
223
|
+
- [ ] **Step 3.1: Write the CRUD tests**
|
|
224
|
+
|
|
225
|
+
Append to `tests/services/projects.test.js`:
|
|
226
|
+
|
|
227
|
+
```js
|
|
228
|
+
import {
|
|
229
|
+
listProjects, getProject, createProject, updateProject, deleteProject
|
|
230
|
+
} from '../../server/services/projects.js';
|
|
231
|
+
import {load as loadRoles} from '../../server/services/roles.js';
|
|
232
|
+
|
|
233
|
+
const T = () => 'test-project-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
|
234
|
+
|
|
235
|
+
// Roles must be loaded before any service that may consult them.
|
|
236
|
+
await loadRoles();
|
|
237
|
+
|
|
238
|
+
test('createProject → getProject round-trip', async () => {
|
|
239
|
+
const slug = T();
|
|
240
|
+
const project = {
|
|
241
|
+
slug, name: 'Test Project', description: 'd',
|
|
242
|
+
icon: 'box', rootUrl: '/' + slug, sortOrder: 5
|
|
243
|
+
};
|
|
244
|
+
await createProject(project);
|
|
245
|
+
try {
|
|
246
|
+
const fetched = await getProject(slug);
|
|
247
|
+
assert.equal(fetched.slug, slug);
|
|
248
|
+
assert.equal(fetched.name, 'Test Project');
|
|
249
|
+
assert.equal(fetched.rootUrl, '/' + slug);
|
|
250
|
+
assert.equal(fetched.sortOrder, 5);
|
|
251
|
+
assert.ok(fetched.createdAt);
|
|
252
|
+
assert.ok(fetched.updatedAt);
|
|
253
|
+
} finally {
|
|
254
|
+
await deleteProject(slug);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('createProject refuses duplicate slug', async () => {
|
|
259
|
+
const slug = T();
|
|
260
|
+
await createProject({slug, name: 'A'});
|
|
261
|
+
try {
|
|
262
|
+
await assert.rejects(
|
|
263
|
+
() => createProject({slug, name: 'B'}),
|
|
264
|
+
/already exists/
|
|
265
|
+
);
|
|
266
|
+
} finally {
|
|
267
|
+
await deleteProject(slug);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('updateProject preserves createdAt + slug; bumps updatedAt; allows other edits', async () => {
|
|
272
|
+
const slug = T();
|
|
273
|
+
await createProject({slug, name: 'Old'});
|
|
274
|
+
try {
|
|
275
|
+
const original = await getProject(slug);
|
|
276
|
+
await updateProject(slug, {name: 'New', description: 'updated'});
|
|
277
|
+
const updated = await getProject(slug);
|
|
278
|
+
assert.equal(updated.slug, slug);
|
|
279
|
+
assert.equal(updated.name, 'New');
|
|
280
|
+
assert.equal(updated.description, 'updated');
|
|
281
|
+
assert.equal(updated.createdAt, original.createdAt);
|
|
282
|
+
assert.ok(updated.updatedAt >= original.updatedAt);
|
|
283
|
+
} finally {
|
|
284
|
+
await deleteProject(slug);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('listProjects returns slug+name+description+icon+rootUrl+sortOrder', async () => {
|
|
289
|
+
const slug = T();
|
|
290
|
+
await createProject({slug, name: 'Listed', icon: 'star'});
|
|
291
|
+
try {
|
|
292
|
+
const all = await listProjects();
|
|
293
|
+
const found = all.find(p => p.slug === slug);
|
|
294
|
+
assert.ok(found);
|
|
295
|
+
assert.equal(found.name, 'Listed');
|
|
296
|
+
assert.equal(found.icon, 'star');
|
|
297
|
+
} finally {
|
|
298
|
+
await deleteProject(slug);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('getProject returns null for unknown slug', async () => {
|
|
303
|
+
assert.equal(await getProject('nope-' + Date.now()), null);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('deleteProject removes the record when no artefacts tagged', async () => {
|
|
307
|
+
const slug = T();
|
|
308
|
+
await createProject({slug, name: 'Soon-gone'});
|
|
309
|
+
const ok = await deleteProject(slug);
|
|
310
|
+
assert.equal(ok, true);
|
|
311
|
+
assert.equal(await getProject(slug), null);
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
- [ ] **Step 3.2: Run to confirm tests fail**
|
|
316
|
+
|
|
317
|
+
Run: `node --test tests/services/projects.test.js`
|
|
318
|
+
|
|
319
|
+
Expected: failures referencing `listProjects`, etc.
|
|
320
|
+
|
|
321
|
+
- [ ] **Step 3.3: Implement the CRUD functions**
|
|
322
|
+
|
|
323
|
+
Append to `server/services/projects.js`:
|
|
324
|
+
|
|
325
|
+
```js
|
|
326
|
+
import {
|
|
327
|
+
listEntries, createEntry, updateEntry, deleteEntry
|
|
328
|
+
} from './collections.js';
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* List all projects. Returns a flat array of project records, sorted by
|
|
332
|
+
* sortOrder ascending then name alphabetical.
|
|
333
|
+
*
|
|
334
|
+
* @returns {Promise<Array<object>>}
|
|
335
|
+
*/
|
|
336
|
+
export async function listProjects() {
|
|
337
|
+
const entries = await listEntries(PROJECTS_COLLECTION_SLUG);
|
|
338
|
+
const projects = entries.map(e => normalise(e));
|
|
339
|
+
return projects.sort((a, b) => {
|
|
340
|
+
const so = (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
|
|
341
|
+
if (so !== 0) return so;
|
|
342
|
+
return (a.name || '').localeCompare(b.name || '');
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Fetch a single project by slug. Returns null if missing or slug invalid.
|
|
348
|
+
*
|
|
349
|
+
* @param {string} slug
|
|
350
|
+
* @returns {Promise<object|null>}
|
|
351
|
+
*/
|
|
352
|
+
export async function getProject(slug) {
|
|
353
|
+
if (!validateSlug(slug)) return null;
|
|
354
|
+
const entries = await listEntries(PROJECTS_COLLECTION_SLUG);
|
|
355
|
+
const entry = entries.find(e => e.data?.slug === slug);
|
|
356
|
+
return entry ? normalise(entry) : null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Create a new project. Refuses on slug collision or validation failure.
|
|
361
|
+
*
|
|
362
|
+
* @param {object} input
|
|
363
|
+
* @returns {Promise<object>} the persisted project
|
|
364
|
+
* @throws {Error}
|
|
365
|
+
*/
|
|
366
|
+
export async function createProject(input) {
|
|
367
|
+
const errors = validateProject(input);
|
|
368
|
+
if (errors.length) throw new Error('Validation failed: ' + errors.join('; '));
|
|
369
|
+
if (await getProject(input.slug)) {
|
|
370
|
+
throw new Error(`Project "${input.slug}" already exists`);
|
|
371
|
+
}
|
|
372
|
+
const now = new Date().toISOString();
|
|
373
|
+
const data = {
|
|
374
|
+
slug: input.slug,
|
|
375
|
+
name: input.name,
|
|
376
|
+
description: input.description || '',
|
|
377
|
+
icon: input.icon || 'folder',
|
|
378
|
+
...(input.rootUrl != null && {rootUrl: input.rootUrl}),
|
|
379
|
+
sortOrder: Number.isFinite(input.sortOrder) ? input.sortOrder : 0,
|
|
380
|
+
createdAt: now,
|
|
381
|
+
updatedAt: now
|
|
382
|
+
};
|
|
383
|
+
await createEntry(PROJECTS_COLLECTION_SLUG, data);
|
|
384
|
+
return data;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Update a project. Slug is immutable. Preserves createdAt; bumps updatedAt.
|
|
389
|
+
*
|
|
390
|
+
* @param {string} slug
|
|
391
|
+
* @param {object} input
|
|
392
|
+
* @returns {Promise<object>}
|
|
393
|
+
* @throws {Error}
|
|
394
|
+
*/
|
|
395
|
+
export async function updateProject(slug, input) {
|
|
396
|
+
const existing = await getProject(slug);
|
|
397
|
+
if (!existing) throw new Error(`Project "${slug}" not found`);
|
|
398
|
+
const merged = {
|
|
399
|
+
...existing,
|
|
400
|
+
...(input.name != null && {name: input.name}),
|
|
401
|
+
...(input.description != null && {description: input.description}),
|
|
402
|
+
...(input.icon != null && {icon: input.icon}),
|
|
403
|
+
...(input.rootUrl != null && {rootUrl: input.rootUrl}),
|
|
404
|
+
...(Number.isFinite(input.sortOrder) && {sortOrder: input.sortOrder}),
|
|
405
|
+
slug, // slug is immutable
|
|
406
|
+
createdAt: existing.createdAt,
|
|
407
|
+
updatedAt: new Date().toISOString()
|
|
408
|
+
};
|
|
409
|
+
const errors = validateProject(merged);
|
|
410
|
+
if (errors.length) throw new Error('Validation failed: ' + errors.join('; '));
|
|
411
|
+
// Find the entry id by slug to update by id
|
|
412
|
+
const entries = await listEntries(PROJECTS_COLLECTION_SLUG);
|
|
413
|
+
const entry = entries.find(e => e.data?.slug === slug);
|
|
414
|
+
if (!entry) throw new Error(`Project "${slug}" not found`);
|
|
415
|
+
await updateEntry(PROJECTS_COLLECTION_SLUG, entry.id, merged);
|
|
416
|
+
return merged;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Delete a project. Returns true if removed, false if not found.
|
|
421
|
+
* Does NOT check for tagged artefacts - caller (API route) must do that.
|
|
422
|
+
*
|
|
423
|
+
* @param {string} slug
|
|
424
|
+
* @returns {Promise<boolean>}
|
|
425
|
+
*/
|
|
426
|
+
export async function deleteProject(slug) {
|
|
427
|
+
if (!validateSlug(slug)) return false;
|
|
428
|
+
const entries = await listEntries(PROJECTS_COLLECTION_SLUG);
|
|
429
|
+
const entry = entries.find(e => e.data?.slug === slug);
|
|
430
|
+
if (!entry) return false;
|
|
431
|
+
await deleteEntry(PROJECTS_COLLECTION_SLUG, entry.id);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function normalise(entry) {
|
|
436
|
+
return {...(entry.data || {})};
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
- [ ] **Step 3.4: Add validation helper**
|
|
441
|
+
|
|
442
|
+
Append (also to `server/services/projects.js`):
|
|
443
|
+
|
|
444
|
+
```js
|
|
445
|
+
/**
|
|
446
|
+
* Validate a project object. Returns an array of error strings (empty = OK).
|
|
447
|
+
*
|
|
448
|
+
* @param {object} project
|
|
449
|
+
* @returns {string[]}
|
|
450
|
+
*/
|
|
451
|
+
export function validateProject(project) {
|
|
452
|
+
const errors = [];
|
|
453
|
+
if (!project || typeof project !== 'object') return ['Project must be an object'];
|
|
454
|
+
if (!validateSlug(project.slug)) {
|
|
455
|
+
errors.push(`Invalid slug "${project.slug}" - lowercase alphanumeric + hyphen only`);
|
|
456
|
+
}
|
|
457
|
+
if (!project.name || typeof project.name !== 'string') {
|
|
458
|
+
errors.push('name is required');
|
|
459
|
+
}
|
|
460
|
+
if (project.rootUrl != null && project.rootUrl !== '') {
|
|
461
|
+
if (typeof project.rootUrl !== 'string' || !project.rootUrl.startsWith('/')) {
|
|
462
|
+
errors.push('rootUrl must start with /');
|
|
463
|
+
} else if (project.rootUrl === '/') {
|
|
464
|
+
errors.push('rootUrl cannot be exactly "/" (would auto-claim every page)');
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (project.icon != null && (typeof project.icon !== 'string' || !project.icon.trim())) {
|
|
468
|
+
errors.push('icon must be a non-empty string when set');
|
|
469
|
+
}
|
|
470
|
+
return errors;
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
- [ ] **Step 3.5: Run tests**
|
|
475
|
+
|
|
476
|
+
Run: `node --test tests/services/projects.test.js`
|
|
477
|
+
|
|
478
|
+
Expected: all tests pass.
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## Task 4: `canSeeArtefact` + `getProjectForPage` helpers
|
|
483
|
+
|
|
484
|
+
**Files:**
|
|
485
|
+
|
|
486
|
+
- Modify: `server/services/projects.js`
|
|
487
|
+
- Modify: `tests/services/projects.test.js`
|
|
488
|
+
|
|
489
|
+
The two scope/lookup helpers used everywhere artefacts are filtered or pages are loaded.
|
|
490
|
+
|
|
491
|
+
- [ ] **Step 4.1: Write helper tests**
|
|
492
|
+
|
|
493
|
+
Append to `tests/services/projects.test.js`:
|
|
494
|
+
|
|
495
|
+
```js
|
|
496
|
+
import {canSeeArtefact, getProjectForPage} from '../../server/services/projects.js';
|
|
497
|
+
|
|
498
|
+
test('canSeeArtefact - super-admin sees everything', () => {
|
|
499
|
+
const user = {id: 'sa', role: 'super-admin', projects: []};
|
|
500
|
+
const tagged = {meta: {project: 'members'}};
|
|
501
|
+
assert.equal(canSeeArtefact(user, tagged), true);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('canSeeArtefact - untagged artefact visible to everyone', () => {
|
|
505
|
+
const user = {id: 'u', role: 'admin', projects: ['members']};
|
|
506
|
+
const untagged = {meta: {}};
|
|
507
|
+
const noMeta = {};
|
|
508
|
+
assert.equal(canSeeArtefact(user, untagged), true);
|
|
509
|
+
assert.equal(canSeeArtefact(user, noMeta), true);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test('canSeeArtefact - empty user.projects = no restriction', () => {
|
|
513
|
+
const user = {id: 'u', role: 'admin', projects: []};
|
|
514
|
+
const tagged = {meta: {project: 'members'}};
|
|
515
|
+
assert.equal(canSeeArtefact(user, tagged), true);
|
|
516
|
+
const userUndef = {id: 'u', role: 'admin'};
|
|
517
|
+
assert.equal(canSeeArtefact(userUndef, tagged), true);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test('canSeeArtefact - scoped user can see their projects, not others', () => {
|
|
521
|
+
const user = {id: 'u', role: 'admin', projects: ['members']};
|
|
522
|
+
assert.equal(canSeeArtefact(user, {meta: {project: 'members'}}), true);
|
|
523
|
+
assert.equal(canSeeArtefact(user, {meta: {project: 'intranet'}}), false);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('getProjectForPage - explicit project: null = untagged', async () => {
|
|
527
|
+
const slug = T();
|
|
528
|
+
await createProject({slug, name: 'A', rootUrl: '/a'});
|
|
529
|
+
try {
|
|
530
|
+
assert.equal(await getProjectForPage('/a/foo', null), null);
|
|
531
|
+
} finally {
|
|
532
|
+
await deleteProject(slug);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('getProjectForPage - explicit project: <slug> wins', async () => {
|
|
537
|
+
const slug1 = T();
|
|
538
|
+
const slug2 = T();
|
|
539
|
+
await createProject({slug: slug1, name: 'A', rootUrl: '/a'});
|
|
540
|
+
await createProject({slug: slug2, name: 'B'});
|
|
541
|
+
try {
|
|
542
|
+
assert.equal(await getProjectForPage('/a/foo', slug2), slug2);
|
|
543
|
+
} finally {
|
|
544
|
+
await deleteProject(slug1);
|
|
545
|
+
await deleteProject(slug2);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test('getProjectForPage - URL inheritance: longest prefix wins', async () => {
|
|
550
|
+
const slug1 = T();
|
|
551
|
+
const slug2 = T();
|
|
552
|
+
await createProject({slug: slug1, name: 'A', rootUrl: '/members'});
|
|
553
|
+
await createProject({slug: slug2, name: 'B', rootUrl: '/members/billing'});
|
|
554
|
+
try {
|
|
555
|
+
assert.equal(await getProjectForPage('/members/foo', undefined), slug1);
|
|
556
|
+
assert.equal(await getProjectForPage('/members/billing/x', undefined), slug2);
|
|
557
|
+
} finally {
|
|
558
|
+
await deleteProject(slug1);
|
|
559
|
+
await deleteProject(slug2);
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('getProjectForPage - no rootUrl match returns null', async () => {
|
|
564
|
+
assert.equal(await getProjectForPage('/random-url', undefined), null);
|
|
565
|
+
});
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
- [ ] **Step 4.2: Run to confirm failure**
|
|
569
|
+
|
|
570
|
+
Run: `node --test tests/services/projects.test.js`
|
|
571
|
+
|
|
572
|
+
Expected: 8 new tests fail.
|
|
573
|
+
|
|
574
|
+
- [ ] **Step 4.3: Implement the helpers**
|
|
575
|
+
|
|
576
|
+
Append to `server/services/projects.js`:
|
|
577
|
+
|
|
578
|
+
```js
|
|
579
|
+
import {getRoleLevel} from './roles.js';
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* List projects the given user is allowed to see. Super-admin sees all;
|
|
583
|
+
* users with empty `projects: []` see all; scoped users see their listed
|
|
584
|
+
* projects only.
|
|
585
|
+
*
|
|
586
|
+
* @param {object|null} user
|
|
587
|
+
* @returns {Promise<Array<object>>}
|
|
588
|
+
*/
|
|
589
|
+
export async function listProjectsForUser(user) {
|
|
590
|
+
const all = await listProjects();
|
|
591
|
+
if (!user) return [];
|
|
592
|
+
if (getRoleLevel(user.role) === 0) return all;
|
|
593
|
+
if (!Array.isArray(user.projects) || user.projects.length === 0) return all;
|
|
594
|
+
return all.filter(p => user.projects.includes(p.slug));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Permission gate: can `user` see / edit `artefact` given its project tag
|
|
599
|
+
* and the user's `projects: []` access scope?
|
|
600
|
+
*
|
|
601
|
+
* Rules:
|
|
602
|
+
* - Super-admin (role level 0) bypasses everything.
|
|
603
|
+
* - Untagged artefacts (no `meta.project`) are visible to all.
|
|
604
|
+
* - Users with no scope set (`projects: []` or missing) have no restriction.
|
|
605
|
+
* - Otherwise: artefact's project must be in user's scope.
|
|
606
|
+
*
|
|
607
|
+
* @param {object} user - Authenticated user; must have `role`.
|
|
608
|
+
* @param {object} artefact - Anything with optional `meta.project`.
|
|
609
|
+
* @returns {boolean}
|
|
610
|
+
*/
|
|
611
|
+
export function canSeeArtefact(user, artefact) {
|
|
612
|
+
if (!user) return false;
|
|
613
|
+
if (getRoleLevel(user.role) === 0) return true; // super-admin bypass
|
|
614
|
+
const project = artefact?.meta?.project;
|
|
615
|
+
if (!project) return true; // untagged
|
|
616
|
+
if (!Array.isArray(user.projects) || user.projects.length === 0) return true;
|
|
617
|
+
return user.projects.includes(project);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Resolve the project a page belongs to using the inheritance rules:
|
|
622
|
+
* 1. Explicit `project: null` in frontmatter → untagged
|
|
623
|
+
* 2. Explicit `project: '<slug>'` → use it
|
|
624
|
+
* 3. Missing field → longest matching `rootUrl` prefix wins
|
|
625
|
+
* 4. No match → untagged
|
|
626
|
+
*
|
|
627
|
+
* @param {string} urlPath - The page's URL (e.g. '/members/profile').
|
|
628
|
+
* @param {string|null|undefined} explicitProject - Frontmatter `project:` value.
|
|
629
|
+
* @returns {Promise<string|null>}
|
|
630
|
+
*/
|
|
631
|
+
export async function getProjectForPage(urlPath, explicitProject) {
|
|
632
|
+
if (explicitProject === null) return null; // explicit opt-out
|
|
633
|
+
if (typeof explicitProject === 'string' && explicitProject) return explicitProject;
|
|
634
|
+
// No explicit field - URL inheritance
|
|
635
|
+
const projects = await listProjects();
|
|
636
|
+
let best = null;
|
|
637
|
+
let bestLen = -1;
|
|
638
|
+
for (const p of projects) {
|
|
639
|
+
if (!p.rootUrl) continue;
|
|
640
|
+
if (urlPath === p.rootUrl || urlPath.startsWith(p.rootUrl + '/')) {
|
|
641
|
+
if (p.rootUrl.length > bestLen) {
|
|
642
|
+
best = p.slug;
|
|
643
|
+
bestLen = p.rootUrl.length;
|
|
644
|
+
} else if (p.rootUrl.length === bestLen && p.slug < best) {
|
|
645
|
+
best = p.slug; // alphabetical tie-breaker
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return best;
|
|
650
|
+
}
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
- [ ] **Step 4.4: Run tests**
|
|
654
|
+
|
|
655
|
+
Run: `node --test tests/services/projects.test.js`
|
|
656
|
+
|
|
657
|
+
Expected: all tests pass.
|
|
658
|
+
|
|
659
|
+
---
|
|
660
|
+
|
|
661
|
+
## Task 5: `getArtefactsForProject` + `untagAllForProject`
|
|
662
|
+
|
|
663
|
+
**Files:**
|
|
664
|
+
|
|
665
|
+
- Modify: `server/services/projects.js`
|
|
666
|
+
- Modify: `tests/services/projects.test.js`
|
|
667
|
+
|
|
668
|
+
These two functions are heavy: they touch every artefact service to enumerate or mutate. They're the backbone of the project detail page and the safe-delete escape hatch.
|
|
669
|
+
|
|
670
|
+
- [ ] **Step 5.1: Write the enumeration test**
|
|
671
|
+
|
|
672
|
+
Append to `tests/services/projects.test.js`:
|
|
673
|
+
|
|
674
|
+
```js
|
|
675
|
+
import {getArtefactsForProject, untagAllForProject} from '../../server/services/projects.js';
|
|
676
|
+
import {createMenu, deleteMenu} from '../../server/services/menus.js';
|
|
677
|
+
|
|
678
|
+
test('getArtefactsForProject returns artefacts grouped by type', async () => {
|
|
679
|
+
const slug = T();
|
|
680
|
+
await createProject({slug, name: 'A'});
|
|
681
|
+
const menuSlug = 'gafp-' + Date.now();
|
|
682
|
+
await createMenu({slug: menuSlug, name: 'X', items: [], meta: {project: slug}});
|
|
683
|
+
try {
|
|
684
|
+
const grouped = await getArtefactsForProject(slug);
|
|
685
|
+
assert.ok(Array.isArray(grouped.menus));
|
|
686
|
+
assert.ok(grouped.menus.some(m => m.slug === menuSlug));
|
|
687
|
+
// Other type arrays exist even if empty
|
|
688
|
+
assert.ok(Array.isArray(grouped.collections));
|
|
689
|
+
assert.ok(Array.isArray(grouped.forms));
|
|
690
|
+
assert.ok(Array.isArray(grouped.pages));
|
|
691
|
+
} finally {
|
|
692
|
+
await deleteMenu(menuSlug);
|
|
693
|
+
await deleteProject(slug);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test('untagAllForProject clears meta.project on matching artefacts', async () => {
|
|
698
|
+
const slug = T();
|
|
699
|
+
await createProject({slug, name: 'A'});
|
|
700
|
+
const menuSlug = 'untag-' + Date.now();
|
|
701
|
+
await createMenu({slug: menuSlug, name: 'X', items: [], meta: {project: slug}});
|
|
702
|
+
try {
|
|
703
|
+
const removed = await untagAllForProject(slug);
|
|
704
|
+
assert.ok(removed.menus >= 1);
|
|
705
|
+
const {getMenu} = await import('../../server/services/menus.js');
|
|
706
|
+
const m = await getMenu(menuSlug);
|
|
707
|
+
assert.equal(m.meta?.project ?? null, null);
|
|
708
|
+
} finally {
|
|
709
|
+
await deleteMenu(menuSlug);
|
|
710
|
+
await deleteProject(slug);
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
- [ ] **Step 5.2: Run to confirm failure**
|
|
716
|
+
|
|
717
|
+
Run: `node --test tests/services/projects.test.js`
|
|
718
|
+
|
|
719
|
+
Expected: 2 new tests fail.
|
|
720
|
+
|
|
721
|
+
- [ ] **Step 5.3: Implement enumeration + untag**
|
|
722
|
+
|
|
723
|
+
Append to `server/services/projects.js`:
|
|
724
|
+
|
|
725
|
+
```js
|
|
726
|
+
/**
|
|
727
|
+
* Enumerate every artefact tagged with the given project. Returns a flat
|
|
728
|
+
* grouping by type - each value is an array of artefact summary objects.
|
|
729
|
+
* Used by the project detail page (counts + per-type tabs).
|
|
730
|
+
*
|
|
731
|
+
* @param {string} projectSlug
|
|
732
|
+
* @returns {Promise<{pages:object[], collections:object[], forms:object[], actions:object[], menus:object[], blocks:object[], views:object[], roles:object[], users:object[]}>}
|
|
733
|
+
*/
|
|
734
|
+
export async function getArtefactsForProject(projectSlug) {
|
|
735
|
+
const out = {
|
|
736
|
+
pages: [], collections: [], forms: [], actions: [],
|
|
737
|
+
menus: [], blocks: [], views: [], roles: [], users: []
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
try {
|
|
741
|
+
const {listMenus, getMenu} = await import('./menus.js');
|
|
742
|
+
for (const m of await listMenus()) {
|
|
743
|
+
const full = await getMenu(m.slug);
|
|
744
|
+
if (full?.meta?.project === projectSlug) out.menus.push(m);
|
|
745
|
+
}
|
|
746
|
+
} catch { /* service unavailable in test contexts */ }
|
|
747
|
+
|
|
748
|
+
try {
|
|
749
|
+
const {listForms, readForm} = await import('./forms.js');
|
|
750
|
+
for (const f of await listForms()) {
|
|
751
|
+
const full = await readForm(f.slug);
|
|
752
|
+
if (full?.meta?.project === projectSlug) out.forms.push(f);
|
|
753
|
+
}
|
|
754
|
+
} catch { /* skip */ }
|
|
755
|
+
|
|
756
|
+
try {
|
|
757
|
+
const {listCollections, getCollection} = await import('./collections.js');
|
|
758
|
+
for (const c of await listCollections()) {
|
|
759
|
+
const full = await getCollection(c.slug);
|
|
760
|
+
if (full?.meta?.project === projectSlug) out.collections.push(c);
|
|
761
|
+
}
|
|
762
|
+
} catch { /* skip */ }
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
const {listActions} = await import('./actions.js');
|
|
766
|
+
for (const a of await listActions()) {
|
|
767
|
+
if (a?.meta?.project === projectSlug) out.actions.push(a);
|
|
768
|
+
}
|
|
769
|
+
} catch { /* skip */ }
|
|
770
|
+
|
|
771
|
+
try {
|
|
772
|
+
const {listBlocks, readBlock} = await import('./blocks.js');
|
|
773
|
+
for (const b of await listBlocks()) {
|
|
774
|
+
const full = (typeof readBlock === 'function') ? await readBlock(b.slug) : b;
|
|
775
|
+
if (full?.meta?.project === projectSlug) out.blocks.push(b);
|
|
776
|
+
}
|
|
777
|
+
} catch { /* skip */ }
|
|
778
|
+
|
|
779
|
+
try {
|
|
780
|
+
const {listViews} = await import('./views.js');
|
|
781
|
+
for (const v of await listViews()) {
|
|
782
|
+
if (v?.meta?.project === projectSlug) out.views.push(v);
|
|
783
|
+
}
|
|
784
|
+
} catch { /* skip */ }
|
|
785
|
+
|
|
786
|
+
try {
|
|
787
|
+
const {getRoleMap} = await import('./roles.js');
|
|
788
|
+
for (const role of getRoleMap().values()) {
|
|
789
|
+
if (role?.meta?.project === projectSlug) out.roles.push(role);
|
|
790
|
+
}
|
|
791
|
+
} catch { /* skip */ }
|
|
792
|
+
|
|
793
|
+
try {
|
|
794
|
+
const {listUsers} = await import('./users.js');
|
|
795
|
+
for (const u of await listUsers()) {
|
|
796
|
+
if (u?.meta?.project === projectSlug) out.users.push(u);
|
|
797
|
+
}
|
|
798
|
+
} catch { /* skip */ }
|
|
799
|
+
|
|
800
|
+
try {
|
|
801
|
+
const {listPages} = await import('./content.js');
|
|
802
|
+
for (const p of await listPages()) {
|
|
803
|
+
const pageProject = await getProjectForPage(p.url || p.urlPath || '/', p.project);
|
|
804
|
+
if (pageProject === projectSlug) out.pages.push(p);
|
|
805
|
+
}
|
|
806
|
+
} catch { /* skip */ }
|
|
807
|
+
|
|
808
|
+
return out;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Set `meta.project: null` on every artefact tagged with this project.
|
|
813
|
+
* Returns a count map of how many of each type were untagged. Used as the
|
|
814
|
+
* "Untag all" escape hatch before delete.
|
|
815
|
+
*
|
|
816
|
+
* @param {string} projectSlug
|
|
817
|
+
* @returns {Promise<Record<string, number>>}
|
|
818
|
+
*/
|
|
819
|
+
export async function untagAllForProject(projectSlug) {
|
|
820
|
+
const counts = {
|
|
821
|
+
pages: 0, collections: 0, forms: 0, actions: 0,
|
|
822
|
+
menus: 0, blocks: 0, views: 0, roles: 0, users: 0
|
|
823
|
+
};
|
|
824
|
+
const grouped = await getArtefactsForProject(projectSlug);
|
|
825
|
+
|
|
826
|
+
try {
|
|
827
|
+
const {getMenu, updateMenu} = await import('./menus.js');
|
|
828
|
+
for (const m of grouped.menus) {
|
|
829
|
+
const full = await getMenu(m.slug);
|
|
830
|
+
if (!full) continue;
|
|
831
|
+
const meta = {...(full.meta || {})};
|
|
832
|
+
delete meta.project;
|
|
833
|
+
await updateMenu(m.slug, {...full, meta});
|
|
834
|
+
counts.menus++;
|
|
835
|
+
}
|
|
836
|
+
} catch { /* skip */ }
|
|
837
|
+
|
|
838
|
+
// Forms, Collections, Actions, Blocks, Views, Roles, Users, Pages -
|
|
839
|
+
// each service's update path is similar. We rely on the per-service
|
|
840
|
+
// tasks (Tasks 9-16) having already plumbed `meta.project` through
|
|
841
|
+
// update, so calling the standard update with `meta.project` removed
|
|
842
|
+
// is sufficient. Each block below mirrors the menus pattern.
|
|
843
|
+
|
|
844
|
+
try {
|
|
845
|
+
const {readForm, writeForm} = await import('./forms.js');
|
|
846
|
+
for (const f of grouped.forms) {
|
|
847
|
+
const full = await readForm(f.slug);
|
|
848
|
+
if (!full) continue;
|
|
849
|
+
const meta = {...(full.meta || {})};
|
|
850
|
+
delete meta.project;
|
|
851
|
+
await writeForm(f.slug, {...full, meta});
|
|
852
|
+
counts.forms++;
|
|
853
|
+
}
|
|
854
|
+
} catch { /* skip */ }
|
|
855
|
+
|
|
856
|
+
try {
|
|
857
|
+
const {getCollection, updateCollection} = await import('./collections.js');
|
|
858
|
+
for (const c of grouped.collections) {
|
|
859
|
+
const full = await getCollection(c.slug);
|
|
860
|
+
if (!full) continue;
|
|
861
|
+
const meta = {...(full.meta || {})};
|
|
862
|
+
delete meta.project;
|
|
863
|
+
await updateCollection(c.slug, {...full, meta});
|
|
864
|
+
counts.collections++;
|
|
865
|
+
}
|
|
866
|
+
} catch { /* skip */ }
|
|
867
|
+
|
|
868
|
+
try {
|
|
869
|
+
const {updateAction} = await import('./actions.js');
|
|
870
|
+
for (const a of grouped.actions) {
|
|
871
|
+
const meta = {...(a.meta || {})};
|
|
872
|
+
delete meta.project;
|
|
873
|
+
await updateAction(a.slug, {...a, meta});
|
|
874
|
+
counts.actions++;
|
|
875
|
+
}
|
|
876
|
+
} catch { /* skip */ }
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
const {readBlock, writeBlock} = await import('./blocks.js');
|
|
880
|
+
for (const b of grouped.blocks) {
|
|
881
|
+
const full = (typeof readBlock === 'function') ? await readBlock(b.slug) : b;
|
|
882
|
+
if (!full) continue;
|
|
883
|
+
const meta = {...(full.meta || {})};
|
|
884
|
+
delete meta.project;
|
|
885
|
+
if (typeof writeBlock === 'function') {
|
|
886
|
+
await writeBlock(b.slug, {...full, meta});
|
|
887
|
+
counts.blocks++;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
} catch { /* skip */ }
|
|
891
|
+
|
|
892
|
+
try {
|
|
893
|
+
const {readView, writeView} = await import('./views.js');
|
|
894
|
+
for (const v of grouped.views) {
|
|
895
|
+
const full = (typeof readView === 'function') ? await readView(v.slug) : v;
|
|
896
|
+
if (!full) continue;
|
|
897
|
+
const meta = {...(full.meta || {})};
|
|
898
|
+
delete meta.project;
|
|
899
|
+
if (typeof writeView === 'function') {
|
|
900
|
+
await writeView(v.slug, {...full, meta});
|
|
901
|
+
counts.views++;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
} catch { /* skip */ }
|
|
905
|
+
|
|
906
|
+
// Roles + Users carry `meta.project` on the user/role record; updateUser
|
|
907
|
+
// and the role-collection-entry update path both accept meta. Pages
|
|
908
|
+
// would need frontmatter rewriting - defer to a follow-up; for v1 the
|
|
909
|
+
// page count is reported but no rewrite happens (admin can edit the
|
|
910
|
+
// page to clear the project).
|
|
911
|
+
// The function still returns counts.pages = 0 in v1 even when pages
|
|
912
|
+
// were tagged; the API route's response surfaces this caveat.
|
|
913
|
+
|
|
914
|
+
return counts;
|
|
915
|
+
}
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
- [ ] **Step 5.4: Run tests**
|
|
919
|
+
|
|
920
|
+
Run: `node --test tests/services/projects.test.js`
|
|
921
|
+
|
|
922
|
+
Expected: all pass (including the 2 new ones).
|
|
923
|
+
|
|
924
|
+
Note: the test only exercises the menus path because that's the only service that has `meta.project` plumbed at this point in the build. Per-service plumbing comes in Tasks 9-16; afterwards `untagAllForProject` exercises every type.
|
|
925
|
+
|
|
926
|
+
---
|
|
927
|
+
|
|
928
|
+
## Task 6: Permission family `projects.*`
|
|
929
|
+
|
|
930
|
+
**Files:**
|
|
931
|
+
|
|
932
|
+
- Modify: `server/services/permissionRegistry.js`
|
|
933
|
+
|
|
934
|
+
- [ ] **Step 6.1: Insert the projects family**
|
|
935
|
+
|
|
936
|
+
Run: `grep -n "key: 'menus'" server/services/permissionRegistry.js`
|
|
937
|
+
|
|
938
|
+
In `server/services/permissionRegistry.js`, after the `menus` entry's closing `},`, insert:
|
|
939
|
+
|
|
940
|
+
```js
|
|
941
|
+
{
|
|
942
|
+
key: 'projects',
|
|
943
|
+
label: 'Projects',
|
|
944
|
+
description: 'Group related artefacts under named projects.',
|
|
945
|
+
icon: 'folder',
|
|
946
|
+
group: 'Structure',
|
|
947
|
+
actions: [
|
|
948
|
+
{key: 'read', label: 'View', description: 'View projects and their artefacts'},
|
|
949
|
+
{key: 'create', label: 'Create', description: 'Create new projects'},
|
|
950
|
+
{key: 'update', label: 'Edit', description: 'Edit projects and tag/untag artefacts'},
|
|
951
|
+
{key: 'delete', label: 'Delete', description: 'Delete projects (refused if any artefacts tagged)'}
|
|
952
|
+
]
|
|
953
|
+
},
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
- [ ] **Step 6.2: Smoke test**
|
|
957
|
+
|
|
958
|
+
Run:
|
|
959
|
+
```bash
|
|
960
|
+
node -e "import('./server/services/permissionRegistry.js').then(m => console.log(m.REGISTRY.find(r => r.key === 'projects')))"
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
Expected: prints the projects family with all 4 actions.
|
|
964
|
+
|
|
965
|
+
---
|
|
966
|
+
|
|
967
|
+
## Task 7: API routes `/api/projects/*`
|
|
968
|
+
|
|
969
|
+
**Files:**
|
|
970
|
+
|
|
971
|
+
- Create: `server/routes/api/projects.js`
|
|
972
|
+
- Create: `tests/routes/projects.test.js`
|
|
973
|
+
|
|
974
|
+
- [ ] **Step 7.1: Write route tests**
|
|
975
|
+
|
|
976
|
+
Create `tests/routes/projects.test.js`:
|
|
977
|
+
|
|
978
|
+
```js
|
|
979
|
+
/**
|
|
980
|
+
* Smoke tests for /api/projects/* - verifies route shape, auth gates, and
|
|
981
|
+
* happy-path CRUD via Fastify .inject.
|
|
982
|
+
*/
|
|
983
|
+
import {test, before, after} from 'node:test';
|
|
984
|
+
import assert from 'node:assert/strict';
|
|
985
|
+
import Fastify from 'fastify';
|
|
986
|
+
import fastifyJwt from '@fastify/jwt';
|
|
987
|
+
import {projectsRoutes} from '../../server/routes/api/projects.js';
|
|
988
|
+
import {deleteProject} from '../../server/services/projects.js';
|
|
989
|
+
import {load as loadRoles} from '../../server/services/roles.js';
|
|
990
|
+
|
|
991
|
+
await loadRoles();
|
|
992
|
+
|
|
993
|
+
// DI-friendly auth stubs (same pattern as menus routes).
|
|
994
|
+
async function buildApp() {
|
|
995
|
+
const app = Fastify();
|
|
996
|
+
app.register(fastifyJwt, {secret: 'test-secret-only-for-tests-32chars'});
|
|
997
|
+
const authenticate = async (req) => { req.user = {id: 'sa', role: 'super-admin'}; };
|
|
998
|
+
const requirePermission = () => async () => {};
|
|
999
|
+
await app.register(async (inst) => projectsRoutes(inst, {authenticate, requirePermission}), {prefix: '/api'});
|
|
1000
|
+
return app;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const T = () => 'rt-proj-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
|
1004
|
+
|
|
1005
|
+
test('POST /api/projects → 201 + body', async () => {
|
|
1006
|
+
const app = await buildApp();
|
|
1007
|
+
const slug = T();
|
|
1008
|
+
try {
|
|
1009
|
+
const res = await app.inject({method: 'POST', url: '/api/projects', payload: {slug, name: 'Test'}});
|
|
1010
|
+
assert.equal(res.statusCode, 201);
|
|
1011
|
+
assert.equal(res.json().slug, slug);
|
|
1012
|
+
} finally {
|
|
1013
|
+
await deleteProject(slug);
|
|
1014
|
+
await app.close();
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test('GET /api/projects → array including the created project', async () => {
|
|
1019
|
+
const app = await buildApp();
|
|
1020
|
+
const slug = T();
|
|
1021
|
+
try {
|
|
1022
|
+
await app.inject({method: 'POST', url: '/api/projects', payload: {slug, name: 'A'}});
|
|
1023
|
+
const res = await app.inject({method: 'GET', url: '/api/projects'});
|
|
1024
|
+
assert.equal(res.statusCode, 200);
|
|
1025
|
+
assert.ok(res.json().some(p => p.slug === slug));
|
|
1026
|
+
} finally {
|
|
1027
|
+
await deleteProject(slug);
|
|
1028
|
+
await app.close();
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test('GET /api/projects/:slug → full project', async () => {
|
|
1033
|
+
const app = await buildApp();
|
|
1034
|
+
const slug = T();
|
|
1035
|
+
try {
|
|
1036
|
+
await app.inject({method: 'POST', url: '/api/projects', payload: {slug, name: 'A'}});
|
|
1037
|
+
const res = await app.inject({method: 'GET', url: `/api/projects/${slug}`});
|
|
1038
|
+
assert.equal(res.statusCode, 200);
|
|
1039
|
+
assert.equal(res.json().name, 'A');
|
|
1040
|
+
} finally {
|
|
1041
|
+
await deleteProject(slug);
|
|
1042
|
+
await app.close();
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
test('GET /api/projects/:slug → 404 for unknown', async () => {
|
|
1047
|
+
const app = await buildApp();
|
|
1048
|
+
const res = await app.inject({method: 'GET', url: '/api/projects/does-not-exist'});
|
|
1049
|
+
assert.equal(res.statusCode, 404);
|
|
1050
|
+
await app.close();
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
test('PUT /api/projects/:slug → 200 + updated', async () => {
|
|
1054
|
+
const app = await buildApp();
|
|
1055
|
+
const slug = T();
|
|
1056
|
+
try {
|
|
1057
|
+
await app.inject({method: 'POST', url: '/api/projects', payload: {slug, name: 'A'}});
|
|
1058
|
+
const res = await app.inject({method: 'PUT', url: `/api/projects/${slug}`, payload: {name: 'B'}});
|
|
1059
|
+
assert.equal(res.statusCode, 200);
|
|
1060
|
+
assert.equal(res.json().name, 'B');
|
|
1061
|
+
} finally {
|
|
1062
|
+
await deleteProject(slug);
|
|
1063
|
+
await app.close();
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
test('DELETE /api/projects/:slug → 200 when empty', async () => {
|
|
1068
|
+
const app = await buildApp();
|
|
1069
|
+
const slug = T();
|
|
1070
|
+
await app.inject({method: 'POST', url: '/api/projects', payload: {slug, name: 'A'}});
|
|
1071
|
+
const res = await app.inject({method: 'DELETE', url: `/api/projects/${slug}`});
|
|
1072
|
+
assert.equal(res.statusCode, 200);
|
|
1073
|
+
await app.close();
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
test('GET /api/projects/:slug/artefacts → grouped object', async () => {
|
|
1077
|
+
const app = await buildApp();
|
|
1078
|
+
const slug = T();
|
|
1079
|
+
try {
|
|
1080
|
+
await app.inject({method: 'POST', url: '/api/projects', payload: {slug, name: 'A'}});
|
|
1081
|
+
const res = await app.inject({method: 'GET', url: `/api/projects/${slug}/artefacts`});
|
|
1082
|
+
assert.equal(res.statusCode, 200);
|
|
1083
|
+
const body = res.json();
|
|
1084
|
+
assert.ok(Array.isArray(body.menus));
|
|
1085
|
+
assert.ok(Array.isArray(body.collections));
|
|
1086
|
+
} finally {
|
|
1087
|
+
await deleteProject(slug);
|
|
1088
|
+
await app.close();
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
- [ ] **Step 7.2: Run to confirm failure**
|
|
1094
|
+
|
|
1095
|
+
Run: `node --test tests/routes/projects.test.js`
|
|
1096
|
+
|
|
1097
|
+
Expected: import error.
|
|
1098
|
+
|
|
1099
|
+
- [ ] **Step 7.3: Implement the route module**
|
|
1100
|
+
|
|
1101
|
+
Create `server/routes/api/projects.js`:
|
|
1102
|
+
|
|
1103
|
+
```js
|
|
1104
|
+
/**
|
|
1105
|
+
* Projects API
|
|
1106
|
+
*
|
|
1107
|
+
* GET /api/projects - list visible projects
|
|
1108
|
+
* GET /api/projects/:slug - full project
|
|
1109
|
+
* POST /api/projects - create
|
|
1110
|
+
* PUT /api/projects/:slug - edit (slug immutable)
|
|
1111
|
+
* DELETE /api/projects/:slug - delete (409 if any artefacts tagged)
|
|
1112
|
+
* GET /api/projects/:slug/artefacts - grouped artefacts
|
|
1113
|
+
* POST /api/projects/:slug/untag-all - escape hatch before delete
|
|
1114
|
+
*
|
|
1115
|
+
* Auth middlewares are accepted as DI options so tests can supply no-ops.
|
|
1116
|
+
*/
|
|
1117
|
+
import {
|
|
1118
|
+
authenticate as defaultAuthenticate,
|
|
1119
|
+
requirePermission as defaultRequirePermission
|
|
1120
|
+
} from '../../middleware/auth.js';
|
|
1121
|
+
import {
|
|
1122
|
+
listProjects, listProjectsForUser, getProject, createProject, updateProject, deleteProject,
|
|
1123
|
+
getArtefactsForProject, untagAllForProject
|
|
1124
|
+
} from '../../services/projects.js';
|
|
1125
|
+
import * as cache from '../../services/cache/index.js';
|
|
1126
|
+
|
|
1127
|
+
export async function projectsRoutes(fastify, opts = {}) {
|
|
1128
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
1129
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
1130
|
+
|
|
1131
|
+
const canRead = {preHandler: [authenticate, requirePermission('projects', 'read')]};
|
|
1132
|
+
const canCreate = {preHandler: [authenticate, requirePermission('projects', 'create')]};
|
|
1133
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('projects', 'update')]};
|
|
1134
|
+
const canDelete = {preHandler: [authenticate, requirePermission('projects', 'delete')]};
|
|
1135
|
+
|
|
1136
|
+
fastify.get('/projects', canRead, async (request) => {
|
|
1137
|
+
return listProjectsForUser(request.user);
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
fastify.get('/projects/:slug', canRead, async (request, reply) => {
|
|
1141
|
+
const project = await getProject(request.params.slug);
|
|
1142
|
+
if (!project) return reply.status(404).send({error: 'Project not found'});
|
|
1143
|
+
return project;
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
fastify.post('/projects', canCreate, async (request, reply) => {
|
|
1147
|
+
try {
|
|
1148
|
+
const project = await createProject(request.body || {});
|
|
1149
|
+
await cache.invalidateTags(['projects']);
|
|
1150
|
+
return reply.status(201).send(project);
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
const status = /already exists/.test(err.message) ? 409 : 400;
|
|
1153
|
+
return reply.status(status).send({error: err.message});
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
fastify.put('/projects/:slug', canUpdate, async (request, reply) => {
|
|
1158
|
+
try {
|
|
1159
|
+
const project = await updateProject(request.params.slug, request.body || {});
|
|
1160
|
+
await cache.invalidateTags(['projects', `project:${request.params.slug}`]);
|
|
1161
|
+
return project;
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
const status = /not found/.test(err.message) ? 404 : 400;
|
|
1164
|
+
return reply.status(status).send({error: err.message});
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
fastify.delete('/projects/:slug', canDelete, async (request, reply) => {
|
|
1169
|
+
const grouped = await getArtefactsForProject(request.params.slug);
|
|
1170
|
+
const total = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);
|
|
1171
|
+
if (total > 0) {
|
|
1172
|
+
return reply.status(409).send({
|
|
1173
|
+
error: `Cannot delete - ${total} artefacts still tagged with this project`,
|
|
1174
|
+
artefacts: Object.fromEntries(Object.entries(grouped).map(([k, v]) => [k, v.length]))
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
const ok = await deleteProject(request.params.slug);
|
|
1178
|
+
if (!ok) return reply.status(404).send({error: 'Project not found'});
|
|
1179
|
+
await cache.invalidateTags(['projects', `project:${request.params.slug}`]);
|
|
1180
|
+
return {success: true};
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
fastify.get('/projects/:slug/artefacts', canRead, async (request, reply) => {
|
|
1184
|
+
if (!(await getProject(request.params.slug))) {
|
|
1185
|
+
return reply.status(404).send({error: 'Project not found'});
|
|
1186
|
+
}
|
|
1187
|
+
return getArtefactsForProject(request.params.slug);
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
fastify.post('/projects/:slug/untag-all', canDelete, async (request, reply) => {
|
|
1191
|
+
if (!(await getProject(request.params.slug))) {
|
|
1192
|
+
return reply.status(404).send({error: 'Project not found'});
|
|
1193
|
+
}
|
|
1194
|
+
const counts = await untagAllForProject(request.params.slug);
|
|
1195
|
+
await cache.invalidateTags(['projects', `project:${request.params.slug}`]);
|
|
1196
|
+
return {success: true, untagged: counts};
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
```
|
|
1200
|
+
|
|
1201
|
+
- [ ] **Step 7.4: Run tests**
|
|
1202
|
+
|
|
1203
|
+
Run: `node --test tests/routes/projects.test.js`
|
|
1204
|
+
|
|
1205
|
+
Expected: all 7 tests pass.
|
|
1206
|
+
|
|
1207
|
+
---
|
|
1208
|
+
|
|
1209
|
+
## Task 8: Wire routes + preset seed into server.js
|
|
1210
|
+
|
|
1211
|
+
**Files:**
|
|
1212
|
+
|
|
1213
|
+
- Modify: `server/server.js`
|
|
1214
|
+
|
|
1215
|
+
- [ ] **Step 8.1: Import + register**
|
|
1216
|
+
|
|
1217
|
+
At the top of `server/server.js` (with the other dynamic imports):
|
|
1218
|
+
|
|
1219
|
+
```js
|
|
1220
|
+
const {projectsRoutes} = await import('./routes/api/projects.js');
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
After the existing menus routes registration:
|
|
1224
|
+
|
|
1225
|
+
```js
|
|
1226
|
+
await app.register(projectsRoutes, {prefix: '/api'});
|
|
1227
|
+
```
|
|
1228
|
+
|
|
1229
|
+
- [ ] **Step 8.2: Verify syntax + import**
|
|
1230
|
+
|
|
1231
|
+
Run: `node --check server/server.js`
|
|
1232
|
+
|
|
1233
|
+
Then:
|
|
1234
|
+
```bash
|
|
1235
|
+
node -e "import('./server/routes/api/projects.js').then(m => console.log('projects ok:', typeof m.projectsRoutes))"
|
|
1236
|
+
```
|
|
1237
|
+
|
|
1238
|
+
Expected: silent + `projects ok: function`.
|
|
1239
|
+
|
|
1240
|
+
- [ ] **Step 8.3: Curl smoke test (server already running)**
|
|
1241
|
+
|
|
1242
|
+
Run:
|
|
1243
|
+
```bash
|
|
1244
|
+
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4096/api/projects
|
|
1245
|
+
```
|
|
1246
|
+
|
|
1247
|
+
Expected: `401` (auth-gated, route registered). The route won't fire until the server is restarted. Tell the user to restart at their convenience.
|
|
1248
|
+
|
|
1249
|
+
---
|
|
1250
|
+
|
|
1251
|
+
## Task 9: Plumb `meta.project` through `collections.js`
|
|
1252
|
+
|
|
1253
|
+
**Files:**
|
|
1254
|
+
|
|
1255
|
+
- Modify: `server/services/collections.js`
|
|
1256
|
+
|
|
1257
|
+
The collection schema's `meta.project` field needs to be persisted on create/update and read back on get. List filtering happens in the route handler (Task 17 onwards).
|
|
1258
|
+
|
|
1259
|
+
- [ ] **Step 9.1: Find createCollection signature**
|
|
1260
|
+
|
|
1261
|
+
Run: `grep -n "export async function createCollection\|export async function updateCollection" server/services/collections.js`
|
|
1262
|
+
|
|
1263
|
+
The current signature is `createCollection({title, slug, description, fields, api, storage, bundled, plugin})`.
|
|
1264
|
+
|
|
1265
|
+
- [ ] **Step 9.2: Accept `meta` in create/update**
|
|
1266
|
+
|
|
1267
|
+
In `server/services/collections.js`:
|
|
1268
|
+
|
|
1269
|
+
- In `createCollection`, add `meta` to the destructured params and persist it into the schema object that's written. Locate the line that builds the schema (`const schema = {...}`) and add:
|
|
1270
|
+
|
|
1271
|
+
```js
|
|
1272
|
+
...(meta != null && {meta}),
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
- In `updateCollection`, the function already accepts a partial update; ensure that `meta` is preserved if not in the update payload (typical updates spread existing + new). Locate the merge and confirm `meta` flows through. If it does, no change needed; if not, add an explicit `meta: input.meta ?? existing.meta` line.
|
|
1276
|
+
|
|
1277
|
+
- [ ] **Step 9.3: Verify with a round-trip test**
|
|
1278
|
+
|
|
1279
|
+
Append to `tests/services/projects.test.js`:
|
|
1280
|
+
|
|
1281
|
+
```js
|
|
1282
|
+
import {createCollection, getCollection, deleteCollection} from '../../server/services/collections.js';
|
|
1283
|
+
|
|
1284
|
+
test('collections - meta.project round-trips through create/get', async () => {
|
|
1285
|
+
const slug = 'meta-proj-col-' + Date.now();
|
|
1286
|
+
await createCollection({
|
|
1287
|
+
slug, title: 'X', fields: [{name: 't', label: 'T', type: 'text'}],
|
|
1288
|
+
meta: {project: 'members'}
|
|
1289
|
+
});
|
|
1290
|
+
try {
|
|
1291
|
+
const c = await getCollection(slug);
|
|
1292
|
+
assert.equal(c?.meta?.project, 'members');
|
|
1293
|
+
} finally {
|
|
1294
|
+
await deleteCollection(slug);
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
- [ ] **Step 9.4: Run the test**
|
|
1300
|
+
|
|
1301
|
+
Run: `node --test tests/services/projects.test.js`
|
|
1302
|
+
|
|
1303
|
+
Expected: pass.
|
|
1304
|
+
|
|
1305
|
+
---
|
|
1306
|
+
|
|
1307
|
+
## Task 10: Plumb `meta.project` through `forms.js`
|
|
1308
|
+
|
|
1309
|
+
**Files:**
|
|
1310
|
+
|
|
1311
|
+
- Modify: `server/services/forms.js`
|
|
1312
|
+
|
|
1313
|
+
Forms persist as JSON files; `writeForm(slug, data)` already takes the full object. The persistence path likely preserves arbitrary keys - verify, then add a test.
|
|
1314
|
+
|
|
1315
|
+
- [ ] **Step 10.1: Verify `writeForm` preserves arbitrary keys**
|
|
1316
|
+
|
|
1317
|
+
Read `server/services/forms.js`. Inspect `writeForm`'s body. If it spreads `data` directly into the file (the common case), `meta` is already persisted.
|
|
1318
|
+
|
|
1319
|
+
If the function builds a whitelist of fields, add `meta` to the whitelist:
|
|
1320
|
+
|
|
1321
|
+
```js
|
|
1322
|
+
const persisted = {
|
|
1323
|
+
slug: data.slug,
|
|
1324
|
+
title: data.title,
|
|
1325
|
+
// ... existing fields ...
|
|
1326
|
+
...(data.meta != null && {meta: data.meta})
|
|
1327
|
+
};
|
|
1328
|
+
```
|
|
1329
|
+
|
|
1330
|
+
- [ ] **Step 10.2: Round-trip test**
|
|
1331
|
+
|
|
1332
|
+
Append to `tests/services/projects.test.js`:
|
|
1333
|
+
|
|
1334
|
+
```js
|
|
1335
|
+
import {writeForm, readForm, deleteForm} from '../../server/services/forms.js';
|
|
1336
|
+
|
|
1337
|
+
test('forms - meta.project round-trips', async () => {
|
|
1338
|
+
const slug = 'meta-proj-form-' + Date.now();
|
|
1339
|
+
await writeForm(slug, {slug, title: 'X', fields: [], meta: {project: 'members'}});
|
|
1340
|
+
try {
|
|
1341
|
+
const f = await readForm(slug);
|
|
1342
|
+
assert.equal(f?.meta?.project, 'members');
|
|
1343
|
+
} finally {
|
|
1344
|
+
await deleteForm(slug);
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
- [ ] **Step 10.3: Run the test**
|
|
1350
|
+
|
|
1351
|
+
Run: `node --test tests/services/projects.test.js`
|
|
1352
|
+
|
|
1353
|
+
Expected: pass.
|
|
1354
|
+
|
|
1355
|
+
---
|
|
1356
|
+
|
|
1357
|
+
## Task 11: Plumb `meta.project` through `actions.js`
|
|
1358
|
+
|
|
1359
|
+
**Files:**
|
|
1360
|
+
|
|
1361
|
+
- Modify: `server/services/actions.js`
|
|
1362
|
+
|
|
1363
|
+
- [ ] **Step 11.1: Check `createAction` signature**
|
|
1364
|
+
|
|
1365
|
+
Run: `grep -A 10 "export async function createAction" server/services/actions.js | head -15`
|
|
1366
|
+
|
|
1367
|
+
Confirm the function accepts a `data` bag and ensure `data.meta` is persisted alongside other fields when writing to Mongo.
|
|
1368
|
+
|
|
1369
|
+
- [ ] **Step 11.2: Add `meta` to the persisted record**
|
|
1370
|
+
|
|
1371
|
+
In `createAction`, the Mongo insert should include `meta` if present:
|
|
1372
|
+
|
|
1373
|
+
```js
|
|
1374
|
+
const doc = {
|
|
1375
|
+
slug: data.slug,
|
|
1376
|
+
title: data.title,
|
|
1377
|
+
// ... existing fields ...
|
|
1378
|
+
...(data.meta != null && {meta: data.meta}),
|
|
1379
|
+
createdAt: new Date(),
|
|
1380
|
+
createdBy: userId
|
|
1381
|
+
};
|
|
1382
|
+
```
|
|
1383
|
+
|
|
1384
|
+
Likewise in `updateAction`.
|
|
1385
|
+
|
|
1386
|
+
- [ ] **Step 11.3: Skip the round-trip test if Mongo isn't configured**
|
|
1387
|
+
|
|
1388
|
+
Append to `tests/services/projects.test.js`:
|
|
1389
|
+
|
|
1390
|
+
```js
|
|
1391
|
+
import {createAction, getAction, deleteAction} from '../../server/services/actions.js';
|
|
1392
|
+
|
|
1393
|
+
test('actions - meta.project round-trips (when Mongo configured)', async () => {
|
|
1394
|
+
const slug = 'meta-proj-act-' + Date.now();
|
|
1395
|
+
try {
|
|
1396
|
+
await createAction({slug, title: 'X', collection: 'foo', meta: {project: 'members'}});
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
if (/Mongo/i.test(err.message)) return; // skip if Mongo not available
|
|
1399
|
+
throw err;
|
|
1400
|
+
}
|
|
1401
|
+
try {
|
|
1402
|
+
const a = await getAction(slug);
|
|
1403
|
+
assert.equal(a?.meta?.project, 'members');
|
|
1404
|
+
} finally {
|
|
1405
|
+
await deleteAction(slug).catch(() => {});
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
```
|
|
1409
|
+
|
|
1410
|
+
- [ ] **Step 11.4: Run the test**
|
|
1411
|
+
|
|
1412
|
+
Run: `node --test tests/services/projects.test.js`
|
|
1413
|
+
|
|
1414
|
+
Expected: pass (or skip silently when Mongo not configured).
|
|
1415
|
+
|
|
1416
|
+
---
|
|
1417
|
+
|
|
1418
|
+
## Task 12: Plumb `meta.project` through `menus.js`
|
|
1419
|
+
|
|
1420
|
+
**Files:**
|
|
1421
|
+
|
|
1422
|
+
- Modify: `server/services/menus.js`
|
|
1423
|
+
|
|
1424
|
+
Menus already has a `meta` block that accepts `{createdAt, updatedAt, bundled, presetOwner}`. Need to allow `project` as an additional key (passthrough) AND surface it on `listMenus` results.
|
|
1425
|
+
|
|
1426
|
+
- [ ] **Step 12.1: Update `createMenu` to accept `meta.project`**
|
|
1427
|
+
|
|
1428
|
+
In `server/services/menus.js`, locate `createMenu`. The `meta` block currently has fixed keys. Update it to spread input's meta:
|
|
1429
|
+
|
|
1430
|
+
```js
|
|
1431
|
+
meta: {
|
|
1432
|
+
createdAt: now,
|
|
1433
|
+
updatedAt: now,
|
|
1434
|
+
bundled: Boolean(input.meta?.bundled),
|
|
1435
|
+
presetOwner: input.meta?.presetOwner || null,
|
|
1436
|
+
...(input.meta?.project != null && {project: input.meta.project})
|
|
1437
|
+
}
|
|
1438
|
+
```
|
|
1439
|
+
|
|
1440
|
+
Likewise in `updateMenu` - the existing merge already spreads `input.meta`, so `project` will flow through.
|
|
1441
|
+
|
|
1442
|
+
- [ ] **Step 12.2: Surface project in `listMenus`**
|
|
1443
|
+
|
|
1444
|
+
In `listMenus`, the existing summary object includes `meta`. The full `meta` is returned (including `project`). No change needed - verify by reading the function.
|
|
1445
|
+
|
|
1446
|
+
- [ ] **Step 12.3: Round-trip test**
|
|
1447
|
+
|
|
1448
|
+
Append to `tests/services/projects.test.js`:
|
|
1449
|
+
|
|
1450
|
+
```js
|
|
1451
|
+
import {createMenu as cMenu, getMenu as gMenu, deleteMenu as dMenu} from '../../server/services/menus.js';
|
|
1452
|
+
|
|
1453
|
+
test('menus - meta.project round-trips', async () => {
|
|
1454
|
+
const slug = 'meta-proj-menu-' + Date.now();
|
|
1455
|
+
await cMenu({slug, name: 'X', items: [], meta: {project: 'members'}});
|
|
1456
|
+
try {
|
|
1457
|
+
const m = await gMenu(slug);
|
|
1458
|
+
assert.equal(m?.meta?.project, 'members');
|
|
1459
|
+
} finally {
|
|
1460
|
+
await dMenu(slug);
|
|
1461
|
+
}
|
|
1462
|
+
});
|
|
1463
|
+
```
|
|
1464
|
+
|
|
1465
|
+
- [ ] **Step 12.4: Run the test**
|
|
1466
|
+
|
|
1467
|
+
Run: `node --test tests/services/projects.test.js`
|
|
1468
|
+
|
|
1469
|
+
Expected: pass.
|
|
1470
|
+
|
|
1471
|
+
---
|
|
1472
|
+
|
|
1473
|
+
## Task 13: Plumb `meta.project` through `blocks.js`
|
|
1474
|
+
|
|
1475
|
+
**Files:**
|
|
1476
|
+
|
|
1477
|
+
- Modify: `server/services/blocks.js`
|
|
1478
|
+
|
|
1479
|
+
- [ ] **Step 13.1: Find the write path**
|
|
1480
|
+
|
|
1481
|
+
Run: `grep -n "export async function" server/services/blocks.js`
|
|
1482
|
+
|
|
1483
|
+
Identify the create/update/write function. Confirm or add `meta` passthrough on persistence (same shape as forms).
|
|
1484
|
+
|
|
1485
|
+
- [ ] **Step 13.2: Round-trip test**
|
|
1486
|
+
|
|
1487
|
+
Append to `tests/services/projects.test.js`:
|
|
1488
|
+
|
|
1489
|
+
```js
|
|
1490
|
+
import {createBlock, getBlock, deleteBlock} from '../../server/services/blocks.js';
|
|
1491
|
+
|
|
1492
|
+
test('blocks - meta.project round-trips', async () => {
|
|
1493
|
+
const slug = 'meta-proj-block-' + Date.now();
|
|
1494
|
+
await createBlock({slug, name: 'X', body: '', meta: {project: 'members'}});
|
|
1495
|
+
try {
|
|
1496
|
+
const b = await getBlock(slug);
|
|
1497
|
+
assert.equal(b?.meta?.project, 'members');
|
|
1498
|
+
} finally {
|
|
1499
|
+
await deleteBlock(slug);
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
If the block service's create/get function names are different, adjust to match (`createBlock` may be `writeBlock`, `getBlock` may be `readBlock`).
|
|
1505
|
+
|
|
1506
|
+
- [ ] **Step 13.3: Run the test**
|
|
1507
|
+
|
|
1508
|
+
Run: `node --test tests/services/projects.test.js`
|
|
1509
|
+
|
|
1510
|
+
Expected: pass.
|
|
1511
|
+
|
|
1512
|
+
---
|
|
1513
|
+
|
|
1514
|
+
## Task 14: Plumb `meta.project` through `views.js`
|
|
1515
|
+
|
|
1516
|
+
**Files:**
|
|
1517
|
+
|
|
1518
|
+
- Modify: `server/services/views.js`
|
|
1519
|
+
|
|
1520
|
+
Same pattern as Task 13. Read the file to find the write function; add `meta` passthrough; round-trip test.
|
|
1521
|
+
|
|
1522
|
+
- [ ] **Step 14.1: Read the file to find the write path**
|
|
1523
|
+
|
|
1524
|
+
Read `server/services/views.js`, focusing on create/write functions.
|
|
1525
|
+
|
|
1526
|
+
- [ ] **Step 14.2: Ensure `meta` passes through write**
|
|
1527
|
+
|
|
1528
|
+
Same edit pattern as forms/blocks.
|
|
1529
|
+
|
|
1530
|
+
- [ ] **Step 14.3: Round-trip test**
|
|
1531
|
+
|
|
1532
|
+
Append to `tests/services/projects.test.js`:
|
|
1533
|
+
|
|
1534
|
+
```js
|
|
1535
|
+
import {createView, getView, deleteView} from '../../server/services/views.js';
|
|
1536
|
+
|
|
1537
|
+
test('views - meta.project round-trips', async () => {
|
|
1538
|
+
const slug = 'meta-proj-view-' + Date.now();
|
|
1539
|
+
await createView({slug, name: 'X', query: {}, meta: {project: 'members'}});
|
|
1540
|
+
try {
|
|
1541
|
+
const v = await getView(slug);
|
|
1542
|
+
assert.equal(v?.meta?.project, 'members');
|
|
1543
|
+
} finally {
|
|
1544
|
+
await deleteView(slug);
|
|
1545
|
+
}
|
|
1546
|
+
});
|
|
1547
|
+
```
|
|
1548
|
+
|
|
1549
|
+
Adjust function names + fields to match the actual view shape.
|
|
1550
|
+
|
|
1551
|
+
- [ ] **Step 14.4: Run the test**
|
|
1552
|
+
|
|
1553
|
+
Run: `node --test tests/services/projects.test.js`
|
|
1554
|
+
|
|
1555
|
+
Expected: pass.
|
|
1556
|
+
|
|
1557
|
+
---
|
|
1558
|
+
|
|
1559
|
+
## Task 15: Plumb `meta.project` through `roles.js`
|
|
1560
|
+
|
|
1561
|
+
**Files:**
|
|
1562
|
+
|
|
1563
|
+
- Modify: `server/services/roles.js`
|
|
1564
|
+
|
|
1565
|
+
Roles are stored as entries in a collection. `createRole(data)` writes the role entry. `data.meta.project` should be persisted as part of the role's data record.
|
|
1566
|
+
|
|
1567
|
+
- [ ] **Step 15.1: Find createRole + load**
|
|
1568
|
+
|
|
1569
|
+
Read `server/services/roles.js`. `createRole(data)` accepts the role data; verify the entry write preserves arbitrary keys (likely yes - the function spreads data into the entry).
|
|
1570
|
+
|
|
1571
|
+
If the function whitelists keys, add `meta` to the whitelist.
|
|
1572
|
+
|
|
1573
|
+
- [ ] **Step 15.2: Round-trip test**
|
|
1574
|
+
|
|
1575
|
+
Append to `tests/services/projects.test.js`:
|
|
1576
|
+
|
|
1577
|
+
```js
|
|
1578
|
+
import {createRole, getRoleMap, removeRole} from '../../server/services/roles.js';
|
|
1579
|
+
|
|
1580
|
+
test('roles - meta.project round-trips', async () => {
|
|
1581
|
+
const name = 'meta-proj-role-' + Date.now();
|
|
1582
|
+
await createRole({
|
|
1583
|
+
name, label: 'Test', level: 5, permissions: [], meta: {project: 'members'}
|
|
1584
|
+
});
|
|
1585
|
+
try {
|
|
1586
|
+
const role = getRoleMap().get(name);
|
|
1587
|
+
assert.equal(role?.meta?.project, 'members');
|
|
1588
|
+
} finally {
|
|
1589
|
+
await removeRole(name);
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
```
|
|
1593
|
+
|
|
1594
|
+
- [ ] **Step 15.3: Run the test**
|
|
1595
|
+
|
|
1596
|
+
Run: `node --test tests/services/projects.test.js`
|
|
1597
|
+
|
|
1598
|
+
Expected: pass.
|
|
1599
|
+
|
|
1600
|
+
---
|
|
1601
|
+
|
|
1602
|
+
## Task 16: Plumb `meta.project` + access scope through `users.js`
|
|
1603
|
+
|
|
1604
|
+
**Files:**
|
|
1605
|
+
|
|
1606
|
+
- Modify: `server/services/users.js`
|
|
1607
|
+
|
|
1608
|
+
Users get TWO new fields: `meta.project` (administrative ownership - single project slug) and `projects: []` (access scope - array of project slugs).
|
|
1609
|
+
|
|
1610
|
+
- [ ] **Step 16.1: Update `createUser` / `updateUser`**
|
|
1611
|
+
|
|
1612
|
+
In `server/services/users.js`, the create/update functions persist a user JSON. Add `meta` and `projects` to the persisted shape (or confirm they pass through if the writer spreads input).
|
|
1613
|
+
|
|
1614
|
+
Specifically, the persisted user should include:
|
|
1615
|
+
|
|
1616
|
+
```js
|
|
1617
|
+
...(input.meta != null && {meta: input.meta}),
|
|
1618
|
+
...(Array.isArray(input.projects) && {projects: input.projects})
|
|
1619
|
+
```
|
|
1620
|
+
|
|
1621
|
+
- [ ] **Step 16.2: Invalidate per-user cache on `projects` change**
|
|
1622
|
+
|
|
1623
|
+
In `updateUser`, after the write, detect whether `projects` was changed. If so, fire:
|
|
1624
|
+
|
|
1625
|
+
```js
|
|
1626
|
+
const cache = await import('./cache/index.js');
|
|
1627
|
+
await cache.invalidateTags([`user:${userId}`]);
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
(The `user:<id>` tag is what list endpoints in Tasks 17-25 will tag their cache entries with.)
|
|
1631
|
+
|
|
1632
|
+
- [ ] **Step 16.3: Round-trip test**
|
|
1633
|
+
|
|
1634
|
+
Append to `tests/services/projects.test.js`:
|
|
1635
|
+
|
|
1636
|
+
```js
|
|
1637
|
+
import {createUser, getUserById, deleteUser} from '../../server/services/users.js';
|
|
1638
|
+
|
|
1639
|
+
test('users - meta.project + projects[] round-trip', async () => {
|
|
1640
|
+
const email = 'meta-proj-' + Date.now() + '@test.local';
|
|
1641
|
+
const user = await createUser({
|
|
1642
|
+
name: 'T', email, password: 'longenoughpw',
|
|
1643
|
+
role: 'user', meta: {project: 'members'}, projects: ['members']
|
|
1644
|
+
});
|
|
1645
|
+
try {
|
|
1646
|
+
const fetched = await getUserById(user.id);
|
|
1647
|
+
assert.equal(fetched?.meta?.project, 'members');
|
|
1648
|
+
assert.deepEqual(fetched?.projects, ['members']);
|
|
1649
|
+
} finally {
|
|
1650
|
+
await deleteUser(user.id);
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
```
|
|
1654
|
+
|
|
1655
|
+
- [ ] **Step 16.4: Run the test**
|
|
1656
|
+
|
|
1657
|
+
Run: `node --test tests/services/projects.test.js`
|
|
1658
|
+
|
|
1659
|
+
Expected: pass.
|
|
1660
|
+
|
|
1661
|
+
---
|
|
1662
|
+
|
|
1663
|
+
## Task 17: Page list URL inheritance via `getProjectForPage`
|
|
1664
|
+
|
|
1665
|
+
**Files:**
|
|
1666
|
+
|
|
1667
|
+
- Modify: `server/services/content.js`
|
|
1668
|
+
|
|
1669
|
+
Pages already accept arbitrary frontmatter (parseMarkdown preserves `project:` automatically). For *list* endpoints we need the resolved project (via URL inheritance) attached to each page summary so the admin can filter and the sidebar can group.
|
|
1670
|
+
|
|
1671
|
+
- [ ] **Step 17.1: Decorate page list summaries with resolved project**
|
|
1672
|
+
|
|
1673
|
+
In `server/services/content.js`, find `listPages` (or equivalent - `grep -n "listPages\|listAllPages" server/services/content.js`). After the list is built, decorate each entry:
|
|
1674
|
+
|
|
1675
|
+
```js
|
|
1676
|
+
const {getProjectForPage} = await import('./projects.js');
|
|
1677
|
+
for (const page of pages) {
|
|
1678
|
+
page.resolvedProject = await getProjectForPage(page.url || page.urlPath || '/', page.project);
|
|
1679
|
+
}
|
|
1680
|
+
```
|
|
1681
|
+
|
|
1682
|
+
(`resolvedProject` is a runtime-computed field - not persisted to disk.)
|
|
1683
|
+
|
|
1684
|
+
- [ ] **Step 17.2: Page-resolve test**
|
|
1685
|
+
|
|
1686
|
+
Append to `tests/services/projects.test.js`:
|
|
1687
|
+
|
|
1688
|
+
```js
|
|
1689
|
+
test('listPages decorates each entry with resolvedProject', async () => {
|
|
1690
|
+
// This test is intentionally light - it just asserts the field exists
|
|
1691
|
+
// on the returned shape. URL-inheritance correctness is covered by
|
|
1692
|
+
// getProjectForPage's own tests above.
|
|
1693
|
+
const {listPages} = await import('../../server/services/content.js');
|
|
1694
|
+
const pages = await listPages();
|
|
1695
|
+
if (!pages.length) return;
|
|
1696
|
+
// Every page object should have `resolvedProject` (null or string)
|
|
1697
|
+
for (const p of pages) {
|
|
1698
|
+
assert.ok('resolvedProject' in p);
|
|
1699
|
+
}
|
|
1700
|
+
});
|
|
1701
|
+
```
|
|
1702
|
+
|
|
1703
|
+
- [ ] **Step 17.3: Run the test**
|
|
1704
|
+
|
|
1705
|
+
Run: `node --test tests/services/projects.test.js`
|
|
1706
|
+
|
|
1707
|
+
Expected: pass.
|
|
1708
|
+
|
|
1709
|
+
---
|
|
1710
|
+
|
|
1711
|
+
## Task 18: Scaffolder integration - project block + meta.project stamping
|
|
1712
|
+
|
|
1713
|
+
**Files:**
|
|
1714
|
+
|
|
1715
|
+
- Modify: `server/services/scaffolder.js`
|
|
1716
|
+
- Create: `tests/services/projects-scaffolder.test.js`
|
|
1717
|
+
|
|
1718
|
+
- [ ] **Step 18.1: Write the scaffolder test**
|
|
1719
|
+
|
|
1720
|
+
Create `tests/services/projects-scaffolder.test.js`:
|
|
1721
|
+
|
|
1722
|
+
```js
|
|
1723
|
+
/**
|
|
1724
|
+
* Scaffolder + Projects integration - verify that applying a recipe with
|
|
1725
|
+
* a `project` block creates the project record and stamps meta.project
|
|
1726
|
+
* onto every produced artefact.
|
|
1727
|
+
*/
|
|
1728
|
+
import {test} from 'node:test';
|
|
1729
|
+
import assert from 'node:assert/strict';
|
|
1730
|
+
import fs from 'fs/promises';
|
|
1731
|
+
import path from 'path';
|
|
1732
|
+
import {fileURLToPath} from 'url';
|
|
1733
|
+
import {applyRecipe} from '../../server/services/scaffolder.js';
|
|
1734
|
+
import {getProject, deleteProject} from '../../server/services/projects.js';
|
|
1735
|
+
import {load as loadRoles} from '../../server/services/roles.js';
|
|
1736
|
+
|
|
1737
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
1738
|
+
const RECIPES_DIR = path.join(path.dirname(__filename), '..', '..', 'server', 'services', 'recipes');
|
|
1739
|
+
|
|
1740
|
+
await loadRoles();
|
|
1741
|
+
|
|
1742
|
+
test('applyRecipe with project block creates the project + stamps meta.project on menus', async () => {
|
|
1743
|
+
const ns = 'sproj-' + Date.now();
|
|
1744
|
+
const recipePath = path.join(RECIPES_DIR, 'test-scaff-project.json');
|
|
1745
|
+
const recipe = {
|
|
1746
|
+
slug: 'test-scaff-project',
|
|
1747
|
+
name: 'Test scaff project',
|
|
1748
|
+
options: [{name: 'namespace', default: ns}],
|
|
1749
|
+
project: {
|
|
1750
|
+
name: 'Test Scaff Project',
|
|
1751
|
+
description: 'recipe-created',
|
|
1752
|
+
icon: 'box',
|
|
1753
|
+
rootUrl: '/{{namespace}}'
|
|
1754
|
+
},
|
|
1755
|
+
menus: [
|
|
1756
|
+
{slug: '{{namespace}}-menu', name: 'Test menu', items: [{text: 'X', url: '/x'}]}
|
|
1757
|
+
]
|
|
1758
|
+
};
|
|
1759
|
+
await fs.writeFile(recipePath, JSON.stringify(recipe), 'utf8');
|
|
1760
|
+
try {
|
|
1761
|
+
const result = await applyRecipe('test-scaff-project', {options: {namespace: ns}});
|
|
1762
|
+
// Project was created
|
|
1763
|
+
const proj = await getProject(ns);
|
|
1764
|
+
assert.ok(proj, 'project should exist');
|
|
1765
|
+
assert.equal(proj.name, 'Test Scaff Project');
|
|
1766
|
+
assert.equal(proj.rootUrl, '/' + ns);
|
|
1767
|
+
|
|
1768
|
+
// The menu was created with meta.project = namespace
|
|
1769
|
+
assert.ok(result.created.menus.includes(ns + '-menu'));
|
|
1770
|
+
const {getMenu, deleteMenu} = await import('../../server/services/menus.js');
|
|
1771
|
+
const m = await getMenu(ns + '-menu');
|
|
1772
|
+
assert.equal(m?.meta?.project, ns);
|
|
1773
|
+
await deleteMenu(ns + '-menu');
|
|
1774
|
+
} finally {
|
|
1775
|
+
await deleteProject(ns).catch(() => {});
|
|
1776
|
+
await fs.unlink(recipePath).catch(() => {});
|
|
1777
|
+
}
|
|
1778
|
+
});
|
|
1779
|
+
```
|
|
1780
|
+
|
|
1781
|
+
- [ ] **Step 18.2: Run to confirm failure**
|
|
1782
|
+
|
|
1783
|
+
Run: `node --test tests/services/projects-scaffolder.test.js`
|
|
1784
|
+
|
|
1785
|
+
Expected: fails (project not auto-created; menu not tagged).
|
|
1786
|
+
|
|
1787
|
+
- [ ] **Step 18.3: Implement project auto-create in scaffolder**
|
|
1788
|
+
|
|
1789
|
+
In `server/services/scaffolder.js`:
|
|
1790
|
+
|
|
1791
|
+
- Add imports near the existing imports:
|
|
1792
|
+
|
|
1793
|
+
```js
|
|
1794
|
+
import {getProject, createProject} from './projects.js';
|
|
1795
|
+
```
|
|
1796
|
+
|
|
1797
|
+
- In `applyRecipe`, AFTER the `resolveOptions` call and BEFORE preflight, add a project step:
|
|
1798
|
+
|
|
1799
|
+
```js
|
|
1800
|
+
// Project - auto-create if the recipe declares one and it doesn't exist.
|
|
1801
|
+
let projectSlug = null;
|
|
1802
|
+
if (resolved.project) {
|
|
1803
|
+
projectSlug = tokens.namespace;
|
|
1804
|
+
if (projectSlug && !(await getProject(projectSlug))) {
|
|
1805
|
+
try {
|
|
1806
|
+
await createProject({
|
|
1807
|
+
slug: projectSlug,
|
|
1808
|
+
name: resolved.project.name || projectSlug,
|
|
1809
|
+
description: resolved.project.description || '',
|
|
1810
|
+
icon: resolved.project.icon || 'folder',
|
|
1811
|
+
rootUrl: resolved.project.rootUrl || undefined,
|
|
1812
|
+
sortOrder: resolved.project.sortOrder
|
|
1813
|
+
});
|
|
1814
|
+
} catch (err) {
|
|
1815
|
+
const e = new Error(`Cannot create project "${projectSlug}": ${err.message}`);
|
|
1816
|
+
e.statusCode = 400;
|
|
1817
|
+
throw e;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
```
|
|
1822
|
+
|
|
1823
|
+
- In the menus loop, modify the `createMenuFile` call to include `meta.project`:
|
|
1824
|
+
|
|
1825
|
+
```js
|
|
1826
|
+
await createMenuFile({
|
|
1827
|
+
slug: menu.slug,
|
|
1828
|
+
name: menu.name || menu.slug,
|
|
1829
|
+
// ... existing fields ...
|
|
1830
|
+
meta: {bundled: true, presetOwner: recipeSlug, ...(projectSlug && {project: projectSlug})}
|
|
1831
|
+
});
|
|
1832
|
+
```
|
|
1833
|
+
|
|
1834
|
+
- In the collection creation block (`createCollection({...})`), add `meta: {project: projectSlug}` when `projectSlug` is set.
|
|
1835
|
+
|
|
1836
|
+
- In the form creation block, add `meta: {project: projectSlug}` to the form data passed to `writeForm`.
|
|
1837
|
+
|
|
1838
|
+
- In the action creation loop, add `meta: {project: projectSlug}` to each `createAction` payload.
|
|
1839
|
+
|
|
1840
|
+
- In the role creation loop, add `meta: {project: projectSlug}` to each `createRole` payload.
|
|
1841
|
+
|
|
1842
|
+
- In the user creation loop, add `meta: {project: projectSlug}` AND `projects: [projectSlug]` (so the seeded user can immediately log in and see only their project's artefacts):
|
|
1843
|
+
|
|
1844
|
+
```js
|
|
1845
|
+
await createUser({
|
|
1846
|
+
name: u.name || u.email,
|
|
1847
|
+
// ... existing fields ...
|
|
1848
|
+
...(projectSlug && {meta: {project: projectSlug}, projects: [projectSlug]})
|
|
1849
|
+
});
|
|
1850
|
+
```
|
|
1851
|
+
|
|
1852
|
+
- [ ] **Step 18.4: Run the test**
|
|
1853
|
+
|
|
1854
|
+
Run: `node --test tests/services/projects-scaffolder.test.js`
|
|
1855
|
+
|
|
1856
|
+
Expected: pass.
|
|
1857
|
+
|
|
1858
|
+
---
|
|
1859
|
+
|
|
1860
|
+
## Task 19: Cross-service scope filtering - `canSeeArtefact` integration
|
|
1861
|
+
|
|
1862
|
+
**Files:**
|
|
1863
|
+
|
|
1864
|
+
- Modify: `server/routes/api/menus.js`
|
|
1865
|
+
- Modify: `server/routes/api/collections.js`
|
|
1866
|
+
- Modify: `server/routes/api/forms.js`
|
|
1867
|
+
- Modify: `server/routes/api/actions.js`
|
|
1868
|
+
- Modify: `server/routes/api/blocks.js`
|
|
1869
|
+
- Modify: `server/routes/api/views.js`
|
|
1870
|
+
- Modify: `server/routes/api/users.js`
|
|
1871
|
+
- Create: `tests/services/projects-scope.test.js`
|
|
1872
|
+
|
|
1873
|
+
This task adds the scope filter to every list/get endpoint that returns artefacts. Each route uses the same one-line pattern.
|
|
1874
|
+
|
|
1875
|
+
- [ ] **Step 19.1: Write the cross-service scope test**
|
|
1876
|
+
|
|
1877
|
+
Create `tests/services/projects-scope.test.js`:
|
|
1878
|
+
|
|
1879
|
+
```js
|
|
1880
|
+
/**
|
|
1881
|
+
* Cross-service scope filtering - verify that listMenus, listForms, etc.
|
|
1882
|
+
* are correctly filtered by canSeeArtefact when called via API.
|
|
1883
|
+
* Service-layer functions return everything; route handlers apply the filter.
|
|
1884
|
+
* This test exercises the routes via Fastify .inject.
|
|
1885
|
+
*/
|
|
1886
|
+
import {test} from 'node:test';
|
|
1887
|
+
import assert from 'node:assert/strict';
|
|
1888
|
+
import Fastify from 'fastify';
|
|
1889
|
+
import fastifyJwt from '@fastify/jwt';
|
|
1890
|
+
import {menusRoutes} from '../../server/routes/api/menus.js';
|
|
1891
|
+
import {createMenu, deleteMenu} from '../../server/services/menus.js';
|
|
1892
|
+
import {load as loadRoles} from '../../server/services/roles.js';
|
|
1893
|
+
|
|
1894
|
+
await loadRoles();
|
|
1895
|
+
|
|
1896
|
+
async function buildWithUser(user) {
|
|
1897
|
+
const app = Fastify();
|
|
1898
|
+
app.register(fastifyJwt, {secret: 'test-secret-only-for-tests-32chars'});
|
|
1899
|
+
const authenticate = async (req) => { req.user = user; };
|
|
1900
|
+
const requirePermission = () => async () => {};
|
|
1901
|
+
await app.register(async (inst) => menusRoutes(inst, {authenticate, requirePermission}), {prefix: '/api'});
|
|
1902
|
+
return app;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
test('GET /api/menus filters by user.projects scope', async () => {
|
|
1906
|
+
const taggedSlug = 'scope-tagged-' + Date.now();
|
|
1907
|
+
const untaggedSlug = 'scope-untagged-' + Date.now();
|
|
1908
|
+
await createMenu({slug: taggedSlug, name: 'M', items: [], meta: {project: 'members'}});
|
|
1909
|
+
await createMenu({slug: untaggedSlug, name: 'U', items: []});
|
|
1910
|
+
try {
|
|
1911
|
+
// Super-admin sees both
|
|
1912
|
+
const sa = await buildWithUser({id: 'sa', role: 'super-admin'});
|
|
1913
|
+
const r1 = await sa.inject({method: 'GET', url: '/api/menus'});
|
|
1914
|
+
const slugs1 = r1.json().map(m => m.slug);
|
|
1915
|
+
assert.ok(slugs1.includes(taggedSlug));
|
|
1916
|
+
assert.ok(slugs1.includes(untaggedSlug));
|
|
1917
|
+
await sa.close();
|
|
1918
|
+
|
|
1919
|
+
// Scoped user (not in 'members') sees only the untagged one
|
|
1920
|
+
const scoped = await buildWithUser({id: 'u', role: 'admin', projects: ['intranet']});
|
|
1921
|
+
const r2 = await scoped.inject({method: 'GET', url: '/api/menus'});
|
|
1922
|
+
const slugs2 = r2.json().map(m => m.slug);
|
|
1923
|
+
assert.equal(slugs2.includes(taggedSlug), false);
|
|
1924
|
+
assert.ok(slugs2.includes(untaggedSlug));
|
|
1925
|
+
await scoped.close();
|
|
1926
|
+
|
|
1927
|
+
// Scoped user IN 'members' sees both
|
|
1928
|
+
const member = await buildWithUser({id: 'u', role: 'admin', projects: ['members']});
|
|
1929
|
+
const r3 = await member.inject({method: 'GET', url: '/api/menus'});
|
|
1930
|
+
const slugs3 = r3.json().map(m => m.slug);
|
|
1931
|
+
assert.ok(slugs3.includes(taggedSlug));
|
|
1932
|
+
assert.ok(slugs3.includes(untaggedSlug));
|
|
1933
|
+
await member.close();
|
|
1934
|
+
} finally {
|
|
1935
|
+
await deleteMenu(taggedSlug);
|
|
1936
|
+
await deleteMenu(untaggedSlug);
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
```
|
|
1940
|
+
|
|
1941
|
+
- [ ] **Step 19.2: Run to confirm failure**
|
|
1942
|
+
|
|
1943
|
+
Run: `node --test tests/services/projects-scope.test.js`
|
|
1944
|
+
|
|
1945
|
+
Expected: the scoped-user test fails (they currently see all menus).
|
|
1946
|
+
|
|
1947
|
+
- [ ] **Step 19.3: Add scope filter to menus list endpoint**
|
|
1948
|
+
|
|
1949
|
+
In `server/routes/api/menus.js`, modify the `GET /menus` handler:
|
|
1950
|
+
|
|
1951
|
+
```js
|
|
1952
|
+
fastify.get('/menus', canRead, async (request) => {
|
|
1953
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
1954
|
+
const all = await listMenus();
|
|
1955
|
+
// Service returns metadata only - fetch full record per menu to get meta.project.
|
|
1956
|
+
// For performance, fetch full once per slug.
|
|
1957
|
+
const filtered = [];
|
|
1958
|
+
for (const m of all) {
|
|
1959
|
+
const full = await getMenu(m.slug);
|
|
1960
|
+
if (canSeeArtefact(request.user, full)) filtered.push(m);
|
|
1961
|
+
}
|
|
1962
|
+
return filtered;
|
|
1963
|
+
});
|
|
1964
|
+
```
|
|
1965
|
+
|
|
1966
|
+
Also gate `GET /menus/:slug` (return 403 if not visible):
|
|
1967
|
+
|
|
1968
|
+
```js
|
|
1969
|
+
fastify.get('/menus/:slug', canRead, async (request, reply) => {
|
|
1970
|
+
const menu = await getMenu(request.params.slug);
|
|
1971
|
+
if (!menu) return reply.status(404).send({error: 'Menu not found'});
|
|
1972
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
1973
|
+
if (!canSeeArtefact(request.user, menu)) {
|
|
1974
|
+
return reply.status(403).send({error: 'Access denied for this project'});
|
|
1975
|
+
}
|
|
1976
|
+
return menu;
|
|
1977
|
+
});
|
|
1978
|
+
```
|
|
1979
|
+
|
|
1980
|
+
- [ ] **Step 19.4: Verify menus filter test passes**
|
|
1981
|
+
|
|
1982
|
+
Run: `node --test tests/services/projects-scope.test.js`
|
|
1983
|
+
|
|
1984
|
+
Expected: pass.
|
|
1985
|
+
|
|
1986
|
+
- [ ] **Step 19.5: Apply the same pattern to the other route modules**
|
|
1987
|
+
|
|
1988
|
+
Repeat Step 19.3's edits for each of these route files. Each file's `GET /<resource>` list handler and `GET /<resource>/:slug` get the same `canSeeArtefact` check:
|
|
1989
|
+
|
|
1990
|
+
- `server/routes/api/collections.js` - wrap `listCollections` results; gate `GET /collections/:slug`. Use `getCollection(slug)` to load the full record for filtering.
|
|
1991
|
+
- `server/routes/api/forms.js` - `listForms` + `readForm`
|
|
1992
|
+
- `server/routes/api/actions.js` - `listActions` directly returns full records, so just filter
|
|
1993
|
+
- `server/routes/api/blocks.js` - `listBlocks` + `readBlock`
|
|
1994
|
+
- `server/routes/api/views.js` - `listViews` directly returns full records
|
|
1995
|
+
- `server/routes/api/users.js` - list path needs filtering; `meta.project` on each user
|
|
1996
|
+
|
|
1997
|
+
Each is the same one-import + one-filter pattern.
|
|
1998
|
+
|
|
1999
|
+
- [ ] **Step 19.6: Verify nothing else broke**
|
|
2000
|
+
|
|
2001
|
+
Run: `node --test tests/services/ tests/routes/ 2>&1 | tail -10`
|
|
2002
|
+
|
|
2003
|
+
Expected: all pass.
|
|
2004
|
+
|
|
2005
|
+
---
|
|
2006
|
+
|
|
2007
|
+
## Task 20: Admin api.js client
|
|
2008
|
+
|
|
2009
|
+
**Files:**
|
|
2010
|
+
|
|
2011
|
+
- Modify: `admin/js/api.js`
|
|
2012
|
+
|
|
2013
|
+
- [ ] **Step 20.1: Add projects client**
|
|
2014
|
+
|
|
2015
|
+
In `admin/js/api.js`, alongside the existing resource clients (after `menuLocations`), add:
|
|
2016
|
+
|
|
2017
|
+
```js
|
|
2018
|
+
// Projects
|
|
2019
|
+
projects: {
|
|
2020
|
+
list: () => apiRequest('/projects', {method: 'GET'}),
|
|
2021
|
+
get: (slug) => apiRequest(`/projects/${encodeURIComponent(slug)}`, {method: 'GET'}),
|
|
2022
|
+
create: (project) => apiRequest('/projects', {method: 'POST', body: JSON.stringify(project)}),
|
|
2023
|
+
update: (slug, patch) => apiRequest(`/projects/${encodeURIComponent(slug)}`, {method: 'PUT', body: JSON.stringify(patch)}),
|
|
2024
|
+
remove: (slug) => apiRequest(`/projects/${encodeURIComponent(slug)}`, {method: 'DELETE'}),
|
|
2025
|
+
artefacts: (slug) => apiRequest(`/projects/${encodeURIComponent(slug)}/artefacts`, {method: 'GET'}),
|
|
2026
|
+
untagAll: (slug) => apiRequest(`/projects/${encodeURIComponent(slug)}/untag-all`, {method: 'POST'})
|
|
2027
|
+
},
|
|
2028
|
+
```
|
|
2029
|
+
|
|
2030
|
+
- [ ] **Step 20.2: Verify syntax**
|
|
2031
|
+
|
|
2032
|
+
Run: `node --check admin/js/api.js`
|
|
2033
|
+
|
|
2034
|
+
Expected: silent.
|
|
2035
|
+
|
|
2036
|
+
---
|
|
2037
|
+
|
|
2038
|
+
## Task 21: Sidebar - Projects section with lazy expansion
|
|
2039
|
+
|
|
2040
|
+
**Files:**
|
|
2041
|
+
|
|
2042
|
+
- Modify: `admin/js/config/sidebar-config.js`
|
|
2043
|
+
- Modify: `admin/js/app.js`
|
|
2044
|
+
- Modify: `admin/css/admin.css`
|
|
2045
|
+
|
|
2046
|
+
The sidebar needs to dynamically load the user's visible projects on mount, then render each as an expandable group whose children load lazily on expand.
|
|
2047
|
+
|
|
2048
|
+
- [ ] **Step 21.1: Update sidebar-config.js to accept projects + render the new section**
|
|
2049
|
+
|
|
2050
|
+
In `admin/js/config/sidebar-config.js`:
|
|
2051
|
+
|
|
2052
|
+
- Update the function signature to accept `projects`:
|
|
2053
|
+
|
|
2054
|
+
```js
|
|
2055
|
+
export function getSidebarConfig(permissions = [], counts = {}, pluginItems = [], projects = []) {
|
|
2056
|
+
// ... existing ...
|
|
2057
|
+
```
|
|
2058
|
+
|
|
2059
|
+
- Add a new section AFTER the Overview group (which is currently the first item) and BEFORE the Plugins divider:
|
|
2060
|
+
|
|
2061
|
+
```js
|
|
2062
|
+
// Projects - appears between Overview and Plugins; only when user can see at least one
|
|
2063
|
+
if (canAny('projects') && projects.length) {
|
|
2064
|
+
items.push({heading: 'Projects'});
|
|
2065
|
+
for (const p of projects) {
|
|
2066
|
+
items.push({
|
|
2067
|
+
id: 'project-' + p.slug,
|
|
2068
|
+
text: p.name,
|
|
2069
|
+
icon: p.icon || 'folder',
|
|
2070
|
+
url: '#/projects/' + encodeURIComponent(p.slug),
|
|
2071
|
+
section: '#/projects/' + encodeURIComponent(p.slug)
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
2074
|
+
items.push({divider: true});
|
|
2075
|
+
}
|
|
2076
|
+
```
|
|
2077
|
+
|
|
2078
|
+
Note: For v1, the Projects entries link to the Project detail page (`#/projects/:slug`) rather than rendering an expandable in-sidebar tree of artefact sub-types. Lazy artefact-count expansion is a follow-up. The Project detail page already shows all sub-types as tabs (Task 24), so the user experience is "click project, see everything" - one click instead of two.
|
|
2079
|
+
|
|
2080
|
+
- [ ] **Step 21.2: Update app.js to fetch projects and pass them to the sidebar**
|
|
2081
|
+
|
|
2082
|
+
In `admin/js/app.js`, find `buildSidebar(permissions)` (around line 122). Update its signature and the parallel-fetch block:
|
|
2083
|
+
|
|
2084
|
+
```js
|
|
2085
|
+
async function buildSidebar(permissions) {
|
|
2086
|
+
// ... existing ...
|
|
2087
|
+
const [counts, dbStatus, siteSettings, projects] = await Promise.all([
|
|
2088
|
+
fetchSidebarCounts(),
|
|
2089
|
+
fetchDbStatus(),
|
|
2090
|
+
isAuthenticated() ? api.settings.get().catch(() => ({})) : Promise.resolve({}),
|
|
2091
|
+
permissions.includes('projects') || permissions.includes('projects.read')
|
|
2092
|
+
? api.projects.list().catch(() => [])
|
|
2093
|
+
: Promise.resolve([])
|
|
2094
|
+
]);
|
|
2095
|
+
// ... existing ...
|
|
2096
|
+
```
|
|
2097
|
+
|
|
2098
|
+
Find the `getSidebarConfig(permissions, counts, enrichedPluginItems)` call and update to pass `projects`:
|
|
2099
|
+
|
|
2100
|
+
```js
|
|
2101
|
+
items: getSidebarConfig(permissions, counts, enrichedPluginItems, projects),
|
|
2102
|
+
```
|
|
2103
|
+
|
|
2104
|
+
- [ ] **Step 21.3: Verify syntax**
|
|
2105
|
+
|
|
2106
|
+
Run:
|
|
2107
|
+
```bash
|
|
2108
|
+
node --check admin/js/config/sidebar-config.js
|
|
2109
|
+
node --check admin/js/app.js
|
|
2110
|
+
```
|
|
2111
|
+
|
|
2112
|
+
Expected: silent.
|
|
2113
|
+
|
|
2114
|
+
- [ ] **Step 21.4: Browser smoke test required**
|
|
2115
|
+
|
|
2116
|
+
State explicitly: a real browser load is required to confirm:
|
|
2117
|
+
- The Projects section appears between Overview and Plugins
|
|
2118
|
+
- Each project links to `#/projects/:slug`
|
|
2119
|
+
- Section is suppressed when the user has no `projects.read` permission OR no visible projects
|
|
2120
|
+
|
|
2121
|
+
---
|
|
2122
|
+
|
|
2123
|
+
## Task 22: Admin route table - projects routes + placeholders
|
|
2124
|
+
|
|
2125
|
+
**Files:**
|
|
2126
|
+
|
|
2127
|
+
- Modify: `admin/js/app.js`
|
|
2128
|
+
- Modify: `admin/js/views/index.js`
|
|
2129
|
+
|
|
2130
|
+
- [ ] **Step 22.1: Add the routes**
|
|
2131
|
+
|
|
2132
|
+
In `admin/js/app.js`, find the `coreRoutes` array. Add (alongside other resource routes):
|
|
2133
|
+
|
|
2134
|
+
```js
|
|
2135
|
+
{ path: '/projects', view: 'projects', title: 'Projects - Domma CMS' },
|
|
2136
|
+
{ path: '/projects/new', view: 'projectEditor', title: 'New Project - Domma CMS' },
|
|
2137
|
+
{ path: '/projects/edit/:slug', view: 'projectEditor', title: 'Edit Project - Domma CMS' },
|
|
2138
|
+
{ path: '/projects/:slug', view: 'projectDetail', title: 'Project - Domma CMS' },
|
|
2139
|
+
```
|
|
2140
|
+
|
|
2141
|
+
- [ ] **Step 22.2: Add placeholder views in `admin/js/views/index.js`**
|
|
2142
|
+
|
|
2143
|
+
Add the three new view entries to the `views` object using placeholders that print a console warning:
|
|
2144
|
+
|
|
2145
|
+
```js
|
|
2146
|
+
views.projects = {templateUrl: '', async onMount() { console.warn('[projects] projects view not yet implemented'); }};
|
|
2147
|
+
views.projectEditor = {templateUrl: '', async onMount() { console.warn('[projects] projectEditor view not yet implemented'); }};
|
|
2148
|
+
views.projectDetail = {templateUrl: '', async onMount() { console.warn('[projects] projectDetail view not yet implemented'); }};
|
|
2149
|
+
```
|
|
2150
|
+
|
|
2151
|
+
These are replaced by Tasks 23 + 24.
|
|
2152
|
+
|
|
2153
|
+
- [ ] **Step 22.3: Verify syntax**
|
|
2154
|
+
|
|
2155
|
+
Run:
|
|
2156
|
+
```bash
|
|
2157
|
+
node --check admin/js/app.js
|
|
2158
|
+
node --check admin/js/views/index.js
|
|
2159
|
+
```
|
|
2160
|
+
|
|
2161
|
+
Expected: silent.
|
|
2162
|
+
|
|
2163
|
+
---
|
|
2164
|
+
|
|
2165
|
+
## Task 23: Projects list view + editor
|
|
2166
|
+
|
|
2167
|
+
**Files:**
|
|
2168
|
+
|
|
2169
|
+
- Create: `admin/js/views/projects.js`
|
|
2170
|
+
- Create: `admin/js/views/project-editor.js`
|
|
2171
|
+
- Create: `admin/js/templates/projects.html`
|
|
2172
|
+
- Create: `admin/js/templates/project-editor.html`
|
|
2173
|
+
- Modify: `admin/js/views/index.js`
|
|
2174
|
+
|
|
2175
|
+
- [ ] **Step 23.1: Create the list template**
|
|
2176
|
+
|
|
2177
|
+
Create `admin/js/templates/projects.html`:
|
|
2178
|
+
|
|
2179
|
+
```html
|
|
2180
|
+
<div class="page-header">
|
|
2181
|
+
<h2><span data-icon="folder"></span> Projects</h2>
|
|
2182
|
+
<div class="header-actions">
|
|
2183
|
+
<a class="btn btn-primary" href="#/projects/new"><span data-icon="plus"></span> New project</a>
|
|
2184
|
+
</div>
|
|
2185
|
+
</div>
|
|
2186
|
+
<p class="text-muted">Group related artefacts under a project for navigation and per-user access scoping.</p>
|
|
2187
|
+
<div id="projects-table"></div>
|
|
2188
|
+
```
|
|
2189
|
+
|
|
2190
|
+
- [ ] **Step 23.2: Create the list view**
|
|
2191
|
+
|
|
2192
|
+
Create `admin/js/views/projects.js`:
|
|
2193
|
+
|
|
2194
|
+
```js
|
|
2195
|
+
import {api} from '../api.js';
|
|
2196
|
+
|
|
2197
|
+
function esc(s) {
|
|
2198
|
+
return String(s == null ? '' : s)
|
|
2199
|
+
.replace(/&/g, '&').replace(/</g, '<')
|
|
2200
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
export const projectsView = {
|
|
2204
|
+
templateUrl: '/admin/js/templates/projects.html',
|
|
2205
|
+
async onMount($container) {
|
|
2206
|
+
const projects = await api.projects.list().catch(() => []);
|
|
2207
|
+
|
|
2208
|
+
// Fetch artefact counts in parallel for the badge
|
|
2209
|
+
const counts = await Promise.all(projects.map(p =>
|
|
2210
|
+
api.projects.artefacts(p.slug)
|
|
2211
|
+
.then(g => Object.values(g).reduce((sum, arr) => sum + arr.length, 0))
|
|
2212
|
+
.catch(() => 0)
|
|
2213
|
+
));
|
|
2214
|
+
const rows = projects.map((p, i) => ({...p, artefactCount: counts[i]}));
|
|
2215
|
+
|
|
2216
|
+
T.create('#projects-table', {
|
|
2217
|
+
data: rows,
|
|
2218
|
+
emptyMessage: 'No projects yet. Click "New project" to create one.',
|
|
2219
|
+
columns: [
|
|
2220
|
+
{key: 'slug', title: 'Slug', sortable: true},
|
|
2221
|
+
{key: 'name', title: 'Name', sortable: true},
|
|
2222
|
+
{key: 'icon', title: 'Icon', render: r => r.icon ? `<span data-icon="${esc(r.icon)}"></span>` : ''},
|
|
2223
|
+
{key: 'rootUrl', title: 'Root URL', render: r => r.rootUrl ? `<code>${esc(r.rootUrl)}</code>` : '<span class="text-muted">-</span>'},
|
|
2224
|
+
{key: 'artefactCount', title: 'Artefacts', render: r => `<span class="badge badge-info">${r.artefactCount}</span>`},
|
|
2225
|
+
{key: '_actions', title: 'Actions', render: r => {
|
|
2226
|
+
const empty = r.artefactCount === 0;
|
|
2227
|
+
const delTip = empty ? '' : 'data-tooltip="Untag artefacts first or use the project detail page"';
|
|
2228
|
+
const delDisabled = empty ? '' : 'disabled';
|
|
2229
|
+
return `
|
|
2230
|
+
<a class="btn btn-sm btn-ghost" href="#/projects/${encodeURIComponent(r.slug)}"><span data-icon="eye"></span></a>
|
|
2231
|
+
<a class="btn btn-sm btn-ghost" href="#/projects/edit/${encodeURIComponent(r.slug)}"><span data-icon="edit"></span></a>
|
|
2232
|
+
<button class="btn btn-sm btn-danger btn-delete-project" data-slug="${esc(r.slug)}" ${delDisabled} ${delTip}><span data-icon="trash"></span></button>
|
|
2233
|
+
`;
|
|
2234
|
+
}}
|
|
2235
|
+
]
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
Domma.icons.scan('#projects-table');
|
|
2239
|
+
document.querySelectorAll('#projects-table [data-tooltip]').forEach(el => {
|
|
2240
|
+
E.tooltip(el, {content: el.getAttribute('data-tooltip'), position: 'top'});
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
$container.off('click', '.btn-delete-project').on('click', '.btn-delete-project', async function() {
|
|
2244
|
+
if (this.disabled) return;
|
|
2245
|
+
const slug = $(this).data('slug');
|
|
2246
|
+
if (!await E.confirm(`Delete project "${slug}"? This cannot be undone.`)) return;
|
|
2247
|
+
try {
|
|
2248
|
+
await api.projects.remove(slug);
|
|
2249
|
+
E.toast('Project deleted.', {type: 'success'});
|
|
2250
|
+
location.reload();
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
E.toast(`Delete failed: ${err.message || err}`, {type: 'error'});
|
|
2253
|
+
}
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
};
|
|
2257
|
+
```
|
|
2258
|
+
|
|
2259
|
+
- [ ] **Step 23.3: Create the editor template**
|
|
2260
|
+
|
|
2261
|
+
Create `admin/js/templates/project-editor.html`:
|
|
2262
|
+
|
|
2263
|
+
```html
|
|
2264
|
+
<div class="page-header">
|
|
2265
|
+
<h2><span data-icon="folder"></span> <span id="pe-title">Edit project</span></h2>
|
|
2266
|
+
<div class="header-actions">
|
|
2267
|
+
<a class="btn btn-secondary" href="#/projects"><span data-icon="arrow-left"></span> Back</a>
|
|
2268
|
+
<button class="btn btn-primary" id="pe-save"><span data-icon="check"></span> Save</button>
|
|
2269
|
+
</div>
|
|
2270
|
+
</div>
|
|
2271
|
+
<div id="pe-form"></div>
|
|
2272
|
+
```
|
|
2273
|
+
|
|
2274
|
+
- [ ] **Step 23.4: Create the editor view**
|
|
2275
|
+
|
|
2276
|
+
Create `admin/js/views/project-editor.js`:
|
|
2277
|
+
|
|
2278
|
+
```js
|
|
2279
|
+
import {api} from '../api.js';
|
|
2280
|
+
|
|
2281
|
+
function readForm(el, keys) {
|
|
2282
|
+
const out = {};
|
|
2283
|
+
for (const k of keys) {
|
|
2284
|
+
const input = el.querySelector(`[name="${k}"]`);
|
|
2285
|
+
if (!input) continue;
|
|
2286
|
+
out[k] = input.type === 'checkbox' ? input.checked : input.value;
|
|
2287
|
+
}
|
|
2288
|
+
return out;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
export const projectEditorView = {
|
|
2292
|
+
templateUrl: '/admin/js/templates/project-editor.html',
|
|
2293
|
+
async onMount($container) {
|
|
2294
|
+
const editMatch = location.hash.match(/^#\/projects\/edit\/(.+)$/);
|
|
2295
|
+
const slug = editMatch ? decodeURIComponent(editMatch[1]) : null;
|
|
2296
|
+
const isNew = !slug;
|
|
2297
|
+
|
|
2298
|
+
let project = {slug: '', name: '', description: '', icon: 'folder', rootUrl: '', sortOrder: 0};
|
|
2299
|
+
if (!isNew) {
|
|
2300
|
+
try {
|
|
2301
|
+
project = await api.projects.get(slug);
|
|
2302
|
+
} catch (err) {
|
|
2303
|
+
E.toast(`Failed to load project: ${err.message || err}`, {type: 'error'});
|
|
2304
|
+
location.hash = '#/projects';
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
$container.find('#pe-title').text(isNew ? 'New project' : `Edit "${project.slug}"`);
|
|
2310
|
+
|
|
2311
|
+
const blueprint = {
|
|
2312
|
+
slug: {label: 'Slug', type: 'text', required: true, disabled: !isNew, hint: 'Lowercase alphanumeric + hyphen; immutable after create'},
|
|
2313
|
+
name: {label: 'Name', type: 'text', required: true},
|
|
2314
|
+
description: {label: 'Description', type: 'textarea'},
|
|
2315
|
+
icon: {label: 'Icon', type: 'text', hint: 'Domma icon name; defaults to "folder"'},
|
|
2316
|
+
rootUrl: {label: 'Root URL', type: 'text', hint: 'URL prefix for auto-inheritance (e.g. "/members"); must start with /'},
|
|
2317
|
+
sortOrder: {label: 'Sort order', type: 'number', hint: 'Lower = higher in the sidebar'}
|
|
2318
|
+
};
|
|
2319
|
+
const values = {
|
|
2320
|
+
slug: project.slug || '',
|
|
2321
|
+
name: project.name || '',
|
|
2322
|
+
description: project.description || '',
|
|
2323
|
+
icon: project.icon || 'folder',
|
|
2324
|
+
rootUrl: project.rootUrl || '',
|
|
2325
|
+
sortOrder: project.sortOrder ?? 0
|
|
2326
|
+
};
|
|
2327
|
+
|
|
2328
|
+
const formEl = $container.find('#pe-form').get(0);
|
|
2329
|
+
F.create(blueprint, values, {showSubmitButton: false}).renderTo(formEl);
|
|
2330
|
+
|
|
2331
|
+
$container.find('#pe-save').on('click', async () => {
|
|
2332
|
+
const data = readForm(formEl, ['slug', 'name', 'description', 'icon', 'rootUrl', 'sortOrder']);
|
|
2333
|
+
data.sortOrder = Number.parseInt(data.sortOrder, 10) || 0;
|
|
2334
|
+
if (!data.rootUrl) delete data.rootUrl;
|
|
2335
|
+
|
|
2336
|
+
try {
|
|
2337
|
+
if (isNew) {
|
|
2338
|
+
await api.projects.create(data);
|
|
2339
|
+
} else {
|
|
2340
|
+
// Slug is immutable; drop it from the patch.
|
|
2341
|
+
const {slug: _drop, ...patch} = data;
|
|
2342
|
+
await api.projects.update(slug, patch);
|
|
2343
|
+
}
|
|
2344
|
+
E.toast('Saved.', {type: 'success'});
|
|
2345
|
+
location.hash = '#/projects';
|
|
2346
|
+
} catch (err) {
|
|
2347
|
+
E.toast(`Save failed: ${err.message || err}`, {type: 'error'});
|
|
2348
|
+
}
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
};
|
|
2352
|
+
```
|
|
2353
|
+
|
|
2354
|
+
- [ ] **Step 23.5: Wire into views/index.js**
|
|
2355
|
+
|
|
2356
|
+
Replace the placeholders in `admin/js/views/index.js`:
|
|
2357
|
+
|
|
2358
|
+
```js
|
|
2359
|
+
views.projects = (await import('./projects.js')).projectsView;
|
|
2360
|
+
views.projectEditor = (await import('./project-editor.js')).projectEditorView;
|
|
2361
|
+
```
|
|
2362
|
+
|
|
2363
|
+
(Or use direct imports if that's the file's pattern. Match what menus did.)
|
|
2364
|
+
|
|
2365
|
+
- [ ] **Step 23.6: Verify syntax**
|
|
2366
|
+
|
|
2367
|
+
Run:
|
|
2368
|
+
```bash
|
|
2369
|
+
node --check admin/js/views/projects.js
|
|
2370
|
+
node --check admin/js/views/project-editor.js
|
|
2371
|
+
```
|
|
2372
|
+
|
|
2373
|
+
Expected: silent.
|
|
2374
|
+
|
|
2375
|
+
- [ ] **Step 23.7: Browser smoke test required**
|
|
2376
|
+
|
|
2377
|
+
State explicitly: real browser load required for `#/projects`, `#/projects/new`, `#/projects/edit/:slug`.
|
|
2378
|
+
|
|
2379
|
+
---
|
|
2380
|
+
|
|
2381
|
+
## Task 24: Project detail view with tabbed artefacts
|
|
2382
|
+
|
|
2383
|
+
**Files:**
|
|
2384
|
+
|
|
2385
|
+
- Create: `admin/js/views/project-detail.js`
|
|
2386
|
+
- Create: `admin/js/templates/project-detail.html`
|
|
2387
|
+
- Modify: `admin/js/views/index.js`
|
|
2388
|
+
|
|
2389
|
+
- [ ] **Step 24.1: Template**
|
|
2390
|
+
|
|
2391
|
+
Create `admin/js/templates/project-detail.html`:
|
|
2392
|
+
|
|
2393
|
+
```html
|
|
2394
|
+
<div class="page-header">
|
|
2395
|
+
<h2><span id="pd-icon" data-icon="folder"></span> <span id="pd-name">Project</span></h2>
|
|
2396
|
+
<div class="header-actions">
|
|
2397
|
+
<a class="btn btn-secondary" href="#/projects"><span data-icon="arrow-left"></span> Back</a>
|
|
2398
|
+
<a class="btn btn-secondary" id="pd-edit"><span data-icon="edit"></span> Edit</a>
|
|
2399
|
+
</div>
|
|
2400
|
+
</div>
|
|
2401
|
+
<p id="pd-desc" class="text-muted"></p>
|
|
2402
|
+
|
|
2403
|
+
<div class="tabs" id="pd-tabs">
|
|
2404
|
+
<div class="tab-list">
|
|
2405
|
+
<button class="tab-item active" data-tab="pd-overview">Overview</button>
|
|
2406
|
+
<button class="tab-item" data-tab="pd-pages">Pages</button>
|
|
2407
|
+
<button class="tab-item" data-tab="pd-collections">Collections</button>
|
|
2408
|
+
<button class="tab-item" data-tab="pd-forms">Forms</button>
|
|
2409
|
+
<button class="tab-item" data-tab="pd-actions">Actions</button>
|
|
2410
|
+
<button class="tab-item" data-tab="pd-menus">Menus</button>
|
|
2411
|
+
<button class="tab-item" data-tab="pd-blocks">Blocks</button>
|
|
2412
|
+
<button class="tab-item" data-tab="pd-views">Views</button>
|
|
2413
|
+
<button class="tab-item" data-tab="pd-roles">Roles</button>
|
|
2414
|
+
<button class="tab-item" data-tab="pd-users">Users</button>
|
|
2415
|
+
<button class="tab-item" data-tab="pd-settings">Settings</button>
|
|
2416
|
+
</div>
|
|
2417
|
+
<div class="tab-content">
|
|
2418
|
+
<div class="tab-panel active" id="pd-overview" data-panel="pd-overview"><div id="pd-overview-body"></div></div>
|
|
2419
|
+
<div class="tab-panel" id="pd-pages" data-panel="pd-pages"><div id="pd-pages-list"></div></div>
|
|
2420
|
+
<div class="tab-panel" id="pd-collections" data-panel="pd-collections"><div id="pd-collections-list"></div></div>
|
|
2421
|
+
<div class="tab-panel" id="pd-forms" data-panel="pd-forms"><div id="pd-forms-list"></div></div>
|
|
2422
|
+
<div class="tab-panel" id="pd-actions" data-panel="pd-actions"><div id="pd-actions-list"></div></div>
|
|
2423
|
+
<div class="tab-panel" id="pd-menus" data-panel="pd-menus"><div id="pd-menus-list"></div></div>
|
|
2424
|
+
<div class="tab-panel" id="pd-blocks" data-panel="pd-blocks"><div id="pd-blocks-list"></div></div>
|
|
2425
|
+
<div class="tab-panel" id="pd-views" data-panel="pd-views"><div id="pd-views-list"></div></div>
|
|
2426
|
+
<div class="tab-panel" id="pd-roles" data-panel="pd-roles"><div id="pd-roles-list"></div></div>
|
|
2427
|
+
<div class="tab-panel" id="pd-users" data-panel="pd-users"><div id="pd-users-list"></div></div>
|
|
2428
|
+
<div class="tab-panel" id="pd-settings" data-panel="pd-settings"><div id="pd-settings-body"></div></div>
|
|
2429
|
+
</div>
|
|
2430
|
+
</div>
|
|
2431
|
+
```
|
|
2432
|
+
|
|
2433
|
+
- [ ] **Step 24.2: View**
|
|
2434
|
+
|
|
2435
|
+
Create `admin/js/views/project-detail.js`:
|
|
2436
|
+
|
|
2437
|
+
```js
|
|
2438
|
+
import {api} from '../api.js';
|
|
2439
|
+
|
|
2440
|
+
function esc(s) {
|
|
2441
|
+
return String(s == null ? '' : s)
|
|
2442
|
+
.replace(/&/g, '&').replace(/</g, '<')
|
|
2443
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
function renderArtefactList(containerSelector, items, columns) {
|
|
2447
|
+
if (!items.length) {
|
|
2448
|
+
$(containerSelector).html('<p class="text-muted">No items in this project.</p>');
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
T.create(containerSelector, {data: items, columns, emptyMessage: 'None'});
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
export const projectDetailView = {
|
|
2455
|
+
templateUrl: '/admin/js/templates/project-detail.html',
|
|
2456
|
+
async onMount($container) {
|
|
2457
|
+
const m = location.hash.match(/^#\/projects\/([^/]+)$/);
|
|
2458
|
+
const slug = m ? decodeURIComponent(m[1]) : null;
|
|
2459
|
+
if (!slug) { location.hash = '#/projects'; return; }
|
|
2460
|
+
|
|
2461
|
+
const project = await api.projects.get(slug).catch(() => null);
|
|
2462
|
+
if (!project) {
|
|
2463
|
+
E.toast('Project not found.', {type: 'error'});
|
|
2464
|
+
location.hash = '#/projects';
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
$container.find('#pd-name').text(project.name);
|
|
2469
|
+
$container.find('#pd-icon').attr('data-icon', project.icon || 'folder');
|
|
2470
|
+
$container.find('#pd-desc').text(project.description || '');
|
|
2471
|
+
$container.find('#pd-edit').attr('href', '#/projects/edit/' + encodeURIComponent(slug));
|
|
2472
|
+
Domma.icons.scan($container.find('.page-header').get(0));
|
|
2473
|
+
|
|
2474
|
+
E.tabs($container.find('#pd-tabs').get(0));
|
|
2475
|
+
|
|
2476
|
+
const grouped = await api.projects.artefacts(slug).catch(() => ({}));
|
|
2477
|
+
|
|
2478
|
+
// Overview - counts grid
|
|
2479
|
+
const overviewBody = $container.find('#pd-overview-body');
|
|
2480
|
+
overviewBody.html(`
|
|
2481
|
+
<div class="grid grid-cols-3" style="gap:1rem;">
|
|
2482
|
+
${Object.entries(grouped).map(([type, arr]) =>
|
|
2483
|
+
`<div class="card"><div class="card-body"><strong>${type}</strong>: ${arr.length}</div></div>`
|
|
2484
|
+
).join('')}
|
|
2485
|
+
</div>
|
|
2486
|
+
`);
|
|
2487
|
+
|
|
2488
|
+
// Per-type lists - simple slug+name tables with edit links
|
|
2489
|
+
renderArtefactList('#pd-pages-list', grouped.pages || [], [
|
|
2490
|
+
{key: 'url', title: 'URL'},
|
|
2491
|
+
{key: 'title', title: 'Title'}
|
|
2492
|
+
]);
|
|
2493
|
+
renderArtefactList('#pd-collections-list', grouped.collections || [], [
|
|
2494
|
+
{key: 'slug', title: 'Slug'},
|
|
2495
|
+
{key: 'title', title: 'Title'}
|
|
2496
|
+
]);
|
|
2497
|
+
renderArtefactList('#pd-forms-list', grouped.forms || [], [
|
|
2498
|
+
{key: 'slug', title: 'Slug'},
|
|
2499
|
+
{key: 'title', title: 'Title'}
|
|
2500
|
+
]);
|
|
2501
|
+
renderArtefactList('#pd-actions-list', grouped.actions || [], [
|
|
2502
|
+
{key: 'slug', title: 'Slug'},
|
|
2503
|
+
{key: 'title', title: 'Title'}
|
|
2504
|
+
]);
|
|
2505
|
+
renderArtefactList('#pd-menus-list', grouped.menus || [], [
|
|
2506
|
+
{key: 'slug', title: 'Slug'},
|
|
2507
|
+
{key: 'name', title: 'Name'}
|
|
2508
|
+
]);
|
|
2509
|
+
renderArtefactList('#pd-blocks-list', grouped.blocks || [], [
|
|
2510
|
+
{key: 'slug', title: 'Slug'},
|
|
2511
|
+
{key: 'name', title: 'Name'}
|
|
2512
|
+
]);
|
|
2513
|
+
renderArtefactList('#pd-views-list', grouped.views || [], [
|
|
2514
|
+
{key: 'slug', title: 'Slug'},
|
|
2515
|
+
{key: 'name', title: 'Name'}
|
|
2516
|
+
]);
|
|
2517
|
+
renderArtefactList('#pd-roles-list', grouped.roles || [], [
|
|
2518
|
+
{key: 'name', title: 'Name'},
|
|
2519
|
+
{key: 'label', title: 'Label'}
|
|
2520
|
+
]);
|
|
2521
|
+
renderArtefactList('#pd-users-list', grouped.users || [], [
|
|
2522
|
+
{key: 'email', title: 'Email'},
|
|
2523
|
+
{key: 'name', title: 'Name'}
|
|
2524
|
+
]);
|
|
2525
|
+
|
|
2526
|
+
// Settings - reuse the editor form + danger zone
|
|
2527
|
+
$container.find('#pd-settings-body').html(`
|
|
2528
|
+
<div class="card"><div class="card-body">
|
|
2529
|
+
<h3>Settings</h3>
|
|
2530
|
+
<p>Edit project details on the <a href="#/projects/edit/${encodeURIComponent(slug)}">Edit page</a>.</p>
|
|
2531
|
+
</div></div>
|
|
2532
|
+
<div class="card" style="margin-top:1rem;border-color:#c33;"><div class="card-body">
|
|
2533
|
+
<h3>Danger zone</h3>
|
|
2534
|
+
<p>Untagging removes <code>meta.project</code> from every artefact currently tagged with "${esc(slug)}". The artefacts remain (un-tagged); the project record is unchanged.</p>
|
|
2535
|
+
<button class="btn btn-warning" id="pd-untag-all"><span data-icon="tag"></span> Untag all artefacts</button>
|
|
2536
|
+
<p style="margin-top:0.5rem;">Deleting the project is only possible when no artefacts are tagged.</p>
|
|
2537
|
+
<button class="btn btn-danger" id="pd-delete"><span data-icon="trash"></span> Delete project</button>
|
|
2538
|
+
</div></div>
|
|
2539
|
+
`);
|
|
2540
|
+
Domma.icons.scan('#pd-settings-body');
|
|
2541
|
+
|
|
2542
|
+
$container.find('#pd-untag-all').on('click', async () => {
|
|
2543
|
+
if (!await E.confirm(`Untag all artefacts from project "${slug}"? They will become site-wide.`)) return;
|
|
2544
|
+
try {
|
|
2545
|
+
const res = await api.projects.untagAll(slug);
|
|
2546
|
+
E.toast(`Untagged: ${JSON.stringify(res.untagged)}`, {type: 'success'});
|
|
2547
|
+
setTimeout(() => location.reload(), 600);
|
|
2548
|
+
} catch (err) {
|
|
2549
|
+
E.toast(`Failed: ${err.message || err}`, {type: 'error'});
|
|
2550
|
+
}
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
$container.find('#pd-delete').on('click', async () => {
|
|
2554
|
+
if (!await E.confirm(`Delete project "${slug}"? This refuses if any artefacts are still tagged.`)) return;
|
|
2555
|
+
try {
|
|
2556
|
+
await api.projects.remove(slug);
|
|
2557
|
+
E.toast('Project deleted.', {type: 'success'});
|
|
2558
|
+
location.hash = '#/projects';
|
|
2559
|
+
} catch (err) {
|
|
2560
|
+
E.toast(`Delete failed: ${err.message || err}`, {type: 'error'});
|
|
2561
|
+
}
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2564
|
+
};
|
|
2565
|
+
```
|
|
2566
|
+
|
|
2567
|
+
- [ ] **Step 24.3: Wire into views/index.js**
|
|
2568
|
+
|
|
2569
|
+
Replace the placeholder:
|
|
2570
|
+
|
|
2571
|
+
```js
|
|
2572
|
+
views.projectDetail = (await import('./project-detail.js')).projectDetailView;
|
|
2573
|
+
```
|
|
2574
|
+
|
|
2575
|
+
- [ ] **Step 24.4: Verify syntax**
|
|
2576
|
+
|
|
2577
|
+
Run: `node --check admin/js/views/project-detail.js`
|
|
2578
|
+
|
|
2579
|
+
Expected: silent.
|
|
2580
|
+
|
|
2581
|
+
- [ ] **Step 24.5: Browser smoke test required**
|
|
2582
|
+
|
|
2583
|
+
Real browser test for the tabbed view + danger zone actions.
|
|
2584
|
+
|
|
2585
|
+
---
|
|
2586
|
+
|
|
2587
|
+
## Task 25: Project field in artefact editors (menus, collections, forms, blocks, views, actions, roles)
|
|
2588
|
+
|
|
2589
|
+
**Files:**
|
|
2590
|
+
|
|
2591
|
+
- Modify: `admin/js/views/menu-editor.js`
|
|
2592
|
+
- Modify: `admin/js/views/collection-editor.js`
|
|
2593
|
+
- Modify: `admin/js/views/form-editor.js`
|
|
2594
|
+
- Modify: `admin/js/views/block-editor.js`
|
|
2595
|
+
- Modify: `admin/js/views/view-editor.js`
|
|
2596
|
+
- Modify: `admin/js/views/action-editor.js`
|
|
2597
|
+
- Modify: `admin/js/views/role-editor.js`
|
|
2598
|
+
|
|
2599
|
+
The same one-field addition repeats across seven editors. Each gets a "Project" select in its Meta tab (or main form) that sets `meta.project` on save.
|
|
2600
|
+
|
|
2601
|
+
- [ ] **Step 25.1: Add Project select to menu-editor.js**
|
|
2602
|
+
|
|
2603
|
+
In `admin/js/views/menu-editor.js`, find the Meta tab's F.create blueprint. Add `project` to the blueprint AFTER `description`:
|
|
2604
|
+
|
|
2605
|
+
```js
|
|
2606
|
+
project: {label: 'Project', type: 'select', options: [], hint: 'Group this menu under a project'},
|
|
2607
|
+
```
|
|
2608
|
+
|
|
2609
|
+
Before `F.create(...).renderTo(metaEl)`, fetch projects + populate the options:
|
|
2610
|
+
|
|
2611
|
+
```js
|
|
2612
|
+
const projects = await api.projects.list().catch(() => []);
|
|
2613
|
+
metaBlueprint.project.options = [{value: '', label: '- none -'}].concat(
|
|
2614
|
+
projects.map(p => ({value: p.slug, label: p.name}))
|
|
2615
|
+
);
|
|
2616
|
+
metaValues.project = menu.meta?.project || '';
|
|
2617
|
+
```
|
|
2618
|
+
|
|
2619
|
+
In the save handler, read the project value and stamp into `meta.project`:
|
|
2620
|
+
|
|
2621
|
+
```js
|
|
2622
|
+
const metaFormVals = readForm(metaEl, ['slug', 'name', 'description', 'project']);
|
|
2623
|
+
next.meta = {
|
|
2624
|
+
...(menu.meta || {}),
|
|
2625
|
+
...(metaFormVals.project ? {project: metaFormVals.project} : {project: null})
|
|
2626
|
+
};
|
|
2627
|
+
// (Existing per-field assignment for slug/name/description still applies.)
|
|
2628
|
+
```
|
|
2629
|
+
|
|
2630
|
+
- [ ] **Step 25.2: Apply the same pattern to the other six editors**
|
|
2631
|
+
|
|
2632
|
+
Repeat Step 25.1's three-edit pattern in:
|
|
2633
|
+
- `admin/js/views/collection-editor.js`
|
|
2634
|
+
- `admin/js/views/form-editor.js`
|
|
2635
|
+
- `admin/js/views/block-editor.js`
|
|
2636
|
+
- `admin/js/views/view-editor.js`
|
|
2637
|
+
- `admin/js/views/action-editor.js`
|
|
2638
|
+
- `admin/js/views/role-editor.js`
|
|
2639
|
+
|
|
2640
|
+
Each editor:
|
|
2641
|
+
1. Fetch projects via `api.projects.list().catch(() => [])`
|
|
2642
|
+
2. Add a `project` field to the F.create blueprint (or however that editor builds its form)
|
|
2643
|
+
3. Populate options + value
|
|
2644
|
+
4. On save, set `data.meta = {...(existing.meta || {}), project: <value or null>}`
|
|
2645
|
+
|
|
2646
|
+
If an editor doesn't have a Meta tab structure, add the Project select inline next to other settings.
|
|
2647
|
+
|
|
2648
|
+
- [ ] **Step 25.3: Verify syntax across all seven**
|
|
2649
|
+
|
|
2650
|
+
Run:
|
|
2651
|
+
```bash
|
|
2652
|
+
for f in admin/js/views/menu-editor.js admin/js/views/collection-editor.js admin/js/views/form-editor.js admin/js/views/block-editor.js admin/js/views/view-editor.js admin/js/views/action-editor.js admin/js/views/role-editor.js; do node --check "$f"; done
|
|
2653
|
+
```
|
|
2654
|
+
|
|
2655
|
+
Expected: silent for all seven.
|
|
2656
|
+
|
|
2657
|
+
- [ ] **Step 25.4: Browser smoke test required**
|
|
2658
|
+
|
|
2659
|
+
Real browser load - open each editor and verify the Project select appears, defaults correctly, and saves the chosen value.
|
|
2660
|
+
|
|
2661
|
+
---
|
|
2662
|
+
|
|
2663
|
+
## Task 26: Page editor - Project select with URL-inheritance pre-fill
|
|
2664
|
+
|
|
2665
|
+
**Files:**
|
|
2666
|
+
|
|
2667
|
+
- Modify: `admin/js/views/page-editor.js`
|
|
2668
|
+
|
|
2669
|
+
Pages are special because URL inheritance applies. The select has just `(none)` + project list - no `(auto)`. The pre-selected value is whatever `getProjectForPage(url, frontmatter.project)` resolves to.
|
|
2670
|
+
|
|
2671
|
+
- [ ] **Step 26.1: Add the Project select**
|
|
2672
|
+
|
|
2673
|
+
In `admin/js/views/page-editor.js`, find the form blueprint or fields list. Add a `project` field in the same area as other frontmatter fields (visibility, status, etc.).
|
|
2674
|
+
|
|
2675
|
+
```js
|
|
2676
|
+
project: {label: 'Project', type: 'select', options: []}
|
|
2677
|
+
```
|
|
2678
|
+
|
|
2679
|
+
Fetch projects and populate options:
|
|
2680
|
+
|
|
2681
|
+
```js
|
|
2682
|
+
const projects = await api.projects.list().catch(() => []);
|
|
2683
|
+
// Pre-selected value: existing frontmatter.project OR URL-inheritance resolution
|
|
2684
|
+
let preProject = page.project ?? null;
|
|
2685
|
+
if (preProject === undefined && page.url) {
|
|
2686
|
+
// URL-inheritance resolution client-side
|
|
2687
|
+
let bestSlug = '';
|
|
2688
|
+
let bestLen = -1;
|
|
2689
|
+
for (const p of projects) {
|
|
2690
|
+
if (!p.rootUrl) continue;
|
|
2691
|
+
if (page.url === p.rootUrl || page.url.startsWith(p.rootUrl + '/')) {
|
|
2692
|
+
if (p.rootUrl.length > bestLen) { bestSlug = p.slug; bestLen = p.rootUrl.length; }
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
preProject = bestSlug;
|
|
2696
|
+
}
|
|
2697
|
+
blueprint.project.options = [{value: '', label: '- none -'}].concat(
|
|
2698
|
+
projects.map(p => ({value: p.slug, label: p.name}))
|
|
2699
|
+
);
|
|
2700
|
+
values.project = preProject || '';
|
|
2701
|
+
```
|
|
2702
|
+
|
|
2703
|
+
On save, write the explicit `project:` frontmatter:
|
|
2704
|
+
|
|
2705
|
+
```js
|
|
2706
|
+
if (formVals.project) {
|
|
2707
|
+
payload.project = formVals.project;
|
|
2708
|
+
} else {
|
|
2709
|
+
// Empty selection - explicit null to opt out of URL inheritance
|
|
2710
|
+
payload.project = null;
|
|
2711
|
+
}
|
|
2712
|
+
```
|
|
2713
|
+
|
|
2714
|
+
- [ ] **Step 26.2: Verify syntax**
|
|
2715
|
+
|
|
2716
|
+
Run: `node --check admin/js/views/page-editor.js`
|
|
2717
|
+
|
|
2718
|
+
Expected: silent.
|
|
2719
|
+
|
|
2720
|
+
- [ ] **Step 26.3: Browser smoke test required**
|
|
2721
|
+
|
|
2722
|
+
Real browser test - open a page editor for a page under a project's `rootUrl`, verify the Project select is pre-selected.
|
|
2723
|
+
|
|
2724
|
+
---
|
|
2725
|
+
|
|
2726
|
+
## Task 27: User editor - `projects: []` + `meta.project`
|
|
2727
|
+
|
|
2728
|
+
**Files:**
|
|
2729
|
+
|
|
2730
|
+
- Modify: `admin/js/views/user-editor.js`
|
|
2731
|
+
|
|
2732
|
+
- [ ] **Step 27.1: Add the two new fields**
|
|
2733
|
+
|
|
2734
|
+
In `admin/js/views/user-editor.js`, locate the form blueprint. Add:
|
|
2735
|
+
|
|
2736
|
+
```js
|
|
2737
|
+
metaProject: {label: 'Owned by project', type: 'select', options: [], hint: 'Where this user appears in the sidebar'},
|
|
2738
|
+
projects: {label: 'Project access', type: 'multiselect', options: [], hint: 'Leave empty for no restriction'}
|
|
2739
|
+
```
|
|
2740
|
+
|
|
2741
|
+
Fetch projects and populate both:
|
|
2742
|
+
|
|
2743
|
+
```js
|
|
2744
|
+
const projects = await api.projects.list().catch(() => []);
|
|
2745
|
+
blueprint.metaProject.options = [{value: '', label: '- none -'}].concat(
|
|
2746
|
+
projects.map(p => ({value: p.slug, label: p.name}))
|
|
2747
|
+
);
|
|
2748
|
+
blueprint.projects.options = projects.map(p => ({value: p.slug, label: p.name}));
|
|
2749
|
+
values.metaProject = user.meta?.project || '';
|
|
2750
|
+
values.projects = Array.isArray(user.projects) ? user.projects : [];
|
|
2751
|
+
```
|
|
2752
|
+
|
|
2753
|
+
On save:
|
|
2754
|
+
|
|
2755
|
+
```js
|
|
2756
|
+
payload.meta = {
|
|
2757
|
+
...(user.meta || {}),
|
|
2758
|
+
...(formVals.metaProject ? {project: formVals.metaProject} : {project: null})
|
|
2759
|
+
};
|
|
2760
|
+
// multiselect returns an array directly; if it's a single string, normalise
|
|
2761
|
+
payload.projects = Array.isArray(formVals.projects) ? formVals.projects : [formVals.projects].filter(Boolean);
|
|
2762
|
+
```
|
|
2763
|
+
|
|
2764
|
+
If Domma's `F.create` doesn't have a native `multiselect` type, use a comma-separated `text` field with a hint and parse on save. Verify by reading what `multiselect` accepts in this codebase (`grep -n "type: 'multiselect'" admin/js/views/`).
|
|
2765
|
+
|
|
2766
|
+
- [ ] **Step 27.2: Verify syntax**
|
|
2767
|
+
|
|
2768
|
+
Run: `node --check admin/js/views/user-editor.js`
|
|
2769
|
+
|
|
2770
|
+
Expected: silent.
|
|
2771
|
+
|
|
2772
|
+
- [ ] **Step 27.3: Browser smoke test required**
|
|
2773
|
+
|
|
2774
|
+
Real browser test - open a user editor, verify both Project fields work.
|
|
2775
|
+
|
|
2776
|
+
---
|
|
2777
|
+
|
|
2778
|
+
## Task 28: Documentation updates
|
|
2779
|
+
|
|
2780
|
+
**Files:**
|
|
2781
|
+
|
|
2782
|
+
- Modify: `docs/configuration.md`
|
|
2783
|
+
- Modify: `docs/scaffolding.md`
|
|
2784
|
+
- Modify: `docs/api-reference.md`
|
|
2785
|
+
- Modify: `CLAUDE.md`
|
|
2786
|
+
|
|
2787
|
+
- [ ] **Step 28.1: Update `docs/configuration.md`**
|
|
2788
|
+
|
|
2789
|
+
Add a new top-level section `## Projects` after the existing Menus section. Document:
|
|
2790
|
+
|
|
2791
|
+
- The `content/collections/projects/` storage (entry shape from the spec)
|
|
2792
|
+
- The `meta.project` field on each of the nine in-scope artefact types
|
|
2793
|
+
- The `rootUrl` URL-inheritance rule
|
|
2794
|
+
- The user `projects: []` access-scope field + `meta.project` ownership distinction
|
|
2795
|
+
- That permissions don't auto-grant - admin must enable `projects.read` for existing roles (same gotcha as Menus)
|
|
2796
|
+
|
|
2797
|
+
- [ ] **Step 28.2: Update `docs/scaffolding.md`**
|
|
2798
|
+
|
|
2799
|
+
Add a section on the recipe `project: {name, description, icon, rootUrl}` block. Document that:
|
|
2800
|
+
- `tokens.namespace` becomes the project slug
|
|
2801
|
+
- The project is created if missing on apply (idempotent)
|
|
2802
|
+
- Every produced artefact gets `meta.project` stamped
|
|
2803
|
+
- Seeded users additionally get `projects: ['<namespace>']` on their access scope
|
|
2804
|
+
|
|
2805
|
+
- [ ] **Step 28.3: Update `docs/api-reference.md`**
|
|
2806
|
+
|
|
2807
|
+
Add the `/api/projects/*` endpoints with request/response shapes from the spec.
|
|
2808
|
+
|
|
2809
|
+
- [ ] **Step 28.4: Update project `CLAUDE.md`**
|
|
2810
|
+
|
|
2811
|
+
Append a `## Projects` section summarising:
|
|
2812
|
+
- Data model: preset collection at `content/collections/projects/`, `meta.project` tag on nine artefact types
|
|
2813
|
+
- URL inheritance rule + the three-way frontmatter semantics (`project: null` opts out; `project: '<slug>'` explicit; missing field falls through to URL match)
|
|
2814
|
+
- `user.projects: []` access scope semantics + super-admin bypass
|
|
2815
|
+
- Cross-cutting nature (touches 9 services)
|
|
2816
|
+
|
|
2817
|
+
---
|
|
2818
|
+
|
|
2819
|
+
## Task 29: Final verification + handoff
|
|
2820
|
+
|
|
2821
|
+
**Files:** none (verification only)
|
|
2822
|
+
|
|
2823
|
+
- [ ] **Step 29.1: Run the full feature test suite**
|
|
2824
|
+
|
|
2825
|
+
Run:
|
|
2826
|
+
```bash
|
|
2827
|
+
node --test tests/services/projects.test.js \
|
|
2828
|
+
tests/services/projects-scope.test.js \
|
|
2829
|
+
tests/services/projects-scaffolder.test.js \
|
|
2830
|
+
tests/routes/projects.test.js \
|
|
2831
|
+
tests/services/menus.test.js \
|
|
2832
|
+
tests/routes/menus.test.js \
|
|
2833
|
+
tests/services/scaffolder.test.js \
|
|
2834
|
+
tests/services/visibility.test.js
|
|
2835
|
+
```
|
|
2836
|
+
|
|
2837
|
+
Expected: all pass.
|
|
2838
|
+
|
|
2839
|
+
- [ ] **Step 29.2: Curl smoke test on the running server**
|
|
2840
|
+
|
|
2841
|
+
Server must have been restarted after Tasks 8 + 18. The user controls restarts.
|
|
2842
|
+
|
|
2843
|
+
```bash
|
|
2844
|
+
curl -s -o /dev/null -w "GET /api/projects: %{http_code}\n" http://localhost:4096/api/projects
|
|
2845
|
+
```
|
|
2846
|
+
|
|
2847
|
+
Expected: 401 (auth-gated, route registered).
|
|
2848
|
+
|
|
2849
|
+
- [ ] **Step 29.3: Live browser walkthrough**
|
|
2850
|
+
|
|
2851
|
+
Hard-refresh the admin SPA. Walk through:
|
|
2852
|
+
|
|
2853
|
+
1. After granting `projects` permission to your role (Users → Roles → super-admin → add `projects`), the Projects sidebar section appears (empty until you create one).
|
|
2854
|
+
2. `#/projects/new` → create a project (e.g. slug=`demo`, rootUrl=`/demo`).
|
|
2855
|
+
3. Sidebar refreshes → Projects section shows `demo`.
|
|
2856
|
+
4. Open the Menu editor for `main`; set Project = `demo`; save.
|
|
2857
|
+
5. `#/projects/demo` → the Menus tab lists `main`; Overview shows counts.
|
|
2858
|
+
6. User editor → set `projects: ['demo']` on a non-super-admin user; log in as them; they only see `demo` artefacts.
|
|
2859
|
+
7. Scaffolder: apply a recipe (Onboarding or any) - if it has a `project` block in the recipe JSON, the project auto-creates.
|
|
2860
|
+
8. Settings → Untag all → confirm the menu becomes untagged again.
|
|
2861
|
+
9. Delete the project - succeeds when no tagged artefacts remain.
|
|
2862
|
+
|
|
2863
|
+
Per memory `feedback_no_verified_without_browser.md`, surface this as a real limit if you cannot drive the browser yourself.
|
|
2864
|
+
|
|
2865
|
+
- [ ] **Step 29.4: Hand off**
|
|
2866
|
+
|
|
2867
|
+
Do NOT commit. Summarise the changes and let the user commit. Suggested commit message:
|
|
2868
|
+
|
|
2869
|
+
> Projects feature - grouping artefacts under named projects with sidebar navigation, per-user permission scoping (user.projects[]), URL inheritance for pages, and scaffolder integration (recipe project block auto-creates + stamps meta.project across 9 artefact types).
|