@unbrained/pm-web 2026.6.2 → 2026.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +92 -1
  3. package/dist/auth.js +6 -0
  4. package/dist/auth.js.map +1 -1
  5. package/dist/board.js +27 -7
  6. package/dist/board.js.map +1 -1
  7. package/dist/ical.js +141 -0
  8. package/dist/ical.js.map +1 -0
  9. package/dist/index.js +351 -6
  10. package/dist/index.js.map +1 -1
  11. package/dist/routes/admin.js +11 -10
  12. package/dist/routes/admin.js.map +1 -1
  13. package/dist/routes/github.js +19 -18
  14. package/dist/routes/github.js.map +1 -1
  15. package/dist/routes/groups.js +11 -10
  16. package/dist/routes/groups.js.map +1 -1
  17. package/dist/routes/pm.js +241 -197
  18. package/dist/routes/pm.js.map +1 -1
  19. package/dist/routes/projects.js +4 -3
  20. package/dist/routes/projects.js.map +1 -1
  21. package/dist/routes/route-params.js +7 -0
  22. package/dist/routes/route-params.js.map +1 -0
  23. package/dist/routes/sharing.js +5 -4
  24. package/dist/routes/sharing.js.map +1 -1
  25. package/dist/server.js +13 -2
  26. package/dist/server.js.map +1 -1
  27. package/manifest.json +1 -1
  28. package/package.json +13 -13
  29. package/public/index.html +2 -1
  30. package/public/src/app.js +12 -1
  31. package/public/src/app.js.map +1 -1
  32. package/public/src/app.ts +12 -1
  33. package/public/src/filters.js +40 -0
  34. package/public/src/filters.js.map +1 -0
  35. package/public/src/filters.ts +52 -0
  36. package/public/src/state.js +1 -1
  37. package/public/src/state.js.map +1 -1
  38. package/public/src/state.ts +1 -1
  39. package/public/src/theme.js +74 -0
  40. package/public/src/theme.js.map +1 -0
  41. package/public/src/theme.ts +75 -0
  42. package/public/src/types.ts +1 -0
  43. package/public/src/utils.js +3 -1
  44. package/public/src/utils.js.map +1 -1
  45. package/public/src/utils.ts +3 -1
  46. package/public/src/views/calendar.js +1 -0
  47. package/public/src/views/calendar.js.map +1 -1
  48. package/public/src/views/calendar.ts +1 -0
  49. package/public/src/views/items.js +56 -3
  50. package/public/src/views/items.js.map +1 -1
  51. package/public/src/views/items.ts +55 -3
  52. package/public/styles.css +85 -0
package/dist/routes/pm.js CHANGED
@@ -2,10 +2,12 @@ import { Router } from "express";
2
2
  import { requireAuth } from "../middleware/auth.js";
3
3
  import { ensureGraphExtension, runPm } from "../services/pm-runner.js";
4
4
  import { boardColumns, filterItemsByQuery } from "../board.js";
5
+ import { buildIcsCalendar } from "../ical.js";
5
6
  import { verifyProjectAccess } from "./projects.js";
6
7
  import { addSSEClient, broadcastProjectEvent, setupSSEHeaders, broadcastPresence, updateClientView, getProjectPresence } from "../services/sse.js";
7
8
  import { v4 as uuidv4 } from "uuid";
8
9
  import neo4j from "neo4j-driver";
10
+ import { routeParam } from "./route-params.js";
9
11
  // Singleton Neo4j driver — reused across sync calls to avoid per-call connection overhead.
10
12
  let _neo4jDriver = null;
11
13
  let _neo4jDriverKey = "";
