@sienklogic/plan-build-run 2.32.0 → 2.33.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 +14 -0
- package/dashboard/src/index.tsx +8 -2
- package/package.json +2 -2
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/scripts/enforce-pbr-workflow.js +19 -3
- package/dashboard/src/app.js +0 -91
- package/dashboard/src/middleware/current-phase.js +0 -25
- package/dashboard/src/middleware/errorHandler.js +0 -62
- package/dashboard/src/middleware/notFoundHandler.js +0 -9
- package/dashboard/src/routes/events.routes.js +0 -94
- package/dashboard/src/routes/index.routes.js +0 -35
- package/dashboard/src/routes/pages.routes.js +0 -853
- package/dashboard/src/views/analytics.ejs +0 -5
- package/dashboard/src/views/audit-detail.ejs +0 -5
- package/dashboard/src/views/audits.ejs +0 -5
- package/dashboard/src/views/config.ejs +0 -5
- package/dashboard/src/views/dependencies.ejs +0 -5
- package/dashboard/src/views/error.ejs +0 -20
- package/dashboard/src/views/index.ejs +0 -5
- package/dashboard/src/views/logs.ejs +0 -3
- package/dashboard/src/views/milestone-detail.ejs +0 -5
- package/dashboard/src/views/milestones.ejs +0 -5
- package/dashboard/src/views/note-detail.ejs +0 -3
- package/dashboard/src/views/notes.ejs +0 -5
- package/dashboard/src/views/partials/activity-feed.ejs +0 -27
- package/dashboard/src/views/partials/analytics-content.ejs +0 -241
- package/dashboard/src/views/partials/audit-detail-content.ejs +0 -14
- package/dashboard/src/views/partials/audits-content.ejs +0 -36
- package/dashboard/src/views/partials/breadcrumbs.ejs +0 -18
- package/dashboard/src/views/partials/config-content.ejs +0 -219
- package/dashboard/src/views/partials/dashboard-content.ejs +0 -124
- package/dashboard/src/views/partials/dependencies-content.ejs +0 -50
- package/dashboard/src/views/partials/empty-state.ejs +0 -12
- package/dashboard/src/views/partials/footer.ejs +0 -9
- package/dashboard/src/views/partials/head.ejs +0 -31
- package/dashboard/src/views/partials/header.ejs +0 -18
- package/dashboard/src/views/partials/layout-bottom.ejs +0 -8
- package/dashboard/src/views/partials/layout-top.ejs +0 -17
- package/dashboard/src/views/partials/log-entries-content.ejs +0 -17
- package/dashboard/src/views/partials/logs-content.ejs +0 -131
- package/dashboard/src/views/partials/milestone-detail-content.ejs +0 -20
- package/dashboard/src/views/partials/milestones-content.ejs +0 -127
- package/dashboard/src/views/partials/note-detail-content.ejs +0 -24
- package/dashboard/src/views/partials/notes-content.ejs +0 -28
- package/dashboard/src/views/partials/phase-content.ejs +0 -226
- package/dashboard/src/views/partials/phase-doc-content.ejs +0 -36
- package/dashboard/src/views/partials/phase-timeline.ejs +0 -27
- package/dashboard/src/views/partials/phases-content.ejs +0 -137
- package/dashboard/src/views/partials/quick-content.ejs +0 -42
- package/dashboard/src/views/partials/quick-detail-content.ejs +0 -30
- package/dashboard/src/views/partials/requirements-content.ejs +0 -44
- package/dashboard/src/views/partials/research-content.ejs +0 -56
- package/dashboard/src/views/partials/research-detail-content.ejs +0 -25
- package/dashboard/src/views/partials/roadmap-content.ejs +0 -197
- package/dashboard/src/views/partials/sidebar.ejs +0 -98
- package/dashboard/src/views/partials/todo-create-content.ejs +0 -59
- package/dashboard/src/views/partials/todo-detail-content.ejs +0 -43
- package/dashboard/src/views/partials/todos-content.ejs +0 -110
- package/dashboard/src/views/partials/todos-done-content.ejs +0 -46
- package/dashboard/src/views/phase-detail.ejs +0 -5
- package/dashboard/src/views/phase-doc.ejs +0 -5
- package/dashboard/src/views/phases.ejs +0 -5
- package/dashboard/src/views/quick-detail.ejs +0 -5
- package/dashboard/src/views/quick.ejs +0 -5
- package/dashboard/src/views/requirements.ejs +0 -3
- package/dashboard/src/views/research-detail.ejs +0 -3
- package/dashboard/src/views/research.ejs +0 -3
- package/dashboard/src/views/roadmap.ejs +0 -5
- package/dashboard/src/views/todo-create.ejs +0 -5
- package/dashboard/src/views/todo-detail.ejs +0 -5
- package/dashboard/src/views/todos-done.ejs +0 -3
- package/dashboard/src/views/todos.ejs +0 -5
|
@@ -1,853 +0,0 @@
|
|
|
1
|
-
import { Router } from 'express';
|
|
2
|
-
import { getPhaseDetail, getPhaseDocument } from '../services/phase.service.js';
|
|
3
|
-
import { getRoadmapData, generateDependencyMermaid } from '../services/roadmap.service.js';
|
|
4
|
-
import { parseStateFile, derivePhaseStatuses } from '../services/dashboard.service.js';
|
|
5
|
-
import { listPendingTodos, getTodoDetail, createTodo, completeTodo, listDoneTodos } from '../services/todo.service.js';
|
|
6
|
-
import { getAllMilestones, getMilestoneDetail } from '../services/milestone.service.js';
|
|
7
|
-
import { getProjectAnalytics } from '../services/analytics.service.js';
|
|
8
|
-
import { getLlmMetrics } from '../services/local-llm-metrics.service.js';
|
|
9
|
-
import { listNotes, getNoteBySlug } from '../services/notes.service.js';
|
|
10
|
-
import { listResearchDocs, listCodebaseDocs, getResearchDocBySlug } from '../services/research.service.js';
|
|
11
|
-
import { listQuickTasks, getQuickTask } from '../services/quick.service.js';
|
|
12
|
-
import { listAuditReports, getAuditReport } from '../services/audit.service.js';
|
|
13
|
-
import { readConfig, writeConfig } from '../services/config.service.js';
|
|
14
|
-
import { getRequirementsData } from '../services/requirements.service.js';
|
|
15
|
-
import { listLogFiles, readLogPage } from '../services/log.service.js';
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Merge flat HTML form fields back into a nested config object.
|
|
19
|
-
* Form field names use dot-notation: "features.autoVerify", "models.default".
|
|
20
|
-
* Boolean checkboxes arrive as "on"/"off" or are absent when unchecked.
|
|
21
|
-
* @param {object} existing - current config object from disk
|
|
22
|
-
* @param {object} form - req.body from express.urlencoded
|
|
23
|
-
* @returns {object}
|
|
24
|
-
*/
|
|
25
|
-
function mergeFormIntoConfig(existing, form) {
|
|
26
|
-
const result = JSON.parse(JSON.stringify(existing)); // deep clone
|
|
27
|
-
for (const [key, value] of Object.entries(form)) {
|
|
28
|
-
const parts = key.split('.');
|
|
29
|
-
let target = result;
|
|
30
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
31
|
-
if (target[parts[i]] == null || typeof target[parts[i]] !== 'object') {
|
|
32
|
-
target[parts[i]] = {};
|
|
33
|
-
}
|
|
34
|
-
target = target[parts[i]];
|
|
35
|
-
}
|
|
36
|
-
const leaf = parts[parts.length - 1];
|
|
37
|
-
// Coerce booleans: checkboxes send "on", absent means false
|
|
38
|
-
if (typeof existing?.[parts[0]]?.[leaf] === 'boolean' || (parts.length === 2 && typeof (existing?.[parts[0]] ?? {})[leaf] === 'boolean')) {
|
|
39
|
-
target[leaf] = value === 'on' || value === 'true';
|
|
40
|
-
} else if (typeof target[leaf] === 'number') {
|
|
41
|
-
target[leaf] = Number(value);
|
|
42
|
-
} else {
|
|
43
|
-
target[leaf] = value;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
// Uncheck all feature booleans not present in form (unchecked checkboxes are absent)
|
|
47
|
-
if (result.features && typeof result.features === 'object') {
|
|
48
|
-
for (const k of Object.keys(result.features)) {
|
|
49
|
-
if (typeof result.features[k] === 'boolean' && !(`features.${k}` in form)) {
|
|
50
|
-
result.features[k] = false;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
if (result.gates && typeof result.gates === 'object') {
|
|
55
|
-
for (const k of Object.keys(result.gates)) {
|
|
56
|
-
if (typeof result.gates[k] === 'boolean' && !(`gates.${k}` in form)) {
|
|
57
|
-
result.gates[k] = false;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
if (result.safety && typeof result.safety === 'object') {
|
|
62
|
-
for (const k of Object.keys(result.safety)) {
|
|
63
|
-
if (typeof result.safety[k] === 'boolean' && !(`safety.${k}` in form)) {
|
|
64
|
-
result.safety[k] = false;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return result;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const router = Router();
|
|
72
|
-
|
|
73
|
-
router.get('/phases', async (req, res) => {
|
|
74
|
-
const projectDir = req.app.locals.projectDir;
|
|
75
|
-
const [roadmapData, stateData] = await Promise.all([
|
|
76
|
-
getRoadmapData(projectDir),
|
|
77
|
-
parseStateFile(projectDir)
|
|
78
|
-
]);
|
|
79
|
-
|
|
80
|
-
const templateData = {
|
|
81
|
-
title: 'Phases',
|
|
82
|
-
activePage: 'phases',
|
|
83
|
-
currentPath: '/phases',
|
|
84
|
-
phases: derivePhaseStatuses(roadmapData.phases, stateData.currentPhase),
|
|
85
|
-
milestones: roadmapData.milestones,
|
|
86
|
-
breadcrumbs: [{ label: 'Phases' }]
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
res.setHeader('Vary', 'HX-Request');
|
|
90
|
-
|
|
91
|
-
if (req.get('HX-Request') === 'true') {
|
|
92
|
-
res.render('partials/phases-content', templateData);
|
|
93
|
-
} else {
|
|
94
|
-
res.render('phases', templateData);
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
router.get('/phases/:phaseId', async (req, res) => {
|
|
99
|
-
const { phaseId } = req.params;
|
|
100
|
-
|
|
101
|
-
// Validate phaseId: two digits, optionally followed by decimal (e.g., 01, 05, 3.1)
|
|
102
|
-
if (!/^\d{1,2}(\.\d+)?$/.test(phaseId)) {
|
|
103
|
-
const err = new Error('Phase ID must be a number (e.g., 01, 05, 3.1)');
|
|
104
|
-
err.status = 404;
|
|
105
|
-
throw err;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const projectDir = req.app.locals.projectDir;
|
|
109
|
-
const [phaseData, roadmapData] = await Promise.all([
|
|
110
|
-
getPhaseDetail(projectDir, phaseId),
|
|
111
|
-
getRoadmapData(projectDir)
|
|
112
|
-
]);
|
|
113
|
-
|
|
114
|
-
const phaseIdNum = parseInt(phaseId, 10);
|
|
115
|
-
const allPhases = roadmapData.phases || [];
|
|
116
|
-
const currentIdx = allPhases.findIndex(p => String(p.id) === String(phaseIdNum));
|
|
117
|
-
const prevPhase = currentIdx > 0 ? allPhases[currentIdx - 1] : null;
|
|
118
|
-
const nextPhase = currentIdx >= 0 && currentIdx < allPhases.length - 1
|
|
119
|
-
? allPhases[currentIdx + 1]
|
|
120
|
-
: null;
|
|
121
|
-
|
|
122
|
-
const templateData = {
|
|
123
|
-
title: `Phase ${phaseId}: ${phaseData.phaseName}`,
|
|
124
|
-
activePage: 'phases',
|
|
125
|
-
currentPath: '/phases/' + phaseId,
|
|
126
|
-
breadcrumbs: [{ label: 'Phases', url: '/phases' }, { label: 'Phase ' + phaseId }],
|
|
127
|
-
prevPhase,
|
|
128
|
-
nextPhase,
|
|
129
|
-
...phaseData
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
res.setHeader('Vary', 'HX-Request');
|
|
133
|
-
|
|
134
|
-
if (req.get('HX-Request') === 'true') {
|
|
135
|
-
res.render('partials/phase-content', templateData);
|
|
136
|
-
} else {
|
|
137
|
-
res.render('phase-detail', templateData);
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
router.get('/phases/:phaseId/:planId/:docType', async (req, res) => {
|
|
142
|
-
const { phaseId, planId, docType } = req.params;
|
|
143
|
-
|
|
144
|
-
// Validate phaseId
|
|
145
|
-
if (!/^\d{1,2}(\.\d+)?$/.test(phaseId)) {
|
|
146
|
-
const err = new Error('Phase ID must be a number (e.g., 01, 05, 3.1)');
|
|
147
|
-
err.status = 404;
|
|
148
|
-
throw err;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Validate planId: NN-NN format
|
|
152
|
-
if (!/^\d{2}-\d{2}$/.test(planId)) {
|
|
153
|
-
const err = new Error('Plan ID must be in NN-NN format (e.g., 04-01)');
|
|
154
|
-
err.status = 404;
|
|
155
|
-
throw err;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Validate docType
|
|
159
|
-
if (docType !== 'plan' && docType !== 'summary' && docType !== 'verification') {
|
|
160
|
-
const err = new Error('Document type must be "plan", "summary", or "verification"');
|
|
161
|
-
err.status = 404;
|
|
162
|
-
throw err;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const projectDir = req.app.locals.projectDir;
|
|
166
|
-
const doc = await getPhaseDocument(projectDir, phaseId, planId, docType);
|
|
167
|
-
|
|
168
|
-
if (!doc) {
|
|
169
|
-
const labels = { plan: 'Plan', summary: 'Summary', verification: 'Verification' };
|
|
170
|
-
const err = new Error(`${labels[docType] || docType} ${planId} not found for phase ${phaseId}`);
|
|
171
|
-
err.status = 404;
|
|
172
|
-
throw err;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const docLabel = docType === 'plan' ? 'Plan' : docType === 'verification' ? 'Verification' : 'Summary';
|
|
176
|
-
const templateData = {
|
|
177
|
-
title: `${docLabel} ${planId} — Phase ${phaseId}: ${doc.phaseName}`,
|
|
178
|
-
activePage: 'phases',
|
|
179
|
-
currentPath: `/phases/${phaseId}/${planId}/${docType}`,
|
|
180
|
-
breadcrumbs: [{ label: 'Phases', url: '/phases' }, { label: 'Phase ' + phaseId, url: '/phases/' + phaseId }, { label: docLabel + ' ' + planId }],
|
|
181
|
-
...doc
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
res.setHeader('Vary', 'HX-Request');
|
|
185
|
-
|
|
186
|
-
if (req.get('HX-Request') === 'true') {
|
|
187
|
-
res.render('partials/phase-doc-content', templateData);
|
|
188
|
-
} else {
|
|
189
|
-
res.render('phase-doc', templateData);
|
|
190
|
-
}
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
router.get('/todos', async (req, res) => {
|
|
194
|
-
const projectDir = req.app.locals.projectDir;
|
|
195
|
-
const { priority, status, q } = req.query;
|
|
196
|
-
const filters = {};
|
|
197
|
-
if (priority) filters.priority = priority;
|
|
198
|
-
if (status) filters.status = status;
|
|
199
|
-
if (q) filters.q = q;
|
|
200
|
-
const todos = await listPendingTodos(projectDir, filters);
|
|
201
|
-
|
|
202
|
-
const templateData = {
|
|
203
|
-
title: 'Todos',
|
|
204
|
-
activePage: 'todos',
|
|
205
|
-
currentPath: '/todos',
|
|
206
|
-
breadcrumbs: [{ label: 'Todos' }],
|
|
207
|
-
todos,
|
|
208
|
-
filters: { priority: priority || '', status: status || '', q: q || '' }
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
res.setHeader('Vary', 'HX-Request');
|
|
212
|
-
|
|
213
|
-
if (req.get('HX-Request') === 'true') {
|
|
214
|
-
res.render('partials/todos-content', templateData);
|
|
215
|
-
} else {
|
|
216
|
-
res.render('todos', templateData);
|
|
217
|
-
}
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
router.post('/todos/bulk-complete', async (req, res) => {
|
|
221
|
-
const projectDir = req.app.locals.projectDir;
|
|
222
|
-
const { priority, status, q } = req.query;
|
|
223
|
-
const filters = {};
|
|
224
|
-
if (priority) filters.priority = priority;
|
|
225
|
-
if (status) filters.status = status;
|
|
226
|
-
if (q) filters.q = q;
|
|
227
|
-
|
|
228
|
-
const todos = await listPendingTodos(projectDir, filters);
|
|
229
|
-
for (const todo of todos) {
|
|
230
|
-
await completeTodo(projectDir, todo.id);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
if (req.get('HX-Request') === 'true') {
|
|
234
|
-
const remaining = await listPendingTodos(projectDir);
|
|
235
|
-
res.render('partials/todos-content', {
|
|
236
|
-
title: 'Todos',
|
|
237
|
-
activePage: 'todos',
|
|
238
|
-
currentPath: '/todos',
|
|
239
|
-
breadcrumbs: [{ label: 'Todos' }],
|
|
240
|
-
todos: remaining,
|
|
241
|
-
filters: { priority: '', status: '', q: '' }
|
|
242
|
-
});
|
|
243
|
-
} else {
|
|
244
|
-
res.redirect('/todos');
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
router.get('/todos/new', (req, res) => {
|
|
249
|
-
const templateData = {
|
|
250
|
-
title: 'Create Todo',
|
|
251
|
-
activePage: 'todos',
|
|
252
|
-
currentPath: '/todos/new',
|
|
253
|
-
breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Create' }]
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
res.setHeader('Vary', 'HX-Request');
|
|
257
|
-
|
|
258
|
-
if (req.get('HX-Request') === 'true') {
|
|
259
|
-
res.render('partials/todo-create-content', templateData);
|
|
260
|
-
} else {
|
|
261
|
-
res.render('todo-create', templateData);
|
|
262
|
-
}
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
router.get('/todos/done', async (req, res) => {
|
|
266
|
-
const projectDir = req.app.locals.projectDir;
|
|
267
|
-
const todos = await listDoneTodos(projectDir);
|
|
268
|
-
|
|
269
|
-
const templateData = {
|
|
270
|
-
title: 'Completed Todos',
|
|
271
|
-
activePage: 'todos',
|
|
272
|
-
currentPath: '/todos/done',
|
|
273
|
-
breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Completed' }],
|
|
274
|
-
todos
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
res.setHeader('Vary', 'HX-Request');
|
|
278
|
-
|
|
279
|
-
if (req.get('HX-Request') === 'true') {
|
|
280
|
-
res.render('partials/todos-done-content', templateData);
|
|
281
|
-
} else {
|
|
282
|
-
res.render('todos-done', templateData);
|
|
283
|
-
}
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
router.get('/todos/:id', async (req, res) => {
|
|
287
|
-
const { id } = req.params;
|
|
288
|
-
|
|
289
|
-
// Validate ID format: must be exactly three digits
|
|
290
|
-
if (!/^\d{3}$/.test(id)) {
|
|
291
|
-
const err = new Error('Todo ID must be a three-digit number (e.g., 001, 005, 042)');
|
|
292
|
-
err.status = 404;
|
|
293
|
-
throw err;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const projectDir = req.app.locals.projectDir;
|
|
297
|
-
const todo = await getTodoDetail(projectDir, id);
|
|
298
|
-
|
|
299
|
-
const templateData = {
|
|
300
|
-
title: `Todo ${todo.id}: ${todo.title}`,
|
|
301
|
-
activePage: 'todos',
|
|
302
|
-
currentPath: '/todos/' + id,
|
|
303
|
-
breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Todo ' + id }],
|
|
304
|
-
...todo
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
res.setHeader('Vary', 'HX-Request');
|
|
308
|
-
|
|
309
|
-
if (req.get('HX-Request') === 'true') {
|
|
310
|
-
res.render('partials/todo-detail-content', templateData);
|
|
311
|
-
} else {
|
|
312
|
-
res.render('todo-detail', templateData);
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
router.post('/todos', async (req, res) => {
|
|
317
|
-
const { title, priority, phase, description } = req.body;
|
|
318
|
-
const projectDir = req.app.locals.projectDir;
|
|
319
|
-
|
|
320
|
-
const todoId = await createTodo(projectDir, {
|
|
321
|
-
title,
|
|
322
|
-
priority,
|
|
323
|
-
phase: phase || '',
|
|
324
|
-
description
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
if (req.get('HX-Request') === 'true') {
|
|
328
|
-
// For HTMX: fetch the new todo and render its detail as a fragment
|
|
329
|
-
const todo = await getTodoDetail(projectDir, todoId);
|
|
330
|
-
res.render('partials/todo-detail-content', {
|
|
331
|
-
title: `Todo ${todo.id}: ${todo.title}`,
|
|
332
|
-
activePage: 'todos',
|
|
333
|
-
currentPath: '/todos/' + todoId,
|
|
334
|
-
breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Todo ' + todoId }],
|
|
335
|
-
...todo
|
|
336
|
-
});
|
|
337
|
-
} else {
|
|
338
|
-
res.redirect(`/todos/${todoId}`);
|
|
339
|
-
}
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
router.post('/todos/:id/done', async (req, res) => {
|
|
343
|
-
const { id } = req.params;
|
|
344
|
-
|
|
345
|
-
if (!/^\d{3}$/.test(id)) {
|
|
346
|
-
const err = new Error('Todo ID must be a three-digit number');
|
|
347
|
-
err.status = 404;
|
|
348
|
-
throw err;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const projectDir = req.app.locals.projectDir;
|
|
352
|
-
await completeTodo(projectDir, id);
|
|
353
|
-
|
|
354
|
-
if (req.get('HX-Request') === 'true') {
|
|
355
|
-
// For HTMX: re-render the full todo list as a fragment
|
|
356
|
-
const todos = await listPendingTodos(projectDir);
|
|
357
|
-
res.render('partials/todos-content', {
|
|
358
|
-
title: 'Todos',
|
|
359
|
-
activePage: 'todos',
|
|
360
|
-
currentPath: '/todos',
|
|
361
|
-
breadcrumbs: [{ label: 'Todos' }],
|
|
362
|
-
todos
|
|
363
|
-
});
|
|
364
|
-
} else {
|
|
365
|
-
res.redirect('/todos');
|
|
366
|
-
}
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
router.get('/milestones', async (req, res) => {
|
|
370
|
-
const projectDir = req.app.locals.projectDir;
|
|
371
|
-
const [milestoneData, roadmapData, stateData] = await Promise.all([
|
|
372
|
-
getAllMilestones(projectDir),
|
|
373
|
-
getRoadmapData(projectDir),
|
|
374
|
-
parseStateFile(projectDir)
|
|
375
|
-
]);
|
|
376
|
-
|
|
377
|
-
const phases = derivePhaseStatuses(roadmapData.phases, stateData.currentPhase);
|
|
378
|
-
|
|
379
|
-
const templateData = {
|
|
380
|
-
title: 'Milestones',
|
|
381
|
-
activePage: 'milestones',
|
|
382
|
-
currentPath: '/milestones',
|
|
383
|
-
breadcrumbs: [{ label: 'Milestones' }],
|
|
384
|
-
phases,
|
|
385
|
-
...milestoneData
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
res.setHeader('Vary', 'HX-Request');
|
|
389
|
-
|
|
390
|
-
if (req.get('HX-Request') === 'true') {
|
|
391
|
-
res.render('partials/milestones-content', templateData);
|
|
392
|
-
} else {
|
|
393
|
-
res.render('milestones', templateData);
|
|
394
|
-
}
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
router.get('/milestones/:version', async (req, res) => {
|
|
398
|
-
const { version } = req.params;
|
|
399
|
-
|
|
400
|
-
// Validate version: alphanumeric with dots and dashes
|
|
401
|
-
if (!/^[\w.-]+$/.test(version)) {
|
|
402
|
-
const err = new Error('Invalid milestone version format');
|
|
403
|
-
err.status = 404;
|
|
404
|
-
throw err;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
const projectDir = req.app.locals.projectDir;
|
|
408
|
-
const detail = await getMilestoneDetail(projectDir, version);
|
|
409
|
-
|
|
410
|
-
if (detail.sections.length === 0) {
|
|
411
|
-
const err = new Error(`No archived files found for milestone v${version}`);
|
|
412
|
-
err.status = 404;
|
|
413
|
-
throw err;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const templateData = {
|
|
417
|
-
title: `Milestone v${version}`,
|
|
418
|
-
activePage: 'milestones',
|
|
419
|
-
currentPath: '/milestones/' + version,
|
|
420
|
-
breadcrumbs: [{ label: 'Milestones', url: '/milestones' }, { label: 'v' + version }],
|
|
421
|
-
...detail
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
res.setHeader('Vary', 'HX-Request');
|
|
425
|
-
|
|
426
|
-
if (req.get('HX-Request') === 'true') {
|
|
427
|
-
res.render('partials/milestone-detail-content', templateData);
|
|
428
|
-
} else {
|
|
429
|
-
res.render('milestone-detail', templateData);
|
|
430
|
-
}
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
router.get('/dependencies', async (req, res) => {
|
|
434
|
-
const projectDir = req.app.locals.projectDir;
|
|
435
|
-
const mermaidCode = await generateDependencyMermaid(projectDir);
|
|
436
|
-
|
|
437
|
-
const templateData = {
|
|
438
|
-
title: 'Dependencies',
|
|
439
|
-
activePage: 'dependencies',
|
|
440
|
-
currentPath: '/dependencies',
|
|
441
|
-
breadcrumbs: [{ label: 'Dependencies' }],
|
|
442
|
-
mermaidCode
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
res.setHeader('Vary', 'HX-Request');
|
|
446
|
-
|
|
447
|
-
if (req.get('HX-Request') === 'true') {
|
|
448
|
-
res.render('partials/dependencies-content', templateData);
|
|
449
|
-
} else {
|
|
450
|
-
res.render('dependencies', templateData);
|
|
451
|
-
}
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
router.get('/analytics', async (req, res) => {
|
|
455
|
-
const projectDir = req.app.locals.projectDir;
|
|
456
|
-
const [analytics, llmMetrics] = await Promise.all([
|
|
457
|
-
getProjectAnalytics(projectDir),
|
|
458
|
-
getLlmMetrics(projectDir)
|
|
459
|
-
]);
|
|
460
|
-
|
|
461
|
-
const templateData = {
|
|
462
|
-
title: 'Analytics',
|
|
463
|
-
activePage: 'analytics',
|
|
464
|
-
currentPath: '/analytics',
|
|
465
|
-
breadcrumbs: [{ label: 'Analytics' }],
|
|
466
|
-
analytics,
|
|
467
|
-
llmMetrics
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
res.setHeader('Vary', 'HX-Request');
|
|
471
|
-
|
|
472
|
-
if (req.get('HX-Request') === 'true') {
|
|
473
|
-
res.render('partials/analytics-content', templateData);
|
|
474
|
-
} else {
|
|
475
|
-
res.render('analytics', templateData);
|
|
476
|
-
}
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
router.get('/notes', async (req, res) => {
|
|
480
|
-
const projectDir = req.app.locals.projectDir;
|
|
481
|
-
const notes = await listNotes(projectDir);
|
|
482
|
-
|
|
483
|
-
const templateData = {
|
|
484
|
-
title: 'Notes',
|
|
485
|
-
activePage: 'notes',
|
|
486
|
-
currentPath: '/notes',
|
|
487
|
-
breadcrumbs: [{ label: 'Notes' }],
|
|
488
|
-
notes
|
|
489
|
-
};
|
|
490
|
-
|
|
491
|
-
res.setHeader('Vary', 'HX-Request');
|
|
492
|
-
|
|
493
|
-
if (req.get('HX-Request') === 'true') {
|
|
494
|
-
res.render('partials/notes-content', templateData);
|
|
495
|
-
} else {
|
|
496
|
-
res.render('notes', templateData);
|
|
497
|
-
}
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
router.get('/notes/:slug', async (req, res) => {
|
|
501
|
-
const { slug } = req.params;
|
|
502
|
-
|
|
503
|
-
// Validate slug: lowercase alphanumeric and dashes only
|
|
504
|
-
if (!/^[a-z0-9-]+$/.test(slug)) {
|
|
505
|
-
const err = new Error('Invalid note slug format');
|
|
506
|
-
err.status = 404;
|
|
507
|
-
throw err;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const projectDir = req.app.locals.projectDir;
|
|
511
|
-
const note = await getNoteBySlug(projectDir, slug);
|
|
512
|
-
|
|
513
|
-
if (!note) {
|
|
514
|
-
const err = new Error(`Note "${slug}" not found`);
|
|
515
|
-
err.status = 404;
|
|
516
|
-
throw err;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const templateData = {
|
|
520
|
-
title: note.title,
|
|
521
|
-
activePage: 'notes',
|
|
522
|
-
currentPath: '/notes/' + slug,
|
|
523
|
-
breadcrumbs: [{ label: 'Notes', url: '/notes' }, { label: note.title }],
|
|
524
|
-
...note
|
|
525
|
-
};
|
|
526
|
-
|
|
527
|
-
res.setHeader('Vary', 'HX-Request');
|
|
528
|
-
|
|
529
|
-
if (req.get('HX-Request') === 'true') {
|
|
530
|
-
res.render('partials/note-detail-content', templateData);
|
|
531
|
-
} else {
|
|
532
|
-
res.render('note-detail', templateData);
|
|
533
|
-
}
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
router.get('/research', async (req, res) => {
|
|
537
|
-
const projectDir = req.app.locals.projectDir;
|
|
538
|
-
const [researchDocs, codebaseDocs] = await Promise.all([
|
|
539
|
-
listResearchDocs(projectDir),
|
|
540
|
-
listCodebaseDocs(projectDir)
|
|
541
|
-
]);
|
|
542
|
-
|
|
543
|
-
const templateData = {
|
|
544
|
-
title: 'Research',
|
|
545
|
-
activePage: 'research',
|
|
546
|
-
currentPath: '/research',
|
|
547
|
-
breadcrumbs: [{ label: 'Research' }],
|
|
548
|
-
researchDocs,
|
|
549
|
-
codebaseDocs
|
|
550
|
-
};
|
|
551
|
-
|
|
552
|
-
res.setHeader('Vary', 'HX-Request');
|
|
553
|
-
if (req.get('HX-Request') === 'true') {
|
|
554
|
-
res.render('partials/research-content', templateData);
|
|
555
|
-
} else {
|
|
556
|
-
res.render('research', templateData);
|
|
557
|
-
}
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
router.get('/research/:slug', async (req, res) => {
|
|
561
|
-
const { slug } = req.params;
|
|
562
|
-
|
|
563
|
-
// Validate slug: lowercase alphanumeric, dashes, and dots only
|
|
564
|
-
if (!/^[a-z0-9._-]+$/.test(slug)) {
|
|
565
|
-
const err = new Error('Invalid research document slug format');
|
|
566
|
-
err.status = 404;
|
|
567
|
-
throw err;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
const projectDir = req.app.locals.projectDir;
|
|
571
|
-
const doc = await getResearchDocBySlug(projectDir, slug);
|
|
572
|
-
|
|
573
|
-
if (!doc) {
|
|
574
|
-
const err = new Error(`Research document "${slug}" not found`);
|
|
575
|
-
err.status = 404;
|
|
576
|
-
throw err;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
const templateData = {
|
|
580
|
-
title: doc.title,
|
|
581
|
-
activePage: 'research',
|
|
582
|
-
currentPath: '/research/' + slug,
|
|
583
|
-
breadcrumbs: [{ label: 'Research', url: '/research' }, { label: doc.title }],
|
|
584
|
-
...doc
|
|
585
|
-
};
|
|
586
|
-
|
|
587
|
-
res.setHeader('Vary', 'HX-Request');
|
|
588
|
-
if (req.get('HX-Request') === 'true') {
|
|
589
|
-
res.render('partials/research-detail-content', templateData);
|
|
590
|
-
} else {
|
|
591
|
-
res.render('research-detail', templateData);
|
|
592
|
-
}
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
router.get('/requirements', async (req, res) => {
|
|
596
|
-
const projectDir = req.app.locals.projectDir;
|
|
597
|
-
const { sections, totalCount, coveredCount } = await getRequirementsData(projectDir);
|
|
598
|
-
|
|
599
|
-
const templateData = {
|
|
600
|
-
title: 'Requirements',
|
|
601
|
-
activePage: 'requirements',
|
|
602
|
-
currentPath: '/requirements',
|
|
603
|
-
breadcrumbs: [{ label: 'Requirements' }],
|
|
604
|
-
sections,
|
|
605
|
-
totalCount,
|
|
606
|
-
coveredCount,
|
|
607
|
-
uncoveredCount: totalCount - coveredCount
|
|
608
|
-
};
|
|
609
|
-
|
|
610
|
-
res.setHeader('Vary', 'HX-Request');
|
|
611
|
-
if (req.get('HX-Request') === 'true') {
|
|
612
|
-
res.render('partials/requirements-content', templateData);
|
|
613
|
-
} else {
|
|
614
|
-
res.render('requirements', templateData);
|
|
615
|
-
}
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
router.get('/roadmap', async (req, res) => {
|
|
619
|
-
const projectDir = req.app.locals.projectDir;
|
|
620
|
-
const [roadmapData, stateData] = await Promise.all([
|
|
621
|
-
getRoadmapData(projectDir),
|
|
622
|
-
parseStateFile(projectDir)
|
|
623
|
-
]);
|
|
624
|
-
|
|
625
|
-
const templateData = {
|
|
626
|
-
title: 'Roadmap',
|
|
627
|
-
activePage: 'roadmap',
|
|
628
|
-
currentPath: '/roadmap',
|
|
629
|
-
phases: derivePhaseStatuses(roadmapData.phases, stateData.currentPhase),
|
|
630
|
-
milestones: roadmapData.milestones,
|
|
631
|
-
breadcrumbs: [{ label: 'Roadmap' }]
|
|
632
|
-
};
|
|
633
|
-
|
|
634
|
-
res.setHeader('Vary', 'HX-Request');
|
|
635
|
-
|
|
636
|
-
if (req.get('HX-Request') === 'true') {
|
|
637
|
-
res.render('partials/roadmap-content', templateData);
|
|
638
|
-
} else {
|
|
639
|
-
res.render('roadmap', templateData);
|
|
640
|
-
}
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
router.get('/quick', async (req, res) => {
|
|
644
|
-
const projectDir = req.app.locals.projectDir;
|
|
645
|
-
const tasks = await listQuickTasks(projectDir);
|
|
646
|
-
|
|
647
|
-
const templateData = {
|
|
648
|
-
title: 'Quick Tasks',
|
|
649
|
-
activePage: 'quick',
|
|
650
|
-
currentPath: '/quick',
|
|
651
|
-
breadcrumbs: [{ label: 'Quick Tasks' }],
|
|
652
|
-
tasks
|
|
653
|
-
};
|
|
654
|
-
|
|
655
|
-
res.setHeader('Vary', 'HX-Request');
|
|
656
|
-
|
|
657
|
-
if (req.get('HX-Request') === 'true') {
|
|
658
|
-
res.render('partials/quick-content', templateData);
|
|
659
|
-
} else {
|
|
660
|
-
res.render('quick', templateData);
|
|
661
|
-
}
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
router.get('/quick/:id', async (req, res) => {
|
|
665
|
-
const { id } = req.params;
|
|
666
|
-
|
|
667
|
-
// Validate ID format: must be exactly three digits
|
|
668
|
-
if (!/^\d{3}$/.test(id)) {
|
|
669
|
-
const err = new Error('Quick Task ID must be a three-digit number (e.g., 001, 005, 042)');
|
|
670
|
-
err.status = 404;
|
|
671
|
-
throw err;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
const projectDir = req.app.locals.projectDir;
|
|
675
|
-
const task = await getQuickTask(projectDir, id);
|
|
676
|
-
|
|
677
|
-
if (!task) {
|
|
678
|
-
const err = new Error(`Quick task ${id} not found`);
|
|
679
|
-
err.status = 404;
|
|
680
|
-
throw err;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
const templateData = {
|
|
684
|
-
title: `Quick Task ${task.id}: ${task.title}`,
|
|
685
|
-
activePage: 'quick',
|
|
686
|
-
currentPath: '/quick/' + id,
|
|
687
|
-
breadcrumbs: [{ label: 'Quick Tasks', url: '/quick' }, { label: 'Task ' + id }],
|
|
688
|
-
...task
|
|
689
|
-
};
|
|
690
|
-
|
|
691
|
-
res.setHeader('Vary', 'HX-Request');
|
|
692
|
-
|
|
693
|
-
if (req.get('HX-Request') === 'true') {
|
|
694
|
-
res.render('partials/quick-detail-content', templateData);
|
|
695
|
-
} else {
|
|
696
|
-
res.render('quick-detail', templateData);
|
|
697
|
-
}
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
router.get('/audits', async (req, res) => {
|
|
701
|
-
const projectDir = req.app.locals.projectDir;
|
|
702
|
-
const reports = await listAuditReports(projectDir);
|
|
703
|
-
|
|
704
|
-
const templateData = {
|
|
705
|
-
title: 'Audit Reports',
|
|
706
|
-
activePage: 'audits',
|
|
707
|
-
currentPath: '/audits',
|
|
708
|
-
breadcrumbs: [{ label: 'Audit Reports' }],
|
|
709
|
-
reports
|
|
710
|
-
};
|
|
711
|
-
|
|
712
|
-
res.setHeader('Vary', 'HX-Request');
|
|
713
|
-
|
|
714
|
-
if (req.get('HX-Request') === 'true') {
|
|
715
|
-
res.render('partials/audits-content', templateData);
|
|
716
|
-
} else {
|
|
717
|
-
res.render('audits', templateData);
|
|
718
|
-
}
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
router.get('/audits/:filename', async (req, res) => {
|
|
722
|
-
const { filename } = req.params;
|
|
723
|
-
|
|
724
|
-
// Validate filename: safe characters only, must end in .md
|
|
725
|
-
if (!/^[\w.-]+\.md$/.test(filename)) {
|
|
726
|
-
const err = new Error('Invalid audit report filename');
|
|
727
|
-
err.status = 404;
|
|
728
|
-
throw err;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
const projectDir = req.app.locals.projectDir;
|
|
732
|
-
const report = await getAuditReport(projectDir, filename);
|
|
733
|
-
|
|
734
|
-
if (!report) {
|
|
735
|
-
const err = new Error(`Audit report "${filename}" not found`);
|
|
736
|
-
err.status = 404;
|
|
737
|
-
throw err;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
const templateData = {
|
|
741
|
-
title: report.title,
|
|
742
|
-
activePage: 'audits',
|
|
743
|
-
currentPath: '/audits/' + filename,
|
|
744
|
-
breadcrumbs: [{ label: 'Audit Reports', url: '/audits' }, { label: report.title }],
|
|
745
|
-
...report
|
|
746
|
-
};
|
|
747
|
-
|
|
748
|
-
res.setHeader('Vary', 'HX-Request');
|
|
749
|
-
|
|
750
|
-
if (req.get('HX-Request') === 'true') {
|
|
751
|
-
res.render('partials/audit-detail-content', templateData);
|
|
752
|
-
} else {
|
|
753
|
-
res.render('audit-detail', templateData);
|
|
754
|
-
}
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
router.get('/config', async (req, res) => {
|
|
758
|
-
const projectDir = req.app.locals.projectDir;
|
|
759
|
-
const config = await readConfig(projectDir);
|
|
760
|
-
|
|
761
|
-
const templateData = {
|
|
762
|
-
title: 'Config',
|
|
763
|
-
activePage: 'config',
|
|
764
|
-
currentPath: '/config',
|
|
765
|
-
breadcrumbs: [{ label: 'Config' }],
|
|
766
|
-
config: config ?? {}
|
|
767
|
-
};
|
|
768
|
-
|
|
769
|
-
res.setHeader('Vary', 'HX-Request');
|
|
770
|
-
|
|
771
|
-
if (req.get('HX-Request') === 'true') {
|
|
772
|
-
res.render('partials/config-content', templateData);
|
|
773
|
-
} else {
|
|
774
|
-
res.render('config', templateData);
|
|
775
|
-
}
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
router.get('/logs', async (req, res) => {
|
|
779
|
-
const projectDir = req.app.locals.projectDir;
|
|
780
|
-
const { file, page, type, q } = req.query;
|
|
781
|
-
|
|
782
|
-
const logFiles = await listLogFiles(projectDir);
|
|
783
|
-
|
|
784
|
-
// Determine selected file (first in list if not specified)
|
|
785
|
-
const selectedFile = file || (logFiles.length > 0 ? logFiles[0].name : null);
|
|
786
|
-
|
|
787
|
-
let logData = null;
|
|
788
|
-
if (selectedFile) {
|
|
789
|
-
// Validate: no path traversal, must be a .jsonl filename
|
|
790
|
-
if (/^[\w.-]+\.jsonl$/.test(selectedFile)) {
|
|
791
|
-
const { join } = await import('node:path');
|
|
792
|
-
const filePath = join(projectDir, '.planning', 'logs', selectedFile);
|
|
793
|
-
logData = await readLogPage(filePath, {
|
|
794
|
-
page: parseInt(page, 10) || 1,
|
|
795
|
-
pageSize: 100,
|
|
796
|
-
typeFilter: type || '',
|
|
797
|
-
q: q || ''
|
|
798
|
-
});
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
const templateData = {
|
|
803
|
-
title: 'Logs',
|
|
804
|
-
activePage: 'logs',
|
|
805
|
-
currentPath: '/logs',
|
|
806
|
-
breadcrumbs: [{ label: 'Logs' }],
|
|
807
|
-
logFiles,
|
|
808
|
-
selectedFile,
|
|
809
|
-
logData,
|
|
810
|
-
filters: { type: type || '', q: q || '', page: parseInt(page, 10) || 1 }
|
|
811
|
-
};
|
|
812
|
-
|
|
813
|
-
res.setHeader('Vary', 'HX-Request');
|
|
814
|
-
if (req.get('HX-Request') === 'true') {
|
|
815
|
-
// If the request is for a different file/filter, re-render only the entries fragment
|
|
816
|
-
if (req.query.fragment === 'entries') {
|
|
817
|
-
res.render('partials/log-entries-content', templateData);
|
|
818
|
-
} else {
|
|
819
|
-
res.render('partials/logs-content', templateData);
|
|
820
|
-
}
|
|
821
|
-
} else {
|
|
822
|
-
res.render('logs', templateData);
|
|
823
|
-
}
|
|
824
|
-
});
|
|
825
|
-
|
|
826
|
-
router.post('/api/config', async (req, res) => {
|
|
827
|
-
const projectDir = req.app.locals.projectDir;
|
|
828
|
-
|
|
829
|
-
// Accept either JSON body (raw editor) or form-encoded (hybrid form)
|
|
830
|
-
let incoming = req.body;
|
|
831
|
-
|
|
832
|
-
// If the request carries a `rawJson` field, parse it as the full config
|
|
833
|
-
if (typeof incoming.rawJson === 'string') {
|
|
834
|
-
try {
|
|
835
|
-
incoming = JSON.parse(incoming.rawJson);
|
|
836
|
-
} catch {
|
|
837
|
-
return res.status(400).send('<span class="config-feedback config-feedback--error">Invalid JSON</span>');
|
|
838
|
-
}
|
|
839
|
-
} else {
|
|
840
|
-
// Merge form fields into existing config to avoid clobbering unrendered keys
|
|
841
|
-
const existing = await readConfig(projectDir) ?? {};
|
|
842
|
-
incoming = mergeFormIntoConfig(existing, incoming);
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
try {
|
|
846
|
-
await writeConfig(projectDir, incoming);
|
|
847
|
-
res.send('<span class="config-feedback config-feedback--success">Saved</span>');
|
|
848
|
-
} catch (err) {
|
|
849
|
-
res.status(400).send(`<span class="config-feedback config-feedback--error">${err.message}</span>`);
|
|
850
|
-
}
|
|
851
|
-
});
|
|
852
|
-
|
|
853
|
-
export default router;
|