@open-mercato/core 0.10.1-develop.7954.1.48da4a7d13 → 0.10.1-develop.7972.1.03f813eaaf

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 (88) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/logout.js +3 -3
  3. package/dist/modules/auth/api/logout.js.map +2 -2
  4. package/dist/modules/auth/commands/roles.js +42 -31
  5. package/dist/modules/auth/commands/roles.js.map +2 -2
  6. package/dist/modules/communication_channels/setup.js +49 -45
  7. package/dist/modules/communication_channels/setup.js.map +2 -2
  8. package/dist/modules/customer_accounts/services/domainMappingService.js +55 -29
  9. package/dist/modules/customer_accounts/services/domainMappingService.js.map +2 -2
  10. package/dist/modules/customers/components/detail/ActivitiesSection.js +8 -3
  11. package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
  12. package/dist/modules/customers/components/detail/ActivityCard.js +3 -3
  13. package/dist/modules/customers/components/detail/ActivityCard.js.map +2 -2
  14. package/dist/modules/customers/components/detail/ActivityHistorySection.js +19 -7
  15. package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
  16. package/dist/modules/customers/components/detail/ActivityTimeline.js +5 -5
  17. package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
  18. package/dist/modules/directory/subscribers/seedOrganizationDefaults.js +67 -0
  19. package/dist/modules/directory/subscribers/seedOrganizationDefaults.js.map +7 -0
  20. package/dist/modules/ecommerce/api/storefront/categories/[slug]/route.js +1 -1
  21. package/dist/modules/ecommerce/api/storefront/categories/[slug]/route.js.map +2 -2
  22. package/dist/modules/ecommerce/api/storefront/categories/route.js +1 -1
  23. package/dist/modules/ecommerce/api/storefront/categories/route.js.map +2 -2
  24. package/dist/modules/ecommerce/api/storefront/context/route.js +1 -1
  25. package/dist/modules/ecommerce/api/storefront/context/route.js.map +2 -2
  26. package/dist/modules/ecommerce/api/storefront/products/[idOrHandle]/route.js +1 -1
  27. package/dist/modules/ecommerce/api/storefront/products/[idOrHandle]/route.js.map +2 -2
  28. package/dist/modules/ecommerce/api/storefront/products/route.js +1 -1
  29. package/dist/modules/ecommerce/api/storefront/products/route.js.map +2 -2
  30. package/dist/modules/ecommerce/api/storefront/search/suggest/route.js +1 -1
  31. package/dist/modules/ecommerce/api/storefront/search/suggest/route.js.map +2 -2
  32. package/dist/modules/ecommerce/lib/cacheInvalidation.js +3 -0
  33. package/dist/modules/ecommerce/lib/cacheInvalidation.js.map +2 -2
  34. package/dist/modules/ecommerce/lib/cacheKeys.js +4 -0
  35. package/dist/modules/ecommerce/lib/cacheKeys.js.map +2 -2
  36. package/dist/modules/ecommerce/lib/storeContext.js +36 -12
  37. package/dist/modules/ecommerce/lib/storeContext.js.map +2 -2
  38. package/dist/modules/ecommerce/subscribers/sales-channel-cache-invalidation.js +14 -0
  39. package/dist/modules/ecommerce/subscribers/sales-channel-cache-invalidation.js.map +7 -0
  40. package/dist/modules/integrations/setup.js +34 -24
  41. package/dist/modules/integrations/setup.js.map +2 -2
  42. package/dist/modules/messages/components/MessagesInboxPageClient.js +1 -1
  43. package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
  44. package/dist/modules/messages/components/message-detail/panels/MainMessageHeader.js +3 -3
  45. package/dist/modules/messages/components/message-detail/panels/MainMessageHeader.js.map +2 -2
  46. package/dist/modules/messages/components/message-detail/panels/body-panel.js +2 -2
  47. package/dist/modules/messages/components/message-detail/panels/body-panel.js.map +2 -2
  48. package/dist/modules/payment_gateways/setup.js +3 -0
  49. package/dist/modules/payment_gateways/setup.js.map +2 -2
  50. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js +30 -11
  51. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js.map +3 -3
  52. package/dist/modules/warranty_claims/setup.js +34 -30
  53. package/dist/modules/warranty_claims/setup.js.map +2 -2
  54. package/dist/modules/workflows/setup.js +7 -0
  55. package/dist/modules/workflows/setup.js.map +2 -2
  56. package/package.json +7 -7
  57. package/src/modules/auth/api/logout.ts +3 -3
  58. package/src/modules/auth/commands/roles.ts +43 -32
  59. package/src/modules/communication_channels/setup.ts +59 -53
  60. package/src/modules/customer_accounts/services/domainMappingService.ts +57 -29
  61. package/src/modules/customers/components/detail/ActivitiesSection.tsx +8 -3
  62. package/src/modules/customers/components/detail/ActivityCard.tsx +3 -3
  63. package/src/modules/customers/components/detail/ActivityHistorySection.tsx +20 -8
  64. package/src/modules/customers/components/detail/ActivityTimeline.tsx +7 -7
  65. package/src/modules/customers/i18n/de.json +1 -0
  66. package/src/modules/customers/i18n/en.json +1 -0
  67. package/src/modules/customers/i18n/es.json +1 -0
  68. package/src/modules/customers/i18n/ko.json +1 -0
  69. package/src/modules/customers/i18n/pl.json +1 -0
  70. package/src/modules/directory/subscribers/seedOrganizationDefaults.ts +84 -0
  71. package/src/modules/ecommerce/api/storefront/categories/[slug]/route.ts +1 -1
  72. package/src/modules/ecommerce/api/storefront/categories/route.ts +1 -1
  73. package/src/modules/ecommerce/api/storefront/context/route.ts +1 -1
  74. package/src/modules/ecommerce/api/storefront/products/[idOrHandle]/route.ts +1 -1
  75. package/src/modules/ecommerce/api/storefront/products/route.ts +1 -1
  76. package/src/modules/ecommerce/api/storefront/search/suggest/route.ts +1 -1
  77. package/src/modules/ecommerce/lib/cacheInvalidation.ts +4 -0
  78. package/src/modules/ecommerce/lib/cacheKeys.ts +4 -0
  79. package/src/modules/ecommerce/lib/storeContext.ts +56 -10
  80. package/src/modules/ecommerce/subscribers/sales-channel-cache-invalidation.ts +12 -0
  81. package/src/modules/integrations/setup.ts +41 -24
  82. package/src/modules/messages/components/MessagesInboxPageClient.tsx +1 -1
  83. package/src/modules/messages/components/message-detail/panels/MainMessageHeader.tsx +3 -3
  84. package/src/modules/messages/components/message-detail/panels/body-panel.tsx +2 -2
  85. package/src/modules/payment_gateways/setup.ts +4 -0
  86. package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.tsx +55 -28
  87. package/src/modules/warranty_claims/setup.ts +40 -30
  88. package/src/modules/workflows/setup.ts +8 -0
