@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.
- package/CHANGELOG.md +7 -0
- package/README.md +107 -0
- package/dist/auth.js +20 -0
- package/dist/auth.js.map +1 -0
- package/dist/crypto.js +42 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db.js +111 -0
- package/dist/db.js.map +1 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.js +16 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/routes/admin.js +207 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/auth.js +163 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/github.js +354 -0
- package/dist/routes/github.js.map +1 -0
- package/dist/routes/groups.js +180 -0
- package/dist/routes/groups.js.map +1 -0
- package/dist/routes/pm.js +2446 -0
- package/dist/routes/pm.js.map +1 -0
- package/dist/routes/projects.js +151 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/sharing.js +155 -0
- package/dist/routes/sharing.js.map +1 -0
- package/dist/server.js +64 -0
- package/dist/server.js.map +1 -0
- package/dist/services/pm-runner.js +190 -0
- package/dist/services/pm-runner.js.map +1 -0
- package/dist/services/sse.js +111 -0
- package/dist/services/sse.js.map +1 -0
- package/manifest.json +15 -0
- package/package.json +111 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/index.html +265 -0
- package/public/manifest.json +66 -0
- package/public/src/api.js +28 -0
- package/public/src/api.js.map +1 -0
- package/public/src/api.ts +29 -0
- package/public/src/app.js +926 -0
- package/public/src/app.js.map +1 -0
- package/public/src/app.ts +929 -0
- package/public/src/components/modals.js +62 -0
- package/public/src/components/modals.js.map +1 -0
- package/public/src/components/modals.ts +73 -0
- package/public/src/components/toast.js +10 -0
- package/public/src/components/toast.js.map +1 -0
- package/public/src/components/toast.ts +13 -0
- package/public/src/constants.js +30 -0
- package/public/src/constants.js.map +1 -0
- package/public/src/constants.ts +41 -0
- package/public/src/state.js +15 -0
- package/public/src/state.js.map +1 -0
- package/public/src/state.ts +19 -0
- package/public/src/types.js +5 -0
- package/public/src/types.js.map +1 -0
- package/public/src/types.ts +253 -0
- package/public/src/utils.js +57 -0
- package/public/src/utils.js.map +1 -0
- package/public/src/utils.ts +56 -0
- package/public/src/views/activity.js +47 -0
- package/public/src/views/activity.js.map +1 -0
- package/public/src/views/activity.ts +41 -0
- package/public/src/views/admin.js +435 -0
- package/public/src/views/admin.js.map +1 -0
- package/public/src/views/admin.ts +504 -0
- package/public/src/views/auth.js +81 -0
- package/public/src/views/auth.js.map +1 -0
- package/public/src/views/auth.ts +74 -0
- package/public/src/views/calendar.js +133 -0
- package/public/src/views/calendar.js.map +1 -0
- package/public/src/views/calendar.ts +129 -0
- package/public/src/views/comments-audit.js +109 -0
- package/public/src/views/comments-audit.js.map +1 -0
- package/public/src/views/comments-audit.ts +108 -0
- package/public/src/views/config.js +322 -0
- package/public/src/views/config.js.map +1 -0
- package/public/src/views/config.ts +344 -0
- package/public/src/views/context.js +98 -0
- package/public/src/views/context.js.map +1 -0
- package/public/src/views/context.ts +100 -0
- package/public/src/views/create.js +293 -0
- package/public/src/views/create.js.map +1 -0
- package/public/src/views/create.ts +246 -0
- package/public/src/views/dedupe.js +51 -0
- package/public/src/views/dedupe.js.map +1 -0
- package/public/src/views/dedupe.ts +43 -0
- package/public/src/views/export.js +300 -0
- package/public/src/views/export.js.map +1 -0
- package/public/src/views/export.ts +274 -0
- package/public/src/views/github.js +360 -0
- package/public/src/views/github.js.map +1 -0
- package/public/src/views/github.ts +308 -0
- package/public/src/views/graph-canvas.js +1986 -0
- package/public/src/views/graph-canvas.js.map +1 -0
- package/public/src/views/graph-canvas.ts +2218 -0
- package/public/src/views/graph.js +1824 -0
- package/public/src/views/graph.js.map +1 -0
- package/public/src/views/graph.ts +1891 -0
- package/public/src/views/groups.js +186 -0
- package/public/src/views/groups.js.map +1 -0
- package/public/src/views/groups.ts +172 -0
- package/public/src/views/guide.js +151 -0
- package/public/src/views/guide.js.map +1 -0
- package/public/src/views/guide.ts +162 -0
- package/public/src/views/health.js +105 -0
- package/public/src/views/health.js.map +1 -0
- package/public/src/views/health.ts +102 -0
- package/public/src/views/items.js +1306 -0
- package/public/src/views/items.js.map +1 -0
- package/public/src/views/items.ts +1196 -0
- package/public/src/views/normalize.js +67 -0
- package/public/src/views/normalize.js.map +1 -0
- package/public/src/views/normalize.ts +58 -0
- package/public/src/views/plan.js +454 -0
- package/public/src/views/plan.js.map +1 -0
- package/public/src/views/plan.ts +496 -0
- package/public/src/views/projects.js +204 -0
- package/public/src/views/projects.js.map +1 -0
- package/public/src/views/projects.ts +196 -0
- package/public/src/views/router.js +227 -0
- package/public/src/views/router.js.map +1 -0
- package/public/src/views/router.ts +188 -0
- package/public/src/views/search.js +103 -0
- package/public/src/views/search.js.map +1 -0
- package/public/src/views/search.ts +94 -0
- package/public/src/views/settings.js +272 -0
- package/public/src/views/settings.js.map +1 -0
- package/public/src/views/settings.ts +190 -0
- package/public/src/views/shared.js +49 -0
- package/public/src/views/shared.js.map +1 -0
- package/public/src/views/shared.ts +49 -0
- package/public/src/views/sharing.js +152 -0
- package/public/src/views/sharing.js.map +1 -0
- package/public/src/views/sharing.ts +139 -0
- package/public/src/views/stats.js +92 -0
- package/public/src/views/stats.js.map +1 -0
- package/public/src/views/stats.ts +88 -0
- package/public/src/views/templates.js +117 -0
- package/public/src/views/templates.js.map +1 -0
- package/public/src/views/templates.ts +113 -0
- package/public/src/views/validate.js +54 -0
- package/public/src/views/validate.js.map +1 -0
- package/public/src/views/validate.ts +48 -0
- package/public/styles.css +2231 -0
- package/public/sw.js +318 -0
- package/public/tsconfig.json +20 -0
- 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
|