@taskforcehq/taskforce 0.3.303 → 0.3.305
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/README.md +140 -247
- package/dist/Taskforce.module.css +102 -5
- package/dist/TaskforceCore.js +635 -238
- package/dist/TaskforceCore.test.js +2243 -495
- package/dist/annotatedAttachments/service.d.ts +1 -1
- package/dist/annotatedAttachments/types.d.ts +1 -1
- package/dist/compat/workspaceSyncCompat.js +6 -0
- package/dist/components/features/AgentsModule.js +1 -1
- package/dist/components/features/AgentsModule.test.js +1 -1
- package/dist/components/features/DocumentIndex.d.ts +1 -1
- package/dist/components/features/DocumentIndex.js +1 -1
- package/dist/components/features/DocumentViewer.d.ts +1 -1
- package/dist/components/features/DocumentViewer.js +32 -9
- package/dist/components/features/DocumentWorkspace.d.ts +5 -22
- package/dist/components/features/DocumentWorkspace.js +12 -155
- package/dist/components/features/DocumentWorkspace.test.js +193 -6
- package/dist/components/features/TaskSettings.js +87 -41
- package/dist/components/features/TaskSettings.test.js +180 -40
- package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
- package/dist/components/features/documentWorkspaceModel.js +57 -0
- package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
- package/dist/components/features/useDocumentWorkspaceController.js +113 -0
- package/dist/components/task/TaskActionHeader.d.ts +2 -1
- package/dist/components/task/TaskActionHeader.js +3 -3
- package/dist/components/task/TaskActionHeader.test.js +8 -0
- package/dist/components/task/TaskCard.js +16 -1
- package/dist/components/task/TaskCard.test.js +31 -0
- package/dist/components/task/TaskForm.d.ts +9 -8
- package/dist/components/task/TaskForm.js +374 -219
- package/dist/components/task/TaskForm.test.js +482 -361
- package/dist/components/task/TaskKanban.d.ts +1 -1
- package/dist/components/task/TaskKanban.js +33 -161
- package/dist/components/task/TaskStatusActions.d.ts +6 -1
- package/dist/components/task/TaskStatusActions.js +8 -3
- package/dist/components/task/TaskStatusActions.test.js +9 -0
- package/dist/components/task/taskKanbanDropRules.d.ts +58 -0
- package/dist/components/task/taskKanbanDropRules.js +187 -0
- package/dist/components/task/taskKanbanDropRules.test.d.ts +1 -0
- package/dist/components/task/taskKanbanDropRules.test.js +114 -0
- package/dist/components/ui/Modal.d.ts +1 -1
- package/dist/components/ui/Modal.js +1 -0
- package/dist/components/views/AppShellHeader.js +5 -1
- package/dist/components/views/AppShellHeader.test.js +4 -0
- package/dist/components/views/PlanComparisonPage.d.ts +3 -2
- package/dist/components/views/PlanComparisonPage.js +9 -10
- package/dist/components/views/PlanComparisonPage.test.js +55 -41
- package/dist/components/views/PlansPage.d.ts +5 -1
- package/dist/components/views/PlansPage.js +150 -81
- package/dist/components/views/PlansPage.test.js +367 -41
- package/dist/components/views/StandaloneLayout.js +135 -54
- package/dist/components/views/WidgetView.js +11 -2
- package/dist/components/views/panels/FilterToolbar.test.js +6 -4
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +2 -14
- package/dist/components/views/standalone/modals/AccountHubModal.js +12 -14
- package/dist/components/views/standalone/modals/AccountHubModal.test.js +24 -1
- package/dist/components/views/standalone/modals/SyncStatusModal.js +1 -1
- package/dist/config/envSchema.js +1 -1
- package/dist/core/AiProfileService.d.ts +6 -1
- package/dist/core/AiProfileService.js +161 -16
- package/dist/core/AiProfileService.test.js +3 -1
- package/dist/core/AiProfiles.test.js +200 -0
- package/dist/core/AuthTokenService.test.d.ts +1 -0
- package/dist/core/AuthTokenService.test.js +78 -0
- package/dist/core/DeletedTaskLifecycleService.d.ts +2 -1
- package/dist/core/EntitlementsPolicy.test.js +75 -13
- package/dist/core/PlanEntitlementService.d.ts +9 -1
- package/dist/core/PlanEntitlementService.js +144 -19
- package/dist/core/PlanVersionPolicy.test.js +19 -26
- package/dist/core/SignupMonetizationSettings.test.js +75 -15
- package/dist/core/SystemAdmin.test.js +212 -56
- package/dist/core/TaskActivityService.d.ts +4 -1
- package/dist/core/TaskActivityService.js +45 -0
- package/dist/core/TaskAuditTimeline.test.js +52 -1
- package/dist/core/TaskTaxonomyValidation.test.js +53 -0
- package/dist/core/Taskforce.d.ts +18 -11
- package/dist/core/Taskforce.js +93 -12
- package/dist/core/WorkspacePlanMode.test.js +73 -8
- package/dist/core/shared.d.ts +2 -0
- package/dist/core/shared.js +11 -0
- package/dist/documentReviews/service.d.ts +1 -1
- package/dist/documentReviews/types.d.ts +1 -1
- package/dist/hooks/auth/useTaskforceAuthBootstrap.d.ts +48 -0
- package/dist/hooks/auth/useTaskforceAuthBootstrap.js +219 -0
- package/dist/hooks/sync/auth.d.ts +37 -0
- package/dist/hooks/sync/auth.js +63 -0
- package/dist/hooks/sync/bootstrap.d.ts +34 -0
- package/dist/hooks/sync/bootstrap.js +52 -0
- package/dist/hooks/sync/orchestratorShared.d.ts +36 -0
- package/dist/hooks/sync/orchestratorShared.js +209 -0
- package/dist/hooks/sync/orchestratorShared.test.d.ts +1 -0
- package/dist/hooks/sync/orchestratorShared.test.js +49 -0
- package/dist/hooks/sync/realtime.d.ts +25 -0
- package/dist/hooks/sync/realtime.js +60 -0
- package/dist/hooks/sync/recovery.d.ts +69 -0
- package/dist/hooks/sync/recovery.js +118 -0
- package/dist/hooks/sync/transfers.d.ts +123 -0
- package/dist/hooks/sync/transfers.js +435 -0
- package/dist/hooks/sync/useDataVersionRefresh.d.ts +16 -0
- package/dist/hooks/sync/useDataVersionRefresh.js +73 -0
- package/dist/hooks/ui/useResourceExport.d.ts +3 -1
- package/dist/hooks/ui/useResourceExport.js +5 -2
- package/dist/hooks/ui/useWorkflowTemplates.js +11 -6
- package/dist/hooks/useRealtimeSync.js +2 -1
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +8 -0
- package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +1 -0
- package/dist/hooks/useSyncOrchestrator.context-assets.test.js +228 -0
- package/dist/hooks/useSyncOrchestrator.d.ts +12 -8
- package/dist/hooks/useSyncOrchestrator.js +388 -879
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +641 -6
- package/dist/hooks/useTaskMutations.d.ts +2 -1
- package/dist/hooks/useTaskMutations.js +57 -7
- package/dist/hooks/useTaskMutations.test.js +176 -0
- package/dist/hooks/useTaskforce.d.ts +13 -4
- package/dist/hooks/useTaskforce.js +302 -817
- package/dist/hooks/useTaskforce.sync-behavior.test.js +78 -0
- package/dist/hooks/useWorkspaceSyncController.d.ts +4 -1
- package/dist/hooks/useWorkspaceSyncController.js +33 -11
- package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
- package/dist/hooks/workspace/useTaskforceApiRouting.test.js +17 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +166 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +434 -0
- package/dist/hooks/workspace/workspaceLocalState.d.ts +19 -0
- package/dist/hooks/workspace/workspaceLocalState.js +100 -0
- package/dist/hooks/workspace/workspaceLocalState.test.d.ts +1 -0
- package/dist/hooks/workspace/workspaceLocalState.test.js +59 -0
- package/dist/localization/locales/en-US.js +2 -2
- package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
- package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
- package/dist/mcp/canonicalAssetHelpers.d.ts +70 -0
- package/dist/mcp/canonicalAssetHelpers.js +259 -0
- package/dist/mcp/clientIntegrations.d.ts +12 -1
- package/dist/mcp/clientIntegrations.js +161 -6
- package/dist/mcp/clientIntegrations.test.js +76 -4
- package/dist/mcp/collaborationRegistrar.d.ts +2 -0
- package/dist/mcp/collaborationRegistrar.js +71 -0
- package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
- package/dist/mcp/documentAssetRegistrar.js +346 -0
- package/dist/mcp/documentHelpers.d.ts +100 -0
- package/dist/mcp/documentHelpers.js +161 -0
- package/dist/mcp/runtime.js +2455 -3925
- package/dist/mcp/runtime.test.js +240 -8
- package/dist/mcp/taskAttachmentHelpers.d.ts +41 -0
- package/dist/mcp/taskAttachmentHelpers.js +60 -0
- package/dist/mcp/taskAttachmentTypes.d.ts +37 -0
- package/dist/mcp/taskAttachmentTypes.js +1 -0
- package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
- package/dist/mcp/taskPlanningRegistrar.js +418 -0
- package/dist/mcp/toolCatalog.d.ts +35 -0
- package/dist/mcp/toolCatalog.js +3 -0
- package/dist/plugins/vite.js +1 -1
- package/dist/runtime/appGateDefinitions.d.ts +1 -1
- package/dist/runtime/appGateDefinitions.js +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +46 -3
- package/dist/server/index.test.js +12 -1
- package/dist/server/routes/admin.d.ts +1 -5
- package/dist/server/routes/admin.js +151 -80
- package/dist/server/routes/annotatedAttachments.d.ts +1 -1
- package/dist/server/routes/annotatedAttachments.js +1 -1
- package/dist/server/routes/auth.d.ts +1 -4
- package/dist/server/routes/auth.js +59 -64
- package/dist/server/routes/authSupport.d.ts +30 -0
- package/dist/server/routes/authSupport.js +81 -0
- package/dist/server/routes/billing.d.ts +1 -1
- package/dist/server/routes/billing.js +122 -255
- package/dist/server/routes/billing.test.js +257 -86
- package/dist/server/routes/documentReviews.d.ts +1 -1
- package/dist/server/routes/documentReviews.js +1 -1
- package/dist/server/routes/documents.d.ts +4 -2
- package/dist/server/routes/documents.js +73 -7
- package/dist/server/routes/primitives.d.ts +11 -0
- package/dist/server/routes/primitives.js +73 -0
- package/dist/server/routes/resources.d.ts +1 -1
- package/dist/server/routes/resources.js +1 -1
- package/dist/server/routes/shared.d.ts +31 -1
- package/dist/server/routes/shared.js +26 -0
- package/dist/server/routes/sync.d.ts +1 -1
- package/dist/server/routes/sync.integration.test.js +20 -0
- package/dist/server/routes/sync.js +111 -238
- package/dist/server/routes/tasks.d.ts +1 -1
- package/dist/server/routes/tasks.js +50 -8
- package/dist/server/routes/workspaces.d.ts +1 -1
- package/dist/server/routes/workspaces.js +1 -1
- package/dist/server/routes.d.ts +2 -9
- package/dist/server/routes.js +84 -181
- package/dist/server/routes.test.js +912 -32
- package/dist/shared/taskActor.d.ts +10 -0
- package/dist/shared/taskActor.js +1 -0
- package/dist/sync/collaborationSyncPayload.js +4 -0
- package/dist/sync/collaborationSyncPayload.test.js +8 -0
- package/dist/sync/collaborationSyncState.d.ts +3 -1
- package/dist/sync/collaborationSyncState.js +19 -0
- package/dist/sync/contentSyncState.js +28 -17
- package/dist/sync/syncApplyHandlers.d.ts +87 -1
- package/dist/sync/syncApplyHandlers.js +370 -8
- package/dist/sync/syncApplyHandlers.test.d.ts +1 -0
- package/dist/sync/syncApplyHandlers.test.js +379 -0
- package/dist/sync/syncService.d.ts +9 -1
- package/dist/sync/syncService.js +44 -2
- package/dist/sync/syncService.test.js +62 -0
- package/dist/sync/workspacePullFeed.d.ts +1 -0
- package/dist/sync/workspacePullFeed.js +66 -59
- package/dist/sync/workspacePullFeed.test.js +72 -4
- package/dist/sync/workspaceSyncModel.d.ts +25 -1
- package/dist/sync/workspaceSyncModel.js +77 -3
- package/dist/sync/workspaceSyncModel.test.js +153 -0
- package/dist/sync/workspaceSyncState.d.ts +2 -0
- package/dist/sync/workspaceSyncState.js +1 -0
- package/dist/types.d.ts +2 -10
- package/dist/ui/assets/AgentsModule-N2MnQBZk.js +1 -0
- package/dist/ui/assets/{AnnotatedAttachmentWorkspace-C_TmXZwA.js → AnnotatedAttachmentWorkspace-BG6P_GVW.js} +1 -1
- package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +1 -0
- package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +252 -0
- package/dist/ui/assets/{InitiativesModule-4-Rh2K2n.js → InitiativesModule-Dhm7M8e7.js} +1 -1
- package/dist/ui/assets/{PlansPage-BlVC_lRq.css → PlansPage-C2dd2n1X.css} +1 -1
- package/dist/ui/assets/PlansPage-Cq0zXj3I.js +1 -0
- package/dist/ui/assets/TaskSettings-ln0aF7xc.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-CGk9s3NJ.js → WorkflowsModule-BcS4afRn.js} +1 -1
- package/dist/ui/assets/index-C2-OXZV7.css +1 -0
- package/dist/ui/assets/index-DcSI5F86.js +6 -0
- package/dist/ui/assets/{vendor-icons-pWocEipT.js → vendor-icons-BSUaRwF8.js} +1 -1
- package/dist/ui/index.html +4 -4
- package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
- package/dist/utils/commercialLifecyclePresentation.js +196 -0
- package/dist/utils/taskActivity.js +4 -0
- package/dist/utils/taskActivity.test.js +25 -1
- package/package.json +10 -1
- package/scripts/check-cloud-mcp.mjs +83 -0
- package/scripts/playwright-auth.sh +9 -1
- package/scripts/run-billing-performance-smoke.sh +24 -0
- package/dist/ui/assets/AgentsModule-CpsTmrIW.js +0 -1
- package/dist/ui/assets/DocumentWorkspace-C3GyzrWm.js +0 -250
- package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
- package/dist/ui/assets/PlansPage-BP7AYOqr.js +0 -1
- package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +0 -8
- package/dist/ui/assets/index-CLIMgR6g.js +0 -6
- package/dist/ui/assets/index-DneETxcu.css +0 -1
|
@@ -211,6 +211,18 @@ function setupBillingSchema(db) {
|
|
|
211
211
|
function buildBillingRouteHandlers(db, opts) {
|
|
212
212
|
const routes = new Map();
|
|
213
213
|
const tenantId = 'default';
|
|
214
|
+
const resolvePlanIdForVersion = (planVersionIdRaw) => {
|
|
215
|
+
const planVersionId = String(planVersionIdRaw || '').trim();
|
|
216
|
+
if (!planVersionId)
|
|
217
|
+
return '';
|
|
218
|
+
const row = db.prepare(`
|
|
219
|
+
SELECT plan_id
|
|
220
|
+
FROM plan_versions
|
|
221
|
+
WHERE tenant_id = ? AND plan_version_id = ?
|
|
222
|
+
LIMIT 1
|
|
223
|
+
`).get(tenantId, planVersionId);
|
|
224
|
+
return String(row?.plan_id || '').trim();
|
|
225
|
+
};
|
|
214
226
|
const core = {
|
|
215
227
|
getDatabaseAdapter: () => db,
|
|
216
228
|
getTenantId: () => tenantId,
|
|
@@ -223,6 +235,37 @@ function buildBillingRouteHandlers(db, opts) {
|
|
|
223
235
|
ON CONFLICT (user_id) DO NOTHING
|
|
224
236
|
`).run(userId, tenantId, now, now);
|
|
225
237
|
},
|
|
238
|
+
transitionCanonicalCommercialState: (input) => {
|
|
239
|
+
const now = new Date().toISOString();
|
|
240
|
+
const planVersionId = String(input.planVersionId || '').trim();
|
|
241
|
+
const effectiveAt = input.effectiveAt || now;
|
|
242
|
+
const effectiveUntil = input.effectiveUntil || null;
|
|
243
|
+
db.prepare(`
|
|
244
|
+
INSERT INTO account_entitlements (
|
|
245
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
|
|
246
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
247
|
+
ON CONFLICT (account_id) DO UPDATE SET
|
|
248
|
+
plan_version_id = excluded.plan_version_id,
|
|
249
|
+
entitlement_state = excluded.entitlement_state,
|
|
250
|
+
effective_at = excluded.effective_at,
|
|
251
|
+
effective_until = excluded.effective_until,
|
|
252
|
+
updated_by = excluded.updated_by,
|
|
253
|
+
updated_at = excluded.updated_at
|
|
254
|
+
`).run(input.accountId, tenantId, planVersionId, input.state, effectiveAt, effectiveUntil, input.updatedBy, input.createdAt || now, input.updatedAt || now);
|
|
255
|
+
const planId = typeof input.billingMirrorPlanId === 'string'
|
|
256
|
+
? input.billingMirrorPlanId
|
|
257
|
+
: resolvePlanIdForVersion(planVersionId);
|
|
258
|
+
db.prepare(`
|
|
259
|
+
INSERT INTO user_billing (
|
|
260
|
+
user_id, tenant_id, tier, entitlement_state, effective_until, created_at, updated_at
|
|
261
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
262
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
263
|
+
tier = excluded.tier,
|
|
264
|
+
entitlement_state = excluded.entitlement_state,
|
|
265
|
+
effective_until = excluded.effective_until,
|
|
266
|
+
updated_at = excluded.updated_at
|
|
267
|
+
`).run(input.accountId, tenantId, planId, input.state, effectiveUntil, now, now);
|
|
268
|
+
},
|
|
226
269
|
listPlanCatalog: () => [
|
|
227
270
|
{ planId: 'pro', displayName: 'Pro Plan', status: 'active', createdAt: '', updatedAt: '' },
|
|
228
271
|
{ planId: 'inactive-plan', displayName: 'Inactive', status: 'archived', createdAt: '', updatedAt: '' },
|
|
@@ -1372,7 +1415,7 @@ describe('Billing Webhook Routes', () => {
|
|
|
1372
1415
|
stripe_subscription_id: 'sub_email_match'
|
|
1373
1416
|
}));
|
|
1374
1417
|
});
|
|
1375
|
-
it('updates the existing paid subscription
|
|
1418
|
+
it('updates the existing paid subscription and keeps canonical entitlement aligned with the new plan', async () => {
|
|
1376
1419
|
const now = new Date().toISOString();
|
|
1377
1420
|
db.prepare(`
|
|
1378
1421
|
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
@@ -1433,12 +1476,26 @@ describe('Billing Webhook Routes', () => {
|
|
|
1433
1476
|
planVersionId: 'team-v1'
|
|
1434
1477
|
}));
|
|
1435
1478
|
const entitlement = db.prepare(`
|
|
1436
|
-
SELECT plan_version_id
|
|
1479
|
+
SELECT plan_version_id, entitlement_state, updated_by
|
|
1437
1480
|
FROM account_entitlements
|
|
1438
1481
|
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1439
1482
|
LIMIT 1
|
|
1440
1483
|
`).get();
|
|
1441
|
-
expect(entitlement
|
|
1484
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1485
|
+
plan_version_id: 'team-v1',
|
|
1486
|
+
entitlement_state: 'active',
|
|
1487
|
+
updated_by: 'checkout-confirm'
|
|
1488
|
+
}));
|
|
1489
|
+
const billingRow = db.prepare(`
|
|
1490
|
+
SELECT stripe_price_id, billing_interval
|
|
1491
|
+
FROM user_billing
|
|
1492
|
+
WHERE tenant_id = 'default' AND user_id = 'u1'
|
|
1493
|
+
LIMIT 1
|
|
1494
|
+
`).get();
|
|
1495
|
+
expect(billingRow).toEqual(expect.objectContaining({
|
|
1496
|
+
stripe_price_id: 'price_team_month',
|
|
1497
|
+
billing_interval: 'month'
|
|
1498
|
+
}));
|
|
1442
1499
|
});
|
|
1443
1500
|
it('updates the existing subscription when only the interval changes on the same plan', async () => {
|
|
1444
1501
|
const now = new Date().toISOString();
|
|
@@ -1501,7 +1558,7 @@ describe('Billing Webhook Routes', () => {
|
|
|
1501
1558
|
planVersionId: 'pro-v1'
|
|
1502
1559
|
}));
|
|
1503
1560
|
});
|
|
1504
|
-
it('confirms a successful checkout session and
|
|
1561
|
+
it('confirms a successful checkout session, activates canonical entitlement, and closes the checkout attempt', async () => {
|
|
1505
1562
|
const now = new Date().toISOString();
|
|
1506
1563
|
db.prepare(`
|
|
1507
1564
|
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
@@ -1511,6 +1568,11 @@ describe('Billing Webhook Routes', () => {
|
|
|
1511
1568
|
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1512
1569
|
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1513
1570
|
`).run(now, now);
|
|
1571
|
+
db.prepare(`
|
|
1572
|
+
INSERT INTO billing_checkout_attempts (
|
|
1573
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
1574
|
+
) VALUES ('cs_success_123', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
|
|
1575
|
+
`).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
|
|
1514
1576
|
const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
|
|
1515
1577
|
expect(confirmHandler).toBeTypeOf('function');
|
|
1516
1578
|
const req = createMockReq(JSON.stringify({
|
|
@@ -1527,14 +1589,36 @@ describe('Billing Webhook Routes', () => {
|
|
|
1527
1589
|
}));
|
|
1528
1590
|
expect(mockSubscriptionRetrieve).toHaveBeenCalledWith('sub_123');
|
|
1529
1591
|
const entitlement = db.prepare(`
|
|
1530
|
-
SELECT plan_version_id, entitlement_state
|
|
1592
|
+
SELECT plan_version_id, entitlement_state, updated_by
|
|
1531
1593
|
FROM account_entitlements
|
|
1532
1594
|
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1533
1595
|
LIMIT 1
|
|
1534
1596
|
`).get();
|
|
1535
1597
|
expect(entitlement).toEqual(expect.objectContaining({
|
|
1536
1598
|
plan_version_id: 'pro-v1',
|
|
1537
|
-
entitlement_state: 'active'
|
|
1599
|
+
entitlement_state: 'active',
|
|
1600
|
+
updated_by: 'checkout-confirm'
|
|
1601
|
+
}));
|
|
1602
|
+
const billingRow = db.prepare(`
|
|
1603
|
+
SELECT stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status
|
|
1604
|
+
FROM user_billing
|
|
1605
|
+
WHERE tenant_id = 'default' AND user_id = 'u1'
|
|
1606
|
+
LIMIT 1
|
|
1607
|
+
`).get();
|
|
1608
|
+
expect(billingRow).toEqual(expect.objectContaining({
|
|
1609
|
+
stripe_customer_id: 'cus_123',
|
|
1610
|
+
stripe_subscription_id: 'sub_123',
|
|
1611
|
+
stripe_price_id: 'price_pro_month',
|
|
1612
|
+
stripe_status: 'active'
|
|
1613
|
+
}));
|
|
1614
|
+
const attempt = db.prepare(`
|
|
1615
|
+
SELECT status
|
|
1616
|
+
FROM billing_checkout_attempts
|
|
1617
|
+
WHERE tenant_id = 'default' AND session_id = 'cs_success_123'
|
|
1618
|
+
LIMIT 1
|
|
1619
|
+
`).get();
|
|
1620
|
+
expect(attempt).toEqual(expect.objectContaining({
|
|
1621
|
+
status: 'confirmed'
|
|
1538
1622
|
}));
|
|
1539
1623
|
});
|
|
1540
1624
|
it('does not activate entitlement when the checkout session is still incomplete', async () => {
|
|
@@ -1588,6 +1672,156 @@ describe('Billing Webhook Routes', () => {
|
|
|
1588
1672
|
updated_by: 'checkout-start'
|
|
1589
1673
|
}));
|
|
1590
1674
|
});
|
|
1675
|
+
it('keeps checkout confirmation retryable until Stripe attaches the subscription to the completed session', async () => {
|
|
1676
|
+
const now = new Date().toISOString();
|
|
1677
|
+
db.prepare(`
|
|
1678
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1679
|
+
VALUES ('free-v1', 'default', 'free', 1, 1, '{}', ?, ?)
|
|
1680
|
+
`).run(now, now);
|
|
1681
|
+
db.prepare(`
|
|
1682
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1683
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 0, '{}', ?, ?)
|
|
1684
|
+
`).run(now, now);
|
|
1685
|
+
db.prepare(`
|
|
1686
|
+
INSERT INTO account_entitlements (
|
|
1687
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
1688
|
+
) VALUES ('u1', 'default', 'free-v1', 'active', ?, 'registration', ?, ?)
|
|
1689
|
+
`).run(now, now, now);
|
|
1690
|
+
db.prepare(`
|
|
1691
|
+
INSERT INTO billing_checkout_attempts (
|
|
1692
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
1693
|
+
) VALUES ('cs_pending_subscription', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
|
|
1694
|
+
`).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
|
|
1695
|
+
mockCheckoutRetrieve.mockResolvedValueOnce({
|
|
1696
|
+
id: 'cs_pending_subscription',
|
|
1697
|
+
mode: 'subscription',
|
|
1698
|
+
status: 'complete',
|
|
1699
|
+
payment_status: 'paid',
|
|
1700
|
+
customer: 'cus_123',
|
|
1701
|
+
subscription: null,
|
|
1702
|
+
metadata: { userId: 'u1', planVersionId: 'pro-v1' }
|
|
1703
|
+
});
|
|
1704
|
+
const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
|
|
1705
|
+
const req = createMockReq(JSON.stringify({
|
|
1706
|
+
sessionId: 'cs_pending_subscription'
|
|
1707
|
+
}), {
|
|
1708
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1709
|
+
host: 'app.taskforcehq.ai'
|
|
1710
|
+
});
|
|
1711
|
+
const res = createMockRes();
|
|
1712
|
+
await confirmHandler(req, res, {});
|
|
1713
|
+
expect(res.statusCode).toBe(409);
|
|
1714
|
+
expect(JSON.parse(res.body)).toEqual({
|
|
1715
|
+
error: 'Checkout session is still finalizing your subscription.',
|
|
1716
|
+
code: 'CHECKOUT_SESSION_INCOMPLETE'
|
|
1717
|
+
});
|
|
1718
|
+
expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
|
|
1719
|
+
const entitlement = db.prepare(`
|
|
1720
|
+
SELECT plan_version_id, entitlement_state
|
|
1721
|
+
FROM account_entitlements
|
|
1722
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1723
|
+
LIMIT 1
|
|
1724
|
+
`).get();
|
|
1725
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1726
|
+
plan_version_id: 'free-v1',
|
|
1727
|
+
entitlement_state: 'active'
|
|
1728
|
+
}));
|
|
1729
|
+
const attempt = db.prepare(`
|
|
1730
|
+
SELECT status
|
|
1731
|
+
FROM billing_checkout_attempts
|
|
1732
|
+
WHERE tenant_id = 'default' AND session_id = 'cs_pending_subscription'
|
|
1733
|
+
LIMIT 1
|
|
1734
|
+
`).get();
|
|
1735
|
+
expect(attempt).toEqual(expect.objectContaining({
|
|
1736
|
+
status: 'open'
|
|
1737
|
+
}));
|
|
1738
|
+
});
|
|
1739
|
+
it('keeps checkout confirmation retryable until the subscription settles into an active commercial state', async () => {
|
|
1740
|
+
const now = new Date().toISOString();
|
|
1741
|
+
db.prepare(`
|
|
1742
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1743
|
+
VALUES ('free-v1', 'default', 'free', 1, 1, '{}', ?, ?)
|
|
1744
|
+
`).run(now, now);
|
|
1745
|
+
db.prepare(`
|
|
1746
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1747
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 0, '{}', ?, ?)
|
|
1748
|
+
`).run(now, now);
|
|
1749
|
+
db.prepare(`
|
|
1750
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1751
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1752
|
+
`).run(now, now);
|
|
1753
|
+
db.prepare(`
|
|
1754
|
+
INSERT INTO user_billing (
|
|
1755
|
+
user_id, tenant_id, tier, entitlement_state, created_at, updated_at
|
|
1756
|
+
) VALUES ('u1', 'default', 'free', 'active', ?, ?)
|
|
1757
|
+
`).run(now, now);
|
|
1758
|
+
db.prepare(`
|
|
1759
|
+
INSERT INTO account_entitlements (
|
|
1760
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
1761
|
+
) VALUES ('u1', 'default', 'free-v1', 'active', ?, 'registration', ?, ?)
|
|
1762
|
+
`).run(now, now, now);
|
|
1763
|
+
db.prepare(`
|
|
1764
|
+
INSERT INTO billing_checkout_attempts (
|
|
1765
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
1766
|
+
) VALUES ('cs_wait_for_active', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
|
|
1767
|
+
`).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
|
|
1768
|
+
mockCheckoutRetrieve.mockResolvedValueOnce({
|
|
1769
|
+
id: 'cs_wait_for_active',
|
|
1770
|
+
mode: 'subscription',
|
|
1771
|
+
status: 'complete',
|
|
1772
|
+
payment_status: 'paid',
|
|
1773
|
+
customer: 'cus_123',
|
|
1774
|
+
subscription: 'sub_123',
|
|
1775
|
+
metadata: { userId: 'u1', planVersionId: 'pro-v1' }
|
|
1776
|
+
});
|
|
1777
|
+
mockSubscriptionRetrieve.mockResolvedValueOnce({
|
|
1778
|
+
id: 'sub_123',
|
|
1779
|
+
customer: 'cus_123',
|
|
1780
|
+
status: 'incomplete',
|
|
1781
|
+
trial_end: null,
|
|
1782
|
+
metadata: { userId: 'u1', planVersionId: 'pro-v1' },
|
|
1783
|
+
items: {
|
|
1784
|
+
data: [{
|
|
1785
|
+
id: 'si_123',
|
|
1786
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
1787
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
1788
|
+
}]
|
|
1789
|
+
}
|
|
1790
|
+
});
|
|
1791
|
+
const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
|
|
1792
|
+
const req = createMockReq(JSON.stringify({
|
|
1793
|
+
sessionId: 'cs_wait_for_active'
|
|
1794
|
+
}), {
|
|
1795
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1796
|
+
host: 'app.taskforcehq.ai'
|
|
1797
|
+
});
|
|
1798
|
+
const res = createMockRes();
|
|
1799
|
+
await confirmHandler(req, res, {});
|
|
1800
|
+
expect(res.statusCode).toBe(409);
|
|
1801
|
+
expect(JSON.parse(res.body)).toEqual({
|
|
1802
|
+
error: 'Checkout session is still finalizing your plan.',
|
|
1803
|
+
code: 'CHECKOUT_SESSION_INCOMPLETE'
|
|
1804
|
+
});
|
|
1805
|
+
const entitlement = db.prepare(`
|
|
1806
|
+
SELECT plan_version_id, entitlement_state
|
|
1807
|
+
FROM account_entitlements
|
|
1808
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1809
|
+
LIMIT 1
|
|
1810
|
+
`).get();
|
|
1811
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1812
|
+
plan_version_id: 'free-v1',
|
|
1813
|
+
entitlement_state: 'active'
|
|
1814
|
+
}));
|
|
1815
|
+
const attempt = db.prepare(`
|
|
1816
|
+
SELECT status
|
|
1817
|
+
FROM billing_checkout_attempts
|
|
1818
|
+
WHERE tenant_id = 'default' AND session_id = 'cs_wait_for_active'
|
|
1819
|
+
LIMIT 1
|
|
1820
|
+
`).get();
|
|
1821
|
+
expect(attempt).toEqual(expect.objectContaining({
|
|
1822
|
+
status: 'open'
|
|
1823
|
+
}));
|
|
1824
|
+
});
|
|
1591
1825
|
it('rejects checkout confirmation when a newer pending checkout superseded the session plan', async () => {
|
|
1592
1826
|
const now = new Date().toISOString();
|
|
1593
1827
|
db.prepare(`
|
|
@@ -1885,14 +2119,7 @@ describe('Billing Webhook Routes', () => {
|
|
|
1885
2119
|
`).get();
|
|
1886
2120
|
expect(purged).toBeUndefined();
|
|
1887
2121
|
});
|
|
1888
|
-
it('
|
|
1889
|
-
mockPricesList.mockResolvedValueOnce({
|
|
1890
|
-
data: [
|
|
1891
|
-
{ id: 'price_month_1', active: true, currency: 'usd', unit_amount: 1200, recurring: { interval: 'month' }, nickname: 'Monthly', product: 'prod_1' },
|
|
1892
|
-
{ id: 'price_year_1', active: true, currency: 'usd', unit_amount: 12000, recurring: { interval: 'year' }, nickname: 'Yearly', product: 'prod_1' }
|
|
1893
|
-
],
|
|
1894
|
-
has_more: false
|
|
1895
|
-
});
|
|
2122
|
+
it('rejects manual stripe price option listing now that pricing is import-only', async () => {
|
|
1896
2123
|
const route = routeHandlers.get('GET /api/taskforce/admin/billing/stripe-price-options');
|
|
1897
2124
|
expect(route).toBeTypeOf('function');
|
|
1898
2125
|
const req = createMockReq('', {
|
|
@@ -1901,16 +2128,11 @@ describe('Billing Webhook Routes', () => {
|
|
|
1901
2128
|
req.url = '/api/taskforce/admin/billing/stripe-price-options?interval=month';
|
|
1902
2129
|
const res = createMockRes();
|
|
1903
2130
|
await route(req, res, {});
|
|
1904
|
-
expect(res.statusCode).toBe(
|
|
2131
|
+
expect(res.statusCode).toBe(410);
|
|
1905
2132
|
const body = JSON.parse(res.body);
|
|
1906
|
-
expect(
|
|
1907
|
-
expect(body.options.length).toBe(1);
|
|
1908
|
-
expect(body.options[0]).toEqual(expect.objectContaining({
|
|
1909
|
-
stripePriceId: 'price_month_1',
|
|
1910
|
-
interval: 'month'
|
|
1911
|
-
}));
|
|
2133
|
+
expect(body.code).toBe('BILLING_PRICE_MANUAL_MAPPING_REMOVED');
|
|
1912
2134
|
});
|
|
1913
|
-
it('keeps active
|
|
2135
|
+
it('keeps active imported price mappings during Stripe import when the Stripe price is still active but lacks plan metadata', async () => {
|
|
1914
2136
|
const now = new Date().toISOString();
|
|
1915
2137
|
db.prepare(`
|
|
1916
2138
|
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
@@ -1949,23 +2171,12 @@ describe('Billing Webhook Routes', () => {
|
|
|
1949
2171
|
`).get();
|
|
1950
2172
|
expect(row.active).toBe(1);
|
|
1951
2173
|
});
|
|
1952
|
-
it('
|
|
2174
|
+
it('rejects manual price mapping upserts now that pricing is import-only', async () => {
|
|
1953
2175
|
const now = new Date().toISOString();
|
|
1954
2176
|
db.prepare(`
|
|
1955
2177
|
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1956
2178
|
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
1957
2179
|
`).run(now, now);
|
|
1958
|
-
db.prepare(`
|
|
1959
|
-
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at)
|
|
1960
|
-
VALUES ('default', 'collab-v1', 'month', 'price_old', 'stripe', 1, 1100, 'usd', ?, ?)
|
|
1961
|
-
`).run(now, now);
|
|
1962
|
-
mockPriceRetrieve.mockResolvedValueOnce({
|
|
1963
|
-
id: 'price_new',
|
|
1964
|
-
active: true,
|
|
1965
|
-
currency: 'usd',
|
|
1966
|
-
unit_amount: 1500,
|
|
1967
|
-
recurring: { interval: 'month' }
|
|
1968
|
-
});
|
|
1969
2180
|
const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
|
|
1970
2181
|
expect(upsertRoute).toBeTypeOf('function');
|
|
1971
2182
|
const upsertReq = createMockReq(JSON.stringify({
|
|
@@ -1976,19 +2187,12 @@ describe('Billing Webhook Routes', () => {
|
|
|
1976
2187
|
}), { 'x-taskforce-runtime-mode': 'cloud' });
|
|
1977
2188
|
const upsertRes = createMockRes();
|
|
1978
2189
|
await upsertRoute(upsertReq, upsertRes, {});
|
|
1979
|
-
expect(upsertRes.statusCode).toBe(
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
WHERE tenant_id = 'default' AND plan_version_id = 'collab-v1' AND billing_interval = 'month'
|
|
1984
|
-
ORDER BY stripe_price_id ASC
|
|
1985
|
-
`).all();
|
|
1986
|
-
expect(rows).toEqual([
|
|
1987
|
-
{ stripe_price_id: 'price_new', active: 1 },
|
|
1988
|
-
{ stripe_price_id: 'price_old', active: 0 }
|
|
1989
|
-
]);
|
|
2190
|
+
expect(upsertRes.statusCode).toBe(410);
|
|
2191
|
+
expect(JSON.parse(upsertRes.body)).toEqual(expect.objectContaining({
|
|
2192
|
+
code: 'BILLING_PRICE_MANUAL_MAPPING_REMOVED'
|
|
2193
|
+
}));
|
|
1990
2194
|
});
|
|
1991
|
-
it('
|
|
2195
|
+
it('rejects manual reuse of a Stripe price id across multiple plan versions', async () => {
|
|
1992
2196
|
const now = new Date().toISOString();
|
|
1993
2197
|
db.prepare(`
|
|
1994
2198
|
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
@@ -1998,13 +2202,6 @@ describe('Billing Webhook Routes', () => {
|
|
|
1998
2202
|
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1999
2203
|
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
2000
2204
|
`).run(now, now);
|
|
2001
|
-
mockPriceRetrieve.mockResolvedValue({
|
|
2002
|
-
id: 'price_shared',
|
|
2003
|
-
active: true,
|
|
2004
|
-
currency: 'usd',
|
|
2005
|
-
unit_amount: 1900,
|
|
2006
|
-
recurring: { interval: 'month' }
|
|
2007
|
-
});
|
|
2008
2205
|
const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
|
|
2009
2206
|
const firstReq = createMockReq(JSON.stringify({
|
|
2010
2207
|
planVersionId: 'collab-v1',
|
|
@@ -2014,28 +2211,9 @@ describe('Billing Webhook Routes', () => {
|
|
|
2014
2211
|
}), { 'x-taskforce-runtime-mode': 'cloud' });
|
|
2015
2212
|
const firstRes = createMockRes();
|
|
2016
2213
|
await upsertRoute(firstReq, firstRes, {});
|
|
2017
|
-
expect(firstRes.statusCode).toBe(
|
|
2018
|
-
const secondReq = createMockReq(JSON.stringify({
|
|
2019
|
-
planVersionId: 'team-v1',
|
|
2020
|
-
interval: 'month',
|
|
2021
|
-
stripePriceId: 'price_shared',
|
|
2022
|
-
active: true
|
|
2023
|
-
}), { 'x-taskforce-runtime-mode': 'cloud' });
|
|
2024
|
-
const secondRes = createMockRes();
|
|
2025
|
-
await upsertRoute(secondReq, secondRes, {});
|
|
2026
|
-
expect(secondRes.statusCode).toBe(200);
|
|
2027
|
-
const rows = db.prepare(`
|
|
2028
|
-
SELECT plan_version_id, stripe_price_id, active
|
|
2029
|
-
FROM billing_price_mappings
|
|
2030
|
-
WHERE tenant_id = 'default' AND stripe_price_id = 'price_shared'
|
|
2031
|
-
ORDER BY plan_version_id ASC
|
|
2032
|
-
`).all();
|
|
2033
|
-
expect(rows).toEqual([
|
|
2034
|
-
{ plan_version_id: 'collab-v1', stripe_price_id: 'price_shared', active: 1 },
|
|
2035
|
-
{ plan_version_id: 'team-v1', stripe_price_id: 'price_shared', active: 1 }
|
|
2036
|
-
]);
|
|
2214
|
+
expect(firstRes.statusCode).toBe(410);
|
|
2037
2215
|
});
|
|
2038
|
-
it('keeps reused mappings
|
|
2216
|
+
it('keeps reused active mappings during Stripe import when the shared Stripe price is still active', async () => {
|
|
2039
2217
|
const now = new Date().toISOString();
|
|
2040
2218
|
db.prepare(`
|
|
2041
2219
|
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
@@ -2082,7 +2260,7 @@ describe('Billing Webhook Routes', () => {
|
|
|
2082
2260
|
{ plan_version_id: 'team-v1', stripe_price_id: 'price_shared', active: 1 }
|
|
2083
2261
|
]);
|
|
2084
2262
|
});
|
|
2085
|
-
it('
|
|
2263
|
+
it('rejects manual free price mapping upserts now that pricing is import-only', async () => {
|
|
2086
2264
|
const now = new Date().toISOString();
|
|
2087
2265
|
db.prepare(`
|
|
2088
2266
|
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
@@ -2098,17 +2276,10 @@ describe('Billing Webhook Routes', () => {
|
|
|
2098
2276
|
}), { 'x-taskforce-runtime-mode': 'cloud' });
|
|
2099
2277
|
const upsertRes = createMockRes();
|
|
2100
2278
|
await upsertRoute(upsertReq, upsertRes, {});
|
|
2101
|
-
expect(upsertRes.statusCode).toBe(
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
WHERE tenant_id = 'default' AND plan_version_id = 'starter-v1' AND billing_interval = 'month'
|
|
2106
|
-
LIMIT 1
|
|
2107
|
-
`).get();
|
|
2108
|
-
expect(row.stripe_price_id).toBe('free:starter-v1:month');
|
|
2109
|
-
expect(row.pricing_type).toBe('free');
|
|
2110
|
-
expect(row.active).toBe(1);
|
|
2111
|
-
expect(row.unit_amount).toBe(0);
|
|
2279
|
+
expect(upsertRes.statusCode).toBe(410);
|
|
2280
|
+
expect(JSON.parse(upsertRes.body)).toEqual(expect.objectContaining({
|
|
2281
|
+
code: 'BILLING_PRICE_MANUAL_MAPPING_REMOVED'
|
|
2282
|
+
}));
|
|
2112
2283
|
});
|
|
2113
2284
|
it('rejects webhook failure diagnostics for non-admin', async () => {
|
|
2114
2285
|
const nonAdminHandlers = buildBillingRouteHandlers(db, { isAdmin: false });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'http';
|
|
2
2
|
import type { TaskforceCore } from '../../core/Taskforce.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type RouteHandler } from './primitives.js';
|
|
4
4
|
export interface DocumentReviewRouteDependencies {
|
|
5
5
|
addRoute: (method: string, path: string, handler: RouteHandler) => void;
|
|
6
6
|
core: TaskforceCore;
|
|
@@ -28,7 +28,7 @@ import { ObjectStorageClient } from '../../storage/objectStorageClient.js';
|
|
|
28
28
|
import type { DocumentRegistryEntry } from '../../storage/documentRegistry.js';
|
|
29
29
|
import { TaskAssetStore } from '../../storage/taskAssetStore.js';
|
|
30
30
|
import { WorkspaceAssetStore, type WorkspaceAssetEntry, type WorkspaceAssetKind } from '../../storage/workspaceAssetStore.js';
|
|
31
|
-
import type
|
|
31
|
+
import { type RouteContext, type RouteHandler } from './primitives.js';
|
|
32
32
|
type AssetMetadata = DocumentRegistryEntry & {
|
|
33
33
|
quarantineStatus?: 'none' | 'quarantined' | 'released';
|
|
34
34
|
workspaceId?: string;
|
|
@@ -51,7 +51,9 @@ export interface DocumentRouteDependencies {
|
|
|
51
51
|
resolveEffectiveObjectStorageConfig: () => ResolvedObjectStorageConfig;
|
|
52
52
|
getObjectStorageClient: () => ObjectStorageClient | null;
|
|
53
53
|
resolveStorageWorkspaceId: (req: IncomingMessage, body?: any) => string | null;
|
|
54
|
-
ensureContextUploadAllowed: (req: IncomingMessage, res: ServerResponse, workspaceId: string
|
|
54
|
+
ensureContextUploadAllowed: (req: IncomingMessage, res: ServerResponse, workspaceId: string, options?: {
|
|
55
|
+
requestedBytes?: number | null;
|
|
56
|
+
}) => boolean;
|
|
55
57
|
parseStorageFolderSegments: (folderPathRaw: unknown) => string[] | null;
|
|
56
58
|
buildCanonicalWorkspaceAssetRelativePath: (params: {
|
|
57
59
|
workspaceIdRaw: unknown;
|
|
@@ -29,21 +29,53 @@ import { purgeExpiredDocuments } from '../../storage/documentPurge.js';
|
|
|
29
29
|
import { parsePlan } from '../../utils/planParser.js';
|
|
30
30
|
import { getCanonicalDocumentName, getReadableDocumentName, isGeneratedDocumentName } from '../../utils/documentNames.js';
|
|
31
31
|
import { requestRuntimeMode } from './shared.js';
|
|
32
|
-
import { parseJsonBody } from '
|
|
32
|
+
import { parseJsonBody } from './primitives.js';
|
|
33
33
|
export function registerDocumentRoutes(deps) {
|
|
34
34
|
const { addRoute, core, context, requestWorkspaceId, ensureAdminRole, resolveEffectiveObjectStorageConfig, getObjectStorageClient, resolveStorageWorkspaceId, ensureContextUploadAllowed, parseStorageFolderSegments, buildCanonicalWorkspaceAssetRelativePath, inferAssetKindFromPath, appendStorageTelemetry, scanAndRecordDocument, buildTaskAttachmentPayload, workspaceAssetStore, taskAssetStore, normalizeDocumentPathInput, getAssetMetadata, getCanonicalAssetMetadata, documentRetentionDays, appendDocumentPurgeHistory, markAssetDeleted, isAssetDeleted, clearAssetDeleted, ensureWithinDir, sanitizeDownloadFilename, contentTypeForExt, inferInlineDocumentFromPath, resolveCanonicalDocumentRead, inferWorkspaceIdFromStoragePath, inferTaskIdFromStoragePath, } = deps;
|
|
35
|
+
const removeDocumentAttachmentFromTaskAttachments = (attachmentsRaw, asset) => {
|
|
36
|
+
const canonicalDocumentPath = `/api/taskforce/documents/${encodeURIComponent(asset.assetId)}/content`;
|
|
37
|
+
const legacyContextPath = `/api/taskforce/context/${encodeURIComponent(asset.storageKey)}`;
|
|
38
|
+
if (!Array.isArray(attachmentsRaw))
|
|
39
|
+
return [];
|
|
40
|
+
return attachmentsRaw.filter((attachment) => {
|
|
41
|
+
if (typeof attachment === 'string') {
|
|
42
|
+
const normalized = attachment.trim();
|
|
43
|
+
if (!normalized)
|
|
44
|
+
return false;
|
|
45
|
+
return normalized !== canonicalDocumentPath
|
|
46
|
+
&& normalized !== legacyContextPath
|
|
47
|
+
&& normalized !== asset.storageKey;
|
|
48
|
+
}
|
|
49
|
+
if (!attachment || typeof attachment !== 'object')
|
|
50
|
+
return false;
|
|
51
|
+
const candidate = attachment;
|
|
52
|
+
const candidatePath = String(candidate.path || '').trim();
|
|
53
|
+
const candidateFsPath = String(candidate.fsPath || '').trim();
|
|
54
|
+
const candidateAssetId = String(candidate.assetId || '').trim();
|
|
55
|
+
if (candidateAssetId && candidateAssetId === asset.assetId)
|
|
56
|
+
return false;
|
|
57
|
+
if (candidatePath === canonicalDocumentPath || candidatePath === legacyContextPath)
|
|
58
|
+
return false;
|
|
59
|
+
if (candidateFsPath && candidateFsPath === asset.storageKey)
|
|
60
|
+
return false;
|
|
61
|
+
return !!candidatePath;
|
|
62
|
+
});
|
|
63
|
+
};
|
|
35
64
|
addRoute('POST', '/api/taskforce/context-upload/init', async (req, res) => {
|
|
36
65
|
const resolvedObjectStorage = resolveEffectiveObjectStorageConfig();
|
|
37
66
|
const objectStorageClient = getObjectStorageClient();
|
|
38
67
|
const body = await parseJsonBody(req);
|
|
39
68
|
const { originalName, mimeType, taskId, folderPath } = body || {};
|
|
40
69
|
const workspaceId = resolveStorageWorkspaceId(req, body);
|
|
70
|
+
const requestedBytes = Number(body?.size);
|
|
41
71
|
if (!workspaceId) {
|
|
42
72
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
43
73
|
res.end(JSON.stringify({ error: 'workspaceId is required and cannot be default for uploads.' }));
|
|
44
74
|
return;
|
|
45
75
|
}
|
|
46
|
-
if (!ensureContextUploadAllowed(req, res, workspaceId
|
|
76
|
+
if (!ensureContextUploadAllowed(req, res, workspaceId, {
|
|
77
|
+
requestedBytes: Number.isFinite(requestedBytes) ? requestedBytes : null
|
|
78
|
+
}))
|
|
47
79
|
return;
|
|
48
80
|
const folderSegments = parseStorageFolderSegments(folderPath);
|
|
49
81
|
if (folderSegments === null) {
|
|
@@ -144,6 +176,10 @@ export function registerDocumentRoutes(deps) {
|
|
|
144
176
|
res.end(JSON.stringify({ error: 'Uploaded file not found.' }));
|
|
145
177
|
return;
|
|
146
178
|
}
|
|
179
|
+
if (!ensureContextUploadAllowed(req, res, workspaceId, {
|
|
180
|
+
requestedBytes: object.body.length
|
|
181
|
+
}))
|
|
182
|
+
return;
|
|
147
183
|
try {
|
|
148
184
|
const taskId = typeof body?.taskId === 'string' && body.taskId.trim().length > 0
|
|
149
185
|
? body.taskId.trim()
|
|
@@ -191,8 +227,6 @@ export function registerDocumentRoutes(deps) {
|
|
|
191
227
|
res.end(JSON.stringify({ error: 'workspaceId is required and cannot be default for uploads.' }));
|
|
192
228
|
return;
|
|
193
229
|
}
|
|
194
|
-
if (!ensureContextUploadAllowed(req, res, workspaceId))
|
|
195
|
-
return;
|
|
196
230
|
const folderSegments = parseStorageFolderSegments(folderPath);
|
|
197
231
|
if (folderSegments === null) {
|
|
198
232
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -233,6 +267,10 @@ export function registerDocumentRoutes(deps) {
|
|
|
233
267
|
const actualFilename = `${safeBase}-${Date.now()}${extension}`;
|
|
234
268
|
const { basePath, projectRoot } = core.getPaths();
|
|
235
269
|
const buffer = Buffer.from(base64Data, 'base64');
|
|
270
|
+
if (!ensureContextUploadAllowed(req, res, workspaceId, {
|
|
271
|
+
requestedBytes: buffer.length
|
|
272
|
+
}))
|
|
273
|
+
return;
|
|
236
274
|
const relativeToStorage = buildCanonicalWorkspaceAssetRelativePath({
|
|
237
275
|
workspaceIdRaw: workspaceId,
|
|
238
276
|
kind: mimeType.startsWith('image/') ? 'image' : inferAssetKindFromPath(actualFilename),
|
|
@@ -771,6 +809,8 @@ export function registerDocumentRoutes(deps) {
|
|
|
771
809
|
addRoute('DELETE', '/api/taskforce/documents/:assetId', async (req, res, params) => {
|
|
772
810
|
const workspaceId = requestWorkspaceId(req);
|
|
773
811
|
const assetId = String(params.assetId || '').trim();
|
|
812
|
+
const body = await parseJsonBody(req);
|
|
813
|
+
const forceDetach = body?.forceDetach === true;
|
|
774
814
|
if (!workspaceAssetStore || !assetId) {
|
|
775
815
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
776
816
|
res.end(JSON.stringify({ error: 'Document not found' }));
|
|
@@ -786,14 +826,39 @@ export function registerDocumentRoutes(deps) {
|
|
|
786
826
|
.filter((link) => !link.deletedAt)
|
|
787
827
|
.filter((link) => !!core.getTask(link.taskId, workspaceId));
|
|
788
828
|
if (activeLinks.length > 0) {
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
829
|
+
if (!forceDetach) {
|
|
830
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
831
|
+
res.end(JSON.stringify({
|
|
832
|
+
error: 'Document is still attached to one or more tasks.',
|
|
833
|
+
activeAttachmentCount: activeLinks.length,
|
|
834
|
+
requiresForceDetach: true
|
|
835
|
+
}));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
for (const link of activeLinks) {
|
|
839
|
+
const task = core.getTask(link.taskId, workspaceId);
|
|
840
|
+
if (!task)
|
|
841
|
+
continue;
|
|
842
|
+
const updatedAttachments = removeDocumentAttachmentFromTaskAttachments(task.attachments, asset);
|
|
843
|
+
core.updateTask(link.taskId, { attachments: updatedAttachments }, workspaceId, {
|
|
844
|
+
actorRef: 'user',
|
|
845
|
+
saveSource: 'manual'
|
|
846
|
+
});
|
|
847
|
+
}
|
|
792
848
|
}
|
|
793
849
|
const nowIso = new Date().toISOString();
|
|
794
850
|
const purgeAfter = new Date(Date.now() + documentRetentionDays * 24 * 60 * 60 * 1000).toISOString();
|
|
795
851
|
const metadata = markAssetDeleted(asset.storageKey);
|
|
796
852
|
workspaceAssetStore.markDeleted(asset.assetId, workspaceId, nowIso, purgeAfter);
|
|
853
|
+
const links = workspaceAssetStore.listLinksForAsset(asset.assetId, workspaceId, { includeDeleted: true });
|
|
854
|
+
for (const link of links) {
|
|
855
|
+
workspaceAssetStore.deleteLink({
|
|
856
|
+
workspaceId,
|
|
857
|
+
assetId: asset.assetId,
|
|
858
|
+
taskId: link.taskId,
|
|
859
|
+
role: link.role
|
|
860
|
+
});
|
|
861
|
+
}
|
|
797
862
|
appendStorageTelemetry('delete.soft', {
|
|
798
863
|
path: asset.storageKey,
|
|
799
864
|
status: 'soft-deleted',
|
|
@@ -803,6 +868,7 @@ export function registerDocumentRoutes(deps) {
|
|
|
803
868
|
res.end(JSON.stringify({
|
|
804
869
|
success: true,
|
|
805
870
|
softDeleted: true,
|
|
871
|
+
detachedTaskCount: activeLinks.length,
|
|
806
872
|
retentionDays: documentRetentionDays,
|
|
807
873
|
purgeAfter: metadata?.purgeAfter || purgeAfter
|
|
808
874
|
}));
|