@open-mercato/core 0.8.1-develop.7221.1.ece7ca874e → 0.8.1-develop.7227.1.9248c14599

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 (102) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js +166 -0
  3. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js.map +7 -0
  4. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js +16 -0
  5. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js.map +7 -0
  6. package/dist/modules/catalog/backend/catalog/prices/create/page.js +68 -0
  7. package/dist/modules/catalog/backend/catalog/prices/create/page.js.map +7 -0
  8. package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js +25 -0
  9. package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js.map +7 -0
  10. package/dist/modules/catalog/backend/catalog/prices/page.js +11 -0
  11. package/dist/modules/catalog/backend/catalog/prices/page.js.map +7 -0
  12. package/dist/modules/catalog/backend/catalog/prices/page.meta.js +16 -0
  13. package/dist/modules/catalog/backend/catalog/prices/page.meta.js.map +7 -0
  14. package/dist/modules/catalog/components/prices/PriceScopeSelectors.js +201 -0
  15. package/dist/modules/catalog/components/prices/PriceScopeSelectors.js.map +7 -0
  16. package/dist/modules/catalog/components/prices/PricesDataTable.js +273 -0
  17. package/dist/modules/catalog/components/prices/PricesDataTable.js.map +7 -0
  18. package/dist/modules/catalog/components/prices/normalizePriceRecord.js +43 -0
  19. package/dist/modules/catalog/components/prices/normalizePriceRecord.js.map +7 -0
  20. package/dist/modules/catalog/components/prices/priceFormFields.js +195 -0
  21. package/dist/modules/catalog/components/prices/priceFormFields.js.map +7 -0
  22. package/dist/modules/catalog/components/prices/useBatchLabels.js +42 -0
  23. package/dist/modules/catalog/components/prices/useBatchLabels.js.map +7 -0
  24. package/dist/modules/catalog/data/validators.js +21 -2
  25. package/dist/modules/catalog/data/validators.js.map +2 -2
  26. package/dist/modules/catalog/extension-points.js +2 -1
  27. package/dist/modules/catalog/extension-points.js.map +2 -2
  28. package/dist/modules/catalog/lib/pricing.js +37 -8
  29. package/dist/modules/catalog/lib/pricing.js.map +2 -2
  30. package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js +5 -1
  31. package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js.map +2 -2
  32. package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js +88 -0
  33. package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js.map +7 -0
  34. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +17 -19
  35. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
  36. package/dist/modules/communication_channels/commands/connect-credential-channel.js +7 -2
  37. package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
  38. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +11 -2
  39. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  40. package/dist/modules/communication_channels/commands/queue-import-history.js +40 -7
  41. package/dist/modules/communication_channels/commands/queue-import-history.js.map +2 -2
  42. package/dist/modules/communication_channels/extension-points.js +1 -1
  43. package/dist/modules/communication_channels/extension-points.js.map +1 -1
  44. package/dist/modules/communication_channels/lib/connect-channel.js +33 -0
  45. package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
  46. package/dist/modules/communication_channels/lib/import-history-limits.js +69 -0
  47. package/dist/modules/communication_channels/lib/import-history-limits.js.map +7 -0
  48. package/dist/modules/communication_channels/workers/channel-import-history.js +36 -2
  49. package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
  50. package/dist/modules/messages/api/route.js +5 -1
  51. package/dist/modules/messages/api/route.js.map +2 -2
  52. package/dist/modules/messages/commands/messages.js +3 -2
  53. package/dist/modules/messages/commands/messages.js.map +2 -2
  54. package/dist/modules/messages/data/validators.js +12 -2
  55. package/dist/modules/messages/data/validators.js.map +2 -2
  56. package/dist/modules/messages/subscribers/message-notification.js +4 -1
  57. package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
  58. package/dist/modules/query_index/lib/engine.js +19 -1
  59. package/dist/modules/query_index/lib/engine.js.map +2 -2
  60. package/package.json +7 -7
  61. package/src/modules/catalog/AGENTS.md +6 -2
  62. package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.ts +12 -0
  63. package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.tsx +198 -0
  64. package/src/modules/catalog/backend/catalog/prices/create/page.meta.ts +23 -0
  65. package/src/modules/catalog/backend/catalog/prices/create/page.tsx +70 -0
  66. package/src/modules/catalog/backend/catalog/prices/page.meta.ts +12 -0
  67. package/src/modules/catalog/backend/catalog/prices/page.tsx +14 -0
  68. package/src/modules/catalog/components/prices/PriceScopeSelectors.tsx +243 -0
  69. package/src/modules/catalog/components/prices/PricesDataTable.tsx +320 -0
  70. package/src/modules/catalog/components/prices/normalizePriceRecord.ts +71 -0
  71. package/src/modules/catalog/components/prices/priceFormFields.tsx +228 -0
  72. package/src/modules/catalog/components/prices/useBatchLabels.ts +57 -0
  73. package/src/modules/catalog/data/validators.ts +43 -2
  74. package/src/modules/catalog/extension-points.ts +1 -0
  75. package/src/modules/catalog/i18n/de.json +72 -0
  76. package/src/modules/catalog/i18n/en.json +72 -0
  77. package/src/modules/catalog/i18n/es.json +72 -0
  78. package/src/modules/catalog/i18n/ko.json +72 -0
  79. package/src/modules/catalog/i18n/pl.json +72 -0
  80. package/src/modules/catalog/lib/pricing.ts +103 -9
  81. package/src/modules/communication_channels/api/post/channels/[id]/import-history/route.ts +6 -1
  82. package/src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx +120 -0
  83. package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +20 -17
  84. package/src/modules/communication_channels/commands/connect-credential-channel.ts +9 -0
  85. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +17 -2
  86. package/src/modules/communication_channels/commands/queue-import-history.ts +59 -11
  87. package/src/modules/communication_channels/extension-points.ts +1 -1
  88. package/src/modules/communication_channels/i18n/de.json +4 -3
  89. package/src/modules/communication_channels/i18n/en.json +4 -3
  90. package/src/modules/communication_channels/i18n/es.json +4 -3
  91. package/src/modules/communication_channels/i18n/ko.json +4 -3
  92. package/src/modules/communication_channels/i18n/pl.json +4 -3
  93. package/src/modules/communication_channels/lib/adapter.ts +16 -2
  94. package/src/modules/communication_channels/lib/connect-channel.ts +49 -0
  95. package/src/modules/communication_channels/lib/import-history-limits.ts +109 -0
  96. package/src/modules/communication_channels/workers/channel-import-history.ts +46 -5
  97. package/src/modules/messages/api/route.ts +8 -2
  98. package/src/modules/messages/commands/messages.ts +17 -2
  99. package/src/modules/messages/data/validators.ts +26 -11
  100. package/src/modules/messages/subscribers/message-notification.ts +17 -5
  101. package/src/modules/query_index/lib/engine.ts +23 -1
  102. package/src/modules/sales/AGENTS.md +2 -3
