@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
@@ -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.",
@@ -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": "Fecha desconocida",
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": "{{name}} 작성",
3013
3013
  "customers.timeline.date.today": "오늘",
3014
3014
  "customers.timeline.date.tomorrow": "내일",
3015
+ "customers.timeline.date.unknown": "날짜 알 수 없음",
3015
3016
  "customers.timeline.date.yesterday": "어제",
3016
3017
  "customers.timeline.edit": "편집",
3017
3018
  "customers.timeline.empty": "현재 필터와 일치하는 활동이 없습니다.",
@@ -3012,6 +3012,7 @@
3012
3012
  "customers.timeline.author": "przez {{name}}",
3013
3013
  "customers.timeline.date.today": "dziś",
3014
3014
  "customers.timeline.date.tomorrow": "jutro",
3015
+ "customers.timeline.date.unknown": "Nieznana data",
3015
3016
  "customers.timeline.date.yesterday": "wczoraj",
3016
3017
  "customers.timeline.edit": "Edytuj",
3017
3018
  "customers.timeline.empty": "Brak aktywności pasujących do filtrów.",
@@ -0,0 +1,84 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { AwilixContainer } from 'awilix'
3
+ import { createLogger } from '@open-mercato/shared/lib/logger'
4
+ import { getModules } from '@open-mercato/shared/lib/modules/registry'
5
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
6
+
7
+ const logger = createLogger('directory')
8
+
9
+ type OrganizationCreatedContext = {
10
+ resolve: <T = unknown>(name: string) => T
11
+ tenantId?: string | null
12
+ organizationId?: string | null
13
+ }
14
+
15
+ function createSeedContainer(
16
+ ctx: OrganizationCreatedContext,
17
+ em: EntityManager,
18
+ ): AwilixContainer {
19
+ const resolve = <T = unknown>(name: string): T => {
20
+ if (name === 'em') return em as T
21
+ return ctx.resolve<T>(name)
22
+ }
23
+
24
+ return {
25
+ resolve,
26
+ hasRegistration: (name: string) => {
27
+ try {
28
+ resolve(name)
29
+ return true
30
+ } catch {
31
+ return false
32
+ }
33
+ },
34
+ } as unknown as AwilixContainer
35
+ }
36
+
37
+ export const metadata = {
38
+ event: 'directory.organization.created',
39
+ persistent: true,
40
+ id: 'directory:seed-organization-defaults',
41
+ }
42
+
43
+ export default async function seedOrganizationDefaults(
44
+ _payload: unknown,
45
+ ctx: OrganizationCreatedContext,
46
+ ): Promise<void> {
47
+ const tenantId = typeof ctx.tenantId === 'string' ? ctx.tenantId : null
48
+ const organizationId = typeof ctx.organizationId === 'string' ? ctx.organizationId : null
49
+ if (!tenantId || !organizationId) return
50
+
51
+ const rootEm = ctx.resolve<EntityManager>('em')
52
+ const failures: unknown[] = []
53
+ for (const moduleEntry of getModules()) {
54
+ const onOrganizationCreated = moduleEntry.setup?.onOrganizationCreated
55
+ if (!onOrganizationCreated) continue
56
+
57
+ const em = rootEm.fork()
58
+ try {
59
+ await onOrganizationCreated({
60
+ em,
61
+ tenantId,
62
+ organizationId,
63
+ container: createSeedContainer(ctx, em),
64
+ })
65
+ } catch (error) {
66
+ failures.push(error)
67
+ logger.error('Organization defaults provisioning failed', {
68
+ moduleId: moduleEntry.id,
69
+ tenantId,
70
+ organizationId,
71
+ err: error,
72
+ })
73
+ getTelemetryRuntime()?.reportError(error, {
74
+ module: 'directory',
75
+ code: 'directory.organization_defaults_provisioning_failed',
76
+ attributes: { moduleId: moduleEntry.id, tenantId, organizationId },
77
+ })
78
+ }
79
+ }
80
+
81
+ if (failures.length > 0) {
82
+ throw new AggregateError(failures, 'One or more organization default hooks failed')
83
+ }
84
+ }
@@ -81,7 +81,7 @@ const storefrontCategoryGetDoc: OpenApiMethodDoc = {
81
81
  { status: 404, description: 'No store serves this host or path, or the category is not visible to this buyer', schema: storefrontErrorSchema },
82
82
  { status: 410, description: 'Store archived', schema: storefrontErrorSchema },
83
83
  { status: 429, description: 'Too many requests', schema: rateLimitErrorSchema },
84
- { status: 503, description: 'Store misconfigured (no default channel binding)', schema: storefrontErrorSchema },
84
+ { status: 503, description: 'Store misconfigured (no usable default channel binding)', schema: storefrontErrorSchema },
85
85
  ],
