@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js +166 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js +16 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.js +68 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js +25 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/page.js +11 -0
- package/dist/modules/catalog/backend/catalog/prices/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/page.meta.js +16 -0
- package/dist/modules/catalog/backend/catalog/prices/page.meta.js.map +7 -0
- package/dist/modules/catalog/components/prices/PriceScopeSelectors.js +201 -0
- package/dist/modules/catalog/components/prices/PriceScopeSelectors.js.map +7 -0
- package/dist/modules/catalog/components/prices/PricesDataTable.js +273 -0
- package/dist/modules/catalog/components/prices/PricesDataTable.js.map +7 -0
- package/dist/modules/catalog/components/prices/normalizePriceRecord.js +43 -0
- package/dist/modules/catalog/components/prices/normalizePriceRecord.js.map +7 -0
- package/dist/modules/catalog/components/prices/priceFormFields.js +195 -0
- package/dist/modules/catalog/components/prices/priceFormFields.js.map +7 -0
- package/dist/modules/catalog/components/prices/useBatchLabels.js +42 -0
- package/dist/modules/catalog/components/prices/useBatchLabels.js.map +7 -0
- package/dist/modules/catalog/data/validators.js +21 -2
- package/dist/modules/catalog/data/validators.js.map +2 -2
- package/dist/modules/catalog/extension-points.js +2 -1
- package/dist/modules/catalog/extension-points.js.map +2 -2
- package/dist/modules/catalog/lib/pricing.js +37 -8
- package/dist/modules/catalog/lib/pricing.js.map +2 -2
- package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js +5 -1
- package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js.map +2 -2
- package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js +88 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js.map +7 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +17 -19
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
- package/dist/modules/communication_channels/commands/connect-credential-channel.js +7 -2
- package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js +11 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/communication_channels/commands/queue-import-history.js +40 -7
- package/dist/modules/communication_channels/commands/queue-import-history.js.map +2 -2
- package/dist/modules/communication_channels/extension-points.js +1 -1
- package/dist/modules/communication_channels/extension-points.js.map +1 -1
- package/dist/modules/communication_channels/lib/connect-channel.js +33 -0
- package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
- package/dist/modules/communication_channels/lib/import-history-limits.js +69 -0
- package/dist/modules/communication_channels/lib/import-history-limits.js.map +7 -0
- package/dist/modules/communication_channels/workers/channel-import-history.js +36 -2
- package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
- package/dist/modules/messages/api/route.js +5 -1
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/commands/messages.js +3 -2
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/data/validators.js +12 -2
- package/dist/modules/messages/data/validators.js.map +2 -2
- package/dist/modules/messages/subscribers/message-notification.js +4 -1
- package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +19 -1
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/AGENTS.md +6 -2
- package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.ts +12 -0
- package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.tsx +198 -0
- package/src/modules/catalog/backend/catalog/prices/create/page.meta.ts +23 -0
- package/src/modules/catalog/backend/catalog/prices/create/page.tsx +70 -0
- package/src/modules/catalog/backend/catalog/prices/page.meta.ts +12 -0
- package/src/modules/catalog/backend/catalog/prices/page.tsx +14 -0
- package/src/modules/catalog/components/prices/PriceScopeSelectors.tsx +243 -0
- package/src/modules/catalog/components/prices/PricesDataTable.tsx +320 -0
- package/src/modules/catalog/components/prices/normalizePriceRecord.ts +71 -0
- package/src/modules/catalog/components/prices/priceFormFields.tsx +228 -0
- package/src/modules/catalog/components/prices/useBatchLabels.ts +57 -0
- package/src/modules/catalog/data/validators.ts +43 -2
- package/src/modules/catalog/extension-points.ts +1 -0
- package/src/modules/catalog/i18n/de.json +72 -0
- package/src/modules/catalog/i18n/en.json +72 -0
- package/src/modules/catalog/i18n/es.json +72 -0
- package/src/modules/catalog/i18n/ko.json +72 -0
- package/src/modules/catalog/i18n/pl.json +72 -0
- package/src/modules/catalog/lib/pricing.ts +103 -9
- package/src/modules/communication_channels/api/post/channels/[id]/import-history/route.ts +6 -1
- package/src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx +120 -0
- package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +20 -17
- package/src/modules/communication_channels/commands/connect-credential-channel.ts +9 -0
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +17 -2
- package/src/modules/communication_channels/commands/queue-import-history.ts +59 -11
- package/src/modules/communication_channels/extension-points.ts +1 -1
- package/src/modules/communication_channels/i18n/de.json +4 -3
- package/src/modules/communication_channels/i18n/en.json +4 -3
- package/src/modules/communication_channels/i18n/es.json +4 -3
- package/src/modules/communication_channels/i18n/ko.json +4 -3
- package/src/modules/communication_channels/i18n/pl.json +4 -3
- package/src/modules/communication_channels/lib/adapter.ts +16 -2
- package/src/modules/communication_channels/lib/connect-channel.ts +49 -0
- package/src/modules/communication_channels/lib/import-history-limits.ts +109 -0
- package/src/modules/communication_channels/workers/channel-import-history.ts +46 -5
- package/src/modules/messages/api/route.ts +8 -2
- package/src/modules/messages/commands/messages.ts +17 -2
- package/src/modules/messages/data/validators.ts +26 -11
- package/src/modules/messages/subscribers/message-notification.ts +17 -5
- package/src/modules/query_index/lib/engine.ts +23 -1
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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`
|
|
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
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|