@open-mercato/core 0.10.1-develop.7961.1.d65694a8e6 → 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 (85) 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/communication_channels/setup.js +49 -45
  5. package/dist/modules/communication_channels/setup.js.map +2 -2
  6. package/dist/modules/customer_accounts/services/domainMappingService.js +55 -29
  7. package/dist/modules/customer_accounts/services/domainMappingService.js.map +2 -2
  8. package/dist/modules/customers/components/detail/ActivitiesSection.js +8 -3
  9. package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
  10. package/dist/modules/customers/components/detail/ActivityCard.js +3 -3
  11. package/dist/modules/customers/components/detail/ActivityCard.js.map +2 -2
  12. package/dist/modules/customers/components/detail/ActivityHistorySection.js +19 -7
  13. package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
  14. package/dist/modules/customers/components/detail/ActivityTimeline.js +5 -5
  15. package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
  16. package/dist/modules/directory/subscribers/seedOrganizationDefaults.js +67 -0
  17. package/dist/modules/directory/subscribers/seedOrganizationDefaults.js.map +7 -0
  18. package/dist/modules/ecommerce/api/storefront/categories/[slug]/route.js +1 -1
  19. package/dist/modules/ecommerce/api/storefront/categories/[slug]/route.js.map +2 -2
  20. package/dist/modules/ecommerce/api/storefront/categories/route.js +1 -1
  21. package/dist/modules/ecommerce/api/storefront/categories/route.js.map +2 -2
  22. package/dist/modules/ecommerce/api/storefront/context/route.js +1 -1
  23. package/dist/modules/ecommerce/api/storefront/context/route.js.map +2 -2
  24. package/dist/modules/ecommerce/api/storefront/products/[idOrHandle]/route.js +1 -1
  25. package/dist/modules/ecommerce/api/storefront/products/[idOrHandle]/route.js.map +2 -2
  26. package/dist/modules/ecommerce/api/storefront/products/route.js +1 -1
  27. package/dist/modules/ecommerce/api/storefront/products/route.js.map +2 -2
  28. package/dist/modules/ecommerce/api/storefront/search/suggest/route.js +1 -1
  29. package/dist/modules/ecommerce/api/storefront/search/suggest/route.js.map +2 -2
  30. package/dist/modules/ecommerce/lib/cacheInvalidation.js +3 -0
  31. package/dist/modules/ecommerce/lib/cacheInvalidation.js.map +2 -2
  32. package/dist/modules/ecommerce/lib/cacheKeys.js +4 -0
  33. package/dist/modules/ecommerce/lib/cacheKeys.js.map +2 -2
  34. package/dist/modules/ecommerce/lib/storeContext.js +36 -12
  35. package/dist/modules/ecommerce/lib/storeContext.js.map +2 -2
  36. package/dist/modules/ecommerce/subscribers/sales-channel-cache-invalidation.js +14 -0
  37. package/dist/modules/ecommerce/subscribers/sales-channel-cache-invalidation.js.map +7 -0
  38. package/dist/modules/integrations/setup.js +34 -24
  39. package/dist/modules/integrations/setup.js.map +2 -2
  40. package/dist/modules/messages/components/MessagesInboxPageClient.js +1 -1
  41. package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
  42. package/dist/modules/messages/components/message-detail/panels/MainMessageHeader.js +3 -3
  43. package/dist/modules/messages/components/message-detail/panels/MainMessageHeader.js.map +2 -2
  44. package/dist/modules/messages/components/message-detail/panels/body-panel.js +2 -2
  45. package/dist/modules/messages/components/message-detail/panels/body-panel.js.map +2 -2
  46. package/dist/modules/payment_gateways/setup.js +3 -0
  47. package/dist/modules/payment_gateways/setup.js.map +2 -2
  48. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js +30 -11
  49. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js.map +3 -3
  50. package/dist/modules/warranty_claims/setup.js +34 -30
  51. package/dist/modules/warranty_claims/setup.js.map +2 -2
  52. package/dist/modules/workflows/setup.js +7 -0
  53. package/dist/modules/workflows/setup.js.map +2 -2
  54. package/package.json +7 -7
  55. package/src/modules/auth/api/logout.ts +3 -3
  56. package/src/modules/communication_channels/setup.ts +59 -53
  57. package/src/modules/customer_accounts/services/domainMappingService.ts +57 -29
  58. package/src/modules/customers/components/detail/ActivitiesSection.tsx +8 -3
  59. package/src/modules/customers/components/detail/ActivityCard.tsx +3 -3
  60. package/src/modules/customers/components/detail/ActivityHistorySection.tsx +20 -8
  61. package/src/modules/customers/components/detail/ActivityTimeline.tsx +7 -7
  62. package/src/modules/customers/i18n/de.json +1 -0
  63. package/src/modules/customers/i18n/en.json +1 -0
  64. package/src/modules/customers/i18n/es.json +1 -0
  65. package/src/modules/customers/i18n/ko.json +1 -0
  66. package/src/modules/customers/i18n/pl.json +1 -0
  67. package/src/modules/directory/subscribers/seedOrganizationDefaults.ts +84 -0
  68. package/src/modules/ecommerce/api/storefront/categories/[slug]/route.ts +1 -1
  69. package/src/modules/ecommerce/api/storefront/categories/route.ts +1 -1
  70. package/src/modules/ecommerce/api/storefront/context/route.ts +1 -1
  71. package/src/modules/ecommerce/api/storefront/products/[idOrHandle]/route.ts +1 -1
  72. package/src/modules/ecommerce/api/storefront/products/route.ts +1 -1
  73. package/src/modules/ecommerce/api/storefront/search/suggest/route.ts +1 -1
  74. package/src/modules/ecommerce/lib/cacheInvalidation.ts +4 -0
  75. package/src/modules/ecommerce/lib/cacheKeys.ts +4 -0
  76. package/src/modules/ecommerce/lib/storeContext.ts +56 -10
  77. package/src/modules/ecommerce/subscribers/sales-channel-cache-invalidation.ts +12 -0
  78. package/src/modules/integrations/setup.ts +41 -24
  79. package/src/modules/messages/components/MessagesInboxPageClient.tsx +1 -1
  80. package/src/modules/messages/components/message-detail/panels/MainMessageHeader.tsx +3 -3
  81. package/src/modules/messages/components/message-detail/panels/body-panel.tsx +2 -2
  82. package/src/modules/payment_gateways/setup.ts +4 -0
  83. package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.tsx +55 -28
  84. package/src/modules/warranty_claims/setup.ts +40 -30
  85. 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": "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",
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.7961.1.d65694a8e6",
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.7961.1.d65694a8e6",
270
- "@open-mercato/shared": "0.10.1-develop.7961.1.d65694a8e6",
271
- "@open-mercato/ui": "0.10.1-develop.7961.1.d65694a8e6",
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.7961.1.d65694a8e6",
277
- "@open-mercato/shared": "0.10.1-develop.7961.1.d65694a8e6",
278
- "@open-mercato/ui": "0.10.1-develop.7961.1.d65694a8e6",
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
  },