@@ -42,6 +42,36 @@ function stableScheduleUuid(stableKey) {
42
42
  function stableSlaSweepScheduleId(organizationId) {
43
43
  return stableScheduleUuid(`warranty_claims:sla-sweep:${organizationId}`);
44
44
  }
45
+ async function ensureSlaSweepSchedule(ctx) {
46
+ const cradle = ctx.container;
47
+ if (typeof cradle.hasRegistration !== "function" || !cradle.hasRegistration("schedulerService")) return;
48
+ const schedulerService = ctx.container.resolve("schedulerService");
49
+ try {
50
+ await schedulerService.register({
51
+ id: stableSlaSweepScheduleId(ctx.organizationId),
52
+ name: "Warranty claims SLA sweep",
53
+ description: "Enqueues tenant-scoped warranty claim SLA escalation sweeps every 900 seconds.",
54
+ scopeType: "organization",
55
+ organizationId: ctx.organizationId,
56
+ tenantId: ctx.tenantId,
57
+ scheduleType: "interval",
58
+ scheduleValue: "900s",
59
+ timezone: "UTC",
60
+ targetType: "queue",
61
+ targetQueue: "warranty_claims.sla_sweep",
62
+ targetPayload: {
63
+ scope: { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
64
+ },
65
+ sourceType: "module",
66
+ sourceModule: "warranty_claims",
67
+ isEnabled: true
68
+ });
69
+ } catch (error) {
70
+ logger.warn("[warranty_claims] Failed to register SLA sweep schedule", {
71
+ error: error instanceof Error ? error.message : error
72
+ });
73
+ }
74
+ }
45
75
  const setup = {
46
76
  defaultRoleFeatures: {
47
77
  admin: adminFeatures,
@@ -81,36 +111,10 @@ const setup = {
81
111
  },
82
112
  async seedDefaults({ em, tenantId, organizationId, container }) {
83
113
  await seedWarrantyClaimDictionaries(em, { tenantId, organizationId });
84
- const cradle = container;
85
- if (typeof cradle.hasRegistration !== "function" || !cradle.hasRegistration("schedulerService")) {
86
- return;
87
- }
88
- const schedulerService = container.resolve("schedulerService");
89
- try {
90
- await schedulerService.register({
91
- id: stableSlaSweepScheduleId(organizationId),
92
- name: "Warranty claims SLA sweep",
93
- description: "Enqueues tenant-scoped warranty claim SLA escalation sweeps every 900 seconds.",
94
- scopeType: "organization",
95
- organizationId,
96
- tenantId,
97
- scheduleType: "interval",
98
- scheduleValue: "900s",
99
- timezone: "UTC",
100
- targetType: "queue",
101
- targetQueue: "warranty_claims.sla_sweep",
102
- targetPayload: {
103
- scope: { tenantId, organizationId }
104
- },
105
- sourceType: "module",
106
- sourceModule: "warranty_claims",
107
- isEnabled: true
108
- });
109
- } catch (error) {
110
- logger.warn("[warranty_claims] Failed to register SLA sweep schedule", {
111
- error: error instanceof Error ? error.message : error
112
- });
113
- }
114
+ await ensureSlaSweepSchedule({ container, tenantId, organizationId });
115
+ },
116
+ async onOrganizationCreated({ container, tenantId, organizationId }) {
117
+ await ensureSlaSweepSchedule({ container, tenantId, organizationId });
114
118
  }
115
119
  };
116
120
  var setup_default = setup;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/warranty_claims/setup.ts"],
4
- "sourcesContent": ["import { createHash } from 'node:crypto'\nimport type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport { CLAIM_TYPES } from './data/validators'\nimport { WarrantyClaimSequence } from './data/entities'\nimport { seedWarrantyClaimDictionaries } from './lib/dictionaries'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('warranty_claims')\n\ntype SchedulerServiceLike = {\n register: (registration: {\n id: string\n name: string\n scopeType: 'system' | 'organization' | 'tenant'\n organizationId?: string\n tenantId?: string\n scheduleType: 'cron' | 'interval'\n scheduleValue: string\n timezone?: string\n targetType: 'queue' | 'command'\n targetQueue?: string\n targetPayload?: unknown\n sourceType?: 'user' | 'module'\n sourceModule?: string\n isEnabled?: boolean\n description?: string\n }) => Promise<void>\n}\n\n// Cross-module READ grants the desk genuinely needs: the intake order picker and\n// the product picker call the sales and catalog list APIs directly. Assignee names\n// are resolved through this module's own `warranty_claims.claim.view`-gated\n// `/api/warranty_claims/assignees` endpoint, so no `auth.*` grant is required here \u2014\n// granting one would hand every role that can see a claim the tenant's full user\n// directory, including emails and role assignments.\nconst connectedIntakeFeatures = ['sales.orders.view', 'catalog.products.view']\n\nconst adminFeatures = [\n 'warranty_claims.*',\n 'warranty_claims.claim.view',\n 'warranty_claims.claim.create',\n 'warranty_claims.claim.manage',\n 'warranty_claims.claim.delete',\n 'warranty_claims.settings.manage',\n 'warranty_claims.external.submit',\n 'warranty_claims.external.view',\n 'warranty_claims.registration.view',\n 'warranty_claims.registration.manage',\n 'warranty_claims.vendor_policy.manage',\n 'warranty_claims.troubleshooting.manage',\n 'warranty_claims.receiving.manage',\n ...connectedIntakeFeatures,\n]\n\nconst ownerFeatures = [\n 'warranty_claims.*',\n 'warranty_claims.claim.view',\n 'warranty_claims.claim.create',\n 'warranty_claims.claim.manage',\n 'warranty_claims.claim.delete',\n 'warranty_claims.settings.manage',\n 'warranty_claims.registration.view',\n 'warranty_claims.registration.manage',\n 'warranty_claims.vendor_policy.manage',\n 'warranty_claims.troubleshooting.manage',\n 'warranty_claims.receiving.manage',\n ...connectedIntakeFeatures,\n]\n\nfunction stableScheduleUuid(stableKey: string): string {\n const hex = createHash('sha256').update(stableKey).digest('hex')\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`\n}\n\nfunction stableSlaSweepScheduleId(organizationId: string): string {\n return stableScheduleUuid(`warranty_claims:sla-sweep:${organizationId}`)\n}\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n admin: adminFeatures,\n owner: ownerFeatures,\n employee: [\n 'warranty_claims.claim.view',\n 'warranty_claims.claim.create',\n 'warranty_claims.claim.manage',\n 'warranty_claims.registration.view',\n 'warranty_claims.registration.manage',\n 'warranty_claims.receiving.manage',\n ...connectedIntakeFeatures,\n ],\n },\n\n async onTenantCreated({ em, tenantId, organizationId }) {\n const now = new Date()\n for (const claimType of CLAIM_TYPES) {\n const sequence = await em.findOne(WarrantyClaimSequence, {\n tenantId,\n organizationId,\n claimType,\n })\n if (!sequence) {\n em.persist(\n em.create(WarrantyClaimSequence, {\n tenantId,\n organizationId,\n claimType,\n nextNumber: 1,\n createdAt: now,\n updatedAt: now,\n })\n )\n }\n }\n await em.flush()\n },\n\n async seedDefaults({ em, tenantId, organizationId, container }) {\n await seedWarrantyClaimDictionaries(em, { tenantId, organizationId })\n const cradle = container as { hasRegistration?: (name: string) => boolean }\n if (typeof cradle.hasRegistration !== 'function' || !cradle.hasRegistration('schedulerService')) {\n return\n }\n\n const schedulerService = container.resolve('schedulerService') as SchedulerServiceLike\n try {\n await schedulerService.register({\n id: stableSlaSweepScheduleId(organizationId),\n name: 'Warranty claims SLA sweep',\n description: 'Enqueues tenant-scoped warranty claim SLA escalation sweeps every 900 seconds.',\n scopeType: 'organization',\n organizationId,\n tenantId,\n scheduleType: 'interval',\n scheduleValue: '900s',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'warranty_claims.sla_sweep',\n targetPayload: {\n scope: { tenantId, organizationId },\n },\n sourceType: 'module',\n sourceModule: 'warranty_claims',\n isEnabled: true,\n })\n } catch (error) {\n logger.warn('[warranty_claims] Failed to register SLA sweep schedule', {\n error: error instanceof Error ? error.message : error,\n })\n }\n },\n}\n\nexport default setup\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createHash } from 'node:crypto'\nimport type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport { CLAIM_TYPES } from './data/validators'\nimport { WarrantyClaimSequence } from './data/entities'\nimport { seedWarrantyClaimDictionaries } from './lib/dictionaries'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('warranty_claims')\n\ntype SchedulerServiceLike = {\n register: (registration: {\n id: string\n name: string\n scopeType: 'system' | 'organization' | 'tenant'\n organizationId?: string\n tenantId?: string\n scheduleType: 'cron' | 'interval'\n scheduleValue: string\n timezone?: string\n targetType: 'queue' | 'command'\n targetQueue?: string\n targetPayload?: unknown\n sourceType?: 'user' | 'module'\n sourceModule?: string\n isEnabled?: boolean\n description?: string\n }) => Promise<void>\n}\n\n// Cross-module READ grants the desk genuinely needs: the intake order picker and\n// the product picker call the sales and catalog list APIs directly. Assignee names\n// are resolved through this module's own `warranty_claims.claim.view`-gated\n// `/api/warranty_claims/assignees` endpoint, so no `auth.*` grant is required here \u2014\n// granting one would hand every role that can see a claim the tenant's full user\n// directory, including emails and role assignments.\nconst connectedIntakeFeatures = ['sales.orders.view', 'catalog.products.view']\n\nconst adminFeatures = [\n 'warranty_claims.*',\n 'warranty_claims.claim.view',\n 'warranty_claims.claim.create',\n 'warranty_claims.claim.manage',\n 'warranty_claims.claim.delete',\n 'warranty_claims.settings.manage',\n 'warranty_claims.external.submit',\n 'warranty_claims.external.view',\n 'warranty_claims.registration.view',\n 'warranty_claims.registration.manage',\n 'warranty_claims.vendor_policy.manage',\n 'warranty_claims.troubleshooting.manage',\n 'warranty_claims.receiving.manage',\n ...connectedIntakeFeatures,\n]\n\nconst ownerFeatures = [\n 'warranty_claims.*',\n 'warranty_claims.claim.view',\n 'warranty_claims.claim.create',\n 'warranty_claims.claim.manage',\n 'warranty_claims.claim.delete',\n 'warranty_claims.settings.manage',\n 'warranty_claims.registration.view',\n 'warranty_claims.registration.manage',\n 'warranty_claims.vendor_policy.manage',\n 'warranty_claims.troubleshooting.manage',\n 'warranty_claims.receiving.manage',\n ...connectedIntakeFeatures,\n]\n\nfunction stableScheduleUuid(stableKey: string): string {\n const hex = createHash('sha256').update(stableKey).digest('hex')\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`\n}\n\nfunction stableSlaSweepScheduleId(organizationId: string): string {\n return stableScheduleUuid(`warranty_claims:sla-sweep:${organizationId}`)\n}\n\nasync function ensureSlaSweepSchedule(ctx: {\n container: import('awilix').AwilixContainer\n tenantId: string\n organizationId: string\n}): Promise<void> {\n const cradle = ctx.container as { hasRegistration?: (name: string) => boolean }\n if (typeof cradle.hasRegistration !== 'function' || !cradle.hasRegistration('schedulerService')) return\n\n const schedulerService = ctx.container.resolve('schedulerService') as SchedulerServiceLike\n try {\n await schedulerService.register({\n id: stableSlaSweepScheduleId(ctx.organizationId),\n name: 'Warranty claims SLA sweep',\n description: 'Enqueues tenant-scoped warranty claim SLA escalation sweeps every 900 seconds.',\n scopeType: 'organization',\n organizationId: ctx.organizationId,\n tenantId: ctx.tenantId,\n scheduleType: 'interval',\n scheduleValue: '900s',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'warranty_claims.sla_sweep',\n targetPayload: {\n scope: { tenantId: ctx.tenantId, organizationId: ctx.organizationId },\n },\n sourceType: 'module',\n sourceModule: 'warranty_claims',\n isEnabled: true,\n })\n } catch (error) {\n logger.warn('[warranty_claims] Failed to register SLA sweep schedule', {\n error: error instanceof Error ? error.message : error,\n })\n }\n}\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n admin: adminFeatures,\n owner: ownerFeatures,\n employee: [\n 'warranty_claims.claim.view',\n 'warranty_claims.claim.create',\n 'warranty_claims.claim.manage',\n 'warranty_claims.registration.view',\n 'warranty_claims.registration.manage',\n 'warranty_claims.receiving.manage',\n ...connectedIntakeFeatures,\n ],\n },\n\n async onTenantCreated({ em, tenantId, organizationId }) {\n const now = new Date()\n for (const claimType of CLAIM_TYPES) {\n const sequence = await em.findOne(WarrantyClaimSequence, {\n tenantId,\n organizationId,\n claimType,\n })\n if (!sequence) {\n em.persist(\n em.create(WarrantyClaimSequence, {\n tenantId,\n organizationId,\n claimType,\n nextNumber: 1,\n createdAt: now,\n updatedAt: now,\n })\n )\n }\n }\n await em.flush()\n },\n\n async seedDefaults({ em, tenantId, organizationId, container }) {\n await seedWarrantyClaimDictionaries(em, { tenantId, organizationId })\n await ensureSlaSweepSchedule({ container, tenantId, organizationId })\n },\n\n async onOrganizationCreated({ container, tenantId, organizationId }) {\n await ensureSlaSweepSchedule({ container, tenantId, organizationId })\n },\n}\n\nexport default setup\n"],
5
+ "mappings": "AAAA,SAAS,kBAAkB;AAE3B,SAAS,mBAAmB;AAC5B,SAAS,6BAA6B;AACtC,SAAS,qCAAqC;AAC9C,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,iBAAiB;AA4B7C,MAAM,0BAA0B,CAAC,qBAAqB,uBAAuB;AAE7E,MAAM,gBAAgB;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL;AAEA,MAAM,gBAAgB;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL;AAEA,SAAS,mBAAmB,WAA2B;AACrD,QAAM,MAAM,WAAW,QAAQ,EAAE,OAAO,SAAS,EAAE,OAAO,KAAK;AAC/D,SAAO,GAAG,IAAI,MAAM,GAAG,CAAC,CAAC,IAAI,IAAI,MAAM,GAAG,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC;AAC9G;AAEA,SAAS,yBAAyB,gBAAgC;AAChE,SAAO,mBAAmB,6BAA6B,cAAc,EAAE;AACzE;AAEA,eAAe,uBAAuB,KAIpB;AAChB,QAAM,SAAS,IAAI;AACnB,MAAI,OAAO,OAAO,oBAAoB,cAAc,CAAC,OAAO,gBAAgB,kBAAkB,EAAG;AAEjG,QAAM,mBAAmB,IAAI,UAAU,QAAQ,kBAAkB;AACjE,MAAI;AACF,UAAM,iBAAiB,SAAS;AAAA,MAC9B,IAAI,yBAAyB,IAAI,cAAc;AAAA,MAC/C,MAAM;AAAA,MACN,aAAa;AAAA,MACb,WAAW;AAAA,MACX,gBAAgB,IAAI;AAAA,MACpB,UAAU,IAAI;AAAA,MACd,cAAc;AAAA,MACd,eAAe;AAAA,MACf,UAAU;AAAA,MACV,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,eAAe;AAAA,QACb,OAAO,EAAE,UAAU,IAAI,UAAU,gBAAgB,IAAI,eAAe;AAAA,MACtE;AAAA,MACA,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,WAAW;AAAA,IACb,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,KAAK,2DAA2D;AAAA,MACrE,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD,CAAC;AAAA,EACH;AACF;AAEO,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA,IACnB,OAAO;AAAA,IACP,OAAO;AAAA,IACP,UAAU;AAAA,MACR;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,GAAG;AAAA,IACL;AAAA,EACF;AAAA,EAEA,MAAM,gBAAgB,EAAE,IAAI,UAAU,eAAe,GAAG;AACtD,UAAM,MAAM,oBAAI,KAAK;AACrB,eAAW,aAAa,aAAa;AACnC,YAAM,WAAW,MAAM,GAAG,QAAQ,uBAAuB;AAAA,QACvD;AAAA,QACA;AAAA,QACA;AAAA,MACF,CAAC;AACD,UAAI,CAAC,UAAU;AACb,WAAG;AAAA,UACD,GAAG,OAAO,uBAAuB;AAAA,YAC/B;AAAA,YACA;AAAA,YACA;AAAA,YACA,YAAY;AAAA,YACZ,WAAW;AAAA,YACX,WAAW;AAAA,UACb,CAAC;AAAA,QACH;AAAA,MACF;AAAA,IACF;AACA,UAAM,GAAG,MAAM;AAAA,EACjB;AAAA,EAEA,MAAM,aAAa,EAAE,IAAI,UAAU,gBAAgB,UAAU,GAAG;AAC9D,UAAM,8BAA8B,IAAI,EAAE,UAAU,eAAe,CAAC;AACpE,UAAM,uBAAuB,EAAE,WAAW,UAAU,eAAe,CAAC;AAAA,EACtE;AAAA,EAEA,MAAM,sBAAsB,EAAE,WAAW,UAAU,eAAe,GAAG;AACnE,UAAM,uBAAuB,EAAE,WAAW,UAAU,eAAe,CAAC;AAAA,EACtE;AACF;AAEA,IAAO,gBAAQ;",
6
6
  "names": []
7
7
  }
@@ -45,6 +45,13 @@ const setup = {
45
45
  organizationId: ctx.organizationId
46
46
  });
47
47
  },