@@ -368,7 +368,10 @@ export interface ApplyPushNotificationInput {
368
368
  export interface ImportHistoryInput {
369
369
  credentials: Record<string, unknown>
370
370
  scope: TenantScope
371
- /** Look back at most this many days. Clamped 1..365 by the hub. */
371
+ /**
372
+ * Look back at most this many days. The hub accepts 1..`OM_IMPORT_HISTORY_MAX_SINCE_DAYS`
373
+ * (default ceiling 3650, i.e. ten years) and defaults to 30 when omitted.
374
+ */
372
375
  sinceDays: number
373
376
  /**
374
377
  * Optional sender-filter hint. Adapters SHOULD use it for server-side
@@ -376,7 +379,10 @@ export interface ImportHistoryInput {
376
379
  * import scans the entire `SINCE` window.
377
380
  */
378
381
  contactEmails?: string[]
379
- /** Total cap across all pages. Hub default 1000. Adapter MUST respect. */
382
+ /**
383
+ * Total cap across all pages. Hub default 1000, accepted up to
384
+ * `OM_IMPORT_HISTORY_MAX_MESSAGES` (default ceiling 50000). Adapter MUST respect.
385
+ */
380
386
  maxMessages?: number
381
387
  /** Opaque resumption cursor returned by the previous page. */
382
388
  cursor?: string
@@ -519,6 +525,14 @@ export interface ValidateCredentialsResult {
519
525
  * that don't emit codes keep working and callers fall back to `errors`.
520
526
  */
521
527
  errorCodes?: Record<string, string>
528
+ /**
529
+ * Stable identity of the connected account, for providers whose credentials
530
+ * carry no email-shaped `username` / `email` / `fromAddress` (e.g. a Discord
531
+ * bot). When present on a successful validation the connect flow uses it as
532
+ * `CommunicationChannel.externalIdentifier`, so reconnecting the same account
533
+ * heals the existing channel instead of inserting a duplicate row.
534
+ */
535
+ externalIdentifier?: string
522
536
  }
523
537
 
524
538
  // ── The adapter contract ─────────────────────────────────────
@@ -1,4 +1,5 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
2
3
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
4
  import { CommunicationChannel } from '../data/entities'
4
5
  import type { ChannelAdapter } from './adapter'
@@ -44,6 +45,14 @@ export interface CreateConnectedChannelRowArgs {
44
45
  * the adapter's push capability (push-capable → null, polling-only → 300).
45
46
  */
46
47
  pollIntervalSeconds?: number | null
48
+ /**
49
+ * The identifier was reported by the adapter itself (not sniffed from the
50
+ * credential bag). Rows this user created for the same provider before the
51
+ * adapter reported one carry `external_identifier = NULL` and would never match
52
+ * the heal key, so the newest such row is adopted instead of inserting another
53
+ * one next to it.
54
+ */
55
+ adoptUnidentifiedChannel?: boolean
47
56
  }
48
57
 
49
58
  /**
@@ -163,6 +172,46 @@ export async function createConnectedChannelRow(
163
172
  }
164
173
  }
165
174
 
175
+ if (args.adoptUnidentifiedChannel && effectiveExternalIdentifier && !isTenantWidePush) {
176
+ const unidentified = (await findWithDecryption(
177
+ em,
178
+ CommunicationChannel,
179
+ { tenantId: scope.tenantId, userId, providerKey, externalIdentifier: null, deletedAt: null },
180
+ { orderBy: { createdAt: 'desc' } },
181
+ dscope,
182
+ )) as CommunicationChannel[]
183
+ const [adopted, ...superseded] = unidentified
184
+ if (adopted) {
185
+ // Per-user credentials are stored once per provider, so every older
186
+ // identifier-less row is a duplicate of the adopted one. Left active, a
187
+ // stuck `requires_reauth` duplicate would keep its reauth banner forever.
188
+ const inheritsPrimary = superseded.some((duplicate) => duplicate.isPrimary)
189
+ // Postgres checks the partial `communication_channels_one_primary_per_user_uq`
190
+ // index per statement and one flush does not order the SET-false before
191
+ // the SET-true, so the old primary is cleared in its own phase (each phase
192
+ // flushes) before the adopted row takes the flag, inside one transaction.
193
+ await withAtomicFlush(
194
+ em,
195
+ [
196
+ () => {
197
+ for (const duplicate of superseded) {
198
+ duplicate.isActive = false
199
+ duplicate.isPrimary = false
200
+ duplicate.status = 'disconnected'
201
+ duplicate.lastError = 'superseded_by_reconnect'
202
+ }
203
+ },
204
+ () => {
205
+ applyConnectionState(adopted)
206
+ if (inheritsPrimary) adopted.isPrimary = true
207
+ },
208
+ ],
209
+ { transaction: true },
210
+ )
211
+ return adopted
212
+ }
213
+ }
214
+
166
215
  const channel = em.create(CommunicationChannel, {
167
216
  providerKey,
168
217
  channelType: adapter.channelType,
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Single source of truth for the operator-triggered history-import bounds.
3
+ *
4
+ * Defaults are the values a request gets when it omits the field; ceilings are
5
+ * the largest value the hub accepts. Ceilings are deliberately wide enough for
6
+ * a multi-year backfill and can be raised or lowered per deployment through
7
+ * `OM_IMPORT_HISTORY_MAX_SINCE_DAYS` / `OM_IMPORT_HISTORY_MAX_MESSAGES`.
8
+ *
9
+ * Env is read at call time (not module load) so a deployment can change the
10
+ * ceiling without a rebuild, and so tests can exercise the override. In the
11
+ * browser bundle the variables are absent and the built-in ceilings apply —
12
+ * client-side validation is a UX affordance, the API remains the authority.
13
+ */
14
+
15
+ export const IMPORT_HISTORY_DEFAULT_SINCE_DAYS = 30
16
+ export const IMPORT_HISTORY_DEFAULT_MAX_MESSAGES = 1000
17
+
18
+ export const IMPORT_HISTORY_MAX_SINCE_DAYS_FALLBACK = 3650
19
+ export const IMPORT_HISTORY_MAX_MESSAGES_FALLBACK = 50000
20
+
21
+ export const IMPORT_HISTORY_MAX_CONTACT_EMAILS = 200
22
+
23
+ export const IMPORT_HISTORY_MIN_PAGE_BUDGET = 100
24
+ export const IMPORT_HISTORY_ABSOLUTE_MAX_PAGES = 10000
25
+
26
+ /**
27
+ * Consecutive pages that may return no messages while the adapter still claims
28
+ * `hasMore` and advances its cursor. Empty pages are legitimate: the Gmail
29
+ * adapter drops messages that returned 404/410 between listing and fetching,
30
+ * and server-side filters can eliminate a whole page. A stuck adapter is caught
31
+ * by the repeated-cursor check, so this counter only has to bound the run when
32
+ * the cursor keeps moving but nothing is ever returned.
33
+ */
34
+ export const IMPORT_HISTORY_MAX_EMPTY_PAGES = 10
35
+
36
+ export const IMPORT_HISTORY_MAX_SINCE_DAYS_ENV = 'OM_IMPORT_HISTORY_MAX_SINCE_DAYS'
37
+ export const IMPORT_HISTORY_MAX_MESSAGES_ENV = 'OM_IMPORT_HISTORY_MAX_MESSAGES'
38
+
39
+ export type ImportHistoryLimits = {
40
+ defaultSinceDays: number
41
+ defaultMaxMessages: number
42
+ maxSinceDays: number
43
+ maxMessages: number
44
+ }
45
+
46
+ function readPositiveIntEnv(name: string, fallback: number): number {
47
+ const raw = typeof process !== 'undefined' ? process.env?.[name] : undefined
48
+ const parsed = Number.parseInt(raw ?? '', 10)
49
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
50
+ }
51
+
52
+ export function getImportHistoryMaxSinceDays(): number {
53
+ return Math.max(
54
+ IMPORT_HISTORY_DEFAULT_SINCE_DAYS,
55
+ readPositiveIntEnv(IMPORT_HISTORY_MAX_SINCE_DAYS_ENV, IMPORT_HISTORY_MAX_SINCE_DAYS_FALLBACK),
56
+ )
57
+ }
58
+
59
+ export function getImportHistoryMaxMessages(): number {
60
+ return Math.max(
61
+ IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,
62
+ readPositiveIntEnv(IMPORT_HISTORY_MAX_MESSAGES_ENV, IMPORT_HISTORY_MAX_MESSAGES_FALLBACK),
63
+ )
64
+ }
65
+
66
+ /**
67
+ * Per-provider ceilings, lower than the deployment-wide ceiling above.
68
+ *
69
+ * The IMAP adapter's cursor embeds the *entire* remaining UID list discovered
70
+ * by `SEARCH` (unlike Gmail's opaque `nextPageToken` cursor), so a wide ceiling
71
+ * there means a multi-megabyte cursor round-tripping through the worker on
72
+ * every page. Until that adapter gets a paging cursor of its own, its ceiling
73
+ * stays at the pre-widening values so the dialog, the hub's validation, and the
74
+ * adapter's own clamp (`packages/channel-imap/.../adapter.ts`) agree — a
75
+ * request above this is rejected rather than silently truncated.
76
+ */
77
+ const PROVIDER_MAX_SINCE_DAYS: Record<string, number> = {
78
+ imap: 365,
79
+ }
80
+ const PROVIDER_MAX_MESSAGES: Record<string, number> = {
81
+ imap: 5000,
82
+ }
83
+
84
+ export function getImportHistoryLimits(providerKey?: string): ImportHistoryLimits {
85
+ const maxSinceDays = getImportHistoryMaxSinceDays()
86
+ const maxMessages = getImportHistoryMaxMessages()
87
+ const providerMaxSinceDays = providerKey ? PROVIDER_MAX_SINCE_DAYS[providerKey] : undefined
88
+ const providerMaxMessages = providerKey ? PROVIDER_MAX_MESSAGES[providerKey] : undefined
89
+ return {
90
+ defaultSinceDays: IMPORT_HISTORY_DEFAULT_SINCE_DAYS,
91
+ defaultMaxMessages: IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,
92
+ maxSinceDays: providerMaxSinceDays !== undefined ? Math.min(maxSinceDays, providerMaxSinceDays) : maxSinceDays,
93
+ maxMessages: providerMaxMessages !== undefined ? Math.min(maxMessages, providerMaxMessages) : maxMessages,
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Page budget for the import drain loop, derived from the effective message cap
99
+ * so a large import is never truncated before it reaches that cap. It is only a
100
+ * backstop: an adapter that never makes progress is stopped much earlier by the
101
+ * consecutive-empty-page guard.
102
+ */
103
+ export function resolveImportHistoryPageBudget(maxMessages: number): number {
104
+ if (!Number.isFinite(maxMessages) || maxMessages <= 0) return IMPORT_HISTORY_MIN_PAGE_BUDGET
105
+ return Math.min(
106
+ IMPORT_HISTORY_ABSOLUTE_MAX_PAGES,
107
+ Math.max(IMPORT_HISTORY_MIN_PAGE_BUDGET, Math.ceil(maxMessages)),
108
+ )
109
+ }
@@ -10,6 +10,10 @@ import {
10
10
  import { COMMUNICATION_CHANNELS_QUEUES } from '../lib/queue'
11
11
  import { refreshCredentialsIfNeeded } from '../lib/credential-refresh'
12
12
  import { classifyOutboundError } from '../lib/error-classification'
13
+ import {
14
+ IMPORT_HISTORY_MAX_EMPTY_PAGES,
15
+ resolveImportHistoryPageBudget,
16
+ } from '../lib/import-history-limits'
13
17
  import type { ChannelAdapterRegistry } from '../lib/registry'
14
18
  import type {
15
19
  ChannelImportHistoryJobPayload,
@@ -159,16 +163,21 @@ export default async function handle(
159
163
  let totalCount = maxMessages
160
164
  let firstPage = true
161
165
 
162
- // Bounded loop guards against a misbehaving adapter (infinite hasMore).
163
- // 100 pages * HARD_CAP (default 200) = 20k messages — far above the 5k cap
164
- // enforced by the schema, so this only trips on adapter bugs.
165
- const MAX_PAGES = 100
166
- for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
166
+ // Guards against an adapter that claims `hasMore` forever. An empty page is
167
+ // NOT a fault on its own — adapters legitimately drop every message on a
168
+ // page (Gmail 404/410 between listing and fetching, server-side filters) —
169
+ // so the hard stop is a cursor that stops advancing; the empty-page counter
170
+ // and the page budget are backstops.
171
+ const maxPages = resolveImportHistoryPageBudget(maxMessages)
172
+ let consecutiveEmptyPages = 0
173
+ let truncationReason: string | null = null
174
+ for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
167
175
  if (await progressService.isCancellationRequested(progressJobId, scope.tenantId, scope.organizationId)) {
168
176
  await progressService.markCancelled(progressJobId, progressContext)
169
177
  return
170
178
  }
171
179
 
180
+ const requestedCursor = cursor
172
181
  const page = await adapter.importHistory!({
173
182
  credentials,
174
183
  scope: { tenantId: scope.tenantId, organizationId: scope.organizationId },
@@ -178,6 +187,21 @@ export default async function handle(
178
187
  cursor,
179
188
  })
180
189
 
190
+ if (page.hasMore && page.nextCursor !== undefined && page.nextCursor === requestedCursor) {
191
+ truncationReason = 'adapter repeated the same pagination cursor'
192
+ break
193
+ }
194
+
195
+ if (page.messages.length === 0 && page.hasMore) {
196
+ consecutiveEmptyPages += 1
197
+ if (consecutiveEmptyPages >= IMPORT_HISTORY_MAX_EMPTY_PAGES) {
198
+ truncationReason = `adapter returned no messages on ${consecutiveEmptyPages} consecutive pages`
199
+ break
200
+ }
201
+ } else {
202
+ consecutiveEmptyPages = 0
203
+ }
204
+
181
205
  if (firstPage && typeof page.totalCandidates === 'number') {
182
206
  totalCount = Math.min(maxMessages, page.totalCandidates)
183
207
  await progressService.updateProgress(
@@ -225,6 +249,23 @@ export default async function handle(
225
249
  if (processedCount >= maxMessages) break
226
250
  if (!page.hasMore || !page.nextCursor) break
227
251
  cursor = page.nextCursor
252
+ if (pageIndex === maxPages - 1) {
253
+ truncationReason = `page budget of ${maxPages} exhausted while the adapter still reported more messages`
254
+ }
255
+ }
256
+
257
+ if (truncationReason) {
258
+ // Surfaced as a failed job on purpose: the operator must see that the
259
+ // backlog is incomplete rather than a clean "imported N" summary.
260
+ logger.warn('history import stopped early', { channelId: channel.id, reason: truncationReason, processedCount })
261
+ await progressService.failJob(
262
+ progressJobId,
263
+ {
264
+ errorMessage: `History import stopped early after ${processedCount} messages: ${truncationReason}`,
265
+ },
266
+ progressContext,
267
+ )
268
+ return
228
269
  }
229
270
 
230
271
  await progressService.completeJob(
@@ -470,8 +470,14 @@ export async function POST(req: Request) {
470
470
  sourceHint.data,
471
471
  )
472
472
  : undefined
473
- const { sourceChannelType: _clientSuppliedChannelType, ...clientBody } =
474
- (body ?? {}) as Record<string, unknown>
473
+ // `inboundFromChannel` is likewise server-only (#6093): channel ingest sets
474
+ // it so an inbound message can be addressed to the conversation's assignee,
475
+ // and a request body must not be able to waive the recipients rule with it.
476
+ const {
477
+ sourceChannelType: _clientSuppliedChannelType,
478
+ inboundFromChannel: _clientSuppliedInboundFlag,
479
+ ...clientBody
480
+ } = (body ?? {}) as Record<string, unknown>
475
481
  const input = composeMessageSchema.parse({
476
482
  ...clientBody,
477
483
  ...(sourceChannelType ? { sourceChannelType } : {}),
@@ -30,6 +30,13 @@ type MessageSentEventPayload = {
30
30
  senderUserId: string
31
31
  recipientUserIds: string[]
32
32
  sendViaEmail: boolean
33
+ /**
34
+ * The message arrived from an external channel (#6093). Its `senderUserId`
35
+ * is the channel's system user and its real correspondent lives in
36
+ * `externalName` / `externalEmail`; subscribers that show a sender read it
37
+ * from there.
38
+ */
39
+ inboundFromChannel?: boolean
33
40
  externalEmail?: string | null
34
41
  forwardedFrom?: string
35
42
  replyTo?: string
@@ -303,7 +310,13 @@ const composeMessageCommand: CommandHandler<unknown, { id: string; threadId: str
303
310
  : undefined
304
311
 
305
312
  const isPublicVisibility = input.visibility === 'public'
306
- const sendViaEmail = isPublicVisibility ? true : input.sendViaEmail
313
+ // #6093: a channel-ingested message was RECEIVED, not sent. Ingest passes
314
+ // `sendViaEmail: false` on purpose; forcing it for public visibility would
315
+ // mail the assignee a copy and echo the message to its external sender
316
+ // (#6089) the moment the recipients waiver lets it compose. #6090 removes
317
+ // the forcing for every caller; until then it is skipped here for the
318
+ // ingest path only.
319
+ const sendViaEmail = isPublicVisibility && !input.inboundFromChannel ? true : input.sendViaEmail
307
320
  const message = trx.create(Message, {
308
321
  type: input.type,
309
322
  visibility: input.visibility ?? null,
@@ -407,7 +420,9 @@ const composeMessageCommand: CommandHandler<unknown, { id: string; threadId: str
407
420
  messageId,
408
421
  senderUserId: input.userId,
409
422
  recipientUserIds: input.recipients.map((recipient) => recipient.userId),
410
- sendViaEmail: input.visibility === 'public' ? true : input.sendViaEmail,
423
+ sendViaEmail:
424
+ input.visibility === 'public' && !input.inboundFromChannel ? true : input.sendViaEmail,
425
+ inboundFromChannel: input.inboundFromChannel === true,
411
426
  externalEmail: responseExternalEmail,
412
427
  tenantId: input.tenantId,
413
428
  organizationId: input.organizationId,
@@ -116,6 +116,16 @@ const composeMessageBaseSchema = z.object({
116
116
  * asserting its own channel type.
117
117
  */
118
118
  sourceChannelType: z.string().min(1).max(64).optional(),
119
+ /**
120
+ * Set by channel ingest for a message that arrived FROM an external channel
121
+ * (#6093). On such a message `recipients` are not external addressees — a
122
+ * public message has none — but the internal users the hub routes it to
123
+ * (the conversation's assignee), so the "no recipients on a public message"
124
+ * rule does not apply. Server-only like `sourceChannelType`: the HTTP route
125
+ * strips any client-sent value, so a caller cannot waive the rule by
126
+ * asserting its message came in from a channel.
127
+ */
128
+ inboundFromChannel: z.boolean().optional(),
119
129
  recipients: z.array(messageRecipientSchema).max(100).optional().default([]),
120
130
  subject: z.string().max(500).optional().default(''),
121
131
  body: z.string().max(50000).optional().default(''),
@@ -155,7 +165,11 @@ function refineComposeMessage(value: ComposeMessageRefinementValue, ctx: z.Refin
155
165
  message: 'externalEmail is required when visibility is public',
156
166
  })
157
167
  }
158
- if (recipientCount > 0) {
168
+ // #6093: a channel-ingested message is addressed to the assignee of its
169
+ // conversation; that recipient is internal routing, not a second
170
+ // external addressee, so only a user-composed public message is bound
171
+ // by this rule.
172
+ if (recipientCount > 0 && !value.inboundFromChannel) {
159
173
  ctx.addIssue({
160
174
  code: z.ZodIssueCode.custom,
161
175
  path: ['recipients'],
@@ -199,22 +213,23 @@ function refineComposeMessage(value: ComposeMessageRefinementValue, ctx: z.Refin
199
213
  }
200
214
 
201
215
  /**
202
- * Full compose contract, including the server-resolved `sourceChannelType`.
203
- * Used by the `messages.messages.compose` command and by the HTTP route AFTER
204
- * it has resolved the channel type itself.
216
+ * Full compose contract, including the server-resolved `sourceChannelType` and
217
+ * the ingest-only `inboundFromChannel`. Used by the `messages.messages.compose`
218
+ * command and by the HTTP route AFTER it has resolved the channel type itself.
205
219
  */
206
220
  export const composeMessageSchema = composeMessageBaseSchema.superRefine(refineComposeMessage)
207
221
 
208
222
  /**
209
- * Client-facing compose contract — the same rules minus `sourceChannelType`,
210
- * which is never accepted from a request body (#4975). Published in OpenAPI so
211
- * the documented request shape matches what `POST /api/messages` actually reads:
212
- * the route discards any client-sent channel type, resolves the real one from
213
- * the referenced conversation or parent message, and only then validates against
214
- * {@link composeMessageSchema}.
223
+ * Client-facing compose contract — the same rules minus the server-only fields:
224
+ * `sourceChannelType` is never accepted from a request body (#4975) and neither
225
+ * is `inboundFromChannel` (#6093). Published in OpenAPI so the documented
226
+ * request shape matches what `POST /api/messages` actually reads: the route
227
+ * discards any client-sent value for these, resolves the real channel type from
228
+ * the referenced conversation or parent message, and only then validates
229
+ * against {@link composeMessageSchema}.
215
230
  */
216
231
  export const composeMessageRequestSchema = composeMessageBaseSchema
217
- .omit({ sourceChannelType: true })
232
+ .omit({ sourceChannelType: true, inboundFromChannel: true })
218
233
  .superRefine(refineComposeMessage)
219
234
 
220
235
  export const updateDraftSchema = z.object({
@@ -22,6 +22,8 @@ type MessageSentPayload = {
22
22
  senderUserId: string
23
23
  recipientUserIds: string[]
24
24
  sendViaEmail: boolean
25
+ /** Set by compose for channel-ingested messages (#6093); see `resolveNotificationVariables`. */
26
+ inboundFromChannel?: boolean
25
27
  externalEmail?: string | null
26
28
  forwardedFrom?: string
27
29
  replyTo?: string
@@ -72,13 +74,23 @@ async function resolveNotificationVariables(payload: MessageSentPayload, ctx: Re
72
74
  ),
73
75
  ])
74
76
 
77
+ // #6093: a channel-ingested message is composed on behalf of the channel's
78
+ // system user (or, without one, the assignee), so `senderUserId` is not who
79
+ // wrote it. The correspondent is on the message itself.
80
+ const externalFrom = payload.inboundFromChannel
81
+ ? [message?.externalName, message?.externalEmail].find(
82
+ (value): value is string => typeof value === 'string' && value.trim().length > 0,
83
+ )
84
+ : undefined
85
+
75
86
  return {
76
87
  title: typeof message?.subject === 'string' ? message.subject : '',
77
- from: typeof sender?.name === 'string' && sender.name.trim().length > 0
78
- ? sender.name
79
- : typeof sender?.email === 'string'
80
- ? sender.email
81
- : '',
88
+ from: externalFrom
89
+ ?? (typeof sender?.name === 'string' && sender.name.trim().length > 0
90
+ ? sender.name
91
+ : typeof sender?.email === 'string'
92
+ ? sender.email
93
+ : ''),
82
94
  }
83
95
  } catch {
84
96
  return { title: '', from: '' }
@@ -38,6 +38,7 @@ import { resolveListCountCap } from '@open-mercato/shared/lib/query/count-cap'
38
38
  import { mapWithConcurrency } from '@open-mercato/shared/lib/query/bounded-decrypt'
39
39
  import { createLogger } from '@open-mercato/shared/lib/logger'
40
40
  import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
41
+ import { createBoundedTtlMemo } from '@open-mercato/shared/lib/query/bounded-ttl-memo'
41
42
 
42
43
  const logger = createLogger('query_index').child({ component: 'engine' })
43
44
 
@@ -83,6 +84,24 @@ function markAutoReindexScheduled(key: string, debounceMs: number, now: number):
83
84
  return true
84
85
  }
85
86
 
87
+ /** Only `true` is cached: a cached `false` would keep routing to the fallback engine after a migration creates the table. */
88
+ const baseTableExistsCache = createBoundedTtlMemo<true>({
89
+ ttlEnv: 'OM_QUERY_INDEX_BASE_TABLE_EXISTS_CACHE_MS',
90
+ maxEntriesEnv: 'OM_QUERY_INDEX_BASE_TABLE_EXISTS_CACHE_MAX_ENTRIES',
91
+ defaultTtlMs: 3_600_000,
92
+ defaultMaxEntries: 1_000,
93
+ })
94
+
95
+ /** Test-only: the module-scoped memo would otherwise leak state across specs. */
96
+ export function clearBaseTableExistsCache(): void {
97
+ baseTableExistsCache.clear()
98
+ }
99
+
100
+ /** Test-only: entry count of the table-existence memo, for the cap regression test. */
101
+ export function baseTableExistsCacheSize(): number {
102
+ return baseTableExistsCache.size()
103
+ }
104
+
86
105
  function buildFilterableCustomFieldJoins(
87
106
  sources: QueryCustomFieldSource[] | undefined,
88
107
  ): Array<{
@@ -2162,13 +2181,16 @@ export class HybridQueryEngine implements QueryEngine {
2162
2181
  }
2163
2182
 
2164
2183
  private async tableExists(table: string): Promise<boolean> {
2184
+ if (baseTableExistsCache.get(table)) return true
2165
2185
  const db = this.getDb() as any
2166
2186
  const exists = await db
2167
2187
  .selectFrom('information_schema.tables')
2168
2188
  .select(sql<number>`1`.as('one'))
2169
2189
  .where('table_name', '=', table)
2170
2190
  .executeTakeFirst()
2171
- return !!exists
2191
+ const present = !!exists
2192
+ if (present) baseTableExistsCache.set(table, true)
2193
+ return present
2172
2194
  }
2173
2195
 
2174
2196
  private async resolveAvailableCustomFieldKeys(entityIds: string[], tenantId: string | null): Promise<string[]> {
@@ -6,8 +6,7 @@ Use the sales module for orders, quotes, invoices, shipments, and payments. This
6
6
 
7
7
  1. **MUST use `salesCalculationService` from DI** for document math.
8
8
  2. **MUST follow document flow**: Quote → Order → Invoice — no skipping steps
9
- 3. **MUST use `selectBestPrice`** from catalog pricing helpers.
10
- 4. **MUST scope all documents to a channel** — channel selection affects pricing, numbering, and visibility
9
+ 3. **MUST scope all documents to a channel** — channel selection affects pricing, numbering, and visibility
11
10
 
12
11
  ## Ask First
13
12
 
@@ -51,7 +50,7 @@ const calcService = container.resolve('salesCalculationService')
51
50
 
52
51
  - Dispatches `sales.line.calculate.*` / `sales.document.calculate.*` events
53
52
  - Register line/totals calculators or override via DI
54
- - For catalog pricing: use `selectBestPrice`, `resolvePriceVariantId` from catalog module
53
+ - **Not yet wired:** no code here calls catalog's `selectBestPrice`/`catalogPricingService` — lines are priced directly, not resolved from `CatalogProductPrice`. Out of scope for the pricing-engine spec; future wiring routes through `catalogPricingService` — never reimplement pricing inline.
55
54
 
56
55
  ## Data Model Constraints
57
56