@penclipai/server 2026.704.0 → 2026.714.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/dist/adapters/index.d.ts +1 -1
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/process/execute.d.ts.map +1 -1
- package/dist/adapters/process/execute.js +1 -0
- package/dist/adapters/process/execute.js.map +1 -1
- package/dist/adapters/registry.d.ts.map +1 -1
- package/dist/adapters/registry.js +79 -51
- package/dist/adapters/registry.js.map +1 -1
- package/dist/adapters/utils.d.ts +5 -0
- package/dist/adapters/utils.d.ts.map +1 -1
- package/dist/adapters/utils.js.map +1 -1
- package/dist/agent-auth-jwt.d.ts +5 -1
- package/dist/agent-auth-jwt.d.ts.map +1 -1
- package/dist/agent-auth-jwt.js +72 -20
- package/dist/agent-auth-jwt.js.map +1 -1
- package/dist/app.d.ts +2 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +10 -0
- package/dist/app.js.map +1 -1
- package/dist/built-ins/agents/reflection-coach/AGENTS.md +47 -0
- package/dist/built-ins/agents/reflection-coach/routines/recent-agent-reflection.md +77 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +208 -116
- package/dist/index.js.map +1 -1
- package/dist/middleware/api-compression.d.ts +8 -0
- package/dist/middleware/api-compression.d.ts.map +1 -0
- package/dist/middleware/api-compression.js +207 -0
- package/dist/middleware/api-compression.js.map +1 -0
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +130 -3
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/error-handler.d.ts.map +1 -1
- package/dist/middleware/error-handler.js +27 -0
- package/dist/middleware/error-handler.js.map +1 -1
- package/dist/onboarding-assets/default/AGENTS.md +1 -0
- package/dist/redaction.d.ts.map +1 -1
- package/dist/redaction.js +11 -0
- package/dist/redaction.js.map +1 -1
- package/dist/routes/access.d.ts.map +1 -1
- package/dist/routes/access.js +4 -3
- package/dist/routes/access.js.map +1 -1
- package/dist/routes/adapters.d.ts.map +1 -1
- package/dist/routes/adapters.js +2 -0
- package/dist/routes/adapters.js.map +1 -1
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +222 -87
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/attention.d.ts +3 -0
- package/dist/routes/attention.d.ts.map +1 -0
- package/dist/routes/attention.js +24 -0
- package/dist/routes/attention.js.map +1 -0
- package/dist/routes/authz.d.ts.map +1 -1
- package/dist/routes/authz.js +29 -1
- package/dist/routes/authz.js.map +1 -1
- package/dist/routes/board-chat.d.ts.map +1 -1
- package/dist/routes/board-chat.js +4 -1
- package/dist/routes/board-chat.js.map +1 -1
- package/dist/routes/built-in-agents.d.ts +3 -0
- package/dist/routes/built-in-agents.d.ts.map +1 -0
- package/dist/routes/built-in-agents.js +267 -0
- package/dist/routes/built-in-agents.js.map +1 -0
- package/dist/routes/cases.d.ts +46 -0
- package/dist/routes/cases.d.ts.map +1 -0
- package/dist/routes/cases.js +1395 -0
- package/dist/routes/cases.js.map +1 -0
- package/dist/routes/companies.d.ts.map +1 -1
- package/dist/routes/companies.js +4 -1
- package/dist/routes/companies.js.map +1 -1
- package/dist/routes/company-skills.d.ts.map +1 -1
- package/dist/routes/company-skills.js +453 -33
- package/dist/routes/company-skills.js.map +1 -1
- package/dist/routes/environments.d.ts.map +1 -1
- package/dist/routes/environments.js +71 -8
- package/dist/routes/environments.js.map +1 -1
- package/dist/routes/execution-workspaces.d.ts.map +1 -1
- package/dist/routes/execution-workspaces.js +89 -3
- package/dist/routes/execution-workspaces.js.map +1 -1
- package/dist/routes/health.d.ts +2 -0
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +30 -0
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/inbox-dismissals.d.ts.map +1 -1
- package/dist/routes/inbox-dismissals.js +80 -19
- package/dist/routes/inbox-dismissals.js.map +1 -1
- package/dist/routes/index.d.ts +2 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/issues.d.ts +25 -0
- package/dist/routes/issues.d.ts.map +1 -1
- package/dist/routes/issues.js +1455 -46
- package/dist/routes/issues.js.map +1 -1
- package/dist/routes/openapi.d.ts.map +1 -1
- package/dist/routes/openapi.js +448 -11
- package/dist/routes/openapi.js.map +1 -1
- package/dist/routes/pipelines.js +2 -2
- package/dist/routes/pipelines.js.map +1 -1
- package/dist/routes/resource-memberships.d.ts.map +1 -1
- package/dist/routes/resource-memberships.js +13 -5
- package/dist/routes/resource-memberships.js.map +1 -1
- package/dist/routes/secrets.d.ts.map +1 -1
- package/dist/routes/secrets.js +261 -1
- package/dist/routes/secrets.js.map +1 -1
- package/dist/routes/sidebar-badges.d.ts.map +1 -1
- package/dist/routes/sidebar-badges.js +18 -2
- package/dist/routes/sidebar-badges.js.map +1 -1
- package/dist/routes/user-profiles.d.ts.map +1 -1
- package/dist/routes/user-profiles.js +5 -4
- package/dist/routes/user-profiles.js.map +1 -1
- package/dist/server-info.d.ts +2 -0
- package/dist/server-info.d.ts.map +1 -1
- package/dist/server-info.js +20 -5
- package/dist/server-info.js.map +1 -1
- package/dist/services/access.d.ts +4 -4
- package/dist/services/access.d.ts.map +1 -1
- package/dist/services/activity.d.ts +3 -3
- package/dist/services/activity.d.ts.map +1 -1
- package/dist/services/activity.js +5 -4
- package/dist/services/activity.js.map +1 -1
- package/dist/services/adapter-registry-bootstrap.reconcile.test.js +1 -0
- package/dist/services/adapter-registry-bootstrap.reconcile.test.js.map +1 -1
- package/dist/services/agent-secret-bindings.d.ts +15 -0
- package/dist/services/agent-secret-bindings.d.ts.map +1 -1
- package/dist/services/agent-secret-bindings.js +43 -0
- package/dist/services/agent-secret-bindings.js.map +1 -1
- package/dist/services/agents.d.ts +194 -133
- package/dist/services/agents.d.ts.map +1 -1
- package/dist/services/agents.js +107 -4
- package/dist/services/agents.js.map +1 -1
- package/dist/services/approvals.d.ts +10 -10
- package/dist/services/approvals.d.ts.map +1 -1
- package/dist/services/approvals.js +9 -1
- package/dist/services/approvals.js.map +1 -1
- package/dist/services/assets.d.ts +5 -5
- package/dist/services/attention.d.ts +11 -0
- package/dist/services/attention.d.ts.map +1 -0
- package/dist/services/attention.js +1024 -0
- package/dist/services/attention.js.map +1 -0
- package/dist/services/authorization.d.ts +15 -2
- package/dist/services/authorization.d.ts.map +1 -1
- package/dist/services/authorization.js +324 -8
- package/dist/services/authorization.js.map +1 -1
- package/dist/services/board-auth.d.ts +2 -2
- package/dist/services/budgets.d.ts.map +1 -1
- package/dist/services/budgets.js +8 -7
- package/dist/services/budgets.js.map +1 -1
- package/dist/services/built-in-agent-metadata.d.ts +9 -0
- package/dist/services/built-in-agent-metadata.d.ts.map +1 -0
- package/dist/services/built-in-agent-metadata.js +39 -0
- package/dist/services/built-in-agent-metadata.js.map +1 -0
- package/dist/services/built-in-agents.d.ts +394 -0
- package/dist/services/built-in-agents.d.ts.map +1 -0
- package/dist/services/built-in-agents.js +1426 -0
- package/dist/services/built-in-agents.js.map +1 -0
- package/dist/services/change-consent-gate.d.ts +18 -0
- package/dist/services/change-consent-gate.d.ts.map +1 -0
- package/dist/services/change-consent-gate.js +170 -0
- package/dist/services/change-consent-gate.js.map +1 -0
- package/dist/services/companies.d.ts +14 -8
- package/dist/services/companies.d.ts.map +1 -1
- package/dist/services/companies.js +6 -1
- package/dist/services/companies.js.map +1 -1
- package/dist/services/company-artifacts.d.ts +4 -1
- package/dist/services/company-artifacts.d.ts.map +1 -1
- package/dist/services/company-artifacts.js +9 -1
- package/dist/services/company-artifacts.js.map +1 -1
- package/dist/services/company-member-roles.d.ts.map +1 -1
- package/dist/services/company-member-roles.js +2 -0
- package/dist/services/company-member-roles.js.map +1 -1
- package/dist/services/company-portability.d.ts +1 -1
- package/dist/services/company-portability.d.ts.map +1 -1
- package/dist/services/company-portability.js +113 -8
- package/dist/services/company-portability.js.map +1 -1
- package/dist/services/company-search.d.ts.map +1 -1
- package/dist/services/company-search.js +843 -294
- package/dist/services/company-search.js.map +1 -1
- package/dist/services/company-skills.d.ts +64 -3
- package/dist/services/company-skills.d.ts.map +1 -1
- package/dist/services/company-skills.js +1205 -20
- package/dist/services/company-skills.js.map +1 -1
- package/dist/services/costs.d.ts +9 -8
- package/dist/services/costs.d.ts.map +1 -1
- package/dist/services/costs.js +9 -2
- package/dist/services/costs.js.map +1 -1
- package/dist/services/dashboard.d.ts +2 -0
- package/dist/services/dashboard.d.ts.map +1 -1
- package/dist/services/dashboard.js +60 -17
- package/dist/services/dashboard.js.map +1 -1
- package/dist/services/database-backup-health.d.ts +34 -0
- package/dist/services/database-backup-health.d.ts.map +1 -0
- package/dist/services/database-backup-health.js +104 -0
- package/dist/services/database-backup-health.js.map +1 -0
- package/dist/services/document-annotations.d.ts +169 -1
- package/dist/services/document-annotations.d.ts.map +1 -1
- package/dist/services/document-annotations.js +272 -1
- package/dist/services/document-annotations.js.map +1 -1
- package/dist/services/documents.d.ts +368 -0
- package/dist/services/documents.d.ts.map +1 -1
- package/dist/services/documents.js +2 -2
- package/dist/services/documents.js.map +1 -1
- package/dist/services/environment-config.d.ts.map +1 -1
- package/dist/services/environment-config.js +6 -0
- package/dist/services/environment-config.js.map +1 -1
- package/dist/services/environment-custom-image-runtime.d.ts +28 -0
- package/dist/services/environment-custom-image-runtime.d.ts.map +1 -1
- package/dist/services/environment-custom-image-runtime.js +106 -7
- package/dist/services/environment-custom-image-runtime.js.map +1 -1
- package/dist/services/environment-custom-images.d.ts +31 -1
- package/dist/services/environment-custom-images.d.ts.map +1 -1
- package/dist/services/environment-custom-images.js +110 -4
- package/dist/services/environment-custom-images.js.map +1 -1
- package/dist/services/environment-execution-target.d.ts.map +1 -1
- package/dist/services/environment-execution-target.js +1 -3
- package/dist/services/environment-execution-target.js.map +1 -1
- package/dist/services/environment-probe.d.ts +1 -0
- package/dist/services/environment-probe.d.ts.map +1 -1
- package/dist/services/environment-probe.js +99 -5
- package/dist/services/environment-probe.js.map +1 -1
- package/dist/services/environments.d.ts +3 -1
- package/dist/services/environments.d.ts.map +1 -1
- package/dist/services/environments.js +98 -3
- package/dist/services/environments.js.map +1 -1
- package/dist/services/execution-workspace-policy.d.ts +29 -3
- package/dist/services/execution-workspace-policy.d.ts.map +1 -1
- package/dist/services/execution-workspace-policy.js +44 -1
- package/dist/services/execution-workspace-policy.js.map +1 -1
- package/dist/services/execution-workspaces.d.ts +64 -1
- package/dist/services/execution-workspaces.d.ts.map +1 -1
- package/dist/services/execution-workspaces.js +634 -3
- package/dist/services/execution-workspaces.js.map +1 -1
- package/dist/services/external-objects.d.ts +26 -26
- package/dist/services/feedback.d.ts +2 -2
- package/dist/services/finance.d.ts +5 -5
- package/dist/services/goals.d.ts +8 -8
- package/dist/services/heartbeat-stop-metadata.d.ts +2 -2
- package/dist/services/heartbeat-stop-metadata.d.ts.map +1 -1
- package/dist/services/heartbeat-stop-metadata.js +4 -0
- package/dist/services/heartbeat-stop-metadata.js.map +1 -1
- package/dist/services/heartbeat-stop-metadata.test.js +9 -0
- package/dist/services/heartbeat-stop-metadata.test.js.map +1 -1
- package/dist/services/heartbeat.d.ts +133 -28
- package/dist/services/heartbeat.d.ts.map +1 -1
- package/dist/services/heartbeat.js +1963 -239
- package/dist/services/heartbeat.js.map +1 -1
- package/dist/services/inbox-dismissals.d.ts +26 -0
- package/dist/services/inbox-dismissals.d.ts.map +1 -1
- package/dist/services/inbox-dismissals.js +33 -18
- package/dist/services/inbox-dismissals.js.map +1 -1
- package/dist/services/index.d.ts +3 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +3 -1
- package/dist/services/index.js.map +1 -1
- package/dist/services/instance-settings.d.ts +25 -1
- package/dist/services/instance-settings.d.ts.map +1 -1
- package/dist/services/instance-settings.js +103 -5
- package/dist/services/instance-settings.js.map +1 -1
- package/dist/services/issue-continuation-summary.js +1 -1
- package/dist/services/issue-continuation-summary.js.map +1 -1
- package/dist/services/issue-recovery-actions.d.ts +3 -0
- package/dist/services/issue-recovery-actions.d.ts.map +1 -1
- package/dist/services/issue-recovery-actions.js +9 -0
- package/dist/services/issue-recovery-actions.js.map +1 -1
- package/dist/services/issue-rewake-throttle.d.ts +92 -0
- package/dist/services/issue-rewake-throttle.d.ts.map +1 -0
- package/dist/services/issue-rewake-throttle.js +139 -0
- package/dist/services/issue-rewake-throttle.js.map +1 -0
- package/dist/services/issue-thread-interactions.d.ts +8 -1
- package/dist/services/issue-thread-interactions.d.ts.map +1 -1
- package/dist/services/issue-thread-interactions.js +231 -16
- package/dist/services/issue-thread-interactions.js.map +1 -1
- package/dist/services/issue-visibility.d.ts +4 -0
- package/dist/services/issue-visibility.d.ts.map +1 -0
- package/dist/services/issue-visibility.js +9 -0
- package/dist/services/issue-visibility.js.map +1 -0
- package/dist/services/issues.d.ts +225 -105
- package/dist/services/issues.d.ts.map +1 -1
- package/dist/services/issues.js +540 -18
- package/dist/services/issues.js.map +1 -1
- package/dist/services/local-service-supervisor.d.ts +3 -0
- package/dist/services/local-service-supervisor.d.ts.map +1 -1
- package/dist/services/local-service-supervisor.js +51 -0
- package/dist/services/local-service-supervisor.js.map +1 -1
- package/dist/services/pipeline-case-outputs.d.ts.map +1 -1
- package/dist/services/pipeline-case-outputs.js +2 -1
- package/dist/services/pipeline-case-outputs.js.map +1 -1
- package/dist/services/pipelines-aggregation.d.ts.map +1 -1
- package/dist/services/pipelines-aggregation.js +3 -2
- package/dist/services/pipelines-aggregation.js.map +1 -1
- package/dist/services/pipelines.d.ts +165 -165
- package/dist/services/pipelines.d.ts.map +1 -1
- package/dist/services/pipelines.js +6 -4
- package/dist/services/pipelines.js.map +1 -1
- package/dist/services/plugin-database.d.ts +2 -2
- package/dist/services/plugin-environment-driver.d.ts +1 -1
- package/dist/services/plugin-host-services.d.ts.map +1 -1
- package/dist/services/plugin-host-services.js +3 -1
- package/dist/services/plugin-host-services.js.map +1 -1
- package/dist/services/plugin-managed-routines.d.ts +1 -0
- package/dist/services/plugin-managed-routines.d.ts.map +1 -1
- package/dist/services/plugin-registry.d.ts +21 -21
- package/dist/services/productivity-review.d.ts +1 -0
- package/dist/services/productivity-review.d.ts.map +1 -1
- package/dist/services/productivity-review.js +6 -5
- package/dist/services/productivity-review.js.map +1 -1
- package/dist/services/projects.d.ts +9 -9
- package/dist/services/recovery/service.d.ts +82 -54
- package/dist/services/recovery/service.d.ts.map +1 -1
- package/dist/services/recovery/service.js +200 -35
- package/dist/services/recovery/service.js.map +1 -1
- package/dist/services/recovery/successful-run-handoff.d.ts +1 -0
- package/dist/services/recovery/successful-run-handoff.d.ts.map +1 -1
- package/dist/services/recovery/successful-run-handoff.js +2 -0
- package/dist/services/recovery/successful-run-handoff.js.map +1 -1
- package/dist/services/recovery/successful-run-handoff.test.js +20 -0
- package/dist/services/recovery/successful-run-handoff.test.js.map +1 -1
- package/dist/services/resource-memberships.d.ts +13 -10
- package/dist/services/resource-memberships.d.ts.map +1 -1
- package/dist/services/resource-memberships.js +89 -20
- package/dist/services/resource-memberships.js.map +1 -1
- package/dist/services/responsible-user-denial-run-outcomes.d.ts +60 -0
- package/dist/services/responsible-user-denial-run-outcomes.d.ts.map +1 -0
- package/dist/services/responsible-user-denial-run-outcomes.js +56 -0
- package/dist/services/responsible-user-denial-run-outcomes.js.map +1 -0
- package/dist/services/responsible-user-denial-run-outcomes.test.d.ts +2 -0
- package/dist/services/responsible-user-denial-run-outcomes.test.d.ts.map +1 -0
- package/dist/services/responsible-user-denial-run-outcomes.test.js +75 -0
- package/dist/services/responsible-user-denial-run-outcomes.test.js.map +1 -0
- package/dist/services/routines.d.ts +30 -8
- package/dist/services/routines.d.ts.map +1 -1
- package/dist/services/routines.js +241 -18
- package/dist/services/routines.js.map +1 -1
- package/dist/services/run-liveness.d.ts.map +1 -1
- package/dist/services/run-liveness.js +21 -0
- package/dist/services/run-liveness.js.map +1 -1
- package/dist/services/run-log-store.d.ts +1 -0
- package/dist/services/run-log-store.d.ts.map +1 -1
- package/dist/services/run-log-store.js +4 -0
- package/dist/services/run-log-store.js.map +1 -1
- package/dist/services/run-scratch.d.ts +42 -0
- package/dist/services/run-scratch.d.ts.map +1 -0
- package/dist/services/run-scratch.js +111 -0
- package/dist/services/run-scratch.js.map +1 -0
- package/dist/services/run-scratch.test.d.ts +2 -0
- package/dist/services/run-scratch.test.d.ts.map +1 -0
- package/dist/services/run-scratch.test.js +98 -0
- package/dist/services/run-scratch.test.js.map +1 -0
- package/dist/services/secrets.d.ts +1261 -64
- package/dist/services/secrets.d.ts.map +1 -1
- package/dist/services/secrets.js +1089 -96
- package/dist/services/secrets.js.map +1 -1
- package/dist/services/task-watchdogs.d.ts +1 -0
- package/dist/services/task-watchdogs.d.ts.map +1 -1
- package/dist/services/task-watchdogs.js +78 -9
- package/dist/services/task-watchdogs.js.map +1 -1
- package/dist/services/work-timeline.d.ts.map +1 -1
- package/dist/services/work-timeline.js +34 -2
- package/dist/services/work-timeline.js.map +1 -1
- package/dist/services/workspace-runtime-read-model.d.ts +30 -29
- package/dist/services/workspace-runtime-read-model.d.ts.map +1 -1
- package/dist/services/workspace-runtime-read-model.js.map +1 -1
- package/dist/services/workspace-runtime.d.ts +59 -13
- package/dist/services/workspace-runtime.d.ts.map +1 -1
- package/dist/services/workspace-runtime.js +1121 -102
- package/dist/services/workspace-runtime.js.map +1 -1
- package/dist/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md +202 -0
- package/dist/skills-catalog/catalog/bundled/product/paperclip-capsules/SKILL.md +1 -1
- package/dist/skills-catalog/catalog/bundled/product/wireframe/SKILL.md +1 -1
- package/dist/skills-catalog/catalog/optional/content/release-announcement/SKILL.md +90 -0
- package/dist/skills-catalog/catalog/optional/finance/ramp/SKILL.md +98 -0
- package/dist/skills-catalog/generated/catalog.json +84 -12
- package/dist/ui-branding.d.ts +7 -0
- package/dist/ui-branding.d.ts.map +1 -1
- package/dist/ui-branding.js +8 -2
- package/dist/ui-branding.js.map +1 -1
- package/dist/version.d.ts +14 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +114 -3
- package/dist/version.js.map +1 -1
- package/package.json +19 -20
- package/skills/paperclip/SKILL.md +45 -10
- package/skills/paperclip/references/api-reference.md +246 -1
- package/skills/paperclip/references/cases.md +295 -0
- package/skills/paperclip/references/company-skills.md +1 -1
- package/skills/paperclip-board/SKILL.md +3 -4
- package/skills/paperclip-converting-plans-to-tasks/SKILL.md +3 -7
- package/skills/para-memory-files/SKILL.md +3 -7
- package/ui-dist/assets/{abnfDiagram-VRR7QNED-BsFz2IiG.js → abnfDiagram-VRR7QNED-Dtgfy1PK.js} +1 -1
- package/ui-dist/assets/{arc-HZhAwbOs.js → arc-BGdvNvs8.js} +1 -1
- package/ui-dist/assets/{architectureDiagram-ZJ3FMSHR-BteMjuOn.js → architectureDiagram-ZJ3FMSHR-CK19SQDx.js} +1 -1
- package/ui-dist/assets/{blockDiagram-677ZJIJ3-Iq66uZjf.js → blockDiagram-677ZJIJ3-Kh4N3XG4.js} +1 -1
- package/ui-dist/assets/{browser-ponyfill-BBsuyZJt.js → browser-ponyfill-B3hXtEFW.js} +1 -1
- package/ui-dist/assets/{c4Diagram-LMCZKHZV-_nlglj7I.js → c4Diagram-LMCZKHZV-CBHpGrgU.js} +1 -1
- package/ui-dist/assets/channel-4fjB8e8N.js +1 -0
- package/ui-dist/assets/{chunk-2Q5K7J3B-Cvz7WwSQ.js → chunk-2Q5K7J3B-BdHbqi7k.js} +1 -1
- package/ui-dist/assets/{chunk-32BRIVSS-LLubniCS.js → chunk-32BRIVSS-DxtzZzpt.js} +1 -1
- package/ui-dist/assets/{chunk-5VM5RSS4-BOst5hcr.js → chunk-5VM5RSS4-DMKxBbgu.js} +1 -1
- package/ui-dist/assets/{chunk-EX3LRPZG-D5WNShVm.js → chunk-EX3LRPZG-BaIOPi4v.js} +1 -1
- package/ui-dist/assets/{chunk-JWPE2WC7-CA07x4av.js → chunk-JWPE2WC7-DGo3MPmr.js} +1 -1
- package/ui-dist/assets/{chunk-MOJQB5TN-Clfi83rK.js → chunk-MOJQB5TN-BEhQg0uD.js} +1 -1
- package/ui-dist/assets/{chunk-RYQCIY6F-BhZ6p7YJ.js → chunk-RYQCIY6F-HPJ6zRpJ.js} +1 -1
- package/ui-dist/assets/{chunk-V7JOEXUC-CdexJGoV.js → chunk-V7JOEXUC-Bl_6cAJD.js} +1 -1
- package/ui-dist/assets/{chunk-VR4S4FIN-8vjmBhr1.js → chunk-VR4S4FIN-KMjgGCsw.js} +1 -1
- package/ui-dist/assets/{chunk-XXDRQBXY-CqmCaplj.js → chunk-XXDRQBXY-oWGAIKjr.js} +1 -1
- package/ui-dist/assets/classDiagram-OUVF2IWQ-CTXxO5l9.js +1 -0
- package/ui-dist/assets/classDiagram-v2-EOCWNBFH-CTXxO5l9.js +1 -0
- package/ui-dist/assets/{cose-bilkent-JH36ORCC-CY-zAygY.js → cose-bilkent-JH36ORCC-Ckqse1pH.js} +1 -1
- package/ui-dist/assets/{cynefin-VYW2F7L2-BQSQ1HKr.js → cynefin-VYW2F7L2-DfZ8Hzqi.js} +1 -1
- package/ui-dist/assets/{cynefinDiagram-TSTJHNR4-NJCuFrZL.js → cynefinDiagram-TSTJHNR4-BkDQo9oi.js} +1 -1
- package/ui-dist/assets/{dagre-VKFMJZFB-DAUPIc8g.js → dagre-VKFMJZFB-ClghXQ6E.js} +1 -1
- package/ui-dist/assets/{diagram-FQU43EPY-DvMwJlCi.js → diagram-FQU43EPY-D5a0CwpV.js} +1 -1
- package/ui-dist/assets/{diagram-G47NLZAW-DPY0Jz_y.js → diagram-G47NLZAW-hVjYR_xl.js} +1 -1
- package/ui-dist/assets/{diagram-NH7WQ7WH-BpopeEhp.js → diagram-NH7WQ7WH-BlDPgD5Z.js} +1 -1
- package/ui-dist/assets/{diagram-OA4YK3LP-Qpu05z9X.js → diagram-OA4YK3LP-D0iZKIS3.js} +1 -1
- package/ui-dist/assets/{diagram-WEI45ONY-DHnvw9XU.js → diagram-WEI45ONY-m6hrfD0J.js} +1 -1
- package/ui-dist/assets/{ebnfDiagram-CCIWWBDH-D61S54_7.js → ebnfDiagram-CCIWWBDH-D4SQYLod.js} +1 -1
- package/ui-dist/assets/{erDiagram-Q63AITRT-pplAN_x2.js → erDiagram-Q63AITRT-Byzb6-I5.js} +1 -1
- package/ui-dist/assets/{flowDiagram-23GEKE2U-BaBcbyDq.js → flowDiagram-23GEKE2U-CaTUkFO6.js} +1 -1
- package/ui-dist/assets/{ganttDiagram-NO4QXBWP-BOJSII2b.js → ganttDiagram-NO4QXBWP-DmdMQ3ES.js} +1 -1
- package/ui-dist/assets/{gitGraphDiagram-IHSO6WYX-aYAaO4a2.js → gitGraphDiagram-IHSO6WYX-Bt-1g2CS.js} +1 -1
- package/ui-dist/assets/{index-CZxYajxF.js → index-2Dk9yO0W.js} +1 -1
- package/ui-dist/assets/{index-B8H0SHw1.js → index-8jLpgFNU.js} +1 -1
- package/ui-dist/assets/{index-3EKATXgc.js → index-AP_iimxs.js} +1 -1
- package/ui-dist/assets/{index-C2JgdjPa.js → index-BNJasQY9.js} +1 -1
- package/ui-dist/assets/{index-BaiskB3o.js → index-BO-aNmyX.js} +1 -1
- package/ui-dist/assets/{index-B5YenxAQ.js → index-BPCzCn1Y.js} +1 -1
- package/ui-dist/assets/{index-Da2kqT1M.js → index-BSLnLTb2.js} +1 -1
- package/ui-dist/assets/{index-By0oeEvz.js → index-CCZ2qgHN.js} +1 -1
- package/ui-dist/assets/{index-bUFCSEgv.js → index-CLZXR1k9.js} +1 -1
- package/ui-dist/assets/{index-CJlw4l9L.js → index-CTNfrE1s.js} +1 -1
- package/ui-dist/assets/{index-TmpY6Xh_.js → index-ChNd25s7.js} +1 -1
- package/ui-dist/assets/{index-BRgdHUNR.js → index-CjHiMOEO.js} +1 -1
- package/ui-dist/assets/{index-DrL6pHSJ.js → index-CmeJQpXZ.js} +1 -1
- package/ui-dist/assets/index-CyuYtn7U.css +1 -0
- package/ui-dist/assets/{index-CKKytnOd.js → index-CzZjM8eh.js} +1 -1
- package/ui-dist/assets/{index-Dceamza_.js → index-D-6T0Aft.js} +1 -1
- package/ui-dist/assets/{index-CjDqB1SX.js → index-DG_7V1pG.js} +1 -1
- package/ui-dist/assets/index-DIKcgxCt.js +676 -0
- package/ui-dist/assets/{index-BKVeJ3Jh.js → index-DiUerHmi.js} +1 -1
- package/ui-dist/assets/{index-C-nKjFZh.js → index-NZ7v5Fc2.js} +1 -1
- package/ui-dist/assets/{index-CvPPuNs1.js → index-Py70EvBg.js} +1 -1
- package/ui-dist/assets/{index-LHkZdu0h.js → index-R3aYn0qM.js} +1 -1
- package/ui-dist/assets/{index-BT8b79Bd.js → index-YXpm7gW-.js} +1 -1
- package/ui-dist/assets/{index-CemenVOf.js → index-n4q3gzki.js} +1 -1
- package/ui-dist/assets/{index-D3ODiUup.js → index-vlZ3GoYY.js} +1 -1
- package/ui-dist/assets/{infoDiagram-FWYZ7A6U-C7_90qGq.js → infoDiagram-FWYZ7A6U-jdi5ciVg.js} +1 -1
- package/ui-dist/assets/{ishikawaDiagram-FXEZZL3T-T-nPPdTt.js → ishikawaDiagram-FXEZZL3T-CMz5VvYp.js} +1 -1
- package/ui-dist/assets/{journeyDiagram-5HDEW3XC-C3hqQN0R.js → journeyDiagram-5HDEW3XC-DK6dk2R-.js} +1 -1
- package/ui-dist/assets/{kanban-definition-HUTT4EX6-CPoD9DUm.js → kanban-definition-HUTT4EX6-DX5cZL-D.js} +1 -1
- package/ui-dist/assets/{linear-D-S8yr3Z.js → linear-CI96ABKp.js} +1 -1
- package/ui-dist/assets/{mermaid.core-DhvH8shK.js → mermaid.core-DnsEVfbd.js} +4 -4
- package/ui-dist/assets/{mindmap-definition-LN4V7U3C-CxCIKrKo.js → mindmap-definition-LN4V7U3C-BdDCY7hf.js} +1 -1
- package/ui-dist/assets/{pegDiagram-2B236MQR-D4G-PC6N.js → pegDiagram-2B236MQR-DuY4TF-f.js} +1 -1
- package/ui-dist/assets/{pieDiagram-ENE6RG2P-N4X8UvqY.js → pieDiagram-ENE6RG2P-bgFw7dm3.js} +1 -1
- package/ui-dist/assets/{quadrantDiagram-ABIIQ3AL-CZohoDln.js → quadrantDiagram-ABIIQ3AL-BCZVWyWS.js} +1 -1
- package/ui-dist/assets/{railroadDiagram-RFXS5EU6-fMGQVhwF.js → railroadDiagram-RFXS5EU6-D9V5_Cgc.js} +1 -1
- package/ui-dist/assets/{requirementDiagram-TGXJPOKE-CK0IuUmk.js → requirementDiagram-TGXJPOKE-CItsya2r.js} +1 -1
- package/ui-dist/assets/{sankeyDiagram-HTMAVEWB-pSfb3CqG.js → sankeyDiagram-HTMAVEWB-Cz9IHsZO.js} +1 -1
- package/ui-dist/assets/{sequenceDiagram-DBY2YBRQ-CbhkqIeq.js → sequenceDiagram-DBY2YBRQ-hMSqTiwK.js} +1 -1
- package/ui-dist/assets/{sizeCapture-X5ZJPWSS-B6vqqPx-.js → sizeCapture-X5ZJPWSS-0g9M5wMf.js} +1 -1
- package/ui-dist/assets/{stateDiagram-2N3HPSRC-DIJf9h5C.js → stateDiagram-2N3HPSRC-F8_YhgWl.js} +1 -1
- package/ui-dist/assets/stateDiagram-v2-6OUMAXLB-fZImUBtq.js +1 -0
- package/ui-dist/assets/{swimlanes-5IMT3BWC-DIX_LV8O.js → swimlanes-5IMT3BWC-CCZqPbpE.js} +2 -2
- package/ui-dist/assets/swimlanesDiagram-G3AALYLV-9C1UT51R.js +8 -0
- package/ui-dist/assets/{timeline-definition-FHXFAJF6-CD-Pxb_g.js → timeline-definition-FHXFAJF6-BeUIr18x.js} +1 -1
- package/ui-dist/assets/{vennDiagram-L72KCM5P-BAZsWL0R.js → vennDiagram-L72KCM5P-D3d5h_G4.js} +1 -1
- package/ui-dist/assets/{wardleyDiagram-EHGQE667-CnNyfwX5.js → wardleyDiagram-EHGQE667-Bzt-As9A.js} +1 -1
- package/ui-dist/assets/{xychartDiagram-FW5EYKEG-j9YgsD3Y.js → xychartDiagram-FW5EYKEG-C2mly0xf.js} +1 -1
- package/ui-dist/fonts/InterVariable-Italic.woff2 +0 -0
- package/ui-dist/fonts/InterVariable.woff2 +0 -0
- package/ui-dist/fonts/NOTICE.md +17 -0
- package/ui-dist/index.html +2 -2
- package/ui-dist/locales/en/common.json +1142 -5
- package/ui-dist/locales/zh-CN/common.json +1140 -3
- package/ui-dist/assets/channel-CsiI2LYw.js +0 -1
- package/ui-dist/assets/classDiagram-OUVF2IWQ-CBn1Jqo8.js +0 -1
- package/ui-dist/assets/classDiagram-v2-EOCWNBFH-CBn1Jqo8.js +0 -1
- package/ui-dist/assets/index-BsL8K1vL.css +0 -1
- package/ui-dist/assets/index-D__fTsb2.js +0 -643
- package/ui-dist/assets/stateDiagram-v2-6OUMAXLB-DpwLD7YL.js +0 -1
- package/ui-dist/assets/swimlanesDiagram-G3AALYLV-Cklauutg.js +0 -8
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import multer from "multer";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
|
5
|
+
import { agents, assets, caseAttachments, caseDocuments, caseEvents, caseIssueLinks, caseLabels, cases, companies, documents, documentRevisions, issues, labels, projects, } from "@penclipai/db";
|
|
6
|
+
import { createDocumentAnnotationCommentSchema, createDocumentAnnotationThreadSchema, updateDocumentAnnotationThreadSchema, isUuidLike, } from "@penclipai/shared";
|
|
7
|
+
import { normalizeContentType } from "../attachment-types.js";
|
|
8
|
+
import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
|
9
|
+
import { validate } from "../middleware/validate.js";
|
|
10
|
+
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
11
|
+
import { documentAnnotationService, logActivity } from "../services/index.js";
|
|
12
|
+
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
|
13
|
+
const CASE_STATUSES = ["draft", "in_progress", "in_review", "approved", "done", "cancelled"];
|
|
14
|
+
const CASE_LINK_ROLES = ["origin", "work", "reference"];
|
|
15
|
+
const DEFAULT_EVENTS_LIMIT = 100;
|
|
16
|
+
const MAX_EVENTS_LIMIT = 500;
|
|
17
|
+
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
|
18
|
+
const caseStatusSchema = z.enum(CASE_STATUSES);
|
|
19
|
+
const caseTypeSchema = z.string().trim().min(1).max(120);
|
|
20
|
+
const caseKeySchema = z.string().trim().min(1).max(512);
|
|
21
|
+
const documentKeySchema = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9_.:-]+$/);
|
|
22
|
+
const createCaseSchema = z.object({
|
|
23
|
+
projectId: z.string().uuid().nullable().optional(),
|
|
24
|
+
caseType: caseTypeSchema,
|
|
25
|
+
key: caseKeySchema.nullable().optional(),
|
|
26
|
+
title: z.string().trim().min(1).max(500),
|
|
27
|
+
summary: z.string().max(8_000).nullable().optional(),
|
|
28
|
+
status: caseStatusSchema.optional(),
|
|
29
|
+
fields: jsonObjectSchema.optional(),
|
|
30
|
+
parentCaseId: z.string().uuid().nullable().optional(),
|
|
31
|
+
}).strict();
|
|
32
|
+
const patchCaseSchema = z.object({
|
|
33
|
+
projectId: z.string().uuid().nullable().optional(),
|
|
34
|
+
title: z.string().trim().min(1).max(500).optional(),
|
|
35
|
+
summary: z.string().max(8_000).nullable().optional(),
|
|
36
|
+
status: caseStatusSchema.optional(),
|
|
37
|
+
fields: jsonObjectSchema.optional(),
|
|
38
|
+
parentCaseId: z.string().uuid().nullable().optional(),
|
|
39
|
+
labels: z.array(z.string().uuid()).max(100).optional(),
|
|
40
|
+
labelIds: z.array(z.string().uuid()).max(100).optional(),
|
|
41
|
+
}).strict();
|
|
42
|
+
const createIssueLinkSchema = z.object({
|
|
43
|
+
issueId: z.string().uuid(),
|
|
44
|
+
role: z.enum(CASE_LINK_ROLES),
|
|
45
|
+
}).strict();
|
|
46
|
+
const upsertCaseDocumentSchema = z.object({
|
|
47
|
+
title: z.string().trim().min(1).max(200).optional(),
|
|
48
|
+
format: z.string().trim().min(1).max(80).optional().default("markdown"),
|
|
49
|
+
body: z.string().max(200_000),
|
|
50
|
+
changeSummary: z.string().trim().max(1_000).nullable().optional(),
|
|
51
|
+
baseRevisionId: z.string().uuid().nullable().optional(),
|
|
52
|
+
}).strict();
|
|
53
|
+
const queryListParamSchema = z.union([z.string(), z.array(z.string())]).optional();
|
|
54
|
+
const listCasesQuerySchema = z.object({
|
|
55
|
+
type: z.string().trim().min(1).max(120).optional(),
|
|
56
|
+
types: queryListParamSchema,
|
|
57
|
+
status: z.string().trim().min(1).max(120).optional(),
|
|
58
|
+
statuses: queryListParamSchema,
|
|
59
|
+
project: z.string().uuid().optional(),
|
|
60
|
+
projectId: z.string().uuid().optional(),
|
|
61
|
+
projectIds: queryListParamSchema,
|
|
62
|
+
includeNoProject: z.enum(["true", "false", "1", "0"]).optional(),
|
|
63
|
+
label: z.string().uuid().optional(),
|
|
64
|
+
labelId: z.string().uuid().optional(),
|
|
65
|
+
parent: z.string().uuid().optional(),
|
|
66
|
+
q: z.string().trim().min(1).max(200).optional(),
|
|
67
|
+
includeAncestors: z.enum(["true", "false", "1", "0"]).optional(),
|
|
68
|
+
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
|
|
69
|
+
}).strict();
|
|
70
|
+
const listEventsQuerySchema = z.object({
|
|
71
|
+
limit: z.coerce.number().int().min(1).max(MAX_EVENTS_LIMIT).optional().default(DEFAULT_EVENTS_LIMIT),
|
|
72
|
+
}).strict();
|
|
73
|
+
function eventActorValues(actor) {
|
|
74
|
+
return {
|
|
75
|
+
actorType: actor.actorType,
|
|
76
|
+
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
77
|
+
actorAgentId: actor.agentId,
|
|
78
|
+
runId: actor.runId && isUuidLike(actor.runId) ? actor.runId : null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function assertCasesEnabled(db) {
|
|
82
|
+
const experimental = await instanceSettingsService(db).getExperimental();
|
|
83
|
+
if (!experimental.enableCases) {
|
|
84
|
+
throw forbidden("Cases are disabled");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function lockCaseUpsertKey(db, input) {
|
|
88
|
+
const lockKey = `paperclip:case-upsert:${input.companyId}:${input.caseType}:${input.key ?? "<null>"}`;
|
|
89
|
+
await db.execute(sql `select pg_advisory_xact_lock(hashtext(${lockKey}))`);
|
|
90
|
+
}
|
|
91
|
+
async function lockCaseDocumentKey(db, input) {
|
|
92
|
+
const lockKey = `paperclip:case-document:${input.companyId}:${input.caseId}:${input.key}`;
|
|
93
|
+
await db.execute(sql `select pg_advisory_xact_lock(hashtext(${lockKey}))`);
|
|
94
|
+
}
|
|
95
|
+
async function lockCaseLabels(db, input) {
|
|
96
|
+
const lockKey = `paperclip:case-labels:${input.companyId}:${input.caseId}`;
|
|
97
|
+
await db.execute(sql `select pg_advisory_xact_lock(hashtext(${lockKey}))`);
|
|
98
|
+
}
|
|
99
|
+
function parseDocumentKey(raw) {
|
|
100
|
+
const parsed = documentKeySchema.safeParse(raw);
|
|
101
|
+
if (!parsed.success)
|
|
102
|
+
throw badRequest("Invalid document key", parsed.error.issues);
|
|
103
|
+
return parsed.data;
|
|
104
|
+
}
|
|
105
|
+
function parseBooleanQuery(value) {
|
|
106
|
+
return value === true || value === "true" || value === "1";
|
|
107
|
+
}
|
|
108
|
+
function parseQueryList(value) {
|
|
109
|
+
const values = Array.isArray(value) ? value : value ? [value] : [];
|
|
110
|
+
return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
|
|
111
|
+
}
|
|
112
|
+
function annotationActorInput(req) {
|
|
113
|
+
const actor = getActorInfo(req);
|
|
114
|
+
return {
|
|
115
|
+
actor,
|
|
116
|
+
annotationActor: {
|
|
117
|
+
actorType: actor.actorType,
|
|
118
|
+
actorId: actor.actorId,
|
|
119
|
+
agentId: actor.agentId,
|
|
120
|
+
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
121
|
+
runId: actor.runId,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
async function loadCaseByIdOrIdentifier(db, idOrIdentifier, companyIds) {
|
|
126
|
+
if (companyIds && companyIds.length === 0)
|
|
127
|
+
return null;
|
|
128
|
+
const normalizedIdentifier = idOrIdentifier.trim().toUpperCase();
|
|
129
|
+
const identityWhere = isUuidLike(idOrIdentifier)
|
|
130
|
+
? or(eq(cases.id, idOrIdentifier), eq(cases.identifier, normalizedIdentifier))
|
|
131
|
+
: eq(cases.identifier, normalizedIdentifier);
|
|
132
|
+
const where = companyIds
|
|
133
|
+
? and(identityWhere, inArray(cases.companyId, companyIds))
|
|
134
|
+
: identityWhere;
|
|
135
|
+
return db.select().from(cases).where(where).limit(1).then((rows) => rows[0] ?? null);
|
|
136
|
+
}
|
|
137
|
+
async function loadIssueByIdOrIdentifier(db, idOrIdentifier, companyIds) {
|
|
138
|
+
if (companyIds && companyIds.length === 0)
|
|
139
|
+
return null;
|
|
140
|
+
const normalizedIdentifier = idOrIdentifier.trim().toUpperCase();
|
|
141
|
+
const identityWhere = isUuidLike(idOrIdentifier)
|
|
142
|
+
? or(eq(issues.id, idOrIdentifier), eq(issues.identifier, normalizedIdentifier))
|
|
143
|
+
: eq(issues.identifier, normalizedIdentifier);
|
|
144
|
+
const where = companyIds
|
|
145
|
+
? and(identityWhere, inArray(issues.companyId, companyIds))
|
|
146
|
+
: identityWhere;
|
|
147
|
+
return db
|
|
148
|
+
.select({ id: issues.id, companyId: issues.companyId })
|
|
149
|
+
.from(issues)
|
|
150
|
+
.where(where)
|
|
151
|
+
.limit(1)
|
|
152
|
+
.then((rows) => rows[0] ?? null);
|
|
153
|
+
}
|
|
154
|
+
function caseLookupCompanyIds(req) {
|
|
155
|
+
if (req.actor.type === "agent")
|
|
156
|
+
return req.actor.companyId ? [req.actor.companyId] : [];
|
|
157
|
+
if (req.actor.type === "board" && req.actor.source === "local_implicit")
|
|
158
|
+
return undefined;
|
|
159
|
+
if (req.actor.type === "board" && Array.isArray(req.actor.companyIds) && req.actor.companyIds.length > 0) {
|
|
160
|
+
return req.actor.companyIds;
|
|
161
|
+
}
|
|
162
|
+
if (req.actor.type === "board" && req.actor.isInstanceAdmin)
|
|
163
|
+
return undefined;
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
async function assertCaseAccess(db, req, idOrIdentifier) {
|
|
167
|
+
const row = await loadCaseByIdOrIdentifier(db, idOrIdentifier, caseLookupCompanyIds(req));
|
|
168
|
+
if (!row)
|
|
169
|
+
throw notFound("Case not found");
|
|
170
|
+
assertCompanyAccess(req, row.companyId);
|
|
171
|
+
return row;
|
|
172
|
+
}
|
|
173
|
+
// The pipelines feature registers its own /cases/:caseId routes after this
|
|
174
|
+
// router. On paths both features share, return null (caller falls through via
|
|
175
|
+
// next()) when the id is not a new-Cases row so pipeline case requests still
|
|
176
|
+
// reach their handler regardless of the enableCases flag.
|
|
177
|
+
async function resolveSharedPathCase(db, req, idOrIdentifier) {
|
|
178
|
+
const companyIds = caseLookupCompanyIds(req);
|
|
179
|
+
const row = await loadCaseByIdOrIdentifier(db, idOrIdentifier, companyIds);
|
|
180
|
+
if (!row)
|
|
181
|
+
return null;
|
|
182
|
+
await assertCasesEnabled(db);
|
|
183
|
+
assertCompanyAccess(req, row.companyId);
|
|
184
|
+
return row;
|
|
185
|
+
}
|
|
186
|
+
async function assertProjectBelongsToCompany(db, input) {
|
|
187
|
+
if (!input.projectId)
|
|
188
|
+
return;
|
|
189
|
+
const row = await db
|
|
190
|
+
.select({ id: projects.id })
|
|
191
|
+
.from(projects)
|
|
192
|
+
.where(and(eq(projects.id, input.projectId), eq(projects.companyId, input.companyId)))
|
|
193
|
+
.limit(1)
|
|
194
|
+
.then((rows) => rows[0] ?? null);
|
|
195
|
+
if (!row)
|
|
196
|
+
throw unprocessable("Project does not belong to company");
|
|
197
|
+
}
|
|
198
|
+
async function assertParentCaseBelongsToCompany(db, input) {
|
|
199
|
+
if (!input.parentCaseId)
|
|
200
|
+
return;
|
|
201
|
+
if (input.caseId && input.parentCaseId === input.caseId) {
|
|
202
|
+
throw unprocessable("A case cannot be its own parent");
|
|
203
|
+
}
|
|
204
|
+
const row = await db
|
|
205
|
+
.select({ id: cases.id })
|
|
206
|
+
.from(cases)
|
|
207
|
+
.where(and(eq(cases.id, input.parentCaseId), eq(cases.companyId, input.companyId)))
|
|
208
|
+
.limit(1)
|
|
209
|
+
.then((rows) => rows[0] ?? null);
|
|
210
|
+
if (!row)
|
|
211
|
+
throw unprocessable("Parent case does not belong to company");
|
|
212
|
+
}
|
|
213
|
+
async function assertLabelsBelongToCompany(db, companyId, labelIds) {
|
|
214
|
+
if (labelIds.length === 0)
|
|
215
|
+
return;
|
|
216
|
+
const uniqueIds = [...new Set(labelIds)];
|
|
217
|
+
const rows = await db
|
|
218
|
+
.select({ id: labels.id })
|
|
219
|
+
.from(labels)
|
|
220
|
+
.where(and(eq(labels.companyId, companyId), inArray(labels.id, uniqueIds)));
|
|
221
|
+
if (rows.length !== uniqueIds.length) {
|
|
222
|
+
throw unprocessable("One or more labels do not belong to company");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function insertCaseEvent(db, input) {
|
|
226
|
+
const now = new Date();
|
|
227
|
+
const [event] = await db.insert(caseEvents).values({
|
|
228
|
+
companyId: input.companyId,
|
|
229
|
+
caseId: input.caseId,
|
|
230
|
+
kind: input.kind,
|
|
231
|
+
...eventActorValues(input.actor),
|
|
232
|
+
payload: input.payload ?? {},
|
|
233
|
+
createdAt: now,
|
|
234
|
+
updatedAt: now,
|
|
235
|
+
}).returning();
|
|
236
|
+
return event;
|
|
237
|
+
}
|
|
238
|
+
async function resolveIssueForRun(db, companyId, runId) {
|
|
239
|
+
if (!runId || !isUuidLike(runId))
|
|
240
|
+
return null;
|
|
241
|
+
return db
|
|
242
|
+
.select({ id: issues.id })
|
|
243
|
+
.from(issues)
|
|
244
|
+
.where(and(eq(issues.companyId, companyId), or(eq(issues.executionRunId, runId), eq(issues.checkoutRunId, runId), eq(issues.originRunId, runId))))
|
|
245
|
+
.orderBy(desc(issues.updatedAt), desc(issues.createdAt))
|
|
246
|
+
.limit(1)
|
|
247
|
+
.then((rows) => rows[0] ?? null);
|
|
248
|
+
}
|
|
249
|
+
/** Batch resolve agent display names for a set of agent ids. */
|
|
250
|
+
async function resolveAgentNames(db, agentIds) {
|
|
251
|
+
const valid = [...new Set(agentIds.filter((id) => !!id))];
|
|
252
|
+
if (valid.length === 0)
|
|
253
|
+
return new Map();
|
|
254
|
+
const rows = await db
|
|
255
|
+
.select({ id: agents.id, name: agents.name })
|
|
256
|
+
.from(agents)
|
|
257
|
+
.where(inArray(agents.id, valid));
|
|
258
|
+
return new Map(rows.map((row) => [row.id, row.name]));
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Batch resolve run → issue attribution. Mirrors resolveIssueForRun's precedence
|
|
262
|
+
* (latest-updated issue whose execution/checkout/origin run matches), but for a
|
|
263
|
+
* whole set of runs at once so the activity feed / revisions rail avoid N+1s.
|
|
264
|
+
*/
|
|
265
|
+
async function resolveIssuesForRuns(db, companyId, runIds) {
|
|
266
|
+
const valid = [...new Set(runIds.filter((id) => !!id && isUuidLike(id)))];
|
|
267
|
+
const map = new Map();
|
|
268
|
+
if (valid.length === 0)
|
|
269
|
+
return map;
|
|
270
|
+
const rows = await db
|
|
271
|
+
.select({
|
|
272
|
+
id: issues.id,
|
|
273
|
+
identifier: issues.identifier,
|
|
274
|
+
title: issues.title,
|
|
275
|
+
status: issues.status,
|
|
276
|
+
executionRunId: issues.executionRunId,
|
|
277
|
+
checkoutRunId: issues.checkoutRunId,
|
|
278
|
+
originRunId: issues.originRunId,
|
|
279
|
+
updatedAt: issues.updatedAt,
|
|
280
|
+
createdAt: issues.createdAt,
|
|
281
|
+
})
|
|
282
|
+
.from(issues)
|
|
283
|
+
.where(and(eq(issues.companyId, companyId), or(inArray(issues.executionRunId, valid), inArray(issues.checkoutRunId, valid), inArray(issues.originRunId, valid))))
|
|
284
|
+
.orderBy(desc(issues.updatedAt), desc(issues.createdAt));
|
|
285
|
+
for (const runId of valid) {
|
|
286
|
+
const match = rows.find((row) => row.executionRunId === runId || row.checkoutRunId === runId || row.originRunId === runId);
|
|
287
|
+
if (match) {
|
|
288
|
+
map.set(runId, { id: match.id, identifier: match.identifier ?? match.id, title: match.title, status: match.status });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return map;
|
|
292
|
+
}
|
|
293
|
+
function payloadIssueIdForEvent(kind, payload) {
|
|
294
|
+
if (kind !== "issue_linked" && kind !== "issue_unlinked")
|
|
295
|
+
return null;
|
|
296
|
+
const issueId = payload?.issueId;
|
|
297
|
+
return typeof issueId === "string" && isUuidLike(issueId) ? issueId : null;
|
|
298
|
+
}
|
|
299
|
+
async function resolveIssuesByIds(db, companyId, issueIds) {
|
|
300
|
+
const valid = [...new Set(issueIds.filter((id) => !!id && isUuidLike(id)))];
|
|
301
|
+
const map = new Map();
|
|
302
|
+
if (valid.length === 0)
|
|
303
|
+
return map;
|
|
304
|
+
const rows = await db
|
|
305
|
+
.select({
|
|
306
|
+
id: issues.id,
|
|
307
|
+
identifier: issues.identifier,
|
|
308
|
+
title: issues.title,
|
|
309
|
+
status: issues.status,
|
|
310
|
+
})
|
|
311
|
+
.from(issues)
|
|
312
|
+
.where(and(eq(issues.companyId, companyId), inArray(issues.id, valid)));
|
|
313
|
+
for (const row of rows) {
|
|
314
|
+
map.set(row.id, { id: row.id, identifier: row.identifier ?? row.id, title: row.title, status: row.status });
|
|
315
|
+
}
|
|
316
|
+
return map;
|
|
317
|
+
}
|
|
318
|
+
async function autoLinkRunIssue(db, input) {
|
|
319
|
+
const issue = await resolveIssueForRun(db, input.companyId, input.actor.runId);
|
|
320
|
+
if (!issue)
|
|
321
|
+
return null;
|
|
322
|
+
const now = new Date();
|
|
323
|
+
const [link] = await db.insert(caseIssueLinks).values({
|
|
324
|
+
companyId: input.companyId,
|
|
325
|
+
caseId: input.caseId,
|
|
326
|
+
issueId: issue.id,
|
|
327
|
+
role: input.role,
|
|
328
|
+
createdByRunId: input.actor.runId && isUuidLike(input.actor.runId) ? input.actor.runId : null,
|
|
329
|
+
createdAt: now,
|
|
330
|
+
updatedAt: now,
|
|
331
|
+
}).onConflictDoNothing({
|
|
332
|
+
target: [caseIssueLinks.caseId, caseIssueLinks.issueId],
|
|
333
|
+
}).returning();
|
|
334
|
+
if (!link)
|
|
335
|
+
return null;
|
|
336
|
+
await insertCaseEvent(db, {
|
|
337
|
+
companyId: input.companyId,
|
|
338
|
+
caseId: input.caseId,
|
|
339
|
+
kind: "issue_linked",
|
|
340
|
+
actor: input.actor,
|
|
341
|
+
payload: { issueId: issue.id, role: input.role, autoLinked: true },
|
|
342
|
+
});
|
|
343
|
+
return link;
|
|
344
|
+
}
|
|
345
|
+
async function nextCaseIdentity(db, companyId) {
|
|
346
|
+
await db.execute(sql `select pg_advisory_xact_lock(hashtext(${`paperclip:cases:${companyId}`}))`);
|
|
347
|
+
const [company] = await db
|
|
348
|
+
.select({ issuePrefix: companies.issuePrefix })
|
|
349
|
+
.from(companies)
|
|
350
|
+
.where(eq(companies.id, companyId))
|
|
351
|
+
.limit(1);
|
|
352
|
+
if (!company)
|
|
353
|
+
throw notFound("Company not found");
|
|
354
|
+
const [maxRow] = await db
|
|
355
|
+
.select({ maxNum: sql `coalesce(max(${cases.caseNumber}), 0)` })
|
|
356
|
+
.from(cases)
|
|
357
|
+
.where(eq(cases.companyId, companyId));
|
|
358
|
+
const caseNumber = (maxRow?.maxNum ?? 0) + 1;
|
|
359
|
+
return {
|
|
360
|
+
caseNumber,
|
|
361
|
+
identifier: `${company.issuePrefix.toUpperCase()}-C${caseNumber}`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function completedAtForStatus(status, previous) {
|
|
365
|
+
if (status === "done" || status === "cancelled")
|
|
366
|
+
return previous ?? new Date();
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
export function buildCasePatchUpdateValues(body, caseRow, now) {
|
|
370
|
+
const status = body.status ?? caseRow.status;
|
|
371
|
+
return {
|
|
372
|
+
...(Object.hasOwn(body, "projectId") ? { projectId: body.projectId ?? null } : {}),
|
|
373
|
+
...(body.title !== undefined ? { title: body.title } : {}),
|
|
374
|
+
...(Object.hasOwn(body, "summary") ? { summary: body.summary ?? null } : {}),
|
|
375
|
+
...(body.status !== undefined ? { status, completedAt: completedAtForStatus(status, caseRow.completedAt) } : {}),
|
|
376
|
+
...(body.fields !== undefined ? { fields: body.fields } : {}),
|
|
377
|
+
...(Object.hasOwn(body, "parentCaseId") ? { parentCaseId: body.parentCaseId ?? null } : {}),
|
|
378
|
+
updatedAt: now,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
async function loadCaseDetail(db, row) {
|
|
382
|
+
const [labelRows, linkRows, documentRows, attachmentRows] = await Promise.all([
|
|
383
|
+
db
|
|
384
|
+
.select({ label: labels })
|
|
385
|
+
.from(caseLabels)
|
|
386
|
+
.innerJoin(labels, eq(caseLabels.labelId, labels.id))
|
|
387
|
+
.where(and(eq(caseLabels.companyId, row.companyId), eq(caseLabels.caseId, row.id)))
|
|
388
|
+
.orderBy(asc(labels.name)),
|
|
389
|
+
db
|
|
390
|
+
.select({ link: caseIssueLinks, issue: issues })
|
|
391
|
+
.from(caseIssueLinks)
|
|
392
|
+
.innerJoin(issues, eq(caseIssueLinks.issueId, issues.id))
|
|
393
|
+
.where(and(eq(caseIssueLinks.companyId, row.companyId), eq(caseIssueLinks.caseId, row.id)))
|
|
394
|
+
.orderBy(asc(caseIssueLinks.createdAt)),
|
|
395
|
+
db
|
|
396
|
+
.select({ link: caseDocuments, document: documents })
|
|
397
|
+
.from(caseDocuments)
|
|
398
|
+
.innerJoin(documents, eq(caseDocuments.documentId, documents.id))
|
|
399
|
+
.where(and(eq(caseDocuments.companyId, row.companyId), eq(caseDocuments.caseId, row.id)))
|
|
400
|
+
.orderBy(asc(caseDocuments.key)),
|
|
401
|
+
db
|
|
402
|
+
.select({ link: caseAttachments, asset: assets })
|
|
403
|
+
.from(caseAttachments)
|
|
404
|
+
.innerJoin(assets, eq(caseAttachments.assetId, assets.id))
|
|
405
|
+
.where(and(eq(caseAttachments.companyId, row.companyId), eq(caseAttachments.caseId, row.id)))
|
|
406
|
+
.orderBy(asc(caseAttachments.createdAt)),
|
|
407
|
+
]);
|
|
408
|
+
const parent = row.parentCaseId
|
|
409
|
+
? await db
|
|
410
|
+
.select({
|
|
411
|
+
id: cases.id,
|
|
412
|
+
identifier: cases.identifier,
|
|
413
|
+
title: cases.title,
|
|
414
|
+
caseType: cases.caseType,
|
|
415
|
+
status: cases.status,
|
|
416
|
+
})
|
|
417
|
+
.from(cases)
|
|
418
|
+
.where(eq(cases.id, row.parentCaseId))
|
|
419
|
+
.limit(1)
|
|
420
|
+
.then((rows) => rows[0] ?? null)
|
|
421
|
+
: null;
|
|
422
|
+
return {
|
|
423
|
+
...row,
|
|
424
|
+
parent,
|
|
425
|
+
labels: labelRows.map((item) => item.label),
|
|
426
|
+
issueLinks: linkRows.map((item) => ({
|
|
427
|
+
...item.link,
|
|
428
|
+
issue: {
|
|
429
|
+
id: item.issue.id,
|
|
430
|
+
identifier: item.issue.identifier,
|
|
431
|
+
title: item.issue.title,
|
|
432
|
+
status: item.issue.status,
|
|
433
|
+
},
|
|
434
|
+
})),
|
|
435
|
+
documents: documentRows.map((item) => ({
|
|
436
|
+
key: item.link.key,
|
|
437
|
+
document: item.document,
|
|
438
|
+
})),
|
|
439
|
+
attachments: attachmentRows.map((item) => ({
|
|
440
|
+
id: item.link.id,
|
|
441
|
+
asset: item.asset,
|
|
442
|
+
createdAt: item.link.createdAt,
|
|
443
|
+
updatedAt: item.link.updatedAt,
|
|
444
|
+
})),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
async function loadCaseDocumentLink(db, input) {
|
|
448
|
+
return db
|
|
449
|
+
.select({ link: caseDocuments, document: documents })
|
|
450
|
+
.from(caseDocuments)
|
|
451
|
+
.innerJoin(documents, eq(caseDocuments.documentId, documents.id))
|
|
452
|
+
.where(and(eq(caseDocuments.companyId, input.companyId), eq(caseDocuments.caseId, input.caseId), eq(caseDocuments.key, input.key)))
|
|
453
|
+
.limit(1)
|
|
454
|
+
.then((rows) => rows[0] ?? null);
|
|
455
|
+
}
|
|
456
|
+
async function includeCaseAncestors(db, companyId, baseRows) {
|
|
457
|
+
const baseIds = new Set(baseRows.map((row) => row.id));
|
|
458
|
+
const rowsById = new Map(baseRows.map((row) => [row.id, row]));
|
|
459
|
+
const ancestorRows = [];
|
|
460
|
+
let pending = [...new Set(baseRows
|
|
461
|
+
.map((row) => row.parentCaseId)
|
|
462
|
+
.filter((id) => {
|
|
463
|
+
if (!id)
|
|
464
|
+
return false;
|
|
465
|
+
return !rowsById.has(id);
|
|
466
|
+
}))];
|
|
467
|
+
while (pending.length > 0) {
|
|
468
|
+
const ancestors = await db
|
|
469
|
+
.select()
|
|
470
|
+
.from(cases)
|
|
471
|
+
.where(and(eq(cases.companyId, companyId), inArray(cases.id, pending)));
|
|
472
|
+
const nextPending = new Set();
|
|
473
|
+
for (const row of ancestors) {
|
|
474
|
+
if (rowsById.has(row.id))
|
|
475
|
+
continue;
|
|
476
|
+
rowsById.set(row.id, row);
|
|
477
|
+
ancestorRows.push(row);
|
|
478
|
+
if (row.parentCaseId && !rowsById.has(row.parentCaseId)) {
|
|
479
|
+
nextPending.add(row.parentCaseId);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
pending = [...nextPending];
|
|
483
|
+
}
|
|
484
|
+
return [...baseRows, ...ancestorRows].map((row) => ({
|
|
485
|
+
...row,
|
|
486
|
+
matchesListFilters: baseIds.has(row.id),
|
|
487
|
+
}));
|
|
488
|
+
}
|
|
489
|
+
function caseDocumentResponse(input) {
|
|
490
|
+
return {
|
|
491
|
+
...input.document,
|
|
492
|
+
key: input.key,
|
|
493
|
+
body: input.document.latestBody,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
function singleFileUpload(req, res, maxBytes) {
|
|
497
|
+
const upload = multer({
|
|
498
|
+
storage: multer.memoryStorage(),
|
|
499
|
+
limits: { fileSize: maxBytes, files: 1 },
|
|
500
|
+
}).single("file");
|
|
501
|
+
return new Promise((resolve, reject) => {
|
|
502
|
+
upload(req, res, (err) => {
|
|
503
|
+
if (err)
|
|
504
|
+
reject(err);
|
|
505
|
+
else
|
|
506
|
+
resolve();
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
export function caseRoutes(db, storage) {
|
|
511
|
+
const router = Router();
|
|
512
|
+
const documentAnnotationsSvc = documentAnnotationService(db);
|
|
513
|
+
async function logCaseAnnotationRemaps(input) {
|
|
514
|
+
const remapped = await documentAnnotationsSvc.remapOpenThreadsForCaseDocument({
|
|
515
|
+
caseId: input.caseRow.id,
|
|
516
|
+
key: input.key,
|
|
517
|
+
documentId: input.document.id,
|
|
518
|
+
nextRevisionId: input.document.latestRevisionId,
|
|
519
|
+
nextRevisionNumber: input.document.latestRevisionNumber,
|
|
520
|
+
nextBody: input.body,
|
|
521
|
+
});
|
|
522
|
+
for (const remap of remapped) {
|
|
523
|
+
await logActivity(db, {
|
|
524
|
+
companyId: input.caseRow.companyId,
|
|
525
|
+
actorType: input.actor.actorType,
|
|
526
|
+
actorId: input.actor.actorId,
|
|
527
|
+
agentId: input.actor.agentId,
|
|
528
|
+
runId: input.actor.runId,
|
|
529
|
+
action: "case.document_annotation_remapped",
|
|
530
|
+
entityType: "case",
|
|
531
|
+
entityId: input.caseRow.id,
|
|
532
|
+
details: {
|
|
533
|
+
key: input.key,
|
|
534
|
+
documentKey: input.key,
|
|
535
|
+
documentId: input.document.id,
|
|
536
|
+
threadId: remap.thread.id,
|
|
537
|
+
revisionNumber: input.document.latestRevisionNumber,
|
|
538
|
+
anchorState: remap.thread.anchorState,
|
|
539
|
+
anchorConfidence: remap.thread.anchorConfidence,
|
|
540
|
+
snapshotId: remap.snapshot.id,
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
router.post("/companies/:companyId/cases", validate(createCaseSchema), async (req, res) => {
|
|
546
|
+
await assertCasesEnabled(db);
|
|
547
|
+
const companyId = req.params.companyId;
|
|
548
|
+
assertCompanyAccess(req, companyId);
|
|
549
|
+
const actor = getActorInfo(req);
|
|
550
|
+
const body = req.body;
|
|
551
|
+
const result = await db.transaction(async (tx) => {
|
|
552
|
+
await assertProjectBelongsToCompany(tx, { companyId, projectId: body.projectId ?? null });
|
|
553
|
+
await assertParentCaseBelongsToCompany(tx, { companyId, parentCaseId: body.parentCaseId ?? null });
|
|
554
|
+
await lockCaseUpsertKey(tx, { companyId, caseType: body.caseType, key: body.key });
|
|
555
|
+
const keyFilter = body.key ? eq(cases.key, body.key) : isNull(cases.key);
|
|
556
|
+
const now = new Date();
|
|
557
|
+
const existing = await tx
|
|
558
|
+
.select()
|
|
559
|
+
.from(cases)
|
|
560
|
+
.where(and(eq(cases.companyId, companyId), eq(cases.caseType, body.caseType), keyFilter))
|
|
561
|
+
.limit(1)
|
|
562
|
+
.then((rows) => rows[0] ?? null);
|
|
563
|
+
if (existing) {
|
|
564
|
+
const status = body.status ?? existing.status;
|
|
565
|
+
const [updated] = await tx.update(cases).set({
|
|
566
|
+
projectId: body.projectId ?? existing.projectId,
|
|
567
|
+
title: body.title,
|
|
568
|
+
summary: body.summary ?? existing.summary,
|
|
569
|
+
status,
|
|
570
|
+
fields: body.fields ?? existing.fields,
|
|
571
|
+
parentCaseId: body.parentCaseId ?? existing.parentCaseId,
|
|
572
|
+
completedAt: completedAtForStatus(status, existing.completedAt),
|
|
573
|
+
updatedAt: now,
|
|
574
|
+
}).where(eq(cases.id, existing.id)).returning();
|
|
575
|
+
await insertCaseEvent(tx, {
|
|
576
|
+
companyId,
|
|
577
|
+
caseId: existing.id,
|
|
578
|
+
kind: "updated",
|
|
579
|
+
actor,
|
|
580
|
+
payload: { upsert: true },
|
|
581
|
+
});
|
|
582
|
+
await autoLinkRunIssue(tx, { companyId, caseId: existing.id, actor, role: "origin" });
|
|
583
|
+
return { created: false, row: updated };
|
|
584
|
+
}
|
|
585
|
+
const identity = await nextCaseIdentity(tx, companyId);
|
|
586
|
+
const status = body.status ?? "draft";
|
|
587
|
+
const [created] = await tx.insert(cases).values({
|
|
588
|
+
companyId,
|
|
589
|
+
projectId: body.projectId ?? null,
|
|
590
|
+
...identity,
|
|
591
|
+
caseType: body.caseType,
|
|
592
|
+
key: body.key ?? null,
|
|
593
|
+
title: body.title,
|
|
594
|
+
summary: body.summary ?? null,
|
|
595
|
+
status,
|
|
596
|
+
fields: body.fields ?? {},
|
|
597
|
+
parentCaseId: body.parentCaseId ?? null,
|
|
598
|
+
createdByAgentId: actor.agentId,
|
|
599
|
+
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
600
|
+
completedAt: completedAtForStatus(status),
|
|
601
|
+
createdAt: now,
|
|
602
|
+
updatedAt: now,
|
|
603
|
+
}).returning();
|
|
604
|
+
await insertCaseEvent(tx, {
|
|
605
|
+
companyId,
|
|
606
|
+
caseId: created.id,
|
|
607
|
+
kind: "created",
|
|
608
|
+
actor,
|
|
609
|
+
payload: { caseType: body.caseType, key: body.key ?? null },
|
|
610
|
+
});
|
|
611
|
+
await autoLinkRunIssue(tx, { companyId, caseId: created.id, actor, role: "origin" });
|
|
612
|
+
return { created: true, row: created };
|
|
613
|
+
});
|
|
614
|
+
res.status(result.created ? 201 : 200).json(await loadCaseDetail(db, result.row));
|
|
615
|
+
});
|
|
616
|
+
router.get("/companies/:companyId/cases", async (req, res) => {
|
|
617
|
+
await assertCasesEnabled(db);
|
|
618
|
+
const companyId = req.params.companyId;
|
|
619
|
+
assertCompanyAccess(req, companyId);
|
|
620
|
+
const parsed = listCasesQuerySchema.safeParse(req.query);
|
|
621
|
+
if (!parsed.success)
|
|
622
|
+
throw badRequest("Invalid case list query", parsed.error.issues);
|
|
623
|
+
const query = parsed.data;
|
|
624
|
+
const filters = [eq(cases.companyId, companyId)];
|
|
625
|
+
const typeFilters = parseQueryList(query.types ?? query.type);
|
|
626
|
+
if (typeFilters.length === 1)
|
|
627
|
+
filters.push(eq(cases.caseType, typeFilters[0]));
|
|
628
|
+
else if (typeFilters.length > 1)
|
|
629
|
+
filters.push(inArray(cases.caseType, typeFilters));
|
|
630
|
+
const statusFilters = parseQueryList(query.statuses ?? (query.status === "active" ? undefined : query.status));
|
|
631
|
+
if (query.status === "active" && statusFilters.length === 0) {
|
|
632
|
+
filters.push(sql `${cases.status} not in ('done', 'cancelled')`);
|
|
633
|
+
}
|
|
634
|
+
else if (statusFilters.length > 0) {
|
|
635
|
+
for (const status of statusFilters) {
|
|
636
|
+
if (!CASE_STATUSES.includes(status)) {
|
|
637
|
+
throw badRequest("Invalid case status");
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
filters.push(statusFilters.length === 1 ? eq(cases.status, statusFilters[0]) : inArray(cases.status, statusFilters));
|
|
641
|
+
}
|
|
642
|
+
const projectFilters = parseQueryList(query.projectIds ?? query.projectId ?? query.project);
|
|
643
|
+
for (const projectId of projectFilters) {
|
|
644
|
+
if (!isUuidLike(projectId))
|
|
645
|
+
throw badRequest("Invalid project id");
|
|
646
|
+
}
|
|
647
|
+
const includeNoProject = parseBooleanQuery(query.includeNoProject);
|
|
648
|
+
if (projectFilters.length > 0 && includeNoProject) {
|
|
649
|
+
filters.push(or(inArray(cases.projectId, projectFilters), isNull(cases.projectId)));
|
|
650
|
+
}
|
|
651
|
+
else if (projectFilters.length === 1) {
|
|
652
|
+
filters.push(eq(cases.projectId, projectFilters[0]));
|
|
653
|
+
}
|
|
654
|
+
else if (projectFilters.length > 1) {
|
|
655
|
+
filters.push(inArray(cases.projectId, projectFilters));
|
|
656
|
+
}
|
|
657
|
+
else if (includeNoProject) {
|
|
658
|
+
filters.push(isNull(cases.projectId));
|
|
659
|
+
}
|
|
660
|
+
if (query.parent)
|
|
661
|
+
filters.push(eq(cases.parentCaseId, query.parent));
|
|
662
|
+
const labelId = query.labelId ?? query.label;
|
|
663
|
+
if (labelId) {
|
|
664
|
+
filters.push(sql `${cases.id} in (
|
|
665
|
+
select ${caseLabels.caseId} from ${caseLabels}
|
|
666
|
+
where ${caseLabels.companyId} = ${companyId} and ${caseLabels.labelId} = ${labelId}
|
|
667
|
+
)`);
|
|
668
|
+
}
|
|
669
|
+
if (query.q) {
|
|
670
|
+
const pattern = `%${query.q.replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
|
|
671
|
+
filters.push(or(ilike(cases.identifier, pattern), ilike(cases.title, pattern), ilike(cases.summary, pattern), ilike(cases.key, pattern)));
|
|
672
|
+
}
|
|
673
|
+
const rows = await db
|
|
674
|
+
.select()
|
|
675
|
+
.from(cases)
|
|
676
|
+
.where(and(...filters))
|
|
677
|
+
.orderBy(desc(cases.updatedAt), desc(cases.createdAt))
|
|
678
|
+
.limit(query.limit);
|
|
679
|
+
res.json(parseBooleanQuery(query.includeAncestors) ? await includeCaseAncestors(db, companyId, rows) : rows);
|
|
680
|
+
});
|
|
681
|
+
router.get("/cases/:id/documents/:key", async (req, res, next) => {
|
|
682
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
683
|
+
if (!caseRow)
|
|
684
|
+
return next();
|
|
685
|
+
const key = parseDocumentKey(req.params.key);
|
|
686
|
+
const link = await loadCaseDocumentLink(db, { companyId: caseRow.companyId, caseId: caseRow.id, key });
|
|
687
|
+
if (!link)
|
|
688
|
+
throw notFound("Case document not found");
|
|
689
|
+
res.json(caseDocumentResponse({ key, document: link.document }));
|
|
690
|
+
});
|
|
691
|
+
router.get("/cases/:id/documents/:key/annotations", async (req, res, next) => {
|
|
692
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
693
|
+
if (!caseRow)
|
|
694
|
+
return next();
|
|
695
|
+
const key = parseDocumentKey(req.params.key);
|
|
696
|
+
const status = req.query.status === "resolved" || req.query.status === "all" ? req.query.status : "open";
|
|
697
|
+
const threads = await documentAnnotationsSvc.listThreadsForCaseDocument(caseRow.id, key, {
|
|
698
|
+
status,
|
|
699
|
+
includeComments: parseBooleanQuery(req.query.includeComments),
|
|
700
|
+
});
|
|
701
|
+
res.json(threads);
|
|
702
|
+
});
|
|
703
|
+
router.get("/cases/:id/documents/:key/annotations/:threadId", async (req, res, next) => {
|
|
704
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
705
|
+
if (!caseRow)
|
|
706
|
+
return next();
|
|
707
|
+
const key = parseDocumentKey(req.params.key);
|
|
708
|
+
const thread = await documentAnnotationsSvc.getThreadForCaseDocument(caseRow.id, key, req.params.threadId);
|
|
709
|
+
if (!thread)
|
|
710
|
+
throw notFound("Annotation thread not found");
|
|
711
|
+
res.json(thread);
|
|
712
|
+
});
|
|
713
|
+
router.post("/cases/:id/documents/:key/annotations", validate(createDocumentAnnotationThreadSchema), async (req, res, next) => {
|
|
714
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
715
|
+
if (!caseRow)
|
|
716
|
+
return next();
|
|
717
|
+
const key = parseDocumentKey(req.params.key);
|
|
718
|
+
const { actor, annotationActor } = annotationActorInput(req);
|
|
719
|
+
const thread = await documentAnnotationsSvc.createCaseThread(caseRow.id, key, req.body, annotationActor);
|
|
720
|
+
const firstComment = thread.comments[0];
|
|
721
|
+
await logActivity(db, {
|
|
722
|
+
companyId: caseRow.companyId,
|
|
723
|
+
actorType: actor.actorType,
|
|
724
|
+
actorId: actor.actorId,
|
|
725
|
+
agentId: actor.agentId,
|
|
726
|
+
runId: actor.runId,
|
|
727
|
+
action: "case.document_annotation_thread_created",
|
|
728
|
+
entityType: "case",
|
|
729
|
+
entityId: caseRow.id,
|
|
730
|
+
details: {
|
|
731
|
+
key: thread.documentKey,
|
|
732
|
+
documentKey: thread.documentKey,
|
|
733
|
+
documentId: thread.documentId,
|
|
734
|
+
threadId: thread.id,
|
|
735
|
+
commentId: firstComment?.id ?? null,
|
|
736
|
+
revisionNumber: thread.currentRevisionNumber,
|
|
737
|
+
quote: thread.selectedText.slice(0, 240),
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
res.status(201).json(thread);
|
|
741
|
+
});
|
|
742
|
+
router.post("/cases/:id/documents/:key/annotations/:threadId/comments", validate(createDocumentAnnotationCommentSchema), async (req, res, next) => {
|
|
743
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
744
|
+
if (!caseRow)
|
|
745
|
+
return next();
|
|
746
|
+
const key = parseDocumentKey(req.params.key);
|
|
747
|
+
const { actor, annotationActor } = annotationActorInput(req);
|
|
748
|
+
const comment = await documentAnnotationsSvc.addCaseComment(caseRow.id, key, req.params.threadId, req.body, annotationActor);
|
|
749
|
+
await logActivity(db, {
|
|
750
|
+
companyId: caseRow.companyId,
|
|
751
|
+
actorType: actor.actorType,
|
|
752
|
+
actorId: actor.actorId,
|
|
753
|
+
agentId: actor.agentId,
|
|
754
|
+
runId: actor.runId,
|
|
755
|
+
action: "case.document_annotation_comment_added",
|
|
756
|
+
entityType: "case",
|
|
757
|
+
entityId: caseRow.id,
|
|
758
|
+
details: {
|
|
759
|
+
key,
|
|
760
|
+
documentKey: key,
|
|
761
|
+
threadId: comment.threadId,
|
|
762
|
+
commentId: comment.id,
|
|
763
|
+
bodySnippet: comment.body.slice(0, 120),
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
res.status(201).json(comment);
|
|
767
|
+
});
|
|
768
|
+
router.patch("/cases/:id/documents/:key/annotations/:threadId", validate(updateDocumentAnnotationThreadSchema), async (req, res, next) => {
|
|
769
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
770
|
+
if (!caseRow)
|
|
771
|
+
return next();
|
|
772
|
+
const key = parseDocumentKey(req.params.key);
|
|
773
|
+
const { actor, annotationActor } = annotationActorInput(req);
|
|
774
|
+
const thread = await documentAnnotationsSvc.updateCaseThread(caseRow.id, key, req.params.threadId, req.body, annotationActor);
|
|
775
|
+
await logActivity(db, {
|
|
776
|
+
companyId: caseRow.companyId,
|
|
777
|
+
actorType: actor.actorType,
|
|
778
|
+
actorId: actor.actorId,
|
|
779
|
+
agentId: actor.agentId,
|
|
780
|
+
runId: actor.runId,
|
|
781
|
+
action: thread.status === "resolved"
|
|
782
|
+
? "case.document_annotation_thread_resolved"
|
|
783
|
+
: "case.document_annotation_thread_reopened",
|
|
784
|
+
entityType: "case",
|
|
785
|
+
entityId: caseRow.id,
|
|
786
|
+
details: {
|
|
787
|
+
key: thread.documentKey,
|
|
788
|
+
documentKey: thread.documentKey,
|
|
789
|
+
documentId: thread.documentId,
|
|
790
|
+
threadId: thread.id,
|
|
791
|
+
status: thread.status,
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
res.json(thread);
|
|
795
|
+
});
|
|
796
|
+
router.put("/cases/:id/documents/:key", async (req, res, next) => {
|
|
797
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
798
|
+
if (!caseRow)
|
|
799
|
+
return next();
|
|
800
|
+
const key = parseDocumentKey(req.params.key);
|
|
801
|
+
const actor = getActorInfo(req);
|
|
802
|
+
const body = upsertCaseDocumentSchema.parse(req.body);
|
|
803
|
+
const result = await db.transaction(async (tx) => {
|
|
804
|
+
await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key });
|
|
805
|
+
const existing = await tx
|
|
806
|
+
.select({ link: caseDocuments, document: documents, revision: documentRevisions })
|
|
807
|
+
.from(caseDocuments)
|
|
808
|
+
.innerJoin(documents, eq(caseDocuments.documentId, documents.id))
|
|
809
|
+
.leftJoin(documentRevisions, eq(documents.latestRevisionId, documentRevisions.id))
|
|
810
|
+
.where(and(eq(caseDocuments.companyId, caseRow.companyId), eq(caseDocuments.caseId, caseRow.id), eq(caseDocuments.key, key)))
|
|
811
|
+
.limit(1)
|
|
812
|
+
.then((rows) => rows[0] ?? null);
|
|
813
|
+
if (existing?.document.lockedAt) {
|
|
814
|
+
throw conflict("Document is locked", {
|
|
815
|
+
key,
|
|
816
|
+
documentId: existing.document.id,
|
|
817
|
+
lockedAt: existing.document.lockedAt,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
if (existing && !body.baseRevisionId) {
|
|
821
|
+
throw conflict("Case document update requires baseRevisionId", {
|
|
822
|
+
code: "stale_base_revision",
|
|
823
|
+
latestRevisionId: existing.document.latestRevisionId,
|
|
824
|
+
latestRevisionNumber: existing.document.latestRevisionNumber,
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
if (existing && body.baseRevisionId !== existing.document.latestRevisionId) {
|
|
828
|
+
throw conflict("Case document was updated by someone else", {
|
|
829
|
+
code: "stale_base_revision",
|
|
830
|
+
latestRevisionId: existing.document.latestRevisionId,
|
|
831
|
+
latestRevisionNumber: existing.document.latestRevisionNumber,
|
|
832
|
+
latestRevision: existing.revision,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
if (!existing && body.baseRevisionId) {
|
|
836
|
+
throw conflict("Case document does not exist yet", {
|
|
837
|
+
code: "stale_base_revision",
|
|
838
|
+
latestRevisionId: null,
|
|
839
|
+
latestRevisionNumber: null,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
const now = new Date();
|
|
843
|
+
const [document] = existing
|
|
844
|
+
? await tx.update(documents).set({
|
|
845
|
+
title: body.title ?? existing.document.title,
|
|
846
|
+
format: body.format,
|
|
847
|
+
updatedAt: now,
|
|
848
|
+
updatedByAgentId: actor.agentId,
|
|
849
|
+
updatedByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
850
|
+
}).where(eq(documents.id, existing.document.id)).returning()
|
|
851
|
+
: await tx.insert(documents).values({
|
|
852
|
+
companyId: caseRow.companyId,
|
|
853
|
+
title: body.title ?? key,
|
|
854
|
+
format: body.format,
|
|
855
|
+
latestBody: body.body,
|
|
856
|
+
latestRevisionNumber: 1,
|
|
857
|
+
createdByAgentId: actor.agentId,
|
|
858
|
+
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
859
|
+
updatedByAgentId: actor.agentId,
|
|
860
|
+
updatedByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
861
|
+
createdAt: now,
|
|
862
|
+
updatedAt: now,
|
|
863
|
+
}).returning();
|
|
864
|
+
const nextRevisionNumber = existing ? existing.document.latestRevisionNumber + 1 : 1;
|
|
865
|
+
const [revision] = await tx.insert(documentRevisions).values({
|
|
866
|
+
companyId: caseRow.companyId,
|
|
867
|
+
documentId: document.id,
|
|
868
|
+
revisionNumber: nextRevisionNumber,
|
|
869
|
+
title: body.title ?? document.title,
|
|
870
|
+
format: body.format,
|
|
871
|
+
body: body.body,
|
|
872
|
+
changeSummary: body.changeSummary ?? null,
|
|
873
|
+
createdByAgentId: actor.agentId,
|
|
874
|
+
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
875
|
+
createdByRunId: actor.runId && isUuidLike(actor.runId) ? actor.runId : null,
|
|
876
|
+
createdAt: now,
|
|
877
|
+
}).returning();
|
|
878
|
+
await tx.update(documents).set({
|
|
879
|
+
title: body.title ?? document.title,
|
|
880
|
+
format: body.format,
|
|
881
|
+
latestBody: body.body,
|
|
882
|
+
latestRevisionId: revision.id,
|
|
883
|
+
latestRevisionNumber: revision.revisionNumber,
|
|
884
|
+
updatedByAgentId: actor.agentId,
|
|
885
|
+
updatedByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
886
|
+
updatedAt: now,
|
|
887
|
+
}).where(eq(documents.id, document.id));
|
|
888
|
+
if (!existing) {
|
|
889
|
+
await tx.insert(caseDocuments).values({
|
|
890
|
+
companyId: caseRow.companyId,
|
|
891
|
+
caseId: caseRow.id,
|
|
892
|
+
documentId: document.id,
|
|
893
|
+
key,
|
|
894
|
+
createdAt: now,
|
|
895
|
+
updatedAt: now,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
await tx.update(caseDocuments).set({ updatedAt: now }).where(eq(caseDocuments.documentId, document.id));
|
|
900
|
+
}
|
|
901
|
+
await insertCaseEvent(tx, {
|
|
902
|
+
companyId: caseRow.companyId,
|
|
903
|
+
caseId: caseRow.id,
|
|
904
|
+
kind: "document_revised",
|
|
905
|
+
actor,
|
|
906
|
+
payload: { key, documentId: document.id, revisionId: revision.id, revisionNumber: revision.revisionNumber },
|
|
907
|
+
});
|
|
908
|
+
await autoLinkRunIssue(tx, { companyId: caseRow.companyId, caseId: caseRow.id, actor, role: "work" });
|
|
909
|
+
return {
|
|
910
|
+
document: {
|
|
911
|
+
...document,
|
|
912
|
+
title: body.title ?? document.title,
|
|
913
|
+
format: body.format,
|
|
914
|
+
latestBody: body.body,
|
|
915
|
+
latestRevisionId: revision.id,
|
|
916
|
+
latestRevisionNumber: revision.revisionNumber,
|
|
917
|
+
updatedAt: now,
|
|
918
|
+
},
|
|
919
|
+
revision,
|
|
920
|
+
};
|
|
921
|
+
});
|
|
922
|
+
await logCaseAnnotationRemaps({
|
|
923
|
+
caseRow,
|
|
924
|
+
key,
|
|
925
|
+
document: result.document,
|
|
926
|
+
body: result.document.latestBody,
|
|
927
|
+
actor,
|
|
928
|
+
});
|
|
929
|
+
res.json(result);
|
|
930
|
+
});
|
|
931
|
+
router.post("/cases/:id/documents/:key/lock", async (req, res, next) => {
|
|
932
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
933
|
+
if (!caseRow)
|
|
934
|
+
return next();
|
|
935
|
+
const key = parseDocumentKey(req.params.key);
|
|
936
|
+
const actor = getActorInfo(req);
|
|
937
|
+
const result = await db.transaction(async (tx) => {
|
|
938
|
+
await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key });
|
|
939
|
+
const link = await loadCaseDocumentLink(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key });
|
|
940
|
+
if (!link)
|
|
941
|
+
throw notFound("Case document not found");
|
|
942
|
+
if (link.document.lockedAt)
|
|
943
|
+
return caseDocumentResponse({ key, document: link.document });
|
|
944
|
+
const now = new Date();
|
|
945
|
+
const [document] = await tx.update(documents).set({
|
|
946
|
+
lockedAt: now,
|
|
947
|
+
lockedByAgentId: actor.agentId,
|
|
948
|
+
lockedByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
949
|
+
updatedAt: now,
|
|
950
|
+
}).where(eq(documents.id, link.document.id)).returning();
|
|
951
|
+
await tx.update(caseDocuments).set({ updatedAt: now }).where(eq(caseDocuments.documentId, link.document.id));
|
|
952
|
+
return caseDocumentResponse({ key, document: document });
|
|
953
|
+
});
|
|
954
|
+
res.json(result);
|
|
955
|
+
});
|
|
956
|
+
router.post("/cases/:id/documents/:key/unlock", async (req, res, next) => {
|
|
957
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
958
|
+
if (!caseRow)
|
|
959
|
+
return next();
|
|
960
|
+
const key = parseDocumentKey(req.params.key);
|
|
961
|
+
const result = await db.transaction(async (tx) => {
|
|
962
|
+
await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key });
|
|
963
|
+
const link = await loadCaseDocumentLink(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key });
|
|
964
|
+
if (!link)
|
|
965
|
+
throw notFound("Case document not found");
|
|
966
|
+
if (!link.document.lockedAt)
|
|
967
|
+
return caseDocumentResponse({ key, document: link.document });
|
|
968
|
+
const now = new Date();
|
|
969
|
+
const [document] = await tx.update(documents).set({
|
|
970
|
+
lockedAt: null,
|
|
971
|
+
lockedByAgentId: null,
|
|
972
|
+
lockedByUserId: null,
|
|
973
|
+
updatedAt: now,
|
|
974
|
+
}).where(eq(documents.id, link.document.id)).returning();
|
|
975
|
+
await tx.update(caseDocuments).set({ updatedAt: now }).where(eq(caseDocuments.documentId, link.document.id));
|
|
976
|
+
return caseDocumentResponse({ key, document: document });
|
|
977
|
+
});
|
|
978
|
+
res.json(result);
|
|
979
|
+
});
|
|
980
|
+
router.post("/cases/:id/documents/:key/revisions/:revisionId/restore", async (req, res, next) => {
|
|
981
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
982
|
+
if (!caseRow)
|
|
983
|
+
return next();
|
|
984
|
+
const key = parseDocumentKey(req.params.key);
|
|
985
|
+
const revisionId = req.params.revisionId;
|
|
986
|
+
const actor = getActorInfo(req);
|
|
987
|
+
const result = await db.transaction(async (tx) => {
|
|
988
|
+
await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key });
|
|
989
|
+
const existing = await loadCaseDocumentLink(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key });
|
|
990
|
+
if (!existing)
|
|
991
|
+
throw notFound("Case document not found");
|
|
992
|
+
if (existing.document.lockedAt) {
|
|
993
|
+
throw conflict("Document is locked", {
|
|
994
|
+
key,
|
|
995
|
+
documentId: existing.document.id,
|
|
996
|
+
lockedAt: existing.document.lockedAt,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
const sourceRevision = await tx
|
|
1000
|
+
.select()
|
|
1001
|
+
.from(documentRevisions)
|
|
1002
|
+
.where(and(eq(documentRevisions.id, revisionId), eq(documentRevisions.documentId, existing.document.id)))
|
|
1003
|
+
.limit(1)
|
|
1004
|
+
.then((rows) => rows[0] ?? null);
|
|
1005
|
+
if (!sourceRevision)
|
|
1006
|
+
throw notFound("Case document revision not found");
|
|
1007
|
+
if (existing.document.latestRevisionId === sourceRevision.id) {
|
|
1008
|
+
throw conflict("Selected revision is already the latest revision", {
|
|
1009
|
+
currentRevisionId: existing.document.latestRevisionId,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
const now = new Date();
|
|
1013
|
+
const nextRevisionNumber = existing.document.latestRevisionNumber + 1;
|
|
1014
|
+
const [restoredRevision] = await tx.insert(documentRevisions).values({
|
|
1015
|
+
companyId: caseRow.companyId,
|
|
1016
|
+
documentId: existing.document.id,
|
|
1017
|
+
revisionNumber: nextRevisionNumber,
|
|
1018
|
+
title: sourceRevision.title ?? null,
|
|
1019
|
+
format: sourceRevision.format,
|
|
1020
|
+
body: sourceRevision.body,
|
|
1021
|
+
changeSummary: `Restored from revision ${sourceRevision.revisionNumber}`,
|
|
1022
|
+
createdByAgentId: actor.agentId,
|
|
1023
|
+
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
1024
|
+
createdByRunId: actor.runId && isUuidLike(actor.runId) ? actor.runId : null,
|
|
1025
|
+
createdAt: now,
|
|
1026
|
+
}).returning();
|
|
1027
|
+
const [document] = await tx.update(documents).set({
|
|
1028
|
+
title: sourceRevision.title ?? null,
|
|
1029
|
+
format: sourceRevision.format,
|
|
1030
|
+
latestBody: sourceRevision.body,
|
|
1031
|
+
latestRevisionId: restoredRevision.id,
|
|
1032
|
+
latestRevisionNumber: nextRevisionNumber,
|
|
1033
|
+
updatedByAgentId: actor.agentId,
|
|
1034
|
+
updatedByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
1035
|
+
updatedAt: now,
|
|
1036
|
+
}).where(eq(documents.id, existing.document.id)).returning();
|
|
1037
|
+
await tx.update(caseDocuments).set({ updatedAt: now }).where(eq(caseDocuments.documentId, existing.document.id));
|
|
1038
|
+
await insertCaseEvent(tx, {
|
|
1039
|
+
companyId: caseRow.companyId,
|
|
1040
|
+
caseId: caseRow.id,
|
|
1041
|
+
kind: "document_revised",
|
|
1042
|
+
actor,
|
|
1043
|
+
payload: {
|
|
1044
|
+
key,
|
|
1045
|
+
documentId: existing.document.id,
|
|
1046
|
+
revisionId: restoredRevision.id,
|
|
1047
|
+
revisionNumber: restoredRevision.revisionNumber,
|
|
1048
|
+
restoredFromRevisionId: sourceRevision.id,
|
|
1049
|
+
restoredFromRevisionNumber: sourceRevision.revisionNumber,
|
|
1050
|
+
},
|
|
1051
|
+
});
|
|
1052
|
+
await autoLinkRunIssue(tx, { companyId: caseRow.companyId, caseId: caseRow.id, actor, role: "work" });
|
|
1053
|
+
return {
|
|
1054
|
+
document: caseDocumentResponse({ key, document: document }),
|
|
1055
|
+
revision: restoredRevision,
|
|
1056
|
+
restoredFromRevisionId: sourceRevision.id,
|
|
1057
|
+
restoredFromRevisionNumber: sourceRevision.revisionNumber,
|
|
1058
|
+
};
|
|
1059
|
+
});
|
|
1060
|
+
await logCaseAnnotationRemaps({
|
|
1061
|
+
caseRow,
|
|
1062
|
+
key,
|
|
1063
|
+
document: result.document,
|
|
1064
|
+
body: result.document.body,
|
|
1065
|
+
actor,
|
|
1066
|
+
});
|
|
1067
|
+
res.json(result);
|
|
1068
|
+
});
|
|
1069
|
+
router.delete("/cases/:id/documents/:key", async (req, res, next) => {
|
|
1070
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
1071
|
+
if (!caseRow)
|
|
1072
|
+
return next();
|
|
1073
|
+
const key = parseDocumentKey(req.params.key);
|
|
1074
|
+
await db.transaction(async (tx) => {
|
|
1075
|
+
await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key });
|
|
1076
|
+
const link = await loadCaseDocumentLink(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key });
|
|
1077
|
+
if (!link)
|
|
1078
|
+
return;
|
|
1079
|
+
if (link.document.lockedAt) {
|
|
1080
|
+
throw conflict("Document is locked", {
|
|
1081
|
+
key,
|
|
1082
|
+
documentId: link.document.id,
|
|
1083
|
+
lockedAt: link.document.lockedAt,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
await tx.delete(caseDocuments).where(eq(caseDocuments.documentId, link.document.id));
|
|
1087
|
+
await tx.delete(documents).where(eq(documents.id, link.document.id));
|
|
1088
|
+
});
|
|
1089
|
+
res.json({ ok: true });
|
|
1090
|
+
});
|
|
1091
|
+
router.post("/cases/:id/links", validate(createIssueLinkSchema), async (req, res) => {
|
|
1092
|
+
await assertCasesEnabled(db);
|
|
1093
|
+
const caseRow = await assertCaseAccess(db, req, req.params.id);
|
|
1094
|
+
const actor = getActorInfo(req);
|
|
1095
|
+
const body = req.body;
|
|
1096
|
+
const result = await db.transaction(async (tx) => {
|
|
1097
|
+
const issue = await tx
|
|
1098
|
+
.select({ id: issues.id })
|
|
1099
|
+
.from(issues)
|
|
1100
|
+
.where(and(eq(issues.id, body.issueId), eq(issues.companyId, caseRow.companyId)))
|
|
1101
|
+
.limit(1)
|
|
1102
|
+
.then((rows) => rows[0] ?? null);
|
|
1103
|
+
if (!issue)
|
|
1104
|
+
throw unprocessable("Issue does not belong to case company");
|
|
1105
|
+
const now = new Date();
|
|
1106
|
+
const [link] = await tx.insert(caseIssueLinks).values({
|
|
1107
|
+
companyId: caseRow.companyId,
|
|
1108
|
+
caseId: caseRow.id,
|
|
1109
|
+
issueId: body.issueId,
|
|
1110
|
+
role: body.role,
|
|
1111
|
+
createdByRunId: actor.runId && isUuidLike(actor.runId) ? actor.runId : null,
|
|
1112
|
+
createdAt: now,
|
|
1113
|
+
updatedAt: now,
|
|
1114
|
+
}).onConflictDoNothing({
|
|
1115
|
+
target: [caseIssueLinks.caseId, caseIssueLinks.issueId],
|
|
1116
|
+
}).returning();
|
|
1117
|
+
if (link) {
|
|
1118
|
+
await insertCaseEvent(tx, {
|
|
1119
|
+
companyId: caseRow.companyId,
|
|
1120
|
+
caseId: caseRow.id,
|
|
1121
|
+
kind: "issue_linked",
|
|
1122
|
+
actor,
|
|
1123
|
+
payload: { issueId: body.issueId, role: body.role, autoLinked: false },
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
return link ?? await tx
|
|
1127
|
+
.select()
|
|
1128
|
+
.from(caseIssueLinks)
|
|
1129
|
+
.where(and(eq(caseIssueLinks.caseId, caseRow.id), eq(caseIssueLinks.issueId, body.issueId)))
|
|
1130
|
+
.limit(1)
|
|
1131
|
+
.then((rows) => rows[0]);
|
|
1132
|
+
});
|
|
1133
|
+
res.status(201).json(result);
|
|
1134
|
+
});
|
|
1135
|
+
router.post("/cases/:id/attachments", async (req, res) => {
|
|
1136
|
+
await assertCasesEnabled(db);
|
|
1137
|
+
const caseRow = await assertCaseAccess(db, req, req.params.id);
|
|
1138
|
+
const actor = getActorInfo(req);
|
|
1139
|
+
const [company] = await db
|
|
1140
|
+
.select({ attachmentMaxBytes: companies.attachmentMaxBytes })
|
|
1141
|
+
.from(companies)
|
|
1142
|
+
.where(eq(companies.id, caseRow.companyId))
|
|
1143
|
+
.limit(1);
|
|
1144
|
+
const maxBytes = company?.attachmentMaxBytes ?? 10 * 1024 * 1024;
|
|
1145
|
+
try {
|
|
1146
|
+
await singleFileUpload(req, res, maxBytes);
|
|
1147
|
+
}
|
|
1148
|
+
catch (err) {
|
|
1149
|
+
if (err instanceof multer.MulterError) {
|
|
1150
|
+
if (err.code === "LIMIT_FILE_SIZE") {
|
|
1151
|
+
throw unprocessable(`Attachment exceeds ${maxBytes} bytes`);
|
|
1152
|
+
}
|
|
1153
|
+
throw badRequest(err.message);
|
|
1154
|
+
}
|
|
1155
|
+
throw err;
|
|
1156
|
+
}
|
|
1157
|
+
const file = req.file;
|
|
1158
|
+
if (!file)
|
|
1159
|
+
throw badRequest("Missing file field 'file'");
|
|
1160
|
+
if (file.buffer.length <= 0)
|
|
1161
|
+
throw unprocessable("Attachment is empty");
|
|
1162
|
+
const stored = await storage.putFile({
|
|
1163
|
+
companyId: caseRow.companyId,
|
|
1164
|
+
namespace: `cases/${caseRow.id}`,
|
|
1165
|
+
originalFilename: file.originalname || null,
|
|
1166
|
+
contentType: normalizeContentType(file.mimetype),
|
|
1167
|
+
body: file.buffer,
|
|
1168
|
+
});
|
|
1169
|
+
const result = await db.transaction(async (tx) => {
|
|
1170
|
+
const now = new Date();
|
|
1171
|
+
const [asset] = await tx.insert(assets).values({
|
|
1172
|
+
companyId: caseRow.companyId,
|
|
1173
|
+
provider: stored.provider,
|
|
1174
|
+
objectKey: stored.objectKey,
|
|
1175
|
+
contentType: stored.contentType,
|
|
1176
|
+
byteSize: stored.byteSize,
|
|
1177
|
+
sha256: stored.sha256,
|
|
1178
|
+
originalFilename: stored.originalFilename,
|
|
1179
|
+
createdByAgentId: actor.agentId,
|
|
1180
|
+
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
1181
|
+
createdAt: now,
|
|
1182
|
+
updatedAt: now,
|
|
1183
|
+
}).returning();
|
|
1184
|
+
const [attachment] = await tx.insert(caseAttachments).values({
|
|
1185
|
+
companyId: caseRow.companyId,
|
|
1186
|
+
caseId: caseRow.id,
|
|
1187
|
+
assetId: asset.id,
|
|
1188
|
+
createdAt: now,
|
|
1189
|
+
updatedAt: now,
|
|
1190
|
+
}).returning();
|
|
1191
|
+
await insertCaseEvent(tx, {
|
|
1192
|
+
companyId: caseRow.companyId,
|
|
1193
|
+
caseId: caseRow.id,
|
|
1194
|
+
kind: "attachment_added",
|
|
1195
|
+
actor,
|
|
1196
|
+
payload: { attachmentId: attachment.id, assetId: asset.id, originalFilename: asset.originalFilename },
|
|
1197
|
+
});
|
|
1198
|
+
await autoLinkRunIssue(tx, { companyId: caseRow.companyId, caseId: caseRow.id, actor, role: "work" });
|
|
1199
|
+
return { ...attachment, asset };
|
|
1200
|
+
});
|
|
1201
|
+
res.status(201).json(result);
|
|
1202
|
+
});
|
|
1203
|
+
router.get("/cases/:id/events", async (req, res, next) => {
|
|
1204
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
1205
|
+
if (!caseRow)
|
|
1206
|
+
return next();
|
|
1207
|
+
const parsed = listEventsQuerySchema.safeParse(req.query);
|
|
1208
|
+
if (!parsed.success)
|
|
1209
|
+
throw badRequest("Invalid case events query", parsed.error.issues);
|
|
1210
|
+
const rows = await db
|
|
1211
|
+
.select()
|
|
1212
|
+
.from(caseEvents)
|
|
1213
|
+
.where(and(eq(caseEvents.companyId, caseRow.companyId), eq(caseEvents.caseId, caseRow.id)))
|
|
1214
|
+
.orderBy(desc(caseEvents.createdAt), desc(caseEvents.id))
|
|
1215
|
+
.limit(parsed.data.limit);
|
|
1216
|
+
// Enrich each row with its actor's display name, run→issue attribution,
|
|
1217
|
+
// and the linked issue captured in link/unlink payloads.
|
|
1218
|
+
const payloadIssueIds = rows.map((row) => payloadIssueIdForEvent(row.kind, row.payload));
|
|
1219
|
+
const [agentNames, issueMap, payloadIssueMap] = await Promise.all([
|
|
1220
|
+
resolveAgentNames(db, rows.map((row) => row.actorAgentId)),
|
|
1221
|
+
resolveIssuesForRuns(db, caseRow.companyId, rows.map((row) => row.runId)),
|
|
1222
|
+
resolveIssuesByIds(db, caseRow.companyId, payloadIssueIds),
|
|
1223
|
+
]);
|
|
1224
|
+
res.json(rows.map((row) => ({
|
|
1225
|
+
...row,
|
|
1226
|
+
actorAgentName: row.actorAgentId ? agentNames.get(row.actorAgentId) ?? null : null,
|
|
1227
|
+
issue: payloadIssueMap.get(payloadIssueIdForEvent(row.kind, row.payload) ?? "")
|
|
1228
|
+
?? (row.runId ? issueMap.get(row.runId) ?? null : null),
|
|
1229
|
+
})));
|
|
1230
|
+
});
|
|
1231
|
+
router.get("/cases/:id/documents/:key/revisions", async (req, res, next) => {
|
|
1232
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
1233
|
+
if (!caseRow)
|
|
1234
|
+
return next();
|
|
1235
|
+
const key = parseDocumentKey(req.params.key);
|
|
1236
|
+
const link = await db
|
|
1237
|
+
.select({ documentId: caseDocuments.documentId, document: documents })
|
|
1238
|
+
.from(caseDocuments)
|
|
1239
|
+
.innerJoin(documents, eq(caseDocuments.documentId, documents.id))
|
|
1240
|
+
.where(and(eq(caseDocuments.companyId, caseRow.companyId), eq(caseDocuments.caseId, caseRow.id), eq(caseDocuments.key, key)))
|
|
1241
|
+
.limit(1)
|
|
1242
|
+
.then((rows) => rows[0] ?? null);
|
|
1243
|
+
if (!link)
|
|
1244
|
+
throw notFound("Case document not found");
|
|
1245
|
+
const revisions = await db
|
|
1246
|
+
.select()
|
|
1247
|
+
.from(documentRevisions)
|
|
1248
|
+
.where(and(eq(documentRevisions.companyId, caseRow.companyId), eq(documentRevisions.documentId, link.documentId)))
|
|
1249
|
+
.orderBy(desc(documentRevisions.revisionNumber));
|
|
1250
|
+
const [agentNames, issueMap] = await Promise.all([
|
|
1251
|
+
resolveAgentNames(db, revisions.map((rev) => rev.createdByAgentId)),
|
|
1252
|
+
resolveIssuesForRuns(db, caseRow.companyId, revisions.map((rev) => rev.createdByRunId)),
|
|
1253
|
+
]);
|
|
1254
|
+
res.json({
|
|
1255
|
+
key,
|
|
1256
|
+
document: {
|
|
1257
|
+
id: link.document.id,
|
|
1258
|
+
title: link.document.title,
|
|
1259
|
+
format: link.document.format,
|
|
1260
|
+
latestRevisionId: link.document.latestRevisionId,
|
|
1261
|
+
latestRevisionNumber: link.document.latestRevisionNumber,
|
|
1262
|
+
},
|
|
1263
|
+
revisions: revisions.map((rev) => ({
|
|
1264
|
+
id: rev.id,
|
|
1265
|
+
revisionNumber: rev.revisionNumber,
|
|
1266
|
+
title: rev.title,
|
|
1267
|
+
format: rev.format,
|
|
1268
|
+
body: rev.body,
|
|
1269
|
+
changeSummary: rev.changeSummary,
|
|
1270
|
+
createdAt: rev.createdAt,
|
|
1271
|
+
createdByAgentId: rev.createdByAgentId,
|
|
1272
|
+
createdByUserId: rev.createdByUserId,
|
|
1273
|
+
createdByRunId: rev.createdByRunId,
|
|
1274
|
+
actorAgentName: rev.createdByAgentId ? agentNames.get(rev.createdByAgentId) ?? null : null,
|
|
1275
|
+
issue: rev.createdByRunId ? issueMap.get(rev.createdByRunId) ?? null : null,
|
|
1276
|
+
})),
|
|
1277
|
+
});
|
|
1278
|
+
});
|
|
1279
|
+
router.get("/issues/:issueId/cases", async (req, res) => {
|
|
1280
|
+
await assertCasesEnabled(db);
|
|
1281
|
+
const issueIdOrIdentifier = req.params.issueId.trim();
|
|
1282
|
+
const issue = await loadIssueByIdOrIdentifier(db, issueIdOrIdentifier, caseLookupCompanyIds(req));
|
|
1283
|
+
if (!issue)
|
|
1284
|
+
throw notFound("Issue not found");
|
|
1285
|
+
assertCompanyAccess(req, issue.companyId);
|
|
1286
|
+
const rows = await db
|
|
1287
|
+
.select({ link: caseIssueLinks, caseRow: cases })
|
|
1288
|
+
.from(caseIssueLinks)
|
|
1289
|
+
.innerJoin(cases, eq(caseIssueLinks.caseId, cases.id))
|
|
1290
|
+
.where(and(eq(caseIssueLinks.companyId, issue.companyId), eq(caseIssueLinks.issueId, issue.id)))
|
|
1291
|
+
.orderBy(asc(caseIssueLinks.createdAt));
|
|
1292
|
+
res.json(rows.map((row) => ({
|
|
1293
|
+
id: row.link.id,
|
|
1294
|
+
role: row.link.role,
|
|
1295
|
+
createdAt: row.link.createdAt,
|
|
1296
|
+
case: {
|
|
1297
|
+
id: row.caseRow.id,
|
|
1298
|
+
identifier: row.caseRow.identifier,
|
|
1299
|
+
title: row.caseRow.title,
|
|
1300
|
+
caseType: row.caseRow.caseType,
|
|
1301
|
+
status: row.caseRow.status,
|
|
1302
|
+
},
|
|
1303
|
+
})));
|
|
1304
|
+
});
|
|
1305
|
+
router.get("/cases/:id", async (req, res, next) => {
|
|
1306
|
+
const row = await resolveSharedPathCase(db, req, req.params.id);
|
|
1307
|
+
if (!row)
|
|
1308
|
+
return next();
|
|
1309
|
+
res.json(await loadCaseDetail(db, row));
|
|
1310
|
+
});
|
|
1311
|
+
router.patch("/cases/:id", async (req, res, next) => {
|
|
1312
|
+
const caseRow = await resolveSharedPathCase(db, req, req.params.id);
|
|
1313
|
+
if (!caseRow)
|
|
1314
|
+
return next();
|
|
1315
|
+
const actor = getActorInfo(req);
|
|
1316
|
+
const body = patchCaseSchema.parse(req.body);
|
|
1317
|
+
const nextLabelIds = body.labelIds ?? body.labels;
|
|
1318
|
+
const updated = await db.transaction(async (tx) => {
|
|
1319
|
+
await assertProjectBelongsToCompany(tx, { companyId: caseRow.companyId, projectId: body.projectId ?? null });
|
|
1320
|
+
await assertParentCaseBelongsToCompany(tx, {
|
|
1321
|
+
companyId: caseRow.companyId,
|
|
1322
|
+
caseId: caseRow.id,
|
|
1323
|
+
parentCaseId: body.parentCaseId ?? null,
|
|
1324
|
+
});
|
|
1325
|
+
if (nextLabelIds)
|
|
1326
|
+
await assertLabelsBelongToCompany(tx, caseRow.companyId, nextLabelIds);
|
|
1327
|
+
const now = new Date();
|
|
1328
|
+
const [row] = await tx.update(cases).set(buildCasePatchUpdateValues(body, caseRow, now)).where(eq(cases.id, caseRow.id)).returning();
|
|
1329
|
+
if (nextLabelIds) {
|
|
1330
|
+
await lockCaseLabels(tx, { companyId: caseRow.companyId, caseId: caseRow.id });
|
|
1331
|
+
const current = await tx
|
|
1332
|
+
.select({ labelId: caseLabels.labelId })
|
|
1333
|
+
.from(caseLabels)
|
|
1334
|
+
.where(and(eq(caseLabels.companyId, caseRow.companyId), eq(caseLabels.caseId, caseRow.id)));
|
|
1335
|
+
const currentIds = new Set(current.map((item) => item.labelId));
|
|
1336
|
+
const desiredIds = new Set(nextLabelIds);
|
|
1337
|
+
const added = [...desiredIds].filter((id) => !currentIds.has(id));
|
|
1338
|
+
const removed = [...currentIds].filter((id) => !desiredIds.has(id));
|
|
1339
|
+
if (removed.length > 0) {
|
|
1340
|
+
await tx.delete(caseLabels).where(and(eq(caseLabels.caseId, caseRow.id), inArray(caseLabels.labelId, removed)));
|
|
1341
|
+
for (const labelId of removed) {
|
|
1342
|
+
await insertCaseEvent(tx, {
|
|
1343
|
+
companyId: caseRow.companyId,
|
|
1344
|
+
caseId: caseRow.id,
|
|
1345
|
+
kind: "label_removed",
|
|
1346
|
+
actor,
|
|
1347
|
+
payload: { labelId },
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
if (added.length > 0) {
|
|
1352
|
+
await tx.insert(caseLabels).values(added.map((labelId) => ({
|
|
1353
|
+
companyId: caseRow.companyId,
|
|
1354
|
+
caseId: caseRow.id,
|
|
1355
|
+
labelId,
|
|
1356
|
+
createdAt: now,
|
|
1357
|
+
updatedAt: now,
|
|
1358
|
+
}))).onConflictDoNothing();
|
|
1359
|
+
for (const labelId of added) {
|
|
1360
|
+
await insertCaseEvent(tx, {
|
|
1361
|
+
companyId: caseRow.companyId,
|
|
1362
|
+
caseId: caseRow.id,
|
|
1363
|
+
kind: "label_added",
|
|
1364
|
+
actor,
|
|
1365
|
+
payload: { labelId },
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const kind = body.status !== undefined
|
|
1371
|
+
? "status_changed"
|
|
1372
|
+
: body.fields !== undefined
|
|
1373
|
+
? "fields_changed"
|
|
1374
|
+
: Object.hasOwn(body, "parentCaseId") && body.parentCaseId
|
|
1375
|
+
? "child_linked"
|
|
1376
|
+
: "updated";
|
|
1377
|
+
await insertCaseEvent(tx, {
|
|
1378
|
+
companyId: caseRow.companyId,
|
|
1379
|
+
caseId: caseRow.id,
|
|
1380
|
+
kind,
|
|
1381
|
+
actor,
|
|
1382
|
+
payload: {
|
|
1383
|
+
previousStatus: body.status !== undefined ? caseRow.status : undefined,
|
|
1384
|
+
status: body.status,
|
|
1385
|
+
parentCaseId: body.parentCaseId,
|
|
1386
|
+
},
|
|
1387
|
+
});
|
|
1388
|
+
await autoLinkRunIssue(tx, { companyId: caseRow.companyId, caseId: caseRow.id, actor, role: "work" });
|
|
1389
|
+
return row;
|
|
1390
|
+
});
|
|
1391
|
+
res.json(await loadCaseDetail(db, updated));
|
|
1392
|
+
});
|
|
1393
|
+
return router;
|
|
1394
|
+
}
|
|
1395
|
+
//# sourceMappingURL=cases.js.map
|