86
86
  }
87
87
 
@@ -65,7 +65,7 @@ const storefrontCategoriesGetDoc: OpenApiMethodDoc = {
65
65
  { status: 404, description: 'No store serves this host or path', schema: storefrontErrorSchema },
66
66
  { status: 410, description: 'Store archived', schema: storefrontErrorSchema },
67
67
  { status: 429, description: 'Too many requests', schema: rateLimitErrorSchema },
68
- { status: 503, description: 'Store misconfigured (no default channel binding)', schema: storefrontErrorSchema },
68
+ { status: 503, description: 'Store misconfigured (no usable default channel binding)', schema: storefrontErrorSchema },
69
69
  ],
70
70
  }
71
71
 
@@ -190,7 +190,7 @@ const storefrontContextGetDoc: OpenApiMethodDoc = {
190
190
  { status: 404, description: 'No store serves this host or path', schema: storefrontErrorSchema },
191
191
  { status: 410, description: 'Store archived', schema: storefrontErrorSchema },
192
192
  { status: 429, description: 'Too many requests', schema: rateLimitErrorSchema },
193
- { status: 503, description: 'Store misconfigured (no default channel binding)', schema: storefrontErrorSchema },
193
+ { status: 503, description: 'Store misconfigured (no usable default channel binding)', schema: storefrontErrorSchema },
194
194
  ],
195
195
  }
196
196
 
@@ -81,7 +81,7 @@ const storefrontProductGetDoc: OpenApiMethodDoc = {
81
81
  { status: 404, description: 'No store serves this host or path, or the product is not visible to this buyer', schema: storefrontErrorSchema },
82
82
  { status: 410, description: 'Store archived', schema: storefrontErrorSchema },
83
83
  { status: 429, description: 'Too many requests', schema: rateLimitErrorSchema },
84
- { status: 503, description: 'Store misconfigured (no default channel binding)', schema: storefrontErrorSchema },
84
+ { status: 503, description: 'Store misconfigured (no usable default channel binding)', schema: storefrontErrorSchema },
85
85
  ],
86
86
  }
87
87
 
@@ -68,7 +68,7 @@ const storefrontProductsGetDoc: OpenApiMethodDoc = {
68
68
  { status: 404, description: 'No store serves this host or path', schema: storefrontErrorSchema },
69
69
  { status: 410, description: 'Store archived', schema: storefrontErrorSchema },
70
70
  { status: 429, description: 'Too many requests', schema: rateLimitErrorSchema },
71
- { status: 503, description: 'Store misconfigured (no default channel binding)', schema: storefrontErrorSchema },
71
+ { status: 503, description: 'Store misconfigured (no usable default channel binding)', schema: storefrontErrorSchema },
72
72
  ],
73
73
  }
74
74
 
@@ -65,7 +65,7 @@ const storefrontSearchSuggestGetDoc: OpenApiMethodDoc = {
65
65
  { status: 404, description: 'No store serves this host or path', schema: storefrontErrorSchema },
66
66
  { status: 410, description: 'Store archived', schema: storefrontErrorSchema },
67
67
  { status: 429, description: 'Too many requests', schema: rateLimitErrorSchema },
68
- { status: 503, description: 'Store misconfigured (no default channel binding)', schema: storefrontErrorSchema },
68
+ { status: 503, description: 'Store misconfigured (no usable default channel binding)', schema: storefrontErrorSchema },
69
69
  ],
70
70
  }
71
71
 
