@taskforcehq/taskforce 0.3.306 → 0.3.308

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/README.md +0 -2
  2. package/dist/Taskforce.module.css +317 -27
  3. package/dist/TaskforceCore.js +1 -0
  4. package/dist/TaskforceCore.test.js +178 -0
  5. package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
  6. package/dist/components/activity/EntityActivityTimeline.js +81 -0
  7. package/dist/components/context/ContextAttachmentManager.js +15 -3
  8. package/dist/components/features/AgentsModule.d.ts +5 -1
  9. package/dist/components/features/AgentsModule.js +142 -39
  10. package/dist/components/features/AgentsModule.test.js +260 -11
  11. package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
  12. package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
  13. package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
  14. package/dist/components/features/DocumentIndex.d.ts +2 -2
  15. package/dist/components/features/DocumentIndex.js +3 -4
  16. package/dist/components/features/DocumentViewer.d.ts +3 -1
  17. package/dist/components/features/DocumentViewer.js +6 -5
  18. package/dist/components/features/DocumentWorkspace.d.ts +10 -2
  19. package/dist/components/features/DocumentWorkspace.js +15 -4
  20. package/dist/components/features/DocumentWorkspace.test.js +20 -3
  21. package/dist/components/features/TaskSettings.d.ts +2 -0
  22. package/dist/components/features/TaskSettings.js +83 -78
  23. package/dist/components/features/TaskSettings.test.js +171 -82
  24. package/dist/components/task/TaskActionHeader.js +11 -5
  25. package/dist/components/task/TaskActionHeader.test.js +10 -0
  26. package/dist/components/task/TaskCard.js +6 -2
  27. package/dist/components/task/TaskContextUpload.test.js +3 -3
  28. package/dist/components/task/TaskForm.js +14 -64
  29. package/dist/components/task/TaskKanban.js +0 -8
  30. package/dist/components/task/TaskList.js +5 -1
  31. package/dist/components/task/TaskList.test.js +4 -0
  32. package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
  33. package/dist/components/ui/HierarchyActionBadge.js +5 -0
  34. package/dist/components/views/AppShellHeader.d.ts +3 -1
  35. package/dist/components/views/AppShellHeader.js +27 -11
  36. package/dist/components/views/AppShellHeader.test.js +18 -1
  37. package/dist/components/views/PlanComparisonPage.d.ts +20 -15
  38. package/dist/components/views/PlanComparisonPage.js +221 -82
  39. package/dist/components/views/PlanComparisonPage.test.js +268 -14
  40. package/dist/components/views/PlansPage.js +16 -10
  41. package/dist/components/views/PlansPage.test.js +4 -2
  42. package/dist/components/views/StandaloneLayout.js +256 -69
  43. package/dist/components/views/WidgetView.js +10 -8
  44. package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
  45. package/dist/components/views/panels/PlanningDrawer.js +259 -51
  46. package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
  47. package/dist/config/envSchema.js +1 -0
  48. package/dist/core/AiProfileService.d.ts +7 -0
  49. package/dist/core/AiProfileService.js +460 -35
  50. package/dist/core/AiProfiles.test.js +648 -2
  51. package/dist/core/AttachmentLinkService.d.ts +3 -0
  52. package/dist/core/AttachmentLinkService.js +58 -13
  53. package/dist/core/GlobalSettingsService.js +30 -0
  54. package/dist/core/GlobalSettingsService.test.d.ts +1 -0
  55. package/dist/core/GlobalSettingsService.test.js +45 -0
  56. package/dist/core/McpTokenService.d.ts +12 -0
  57. package/dist/core/McpTokenService.js +44 -1
  58. package/dist/core/PlanEntitlementService.d.ts +20 -0
  59. package/dist/core/PlanEntitlementService.js +215 -19
  60. package/dist/core/PlanFeatureCatalog.test.js +55 -0
  61. package/dist/core/PlanningEntities.test.js +366 -4
  62. package/dist/core/TaskActivityService.d.ts +28 -1
  63. package/dist/core/TaskActivityService.js +227 -46
  64. package/dist/core/TaskActivitySync.test.d.ts +1 -0
  65. package/dist/core/TaskActivitySync.test.js +90 -0
  66. package/dist/core/TaskAuditTimeline.test.js +96 -0
  67. package/dist/core/Taskforce.d.ts +204 -26
  68. package/dist/core/Taskforce.ids.test.js +58 -0
  69. package/dist/core/Taskforce.js +781 -71
  70. package/dist/core/Taskforce.mcpAuth.test.js +109 -0
  71. package/dist/core/types.d.ts +10 -0
  72. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
  73. package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
  74. package/dist/hooks/bootstrapTimeouts.js +8 -0
  75. package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
  76. package/dist/hooks/bootstrapTimeouts.test.js +12 -0
  77. package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
  78. package/dist/hooks/sync/orchestratorShared.js +61 -1
  79. package/dist/hooks/sync/recovery.js +8 -0
  80. package/dist/hooks/sync/recovery.test.js +5 -5
  81. package/dist/hooks/sync/transfers.d.ts +2 -1
  82. package/dist/hooks/sync/transfers.js +36 -9
  83. package/dist/hooks/sync/transfers.test.js +534 -2
  84. package/dist/hooks/useFilterSort.js +13 -13
  85. package/dist/hooks/useFilterSort.test.js +87 -1
  86. package/dist/hooks/useSyncOrchestrator.js +50 -5
  87. package/dist/hooks/useTaskforce.d.ts +85 -50
  88. package/dist/hooks/useTaskforce.js +44 -14
  89. package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
  90. package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
  91. package/dist/hooks/useWorkspaceSyncController.js +7 -1
  92. package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
  93. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
  94. package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
  95. package/dist/mcp/canonicalAssetHelpers.js +18 -10
  96. package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
  97. package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
  98. package/dist/mcp/clientIntegrations.d.ts +1 -0
  99. package/dist/mcp/clientIntegrations.js +25 -0
  100. package/dist/mcp/clientIntegrations.test.js +14 -3
  101. package/dist/mcp/clientProfileDefaults.js +3 -3
  102. package/dist/mcp/collaborationRegistrar.js +7 -7
  103. package/dist/mcp/documentAssetRegistrar.js +113 -7
  104. package/dist/mcp/runtime.d.ts +1 -0
  105. package/dist/mcp/runtime.js +969 -122
  106. package/dist/mcp/runtime.test.js +1083 -11
  107. package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
  108. package/dist/mcp/taskPlanningRegistrar.js +48 -0
  109. package/dist/mcp/toolRegistry.d.ts +56 -0
  110. package/dist/mcp/toolRegistry.js +8 -0
  111. package/dist/mcp/toolRegistry.test.js +4 -0
  112. package/dist/migrations/taskSchemaMigrations.js +58 -15
  113. package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
  114. package/dist/server/aiProfileAvatarAssets.js +319 -0
  115. package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
  116. package/dist/server/aiProfileAvatarAssets.test.js +213 -0
  117. package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
  118. package/dist/server/index.js +4 -0
  119. package/dist/server/index.test.js +6 -1
  120. package/dist/server/routes/admin.js +328 -9
  121. package/dist/server/routes/annotatedAttachments.js +51 -0
  122. package/dist/server/routes/auth.d.ts +11 -0
  123. package/dist/server/routes/auth.js +189 -27
  124. package/dist/server/routes/authSupport.d.ts +5 -0
  125. package/dist/server/routes/authSupport.js +13 -1
  126. package/dist/server/routes/billing.d.ts +1 -0
  127. package/dist/server/routes/billing.js +116 -63
  128. package/dist/server/routes/billing.test.js +162 -11
  129. package/dist/server/routes/documents.js +10 -0
  130. package/dist/server/routes/shared.js +0 -10
  131. package/dist/server/routes/sync.integration.test.js +221 -0
  132. package/dist/server/routes/sync.js +13 -3
  133. package/dist/server/routes/syncSnapshotRoutes.js +2 -0
  134. package/dist/server/routes/tasks.js +12 -9
  135. package/dist/server/routes.js +87 -8
  136. package/dist/server/routes.test.js +972 -23
  137. package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
  138. package/dist/shared/aiProfileSurfaceType.js +10 -8
  139. package/dist/storage/objectStorage.d.ts +1 -0
  140. package/dist/storage/objectStorage.js +2 -1
  141. package/dist/storage/objectStorageClient.d.ts +1 -0
  142. package/dist/storage/objectStorageClient.js +41 -8
  143. package/dist/storage/objectStorageClient.test.js +26 -1
  144. package/dist/storage/objectStorageConfig.js +3 -1
  145. package/dist/storage/objectStorageConfig.test.js +13 -1
  146. package/dist/sync/collaborationSyncPayload.js +2 -0
  147. package/dist/sync/collaborationSyncPayload.test.js +1 -0
  148. package/dist/sync/collaborationSyncState.d.ts +3 -3
  149. package/dist/sync/collaborationSyncState.js +19 -12
  150. package/dist/sync/contentSyncPayload.d.ts +5 -1
  151. package/dist/sync/contentSyncPayload.js +9 -0
  152. package/dist/sync/contentSyncPayload.test.js +39 -0
  153. package/dist/sync/contentSyncState.d.ts +5 -1
  154. package/dist/sync/contentSyncState.js +231 -37
  155. package/dist/sync/planningSyncPayload.js +5 -0
  156. package/dist/sync/planningSyncPayload.test.js +24 -0
  157. package/dist/sync/syncApplyHandlers.d.ts +4 -4
  158. package/dist/sync/syncApplyHandlers.js +89 -31
  159. package/dist/sync/syncApplyHandlers.test.js +184 -4
  160. package/dist/sync/syncResourceAdapters.d.ts +2 -0
  161. package/dist/sync/syncResourceAdapters.js +2 -0
  162. package/dist/sync/syncService.d.ts +1 -0
  163. package/dist/sync/syncService.js +13 -1
  164. package/dist/sync/syncService.test.js +39 -0
  165. package/dist/sync/workspacePullFeed.d.ts +4 -0
  166. package/dist/sync/workspacePullFeed.js +129 -70
  167. package/dist/sync/workspacePullFeed.test.js +49 -0
  168. package/dist/sync/workspaceSyncModel.d.ts +28 -6
  169. package/dist/sync/workspaceSyncModel.js +159 -45
  170. package/dist/sync/workspaceSyncModel.test.js +269 -3
  171. package/dist/types.d.ts +33 -2
  172. package/dist/ui/agent-logos/ChatGPT.png +0 -0
  173. package/dist/ui/agent-logos/Gemini CLI.jpeg +0 -0
  174. package/dist/ui/agent-logos/antigravity.jpeg +0 -0
  175. package/dist/ui/agent-logos/claude-code.jpeg +0 -0
  176. package/dist/ui/agent-logos/codex.jpeg +0 -0
  177. package/dist/ui/agent-logos/cursor.png +0 -0
  178. package/dist/ui/agent-logos/openclaw.jpeg +0 -0
  179. package/dist/ui/agent-logos/windsurf.png +0 -0
  180. package/dist/ui/assets/AgentsModule-BS4Pi_nC.js +1 -0
  181. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
  182. package/dist/ui/assets/AnnotatedAttachmentWorkspace-CCckzWYZ.js +3 -0
  183. package/dist/ui/assets/ContextAttachmentManager-CfG_ER7C.js +3 -0
  184. package/dist/ui/assets/DocumentWorkspace-BVaWPA25.js +252 -0
  185. package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
  186. package/dist/ui/assets/EntityActivityTimeline-DSj0ThaK.js +1 -0
  187. package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
  188. package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-AxMkWtxH.js} +1 -1
  189. package/dist/ui/assets/PlansPage-B_z-D6Nh.css +1 -0
  190. package/dist/ui/assets/PlansPage-Ndt7mYfo.js +1 -0
  191. package/dist/ui/assets/TaskContextUpload-Dw4rTF4i.js +1 -0
  192. package/dist/ui/assets/TaskSettings-f2ga42_V.js +9 -0
  193. package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-E4UL3mov.js} +4 -4
  194. package/dist/ui/assets/documentReferences-BOUcJm6-.js +1 -0
  195. package/dist/ui/assets/index-BvAbqx5Q.css +1 -0
  196. package/dist/ui/assets/index-MAHKvFqp.js +4 -0
  197. package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
  198. package/dist/ui/index.html +3 -3
  199. package/dist/ui/og-image.png +0 -0
  200. package/dist/ui/product/agent manager.png +0 -0
  201. package/dist/ui/product/agent_manager_02.png +0 -0
  202. package/dist/utils/aiProfileEvents.d.ts +7 -0
  203. package/dist/utils/aiProfileEvents.js +6 -0
  204. package/dist/utils/assignees.d.ts +6 -0
  205. package/dist/utils/assignees.js +8 -1
  206. package/dist/utils/assignees.test.d.ts +1 -0
  207. package/dist/utils/assignees.test.js +25 -0
  208. package/dist/utils/avatarDisplay.d.ts +1 -0
  209. package/dist/utils/avatarDisplay.js +15 -0
  210. package/dist/utils/contextFiles.d.ts +3 -1
  211. package/dist/utils/contextFiles.js +2 -1
  212. package/dist/utils/taskActivity.d.ts +7 -1
  213. package/dist/utils/taskActivity.js +63 -1
  214. package/dist/utils/taskActivity.test.js +23 -1
  215. package/dist/utils/taskSearch.d.ts +22 -0
  216. package/dist/utils/taskSearch.js +87 -0
  217. package/dist/utils/taskSearch.test.d.ts +1 -0
  218. package/dist/utils/taskSearch.test.js +69 -0
  219. package/dist/utils/taskUtils.js +10 -3
  220. package/dist/utils/taskUtils.test.d.ts +1 -0
  221. package/dist/utils/taskUtils.test.js +19 -0
  222. package/package.json +1 -1
  223. package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
  224. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
  225. package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
  226. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
  227. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
  228. package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
  229. package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
  230. package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
  231. package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
  232. package/dist/ui/assets/index-C6k75jr8.js +0 -6