48
+ onOrganizationCreated: async (ctx) => {
49
+ await ensureMetricRollupSchedule({
50
+ container: ctx.container,
51
+ tenantId: ctx.tenantId,
52
+ organizationId: ctx.organizationId
53
+ });
54
+ },
48
55
  /**
49
56
  * Portal grants for the §6.4 portal task surface.
50
57
  *
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/workflows/setup.ts"],
4
- "sourcesContent": ["import { createHash } from 'node:crypto'\nimport type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport { resolveBusinessRuleDiscoveryCache } from '@open-mercato/core/modules/business_rules/lib/rule-engine'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { seedExampleWorkflows } from './lib/seeds'\nimport { WORKFLOW_DEFINITION_METRIC_ROLLUP_INTERVAL } from './lib/metrics/rollup-schedule'\n\nconst logger = createLogger('workflows')\n\ntype SchedulerServiceLike = {\n register: (registration: {\n id: string\n name: string\n description?: string\n scopeType: 'system' | 'organization' | 'tenant'\n organizationId?: string\n tenantId?: string\n scheduleType: 'cron' | 'interval'\n scheduleValue: string\n timezone?: string\n targetType: 'queue'\n targetQueue: string\n targetPayload?: Record<string, unknown>\n sourceType?: string\n sourceModule?: string\n isEnabled?: boolean\n }) => Promise<void>\n}\n\n/**\n * `scheduled_jobs.id` is a uuid column, so a module-owned schedule's stable\n * registration key must be hashed into a uuid rather than used verbatim \u2014 this\n * keeps `schedulerService.register()` an idempotent upsert across re-runs of\n * `seedDefaults` instead of trying to insert a raw string into the uuid PK.\n */\nfunction stableScheduleUuid(stableKey: string): string {\n const hex = createHash('sha256').update(stableKey).digest('hex')\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`\n}\n\n/**\n * Register the per-organization KPI rollup tick (spec \u00A78.5).\n *\n * Per ORGANIZATION rather than one system-wide tick that enumerates tenants:\n * the pass would otherwise need an unscoped read to discover which\n * organizations exist, and this module's rule is that every query carries both\n * scope columns. One schedule row per organization is the price of that.\n *\n * Best-effort in both directions: `@open-mercato/scheduler` is an optional peer\n * (no registration in the container \u21D2 silent no-op), and a failure to register\n * must never abort tenant setup \u2014 the read API live-computes when no rollup row\n * exists, so a missing schedule degrades freshness, not correctness.\n */\nasync function ensureMetricRollupSchedule(ctx: {\n container: { resolve: (name: string) => unknown; hasRegistration?: (name: string) => boolean }\n tenantId: string\n organizationId: string\n}): Promise<void> {\n const cradle = ctx.container as { hasRegistration?: (name: string) => boolean }\n if (typeof cradle.hasRegistration !== 'function' || !cradle.hasRegistration('schedulerService')) return\n try {\n const schedulerService = ctx.container.resolve('schedulerService') as SchedulerServiceLike\n await schedulerService.register({\n id: stableScheduleUuid(`workflows:definition-metric-rollup:${ctx.organizationId}`),\n name: 'Workflow definition metric rollup',\n description:\n 'Recomputes per-definition run, success-rate, duration-percentile and task-SLA KPIs into workflow_definition_metric_rollups.',\n scopeType: 'organization',\n organizationId: ctx.organizationId,\n tenantId: ctx.tenantId,\n scheduleType: 'interval',\n scheduleValue: WORKFLOW_DEFINITION_METRIC_ROLLUP_INTERVAL,\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'workflow-definition-metric-rollup',\n targetPayload: { scope: { tenantId: ctx.tenantId, organizationId: ctx.organizationId } },\n sourceType: 'module',\n sourceModule: 'workflows',\n isEnabled: true,\n })\n } catch (error) {\n logger.warn('[internal] workflows: failed to register the metric-rollup schedule', { err: error })\n }\n}\n\nexport const setup: ModuleSetupConfig = {\n seedDefaults: async (ctx) => {\n const scope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId }\n const cache = resolveBusinessRuleDiscoveryCache(ctx.container.resolve.bind(ctx.container))\n await seedExampleWorkflows(ctx.em, scope, { cache })\n await ensureMetricRollupSchedule({\n container: ctx.container as never,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n })\n },\n\n /**\n * Portal grants for the \u00A76.4 portal task surface.\n *\n * Declared HERE rather than in `customer_accounts` because a module ships its\n * own portal features the same way it ships its own backoffice ones \u2014\n * `syncDefaultCustomerRoleAcls` collects `defaultCustomerRoleFeatures` from\n * every enabled module and merges them into the seeded customer roles.\n * Workflows is the first shipped consumer of that seam.\n *\n * `portal_admin` is deliberately absent: it already carries `portal.*`, and a\n * wildcard is exactly what the portal branch of the predicate refuses to treat\n * as ownership. Granting it explicitly would suggest the wildcard means\n * something it does not.\n *\n * Two rollout caveats, neither a bug:\n * - The merge is additive into EXISTING roles and runs during tenant setup,\n * so an already-provisioned tenant picks these up only after\n * `mercato customer_accounts sync-customer-role-acls`.\n * - `CustomerRbacService` caches ACLs for 5 minutes, so a fresh grant is not\n * immediately visible to a signed-in portal user.\n */\n defaultCustomerRoleFeatures: {\n buyer: ['portal.tasks.view', 'portal.tasks.complete'],\n viewer: ['portal.tasks.view'],\n },\n\n defaultRoleFeatures: {\n // `workflows.*` already grants every concrete id, including the spec \u00A78.4\n // recovery features `workflows.instances.rerun_step` and\n // `workflows.instances.bulk_ops`, the spec \u00A78.5 ops feature\n // `workflows.metrics.view`, and the execution-identity feature\n // `workflows.definitions.grant_features` (safe to hold under a wildcard: a\n // grant can never exceed the granting user's OWN current features, so an\n // admin can only delegate powers they already have). Existing tenants pick\n // up newly declared features only after `yarn mercato auth sync-role-acls`.\n admin: ['workflows.*'],\n employee: [\n 'workflows.view',\n 'workflows.view_tasks',\n 'workflows.tasks.view',\n 'workflows.tasks.claim',\n 'workflows.tasks.complete',\n 'workflows.instances.view',\n ],\n },\n}\n\nexport default setup\n"],
5
- "mappings": "AAAA,SAAS,kBAAkB;AAE3B,SAAS,yCAAyC;AAClD,SAAS,oBAAoB;AAC7B,SAAS,4BAA4B;AACrC,SAAS,kDAAkD;AAE3D,MAAM,SAAS,aAAa,WAAW;AA4BvC,SAAS,mBAAmB,WAA2B;AACrD,QAAM,MAAM,WAAW,QAAQ,EAAE,OAAO,SAAS,EAAE,OAAO,KAAK;AAC/D,SAAO,GAAG,IAAI,MAAM,GAAG,CAAC,CAAC,IAAI,IAAI,MAAM,GAAG,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC;AAC9G;AAeA,eAAe,2BAA2B,KAIxB;AAChB,QAAM,SAAS,IAAI;AACnB,MAAI,OAAO,OAAO,oBAAoB,cAAc,CAAC,OAAO,gBAAgB,kBAAkB,EAAG;AACjG,MAAI;AACF,UAAM,mBAAmB,IAAI,UAAU,QAAQ,kBAAkB;AACjE,UAAM,iBAAiB,SAAS;AAAA,MAC9B,IAAI,mBAAmB,sCAAsC,IAAI,cAAc,EAAE;AAAA,MACjF,MAAM;AAAA,MACN,aACE;AAAA,MACF,WAAW;AAAA,MACX,gBAAgB,IAAI;AAAA,MACpB,UAAU,IAAI;AAAA,MACd,cAAc;AAAA,MACd,eAAe;AAAA,MACf,UAAU;AAAA,MACV,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,eAAe,EAAE,OAAO,EAAE,UAAU,IAAI,UAAU,gBAAgB,IAAI,eAAe,EAAE;AAAA,MACvF,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,WAAW;AAAA,IACb,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,KAAK,uEAAuE,EAAE,KAAK,MAAM,CAAC;AAAA,EACnG;AACF;AAEO,MAAM,QAA2B;AAAA,EACtC,cAAc,OAAO,QAAQ;AAC3B,UAAM,QAAQ,EAAE,UAAU,IAAI,UAAU,gBAAgB,IAAI,eAAe;AAC3E,UAAM,QAAQ,kCAAkC,IAAI,UAAU,QAAQ,KAAK,IAAI,SAAS,CAAC;AACzF,UAAM,qBAAqB,IAAI,IAAI,OAAO,EAAE,MAAM,CAAC;AACnD,UAAM,2BAA2B;AAAA,MAC/B,WAAW,IAAI;AAAA,MACf,UAAU,IAAI;AAAA,MACd,gBAAgB,IAAI;AAAA,IACtB,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAuBA,6BAA6B;AAAA,IAC3B,OAAO,CAAC,qBAAqB,uBAAuB;AAAA,IACpD,QAAQ,CAAC,mBAAmB;AAAA,EAC9B;AAAA,EAEA,qBAAqB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASnB,OAAO,CAAC,aAAa;AAAA,IACrB,UAAU;AAAA,MACR;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;AAEA,IAAO,gBAAQ;",
4
+ "sourcesContent": ["import { createHash } from 'node:crypto'\nimport type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport { resolveBusinessRuleDiscoveryCache } from '@open-mercato/core/modules/business_rules/lib/rule-engine'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { seedExampleWorkflows } from './lib/seeds'\nimport { WORKFLOW_DEFINITION_METRIC_ROLLUP_INTERVAL } from './lib/metrics/rollup-schedule'\n\nconst logger = createLogger('workflows')\n\ntype SchedulerServiceLike = {\n register: (registration: {\n id: string\n name: string\n description?: string\n scopeType: 'system' | 'organization' | 'tenant'\n organizationId?: string\n tenantId?: string\n scheduleType: 'cron' | 'interval'\n scheduleValue: string\n timezone?: string\n targetType: 'queue'\n targetQueue: string\n targetPayload?: Record<string, unknown>\n sourceType?: string\n sourceModule?: string\n isEnabled?: boolean\n }) => Promise<void>\n}\n\n/**\n * `scheduled_jobs.id` is a uuid column, so a module-owned schedule's stable\n * registration key must be hashed into a uuid rather than used verbatim \u2014 this\n * keeps `schedulerService.register()` an idempotent upsert across re-runs of\n * `seedDefaults` instead of trying to insert a raw string into the uuid PK.\n */\nfunction stableScheduleUuid(stableKey: string): string {\n const hex = createHash('sha256').update(stableKey).digest('hex')\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`\n}\n\n/**\n * Register the per-organization KPI rollup tick (spec \u00A78.5).\n *\n * Per ORGANIZATION rather than one system-wide tick that enumerates tenants:\n * the pass would otherwise need an unscoped read to discover which\n * organizations exist, and this module's rule is that every query carries both\n * scope columns. One schedule row per organization is the price of that.\n *\n * Best-effort in both directions: `@open-mercato/scheduler` is an optional peer\n * (no registration in the container \u21D2 silent no-op), and a failure to register\n * must never abort tenant setup \u2014 the read API live-computes when no rollup row\n * exists, so a missing schedule degrades freshness, not correctness.\n */\nasync function ensureMetricRollupSchedule(ctx: {\n container: { resolve: (name: string) => unknown; hasRegistration?: (name: string) => boolean }\n tenantId: string\n organizationId: string\n}): Promise<void> {\n const cradle = ctx.container as { hasRegistration?: (name: string) => boolean }\n if (typeof cradle.hasRegistration !== 'function' || !cradle.hasRegistration('schedulerService')) return\n try {\n const schedulerService = ctx.container.resolve('schedulerService') as SchedulerServiceLike\n await schedulerService.register({\n id: stableScheduleUuid(`workflows:definition-metric-rollup:${ctx.organizationId}`),\n name: 'Workflow definition metric rollup',\n description:\n 'Recomputes per-definition run, success-rate, duration-percentile and task-SLA KPIs into workflow_definition_metric_rollups.',\n scopeType: 'organization',\n organizationId: ctx.organizationId,\n tenantId: ctx.tenantId,\n scheduleType: 'interval',\n scheduleValue: WORKFLOW_DEFINITION_METRIC_ROLLUP_INTERVAL,\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'workflow-definition-metric-rollup',\n targetPayload: { scope: { tenantId: ctx.tenantId, organizationId: ctx.organizationId } },\n sourceType: 'module',\n sourceModule: 'workflows',\n isEnabled: true,\n })\n } catch (error) {\n logger.warn('[internal] workflows: failed to register the metric-rollup schedule', { err: error })\n }\n}\n\nexport const setup: ModuleSetupConfig = {\n seedDefaults: async (ctx) => {\n const scope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId }\n const cache = resolveBusinessRuleDiscoveryCache(ctx.container.resolve.bind(ctx.container))\n await seedExampleWorkflows(ctx.em, scope, { cache })\n await ensureMetricRollupSchedule({\n container: ctx.container as never,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n })\n },\n\n onOrganizationCreated: async (ctx) => {\n await ensureMetricRollupSchedule({\n container: ctx.container as never,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n })\n },\n\n /**\n * Portal grants for the \u00A76.4 portal task surface.\n *\n * Declared HERE rather than in `customer_accounts` because a module ships its\n * own portal features the same way it ships its own backoffice ones \u2014\n * `syncDefaultCustomerRoleAcls` collects `defaultCustomerRoleFeatures` from\n * every enabled module and merges them into the seeded customer roles.\n * Workflows is the first shipped consumer of that seam.\n *\n * `portal_admin` is deliberately absent: it already carries `portal.*`, and a\n * wildcard is exactly what the portal branch of the predicate refuses to treat\n * as ownership. Granting it explicitly would suggest the wildcard means\n * something it does not.\n *\n * Two rollout caveats, neither a bug:\n * - The merge is additive into EXISTING roles and runs during tenant setup,\n * so an already-provisioned tenant picks these up only after\n * `mercato customer_accounts sync-customer-role-acls`.\n * - `CustomerRbacService` caches ACLs for 5 minutes, so a fresh grant is not\n * immediately visible to a signed-in portal user.\n */\n defaultCustomerRoleFeatures: {\n buyer: ['portal.tasks.view', 'portal.tasks.complete'],\n viewer: ['portal.tasks.view'],\n },\n\n defaultRoleFeatures: {\n // `workflows.*` already grants every concrete id, including the spec \u00A78.4\n // recovery features `workflows.instances.rerun_step` and\n // `workflows.instances.bulk_ops`, the spec \u00A78.5 ops feature\n // `workflows.metrics.view`, and the execution-identity feature\n // `workflows.definitions.grant_features` (safe to hold under a wildcard: a\n // grant can never exceed the granting user's OWN current features, so an\n // admin can only delegate powers they already have). Existing tenants pick\n // up newly declared features only after `yarn mercato auth sync-role-acls`.\n admin: ['workflows.*'],\n employee: [\n 'workflows.view',\n 'workflows.view_tasks',\n 'workflows.tasks.view',\n 'workflows.tasks.claim',\n 'workflows.tasks.complete',\n 'workflows.instances.view',\n ],\n },\n}\n\nexport default setup\n"],
5
+ "mappings": "AAAA,SAAS,kBAAkB;AAE3B,SAAS,yCAAyC;AAClD,SAAS,oBAAoB;AAC7B,SAAS,4BAA4B;AACrC,SAAS,kDAAkD;AAE3D,MAAM,SAAS,aAAa,WAAW;AA4BvC,SAAS,mBAAmB,WAA2B;AACrD,QAAM,MAAM,WAAW,QAAQ,EAAE,OAAO,SAAS,EAAE,OAAO,KAAK;AAC/D,SAAO,GAAG,IAAI,MAAM,GAAG,CAAC,CAAC,IAAI,IAAI,MAAM,GAAG,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC;AAC9G;AAeA,eAAe,2BAA2B,KAIxB;AAChB,QAAM,SAAS,IAAI;AACnB,MAAI,OAAO,OAAO,oBAAoB,cAAc,CAAC,OAAO,gBAAgB,kBAAkB,EAAG;AACjG,MAAI;AACF,UAAM,mBAAmB,IAAI,UAAU,QAAQ,kBAAkB;AACjE,UAAM,iBAAiB,SAAS;AAAA,MAC9B,IAAI,mBAAmB,sCAAsC,IAAI,cAAc,EAAE;AAAA,MACjF,MAAM;AAAA,MACN,aACE;AAAA,MACF,WAAW;AAAA,MACX,gBAAgB,IAAI;AAAA,MACpB,UAAU,IAAI;AAAA,MACd,cAAc;AAAA,MACd,eAAe;AAAA,MACf,UAAU;AAAA,MACV,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,eAAe,EAAE,OAAO,EAAE,UAAU,IAAI,UAAU,gBAAgB,IAAI,eAAe,EAAE;AAAA,MACvF,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,WAAW;AAAA,IACb,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,KAAK,uEAAuE,EAAE,KAAK,MAAM,CAAC;AAAA,EACnG;AACF;AAEO,MAAM,QAA2B;AAAA,EACtC,cAAc,OAAO,QAAQ;AAC3B,UAAM,QAAQ,EAAE,UAAU,IAAI,UAAU,gBAAgB,IAAI,eAAe;AAC3E,UAAM,QAAQ,kCAAkC,IAAI,UAAU,QAAQ,KAAK,IAAI,SAAS,CAAC;AACzF,UAAM,qBAAqB,IAAI,IAAI,OAAO,EAAE,MAAM,CAAC;AACnD,UAAM,2BAA2B;AAAA,MAC/B,WAAW,IAAI;AAAA,MACf,UAAU,IAAI;AAAA,MACd,gBAAgB,IAAI;AAAA,IACtB,CAAC;AAAA,EACH;AAAA,EAEA,uBAAuB,OAAO,QAAQ;AACpC,UAAM,2BAA2B;AAAA,MAC/B,WAAW,IAAI;AAAA,MACf,UAAU,IAAI;AAAA,MACd,gBAAgB,IAAI;AAAA,IACtB,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAuBA,6BAA6B;AAAA,IAC3B,OAAO,CAAC,qBAAqB,uBAAuB;AAAA,IACpD,QAAQ,CAAC,mBAAmB;AAAA,EAC9B;AAAA,EAEA,qBAAqB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASnB,OAAO,CAAC,aAAa;AAAA,IACrB,UAAU;AAAA,MACR;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;AAEA,IAAO,gBAAQ;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7954.1.48da4a7d13",
3
+ "version": "0.10.1-develop.7972.1.03f813eaaf",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -266,16 +266,16 @@
266
266
  "zod": "^4.6.6"
267
267
  },
