@unbrained/pm-web 1.0.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.
Files changed (150) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +107 -0
  3. package/dist/auth.js +20 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/crypto.js +42 -0
  6. package/dist/crypto.js.map +1 -0
  7. package/dist/db.js +111 -0
  8. package/dist/db.js.map +1 -0
  9. package/dist/index.js +88 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/middleware/auth.js +16 -0
  12. package/dist/middleware/auth.js.map +1 -0
  13. package/dist/routes/admin.js +207 -0
  14. package/dist/routes/admin.js.map +1 -0
  15. package/dist/routes/auth.js +163 -0
  16. package/dist/routes/auth.js.map +1 -0
  17. package/dist/routes/github.js +354 -0
  18. package/dist/routes/github.js.map +1 -0
  19. package/dist/routes/groups.js +180 -0
  20. package/dist/routes/groups.js.map +1 -0
  21. package/dist/routes/pm.js +2446 -0
  22. package/dist/routes/pm.js.map +1 -0
  23. package/dist/routes/projects.js +151 -0
  24. package/dist/routes/projects.js.map +1 -0
  25. package/dist/routes/sharing.js +155 -0
  26. package/dist/routes/sharing.js.map +1 -0
  27. package/dist/server.js +64 -0
  28. package/dist/server.js.map +1 -0
  29. package/dist/services/pm-runner.js +190 -0
  30. package/dist/services/pm-runner.js.map +1 -0
  31. package/dist/services/sse.js +111 -0
  32. package/dist/services/sse.js.map +1 -0
  33. package/manifest.json +15 -0
  34. package/package.json +111 -0
  35. package/public/icons/icon-192.png +0 -0
  36. package/public/icons/icon-512.png +0 -0
  37. package/public/index.html +265 -0
  38. package/public/manifest.json +66 -0
  39. package/public/src/api.js +28 -0
  40. package/public/src/api.js.map +1 -0
  41. package/public/src/api.ts +29 -0
  42. package/public/src/app.js +926 -0
  43. package/public/src/app.js.map +1 -0
  44. package/public/src/app.ts +929 -0
  45. package/public/src/components/modals.js +62 -0
  46. package/public/src/components/modals.js.map +1 -0
  47. package/public/src/components/modals.ts +73 -0
  48. package/public/src/components/toast.js +10 -0
  49. package/public/src/components/toast.js.map +1 -0
  50. package/public/src/components/toast.ts +13 -0
  51. package/public/src/constants.js +30 -0
  52. package/public/src/constants.js.map +1 -0
  53. package/public/src/constants.ts +41 -0
  54. package/public/src/state.js +15 -0
  55. package/public/src/state.js.map +1 -0
  56. package/public/src/state.ts +19 -0
  57. package/public/src/types.js +5 -0
  58. package/public/src/types.js.map +1 -0
  59. package/public/src/types.ts +253 -0
  60. package/public/src/utils.js +57 -0
  61. package/public/src/utils.js.map +1 -0
  62. package/public/src/utils.ts +56 -0
  63. package/public/src/views/activity.js +47 -0
  64. package/public/src/views/activity.js.map +1 -0
  65. package/public/src/views/activity.ts +41 -0
  66. package/public/src/views/admin.js +435 -0
  67. package/public/src/views/admin.js.map +1 -0
  68. package/public/src/views/admin.ts +504 -0
  69. package/public/src/views/auth.js +81 -0
  70. package/public/src/views/auth.js.map +1 -0
  71. package/public/src/views/auth.ts +74 -0
  72. package/public/src/views/calendar.js +133 -0
  73. package/public/src/views/calendar.js.map +1 -0
  74. package/public/src/views/calendar.ts +129 -0
  75. package/public/src/views/comments-audit.js +109 -0
  76. package/public/src/views/comments-audit.js.map +1 -0
  77. package/public/src/views/comments-audit.ts +108 -0
  78. package/public/src/views/config.js +322 -0
  79. package/public/src/views/config.js.map +1 -0
  80. package/public/src/views/config.ts +344 -0
  81. package/public/src/views/context.js +98 -0
  82. package/public/src/views/context.js.map +1 -0
  83. package/public/src/views/context.ts +100 -0
  84. package/public/src/views/create.js +293 -0
  85. package/public/src/views/create.js.map +1 -0
  86. package/public/src/views/create.ts +246 -0
  87. package/public/src/views/dedupe.js +51 -0
  88. package/public/src/views/dedupe.js.map +1 -0
  89. package/public/src/views/dedupe.ts +43 -0
  90. package/public/src/views/export.js +300 -0
  91. package/public/src/views/export.js.map +1 -0
  92. package/public/src/views/export.ts +274 -0
  93. package/public/src/views/github.js +360 -0
  94. package/public/src/views/github.js.map +1 -0
  95. package/public/src/views/github.ts +308 -0
  96. package/public/src/views/graph-canvas.js +1986 -0
  97. package/public/src/views/graph-canvas.js.map +1 -0
  98. package/public/src/views/graph-canvas.ts +2218 -0
  99. package/public/src/views/graph.js +1824 -0
  100. package/public/src/views/graph.js.map +1 -0
  101. package/public/src/views/graph.ts +1891 -0
  102. package/public/src/views/groups.js +186 -0
  103. package/public/src/views/groups.js.map +1 -0
  104. package/public/src/views/groups.ts +172 -0
  105. package/public/src/views/guide.js +151 -0
  106. package/public/src/views/guide.js.map +1 -0
  107. package/public/src/views/guide.ts +162 -0
  108. package/public/src/views/health.js +105 -0
  109. package/public/src/views/health.js.map +1 -0
  110. package/public/src/views/health.ts +102 -0
  111. package/public/src/views/items.js +1306 -0
  112. package/public/src/views/items.js.map +1 -0
  113. package/public/src/views/items.ts +1196 -0
  114. package/public/src/views/normalize.js +67 -0
  115. package/public/src/views/normalize.js.map +1 -0
  116. package/public/src/views/normalize.ts +58 -0
  117. package/public/src/views/plan.js +454 -0
  118. package/public/src/views/plan.js.map +1 -0
  119. package/public/src/views/plan.ts +496 -0
  120. package/public/src/views/projects.js +204 -0
  121. package/public/src/views/projects.js.map +1 -0
  122. package/public/src/views/projects.ts +196 -0
  123. package/public/src/views/router.js +227 -0
  124. package/public/src/views/router.js.map +1 -0
  125. package/public/src/views/router.ts +188 -0
  126. package/public/src/views/search.js +103 -0
  127. package/public/src/views/search.js.map +1 -0
  128. package/public/src/views/search.ts +94 -0
  129. package/public/src/views/settings.js +272 -0
  130. package/public/src/views/settings.js.map +1 -0
  131. package/public/src/views/settings.ts +190 -0
  132. package/public/src/views/shared.js +49 -0
  133. package/public/src/views/shared.js.map +1 -0
  134. package/public/src/views/shared.ts +49 -0
  135. package/public/src/views/sharing.js +152 -0
  136. package/public/src/views/sharing.js.map +1 -0
  137. package/public/src/views/sharing.ts +139 -0
  138. package/public/src/views/stats.js +92 -0
  139. package/public/src/views/stats.js.map +1 -0
  140. package/public/src/views/stats.ts +88 -0
  141. package/public/src/views/templates.js +117 -0
  142. package/public/src/views/templates.js.map +1 -0
  143. package/public/src/views/templates.ts +113 -0
  144. package/public/src/views/validate.js +54 -0
  145. package/public/src/views/validate.js.map +1 -0
  146. package/public/src/views/validate.ts +48 -0
  147. package/public/styles.css +2231 -0
  148. package/public/sw.js +318 -0
  149. package/public/tsconfig.json +20 -0
  150. package/sql/schema.sql +105 -0
