@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3653.1.b8558737ae

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 (160) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_settings/index.js +2 -0
  3. package/dist/generated/entities/customer_settings/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
  7. package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
  8. package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
  9. package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
  10. package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
  11. package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
  12. package/dist/modules/customers/api/deals/route.js +255 -122
  13. package/dist/modules/customers/api/deals/route.js.map +2 -2
  14. package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
  15. package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
  16. package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
  17. package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
  18. package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
  19. package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
  20. package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
  21. package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
  22. package/dist/modules/customers/backend/customers/deals/page.js +2 -0
  23. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
  25. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
  26. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
  27. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
  28. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
  29. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
  30. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
  31. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
  32. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
  33. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
  34. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
  35. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
  36. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
  37. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
  38. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
  39. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
  40. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
  41. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
  42. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
  43. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
  44. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
  45. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
  46. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
  47. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
  48. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
  49. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
  50. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
  51. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
  52. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
  53. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
  54. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
  55. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
  56. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
  57. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
  58. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
  59. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
  60. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
  61. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
  62. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
  63. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
  64. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
  65. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
  66. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
  67. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
  68. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
  69. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
  70. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
  71. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
  72. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
  73. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
  74. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
  75. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
  76. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
  77. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
  78. package/dist/modules/customers/commands/deals.js +6 -0
  79. package/dist/modules/customers/commands/deals.js.map +2 -2
  80. package/dist/modules/customers/commands/pipeline-stages.js +48 -13
  81. package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
  82. package/dist/modules/customers/commands/settings.js +45 -5
  83. package/dist/modules/customers/commands/settings.js.map +2 -2
  84. package/dist/modules/customers/data/enrichers.js +130 -1
  85. package/dist/modules/customers/data/enrichers.js.map +2 -2
  86. package/dist/modules/customers/data/entities.js +4 -0
  87. package/dist/modules/customers/data/entities.js.map +2 -2
  88. package/dist/modules/customers/data/validators.js +29 -2
  89. package/dist/modules/customers/data/validators.js.map +2 -2
  90. package/dist/modules/customers/lib/bulkDeals.js +150 -0
  91. package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
  92. package/dist/modules/customers/lib/kysely.js +12 -0
  93. package/dist/modules/customers/lib/kysely.js.map +7 -0
  94. package/dist/modules/customers/lib/stuckDeals.js +32 -0
  95. package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
  96. package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
  97. package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
  98. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
  99. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
  100. package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
  101. package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
  102. package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
  103. package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
  104. package/generated/entities/customer_settings/index.ts +1 -0
  105. package/generated/entity-fields-registry.ts +1 -0
  106. package/package.json +7 -7
  107. package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
  108. package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
  109. package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
  110. package/src/modules/customers/api/deals/route.ts +330 -135
  111. package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
  112. package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
  113. package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
  114. package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
  115. package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
  116. package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
  117. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
  118. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
  119. package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
  120. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
  121. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
  122. package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
  123. package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
  124. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
  125. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
  126. package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
  127. package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
  128. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
  129. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
  130. package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
  131. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
  132. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
  133. package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
  134. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
  135. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
  136. package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
  137. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
  138. package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
  139. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
  140. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
  141. package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
  142. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
  143. package/src/modules/customers/commands/deals.ts +19 -0
  144. package/src/modules/customers/commands/pipeline-stages.ts +68 -13
  145. package/src/modules/customers/commands/settings.ts +64 -5
  146. package/src/modules/customers/data/enrichers.ts +209 -2
  147. package/src/modules/customers/data/entities.ts +4 -1
  148. package/src/modules/customers/data/validators.ts +36 -2
  149. package/src/modules/customers/i18n/de.json +244 -0
  150. package/src/modules/customers/i18n/en.json +244 -0
  151. package/src/modules/customers/i18n/es.json +244 -0
  152. package/src/modules/customers/i18n/pl.json +244 -0
  153. package/src/modules/customers/lib/bulkDeals.ts +231 -0
  154. package/src/modules/customers/lib/kysely.ts +60 -0
  155. package/src/modules/customers/lib/stuckDeals.ts +88 -0
  156. package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
  157. package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
  158. package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
  159. package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
  160. package/src/modules/customers/workers/deals-bulk-update-stage.ts +46 -0