268
268
  "peerDependencies": {
269
- "@open-mercato/ai-assistant": "0.10.1-develop.7954.1.48da4a7d13",
270
- "@open-mercato/shared": "0.10.1-develop.7954.1.48da4a7d13",
271
- "@open-mercato/ui": "0.10.1-develop.7954.1.48da4a7d13",
269
+ "@open-mercato/ai-assistant": "0.10.1-develop.7972.1.03f813eaaf",
270
+ "@open-mercato/shared": "0.10.1-develop.7972.1.03f813eaaf",
271
+ "@open-mercato/ui": "0.10.1-develop.7972.1.03f813eaaf",
272
272
  "react": "^19.0.0",
273
273
  "react-dom": "^19.0.0"
274
274
  },
275
275
  "devDependencies": {
276
- "@open-mercato/ai-assistant": "0.10.1-develop.7954.1.48da4a7d13",
277
- "@open-mercato/shared": "0.10.1-develop.7954.1.48da4a7d13",
278
- "@open-mercato/ui": "0.10.1-develop.7954.1.48da4a7d13",
276
+ "@open-mercato/ai-assistant": "0.10.1-develop.7972.1.03f813eaaf",
277
+ "@open-mercato/shared": "0.10.1-develop.7972.1.03f813eaaf",
278
+ "@open-mercato/ui": "0.10.1-develop.7972.1.03f813eaaf",
279
279
  "@testing-library/dom": "^10.4.1",
280
280
  "@testing-library/jest-dom": "^7.0.1",
281
281
  "@testing-library/react": "^16.3.3",
@@ -55,7 +55,7 @@ function extractAuthTokenClaims(token: string | null): AuthTokenClaims {
55
55
  export async function POST(req: Request) {
56
56
  const sessToken = parseCookie(req, 'session_token')
57
57
  const authToken = parseCookie(req, 'auth_token')
58
- if (!sessToken && !authToken) return buildSafeRedirectResponse(req, '/login')
58
+ if (!sessToken && !authToken) return buildSafeRedirectResponse(req, '/login', 303)
59
59
  const claims = extractAuthTokenClaims(authToken)
60
60
  let sessionRevoked = false
61
61
  if (sessToken || claims.sessionId) {
@@ -83,7 +83,7 @@ export async function POST(req: Request) {
83
83
  at: new Date().toISOString(),
84
84
  }, { persistent: true }).catch(() => undefined)
85
85
  }
86
- const res = buildSafeRedirectResponse(req, '/login')
86
+ const res = buildSafeRedirectResponse(req, '/login', 303)
87
87
  res.cookies.set('auth_token', '', { path: '/', maxAge: 0 })
88
88
  res.cookies.set('session_token', '', { path: '/', maxAge: 0 })
89
89
  res.cookies.set('om_selected_org', '', { path: '/', maxAge: 0 })
@@ -103,7 +103,7 @@ export const openApi: OpenApiRouteDoc = {
103
103
  summary: 'Invalidate session and redirect',
104
104
  description: 'Revokes the session identified by the `auth_token` sid claim and the `session_token` cookie, clears both cookies together with the organization-scope cookies (`om_selected_org`, `om_selected_tenant`), and redirects the browser to the login page. A valid access token is not required, so a logout submitted after the access token expired still revokes the remember-me refresh session. A request that carries neither session cookie is only redirected.',
105
105
  responses: [
106
- { status: 307, description: 'Redirect to login after successful logout', mediaType: 'text/html' },
106
+ { status: 303, description: 'See Other: navigate to login with GET after logout', mediaType: 'text/html' },
107
107
  ],
108
108
  },
109
109
  },
@@ -415,41 +415,52 @@ const updateRoleCommand: CommandHandler<Record<string, unknown>, Role> = {
415
415
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
416
416
  const redoLogEntry = (ctx as RoleReplayRuntimeContext)[ROLE_REDO_LOG]
417
417
  let role: Role | null = null
418
- await withAtomicFlush(em, [async () => {
419
- if (redoLogEntry) {
420
- await authorizeRoleReplayAtMutation('update', {
421
- operation: 'redo',
422
- input: rawInput,
423
- ctx,
424
- logEntry: redoLogEntry,
418
+ try {
419
+ await withAtomicFlush(em, [async () => {
420
+ if (redoLogEntry) {
421
+ await authorizeRoleReplayAtMutation('update', {
422
+ operation: 'redo',
423
+ input: rawInput,
424
+ ctx,
425
+ logEntry: redoLogEntry,
426
+ })
427
+ }
428
+ await lockRoleWriterAuthorizationState(em, [parsed.id])
429
+ if (wantsTenantChange) {
430
+ await em.nativeDelete(RoleAcl, { role: parsed.id as unknown as Role })
431
+ }
432
+ role = await de.updateOrmEntity({
433
+ entity: Role,
434
+ where: buildScopedRoleFilter(parsed.id, scope),
435
+ apply: (entity) => {
436
+ if (parsed.name !== undefined) entity.name = parsed.name
437
+ if (parsed.tenantId !== undefined && scope.isSuperAdmin) entity.tenantId = parsed.tenantId
438
+ },
425
439
  })
426
- }
427
- await lockRoleWriterAuthorizationState(em, [parsed.id])
428
- if (wantsTenantChange) {
429
- await em.nativeDelete(RoleAcl, { role: parsed.id as unknown as Role })
430
- }
431
- role = await de.updateOrmEntity({
432
- entity: Role,
433
- where: buildScopedRoleFilter(parsed.id, scope),
434
- apply: (entity) => {
435
- if (parsed.name !== undefined) entity.name = parsed.name
436
- if (parsed.tenantId !== undefined && scope.isSuperAdmin) entity.tenantId = parsed.tenantId
437
- },
438
- })
439
- if (!role) throw new CrudHttpError(404, { error: 'Role not found' })
440
+ if (!role) throw new CrudHttpError(404, { error: 'Role not found' })
440
441
 
441
- await setCustomFieldsIfAny({
442
- dataEngine: de,
443
- entityId: E.auth.role,
444
- recordId: String(role.id),
445
- organizationId: null,
446
- tenantId: role.tenantId ? String(role.tenantId) : null,
447
- values: custom,
442
+ await setCustomFieldsIfAny({
443
+ dataEngine: de,
444
+ entityId: E.auth.role,
445
+ recordId: String(role.id),
446
+ organizationId: null,
447
+ tenantId: role.tenantId ? String(role.tenantId) : null,
448
+ values: custom,
449
+ })
450
+ }], {
451
+ transaction: true,
452
+ label: redoLogEntry ? 'auth.roles.update.redo' : 'auth.roles.update',
448
453
  })
449
- }], {
450
- transaction: true,
451
- label: redoLogEntry ? 'auth.roles.update.redo' : 'auth.roles.update',
452
- })
454
+ } catch (error) {
455
+ if (!isUniqueViolation(error, 'roles_tenant_id_name_unique')) throw error
456
+ const { translate } = await resolveTranslations()
457
+ const message = translate('auth.roles.errors.nameExists', 'A role with this name already exists')
458
+ throw new CrudHttpError(400, {
459
+ error: message,
460
+ fieldErrors: { name: message },
461
+ details: [{ path: ['name'], message, code: 'duplicate', origin: 'validation' }],
462
+ })
463
+ }
453
464
  const updatedRole = role as Role | null
454
465
  if (!updatedRole) throw new CrudHttpError(404, { error: 'Role not found' })
455
466
 
@@ -71,6 +71,60 @@ function stablePollTickScheduleId(organizationId: string): string {
71
71
  return stableScheduleUuid(`communication_channels:poll-tick:${organizationId}`)
72
72
  }
73
73
 
74
+ async function ensureCommunicationChannelSchedules(ctx: {
75
+ container: import('awilix').AwilixContainer
76
+ tenantId: string
77
+ organizationId: string
78
+ }): Promise<void> {
79
+ const cradle = ctx.container as { hasRegistration?: (name: string) => boolean }
80
+ if (typeof cradle.hasRegistration !== 'function' || !cradle.hasRegistration('schedulerService')) return
81
+
82
+ const schedulerService = ctx.container.resolve('schedulerService') as SchedulerServiceLike
83
+ try {
84
+ await schedulerService.register({
85
+ id: stablePollTickScheduleId(ctx.organizationId),
86
+ name: 'Communication channels poll tick',
87
+ description:
88
+ `Enumerates active polling channels every ${POLL_TICK_INTERVAL_SECONDS}s and enqueues per-channel poll jobs.`,
89
+ scopeType: 'organization',
90
+ organizationId: ctx.organizationId,
91
+ tenantId: ctx.tenantId,
92
+ scheduleType: 'interval',
93
+ scheduleValue: `${POLL_TICK_INTERVAL_SECONDS}s`,
94
+ timezone: 'UTC',
95
+ targetType: 'queue',
96
+ targetQueue: COMMUNICATION_CHANNELS_QUEUES.pollTick,
97
+ targetPayload: {
98
+ scope: { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
99
+ },
100
+ sourceType: 'module',
101
+ sourceModule: 'communication_channels',
102
+ isEnabled: true,
103
+ })
104
+
105
+ await schedulerService.register({
106
+ id: stableScheduleUuid(`communication_channels:gmail-renew-watch:${ctx.organizationId}`),
107
+ name: 'Gmail watch renewal',
108
+ description:
109
+ 'Daily 04:00 UTC. Re-issues gmail.users.watch for channels within OM_PUSH_RENEWAL_GMAIL_LEAD_HOURS of expiry.',
110
+ scopeType: 'organization',
111
+ organizationId: ctx.organizationId,
112
+ tenantId: ctx.tenantId,
113
+ scheduleType: 'cron',
114
+ scheduleValue: '0 4 * * *',
115
+ timezone: 'UTC',
116
+ targetType: 'queue',
117
+ targetQueue: COMMUNICATION_CHANNELS_QUEUES.gmailRenewWatch,
118
+ targetPayload: { scope: { tenantId: ctx.tenantId, organizationId: ctx.organizationId } },
119
+ sourceType: 'module',
120
+ sourceModule: 'communication_channels',
121
+ isEnabled: true,
122
+ })
123
+ } catch (error) {
124
+ logger.warn('Failed to register module schedules', { err: error })
125
+ }
126
+ }
127
+
74
128
  export const setup: ModuleSetupConfig = {
75
129
  defaultRoleFeatures: {
76
130
  superadmin: [
@@ -195,59 +249,11 @@ export const setup: ModuleSetupConfig = {
195
249
  * scheduler module isn't enabled — keeps the hub usable in scheduler-less
196
250
  * test harnesses.
197
251
  */
198
- const cradle = container as { hasRegistration?: (name: string) => boolean }
199
- if (typeof cradle.hasRegistration !== 'function' || !cradle.hasRegistration('schedulerService')) {
200
- return
201
- }
202
- const schedulerService = container.resolve('schedulerService') as SchedulerServiceLike
203
- // Best-effort: a scheduler failure must not abort tenant initialization for
204
- // every other module (mirrors the ai_assistant setup pattern). The schedule
205
- // ids are deterministic uuids so re-runs upsert idempotently.
206
- try {
207
- await schedulerService.register({
208
- id: stablePollTickScheduleId(organizationId),
209
- name: 'Communication channels poll tick',
210
- description:
211
- `Enumerates active polling channels every ${POLL_TICK_INTERVAL_SECONDS}s and enqueues per-channel poll jobs.`,
212
- scopeType: 'organization',
213
- organizationId,
214
- tenantId,
215
- scheduleType: 'interval',
216
- scheduleValue: `${POLL_TICK_INTERVAL_SECONDS}s`,
217
- timezone: 'UTC',
218
- targetType: 'queue',
219
- targetQueue: COMMUNICATION_CHANNELS_QUEUES.pollTick,
220
- targetPayload: {
221
- scope: { tenantId, organizationId },
222
- },
223
- sourceType: 'module',
224
- sourceModule: 'communication_channels',
225
- isEnabled: true,
226
- })
227
-
228
- // Spec C § Phase C4 — Gmail watch renewal cron, per-org so multi-tenant
229
- // deploys schedule independently.
230
- await schedulerService.register({
231
- id: stableScheduleUuid(`communication_channels:gmail-renew-watch:${organizationId}`),
232
- name: 'Gmail watch renewal',
233
- description:
234
- 'Daily 04:00 UTC. Re-issues gmail.users.watch for channels within OM_PUSH_RENEWAL_GMAIL_LEAD_HOURS of expiry.',
235
- scopeType: 'organization',
236
- organizationId,
237
- tenantId,
238
- scheduleType: 'cron',
239
- scheduleValue: '0 4 * * *',
240
- timezone: 'UTC',
241
- targetType: 'queue',
242
- targetQueue: COMMUNICATION_CHANNELS_QUEUES.gmailRenewWatch,
243
- targetPayload: { scope: { tenantId, organizationId } },
244
- sourceType: 'module',
245
- sourceModule: 'communication_channels',
246
- isEnabled: true,
247
- })
248
- } catch (error) {
249
- logger.warn('Failed to register module schedules', { err: error })
250
- }
252
+ await ensureCommunicationChannelSchedules({ container, tenantId, organizationId })
253
+ },
254
+
255
+ async onOrganizationCreated({ container, tenantId, organizationId }) {
256
+ await ensureCommunicationChannelSchedules({ container, tenantId, organizationId })
251
257
  },
252
258
  }
253
259
 
@@ -1,5 +1,8 @@
1
1
  import { Resolver, promises as dnsPromises } from 'node:dns'
2
2
  import { request as httpsRequest } from 'node:https'
3
+ import type { ClientRequest } from 'node:http'
4
+ import { resolveSafeHostAddress } from '@open-mercato/shared/lib/host-pinning'
5
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
3
6
  import { setTimeout as delay } from 'node:timers/promises'
4
7
  import { EntityManager } from '@mikro-orm/postgresql'
5
8
  import { runWithCacheTenant } from '@open-mercato/cache'
@@ -99,36 +102,61 @@ const defaultDnsResolver: DnsResolverContract = {
99
102
 
100
103
  const defaultHealthCheck: HealthCheckContract = (hostname, timeoutMs) =>
101
104
  new Promise((resolve) => {
102
- const headerName = (process.env.CUSTOMER_DOMAIN_ORIGIN_HEADER ?? 'X-Open-Mercato-Origin').toLowerCase()
103
- const req = httpsRequest(
104
- {
105
- host: hostname,
106
- port: 443,
107
- path: '/api/customer_accounts/domain-check',
108
- method: 'GET',
109
- headers: { 'X-Domain-Check-Secret': process.env.DOMAIN_CHECK_SECRET ?? '' },
110
- timeout: timeoutMs,
111
- },
112
- (res) => {
113
- const headerValue = res.headers[headerName]
114
- const originHeader = Array.isArray(headerValue) ? headerValue[0] : headerValue
115
- const originHeaderPresent = typeof originHeader === 'string' && originHeader === '1'
116
- const status = res.statusCode ?? 0
117
- res.resume() // discard body
118
- resolve({
119
- ok: status >= 200 && status < 400,
120
- originHeaderPresent,
121
- reason: status >= 200 && status < 400 ? undefined : `HTTP ${status}`,
122
- })
123
- },
124
- )
125
- req.on('timeout', () => {
126
- req.destroy(new Error('TLS health check timed out'))
127
- })
128
- req.on('error', (err) => {
129
- resolve({ ok: false, originHeaderPresent: false, reason: (err as Error).message })
105
+ let req: ClientRequest | undefined
106
+ let settled = false
107
+ const failure = { ok: false, originHeaderPresent: false, reason: 'TLS health check failed' }
108
+ const finish = (result: Awaited<ReturnType<HealthCheckContract>>) => {
109
+ if (settled) return
110
+ settled = true
111
+ clearTimeout(deadline)
112
+ resolve(result)
113
+ }
114
+ const deadline = setTimeout(() => {
115
+ finish(failure)
116
+ req?.destroy()
117
+ }, timeoutMs)
118
+
119
+ void resolveSafeHostAddress(hostname).then(({ host }) => {
120
+ if (settled) return
121
+ const headerName = (process.env.CUSTOMER_DOMAIN_ORIGIN_HEADER ?? 'X-Open-Mercato-Origin').toLowerCase()
122
+ req = httpsRequest(
123
+ {
124
+ host,
125
+ servername: hostname,
126
+ rejectUnauthorized: true,
127
+ agent: false,
128
+ port: 443,
129
+ path: '/api/customer_accounts/domain-check',
130
+ method: 'GET',
131
+ headers: { Host: hostname, 'X-Domain-Check-Secret': process.env.DOMAIN_CHECK_SECRET ?? '' },
132
+ timeout: timeoutMs,
133
+ },
134
+ (res) => {
135
+ const headerValue = res.headers[headerName]
136
+ const originHeader = Array.isArray(headerValue) ? headerValue[0] : headerValue
137
+ const originHeaderPresent = typeof originHeader === 'string' && originHeader === '1'
138
+ const status = res.statusCode ?? 0
139
+ res.resume()
140
+ finish({
141
+ ok: status >= 200 && status < 400,
142
+ originHeaderPresent,
143
+ reason: status >= 200 && status < 400 ? undefined : `HTTP ${status}`,
144
+ })
145
+ },
146
+ )
147
+ req.on('timeout', () => {
148
+ finish(failure)
149
+ req?.destroy()
150
+ })
151
+ req.on('error', () => finish(failure))
152
+ req.end()
153
+ }).catch(() => {
154
+ getTelemetryRuntime()?.reportError(new Error('[internal] Domain TLS health check failed'), {
155
+ module: 'customer_accounts',
156
+ code: 'customer_accounts.domain_health_check_failed',
157
+ })
158
+ finish(failure)
130
159
  })
131
- req.end()
132
160
  })
133
161
 
134
162
  export class DomainMappingService {
@@ -94,8 +94,12 @@ function sortTimelineActivities(items: InteractionSummary[]): InteractionSummary
94
94
  return leftScheduled - rightScheduled
95
95
  }
96
96
 
97
- const leftTime = left.occurredAt ?? left.createdAt
98
- const rightTime = right.occurredAt ?? right.createdAt
97
+ const leftTime = left.occurredAt ?? left.scheduledAt ?? null
98
+ const rightTime = right.occurredAt ?? right.scheduledAt ?? null
99
+ if (!leftTime || !rightTime) {
100
+ if (leftTime !== rightTime) return leftTime ? -1 : 1
101
+ return right.id.localeCompare(left.id)
102
+ }
99
103
  const compare = rightTime.localeCompare(leftTime)
100
104
  if (compare !== 0) return compare
101
105
  return right.id.localeCompare(left.id)
@@ -242,7 +246,8 @@ export function ActivitiesSection({
242
246
  }
243
247
  const legacyFiltered = legacyItems.filter((entry) => {
244
248
  if (filterTypes.length > 0 && !filterTypes.includes(entry.interactionType)) return false
245
- const dateOnly = toDateOnly(entry.occurredAt ?? entry.createdAt)
249
+ const dateOnly = toDateOnly(entry.occurredAt ?? entry.scheduledAt)
250
+ if ((filterDateFrom || filterDateTo) && !dateOnly) return false
246
251
  if (filterDateFrom && dateOnly < filterDateFrom) return false
247
252
  if (filterDateTo && dateOnly > filterDateTo) return false
248
253
  return true
@@ -116,7 +116,7 @@ function buildEmailCardWidgetData(activity: InteractionSummary): EmailCardWidget
116
116
 
117
117
  export function ActivityCard({ activity, onOpen, onChanged, runMutation }: ActivityCardProps) {
118
118
  const t = useT()
119
- const timestamp = activity.occurredAt ?? activity.scheduledAt ?? activity.createdAt
119
+ const timestamp = activity.occurredAt ?? activity.scheduledAt
120
120
  const TypeIcon = TYPE_ICONS[activity.interactionType] ?? StickyNote
121
121
  const titleBase = activity.title ?? activity.body ?? activity.interactionType
122
122
  const title = activity.duration ? `${titleBase} (${activity.duration} min)` : titleBase
@@ -179,8 +179,8 @@ export function ActivityCard({ activity, onOpen, onChanged, runMutation }: Activ
179
179
  } : undefined}
180
180
  >
181
181
  <div className="pt-1 text-xs text-muted-foreground">
182
- <div className="font-semibold text-foreground">{formatDayLabel(timestamp, t)}</div>
183
- <div>{formatTimeLabel(timestamp)}</div>
182
+ <div className="font-semibold text-foreground">{timestamp ? formatDayLabel(timestamp, t) : t('customers.timeline.date.unknown', 'Unknown date')}</div>
183
+ <div>{timestamp ? formatTimeLabel(timestamp) : null}</div>
184
184
  </div>
185
185
 
186
186
  <div className="flex size-9 items-center justify-center rounded-lg bg-muted/80">