@@ -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">
@@ -73,8 +73,8 @@ function computeRangeStart(range: '7d' | '30d' | '90d'): Date {
73
73
  return date
74
74
  }
75
75
 
76
- function toTimelineTimestamp(activity: InteractionSummary): string {
77
- return activity.occurredAt ?? activity.scheduledAt ?? activity.createdAt
76
+ function toTimelineTimestamp(activity: InteractionSummary): string | null {
77
+ return activity.occurredAt ?? activity.scheduledAt ?? null
78
78
  }
79
79
 
80
80
  function normalizeLegacyActivity(activity: ActivitySummary): InteractionSummary {
@@ -116,9 +116,17 @@ function sortTimelineActivities(items: InteractionSummary[]): InteractionSummary
116
116
  const rightIsUpcoming = rightIsPlanned && rightScheduled >= now
117
117
 
118
118
  if (leftIsUpcoming !== rightIsUpcoming) return leftIsUpcoming ? -1 : 1
119
- if (leftIsUpcoming && rightIsUpcoming) return leftScheduled - rightScheduled
119
+ if (leftIsUpcoming && rightIsUpcoming) {
120
+ return leftScheduled - rightScheduled || left.id.localeCompare(right.id)
121
+ }
120
122
 
121
- const compare = toTimelineTimestamp(right).localeCompare(toTimelineTimestamp(left))
123
+ const leftTime = toTimelineTimestamp(left)
124
+ const rightTime = toTimelineTimestamp(right)
125
+ if (!leftTime || !rightTime) {
126
+ if (leftTime !== rightTime) return leftTime ? -1 : 1
127
+ return right.id.localeCompare(left.id)
128
+ }
129
+ const compare = rightTime.localeCompare(leftTime)
122
130
  if (compare !== 0) return compare
123
131
  return right.id.localeCompare(left.id)
124
132
  })
@@ -152,7 +160,9 @@ function matchesSearch(activity: InteractionSummary, query: string): boolean {
152
160
  }
153
161
 
154
162
  function isWithinRange(activity: InteractionSummary, start: Date): boolean {
155
- const timestamp = new Date(toTimelineTimestamp(activity))
163
+ const value = toTimelineTimestamp(activity)
164
+ if (!value) return false
165
+ const timestamp = new Date(value)
156
166
  if (Number.isNaN(timestamp.getTime())) return false
157
167
  return timestamp >= start
158
168
  }