@@ -0,0 +1,41 @@
1
+ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
2
+ import {
3
+ CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE,
4
+ bulkUpdateDealOwnerWithProgress
5
+ } from "../lib/bulkDeals.js";
6
+ const metadata = {
7
+ queue: CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE,
8
+ id: "customers:deals-bulk-update-owner",
9
+ concurrency: 1
10
+ };
11
+ async function handle(job, _ctx) {
12
+ const container = await createRequestContainer();
13
+ try {
14
+ await bulkUpdateDealOwnerWithProgress({
15
+ container,
16
+ progressJobId: job.payload.progressJobId,
17
+ ids: job.payload.ids,
18
+ ownerUserId: job.payload.ownerUserId,
19
+ scope: job.payload.scope
20
+ });
21
+ } catch (error) {
22
+ const progressService = container.resolve("progressService");
23
+ await progressService.failJob(
24
+ job.payload.progressJobId,
25
+ {
26
+ errorMessage: error instanceof Error ? error.message : "Bulk deal owner update failed"
27
+ },
28
+ {
29
+ tenantId: job.payload.scope.tenantId,
30
+ organizationId: job.payload.scope.organizationId,
31
+ userId: job.payload.scope.userId
32
+ }
33
+ );
34
+ throw error;
35
+ }
36
+ }
37
+ export {
38
+ handle as default,
39
+ metadata
40
+ };
41
+ //# sourceMappingURL=deals-bulk-update-owner.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/customers/workers/deals-bulk-update-owner.ts"],
4
+ "sourcesContent": ["import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { ProgressService } from '../../progress/lib/progressService'\nimport {\n CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE,\n bulkUpdateDealOwnerWithProgress,\n type CustomersDealsBulkUpdateOwnerJobPayload,\n} from '../lib/bulkDeals'\n\nexport const metadata: WorkerMeta = {\n queue: CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE,\n id: 'customers:deals-bulk-update-owner',\n concurrency: 1,\n}\n\nexport default async function handle(\n job: QueuedJob<CustomersDealsBulkUpdateOwnerJobPayload>,\n _ctx: JobContext,\n): Promise<void> {\n const container = await createRequestContainer()\n\n try {\n await bulkUpdateDealOwnerWithProgress({\n container,\n progressJobId: job.payload.progressJobId,\n ids: job.payload.ids,\n ownerUserId: job.payload.ownerUserId,\n scope: job.payload.scope,\n })\n } catch (error) {\n const progressService = container.resolve('progressService') as ProgressService\n await progressService.failJob(\n job.payload.progressJobId,\n {\n errorMessage:\n error instanceof Error ? error.message : 'Bulk deal owner update failed',\n },\n {\n tenantId: job.payload.scope.tenantId,\n organizationId: job.payload.scope.organizationId,\n userId: job.payload.scope.userId,\n },\n )\n throw error\n }\n}\n"],
5
+ "mappings": "AACA,SAAS,8BAA8B;AAEvC;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AAEA,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAEA,eAAO,OACL,KACA,MACe;AACf,QAAM,YAAY,MAAM,uBAAuB;AAE/C,MAAI;AACF,UAAM,gCAAgC;AAAA,MACpC;AAAA,MACA,eAAe,IAAI,QAAQ;AAAA,MAC3B,KAAK,IAAI,QAAQ;AAAA,MACjB,aAAa,IAAI,QAAQ;AAAA,MACzB,OAAO,IAAI,QAAQ;AAAA,IACrB,CAAC;AAAA,EACH,SAAS,OAAO;AACd,UAAM,kBAAkB,UAAU,QAAQ,iBAAiB;AAC3D,UAAM,gBAAgB;AAAA,MACpB,IAAI,QAAQ;AAAA,MACZ;AAAA,QACE,cACE,iBAAiB,QAAQ,MAAM,UAAU;AAAA,MAC7C;AAAA,MACA;AAAA,QACE,UAAU,IAAI,QAAQ,MAAM;AAAA,QAC5B,gBAAgB,IAAI,QAAQ,MAAM;AAAA,QAClC,QAAQ,IAAI,QAAQ,MAAM;AAAA,MAC5B;AAAA,IACF;AACA,UAAM;AAAA,EACR;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,41 @@
1
+ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
2
+ import {
3
+ CUSTOMERS_DEALS_BULK_UPDATE_STAGE_QUEUE,
4
+ bulkUpdateDealStageWithProgress
5
+ } from "../lib/bulkDeals.js";
6
+ const metadata = {
7
+ queue: CUSTOMERS_DEALS_BULK_UPDATE_STAGE_QUEUE,
8
+ id: "customers:deals-bulk-update-stage",
9
+ concurrency: 1
10
+ };
11
+ async function handle(job, _ctx) {
12
+ const container = await createRequestContainer();
13
+ try {
14
+ await bulkUpdateDealStageWithProgress({
15
+ container,
16
+ progressJobId: job.payload.progressJobId,
17
+ ids: job.payload.ids,
18
+ pipelineStageId: job.payload.pipelineStageId,
19
+ scope: job.payload.scope
20
+ });
21
+ } catch (error) {
22
+ const progressService = container.resolve("progressService");
23
+ await progressService.failJob(
24
+ job.payload.progressJobId,
25
+ {
26
+ errorMessage: error instanceof Error ? error.message : "Bulk deal stage update failed"
27
+ },
28
+ {
29
+ tenantId: job.payload.scope.tenantId,
30
+ organizationId: job.payload.scope.organizationId,
31
+ userId: job.payload.scope.userId
32
+ }
33
+ );
34
+ throw error;
35
+ }
36
+ }
37
+ export {
38
+ handle as default,
39
+ metadata
40
+ };
41
+ //# sourceMappingURL=deals-bulk-update-stage.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/customers/workers/deals-bulk-update-stage.ts"],
4
+ "sourcesContent": ["import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { ProgressService } from '../../progress/lib/progressService'\nimport {\n CUSTOMERS_DEALS_BULK_UPDATE_STAGE_QUEUE,\n bulkUpdateDealStageWithProgress,\n type CustomersDealsBulkUpdateStageJobPayload,\n} from '../lib/bulkDeals'\n\nexport const metadata: WorkerMeta = {\n queue: CUSTOMERS_DEALS_BULK_UPDATE_STAGE_QUEUE,\n id: 'customers:deals-bulk-update-stage',\n concurrency: 1,\n}\n\nexport default async function handle(\n job: QueuedJob<CustomersDealsBulkUpdateStageJobPayload>,\n _ctx: JobContext,\n): Promise<void> {\n const container = await createRequestContainer()\n\n try {\n await bulkUpdateDealStageWithProgress({\n container,\n progressJobId: job.payload.progressJobId,\n ids: job.payload.ids,\n pipelineStageId: job.payload.pipelineStageId,\n scope: job.payload.scope,\n })\n } catch (error) {\n const progressService = container.resolve('progressService') as ProgressService\n await progressService.failJob(\n job.payload.progressJobId,\n {\n errorMessage:\n error instanceof Error ? error.message : 'Bulk deal stage update failed',\n },\n {\n tenantId: job.payload.scope.tenantId,\n organizationId: job.payload.scope.organizationId,\n userId: job.payload.scope.userId,\n },\n )\n throw error\n }\n}\n"],
5
+ "mappings": "AACA,SAAS,8BAA8B;AAEvC;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AAEA,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAEA,eAAO,OACL,KACA,MACe;AACf,QAAM,YAAY,MAAM,uBAAuB;AAE/C,MAAI;AACF,UAAM,gCAAgC;AAAA,MACpC;AAAA,MACA,eAAe,IAAI,QAAQ;AAAA,MAC3B,KAAK,IAAI,QAAQ;AAAA,MACjB,iBAAiB,IAAI,QAAQ;AAAA,MAC7B,OAAO,IAAI,QAAQ;AAAA,IACrB,CAAC;AAAA,EACH,SAAS,OAAO;AACd,UAAM,kBAAkB,UAAU,QAAQ,iBAAiB;AAC3D,UAAM,gBAAgB;AAAA,MACpB,IAAI,QAAQ;AAAA,MACZ;AAAA,QACE,cACE,iBAAiB,QAAQ,MAAM,UAAU;AAAA,MAC7C;AAAA,MACA;AAAA,QACE,UAAU,IAAI,QAAQ,MAAM;AAAA,QAC5B,gBAAgB,IAAI,QAAQ,MAAM;AAAA,QAClC,QAAQ,IAAI,QAAQ,MAAM;AAAA,MAC5B;AAAA,IACF;AACA,UAAM;AAAA,EACR;AACF;",
6
+ "names": []
7
+ }
@@ -2,5 +2,6 @@ export const id = "id";
2
2
  export const organization_id = "organization_id";
