@taskforcehq/taskforce 0.3.170 → 0.3.300
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 +52 -21
- package/dist/Taskforce.module.css +3394 -5981
- package/dist/TaskforceCore.CategoryFallback.test.js +5 -2
- package/dist/TaskforceCore.filter-persistence.test.js +402 -0
- package/dist/TaskforceCore.js +388 -71
- package/dist/TaskforceCore.routing.test.js +311 -0
- package/dist/TaskforceCore.test.js +3344 -615
- package/dist/TaskforceWidget.js +3 -2
- package/dist/__tests__/localizationCatalogs.test.js +48 -31
- package/dist/__tests__/workflowSimulation.test.js +2 -2
- package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.d.ts +60 -0
- package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.js +324 -0
- package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.test.js +278 -0
- package/dist/annotatedAttachments/payload.d.ts +2 -0
- package/dist/annotatedAttachments/payload.js +54 -0
- package/dist/annotatedAttachments/payload.test.js +67 -0
- package/dist/annotatedAttachments/service.d.ts +49 -0
- package/dist/annotatedAttachments/service.js +121 -0
- package/dist/annotatedAttachments/types.d.ts +86 -0
- package/dist/cli.js +74 -25
- package/dist/compat/deploymentCompat.d.ts +12 -0
- package/dist/compat/deploymentCompat.js +43 -0
- package/dist/compat/workspaceSyncCompat.d.ts +6 -0
- package/dist/compat/workspaceSyncCompat.js +79 -0
- package/dist/compat/workspaceSyncCompat.test.js +87 -0
- package/dist/components/features/AgentsModule.d.ts +5 -0
- package/dist/components/features/AgentsModule.js +131 -0
- package/dist/components/features/AgentsModule.test.js +102 -0
- package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +24 -0
- package/dist/components/features/AnnotatedAttachmentWorkspace.js +1942 -0
- package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +2831 -0
- package/dist/components/features/DocumentGenerateModal.js +8 -16
- package/dist/components/features/DocumentIndex.d.ts +3 -4
- package/dist/components/features/DocumentIndex.js +22 -10
- package/dist/components/features/DocumentViewer.d.ts +6 -2
- package/dist/components/features/DocumentViewer.js +1000 -18
- package/dist/components/features/DocumentWorkspace.d.ts +40 -2
- package/dist/components/features/DocumentWorkspace.js +96 -16
- package/dist/components/features/DocumentWorkspace.test.js +1202 -0
- package/dist/components/features/InitiativesModule.d.ts +22 -0
- package/dist/components/features/InitiativesModule.js +33 -0
- package/dist/components/features/TaskSettings.d.ts +41 -32
- package/dist/components/features/TaskSettings.js +798 -313
- package/dist/components/features/TaskSettings.test.js +1221 -47
- package/dist/components/features/WorkflowsModule.d.ts +27 -0
- package/dist/components/features/WorkflowsModule.js +21 -0
- package/dist/components/features/documentWorkspaceFilters.d.ts +10 -0
- package/dist/components/features/documentWorkspaceFilters.js +11 -0
- package/dist/components/features/settings/CategoryManager.d.ts +2 -0
- package/dist/components/features/settings/CategoryManager.js +15 -8
- package/dist/components/features/settings/PriorityManager.d.ts +18 -0
- package/dist/components/features/settings/PriorityManager.js +51 -0
- package/dist/components/features/settings/TaxonomyEditor.d.ts +2 -1
- package/dist/components/features/settings/TaxonomyEditor.js +41 -18
- package/dist/components/features/settings/TaxonomyLibraryManager.d.ts +26 -0
- package/dist/components/features/settings/TaxonomyLibraryManager.js +301 -0
- package/dist/components/features/settings/TaxonomyLibraryManager.test.js +141 -0
- package/dist/components/features/settings/TaxonomyManager.d.ts +35 -12
- package/dist/components/features/settings/TaxonomyManager.js +59 -27
- package/dist/components/features/settings/TypeManager.d.ts +11 -0
- package/dist/components/features/settings/TypeManager.js +48 -11
- package/dist/components/features/settings/{WorkflowEditorPrototype.d.ts → WorkflowEditor.d.ts} +2 -2
- package/dist/components/features/settings/{WorkflowEditorPrototype.js → WorkflowEditor.js} +3 -3
- package/dist/components/task/TaskActionHeader.d.ts +12 -2
- package/dist/components/task/TaskActionHeader.js +64 -8
- package/dist/components/task/TaskActionHeader.test.js +73 -0
- package/dist/components/task/TaskCard.d.ts +14 -16
- package/dist/components/task/TaskCard.js +144 -174
- package/dist/components/task/TaskCard.test.js +416 -64
- package/dist/components/task/TaskContextUpload.d.ts +6 -5
- package/dist/components/task/TaskContextUpload.js +199 -64
- package/dist/components/task/TaskContextUpload.test.js +331 -14
- package/dist/components/task/TaskForm.d.ts +23 -29
- package/dist/components/task/TaskForm.js +348 -169
- package/dist/components/task/TaskForm.test.js +419 -23
- package/dist/components/task/TaskKanban.d.ts +20 -21
- package/dist/components/task/TaskKanban.js +176 -316
- package/dist/components/task/TaskKanban.planning-drop.test.js +93 -0
- package/dist/components/task/TaskList.d.ts +22 -11
- package/dist/components/task/TaskList.js +98 -12
- package/dist/components/task/TaskList.test.js +206 -7
- package/dist/components/task/TaskReferenceBadge.d.ts +12 -0
- package/dist/components/task/TaskReferenceBadge.js +5 -0
- package/dist/components/task/TaskStatusActions.d.ts +12 -0
- package/dist/components/task/TaskStatusActions.js +18 -0
- package/dist/components/task/TaskStatusActions.test.js +45 -0
- package/dist/components/ui/ImageReferenceBadge.d.ts +12 -0
- package/dist/components/ui/ImageReferenceBadge.js +5 -0
- package/dist/components/ui/LevelSelector.d.ts +2 -1
- package/dist/components/ui/LevelSelector.js +2 -2
- package/dist/components/ui/Markdown.d.ts +1 -2
- package/dist/components/ui/Markdown.js +2 -13
- package/dist/components/ui/Modal.d.ts +3 -2
- package/dist/components/ui/Modal.js +4 -3
- package/dist/components/ui/MultiSelectFilter.d.ts +3 -1
- package/dist/components/ui/MultiSelectFilter.js +2 -2
- package/dist/components/ui/ReferenceBadgeButton.d.ts +13 -0
- package/dist/components/ui/ReferenceBadgeButton.js +6 -0
- package/dist/components/ui/TaxonomyDropdown.d.ts +7 -1
- package/dist/components/ui/TaxonomyDropdown.js +58 -23
- package/dist/components/ui/TopNoticeLayer.d.ts +7 -0
- package/dist/components/ui/TopNoticeLayer.js +14 -0
- package/dist/components/ui/TopNoticeLayer.test.js +25 -0
- package/dist/components/views/AppShellHeader.d.ts +13 -0
- package/dist/components/views/AppShellHeader.js +19 -0
- package/dist/components/views/AppShellHeader.test.js +21 -0
- package/dist/components/views/PlanComparisonPage.d.ts +73 -0
- package/dist/components/views/PlanComparisonPage.js +196 -0
- package/dist/components/views/PlanComparisonPage.test.js +251 -0
- package/dist/components/views/PlansPage.d.ts +14 -0
- package/dist/components/views/PlansPage.js +355 -0
- package/dist/components/views/StandaloneLayout.d.ts +2 -2
- package/dist/components/views/StandaloneLayout.js +2212 -1194
- package/dist/components/views/WidgetView.d.ts +2 -2
- package/dist/components/views/WidgetView.js +149 -43
- package/dist/components/views/chrome/WorkspaceChromeSections.d.ts +57 -0
- package/dist/components/views/chrome/WorkspaceChromeSections.js +38 -0
- package/dist/components/views/chrome/WorkspaceChromeSections.test.js +54 -0
- package/dist/components/views/chrome/workspaceChrome.d.ts +31 -0
- package/dist/components/views/chrome/workspaceChrome.js +108 -0
- package/dist/components/views/chrome/workspaceChrome.test.js +52 -0
- package/dist/components/views/panels/DocumentFilterToolbar.d.ts +10 -0
- package/dist/components/views/panels/DocumentFilterToolbar.js +7 -0
- package/dist/components/views/panels/DocumentFilterToolbar.test.js +19 -0
- package/dist/components/views/panels/FilterToolbar.d.ts +66 -0
- package/dist/components/views/panels/FilterToolbar.js +21 -0
- package/dist/components/views/panels/FilterToolbar.test.d.ts +1 -0
- package/dist/components/views/panels/FilterToolbar.test.js +45 -0
- package/dist/components/views/panels/FilterToolbarControls.d.ts +46 -0
- package/dist/components/views/panels/FilterToolbarControls.js +61 -0
- package/dist/components/views/panels/PlanningDrawer.d.ts +115 -0
- package/dist/components/views/panels/PlanningDrawer.js +220 -0
- package/dist/components/views/panels/ScheduleSidebar.d.ts +52 -0
- package/dist/components/views/panels/ScheduleSidebar.js +127 -0
- package/dist/components/views/panels/scheduleUtils.d.ts +14 -0
- package/dist/components/views/panels/scheduleUtils.js +79 -0
- package/dist/components/views/teamManagementAccess.d.ts +6 -0
- package/dist/components/views/teamManagementAccess.js +18 -0
- package/dist/components/views/teamManagementAccess.test.d.ts +1 -0
- package/dist/components/views/teamManagementAccess.test.js +44 -0
- package/dist/config/clientDeployment.d.ts +2 -0
- package/dist/config/clientDeployment.js +13 -0
- package/dist/config/cloudEnvironment.d.ts +15 -0
- package/dist/config/cloudEnvironment.js +60 -0
- package/dist/config/deployment.d.ts +20 -0
- package/dist/config/deployment.js +67 -0
- package/dist/config/deployment.test.d.ts +1 -0
- package/dist/config/deployment.test.js +88 -0
- package/dist/config/envSchema.js +36 -8
- package/dist/config/envSchema.test.js +28 -0
- package/dist/core/AiProfileService.d.ts +66 -0
- package/dist/core/AiProfileService.js +320 -0
- package/dist/core/AiProfileService.test.d.ts +1 -0
- package/dist/core/AiProfileService.test.js +164 -0
- package/dist/core/AiProfiles.test.d.ts +1 -0
- package/dist/core/AiProfiles.test.js +449 -0
- package/dist/core/AttachmentLinkService.d.ts +15 -0
- package/dist/core/AttachmentLinkService.js +157 -0
- package/dist/core/AuthTokenService.d.ts +44 -0
- package/dist/core/AuthTokenService.js +207 -0
- package/dist/core/CreatedBy.test.js +134 -6
- package/dist/core/DeletedTaskLifecycleService.d.ts +33 -0
- package/dist/core/DeletedTaskLifecycleService.js +158 -0
- package/dist/core/DeletedTaskTrash.test.d.ts +1 -0
- package/dist/core/DeletedTaskTrash.test.js +104 -0
- package/dist/core/EntitlementsPolicy.test.js +78 -5
- package/dist/core/GlobalSettingsService.d.ts +16 -0
- package/dist/core/GlobalSettingsService.js +370 -0
- package/dist/core/JsonBackupRuntime.test.d.ts +1 -0
- package/dist/core/JsonBackupRuntime.test.js +155 -0
- package/dist/core/McpTokenService.d.ts +118 -0
- package/dist/core/McpTokenService.js +921 -0
- package/dist/core/PlanEntitlementService.d.ts +201 -0
- package/dist/core/PlanEntitlementService.js +1442 -0
- package/dist/core/PlanVersionPolicy.test.d.ts +1 -0
- package/dist/core/PlanVersionPolicy.test.js +178 -0
- package/dist/core/PlanningEntities.test.d.ts +1 -0
- package/dist/core/PlanningEntities.test.js +95 -0
- package/dist/core/ScheduleFields.test.js +15 -19
- package/dist/core/SignupMonetizationSettings.test.d.ts +1 -0
- package/dist/core/SignupMonetizationSettings.test.js +159 -0
- package/dist/core/Stats.test.d.ts +1 -0
- package/dist/core/Stats.test.js +36 -0
- package/dist/core/SyncReconciliationService.d.ts +93 -0
- package/dist/core/SyncReconciliationService.js +406 -0
- package/dist/core/SystemAdmin.test.js +578 -13
- package/dist/core/TaskActivityService.d.ts +21 -0
- package/dist/core/TaskActivityService.js +130 -0
- package/dist/core/TaskAttachmentsCanonical.test.d.ts +1 -0
- package/dist/core/TaskAttachmentsCanonical.test.js +306 -0
- package/dist/core/TaskAuditTimeline.test.d.ts +1 -0
- package/dist/core/TaskAuditTimeline.test.js +132 -0
- package/dist/core/TaskTaxonomyValidation.test.js +338 -15
- package/dist/core/Taskforce.d.ts +930 -183
- package/dist/core/Taskforce.ids.test.js +252 -0
- package/dist/core/Taskforce.js +3848 -2532
- package/dist/core/Taskforce.mcpAuth.test.d.ts +1 -0
- package/dist/core/Taskforce.mcpAuth.test.js +343 -0
- package/dist/core/Taskforce.realtimeMutation.test.js +57 -2
- package/dist/core/TaxonomySettingsService.d.ts +18 -0
- package/dist/core/TaxonomySettingsService.js +162 -0
- package/dist/core/WorkspaceDelete.test.js +32 -13
- package/dist/core/WorkspaceLifecycleService.d.ts +11 -0
- package/dist/core/WorkspaceLifecycleService.js +88 -0
- package/dist/core/WorkspacePermissions.test.js +95 -0
- package/dist/core/WorkspacePlanMode.test.js +114 -1
- package/dist/core/WorkspacePreferenceService.d.ts +41 -0
- package/dist/core/WorkspacePreferenceService.js +246 -0
- package/dist/core/WorkspaceSyncSettings.test.d.ts +1 -0
- package/dist/core/WorkspaceSyncSettings.test.js +65 -0
- package/dist/core/__tests__/TaskAttachmentsCanonical.test.d.ts +1 -0
- package/dist/core/__tests__/TaskAttachmentsCanonical.test.js +212 -0
- package/dist/core/shared.d.ts +36 -0
- package/dist/core/shared.js +128 -0
- package/dist/core/types.d.ts +614 -0
- package/dist/core/types.js +27 -0
- package/dist/documentReviews/DocumentReviewCommentStore.d.ts +56 -0
- package/dist/documentReviews/DocumentReviewCommentStore.js +230 -0
- package/dist/documentReviews/DocumentReviewSessionStore.d.ts +52 -0
- package/dist/documentReviews/DocumentReviewSessionStore.js +193 -0
- package/dist/documentReviews/DocumentReviewSessionStore.test.d.ts +1 -0
- package/dist/documentReviews/DocumentReviewSessionStore.test.js +127 -0
- package/dist/documentReviews/service.d.ts +68 -0
- package/dist/documentReviews/service.js +210 -0
- package/dist/documentReviews/types.d.ts +58 -0
- package/dist/documentReviews/types.js +1 -0
- package/dist/hooks/auth/useAuthGuardPolicy.d.ts +2 -0
- package/dist/hooks/auth/useAuthGuardPolicy.js +1 -0
- package/dist/hooks/tasks/useCurrentTaskRelations.d.ts +15 -0
- package/dist/hooks/tasks/useCurrentTaskRelations.js +37 -0
- package/dist/hooks/tasks/useTaskActions.d.ts +3 -0
- package/dist/hooks/tasks/useTaskActions.js +2 -0
- package/dist/hooks/tasks/useTaskEditorNavigation.d.ts +65 -0
- package/dist/hooks/tasks/useTaskEditorNavigation.js +235 -0
- package/dist/hooks/tasks/useTaskFilters.d.ts +2 -0
- package/dist/hooks/tasks/useTaskFilters.js +2 -0
- package/dist/hooks/tasks/useTaskFormActions.d.ts +58 -0
- package/dist/hooks/tasks/useTaskFormActions.js +365 -0
- package/dist/hooks/tasks/useTaskRelationships.d.ts +16 -0
- package/dist/hooks/tasks/useTaskRelationships.js +64 -0
- package/dist/hooks/ui/useResourceExport.d.ts +19 -0
- package/dist/hooks/ui/useResourceExport.js +84 -0
- package/dist/hooks/ui/useSettingsModel.d.ts +203 -0
- package/dist/hooks/ui/useSettingsModel.js +125 -0
- package/dist/hooks/ui/useSettingsPersistence.d.ts +36 -0
- package/dist/hooks/ui/useSettingsPersistence.js +254 -0
- package/dist/hooks/ui/useSettingsTaxonomyManagement.d.ts +119 -0
- package/dist/hooks/ui/useSettingsTaxonomyManagement.js +536 -0
- package/dist/hooks/ui/useUiNotice.d.ts +1 -0
- package/dist/hooks/ui/useUiNotice.js +1 -0
- package/dist/hooks/ui/useWorkflowTemplates.d.ts +37 -0
- package/dist/hooks/ui/useWorkflowTemplates.js +111 -0
- package/dist/hooks/ui/useZenMode.d.ts +5 -0
- package/dist/hooks/ui/useZenMode.js +32 -0
- package/dist/hooks/useFilterSort.d.ts +60 -0
- package/dist/hooks/useFilterSort.js +362 -0
- package/dist/hooks/useFilterSort.test.d.ts +1 -0
- package/dist/hooks/useFilterSort.test.js +333 -0
- package/dist/hooks/useRealtimeSync.d.ts +1 -0
- package/dist/hooks/useRealtimeSync.js +24 -9
- package/dist/hooks/useRealtimeSync.test.js +28 -1
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.d.ts +1 -0
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +34 -0
- package/dist/hooks/useSyncOrchestrator.d.ts +65 -103
- package/dist/hooks/useSyncOrchestrator.js +1538 -670
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +700 -174
- package/dist/hooks/useTaskData.d.ts +35 -0
- package/dist/hooks/useTaskData.js +239 -0
- package/dist/hooks/useTaskData.test.d.ts +1 -0
- package/dist/hooks/useTaskData.test.js +119 -0
- package/dist/hooks/useTaskMutations.d.ts +46 -0
- package/dist/hooks/useTaskMutations.js +472 -0
- package/dist/hooks/useTaskMutations.test.d.ts +1 -0
- package/dist/hooks/useTaskMutations.test.js +80 -0
- package/dist/hooks/useTaskforce.contract.test.d.ts +1 -0
- package/dist/hooks/useTaskforce.contract.test.js +7 -0
- package/dist/hooks/useTaskforce.d.ts +260 -295
- package/dist/hooks/useTaskforce.js +1954 -2623
- package/dist/hooks/useTaskforce.priority-filters.test.js +6 -11
- package/dist/hooks/useTaskforce.sync-behavior.test.d.ts +1 -0
- package/dist/hooks/useTaskforce.sync-behavior.test.js +1120 -0
- package/dist/hooks/useUiNotice.d.ts +16 -0
- package/dist/hooks/useUiNotice.js +49 -0
- package/dist/hooks/useWorkspaceSyncController.d.ts +23 -46
- package/dist/hooks/useWorkspaceSyncController.js +280 -461
- package/dist/hooks/useWorkspaceSyncController.test.js +106 -199
- package/dist/hooks/workspace/useTaskforceApiRouting.d.ts +8 -0
- package/dist/hooks/workspace/useTaskforceApiRouting.js +91 -0
- package/dist/hooks/workspace/useTaskforceApiRouting.test.d.ts +1 -0
- package/dist/hooks/workspace/useTaskforceApiRouting.test.js +60 -0
- package/dist/hooks/workspace/useWorkspaceSyncController.d.ts +1 -0
- package/dist/hooks/workspace/useWorkspaceSyncController.js +1 -0
- package/dist/hooks/workspace/workspaceLocalState.d.ts +30 -0
- package/dist/hooks/workspace/workspaceLocalState.js +101 -0
- package/dist/localization/locales/en-US.d.ts +1 -8
- package/dist/localization/locales/en-US.js +4 -11
- package/dist/localization/locales/es-419.js +2 -9
- package/dist/localization/locales/pt-BR.js +2 -9
- package/dist/localization/localizationCatalogs.test.d.ts +1 -0
- package/dist/localization/localizationCatalogs.test.js +37 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +9 -15
- package/dist/mcp/clientIntegrations.d.ts +33 -0
- package/dist/mcp/clientIntegrations.js +199 -0
- package/dist/mcp/clientIntegrations.test.d.ts +1 -0
- package/dist/mcp/clientIntegrations.test.js +110 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/optionResolution.test.js +6 -2
- package/dist/mcp/runtime.d.ts +120 -0
- package/dist/mcp/runtime.js +5668 -0
- package/dist/mcp/runtime.test.d.ts +1 -0
- package/dist/mcp/runtime.test.js +3293 -0
- package/dist/mcp/server.js +2 -1793
- package/dist/mcp/structuredCommentValidation.test.d.ts +1 -0
- package/dist/{__tests__ → mcp}/structuredCommentValidation.test.js +1 -1
- package/dist/mcp/structuredDocValidation.js +1 -2
- package/dist/mcp/structuredDocValidation.test.d.ts +1 -0
- package/dist/{__tests__ → mcp}/structuredDocValidation.test.js +9 -4
- package/dist/mcp/toolRegistry.d.ts +570 -0
- package/dist/mcp/toolRegistry.js +90 -0
- package/dist/mcp/toolRegistry.test.d.ts +1 -0
- package/dist/mcp/toolRegistry.test.js +30 -0
- package/dist/migrations/billingSchemaParity.test.js +111 -0
- package/dist/migrations/taskSchemaMigrations.d.ts +5 -0
- package/dist/migrations/taskSchemaMigrations.js +681 -39
- package/dist/migrations/taskSchemaMigrations.test.d.ts +1 -0
- package/dist/migrations/taskSchemaMigrations.test.js +210 -0
- package/dist/plugins/vite.js +13 -7
- package/dist/public/apple-touch-icon.png +0 -0
- package/dist/public/archive/phoenix/apple-touch-icon.png +0 -0
- package/dist/public/archive/phoenix/favicon.ico +0 -0
- package/dist/public/archive/phoenix/site.webmanifest +1 -0
- package/dist/public/favicon-96x96.png +0 -0
- package/dist/public/favicon.ico +0 -0
- package/dist/public/favicon.svg +17 -0
- package/dist/public/site.webmanifest +21 -1
- package/dist/public/web-app-manifest-192x192.png +0 -0
- package/dist/public/web-app-manifest-512x512.png +0 -0
- package/dist/releaseVersion.test.d.ts +1 -0
- package/dist/{__tests__/releaseVersion.test.js → releaseVersion.test.js} +1 -1
- package/dist/resources/templates/WORKFLOW_V2_POLICY.md +1 -1
- package/dist/resources/templates/workflow-sources/workflowDocs.mjs +62 -52
- package/dist/resources/templates/workflows/collaborate.yaml +11 -5
- package/dist/resources/templates/workflows/do.yaml +8 -2
- package/dist/resources/templates/workflows/evaluate.yaml +13 -7
- package/dist/resources/templates/workflows/execute.yaml +40 -23
- package/dist/resources/templates/workflows/plan.yaml +15 -8
- package/dist/resources/templates/workflows/review.yaml +14 -8
- package/dist/runtime/appGateDefinitions.d.ts +32 -0
- package/dist/runtime/appGateDefinitions.js +32 -0
- package/dist/runtime/appGates.d.ts +29 -0
- package/dist/runtime/appGates.js +53 -0
- package/dist/runtime/appGates.test.d.ts +1 -0
- package/dist/runtime/appGates.test.js +42 -0
- package/dist/runtime/migrationFlags.d.ts +1 -0
- package/dist/runtime/migrationFlags.js +1 -0
- package/dist/server/annotatedAttachmentsRoutes.test.d.ts +1 -0
- package/dist/server/annotatedAttachmentsRoutes.test.js +473 -0
- package/dist/server/auth.js +14 -2
- package/dist/server/auth.test.js +21 -1
- package/dist/server/cors.js +11 -5
- package/dist/server/cors.test.js +6 -0
- package/dist/server/documentReviewRoutes.test.d.ts +1 -0
- package/dist/server/documentReviewRoutes.test.js +157 -0
- package/dist/server/index.cookieProxy.test.js +46 -1
- package/dist/server/index.d.ts +20 -0
- package/dist/server/index.js +168 -41
- package/dist/server/index.rateLimit.test.js +6 -0
- package/dist/server/index.test.js +133 -2
- package/dist/server/mockStripe.d.ts +72 -0
- package/dist/server/mockStripe.js +357 -0
- package/dist/server/realtimeSyncWs.js +4 -0
- package/dist/server/routes/admin.d.ts +114 -0
- package/dist/server/routes/admin.js +2226 -0
- package/dist/server/routes/annotatedAttachments.d.ts +24 -0
- package/dist/server/routes/annotatedAttachments.js +235 -0
- package/dist/server/routes/auth.d.ts +61 -0
- package/dist/server/routes/auth.js +1296 -0
- package/dist/server/{routes.billing.d.ts → routes/billing.d.ts} +3 -2
- package/dist/server/routes/billing.js +2001 -0
- package/dist/server/routes/billing.test.d.ts +1 -0
- package/dist/server/routes/billing.test.js +1631 -0
- package/dist/server/routes/documentReviews.d.ts +11 -0
- package/dist/server/routes/documentReviews.js +191 -0
- package/dist/server/routes/documents.d.ts +118 -0
- package/dist/server/routes/documents.js +1113 -0
- package/dist/server/routes/resources.d.ts +25 -0
- package/dist/server/routes/resources.js +225 -0
- package/dist/server/routes/shared.d.ts +35 -0
- package/dist/server/routes/shared.js +305 -0
- package/dist/server/routes/shared.test.d.ts +1 -0
- package/dist/server/routes/shared.test.js +32 -0
- package/dist/server/{routes.sync.d.ts → routes/sync.d.ts} +5 -5
- package/dist/server/routes/sync.integration.test.d.ts +1 -0
- package/dist/server/routes/sync.integration.test.js +2003 -0
- package/dist/server/routes/sync.js +3143 -0
- package/dist/server/routes/tasks.d.ts +72 -0
- package/dist/server/routes/tasks.js +1252 -0
- package/dist/server/routes/workspaces.d.ts +32 -0
- package/dist/server/routes/workspaces.js +307 -0
- package/dist/server/routes.d.ts +8 -9
- package/dist/server/routes.js +968 -4092
- package/dist/server/routes.test.js +9043 -2415
- package/dist/server/runtimeMode.d.ts +1 -1
- package/dist/server/runtimeMode.js +3 -18
- package/dist/server/runtimeMode.test.js +3 -3
- package/dist/server/securityHeaders.d.ts +1 -1
- package/dist/server/securityHeaders.js +65 -6
- package/dist/server/securityHeaders.test.js +40 -2
- package/dist/services/templateEngine.test.d.ts +1 -0
- package/dist/{__tests__ → services}/templateEngine.test.js +1 -1
- package/dist/services/templates.test.d.ts +1 -0
- package/dist/{__tests__ → services}/templates.test.js +1 -1
- package/dist/services/workflowDocumentFixtures.test.d.ts +1 -0
- package/dist/{__tests__ → services}/workflowDocumentFixtures.test.js +4 -4
- package/dist/services/workflowExportMatrix.test.d.ts +1 -0
- package/dist/{__tests__ → services}/workflowExportMatrix.test.js +1 -1
- package/dist/services/workflowExportSnapshots.test.d.ts +1 -0
- package/dist/services/workflowGeneration.test.d.ts +1 -0
- package/dist/services/workflowSimulation.test.d.ts +1 -0
- package/dist/services/workflowSimulation.test.js +113 -0
- package/dist/services/workflowV2Policy.test.d.ts +1 -0
- package/dist/{__tests__ → services}/workflowV2Policy.test.js +2 -2
- package/dist/shared/aiProfileSurfaceType.d.ts +9 -0
- package/dist/shared/aiProfileSurfaceType.js +49 -0
- package/dist/shared/customTaxonomyLibrary.d.ts +11 -0
- package/dist/shared/customTaxonomyLibrary.js +276 -0
- package/dist/shared/customTaxonomyLibrary.test.d.ts +1 -0
- package/dist/shared/customTaxonomyLibrary.test.js +20 -0
- package/dist/shared/taxonomyCatalog.d.ts +19 -0
- package/dist/shared/taxonomyCatalog.js +36 -0
- package/dist/shared/taxonomyDefaults.d.ts +29 -0
- package/dist/shared/taxonomyDefaults.js +26 -0
- package/dist/shared/taxonomyLibrary.d.ts +15 -0
- package/dist/shared/taxonomyLibrary.js +98 -0
- package/dist/shared/taxonomyLibrary.test.d.ts +1 -0
- package/dist/shared/taxonomyLibrary.test.js +26 -0
- package/dist/shared/taxonomyState.d.ts +36 -0
- package/dist/shared/taxonomyState.js +73 -0
- package/dist/shared/taxonomyState.test.d.ts +1 -0
- package/dist/shared/taxonomyState.test.js +71 -0
- package/dist/shared/workflowDefaults.d.ts +9 -0
- package/dist/shared/workflowDefaults.js +11 -0
- package/dist/storage/documentIntegrity.d.ts +2 -0
- package/dist/storage/documentIntegrity.js +34 -17
- package/dist/storage/documentPurge.d.ts +2 -1
- package/dist/storage/documentPurge.js +35 -8
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/objectStorage.d.ts +1 -1
- package/dist/storage/objectStorageClient.d.ts +6 -4
- package/dist/storage/objectStorageClient.js +77 -29
- package/dist/storage/objectStorageClient.test.d.ts +1 -0
- package/dist/storage/objectStorageClient.test.js +57 -0
- package/dist/storage/objectStorageConfig.js +3 -1
- package/dist/storage/objectStorageConfig.test.d.ts +1 -0
- package/dist/storage/objectStorageConfig.test.js +38 -0
- package/dist/storage/postgresAdapter.d.ts +5 -0
- package/dist/storage/postgresAdapter.js +2 -2
- package/dist/storage/postgresAdapter.test.d.ts +1 -0
- package/dist/storage/postgresAdapter.test.js +29 -0
- package/dist/storage/taskAssetStore.d.ts +7 -4
- package/dist/storage/taskAssetStore.js +65 -60
- package/dist/storage/workspaceAssetStore.d.ts +122 -0
- package/dist/storage/workspaceAssetStore.js +496 -0
- package/dist/storage/workspaceAssetStore.test.d.ts +1 -0
- package/dist/storage/workspaceAssetStore.test.js +501 -0
- package/dist/sync/cloudSyncApi.d.ts +33 -2
- package/dist/sync/cloudSyncApi.js +37 -15
- package/dist/sync/cloudSyncApi.test.js +60 -3
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.js +3 -0
- package/dist/sync/syncService.d.ts +25 -0
- package/dist/sync/syncService.js +184 -16
- package/dist/sync/syncService.test.js +289 -25
- package/dist/sync/workspaceRepair.d.ts +114 -0
- package/dist/sync/workspaceRepair.js +566 -0
- package/dist/sync/workspaceSyncModel.d.ts +230 -2
- package/dist/sync/workspaceSyncModel.js +456 -17
- package/dist/sync/workspaceSyncModel.test.js +642 -0
- package/dist/sync/workspaceSyncState.d.ts +12 -0
- package/dist/sync/workspaceSyncState.js +12 -0
- package/dist/sync/workspaceSyncSurface.d.ts +33 -0
- package/dist/sync/workspaceSyncSurface.js +74 -0
- package/dist/types.d.ts +143 -18
- package/dist/types.js +9 -24
- package/dist/ui/assets/AgentsModule-TVmCgizS.js +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-CAyhnPeR.js +3 -0
- package/dist/ui/assets/DocumentWorkspace-BOCw5e4D.js +250 -0
- package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +1 -0
- package/dist/ui/assets/InitiativesModule-CN0r02Oi.js +1 -0
- package/dist/ui/assets/PlansPage-BlVC_lRq.css +1 -0
- package/dist/ui/assets/PlansPage-FcQSNLZd.js +1 -0
- package/dist/ui/assets/TaskSettings-BwB9NtmJ.css +1 -0
- package/dist/ui/assets/TaskSettings-Cw5zYD1L.js +8 -0
- package/dist/ui/assets/WorkflowsModule-CY9R2zPB.js +5 -0
- package/dist/ui/assets/index-Bzqy5QGm.js +6 -0
- package/dist/ui/assets/index-DxRiI3tY.css +1 -0
- package/dist/ui/assets/vendor-dnd-DRzYolkg.js +5 -0
- package/dist/ui/assets/{vendor-icons-ZFG3SGwo.js → vendor-icons-QZyhEwgT.js} +1 -1
- package/dist/ui/assets/{vendor-markdown-CKYkQHfj.js → vendor-markdown-BUxTU7dS.js} +1 -1
- package/dist/ui/assets/vendor-react-CKJs5o3c.js +61 -0
- package/dist/ui/assets/vendor-router-BbWMxlnO.js +3 -0
- package/dist/ui/branding/logos/logo.png +0 -0
- package/dist/ui/branding/logos/logo_dark.svg +27 -0
- package/dist/ui/branding/logos/logo_favicon.svg +35 -0
- package/dist/ui/branding/logos/logo_light.svg +27 -0
- package/dist/ui/favicon/apple-touch-icon.png +0 -0
- package/dist/ui/favicon/archive/phoenix/apple-touch-icon.png +0 -0
- package/dist/ui/favicon/archive/phoenix/favicon.ico +0 -0
- package/dist/ui/favicon/archive/phoenix/site.webmanifest +1 -0
- package/dist/ui/favicon/favicon-96x96.png +0 -0
- package/dist/ui/favicon/favicon.ico +0 -0
- package/dist/ui/favicon/favicon.svg +17 -0
- package/dist/ui/favicon/site.webmanifest +21 -1
- package/dist/ui/favicon/web-app-manifest-192x192.png +0 -0
- package/dist/ui/favicon/web-app-manifest-512x512.png +0 -0
- package/dist/ui/index.html +10 -7
- package/dist/ui/product/screen_01.png +0 -0
- package/dist/ui/product/screen_02.png +0 -0
- package/dist/ui/product/screen_03.png +0 -0
- package/dist/utils/annotatedAttachments.d.ts +20 -0
- package/dist/utils/annotatedAttachments.js +74 -0
- package/dist/utils/annotatedAttachments.test.d.ts +1 -0
- package/dist/utils/annotatedAttachments.test.js +108 -0
- package/dist/utils/assignees.d.ts +26 -0
- package/dist/utils/assignees.js +132 -0
- package/dist/utils/canonicalAssetKind.d.ts +16 -0
- package/dist/utils/canonicalAssetKind.js +39 -0
- package/dist/utils/canonicalAssetKind.test.d.ts +1 -0
- package/dist/utils/canonicalAssetKind.test.js +24 -0
- package/dist/utils/coalescedRefresh.d.ts +5 -0
- package/dist/utils/coalescedRefresh.js +16 -0
- package/dist/utils/coalescedRefresh.test.d.ts +1 -0
- package/dist/utils/coalescedRefresh.test.js +30 -0
- package/dist/utils/constants.d.ts +16 -3
- package/dist/utils/constants.icons.test.js +2 -1
- package/dist/utils/constants.js +70 -13
- package/dist/utils/contextAssetEvents.d.ts +7 -0
- package/dist/utils/contextAssetEvents.js +6 -0
- package/dist/utils/contextFiles.d.ts +14 -3
- package/dist/utils/contextFiles.js +40 -8
- package/dist/utils/documentManagerApi.d.ts +6 -0
- package/dist/utils/documentManagerApi.js +19 -0
- package/dist/utils/documentNames.d.ts +24 -0
- package/dist/utils/documentNames.js +91 -0
- package/dist/utils/documentReferences.d.ts +9 -0
- package/dist/utils/documentReferences.js +11 -0
- package/dist/utils/entityReferences.d.ts +9 -0
- package/dist/utils/entityReferences.js +32 -0
- package/dist/utils/imageReferences.d.ts +9 -0
- package/dist/utils/imageReferences.js +11 -0
- package/dist/utils/planParser.d.ts +2 -2
- package/dist/utils/planParser.js +11 -11
- package/dist/utils/planParser.test.d.ts +1 -0
- package/dist/{__tests__ → utils}/planParser.test.js +22 -22
- package/dist/utils/planningReferences.d.ts +17 -0
- package/dist/utils/planningReferences.js +30 -0
- package/dist/utils/planningReferences.test.d.ts +1 -0
- package/dist/utils/planningReferences.test.js +17 -0
- package/dist/utils/taskActivity.d.ts +9 -0
- package/dist/utils/taskActivity.js +147 -0
- package/dist/utils/taskActivity.test.d.ts +1 -0
- package/dist/utils/taskActivity.test.js +34 -0
- package/dist/utils/taskDraftDefaults.d.ts +21 -0
- package/dist/utils/taskDraftDefaults.js +40 -0
- package/dist/utils/taskDraftDefaults.test.d.ts +1 -0
- package/dist/utils/taskDraftDefaults.test.js +34 -0
- package/dist/utils/taskNormalization.d.ts +25 -0
- package/dist/utils/taskNormalization.js +161 -0
- package/dist/utils/taskNormalization.test.d.ts +1 -0
- package/dist/utils/taskNormalization.test.js +158 -0
- package/dist/utils/taskReferences.d.ts +5 -0
- package/dist/utils/taskReferences.js +11 -0
- package/dist/utils/taskforceApiClient.d.ts +2 -0
- package/dist/utils/taskforceApiClient.js +101 -3
- package/dist/utils/taskforceApiClient.test.d.ts +1 -0
- package/dist/utils/taskforceApiClient.test.js +44 -0
- package/dist/utils/taxonomyFilterOptions.d.ts +2 -0
- package/dist/utils/taxonomyFilterOptions.js +17 -0
- package/dist/utils/taxonomyFormDefaults.d.ts +3 -0
- package/dist/utils/taxonomyFormDefaults.js +28 -0
- package/dist/utils/taxonomyFormDefaults.test.d.ts +1 -0
- package/dist/utils/taxonomyFormDefaults.test.js +63 -0
- package/dist/utils/taxonomyLifecycle.d.ts +3 -0
- package/dist/utils/taxonomyLifecycle.js +17 -0
- package/dist/utils/taxonomySorting.d.ts +7 -0
- package/dist/utils/taxonomySorting.js +74 -0
- package/dist/utils/theme.d.ts +15 -0
- package/dist/utils/theme.js +23 -0
- package/dist/utils/uiNotice.d.ts +7 -0
- package/dist/utils/uiNotice.js +6 -0
- package/dist/utils/uiNotice.test.d.ts +1 -0
- package/dist/utils/uiNotice.test.js +18 -0
- package/dist/utils/workspaceIdentity.d.ts +14 -0
- package/dist/utils/workspaceIdentity.js +21 -0
- package/dist/utils/workspaceIdentity.test.js +30 -1
- package/dist/utils/workspaceSyncPresentation.d.ts +20 -0
- package/dist/utils/workspaceSyncPresentation.js +21 -0
- package/dist/utils/workspaceSyncPresentation.test.d.ts +1 -0
- package/dist/utils/workspaceSyncPresentation.test.js +36 -0
- package/package.json +28 -23
- package/scripts/bootstrap-playwright-auth.mjs +155 -0
- package/scripts/check-compat-boundaries.mjs +41 -0
- package/scripts/check-env.ts +9 -2
- package/scripts/debug-login.mjs +32 -0
- package/scripts/migrate-planning-model.ts +233 -0
- package/scripts/migrate-task-data-assets.mjs +408 -0
- package/scripts/playwright-auth.sh +11 -0
- package/scripts/run-dev-proxy.sh +2 -0
- package/scripts/update-homebrew-formula.sh +2 -2
- package/scripts/with-esbuild.sh +11 -0
- package/src/resources/templates/WORKFLOW_V2_POLICY.md +1 -1
- package/src/resources/templates/workflow-sources/workflowDocs.mjs +62 -52
- package/src/resources/templates/workflows/collaborate.yaml +11 -5
- package/src/resources/templates/workflows/do.yaml +8 -2
- package/src/resources/templates/workflows/evaluate.yaml +13 -7
- package/src/resources/templates/workflows/execute.yaml +40 -23
- package/src/resources/templates/workflows/plan.yaml +15 -8
- package/src/resources/templates/workflows/review.yaml +14 -8
- package/dist/TaskforceScreenshot.d.ts +0 -11
- package/dist/TaskforceScreenshot.js +0 -113
- package/dist/compat/priorityCompat.d.ts +0 -2
- package/dist/compat/priorityCompat.js +0 -24
- package/dist/components/features/settings/TaxonomyManager 2.d.ts +0 -52
- package/dist/components/features/settings/TaxonomyManager 2.js +0 -75
- package/dist/components/task/TaskKanban 2.d.ts +0 -76
- package/dist/components/task/TaskKanban 2.js +0 -1004
- package/dist/components/task/TaskScreenshotSection.d.ts +0 -15
- package/dist/components/task/TaskScreenshotSection.js +0 -22
- package/dist/components/views/StandaloneLayout 2.d.ts +0 -8
- package/dist/components/views/StandaloneLayout 2.js +0 -1660
- package/dist/core/InitiativeTemplates.test.js +0 -50
- package/dist/core/ParentLifecycle.test.js +0 -98
- package/dist/migrations/clientMigrations.d.ts +0 -9
- package/dist/migrations/clientMigrations.js +0 -74
- package/dist/migrations/flags.d.ts +0 -6
- package/dist/migrations/flags.js +0 -8
- package/dist/migrations/taskMigrations.d.ts +0 -71
- package/dist/migrations/taskMigrations.js +0 -236
- package/dist/migrations/taskMigrations.test.js +0 -34
- package/dist/server/routes.billing.js +0 -991
- package/dist/server/routes.billing.test.js +0 -387
- package/dist/server/routes.sync.integration.test.js +0 -470
- package/dist/server/routes.sync.js +0 -1141
- package/dist/soak/syncSoakHarness.d.ts +0 -6
- package/dist/soak/syncSoakHarness.js +0 -10
- package/dist/ui/assets/index-0W5vyCpn.css +0 -1
- package/dist/ui/assets/index-MCZuNNfn.js +0 -23
- package/dist/ui/assets/logo_white_01-0Vx-iIIv.png +0 -0
- package/dist/ui/assets/vendor-dnd-BI1K77C1.js +0 -5
- package/dist/ui/assets/vendor-react-DdIDsh7f.js +0 -9
- package/dist/ui/assets/vendor-router-BtiNU7Dl.js +0 -3
- package/dist/utils/taskHierarchy.d.ts +0 -4
- package/dist/utils/taskHierarchy.js +0 -69
- package/dist/utils/taskHierarchy.test.js +0 -38
- package/dist/utils/taskParenting.d.ts +0 -3
- package/dist/utils/taskParenting.js +0 -24
- package/dist/utils/taskParenting.test.js +0 -21
- package/dist/utils/taskRelations.d.ts +0 -6
- package/dist/utils/taskRelations.js +0 -24
- package/dist/utils/taskRelations.test.js +0 -34
- package/scripts/check-compat-boundaries.js +0 -48
- /package/dist/{__tests__/planParser.test.d.ts → TaskforceCore.filter-persistence.test.d.ts} +0 -0
- /package/dist/{__tests__/releaseVersion.test.d.ts → TaskforceCore.routing.test.d.ts} +0 -0
- /package/dist/{__tests__/structuredCommentValidation.test.d.ts → annotatedAttachments/AnnotatedAttachmentSessionStore.test.d.ts} +0 -0
- /package/dist/{__tests__/structuredDocValidation.test.d.ts → annotatedAttachments/payload.test.d.ts} +0 -0
- /package/dist/{__tests__/templateEngine.test.d.ts → annotatedAttachments/types.js} +0 -0
- /package/dist/{__tests__/templates.test.d.ts → compat/workspaceSyncCompat.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowDocumentFixtures.test.d.ts → components/features/AgentsModule.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowExportMatrix.test.d.ts → components/features/AnnotatedAttachmentWorkspace.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowExportSnapshots.test.d.ts → components/features/DocumentWorkspace.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowGeneration.test.d.ts → components/features/settings/TaxonomyLibraryManager.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowV2Policy.test.d.ts → components/task/TaskActionHeader.test.d.ts} +0 -0
- /package/dist/{core/InitiativeTemplates.test.d.ts → components/task/TaskKanban.planning-drop.test.d.ts} +0 -0
- /package/dist/{core/ParentLifecycle.test.d.ts → components/task/TaskStatusActions.test.d.ts} +0 -0
- /package/dist/{migrations/taskMigrations.test.d.ts → components/ui/TopNoticeLayer.test.d.ts} +0 -0
- /package/dist/{server/routes.billing.test.d.ts → components/views/AppShellHeader.test.d.ts} +0 -0
- /package/dist/{server/routes.sync.integration.test.d.ts → components/views/PlanComparisonPage.test.d.ts} +0 -0
- /package/dist/{utils/taskHierarchy.test.d.ts → components/views/chrome/WorkspaceChromeSections.test.d.ts} +0 -0
- /package/dist/{utils/taskParenting.test.d.ts → components/views/chrome/workspaceChrome.test.d.ts} +0 -0
- /package/dist/{utils/taskRelations.test.d.ts → components/views/panels/DocumentFilterToolbar.test.d.ts} +0 -0
- /package/dist/public/{android-chrome-192x192.png → archive/phoenix/android-chrome-192x192.png} +0 -0
- /package/dist/public/{android-chrome-512x512.png → archive/phoenix/android-chrome-512x512.png} +0 -0
- /package/dist/public/{favicon-16x16.png → archive/phoenix/favicon-16x16.png} +0 -0
- /package/dist/public/{favicon-32x32.png → archive/phoenix/favicon-32x32.png} +0 -0
- /package/dist/public/{favicon.png → archive/phoenix/favicon.png} +0 -0
- /package/dist/{__tests__ → services}/workflowExportSnapshots.test.js +0 -0
- /package/dist/{__tests__ → services}/workflowGeneration.test.js +0 -0
- /package/dist/ui/favicon/{android-chrome-192x192.png → archive/phoenix/android-chrome-192x192.png} +0 -0
- /package/dist/ui/favicon/{android-chrome-512x512.png → archive/phoenix/android-chrome-512x512.png} +0 -0
- /package/dist/ui/favicon/{favicon-16x16.png → archive/phoenix/favicon-16x16.png} +0 -0
- /package/dist/ui/favicon/{favicon-32x32.png → archive/phoenix/favicon-32x32.png} +0 -0
- /package/dist/ui/favicon/{favicon.png → archive/phoenix/favicon.png} +0 -0
|
@@ -0,0 +1,1442 @@
|
|
|
1
|
+
import { TaskforceRuleError } from './types.js';
|
|
2
|
+
import { DOCUMENT_CLOUD_STORAGE_FEATURE_KEY, WORKSPACES_FEATURE_KEY, DOCUMENT_CLOUD_STORAGE_LIMIT_CONFIG_KEY, WORKSPACES_LIMIT_CONFIG_KEY, DEFAULT_PLAN_FEATURE_CATALOG, PLAN_FEATURE_DEPENDENCIES, generateUuidV7, } from './shared.js';
|
|
3
|
+
const LEGACY_REMOVED_PLAN_FEATURE_KEYS = Object.freeze([
|
|
4
|
+
'documents.workspace_access'
|
|
5
|
+
]);
|
|
6
|
+
/**
|
|
7
|
+
* Handles all plan catalog, plan version, and account entitlement logic.
|
|
8
|
+
* Extracted from Taskforce.ts to reduce monolith size.
|
|
9
|
+
*/
|
|
10
|
+
export class PlanEntitlementService {
|
|
11
|
+
db;
|
|
12
|
+
tenantId;
|
|
13
|
+
deps;
|
|
14
|
+
constructor(db, tenantId, deps) {
|
|
15
|
+
this.db = db;
|
|
16
|
+
this.tenantId = tenantId;
|
|
17
|
+
this.deps = deps;
|
|
18
|
+
}
|
|
19
|
+
generateEntityId(prefix) {
|
|
20
|
+
return `${prefix}-${generateUuidV7()}`;
|
|
21
|
+
}
|
|
22
|
+
parseJsonObject(raw) {
|
|
23
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
24
|
+
return {};
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
28
|
+
? parsed
|
|
29
|
+
: {};
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// ─── Normalizers ─────────────────────────────────────────────────────────
|
|
36
|
+
normalizeEntitlementState(value) {
|
|
37
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
38
|
+
if (normalized === 'active' || normalized === 'trialing' || normalized === 'grace' || normalized === 'suspended' || normalized === 'canceled') {
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
return 'canceled';
|
|
42
|
+
}
|
|
43
|
+
normalizeFeatureAccess(value) {
|
|
44
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
45
|
+
if (normalized === 'enabled' || normalized === 'limited' || normalized === 'disabled') {
|
|
46
|
+
return normalized;
|
|
47
|
+
}
|
|
48
|
+
return 'disabled';
|
|
49
|
+
}
|
|
50
|
+
readPositiveIntegerConfig(value, minimum) {
|
|
51
|
+
const numeric = Number(value);
|
|
52
|
+
if (!Number.isFinite(numeric))
|
|
53
|
+
return null;
|
|
54
|
+
return Math.max(minimum, Math.floor(numeric));
|
|
55
|
+
}
|
|
56
|
+
normalizePlanVersionTrialDays(value) {
|
|
57
|
+
if (value === null || value === undefined || value === '')
|
|
58
|
+
return 0;
|
|
59
|
+
const numeric = Number(value);
|
|
60
|
+
if (!Number.isFinite(numeric) || !Number.isInteger(numeric)) {
|
|
61
|
+
throw new TaskforceRuleError('trialDays must be an integer between 0 and 99.', 400, 'PLAN_TRIAL_DAYS_INVALID');
|
|
62
|
+
}
|
|
63
|
+
const normalized = Math.floor(numeric);
|
|
64
|
+
if (normalized < 0 || normalized > 99) {
|
|
65
|
+
throw new TaskforceRuleError('trialDays must be an integer between 0 and 99.', 400, 'PLAN_TRIAL_DAYS_INVALID');
|
|
66
|
+
}
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
69
|
+
readPlanVersionTrialDays(metadata) {
|
|
70
|
+
const rawValue = metadata.trialDays;
|
|
71
|
+
if (rawValue === null || rawValue === undefined || rawValue === '')
|
|
72
|
+
return 0;
|
|
73
|
+
const numeric = Number(rawValue);
|
|
74
|
+
if (!Number.isFinite(numeric) || !Number.isInteger(numeric))
|
|
75
|
+
return 0;
|
|
76
|
+
const normalized = Math.floor(numeric);
|
|
77
|
+
return normalized >= 0 && normalized <= 99 ? normalized : 0;
|
|
78
|
+
}
|
|
79
|
+
applyPlanVersionTrialDaysMetadata(metadata, trialDays) {
|
|
80
|
+
const nextMetadata = metadata && typeof metadata === 'object' ? { ...metadata } : {};
|
|
81
|
+
if (trialDays === undefined) {
|
|
82
|
+
return nextMetadata;
|
|
83
|
+
}
|
|
84
|
+
nextMetadata.trialDays = trialDays;
|
|
85
|
+
return nextMetadata;
|
|
86
|
+
}
|
|
87
|
+
sanitizePlanFeatureConfig(featureKey, access, config) {
|
|
88
|
+
if (featureKey === 'collaboration.seat_limit')
|
|
89
|
+
return {};
|
|
90
|
+
if (featureKey === DOCUMENT_CLOUD_STORAGE_FEATURE_KEY) {
|
|
91
|
+
const limit = this.readPositiveIntegerConfig(config[DOCUMENT_CLOUD_STORAGE_LIMIT_CONFIG_KEY], 1);
|
|
92
|
+
if (access === 'limited') {
|
|
93
|
+
return {
|
|
94
|
+
[DOCUMENT_CLOUD_STORAGE_LIMIT_CONFIG_KEY]: limit ?? 1024
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (limit !== null) {
|
|
98
|
+
return {
|
|
99
|
+
[DOCUMENT_CLOUD_STORAGE_LIMIT_CONFIG_KEY]: limit
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
if (featureKey === WORKSPACES_FEATURE_KEY) {
|
|
105
|
+
const limit = this.readPositiveIntegerConfig(config[WORKSPACES_LIMIT_CONFIG_KEY], 1);
|
|
106
|
+
return {
|
|
107
|
+
[WORKSPACES_LIMIT_CONFIG_KEY]: limit ?? 1
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return config && typeof config === 'object' ? { ...config } : {};
|
|
111
|
+
}
|
|
112
|
+
isEntitlementStateAllowed(state) {
|
|
113
|
+
return state === 'active' || state === 'trialing' || state === 'grace';
|
|
114
|
+
}
|
|
115
|
+
// ─── Plan defaults repair ─────────────────────────────────────────────────
|
|
116
|
+
repairPlanDefaultVersionIfNeeded(planIdRaw) {
|
|
117
|
+
const planId = String(planIdRaw || '').trim().toLowerCase();
|
|
118
|
+
if (!planId)
|
|
119
|
+
return;
|
|
120
|
+
const rows = this.db.prepare(`
|
|
121
|
+
SELECT plan_version_id, version_number, is_default
|
|
122
|
+
FROM plan_versions
|
|
123
|
+
WHERE tenant_id = ? AND plan_id = ?
|
|
124
|
+
ORDER BY version_number DESC, created_at DESC
|
|
125
|
+
`).all(this.tenantId, planId);
|
|
126
|
+
if (rows.length === 0)
|
|
127
|
+
return;
|
|
128
|
+
const defaultRows = rows.filter((row) => Boolean(row.is_default) && row.plan_version_id);
|
|
129
|
+
if (defaultRows.length === 1)
|
|
130
|
+
return;
|
|
131
|
+
const fallback = rows.find((row) => row.plan_version_id) || null;
|
|
132
|
+
if (!fallback?.plan_version_id)
|
|
133
|
+
return;
|
|
134
|
+
const now = new Date().toISOString();
|
|
135
|
+
this.db.prepare(`
|
|
136
|
+
UPDATE plan_versions
|
|
137
|
+
SET is_default = CASE WHEN plan_version_id = ? THEN 1 ELSE 0 END,
|
|
138
|
+
updated_at = ?
|
|
139
|
+
WHERE tenant_id = ? AND plan_id = ?
|
|
140
|
+
`).run(String(fallback.plan_version_id), now, this.tenantId, planId);
|
|
141
|
+
}
|
|
142
|
+
repairPlanCatalogDefaultsIfNeeded() {
|
|
143
|
+
const planRows = this.db.prepare(`
|
|
144
|
+
SELECT DISTINCT plan_id
|
|
145
|
+
FROM plan_versions
|
|
146
|
+
WHERE tenant_id = ?
|
|
147
|
+
`).all(this.tenantId);
|
|
148
|
+
for (const row of planRows) {
|
|
149
|
+
const planId = String(row.plan_id || '').trim().toLowerCase();
|
|
150
|
+
if (!planId)
|
|
151
|
+
continue;
|
|
152
|
+
this.repairPlanDefaultVersionIfNeeded(planId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
repairSignupMonetizationSettingsIfNeeded() {
|
|
156
|
+
const current = this.deps.readGlobalConfig();
|
|
157
|
+
const currentAuth = current.auth && typeof current.auth === 'object'
|
|
158
|
+
? current.auth
|
|
159
|
+
: {};
|
|
160
|
+
const strategy = currentAuth.signupMonetizationStrategy === 'plan_selection_required'
|
|
161
|
+
? 'plan_selection_required'
|
|
162
|
+
: currentAuth.signupMonetizationStrategy === 'auto_assign_default'
|
|
163
|
+
? 'auto_assign_default'
|
|
164
|
+
: null;
|
|
165
|
+
const hasStrategy = strategy === 'auto_assign_default' || strategy === 'plan_selection_required';
|
|
166
|
+
const hasDefaultVersionId = typeof currentAuth.defaultSignupPlanVersionId === 'string'
|
|
167
|
+
&& currentAuth.defaultSignupPlanVersionId.trim().length > 0;
|
|
168
|
+
if (strategy === 'plan_selection_required') {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (strategy === 'auto_assign_default' && hasDefaultVersionId) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const fallbackVersion = this.tryGetDefaultPlanVersionByMode('personal', { allowFallback: true });
|
|
175
|
+
if (!fallbackVersion) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.deps.writeGlobalConfig({
|
|
179
|
+
...current,
|
|
180
|
+
auth: {
|
|
181
|
+
...currentAuth,
|
|
182
|
+
signupMonetizationStrategy: hasStrategy
|
|
183
|
+
? strategy
|
|
184
|
+
: 'auto_assign_default',
|
|
185
|
+
defaultSignupPlanVersionId: strategy === 'auto_assign_default' && hasDefaultVersionId
|
|
186
|
+
? currentAuth.defaultSignupPlanVersionId
|
|
187
|
+
: fallbackVersion.planVersionId
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
normalizeLegacyPlanDataIfNeeded() {
|
|
192
|
+
this.migrateLegacySeatLimitFeatureConfigToVersionSeatLimit();
|
|
193
|
+
this.normalizeLegacyBillingMirrorRows();
|
|
194
|
+
this.removeLegacyPlanFeatureRows();
|
|
195
|
+
}
|
|
196
|
+
removeLegacyPlanFeatureRows() {
|
|
197
|
+
const featureKeys = [...LEGACY_REMOVED_PLAN_FEATURE_KEYS];
|
|
198
|
+
if (featureKeys.length < 1)
|
|
199
|
+
return;
|
|
200
|
+
const placeholders = featureKeys.map(() => '?').join(', ');
|
|
201
|
+
this.db.prepare(`
|
|
202
|
+
DELETE FROM plan_feature_matrix
|
|
203
|
+
WHERE tenant_id = ?
|
|
204
|
+
AND feature_key IN (${placeholders})
|
|
205
|
+
`).run(this.tenantId, ...featureKeys);
|
|
206
|
+
}
|
|
207
|
+
migrateLegacySeatLimitFeatureConfigToVersionSeatLimit() {
|
|
208
|
+
const rows = this.db.prepare(`
|
|
209
|
+
SELECT pfm.plan_version_id, pfm.config_json, pv.seat_limit
|
|
210
|
+
FROM plan_feature_matrix pfm
|
|
211
|
+
JOIN plan_versions pv
|
|
212
|
+
ON pv.tenant_id = pfm.tenant_id
|
|
213
|
+
AND pv.plan_version_id = pfm.plan_version_id
|
|
214
|
+
WHERE pfm.tenant_id = ?
|
|
215
|
+
AND pfm.feature_key = 'collaboration.seat_limit'
|
|
216
|
+
`).all(this.tenantId);
|
|
217
|
+
if (rows.length < 1)
|
|
218
|
+
return;
|
|
219
|
+
const now = new Date().toISOString();
|
|
220
|
+
const tx = this.db.transaction(() => {
|
|
221
|
+
for (const row of rows) {
|
|
222
|
+
const planVersionId = String(row.plan_version_id || '').trim();
|
|
223
|
+
if (!planVersionId)
|
|
224
|
+
continue;
|
|
225
|
+
const config = this.parseJsonObject(row.config_json);
|
|
226
|
+
const legacySeatLimit = this.readPositiveIntegerConfig(config.seatLimit ?? config.seat_limit, 1);
|
|
227
|
+
if (legacySeatLimit !== null && row.seat_limit === null) {
|
|
228
|
+
this.db.prepare(`
|
|
229
|
+
UPDATE plan_versions
|
|
230
|
+
SET seat_limit = ?, updated_at = ?
|
|
231
|
+
WHERE tenant_id = ? AND plan_version_id = ?
|
|
232
|
+
`).run(legacySeatLimit, now, this.tenantId, planVersionId);
|
|
233
|
+
}
|
|
234
|
+
if (Object.keys(config).length > 0) {
|
|
235
|
+
this.db.prepare(`
|
|
236
|
+
UPDATE plan_feature_matrix
|
|
237
|
+
SET config_json = ?, updated_at = ?
|
|
238
|
+
WHERE tenant_id = ? AND plan_version_id = ? AND feature_key = 'collaboration.seat_limit'
|
|
239
|
+
`).run('{}', now, this.tenantId, planVersionId);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
tx();
|
|
244
|
+
}
|
|
245
|
+
normalizeLegacyBillingMirrorRows() {
|
|
246
|
+
const defaultPersonalVersion = this.tryGetDefaultPlanVersionByMode('personal', { allowFallback: true });
|
|
247
|
+
if (!defaultPersonalVersion)
|
|
248
|
+
return;
|
|
249
|
+
const rows = this.db.prepare(`
|
|
250
|
+
SELECT user_id, tier, entitlement_state
|
|
251
|
+
FROM user_billing
|
|
252
|
+
WHERE tenant_id = ?
|
|
253
|
+
`).all(this.tenantId);
|
|
254
|
+
if (rows.length < 1)
|
|
255
|
+
return;
|
|
256
|
+
const now = new Date().toISOString();
|
|
257
|
+
const tx = this.db.transaction(() => {
|
|
258
|
+
for (const row of rows) {
|
|
259
|
+
const userId = String(row.user_id || '').trim();
|
|
260
|
+
if (!userId)
|
|
261
|
+
continue;
|
|
262
|
+
const tier = String(row.tier || '').trim().toLowerCase();
|
|
263
|
+
const state = String(row.entitlement_state || '').trim().toLowerCase();
|
|
264
|
+
if (state === 'pending_plan_selection')
|
|
265
|
+
continue;
|
|
266
|
+
if (tier === 'free' || state === 'free') {
|
|
267
|
+
this.db.prepare(`
|
|
268
|
+
UPDATE user_billing
|
|
269
|
+
SET tier = ?, entitlement_state = 'active', updated_at = ?
|
|
270
|
+
WHERE tenant_id = ? AND user_id = ?
|
|
271
|
+
`).run(defaultPersonalVersion.planId, now, this.tenantId, userId);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
tx();
|
|
276
|
+
}
|
|
277
|
+
backfillAccountEntitlements() {
|
|
278
|
+
const defaultPersonalVersion = this.tryGetDefaultPlanVersionByMode('personal', { allowFallback: true });
|
|
279
|
+
if (!defaultPersonalVersion) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const users = this.db.prepare(`
|
|
283
|
+
SELECT
|
|
284
|
+
u.id AS user_id,
|
|
285
|
+
ub.tier,
|
|
286
|
+
ub.entitlement_state,
|
|
287
|
+
ub.created_at AS billing_created_at,
|
|
288
|
+
ub.updated_at AS billing_updated_at
|
|
289
|
+
FROM users u
|
|
290
|
+
LEFT JOIN user_billing ub
|
|
291
|
+
ON ub.tenant_id = u.tenant_id
|
|
292
|
+
AND ub.user_id = u.id
|
|
293
|
+
LEFT JOIN account_entitlements ae
|
|
294
|
+
ON ae.tenant_id = u.tenant_id
|
|
295
|
+
AND ae.account_id = u.id
|
|
296
|
+
WHERE u.tenant_id = ?
|
|
297
|
+
AND ae.account_id IS NULL
|
|
298
|
+
`).all(this.tenantId);
|
|
299
|
+
for (const row of users) {
|
|
300
|
+
const accountId = String(row.user_id || '').trim();
|
|
301
|
+
if (!accountId)
|
|
302
|
+
continue;
|
|
303
|
+
const rawBillingState = String(row.entitlement_state || '').trim().toLowerCase();
|
|
304
|
+
if (rawBillingState === 'pending_plan_selection')
|
|
305
|
+
continue;
|
|
306
|
+
const mirroredPlanId = String(row.tier || '').trim().toLowerCase();
|
|
307
|
+
const planVersion = this.tryGetDefaultPlanVersionByPlanId(mirroredPlanId)
|
|
308
|
+
|| defaultPersonalVersion;
|
|
309
|
+
this.upsertCanonicalEntitlementRecord({
|
|
310
|
+
accountId,
|
|
311
|
+
planVersionId: planVersion.planVersionId,
|
|
312
|
+
state: this.resolveCanonicalEntitlementStateFromBilling(row.entitlement_state),
|
|
313
|
+
effectiveAt: row.billing_created_at ? String(row.billing_created_at) : undefined,
|
|
314
|
+
updatedBy: 'backfill',
|
|
315
|
+
createdAt: row.billing_created_at ? String(row.billing_created_at) : undefined,
|
|
316
|
+
updatedAt: row.billing_updated_at ? String(row.billing_updated_at) : undefined
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────
|
|
321
|
+
getRequiredDefaultPlanVersion(planIdRaw) {
|
|
322
|
+
const planId = String(planIdRaw || '').trim().toLowerCase();
|
|
323
|
+
if (!planId) {
|
|
324
|
+
throw new TaskforceRuleError('planId is required', 400, 'PLAN_ID_REQUIRED');
|
|
325
|
+
}
|
|
326
|
+
const planRow = this.db.prepare(`
|
|
327
|
+
SELECT plan_id, status
|
|
328
|
+
FROM plan_catalog
|
|
329
|
+
WHERE tenant_id = ? AND plan_id = ?
|
|
330
|
+
LIMIT 1
|
|
331
|
+
`).get(this.tenantId, planId);
|
|
332
|
+
if (!planRow?.plan_id) {
|
|
333
|
+
throw new TaskforceRuleError(`Required plan "${planId}" is missing`, 500, 'PLAN_NOT_CONFIGURED');
|
|
334
|
+
}
|
|
335
|
+
if (String(planRow.status || '').trim().toLowerCase() !== 'active') {
|
|
336
|
+
throw new TaskforceRuleError(`Required plan "${planId}" is not active`, 500, 'PLAN_NOT_ACTIVE');
|
|
337
|
+
}
|
|
338
|
+
this.repairPlanDefaultVersionIfNeeded(planId);
|
|
339
|
+
const versionRow = this.db.prepare(`
|
|
340
|
+
SELECT plan_version_id
|
|
341
|
+
FROM plan_versions
|
|
342
|
+
WHERE tenant_id = ? AND plan_id = ? AND is_default = 1
|
|
343
|
+
ORDER BY version_number DESC
|
|
344
|
+
LIMIT 1
|
|
345
|
+
`).get(this.tenantId, planId);
|
|
346
|
+
if (!versionRow?.plan_version_id) {
|
|
347
|
+
throw new TaskforceRuleError(`Required default version for "${planId}" is missing`, 500, 'DEFAULT_PLAN_VERSION_NOT_CONFIGURED');
|
|
348
|
+
}
|
|
349
|
+
return this.getPlanVersionSnapshot(String(versionRow.plan_version_id));
|
|
350
|
+
}
|
|
351
|
+
resolveCanonicalEntitlementStateFromBilling(raw) {
|
|
352
|
+
return this.normalizeEntitlementState(raw);
|
|
353
|
+
}
|
|
354
|
+
upsertCanonicalEntitlementRecord(input) {
|
|
355
|
+
const now = new Date().toISOString();
|
|
356
|
+
const createdAt = input.createdAt || now;
|
|
357
|
+
const updatedAt = input.updatedAt || now;
|
|
358
|
+
this.db.prepare(`
|
|
359
|
+
INSERT INTO account_entitlements (account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at)
|
|
360
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
361
|
+
ON CONFLICT (account_id) DO UPDATE SET
|
|
362
|
+
plan_version_id = excluded.plan_version_id,
|
|
363
|
+
entitlement_state = excluded.entitlement_state,
|
|
364
|
+
effective_at = excluded.effective_at,
|
|
365
|
+
effective_until = excluded.effective_until,
|
|
366
|
+
updated_by = excluded.updated_by,
|
|
367
|
+
updated_at = excluded.updated_at
|
|
368
|
+
`).run(input.accountId, this.tenantId, input.planVersionId, input.state, input.effectiveAt || now, input.effectiveUntil || null, input.updatedBy, createdAt, updatedAt);
|
|
369
|
+
}
|
|
370
|
+
isFeatureEnabledForPlanMode(feature) {
|
|
371
|
+
return Boolean(feature && feature.access !== 'disabled');
|
|
372
|
+
}
|
|
373
|
+
syncManagedTeamFeatures(features) {
|
|
374
|
+
const normalized = {};
|
|
375
|
+
for (const [featureKey, feature] of Object.entries(features || {})) {
|
|
376
|
+
normalized[featureKey] = {
|
|
377
|
+
featureKey,
|
|
378
|
+
access: this.normalizeFeatureAccess(feature.access),
|
|
379
|
+
config: feature.config && typeof feature.config === 'object' ? { ...feature.config } : {}
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const teamManagement = normalized['collaboration.team_management'];
|
|
383
|
+
normalized['collaboration.invites'] = {
|
|
384
|
+
featureKey: 'collaboration.invites',
|
|
385
|
+
access: this.isFeatureEnabledForPlanMode(teamManagement) ? 'enabled' : 'disabled',
|
|
386
|
+
config: {}
|
|
387
|
+
};
|
|
388
|
+
return normalized;
|
|
389
|
+
}
|
|
390
|
+
normalizePlanFeatureDependencies(features) {
|
|
391
|
+
const normalized = this.syncManagedTeamFeatures(features);
|
|
392
|
+
let changed = true;
|
|
393
|
+
while (changed) {
|
|
394
|
+
changed = false;
|
|
395
|
+
for (const [featureKey, dependencies] of Object.entries(PLAN_FEATURE_DEPENDENCIES)) {
|
|
396
|
+
const feature = normalized[featureKey];
|
|
397
|
+
if (!feature || feature.access === 'disabled')
|
|
398
|
+
continue;
|
|
399
|
+
const unmetDependency = dependencies.some((dependencyKey) => {
|
|
400
|
+
const dependencyFeature = normalized[dependencyKey];
|
|
401
|
+
return !dependencyFeature || dependencyFeature.access === 'disabled';
|
|
402
|
+
});
|
|
403
|
+
if (!unmetDependency)
|
|
404
|
+
continue;
|
|
405
|
+
normalized[featureKey] = {
|
|
406
|
+
...feature,
|
|
407
|
+
access: 'disabled'
|
|
408
|
+
};
|
|
409
|
+
changed = true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return normalized;
|
|
413
|
+
}
|
|
414
|
+
isTeamPlanVersion(version) {
|
|
415
|
+
const teamFeature = version?.features?.['collaboration.team_management'];
|
|
416
|
+
return this.isFeatureEnabledForPlanMode(teamFeature);
|
|
417
|
+
}
|
|
418
|
+
assertSeatLimitCompatibleWithMode(input) {
|
|
419
|
+
const modeLabel = String(input.modeLabel || 'plan version').trim() || 'plan version';
|
|
420
|
+
const isTeam = this.isTeamPlanVersion({ features: input.features || {} });
|
|
421
|
+
if (!isTeam)
|
|
422
|
+
return;
|
|
423
|
+
if (input.seatLimit === null || !Number.isFinite(input.seatLimit) || input.seatLimit <= 0) {
|
|
424
|
+
throw new TaskforceRuleError(`${modeLabel} requires a positive seat limit when team management is enabled.`, 409, 'PLAN_SEAT_LIMIT_REQUIRED');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
buildPlanModeFeatures(mode) {
|
|
428
|
+
const access = mode === 'team' ? 'enabled' : 'disabled';
|
|
429
|
+
return [
|
|
430
|
+
{ featureKey: 'collaboration.invites', access, config: {} },
|
|
431
|
+
{ featureKey: 'collaboration.team_management', access, config: {} }
|
|
432
|
+
];
|
|
433
|
+
}
|
|
434
|
+
resolveWorkspaceLimitForUser(userId) {
|
|
435
|
+
const normalizedUserId = String(userId || '').trim();
|
|
436
|
+
if (!normalizedUserId) {
|
|
437
|
+
return { limit: 1, feature: null };
|
|
438
|
+
}
|
|
439
|
+
const entitlement = this.getAccountEntitlement(normalizedUserId);
|
|
440
|
+
const feature = entitlement.version.features[WORKSPACES_FEATURE_KEY] || null;
|
|
441
|
+
if (!feature || feature.access === 'disabled') {
|
|
442
|
+
return { limit: 1, feature };
|
|
443
|
+
}
|
|
444
|
+
const configuredLimit = this.readPositiveIntegerConfig(feature.config?.[WORKSPACES_LIMIT_CONFIG_KEY], 1);
|
|
445
|
+
if (configuredLimit !== null) {
|
|
446
|
+
return { limit: configuredLimit, feature };
|
|
447
|
+
}
|
|
448
|
+
return { limit: null, feature };
|
|
449
|
+
}
|
|
450
|
+
planHasAnyActivePricing(planIdRaw) {
|
|
451
|
+
const planId = String(planIdRaw || '').trim().toLowerCase();
|
|
452
|
+
if (!planId)
|
|
453
|
+
return false;
|
|
454
|
+
const row = this.db.prepare(`
|
|
455
|
+
SELECT COUNT(*) AS count
|
|
456
|
+
FROM billing_price_mappings bpm
|
|
457
|
+
JOIN plan_versions pv
|
|
458
|
+
ON pv.tenant_id = bpm.tenant_id
|
|
459
|
+
AND pv.plan_version_id = bpm.plan_version_id
|
|
460
|
+
WHERE bpm.tenant_id = ?
|
|
461
|
+
AND pv.plan_id = ?
|
|
462
|
+
AND bpm.active = 1
|
|
463
|
+
`).get(this.tenantId, planId);
|
|
464
|
+
return Number(row?.count || 0) > 0;
|
|
465
|
+
}
|
|
466
|
+
tryGetDefaultPlanVersionByPlanId(planIdRaw) {
|
|
467
|
+
const planId = String(planIdRaw || '').trim().toLowerCase();
|
|
468
|
+
if (!planId)
|
|
469
|
+
return null;
|
|
470
|
+
try {
|
|
471
|
+
return this.getRequiredDefaultPlanVersion(planId);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
if (error instanceof TaskforceRuleError) {
|
|
475
|
+
if (error.code === 'PLAN_NOT_CONFIGURED'
|
|
476
|
+
|| error.code === 'PLAN_NOT_ACTIVE'
|
|
477
|
+
|| error.code === 'DEFAULT_PLAN_VERSION_NOT_CONFIGURED') {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
getActiveDefaultPlanVersions() {
|
|
485
|
+
const rows = this.db.prepare(`
|
|
486
|
+
SELECT v.plan_version_id
|
|
487
|
+
FROM plan_versions v
|
|
488
|
+
JOIN plan_catalog p
|
|
489
|
+
ON p.tenant_id = v.tenant_id
|
|
490
|
+
AND p.plan_id = v.plan_id
|
|
491
|
+
WHERE v.tenant_id = ?
|
|
492
|
+
AND v.is_default = 1
|
|
493
|
+
AND COALESCE(p.status, 'active') = 'active'
|
|
494
|
+
ORDER BY p.updated_at DESC, p.created_at DESC, v.version_number DESC, v.created_at DESC
|
|
495
|
+
`).all(this.tenantId);
|
|
496
|
+
return rows
|
|
497
|
+
.map((row) => String(row.plan_version_id || '').trim())
|
|
498
|
+
.filter(Boolean)
|
|
499
|
+
.map((planVersionId) => this.getPlanVersionSnapshot(planVersionId));
|
|
500
|
+
}
|
|
501
|
+
getDefaultPlanVersionByMode(mode, options = {}) {
|
|
502
|
+
const versions = this.getActiveDefaultPlanVersions();
|
|
503
|
+
const matching = versions.find((version) => this.isTeamPlanVersion(version) === (mode === 'team'));
|
|
504
|
+
if (matching) {
|
|
505
|
+
return matching;
|
|
506
|
+
}
|
|
507
|
+
if (options.allowFallback && versions.length > 0) {
|
|
508
|
+
return versions[0];
|
|
509
|
+
}
|
|
510
|
+
throw new TaskforceRuleError(`No active default ${mode} plan version is configured`, 500, 'DEFAULT_PLAN_VERSION_NOT_CONFIGURED');
|
|
511
|
+
}
|
|
512
|
+
tryGetDefaultPlanVersionByMode(mode, options = {}) {
|
|
513
|
+
try {
|
|
514
|
+
return this.getDefaultPlanVersionByMode(mode, options);
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
if (error instanceof TaskforceRuleError) {
|
|
518
|
+
if (error.code === 'DEFAULT_PLAN_VERSION_NOT_CONFIGURED') {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
resolveRegistrationPlanAssignment(selectedPlanVersionIdRaw) {
|
|
526
|
+
const selectedPlanVersionId = String(selectedPlanVersionIdRaw || '').trim();
|
|
527
|
+
if (selectedPlanVersionId) {
|
|
528
|
+
const row = this.db.prepare(`
|
|
529
|
+
SELECT v.plan_version_id, v.plan_id, COALESCE(p.status, 'active') AS plan_status
|
|
530
|
+
FROM plan_versions v
|
|
531
|
+
JOIN plan_catalog p ON p.tenant_id = v.tenant_id AND p.plan_id = v.plan_id
|
|
532
|
+
WHERE v.tenant_id = ? AND v.plan_version_id = ?
|
|
533
|
+
LIMIT 1
|
|
534
|
+
`).get(this.tenantId, selectedPlanVersionId);
|
|
535
|
+
if (!row?.plan_version_id || !row.plan_id) {
|
|
536
|
+
throw new TaskforceRuleError(`Selected signup plan version "${selectedPlanVersionId}" was not found.`, 400, 'SIGNUP_PLAN_VERSION_NOT_FOUND', { planVersionId: selectedPlanVersionId });
|
|
537
|
+
}
|
|
538
|
+
if (String(row.plan_status || '').trim().toLowerCase() !== 'active') {
|
|
539
|
+
throw new TaskforceRuleError(`Selected signup plan version "${selectedPlanVersionId}" belongs to a disabled plan.`, 400, 'SIGNUP_PLAN_NOT_ENABLED', {
|
|
540
|
+
planVersionId: selectedPlanVersionId,
|
|
541
|
+
planId: String(row.plan_id)
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
requiresPlanSelection: false,
|
|
546
|
+
planVersionId: String(row.plan_version_id),
|
|
547
|
+
planId: String(row.plan_id)
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
const signupSettings = this.getSignupMonetizationSettings();
|
|
551
|
+
if (signupSettings.strategy === 'plan_selection_required') {
|
|
552
|
+
return {
|
|
553
|
+
requiresPlanSelection: true,
|
|
554
|
+
planVersionId: null,
|
|
555
|
+
planId: null
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
const validation = this.validateSignupMonetizationSettings(signupSettings);
|
|
559
|
+
if (!validation.valid) {
|
|
560
|
+
throw new TaskforceRuleError(validation.message || 'Signup monetization is misconfigured.', 500, validation.code || 'SIGNUP_MONETIZATION_MISCONFIGURED', validation.details);
|
|
561
|
+
}
|
|
562
|
+
const configuredVersion = signupSettings.strategy === 'auto_assign_default' && signupSettings.defaultSignupPlanVersionId
|
|
563
|
+
? this.getPlanVersionSnapshot(signupSettings.defaultSignupPlanVersionId)
|
|
564
|
+
: null;
|
|
565
|
+
if (!configuredVersion) {
|
|
566
|
+
throw new TaskforceRuleError('Default signup plan version is required when signup strategy is auto-assign default.', 500, 'SIGNUP_PLAN_VERSION_REQUIRED');
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
requiresPlanSelection: false,
|
|
570
|
+
planVersionId: configuredVersion.planVersionId,
|
|
571
|
+
planId: configuredVersion.planId
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
// ─── Public billing methods ───────────────────────────────────────────────
|
|
575
|
+
createUserBillingRecord(userId, options) {
|
|
576
|
+
const now = new Date().toISOString();
|
|
577
|
+
const entitlementState = String(options?.entitlementState || 'active').trim().toLowerCase() || 'active';
|
|
578
|
+
const planId = typeof options?.planId === 'string'
|
|
579
|
+
? options.planId.trim().toLowerCase()
|
|
580
|
+
: '';
|
|
581
|
+
this.db.prepare(`
|
|
582
|
+
INSERT INTO user_billing (user_id, tenant_id, entitlement_state, tier, created_at, updated_at)
|
|
583
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
584
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
585
|
+
entitlement_state = excluded.entitlement_state,
|
|
586
|
+
tier = excluded.tier,
|
|
587
|
+
updated_at = excluded.updated_at
|
|
588
|
+
`).run(userId, this.tenantId, entitlementState, planId, now, now);
|
|
589
|
+
}
|
|
590
|
+
getUserEntitlement(userId) {
|
|
591
|
+
const account = this.getAccountEntitlement(userId);
|
|
592
|
+
return {
|
|
593
|
+
entitlementState: account.state,
|
|
594
|
+
tier: account.planId
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
hasAccountEntitlement(accountId) {
|
|
598
|
+
const normalizedAccountId = String(accountId || '').trim();
|
|
599
|
+
if (!normalizedAccountId)
|
|
600
|
+
return false;
|
|
601
|
+
return this.tryGetAccountEntitlement(normalizedAccountId) !== null;
|
|
602
|
+
}
|
|
603
|
+
tryGetAccountEntitlement(accountId) {
|
|
604
|
+
try {
|
|
605
|
+
return this.getAccountEntitlement(accountId);
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
if (error instanceof TaskforceRuleError && error.code === 'ENTITLEMENT_NOT_FOUND') {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
getAccountAccessStatus(accountId) {
|
|
615
|
+
const normalizedAccountId = String(accountId || '').trim();
|
|
616
|
+
if (!normalizedAccountId) {
|
|
617
|
+
throw new TaskforceRuleError('accountId is required', 400, 'ACCOUNT_ID_REQUIRED');
|
|
618
|
+
}
|
|
619
|
+
const signupSettings = this.getSignupMonetizationSettings();
|
|
620
|
+
const validation = this.validateSignupMonetizationSettings(signupSettings);
|
|
621
|
+
const entitlement = this.tryGetAccountEntitlement(normalizedAccountId);
|
|
622
|
+
if (entitlement && this.isEntitlementStateAllowed(entitlement.state)) {
|
|
623
|
+
return {
|
|
624
|
+
gate: 'ok',
|
|
625
|
+
hasEntitlement: true,
|
|
626
|
+
canAccessApp: true,
|
|
627
|
+
canAccessPlans: true,
|
|
628
|
+
signupMonetizationStrategy: signupSettings.strategy,
|
|
629
|
+
planSelectionRequired: false,
|
|
630
|
+
message: null,
|
|
631
|
+
entitlement
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
if (entitlement) {
|
|
635
|
+
return {
|
|
636
|
+
gate: 'missing_entitlement',
|
|
637
|
+
hasEntitlement: true,
|
|
638
|
+
canAccessApp: false,
|
|
639
|
+
canAccessPlans: true,
|
|
640
|
+
signupMonetizationStrategy: signupSettings.strategy,
|
|
641
|
+
planSelectionRequired: false,
|
|
642
|
+
message: entitlement.state === 'suspended'
|
|
643
|
+
? 'Your subscription is suspended. Update billing to continue.'
|
|
644
|
+
: 'Your plan is inactive. Choose a plan to continue.',
|
|
645
|
+
entitlement
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
if (signupSettings.strategy === 'plan_selection_required') {
|
|
649
|
+
const billingRow = this.db.prepare(`
|
|
650
|
+
SELECT entitlement_state
|
|
651
|
+
FROM user_billing
|
|
652
|
+
WHERE tenant_id = ? AND user_id = ?
|
|
653
|
+
LIMIT 1
|
|
654
|
+
`).get(this.tenantId, normalizedAccountId);
|
|
655
|
+
const billingState = String(billingRow?.entitlement_state || '').trim().toLowerCase();
|
|
656
|
+
if (billingState === 'pending_plan_selection') {
|
|
657
|
+
return {
|
|
658
|
+
gate: 'plan_selection_required',
|
|
659
|
+
hasEntitlement: false,
|
|
660
|
+
canAccessApp: false,
|
|
661
|
+
canAccessPlans: true,
|
|
662
|
+
signupMonetizationStrategy: signupSettings.strategy,
|
|
663
|
+
planSelectionRequired: true,
|
|
664
|
+
message: 'Choose a plan to continue.',
|
|
665
|
+
entitlement: null
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
gate: 'plan_selection_required',
|
|
670
|
+
hasEntitlement: false,
|
|
671
|
+
canAccessApp: false,
|
|
672
|
+
canAccessPlans: true,
|
|
673
|
+
signupMonetizationStrategy: signupSettings.strategy,
|
|
674
|
+
planSelectionRequired: true,
|
|
675
|
+
message: 'Choose a plan to continue.',
|
|
676
|
+
entitlement: null
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
if (!validation.valid) {
|
|
680
|
+
return {
|
|
681
|
+
gate: 'misconfigured',
|
|
682
|
+
hasEntitlement: false,
|
|
683
|
+
canAccessApp: false,
|
|
684
|
+
canAccessPlans: true,
|
|
685
|
+
signupMonetizationStrategy: signupSettings.strategy,
|
|
686
|
+
planSelectionRequired: false,
|
|
687
|
+
message: validation.message || 'Signup monetization is misconfigured.',
|
|
688
|
+
entitlement: null
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
gate: 'missing_entitlement',
|
|
693
|
+
hasEntitlement: false,
|
|
694
|
+
canAccessApp: false,
|
|
695
|
+
canAccessPlans: true,
|
|
696
|
+
signupMonetizationStrategy: signupSettings.strategy,
|
|
697
|
+
planSelectionRequired: false,
|
|
698
|
+
message: 'No entitlement is linked to this account.',
|
|
699
|
+
entitlement: null
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
getEffectivePlanVersion(accountId) {
|
|
703
|
+
const normalizedAccountId = String(accountId || '').trim();
|
|
704
|
+
if (!normalizedAccountId) {
|
|
705
|
+
throw new TaskforceRuleError('accountId is required', 400, 'ACCOUNT_ID_REQUIRED');
|
|
706
|
+
}
|
|
707
|
+
const row = this.db.prepare(`
|
|
708
|
+
SELECT v.plan_version_id, v.plan_id, v.version_number, v.is_default, v.seat_limit, v.metadata_json
|
|
709
|
+
FROM account_entitlements e
|
|
710
|
+
JOIN plan_versions v ON v.plan_version_id = e.plan_version_id
|
|
711
|
+
WHERE e.tenant_id = ? AND e.account_id = ?
|
|
712
|
+
LIMIT 1
|
|
713
|
+
`).get(this.tenantId, normalizedAccountId);
|
|
714
|
+
if (!row?.plan_version_id || !row.plan_id) {
|
|
715
|
+
throw new TaskforceRuleError('Plan version not found for account', 404, 'PLAN_VERSION_NOT_FOUND');
|
|
716
|
+
}
|
|
717
|
+
const featuresRows = this.db.prepare(`
|
|
718
|
+
SELECT feature_key, access, config_json
|
|
719
|
+
FROM plan_feature_matrix
|
|
720
|
+
WHERE tenant_id = ? AND plan_version_id = ?
|
|
721
|
+
`).all(this.tenantId, row.plan_version_id);
|
|
722
|
+
const features = {};
|
|
723
|
+
for (const featureRow of featuresRows) {
|
|
724
|
+
const featureKey = String(featureRow.feature_key || '').trim();
|
|
725
|
+
if (!featureKey)
|
|
726
|
+
continue;
|
|
727
|
+
features[featureKey] = {
|
|
728
|
+
featureKey,
|
|
729
|
+
access: this.normalizeFeatureAccess(featureRow.access),
|
|
730
|
+
config: this.parseJsonObject(featureRow.config_json || '{}')
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
const metadata = this.parseJsonObject(row.metadata_json || '{}');
|
|
734
|
+
return {
|
|
735
|
+
planVersionId: String(row.plan_version_id),
|
|
736
|
+
planId: String(row.plan_id),
|
|
737
|
+
versionNumber: Number(row.version_number || 1),
|
|
738
|
+
isDefault: Boolean(row.is_default),
|
|
739
|
+
seatLimit: row.seat_limit === null || row.seat_limit === undefined
|
|
740
|
+
? null
|
|
741
|
+
: Math.max(0, Number(row.seat_limit)),
|
|
742
|
+
trialDays: this.readPlanVersionTrialDays(metadata),
|
|
743
|
+
metadata,
|
|
744
|
+
features
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
getAccountEntitlement(accountId) {
|
|
748
|
+
const normalizedAccountId = String(accountId || '').trim();
|
|
749
|
+
if (!normalizedAccountId) {
|
|
750
|
+
throw new TaskforceRuleError('accountId is required', 400, 'ACCOUNT_ID_REQUIRED');
|
|
751
|
+
}
|
|
752
|
+
const row = this.db.prepare(`
|
|
753
|
+
SELECT account_id, plan_version_id, entitlement_state, effective_at, effective_until
|
|
754
|
+
FROM account_entitlements
|
|
755
|
+
WHERE tenant_id = ? AND account_id = ?
|
|
756
|
+
LIMIT 1
|
|
757
|
+
`).get(this.tenantId, normalizedAccountId);
|
|
758
|
+
if (!row?.account_id || !row.plan_version_id) {
|
|
759
|
+
throw new TaskforceRuleError('Entitlement not found', 404, 'ENTITLEMENT_NOT_FOUND');
|
|
760
|
+
}
|
|
761
|
+
const version = this.getEffectivePlanVersion(normalizedAccountId);
|
|
762
|
+
return {
|
|
763
|
+
accountId: String(row.account_id),
|
|
764
|
+
planVersionId: String(row.plan_version_id),
|
|
765
|
+
planId: version.planId,
|
|
766
|
+
state: this.normalizeEntitlementState(row.entitlement_state),
|
|
767
|
+
effectiveAt: row.effective_at ? String(row.effective_at) : null,
|
|
768
|
+
effectiveUntil: row.effective_until ? String(row.effective_until) : null,
|
|
769
|
+
version
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
countWorkspaceSeatUsage(workspaceId, options = {}) {
|
|
773
|
+
const normalizedWorkspaceId = String(workspaceId || '').trim();
|
|
774
|
+
if (!normalizedWorkspaceId) {
|
|
775
|
+
throw new TaskforceRuleError('workspaceId is required', 400, 'WORKSPACE_ID_REQUIRED');
|
|
776
|
+
}
|
|
777
|
+
const accountId = this.deps.getWorkspaceOwnerUserId(normalizedWorkspaceId);
|
|
778
|
+
if (!accountId) {
|
|
779
|
+
return { used: 0, limit: null, remaining: null };
|
|
780
|
+
}
|
|
781
|
+
const entitlement = this.getAccountEntitlement(accountId);
|
|
782
|
+
const includeInvited = options.includeInvited !== false;
|
|
783
|
+
const statuses = includeInvited ? ['active', 'invited', 'disabled'] : ['active', 'disabled'];
|
|
784
|
+
const placeholders = statuses.map(() => '?').join(', ');
|
|
785
|
+
const row = this.db.prepare(`
|
|
786
|
+
SELECT COUNT(*) AS c
|
|
787
|
+
FROM workspace_memberships
|
|
788
|
+
WHERE tenant_id = ?
|
|
789
|
+
AND workspace_id = ?
|
|
790
|
+
AND COALESCE(status, 'active') IN (${placeholders})
|
|
791
|
+
`).get(this.tenantId, normalizedWorkspaceId, ...statuses);
|
|
792
|
+
const used = Math.max(0, Number(row?.c || 0));
|
|
793
|
+
const limit = entitlement.version.seatLimit;
|
|
794
|
+
const remaining = limit === null ? null : Math.max(0, limit - used);
|
|
795
|
+
return { used, limit, remaining };
|
|
796
|
+
}
|
|
797
|
+
assertSeatAvailable(workspaceId) {
|
|
798
|
+
const seatUsage = this.countWorkspaceSeatUsage(workspaceId, { includeInvited: true });
|
|
799
|
+
if (seatUsage.limit !== null && seatUsage.used >= seatUsage.limit) {
|
|
800
|
+
throw new TaskforceRuleError('Seat limit reached for this workspace', 403, 'SEAT_LIMIT_REACHED', seatUsage);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
evaluateFeatureAccess(input) {
|
|
804
|
+
const workspaceId = String(input.workspaceId || '').trim();
|
|
805
|
+
const featureKey = String(input.featureKey || '').trim();
|
|
806
|
+
if (!workspaceId) {
|
|
807
|
+
return { allowed: false, reasonCode: 'WORKSPACE_ID_REQUIRED', state: 'canceled' };
|
|
808
|
+
}
|
|
809
|
+
if (!featureKey) {
|
|
810
|
+
return { allowed: false, reasonCode: 'FEATURE_KEY_REQUIRED', state: 'canceled' };
|
|
811
|
+
}
|
|
812
|
+
const accountId = this.deps.getWorkspaceOwnerUserId(workspaceId);
|
|
813
|
+
if (!accountId) {
|
|
814
|
+
return { allowed: false, reasonCode: 'ENTITLEMENT_NOT_FOUND', state: 'canceled' };
|
|
815
|
+
}
|
|
816
|
+
const entitlement = this.getAccountEntitlement(accountId);
|
|
817
|
+
if (!this.isEntitlementStateAllowed(entitlement.state)) {
|
|
818
|
+
return { allowed: false, reasonCode: 'ENTITLEMENT_STATE_BLOCKED', state: entitlement.state };
|
|
819
|
+
}
|
|
820
|
+
const feature = entitlement.version.features[featureKey];
|
|
821
|
+
if (!feature) {
|
|
822
|
+
return { allowed: false, reasonCode: 'FEATURE_DISABLED', state: entitlement.state };
|
|
823
|
+
}
|
|
824
|
+
if (feature.access === 'disabled') {
|
|
825
|
+
return { allowed: false, reasonCode: 'FEATURE_DISABLED', state: entitlement.state, feature };
|
|
826
|
+
}
|
|
827
|
+
if (featureKey === 'collaboration.seat_limit') {
|
|
828
|
+
const seatUsage = this.countWorkspaceSeatUsage(workspaceId, { includeInvited: true });
|
|
829
|
+
if (seatUsage.limit !== null && seatUsage.used >= seatUsage.limit) {
|
|
830
|
+
return { allowed: false, reasonCode: 'SEAT_LIMIT_REACHED', state: entitlement.state, seatUsage, feature };
|
|
831
|
+
}
|
|
832
|
+
return { allowed: true, reasonCode: 'OK', state: entitlement.state, seatUsage, feature };
|
|
833
|
+
}
|
|
834
|
+
return { allowed: true, reasonCode: 'OK', state: entitlement.state, feature };
|
|
835
|
+
}
|
|
836
|
+
getUserAccountPlanMode(userId) {
|
|
837
|
+
const normalizedUserId = String(userId || '').trim();
|
|
838
|
+
if (!normalizedUserId)
|
|
839
|
+
return 'personal';
|
|
840
|
+
const entitlement = this.getAccountEntitlement(normalizedUserId);
|
|
841
|
+
return this.isTeamPlanVersion(entitlement.version) ? 'team' : 'personal';
|
|
842
|
+
}
|
|
843
|
+
setUserAccountPlanMode(userId, mode) {
|
|
844
|
+
const normalizedUserId = String(userId || '').trim();
|
|
845
|
+
if (!normalizedUserId) {
|
|
846
|
+
throw new TaskforceRuleError('userId is required', 400, 'USER_ID_REQUIRED');
|
|
847
|
+
}
|
|
848
|
+
const normalizedMode = String(mode || '').trim().toLowerCase() === 'team' ? 'team' : 'personal';
|
|
849
|
+
const targetVersion = this.getDefaultPlanVersionByMode(normalizedMode);
|
|
850
|
+
const now = new Date().toISOString();
|
|
851
|
+
this.db.prepare(`
|
|
852
|
+
INSERT INTO user_billing (user_id, tenant_id, entitlement_state, tier, created_at, updated_at)
|
|
853
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
854
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
855
|
+
entitlement_state = excluded.entitlement_state,
|
|
856
|
+
tier = excluded.tier,
|
|
857
|
+
updated_at = excluded.updated_at
|
|
858
|
+
`).run(normalizedUserId, this.tenantId, 'active', targetVersion.planId, now, now);
|
|
859
|
+
const planVersionId = targetVersion.planVersionId;
|
|
860
|
+
const entitlementState = 'active';
|
|
861
|
+
this.upsertCanonicalEntitlementRecord({
|
|
862
|
+
accountId: normalizedUserId,
|
|
863
|
+
planVersionId,
|
|
864
|
+
state: entitlementState,
|
|
865
|
+
effectiveAt: now,
|
|
866
|
+
updatedBy: 'legacy-plan-mode',
|
|
867
|
+
createdAt: now,
|
|
868
|
+
updatedAt: now
|
|
869
|
+
});
|
|
870
|
+
return normalizedMode;
|
|
871
|
+
}
|
|
872
|
+
getRequiredDefaultPlanVersionId(planId) {
|
|
873
|
+
return this.getRequiredDefaultPlanVersion(planId).planVersionId;
|
|
874
|
+
}
|
|
875
|
+
getDefaultPlanVersionIdByMode(mode, options = {}) {
|
|
876
|
+
return this.getDefaultPlanVersionByMode(mode, options).planVersionId;
|
|
877
|
+
}
|
|
878
|
+
getBillingMirrorProfileForPlanVersion(planVersionId) {
|
|
879
|
+
const version = this.getPlanVersionSnapshot(planVersionId);
|
|
880
|
+
return {
|
|
881
|
+
planId: version.planId,
|
|
882
|
+
entitlementState: 'active'
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
// ─── Signup monetization settings ─────────────────────────────────────────
|
|
886
|
+
getSignupMonetizationSettings() {
|
|
887
|
+
const auth = this.deps.readGlobalConfig().auth || {};
|
|
888
|
+
return {
|
|
889
|
+
strategy: auth.signupMonetizationStrategy === 'plan_selection_required'
|
|
890
|
+
? 'plan_selection_required'
|
|
891
|
+
: 'auto_assign_default',
|
|
892
|
+
defaultSignupPlanVersionId: typeof auth.defaultSignupPlanVersionId === 'string'
|
|
893
|
+
? (auth.defaultSignupPlanVersionId.trim() || null)
|
|
894
|
+
: null
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
validateSignupMonetizationSettings(input) {
|
|
898
|
+
const current = this.getSignupMonetizationSettings();
|
|
899
|
+
const strategy = input?.strategy === 'plan_selection_required'
|
|
900
|
+
? 'plan_selection_required'
|
|
901
|
+
: input?.strategy === 'auto_assign_default'
|
|
902
|
+
? 'auto_assign_default'
|
|
903
|
+
: current.strategy;
|
|
904
|
+
const defaultSignupPlanVersionId = input?.defaultSignupPlanVersionId !== undefined
|
|
905
|
+
? (typeof input.defaultSignupPlanVersionId === 'string' ? input.defaultSignupPlanVersionId.trim() || null : null)
|
|
906
|
+
: current.defaultSignupPlanVersionId;
|
|
907
|
+
if (strategy === 'plan_selection_required') {
|
|
908
|
+
return { valid: true };
|
|
909
|
+
}
|
|
910
|
+
if (!defaultSignupPlanVersionId) {
|
|
911
|
+
return {
|
|
912
|
+
valid: false,
|
|
913
|
+
code: 'SIGNUP_PLAN_VERSION_REQUIRED',
|
|
914
|
+
message: 'Default signup plan version is required when signup strategy is auto-assign default.'
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
const row = this.db.prepare(`
|
|
918
|
+
SELECT v.plan_version_id, v.plan_id, COALESCE(p.status, 'active') AS plan_status
|
|
919
|
+
FROM plan_versions v
|
|
920
|
+
JOIN plan_catalog p ON p.tenant_id = v.tenant_id AND p.plan_id = v.plan_id
|
|
921
|
+
WHERE v.tenant_id = ? AND v.plan_version_id = ?
|
|
922
|
+
LIMIT 1
|
|
923
|
+
`).get(this.tenantId, defaultSignupPlanVersionId);
|
|
924
|
+
if (!row?.plan_version_id || !row.plan_id) {
|
|
925
|
+
return {
|
|
926
|
+
valid: false,
|
|
927
|
+
code: 'SIGNUP_PLAN_VERSION_NOT_FOUND',
|
|
928
|
+
message: `Configured default signup plan version "${defaultSignupPlanVersionId}" was not found.`,
|
|
929
|
+
details: { defaultSignupPlanVersionId }
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
if (String(row.plan_status || '').trim().toLowerCase() !== 'active') {
|
|
933
|
+
return {
|
|
934
|
+
valid: false,
|
|
935
|
+
code: 'SIGNUP_PLAN_NOT_ENABLED',
|
|
936
|
+
message: `Configured default signup plan version "${defaultSignupPlanVersionId}" belongs to a disabled plan.`,
|
|
937
|
+
details: {
|
|
938
|
+
defaultSignupPlanVersionId,
|
|
939
|
+
planId: String(row.plan_id)
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
return { valid: true };
|
|
944
|
+
}
|
|
945
|
+
// ─── Plan catalog admin ───────────────────────────────────────────────────
|
|
946
|
+
isValidEntitlementTransition(fromState, toState) {
|
|
947
|
+
if (fromState === toState)
|
|
948
|
+
return true;
|
|
949
|
+
const allowed = {
|
|
950
|
+
trialing: ['active', 'grace', 'canceled'],
|
|
951
|
+
active: ['grace', 'suspended', 'canceled'],
|
|
952
|
+
grace: ['active', 'suspended', 'canceled'],
|
|
953
|
+
suspended: ['active', 'canceled'],
|
|
954
|
+
canceled: ['trialing', 'active']
|
|
955
|
+
};
|
|
956
|
+
return allowed[fromState].includes(toState);
|
|
957
|
+
}
|
|
958
|
+
addEntitlementAuditLog(input) {
|
|
959
|
+
const now = new Date().toISOString();
|
|
960
|
+
const id = this.generateEntityId('ent-audit');
|
|
961
|
+
this.db.prepare(`
|
|
962
|
+
INSERT INTO entitlement_audit_log (
|
|
963
|
+
id, tenant_id, actor_user_id, actor_role, account_id, action, reason, before_json, after_json, request_id, created_at
|
|
964
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
965
|
+
`).run(id, this.tenantId, String(input.actorUserId || 'anonymous').trim() || 'anonymous', String(input.actorRole || 'system_admin').trim() || 'system_admin', String(input.accountId || '').trim(), String(input.action || '').trim(), input.reason ? String(input.reason) : null, JSON.stringify(input.before && typeof input.before === 'object' ? input.before : {}), JSON.stringify(input.after && typeof input.after === 'object' ? input.after : {}), input.requestId ? String(input.requestId) : null, now);
|
|
966
|
+
}
|
|
967
|
+
upsertPlanCatalog(input) {
|
|
968
|
+
const planId = String(input.planId || '').trim().toLowerCase();
|
|
969
|
+
const displayName = String(input.displayName || '').trim();
|
|
970
|
+
const initialMode = String(input.initialMode || '').trim().toLowerCase() === 'team'
|
|
971
|
+
? 'team'
|
|
972
|
+
: 'personal';
|
|
973
|
+
const initialSeatLimit = input.initialSeatLimit === null || input.initialSeatLimit === undefined
|
|
974
|
+
? null
|
|
975
|
+
: Math.max(0, Math.floor(Number(input.initialSeatLimit)));
|
|
976
|
+
const initialTrialDays = this.normalizePlanVersionTrialDays(input.initialTrialDays);
|
|
977
|
+
if (!planId)
|
|
978
|
+
throw new TaskforceRuleError('planId is required', 400, 'PLAN_ID_REQUIRED');
|
|
979
|
+
if (!displayName)
|
|
980
|
+
throw new TaskforceRuleError('displayName is required', 400, 'PLAN_DISPLAY_NAME_REQUIRED');
|
|
981
|
+
this.assertSeatLimitCompatibleWithMode({
|
|
982
|
+
seatLimit: initialSeatLimit,
|
|
983
|
+
features: this.buildPlanModeFeatures(initialMode).reduce((acc, feature) => {
|
|
984
|
+
acc[feature.featureKey] = {
|
|
985
|
+
featureKey: feature.featureKey,
|
|
986
|
+
access: feature.access,
|
|
987
|
+
config: feature.config
|
|
988
|
+
};
|
|
989
|
+
return acc;
|
|
990
|
+
}, {}),
|
|
991
|
+
modeLabel: 'New team plan'
|
|
992
|
+
});
|
|
993
|
+
const now = new Date().toISOString();
|
|
994
|
+
const existing = this.db.prepare(`
|
|
995
|
+
SELECT plan_id, status
|
|
996
|
+
FROM plan_catalog
|
|
997
|
+
WHERE tenant_id = ? AND plan_id = ?
|
|
998
|
+
LIMIT 1
|
|
999
|
+
`).get(this.tenantId, planId);
|
|
1000
|
+
const isNewPlan = !existing?.plan_id;
|
|
1001
|
+
const status = input.status === 'active'
|
|
1002
|
+
? 'active'
|
|
1003
|
+
: input.status === 'archived'
|
|
1004
|
+
? 'archived'
|
|
1005
|
+
: isNewPlan
|
|
1006
|
+
? 'archived'
|
|
1007
|
+
: String(existing?.status || '').trim().toLowerCase() === 'active'
|
|
1008
|
+
? 'active'
|
|
1009
|
+
: 'archived';
|
|
1010
|
+
const tx = this.db.transaction(() => {
|
|
1011
|
+
this.db.prepare(`
|
|
1012
|
+
INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
|
|
1013
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1014
|
+
ON CONFLICT (plan_id) DO UPDATE SET
|
|
1015
|
+
display_name = excluded.display_name,
|
|
1016
|
+
status = excluded.status,
|
|
1017
|
+
updated_at = excluded.updated_at
|
|
1018
|
+
`).run(planId, this.tenantId, displayName, status, now, now);
|
|
1019
|
+
if (isNewPlan) {
|
|
1020
|
+
this.db.prepare(`
|
|
1021
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, seat_limit, metadata_json, created_at, updated_at)
|
|
1022
|
+
VALUES (?, ?, ?, 1, 1, NULL, ?, ?, ?)
|
|
1023
|
+
ON CONFLICT (plan_version_id) DO NOTHING
|
|
1024
|
+
`).run(`${planId}-v1`, this.tenantId, planId, JSON.stringify(this.applyPlanVersionTrialDaysMetadata({}, initialTrialDays)), now, now);
|
|
1025
|
+
this.db.prepare(`
|
|
1026
|
+
UPDATE plan_versions
|
|
1027
|
+
SET seat_limit = ?, updated_at = ?
|
|
1028
|
+
WHERE tenant_id = ? AND plan_version_id = ?
|
|
1029
|
+
`).run(initialSeatLimit, now, this.tenantId, `${planId}-v1`);
|
|
1030
|
+
for (const feature of this.buildPlanModeFeatures(initialMode)) {
|
|
1031
|
+
this.db.prepare(`
|
|
1032
|
+
INSERT INTO plan_feature_matrix (tenant_id, plan_version_id, feature_key, access, config_json, created_at, updated_at)
|
|
1033
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1034
|
+
ON CONFLICT (tenant_id, plan_version_id, feature_key) DO UPDATE SET
|
|
1035
|
+
access = excluded.access,
|
|
1036
|
+
config_json = excluded.config_json,
|
|
1037
|
+
updated_at = excluded.updated_at
|
|
1038
|
+
`).run(this.tenantId, `${planId}-v1`, feature.featureKey, feature.access, JSON.stringify(feature.config), now, now);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
tx();
|
|
1043
|
+
this.repairPlanDefaultVersionIfNeeded(planId);
|
|
1044
|
+
return { planId, displayName, status };
|
|
1045
|
+
}
|
|
1046
|
+
listPlanCatalog() {
|
|
1047
|
+
const rows = this.db.prepare(`
|
|
1048
|
+
SELECT plan_id, display_name, status, created_at, updated_at
|
|
1049
|
+
FROM plan_catalog
|
|
1050
|
+
WHERE tenant_id = ?
|
|
1051
|
+
ORDER BY updated_at DESC, created_at DESC
|
|
1052
|
+
`).all(this.tenantId);
|
|
1053
|
+
return rows.map((row) => ({
|
|
1054
|
+
planId: String(row.plan_id || ''),
|
|
1055
|
+
displayName: String(row.display_name || ''),
|
|
1056
|
+
status: String(row.status || '').trim().toLowerCase() === 'archived' ? 'archived' : 'active',
|
|
1057
|
+
createdAt: String(row.created_at || ''),
|
|
1058
|
+
updatedAt: String(row.updated_at || '')
|
|
1059
|
+
}));
|
|
1060
|
+
}
|
|
1061
|
+
listPlanFeatureCatalog() {
|
|
1062
|
+
return DEFAULT_PLAN_FEATURE_CATALOG.map((feature) => ({
|
|
1063
|
+
featureKey: feature.featureKey,
|
|
1064
|
+
label: feature.label,
|
|
1065
|
+
description: feature.description,
|
|
1066
|
+
allowedAccessModes: [...feature.allowedAccessModes],
|
|
1067
|
+
configTemplate: { ...feature.configTemplate },
|
|
1068
|
+
...(feature.dependsOn?.length ? { dependsOn: [...feature.dependsOn] } : {})
|
|
1069
|
+
}));
|
|
1070
|
+
}
|
|
1071
|
+
listPlanVersions(planId) {
|
|
1072
|
+
const normalizedPlanId = String(planId || '').trim().toLowerCase();
|
|
1073
|
+
if (!normalizedPlanId)
|
|
1074
|
+
throw new TaskforceRuleError('planId is required', 400, 'PLAN_ID_REQUIRED');
|
|
1075
|
+
this.repairPlanDefaultVersionIfNeeded(normalizedPlanId);
|
|
1076
|
+
const rows = this.db.prepare(`
|
|
1077
|
+
SELECT plan_version_id
|
|
1078
|
+
FROM plan_versions
|
|
1079
|
+
WHERE tenant_id = ? AND plan_id = ?
|
|
1080
|
+
ORDER BY version_number DESC
|
|
1081
|
+
`).all(this.tenantId, normalizedPlanId);
|
|
1082
|
+
return rows
|
|
1083
|
+
.map((row) => String(row.plan_version_id || '').trim())
|
|
1084
|
+
.filter(Boolean)
|
|
1085
|
+
.map((planVersionId) => this.getPlanVersionSnapshot(planVersionId));
|
|
1086
|
+
}
|
|
1087
|
+
getPlanVersionFeatures(planId, versionId) {
|
|
1088
|
+
const normalizedPlanId = String(planId || '').trim().toLowerCase();
|
|
1089
|
+
const normalizedVersionId = String(versionId || '').trim();
|
|
1090
|
+
if (!normalizedPlanId)
|
|
1091
|
+
throw new TaskforceRuleError('planId is required', 400, 'PLAN_ID_REQUIRED');
|
|
1092
|
+
if (!normalizedVersionId)
|
|
1093
|
+
throw new TaskforceRuleError('versionId is required', 400, 'PLAN_VERSION_ID_REQUIRED');
|
|
1094
|
+
const version = this.getPlanVersionSnapshot(normalizedVersionId);
|
|
1095
|
+
if (version.planId !== normalizedPlanId) {
|
|
1096
|
+
throw new TaskforceRuleError('Plan version does not belong to plan', 404, 'PLAN_VERSION_NOT_FOUND');
|
|
1097
|
+
}
|
|
1098
|
+
return version;
|
|
1099
|
+
}
|
|
1100
|
+
searchAccountsByEmail(query, limit = 20) {
|
|
1101
|
+
const q = String(query || '').trim().toLowerCase();
|
|
1102
|
+
if (!q)
|
|
1103
|
+
return [];
|
|
1104
|
+
const safeLimit = Math.max(1, Math.min(100, Math.floor(Number(limit) || 20)));
|
|
1105
|
+
const rows = this.db.prepare(`
|
|
1106
|
+
SELECT id, email, display_name, is_disabled
|
|
1107
|
+
FROM users
|
|
1108
|
+
WHERE tenant_id = ?
|
|
1109
|
+
AND (LOWER(email) LIKE ? OR LOWER(COALESCE(display_name, '')) LIKE ? OR LOWER(id) LIKE ?)
|
|
1110
|
+
ORDER BY updated_at DESC, created_at DESC
|
|
1111
|
+
LIMIT ?
|
|
1112
|
+
`).all(this.tenantId, `%${q}%`, `%${q}%`, `%${q}%`, safeLimit);
|
|
1113
|
+
return rows.map((row) => ({
|
|
1114
|
+
accountId: String(row.id || ''),
|
|
1115
|
+
email: String(row.email || ''),
|
|
1116
|
+
displayName: row.display_name ? String(row.display_name) : null,
|
|
1117
|
+
disabled: Boolean(row.is_disabled)
|
|
1118
|
+
}));
|
|
1119
|
+
}
|
|
1120
|
+
createPlanVersion(input) {
|
|
1121
|
+
const planId = String(input.planId || '').trim().toLowerCase();
|
|
1122
|
+
const versionNumber = Math.max(1, Math.floor(Number(input.versionNumber) || 1));
|
|
1123
|
+
if (!planId)
|
|
1124
|
+
throw new TaskforceRuleError('planId is required', 400, 'PLAN_ID_REQUIRED');
|
|
1125
|
+
const catalog = this.db.prepare(`
|
|
1126
|
+
SELECT plan_id FROM plan_catalog WHERE tenant_id = ? AND plan_id = ? LIMIT 1
|
|
1127
|
+
`).get(this.tenantId, planId);
|
|
1128
|
+
if (!catalog?.plan_id) {
|
|
1129
|
+
throw new TaskforceRuleError('Plan not found', 404, 'PLAN_NOT_FOUND');
|
|
1130
|
+
}
|
|
1131
|
+
const planVersionId = `${planId}-v${versionNumber}`;
|
|
1132
|
+
const existingVersion = this.db.prepare(`
|
|
1133
|
+
SELECT plan_version_id
|
|
1134
|
+
FROM plan_versions
|
|
1135
|
+
WHERE tenant_id = ? AND plan_version_id = ?
|
|
1136
|
+
LIMIT 1
|
|
1137
|
+
`).get(this.tenantId, planVersionId);
|
|
1138
|
+
if (existingVersion?.plan_version_id) {
|
|
1139
|
+
throw new TaskforceRuleError('Plan version already exists', 409, 'PLAN_VERSION_EXISTS');
|
|
1140
|
+
}
|
|
1141
|
+
const isDefault = input.isDefault === true;
|
|
1142
|
+
const seatLimit = input.seatLimit === null || input.seatLimit === undefined
|
|
1143
|
+
? null
|
|
1144
|
+
: Math.max(0, Math.floor(Number(input.seatLimit)));
|
|
1145
|
+
const trialDays = this.normalizePlanVersionTrialDays(input.trialDays);
|
|
1146
|
+
const baseMetadata = input.metadata && typeof input.metadata === 'object' ? input.metadata : {};
|
|
1147
|
+
const metadata = this.applyPlanVersionTrialDaysMetadata(baseMetadata, trialDays);
|
|
1148
|
+
this.assertSeatLimitCompatibleWithMode({
|
|
1149
|
+
seatLimit,
|
|
1150
|
+
features: {},
|
|
1151
|
+
modeLabel: `Plan version ${planVersionId}`
|
|
1152
|
+
});
|
|
1153
|
+
const now = new Date().toISOString();
|
|
1154
|
+
const tx = this.db.transaction(() => {
|
|
1155
|
+
if (isDefault) {
|
|
1156
|
+
this.db.prepare(`
|
|
1157
|
+
UPDATE plan_versions
|
|
1158
|
+
SET is_default = 0, updated_at = ?
|
|
1159
|
+
WHERE tenant_id = ? AND plan_id = ?
|
|
1160
|
+
`).run(now, this.tenantId, planId);
|
|
1161
|
+
}
|
|
1162
|
+
this.db.prepare(`
|
|
1163
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, seat_limit, metadata_json, created_at, updated_at)
|
|
1164
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1165
|
+
`).run(planVersionId, this.tenantId, planId, versionNumber, isDefault ? 1 : 0, seatLimit, JSON.stringify(metadata), now, now);
|
|
1166
|
+
});
|
|
1167
|
+
tx();
|
|
1168
|
+
this.repairPlanDefaultVersionIfNeeded(planId);
|
|
1169
|
+
return this.getPlanVersionSnapshot(planVersionId);
|
|
1170
|
+
}
|
|
1171
|
+
updatePlanVersion(input) {
|
|
1172
|
+
const planId = String(input.planId || '').trim().toLowerCase();
|
|
1173
|
+
const versionNumber = Math.max(1, Math.floor(Number(input.versionNumber) || 1));
|
|
1174
|
+
if (!planId)
|
|
1175
|
+
throw new TaskforceRuleError('planId is required', 400, 'PLAN_ID_REQUIRED');
|
|
1176
|
+
const planVersionId = `${planId}-v${versionNumber}`;
|
|
1177
|
+
const existing = this.db.prepare(`
|
|
1178
|
+
SELECT plan_version_id, is_default, metadata_json, seat_limit
|
|
1179
|
+
FROM plan_versions
|
|
1180
|
+
WHERE tenant_id = ? AND plan_version_id = ? AND plan_id = ?
|
|
1181
|
+
LIMIT 1
|
|
1182
|
+
`).get(this.tenantId, planVersionId, planId);
|
|
1183
|
+
if (!existing?.plan_version_id) {
|
|
1184
|
+
throw new TaskforceRuleError('Plan version not found', 404, 'PLAN_VERSION_NOT_FOUND');
|
|
1185
|
+
}
|
|
1186
|
+
const existingDefault = Boolean(existing.is_default);
|
|
1187
|
+
const nextDefault = input.isDefault === undefined ? existingDefault : input.isDefault === true;
|
|
1188
|
+
if (existingDefault && !nextDefault) {
|
|
1189
|
+
const alternate = this.db.prepare(`
|
|
1190
|
+
SELECT plan_version_id
|
|
1191
|
+
FROM plan_versions
|
|
1192
|
+
WHERE tenant_id = ? AND plan_id = ? AND plan_version_id != ? AND is_default = 1
|
|
1193
|
+
LIMIT 1
|
|
1194
|
+
`).get(this.tenantId, planId, planVersionId);
|
|
1195
|
+
if (!alternate?.plan_version_id) {
|
|
1196
|
+
throw new TaskforceRuleError('A plan must have exactly one default version', 409, 'PLAN_DEFAULT_VERSION_REQUIRED');
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
const seatLimit = input.seatLimit === undefined
|
|
1200
|
+
? (existing.seat_limit === null || existing.seat_limit === undefined ? null : Math.max(0, Number(existing.seat_limit)))
|
|
1201
|
+
: input.seatLimit === null
|
|
1202
|
+
? null
|
|
1203
|
+
: Math.max(0, Math.floor(Number(input.seatLimit)));
|
|
1204
|
+
const baseMetadata = input.metadata === undefined
|
|
1205
|
+
? this.parseJsonObject(existing.metadata_json || '{}')
|
|
1206
|
+
: (input.metadata && typeof input.metadata === 'object' ? input.metadata : {});
|
|
1207
|
+
const trialDays = input.trialDays === undefined
|
|
1208
|
+
? undefined
|
|
1209
|
+
: this.normalizePlanVersionTrialDays(input.trialDays);
|
|
1210
|
+
const metadata = this.applyPlanVersionTrialDaysMetadata(baseMetadata, trialDays);
|
|
1211
|
+
const currentFeatures = this.getPlanVersionSnapshot(planVersionId).features;
|
|
1212
|
+
this.assertSeatLimitCompatibleWithMode({
|
|
1213
|
+
seatLimit,
|
|
1214
|
+
features: currentFeatures,
|
|
1215
|
+
modeLabel: `Plan version ${planVersionId}`
|
|
1216
|
+
});
|
|
1217
|
+
const now = new Date().toISOString();
|
|
1218
|
+
const tx = this.db.transaction(() => {
|
|
1219
|
+
if (nextDefault) {
|
|
1220
|
+
this.db.prepare(`
|
|
1221
|
+
UPDATE plan_versions
|
|
1222
|
+
SET is_default = CASE WHEN plan_version_id = ? THEN 1 ELSE 0 END,
|
|
1223
|
+
updated_at = ?
|
|
1224
|
+
WHERE tenant_id = ? AND plan_id = ?
|
|
1225
|
+
`).run(planVersionId, now, this.tenantId, planId);
|
|
1226
|
+
}
|
|
1227
|
+
else {
|
|
1228
|
+
this.db.prepare(`
|
|
1229
|
+
UPDATE plan_versions
|
|
1230
|
+
SET updated_at = ?
|
|
1231
|
+
WHERE tenant_id = ? AND plan_id = ?
|
|
1232
|
+
`).run(now, this.tenantId, planId);
|
|
1233
|
+
}
|
|
1234
|
+
this.db.prepare(`
|
|
1235
|
+
UPDATE plan_versions
|
|
1236
|
+
SET seat_limit = ?, metadata_json = ?, updated_at = ?
|
|
1237
|
+
WHERE tenant_id = ? AND plan_version_id = ?
|
|
1238
|
+
`).run(seatLimit, JSON.stringify(metadata), now, this.tenantId, planVersionId);
|
|
1239
|
+
});
|
|
1240
|
+
tx();
|
|
1241
|
+
this.repairPlanDefaultVersionIfNeeded(planId);
|
|
1242
|
+
return this.getPlanVersionSnapshot(planVersionId);
|
|
1243
|
+
}
|
|
1244
|
+
getPlanVersionSnapshot(planVersionId) {
|
|
1245
|
+
const row = this.db.prepare(`
|
|
1246
|
+
SELECT plan_version_id, plan_id, version_number, is_default, seat_limit, metadata_json
|
|
1247
|
+
FROM plan_versions
|
|
1248
|
+
WHERE tenant_id = ? AND plan_version_id = ?
|
|
1249
|
+
LIMIT 1
|
|
1250
|
+
`).get(this.tenantId, planVersionId);
|
|
1251
|
+
if (!row?.plan_version_id || !row.plan_id) {
|
|
1252
|
+
throw new TaskforceRuleError('Plan version not found', 404, 'PLAN_VERSION_NOT_FOUND');
|
|
1253
|
+
}
|
|
1254
|
+
const featuresRows = this.db.prepare(`
|
|
1255
|
+
SELECT feature_key, access, config_json
|
|
1256
|
+
FROM plan_feature_matrix
|
|
1257
|
+
WHERE tenant_id = ? AND plan_version_id = ?
|
|
1258
|
+
`).all(this.tenantId, planVersionId);
|
|
1259
|
+
const features = {};
|
|
1260
|
+
for (const featureRow of featuresRows) {
|
|
1261
|
+
const key = String(featureRow.feature_key || '').trim();
|
|
1262
|
+
if (!key)
|
|
1263
|
+
continue;
|
|
1264
|
+
features[key] = {
|
|
1265
|
+
featureKey: key,
|
|
1266
|
+
access: this.normalizeFeatureAccess(featureRow.access),
|
|
1267
|
+
config: this.parseJsonObject(featureRow.config_json || '{}')
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
const metadata = this.parseJsonObject(row.metadata_json || '{}');
|
|
1271
|
+
return {
|
|
1272
|
+
planVersionId: String(row.plan_version_id),
|
|
1273
|
+
planId: String(row.plan_id),
|
|
1274
|
+
versionNumber: Number(row.version_number || 1),
|
|
1275
|
+
isDefault: Boolean(row.is_default),
|
|
1276
|
+
seatLimit: row.seat_limit === null || row.seat_limit === undefined ? null : Number(row.seat_limit),
|
|
1277
|
+
trialDays: this.readPlanVersionTrialDays(metadata),
|
|
1278
|
+
metadata,
|
|
1279
|
+
features: this.normalizePlanFeatureDependencies(features)
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
updatePlanVersionFeatures(input) {
|
|
1283
|
+
const planVersionId = String(input.planVersionId || '').trim();
|
|
1284
|
+
if (!planVersionId)
|
|
1285
|
+
throw new TaskforceRuleError('planVersionId is required', 400, 'PLAN_VERSION_ID_REQUIRED');
|
|
1286
|
+
const now = new Date().toISOString();
|
|
1287
|
+
const features = Array.isArray(input.features) ? input.features : [];
|
|
1288
|
+
const currentVersion = this.getPlanVersionSnapshot(planVersionId);
|
|
1289
|
+
const nextFeatures = {
|
|
1290
|
+
...currentVersion.features
|
|
1291
|
+
};
|
|
1292
|
+
for (const feature of features) {
|
|
1293
|
+
const featureKey = String(feature.featureKey || '').trim();
|
|
1294
|
+
if (!featureKey)
|
|
1295
|
+
continue;
|
|
1296
|
+
const access = this.normalizeFeatureAccess(feature.access);
|
|
1297
|
+
const rawConfig = feature.config && typeof feature.config === 'object' ? feature.config : {};
|
|
1298
|
+
nextFeatures[featureKey] = {
|
|
1299
|
+
featureKey,
|
|
1300
|
+
access,
|
|
1301
|
+
config: this.sanitizePlanFeatureConfig(featureKey, access, rawConfig)
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
const normalizedNextFeatures = this.normalizePlanFeatureDependencies(nextFeatures);
|
|
1305
|
+
this.assertSeatLimitCompatibleWithMode({
|
|
1306
|
+
seatLimit: currentVersion.seatLimit,
|
|
1307
|
+
features: normalizedNextFeatures,
|
|
1308
|
+
modeLabel: `Plan version ${planVersionId}`
|
|
1309
|
+
});
|
|
1310
|
+
const tx = this.db.transaction(() => {
|
|
1311
|
+
for (const [featureKey, feature] of Object.entries(normalizedNextFeatures)) {
|
|
1312
|
+
const access = this.normalizeFeatureAccess(feature.access);
|
|
1313
|
+
const rawConfig = feature.config && typeof feature.config === 'object' ? feature.config : {};
|
|
1314
|
+
const config = this.sanitizePlanFeatureConfig(featureKey, access, rawConfig);
|
|
1315
|
+
this.db.prepare(`
|
|
1316
|
+
INSERT INTO plan_feature_matrix (tenant_id, plan_version_id, feature_key, access, config_json, created_at, updated_at)
|
|
1317
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1318
|
+
ON CONFLICT (tenant_id, plan_version_id, feature_key) DO UPDATE SET
|
|
1319
|
+
access = excluded.access,
|
|
1320
|
+
config_json = excluded.config_json,
|
|
1321
|
+
updated_at = excluded.updated_at
|
|
1322
|
+
`).run(this.tenantId, planVersionId, featureKey, access, JSON.stringify(config), now, now);
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
tx();
|
|
1326
|
+
const snapshot = this.getPlanVersionSnapshot(planVersionId);
|
|
1327
|
+
this.assertSeatLimitCompatibleWithMode({
|
|
1328
|
+
seatLimit: snapshot.seatLimit,
|
|
1329
|
+
features: this.normalizePlanFeatureDependencies(snapshot.features),
|
|
1330
|
+
modeLabel: `Plan version ${planVersionId}`
|
|
1331
|
+
});
|
|
1332
|
+
return snapshot;
|
|
1333
|
+
}
|
|
1334
|
+
updateAccountEntitlement(input) {
|
|
1335
|
+
const accountId = String(input.accountId || '').trim();
|
|
1336
|
+
if (!accountId)
|
|
1337
|
+
throw new TaskforceRuleError('accountId is required', 400, 'ACCOUNT_ID_REQUIRED');
|
|
1338
|
+
const existing = this.db.prepare(`
|
|
1339
|
+
SELECT account_id, plan_version_id, entitlement_state, effective_at, effective_until
|
|
1340
|
+
FROM account_entitlements
|
|
1341
|
+
WHERE tenant_id = ? AND account_id = ?
|
|
1342
|
+
LIMIT 1
|
|
1343
|
+
`).get(this.tenantId, accountId);
|
|
1344
|
+
const before = existing?.account_id ? this.getAccountEntitlement(accountId) : null;
|
|
1345
|
+
const nextPlanVersionId = String(input.planVersionId || before?.planVersionId || '').trim();
|
|
1346
|
+
if (!nextPlanVersionId) {
|
|
1347
|
+
throw new TaskforceRuleError('planVersionId is required', 400, 'PLAN_VERSION_ID_REQUIRED');
|
|
1348
|
+
}
|
|
1349
|
+
const currentState = before?.state || 'active';
|
|
1350
|
+
const nextState = this.normalizeEntitlementState(input.entitlementState || currentState);
|
|
1351
|
+
if (before && !this.isValidEntitlementTransition(before.state, nextState)) {
|
|
1352
|
+
throw new TaskforceRuleError('Invalid entitlement state transition', 409, 'INVALID_STATE_TRANSITION');
|
|
1353
|
+
}
|
|
1354
|
+
const normalizedReason = typeof input.reason === 'string' ? input.reason.trim() : '';
|
|
1355
|
+
if ((nextState === 'suspended' || nextState === 'canceled') && !normalizedReason) {
|
|
1356
|
+
throw new TaskforceRuleError('Reason is required when setting state to suspended/canceled.', 400, 'ENTITLEMENT_REASON_REQUIRED');
|
|
1357
|
+
}
|
|
1358
|
+
const effectiveAt = input.effectiveAt === undefined ? (before?.effectiveAt || null) : (input.effectiveAt ? String(input.effectiveAt) : null);
|
|
1359
|
+
const effectiveUntil = input.effectiveUntil === undefined ? (before?.effectiveUntil || null) : (input.effectiveUntil ? String(input.effectiveUntil) : null);
|
|
1360
|
+
const now = new Date().toISOString();
|
|
1361
|
+
this.upsertCanonicalEntitlementRecord({
|
|
1362
|
+
accountId,
|
|
1363
|
+
planVersionId: nextPlanVersionId,
|
|
1364
|
+
state: nextState,
|
|
1365
|
+
effectiveAt,
|
|
1366
|
+
effectiveUntil,
|
|
1367
|
+
updatedBy: String(input.actorUserId || 'system').trim() || 'system',
|
|
1368
|
+
createdAt: before?.effectiveAt || now,
|
|
1369
|
+
updatedAt: now
|
|
1370
|
+
});
|
|
1371
|
+
const after = this.getAccountEntitlement(accountId);
|
|
1372
|
+
this.addEntitlementAuditLog({
|
|
1373
|
+
actorUserId: input.actorUserId,
|
|
1374
|
+
actorRole: input.actorRole,
|
|
1375
|
+
accountId,
|
|
1376
|
+
action: 'account-entitlement-update',
|
|
1377
|
+
reason: normalizedReason || undefined,
|
|
1378
|
+
before: before || {
|
|
1379
|
+
accountId,
|
|
1380
|
+
missing: true
|
|
1381
|
+
},
|
|
1382
|
+
after,
|
|
1383
|
+
requestId: input.requestId
|
|
1384
|
+
});
|
|
1385
|
+
return after;
|
|
1386
|
+
}
|
|
1387
|
+
listEntitlementAuditLog(accountId, limit = 100, offset = 0) {
|
|
1388
|
+
const normalizedAccountId = String(accountId || '').trim();
|
|
1389
|
+
const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 100)));
|
|
1390
|
+
const safeOffset = Math.max(0, Math.floor(Number(offset) || 0));
|
|
1391
|
+
const rows = this.db.prepare(`
|
|
1392
|
+
SELECT id, account_id, action, actor_user_id, actor_role, reason, before_json, after_json, created_at
|
|
1393
|
+
FROM entitlement_audit_log
|
|
1394
|
+
WHERE tenant_id = ? AND account_id = ?
|
|
1395
|
+
ORDER BY created_at DESC
|
|
1396
|
+
LIMIT ?
|
|
1397
|
+
OFFSET ?
|
|
1398
|
+
`).all(this.tenantId, normalizedAccountId, safeLimit, safeOffset);
|
|
1399
|
+
return rows.map((row) => ({
|
|
1400
|
+
id: String(row.id || ''),
|
|
1401
|
+
accountId: String(row.account_id || ''),
|
|
1402
|
+
action: String(row.action || ''),
|
|
1403
|
+
actorUserId: String(row.actor_user_id || ''),
|
|
1404
|
+
actorRole: String(row.actor_role || ''),
|
|
1405
|
+
reason: row.reason ? String(row.reason) : null,
|
|
1406
|
+
before: this.parseJsonObject(row.before_json || '{}'),
|
|
1407
|
+
after: this.parseJsonObject(row.after_json || '{}'),
|
|
1408
|
+
createdAt: String(row.created_at || '')
|
|
1409
|
+
}));
|
|
1410
|
+
}
|
|
1411
|
+
getAccountEntitlementsPayload(input) {
|
|
1412
|
+
const workspaceId = String(input.workspaceId || '').trim();
|
|
1413
|
+
const userId = String(input.userId || '').trim();
|
|
1414
|
+
const accountId = this.deps.getWorkspaceOwnerUserId(workspaceId) || userId;
|
|
1415
|
+
const entitlement = this.getAccountEntitlement(accountId);
|
|
1416
|
+
const seatUsage = this.countWorkspaceSeatUsage(workspaceId, { includeInvited: true });
|
|
1417
|
+
const features = Object.values(entitlement.version.features).reduce((acc, feature) => {
|
|
1418
|
+
const evaluation = this.evaluateFeatureAccess({
|
|
1419
|
+
userId,
|
|
1420
|
+
workspaceId,
|
|
1421
|
+
featureKey: feature.featureKey
|
|
1422
|
+
});
|
|
1423
|
+
acc[feature.featureKey] = {
|
|
1424
|
+
allowed: evaluation.allowed,
|
|
1425
|
+
access: feature.access,
|
|
1426
|
+
config: feature.config
|
|
1427
|
+
};
|
|
1428
|
+
return acc;
|
|
1429
|
+
}, {});
|
|
1430
|
+
return {
|
|
1431
|
+
accountId,
|
|
1432
|
+
workspaceId,
|
|
1433
|
+
planId: entitlement.planId,
|
|
1434
|
+
planVersionId: entitlement.planVersionId,
|
|
1435
|
+
state: entitlement.state,
|
|
1436
|
+
effectiveAt: entitlement.effectiveAt,
|
|
1437
|
+
effectiveUntil: entitlement.effectiveUntil,
|
|
1438
|
+
seatUsage,
|
|
1439
|
+
features
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
}
|