@@ -451,9 +461,11 @@ export function ActivityHistorySection({
451
461
  ) : (
452
462
  <div className="space-y-4">
453
463
  {activities.map((activity, index) => {
454
- const currentYear = new Date(toTimelineTimestamp(activity)).getFullYear()
455
- const previousYear = index > 0 ? new Date(toTimelineTimestamp(activities[index - 1])).getFullYear() : null
456
- const showYearSeparator = previousYear !== null && currentYear !== previousYear
464
+ const currentDate = toTimelineTimestamp(activity)
465
+ const previousDate = index > 0 ? toTimelineTimestamp(activities[index - 1]) : null
466
+ const currentYear = currentDate ? new Date(currentDate).getFullYear() : null
467
+ const previousYear = previousDate ? new Date(previousDate).getFullYear() : null
468
+ const showYearSeparator = currentYear !== null && previousYear !== null && currentYear !== previousYear
457
469
  return (
458
470
  <React.Fragment key={activity.id}>
459
471
  {showYearSeparator ? (
@@ -38,9 +38,9 @@ export function ActivityTimeline({ activities, onEdit, onMarkDone }: ActivityTim
38
38
  return (
39
39
  <div>
40
40
  {activities.map((activity, index) => {
41
- const dateStr = activity.scheduledAt ?? activity.occurredAt ?? activity.createdAt
41
+ const dateStr = activity.scheduledAt ?? activity.occurredAt
42
42
  const activityYear = dateStr ? new Date(dateStr).getFullYear() : null
43
- const prevDateStr = index > 0 ? (activities[index - 1].scheduledAt ?? activities[index - 1].occurredAt ?? activities[index - 1].createdAt) : null
43
+ const prevDateStr = index > 0 ? (activities[index - 1].scheduledAt ?? activities[index - 1].occurredAt) : null
44
44
  const prevYear = prevDateStr ? new Date(prevDateStr).getFullYear() : null
45
45
  const showYearSeparator = activityYear !== null && prevYear !== null && activityYear !== prevYear
46
46
 
@@ -82,7 +82,7 @@ function TimelineEntry({
82
82
  onEdit?: (activity: InteractionSummary) => void
83
83
  onMarkDone?: (activityId: string, updatedAt?: string | null) => void | Promise<void>
84
84
  }) {
85
- const dateStr = activity.scheduledAt ?? activity.occurredAt ?? activity.createdAt
85
+ const dateStr = activity.scheduledAt ?? activity.occurredAt
86
86
  const TypeIcon = TYPE_ICONS[activity.interactionType]
87
87
  const title = activity.title ?? activity.body ?? activity.interactionType
88
88
  const duration = activity.duration ? ` (${activity.duration} min)` : ''
@@ -112,11 +112,11 @@ function TimelineEntry({
112
112
  <div className="grid items-start gap-3" style={{ gridTemplateColumns: '75px 32px 1fr' }}>
113
113
  {/* Column 1: Date */}
114
114
  <div className="shrink-0">
115
- <span className="block text-[11px] font-semibold leading-tight text-foreground">
116
- {formatRelativeDate(dateStr, t)}
115
+ <span className="block text-xs font-semibold leading-tight text-foreground">
116
+ {dateStr ? formatRelativeDate(dateStr, t) : t('customers.timeline.date.unknown', 'Unknown date')}
117
117
  </span>
118
- <span className="block text-[10px] leading-tight text-muted-foreground">
119
- {formatTime(dateStr)}
118
+ <span className="block text-xs leading-tight text-muted-foreground">
119
+ {dateStr ? formatTime(dateStr) : null}
120
120
  </span>
121
121
  </div>
122
122
 
@@ -3012,6 +3012,7 @@
3012
3012
  "customers.timeline.author": "by {{name}}",
3013
3013
  "customers.timeline.date.today": "today",
3014
3014
  "customers.timeline.date.tomorrow": "tomorrow",
3015
+ "customers.timeline.date.unknown": "Unbekanntes Datum",
3015
3016
  "customers.timeline.date.yesterday": "yesterday",
3016
3017
  "customers.timeline.edit": "Edit",
3017
3018
  "customers.timeline.empty": "No activities match the current filters.",
@@ -3012,6 +3012,7 @@
3012
3012
  "customers.timeline.author": "by {{name}}",
3013
3013
  "customers.timeline.date.today": "today",
3014
3014
  "customers.timeline.date.tomorrow": "tomorrow",
3015
+ "customers.timeline.date.unknown": "Unknown date",
3015
3016
  "customers.timeline.date.yesterday": "yesterday",
3016
3017
  "customers.timeline.edit": "Edit",
3017
3018
  "customers.timeline.empty": "No activities match the current filters.",