@@ -31,7 +33,7 @@ router.use(async (req, res, next) => {
31
33
  next();
32
34
  return;
33
35
  }
34
- const projectId = req.params["projectId"];
36
+ const projectId = routeParam(req, "projectId");
35
37
  if (!projectId) {
36
38
  next();
37
39
  return;
@@ -334,7 +336,7 @@ async function verifyProject(userId, projectId) {
334
336
  // Returns runtime types/statuses from `pm contracts --json` so the frontend
335
337
  // stays in sync with whatever pm CLI version + extensions are installed.
336
338
  router.get("/schema", async (req, res) => {
337
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
339
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
338
340
  if (!project) {
339
341
  res.status(404).json({ error: "Project not found" });
340
342
  return;
@@ -352,7 +354,7 @@ router.get("/schema", async (req, res) => {
352
354
  });
353
355
  // GET /api/projects/:projectId/pm/list
354
356
  router.get("/list", async (req, res) => {
355
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
357
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
356
358
  if (!project) {
357
359
  res.status(404).json({ error: "Project not found" });
358
360
  return;
@@ -378,7 +380,7 @@ router.get("/list", async (req, res) => {
378
380
  });
379
381
  // GET /api/projects/:projectId/pm/list-all
380
382
  router.get("/list-all", async (req, res) => {
381
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
383
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
382
384
  if (!project) {
383
385
  res.status(404).json({ error: "Project not found" });
384
386
  return;
@@ -396,7 +398,7 @@ router.get("/list-all", async (req, res) => {
396
398
  // Kanban board: items grouped into columns by the workspace's runtime statuses
397
399
  // (read live from `pm contracts`) so the board matches the installed CLI.
398
400
  router.get("/board", async (req, res) => {
399
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
401
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
400
402
  if (!project) {
401
403
  res.status(404).json({ error: "Project not found" });
402
404
  return;
@@ -417,7 +419,7 @@ router.get("/board", async (req, res) => {
417
419
  // GET /api/projects/:projectId/pm/search?q=...
418
420
  // Full-text search over id/title/tags/body via a single list-all read.
419
421
  router.get("/search", async (req, res) => {
420
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
422
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
421
423
  if (!project) {
422
424
  res.status(404).json({ error: "Project not found" });
423
425
  return;
@@ -433,7 +435,7 @@ router.get("/search", async (req, res) => {
433
435
  });
434
436
  // POST /api/projects/:projectId/pm/create
435
437
  router.post("/create", async (req, res) => {
436
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
438
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
437
439
  if (!project) {
438
440
  res.status(404).json({ error: "Project not found" });
439
441
  return;
@@ -512,22 +514,22 @@ router.post("/create", async (req, res) => {
512
514
  return;
513
515
  }
514
516
  // Broadcast SSE create event
515
- broadcastProjectEvent(req.params["projectId"], {
517
+ broadcastProjectEvent(routeParam(req, "projectId"), {
516
518
  type: "item-created",
517
519
  data: { result: result.parsed, userId: req.user.userId },
518
520
  });
519
- scheduleGraphSync(req.params["projectId"], project, "item-created");
521
+ scheduleGraphSync(routeParam(req, "projectId"), project, "item-created");
520
522
  res.status(201).json(result.parsed || {});
521
523
  });
522
524
  // GET /api/projects/:projectId/pm/get/:itemId
523
525
  router.get("/get/:itemId", async (req, res) => {
524
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
526
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
525
527
  if (!project) {
526
528
  res.status(404).json({ error: "Project not found" });
527
529
  return;
528
530
  }
529
531
  const result = runPm({
530
- args: ["get", req.params["itemId"]],
532
+ args: ["get", routeParam(req, "itemId")],
531
533
  userId: project.ownerUserId,
532
534
  slug: project.slug,
533
535
  jsonOutput: true,
@@ -540,13 +542,13 @@ router.get("/get/:itemId", async (req, res) => {
540
542
  });
541
543
  // PATCH /api/projects/:projectId/pm/update/:itemId
542
544
  router.patch("/update/:itemId", async (req, res) => {
543
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
545
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
544
546
  if (!project) {
545
547
  res.status(404).json({ error: "Project not found" });
546
548
  return;
547
549
  }
548
550
  const body = req.body;
549
- const args = ["update", req.params["itemId"]];
551
+ const args = ["update", routeParam(req, "itemId")];
550
552
  // String options
551
553
  const stringFlags = {
552
554
  title: "--title", description: "--description", status: "--status", priority: "--priority",
@@ -579,16 +581,16 @@ router.patch("/update/:itemId", async (req, res) => {
579
581
  return;
580
582
  }
581
583
  // Broadcast SSE update event
582
- broadcastProjectEvent(req.params["projectId"], {
584
+ broadcastProjectEvent(routeParam(req, "projectId"), {
583
585
  type: "item-updated",
584
- data: { itemId: req.params["itemId"], userId: req.user.userId },
586
+ data: { itemId: routeParam(req, "itemId"), userId: req.user.userId },
585
587
  });
586
- scheduleGraphSync(req.params["projectId"], project, "item-updated");
588
+ scheduleGraphSync(routeParam(req, "projectId"), project, "item-updated");
587
589
  res.json(result.parsed || {});
588
590
  });
589
591
  // POST /api/projects/:projectId/pm/close/:itemId
590
592
  router.post("/close/:itemId", async (req, res) => {
591
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
593
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
592
594
  if (!project) {
593
595
  res.status(404).json({ error: "Project not found" });
594
596
  return;
@@ -599,7 +601,7 @@ router.post("/close/:itemId", async (req, res) => {
599
601
  return;
600
602
  }
601
603
  const result = runPm({
602
- args: ["close", req.params["itemId"], reason.trim()],
604
+ args: ["close", routeParam(req, "itemId"), reason.trim()],
603
605
  userId: project.ownerUserId,
604
606
  slug: project.slug,
605
607
  jsonOutput: true,
@@ -608,22 +610,22 @@ router.post("/close/:itemId", async (req, res) => {
608
610
  res.status(400).json({ error: result.stderr || "Failed to close item" });
609
611
  return;
610
612
  }
611
- broadcastProjectEvent(req.params["projectId"], {
613
+ broadcastProjectEvent(routeParam(req, "projectId"), {
612
614
  type: "item-closed",
613
- data: { itemId: req.params["itemId"], userId: req.user.userId },
615
+ data: { itemId: routeParam(req, "itemId"), userId: req.user.userId },
614
616
  });
615
- scheduleGraphSync(req.params["projectId"], project, "item-closed");
617
+ scheduleGraphSync(routeParam(req, "projectId"), project, "item-closed");
616
618
  res.json(result.parsed || {});
617
619
  });
618
620
  // DELETE /api/projects/:projectId/pm/delete/:itemId
619
621
  router.delete("/delete/:itemId", async (req, res) => {
620
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
622
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
621
623
  if (!project) {
622
624
  res.status(404).json({ error: "Project not found" });
623
625
  return;
624
626
  }
625
627
  const result = runPm({
626
- args: ["delete", req.params["itemId"], "--yes"],
628
+ args: ["delete", routeParam(req, "itemId"), "--yes"],
627
629
  userId: project.ownerUserId,
628
630
  slug: project.slug,
629
631
  });
@@ -631,16 +633,16 @@ router.delete("/delete/:itemId", async (req, res) => {
631
633
  res.status(400).json({ error: result.stderr || "Failed to delete item" });
632
634
  return;
633
635
  }
634
- broadcastProjectEvent(req.params["projectId"], {
636
+ broadcastProjectEvent(routeParam(req, "projectId"), {
635
637
  type: "item-deleted",
636
- data: { itemId: req.params["itemId"], userId: req.user.userId },
638
+ data: { itemId: routeParam(req, "itemId"), userId: req.user.userId },
637
639
  });
638
- scheduleGraphSync(req.params["projectId"], project, "item-deleted");
640
+ scheduleGraphSync(routeParam(req, "projectId"), project, "item-deleted");
639
641
  res.json({ ok: true });
640
642
  });
641
643
  // POST /api/projects/:projectId/pm/comments/:itemId
642
644
  router.post("/comments/:itemId", async (req, res) => {
643
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
645
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
644
646
  if (!project) {
645
647
  res.status(404).json({ error: "Project not found" });
646
648
  return;
@@ -651,7 +653,7 @@ router.post("/comments/:itemId", async (req, res) => {
651
653
  return;
652
654
  }
653
655
  const result = runPm({
654
- args: ["comments", req.params["itemId"], text.trim()],
656
+ args: ["comments", routeParam(req, "itemId"), text.trim()],
655
657
  userId: project.ownerUserId,
656
658
  slug: project.slug,
657
659
  jsonOutput: true,
@@ -664,13 +666,13 @@ router.post("/comments/:itemId", async (req, res) => {
664
666
  });
665
667
  // GET /api/projects/:projectId/pm/comments/:itemId
666
668
  router.get("/comments/:itemId", async (req, res) => {
667
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
669
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
668
670
  if (!project) {
669
671
  res.status(404).json({ error: "Project not found" });
670
672
  return;
671
673
  }
672
674
  const result = runPm({
673
- args: ["comments", req.params["itemId"]],
675
+ args: ["comments", routeParam(req, "itemId")],
674
676
  userId: project.ownerUserId,
675
677
  slug: project.slug,
676
678
  jsonOutput: true,
@@ -679,13 +681,13 @@ router.get("/comments/:itemId", async (req, res) => {
679
681
  });
680
682
  // GET /api/projects/:projectId/pm/notes/:itemId
681
683
  router.get("/notes/:itemId", async (req, res) => {
682
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
684
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
683
685
  if (!project) {
684
686
  res.status(404).json({ error: "Project not found" });
685
687
  return;
686
688
  }
687
689
  const result = runPm({
688
- args: ["notes", req.params["itemId"]],
690
+ args: ["notes", routeParam(req, "itemId")],
689
691
  userId: project.ownerUserId,
690
692
  slug: project.slug,
691
693
  jsonOutput: true,
@@ -694,7 +696,7 @@ router.get("/notes/:itemId", async (req, res) => {
694
696
  });
695
697
  // POST /api/projects/:projectId/pm/notes/:itemId
696
698
  router.post("/notes/:itemId", async (req, res) => {
697
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
699
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
698
700
  if (!project) {
699
701
  res.status(404).json({ error: "Project not found" });
700
702
  return;
@@ -705,7 +707,7 @@ router.post("/notes/:itemId", async (req, res) => {
705
707
  return;
706
708
  }
707
709
  const result = runPm({
708
- args: ["notes", req.params["itemId"], text.trim()],
710
+ args: ["notes", routeParam(req, "itemId"), text.trim()],
709
711
  userId: project.ownerUserId,
710
712
  slug: project.slug,
711
713
  jsonOutput: true,
@@ -718,7 +720,7 @@ router.post("/notes/:itemId", async (req, res) => {
718
720
  });
719
721
  // GET /api/projects/:projectId/pm/context
720
722
  router.get("/context", async (req, res) => {
721
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
723
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
722
724
  if (!project) {
723
725
  res.status(404).json({ error: "Project not found" });
724
726
  return;
@@ -736,7 +738,7 @@ router.get("/context", async (req, res) => {
736
738
  });
737
739
  // GET /api/projects/:projectId/pm/activity
738
740
  router.get("/activity", async (req, res) => {
739
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
741
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
740
742
  if (!project) {
741
743
  res.status(404).json({ error: "Project not found" });
742
744
  return;
@@ -750,7 +752,7 @@ router.get("/activity", async (req, res) => {
750
752
  });
751
753
  // GET /api/projects/:projectId/pm/stats
752
754
  router.get("/stats", async (req, res) => {
753
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
755
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
754
756
  if (!project) {
755
757
  res.status(404).json({ error: "Project not found" });
756
758
  return;
@@ -765,7 +767,7 @@ router.get("/stats", async (req, res) => {
765
767
  });
766
768
  // GET /api/projects/:projectId/pm/aggregate
767
769
  router.get("/aggregate", async (req, res) => {
768
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
770
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
769
771
  if (!project) {
770
772
  res.status(404).json({ error: "Project not found" });
771
773
  return;
@@ -780,7 +782,7 @@ router.get("/aggregate", async (req, res) => {
780
782
  });
781
783
  // POST /api/projects/:projectId/pm/search
782
784
  router.post("/search", async (req, res) => {
783
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
785
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
784
786
  if (!project) {
785
787
  res.status(404).json({ error: "Project not found" });
786
788
  return;
@@ -809,7 +811,7 @@ router.post("/search", async (req, res) => {
809
811
  });
810
812
  // GET /api/projects/:projectId/pm/calendar
811
813
  router.get("/calendar", async (req, res) => {
812
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
814
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
813
815
  if (!project) {
814
816
  res.status(404).json({ error: "Project not found" });
815
817
  return;
@@ -822,9 +824,51 @@ router.get("/calendar", async (req, res) => {
822
824
  });
823
825
  res.json(result.ok ? (result.parsed || {}) : { events: [] });
824
826
  });
827
+ // GET /api/projects/:projectId/pm/calendar.ics
828
+ // RFC 5545 iCalendar feed of item deadlines, for subscribing in Google
829
+ // Calendar / Outlook / Apple Calendar. Reuses the same list-all read as the
830
+ // calendar view. Auth works via the usual token (header/cookie) or a
831
+ // `?token=` query param, since calendar clients cannot send cookies.
832
+ router.get("/calendar.ics", async (req, res) => {
833
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
834
+ if (!project) {
835
+ res.status(404).json({ error: "Project not found" });
836
+ return;
837
+ }
838
+ const listed = runPm({
839
+ args: ["list-all", "--json"],
840
+ userId: project.ownerUserId,
841
+ slug: project.slug,
842
+ jsonOutput: true,
843
+ });
844
+ if (!listed.ok) {
845
+ res.status(502).json({ error: listed.stderr || "Failed to load items" });
846
+ return;
847
+ }
848
+ const items = itemsFromListAll(listed.parsed)
849
+ .filter((i) => Boolean(i.deadline))
850
+ .map((i) => ({
851
+ id: i.id,
852
+ title: i.title,
853
+ type: i.type,
854
+ status: i.status,
855
+ priority: i.priority,
856
+ deadline: i.deadline,
857
+ assignee: i.assignee,
858
+ tags: i.tags,
859
+ }));
860
+ const ics = buildIcsCalendar(items, {
861
+ calendarName: `pm · ${project.slug}`,
862
+ uidDomain: `${project.slug}.pm-web`,
863
+ });
864
+ res.setHeader("Content-Type", "text/calendar; charset=utf-8");
865
+ res.setHeader("Content-Disposition", `attachment; filename="pm-${project.slug}.ics"`);
866
+ res.setHeader("Cache-Control", "no-cache");
867
+ res.send(ics);
868
+ });
825
869
  // GET /api/projects/:projectId/pm/health
826
870
  router.get("/health", async (req, res) => {
827
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
871
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
828
872
  if (!project) {
829
873
  res.status(404).json({ error: "Project not found" });
830
874
  return;
@@ -839,7 +883,7 @@ router.get("/health", async (req, res) => {
839
883
  });
840
884
  // POST /api/projects/:projectId/pm/append/:itemId
841
885
  router.post("/append/:itemId", async (req, res) => {
842
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
886
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
843
887
  if (!project) {
844
888
  res.status(404).json({ error: "Project not found" });
845
889
  return;
@@ -850,7 +894,7 @@ router.post("/append/:itemId", async (req, res) => {
850
894
  return;
851
895
  }
852
896
  const result = runPm({
853
- args: ["append", req.params["itemId"], text.trim()],
897
+ args: ["append", routeParam(req, "itemId"), text.trim()],
854
898
  userId: project.ownerUserId,
855
899
  slug: project.slug,
856
900
  jsonOutput: true,
@@ -859,18 +903,18 @@ router.post("/append/:itemId", async (req, res) => {
859
903
  res.status(400).json({ error: result.stderr || "Failed to append" });
860
904
  return;
861
905
  }
862
- scheduleGraphSync(req.params["projectId"], project, "item-appended");
906
+ scheduleGraphSync(routeParam(req, "projectId"), project, "item-appended");
863
907
  res.json(result.parsed || { ok: true });
864
908
  });
865
909
  // GET /api/projects/:projectId/pm/history/:itemId
866
910
  router.get("/history/:itemId", async (req, res) => {
867
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
911
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
868
912
  if (!project) {
869
913
  res.status(404).json({ error: "Project not found" });
870
914
  return;
871
915
  }
872
916
  const result = runPm({
873
- args: ["history", req.params["itemId"]],
917
+ args: ["history", routeParam(req, "itemId")],
874
918
  userId: project.ownerUserId,
875
919
  slug: project.slug,
876
920
  jsonOutput: true,
@@ -879,13 +923,13 @@ router.get("/history/:itemId", async (req, res) => {
879
923
  });
880
924
  // GET /api/projects/:projectId/pm/deps/:itemId
881
925
  router.get("/deps/:itemId", async (req, res) => {
882
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
926
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
883
927
  if (!project) {
884
928
  res.status(404).json({ error: "Project not found" });
885
929
  return;
886
930
  }
887
931
  const result = runPm({
888
- args: ["deps", req.params["itemId"]],
932
+ args: ["deps", routeParam(req, "itemId")],
889
933
  userId: project.ownerUserId,
890
934
  slug: project.slug,
891
935
  jsonOutput: true,
@@ -894,7 +938,7 @@ router.get("/deps/:itemId", async (req, res) => {
894
938
  });
895
939
  // POST /api/projects/:projectId/pm/deps/:itemId
896
940
  router.post("/deps/:itemId", async (req, res) => {
897
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
941
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
898
942
  if (!project) {
899
943
  res.status(404).json({ error: "Project not found" });
900
944
  return;
@@ -906,7 +950,7 @@ router.post("/deps/:itemId", async (req, res) => {
906
950
  }
907
951
  const depRel = normalizeDependencyKind(rel);
908
952
  const result = runPm({
909
- args: ["update", req.params["itemId"], "--dep", `id=${targetId.trim()},kind=${depRel}`],
953
+ args: ["update", routeParam(req, "itemId"), "--dep", `id=${targetId.trim()},kind=${depRel}`],
910
954
  userId: project.ownerUserId,
911
955
  slug: project.slug,
912
956
  jsonOutput: true,
@@ -915,9 +959,9 @@ router.post("/deps/:itemId", async (req, res) => {
915
959
  res.status(400).json({ error: result.stderr || "Failed to add dependency" });
916
960
  return;
917
961
  }
918
- scheduleGraphSync(req.params["projectId"], project, "dependency-added");
919
- broadcastDependencyEvent(req.params["projectId"], "dependency-added", {
920
- from: req.params["itemId"],
962
+ scheduleGraphSync(routeParam(req, "projectId"), project, "dependency-added");
963
+ broadcastDependencyEvent(routeParam(req, "projectId"), "dependency-added", {
964
+ from: routeParam(req, "itemId"),
921
965
  to: targetId.trim(),
922
966
  rel: depRel,
923
967
  userId: req.user.userId,
@@ -926,7 +970,7 @@ router.post("/deps/:itemId", async (req, res) => {
926
970
  });
927
971
  // DELETE /api/projects/:projectId/pm/deps/:itemId
928
972
  router.delete("/deps/:itemId", async (req, res) => {
929
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
973
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
930
974
  if (!project) {
931
975
  res.status(404).json({ error: "Project not found" });
932
976
  return;
@@ -939,7 +983,7 @@ router.delete("/deps/:itemId", async (req, res) => {
939
983
  const depRel = normalizeDependencyKind(rel || "relates_to");
940
984
  const selector = `id=${targetId.trim()},kind=${depRel}`;
941
985
  const result = runPm({
942
- args: ["update", req.params["itemId"], "--dep-remove", selector],
986
+ args: ["update", routeParam(req, "itemId"), "--dep-remove", selector],
943
987
  userId: project.ownerUserId,
944
988
  slug: project.slug,
945
989
  jsonOutput: true,
@@ -948,18 +992,18 @@ router.delete("/deps/:itemId", async (req, res) => {
948
992
  res.status(400).json({ error: result.stderr || "Failed to remove dependency" });
949
993
  return;
950
994
  }
951
- scheduleGraphSync(req.params["projectId"], project, "dependency-removed");
952
- broadcastDependencyEvent(req.params["projectId"], "dependency-removed", {
953
- from: req.params["itemId"],
995
+ scheduleGraphSync(routeParam(req, "projectId"), project, "dependency-removed");
996
+ broadcastDependencyEvent(routeParam(req, "projectId"), "dependency-removed", {
997
+ from: routeParam(req, "itemId"),
954
998
  to: targetId.trim(),
955
999
  rel: depRel,
956
1000
  userId: req.user.userId,
957
1001
  });
958
- res.status(200).json({ ok: true, from: req.params["itemId"], to: targetId.trim(), type: depRel, result: result.parsed || null });
1002
+ res.status(200).json({ ok: true, from: routeParam(req, "itemId"), to: targetId.trim(), type: depRel, result: result.parsed || null });
959
1003
  });
960
1004
  // POST /api/projects/:projectId/pm/rel — Create a relationship between two items
961
1005
  router.post("/rel", async (req, res) => {
962
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1006
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
963
1007
  if (!project) {
964
1008
  res.status(404).json({ error: "Project not found" });
965
1009
  return;
@@ -980,8 +1024,8 @@ router.post("/rel", async (req, res) => {
980
1024
  res.status(400).json({ error: result.stderr || "Failed to create relationship" });
981
1025
  return;
982
1026
  }
983
- scheduleGraphSync(req.params["projectId"], project, "rel-created");
984
- broadcastDependencyEvent(req.params["projectId"], "dependency-added", {
1027
+ scheduleGraphSync(routeParam(req, "projectId"), project, "rel-created");
1028
+ broadcastDependencyEvent(routeParam(req, "projectId"), "dependency-added", {
985
1029
  from: from.trim(),
986
1030
  to: to.trim(),
987
1031
  rel: depRel,
@@ -991,7 +1035,7 @@ router.post("/rel", async (req, res) => {
991
1035
  });
992
1036
  // DELETE /api/projects/:projectId/pm/rel — Remove a relationship between two items
993
1037
  router.delete("/rel", async (req, res) => {
994
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1038
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
995
1039
  if (!project) {
996
1040
  res.status(404).json({ error: "Project not found" });
997
1041
  return;
@@ -1013,8 +1057,8 @@ router.delete("/rel", async (req, res) => {
1013
1057
  res.status(400).json({ error: result.stderr || "Failed to remove relationship" });
1014
1058
  return;
1015
1059
  }
1016
- scheduleGraphSync(req.params["projectId"], project, "rel-removed");
1017
- broadcastDependencyEvent(req.params["projectId"], "dependency-removed", {
1060
+ scheduleGraphSync(routeParam(req, "projectId"), project, "rel-removed");
1061
+ broadcastDependencyEvent(routeParam(req, "projectId"), "dependency-removed", {
1018
1062
  from: from.trim(),
1019
1063
  to: to.trim(),
1020
1064
  rel: depRel,
@@ -1024,7 +1068,7 @@ router.delete("/rel", async (req, res) => {
1024
1068
  });
1025
1069
  // GET /api/projects/:projectId/pm/graph
1026
1070
  router.get("/graph", async (req, res) => {
1027
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1071
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1028
1072
  if (!project) {
1029
1073
  res.status(404).json({ error: "Project not found" });
1030
1074
  return;
@@ -1052,7 +1096,7 @@ router.get("/graph", async (req, res) => {
1052
1096
  });
1053
1097
  // POST /api/projects/:projectId/pm/graph/sync
1054
1098
  router.post("/graph/sync", async (req, res) => {
1055
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1099
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1056
1100
  if (!project) {
1057
1101
  res.status(404).json({ error: "Project not found" });
1058
1102
  return;
@@ -1060,21 +1104,21 @@ router.post("/graph/sync", async (req, res) => {
1060
1104
  try {
1061
1105
  const syncResult = await syncProjectGraph(project);
1062
1106
  const payload = { reason: "manual-sync", ...syncResult, projectKey: graphProjectKey(project), source: "pm-web" };
1063
- broadcastProjectEvent(req.params["projectId"], {
1107
+ broadcastProjectEvent(routeParam(req, "projectId"), {
1064
1108
  type: "graph-synced",
1065
1109
  data: payload,
1066
1110
  });
1067
1111
  res.json({ ok: true, ...payload });
1068
1112
  }
1069
1113
  catch (err) {
1070
- broadcastProjectEvent(req.params["projectId"], {
1114
+ broadcastProjectEvent(routeParam(req, "projectId"), {
1071
1115
  type: "graph-sync-failed",
1072
1116
  data: {
1073
1117
  reason: "manual-sync",
1074
1118
  error: err instanceof Error ? err.message : String(err),
1075
1119
  },
1076
1120
  });
1077
- broadcastProjectEvent(req.params["projectId"], {
1121
+ broadcastProjectEvent(routeParam(req, "projectId"), {
1078
1122
  type: "graph_sync_failed",
1079
1123
  data: {
1080
1124
  reason: "manual-sync",
@@ -1086,12 +1130,12 @@ router.post("/graph/sync", async (req, res) => {
1086
1130
  });
1087
1131
  // GET /api/projects/:projectId/pm/graph/neighbors/:nodeId
1088
1132
  router.get("/graph/neighbors/:nodeId", async (req, res) => {
1089
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1133
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1090
1134
  if (!project) {
1091
1135
  res.status(404).json({ error: "Project not found" });
1092
1136
  return;
1093
1137
  }
1094
- const nodeId = req.params["nodeId"];
1138
+ const nodeId = routeParam(req, "nodeId");
1095
1139
  if (!nodeId) {
1096
1140
  res.status(400).json({ error: "nodeId is required" });
1097
1141
  return;
@@ -1117,7 +1161,7 @@ router.get("/graph/neighbors/:nodeId", async (req, res) => {
1117
1161
  });
1118
1162
  // POST /api/projects/:projectId/pm/graph/query
1119
1163
  router.post("/graph/query", async (req, res) => {
1120
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1164
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1121
1165
  if (!project) {
1122
1166
  res.status(404).json({ error: "Project not found" });
1123
1167
  return;
@@ -1147,13 +1191,13 @@ router.post("/graph/query", async (req, res) => {
1147
1191
  });
1148
1192
  // GET /api/projects/:projectId/pm/learnings/:itemId
1149
1193
  router.get("/learnings/:itemId", async (req, res) => {
1150
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1194
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1151
1195
  if (!project) {
1152
1196
  res.status(404).json({ error: "Project not found" });
1153
1197
  return;
1154
1198
  }
1155
1199
  const result = runPm({
1156
- args: ["learnings", req.params["itemId"]],
1200
+ args: ["learnings", routeParam(req, "itemId")],
1157
1201
  userId: project.ownerUserId,
1158
1202
  slug: project.slug,
1159
1203
  jsonOutput: true,
@@ -1162,7 +1206,7 @@ router.get("/learnings/:itemId", async (req, res) => {
1162
1206
  });
1163
1207
  // POST /api/projects/:projectId/pm/learnings/:itemId
1164
1208
  router.post("/learnings/:itemId", async (req, res) => {
1165
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1209
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1166
1210
  if (!project) {
1167
1211
  res.status(404).json({ error: "Project not found" });
1168
1212
  return;
@@ -1173,7 +1217,7 @@ router.post("/learnings/:itemId", async (req, res) => {
1173
1217
  return;
1174
1218
  }
1175
1219
  const result = runPm({
1176
- args: ["learnings", req.params["itemId"], text.trim()],
1220
+ args: ["learnings", routeParam(req, "itemId"), text.trim()],
1177
1221
  userId: project.ownerUserId,
1178
1222
  slug: project.slug,
1179
1223
  jsonOutput: true,
@@ -1186,13 +1230,13 @@ router.post("/learnings/:itemId", async (req, res) => {
1186
1230
  });
1187
1231
  // POST /api/projects/:projectId/pm/claim/:itemId
1188
1232
  router.post("/claim/:itemId", async (req, res) => {
1189
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1233
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1190
1234
  if (!project) {
1191
1235
  res.status(404).json({ error: "Project not found" });
1192
1236
  return;
1193
1237
  }
1194
1238
  const result = runPm({
1195
- args: ["claim", req.params["itemId"]],
1239
+ args: ["claim", routeParam(req, "itemId")],
1196
1240
  userId: project.ownerUserId,
1197
1241
  slug: project.slug,
1198
1242
  jsonOutput: true,
@@ -1201,18 +1245,18 @@ router.post("/claim/:itemId", async (req, res) => {
1201
1245
  res.status(400).json({ error: result.stderr || "Failed to claim item" });
1202
1246
  return;
1203
1247
  }
1204
- scheduleGraphSync(req.params["projectId"], project, "item-claimed");
1248
+ scheduleGraphSync(routeParam(req, "projectId"), project, "item-claimed");
1205
1249
  res.json(result.parsed || { ok: true });
1206
1250
  });
1207
1251
  // POST /api/projects/:projectId/pm/release/:itemId
1208
1252
  router.post("/release/:itemId", async (req, res) => {
1209
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1253
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1210
1254
  if (!project) {
1211
1255
  res.status(404).json({ error: "Project not found" });
1212
1256
  return;
1213
1257
  }
1214
1258
  const result = runPm({
1215
- args: ["release", req.params["itemId"]],
1259
+ args: ["release", routeParam(req, "itemId")],
1216
1260
  userId: project.ownerUserId,
1217
1261
  slug: project.slug,
1218
1262
  jsonOutput: true,
@@ -1221,18 +1265,18 @@ router.post("/release/:itemId", async (req, res) => {
1221
1265
  res.status(400).json({ error: result.stderr || "Failed to release item" });
1222
1266
  return;
1223
1267
  }
1224
- scheduleGraphSync(req.params["projectId"], project, "item-released");
1268
+ scheduleGraphSync(routeParam(req, "projectId"), project, "item-released");
1225
1269
  res.json(result.parsed || { ok: true });
1226
1270
  });
1227
1271
  // POST /api/projects/:projectId/pm/start-task/:itemId
1228
1272
  router.post("/start-task/:itemId", async (req, res) => {
1229
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1273
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1230
1274
  if (!project) {
1231
1275
  res.status(404).json({ error: "Project not found" });
1232
1276
  return;
1233
1277
  }
1234
1278
  const result = runPm({
1235
- args: ["start-task", req.params["itemId"]],
1279
+ args: ["start-task", routeParam(req, "itemId")],
1236
1280
  userId: project.ownerUserId,
1237
1281
  slug: project.slug,
1238
1282
  jsonOutput: true,
@@ -1241,18 +1285,18 @@ router.post("/start-task/:itemId", async (req, res) => {
1241
1285
  res.status(400).json({ error: result.stderr || "Failed to start task" });
1242
1286
  return;
1243
1287
  }
1244
- scheduleGraphSync(req.params["projectId"], project, "task-started");
1288
+ scheduleGraphSync(routeParam(req, "projectId"), project, "task-started");
1245
1289
  res.json(result.parsed || { ok: true });
1246
1290
  });
1247
1291
  // POST /api/projects/:projectId/pm/pause-task/:itemId
1248
1292
  router.post("/pause-task/:itemId", async (req, res) => {
1249
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1293
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1250
1294
  if (!project) {
1251
1295
  res.status(404).json({ error: "Project not found" });
1252
1296
  return;
1253
1297
  }
1254
1298
  const result = runPm({
1255
- args: ["pause-task", req.params["itemId"]],
1299
+ args: ["pause-task", routeParam(req, "itemId")],
1256
1300
  userId: project.ownerUserId,
1257
1301
  slug: project.slug,
1258
1302
  jsonOutput: true,
@@ -1261,18 +1305,18 @@ router.post("/pause-task/:itemId", async (req, res) => {
1261
1305
  res.status(400).json({ error: result.stderr || "Failed to pause task" });
1262
1306
  return;
1263
1307
  }
1264
- scheduleGraphSync(req.params["projectId"], project, "task-paused");
1308
+ scheduleGraphSync(routeParam(req, "projectId"), project, "task-paused");
1265
1309
  res.json(result.parsed || { ok: true });
1266
1310
  });
1267
1311
  // GET /api/projects/:projectId/pm/tests/:itemId
1268
1312
  router.get("/tests/:itemId", async (req, res) => {
1269
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1313
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1270
1314
  if (!project) {
1271
1315
  res.status(404).json({ error: "Project not found" });
1272
1316
  return;
1273
1317
  }
1274
1318
  const result = runPm({
1275
- args: ["test", req.params["itemId"]],
1319
+ args: ["test", routeParam(req, "itemId")],
1276
1320
  userId: project.ownerUserId,
1277
1321
  slug: project.slug,
1278
1322
  jsonOutput: true,
@@ -1281,7 +1325,7 @@ router.get("/tests/:itemId", async (req, res) => {
1281
1325
  });
1282
1326
  // POST /api/projects/:projectId/pm/tests/:itemId
1283
1327
  router.post("/tests/:itemId", async (req, res) => {
1284
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1328
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1285
1329
  if (!project) {
1286
1330
  res.status(404).json({ error: "Project not found" });
1287
1331
  return;
@@ -1291,7 +1335,7 @@ router.post("/tests/:itemId", async (req, res) => {
1291
1335
  res.status(400).json({ error: "Test command is required" });
1292
1336
  return;
1293
1337
  }
1294
- const args = ["test", req.params["itemId"], "--add", "--command", command.trim()];
1338
+ const args = ["test", routeParam(req, "itemId"), "--add", "--command", command.trim()];
1295
1339
  if (description)
1296
1340
  args.push("--description", description.trim());
1297
1341
  const result = runPm({
@@ -1308,7 +1352,7 @@ router.post("/tests/:itemId", async (req, res) => {
1308
1352
  });
1309
1353
  // GET /api/projects/:projectId/pm/dedupe-audit
1310
1354
  router.get("/dedupe-audit", async (req, res) => {
1311
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1355
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1312
1356
  if (!project) {
1313
1357
  res.status(404).json({ error: "Project not found" });
1314
1358
  return;
@@ -1323,7 +1367,7 @@ router.get("/dedupe-audit", async (req, res) => {
1323
1367
  });
1324
1368
  // GET /api/projects/:projectId/pm/validate
1325
1369
  router.get("/validate", async (req, res) => {
1326
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1370
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1327
1371
  if (!project) {
1328
1372
  res.status(404).json({ error: "Project not found" });
1329
1373
  return;
@@ -1338,7 +1382,7 @@ router.get("/validate", async (req, res) => {
1338
1382
  });
1339
1383
  // POST /api/projects/:projectId/pm/restore/:itemId
1340
1384
  router.post("/restore/:itemId", async (req, res) => {
1341
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1385
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1342
1386
  if (!project) {
1343
1387
  res.status(404).json({ error: "Project not found" });
1344
1388
  return;
@@ -1349,7 +1393,7 @@ router.post("/restore/:itemId", async (req, res) => {
1349
1393
  return;
1350
1394
  }
1351
1395
  const result = runPm({
1352
- args: ["restore", req.params["itemId"], target.trim()],
1396
+ args: ["restore", routeParam(req, "itemId"), target.trim()],
1353
1397
  userId: project.ownerUserId,
1354
1398
  slug: project.slug,
1355
1399
  jsonOutput: true,
@@ -1358,12 +1402,12 @@ router.post("/restore/:itemId", async (req, res) => {
1358
1402
  res.status(400).json({ error: result.stderr || "Failed to restore item" });
1359
1403
  return;
1360
1404
  }
1361
- scheduleGraphSync(req.params["projectId"], project, "item-restored");
1405
+ scheduleGraphSync(routeParam(req, "projectId"), project, "item-restored");
1362
1406
  res.json(result.parsed || { ok: true });
1363
1407
  });
1364
1408
  // POST /api/projects/:projectId/pm/close-task/:itemId
1365
1409
  router.post("/close-task/:itemId", async (req, res) => {
1366
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1410
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1367
1411
  if (!project) {
1368
1412
  res.status(404).json({ error: "Project not found" });
1369
1413
  return;
@@ -1374,7 +1418,7 @@ router.post("/close-task/:itemId", async (req, res) => {
1374
1418
  return;
1375
1419
  }
1376
1420
  const result = runPm({
1377
- args: ["close-task", req.params["itemId"], reason.trim()],
1421
+ args: ["close-task", routeParam(req, "itemId"), reason.trim()],
1378
1422
  userId: project.ownerUserId,
1379
1423
  slug: project.slug,
1380
1424
  jsonOutput: true,
@@ -1383,12 +1427,12 @@ router.post("/close-task/:itemId", async (req, res) => {
1383
1427
  res.status(400).json({ error: result.stderr || "Failed to close task" });
1384
1428
  return;
1385
1429
  }
1386
- scheduleGraphSync(req.params["projectId"], project, "task-closed");
1430
+ scheduleGraphSync(routeParam(req, "projectId"), project, "task-closed");
1387
1431
  res.json(result.parsed || { ok: true });
1388
1432
  });
1389
1433
  // POST /api/projects/:projectId/pm/reindex
1390
1434
  router.post("/reindex", async (req, res) => {
1391
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1435
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1392
1436
  if (!project) {
1393
1437
  res.status(404).json({ error: "Project not found" });
1394
1438
  return;
@@ -1411,7 +1455,7 @@ router.post("/reindex", async (req, res) => {
1411
1455
  });
1412
1456
  // POST /api/projects/:projectId/pm/normalize
1413
1457
  router.post("/normalize", async (req, res) => {
1414
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1458
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1415
1459
  if (!project) {
1416
1460
  res.status(404).json({ error: "Project not found" });
1417
1461
  return;
@@ -1426,7 +1470,7 @@ router.post("/normalize", async (req, res) => {
1426
1470
  });
1427
1471
  // GET /api/projects/:projectId/pm/comments-audit
1428
1472
  router.get("/comments-audit", async (req, res) => {
1429
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1473
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1430
1474
  if (!project) {
1431
1475
  res.status(404).json({ error: "Project not found" });
1432
1476
  return;
@@ -1441,7 +1485,7 @@ router.get("/comments-audit", async (req, res) => {
1441
1485
  });
1442
1486
  // POST /api/projects/:projectId/pm/files/:itemId
1443
1487
  router.post("/files/:itemId", async (req, res) => {
1444
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1488
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1445
1489
  if (!project) {
1446
1490
  res.status(404).json({ error: "Project not found" });
1447
1491
  return;
@@ -1454,7 +1498,7 @@ router.post("/files/:itemId", async (req, res) => {
1454
1498
  let addVal = `path=${filePath.trim()}`;
1455
1499
  if (scope)
1456
1500
  addVal += `,scope=${scope}`;
1457
- const args = ["files", req.params["itemId"], "--add", addVal];
1501
+ const args = ["files", routeParam(req, "itemId"), "--add", addVal];
1458
1502
  const result = runPm({
1459
1503
  args,
1460
1504
  userId: project.ownerUserId,
@@ -1465,18 +1509,18 @@ router.post("/files/:itemId", async (req, res) => {
1465
1509
  res.status(400).json({ error: result.stderr || "Failed to link file" });
1466
1510
  return;
1467
1511
  }
1468
- scheduleGraphSync(req.params["projectId"], project, "file-linked");
1512
+ scheduleGraphSync(routeParam(req, "projectId"), project, "file-linked");
1469
1513
  res.status(201).json(result.parsed || { ok: true });
1470
1514
  });
1471
1515
  // GET /api/projects/:projectId/pm/files/:itemId
1472
1516
  router.get("/files/:itemId", async (req, res) => {
1473
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1517
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1474
1518
  if (!project) {
1475
1519
  res.status(404).json({ error: "Project not found" });
1476
1520
  return;
1477
1521
  }
1478
1522
  const result = runPm({
1479
- args: ["files", req.params["itemId"]],
1523
+ args: ["files", routeParam(req, "itemId")],
1480
1524
  userId: project.ownerUserId,
1481
1525
  slug: project.slug,
1482
1526
  jsonOutput: true,
@@ -1485,7 +1529,7 @@ router.get("/files/:itemId", async (req, res) => {
1485
1529
  });
1486
1530
  // GET /api/projects/:projectId/pm/guide — list guide topics
1487
1531
  router.get("/guide", async (req, res) => {
1488
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1532
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1489
1533
  if (!project) {
1490
1534
  res.status(404).json({ error: "Project not found" });
1491
1535
  return;
@@ -1500,13 +1544,13 @@ router.get("/guide", async (req, res) => {
1500
1544
  });
1501
1545
  // GET /api/projects/:projectId/pm/guide/:topicId — get single guide topic
1502
1546
  router.get("/guide/:topicId", async (req, res) => {
1503
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1547
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1504
1548
  if (!project) {
1505
1549
  res.status(404).json({ error: "Project not found" });
1506
1550
  return;
1507
1551
  }
1508
1552
  const result = runPm({
1509
- args: ["guide", req.params["topicId"]],
1553
+ args: ["guide", routeParam(req, "topicId")],
1510
1554
  userId: project.ownerUserId,
1511
1555
  slug: project.slug,
1512
1556
  jsonOutput: true,
@@ -1524,7 +1568,7 @@ router.get("/guide/:topicId", async (req, res) => {
1524
1568
  // ─────────────────────────────────────────────────────────
1525
1569
  // GET /api/projects/:projectId/pm/export?format=json|csv|yaml
1526
1570
  router.get("/export", async (req, res) => {
1527
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1571
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1528
1572
  if (!project) {
1529
1573
  res.status(404).json({ error: "Project not found" });
1530
1574
  return;
@@ -1624,7 +1668,7 @@ router.get("/export", async (req, res) => {
1624
1668
  });
1625
1669
  // POST /api/projects/:projectId/pm/import — import JSON items
1626
1670
  router.post("/import", async (req, res) => {
1627
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1671
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1628
1672
  if (!project) {
1629
1673
  res.status(404).json({ error: "Project not found" });
1630
1674
  return;
@@ -1680,17 +1724,17 @@ router.post("/import", async (req, res) => {
1680
1724
  errors.push(`item[${i}]: ${result.stderr || "create failed"}`);
1681
1725
  }
1682
1726
  }
1683
- broadcastProjectEvent(req.params["projectId"], {
1727
+ broadcastProjectEvent(routeParam(req, "projectId"), {
1684
1728
  type: "items-imported",
1685
1729
  data: { count: created.length, userId: req.user.userId },
1686
1730
  });
1687
1731
  if (created.length > 0)
1688
- scheduleGraphSync(req.params["projectId"], project, "items-imported");
1732
+ scheduleGraphSync(routeParam(req, "projectId"), project, "items-imported");
1689
1733
  res.json({ created, errors, total: items.length });
1690
1734
  });
1691
1735
  // POST /api/projects/:projectId/pm/update-many
1692
1736
  router.post("/update-many", async (req, res) => {
1693
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1737
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1694
1738
  if (!project) {
1695
1739
  res.status(404).json({ error: "Project not found" });
1696
1740
  return;
@@ -1733,11 +1777,11 @@ router.post("/update-many", async (req, res) => {
1733
1777
  res.status(400).json({ error: result.stderr || "update-many failed" });
1734
1778
  return;
1735
1779
  }
1736
- broadcastProjectEvent(req.params["projectId"], {
1780
+ broadcastProjectEvent(routeParam(req, "projectId"), {
1737
1781
  type: "items-bulk-updated",
1738
1782
  data: { userId: req.user.userId },
1739
1783
  });
1740
- scheduleGraphSync(req.params["projectId"], project, "items-bulk-updated");
1784
+ scheduleGraphSync(routeParam(req, "projectId"), project, "items-bulk-updated");
1741
1785
  res.json(result.parsed || {});
1742
1786
  });
1743
1787
  // POST /api/projects/:projectId/pm/close-many
@@ -1745,7 +1789,7 @@ router.post("/update-many", async (req, res) => {
1745
1789
  // Accepts same filter options as update-many plus a required `reason` field.
1746
1790
  // Returns { closed_count, failed_count, skipped_count, rows }.
1747
1791
  router.post("/close-many", async (req, res) => {
1748
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1792
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1749
1793
  if (!project) {
1750
1794
  res.status(404).json({ error: "Project not found" });
1751
1795
  return;
@@ -1801,11 +1845,11 @@ router.post("/close-many", async (req, res) => {
1801
1845
  }
1802
1846
  }
1803
1847
  if (closedCount > 0) {
1804
- broadcastProjectEvent(req.params["projectId"], {
1848
+ broadcastProjectEvent(routeParam(req, "projectId"), {
1805
1849
  type: "items-bulk-updated",
1806
1850
  data: { userId: req.user.userId },
1807
1851
  });
1808
- scheduleGraphSync(req.params["projectId"], project, "items-bulk-updated");
1852
+ scheduleGraphSync(routeParam(req, "projectId"), project, "items-bulk-updated");
1809
1853
  }
1810
1854
  res.json({
1811
1855
  closed_count: closedCount,
@@ -1817,13 +1861,13 @@ router.post("/close-many", async (req, res) => {
1817
1861
  });
1818
1862
  // GET /api/projects/:projectId/pm/docs/:itemId
1819
1863
  router.get("/docs/:itemId", async (req, res) => {
1820
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1864
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1821
1865
  if (!project) {
1822
1866
  res.status(404).json({ error: "Project not found" });
1823
1867
  return;
1824
1868
  }
1825
1869
  const result = runPm({
1826
- args: ["docs", req.params["itemId"]],
1870
+ args: ["docs", routeParam(req, "itemId")],
1827
1871
  userId: project.ownerUserId,
1828
1872
  slug: project.slug,
1829
1873
  jsonOutput: true,
@@ -1832,13 +1876,13 @@ router.get("/docs/:itemId", async (req, res) => {
1832
1876
  });
1833
1877
  // POST /api/projects/:projectId/pm/docs/:itemId
1834
1878
  router.post("/docs/:itemId", async (req, res) => {
1835
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1879
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1836
1880
  if (!project) {
1837
1881
  res.status(404).json({ error: "Project not found" });
1838
1882
  return;
1839
1883
  }
1840
1884
  const { path: docPath, scope, note, remove, validatePaths } = req.body;
1841
- const args = ["docs", req.params["itemId"]];
1885
+ const args = ["docs", routeParam(req, "itemId")];
1842
1886
  if (remove) {
1843
1887
  args.push("--remove", remove);
1844
1888
  }
@@ -1862,12 +1906,12 @@ router.post("/docs/:itemId", async (req, res) => {
1862
1906
  res.status(400).json({ error: result.stderr || "Failed to update docs" });
1863
1907
  return;
1864
1908
  }
1865
- scheduleGraphSync(req.params["projectId"], project, "docs-updated");
1909
+ scheduleGraphSync(routeParam(req, "projectId"), project, "docs-updated");
1866
1910
  res.json(result.parsed || { ok: true });
1867
1911
  });
1868
1912
  // POST /api/projects/:projectId/pm/test-all
1869
1913
  router.post("/test-all", async (req, res) => {
1870
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1914
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1871
1915
  if (!project) {
1872
1916
  res.status(404).json({ error: "Project not found" });
1873
1917
  return;
@@ -1889,7 +1933,7 @@ router.post("/test-all", async (req, res) => {
1889
1933
  });
1890
1934
  // GET /api/projects/:projectId/pm/test-runs
1891
1935
  router.get("/test-runs", async (req, res) => {
1892
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1936
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1893
1937
  if (!project) {
1894
1938
  res.status(404).json({ error: "Project not found" });
1895
1939
  return;
@@ -1905,7 +1949,7 @@ router.get("/test-runs", async (req, res) => {
1905
1949
  });
1906
1950
  // POST /api/projects/:projectId/pm/gc
1907
1951
  router.post("/gc", async (req, res) => {
1908
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1952
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1909
1953
  if (!project) {
1910
1954
  res.status(404).json({ error: "Project not found" });
1911
1955
  return;
@@ -1915,7 +1959,7 @@ router.post("/gc", async (req, res) => {
1915
1959
  });
1916
1960
  // GET /api/projects/:projectId/pm/templates
1917
1961
  router.get("/templates", async (req, res) => {
1918
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1962
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1919
1963
  if (!project) {
1920
1964
  res.status(404).json({ error: "Project not found" });
1921
1965
  return;
@@ -1925,17 +1969,17 @@ router.get("/templates", async (req, res) => {
1925
1969
  });
1926
1970
  // GET /api/projects/:projectId/pm/templates/:name
1927
1971
  router.get("/templates/:name", async (req, res) => {
1928
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1972
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1929
1973
  if (!project) {
1930
1974
  res.status(404).json({ error: "Project not found" });
1931
1975
  return;
1932
1976
  }
1933
- const result = runPm({ args: ["templates", "show", req.params["name"]], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1977
+ const result = runPm({ args: ["templates", "show", routeParam(req, "name")], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1934
1978
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1935
1979
  });
1936
1980
  // GET /api/projects/:projectId/pm/config
1937
1981
  router.get("/config", async (req, res) => {
1938
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1982
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1939
1983
  if (!project) {
1940
1984
  res.status(404).json({ error: "Project not found" });
1941
1985
  return;
@@ -1945,23 +1989,23 @@ router.get("/config", async (req, res) => {
1945
1989
  });
1946
1990
  // GET /api/projects/:projectId/pm/config/:key
1947
1991
  router.get("/config/:key", async (req, res) => {
1948
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
1992
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1949
1993
  if (!project) {
1950
1994
  res.status(404).json({ error: "Project not found" });
1951
1995
  return;
1952
1996
  }
1953
- const key = req.params["key"];
1997
+ const key = routeParam(req, "key");
1954
1998
  const result = runPm({ args: ["config", "project", "get", key], userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
1955
1999
  res.json(result.ok ? (result.parsed || {}) : { error: result.stderr });
1956
2000
  });
1957
2001
  // PATCH /api/projects/:projectId/pm/config/:key
1958
2002
  router.patch("/config/:key", async (req, res) => {
1959
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2003
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1960
2004
  if (!project) {
1961
2005
  res.status(404).json({ error: "Project not found" });
1962
2006
  return;
1963
2007
  }
1964
- const key = req.params["key"];
2008
+ const key = routeParam(req, "key");
1965
2009
  const body = req.body;
1966
2010
  const args = ["config", "project", "set", key];
1967
2011
  if (body.value)
@@ -1977,7 +2021,7 @@ router.patch("/config/:key", async (req, res) => {
1977
2021
  // These wrap pm list-draft, list-open, etc.
1978
2022
  function buildListShortcutRoute(pmCommand) {
1979
2023
  return async (req, res) => {
1980
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2024
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
1981
2025
  if (!project) {
1982
2026
  res.status(404).json({ error: "Project not found" });
1983
2027
  return;
@@ -2015,7 +2059,7 @@ router.get("/list-canceled", buildListShortcutRoute("list-canceled"));
2015
2059
  // ─────────────────────────────────────────────────────────
2016
2060
  // POST /api/projects/:projectId/pm/plan
2017
2061
  router.post("/plan", async (req, res) => {
2018
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2062
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2019
2063
  if (!project) {
2020
2064
  res.status(404).json({ error: "Project not found" });
2021
2065
  return;
@@ -2041,7 +2085,7 @@ router.post("/plan", async (req, res) => {
2041
2085
  res.status(400).json({ error: result.stderr || "Failed to create plan" });
2042
2086
  return;
2043
2087
  }
2044
- broadcastProjectEvent(req.params["projectId"], {
2088
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2045
2089
  type: "item-created",
2046
2090
  data: { result: result.parsed, userId: req.user.userId },
2047
2091
  });
@@ -2049,13 +2093,13 @@ router.post("/plan", async (req, res) => {
2049
2093
  });
2050
2094
  // GET /api/projects/:projectId/pm/plan/:planId
2051
2095
  router.get("/plan/:planId", async (req, res) => {
2052
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2096
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2053
2097
  if (!project) {
2054
2098
  res.status(404).json({ error: "Project not found" });
2055
2099
  return;
2056
2100
  }
2057
2101
  const result = runPm({
2058
- args: ["plan", "show", req.params["planId"], "--depth", "standard"],
2102
+ args: ["plan", "show", routeParam(req, "planId"), "--depth", "standard"],
2059
2103
  userId: project.ownerUserId,
2060
2104
  slug: project.slug,
2061
2105
  jsonOutput: true,
@@ -2068,13 +2112,13 @@ router.get("/plan/:planId", async (req, res) => {
2068
2112
  });
2069
2113
  // PATCH /api/projects/:projectId/pm/plan/:planId
2070
2114
  router.patch("/plan/:planId", async (req, res) => {
2071
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2115
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2072
2116
  if (!project) {
2073
2117
  res.status(404).json({ error: "Project not found" });
2074
2118
  return;
2075
2119
  }
2076
2120
  const { title, description } = req.body;
2077
- const args = ["update", req.params["planId"]];
2121
+ const args = ["update", routeParam(req, "planId")];
2078
2122
  if (title?.trim())
2079
2123
  args.push("--title", title.trim());
2080
2124
  if (description !== undefined)
@@ -2084,21 +2128,21 @@ router.patch("/plan/:planId", async (req, res) => {
2084
2128
  res.status(400).json({ error: result.stderr || "Failed to update plan" });
2085
2129
  return;
2086
2130
  }
2087
- broadcastProjectEvent(req.params["projectId"], {
2131
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2088
2132
  type: "item-updated",
2089
- data: { itemId: req.params["planId"], userId: req.user.userId },
2133
+ data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2090
2134
  });
2091
2135
  res.json(result.parsed || {});
2092
2136
  });
2093
2137
  // DELETE /api/projects/:projectId/pm/plan/:planId
2094
2138
  router.delete("/plan/:planId", async (req, res) => {
2095
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2139
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2096
2140
  if (!project) {
2097
2141
  res.status(404).json({ error: "Project not found" });
2098
2142
  return;
2099
2143
  }
2100
2144
  const result = runPm({
2101
- args: ["delete", req.params["planId"]],
2145
+ args: ["delete", routeParam(req, "planId")],
2102
2146
  userId: project.ownerUserId,
2103
2147
  slug: project.slug,
2104
2148
  jsonOutput: true,
@@ -2107,15 +2151,15 @@ router.delete("/plan/:planId", async (req, res) => {
2107
2151
  res.status(400).json({ error: result.stderr || "Failed to delete plan" });
2108
2152
  return;
2109
2153
  }
2110
- broadcastProjectEvent(req.params["projectId"], {
2154
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2111
2155
  type: "item-deleted",
2112
- data: { itemId: req.params["planId"], userId: req.user.userId },
2156
+ data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2113
2157
  });
2114
2158
  res.json({ ok: true });
2115
2159
  });
2116
2160
  // POST /api/projects/:projectId/pm/plan/:planId/steps
2117
2161
  router.post("/plan/:planId/steps", async (req, res) => {
2118
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2162
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2119
2163
  if (!project) {
2120
2164
  res.status(404).json({ error: "Project not found" });
2121
2165
  return;
@@ -2125,7 +2169,7 @@ router.post("/plan/:planId/steps", async (req, res) => {
2125
2169
  res.status(400).json({ error: "Title is required" });
2126
2170
  return;
2127
2171
  }
2128
- const args = ["plan", "add-step", req.params["planId"], "--title", title.trim()];
2172
+ const args = ["plan", "add-step", routeParam(req, "planId"), "--title", title.trim()];
2129
2173
  if (description)
2130
2174
  args.push("--description", description);
2131
2175
  if (dependsOn)
@@ -2135,21 +2179,21 @@ router.post("/plan/:planId/steps", async (req, res) => {
2135
2179
  res.status(400).json({ error: result.stderr || "Failed to add step" });
2136
2180
  return;
2137
2181
  }
2138
- broadcastProjectEvent(req.params["projectId"], {
2182
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2139
2183
  type: "item-updated",
2140
- data: { itemId: req.params["planId"], userId: req.user.userId },
2184
+ data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2141
2185
  });
2142
2186
  res.status(201).json(result.parsed || {});
2143
2187
  });
2144
2188
  // PATCH /api/projects/:projectId/pm/plan/:planId/steps/:stepRef
2145
2189
  router.patch("/plan/:planId/steps/:stepRef", async (req, res) => {
2146
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2190
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2147
2191
  if (!project) {
2148
2192
  res.status(404).json({ error: "Project not found" });
2149
2193
  return;
2150
2194
  }
2151
2195
  const { title, description } = req.body;
2152
- const args = ["plan", "update-step", req.params["planId"], req.params["stepRef"]];
2196
+ const args = ["plan", "update-step", routeParam(req, "planId"), routeParam(req, "stepRef")];
2153
2197
  if (title)
2154
2198
  args.push("--title", title);
2155
2199
  if (description)
@@ -2159,21 +2203,21 @@ router.patch("/plan/:planId/steps/:stepRef", async (req, res) => {
2159
2203
  res.status(400).json({ error: result.stderr || "Failed to update step" });
2160
2204
  return;
2161
2205
  }
2162
- broadcastProjectEvent(req.params["projectId"], {
2206
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2163
2207
  type: "item-updated",
2164
- data: { itemId: req.params["planId"], userId: req.user.userId },
2208
+ data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2165
2209
  });
2166
2210
  res.json(result.parsed || {});
2167
2211
  });
2168
2212
  // POST /api/projects/:projectId/pm/plan/:planId/steps/:stepRef/complete
2169
2213
  router.post("/plan/:planId/steps/:stepRef/complete", async (req, res) => {
2170
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2214
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2171
2215
  if (!project) {
2172
2216
  res.status(404).json({ error: "Project not found" });
2173
2217
  return;
2174
2218
  }
2175
2219
  const result = runPm({
2176
- args: ["plan", "complete-step", req.params["planId"], req.params["stepRef"]],
2220
+ args: ["plan", "complete-step", routeParam(req, "planId"), routeParam(req, "stepRef")],
2177
2221
  userId: project.ownerUserId,
2178
2222
  slug: project.slug,
2179
2223
  jsonOutput: true,
@@ -2182,15 +2226,15 @@ router.post("/plan/:planId/steps/:stepRef/complete", async (req, res) => {
2182
2226
  res.status(400).json({ error: result.stderr || "Failed to complete step" });
2183
2227
  return;
2184
2228
  }
2185
- broadcastProjectEvent(req.params["projectId"], {
2229
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2186
2230
  type: "item-updated",
2187
- data: { itemId: req.params["planId"], userId: req.user.userId },
2231
+ data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2188
2232
  });
2189
2233
  res.json(result.parsed || {});
2190
2234
  });
2191
2235
  // POST /api/projects/:projectId/pm/plan/:planId/steps/:stepRef/block
2192
2236
  router.post("/plan/:planId/steps/:stepRef/block", async (req, res) => {
2193
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2237
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2194
2238
  if (!project) {
2195
2239
  res.status(404).json({ error: "Project not found" });
2196
2240
  return;
@@ -2201,7 +2245,7 @@ router.post("/plan/:planId/steps/:stepRef/block", async (req, res) => {
2201
2245
  return;
2202
2246
  }
2203
2247
  const result = runPm({
2204
- args: ["plan", "block-step", req.params["planId"], req.params["stepRef"], "--step-blocked-reason", reason.trim()],
2248
+ args: ["plan", "block-step", routeParam(req, "planId"), routeParam(req, "stepRef"), "--step-blocked-reason", reason.trim()],
2205
2249
  userId: project.ownerUserId,
2206
2250
  slug: project.slug,
2207
2251
  jsonOutput: true,
@@ -2210,21 +2254,21 @@ router.post("/plan/:planId/steps/:stepRef/block", async (req, res) => {
2210
2254
  res.status(400).json({ error: result.stderr || "Failed to block step" });
2211
2255
  return;
2212
2256
  }
2213
- broadcastProjectEvent(req.params["projectId"], {
2257
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2214
2258
  type: "item-updated",
2215
- data: { itemId: req.params["planId"], userId: req.user.userId },
2259
+ data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2216
2260
  });
2217
2261
  res.json(result.parsed || {});
2218
2262
  });
2219
2263
  // DELETE /api/projects/:projectId/pm/plan/:planId/steps/:stepRef
2220
2264
  router.delete("/plan/:planId/steps/:stepRef", async (req, res) => {
2221
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2265
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2222
2266
  if (!project) {
2223
2267
  res.status(404).json({ error: "Project not found" });
2224
2268
  return;
2225
2269
  }
2226
2270
  const result = runPm({
2227
- args: ["plan", "remove-step", req.params["planId"], req.params["stepRef"]],
2271
+ args: ["plan", "remove-step", routeParam(req, "planId"), routeParam(req, "stepRef")],
2228
2272
  userId: project.ownerUserId,
2229
2273
  slug: project.slug,
2230
2274
  jsonOutput: true,
@@ -2233,21 +2277,21 @@ router.delete("/plan/:planId/steps/:stepRef", async (req, res) => {
2233
2277
  res.status(400).json({ error: result.stderr || "Failed to remove step" });
2234
2278
  return;
2235
2279
  }
2236
- broadcastProjectEvent(req.params["projectId"], {
2280
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2237
2281
  type: "item-updated",
2238
- data: { itemId: req.params["planId"], userId: req.user.userId },
2282
+ data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2239
2283
  });
2240
2284
  res.json(result.parsed || {});
2241
2285
  });
2242
2286
  // POST /api/projects/:projectId/pm/plan/:planId/approve
2243
2287
  router.post("/plan/:planId/approve", async (req, res) => {
2244
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2288
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2245
2289
  if (!project) {
2246
2290
  res.status(404).json({ error: "Project not found" });
2247
2291
  return;
2248
2292
  }
2249
2293
  const result = runPm({
2250
- args: ["plan", "approve", req.params["planId"]],
2294
+ args: ["plan", "approve", routeParam(req, "planId")],
2251
2295
  userId: project.ownerUserId,
2252
2296
  slug: project.slug,
2253
2297
  jsonOutput: true,
@@ -2256,21 +2300,21 @@ router.post("/plan/:planId/approve", async (req, res) => {
2256
2300
  res.status(400).json({ error: result.stderr || "Failed to approve plan" });
2257
2301
  return;
2258
2302
  }
2259
- broadcastProjectEvent(req.params["projectId"], {
2303
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2260
2304
  type: "item-updated",
2261
- data: { itemId: req.params["planId"], userId: req.user.userId },
2305
+ data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2262
2306
  });
2263
2307
  res.json(result.parsed || {});
2264
2308
  });
2265
2309
  // POST /api/projects/:projectId/pm/plan/:planId/materialize
2266
2310
  router.post("/plan/:planId/materialize", async (req, res) => {
2267
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2311
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2268
2312
  if (!project) {
2269
2313
  res.status(404).json({ error: "Project not found" });
2270
2314
  return;
2271
2315
  }
2272
2316
  const { materializeType, materializeParent, steps } = req.body;
2273
- const args = ["plan", "materialize", req.params["planId"]];
2317
+ const args = ["plan", "materialize", routeParam(req, "planId")];
2274
2318
  if (materializeType)
2275
2319
  args.push("--materialize-type", materializeType);
2276
2320
  if (materializeParent)
@@ -2282,7 +2326,7 @@ router.post("/plan/:planId/materialize", async (req, res) => {
2282
2326
  res.status(400).json({ error: result.stderr || "Failed to materialize plan" });
2283
2327
  return;
2284
2328
  }
2285
- broadcastProjectEvent(req.params["projectId"], {
2329
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2286
2330
  type: "item-created",
2287
2331
  data: { result: result.parsed, userId: req.user.userId },
2288
2332
  });
@@ -2290,7 +2334,7 @@ router.post("/plan/:planId/materialize", async (req, res) => {
2290
2334
  });
2291
2335
  // POST /api/projects/:projectId/pm/plan/:planId/steps/:stepRef/reorder
2292
2336
  router.post("/plan/:planId/steps/:stepRef/reorder", async (req, res) => {
2293
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2337
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2294
2338
  if (!project) {
2295
2339
  res.status(404).json({ error: "Project not found" });
2296
2340
  return;
@@ -2301,7 +2345,7 @@ router.post("/plan/:planId/steps/:stepRef/reorder", async (req, res) => {
2301
2345
  return;
2302
2346
  }
2303
2347
  const result = runPm({
2304
- args: ["plan", "reorder-step", req.params["planId"], req.params["stepRef"], String(reorderTo)],
2348
+ args: ["plan", "reorder-step", routeParam(req, "planId"), routeParam(req, "stepRef"), String(reorderTo)],
2305
2349
  userId: project.ownerUserId,
2306
2350
  slug: project.slug,
2307
2351
  jsonOutput: true,
@@ -2310,15 +2354,15 @@ router.post("/plan/:planId/steps/:stepRef/reorder", async (req, res) => {
2310
2354
  res.status(400).json({ error: result.stderr || "Failed to reorder step" });
2311
2355
  return;
2312
2356
  }
2313
- broadcastProjectEvent(req.params["projectId"], {
2357
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2314
2358
  type: "item-updated",
2315
- data: { itemId: req.params["planId"], userId: req.user.userId },
2359
+ data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2316
2360
  });
2317
2361
  res.json(result.parsed || {});
2318
2362
  });
2319
2363
  // POST /api/projects/:projectId/pm/plan/:planId/link
2320
2364
  router.post("/plan/:planId/link", async (req, res) => {
2321
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2365
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2322
2366
  if (!project) {
2323
2367
  res.status(404).json({ error: "Project not found" });
2324
2368
  return;
@@ -2328,7 +2372,7 @@ router.post("/plan/:planId/link", async (req, res) => {
2328
2372
  res.status(400).json({ error: "link (item id) is required" });
2329
2373
  return;
2330
2374
  }
2331
- const args = ["plan", "link", req.params["planId"], "--link", link.trim()];
2375
+ const args = ["plan", "link", routeParam(req, "planId"), "--link", link.trim()];
2332
2376
  if (linkKind)
2333
2377
  args.push("--link-kind", linkKind);
2334
2378
  if (linkNote)
@@ -2340,15 +2384,15 @@ router.post("/plan/:planId/link", async (req, res) => {
2340
2384
  res.status(400).json({ error: result.stderr || "Failed to link plan" });
2341
2385
  return;
2342
2386
  }
2343
- broadcastProjectEvent(req.params["projectId"], {
2387
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2344
2388
  type: "item-updated",
2345
- data: { itemId: req.params["planId"], userId: req.user.userId },
2389
+ data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2346
2390
  });
2347
2391
  res.status(201).json(result.parsed || {});
2348
2392
  });
2349
2393
  // DELETE /api/projects/:projectId/pm/plan/:planId/link
2350
2394
  router.delete("/plan/:planId/link", async (req, res) => {
2351
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2395
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2352
2396
  if (!project) {
2353
2397
  res.status(404).json({ error: "Project not found" });
2354
2398
  return;
@@ -2358,7 +2402,7 @@ router.delete("/plan/:planId/link", async (req, res) => {
2358
2402
  res.status(400).json({ error: "link (item id) is required" });
2359
2403
  return;
2360
2404
  }
2361
- const args = ["plan", "unlink", req.params["planId"], "--link", link.trim()];
2405
+ const args = ["plan", "unlink", routeParam(req, "planId"), "--link", link.trim()];
2362
2406
  if (linkKind)
2363
2407
  args.push("--link-kind", linkKind);
2364
2408
  const result = runPm({ args, userId: project.ownerUserId, slug: project.slug, jsonOutput: true });
@@ -2366,9 +2410,9 @@ router.delete("/plan/:planId/link", async (req, res) => {
2366
2410
  res.status(400).json({ error: result.stderr || "Failed to unlink plan" });
2367
2411
  return;
2368
2412
  }
2369
- broadcastProjectEvent(req.params["projectId"], {
2413
+ broadcastProjectEvent(routeParam(req, "projectId"), {
2370
2414
  type: "item-updated",
2371
- data: { itemId: req.params["planId"], userId: req.user.userId },
2415
+ data: { itemId: routeParam(req, "planId"), userId: req.user.userId },
2372
2416
  });
2373
2417
  res.json(result.parsed || {});
2374
2418
  });
@@ -2377,7 +2421,7 @@ router.delete("/plan/:planId/link", async (req, res) => {
2377
2421
  // POST /api/projects/:projectId/pm/upgrade
2378
2422
  // Runs pm upgrade --packages-only (never upgrades the CLI itself via the web UI).
2379
2423
  router.get("/upgrade", async (req, res) => {
2380
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2424
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2381
2425
  if (!project) {
2382
2426
  res.status(404).json({ error: "Project not found" });
2383
2427
  return;
@@ -2391,7 +2435,7 @@ router.get("/upgrade", async (req, res) => {
2391
2435
  res.json(result.ok ? (result.parsed || { dryRun: true }) : { error: result.stderr });
2392
2436
  });
2393
2437
  router.post("/upgrade", async (req, res) => {
2394
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2438
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2395
2439
  if (!project) {
2396
2440
  res.status(404).json({ error: "Project not found" });
2397
2441
  return;
@@ -2412,7 +2456,7 @@ router.post("/upgrade", async (req, res) => {
2412
2456
  // ─── Presence endpoints ───
2413
2457
  // GET /api/projects/:projectId/pm/presence
2414
2458
  router.get("/presence", async (req, res) => {
2415
- const projectId = req.params["projectId"];
2459
+ const projectId = routeParam(req, "projectId");
2416
2460
  res.json({ users: getProjectPresence(projectId) });
2417
2461
  });
2418
2462
  // PATCH /api/projects/:projectId/pm/presence/:clientId
@@ -2427,7 +2471,7 @@ router.patch("/presence/:clientId", async (req, res) => {
2427
2471
  // GET /api/projects/:projectId/pm/events
2428
2472
  router.get("/events", async (req, res) => {
2429
2473
  try {
2430
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2474
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2431
2475
  if (!project) {
2432
2476
  res.status(404).json({ error: "Project not found" });
2433
2477
  return;
@@ -2440,7 +2484,7 @@ router.get("/events", async (req, res) => {
2440
2484
  }
2441
2485
  setupSSEHeaders(res);
2442
2486
  const clientId = uuidv4();
2443
- const projectId = req.params["projectId"];
2487
+ const projectId = routeParam(req, "projectId");
2444
2488
  const userId = req.user.userId;
2445
2489
  // Client sends display name as query param; fall back to email
2446
2490
  const displayName = String(req.query["dn"] ?? req.user.email ?? userId);
@@ -2474,12 +2518,12 @@ router.get("/events", async (req, res) => {
2474
2518
  // ─── Presence endpoint ───
2475
2519
  // GET /api/projects/:projectId/pm/presence
2476
2520
  router.get("/presence", async (req, res) => {
2477
- const project = await verifyProject(req.user.userId, req.params["projectId"]);
2521
+ const project = await verifyProject(req.user.userId, routeParam(req, "projectId"));
2478
2522
  if (!project) {
2479
2523
  res.status(404).json({ error: "Project not found" });
2480
2524
  return;
2481
2525
  }
2482
- const users = getProjectPresence(req.params["projectId"]);
2526
+ const users = getProjectPresence(routeParam(req, "projectId"));
2483
2527
  res.json({ users });
2484
2528
  });
2485
2529
  export { router as pmRouter };