create-pmos 0.134.0 → 0.135.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/bin/notes-replay.js +235 -0
- package/workspace/c3-briefing-test.js +130 -0
- package/workspace/c3-foundation-test.js +159 -0
- package/workspace/c3-ib-items-test.js +105 -0
- package/workspace/c3-ib-more-test.js +120 -0
- package/workspace/c3-ib-triage-backlog-test.js +119 -0
- package/workspace/c3-ini-harness.js +66 -0
- package/workspace/c3-initiatives-test.js +140 -0
- package/workspace/c3-one-home-test.js +14 -1
- package/workspace/c3-reports-test.js +57 -18
- package/workspace/c3-settings-test.js +139 -0
- package/workspace/c3-work-test.js +42 -10
- package/workspace/fixtures/one-home-baseline.json +1 -9
- package/workspace/guide/briefing-cards.md +15 -3
- package/workspace/guide/briefing.md +12 -8
- package/workspace/guide/calendar-popup.md +2 -2
- package/workspace/guide/console-calendar.md +39 -10
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-boards.md +65 -0
- package/workspace/guide/jira.md +3 -1
- package/workspace/guide/layout.md +2 -0
- package/workspace/guide/my-work.md +5 -3
- package/workspace/guide/notes.md +16 -1
- package/workspace/guide/reports-dashboards.md +29 -5
- package/workspace/guide/settings.md +68 -0
- package/workspace/guide/welcome.md +10 -10
- package/workspace/lib/calendar-cli.js +3 -2
- package/workspace/lib/calendar-layers.js +21 -10
- package/workspace/lib/calendar-tidy.js +230 -0
- package/workspace/lib/engine/boards.js +542 -0
- package/workspace/lib/engine/breakdown-ops.js +180 -0
- package/workspace/lib/engine/calendar-import.js +84 -0
- package/workspace/lib/engine/calendar-model.js +185 -0
- package/workspace/lib/engine/card-state.js +92 -0
- package/workspace/lib/engine/derived/derive.js +92 -0
- package/workspace/lib/engine/derived/plan.js +3 -0
- package/workspace/lib/engine/derived/trust.js +3 -0
- package/workspace/lib/engine/doc-health.js +142 -0
- package/workspace/lib/engine/doc-sheet.js +75 -0
- package/workspace/lib/engine/intake.js +78 -0
- package/workspace/lib/engine/outbox.js +86 -0
- package/workspace/lib/engine/plan-forecast.js +342 -0
- package/workspace/lib/engine/promises.js +29 -0
- package/workspace/lib/engine/records/index.js +114 -0
- package/workspace/lib/engine/releases.js +96 -0
- package/workspace/lib/engine/replies.js +99 -0
- package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
- package/workspace/lib/engine/report-families.js +175 -0
- package/workspace/lib/engine/roadmap-ops.js +150 -0
- package/workspace/lib/engine/sessions-list.js +27 -0
- package/workspace/lib/engine/signal-rules.js +95 -0
- package/workspace/lib/engine/signals/cards.js +57 -0
- package/workspace/lib/engine/signals/detectors.js +370 -0
- package/workspace/lib/engine/signals/facts-cli.js +8 -0
- package/workspace/lib/engine/signals/facts.js +197 -0
- package/workspace/lib/engine/signals/index.js +33 -0
- package/workspace/lib/engine/signals/kinds.js +110 -0
- package/workspace/lib/engine/signals/layout-paths.js +44 -0
- package/workspace/lib/engine/signals/options.js +24 -0
- package/workspace/lib/engine/signals/score.js +72 -0
- package/workspace/lib/engine/signals/select.js +47 -0
- package/workspace/lib/engine/step-planner.js +65 -0
- package/workspace/lib/engine/trust/claim-rules.json +93 -0
- package/workspace/lib/engine/trust/import-calendar.js +21 -0
- package/workspace/lib/engine/trust/import-guard.js +36 -0
- package/workspace/lib/engine/trust/ledger.js +53 -0
- package/workspace/lib/engine/trust/outbox.js +45 -0
- package/workspace/lib/engine/trust/plan.js +5 -0
- package/workspace/lib/engine/trust/trust.js +21 -0
- package/workspace/lib/engine/truth-table.js +57 -0
- package/workspace/lib/engine/workspace-lib.js +24 -0
- package/workspace/lib/factory-join.js +1 -1
- package/workspace/lib/facts-async.js +4 -4
- package/workspace/lib/headless.js +95 -0
- package/workspace/lib/import-overlay.js +2 -1
- package/workspace/lib/jira-status.js +30 -3
- package/workspace/lib/notes-capture.js +9 -1
- package/workspace/lib/notes-final.js +38 -13
- package/workspace/lib/notes-replay.js +125 -0
- package/workspace/lib/notes-screen.js +180 -0
- package/workspace/lib/notes-whisper.js +10 -3
- package/workspace/lib/outbox-store.js +10 -6
- package/workspace/lib/pmos-verbs.js +3 -1
- package/workspace/lib/proactive-cli.js +7 -0
- package/workspace/lib/proactive.js +15 -2
- package/workspace/lib/repo-script.js +36 -0
- package/workspace/lib/report-dashboards.js +5 -2
- package/workspace/lib/report-frame.js +122 -0
- package/workspace/lib/report-schedules.js +113 -0
- package/workspace/lib/route-modules.js +9 -2
- package/workspace/lib/routes-boards.js +734 -0
- package/workspace/lib/routes-brief-page.js +13 -4
- package/workspace/lib/routes-briefing.js +22 -11
- package/workspace/lib/routes-c3-init.js +4 -3
- package/workspace/lib/routes-c3-life.js +2 -1
- package/workspace/lib/routes-c3-reports.js +96 -13
- package/workspace/lib/routes-c3-strategy.js +4 -3
- package/workspace/lib/routes-c3-timeline.js +3 -2
- package/workspace/lib/routes-calendar-import.js +3 -2
- package/workspace/lib/routes-calendar-views.js +9 -4
- package/workspace/lib/routes-doc-health.js +2 -1
- package/workspace/lib/routes-doc-sheet.js +2 -1
- package/workspace/lib/routes-forecast.js +2 -1
- package/workspace/lib/routes-hooks.js +3 -2
- package/workspace/lib/routes-initiatives.js +2 -1
- package/workspace/lib/routes-intake.js +2 -1
- package/workspace/lib/routes-notes.js +55 -9
- package/workspace/lib/routes-outbox.js +23 -3
- package/workspace/lib/routes-pm-items.js +19 -6
- package/workspace/lib/routes-records.js +3 -2
- package/workspace/lib/routes-releases.js +2 -1
- package/workspace/lib/routes-report-facts.js +4 -3
- package/workspace/lib/routes-report-families.js +2 -1
- package/workspace/lib/routes-roadmap-ops.js +5 -4
- package/workspace/lib/routes-runs.js +6 -5
- package/workspace/lib/routes-settings.js +51 -0
- package/workspace/lib/routes-signals.js +3 -2
- package/workspace/lib/routes-stage-tools.js +220 -0
- package/workspace/lib/run-answers.js +2 -1
- package/workspace/lib/settings-store.js +226 -0
- package/workspace/lib/stage-tools.js +208 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
- package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
- package/workspace/lib/work-evidence.js +73 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/briefing-deck.js +63 -17
- package/workspace/public/pm/briefing-model.js +36 -7
- package/workspace/public/pm/c3-ambient.js +100 -0
- package/workspace/public/pm/c3-brief-needs.js +26 -6
- package/workspace/public/pm/c3-brief-pulse.js +12 -4
- package/workspace/public/pm/c3-brief-today.js +56 -21
- package/workspace/public/pm/c3-brief.js +2 -1
- package/workspace/public/pm/c3-calendar.js +161 -42
- package/workspace/public/pm/c3-calpop.js +5 -5
- package/workspace/public/pm/c3-demo-boards.js +649 -0
- package/workspace/public/pm/c3-demo-brief-page.js +1 -1
- package/workspace/public/pm/c3-demo-cal4.js +3 -2
- package/workspace/public/pm/c3-demo-cal5.js +52 -0
- package/workspace/public/pm/c3-demo-data.js +17 -27
- package/workspace/public/pm/c3-demo-rep3.js +46 -5
- package/workspace/public/pm/c3-demo.js +2 -1
- package/workspace/public/pm/c3-dock.js +1 -1
- package/workspace/public/pm/c3-ib-docs.js +80 -0
- package/workspace/public/pm/c3-ib-initiatives.js +134 -0
- package/workspace/public/pm/c3-ib-items.js +201 -0
- package/workspace/public/pm/c3-ib-releases.js +51 -0
- package/workspace/public/pm/c3-ib-sprints.js +55 -0
- package/workspace/public/pm/c3-ib-triage.js +178 -0
- package/workspace/public/pm/c3-ii-tabs.js +240 -0
- package/workspace/public/pm/c3-ini-records.js +197 -0
- package/workspace/public/pm/c3-ini-timeline.js +278 -0
- package/workspace/public/pm/c3-ini-ui.js +229 -0
- package/workspace/public/pm/c3-ini-work.js +139 -0
- package/workspace/public/pm/c3-initiatives.css +218 -0
- package/workspace/public/pm/c3-initiatives.js +741 -0
- package/workspace/public/pm/c3-plan.js +2 -2
- package/workspace/public/pm/c3-reports.css +47 -47
- package/workspace/public/pm/c3-reports.js +223 -185
- package/workspace/public/pm/c3-settings.css +74 -0
- package/workspace/public/pm/c3-settings.js +703 -0
- package/workspace/public/pm/c3-shell.js +102 -36
- package/workspace/public/pm/c3-terminal-icons.js +2 -2
- package/workspace/public/pm/c3-terminal.js +2 -2
- package/workspace/public/pm/calendar-views.js +1 -1
- package/workspace/public/pm/components.css +319 -213
- package/workspace/public/pm/console3.css +99 -23
- package/workspace/public/pm/dest-notes.js +26 -4
- package/workspace/public/pm/notes-screen-capture.js +165 -0
- package/workspace/public/pm/notes-screen-view.js +98 -0
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/pm-work-view.js +233 -146
- package/workspace/public/pm/shell-bell.js +3 -2
- package/workspace/public/pm/shell-recording.js +29 -8
- package/workspace/public/pm/tokens.css +2 -2
- package/workspace/public/pm/view-notes.js +46 -9
- package/workspace/public/pm/welcome.js +197 -89
- package/workspace/public/welcome.html +6 -4
- package/workspace/server.js +105 -6
- package/workspace/skills/day-check.skill +1 -1
- package/workspace/skills/jira-sync.skill +6 -4
- package/workspace/skills/morning-brief.skill +1 -1
- package/workspace/test-files.json +12 -3
- package/workspace/test-lists/c3-ib-items.json +9 -0
- package/workspace/test-lists/c3-ib-more.json +9 -0
- package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
- package/workspace/test-lists/c3-initiative-boards.json +6 -0
- package/workspace/test-lists/calendar-tidy-up.json +6 -0
- package/workspace/test-lists/console3-briefing.json +9 -0
- package/workspace/test-lists/console3-foundation.json +9 -0
- package/workspace/test-lists/console3-reports.json +3 -3
- package/workspace/test-lists/meeting-notes.json +10 -4
- package/workspace/test-lists/workspace-headless-jobs.json +5 -0
- package/workspace/test-lists/workspace-package-script-closure.json +6 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
- package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
- package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
- package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
- package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
- package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
- package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
- package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
- package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-boards.js — console3 Initiatives: Portfolio, Board and Initiative, with a workflow per board
|
|
3
|
+
* (c3-initiative-boards; planning/prd/c3-initiative-boards-prd.md "Model", "State", "API contract").
|
|
4
|
+
*
|
|
5
|
+
* GET /api/boards → { ok, boards: [portfolio row] } every workspace; `ws` is ignored
|
|
6
|
+
* GET /api/boards/board?ws&board → { ok, board, counts: { total, byStage, byStatus }, asks, nextRelease, recent }
|
|
7
|
+
* GET /api/boards/backlog?ws&board&q&stage&status&owner&horizon&anchor&view&sort&offset&limit
|
|
8
|
+
* → { ok, total, offset, limit, rows, facets: { stage, status, owner, horizon } }
|
|
9
|
+
* GET /api/boards/columns?ws&board&q&owner&cap → { ok, columns: [{ stage, cat, hint, count, rows }] }
|
|
10
|
+
* GET /api/boards/initiative?ws&board&id → { ok, row, board, history, tools }
|
|
11
|
+
* GET /api/boards/asks?ws&board&state → { ok, asks } state: open (default) | accepted | declined | all
|
|
12
|
+
* GET /api/boards/sprints?ws&board → { ok, source: jira|none, readAt, sprints }
|
|
13
|
+
* GET /api/boards/items?ws&board → { ok, build, jira, items, initiatives } PMOS tasks + Jira items (Build and deliver only)
|
|
14
|
+
* POST /api/boards/create { ws, name, domain?, template } → { ok, board }
|
|
15
|
+
* POST /api/boards/workflow { ws, board, stages, moveRemoved?, sprints? } → { ok, board, moved }
|
|
16
|
+
* POST /api/boards/move { ws, board, id, to, why? } → { ok, row, warnings }
|
|
17
|
+
* POST /api/boards/assign { ws, id, to } → { ok }
|
|
18
|
+
* POST /api/boards/asks/add { ws, board, line, source? } → { ok, ask, draft }
|
|
19
|
+
* POST /api/boards/asks/accept { ws, board, ask, title, lane, anchor, intent?, id? } → { ok, initiative, stage }
|
|
20
|
+
* POST /api/boards/asks/decline { ws, board, ask, why } → { ok }
|
|
21
|
+
* POST /api/boards/asks/more { ws, board, ask, note? } → { ok }
|
|
22
|
+
* POST /api/boards/settings { ws, board, buildDeliver?, jira?, offer?, defTpl? } → { ok, board }
|
|
23
|
+
* POST /api/boards/ini-workflow { ws, board, id, template?, stages, moveTo? } → { ok, row, moved }
|
|
24
|
+
* POST /api/boards/tasks/add { ws, board, initiative, title, type?, epic?, start?, due?, owner? } → { ok, item }
|
|
25
|
+
* POST /api/boards/tasks/state { ws, board, task, state } → { ok, item }
|
|
26
|
+
* POST /api/boards/tasks/reschedule { ws, board, task, days } → { ok, item }
|
|
27
|
+
* (console3-initiatives-parity, planning/prd/console3-initiatives-parity-prd.md: the workflow belongs to each initiative, a board
|
|
28
|
+
* says whether it builds and delivers, and PMOS tasks live on the board. All of it is append-only in the same ledger.)
|
|
29
|
+
*
|
|
30
|
+
* THE MODEL is scripts/boards.js (pure). This file reads, folds and appends.
|
|
31
|
+
*
|
|
32
|
+
* BOARDS OF A WORKSPACE. The default board (the repository; id = the workspace id), one board per `products/<domain>/`
|
|
33
|
+
* folder (id = the folder name), and every board the ledger created. An initiative belongs to the default board, or to
|
|
34
|
+
* the domain board whose folder holds its record, until an `assign` or an accepted ask puts it on another board.
|
|
35
|
+
*
|
|
36
|
+
* STATE. <stateDirFor(w)>/boards.jsonl, append-only, one per repository. With no state folder the reads use the
|
|
37
|
+
* defaults and every write answers 409 with the reason (stateDirFor is called without `create`).
|
|
38
|
+
*
|
|
39
|
+
* ROWS come from lib/initiative-rows.js rowsFor (the cached derivation; never a scan per request) and the Roadmap row
|
|
40
|
+
* of routes-initiatives.js rowOf, mapped to the PRD row and cached per repository by the read signature. Until the first
|
|
41
|
+
* derivation of a repository lands, a read answers 202 { ok: true, pending: true } (the Portfolio marks that board
|
|
42
|
+
* `pending`), and a write that needs the rows answers 202 { ok: false, pending: true, error }.
|
|
43
|
+
* Row fields with no source are null, never guessed: `owner` is the record's own `owner:` frontmatter; `start`/`end`
|
|
44
|
+
* are the span of the initiative's dated plan items (lib/item-spans.js); `status` is done (accepted, closed or
|
|
45
|
+
* cancelled), late (health off track, or `end` before today), at risk (health at risk), else on track.
|
|
46
|
+
*
|
|
47
|
+
* THE PMOS-SELF BOARD is the default board of the workspace whose root is the PMOS checkout the server is embedded in
|
|
48
|
+
* (server.js EMBEDDED_SELF, passed as ctx.selfRoot). Its stages are the nine derived stage names (read-only); a move or
|
|
49
|
+
* a workflow edit there is refused with 409 "PMOS keeps its derived ladder".
|
|
50
|
+
*
|
|
51
|
+
* WHO WRITES. Every POST passes pm-guard requirePagePress. A new initiative is made only by lib/initiative-writer.js
|
|
52
|
+
* createInitiative (it refuses an unanchored create). Nothing here reads or writes Jira: sprints come from the cached
|
|
53
|
+
* snapshots of lib/jira-status.js.
|
|
54
|
+
*/
|
|
55
|
+
const fs = require('fs');
|
|
56
|
+
const path = require('path');
|
|
57
|
+
const crypto = require('crypto');
|
|
58
|
+
const guard = require('./pm-guard.js');
|
|
59
|
+
const actorLib = require('./actor.js');
|
|
60
|
+
const rowsLib = require('./initiative-rows.js');
|
|
61
|
+
const writer = require('./initiative-writer.js');
|
|
62
|
+
const itemSpans = require('./item-spans.js');
|
|
63
|
+
const jiraStatus = require('./jira-status.js');
|
|
64
|
+
const routesInit = require('./routes-initiatives.js');
|
|
65
|
+
const jiraTickets = require('./jira-tickets.js');
|
|
66
|
+
const { repoScript } = require('./repo-script.js');
|
|
67
|
+
const B = require(repoScript('boards.js'));
|
|
68
|
+
const intake = require(repoScript('intake.js'));
|
|
69
|
+
|
|
70
|
+
const LEDGER = 'boards.jsonl';
|
|
71
|
+
const SELF_REFUSAL = 'PMOS keeps its derived ladder: its stages come from scripts/workflow-state.js, so a move or a workflow edit is refused here';
|
|
72
|
+
const NO_STATE = 'This repository has no PMOS state folder (pmos/state, state or .pmos). Boards read with the defaults, and nothing is saved until the folder exists.';
|
|
73
|
+
const PENDING = 'The initiatives are being read. Ask again in a few seconds.';
|
|
74
|
+
const ONE_LINE = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !/[\r\n\x00-\x08\x0b-\x1f]/.test(v);
|
|
75
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
76
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
77
|
+
|
|
78
|
+
// ── the ledger ────────────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
function ledgerFile(ctx, w) {
|
|
80
|
+
const d = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
|
|
81
|
+
return d ? path.join(d, LEDGER) : null;
|
|
82
|
+
}
|
|
83
|
+
function readLedger(ctx, w) {
|
|
84
|
+
const f = ledgerFile(ctx, w);
|
|
85
|
+
if (!f) return [];
|
|
86
|
+
try { return fs.readFileSync(f, 'utf8').split('\n').filter(Boolean); } catch { return []; }
|
|
87
|
+
}
|
|
88
|
+
function append(file, actor, ev) {
|
|
89
|
+
const line = { at: new Date().toISOString(), by: actor.by, ...ev };
|
|
90
|
+
fs.appendFileSync(file, JSON.stringify(line) + '\n');
|
|
91
|
+
return line;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── boards of a workspace ─────────────────────────────────────────────────────────────────────────
|
|
95
|
+
const isSelf = (ctx, w) => !!(ctx.selfRoot && w && path.resolve(w.root) === path.resolve(ctx.selfRoot));
|
|
96
|
+
|
|
97
|
+
function domainsOf(root) {
|
|
98
|
+
try {
|
|
99
|
+
return fs.readdirSync(path.join(root, 'products'), { withFileTypes: true })
|
|
100
|
+
.filter((d) => d.isDirectory() && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(d.name)).map((d) => d.name).sort();
|
|
101
|
+
} catch { return []; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function boardsOf(ctx, w, folded) {
|
|
105
|
+
const self = isSelf(ctx, w);
|
|
106
|
+
const base = [{ id: w.id, name: w.name || path.basename(w.root), domain: null, kind: 'default' }];
|
|
107
|
+
for (const d of domainsOf(w.root)) if (d !== w.id) base.push({ id: d, name: d, domain: d, kind: 'domain' });
|
|
108
|
+
const known = new Set(base.map((b) => b.id));
|
|
109
|
+
for (const [id, b] of folded.boards) if (!known.has(id) && b.created) { base.push({ id, name: b.name || id, domain: b.domain || null, kind: 'created' }); known.add(id); }
|
|
110
|
+
return base.map((b0) => {
|
|
111
|
+
const led = folded.boards.get(b0.id) || {};
|
|
112
|
+
const selfBoard = self && b0.kind === 'default';
|
|
113
|
+
const template = selfBoard ? 'pmos' : (B.WORKFLOW_TEMPLATES[led.template] ? led.template : 'product');
|
|
114
|
+
const workflow = selfBoard ? B.SELF_LADDER.map((s) => ({ ...s })) : (Array.isArray(led.workflow) && led.workflow.length ? led.workflow : B.templateWorkflow(template));
|
|
115
|
+
const set = B.boardSettings({ ...led, template, self: selfBoard });
|
|
116
|
+
return {
|
|
117
|
+
ws: w.id, id: b0.id, name: typeof led.name === 'string' && led.name ? led.name : b0.name, domain: 'domain' in led ? led.domain : b0.domain,
|
|
118
|
+
kind: b0.kind, template, templateEdited: selfBoard ? false : B.isTemplateEdited(template, workflow), workflow, self: selfBoard,
|
|
119
|
+
tracker: led.tracker || (set.jira ? `Jira · ${set.jira}` : null), sprints: !!led.sprints || (set.build && !!set.jira),
|
|
120
|
+
build: set.build, buildDeliver: set.build, jira: set.jira, offer: set.offer, defTpl: set.defTpl,
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function boardIdOf(rowBase, boards, folded, w) {
|
|
126
|
+
const a = folded.assigns.get(rowBase.id);
|
|
127
|
+
if (a && boards.some((b) => b.id === a)) return a;
|
|
128
|
+
if (rowBase.domainFolder && boards.some((b) => b.id === rowBase.domainFolder && b.kind === 'domain')) return rowBase.domainFolder;
|
|
129
|
+
return w.id;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── rows ──────────────────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
const rowCache = new Map(); // root → { key, rows }
|
|
134
|
+
|
|
135
|
+
function frontmatter(root, rel) {
|
|
136
|
+
if (!rel) return {};
|
|
137
|
+
let text = '';
|
|
138
|
+
try { text = fs.readFileSync(path.isAbsolute(rel) ? rel : path.join(root, rel), 'utf8'); } catch { return {}; }
|
|
139
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
140
|
+
if (!m) return {};
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
143
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$/.exec(line);
|
|
144
|
+
if (kv) out[kv[1]] = kv[2].replace(/^["']|["']$/g, '');
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function statusOf(rec, healthLevel, end, day) {
|
|
150
|
+
if (rec.accepted || (rec.stopped && ['closed', 'cancelled'].includes(rec.stopped.state))) return 'done';
|
|
151
|
+
if (healthLevel === 'off' || (end && end < day)) return 'late';
|
|
152
|
+
if (healthLevel === 'at-risk') return 'at risk';
|
|
153
|
+
return 'on track';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The board-independent part of every work row of a repository, cached by the read signature. */
|
|
157
|
+
function baseRows(w) {
|
|
158
|
+
const got = rowsLib.rowsFor(w.root);
|
|
159
|
+
if (got.pending) return { pending: true };
|
|
160
|
+
if (!got.ok) return { ok: false, error: got.error || 'the initiatives could not be read' };
|
|
161
|
+
const day = today();
|
|
162
|
+
const sig = routesInit.sigOf(w.root);
|
|
163
|
+
sig.records = rowsLib.answerSig(got);
|
|
164
|
+
const cacheKey = JSON.stringify(sig) + day;
|
|
165
|
+
const hit = rowCache.get(w.root);
|
|
166
|
+
if (hit && hit.key === cacheKey) return { ok: true, rows: hit.rows };
|
|
167
|
+
const F = routesInit.frameOf(w, got.rows, { now: Date.now(), sessions: [] });
|
|
168
|
+
const itemsBy = new Map();
|
|
169
|
+
for (const it of Object.values(F.fold.items || {})) {
|
|
170
|
+
if (!it || !it.initiative) continue;
|
|
171
|
+
if (!itemsBy.has(it.initiative)) itemsBy.set(it.initiative, []);
|
|
172
|
+
itemsBy.get(it.initiative).push(it);
|
|
173
|
+
}
|
|
174
|
+
const rows = [];
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
for (const id of Object.values(F.placed.order).flat()) {
|
|
177
|
+
if (seen.has(id)) continue;
|
|
178
|
+
seen.add(id);
|
|
179
|
+
const rec = F.byId.get(id);
|
|
180
|
+
if (!rec || !F.placed.byInit[id] || (rec.group && rec.group !== 'work')) continue;
|
|
181
|
+
let r;
|
|
182
|
+
try { r = routesInit.rowOf(F, rec); } catch { continue; }
|
|
183
|
+
const fm = frontmatter(w.root, rec.record);
|
|
184
|
+
let span = null;
|
|
185
|
+
try { span = itemSpans.initiativeSpan(itemSpans.spansOf(itemsBy.get(id) || [])); } catch { span = null; }
|
|
186
|
+
const end = span ? span.due || null : null;
|
|
187
|
+
const prd = fm.prd_path || null;
|
|
188
|
+
const hasSpec = !!(prd && fs.existsSync(path.join(w.root, prd)));
|
|
189
|
+
const relRec = String(rec.record || '').split(path.sep).join('/');
|
|
190
|
+
const dm = /^products\/([^/]+)\//.exec(relRec);
|
|
191
|
+
rows.push({
|
|
192
|
+
id, title: r.title, lane: r.lane || null,
|
|
193
|
+
owner: fm.owner ? String(fm.owner) : null,
|
|
194
|
+
anchor: r.anchor || null, horizon: r.horizon || null, rank: Number.isInteger(r.rank) ? r.rank : null, dormant: !!r.dormant,
|
|
195
|
+
status: statusOf(rec, r.health && r.health.level, end, day),
|
|
196
|
+
health: r.health ? r.health.level : null,
|
|
197
|
+
start: span ? span.start || null : null, end,
|
|
198
|
+
progress: r.progress && r.progress.total ? Math.round((r.progress.done / r.progress.total) * 100) / 100 : null, // a fraction 0..1, as the PRD row reads
|
|
199
|
+
progressLabel: r.progress ? (r.progress.label || `${r.progress.done} of ${r.progress.total}`) : null,
|
|
200
|
+
updated: r.last_activity || null,
|
|
201
|
+
reading: B.readingOf(r.nine_stages, { accepted: !!rec.accepted }),
|
|
202
|
+
lifecycle: rec.stopped ? rec.stopped.state : null,
|
|
203
|
+
hasSpec, record: relRec || null, domainFolder: dm ? dm[1] : null,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
rowCache.set(w.root, { key: cacheKey, rows });
|
|
207
|
+
return { ok: true, rows };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The PRD row of one base row on one board. */
|
|
211
|
+
function boardRow(board, base, folded) {
|
|
212
|
+
const s = B.stageOf(board, base.id, base.reading, folded);
|
|
213
|
+
const wf = B.workflowOf(board, base.id, folded);
|
|
214
|
+
const cat = B.catOf({ workflow: wf }, s.stage);
|
|
215
|
+
const row = {
|
|
216
|
+
id: base.id, title: base.title, lane: base.lane, stage: s.stage, cat, owner: base.owner, anchor: base.anchor,
|
|
217
|
+
horizon: base.horizon, status: base.status, start: base.start, end: base.end, progress: base.progress, warnings: [],
|
|
218
|
+
rank: base.rank, health: base.health, updated: base.updated, reading: base.reading, stageSource: s.source, lifecycle: base.lifecycle,
|
|
219
|
+
template: B.templateOf(board, base.id, folded), workflow: wf.map((x) => ({ name: x.name, cat: x.cat })),
|
|
220
|
+
};
|
|
221
|
+
const w = [];
|
|
222
|
+
if (s.source === 'move') {
|
|
223
|
+
const last = [...folded.moves].reverse().find((m) => m.board === board.id && m.id === base.id);
|
|
224
|
+
if (last && last.to === s.stage) for (const x of last.warnings || []) if (/^Skipped /.test(x)) w.push(x);
|
|
225
|
+
}
|
|
226
|
+
for (const x of B.cardWarnings(board, row, { hasSpec: base.hasSpec })) if (!w.includes(x)) w.push(x);
|
|
227
|
+
row.warnings = w;
|
|
228
|
+
return row;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Everything a read of one workspace needs. */
|
|
232
|
+
function frameFor(ctx, w) {
|
|
233
|
+
const folded = B.fold(readLedger(ctx, w));
|
|
234
|
+
const boards = boardsOf(ctx, w, folded);
|
|
235
|
+
return { w, folded, boards };
|
|
236
|
+
}
|
|
237
|
+
function rowsOfBoard(fr, board, base) {
|
|
238
|
+
return base.filter((r) => boardIdOf(r, fr.boards, fr.folded, fr.w) === board.id).map((r) => boardRow(board, r, fr.folded));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function readReleases(ctx) {
|
|
242
|
+
if (!ctx.runtimeDir) return [];
|
|
243
|
+
try {
|
|
244
|
+
const R = require(repoScript('releases.js'));
|
|
245
|
+
return R.foldReleases(fs.readFileSync(path.join(ctx.runtimeDir, 'releases', 'releases.jsonl'), 'utf8').split('\n').filter(Boolean));
|
|
246
|
+
} catch { return []; }
|
|
247
|
+
}
|
|
248
|
+
/** The next release that carries an initiative of the board: the earliest release (or deploy) date from today on. */
|
|
249
|
+
function nextReleaseOf(releases, ids) {
|
|
250
|
+
const day = today();
|
|
251
|
+
let best = null;
|
|
252
|
+
for (const r of releases) {
|
|
253
|
+
if (!(r.initiative_ids || []).some((x) => ids.has(x))) continue;
|
|
254
|
+
const at = r.release_at || r.deploy_at;
|
|
255
|
+
if (!at || at.slice(0, 10) < day) continue;
|
|
256
|
+
if (!best || at < best.at) best = { id: r.id, type: r.type, at };
|
|
257
|
+
}
|
|
258
|
+
return best;
|
|
259
|
+
}
|
|
260
|
+
const openAsks = (folded, boardId) => [...folded.asks.values()].filter((a) => a.board === boardId && a.state === 'open').length;
|
|
261
|
+
const pageAsk = (a) => ({ ...a, by: actorLib.pageName(a.by) });
|
|
262
|
+
const pageMove = (m) => ({ ...m, by: actorLib.pageName(m.by) });
|
|
263
|
+
|
|
264
|
+
/** The work items of a board: its PMOS tasks (the ledger) and, with Build and deliver on, the Jira items of its initiatives
|
|
265
|
+
* (the ticket drafts this repository pushed, with the status of the cached readings; read-only, no dates or people). */
|
|
266
|
+
function itemsOf(w, fr, board, rows) {
|
|
267
|
+
const ids = new Set(rows.map((x) => x.id));
|
|
268
|
+
const out = [];
|
|
269
|
+
const jiraKeys = new Set();
|
|
270
|
+
if (board.build) {
|
|
271
|
+
for (const row of rows) {
|
|
272
|
+
let d = null;
|
|
273
|
+
let snap = null;
|
|
274
|
+
try { d = jiraTickets.readDraft(w.root, row.id); } catch { d = null; }
|
|
275
|
+
if (!d) continue;
|
|
276
|
+
try { snap = jiraStatus.readSnapshot(w.root, row.id); } catch { snap = null; }
|
|
277
|
+
const reading = (k) => (snap && snap.tickets && snap.tickets.get ? snap.tickets.get(k) : null) || null;
|
|
278
|
+
const epicKey = /^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(String((d.fm && d.fm.epic) || '')) ? String(d.fm.epic).toUpperCase() : null;
|
|
279
|
+
if (epicKey) {
|
|
280
|
+
const rd = reading(epicKey);
|
|
281
|
+
out.push({ uid: epicKey, key: epicKey, kind: 'Epic', src: 'jira', title: (rd && rd.summary) || row.title, status: B.itemStatus(rd && rd.status, rd && rd.category), blocked: !!(rd && rd.flagged), owner: null, ini: row.id, epic: null, start: null, end: null, ref: null, board: board.id });
|
|
282
|
+
jiraKeys.add(epicKey);
|
|
283
|
+
}
|
|
284
|
+
for (const t of d.tickets || []) {
|
|
285
|
+
if (!t.jira || jiraKeys.has(t.jira)) continue;
|
|
286
|
+
const rd = reading(t.jira);
|
|
287
|
+
out.push({ uid: t.jira, key: t.jira, kind: ['Story', 'Task', 'Bug'].includes(t.type) ? t.type : 'Task', src: 'jira', title: (rd && rd.summary) || t.summary || t.jira,
|
|
288
|
+
status: B.itemStatus(rd && rd.status, rd && rd.category), blocked: !!(rd && rd.flagged), owner: null, ini: row.id, epic: epicKey, start: null, end: null, ref: null, board: board.id });
|
|
289
|
+
jiraKeys.add(t.jira);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const t of fr.folded.tasks.values()) if (t.board === board.id && ids.has(t.initiative)) out.push(B.taskItem(t));
|
|
294
|
+
return B.rollupItems(out);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── HTTP ──────────────────────────────────────────────────────────────────────────────────────────
|
|
298
|
+
function wsOf(ctx, wsParam) {
|
|
299
|
+
const w = ctx.getWs(wsParam || null);
|
|
300
|
+
if (!w || (wsParam && w.id !== wsParam)) return null;
|
|
301
|
+
return w;
|
|
302
|
+
}
|
|
303
|
+
const fail = (ctx, res, status, error, extra = {}) => { ctx.json(res, { ok: false, error, ...extra }, status); return true; };
|
|
304
|
+
|
|
305
|
+
function portfolio(ctx) {
|
|
306
|
+
const list = ctx.workspaceList ? ctx.workspaceList() : [];
|
|
307
|
+
const releases = readReleases(ctx);
|
|
308
|
+
const out = [];
|
|
309
|
+
for (const w of list) {
|
|
310
|
+
let fr;
|
|
311
|
+
try { fr = frameFor(ctx, w); } catch (e) { out.push({ ws: w.id, id: w.id, name: w.name, error: String((e && e.message) || e).slice(0, 160) }); continue; }
|
|
312
|
+
let base = null;
|
|
313
|
+
let pending = false;
|
|
314
|
+
let error = null;
|
|
315
|
+
try {
|
|
316
|
+
const b = baseRows(w);
|
|
317
|
+
if (b.pending) pending = true; else if (!b.ok) error = b.error; else base = b.rows;
|
|
318
|
+
} catch (e) { error = String((e && e.message) || e).slice(0, 160); }
|
|
319
|
+
for (const board of fr.boards) {
|
|
320
|
+
const entry = { ...board, health: null, counts: null, asks: openAsks(fr.folded, board.id), nextRelease: null };
|
|
321
|
+
if (pending) entry.pending = true;
|
|
322
|
+
if (error) entry.error = error;
|
|
323
|
+
if (base) {
|
|
324
|
+
const rows = rowsOfBoard(fr, board, base);
|
|
325
|
+
const c = B.counts(rows, board.workflow);
|
|
326
|
+
entry.health = B.health(rows);
|
|
327
|
+
entry.counts = { total: c.total, open: c.open, byCat: c.byCat, risk: c.risk, late: c.late };
|
|
328
|
+
entry.nextRelease = nextReleaseOf(releases, new Set(rows.map((r) => r.id)));
|
|
329
|
+
}
|
|
330
|
+
out.push(entry);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return { ok: true, today: today(), boards: out };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Resolve ws + board of a GET, and the board's rows; answers and returns null when it cannot. */
|
|
337
|
+
function boardRead(ctx, res, url, { rows = true } = {}) {
|
|
338
|
+
const w = wsOf(ctx, url.searchParams.get('ws'));
|
|
339
|
+
if (!w) { fail(ctx, res, 404, 'no such workspace'); return null; }
|
|
340
|
+
const fr = frameFor(ctx, w);
|
|
341
|
+
const bid = url.searchParams.get('board') || w.id;
|
|
342
|
+
const board = fr.boards.find((b) => b.id === bid);
|
|
343
|
+
if (!board) { fail(ctx, res, 404, `no board ${bid} in this workspace`); return null; }
|
|
344
|
+
if (!rows) return { w, fr, board, rows: null };
|
|
345
|
+
const b = baseRows(w);
|
|
346
|
+
if (b.pending) { ctx.json(res, { ok: true, pending: true, ws: w.id, board: board.id }, 202); return null; }
|
|
347
|
+
if (!b.ok) { fail(ctx, res, 500, b.error); return null; }
|
|
348
|
+
return { w, fr, board, base: b.rows, rows: rowsOfBoard(fr, board, b.rows) };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const READS = {
|
|
352
|
+
'/api/boards/board': (ctx, res, url) => {
|
|
353
|
+
const r = boardRead(ctx, res, url);
|
|
354
|
+
if (!r) return;
|
|
355
|
+
const c = B.counts(r.rows, r.board.workflow);
|
|
356
|
+
const recent = r.fr.folded.moves.filter((m) => m.board === r.board.id).slice(-10).reverse().map(pageMove);
|
|
357
|
+
ctx.json(res, { ok: true, board: { ...r.board, health: B.health(r.rows) }, counts: { total: c.total, open: c.open, byStage: c.byStage, byStatus: c.byStatus, byCat: c.byCat },
|
|
358
|
+
asks: openAsks(r.fr.folded, r.board.id), nextRelease: nextReleaseOf(readReleases(ctx), new Set(r.rows.map((x) => x.id))), recent });
|
|
359
|
+
},
|
|
360
|
+
'/api/boards/backlog': (ctx, res, url) => {
|
|
361
|
+
const r = boardRead(ctx, res, url);
|
|
362
|
+
if (!r) return;
|
|
363
|
+
const q = Object.fromEntries(['q', 'stage', 'status', 'owner', 'horizon', 'anchor', 'cat', 'view', 'sort', 'offset', 'limit', 'me'].map((k) => [k, url.searchParams.get(k)]));
|
|
364
|
+
if (!q.me) q.me = actorLib.PM_SLUG;
|
|
365
|
+
const out = B.filterRows(r.rows, { ...q, stages: r.board.workflow.map((s) => s.name) });
|
|
366
|
+
ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, ...out });
|
|
367
|
+
},
|
|
368
|
+
'/api/boards/columns': (ctx, res, url) => {
|
|
369
|
+
const r = boardRead(ctx, res, url);
|
|
370
|
+
if (!r) return;
|
|
371
|
+
let cap = Number.parseInt(url.searchParams.get('cap'), 10);
|
|
372
|
+
if (!Number.isFinite(cap) || cap < 0) cap = 8;
|
|
373
|
+
cap = Math.min(cap, 100);
|
|
374
|
+
const q = (url.searchParams.get('q') || '').trim().toLowerCase();
|
|
375
|
+
const owner = url.searchParams.get('owner');
|
|
376
|
+
let rows = r.rows;
|
|
377
|
+
if (q) rows = rows.filter((x) => String(x.title).toLowerCase().includes(q) || x.id.includes(q));
|
|
378
|
+
if (owner) rows = rows.filter((x) => (owner === 'none' ? !x.owner : String(x.owner || '').toLowerCase() === owner.toLowerCase()));
|
|
379
|
+
rows = B.filterRows(rows, { view: 'all', sort: 'rank', limit: rows.length || 1, all: true }).rows;
|
|
380
|
+
const columns = r.board.workflow.map((s) => {
|
|
381
|
+
const inCol = rows.filter((x) => x.stage === s.name);
|
|
382
|
+
return { stage: s.name, cat: s.cat, hint: s.hint || '', count: inCol.length, rows: inCol.slice(0, cap) };
|
|
383
|
+
});
|
|
384
|
+
ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, columns });
|
|
385
|
+
},
|
|
386
|
+
'/api/boards/initiative': (ctx, res, url) => {
|
|
387
|
+
const r = boardRead(ctx, res, url);
|
|
388
|
+
if (!r) return;
|
|
389
|
+
const id = url.searchParams.get('id') || '';
|
|
390
|
+
const base = r.base.find((x) => x.id === id);
|
|
391
|
+
if (!base) return fail(ctx, res, 404, `no work-lane initiative ${id} in this workspace`);
|
|
392
|
+
const own = r.fr.boards.find((b) => b.id === boardIdOf(base, r.fr.boards, r.fr.folded, r.w)) || r.board;
|
|
393
|
+
const row = boardRow(own, base, r.fr.folded);
|
|
394
|
+
const F = r.fr.folded;
|
|
395
|
+
const history = [];
|
|
396
|
+
for (const m of F.moves) if (m.id === id) history.push({ kind: m.via === 'move' ? 'move' : m.via, ...pageMove(m) });
|
|
397
|
+
for (const e of F.events) {
|
|
398
|
+
if (e.kind === 'assign' && e.id === id) history.push({ kind: 'assign', at: e.at || null, by: actorLib.pageName(e.by || null), board: e.board || null, to: e.to });
|
|
399
|
+
if (e.kind === 'ini-workflow' && e.id === id && Array.isArray(e.stages)) history.push({ kind: 'workflow', at: e.at || null, by: actorLib.pageName(e.by || null), board: e.board || null, template: e.template || null, stages: e.stages.map((x) => x && x.name).filter(Boolean) });
|
|
400
|
+
}
|
|
401
|
+
history.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')));
|
|
402
|
+
const kind = row.template && B.INI_TEMPLATES[row.template] ? B.INI_TEMPLATES[row.template].kind : own.template;
|
|
403
|
+
const tools = (B.TOOLS[kind] || B.TOOLS.product)[row.cat] || [];
|
|
404
|
+
ctx.json(res, { ok: true, ws: r.w.id, row, board: own, history, tools, toolsByCat: B.TOOLS[kind] || B.TOOLS.product });
|
|
405
|
+
},
|
|
406
|
+
'/api/boards/asks': (ctx, res, url) => {
|
|
407
|
+
const r = boardRead(ctx, res, url, { rows: false });
|
|
408
|
+
if (!r) return;
|
|
409
|
+
const state = url.searchParams.get('state') || 'open';
|
|
410
|
+
if (!['open', 'accepted', 'declined', 'all'].includes(state)) return fail(ctx, res, 400, 'state is open, accepted, declined or all');
|
|
411
|
+
const asks = [...r.fr.folded.asks.values()].filter((a) => a.board === r.board.id && (state === 'all' || a.state === state))
|
|
412
|
+
.sort((a, b) => String(b.at || '').localeCompare(String(a.at || ''))).map(pageAsk);
|
|
413
|
+
ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, asks });
|
|
414
|
+
},
|
|
415
|
+
'/api/boards/items': (ctx, res, url) => {
|
|
416
|
+
const r = boardRead(ctx, res, url);
|
|
417
|
+
if (!r) return;
|
|
418
|
+
ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, build: r.board.build, jira: r.board.jira, items: itemsOf(r.w, r.fr, r.board, r.rows),
|
|
419
|
+
initiatives: r.rows.map((x) => ({ id: x.id, title: x.title, stage: x.stage, cat: x.cat, status: x.status, owner: x.owner, horizon: x.horizon, start: x.start, end: x.end, progress: x.progress, template: x.template })) });
|
|
420
|
+
},
|
|
421
|
+
'/api/boards/sprints': (ctx, res, url) => {
|
|
422
|
+
const r = boardRead(ctx, res, url);
|
|
423
|
+
if (!r) return;
|
|
424
|
+
const ids = new Set(r.rows.map((x) => x.id));
|
|
425
|
+
const snaps = [];
|
|
426
|
+
try {
|
|
427
|
+
for (const q of jiraStatus.linkedInitiatives(r.w.root).filter((x) => x.linked)) {
|
|
428
|
+
const s = jiraStatus.readSnapshot(r.w.root, q.initiative);
|
|
429
|
+
if (s) snaps.push({ initiative: q.initiative, fetchedAt: s.fetchedAt, tickets: s.tickets });
|
|
430
|
+
}
|
|
431
|
+
} catch { /* no snapshot yet */ }
|
|
432
|
+
ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, ...B.sprintsOf(snaps, (id) => ids.has(id)) });
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// ── writes ────────────────────────────────────────────────────────────────────────────────────────
|
|
437
|
+
/** The workspace, its frame and the named board of a POST body; answers and returns null when it cannot. */
|
|
438
|
+
function postFrame(ctx, res, body, { needBoard = true } = {}) {
|
|
439
|
+
const w = wsOf(ctx, typeof body.ws === 'string' ? body.ws : null);
|
|
440
|
+
if (!w) { fail(ctx, res, 404, 'no such workspace'); return null; }
|
|
441
|
+
const fr = frameFor(ctx, w);
|
|
442
|
+
let board = null;
|
|
443
|
+
if (needBoard) {
|
|
444
|
+
const bid = typeof body.board === 'string' && body.board ? body.board : null;
|
|
445
|
+
if (!bid) { fail(ctx, res, 400, 'board is required'); return null; }
|
|
446
|
+
board = fr.boards.find((b) => b.id === bid);
|
|
447
|
+
if (!board) { fail(ctx, res, 404, `no board ${bid} in this workspace`); return null; }
|
|
448
|
+
}
|
|
449
|
+
return { w, fr, board, file: ledgerFile(ctx, w) };
|
|
450
|
+
}
|
|
451
|
+
function needRows(ctx, res, w) {
|
|
452
|
+
const b = baseRows(w);
|
|
453
|
+
if (b.pending) { ctx.json(res, { ok: false, pending: true, ws: w.id, error: PENDING }, 202); return null; }
|
|
454
|
+
if (!b.ok) { fail(ctx, res, 500, b.error); return null; }
|
|
455
|
+
return b.rows;
|
|
456
|
+
}
|
|
457
|
+
const slug = (s) => String(s || '').toLowerCase().normalize('NFKD').replace(/[^\x00-\x7f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
|
|
458
|
+
|
|
459
|
+
/** The Build and deliver settings of a create or settings body: { value } (only the keys sent) or { error }. */
|
|
460
|
+
function settingsOf(body, board) {
|
|
461
|
+
const v = {};
|
|
462
|
+
const bd = body.buildDeliver != null ? body.buildDeliver : body.build;
|
|
463
|
+
if (bd != null) { if (typeof bd !== 'boolean') return { error: 'buildDeliver is true or false' }; v.buildDeliver = bd; }
|
|
464
|
+
if (body.jira != null && body.jira !== '') {
|
|
465
|
+
if (typeof body.jira !== 'string' || !/^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(body.jira.trim())) return { error: 'jira is a Jira project key: a letter, then letters, digits or _ (at most 20)' };
|
|
466
|
+
v.jira = body.jira.trim().toUpperCase();
|
|
467
|
+
} else if (body.jira === '') v.jira = null;
|
|
468
|
+
const build = 'buildDeliver' in v ? v.buildDeliver : board ? board.build : true;
|
|
469
|
+
if (body.offer != null) {
|
|
470
|
+
if (!Array.isArray(body.offer) || !body.offer.length || body.offer.some((k) => !B.INI_TEMPLATES[k])) return { error: `offer is a list of templates: ${Object.keys(B.INI_TEMPLATES).join(', ')}` };
|
|
471
|
+
const blocked = body.offer.filter((k) => !build && B.templateBuilds(k));
|
|
472
|
+
if (blocked.length) return { error: `${blocked.join(', ')} need Build and deliver on` };
|
|
473
|
+
v.offer = [...new Set(body.offer)];
|
|
474
|
+
}
|
|
475
|
+
if (body.defTpl != null) {
|
|
476
|
+
if (!B.INI_TEMPLATES[body.defTpl]) return { error: `defTpl is one of ${Object.keys(B.INI_TEMPLATES).join(', ')}` };
|
|
477
|
+
const offer = v.offer || (board ? board.offer : Object.keys(B.INI_TEMPLATES));
|
|
478
|
+
if (!offer.includes(body.defTpl)) return { error: `defTpl must be a template the board offers (${offer.join(', ')})` };
|
|
479
|
+
if (!build && B.templateBuilds(body.defTpl)) return { error: `${body.defTpl} needs Build and deliver on` };
|
|
480
|
+
v.defTpl = body.defTpl;
|
|
481
|
+
}
|
|
482
|
+
return { value: v };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const WRITES = {
|
|
486
|
+
'/api/boards/create': (ctx, res, body, g) => {
|
|
487
|
+
const p = postFrame(ctx, res, body, { needBoard: false });
|
|
488
|
+
if (!p) return;
|
|
489
|
+
if (!ONE_LINE(body.name, 60)) return fail(ctx, res, 400, 'give a board name of 1 to 60 characters on one line');
|
|
490
|
+
if (!B.WORKFLOW_TEMPLATES[body.template]) return fail(ctx, res, 400, `template must be one of ${Object.keys(B.WORKFLOW_TEMPLATES).join(', ')}`);
|
|
491
|
+
if (body.domain != null && body.domain !== '' && !ONE_LINE(body.domain, 60)) return fail(ctx, res, 400, 'domain is one line of at most 60 characters');
|
|
492
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
493
|
+
const name = body.name.trim();
|
|
494
|
+
if (p.fr.boards.some((b) => b.name.toLowerCase() === name.toLowerCase())) return fail(ctx, res, 409, `a board named "${name}" exists already in this workspace`);
|
|
495
|
+
const ids = new Set(p.fr.boards.map((b) => b.id));
|
|
496
|
+
const stem = slug(name) || 'board';
|
|
497
|
+
let id = stem;
|
|
498
|
+
for (let n = 2; ids.has(id); n++) id = `${stem}-${n}`;
|
|
499
|
+
const workflow = B.templateWorkflow(body.template);
|
|
500
|
+
const set = settingsOf(body, null);
|
|
501
|
+
if (set.error) return fail(ctx, res, 400, set.error);
|
|
502
|
+
const ev = { kind: 'board', op: 'create', board: id, name, domain: body.domain ? String(body.domain).trim() : null, template: body.template, workflow, sprints: false, tracker: null };
|
|
503
|
+
for (const k of ['buildDeliver', 'jira', 'offer', 'defTpl']) if (k in set.value) ev[k] = set.value[k];
|
|
504
|
+
if (ev.buildDeliver !== false && ev.jira) { ev.tracker = `Jira · ${ev.jira}`; ev.sprints = true; }
|
|
505
|
+
append(p.file, g.actor, ev);
|
|
506
|
+
const board = boardsOf(ctx, p.w, B.fold(readLedger(ctx, p.w))).find((b) => b.id === id);
|
|
507
|
+
ctx.json(res, { ok: true, board });
|
|
508
|
+
},
|
|
509
|
+
'/api/boards/workflow': (ctx, res, body, g) => {
|
|
510
|
+
const p = postFrame(ctx, res, body);
|
|
511
|
+
if (!p) return;
|
|
512
|
+
if (p.board.self) return fail(ctx, res, 409, SELF_REFUSAL);
|
|
513
|
+
if (body.sprints != null && typeof body.sprints !== 'boolean') return fail(ctx, res, 400, 'sprints is true or false');
|
|
514
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
515
|
+
const base = needRows(ctx, res, p.w);
|
|
516
|
+
if (!base) return;
|
|
517
|
+
const rowsByStage = {};
|
|
518
|
+
for (const r of rowsOfBoard(p.fr, p.board, base)) (rowsByStage[r.stage] = rowsByStage[r.stage] || []).push(r.id);
|
|
519
|
+
const ed = B.applyWorkflowEdit(p.board, body.stages, body.moveRemoved, rowsByStage);
|
|
520
|
+
if (!ed.ok) return fail(ctx, res, ed.status, ed.error);
|
|
521
|
+
const ev = { kind: 'workflow', board: p.board.id, stages: ed.workflow, renamed: ed.renamed, moved: ed.moved };
|
|
522
|
+
if (typeof body.sprints === 'boolean') ev.sprints = body.sprints;
|
|
523
|
+
append(p.file, g.actor, ev);
|
|
524
|
+
const board = boardsOf(ctx, p.w, B.fold(readLedger(ctx, p.w))).find((b) => b.id === p.board.id);
|
|
525
|
+
ctx.json(res, { ok: true, board, moved: ed.moved });
|
|
526
|
+
},
|
|
527
|
+
'/api/boards/move': (ctx, res, body, g) => {
|
|
528
|
+
const p = postFrame(ctx, res, body);
|
|
529
|
+
if (!p) return;
|
|
530
|
+
if (p.board.self) return fail(ctx, res, 409, SELF_REFUSAL);
|
|
531
|
+
const wfOwn = typeof body.id === 'string' ? B.workflowOf(p.board, body.id, p.fr.folded) : p.board.workflow;
|
|
532
|
+
if (typeof body.to !== 'string' || !wfOwn.some((s) => s.name === body.to)) {
|
|
533
|
+
return fail(ctx, res, 400, `"${body.to}" is not a stage of this ${wfOwn === p.board.workflow ? 'board' : 'initiative'}'s workflow (${wfOwn.map((s) => s.name).join(', ')})`);
|
|
534
|
+
}
|
|
535
|
+
if (body.why != null && (typeof body.why !== 'string' || body.why.length > 500)) return fail(ctx, res, 400, 'why is text of at most 500 characters');
|
|
536
|
+
if (typeof body.id !== 'string' || !body.id) return fail(ctx, res, 400, 'id is required');
|
|
537
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
538
|
+
const base = needRows(ctx, res, p.w);
|
|
539
|
+
if (!base) return;
|
|
540
|
+
const b0 = base.find((r) => r.id === body.id);
|
|
541
|
+
if (!b0 || boardIdOf(b0, p.fr.boards, p.fr.folded, p.w) !== p.board.id) return fail(ctx, res, 404, `no initiative ${body.id} on this board`);
|
|
542
|
+
const before = boardRow(p.board, b0, p.fr.folded);
|
|
543
|
+
const warnings = B.moveWarnings(p.board, before, before.stage, body.to, { hasSpec: b0.hasSpec });
|
|
544
|
+
append(p.file, g.actor, { kind: 'move', board: p.board.id, id: body.id, from: before.stage, to: body.to, why: body.why || '', warnings });
|
|
545
|
+
const row = boardRow(p.board, b0, B.fold(readLedger(ctx, p.w)));
|
|
546
|
+
ctx.json(res, { ok: true, row, warnings });
|
|
547
|
+
},
|
|
548
|
+
'/api/boards/assign': (ctx, res, body, g) => {
|
|
549
|
+
const p = postFrame(ctx, res, body, { needBoard: false });
|
|
550
|
+
if (!p) return;
|
|
551
|
+
if (typeof body.id !== 'string' || !body.id) return fail(ctx, res, 400, 'id is required');
|
|
552
|
+
const to = p.fr.boards.find((b) => b.id === body.to);
|
|
553
|
+
if (!to) return fail(ctx, res, 404, `no board ${body.to} in this workspace`);
|
|
554
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
555
|
+
const base = needRows(ctx, res, p.w);
|
|
556
|
+
if (!base) return;
|
|
557
|
+
const b0 = base.find((r) => r.id === body.id);
|
|
558
|
+
if (!b0) return fail(ctx, res, 404, `no work-lane initiative ${body.id} in this workspace`);
|
|
559
|
+
append(p.file, g.actor, { kind: 'assign', board: boardIdOf(b0, p.fr.boards, p.fr.folded, p.w), id: body.id, to: to.id });
|
|
560
|
+
ctx.json(res, { ok: true });
|
|
561
|
+
},
|
|
562
|
+
'/api/boards/asks/add': (ctx, res, body, g) => {
|
|
563
|
+
const p = postFrame(ctx, res, body);
|
|
564
|
+
if (!p) return;
|
|
565
|
+
if (!ONE_LINE(body.line, 500)) return fail(ctx, res, 400, 'line is one line of 1 to 500 characters');
|
|
566
|
+
if (body.source != null && !ONE_LINE(body.source, 120)) return fail(ctx, res, 400, 'source is one line of at most 120 characters');
|
|
567
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
568
|
+
const draft = intake.intakeDraft({ line: body.line, root: p.w.root });
|
|
569
|
+
const id = `ask-${crypto.randomBytes(5).toString('hex')}`;
|
|
570
|
+
append(p.file, g.actor, { kind: 'ask', board: p.board.id, ask: id, line: body.line.trim(), source: body.source ? body.source.trim() : null, draft });
|
|
571
|
+
const ask = B.fold(readLedger(ctx, p.w)).asks.get(id);
|
|
572
|
+
ctx.json(res, { ok: true, ask: pageAsk(ask), draft });
|
|
573
|
+
},
|
|
574
|
+
'/api/boards/asks/accept': (ctx, res, body, g) => {
|
|
575
|
+
const p = postFrame(ctx, res, body);
|
|
576
|
+
if (!p) return;
|
|
577
|
+
const a = p.fr.folded.asks.get(body.ask);
|
|
578
|
+
if (!a || a.board !== p.board.id) return fail(ctx, res, 404, `no ask ${body.ask} on this board`);
|
|
579
|
+
if (a.state !== 'open') return fail(ctx, res, 409, `ask ${a.id} is ${a.state} already`);
|
|
580
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
581
|
+
const input = { title: body.title, lane: body.lane, anchor: body.anchor };
|
|
582
|
+
for (const k of ['intent', 'id']) if (body[k] != null && body[k] !== '') input[k] = body[k];
|
|
583
|
+
const r = writer.createInitiative(p.w.root, input, { actor: g.actor });
|
|
584
|
+
if (!r.ok) return fail(ctx, res, r.status || 400, r.error, { field: r.field || null });
|
|
585
|
+
const tpl = p.board.defTpl && B.INI_TEMPLATES[p.board.defTpl] ? p.board.defTpl : null;
|
|
586
|
+
const ownWf = tpl && tpl !== p.board.template ? B.INI_TEMPLATES[tpl].stages.map((x) => ({ name: x.name, cat: x.cat, hint: x.hint || '' })) : null;
|
|
587
|
+
const stage = (ownWf || p.board.workflow)[0].name;
|
|
588
|
+
append(p.file, g.actor, { kind: 'ask-accept', board: p.board.id, ask: a.id, initiative: r.id, stage });
|
|
589
|
+
if (ownWf) append(p.file, g.actor, { kind: 'ini-workflow', board: p.board.id, id: r.id, template: tpl, stages: ownWf, moved: null });
|
|
590
|
+
rowsLib.invalidate(p.w.root);
|
|
591
|
+
rowCache.delete(p.w.root);
|
|
592
|
+
if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(p.w.id);
|
|
593
|
+
ctx.json(res, { ok: true, initiative: { id: r.id, path: r.path }, stage });
|
|
594
|
+
},
|
|
595
|
+
'/api/boards/asks/more': (ctx, res, body, g) => {
|
|
596
|
+
const p = postFrame(ctx, res, body);
|
|
597
|
+
if (!p) return;
|
|
598
|
+
const a = p.fr.folded.asks.get(body.ask);
|
|
599
|
+
if (!a || a.board !== p.board.id) return fail(ctx, res, 404, `no ask ${body.ask} on this board`);
|
|
600
|
+
if (a.state !== 'open') return fail(ctx, res, 409, `ask ${a.id} is ${a.state} already`);
|
|
601
|
+
if (body.note != null && body.note !== '' && !ONE_LINE(body.note, 500)) return fail(ctx, res, 400, 'note is one line of at most 500 characters');
|
|
602
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
603
|
+
append(p.file, g.actor, { kind: 'ask-more', board: p.board.id, ask: a.id, note: body.note ? String(body.note).trim() : '' });
|
|
604
|
+
ctx.json(res, { ok: true });
|
|
605
|
+
},
|
|
606
|
+
'/api/boards/settings': (ctx, res, body, g) => {
|
|
607
|
+
const p = postFrame(ctx, res, body);
|
|
608
|
+
if (!p) return;
|
|
609
|
+
if (p.board.self) return fail(ctx, res, 409, SELF_REFUSAL);
|
|
610
|
+
const set = settingsOf(body, p.board);
|
|
611
|
+
if (set.error) return fail(ctx, res, 400, set.error);
|
|
612
|
+
if (!Object.keys(set.value).length) return fail(ctx, res, 400, 'say what changes: buildDeliver, jira, offer or defTpl');
|
|
613
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
614
|
+
const ev = { kind: 'board', op: 'settings', board: p.board.id, ...set.value };
|
|
615
|
+
const build = 'buildDeliver' in set.value ? set.value.buildDeliver : p.board.build;
|
|
616
|
+
const jira = 'jira' in set.value ? set.value.jira : p.board.jira;
|
|
617
|
+
ev.tracker = build && jira ? `Jira · ${jira}` : null;
|
|
618
|
+
ev.sprints = !!(build && jira);
|
|
619
|
+
append(p.file, g.actor, ev);
|
|
620
|
+
const board = boardsOf(ctx, p.w, B.fold(readLedger(ctx, p.w))).find((b) => b.id === p.board.id);
|
|
621
|
+
ctx.json(res, { ok: true, board });
|
|
622
|
+
},
|
|
623
|
+
'/api/boards/ini-workflow': (ctx, res, body, g) => {
|
|
624
|
+
const p = postFrame(ctx, res, body);
|
|
625
|
+
if (!p) return;
|
|
626
|
+
if (p.board.self) return fail(ctx, res, 409, SELF_REFUSAL);
|
|
627
|
+
if (typeof body.id !== 'string' || !body.id) return fail(ctx, res, 400, 'id is required');
|
|
628
|
+
if (body.template != null && body.template !== '' && !p.board.offer.includes(body.template)) return fail(ctx, res, 400, `${p.board.name} does not offer the template "${body.template}" (it offers ${p.board.offer.join(', ')})`);
|
|
629
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
630
|
+
const base = needRows(ctx, res, p.w);
|
|
631
|
+
if (!base) return;
|
|
632
|
+
const b0 = base.find((r) => r.id === body.id);
|
|
633
|
+
if (!b0 || boardIdOf(b0, p.fr.boards, p.fr.folded, p.w) !== p.board.id) return fail(ctx, res, 404, `no initiative ${body.id} on this board`);
|
|
634
|
+
const before = boardRow(p.board, b0, p.fr.folded);
|
|
635
|
+
const stagesIn = Array.isArray(body.stages) ? body.stages.map((x) => (isObj(x) && x.was != null && !before.workflow.some((s) => s.name === x.was) ? { ...x, was: null } : x)) : body.stages;
|
|
636
|
+
const ed = B.applyIniWorkflow(p.board, B.workflowOf(p.board, body.id, p.fr.folded), before.stage, body.template || null, stagesIn, body.moveTo);
|
|
637
|
+
if (!ed.ok) return fail(ctx, res, ed.status, ed.error);
|
|
638
|
+
append(p.file, g.actor, { kind: 'ini-workflow', board: p.board.id, id: body.id, template: ed.template, stages: ed.workflow, renamed: ed.renamed, moved: ed.moved });
|
|
639
|
+
const row = boardRow(p.board, b0, B.fold(readLedger(ctx, p.w)));
|
|
640
|
+
ctx.json(res, { ok: true, row, moved: ed.moved });
|
|
641
|
+
},
|
|
642
|
+
'/api/boards/tasks/add': (ctx, res, body, g) => {
|
|
643
|
+
const p = postFrame(ctx, res, body);
|
|
644
|
+
if (!p) return;
|
|
645
|
+
if (!ONE_LINE(body.title, 200)) return fail(ctx, res, 400, 'title is one line of 1 to 200 characters');
|
|
646
|
+
if (typeof body.initiative !== 'string' || !body.initiative) return fail(ctx, res, 400, 'initiative is required');
|
|
647
|
+
if (body.type != null && !['Task', 'Epic'].includes(body.type)) return fail(ctx, res, 400, 'type is Task or Epic');
|
|
648
|
+
for (const k of ['start', 'due']) if (body[k] != null && body[k] !== '' && !/^\d{4}-\d{2}-\d{2}$/.test(String(body[k]))) return fail(ctx, res, 400, `${k} is a day, YYYY-MM-DD`);
|
|
649
|
+
if (body.owner != null && body.owner !== '' && !ONE_LINE(body.owner, 80)) return fail(ctx, res, 400, 'owner is one line of at most 80 characters');
|
|
650
|
+
if (body.epic != null && body.epic !== '' && !ONE_LINE(body.epic, 80)) return fail(ctx, res, 400, 'epic is one line of at most 80 characters');
|
|
651
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
652
|
+
const base = needRows(ctx, res, p.w);
|
|
653
|
+
if (!base) return;
|
|
654
|
+
const b0 = base.find((r) => r.id === body.initiative);
|
|
655
|
+
if (!b0 || boardIdOf(b0, p.fr.boards, p.fr.folded, p.w) !== p.board.id) return fail(ctx, res, 404, `no initiative ${body.initiative} on this board`);
|
|
656
|
+
const id = `task-${crypto.randomBytes(5).toString('hex')}`;
|
|
657
|
+
const start = body.start || B.shiftDay(today(), 1);
|
|
658
|
+
const due = body.due || B.shiftDay(start, 7);
|
|
659
|
+
append(p.file, g.actor, { kind: 'task', board: p.board.id, task: id, type: body.type || 'Task', initiative: body.initiative, title: body.title.trim(), epic: body.epic ? String(body.epic).trim() : null,
|
|
660
|
+
owner: body.owner ? String(body.owner).trim() : actorLib.pageName(g.actor.by), state: 'To do', start, due });
|
|
661
|
+
const t = B.taskOf(B.fold(readLedger(ctx, p.w)), id);
|
|
662
|
+
ctx.json(res, { ok: true, item: B.rollupItems([B.taskItem(t)])[0] });
|
|
663
|
+
},
|
|
664
|
+
'/api/boards/tasks/state': (ctx, res, body, g) => {
|
|
665
|
+
const p = postFrame(ctx, res, body);
|
|
666
|
+
if (!p) return;
|
|
667
|
+
const t = B.taskOf(p.fr.folded, body.task);
|
|
668
|
+
if (!t || t.board !== p.board.id) return fail(ctx, res, 404, `no PMOS task ${body.task} on this board`);
|
|
669
|
+
if (!B.TASK_STATES.includes(body.state)) return fail(ctx, res, 400, `state is one of ${B.TASK_STATES.join(', ')}`);
|
|
670
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
671
|
+
append(p.file, g.actor, { kind: 'task-update', board: p.board.id, task: t.id, state: body.state, why: 'status move' });
|
|
672
|
+
const t2 = B.taskOf(B.fold(readLedger(ctx, p.w)), t.id);
|
|
673
|
+
ctx.json(res, { ok: true, item: B.rollupItems([B.taskItem(t2)])[0] });
|
|
674
|
+
},
|
|
675
|
+
'/api/boards/tasks/reschedule': (ctx, res, body, g) => {
|
|
676
|
+
const p = postFrame(ctx, res, body);
|
|
677
|
+
if (!p) return;
|
|
678
|
+
const t = B.taskOf(p.fr.folded, body.task);
|
|
679
|
+
if (!t || t.board !== p.board.id) return fail(ctx, res, 404, `no PMOS task ${body.task} on this board`);
|
|
680
|
+
const days = Number(body.days);
|
|
681
|
+
if (!Number.isInteger(days) || days === 0 || Math.abs(days) > 730) return fail(ctx, res, 400, 'days is a whole number of days, not 0, at most 730 either way');
|
|
682
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
683
|
+
const start = B.shiftDay(t.start || B.shiftDay(t.due || today(), -7), days);
|
|
684
|
+
const due = B.shiftDay(t.due || B.shiftDay(t.start || today(), 7), days);
|
|
685
|
+
append(p.file, g.actor, { kind: 'task-update', board: p.board.id, task: t.id, start, due, why: `rescheduled ${days > 0 ? '+' : ''}${days} days` });
|
|
686
|
+
const t2 = B.taskOf(B.fold(readLedger(ctx, p.w)), t.id);
|
|
687
|
+
ctx.json(res, { ok: true, item: B.rollupItems([B.taskItem(t2)])[0] });
|
|
688
|
+
},
|
|
689
|
+
'/api/boards/asks/decline': (ctx, res, body, g) => {
|
|
690
|
+
const p = postFrame(ctx, res, body);
|
|
691
|
+
if (!p) return;
|
|
692
|
+
const a = p.fr.folded.asks.get(body.ask);
|
|
693
|
+
if (!a || a.board !== p.board.id) return fail(ctx, res, 404, `no ask ${body.ask} on this board`);
|
|
694
|
+
if (a.state !== 'open') return fail(ctx, res, 409, `ask ${a.id} is ${a.state} already`);
|
|
695
|
+
if (!ONE_LINE(body.why, 500)) return fail(ctx, res, 400, 'a decline needs a reason (why) of 1 to 500 characters');
|
|
696
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
697
|
+
append(p.file, g.actor, { kind: 'ask-decline', board: p.board.id, ask: a.id, why: body.why.trim() });
|
|
698
|
+
ctx.json(res, { ok: true });
|
|
699
|
+
},
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
async function handle(req, res, url, ctx) {
|
|
703
|
+
const p = url.pathname;
|
|
704
|
+
if (req.method === 'GET') {
|
|
705
|
+
if (p === '/api/boards') { ctx.json(res, portfolio(ctx)); return true; }
|
|
706
|
+
const fn = READS[p];
|
|
707
|
+
if (!fn) return fail(ctx, res, 404, 'not found');
|
|
708
|
+
fn(ctx, res, url);
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
if (req.method !== 'POST') return fail(ctx, res, 405, 'GET or POST only');
|
|
712
|
+
const fn = WRITES[p];
|
|
713
|
+
if (!fn) return fail(ctx, res, 404, 'not found');
|
|
714
|
+
const got = await ctx.readJson(req);
|
|
715
|
+
if (!got.ok) return fail(ctx, res, 400, 'invalid JSON body');
|
|
716
|
+
const body = got.body;
|
|
717
|
+
if (!isObj(body)) return fail(ctx, res, 400, 'the body must be a JSON object');
|
|
718
|
+
const g = guard.requirePagePress(req, body);
|
|
719
|
+
if (!g.ok) return fail(ctx, res, g.status, g.error);
|
|
720
|
+
fn(ctx, res, body, g);
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** The board of each initiative of a workspace, for other routes (Reports): { boards: [{ id, title }], of(id) → board id }.
|
|
725
|
+
* Rows still being read fall back to the ledger's assignment, else the default board. */
|
|
726
|
+
function boardIndex(ctx, w) {
|
|
727
|
+
const fr = frameFor(ctx, w);
|
|
728
|
+
const got = baseRows(w);
|
|
729
|
+
const byId = new Map();
|
|
730
|
+
if (got.ok) for (const r of got.rows) byId.set(r.id, boardIdOf(r, fr.boards, fr.folded, w));
|
|
731
|
+
return { boards: fr.boards.map((b) => ({ id: b.id, title: b.name })), of: (id) => byId.get(id) || boardIdOf({ id }, fr.boards, fr.folded, w) };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
module.exports = { prefixes: ['/api/boards'], handle, boardIndex, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
|