@unbrained/pm-web 2026.9.23 → 2026.10.5
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 +22 -0
- package/README.md +64 -6
- package/dist/app.js +8 -5
- package/dist/app.js.map +1 -1
- package/dist/db.d.ts +2 -2
- package/dist/db.js +30 -2
- package/dist/db.js.map +1 -1
- package/dist/idempotency.d.ts +59 -0
- package/dist/idempotency.js +361 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/routes/groups.js +10 -1
- package/dist/routes/groups.js.map +1 -1
- package/dist/routes/pm.js +125 -108
- package/dist/routes/pm.js.map +1 -1
- package/dist/services/package-catalog.js +1 -1
- package/dist/services/package-catalog.js.map +1 -1
- package/dist/services/pm-runner.d.ts +3 -2
- package/dist/services/pm-runner.js +19 -6
- package/dist/services/pm-runner.js.map +1 -1
- package/manifest.json +2 -2
- package/package.json +23 -21
- package/public/src/api.js +79 -0
- package/public/src/api.js.map +1 -1
- package/public/src/api.ts +77 -0
- package/public/src/app.js +3 -1
- package/public/src/app.js.map +1 -1
- package/public/src/app.ts +3 -1
- package/public/src/offline-recovery.js +65 -0
- package/public/src/offline-recovery.js.map +1 -0
- package/public/src/offline-recovery.ts +68 -0
- package/public/src/sw.ts +431 -33
- package/public/src/views/auth.js +3 -1
- package/public/src/views/auth.js.map +1 -1
- package/public/src/views/auth.ts +3 -1
- package/public/src/views/graph.js +8 -2
- package/public/src/views/graph.js.map +1 -1
- package/public/src/views/graph.ts +9 -2
- package/public/sw.js +379 -26
- package/sql/schema.sql +25 -0
package/dist/routes/pm.js
CHANGED
|
@@ -18,6 +18,16 @@ import { pool } from "../db.js";
|
|
|
18
18
|
// Singleton Neo4j driver — reused across sync calls to avoid per-call connection overhead.
|
|
19
19
|
let _neo4jDriver = null;
|
|
20
20
|
let _neo4jDriverKey = "";
|
|
21
|
+
/** Require complete graph credentials before provisioning extensions or opening a driver. */
|
|
22
|
+
function requireNeo4jCredentials() {
|
|
23
|
+
const uri = process.env.NEO4J_URI;
|
|
24
|
+
const user = process.env.NEO4J_USER ?? process.env.NEO4J_USERNAME;
|
|
25
|
+
const password = process.env.NEO4J_PASSWORD;
|
|
26
|
+
if (!uri || !user || !password) {
|
|
27
|
+
throw new Error("Set NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD before syncing the graph.");
|
|
28
|
+
}
|
|
29
|
+
return { uri, user, password };
|
|
30
|
+
}
|
|
21
31
|
/**
|
|
22
32
|
* Return the process-wide Neo4j driver, recreating it when the connection key changes.
|
|
23
33
|
*
|
|
@@ -34,9 +44,7 @@ let _neo4jDriverKey = "";
|
|
|
34
44
|
* credential cannot leak through a value held for the lifetime of the process.
|
|
35
45
|
*/
|
|
36
46
|
function getNeo4jDriver() {
|
|
37
|
-
const uri
|
|
38
|
-
const user = process.env.NEO4J_USER ?? process.env.NEO4J_USERNAME ?? "";
|
|
39
|
-
const password = process.env.NEO4J_PASSWORD ?? "";
|
|
47
|
+
const { uri, user, password } = requireNeo4jCredentials();
|
|
40
48
|
const key = `${uri}:${user}:${createHash("sha256").update(password).digest("hex")}`;
|
|
41
49
|
if (!_neo4jDriver || _neo4jDriverKey !== key) {
|
|
42
50
|
if (_neo4jDriver) {
|
|
@@ -224,13 +232,19 @@ function normalizeDependencyKind(input) {
|
|
|
224
232
|
*/
|
|
225
233
|
function graphFromItems(items, depsByItem) {
|
|
226
234
|
const nodesById = new Map();
|
|
235
|
+
const itemIds = new Set(items.map((item) => item.id));
|
|
227
236
|
const relationships = [];
|
|
237
|
+
const relationshipKeys = new Set();
|
|
228
238
|
const addNode = (node) => {
|
|
229
239
|
if (!nodesById.has(node.id))
|
|
230
240
|
nodesById.set(node.id, node);
|
|
231
241
|
};
|
|
232
242
|
const addRelationship = (from, to, type, properties) => {
|
|
233
|
-
|
|
243
|
+
const key = JSON.stringify([from, to, type]);
|
|
244
|
+
if (relationshipKeys.has(key))
|
|
245
|
+
return;
|
|
246
|
+
relationshipKeys.add(key);
|
|
247
|
+
if (!nodesById.has(to) && !itemIds.has(to)) {
|
|
234
248
|
addNode({
|
|
235
249
|
id: to,
|
|
236
250
|
labels: ["ExternalPmItem"],
|
|
@@ -273,16 +287,11 @@ function graphFromItems(items, depsByItem) {
|
|
|
273
287
|
...(item.dependencies ?? []),
|
|
274
288
|
...(depsByItem.get(item.id) ?? []),
|
|
275
289
|
];
|
|
276
|
-
const seenDeps = new Set();
|
|
277
290
|
for (const dep of deps) {
|
|
278
291
|
const target = dependencyTarget(dep);
|
|
279
292
|
if (!target)
|
|
280
293
|
continue;
|
|
281
294
|
const type = graphRelationshipType(dep.type ?? dep.kind ?? dep.relation ?? dep.rel ?? dep.relationship);
|
|
282
|
-
const key = `${item.id}->${target}:${type}`;
|
|
283
|
-
if (seenDeps.has(key))
|
|
284
|
-
continue;
|
|
285
|
-
seenDeps.add(key);
|
|
286
295
|
addRelationship(item.id, target, type, { ...dep });
|
|
287
296
|
}
|
|
288
297
|
const facetLinks = [
|
|
@@ -319,7 +328,7 @@ function graphFromItems(items, depsByItem) {
|
|
|
319
328
|
generatedAt: new Date().toISOString(),
|
|
320
329
|
source: "pm-web",
|
|
321
330
|
nodes: Array.from(nodesById.values()),
|
|
322
|
-
relationships
|
|
331
|
+
relationships,
|
|
323
332
|
};
|
|
324
333
|
}
|
|
325
334
|
function graphProjectKey(project) {
|
|
@@ -338,12 +347,6 @@ function graphProjectKey(project) {
|
|
|
338
347
|
* @returns The number of nodes and relationships written from the graph.
|
|
339
348
|
*/
|
|
340
349
|
async function syncGraphToNeo4j(graph, projectKey) {
|
|
341
|
-
const uri = process.env.NEO4J_URI;
|
|
342
|
-
const user = process.env.NEO4J_USER ?? process.env.NEO4J_USERNAME;
|
|
343
|
-
const password = process.env.NEO4J_PASSWORD;
|
|
344
|
-
if (!uri || !user || !password) {
|
|
345
|
-
throw new Error("Set NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD before syncing the graph.");
|
|
346
|
-
}
|
|
347
350
|
const driver = getNeo4jDriver();
|
|
348
351
|
const session = driver.session({ database: process.env.NEO4J_DATABASE });
|
|
349
352
|
try {
|
|
@@ -361,7 +364,9 @@ async function syncGraphToNeo4j(graph, projectKey) {
|
|
|
361
364
|
}
|
|
362
365
|
return { syncedNodes: graph.nodes.length, syncedRelationships: graph.relationships.length };
|
|
363
366
|
}
|
|
367
|
+
/** Validate graph configuration before assembling a project graph or provisioning its extension. */
|
|
364
368
|
async function syncProjectGraph(project) {
|
|
369
|
+
requireNeo4jCredentials();
|
|
365
370
|
const extensionGraph = await pmGraphExtensionGraphForProject(project);
|
|
366
371
|
const graph = extensionGraph.graph ?? await fallbackGraphForProject(project.ownerUserId, project.slug);
|
|
367
372
|
return syncGraphToNeo4j(graph, graphProjectKey(project));
|
|
@@ -446,7 +451,7 @@ function itemsFromCompleteList(parsed) {
|
|
|
446
451
|
* @returns A pm-web-sourced project graph.
|
|
447
452
|
*/
|
|
448
453
|
async function fallbackGraphForProject(ownerUserId, slug) {
|
|
449
|
-
const itemsResult = await readCompletePmItems(ownerUserId, slug);
|
|
454
|
+
const itemsResult = await readCompletePmItems(ownerUserId, slug, false, true);
|
|
450
455
|
if (!itemsResult.ok)
|
|
451
456
|
throw new Error(itemsResult.stderr || "Failed to load items for graph");
|
|
452
457
|
const items = itemsFromCompleteList(itemsResult.result);
|
|
@@ -457,8 +462,8 @@ async function fallbackGraphForProject(ownerUserId, slug) {
|
|
|
457
462
|
/**
|
|
458
463
|
* Fetch a project graph from the `pm-graph` extension, when installed.
|
|
459
464
|
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
465
|
+
* Used only by edit-protected graph sync. Provisions the extension if needed,
|
|
466
|
+
* then runs `pm-graph export --json` and parses its output. Returns
|
|
462
467
|
* `{ graph }` when the extension produced valid JSON with a graph, otherwise an
|
|
463
468
|
* `{ error }` so the caller can fall back to a pm-web-built graph.
|
|
464
469
|
*
|
|
@@ -467,9 +472,8 @@ async function fallbackGraphForProject(ownerUserId, slug) {
|
|
|
467
472
|
*/
|
|
468
473
|
async function pmGraphExtensionGraphForProject(project) {
|
|
469
474
|
const provision = await ensureGraphExtension(project.ownerUserId, project.slug);
|
|
470
|
-
if (!provision.ok)
|
|
475
|
+
if (!provision.ok)
|
|
471
476
|
return { error: provision.error };
|
|
472
|
-
}
|
|
473
477
|
const extensionResult = await projectPm(project, ["pm-graph", "export", "--json"], false);
|
|
474
478
|
let extensionData;
|
|
475
479
|
if (extensionResult.ok && extensionResult.stdout) {
|
|
@@ -845,42 +849,24 @@ router.post("/items/:itemId/history-repair", async (req, res) => {
|
|
|
845
849
|
}
|
|
846
850
|
res.json(result.parsed || { ok: true, id: itemId });
|
|
847
851
|
});
|
|
848
|
-
//
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
[
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
});
|
|
867
|
-
// GET /api/projects/:projectId/pm/list-all
|
|
868
|
-
router.get("/list-all", async (req, res) => {
|
|
869
|
-
const project = await requireProject(req, res);
|
|
870
|
-
if (!project)
|
|
871
|
-
return;
|
|
872
|
-
const { type, limit, after } = req.query;
|
|
873
|
-
// Preserve the public HTTP compatibility route while invoking the canonical
|
|
874
|
-
// CLI/SDK command internally. This route is intentionally paginated and is
|
|
875
|
-
// therefore distinct from readCompletePmItems used by whole-corpus views.
|
|
876
|
-
const result = await runCursorList(res, project, ["list", "--all"], [
|
|
877
|
-
["--type", type],
|
|
878
|
-
["--limit", limit],
|
|
879
|
-
], after);
|
|
880
|
-
if (!result)
|
|
881
|
-
return;
|
|
882
|
-
res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
|
|
883
|
-
});
|
|
852
|
+
// Keep the public /list-all compatibility route paginated while invoking the
|
|
853
|
+
// canonical CLI/SDK list --all command. Whole-corpus views use readCompletePmItems.
|
|
854
|
+
for (const route of [
|
|
855
|
+
{ path: "/list", args: ["list"], filters: ["status", "type", "limit", "priority", "sprint", "release", "assignee"] },
|
|
856
|
+
{ path: "/list-all", args: ["list", "--all"], filters: ["type", "limit"] },
|
|
857
|
+
]) {
|
|
858
|
+
router.get(route.path, async (req, res) => {
|
|
859
|
+
const project = await requireProject(req, res);
|
|
860
|
+
if (!project)
|
|
861
|
+
return;
|
|
862
|
+
const query = req.query;
|
|
863
|
+
const flags = route.filters.map((key) => [`--${key}`, query[key]]);
|
|
864
|
+
const result = await runCursorList(res, project, route.args, flags, query.after);
|
|
865
|
+
if (!result)
|
|
866
|
+
return;
|
|
867
|
+
res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
|
|
868
|
+
});
|
|
869
|
+
}
|
|
884
870
|
// GET /api/projects/:projectId/pm/board
|
|
885
871
|
// Kanban board: items grouped into columns by the workspace's runtime statuses
|
|
886
872
|
// (read live from `pm contracts`) so the board matches the installed CLI.
|
|
@@ -1382,21 +1368,11 @@ router.get("/graph", async (req, res) => {
|
|
|
1382
1368
|
const project = await requireProject(req, res);
|
|
1383
1369
|
if (!project)
|
|
1384
1370
|
return;
|
|
1385
|
-
const extensionGraph = await pmGraphExtensionGraphForProject(project);
|
|
1386
|
-
if (extensionGraph.graph) {
|
|
1387
|
-
res.json({
|
|
1388
|
-
ok: true,
|
|
1389
|
-
graph: extensionGraph.graph,
|
|
1390
|
-
extensionAvailable: true,
|
|
1391
|
-
});
|
|
1392
|
-
return;
|
|
1393
|
-
}
|
|
1394
1371
|
try {
|
|
1395
1372
|
res.json({
|
|
1396
1373
|
ok: true,
|
|
1397
1374
|
graph: await fallbackGraphForProject(project.ownerUserId, project.slug),
|
|
1398
1375
|
extensionAvailable: false,
|
|
1399
|
-
extensionError: extensionGraph.error,
|
|
1400
1376
|
});
|
|
1401
1377
|
}
|
|
1402
1378
|
catch (err) {
|
|
@@ -1445,18 +1421,32 @@ router.get("/graph/neighbors/:nodeId", async (req, res) => {
|
|
|
1445
1421
|
res.status(400).json({ error: "nodeId is required" });
|
|
1446
1422
|
return;
|
|
1447
1423
|
}
|
|
1448
|
-
const result = await projectPm(project, ["pm-graph", "neighbors", nodeId, "--json"], false);
|
|
1449
|
-
if (!result.ok) {
|
|
1450
|
-
// Extension not available — return empty neighbors
|
|
1451
|
-
res.json({ ok: true, center: null, neighbors: [], extensionAvailable: false, error: result.stderr || "pm-graph extension not available" });
|
|
1452
|
-
return;
|
|
1453
|
-
}
|
|
1454
1424
|
try {
|
|
1455
|
-
const
|
|
1456
|
-
|
|
1425
|
+
const graph = await fallbackGraphForProject(project.ownerUserId, project.slug);
|
|
1426
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
1427
|
+
const center = nodesById.get(nodeId);
|
|
1428
|
+
if (!center) {
|
|
1429
|
+
res.json({ ok: true, center: null, neighbors: [], extensionAvailable: false, message: `No node found with id "${nodeId}".` });
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
const neighbors = graph.relationships
|
|
1433
|
+
.filter((edge) => edge.from === nodeId || edge.to === nodeId)
|
|
1434
|
+
.map((edge) => {
|
|
1435
|
+
// graphFromItems creates both endpoint nodes for every relationship.
|
|
1436
|
+
const node = nodesById.get(edge.from === nodeId ? edge.to : edge.from);
|
|
1437
|
+
return {
|
|
1438
|
+
node: { ...node.properties, _labels: node.labels },
|
|
1439
|
+
relationship: {
|
|
1440
|
+
type: edge.type,
|
|
1441
|
+
direction: edge.from === nodeId ? "outgoing" : "incoming",
|
|
1442
|
+
properties: { ...edge.properties, _type: edge.type },
|
|
1443
|
+
},
|
|
1444
|
+
};
|
|
1445
|
+
});
|
|
1446
|
+
res.json({ ok: true, center: { ...center.properties, _labels: center.labels }, neighbors, extensionAvailable: false });
|
|
1457
1447
|
}
|
|
1458
|
-
catch {
|
|
1459
|
-
res.json({
|
|
1448
|
+
catch (err) {
|
|
1449
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
1460
1450
|
}
|
|
1461
1451
|
});
|
|
1462
1452
|
// POST /api/projects/:projectId/pm/graph/query
|
|
@@ -1562,9 +1552,13 @@ router.post("/tests/:itemId", async (req, res) => {
|
|
|
1562
1552
|
res.status(400).json({ error: "Test command is required" });
|
|
1563
1553
|
return;
|
|
1564
1554
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1555
|
+
// `pm item test --add` takes one CSV/JSON argument; the historical
|
|
1556
|
+
// `--add --command <value>` token shape is rejected by the CLI, so every
|
|
1557
|
+
// add from the UI failed with a usage error. JSON keeps the user's command
|
|
1558
|
+
// verbatim even when it contains commas or equals signs, and the optional
|
|
1559
|
+
// description rides along as the `note` key.
|
|
1560
|
+
const test = description?.trim() ? { command: command.trim(), note: description.trim() } : { command: command.trim() };
|
|
1561
|
+
const args = ["test", routeParam(req, "itemId"), "--add-json", JSON.stringify(test)];
|
|
1568
1562
|
const result = await runMutation(res, project, args, "Failed to add test");
|
|
1569
1563
|
if (!result)
|
|
1570
1564
|
return;
|
|
@@ -1922,10 +1916,23 @@ router.post("/close-many", async (req, res) => {
|
|
|
1922
1916
|
return;
|
|
1923
1917
|
}
|
|
1924
1918
|
const targetStatus = body.targetStatus === "canceled" ? "canceled" : "closed";
|
|
1925
|
-
// First, use update-many --dry-run to get the list of matched items
|
|
1926
|
-
|
|
1919
|
+
// First, use update-many --dry-run to get the list of matched items.
|
|
1920
|
+
// --filter-status selects rows; --status would instead *set* every matched
|
|
1921
|
+
// item's status in the preview, and with no other update flags present the
|
|
1922
|
+
// match set silently widened to the whole project — closed items included.
|
|
1923
|
+
// The status filter is authoritative and passed exactly once: the caller may
|
|
1924
|
+
// narrow to another non-terminal status, but `pm` keeps only the last repeated
|
|
1925
|
+
// flag, so appending a second --filter-status could re-select terminal items.
|
|
1926
|
+
const statusFilter = body.filterStatus?.trim() || "open";
|
|
1927
|
+
// `pm` accepts comma-separated status lists and matches them case- and
|
|
1928
|
+
// whitespace-insensitively, so every token is normalised before the check.
|
|
1929
|
+
if (statusFilter.split(",").some((status) => ["closed", "canceled", "cancelled"].includes(status.trim().toLowerCase()))) {
|
|
1930
|
+
res.status(400).json({ error: "close-many only selects non-terminal items; filterStatus cannot be closed or canceled" });
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
const listArgs = ["update-many", "--dry-run", "--filter-status", statusFilter];
|
|
1927
1934
|
const filterFlags = {
|
|
1928
|
-
|
|
1935
|
+
filterType: "--filter-type",
|
|
1929
1936
|
filterTag: "--filter-tag", filterPriority: "--filter-priority",
|
|
1930
1937
|
filterAssignee: "--filter-assignee", filterParent: "--filter-parent",
|
|
1931
1938
|
filterSprint: "--filter-sprint", filterRelease: "--filter-release",
|
|
@@ -2236,9 +2243,13 @@ router.post("/plan/:planId/steps", async (req, res) => {
|
|
|
2236
2243
|
res.status(400).json({ error: "Title is required" });
|
|
2237
2244
|
return;
|
|
2238
2245
|
}
|
|
2239
|
-
|
|
2246
|
+
// `pm plan add-step` takes the step title on --step-title and the step body
|
|
2247
|
+
// on --step-body; --title/--description address the plan itself, so routing
|
|
2248
|
+
// the request's fields there would fail the whole call (add-step rejects
|
|
2249
|
+
// --title as a missing --step-title) or write to the wrong record.
|
|
2250
|
+
const args = ["plan", "add-step", routeParam(req, "planId"), "--step-title", title.trim()];
|
|
2240
2251
|
if (description)
|
|
2241
|
-
args.push("--
|
|
2252
|
+
args.push("--step-body", description);
|
|
2242
2253
|
if (dependsOn)
|
|
2243
2254
|
args.push("--depends-on", dependsOn);
|
|
2244
2255
|
await runPlanMutation(req, res, project, args, "Failed to add step", 201);
|
|
@@ -2248,8 +2259,16 @@ router.patch("/plan/:planId/steps/:stepRef", async (req, res) => {
|
|
|
2248
2259
|
const project = await requireProject(req, res);
|
|
2249
2260
|
if (!project)
|
|
2250
2261
|
return;
|
|
2262
|
+
// Step edits go to --step-title/--step-body. The plan-level --title/--
|
|
2263
|
+
// --description flags are silently ignored by `pm plan update-step`, so
|
|
2264
|
+
// routing a step edit through them returned success while dropping the
|
|
2265
|
+
// user's change on the floor.
|
|
2266
|
+
const body = req.body;
|
|
2251
2267
|
const args = ["plan", "update-step", routeParam(req, "planId"), routeParam(req, "stepRef")];
|
|
2252
|
-
|
|
2268
|
+
if (body.title?.trim())
|
|
2269
|
+
args.push("--step-title", body.title.trim());
|
|
2270
|
+
if (body.description !== undefined)
|
|
2271
|
+
args.push("--step-body", body.description);
|
|
2253
2272
|
await runPlanMutation(req, res, project, args, "Failed to update step");
|
|
2254
2273
|
});
|
|
2255
2274
|
// POST /api/projects/:projectId/pm/plan/:planId/steps/:stepRef/complete
|
|
@@ -2321,39 +2340,37 @@ router.post("/plan/:planId/steps/:stepRef/reorder", async (req, res) => {
|
|
|
2321
2340
|
}
|
|
2322
2341
|
await runPlanMutation(req, res, project, ["plan", "reorder-step", routeParam(req, "planId"), routeParam(req, "stepRef"), String(reorderTo)], "Failed to reorder step");
|
|
2323
2342
|
});
|
|
2324
|
-
|
|
2325
|
-
|
|
2343
|
+
/** Execute plan linking or unlinking with the CLI's required step positional. */
|
|
2344
|
+
async function mutatePlanLink(req, res, action) {
|
|
2326
2345
|
const project = await requireProject(req, res);
|
|
2327
2346
|
if (!project)
|
|
2328
2347
|
return;
|
|
2329
|
-
const { link,
|
|
2348
|
+
const { link, step } = req.body;
|
|
2330
2349
|
if (!link?.trim()) {
|
|
2331
2350
|
res.status(400).json({ error: "link (item id) is required" });
|
|
2332
2351
|
return;
|
|
2333
2352
|
}
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
args.push("--link-kind", linkKind);
|
|
2337
|
-
if (linkNote)
|
|
2338
|
-
args.push("--link-note", linkNote);
|
|
2339
|
-
if (promoteToItemDep === "true")
|
|
2340
|
-
args.push("--promote-to-item-dep");
|
|
2341
|
-
await runPlanMutation(req, res, project, args, "Failed to link plan", 201);
|
|
2342
|
-
});
|
|
2343
|
-
// DELETE /api/projects/:projectId/pm/plan/:planId/link
|
|
2344
|
-
router.delete("/plan/:planId/link", async (req, res) => {
|
|
2345
|
-
const project = await requireProject(req, res);
|
|
2346
|
-
if (!project)
|
|
2347
|
-
return;
|
|
2348
|
-
const { link, linkKind } = req.body;
|
|
2349
|
-
if (!link?.trim()) {
|
|
2350
|
-
res.status(400).json({ error: "link (item id) is required" });
|
|
2353
|
+
if (!step?.trim()) {
|
|
2354
|
+
res.status(400).json({ error: "step (step id or order) is required" });
|
|
2351
2355
|
return;
|
|
2352
2356
|
}
|
|
2353
|
-
const
|
|
2357
|
+
const { linkKind, linkNote, promoteToItemDep } = req.body;
|
|
2358
|
+
const args = ["plan", action, routeParam(req, "planId"), step.trim(), "--link", link.trim()];
|
|
2354
2359
|
if (linkKind)
|
|
2355
2360
|
args.push("--link-kind", linkKind);
|
|
2356
|
-
|
|
2361
|
+
if (action === "link") {
|
|
2362
|
+
if (linkNote)
|
|
2363
|
+
args.push("--link-note", linkNote);
|
|
2364
|
+
if (promoteToItemDep === "true")
|
|
2365
|
+
args.push("--promote-to-item-dep");
|
|
2366
|
+
}
|
|
2367
|
+
await runPlanMutation(req, res, project, args, `Failed to ${action} plan`, action === "link" ? 201 : 200);
|
|
2368
|
+
}
|
|
2369
|
+
router.post("/plan/:planId/link", async (req, res) => {
|
|
2370
|
+
await mutatePlanLink(req, res, "link");
|
|
2371
|
+
});
|
|
2372
|
+
router.delete("/plan/:planId/link", async (req, res) => {
|
|
2373
|
+
await mutatePlanLink(req, res, "unlink");
|
|
2357
2374
|
});
|
|
2358
2375
|
// GET /api/projects/:projectId/pm/upgrade
|
|
2359
2376
|
// Returns a dry-run preview of what upgrade would do (safe, read-only).
|