@@ -0,0 +1,2446 @@
1
+ import { Router } from "express";
2
+ import { requireAuth } from "../middleware/auth.js";
3
+ import { ensureGraphExtension, runPm } from "../services/pm-runner.js";
4
+ import { verifyProjectAccess } from "./projects.js";
5
+ import { addSSEClient, broadcastProjectEvent, setupSSEHeaders, broadcastPresence, updateClientView, getProjectPresence } from "../services/sse.js";
6
+ import { v4 as uuidv4 } from "uuid";
7
+ import neo4j from "neo4j-driver";
8
+ // Singleton Neo4j driver — reused across sync calls to avoid per-call connection overhead.
9
+ let _neo4jDriver = null;
10
+ let _neo4jDriverKey = "";
11
+ function getNeo4jDriver() {
12
+ const uri = process.env.NEO4J_URI ?? "";
13
+ const user = process.env.NEO4J_USER ?? process.env.NEO4J_USERNAME ?? "";
14
+ const password = process.env.NEO4J_PASSWORD ?? "";
15
+ const key = `${uri}:${user}`;
16
+ if (!_neo4jDriver || _neo4jDriverKey !== key) {
17
+ if (_neo4jDriver) {
18
+ void _neo4jDriver.close().catch(() => undefined);
19
+ }
20
+ _neo4jDriver = neo4j.driver(uri, neo4j.auth.basic(user, password));
21
+ _neo4jDriverKey = key;
22
+ }
23
+ return _neo4jDriver;
24
+ }
25
+ const router = Router({ mergeParams: true });
26
+ router.use(requireAuth);
27
+ const pendingGraphSyncs = new Map();
28
+ router.use(async (req, res, next) => {
29
+ if (["GET", "HEAD", "OPTIONS"].includes(req.method)) {
30
+ next();
31
+ return;
32
+ }
33
+ const projectId = req.params["projectId"];
34
+ if (!projectId) {
35
+ next();
36
+ return;
37
+ }
38
+ try {
39
+ const access = await verifyProjectAccess(req.user.userId, projectId);
40
+ if (!access) {
41
+ res.status(404).json({ error: "Project not found" });
42
+ return;
43
+ }
44
+ if (access.permission !== "edit") {
45
+ res.status(403).json({ error: "This project is shared as view-only." });
46
+ return;
47
+ }
48
+ next();
49
+ }
50
+ catch (err) {
51
+ console.error("Project permission check failed:", err);
52
+ res.status(500).json({ error: "Failed to verify project permission" });
53
+ }
54
+ });
55
+ function graphNodeId(kind, value) {
56
+ return `${kind}:${value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-")}`;
57
+ }
58
+ function graphRelationshipType(rawType) {
59
+ const text = typeof rawType === "string" && rawType.trim().length > 0 ? rawType : "relates-to";
60
+ return text.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
61
+ }
62
+ function dependencyTarget(dep) {
63
+ for (const key of ["id", "target", "target_id", "targetId", "item", "item_id", "itemId"]) {
64
+ const value = dep[key];
65
+ if (typeof value === "string" && value.trim().length > 0)
66
+ return value.trim();
67
+ }
68
+ return null;
69
+ }
70
+ function dependencyRows(raw) {
71
+ if (Array.isArray(raw))
72
+ return raw.filter((entry) => Boolean(entry) && typeof entry === "object");
73
+ if (!raw || typeof raw !== "object")
74
+ return [];
75
+ const data = raw;
76
+ for (const key of ["deps", "dependencies", "items", "relationships"]) {
77
+ const value = data[key];
78
+ if (Array.isArray(value)) {
79
+ return value.filter((entry) => Boolean(entry) && typeof entry === "object");
80
+ }
81
+ }
82
+ return [];
83
+ }
84
+ function normalizeDependencyKind(input) {
85
+ const raw = (input ?? "blocked_by").trim().toLowerCase().replace(/-/g, "_");
86
+ const aliases = {
87
+ blockedby: "blocked_by",
88
+ blocked: "blocked_by",
89
+ blocked_by: "blocked_by",
90
+ blocks: "blocks",
91
+ depends_on: "blocked_by",
92
+ dependson: "blocked_by",
93
+ dependency: "blocked_by",
94
+ parent_of: "parent",
95
+ child_of: "child",
96
+ relates_to: "related",
97
+ related_to: "related",
98
+ related: "related",
99
+ };
100
+ const normalized = aliases[raw] ?? raw;
101
+ const allowed = new Set(["parent", "child", "blocks", "blocked_by", "related"]);
102
+ return allowed.has(normalized) ? normalized : normalized;
103
+ }
104
+ function graphFromItems(items, depsByItem) {
105
+ const nodesById = new Map();
106
+ const relationships = [];
107
+ const addNode = (node) => {
108
+ if (!nodesById.has(node.id))
109
+ nodesById.set(node.id, node);
110
+ };
111
+ const addRelationship = (from, to, type, properties) => {
112
+ if (!nodesById.has(to) && !items.some((item) => item.id === to)) {
113
+ addNode({
114
+ id: to,
115
+ labels: ["ExternalPmItem"],
116
+ properties: { id: to, title: to, type: "ExternalPmItem" },
117
+ });
118
+ }
119
+ relationships.push({ from, to, type, properties });
120
+ };
121
+ for (const item of items) {
122
+ addNode({
123
+ id: item.id,
124
+ labels: ["PmItem", item.type ?? "Item"],
125
+ properties: {
126
+ id: item.id,
127
+ title: item.title ?? "",
128
+ type: item.type ?? "Item",
129
+ status: item.status ?? "unknown",
130
+ priority: item.priority ?? null,
131
+ tags: item.tags ?? [],
132
+ assignee: item.assignee ?? null,
133
+ sprint: item.sprint ?? null,
134
+ release: item.release ?? null,
135
+ deadline: item.deadline ?? null,
136
+ created_at: item.created_at ?? null,
137
+ updated_at: item.updated_at ?? null,
138
+ },
139
+ });
140
+ if (item.parent) {
141
+ addRelationship(item.id, item.parent, "CHILD_OF", { source: "parent" });
142
+ }
143
+ const blockedBy = item.blocked_by ?? item.blockedBy;
144
+ if (typeof blockedBy === "string" && blockedBy.trim().length > 0) {
145
+ addRelationship(item.id, blockedBy.trim(), "BLOCKED_BY", {
146
+ source: "blocked_by",
147
+ reason: item.blocked_reason ?? item.blockedReason ?? null,
148
+ });
149
+ }
150
+ const deps = [
151
+ ...(item.deps ?? []),
152
+ ...(item.dependencies ?? []),
153
+ ...(depsByItem.get(item.id) ?? []),
154
+ ];
155
+ const seenDeps = new Set();
156
+ for (const dep of deps) {
157
+ const target = dependencyTarget(dep);
158
+ if (!target)
159
+ continue;
160
+ const type = graphRelationshipType(dep.type ?? dep.kind ?? dep.relation ?? dep.rel ?? dep.relationship);
161
+ const key = `${item.id}->${target}:${type}`;
162
+ if (seenDeps.has(key))
163
+ continue;
164
+ seenDeps.add(key);
165
+ addRelationship(item.id, target, type, { ...dep });
166
+ }
167
+ const facetLinks = [
168
+ { kind: "type", value: item.type, label: "ItemType", rel: "HAS_TYPE" },
169
+ { kind: "status", value: item.status, label: "Status", rel: "HAS_STATUS" },
170
+ { kind: "assignee", value: item.assignee, label: "Person", rel: "ASSIGNED_TO" },
171
+ { kind: "sprint", value: item.sprint, label: "Sprint", rel: "IN_SPRINT" },
172
+ { kind: "release", value: item.release, label: "Release", rel: "IN_RELEASE" },
173
+ ];
174
+ for (const link of facetLinks) {
175
+ if (typeof link.value !== "string" || link.value.trim().length === 0)
176
+ continue;
177
+ const id = graphNodeId(link.kind, link.value);
178
+ addNode({
179
+ id,
180
+ labels: ["PmFacet", link.label],
181
+ properties: { id, title: link.value, kind: link.kind, value: link.value },
182
+ });
183
+ addRelationship(item.id, id, link.rel, { source: link.kind });
184
+ }
185
+ for (const tag of item.tags ?? []) {
186
+ if (!tag.trim())
187
+ continue;
188
+ const id = graphNodeId("tag", tag);
189
+ addNode({
190
+ id,
191
+ labels: ["PmFacet", "Tag"],
192
+ properties: { id, title: tag, kind: "tag", value: tag },
193
+ });
194
+ addRelationship(item.id, id, "TAGGED_WITH", { source: "tags" });
195
+ }
196
+ }
197
+ return {
198
+ generatedAt: new Date().toISOString(),
199
+ source: "pm-web",
200
+ nodes: Array.from(nodesById.values()),
201
+ relationships: relationships.filter((rel, index, all) => all.findIndex((candidate) => candidate.from === rel.from && candidate.to === rel.to && candidate.type === rel.type) === index),
202
+ };
203
+ }
204
+ function graphProjectKey(project) {
205
+ return `${project.ownerUserId}:${project.slug}`;
206
+ }
207
+ async function syncGraphToNeo4j(graph, projectKey) {
208
+ const uri = process.env.NEO4J_URI;
209
+ const user = process.env.NEO4J_USER ?? process.env.NEO4J_USERNAME;
210
+ const password = process.env.NEO4J_PASSWORD;
211
+ if (!uri || !user || !password) {
212
+ throw new Error("Set NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD before syncing the graph.");
213
+ }
214
+ const driver = getNeo4jDriver();
215
+ const session = driver.session({ database: process.env.NEO4J_DATABASE });
216
+ try {
217
+ await session.executeWrite((tx) => tx.run("MATCH (n:PmGraphNode {projectKey: $projectKey}) DETACH DELETE n", { projectKey }));
218
+ for (const node of graph.nodes) {
219
+ await session.executeWrite((tx) => tx.run("MERGE (n:PmGraphNode {projectKey: $projectKey, id: $id}) SET n += $properties, n.labels = $labels RETURN n.id", { projectKey, id: node.id, properties: { ...node.properties, projectKey }, labels: node.labels }));
220
+ }
221
+ for (const relationship of graph.relationships) {
222
+ const relType = graphRelationshipType(relationship.type);
223
+ await session.executeWrite((tx) => tx.run(`MATCH (from:PmGraphNode {projectKey: $projectKey, id: $from}), (to:PmGraphNode {projectKey: $projectKey, id: $to}) MERGE (from)-[r:${relType}]->(to) SET r += $properties RETURN type(r)`, { projectKey, from: relationship.from, to: relationship.to, properties: { ...relationship.properties, projectKey } }));
224
+ }
225
+ }
226
+ finally {
227
+ await session.close();
228
+ }
229
+ return { syncedNodes: graph.nodes.length, syncedRelationships: graph.relationships.length };
230
+ }
231
+ async function syncProjectGraph(project) {
232
+ const extensionGraph = pmGraphExtensionGraphForProject(project);
233
+ const graph = extensionGraph.graph ?? fallbackGraphForProject(project.ownerUserId, project.slug);
234
+ return syncGraphToNeo4j(graph, graphProjectKey(project));
235
+ }
236
+ function scheduleGraphSync(projectId, project, reason) {
237
+ const existing = pendingGraphSyncs.get(projectId);
238
+ if (existing)
239
+ clearTimeout(existing);
240
+ pendingGraphSyncs.set(projectId, setTimeout(() => {
241
+ pendingGraphSyncs.delete(projectId);
242
+ syncProjectGraph(project)
243
+ .then((result) => {
244
+ broadcastProjectEvent(projectId, {
245
+ type: "graph-synced",
246
+ data: { reason, ...result },
247
+ });
248
+ })
249
+ .catch((err) => {
250
+ console.error(`Neo4j graph auto-sync failed for ${projectId} after ${reason}:`, err);
251
+ broadcastProjectEvent(projectId, {
252
+ type: "graph-sync-failed",
253
+ data: { reason, error: err instanceof Error ? err.message : String(err) },
254
+ });
255
+ broadcastProjectEvent(projectId, {
256
+ type: "graph_sync_failed",
257
+ data: { reason, error: err instanceof Error ? err.message : String(err) },
258
+ });
259
+ });
260
+ }, 750));
261
+ }
262
+ function broadcastDependencyEvent(projectId, kind, data) {
263
+ broadcastProjectEvent(projectId, {
264
+ type: kind,
265
+ data,
266
+ });
267
+ broadcastProjectEvent(projectId, {
268
+ type: "item-updated",
269
+ data: {
270
+ itemId: data.from,
271
+ change: kind,
272
+ target: data.to,
273
+ rel: data.rel,
274
+ userId: data.userId,
275
+ },
276
+ });
277
+ }
278
+ function itemsFromListAll(parsed) {
279
+ return ((parsed?.items) ?? []);
280
+ }
281
+ function fallbackGraphForProject(ownerUserId, slug) {
282
+ const itemsResult = runPm({
283
+ args: ["list-all"],
284
+ userId: ownerUserId,
285
+ slug,
286
+ jsonOutput: true,
287
+ });
288
+ if (!itemsResult.ok)
289
+ throw new Error(itemsResult.stderr || "Failed to load items for graph");
290
+ const items = itemsFromListAll(itemsResult.parsed);
291
+ // Deps are already embedded in list-all output (item.deps / item.dependencies).
292
+ // Avoid N+1 subprocess calls by using only the embedded data.
293
+ return graphFromItems(items, new Map());
294
+ }
295
+ function pmGraphExtensionGraphForProject(project) {
296
+ const provision = ensureGraphExtension(project.ownerUserId, project.slug);
297
+ if (!provision.ok) {
298
+ return { error: provision.error };
299
+ }
300
+ const extensionResult = runPm({
301
+ args: ["pm-graph", "export", "--json"],
302
+ userId: project.ownerUserId,
303
+ slug: project.slug,
304
+ jsonOutput: false,
305
+ });
306
+ let extensionData;
307
+ if (extensionResult.ok && extensionResult.stdout) {
308
+ try {
309
+ extensionData = JSON.parse(extensionResult.stdout);
310
+ }
311
+ catch {
312
+ return { error: "pm-graph export returned invalid JSON." };
313
+ }
314
+ }
315
+ if (extensionResult.ok && extensionData?.graph) {
316
+ return {
317
+ graph: {
318
+ ...extensionData.graph,
319
+ source: "pm-graph",
320
+ },
321
+ };
322
+ }
323
+ return { error: extensionResult.stderr || extensionResult.stdout || "pm-graph export did not return a graph." };
324
+ }
325
+ // Verify project access (owner or shared) and return slug + ownerUserId for pm-runner
326
+ async function verifyProject(userId, projectId) {
327
+ const access = await verifyProjectAccess(userId, projectId);
328
+ if (!access)
329
+ return null;
330
+ return { slug: access.slug, prefix: access.prefix, ownerUserId: access.ownerUserId };
331
+ }
332
+ // GET /api/projects/:projectId/pm/schema
333
+ // Returns runtime types/statuses from `pm contracts --json` so the frontend
334
+ // stays in sync with whatever pm CLI version + extensions are installed.
335
+ router.get("/schema", async (req, res) => {
336
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
337
+ if (!project) {
338
+ res.status(404).json({ error: "Project not found" });
339
+ return;
340
+ }
341
+ const result = runPm({ args: ["contracts", "--json"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
342
+ const contracts = result.ok && result.parsed ? result.parsed : null;
343
+ const rt = contracts?.["runtime_schema"];
344
+ res.json({
345
+ types: Array.isArray(rt?.["types"]) ? rt["types"] : [],
346
+ statuses: Array.isArray(rt?.["statuses"]) ? rt["statuses"] : [],
347
+ openStatus: typeof rt?.["open_status"] === "string" ? rt["open_status"] : "open",
348
+ closeStatus: typeof rt?.["close_status"] === "string" ? rt["close_status"] : "closed",
349
+ canceledStatus: typeof rt?.["canceled_status"] === "string" ? rt["canceled_status"] : "canceled",
350
+ });
351
+ });
352
+ // GET /api/projects/:projectId/pm/list
353
+ router.get("/list", async (req, res) => {
354
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
355
+ if (!project) {
356
+ res.status(404).json({ error: "Project not found" });
357
+ return;
358
+ }
359
+ const { status, type, limit, priority, sprint, release, assignee } = req.query;
360
+ const args = ["list"];
361
+ if (status)
362
+ args.push("--status", status);
363
+ if (type)
364
+ args.push("--type", type);
365
+ if (limit)
366
+ args.push("--limit", limit);
367
+ if (priority)
368
+ args.push("--priority", priority);
369
+ if (sprint)
370
+ args.push("--sprint", sprint);
371
+ if (release)
372
+ args.push("--release", release);
373
+ if (assignee)
374
+ args.push("--assignee", assignee);
375
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
376
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
377
+ });
378
+ // GET /api/projects/:projectId/pm/list-all
379
+ router.get("/list-all", async (req, res) => {
380
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
381
+ if (!project) {
382
+ res.status(404).json({ error: "Project not found" });
383
+ return;
384
+ }
385
+ const { type, limit } = req.query;
386
+ const args = ["list-all"];
387
+ if (type)
388
+ args.push("--type", type);
389
+ if (limit)
390
+ args.push("--limit", limit);
391
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
392
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr, items: [] });
393
+ });
394
+ // POST /api/projects/:projectId/pm/create
395
+ router.post("/create", async (req, res) => {
396
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
397
+ if (!project) {
398
+ res.status(404).json({ error: "Project not found" });
399
+ return;
400
+ }
401
+ const { title, type, priority, description, tags, parent, deadline, assignee, sprint, release, estimate, body, acceptanceCriteria, reporter, component, severity, risk, goal, objective, environment, "blocked-by": blockedBy, "blocked-reason": blockedReason, "repro-steps": reproSteps, "expected-result": expectedResult, "actual-result": actualResult, reviewer, confidence, "why-now": whyNow, value, impact, outcome, "definition-of-ready": definitionOfReady, } = req.body;
402
+ if (!title?.trim()) {
403
+ res.status(400).json({ error: "Title is required" });
404
+ return;
405
+ }
406
+ const args = ["create", "--title", title.trim()];
407
+ if (type)
408
+ args.push("--type", type);
409
+ if (priority)
410
+ args.push("--priority", priority);
411
+ // pm CLI requires --description; provide a sensible default when omitted
412
+ args.push("--description", (description || title.trim()).slice(0, 500));
413
+ if (tags)
414
+ args.push("--tags", tags);
415
+ if (parent)
416
+ args.push("--parent", parent);
417
+ if (deadline)
418
+ args.push("--deadline", deadline);
419
+ if (assignee)
420
+ args.push("--assignee", assignee);
421
+ if (sprint)
422
+ args.push("--sprint", sprint);
423
+ if (release)
424
+ args.push("--release", release);
425
+ if (estimate)
426
+ args.push("--estimate", estimate);
427
+ if (body)
428
+ args.push("--body", body);
429
+ if (acceptanceCriteria)
430
+ args.push("--acceptance-criteria", acceptanceCriteria);
431
+ if (reporter)
432
+ args.push("--reporter", reporter);
433
+ if (component)
434
+ args.push("--component", component);
435
+ if (severity)
436
+ args.push("--severity", severity);
437
+ if (risk)
438
+ args.push("--risk", risk);
439
+ if (goal)
440
+ args.push("--goal", goal);
441
+ if (objective)
442
+ args.push("--objective", objective);
443
+ if (environment)
444
+ args.push("--environment", environment);
445
+ if (blockedBy)
446
+ args.push("--blocked-by", blockedBy);
447
+ if (blockedReason)
448
+ args.push("--blocked-reason", blockedReason);
449
+ if (reproSteps)
450
+ args.push("--repro-steps", reproSteps);
451
+ if (expectedResult)
452
+ args.push("--expected-result", expectedResult);
453
+ if (actualResult)
454
+ args.push("--actual-result", actualResult);
455
+ if (reviewer)
456
+ args.push("--reviewer", reviewer);
457
+ if (confidence)
458
+ args.push("--confidence", confidence);
459
+ if (whyNow)
460
+ args.push("--why-now", whyNow);
461
+ if (value)
462
+ args.push("--value", value);
463
+ if (impact)
464
+ args.push("--impact", impact);
465
+ if (outcome)
466
+ args.push("--outcome", outcome);
467
+ if (definitionOfReady)
468
+ args.push("--definition-of-ready", definitionOfReady);
469
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
470
+ if (!result.ok) {
471
+ res.status(400).json({ error: result.stderr || "Failed to create item" });
472
+ return;
473
+ }
474
+ // Broadcast SSE create event
475
+ broadcastProjectEvent(req.params["projectId"], {
476
+ type: "item-created",
477
+ data: { result: result.parsed, userId: req.user.userId },
478
+ });
479
+ scheduleGraphSync(req.params["projectId"], project, "item-created");
480
+ res.status(201).json(result.parsed || {});
481
+ });
482
+ // GET /api/projects/:projectId/pm/get/:itemId
483
+ router.get("/get/:itemId", async (req, res) => {
484
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
485
+ if (!project) {
486
+ res.status(404).json({ error: "Project not found" });
487
+ return;
488
+ }
489
+ const result = runPm({
490
+ args: ["get", req.params["itemId"]],
491
+ userId: project.ownerUserId,
492
+ slug: project.slug,
493
+ jsonOutput: true,
494
+ });
495
+ if (!result.ok) {
496
+ res.status(404).json({ error: "Item not found" });
497
+ return;
498
+ }
499
+ res.json(result.parsed || {});
500
+ });
501
+ // PATCH /api/projects/:projectId/pm/update/:itemId
502
+ router.patch("/update/:itemId", async (req, res) => {
503
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
504
+ if (!project) {
505
+ res.status(404).json({ error: "Project not found" });
506
+ return;
507
+ }
508
+ const body = req.body;
509
+ const args = ["update", req.params["itemId"]];
510
+ // String options
511
+ const stringFlags = {
512
+ title: "--title", description: "--description", status: "--status", priority: "--priority",
513
+ tags: "--tags", parent: "--parent", deadline: "--deadline", assignee: "--assignee",
514
+ sprint: "--sprint", release: "--release", estimate: "--estimate", body: "--body",
515
+ acceptanceCriteria: "--acceptance-criteria", reviewer: "--reviewer", risk: "--risk",
516
+ confidence: "--confidence", blockedBy: "--blocked-by", blockedReason: "--blocked-reason",
517
+ reporter: "--reporter", severity: "--severity", environment: "--environment",
518
+ reproSteps: "--repro-steps", expectedResult: "--expected-result", actualResult: "--actual-result",
519
+ component: "--component", goal: "--goal", objective: "--objective", value: "--value",
520
+ impact: "--impact", outcome: "--outcome", whyNow: "--why-now",
521
+ definitionOfReady: "--definition-of-ready", author: "--author", message: "--message",
522
+ order: "--order", rank: "--rank", closeReason: "--close-reason",
523
+ resolution: "--resolution", affectedVersion: "--affected-version", fixedVersion: "--fixed-version",
524
+ regression: "--regression", customerImpact: "--customer-impact",
525
+ unblockNote: "--unblock-note",
526
+ };
527
+ for (const [key, flag] of Object.entries(stringFlags)) {
528
+ const val = body[key];
529
+ if (val !== undefined && val !== null && val !== "") {
530
+ args.push(flag, String(val));
531
+ }
532
+ }
533
+ // Type can be set but must use --type
534
+ if (body.type)
535
+ args.push("--type", body.type);
536
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
537
+ if (!result.ok) {
538
+ res.status(400).json({ error: result.stderr || "Failed to update item" });
539
+ return;
540
+ }
541
+ // Broadcast SSE update event
542
+ broadcastProjectEvent(req.params["projectId"], {
543
+ type: "item-updated",
544
+ data: { itemId: req.params["itemId"], userId: req.user.userId },
545
+ });
546
+ scheduleGraphSync(req.params["projectId"], project, "item-updated");
547
+ res.json(result.parsed || {});
548
+ });
549
+ // POST /api/projects/:projectId/pm/close/:itemId
550
+ router.post("/close/:itemId", async (req, res) => {
551
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
552
+ if (!project) {
553
+ res.status(404).json({ error: "Project not found" });
554
+ return;
555
+ }
556
+ const { reason } = req.body;
557
+ if (!reason?.trim()) {
558
+ res.status(400).json({ error: "Close reason is required" });
559
+ return;
560
+ }
561
+ const result = runPm({
562
+ args: ["close", req.params["itemId"], reason.trim()],
563
+ userId: project.ownerUserId,
564
+ slug: project.slug,
565
+ jsonOutput: true,
566
+ });
567
+ if (!result.ok) {
568
+ res.status(400).json({ error: result.stderr || "Failed to close item" });
569
+ return;
570
+ }
571
+ broadcastProjectEvent(req.params["projectId"], {
572
+ type: "item-closed",
573
+ data: { itemId: req.params["itemId"], userId: req.user.userId },
574
+ });
575
+ scheduleGraphSync(req.params["projectId"], project, "item-closed");
576
+ res.json(result.parsed || {});
577
+ });
578
+ // DELETE /api/projects/:projectId/pm/delete/:itemId
579
+ router.delete("/delete/:itemId", async (req, res) => {
580
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
581
+ if (!project) {
582
+ res.status(404).json({ error: "Project not found" });
583
+ return;
584
+ }
585
+ const result = runPm({
586
+ args: ["delete", req.params["itemId"], "--yes"],
587
+ userId: project.ownerUserId,
588
+ slug: project.slug,
589
+ });
590
+ if (!result.ok) {
591
+ res.status(400).json({ error: result.stderr || "Failed to delete item" });
592
+ return;
593
+ }
594
+ broadcastProjectEvent(req.params["projectId"], {
595
+ type: "item-deleted",
596
+ data: { itemId: req.params["itemId"], userId: req.user.userId },
597
+ });
598
+ scheduleGraphSync(req.params["projectId"], project, "item-deleted");
599
+ res.json({ ok: true });
600
+ });
601
+ // POST /api/projects/:projectId/pm/comments/:itemId
602
+ router.post("/comments/:itemId", async (req, res) => {
603
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
604
+ if (!project) {
605
+ res.status(404).json({ error: "Project not found" });
606
+ return;
607
+ }
608
+ const { text } = req.body;
609
+ if (!text?.trim()) {
610
+ res.status(400).json({ error: "Comment text is required" });
611
+ return;
612
+ }
613
+ const result = runPm({
614
+ args: ["comments", req.params["itemId"], text.trim()],
615
+ userId: project.ownerUserId,
616
+ slug: project.slug,
617
+ jsonOutput: true,
618
+ });
619
+ if (!result.ok) {
620
+ res.status(400).json({ error: result.stderr || "Failed to add comment" });
621
+ return;
622
+ }
623
+ res.status(201).json(result.parsed || { ok: true });
624
+ });
625
+ // GET /api/projects/:projectId/pm/comments/:itemId
626
+ router.get("/comments/:itemId", async (req, res) => {
627
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
628
+ if (!project) {
629
+ res.status(404).json({ error: "Project not found" });
630
+ return;
631
+ }
632
+ const result = runPm({
633
+ args: ["comments", req.params["itemId"]],
634
+ userId: project.ownerUserId,
635
+ slug: project.slug,
636
+ jsonOutput: true,
637
+ });
638
+ res.json(result.ok ? (result.parsed || {}) : { comments: [] });
639
+ });
640
+ // GET /api/projects/:projectId/pm/notes/:itemId
641
+ router.get("/notes/:itemId", async (req, res) => {
642
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
643
+ if (!project) {
644
+ res.status(404).json({ error: "Project not found" });
645
+ return;
646
+ }
647
+ const result = runPm({
648
+ args: ["notes", req.params["itemId"]],
649
+ userId: project.ownerUserId,
650
+ slug: project.slug,
651
+ jsonOutput: true,
652
+ });
653
+ res.json(result.ok ? (result.parsed || {}) : { notes: [] });
654
+ });
655
+ // POST /api/projects/:projectId/pm/notes/:itemId
656
+ router.post("/notes/:itemId", async (req, res) => {
657
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
658
+ if (!project) {
659
+ res.status(404).json({ error: "Project not found" });
660
+ return;
661
+ }
662
+ const { text } = req.body;
663
+ if (!text?.trim()) {
664
+ res.status(400).json({ error: "Note text is required" });
665
+ return;
666
+ }
667
+ const result = runPm({
668
+ args: ["notes", req.params["itemId"], text.trim()],
669
+ userId: project.ownerUserId,
670
+ slug: project.slug,
671
+ jsonOutput: true,
672
+ });
673
+ if (!result.ok) {
674
+ res.status(400).json({ error: result.stderr || "Failed to add note" });
675
+ return;
676
+ }
677
+ res.status(201).json(result.parsed || { ok: true });
678
+ });
679
+ // GET /api/projects/:projectId/pm/context
680
+ router.get("/context", async (req, res) => {
681
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
682
+ if (!project) {
683
+ res.status(404).json({ error: "Project not found" });
684
+ return;
685
+ }
686
+ const { depth } = req.query;
687
+ const validDepths = ["brief", "standard", "deep"];
688
+ const resolvedDepth = validDepths.includes(depth) ? depth : "standard";
689
+ const result = runPm({
690
+ args: ["context", "--depth", resolvedDepth],
691
+ userId: project.ownerUserId,
692
+ slug: project.slug,
693
+ jsonOutput: true,
694
+ });
695
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
696
+ });
697
+ // GET /api/projects/:projectId/pm/activity
698
+ router.get("/activity", async (req, res) => {
699
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
700
+ if (!project) {
701
+ res.status(404).json({ error: "Project not found" });
702
+ return;
703
+ }
704
+ const { limit } = req.query;
705
+ const args = ["activity"];
706
+ if (limit)
707
+ args.push("--limit", limit);
708
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
709
+ res.json(result.ok ? (result.parsed || {}) : { activity: [] });
710
+ });
711
+ // GET /api/projects/:projectId/pm/stats
712
+ router.get("/stats", async (req, res) => {
713
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
714
+ if (!project) {
715
+ res.status(404).json({ error: "Project not found" });
716
+ return;
717
+ }
718
+ const result = runPm({
719
+ args: ["stats"],
720
+ userId: project.ownerUserId,
721
+ slug: project.slug,
722
+ jsonOutput: true,
723
+ });
724
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
725
+ });
726
+ // GET /api/projects/:projectId/pm/aggregate
727
+ router.get("/aggregate", async (req, res) => {
728
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
729
+ if (!project) {
730
+ res.status(404).json({ error: "Project not found" });
731
+ return;
732
+ }
733
+ const result = runPm({
734
+ args: ["aggregate"],
735
+ userId: project.ownerUserId,
736
+ slug: project.slug,
737
+ jsonOutput: true,
738
+ });
739
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
740
+ });
741
+ // POST /api/projects/:projectId/pm/search
742
+ router.post("/search", async (req, res) => {
743
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
744
+ if (!project) {
745
+ res.status(404).json({ error: "Project not found" });
746
+ return;
747
+ }
748
+ const { query, mode } = req.body;
749
+ if (!query?.trim()) {
750
+ res.status(400).json({ error: "Search query is required" });
751
+ return;
752
+ }
753
+ const validModes = ["keyword", "semantic", "hybrid"];
754
+ const safeMode = validModes.includes(mode || "") ? mode : "hybrid";
755
+ const result = runPm({
756
+ args: ["search", "--mode", safeMode, ...query.trim().split(/\s+/)],
757
+ userId: project.ownerUserId,
758
+ slug: project.slug,
759
+ jsonOutput: true,
760
+ });
761
+ if (!result.ok) {
762
+ res.status(400).json({
763
+ error: result.stderr || "Search failed. Check that Ollama is reachable and the configured embedding model is available.",
764
+ results: [],
765
+ });
766
+ return;
767
+ }
768
+ res.json(result.parsed || {});
769
+ });
770
+ // GET /api/projects/:projectId/pm/calendar
771
+ router.get("/calendar", async (req, res) => {
772
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
773
+ if (!project) {
774
+ res.status(404).json({ error: "Project not found" });
775
+ return;
776
+ }
777
+ const result = runPm({
778
+ args: ["calendar", "--view", "month"],
779
+ userId: project.ownerUserId,
780
+ slug: project.slug,
781
+ jsonOutput: true,
782
+ });
783
+ res.json(result.ok ? (result.parsed || {}) : { events: [] });
784
+ });
785
+ // GET /api/projects/:projectId/pm/health
786
+ router.get("/health", async (req, res) => {
787
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
788
+ if (!project) {
789
+ res.status(404).json({ error: "Project not found" });
790
+ return;
791
+ }
792
+ const result = runPm({
793
+ args: ["health"],
794
+ userId: project.ownerUserId,
795
+ slug: project.slug,
796
+ jsonOutput: true,
797
+ });
798
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
799
+ });
800
+ // POST /api/projects/:projectId/pm/append/:itemId
801
+ router.post("/append/:itemId", async (req, res) => {
802
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
803
+ if (!project) {
804
+ res.status(404).json({ error: "Project not found" });
805
+ return;
806
+ }
807
+ const { text } = req.body;
808
+ if (!text?.trim()) {
809
+ res.status(400).json({ error: "Text is required" });
810
+ return;
811
+ }
812
+ const result = runPm({
813
+ args: ["append", req.params["itemId"], text.trim()],
814
+ userId: project.ownerUserId,
815
+ slug: project.slug,
816
+ jsonOutput: true,
817
+ });
818
+ if (!result.ok) {
819
+ res.status(400).json({ error: result.stderr || "Failed to append" });
820
+ return;
821
+ }
822
+ scheduleGraphSync(req.params["projectId"], project, "item-appended");
823
+ res.json(result.parsed || { ok: true });
824
+ });
825
+ // GET /api/projects/:projectId/pm/history/:itemId
826
+ router.get("/history/:itemId", async (req, res) => {
827
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
828
+ if (!project) {
829
+ res.status(404).json({ error: "Project not found" });
830
+ return;
831
+ }
832
+ const result = runPm({
833
+ args: ["history", req.params["itemId"]],
834
+ userId: project.ownerUserId,
835
+ slug: project.slug,
836
+ jsonOutput: true,
837
+ });
838
+ res.json(result.ok ? (result.parsed || {}) : { history: [] });
839
+ });
840
+ // GET /api/projects/:projectId/pm/deps/:itemId
841
+ router.get("/deps/:itemId", async (req, res) => {
842
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
843
+ if (!project) {
844
+ res.status(404).json({ error: "Project not found" });
845
+ return;
846
+ }
847
+ const result = runPm({
848
+ args: ["deps", req.params["itemId"]],
849
+ userId: project.ownerUserId,
850
+ slug: project.slug,
851
+ jsonOutput: true,
852
+ });
853
+ res.json(result.ok ? (result.parsed || {}) : { deps: [] });
854
+ });
855
+ // POST /api/projects/:projectId/pm/deps/:itemId
856
+ router.post("/deps/:itemId", async (req, res) => {
857
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
858
+ if (!project) {
859
+ res.status(404).json({ error: "Project not found" });
860
+ return;
861
+ }
862
+ const { targetId, rel } = req.body;
863
+ if (!targetId?.trim()) {
864
+ res.status(400).json({ error: "targetId is required" });
865
+ return;
866
+ }
867
+ const depRel = normalizeDependencyKind(rel);
868
+ const result = runPm({
869
+ args: ["update", req.params["itemId"], "--dep", `id=${targetId.trim()},kind=${depRel}`],
870
+ userId: project.ownerUserId,
871
+ slug: project.slug,
872
+ jsonOutput: true,
873
+ });
874
+ if (!result.ok) {
875
+ res.status(400).json({ error: result.stderr || "Failed to add dependency" });
876
+ return;
877
+ }
878
+ scheduleGraphSync(req.params["projectId"], project, "dependency-added");
879
+ broadcastDependencyEvent(req.params["projectId"], "dependency-added", {
880
+ from: req.params["itemId"],
881
+ to: targetId.trim(),
882
+ rel: depRel,
883
+ userId: req.user.userId,
884
+ });
885
+ res.status(201).json(result.parsed || { ok: true });
886
+ });
887
+ // DELETE /api/projects/:projectId/pm/deps/:itemId
888
+ router.delete("/deps/:itemId", async (req, res) => {
889
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
890
+ if (!project) {
891
+ res.status(404).json({ error: "Project not found" });
892
+ return;
893
+ }
894
+ const { targetId, rel } = req.body;
895
+ if (!targetId?.trim()) {
896
+ res.status(400).json({ error: "targetId is required" });
897
+ return;
898
+ }
899
+ const depRel = normalizeDependencyKind(rel || "relates_to");
900
+ const selector = `id=${targetId.trim()},kind=${depRel}`;
901
+ const result = runPm({
902
+ args: ["update", req.params["itemId"], "--dep-remove", selector],
903
+ userId: project.ownerUserId,
904
+ slug: project.slug,
905
+ jsonOutput: true,
906
+ });
907
+ if (!result.ok) {
908
+ res.status(400).json({ error: result.stderr || "Failed to remove dependency" });
909
+ return;
910
+ }
911
+ scheduleGraphSync(req.params["projectId"], project, "dependency-removed");
912
+ broadcastDependencyEvent(req.params["projectId"], "dependency-removed", {
913
+ from: req.params["itemId"],
914
+ to: targetId.trim(),
915
+ rel: depRel,
916
+ userId: req.user.userId,
917
+ });
918
+ res.status(200).json({ ok: true, from: req.params["itemId"], to: targetId.trim(), type: depRel, result: result.parsed || null });
919
+ });
920
+ // POST /api/projects/:projectId/pm/rel — Create a relationship between two items
921
+ router.post("/rel", async (req, res) => {
922
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
923
+ if (!project) {
924
+ res.status(404).json({ error: "Project not found" });
925
+ return;
926
+ }
927
+ const { from, to, type: relType } = req.body;
928
+ if (!from?.trim() || !to?.trim()) {
929
+ res.status(400).json({ error: "from and to item IDs are required" });
930
+ return;
931
+ }
932
+ const depRel = normalizeDependencyKind(relType || "relates_to");
933
+ const result = runPm({
934
+ args: ["update", from.trim(), "--dep", `id=${to.trim()},kind=${depRel}`],
935
+ userId: project.ownerUserId,
936
+ slug: project.slug,
937
+ jsonOutput: true,
938
+ });
939
+ if (!result.ok) {
940
+ res.status(400).json({ error: result.stderr || "Failed to create relationship" });
941
+ return;
942
+ }
943
+ scheduleGraphSync(req.params["projectId"], project, "rel-created");
944
+ broadcastDependencyEvent(req.params["projectId"], "dependency-added", {
945
+ from: from.trim(),
946
+ to: to.trim(),
947
+ rel: depRel,
948
+ userId: req.user.userId,
949
+ });
950
+ res.status(201).json({ ok: true, from: from.trim(), to: to.trim(), type: depRel });
951
+ });
952
+ // DELETE /api/projects/:projectId/pm/rel — Remove a relationship between two items
953
+ router.delete("/rel", async (req, res) => {
954
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
955
+ if (!project) {
956
+ res.status(404).json({ error: "Project not found" });
957
+ return;
958
+ }
959
+ const { from, to, type: relType } = req.body;
960
+ if (!from?.trim() || !to?.trim()) {
961
+ res.status(400).json({ error: "from and to item IDs are required" });
962
+ return;
963
+ }
964
+ const depRel = normalizeDependencyKind(relType || "relates_to");
965
+ const selector = `id=${to.trim()},kind=${depRel}`;
966
+ const result = runPm({
967
+ args: ["update", from.trim(), "--dep-remove", selector, "--message", `Remove ${depRel} dependency on ${to.trim()}`],
968
+ userId: project.ownerUserId,
969
+ slug: project.slug,
970
+ jsonOutput: true,
971
+ });
972
+ if (!result.ok) {
973
+ res.status(400).json({ error: result.stderr || "Failed to remove relationship" });
974
+ return;
975
+ }
976
+ scheduleGraphSync(req.params["projectId"], project, "rel-removed");
977
+ broadcastDependencyEvent(req.params["projectId"], "dependency-removed", {
978
+ from: from.trim(),
979
+ to: to.trim(),
980
+ rel: depRel,
981
+ userId: req.user.userId,
982
+ });
983
+ res.json({ ok: true, from: from.trim(), to: to.trim(), type: depRel, result: result.parsed || null });
984
+ });
985
+ // GET /api/projects/:projectId/pm/graph
986
+ router.get("/graph", async (req, res) => {
987
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
988
+ if (!project) {
989
+ res.status(404).json({ error: "Project not found" });
990
+ return;
991
+ }
992
+ const extensionGraph = pmGraphExtensionGraphForProject(project);
993
+ if (extensionGraph.graph) {
994
+ res.json({
995
+ ok: true,
996
+ graph: extensionGraph.graph,
997
+ extensionAvailable: true,
998
+ });
999
+ return;
1000
+ }
1001
+ try {
1002
+ res.json({
1003
+ ok: true,
1004
+ graph: fallbackGraphForProject(project.ownerUserId, project.slug),
1005
+ extensionAvailable: false,
1006
+ extensionError: extensionGraph.error,
1007
+ });
1008
+ }
1009
+ catch (err) {
1010
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
1011
+ }
1012
+ });
1013
+ // POST /api/projects/:projectId/pm/graph/sync
1014
+ router.post("/graph/sync", async (req, res) => {
1015
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1016
+ if (!project) {
1017
+ res.status(404).json({ error: "Project not found" });
1018
+ return;
1019
+ }
1020
+ try {
1021
+ const syncResult = await syncProjectGraph(project);
1022
+ const payload = { reason: "manual-sync", ...syncResult, projectKey: graphProjectKey(project), source: "pm-web" };
1023
+ broadcastProjectEvent(req.params["projectId"], {
1024
+ type: "graph-synced",
1025
+ data: payload,
1026
+ });
1027
+ res.json({ ok: true, ...payload });
1028
+ }
1029
+ catch (err) {
1030
+ broadcastProjectEvent(req.params["projectId"], {
1031
+ type: "graph-sync-failed",
1032
+ data: {
1033
+ reason: "manual-sync",
1034
+ error: err instanceof Error ? err.message : String(err),
1035
+ },
1036
+ });
1037
+ broadcastProjectEvent(req.params["projectId"], {
1038
+ type: "graph_sync_failed",
1039
+ data: {
1040
+ reason: "manual-sync",
1041
+ error: err instanceof Error ? err.message : String(err),
1042
+ },
1043
+ });
1044
+ res.status(400).json({ error: err instanceof Error ? err.message : "Graph sync failed." });
1045
+ }
1046
+ });
1047
+ // GET /api/projects/:projectId/pm/graph/neighbors/:nodeId
1048
+ router.get("/graph/neighbors/:nodeId", async (req, res) => {
1049
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1050
+ if (!project) {
1051
+ res.status(404).json({ error: "Project not found" });
1052
+ return;
1053
+ }
1054
+ const nodeId = req.params["nodeId"];
1055
+ if (!nodeId) {
1056
+ res.status(400).json({ error: "nodeId is required" });
1057
+ return;
1058
+ }
1059
+ const result = runPm({
1060
+ args: ["pm-graph", "neighbors", nodeId, "--json"],
1061
+ userId: project.ownerUserId,
1062
+ slug: project.slug,
1063
+ jsonOutput: false,
1064
+ });
1065
+ if (!result.ok) {
1066
+ // Extension not available — return empty neighbors
1067
+ res.json({ ok: true, center: null, neighbors: [], extensionAvailable: false, error: result.stderr || "pm-graph extension not available" });
1068
+ return;
1069
+ }
1070
+ try {
1071
+ const parsed = result.stdout ? JSON.parse(result.stdout) : null;
1072
+ res.json({ ok: true, ...parsed, extensionAvailable: true });
1073
+ }
1074
+ catch {
1075
+ res.json({ ok: true, center: null, neighbors: [], extensionAvailable: false, error: "pm-graph neighbors returned invalid JSON" });
1076
+ }
1077
+ });
1078
+ // POST /api/projects/:projectId/pm/graph/query
1079
+ router.post("/graph/query", async (req, res) => {
1080
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1081
+ if (!project) {
1082
+ res.status(404).json({ error: "Project not found" });
1083
+ return;
1084
+ }
1085
+ const { cypher } = req.body;
1086
+ if (!cypher?.trim()) {
1087
+ res.status(400).json({ error: "cypher query is required" });
1088
+ return;
1089
+ }
1090
+ const result = runPm({
1091
+ args: ["pm-graph", "query", cypher.trim(), "--json"],
1092
+ userId: project.ownerUserId,
1093
+ slug: project.slug,
1094
+ jsonOutput: false,
1095
+ });
1096
+ if (!result.ok) {
1097
+ res.status(400).json({ error: result.stderr || "pm-graph query failed — ensure Neo4j is configured and pm-graph extension is installed" });
1098
+ return;
1099
+ }
1100
+ try {
1101
+ const parsed = result.stdout ? JSON.parse(result.stdout) : { ok: true, records: [] };
1102
+ res.json(parsed);
1103
+ }
1104
+ catch {
1105
+ res.status(500).json({ error: "pm-graph query returned invalid JSON" });
1106
+ }
1107
+ });
1108
+ // GET /api/projects/:projectId/pm/learnings/:itemId
1109
+ router.get("/learnings/:itemId", async (req, res) => {
1110
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1111
+ if (!project) {
1112
+ res.status(404).json({ error: "Project not found" });
1113
+ return;
1114
+ }
1115
+ const result = runPm({
1116
+ args: ["learnings", req.params["itemId"]],
1117
+ userId: project.ownerUserId,
1118
+ slug: project.slug,
1119
+ jsonOutput: true,
1120
+ });
1121
+ res.json(result.ok ? (result.parsed || {}) : { learnings: [] });
1122
+ });
1123
+ // POST /api/projects/:projectId/pm/learnings/:itemId
1124
+ router.post("/learnings/:itemId", async (req, res) => {
1125
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1126
+ if (!project) {
1127
+ res.status(404).json({ error: "Project not found" });
1128
+ return;
1129
+ }
1130
+ const { text } = req.body;
1131
+ if (!text?.trim()) {
1132
+ res.status(400).json({ error: "Learning text is required" });
1133
+ return;
1134
+ }
1135
+ const result = runPm({
1136
+ args: ["learnings", req.params["itemId"], text.trim()],
1137
+ userId: project.ownerUserId,
1138
+ slug: project.slug,
1139
+ jsonOutput: true,
1140
+ });
1141
+ if (!result.ok) {
1142
+ res.status(400).json({ error: result.stderr || "Failed to add learning" });
1143
+ return;
1144
+ }
1145
+ res.status(201).json(result.parsed || { ok: true });
1146
+ });
1147
+ // POST /api/projects/:projectId/pm/claim/:itemId
1148
+ router.post("/claim/:itemId", async (req, res) => {
1149
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1150
+ if (!project) {
1151
+ res.status(404).json({ error: "Project not found" });
1152
+ return;
1153
+ }
1154
+ const result = runPm({
1155
+ args: ["claim", req.params["itemId"]],
1156
+ userId: project.ownerUserId,
1157
+ slug: project.slug,
1158
+ jsonOutput: true,
1159
+ });
1160
+ if (!result.ok) {
1161
+ res.status(400).json({ error: result.stderr || "Failed to claim item" });
1162
+ return;
1163
+ }
1164
+ scheduleGraphSync(req.params["projectId"], project, "item-claimed");
1165
+ res.json(result.parsed || { ok: true });
1166
+ });
1167
+ // POST /api/projects/:projectId/pm/release/:itemId
1168
+ router.post("/release/:itemId", async (req, res) => {
1169
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1170
+ if (!project) {
1171
+ res.status(404).json({ error: "Project not found" });
1172
+ return;
1173
+ }
1174
+ const result = runPm({
1175
+ args: ["release", req.params["itemId"]],
1176
+ userId: project.ownerUserId,
1177
+ slug: project.slug,
1178
+ jsonOutput: true,
1179
+ });
1180
+ if (!result.ok) {
1181
+ res.status(400).json({ error: result.stderr || "Failed to release item" });
1182
+ return;
1183
+ }
1184
+ scheduleGraphSync(req.params["projectId"], project, "item-released");
1185
+ res.json(result.parsed || { ok: true });
1186
+ });
1187
+ // POST /api/projects/:projectId/pm/start-task/:itemId
1188
+ router.post("/start-task/:itemId", async (req, res) => {
1189
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1190
+ if (!project) {
1191
+ res.status(404).json({ error: "Project not found" });
1192
+ return;
1193
+ }
1194
+ const result = runPm({
1195
+ args: ["start-task", req.params["itemId"]],
1196
+ userId: project.ownerUserId,
1197
+ slug: project.slug,
1198
+ jsonOutput: true,
1199
+ });
1200
+ if (!result.ok) {
1201
+ res.status(400).json({ error: result.stderr || "Failed to start task" });
1202
+ return;
1203
+ }
1204
+ scheduleGraphSync(req.params["projectId"], project, "task-started");
1205
+ res.json(result.parsed || { ok: true });
1206
+ });
1207
+ // POST /api/projects/:projectId/pm/pause-task/:itemId
1208
+ router.post("/pause-task/:itemId", async (req, res) => {
1209
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1210
+ if (!project) {
1211
+ res.status(404).json({ error: "Project not found" });
1212
+ return;
1213
+ }
1214
+ const result = runPm({
1215
+ args: ["pause-task", req.params["itemId"]],
1216
+ userId: project.ownerUserId,
1217
+ slug: project.slug,
1218
+ jsonOutput: true,
1219
+ });
1220
+ if (!result.ok) {
1221
+ res.status(400).json({ error: result.stderr || "Failed to pause task" });
1222
+ return;
1223
+ }
1224
+ scheduleGraphSync(req.params["projectId"], project, "task-paused");
1225
+ res.json(result.parsed || { ok: true });
1226
+ });
1227
+ // GET /api/projects/:projectId/pm/tests/:itemId
1228
+ router.get("/tests/:itemId", async (req, res) => {
1229
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1230
+ if (!project) {
1231
+ res.status(404).json({ error: "Project not found" });
1232
+ return;
1233
+ }
1234
+ const result = runPm({
1235
+ args: ["test", req.params["itemId"]],
1236
+ userId: project.ownerUserId,
1237
+ slug: project.slug,
1238
+ jsonOutput: true,
1239
+ });
1240
+ res.json(result.ok ? (result.parsed || {}) : { tests: [] });
1241
+ });
1242
+ // POST /api/projects/:projectId/pm/tests/:itemId
1243
+ router.post("/tests/:itemId", async (req, res) => {
1244
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1245
+ if (!project) {
1246
+ res.status(404).json({ error: "Project not found" });
1247
+ return;
1248
+ }
1249
+ const { command, description } = req.body;
1250
+ if (!command?.trim()) {
1251
+ res.status(400).json({ error: "Test command is required" });
1252
+ return;
1253
+ }
1254
+ const args = ["test", req.params["itemId"], "--add", "--command", command.trim()];
1255
+ if (description)
1256
+ args.push("--description", description.trim());
1257
+ const result = runPm({
1258
+ args,
1259
+ userId: project.ownerUserId,
1260
+ slug: project.slug,
1261
+ jsonOutput: true,
1262
+ });
1263
+ if (!result.ok) {
1264
+ res.status(400).json({ error: result.stderr || "Failed to add test" });
1265
+ return;
1266
+ }
1267
+ res.status(201).json(result.parsed || { ok: true });
1268
+ });
1269
+ // GET /api/projects/:projectId/pm/dedupe-audit
1270
+ router.get("/dedupe-audit", async (req, res) => {
1271
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1272
+ if (!project) {
1273
+ res.status(404).json({ error: "Project not found" });
1274
+ return;
1275
+ }
1276
+ const result = runPm({
1277
+ args: ["dedupe-audit"],
1278
+ userId: project.ownerUserId,
1279
+ slug: project.slug,
1280
+ jsonOutput: true,
1281
+ });
1282
+ res.json(result.ok ? (result.parsed || {}) : { duplicates: [] });
1283
+ });
1284
+ // GET /api/projects/:projectId/pm/validate
1285
+ router.get("/validate", async (req, res) => {
1286
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1287
+ if (!project) {
1288
+ res.status(404).json({ error: "Project not found" });
1289
+ return;
1290
+ }
1291
+ const result = runPm({
1292
+ args: ["validate"],
1293
+ userId: project.ownerUserId,
1294
+ slug: project.slug,
1295
+ jsonOutput: true,
1296
+ });
1297
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1298
+ });
1299
+ // POST /api/projects/:projectId/pm/restore/:itemId
1300
+ router.post("/restore/:itemId", async (req, res) => {
1301
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1302
+ if (!project) {
1303
+ res.status(404).json({ error: "Project not found" });
1304
+ return;
1305
+ }
1306
+ const { target } = req.body;
1307
+ if (!target?.trim()) {
1308
+ res.status(400).json({ error: "Restore target (timestamp or version) is required" });
1309
+ return;
1310
+ }
1311
+ const result = runPm({
1312
+ args: ["restore", req.params["itemId"], target.trim()],
1313
+ userId: project.ownerUserId,
1314
+ slug: project.slug,
1315
+ jsonOutput: true,
1316
+ });
1317
+ if (!result.ok) {
1318
+ res.status(400).json({ error: result.stderr || "Failed to restore item" });
1319
+ return;
1320
+ }
1321
+ scheduleGraphSync(req.params["projectId"], project, "item-restored");
1322
+ res.json(result.parsed || { ok: true });
1323
+ });
1324
+ // POST /api/projects/:projectId/pm/close-task/:itemId
1325
+ router.post("/close-task/:itemId", async (req, res) => {
1326
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1327
+ if (!project) {
1328
+ res.status(404).json({ error: "Project not found" });
1329
+ return;
1330
+ }
1331
+ const { reason } = req.body;
1332
+ if (!reason?.trim()) {
1333
+ res.status(400).json({ error: "Close reason is required" });
1334
+ return;
1335
+ }
1336
+ const result = runPm({
1337
+ args: ["close-task", req.params["itemId"], reason.trim()],
1338
+ userId: project.ownerUserId,
1339
+ slug: project.slug,
1340
+ jsonOutput: true,
1341
+ });
1342
+ if (!result.ok) {
1343
+ res.status(400).json({ error: result.stderr || "Failed to close task" });
1344
+ return;
1345
+ }
1346
+ scheduleGraphSync(req.params["projectId"], project, "task-closed");
1347
+ res.json(result.parsed || { ok: true });
1348
+ });
1349
+ // POST /api/projects/:projectId/pm/reindex
1350
+ router.post("/reindex", async (req, res) => {
1351
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1352
+ if (!project) {
1353
+ res.status(404).json({ error: "Project not found" });
1354
+ return;
1355
+ }
1356
+ const { mode = "keyword" } = req.body;
1357
+ const validModes = ["keyword", "semantic", "hybrid"];
1358
+ const safeMode = validModes.includes(mode) ? mode : "keyword";
1359
+ const result = runPm({
1360
+ args: ["reindex", "--mode", safeMode],
1361
+ userId: project.ownerUserId,
1362
+ slug: project.slug,
1363
+ });
1364
+ if (!result.ok) {
1365
+ res.status(400).json({
1366
+ error: result.stderr || "Reindex failed. Check that Ollama is reachable and the configured embedding model is available.",
1367
+ });
1368
+ return;
1369
+ }
1370
+ res.json({ ok: true, mode: safeMode });
1371
+ });
1372
+ // POST /api/projects/:projectId/pm/normalize
1373
+ router.post("/normalize", async (req, res) => {
1374
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1375
+ if (!project) {
1376
+ res.status(404).json({ error: "Project not found" });
1377
+ return;
1378
+ }
1379
+ const result = runPm({
1380
+ args: ["normalize"],
1381
+ userId: project.ownerUserId,
1382
+ slug: project.slug,
1383
+ jsonOutput: true,
1384
+ });
1385
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1386
+ });
1387
+ // GET /api/projects/:projectId/pm/comments-audit
1388
+ router.get("/comments-audit", async (req, res) => {
1389
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1390
+ if (!project) {
1391
+ res.status(404).json({ error: "Project not found" });
1392
+ return;
1393
+ }
1394
+ const result = runPm({
1395
+ args: ["comments-audit"],
1396
+ userId: project.ownerUserId,
1397
+ slug: project.slug,
1398
+ jsonOutput: true,
1399
+ });
1400
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1401
+ });
1402
+ // POST /api/projects/:projectId/pm/files/:itemId
1403
+ router.post("/files/:itemId", async (req, res) => {
1404
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1405
+ if (!project) {
1406
+ res.status(404).json({ error: "Project not found" });
1407
+ return;
1408
+ }
1409
+ const { path: filePath, scope } = req.body;
1410
+ if (!filePath?.trim()) {
1411
+ res.status(400).json({ error: "File path is required" });
1412
+ return;
1413
+ }
1414
+ let addVal = `path=${filePath.trim()}`;
1415
+ if (scope)
1416
+ addVal += `,scope=${scope}`;
1417
+ const args = ["files", req.params["itemId"], "--add", addVal];
1418
+ const result = runPm({
1419
+ args,
1420
+ userId: project.ownerUserId,
1421
+ slug: project.slug,
1422
+ jsonOutput: true,
1423
+ });
1424
+ if (!result.ok) {
1425
+ res.status(400).json({ error: result.stderr || "Failed to link file" });
1426
+ return;
1427
+ }
1428
+ scheduleGraphSync(req.params["projectId"], project, "file-linked");
1429
+ res.status(201).json(result.parsed || { ok: true });
1430
+ });
1431
+ // GET /api/projects/:projectId/pm/files/:itemId
1432
+ router.get("/files/:itemId", async (req, res) => {
1433
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1434
+ if (!project) {
1435
+ res.status(404).json({ error: "Project not found" });
1436
+ return;
1437
+ }
1438
+ const result = runPm({
1439
+ args: ["files", req.params["itemId"]],
1440
+ userId: project.ownerUserId,
1441
+ slug: project.slug,
1442
+ jsonOutput: true,
1443
+ });
1444
+ res.json(result.ok ? (result.parsed || {}) : { files: [] });
1445
+ });
1446
+ // GET /api/projects/:projectId/pm/guide — list guide topics
1447
+ router.get("/guide", async (req, res) => {
1448
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1449
+ if (!project) {
1450
+ res.status(404).json({ error: "Project not found" });
1451
+ return;
1452
+ }
1453
+ const result = runPm({
1454
+ args: ["guide"],
1455
+ userId: project.ownerUserId,
1456
+ slug: project.slug,
1457
+ jsonOutput: true,
1458
+ });
1459
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1460
+ });
1461
+ // GET /api/projects/:projectId/pm/guide/:topicId — get single guide topic
1462
+ router.get("/guide/:topicId", async (req, res) => {
1463
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1464
+ if (!project) {
1465
+ res.status(404).json({ error: "Project not found" });
1466
+ return;
1467
+ }
1468
+ const result = runPm({
1469
+ args: ["guide", req.params["topicId"]],
1470
+ userId: project.ownerUserId,
1471
+ slug: project.slug,
1472
+ jsonOutput: true,
1473
+ });
1474
+ if (!result.ok) {
1475
+ res.status(404).json({ error: result.stderr || "Topic not found" });
1476
+ return;
1477
+ }
1478
+ res.json(result.parsed || {});
1479
+ });
1480
+ // ─────────────────────────────────────────────────────────
1481
+ // New routes: export, import, update-many, docs, test-all,
1482
+ // test-runs, gc, templates, config, list-status-shortcuts,
1483
+ // SSE endpoint
1484
+ // ─────────────────────────────────────────────────────────
1485
+ // GET /api/projects/:projectId/pm/export?format=json|csv|yaml
1486
+ router.get("/export", async (req, res) => {
1487
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1488
+ if (!project) {
1489
+ res.status(404).json({ error: "Project not found" });
1490
+ return;
1491
+ }
1492
+ const format = req.query["format"] || "json";
1493
+ // Use --full --include-body to get the richest available list-level metadata
1494
+ const result = runPm({
1495
+ args: ["list-all", "--limit", "10000", "--full", "--include-body"],
1496
+ userId: project.ownerUserId,
1497
+ slug: project.slug,
1498
+ jsonOutput: true,
1499
+ });
1500
+ if (!result.ok) {
1501
+ res.status(500).json({ error: result.stderr || "Export failed" });
1502
+ return;
1503
+ }
1504
+ const data = result.parsed;
1505
+ const exportedAt = new Date().toISOString();
1506
+ if (format === "csv") {
1507
+ const items = data?.items ?? [];
1508
+ const rows = items;
1509
+ if (rows.length === 0) {
1510
+ res.setHeader("Content-Type", "text/csv");
1511
+ res.setHeader("Content-Disposition", `attachment; filename="${project.slug}-export.csv"`);
1512
+ res.send("");
1513
+ return;
1514
+ }
1515
+ const headers = ["id", "title", "description", "type", "status", "priority", "tags", "assignee", "sprint", "release", "deadline", "parent", "estimate", "body", "created_at", "updated_at"];
1516
+ const csvLines = [headers.join(",")];
1517
+ for (const item of rows) {
1518
+ const row = headers.map((h) => {
1519
+ const val = item[h];
1520
+ if (val === null || val === undefined)
1521
+ return "";
1522
+ const str = String(Array.isArray(val) ? val.join(";") : val);
1523
+ // Escape CSV: wrap in quotes if contains comma, quote, or newline
1524
+ if (str.includes(",") || str.includes('"') || str.includes("\n")) {
1525
+ return `"${str.replace(/"/g, '""')}"`;
1526
+ }
1527
+ return str;
1528
+ });
1529
+ csvLines.push(row.join(","));
1530
+ }
1531
+ res.setHeader("Content-Type", "text/csv");
1532
+ res.setHeader("Content-Disposition", `attachment; filename="${project.slug}-export.csv"`);
1533
+ res.send(csvLines.join("\n"));
1534
+ }
1535
+ else if (format === "yaml") {
1536
+ const items = (data?.items ?? []);
1537
+ // Simple YAML serializer for this data shape
1538
+ function yamlEscape(v, indent) {
1539
+ const pad = " ".repeat(indent);
1540
+ if (v === null || v === undefined)
1541
+ return "null";
1542
+ if (typeof v === "boolean")
1543
+ return v ? "true" : "false";
1544
+ if (typeof v === "number")
1545
+ return String(v);
1546
+ if (Array.isArray(v)) {
1547
+ if (v.length === 0)
1548
+ return "[]";
1549
+ return "\n" + v.map((item) => `${pad}- ${yamlEscape(item, indent + 1)}`).join("\n");
1550
+ }
1551
+ if (typeof v === "object") {
1552
+ const entries = Object.entries(v).filter(([, val]) => val !== null && val !== undefined);
1553
+ if (entries.length === 0)
1554
+ return "{}";
1555
+ return "\n" + entries.map(([k, val]) => {
1556
+ const valStr = yamlEscape(val, indent + 1);
1557
+ return valStr.startsWith("\n") ? `${pad}${k}:${valStr}` : `${pad}${k}: ${valStr}`;
1558
+ }).join("\n");
1559
+ }
1560
+ const str = String(v);
1561
+ if (str.includes("\n") || str.includes(":") || str.includes("#") || str.includes('"') || str.startsWith(" ") || str.endsWith(" ")) {
1562
+ return `"${str.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
1563
+ }
1564
+ return str || '""';
1565
+ }
1566
+ const yamlItems = items.map((item) => {
1567
+ const fields = Object.keys(item).filter((k) => item[k] !== null && item[k] !== undefined && item[k] !== "");
1568
+ const lines = fields.map((f) => {
1569
+ const valStr = yamlEscape(item[f], 1);
1570
+ return valStr.startsWith("\n") ? ` ${f}:${valStr}` : ` ${f}: ${valStr}`;
1571
+ });
1572
+ return "- " + lines.join("\n").trimStart();
1573
+ });
1574
+ const header = `# pm-web export\n# project: ${project.slug}\n# exported_at: ${exportedAt}\n# version: "2.0"\nitems:\n`;
1575
+ res.setHeader("Content-Type", "text/yaml");
1576
+ res.setHeader("Content-Disposition", `attachment; filename="${project.slug}-export.yaml"`);
1577
+ res.send(header + yamlItems.join("\n"));
1578
+ }
1579
+ else {
1580
+ res.setHeader("Content-Type", "application/json");
1581
+ res.setHeader("Content-Disposition", `attachment; filename="${project.slug}-export.json"`);
1582
+ res.json({ exportedAt, project: project.slug, version: "2.0", items: data?.items ?? [] });
1583
+ }
1584
+ });
1585
+ // POST /api/projects/:projectId/pm/import — import JSON items
1586
+ router.post("/import", async (req, res) => {
1587
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1588
+ if (!project) {
1589
+ res.status(404).json({ error: "Project not found" });
1590
+ return;
1591
+ }
1592
+ const { items } = req.body;
1593
+ if (!items || !Array.isArray(items) || items.length === 0) {
1594
+ res.status(400).json({ error: "items array is required" });
1595
+ return;
1596
+ }
1597
+ if (items.length > 500) {
1598
+ res.status(400).json({ error: "Cannot import more than 500 items at once" });
1599
+ return;
1600
+ }
1601
+ const created = [];
1602
+ const errors = [];
1603
+ for (let i = 0; i < items.length; i++) {
1604
+ const item = items[i];
1605
+ if (!item.title?.trim()) {
1606
+ errors.push(`item[${i}]: title is required`);
1607
+ continue;
1608
+ }
1609
+ const args = ["create", "--title", item.title.trim()];
1610
+ if (item.type)
1611
+ args.push("--type", item.type);
1612
+ if (item.description)
1613
+ args.push("--description", item.description);
1614
+ else
1615
+ args.push("--description", item.title.trim());
1616
+ if (item.priority)
1617
+ args.push("--priority", item.priority);
1618
+ if (item.status)
1619
+ args.push("--status", item.status);
1620
+ if (item.tags)
1621
+ args.push("--tags", item.tags);
1622
+ if (item.assignee)
1623
+ args.push("--assignee", item.assignee);
1624
+ if (item.sprint)
1625
+ args.push("--sprint", item.sprint);
1626
+ if (item.release)
1627
+ args.push("--release", item.release);
1628
+ if (item.deadline)
1629
+ args.push("--deadline", item.deadline);
1630
+ if (item.body)
1631
+ args.push("--body", item.body);
1632
+ if (item.parent)
1633
+ args.push("--parent", item.parent);
1634
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1635
+ if (result.ok && result.parsed) {
1636
+ const parsed = result.parsed;
1637
+ created.push(parsed.item?.id || `item[${i}]`);
1638
+ }
1639
+ else {
1640
+ errors.push(`item[${i}]: ${result.stderr || "create failed"}`);
1641
+ }
1642
+ }
1643
+ broadcastProjectEvent(req.params["projectId"], {
1644
+ type: "items-imported",
1645
+ data: { count: created.length, userId: req.user.userId },
1646
+ });
1647
+ if (created.length > 0)
1648
+ scheduleGraphSync(req.params["projectId"], project, "items-imported");
1649
+ res.json({ created, errors, total: items.length });
1650
+ });
1651
+ // POST /api/projects/:projectId/pm/update-many
1652
+ router.post("/update-many", async (req, res) => {
1653
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1654
+ if (!project) {
1655
+ res.status(404).json({ error: "Project not found" });
1656
+ return;
1657
+ }
1658
+ const body = req.body;
1659
+ const args = ["update-many"];
1660
+ // Filter options
1661
+ const filterFlags = {
1662
+ filterStatus: "--filter-status", filterType: "--filter-type",
1663
+ filterTag: "--filter-tag", filterPriority: "--filter-priority",
1664
+ filterDeadlineBefore: "--filter-deadline-before", filterDeadlineAfter: "--filter-deadline-after",
1665
+ filterAssignee: "--filter-assignee", filterParent: "--filter-parent",
1666
+ filterSprint: "--filter-sprint", filterRelease: "--filter-release",
1667
+ limit: "--limit", offset: "--offset",
1668
+ };
1669
+ for (const [key, flag] of Object.entries(filterFlags)) {
1670
+ if (body[key])
1671
+ args.push(flag, body[key]);
1672
+ }
1673
+ if (body.dryRun === "true")
1674
+ args.push("--dry-run");
1675
+ if (body.rollback)
1676
+ args.push("--rollback", body.rollback);
1677
+ // Update options (same as update)
1678
+ const updateFlags = {
1679
+ title: "--title", description: "--description", body: "--body", status: "--status",
1680
+ priority: "--priority", type: "--type", tags: "--tags", deadline: "--deadline",
1681
+ estimate: "--estimate", acceptanceCriteria: "--acceptance-criteria",
1682
+ definitionOfReady: "--definition-of-ready", sprint: "--sprint", release: "--release",
1683
+ assignee: "--assignee", reviewer: "--reviewer", risk: "--risk", confidence: "--confidence",
1684
+ goal: "--goal", objective: "--objective", value: "--value", impact: "--impact",
1685
+ outcome: "--outcome", whyNow: "--why-now",
1686
+ };
1687
+ for (const [key, flag] of Object.entries(updateFlags)) {
1688
+ if (body[key])
1689
+ args.push(flag, body[key]);
1690
+ }
1691
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1692
+ if (!result.ok) {
1693
+ res.status(400).json({ error: result.stderr || "update-many failed" });
1694
+ return;
1695
+ }
1696
+ broadcastProjectEvent(req.params["projectId"], {
1697
+ type: "items-bulk-updated",
1698
+ data: { userId: req.user.userId },
1699
+ });
1700
+ scheduleGraphSync(req.params["projectId"], project, "items-bulk-updated");
1701
+ res.json(result.parsed || {});
1702
+ });
1703
+ // POST /api/projects/:projectId/pm/close-many
1704
+ // Bulk-close items matching filter criteria using pm close <id> <reason> for each matched item.
1705
+ // Accepts same filter options as update-many plus a required `reason` field.
1706
+ // Returns { closed_count, failed_count, skipped_count, rows }.
1707
+ router.post("/close-many", async (req, res) => {
1708
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1709
+ if (!project) {
1710
+ res.status(404).json({ error: "Project not found" });
1711
+ return;
1712
+ }
1713
+ const body = req.body;
1714
+ const reason = body.reason?.trim();
1715
+ if (!reason) {
1716
+ res.status(400).json({ error: "A close reason is required" });
1717
+ return;
1718
+ }
1719
+ const targetStatus = body.targetStatus === "canceled" ? "canceled" : "closed";
1720
+ // First, use update-many --dry-run to get the list of matched items
1721
+ const listArgs = ["update-many", "--dry-run", "--status", "open"];
1722
+ const filterFlags = {
1723
+ filterStatus: "--filter-status", filterType: "--filter-type",
1724
+ filterTag: "--filter-tag", filterPriority: "--filter-priority",
1725
+ filterAssignee: "--filter-assignee", filterParent: "--filter-parent",
1726
+ filterSprint: "--filter-sprint", filterRelease: "--filter-release",
1727
+ limit: "--limit",
1728
+ };
1729
+ for (const [key, flag] of Object.entries(filterFlags)) {
1730
+ if (body[key])
1731
+ listArgs.push(flag, body[key]);
1732
+ }
1733
+ const listResult = runPm({ args: listArgs, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1734
+ if (!listResult.ok) {
1735
+ res.status(400).json({ error: listResult.stderr || "Failed to list items for close-many" });
1736
+ return;
1737
+ }
1738
+ const parsed = listResult.parsed;
1739
+ const itemPlans = Array.isArray(parsed?.["item_plans"]) ? parsed["item_plans"] : [];
1740
+ const matchedIds = itemPlans.map((p) => p.id).filter(Boolean);
1741
+ if (matchedIds.length === 0) {
1742
+ res.json({ closed_count: 0, failed_count: 0, skipped_count: 0, rows: [], matched_count: 0 });
1743
+ return;
1744
+ }
1745
+ // Close (or cancel) each matched item individually
1746
+ const rows = [];
1747
+ let closedCount = 0;
1748
+ let failedCount = 0;
1749
+ for (const itemId of matchedIds) {
1750
+ const closeArgs = targetStatus === "canceled"
1751
+ ? ["update", itemId, "--status", "canceled"]
1752
+ : ["close", itemId, reason];
1753
+ const closeResult = runPm({ args: closeArgs, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1754
+ if (closeResult.ok) {
1755
+ rows.push({ id: itemId, status: "ok" });
1756
+ closedCount++;
1757
+ }
1758
+ else {
1759
+ rows.push({ id: itemId, status: "failed", error: closeResult.stderr || "close failed" });
1760
+ failedCount++;
1761
+ }
1762
+ }
1763
+ if (closedCount > 0) {
1764
+ broadcastProjectEvent(req.params["projectId"], {
1765
+ type: "items-bulk-updated",
1766
+ data: { userId: req.user.userId },
1767
+ });
1768
+ scheduleGraphSync(req.params["projectId"], project, "items-bulk-updated");
1769
+ }
1770
+ res.json({
1771
+ closed_count: closedCount,
1772
+ failed_count: failedCount,
1773
+ skipped_count: 0,
1774
+ matched_count: matchedIds.length,
1775
+ rows,
1776
+ });
1777
+ });
1778
+ // GET /api/projects/:projectId/pm/docs/:itemId
1779
+ router.get("/docs/:itemId", async (req, res) => {
1780
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1781
+ if (!project) {
1782
+ res.status(404).json({ error: "Project not found" });
1783
+ return;
1784
+ }
1785
+ const result = runPm({
1786
+ args: ["docs", req.params["itemId"]],
1787
+ userId: project.ownerUserId,
1788
+ slug: project.slug,
1789
+ jsonOutput: true,
1790
+ });
1791
+ res.json(result.ok ? (result.parsed || {}) : { docs: [] });
1792
+ });
1793
+ // POST /api/projects/:projectId/pm/docs/:itemId
1794
+ router.post("/docs/:itemId", async (req, res) => {
1795
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1796
+ if (!project) {
1797
+ res.status(404).json({ error: "Project not found" });
1798
+ return;
1799
+ }
1800
+ const { path: docPath, scope, note, remove, validatePaths } = req.body;
1801
+ const args = ["docs", req.params["itemId"]];
1802
+ if (remove) {
1803
+ args.push("--remove", remove);
1804
+ }
1805
+ else if (validatePaths === "true") {
1806
+ args.push("--validate-paths");
1807
+ }
1808
+ else if (docPath) {
1809
+ let addVal = `path=${docPath}`;
1810
+ if (scope)
1811
+ addVal += `,scope=${scope}`;
1812
+ if (note)
1813
+ addVal += `,note=${note}`;
1814
+ args.push("--add", addVal);
1815
+ }
1816
+ else {
1817
+ res.status(400).json({ error: "path, remove, or validatePaths is required" });
1818
+ return;
1819
+ }
1820
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1821
+ if (!result.ok) {
1822
+ res.status(400).json({ error: result.stderr || "Failed to update docs" });
1823
+ return;
1824
+ }
1825
+ scheduleGraphSync(req.params["projectId"], project, "docs-updated");
1826
+ res.json(result.parsed || { ok: true });
1827
+ });
1828
+ // POST /api/projects/:projectId/pm/test-all
1829
+ router.post("/test-all", async (req, res) => {
1830
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1831
+ if (!project) {
1832
+ res.status(404).json({ error: "Project not found" });
1833
+ return;
1834
+ }
1835
+ const body = req.body;
1836
+ const args = ["test-all"];
1837
+ if (body.status)
1838
+ args.push("--status", body.status);
1839
+ if (body.limit)
1840
+ args.push("--limit", body.limit);
1841
+ if (body.timeout)
1842
+ args.push("--timeout", body.timeout);
1843
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1844
+ if (!result.ok) {
1845
+ res.status(400).json({ error: result.stderr || "test-all failed" });
1846
+ return;
1847
+ }
1848
+ res.json(result.parsed || {});
1849
+ });
1850
+ // GET /api/projects/:projectId/pm/test-runs
1851
+ router.get("/test-runs", async (req, res) => {
1852
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1853
+ if (!project) {
1854
+ res.status(404).json({ error: "Project not found" });
1855
+ return;
1856
+ }
1857
+ const { status, limit } = req.query;
1858
+ const args = ["test-runs", "list"];
1859
+ if (status)
1860
+ args.push("--status", status);
1861
+ if (limit)
1862
+ args.push("--limit", limit);
1863
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1864
+ res.json(result.ok ? (result.parsed || {}) : { runs: [] });
1865
+ });
1866
+ // POST /api/projects/:projectId/pm/gc
1867
+ router.post("/gc", async (req, res) => {
1868
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1869
+ if (!project) {
1870
+ res.status(404).json({ error: "Project not found" });
1871
+ return;
1872
+ }
1873
+ const result = runPm({ args: ["gc"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1874
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1875
+ });
1876
+ // GET /api/projects/:projectId/pm/templates
1877
+ router.get("/templates", async (req, res) => {
1878
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1879
+ if (!project) {
1880
+ res.status(404).json({ error: "Project not found" });
1881
+ return;
1882
+ }
1883
+ const result = runPm({ args: ["templates", "list"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1884
+ res.json(result.ok ? (result.parsed || {}) : { templates: [] });
1885
+ });
1886
+ // GET /api/projects/:projectId/pm/templates/:name
1887
+ router.get("/templates/:name", async (req, res) => {
1888
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1889
+ if (!project) {
1890
+ res.status(404).json({ error: "Project not found" });
1891
+ return;
1892
+ }
1893
+ const result = runPm({ args: ["templates", "show", req.params["name"]], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1894
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1895
+ });
1896
+ // GET /api/projects/:projectId/pm/config
1897
+ router.get("/config", async (req, res) => {
1898
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1899
+ if (!project) {
1900
+ res.status(404).json({ error: "Project not found" });
1901
+ return;
1902
+ }
1903
+ const result = runPm({ args: ["config", "project", "list"], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1904
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1905
+ });
1906
+ // GET /api/projects/:projectId/pm/config/:key
1907
+ router.get("/config/:key", async (req, res) => {
1908
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1909
+ if (!project) {
1910
+ res.status(404).json({ error: "Project not found" });
1911
+ return;
1912
+ }
1913
+ const key = req.params["key"];
1914
+ const result = runPm({ args: ["config", "project", "get", key], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1915
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1916
+ });
1917
+ // PATCH /api/projects/:projectId/pm/config/:key
1918
+ router.patch("/config/:key", async (req, res) => {
1919
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1920
+ if (!project) {
1921
+ res.status(404).json({ error: "Project not found" });
1922
+ return;
1923
+ }
1924
+ const key = req.params["key"];
1925
+ const body = req.body;
1926
+ const args = ["config", "project", "set", key];
1927
+ if (body.value)
1928
+ args.push(body.value);
1929
+ if (body.policy)
1930
+ args.push("--policy", body.policy);
1931
+ if (body.format)
1932
+ args.push("--format", body.format);
1933
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1934
+ res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1935
+ });
1936
+ // ─── List status shortcut routes ───
1937
+ // These wrap pm list-draft, list-open, etc.
1938
+ function buildListShortcutRoute(pmCommand) {
1939
+ return async (req, res) => {
1940
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1941
+ if (!project) {
1942
+ res.status(404).json({ error: "Project not found" });
1943
+ return;
1944
+ }
1945
+ const { type, limit, offset, tag, priority, assignee, sprint, release } = req.query;
1946
+ const args = [pmCommand];
1947
+ if (type)
1948
+ args.push("--type", type);
1949
+ if (limit)
1950
+ args.push("--limit", limit);
1951
+ if (offset)
1952
+ args.push("--offset", offset);
1953
+ if (tag)
1954
+ args.push("--tag", tag);
1955
+ if (priority)
1956
+ args.push("--priority", priority);
1957
+ if (assignee)
1958
+ args.push("--assignee", assignee);
1959
+ if (sprint)
1960
+ args.push("--sprint", sprint);
1961
+ if (release)
1962
+ args.push("--release", release);
1963
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1964
+ res.json(result.ok ? (result.parsed || {}) : { items: [] });
1965
+ };
1966
+ }
1967
+ router.get("/list-draft", buildListShortcutRoute("list-draft"));
1968
+ router.get("/list-open", buildListShortcutRoute("list-open"));
1969
+ router.get("/list-in-progress", buildListShortcutRoute("list-in-progress"));
1970
+ router.get("/list-blocked", buildListShortcutRoute("list-blocked"));
1971
+ router.get("/list-closed", buildListShortcutRoute("list-closed"));
1972
+ router.get("/list-canceled", buildListShortcutRoute("list-canceled"));
1973
+ // ─────────────────────────────────────────────────────────
1974
+ // Plan routes
1975
+ // ─────────────────────────────────────────────────────────
1976
+ // POST /api/projects/:projectId/pm/plan
1977
+ router.post("/plan", async (req, res) => {
1978
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
1979
+ if (!project) {
1980
+ res.status(404).json({ error: "Project not found" });
1981
+ return;
1982
+ }
1983
+ const { title, description, scope, tags, priority, body } = req.body;
1984
+ if (!title?.trim()) {
1985
+ res.status(400).json({ error: "Title is required" });
1986
+ return;
1987
+ }
1988
+ const args = ["plan", "create", "--title", title.trim()];
1989
+ if (description)
1990
+ args.push("--description", description);
1991
+ if (scope)
1992
+ args.push("--scope", scope);
1993
+ if (tags)
1994
+ args.push("--tags", tags);
1995
+ if (priority)
1996
+ args.push("--priority", priority);
1997
+ if (body)
1998
+ args.push("--body", body);
1999
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2000
+ if (!result.ok) {
2001
+ res.status(400).json({ error: result.stderr || "Failed to create plan" });
2002
+ return;
2003
+ }
2004
+ broadcastProjectEvent(req.params["projectId"], {
2005
+ type: "item-created",
2006
+ data: { result: result.parsed, userId: req.user.userId },
2007
+ });
2008
+ res.status(201).json(result.parsed || {});
2009
+ });
2010
+ // GET /api/projects/:projectId/pm/plan/:planId
2011
+ router.get("/plan/:planId", async (req, res) => {
2012
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2013
+ if (!project) {
2014
+ res.status(404).json({ error: "Project not found" });
2015
+ return;
2016
+ }
2017
+ const result = runPm({
2018
+ args: ["plan", "show", req.params["planId"], "--depth", "standard"],
2019
+ userId: project.ownerUserId,
2020
+ slug: project.slug,
2021
+ jsonOutput: true,
2022
+ });
2023
+ if (!result.ok) {
2024
+ res.status(404).json({ error: result.stderr || "Plan not found" });
2025
+ return;
2026
+ }
2027
+ res.json(result.parsed || {});
2028
+ });
2029
+ // PATCH /api/projects/:projectId/pm/plan/:planId
2030
+ router.patch("/plan/:planId", async (req, res) => {
2031
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2032
+ if (!project) {
2033
+ res.status(404).json({ error: "Project not found" });
2034
+ return;
2035
+ }
2036
+ const { title, description } = req.body;
2037
+ const args = ["update", req.params["planId"]];
2038
+ if (title?.trim())
2039
+ args.push("--title", title.trim());
2040
+ if (description !== undefined)
2041
+ args.push("--description", description);
2042
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2043
+ if (!result.ok) {
2044
+ res.status(400).json({ error: result.stderr || "Failed to update plan" });
2045
+ return;
2046
+ }
2047
+ broadcastProjectEvent(req.params["projectId"], {
2048
+ type: "item-updated",
2049
+ data: { itemId: req.params["planId"], userId: req.user.userId },
2050
+ });
2051
+ res.json(result.parsed || {});
2052
+ });
2053
+ // DELETE /api/projects/:projectId/pm/plan/:planId
2054
+ router.delete("/plan/:planId", async (req, res) => {
2055
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2056
+ if (!project) {
2057
+ res.status(404).json({ error: "Project not found" });
2058
+ return;
2059
+ }
2060
+ const result = runPm({
2061
+ args: ["delete", req.params["planId"]],
2062
+ userId: project.ownerUserId,
2063
+ slug: project.slug,
2064
+ jsonOutput: true,
2065
+ });
2066
+ if (!result.ok) {
2067
+ res.status(400).json({ error: result.stderr || "Failed to delete plan" });
2068
+ return;
2069
+ }
2070
+ broadcastProjectEvent(req.params["projectId"], {
2071
+ type: "item-deleted",
2072
+ data: { itemId: req.params["planId"], userId: req.user.userId },
2073
+ });
2074
+ res.json({ ok: true });
2075
+ });
2076
+ // POST /api/projects/:projectId/pm/plan/:planId/steps
2077
+ router.post("/plan/:planId/steps", async (req, res) => {
2078
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2079
+ if (!project) {
2080
+ res.status(404).json({ error: "Project not found" });
2081
+ return;
2082
+ }
2083
+ const { title, description, dependsOn } = req.body;
2084
+ if (!title?.trim()) {
2085
+ res.status(400).json({ error: "Title is required" });
2086
+ return;
2087
+ }
2088
+ const args = ["plan", "add-step", req.params["planId"], "--title", title.trim()];
2089
+ if (description)
2090
+ args.push("--description", description);
2091
+ if (dependsOn)
2092
+ args.push("--depends-on", dependsOn);
2093
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2094
+ if (!result.ok) {
2095
+ res.status(400).json({ error: result.stderr || "Failed to add step" });
2096
+ return;
2097
+ }
2098
+ broadcastProjectEvent(req.params["projectId"], {
2099
+ type: "item-updated",
2100
+ data: { itemId: req.params["planId"], userId: req.user.userId },
2101
+ });
2102
+ res.status(201).json(result.parsed || {});
2103
+ });
2104
+ // PATCH /api/projects/:projectId/pm/plan/:planId/steps/:stepRef
2105
+ router.patch("/plan/:planId/steps/:stepRef", async (req, res) => {
2106
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2107
+ if (!project) {
2108
+ res.status(404).json({ error: "Project not found" });
2109
+ return;
2110
+ }
2111
+ const { title, description } = req.body;
2112
+ const args = ["plan", "update-step", req.params["planId"], req.params["stepRef"]];
2113
+ if (title)
2114
+ args.push("--title", title);
2115
+ if (description)
2116
+ args.push("--description", description);
2117
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2118
+ if (!result.ok) {
2119
+ res.status(400).json({ error: result.stderr || "Failed to update step" });
2120
+ return;
2121
+ }
2122
+ broadcastProjectEvent(req.params["projectId"], {
2123
+ type: "item-updated",
2124
+ data: { itemId: req.params["planId"], userId: req.user.userId },
2125
+ });
2126
+ res.json(result.parsed || {});
2127
+ });
2128
+ // POST /api/projects/:projectId/pm/plan/:planId/steps/:stepRef/complete
2129
+ router.post("/plan/:planId/steps/:stepRef/complete", async (req, res) => {
2130
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2131
+ if (!project) {
2132
+ res.status(404).json({ error: "Project not found" });
2133
+ return;
2134
+ }
2135
+ const result = runPm({
2136
+ args: ["plan", "complete-step", req.params["planId"], req.params["stepRef"]],
2137
+ userId: project.ownerUserId,
2138
+ slug: project.slug,
2139
+ jsonOutput: true,
2140
+ });
2141
+ if (!result.ok) {
2142
+ res.status(400).json({ error: result.stderr || "Failed to complete step" });
2143
+ return;
2144
+ }
2145
+ broadcastProjectEvent(req.params["projectId"], {
2146
+ type: "item-updated",
2147
+ data: { itemId: req.params["planId"], userId: req.user.userId },
2148
+ });
2149
+ res.json(result.parsed || {});
2150
+ });
2151
+ // POST /api/projects/:projectId/pm/plan/:planId/steps/:stepRef/block
2152
+ router.post("/plan/:planId/steps/:stepRef/block", async (req, res) => {
2153
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2154
+ if (!project) {
2155
+ res.status(404).json({ error: "Project not found" });
2156
+ return;
2157
+ }
2158
+ const { reason } = req.body;
2159
+ if (!reason?.trim()) {
2160
+ res.status(400).json({ error: "Block reason is required" });
2161
+ return;
2162
+ }
2163
+ const result = runPm({
2164
+ args: ["plan", "block-step", req.params["planId"], req.params["stepRef"], "--step-blocked-reason", reason.trim()],
2165
+ userId: project.ownerUserId,
2166
+ slug: project.slug,
2167
+ jsonOutput: true,
2168
+ });
2169
+ if (!result.ok) {
2170
+ res.status(400).json({ error: result.stderr || "Failed to block step" });
2171
+ return;
2172
+ }
2173
+ broadcastProjectEvent(req.params["projectId"], {
2174
+ type: "item-updated",
2175
+ data: { itemId: req.params["planId"], userId: req.user.userId },
2176
+ });
2177
+ res.json(result.parsed || {});
2178
+ });
2179
+ // DELETE /api/projects/:projectId/pm/plan/:planId/steps/:stepRef
2180
+ router.delete("/plan/:planId/steps/:stepRef", async (req, res) => {
2181
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2182
+ if (!project) {
2183
+ res.status(404).json({ error: "Project not found" });
2184
+ return;
2185
+ }
2186
+ const result = runPm({
2187
+ args: ["plan", "remove-step", req.params["planId"], req.params["stepRef"]],
2188
+ userId: project.ownerUserId,
2189
+ slug: project.slug,
2190
+ jsonOutput: true,
2191
+ });
2192
+ if (!result.ok) {
2193
+ res.status(400).json({ error: result.stderr || "Failed to remove step" });
2194
+ return;
2195
+ }
2196
+ broadcastProjectEvent(req.params["projectId"], {
2197
+ type: "item-updated",
2198
+ data: { itemId: req.params["planId"], userId: req.user.userId },
2199
+ });
2200
+ res.json(result.parsed || {});
2201
+ });
2202
+ // POST /api/projects/:projectId/pm/plan/:planId/approve
2203
+ router.post("/plan/:planId/approve", async (req, res) => {
2204
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2205
+ if (!project) {
2206
+ res.status(404).json({ error: "Project not found" });
2207
+ return;
2208
+ }
2209
+ const result = runPm({
2210
+ args: ["plan", "approve", req.params["planId"]],
2211
+ userId: project.ownerUserId,
2212
+ slug: project.slug,
2213
+ jsonOutput: true,
2214
+ });
2215
+ if (!result.ok) {
2216
+ res.status(400).json({ error: result.stderr || "Failed to approve plan" });
2217
+ return;
2218
+ }
2219
+ broadcastProjectEvent(req.params["projectId"], {
2220
+ type: "item-updated",
2221
+ data: { itemId: req.params["planId"], userId: req.user.userId },
2222
+ });
2223
+ res.json(result.parsed || {});
2224
+ });
2225
+ // POST /api/projects/:projectId/pm/plan/:planId/materialize
2226
+ router.post("/plan/:planId/materialize", async (req, res) => {
2227
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2228
+ if (!project) {
2229
+ res.status(404).json({ error: "Project not found" });
2230
+ return;
2231
+ }
2232
+ const { materializeType, materializeParent, steps } = req.body;
2233
+ const args = ["plan", "materialize", req.params["planId"]];
2234
+ if (materializeType)
2235
+ args.push("--materialize-type", materializeType);
2236
+ if (materializeParent)
2237
+ args.push("--materialize-parent", materializeParent);
2238
+ if (steps)
2239
+ args.push("--steps", steps);
2240
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2241
+ if (!result.ok) {
2242
+ res.status(400).json({ error: result.stderr || "Failed to materialize plan" });
2243
+ return;
2244
+ }
2245
+ broadcastProjectEvent(req.params["projectId"], {
2246
+ type: "item-created",
2247
+ data: { result: result.parsed, userId: req.user.userId },
2248
+ });
2249
+ res.json(result.parsed || {});
2250
+ });
2251
+ // POST /api/projects/:projectId/pm/plan/:planId/steps/:stepRef/reorder
2252
+ router.post("/plan/:planId/steps/:stepRef/reorder", async (req, res) => {
2253
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2254
+ if (!project) {
2255
+ res.status(404).json({ error: "Project not found" });
2256
+ return;
2257
+ }
2258
+ const { reorderTo } = req.body;
2259
+ if (reorderTo === undefined || reorderTo === null || reorderTo === "") {
2260
+ res.status(400).json({ error: "reorderTo (new order integer) is required" });
2261
+ return;
2262
+ }
2263
+ const result = runPm({
2264
+ args: ["plan", "reorder-step", req.params["planId"], req.params["stepRef"], String(reorderTo)],
2265
+ userId: project.ownerUserId,
2266
+ slug: project.slug,
2267
+ jsonOutput: true,
2268
+ });
2269
+ if (!result.ok) {
2270
+ res.status(400).json({ error: result.stderr || "Failed to reorder step" });
2271
+ return;
2272
+ }
2273
+ broadcastProjectEvent(req.params["projectId"], {
2274
+ type: "item-updated",
2275
+ data: { itemId: req.params["planId"], userId: req.user.userId },
2276
+ });
2277
+ res.json(result.parsed || {});
2278
+ });
2279
+ // POST /api/projects/:projectId/pm/plan/:planId/link
2280
+ router.post("/plan/:planId/link", async (req, res) => {
2281
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2282
+ if (!project) {
2283
+ res.status(404).json({ error: "Project not found" });
2284
+ return;
2285
+ }
2286
+ const { link, linkKind, linkNote, promoteToItemDep } = req.body;
2287
+ if (!link?.trim()) {
2288
+ res.status(400).json({ error: "link (item id) is required" });
2289
+ return;
2290
+ }
2291
+ const args = ["plan", "link", req.params["planId"], "--link", link.trim()];
2292
+ if (linkKind)
2293
+ args.push("--link-kind", linkKind);
2294
+ if (linkNote)
2295
+ args.push("--link-note", linkNote);
2296
+ if (promoteToItemDep === "true")
2297
+ args.push("--promote-to-item-dep");
2298
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2299
+ if (!result.ok) {
2300
+ res.status(400).json({ error: result.stderr || "Failed to link plan" });
2301
+ return;
2302
+ }
2303
+ broadcastProjectEvent(req.params["projectId"], {
2304
+ type: "item-updated",
2305
+ data: { itemId: req.params["planId"], userId: req.user.userId },
2306
+ });
2307
+ res.status(201).json(result.parsed || {});
2308
+ });
2309
+ // DELETE /api/projects/:projectId/pm/plan/:planId/link
2310
+ router.delete("/plan/:planId/link", async (req, res) => {
2311
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2312
+ if (!project) {
2313
+ res.status(404).json({ error: "Project not found" });
2314
+ return;
2315
+ }
2316
+ const { link, linkKind } = req.body;
2317
+ if (!link?.trim()) {
2318
+ res.status(400).json({ error: "link (item id) is required" });
2319
+ return;
2320
+ }
2321
+ const args = ["plan", "unlink", req.params["planId"], "--link", link.trim()];
2322
+ if (linkKind)
2323
+ args.push("--link-kind", linkKind);
2324
+ const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
2325
+ if (!result.ok) {
2326
+ res.status(400).json({ error: result.stderr || "Failed to unlink plan" });
2327
+ return;
2328
+ }
2329
+ broadcastProjectEvent(req.params["projectId"], {
2330
+ type: "item-updated",
2331
+ data: { itemId: req.params["planId"], userId: req.user.userId },
2332
+ });
2333
+ res.json(result.parsed || {});
2334
+ });
2335
+ // GET /api/projects/:projectId/pm/upgrade
2336
+ // Returns a dry-run preview of what upgrade would do (safe, read-only).
2337
+ // POST /api/projects/:projectId/pm/upgrade
2338
+ // Runs pm upgrade --packages-only (never upgrades the CLI itself via the web UI).
2339
+ router.get("/upgrade", async (req, res) => {
2340
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2341
+ if (!project) {
2342
+ res.status(404).json({ error: "Project not found" });
2343
+ return;
2344
+ }
2345
+ const result = runPm({
2346
+ args: ["upgrade", "--dry-run", "--packages-only"],
2347
+ userId: project.ownerUserId,
2348
+ slug: project.slug,
2349
+ jsonOutput: true,
2350
+ });
2351
+ res.json(result.ok ? (result.parsed || { dryRun: true }) : { error: result.stderr });
2352
+ });
2353
+ router.post("/upgrade", async (req, res) => {
2354
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2355
+ if (!project) {
2356
+ res.status(404).json({ error: "Project not found" });
2357
+ return;
2358
+ }
2359
+ // Only allow package upgrades from the web UI — never self-upgrade the CLI binary.
2360
+ const result = runPm({
2361
+ args: ["upgrade", "--packages-only"],
2362
+ userId: project.ownerUserId,
2363
+ slug: project.slug,
2364
+ jsonOutput: true,
2365
+ });
2366
+ if (!result.ok) {
2367
+ res.status(400).json({ error: result.stderr || "Upgrade failed" });
2368
+ return;
2369
+ }
2370
+ res.json(result.parsed || { ok: true });
2371
+ });
2372
+ // ─── Presence endpoints ───
2373
+ // GET /api/projects/:projectId/pm/presence
2374
+ router.get("/presence", async (req, res) => {
2375
+ const projectId = req.params["projectId"];
2376
+ res.json({ users: getProjectPresence(projectId) });
2377
+ });
2378
+ // PATCH /api/projects/:projectId/pm/presence/:clientId
2379
+ router.patch("/presence/:clientId", async (req, res) => {
2380
+ const { clientId } = req.params;
2381
+ const { view } = req.body;
2382
+ if (view)
2383
+ updateClientView(clientId, view);
2384
+ res.json({ ok: true });
2385
+ });
2386
+ // ─── SSE endpoint ───
2387
+ // GET /api/projects/:projectId/pm/events
2388
+ router.get("/events", async (req, res) => {
2389
+ try {
2390
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2391
+ if (!project) {
2392
+ res.status(404).json({ error: "Project not found" });
2393
+ return;
2394
+ }
2395
+ }
2396
+ catch (err) {
2397
+ console.error("SSE project verification failed:", err);
2398
+ res.status(500).json({ error: "Failed to verify project for real-time sync" });
2399
+ return;
2400
+ }
2401
+ setupSSEHeaders(res);
2402
+ const clientId = uuidv4();
2403
+ const projectId = req.params["projectId"];
2404
+ const userId = req.user.userId;
2405
+ // Client sends display name as query param; fall back to email
2406
+ const displayName = String(req.query["dn"] ?? req.user.email ?? userId);
2407
+ const currentView = String(req.query["view"] ?? "items");
2408
+ const unsubscribe = addSSEClient({
2409
+ id: clientId,
2410
+ projectId,
2411
+ userId,
2412
+ displayName,
2413
+ currentView,
2414
+ res,
2415
+ connectedAt: new Date(),
2416
+ });
2417
+ // Heartbeat every 30s to keep connection alive and refresh presence
2418
+ const heartbeat = setInterval(() => {
2419
+ try {
2420
+ res.write(": heartbeat\n\n");
2421
+ // Re-broadcast presence on heartbeat to keep list fresh
2422
+ broadcastPresence(projectId);
2423
+ }
2424
+ catch {
2425
+ clearInterval(heartbeat);
2426
+ unsubscribe();
2427
+ }
2428
+ }, 30_000);
2429
+ req.on("close", () => {
2430
+ clearInterval(heartbeat);
2431
+ unsubscribe();
2432
+ });
2433
+ });
2434
+ // ─── Presence endpoint ───
2435
+ // GET /api/projects/:projectId/pm/presence
2436
+ router.get("/presence", async (req, res) => {
2437
+ const project = await verifyProject(req.user.userId, req.params["projectId"]);
2438
+ if (!project) {
2439
+ res.status(404).json({ error: "Project not found" });
2440
+ return;
2441
+ }
2442
+ const users = getProjectPresence(req.params["projectId"]);
2443
+ res.json({ users });
2444
+ });
2445
+ export { router as pmRouter };
2446
+ //# sourceMappingURL=pm.js.map