@@ -11,6 +11,7 @@ import {
11
11
  ecommerceCustomerUserTag,
12
12
  ecommerceDomainMappingTag,
13
13
  ecommerceDomainTag,
14
+ ecommerceSalesChannelTag,
14
15
  ecommerceStoreTag,
15
16
  ecommerceUngroupedBuyerTag,
16
17
  invalidateEcommerceCacheTags,
@@ -64,6 +65,9 @@ export const storeDomainBindingEventTags: TagDeriver = (payload) => [
64
65
  export const storeChannelBindingEventTags: TagDeriver = (payload) =>
65
66
  tagsFrom([readPayloadString(payload, 'storeId'), readPayloadString(payload, 'previousStoreId')], ecommerceStoreTag)
66
67
 
68
+ export const salesChannelEventTags: TagDeriver = (payload) =>
69
+ tagsFrom([readPayloadString(payload, 'id')], ecommerceSalesChannelTag)
70
+
67
71
  export const domainMappingEventTags: TagDeriver = (payload) => [
68
72
  ...tagsFrom(
69
73
  [readPayloadString(payload, 'id'), readPayloadString(payload, 'replacedDomainId')],
@@ -144,6 +144,10 @@ export function ecommerceDomainTag(hostname: string): string {
144
144
  return `ecommerce-domain:${hostname}`
145
145
  }
146
146
 
147
+ export function ecommerceSalesChannelTag(salesChannelId: string): string {
148
+ return `ecommerce-sales-channel:${salesChannelId}`
149
+ }
150
+
147
151
  export function ecommerceCustomerTag(customerId: string): string {
148
152
  return `customer:${customerId}`
149
153
  }
@@ -18,6 +18,7 @@ import {
18
18
  ecommerceDomainMappingTag,
19
19
  ecommerceDomainTag,
20
20
  ecommerceResolutionCache,
21
+ ecommerceSalesChannelTag,
21
22
  ecommerceStoreTag,
22
23
  type CacheContainer,
23
24
  } from './cacheKeys'
@@ -145,6 +146,7 @@ type StoreCandidate = {
145
146
  store: CandidateStore
146
147
  channel: ResolvedStoreChannel | null
147
148
  binding: CandidateBinding | null
149
+ missingSalesChannelId?: string | null
148
150
  }
149
151
 
150
152
  type CachedStoreCandidates = { candidates: StoreCandidate[] }
@@ -187,6 +189,12 @@ type StoreResolutionDatabase = {
187
189
  require_authentication: boolean
188
190
  deleted_at: Date | null
189
191
  }
192
+ sales_channels: {
193
+ id: string
194
+ tenant_id: string
195
+ organization_id: string
196
+ deleted_at: Date | null
197
+ }
190
198
  }
191
199
 
192
200
  type StoreResolutionRow = {
@@ -207,6 +215,7 @@ type StoreResolutionRow = {
207
215
  channel_assortment_scope: unknown
208
216
  channel_price_sort_fallback: string | null
209
217
  channel_require_authentication?: boolean | null
218
+ channel_live_sales_channel_id?: string | null
210
219
  binding_id?: string | null
211
220
  binding_path_prefix?: string | null
212
221
  binding_is_primary?: boolean | null
@@ -230,6 +239,7 @@ const STORE_COLUMNS = [
230
239
  'c.assortment_scope as channel_assortment_scope',
231
240
  'c.price_sort_fallback as channel_price_sort_fallback',
232
241
  'c.require_authentication as channel_require_authentication',
242
+ 'sc.id as channel_live_sales_channel_id',
233
243
  ] as const
234
244
 
235
245
  const FORCE_HOST_HEADER = 'x-force-host'
@@ -401,8 +411,10 @@ function toCandidate(row: StoreResolutionRow): StoreCandidate {
401
411
  defaultCurrencyCode: row.store_default_currency_code,
402
412
  settings: parseSettings(row.store_settings, row.store_id),
403
413
  }
414
+ const salesChannelMissing =
415
+ !!row.channel_binding_id && !!row.channel_sales_channel_id && !row.channel_live_sales_channel_id
404
416
  const channel: ResolvedStoreChannel | null =
405
- row.channel_binding_id && row.channel_sales_channel_id
417
+ row.channel_binding_id && row.channel_sales_channel_id && !salesChannelMissing
406
418
  ? {
407
419
  channelBindingId: row.channel_binding_id,
408
420
  salesChannelId: row.channel_sales_channel_id,
@@ -415,7 +427,7 @@ function toCandidate(row: StoreResolutionRow): StoreCandidate {
415
427
  const binding: CandidateBinding | null = row.binding_id
416
428
  ? { id: row.binding_id, pathPrefix: row.binding_path_prefix ?? null, isPrimary: row.binding_is_primary === true }
417
429
  : null
418
- return { store, channel, binding }
430
+ return { store, channel, binding, missingSalesChannelId: salesChannelMissing ? row.channel_sales_channel_id : null }
419
431
  }
420
432
 
421
433
  async function queryDomainCandidates(
@@ -440,6 +452,13 @@ async function queryDomainCandidates(
440
452
  .on('c.is_default', '=', true)
441
453
  .on('c.deleted_at', 'is', null),
442
454
  )
455
+ .leftJoin('sales_channels as sc', (join) =>
456
+ join
457
+ .onRef('sc.id', '=', 'c.sales_channel_id')
458
+ .onRef('sc.tenant_id', '=', 'c.tenant_id')
459
+ .onRef('sc.organization_id', '=', 'c.organization_id')
460
+ .on('sc.deleted_at', 'is', null),
461
+ )
443
462
  .select([
444
463
  ...STORE_COLUMNS,
445
464
  'b.id as binding_id',
@@ -469,6 +488,13 @@ async function querySlugCandidates(
469
488
  .on('c.is_default', '=', true)
470
489
  .on('c.deleted_at', 'is', null),
471
490
  )
491
+ .leftJoin('sales_channels as sc', (join) =>
492
+ join
493
+ .onRef('sc.id', '=', 'c.sales_channel_id')
494
+ .onRef('sc.tenant_id', '=', 'c.tenant_id')
495
+ .onRef('sc.organization_id', '=', 'c.organization_id')
496
+ .on('sc.deleted_at', 'is', null),
497
+ )
472
498
  .select([...STORE_COLUMNS])
473
499
  .where('s.slug', '=', slug)
474
500
  .where('s.deleted_at', 'is', null)
@@ -476,6 +502,10 @@ async function querySlugCandidates(
476
502
  return query.limit(2).execute()
477
503
  }
478
504
 
505
+ function salesChannelTags(rows: StoreResolutionRow[]): string[] {
506
+ return rows.flatMap((row) => (row.channel_sales_channel_id ? [ecommerceSalesChannelTag(row.channel_sales_channel_id)] : []))
507
+ }
508
+
479
509
  async function loadDomainCandidates(
480
510
  container: StoreContextContainer,
481
511
  mapping: DomainMappingResolution,
@@ -490,6 +520,7 @@ async function loadDomainCandidates(
490
520
  ecommerceDomainMappingTag(mapping.domainMappingId),
491
521
  ecommerceDomainTag(mapping.hostname),
492
522
  ...candidates.map((candidate) => ecommerceStoreTag(candidate.store.id)),
523
+ ...salesChannelTags(rows),
493
524
  ]
494
525
  await cache.set<CachedStoreCandidates>(
495
526
  key,
@@ -516,24 +547,39 @@ async function loadSlugCandidates(
516
547
  { candidates },
517
548
  {
518
549
  ttlMs: STORE_RESOLUTION_TTL_MS,
519
- tags: candidates.map((candidate) => ecommerceStoreTag(candidate.store.id)),
550
+ tags: Array.from(
551
+ new Set([...candidates.map((candidate) => ecommerceStoreTag(candidate.store.id)), ...salesChannelTags(rows)]),
552
+ ),
520
553
  },
521
554
  )
522
555
  }
523
556
  return candidates
524
557
  }
525
558
 
526
- async function reportMisconfiguredStore(container: StoreContextContainer, store: CandidateStore): Promise<void> {
559
+ async function reportMisconfiguredStore(
560
+ container: StoreContextContainer,
561
+ store: CandidateStore,
562
+ missingSalesChannelId: string | null,
563
+ ): Promise<void> {
527
564
  const cache = ecommerceResolutionCache(container)
528
565
  const key = ['misconfigured', 'channel-binding-missing', store.id]
529
566
  const alreadyReported = await cache.get<{ reportedAt: string }>(key)
530
567
  if (alreadyReported) return
531
568
  await cache.set(key, { reportedAt: new Date().toISOString() }, { ttlMs: STORE_MISCONFIGURED_THROTTLE_MS })
532
- logger.warn('Store has no default channel binding; answering 503', {
533
- storeId: store.id,
534
- tenantId: store.tenantId,
535
- organizationId: store.organizationId,
536
- })
569
+ if (missingSalesChannelId) {
570
+ logger.warn('Store default channel binding references a sales channel that no longer exists; answering 503', {
571
+ storeId: store.id,
572
+ tenantId: store.tenantId,
573
+ organizationId: store.organizationId,
574
+ salesChannelId: missingSalesChannelId,
575
+ })
576
+ } else {
577
+ logger.warn('Store has no default channel binding; answering 503', {
578
+ storeId: store.id,
579
+ tenantId: store.tenantId,
580
+ organizationId: store.organizationId,
581
+ })
582
+ }
537
583
  try {
538
584
  await emitEcommerceEvent(
539
585
  'ecommerce.store.misconfigured',
@@ -570,7 +616,7 @@ async function finalizeCandidate(
570
616
  }
571
617
  if (store.status === 'archived') throw new StorefrontResolutionError(410, 'store_archived')
572
618
  if (!candidate.channel) {
573
- await reportMisconfiguredStore(container, store)
619
+ await reportMisconfiguredStore(container, store, candidate.missingSalesChannelId ?? null)
574
620
  throw new StorefrontResolutionError(503, 'store_misconfigured')
575
621
  }
576
622
  const locale = resolveStoreLocale(store, localeCandidates)
@@ -0,0 +1,12 @@
1
+ import { invalidateForEvent, salesChannelEventTags } from '../lib/cacheInvalidation'
2
+ import type { EcommerceSubscriberContext } from '../lib/subscriberSupport'
3
+
4
+ export const metadata = {
5
+ event: 'sales.channel.*',
6
+ persistent: false,
7
+ id: 'ecommerce:sales-channel-cache-invalidation',
8
+ }
9
+
10
+ export default async function handle(payload: unknown, ctx: EcommerceSubscriberContext): Promise<void> {
11
+ await invalidateForEvent(payload, ctx, salesChannelEventTags)
12
+ }
@@ -11,6 +11,7 @@ function stableUuidFromString(input: string): string {
11
11
  }
12
12
 
13
13
  type SchedulerServiceLike = {
14
+ unregister?: (scheduleId: string) => Promise<void>
14
15
  register: (registration: {
15
16
  id: string
16
17
  name: string
@@ -31,6 +32,39 @@ type SchedulerServiceLike = {
31
32
  }) => Promise<void>
32
33
  }
33
34
 
35
+ async function ensureHealthProbeSchedule(ctx: {
36
+ container: import('awilix').AwilixContainer
37
+ organizationId: string
38
+ tenantId: string
39
+ }, legacyScheduleId?: string): Promise<void> {
40
+ const cradle = ctx.container as { hasRegistration?: (name: string) => boolean }
41
+ if (typeof cradle.hasRegistration !== 'function' || !cradle.hasRegistration('schedulerService')) return
42
+
43
+ const schedulerService = ctx.container.resolve('schedulerService') as SchedulerServiceLike
44
+ if (legacyScheduleId && typeof schedulerService.unregister === 'function') {
45
+ await schedulerService.unregister(legacyScheduleId)
46
+ }
47
+ await schedulerService.register({
48
+ id: stableUuidFromString(`integrations:health-probe:${ctx.tenantId}:${ctx.organizationId}`),
49
+ name: 'Integration health probes',
50
+ description: 'Runs outbound health checks for enabled integrations every 15 minutes.',
51
+ scopeType: 'organization',
52
+ organizationId: ctx.organizationId,
53
+ tenantId: ctx.tenantId,
54
+ scheduleType: 'interval',
55
+ scheduleValue: '15m',
56
+ timezone: 'UTC',
57
+ targetType: 'queue',
58
+ targetQueue: 'integration-health-probe',
59
+ targetPayload: {
60
+ scope: { organizationId: ctx.organizationId, tenantId: ctx.tenantId },
61
+ },
62
+ sourceType: 'module',
63
+ sourceModule: 'integrations',
64
+ isEnabled: true,
65
+ })
66
+ }
67
+
34
68
  export const setup: ModuleSetupConfig = {
35
69
  defaultRoleFeatures: {
36
70
  superadmin: ['integrations.*', 'integrations.view', 'integrations.manage', 'integrations.credentials.manage'],
@@ -39,31 +73,14 @@ export const setup: ModuleSetupConfig = {
39
73
  },
40
74
 
41
75
  async seedDefaults({ container, organizationId, tenantId }) {
42
- const cradle = container as { hasRegistration?: (name: string) => boolean }
43
- if (typeof cradle.hasRegistration !== 'function' || !cradle.hasRegistration('schedulerService')) {
44
- return
45
- }
76
+ await ensureHealthProbeSchedule(
77
+ { container, organizationId, tenantId },
78
+ stableUuidFromString(`integrations:health-probe:${tenantId}`),
79
+ )
80
+ },
46
81
 
47
- const schedulerService = container.resolve('schedulerService') as SchedulerServiceLike
48
- await schedulerService.register({
49
- id: stableUuidFromString(`integrations:health-probe:${tenantId}`),
50
- name: 'Integration health probes',
51
- description: 'Runs outbound health checks for enabled integrations every 15 minutes.',
52
- scopeType: 'organization',
53
- organizationId,
54
- tenantId,
55
- scheduleType: 'interval',
56
- scheduleValue: '15m',
57
- timezone: 'UTC',
58
- targetType: 'queue',
59
- targetQueue: 'integration-health-probe',
60
- targetPayload: {
61
- scope: { organizationId, tenantId },
62
- },
63
- sourceType: 'module',
64
- sourceModule: 'integrations',
65
- isEnabled: true,
66
- })
82
+ async onOrganizationCreated({ container, organizationId, tenantId }) {
83
+ await ensureHealthProbeSchedule({ container, organizationId, tenantId })
67
84
  },
68
85
  }
69
86
 
@@ -360,7 +360,7 @@ export function MessagesInboxPageClient() {
360
360
  const totalPages = listQuery.data?.totalPages ?? 0
361
361
 
362
362
  return (
363
- <div className="space-y-4">
363
+ <div className="min-w-0 max-w-full space-y-4 [&_.isolate]:overflow-x-auto">
364
364
  <DataTable
365
365
  title={t('messages.title', 'Messages')}
366
366
  titleHeadingLevel={1}
@@ -98,9 +98,9 @@ export function MainMessageHeader(props: MainMessageHeaderProps) {
98
98
  backHref="/backend/messages"
99
99
  backLabel={t('messages.actions.backToList', 'Back to messages')}
100
100
  title={(
101
- <h1 className="inline-flex items-center gap-2">
102
- <span className="truncate">{props.subject}</span>
103
- <PriorityBadge priority={props.priority} />
101
+ <h1 className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
102
+ <span className="min-w-0 break-words" title={props.subject}>{props.subject}</span>
103
+ <PriorityBadge priority={props.priority} className="shrink-0" />
104
104
  </h1>
105
105
  )}
106
106
  utilityActions={props.canReply ? (
@@ -14,14 +14,14 @@ type BodyPanelProps = {
14
14
  export function MessageDetailBodySection(props: BodyPanelProps) {
15
15
  if (props.ContentComponent) {
16
16
  return (
17
- <div className="rounded border bg-muted/30 p-3">
17
+ <div className="min-w-0 max-w-full overflow-x-auto rounded border bg-muted/30 p-3 break-words">
18
18
  <props.ContentComponent {...props.contentProps} />
19
19
  </div>
20
20
  )
21
21
  }
22
22
 
23
23
  return (
24
- <div className="rounded border bg-muted/30 p-3 text-sm whitespace-pre-wrap">
24
+ <div className="min-w-0 max-w-full overflow-x-auto rounded border bg-muted/30 p-3 text-sm whitespace-pre-wrap break-words">
25
25
  <MarkdownContent
26
26
  body={props.detail.body}
27
27
  format={props.detail.bodyFormat}
@@ -68,6 +68,10 @@ export const setup: ModuleSetupConfig = {
68
68
  async seedDefaults({ container, tenantId, organizationId }) {
69
69
  await ensureSessionInitializationPruneSchedule(container, { tenantId, organizationId })
70
70
  },
71
+
72
+ async onOrganizationCreated({ container, tenantId, organizationId }) {
73
+ await ensureSessionInitializationPruneSchedule(container, { tenantId, organizationId })
74
+ },
71
75
  }
72
76
 
73
77
  export default setup