3
3
  export const tenant_id = "tenant_id";
4
4
  export const address_format = "address_format";
5
+ export const stuck_threshold_days = "stuck_threshold_days";
5
6
  export const created_at = "created_at";
6
7
  export const updated_at = "updated_at";
@@ -810,6 +810,7 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
810
810
  "organization_id": "organization_id",
811
811
  "tenant_id": "tenant_id",
812
812
  "address_format": "address_format",
813
+ "stuck_threshold_days": "stuck_threshold_days",
813
814
  "created_at": "created_at",
814
815
  "updated_at": "updated_at"
815
816
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.3-develop.3651.1.d3a548a5f1",
3
+ "version": "0.6.3-develop.3653.1.b8558737ae",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -242,16 +242,16 @@
242
242
  "zod": "^4.4.3"
243
243
  },
244
244
  "peerDependencies": {
245
- "@open-mercato/ai-assistant": "0.6.3-develop.3651.1.d3a548a5f1",
246
- "@open-mercato/shared": "0.6.3-develop.3651.1.d3a548a5f1",
247
- "@open-mercato/ui": "0.6.3-develop.3651.1.d3a548a5f1",
245
+ "@open-mercato/ai-assistant": "0.6.3-develop.3653.1.b8558737ae",
246
+ "@open-mercato/shared": "0.6.3-develop.3653.1.b8558737ae",
247
+ "@open-mercato/ui": "0.6.3-develop.3653.1.b8558737ae",
248
248
  "react": "^19.0.0",
249
249
  "react-dom": "^19.0.0"
250
250
  },