@@ -4,6 +4,7 @@ const LEGACY_REMOVED_PLAN_FEATURE_KEYS = Object.freeze([
4
4
  'documents.workspace_access'
5
5
  ]);
6
6
  const LEGACY_WORKSPACES_FEATURE_KEY = 'workspace.switching';
7
+ const CUSTOM_PLAN_FEATURE_KEY_PREFIX = 'marketing.';
7
8
  /**
8
9
  * Handles all plan catalog, plan version, and account entitlement logic.
9
10
  * Extracted from Taskforce.ts to reduce monolith size.
@@ -49,9 +50,105 @@ export class PlanEntitlementService {
49
50
  return null;
50
51
  return Math.max(0, Math.floor(numeric));
51
52
  }
53
+ normalizeOptionalCatalogDescriptionVisible(raw) {
54
+ if (raw === null || raw === undefined || raw === '')
55
+ return null;
56
+ return raw === false || raw === 0 || raw === '0' ? false : true;
57
+ }
58
+ isBuiltInPlanFeatureKey(featureKey) {
59
+ return DEFAULT_PLAN_FEATURE_CATALOG.some((feature) => feature.featureKey === featureKey);
60
+ }
61
+ normalizeRequiredCatalogLabel(raw) {
62
+ return String(raw ?? '').trim();
63
+ }
64
+ slugifyCustomPlanFeatureLabel(label) {
65
+ const base = label
66
+ .trim()
67
+ .toLowerCase()
68
+ .replace(/[^a-z0-9]+/g, '_')
69
+ .replace(/^_+|_+$/g, '');
70
+ return base || generateUuidV7().replace(/-/g, '_');
71
+ }
72
+ featureKeyExists(featureKey) {
73
+ if (this.isBuiltInPlanFeatureKey(featureKey))
74
+ return true;
75
+ const row = this.db.prepare(`
76
+ SELECT 1
77
+ FROM plan_feature_catalog_overrides
78
+ WHERE tenant_id = ? AND feature_key = ?
79
+ LIMIT 1
80
+ `).get(this.tenantId, featureKey);
81
+ return Boolean(row);
82
+ }
83
+ generateUniqueCustomPlanFeatureKey(label) {
84
+ const base = `${CUSTOM_PLAN_FEATURE_KEY_PREFIX}${this.slugifyCustomPlanFeatureLabel(label)}`;
85
+ let featureKey = base;
86
+ let suffix = 2;
87
+ while (this.featureKeyExists(featureKey)) {
88
+ featureKey = `${base}_${suffix}`;
89
+ suffix += 1;
90
+ }
91
+ return featureKey;
92
+ }
93
+ listCustomPlanFeatureCatalogEntries() {
94
+ const rows = this.db.prepare(`
95
+ SELECT feature_key, label, description, public_label, public_description, public_description_visible, public_display_order
96
+ FROM plan_feature_catalog_overrides
97
+ WHERE tenant_id = ? AND is_custom = 1
98
+ ORDER BY updated_at ASC, created_at ASC, feature_key ASC
99
+ `).all(this.tenantId);
100
+ const features = [];
101
+ for (const row of rows) {
102
+ const featureKey = this.canonicalizePlanFeatureKey(row.feature_key);
103
+ const label = this.normalizeRequiredCatalogLabel(row.label);
104
+ if (!featureKey || !label)
105
+ continue;
106
+ features.push({
107
+ featureKey,
108
+ label,
109
+ description: this.normalizeRequiredCatalogLabel(row.description),
110
+ isCustom: true,
111
+ publicLabel: this.normalizeOptionalCatalogMarketingCopy(row.public_label),
112
+ publicDescription: this.normalizeOptionalCatalogMarketingCopy(row.public_description),
113
+ publicDescriptionVisible: this.normalizeOptionalCatalogDescriptionVisible(row.public_description_visible),
114
+ publicDisplayOrder: this.normalizeOptionalCatalogDisplayOrder(row.public_display_order),
115
+ allowedAccessModes: ['enabled', 'disabled'],
116
+ configTemplate: {}
117
+ });
118
+ }
119
+ return features;
120
+ }
121
+ getCustomPlanFeatureCatalogEntry(featureKeyRaw) {
122
+ const featureKey = this.canonicalizePlanFeatureKey(featureKeyRaw);
123
+ if (!featureKey)
124
+ return null;
125
+ const row = this.db.prepare(`
126
+ SELECT feature_key, label, description, public_label, public_description, public_description_visible, public_display_order
127
+ FROM plan_feature_catalog_overrides
128
+ WHERE tenant_id = ? AND feature_key = ? AND is_custom = 1
129
+ LIMIT 1
130
+ `).get(this.tenantId, featureKey);
131
+ if (!row)
132
+ return null;
133
+ const label = this.normalizeRequiredCatalogLabel(row.label);
134
+ if (!label)
135
+ return null;
136
+ return {
137
+ featureKey,
138
+ label,
139
+ description: this.normalizeRequiredCatalogLabel(row.description),
140
+ isCustom: true,
141
+ publicLabel: this.normalizeOptionalCatalogMarketingCopy(row.public_label),
142
+ publicDescription: this.normalizeOptionalCatalogMarketingCopy(row.public_description),
143
+ publicDescriptionVisible: this.normalizeOptionalCatalogDescriptionVisible(row.public_description_visible),
144
+ publicDisplayOrder: this.normalizeOptionalCatalogDisplayOrder(row.public_display_order),
145
+ allowedAccessModes: ['enabled', 'disabled'],
146
+ configTemplate: {}
147
+ };
148
+ }
52
149
  listPlanFeatureCatalogOverrideMap() {
53
150
  const rows = this.db.prepare(`
54
- SELECT feature_key, public_label, public_description, public_display_order
151
+ SELECT feature_key, public_label, public_description, public_description_visible, public_display_order, is_custom
55
152
  FROM plan_feature_catalog_overrides
56
153
  WHERE tenant_id = ?
57
154
  `).all(this.tenantId);
@@ -61,12 +158,15 @@ export class PlanEntitlementService {
61
158
  const featureKey = this.canonicalizePlanFeatureKey(originalFeatureKey);
62
159
  if (!featureKey)
63
160
  continue;
161
+ if (Number(row.is_custom || 0) === 1)
162
+ continue;
64
163
  if (featureKey === WORKSPACES_FEATURE_KEY && originalFeatureKey === LEGACY_WORKSPACES_FEATURE_KEY && overrides.has(featureKey)) {
65
164
  continue;
66
165
  }
67
166
  overrides.set(featureKey, {
68
167
  publicLabel: this.normalizeOptionalCatalogMarketingCopy(row.public_label),
69
168
  publicDescription: this.normalizeOptionalCatalogMarketingCopy(row.public_description),
169
+ publicDescriptionVisible: this.normalizeOptionalCatalogDescriptionVisible(row.public_description_visible),
70
170
  publicDisplayOrder: this.normalizeOptionalCatalogDisplayOrder(row.public_display_order)
71
171
  });
72
172
  }
@@ -689,6 +789,7 @@ export class PlanEntitlementService {
689
789
  WHERE bpm.tenant_id = ?
690
790
  AND pv.plan_id = ?
691
791
  AND bpm.active = 1
792
+ AND COALESCE(bpm.pricing_audience, 'public') = 'public'
692
793
  `).get(this.tenantId, planId);
693
794
  return Number(row?.count || 0) > 0;
694
795
  }
@@ -702,6 +803,7 @@ export class PlanEntitlementService {
702
803
  WHERE tenant_id = ?
703
804
  AND plan_version_id = ?
704
805
  AND active = 1
806
+ AND COALESCE(pricing_audience, 'public') = 'public'
705
807
  AND pricing_type = 'free'
706
808
  `).get(this.tenantId, planVersionId);
707
809
  return Number(row?.count || 0) > 0;
@@ -1054,7 +1156,7 @@ export class PlanEntitlementService {
1054
1156
  const mappingRow = this.db.prepare(`
1055
1157
  SELECT plan_version_id
1056
1158
  FROM billing_price_mappings
1057
- WHERE tenant_id = ? AND stripe_price_id = ? AND active = 1
1159
+ WHERE tenant_id = ? AND stripe_price_id = ? AND active = 1 AND COALESCE(pricing_audience, 'public') = 'public'
1058
1160
  ORDER BY updated_at DESC, created_at DESC
1059
1161
  LIMIT 1
1060
1162
  `).get(this.tenantId, stripePriceId);
@@ -1490,31 +1592,104 @@ export class PlanEntitlementService {
1490
1592
  }
1491
1593
  listPlanFeatureCatalog() {
1492
1594
  const overrides = this.listPlanFeatureCatalogOverrideMap();
1493
- return DEFAULT_PLAN_FEATURE_CATALOG.map((feature) => ({
1494
- featureKey: feature.featureKey,
1495
- label: feature.label,
1496
- description: feature.description,
1497
- publicLabel: overrides.get(feature.featureKey)?.publicLabel ?? feature.publicLabel ?? null,
1498
- publicDescription: overrides.get(feature.featureKey)?.publicDescription ?? feature.publicDescription ?? null,
1499
- publicDisplayOrder: overrides.get(feature.featureKey)?.publicDisplayOrder ?? feature.publicDisplayOrder ?? null,
1500
- allowedAccessModes: [...feature.allowedAccessModes],
1501
- configTemplate: { ...feature.configTemplate },
1502
- ...(feature.dependsOn?.length ? { dependsOn: [...feature.dependsOn] } : {})
1503
- }));
1595
+ return [
1596
+ ...DEFAULT_PLAN_FEATURE_CATALOG.map((feature) => ({
1597
+ featureKey: feature.featureKey,
1598
+ label: feature.label,
1599
+ description: feature.description,
1600
+ isCustom: false,
1601
+ publicLabel: overrides.get(feature.featureKey)?.publicLabel ?? feature.publicLabel ?? null,
1602
+ publicDescription: overrides.get(feature.featureKey)?.publicDescription ?? feature.publicDescription ?? null,
1603
+ publicDescriptionVisible: overrides.get(feature.featureKey)?.publicDescriptionVisible ?? feature.publicDescriptionVisible ?? null,
1604
+ publicDisplayOrder: overrides.get(feature.featureKey)?.publicDisplayOrder ?? feature.publicDisplayOrder ?? null,
1605
+ allowedAccessModes: [...feature.allowedAccessModes],
1606
+ configTemplate: { ...feature.configTemplate },
1607
+ ...(feature.dependsOn?.length ? { dependsOn: [...feature.dependsOn] } : {})
1608
+ })),
1609
+ ...this.listCustomPlanFeatureCatalogEntries()
1610
+ ];
1611
+ }
1612
+ createCustomPlanFeatureCatalogEntry(input) {
1613
+ const label = this.normalizeRequiredCatalogLabel(input.label);
1614
+ const description = this.normalizeRequiredCatalogLabel(input.description);
1615
+ if (!label)
1616
+ throw new TaskforceRuleError('Feature label is required', 400, 'PLAN_FEATURE_LABEL_REQUIRED');
1617
+ const featureKey = this.generateUniqueCustomPlanFeatureKey(label);
1618
+ const publicLabel = this.normalizeOptionalCatalogMarketingCopy(input.publicLabel);
1619
+ const publicDescription = this.normalizeOptionalCatalogMarketingCopy(input.publicDescription);
1620
+ const publicDescriptionVisible = this.normalizeOptionalCatalogDescriptionVisible(input.publicDescriptionVisible);
1621
+ const publicDisplayOrder = this.normalizeOptionalCatalogDisplayOrder(input.publicDisplayOrder);
1622
+ const now = new Date().toISOString();
1623
+ this.db.prepare(`
1624
+ INSERT INTO plan_feature_catalog_overrides (
1625
+ tenant_id,
1626
+ feature_key,
1627
+ label,
1628
+ description,
1629
+ is_custom,
1630
+ public_label,
1631
+ public_description,
1632
+ public_description_visible,
1633
+ public_display_order,
1634
+ created_at,
1635
+ updated_at
1636
+ )
1637
+ VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
1638
+ `).run(this.tenantId, featureKey, label, description, publicLabel, publicDescription, publicDescriptionVisible === null ? null : (publicDescriptionVisible ? 1 : 0), publicDisplayOrder, now, now);
1639
+ return this.getCustomPlanFeatureCatalogEntry(featureKey) || {
1640
+ featureKey,
1641
+ label,
1642
+ description,
1643
+ isCustom: true,
1644
+ publicLabel,
1645
+ publicDescription,
1646
+ publicDescriptionVisible,
1647
+ publicDisplayOrder,
1648
+ allowedAccessModes: ['enabled', 'disabled'],
1649
+ configTemplate: {}
1650
+ };
1651
+ }
1652
+ deleteCustomPlanFeatureCatalogEntry(featureKeyRaw) {
1653
+ const featureKey = this.canonicalizePlanFeatureKey(featureKeyRaw);
1654
+ if (!featureKey)
1655
+ throw new TaskforceRuleError('featureKey is required', 400, 'FEATURE_KEY_REQUIRED');
1656
+ if (this.isBuiltInPlanFeatureKey(featureKey)) {
1657
+ throw new TaskforceRuleError('Built-in plan features cannot be deleted', 400, 'PLAN_FEATURE_DELETE_BUILTIN_FORBIDDEN');
1658
+ }
1659
+ const existing = this.getCustomPlanFeatureCatalogEntry(featureKey);
1660
+ if (!existing)
1661
+ throw new TaskforceRuleError('Unknown custom plan feature key', 404, 'PLAN_FEATURE_NOT_FOUND');
1662
+ const tx = this.db.transaction(() => {
1663
+ this.db.prepare(`
1664
+ DELETE FROM plan_feature_catalog_overrides
1665
+ WHERE tenant_id = ? AND feature_key = ? AND is_custom = 1
1666
+ `).run(this.tenantId, featureKey);
1667
+ this.db.prepare(`
1668
+ DELETE FROM plan_feature_matrix
1669
+ WHERE tenant_id = ? AND feature_key = ?
1670
+ `).run(this.tenantId, featureKey);
1671
+ });
1672
+ tx();
1504
1673
  }
1505
1674
  updatePlanFeatureCatalogMarketingCopy(input) {
1506
1675
  const featureKey = this.canonicalizePlanFeatureKey(input.featureKey);
1507
1676
  if (!featureKey)
1508
1677
  throw new TaskforceRuleError('featureKey is required', 400, 'FEATURE_KEY_REQUIRED');
1509
1678
  const catalogFeature = DEFAULT_PLAN_FEATURE_CATALOG.find((feature) => feature.featureKey === featureKey);
1510
- if (!catalogFeature)
1679
+ const customFeature = this.getCustomPlanFeatureCatalogEntry(featureKey);
1680
+ if (!catalogFeature && !customFeature)
1511
1681
  throw new TaskforceRuleError('Unknown plan feature key', 404, 'PLAN_FEATURE_NOT_FOUND');
1682
+ const label = this.normalizeRequiredCatalogLabel(input.label ?? customFeature?.label ?? '');
1683
+ const description = this.normalizeRequiredCatalogLabel(input.description ?? customFeature?.description ?? '');
1684
+ if (customFeature && !label)
1685
+ throw new TaskforceRuleError('Feature label is required', 400, 'PLAN_FEATURE_LABEL_REQUIRED');
1512
1686
  const publicLabel = this.normalizeOptionalCatalogMarketingCopy(input.publicLabel);
1513
1687
  const publicDescription = this.normalizeOptionalCatalogMarketingCopy(input.publicDescription);
1688
+ const publicDescriptionVisible = this.normalizeOptionalCatalogDescriptionVisible(input.publicDescriptionVisible);
1514
1689
  const publicDisplayOrder = this.normalizeOptionalCatalogDisplayOrder(input.publicDisplayOrder);
1515
1690
  const now = new Date().toISOString();
1516
1691
  const tx = this.db.transaction(() => {
1517
- if (!publicLabel && !publicDescription && publicDisplayOrder === null) {
1692
+ if (!customFeature && !publicLabel && !publicDescription && publicDescriptionVisible === null && publicDisplayOrder === null) {
1518
1693
  this.db.prepare(`
1519
1694
  DELETE FROM plan_feature_catalog_overrides
1520
1695
  WHERE tenant_id = ? AND feature_key = ?
@@ -1525,32 +1700,53 @@ export class PlanEntitlementService {
1525
1700
  INSERT INTO plan_feature_catalog_overrides (
1526
1701
  tenant_id,
1527
1702
  feature_key,
1703
+ label,
1704
+ description,
1705
+ is_custom,
1528
1706
  public_label,
1529
1707
  public_description,
1708
+ public_description_visible,
1530
1709
  public_display_order,
1531
1710
  created_at,
1532
1711
  updated_at
1533
1712
  )
1534
- VALUES (?, ?, ?, ?, ?, ?, ?)
1713
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1535
1714
  ON CONFLICT (tenant_id, feature_key) DO UPDATE SET
1715
+ label = excluded.label,
1716
+ description = excluded.description,
1717
+ is_custom = excluded.is_custom,
1536
1718
  public_label = excluded.public_label,
1537
1719
  public_description = excluded.public_description,
1720
+ public_description_visible = excluded.public_description_visible,
1538
1721
  public_display_order = excluded.public_display_order,
1539
1722
  updated_at = excluded.updated_at
1540
- `).run(this.tenantId, featureKey, publicLabel, publicDescription, publicDisplayOrder, now, now);
1723
+ `).run(this.tenantId, featureKey, customFeature ? label : null, customFeature ? description : null, customFeature ? 1 : 0, publicLabel, publicDescription, publicDescriptionVisible === null ? null : (publicDescriptionVisible ? 1 : 0), publicDisplayOrder, now, now);
1541
1724
  });
1542
1725
  tx();
1543
- return this.listPlanFeatureCatalog().find((feature) => feature.featureKey === featureKey) || {
1726
+ return this.listPlanFeatureCatalog().find((feature) => feature.featureKey === featureKey) || (customFeature ? {
1727
+ featureKey,
1728
+ label,
1729
+ description,
1730
+ isCustom: true,
1731
+ publicLabel,
1732
+ publicDescription,
1733
+ publicDescriptionVisible,
1734
+ publicDisplayOrder,
1735
+ allowedAccessModes: ['enabled', 'disabled'],
1736
+ configTemplate: {}
1737
+ } : {
1544
1738
  featureKey: catalogFeature.featureKey,
1545
1739
  label: catalogFeature.label,
1546
1740
  description: catalogFeature.description,
1741
+ isCustom: false,
1547
1742
  publicLabel,
1548
1743
  publicDescription,
1744
+ publicDescriptionVisible,
1549
1745
  publicDisplayOrder,
1550
1746
  allowedAccessModes: [...catalogFeature.allowedAccessModes],
1551
1747
  configTemplate: { ...catalogFeature.configTemplate },
1552
1748
  ...(catalogFeature.dependsOn?.length ? { dependsOn: [...catalogFeature.dependsOn] } : {})
1553
- };
1749
+ });
1554
1750
  }
1555
1751
  listPlanVersions(planId) {
1556
1752
  const normalizedPlanId = String(planId || '').trim().toLowerCase();
@@ -17,33 +17,40 @@ describe('TaskforceCore plan feature catalog marketing copy', () => {
17
17
  expect(initialFeature?.label).toBe('AI Profiles');
18
18
  expect(initialFeature?.publicLabel ?? null).toBeNull();
19
19
  expect(initialFeature?.publicDescription ?? null).toBeNull();
20
+ expect(initialFeature?.publicDescriptionVisible ?? null).toBeNull();
20
21
  expect(initialFeature?.publicDisplayOrder).toBe(20);
21
22
  const updated = core.updatePlanFeatureCatalogMarketingCopy({
22
23
  featureKey: 'collaboration.ai_profiles',
23
24
  publicLabel: 'AI teammates',
24
25
  publicDescription: 'Add reusable AI teammates to your workspace.',
26
+ publicDescriptionVisible: false,
25
27
  publicDisplayOrder: 24
26
28
  });
27
29
  expect(updated.publicLabel).toBe('AI teammates');
28
30
  expect(updated.publicDescription).toBe('Add reusable AI teammates to your workspace.');
31
+ expect(updated.publicDescriptionVisible).toBe(false);
29
32
  expect(updated.publicDisplayOrder).toBe(24);
30
33
  const persisted = core.listPlanFeatureCatalog().find((feature) => feature.featureKey === 'collaboration.ai_profiles');
31
34
  expect(persisted?.publicLabel).toBe('AI teammates');
32
35
  expect(persisted?.publicDescription).toBe('Add reusable AI teammates to your workspace.');
36
+ expect(persisted?.publicDescriptionVisible).toBe(false);
33
37
  expect(persisted?.publicDisplayOrder).toBe(24);
34
38
  const cleared = core.updatePlanFeatureCatalogMarketingCopy({
35
39
  featureKey: 'collaboration.ai_profiles',
36
40
  publicLabel: ' ',
37
41
  publicDescription: '',
42
+ publicDescriptionVisible: null,
38
43
  publicDisplayOrder: null
39
44
  });
40
45
  expect(cleared.publicLabel ?? null).toBeNull();
41
46
  expect(cleared.publicDescription ?? null).toBeNull();
47
+ expect(cleared.publicDescriptionVisible ?? null).toBeNull();
42
48
  expect(cleared.publicDisplayOrder).toBe(20);
43
49
  const fallback = core.listPlanFeatureCatalog().find((feature) => feature.featureKey === 'collaboration.ai_profiles');
44
50
  expect(fallback?.label).toBe('AI Profiles');
45
51
  expect(fallback?.publicLabel ?? null).toBeNull();
46
52
  expect(fallback?.publicDescription ?? null).toBeNull();
53
+ expect(fallback?.publicDescriptionVisible ?? null).toBeNull();
47
54
  expect(fallback?.publicDisplayOrder).toBe(20);
48
55
  core.close();
49
56
  fs.rmSync(projectRoot, { recursive: true, force: true });
@@ -81,4 +88,52 @@ describe('TaskforceCore plan feature catalog marketing copy', () => {
81
88
  core.close();
82
89
  fs.rmSync(projectRoot, { recursive: true, force: true });
83
90
  });
91
+ it('creates, updates, and deletes custom feature catalog entries', () => {
92
+ const projectRoot = makeProjectRoot();
93
+ const core = new TaskforceCore({
94
+ projectRoot,
95
+ storagePath: path.join(projectRoot, '.taskforce')
96
+ });
97
+ const created = core.createCustomPlanFeatureCatalogEntry({
98
+ label: 'Unlimited Local Workspaces',
99
+ description: 'Run unlimited local workspaces on your device.',
100
+ publicDescriptionVisible: false,
101
+ publicDisplayOrder: 12
102
+ });
103
+ expect(created.featureKey).toMatch(/^marketing\./);
104
+ expect(created.isCustom).toBe(true);
105
+ expect(created.label).toBe('Unlimited Local Workspaces');
106
+ expect(created.description).toBe('Run unlimited local workspaces on your device.');
107
+ expect(created.publicDescriptionVisible).toBe(false);
108
+ expect(created.publicDisplayOrder).toBe(12);
109
+ const updated = core.updatePlanFeatureCatalogMarketingCopy({
110
+ featureKey: created.featureKey,
111
+ label: 'Unlimited Local Workspaces',
112
+ description: 'Run unlimited local workspaces offline on your device.',
113
+ publicLabel: 'Unlimited Local Workspaces',
114
+ publicDescription: 'Create as many fully local workspaces as you need.',
115
+ publicDescriptionVisible: true,
116
+ publicDisplayOrder: 14
117
+ });
118
+ expect(updated.isCustom).toBe(true);
119
+ expect(updated.description).toBe('Run unlimited local workspaces offline on your device.');
120
+ expect(updated.publicLabel).toBe('Unlimited Local Workspaces');
121
+ expect(updated.publicDescription).toBe('Create as many fully local workspaces as you need.');
122
+ expect(updated.publicDescriptionVisible).toBe(true);
123
+ expect(updated.publicDisplayOrder).toBe(14);
124
+ const plan = core.upsertPlanCatalog({ planId: 'free', displayName: 'Free', status: 'active' });
125
+ expect(plan.planId).toBe('free');
126
+ core.updatePlanVersionFeatures({
127
+ planVersionId: 'free-v1',
128
+ features: [
129
+ { featureKey: created.featureKey, access: 'enabled', config: {} }
130
+ ]
131
+ });
132
+ expect(core.getPlanVersionFeatures('free', 'free-v1').features[created.featureKey]?.access).toBe('enabled');
133
+ core.deleteCustomPlanFeatureCatalogEntry(created.featureKey);
134
+ expect(core.listPlanFeatureCatalog().some((feature) => feature.featureKey === created.featureKey)).toBe(false);
135
+ expect(core.getPlanVersionFeatures('free', 'free-v1').features[created.featureKey]).toBeUndefined();
136
+ core.close();
137
+ fs.rmSync(projectRoot, { recursive: true, force: true });
138
+ });
84
139
  });