251
251
  "devDependencies": {
252
- "@open-mercato/ai-assistant": "0.6.3-develop.3651.1.d3a548a5f1",
253
- "@open-mercato/shared": "0.6.3-develop.3651.1.d3a548a5f1",
254
- "@open-mercato/ui": "0.6.3-develop.3651.1.d3a548a5f1",
252
+ "@open-mercato/ai-assistant": "0.6.3-develop.3653.1.b8558737ae",
253
+ "@open-mercato/shared": "0.6.3-develop.3653.1.b8558737ae",
254
+ "@open-mercato/ui": "0.6.3-develop.3653.1.b8558737ae",
255
255
  "@testing-library/dom": "^10.4.1",
256
256
  "@testing-library/jest-dom": "^6.9.1",
257
257
  "@testing-library/react": "^16.3.1",
@@ -0,0 +1,438 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'
4
+ import type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'
5
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
8
+ import type { ExchangeRateService } from '@open-mercato/core/modules/currencies/services/exchangeRateService'
9
+ import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
10
+ import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
11
+ import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'
12
+ import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
13
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
14
+ import { fetchStuckDealIds } from '../../../lib/stuckDeals'
15
+ import { findMatchingEntityIdsBySearchTokensAcrossSources } from '../../utils'
16
+ import { E } from '#generated/entities.ids.generated'
17
+
18
+ export const metadata = {
19
+ GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },
20
+ }
21
+
22
+ const querySchema = z.object({
23
+ pipelineId: z.string().uuid().optional(),
24
+ search: z.string().optional(),
25
+ status: z.array(z.enum(['open', 'closed', 'win', 'loose'])).optional(),
26
+ ownerUserId: z.array(z.string().uuid()).optional(),
27
+ personId: z.array(z.string().uuid()).optional(),
28
+ companyId: z.array(z.string().uuid()).optional(),
29
+ isStuck: z.preprocess((value) => {
30
+ const parsed = parseBooleanFromUnknown(value)
31
+ return parsed === null ? value : parsed
32
+ }, z.boolean()).optional(),
33
+ isOverdue: z.preprocess((value) => {
34
+ const parsed = parseBooleanFromUnknown(value)
35
+ return parsed === null ? value : parsed
36
+ }, z.boolean()).optional(),
37
+ expectedCloseAtFrom: z.string().optional(),
38
+ expectedCloseAtTo: z.string().optional(),
39
+ })
40
+
41
+ type StageBreakdownByCurrency = {
42
+ currency: string
43
+ total: number
44
+ count: number
45
+ }
46
+
47
+ type StageAggregate = {
48
+ stageId: string
49
+ count: number
50
+ openCount: number
51
+ totalInBaseCurrency: number
52
+ byCurrency: StageBreakdownByCurrency[]
53
+ /**
54
+ * `true` when every currency present in `byCurrency` was either the base currency or
55
+ * had a usable exchange rate at request time. `false` when at least one currency in
56
+ * `byCurrency` could not be resolved to base — the lane header should surface a
57
+ * "partial" indicator in that case so the converted total isn't read as authoritative.
58
+ */
59
+ convertedAll: boolean
60
+ /**
61
+ * Currencies present in `byCurrency` that had NO exchange rate to the base currency.
62
+ * Empty when conversion was complete or there is no base currency configured. The
63
+ * client uses this to disclose which slice of the value is excluded from `totalInBaseCurrency`.
64
+ */
65
+ missingRateCurrencies: string[]
66
+ }
67
+
68
+ type AggregateResponse = {
69
+ baseCurrencyCode: string | null
70
+ perStage: StageAggregate[]
71
+ }
72
+
73
+ const stageBreakdownByCurrencySchema = z.object({
74
+ currency: z.string(),
75
+ total: z.number(),
76
+ count: z.number(),
77
+ })
78
+
79
+ const stageAggregateSchema = z.object({
80
+ stageId: z.string(),
81
+ count: z.number(),
82
+ openCount: z.number(),
83
+ totalInBaseCurrency: z.number(),
84
+ byCurrency: z.array(stageBreakdownByCurrencySchema),
85
+ convertedAll: z.boolean(),
86
+ missingRateCurrencies: z.array(z.string()),
87
+ })
88
+
89
+ const aggregateResponseSchema = z.object({
90
+ baseCurrencyCode: z.string().nullable(),
91
+ perStage: z.array(stageAggregateSchema),
92
+ })
93
+
94
+ const aggregateErrorSchema = z.object({
95
+ error: z.string(),
96
+ })
97
+
98
+ export const openApi: OpenApiRouteDoc = {
99
+ tag: 'Customers',
100
+ summary: 'Deals aggregate per pipeline stage',
101
+ methods: {
102
+ GET: {
103
+ summary: 'Per-stage counts and currency totals for kanban lane headers',
104
+ description:
105
+ 'Returns per-stage counts and totals for deals, with values converted to the tenant base currency where rates are available. Used to power kanban lane headers without loading every deal.',
106
+ query: querySchema,
107
+ responses: [
108
+ { status: 200, description: 'Per-stage aggregate payload', schema: aggregateResponseSchema },
109
+ ],
110
+ errors: [
111
+ { status: 400, description: 'Invalid query parameters', schema: aggregateErrorSchema },
112
+ { status: 401, description: 'Unauthorized', schema: aggregateErrorSchema },
113
+ ],
114
+ },
115
+ },
116
+ }
117
+
118
+ function readArrayParam(searchParams: URLSearchParams, key: string): string[] | null {
119
+ const all = searchParams.getAll(key)
120
+ if (!all.length) return null
121
+ const flat = all.flatMap((v) => v.split(','))
122
+ const trimmed = flat.map((s) => s.trim()).filter(Boolean)
123
+ return trimmed.length ? trimmed : null
124
+ }
125
+
126
+ function restrictToIds(where: string[], values: Array<string | number | null>, ids: string[]) {
127
+ if (ids.length === 0) {
128
+ where.push('id = ?')
129
+ values.push('00000000-0000-0000-0000-000000000000')
130
+ return
131
+ }
132
+ const placeholders = ids.map(() => '?').join(',')
133
+ where.push(`id IN (${placeholders})`)
134
+ values.push(...ids)
135
+ }
136
+
137
+ export async function GET(req: Request) {
138
+ const auth = await getAuthFromRequest(req)
139
+ if (!auth?.tenantId || !auth.orgId) {
140
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
141
+ }
142
+
143
+ const url = new URL(req.url)
144
+ const params = url.searchParams
145
+ const parsed = querySchema.safeParse({
146
+ pipelineId: params.get('pipelineId') ?? undefined,
147
+ search: params.get('search') ?? undefined,
148
+ status: readArrayParam(params, 'status') ?? undefined,
149
+ ownerUserId: readArrayParam(params, 'ownerUserId') ?? undefined,
150
+ personId: readArrayParam(params, 'personId') ?? undefined,
151
+ companyId: readArrayParam(params, 'companyId') ?? undefined,
152
+ isStuck: params.get('isStuck') ?? undefined,
153
+ isOverdue: params.get('isOverdue') ?? undefined,
154
+ expectedCloseAtFrom: params.get('expectedCloseAtFrom') ?? undefined,
155
+ expectedCloseAtTo: params.get('expectedCloseAtTo') ?? undefined,
156
+ })
157
+ if (!parsed.success) {
158
+ return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })
159
+ }
160
+
161
+ const container = await createRequestContainer()
162
+ const em = container.resolve<CoreEntityManager>('em')
163
+
164
+ // Resolve organization scope from the request so multi-org operators can aggregate
165
+ // deals for any org their RBAC scope permits (matching the deal detail route at
166
+ // [id]/route.ts:360). Falls back to `auth.orgId` when no scope cookie is set or
167
+ // when rbacService cannot be resolved (e.g. in unit tests with a minimal container).
168
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
169
+ const effectiveTenantId = scope.tenantId ?? auth.tenantId
170
+ const orgFilterIds = Array.isArray(scope.filterIds) && scope.filterIds.length > 0
171
+ ? scope.filterIds.filter((id) => typeof id === 'string' && id.length > 0)
172
+ : auth.orgId
173
+ ? [auth.orgId]
174
+ : []
175
+ if (!effectiveTenantId || orgFilterIds.length === 0) {
176
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
177
+ }
178
+
179
+ // Raw SQL is used here intentionally — the route only projects non-encrypted columns
180
+ // (`pipeline_stage_id`, `value_amount`, `value_currency`, `status`, plus filters). It
181
+ // avoids the per-row decryption cost that would be paid by `findWithDecryption` for an
182
+ // aggregate that never reads `title`/`description`. The search path still relies on
183
+ // the token index above to find matching deals when encrypted columns are involved.
184
+ const baseCurrency = await em.getConnection().execute<Array<{ code: string }>>(
185
+ `SELECT code FROM currencies WHERE tenant_id = ? AND organization_id = ? AND is_base = true AND deleted_at IS NULL LIMIT 1`,
186
+ [effectiveTenantId, orgFilterIds[0]],
187
+ )
188
+ const baseCurrencyCode = baseCurrency[0]?.code ?? null
189
+
190
+ // Build WHERE clause shared between count + sum queries
191
+ const orgPlaceholders = orgFilterIds.map(() => '?').join(',')
192
+ const where: string[] = [
193
+ 'tenant_id = ?',
194
+ `organization_id IN (${orgPlaceholders})`,
195
+ 'deleted_at IS NULL',
196
+ ]
197
+ const values: Array<string | number | null> = [effectiveTenantId, ...orgFilterIds]
198
+
199
+ if (parsed.data.pipelineId) {
200
+ where.push('pipeline_id = ?')
201
+ values.push(parsed.data.pipelineId)
202
+ }
203
+ const search = parsed.data.search?.trim()
204
+ if (search) {
205
+ const searchCtx: CrudCtx = {
206
+ container,
207
+ auth,
208
+ organizationScope: scope,
209
+ selectedOrganizationId: orgFilterIds[0],
210
+ organizationIds: orgFilterIds,
211
+ request: req,
212
+ }
213
+ const matchingIds = await findMatchingEntityIdsBySearchTokensAcrossSources({
214
+ ctx: searchCtx,
215
+ query: search,
216
+ sources: [
217
+ {
218
+ entityType: E.customers.customer_deal,
219
+ fields: [
220
+ 'title',
221
+ 'description',
222
+ 'status',
223
+ 'pipeline_stage',
224
+ 'source',
225
+ 'value_amount',
226
+ 'value_currency',
227
+ 'cf:competitive_risk',
228
+ 'cf:implementation_complexity',
229
+ ],
230
+ },
231
+ ],
232
+ })
233
+ if (matchingIds !== null && matchingIds.length > 0) {
234
+ restrictToIds(where, values, matchingIds)
235
+ } else if (isTenantDataEncryptionEnabled()) {
236
+ // `customers:customer_deal.title` and `.description` are declared in
237
+ // `encryption.ts` as encrypted columns. A raw `ILIKE` over the ciphertext would
238
+ // silently match nothing on encrypted tenants and produce misleading lane totals,
239
+ // so we collapse the result to zero rows instead — consistent with the list
240
+ // endpoint's behavior when the token index has nothing to offer.
241
+ restrictToIds(where, values, [])
242
+ } else {
243
+ const searchPattern = `%${escapeLikePattern(search)}%`
244
+ where.push("(title ILIKE ? ESCAPE '\\' OR description ILIKE ? ESCAPE '\\')")
245
+ values.push(searchPattern, searchPattern)
246
+ }
247
+ }
248
+ if (parsed.data.status && parsed.data.status.length) {
249
+ const placeholders = parsed.data.status.map(() => '?').join(',')
250
+ where.push(`status IN (${placeholders})`)
251
+ values.push(...parsed.data.status)
252
+ }
253
+ if (parsed.data.ownerUserId && parsed.data.ownerUserId.length) {
254
+ const placeholders = parsed.data.ownerUserId.map(() => '?').join(',')
255
+ where.push(`owner_user_id IN (${placeholders})`)
256
+ values.push(...parsed.data.ownerUserId)
257
+ }
258
+ if (parsed.data.expectedCloseAtFrom) {
259
+ where.push('expected_close_at >= ?')
260
+ values.push(parsed.data.expectedCloseAtFrom)
261
+ }
262
+ if (parsed.data.expectedCloseAtTo) {
263
+ where.push('expected_close_at <= ?')
264
+ values.push(parsed.data.expectedCloseAtTo)
265
+ }
266
+ if (parsed.data.isOverdue) {
267
+ where.push("expected_close_at < CURRENT_DATE AND status = 'open'")
268
+ }
269
+ if (parsed.data.isStuck) {
270
+ // Reuse the list endpoint's stuck-deal lookup so kanban headers, lane counts, and the
271
+ // cards rendered inside each lane agree on the same definition of "stuck". Empty result
272
+ // → narrow to a sentinel UUID so the WHERE clause collapses to zero rows.
273
+ const stuckIds = await fetchStuckDealIds(em as unknown as PgEntityManager, orgFilterIds[0], effectiveTenantId)
274
+ restrictToIds(where, values, stuckIds)
275
+ }
276
+ if (parsed.data.personId && parsed.data.personId.length) {
277
+ const placeholders = parsed.data.personId.map(() => '?').join(',')
278
+ where.push(`EXISTS (SELECT 1 FROM customer_deal_people dp WHERE dp.deal_id = customer_deals.id AND dp.person_entity_id IN (${placeholders}))`)
279
+ values.push(...parsed.data.personId)
280
+ }
281
+ if (parsed.data.companyId && parsed.data.companyId.length) {
282
+ const placeholders = parsed.data.companyId.map(() => '?').join(',')
283
+ where.push(`EXISTS (SELECT 1 FROM customer_deal_companies dc WHERE dc.deal_id = customer_deals.id AND dc.company_entity_id IN (${placeholders}))`)
284
+ values.push(...parsed.data.companyId)
285
+ }
286
+
287
+ const whereSql = where.join(' AND ')
288
+
289
+ // Aggregate by (stage, currency). Treat null stage as the literal `__unassigned`.
290
+ const rows = await em.getConnection().execute<
291
+ Array<{
292
+ stage_id: string | null
293
+ currency: string | null
294
+ total: string | number | null
295
+ count: string | number
296
+ open_count: string | number
297
+ }>
298
+ >(
299
+ `SELECT
300
+ pipeline_stage_id AS stage_id,
301
+ UPPER(COALESCE(value_currency, '')) AS currency,
302
+ COALESCE(SUM(value_amount), 0) AS total,
303
+ COUNT(*) AS count,
304
+ COUNT(*) FILTER (WHERE status = 'open') AS open_count
305
+ FROM customer_deals
306
+ WHERE ${whereSql}
307
+ GROUP BY pipeline_stage_id, UPPER(COALESCE(value_currency, ''))`,
308
+ values,
309
+ )
310
+
311
+ // Reduce to per-stage aggregates
312
+ const stageMap = new Map<string, StageAggregate>()
313
+ for (const row of rows) {
314
+ const stageId = row.stage_id ?? '__unassigned'
315
+ const total = Number(row.total ?? 0)
316
+ const count = Number(row.count ?? 0)
317
+ const openCount = Number(row.open_count ?? 0)
318
+ const currency = (row.currency ?? '').toString().trim()
319
+
320
+ if (!stageMap.has(stageId)) {
321
+ stageMap.set(stageId, {
322
+ stageId,
323
+ count: 0,
324
+ openCount: 0,
325
+ totalInBaseCurrency: 0,
326
+ byCurrency: [],
327
+ convertedAll: true,
328
+ missingRateCurrencies: [],
329
+ })
330
+ }
331
+ const agg = stageMap.get(stageId)!
332
+ agg.count += count
333
+ agg.openCount += openCount
334
+ // Include any row that has a real currency code, even when its summed amount is
335
+ // zero — `byCurrency` should agree with `count` so the per-currency breakdown
336
+ // doesn't silently drop deals whose amount happens to be zero. Rows with no
337
+ // currency code at all still get folded into the stage totals via `count`.
338
+ if (currency.length > 0) {
339
+ agg.byCurrency.push({ currency, total, count })
340
+ }
341
+ }
342
+
343
+ // Convert to base currency where possible
344
+ if (baseCurrencyCode) {
345
+ const exchange = container.resolve('exchangeRateService') as ExchangeRateService | undefined
346
+ const today = new Date()
347
+ const distinctCurrencies = new Set<string>()
348
+ for (const agg of stageMap.values()) {
349
+ for (const row of agg.byCurrency) {
350
+ if (row.currency && row.currency !== baseCurrencyCode) {
351
+ distinctCurrencies.add(row.currency)
352
+ }
353
+ }
354
+ }
355
+
356
+ const rateCache = new Map<string, number>()
357
+ if (exchange && distinctCurrencies.size > 0) {
358
+ const pairs = Array.from(distinctCurrencies).map((c) => ({
359
+ fromCurrencyCode: c,
360
+ toCurrencyCode: baseCurrencyCode,
361
+ }))
362
+ try {
363
+ const results = await exchange.getRates({
364
+ pairs,
365
+ date: today,
366
+ scope: { tenantId: effectiveTenantId, organizationId: orgFilterIds[0] },
367
+ options: { maxDaysBack: 60, autoFetch: false },
368
+ })
369
+ for (const [key, rateResult] of results) {
370
+ if (rateResult.rates.length > 0) {
371
+ // Pick the first matching rate (sources are equivalent for display purposes)
372
+ const rate = Number(rateResult.rates[0].rate)
373
+ if (Number.isFinite(rate) && rate > 0) {
374
+ rateCache.set(key, rate)
375
+ }
376
+ }
377
+ }
378
+ } catch (err) {
379
+ // Swallow — partial totals are still useful and we'll fall back to currency-native
380
+ // sums. Logging at warn level so operators can correlate missing-rate disclosures in
381
+ // the UI (`convertedAll: false` / `missingRateCurrencies`) with the underlying error.
382
+ console.warn('[customers.deals.aggregate] exchange-rate lookup failed; falling back to per-currency totals', err)
383
+ }
384
+ }
385
+
386
+ for (const agg of stageMap.values()) {
387
+ let totalBase = 0
388
+ let convertedAll = true
389
+ const missingRateCurrencies: string[] = []
390
+ for (const row of agg.byCurrency) {
391
+ if (!row.currency) continue
392
+ if (row.currency === baseCurrencyCode) {
393
+ totalBase += row.total
394
+ continue
395
+ }
396
+ const key = `${row.currency}/${baseCurrencyCode}`
397
+ const rate = rateCache.get(key)
398
+ if (rate !== undefined) {
399
+ totalBase += row.total * rate
400
+ } else {
401
+ convertedAll = false
402
+ if (!missingRateCurrencies.includes(row.currency)) {
403
+ missingRateCurrencies.push(row.currency)
404
+ }
405
+ }
406
+ }
407
+ // Even when some rates are missing, totalInBaseCurrency reflects the converted slice.
408
+ // `convertedAll`/`missingRateCurrencies` let the client distinguish a complete
409
+ // conversion from a partial one and disclose which currencies were excluded.
410
+ agg.totalInBaseCurrency = Math.round(totalBase)
411
+ agg.convertedAll = convertedAll
412
+ agg.missingRateCurrencies = missingRateCurrencies
413
+ }
414
+ } else {
415
+ // No base currency configured for the tenant — surface this by marking every stage as
416
+ // "not converted" with all currencies flagged as missing rates. This way the client UI
417
+ // can fall back to a per-currency breakdown without trying to render a board total.
418
+ for (const agg of stageMap.values()) {
419
+ const present = agg.byCurrency.map((row) => row.currency).filter((c): c is string => !!c)
420
+ if (present.length > 0) {
421
+ agg.convertedAll = false
422
+ agg.missingRateCurrencies = Array.from(new Set(present))
423
+ }
424
+ }
425
+ }
426
+
427
+ // Sort byCurrency rows by total descending for stable display
428
+ for (const agg of stageMap.values()) {
429
+ agg.byCurrency.sort((a, b) => b.total - a.total)
430
+ }
431
+
432
+ const response: AggregateResponse = {
433
+ baseCurrencyCode,
434
+ perStage: Array.from(stageMap.values()),
435
+ }
436
+
437
+ return NextResponse.json(response)
438
+ }