@open-mercato/core 0.8.1-develop.7241.1.fad7b2ce2a → 0.8.1-develop.7254.1.ab24f26208
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/attachments/lib/storage.js +15 -1
- package/dist/modules/attachments/lib/storage.js.map +2 -2
- package/dist/modules/auth/api/reset/confirm.js +3 -2
- package/dist/modules/auth/api/reset/confirm.js.map +2 -2
- package/dist/modules/auth/api/reset.js +2 -1
- package/dist/modules/auth/api/reset.js.map +2 -2
- package/dist/modules/auth/di.js +6 -6
- package/dist/modules/auth/di.js.map +2 -2
- package/dist/modules/auth/lib/tenantHint.js +29 -0
- package/dist/modules/auth/lib/tenantHint.js.map +7 -0
- package/dist/modules/customer_accounts/di.js +3 -3
- package/dist/modules/customer_accounts/di.js.map +2 -2
- package/dist/modules/customer_accounts/lib/customerAuth.js +18 -7
- package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerSessionService.js +23 -0
- package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js +28 -24
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +2 -2
- package/dist/modules/customers/commands/companies.js +5 -0
- package/dist/modules/customers/commands/companies.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +3 -7
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-schedule-service.js +52 -3
- package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
- package/dist/modules/progress/lib/progressServiceImpl.js +1 -1
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/lib/storage.ts +24 -1
- package/src/modules/auth/api/reset/confirm.ts +3 -2
- package/src/modules/auth/api/reset.ts +2 -1
- package/src/modules/auth/di.ts +6 -6
- package/src/modules/auth/lib/tenantHint.ts +49 -0
- package/src/modules/customer_accounts/di.ts +3 -3
- package/src/modules/customer_accounts/lib/customerAuth.ts +36 -7
- package/src/modules/customer_accounts/services/customerSessionService.ts +28 -0
- package/src/modules/customers/ai-tools/activities-tasks-pack.ts +49 -39
- package/src/modules/customers/commands/companies.ts +8 -0
- package/src/modules/data_sync/AGENTS.md +1 -1
- package/src/modules/data_sync/lib/sync-engine.ts +23 -12
- package/src/modules/data_sync/lib/sync-schedule-service.ts +54 -3
- package/src/modules/progress/lib/progressServiceImpl.ts +7 -1
|
@@ -77,41 +77,56 @@ async function loadDealForScope(
|
|
|
77
77
|
/**
|
|
78
78
|
* `CustomerDeal` does NOT carry a direct `entity` field — deals are linked
|
|
79
79
|
* to people / companies via the `customer_deal_person_links` and
|
|
80
|
-
* `customer_deal_company_links` tables. Comments however need
|
|
81
|
-
* `entity_id` (the timeline owner) so this helper resolves the
|
|
82
|
-
*
|
|
83
|
-
* neither exists, the caller MUST instruct the operator to link a
|
|
84
|
-
* before
|
|
80
|
+
* `customer_deal_company_links` tables. Comments and activities however need
|
|
81
|
+
* a non-null `entity_id` (the timeline owner) so this helper resolves the
|
|
82
|
+
* deal's primary linked person, then the oldest person, then the oldest company.
|
|
83
|
+
* When neither exists, the caller MUST instruct the operator to link a
|
|
84
|
+
* contact before writing to the deal.
|
|
85
|
+
*
|
|
86
|
+
* The relations are `CustomerDealPersonLink.person` and
|
|
87
|
+
* `CustomerDealCompanyLink.company` (#6119): populating them under any other
|
|
88
|
+
* name makes MikroORM reject the query, so every write failed even for a deal
|
|
89
|
+
* with linked contacts.
|
|
90
|
+
*
|
|
91
|
+
* The link tables carry no `tenant_id` / `organization_id` column either, so the
|
|
92
|
+
* deal id is the whole criterion — the same query `api/deals/[id]/route.ts` runs.
|
|
93
|
+
* Callers pass a deal already returned by `loadDealForScope`; that check is what
|
|
94
|
+
* keeps the lookup inside the caller's tenant and organization.
|
|
85
95
|
*/
|
|
86
|
-
async function
|
|
87
|
-
em: EntityManager,
|
|
88
|
-
ctx: CustomersToolContext,
|
|
89
|
-
tenantId: string,
|
|
90
|
-
dealId: string,
|
|
91
|
-
): Promise<string | null> {
|
|
96
|
+
async function resolveDealLinkedEntityId(em: EntityManager, dealId: string): Promise<string | null> {
|
|
92
97
|
const personLink = await em.findOne(
|
|
93
98
|
CustomerDealPersonLink,
|
|
94
|
-
{ deal: dealId
|
|
95
|
-
{
|
|
99
|
+
{ deal: dealId } as never,
|
|
100
|
+
{
|
|
101
|
+
populate: ['person'] as never,
|
|
102
|
+
orderBy: { isPrimary: 'DESC', createdAt: 'ASC', id: 'ASC' },
|
|
103
|
+
},
|
|
96
104
|
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (linked && typeof linked === 'object' && typeof linked.id === 'string') return linked.id
|
|
100
|
-
const raw = (personLink as unknown as { personEntity?: unknown }).personEntity
|
|
101
|
-
if (typeof raw === 'string') return raw
|
|
102
|
-
}
|
|
105
|
+
const personId = linkedEntityIdOf(personLink, 'person')
|
|
106
|
+
if (personId) return personId
|
|
103
107
|
const companyLink = await em.findOne(
|
|
104
108
|
CustomerDealCompanyLink,
|
|
105
|
-
{ deal: dealId
|
|
106
|
-
{
|
|
109
|
+
{ deal: dealId } as never,
|
|
110
|
+
{
|
|
111
|
+
populate: ['company'] as never,
|
|
112
|
+
orderBy: { createdAt: 'ASC', id: 'ASC' },
|
|
113
|
+
},
|
|
107
114
|
)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
return linkedEntityIdOf(companyLink, 'company')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Id of a populated relation, or the raw foreign key when it was not loaded. */
|
|
119
|
+
function linkedEntityIdOf(row: unknown, relation: 'person' | 'company'): string | null {
|
|
120
|
+
if (!row || typeof row !== 'object') return null
|
|
121
|
+
const value = (row as Record<string, unknown>)[relation]
|
|
122
|
+
if (value && typeof value === 'object' && typeof (value as { id?: unknown }).id === 'string') {
|
|
123
|
+
return (value as { id: string }).id
|
|
113
124
|
}
|
|
114
|
-
return null
|
|
125
|
+
return typeof value === 'string' ? value : null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function dealHasNoLinkedContactMessage(dealId: string, what: 'comment' | 'activity'): string {
|
|
129
|
+
return `Deal "${dealId}" has no linked person or company. Link a contact to the deal in the backoffice before adding ${what === 'comment' ? 'a comment' : 'an activity'}, or post the ${what} directly on the person/company record instead.`
|
|
115
130
|
}
|
|
116
131
|
|
|
117
132
|
async function loadCommentForScope(
|
|
@@ -493,11 +508,9 @@ const manageDealCommentTool: CustomersAiToolDefinition = {
|
|
|
493
508
|
// people/companies via two link tables. Resolve the first available
|
|
494
509
|
// person, then fall back to the first linked company. Only fail the
|
|
495
510
|
// operation when the deal has no linked contacts at all.
|
|
496
|
-
const dealEntityId = await
|
|
511
|
+
const dealEntityId = await resolveDealLinkedEntityId(em, deal.id)
|
|
497
512
|
if (!dealEntityId) {
|
|
498
|
-
throw new Error(
|
|
499
|
-
`Deal "${deal.id}" has no linked person or company. Link a contact to the deal in the backoffice before adding a comment, or post the comment directly on the person/company record instead.`,
|
|
500
|
-
)
|
|
513
|
+
throw new Error(dealHasNoLinkedContactMessage(deal.id, 'comment'))
|
|
501
514
|
}
|
|
502
515
|
const body: Record<string, unknown> = {
|
|
503
516
|
tenantId,
|
|
@@ -656,15 +669,12 @@ const manageDealActivityTool: CustomersAiToolDefinition = {
|
|
|
656
669
|
if (!deal) throw new Error(`Deal "${input.dealId}" is not accessible to the caller.`)
|
|
657
670
|
const organizationId = deal.organizationId
|
|
658
671
|
if (!organizationId) throw new Error(`Deal "${deal.id}" has no organization scope.`)
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
: typeof dealEntity === 'string'
|
|
664
|
-
? dealEntity
|
|
665
|
-
: null
|
|
672
|
+
// Same rule as the comment tool (#6119): `CustomerDeal` has no `.entity`,
|
|
673
|
+
// the activity's timeline owner is the deal's first linked person, then
|
|
674
|
+
// its first linked company.
|
|
675
|
+
const entityId = await resolveDealLinkedEntityId(em, deal.id)
|
|
666
676
|
if (!entityId) {
|
|
667
|
-
throw new Error(
|
|
677
|
+
throw new Error(dealHasNoLinkedContactMessage(deal.id, 'activity'))
|
|
668
678
|
}
|
|
669
679
|
const body: Record<string, unknown> = {
|
|
670
680
|
tenantId,
|
|
@@ -1035,6 +1035,14 @@ const deleteCompanyCommand: CommandHandler<{ body?: Record<string, unknown>; que
|
|
|
1035
1035
|
}
|
|
1036
1036
|
|
|
1037
1037
|
await em.nativeUpdate(CustomerPersonProfile, { company: record }, { company: null })
|
|
1038
|
+
// Soft-deleted person links keep their `company_entity_id` foreign key, so the hard
|
|
1039
|
+
// delete below would violate it. The guard above already proved no active link
|
|
1040
|
+
// remains, and restricting the filter to `deletedAt != null` keeps that guarantee.
|
|
1041
|
+
await em.nativeDelete(CustomerPersonCompanyLink, {
|
|
1042
|
+
company: record,
|
|
1043
|
+
deletedAt: { $ne: null },
|
|
1044
|
+
...dependentScope,
|
|
1045
|
+
})
|
|
1038
1046
|
await em.nativeDelete(CustomerDealCompanyLink, { company: record })
|
|
1039
1047
|
await em.nativeDelete(CustomerActivity, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
|
|
1040
1048
|
await em.nativeDelete(CustomerInteraction, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
|
|
@@ -288,7 +288,7 @@ Data sync providers can leverage the **Unified Module Extension System (UMES)**
|
|
|
288
288
|
|
|
289
289
|
- `ProgressTopBar` and sync-run detail pages use `progress.job.*` SSE updates for live progress.
|
|
290
290
|
- Create `ProgressJob` in `run`/`retry` endpoints; start/update/complete/fail in `sync-engine`.
|
|
291
|
-
- The engine heartbeats (`touchJobHeartbeat`, forked-EM) on a timer while an adapter batch is being produced, because
|
|
291
|
+
- The engine heartbeats (`touchJobHeartbeat`, forked-EM) on a timer for the whole life of the batch stream — while an adapter batch is being produced AND while the per-batch consumer body commits progress, refreshes coverage snapshots and writes logs — because either side can outlast the 60s stale-job sweep. Keep the `withHeartbeat` wrapper around `streamImport`/`streamExport` when touching the batch loops, and keep its interval armed once per stream rather than restarted per read. Because that interval now fires during the consumer body, where `commitBatchProgress` holds the shared `EntityManager` inside `em.begin()`/`em.commit()`, **every callback on this timer MUST use a forked EM** — both `touchJobHeartbeat` and the `isCancellationRequested` poll do. Adding a third tick callback that reads on the shared EM would interleave with that open transaction, and the tick swallows its own errors, so the damage would surface somewhere else entirely.
|
|
292
292
|
- On redelivery the progress counter is seeded from the progress job's own `processedCount` (`progressService.getJob`), mirroring how `committedBatches` resumes — never reset it to zero (`updateProgress` writes absolute counts) and never seed it from the run's `created/updated/skipped/failed` columns: those count emitted items, while progress counts source records (`batch.processedCount`), and adapters may emit several items per source record.
|
|
293
293
|
- Include `progressJob` details in run detail response.
|
|
294
294
|
- SSE DOM bridge forwards only events with `clientBroadcast: true`.
|
|
@@ -166,9 +166,6 @@ function applyExportCounters(batch: ExportBatch): SyncCounterDelta {
|
|
|
166
166
|
// the per-interval round-trips for the whole life of a run.
|
|
167
167
|
const HEARTBEAT_TICK_MS = (STALE_JOB_TIMEOUT_SECONDS * 1000) / 4
|
|
168
168
|
|
|
169
|
-
// Runs `tick` on an interval only while the source iterator is pending, so heartbeats
|
|
170
|
-
// stop the moment the producer dies and genuinely stale jobs still get swept. The outer
|
|
171
|
-
// finally closes the adapter generator on early exits (cancellation, ownership conflict).
|
|
172
169
|
// Our own abort, as opposed to a failure that merely coincided with one. Adapters are told to
|
|
173
170
|
// return rather than throw, but `signal.throwIfAborted()` and an aborted `fetch` both surface as
|
|
174
171
|
// this, and either is a cancellation rather than a fault.
|
|
@@ -182,21 +179,33 @@ function isAbortError(error: unknown): boolean {
|
|
|
182
179
|
return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'AbortError'
|
|
183
180
|
}
|
|
184
181
|
|
|
182
|
+
// The interval is armed once and spans the whole iteration, the `yield` included, rather than being
|
|
183
|
+
// restarted around each `next()`. Producing a batch is not the only thing that can outlast the stale
|
|
184
|
+
// sweep: the consumer body between `yield` and the next `next()` commits batch progress, refreshes
|
|
185
|
+
// coverage snapshots, logs item failures and writes the operational log, and a coverage refresh over
|
|
186
|
+
// a large query index is slow enough that a per-read timer left that window unheartbeated (#5370).
|
|
187
|
+
// The generator body does not start until the first `next()`, so a stream nobody consumes still arms
|
|
188
|
+
// nothing. Every way out of `forEachBatch` — drain, early close, a throwing read, a throwing consumer —
|
|
189
|
+
// either completes this generator or calls `return()` on it, so the `finally` below always clears the
|
|
190
|
+
// timer. That last part is a property of the consumer rather than of this helper: a consumer that
|
|
191
|
+
// abandoned the generator mid-`yield` without closing it would leak the interval, which the old
|
|
192
|
+
// per-read arming could not do. Any new consumer must honour `IteratorClose`.
|
|
193
|
+
//
|
|
194
|
+
// Every callback on this timer MUST use a forked EntityManager. The window now covers the consumer
|
|
195
|
+
// body, where `commitBatchProgress` holds the shared EM inside `em.begin()`/`em.commit()`, so a tick
|
|
196
|
+
// that read on the shared EM would issue a query into that open transaction and interleave with its
|
|
197
|
+
// UnitOfWork — the shape `packages/core/AGENTS.md` bans and `withAtomicFlush` cannot guard against.
|
|
185
198
|
async function* withHeartbeat<T>(source: AsyncIterable<T>, tick: () => void, intervalMs: number): AsyncGenerator<T, void, undefined> {
|
|
186
199
|
const iterator = source[Symbol.asyncIterator]()
|
|
200
|
+
const timer = setInterval(tick, intervalMs)
|
|
187
201
|
try {
|
|
188
202
|
while (true) {
|
|
189
|
-
const
|
|
190
|
-
let result: IteratorResult<T>
|
|
191
|
-
try {
|
|
192
|
-
result = await iterator.next()
|
|
193
|
-
} finally {
|
|
194
|
-
clearInterval(timer)
|
|
195
|
-
}
|
|
203
|
+
const result = await iterator.next()
|
|
196
204
|
if (result.done) return
|
|
197
205
|
yield result.value
|
|
198
206
|
}
|
|
199
207
|
} finally {
|
|
208
|
+
clearInterval(timer)
|
|
200
209
|
await iterator.return?.()
|
|
201
210
|
}
|
|
202
211
|
}
|
|
@@ -271,8 +280,10 @@ export function createSyncEngine(deps: EngineDeps) {
|
|
|
271
280
|
|
|
272
281
|
// Rides the heartbeat timer, which is the only thing that runs while the adapter is still
|
|
273
282
|
// producing a batch — the engine's own cancellation check sits in the batch handler and is
|
|
274
|
-
// reached only after a yield.
|
|
275
|
-
//
|
|
283
|
+
// reached only after a yield. Since that timer now spans the consumer body too, a cancel is
|
|
284
|
+
// also observed during the per-batch bookkeeping rather than waiting out the next read.
|
|
285
|
+
// Swallows its own errors because it runs on a timer, where an unhandled rejection is fatal,
|
|
286
|
+
// and stops polling once it has aborted.
|
|
276
287
|
function makeCancellationTick(progressJobId: string | null | undefined, scope: SyncScope, controller: AbortController): () => void {
|
|
277
288
|
if (!progressJobId) return () => {}
|
|
278
289
|
let inFlight = false
|
|
@@ -3,8 +3,12 @@ import type { AwilixContainer } from 'awilix'
|
|
|
3
3
|
import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
|
|
4
4
|
import { findAndCountWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
5
|
import { enforceCommandOptimisticLockWithGuards, enforceRecordGoneIsConflict } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
6
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
7
|
+
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
6
8
|
import { SyncSchedule } from '../data/entities'
|
|
7
9
|
|
|
10
|
+
const logger = createLogger('data_sync').child({ component: 'sync-schedule-service' })
|
|
11
|
+
|
|
8
12
|
type SyncScope = {
|
|
9
13
|
organizationId: string
|
|
10
14
|
tenantId: string
|
|
@@ -236,11 +240,58 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
|
|
|
236
240
|
}
|
|
237
241
|
|
|
238
242
|
const scheduledJobId = row.scheduledJobId ?? row.id
|
|
243
|
+
const wasEnabled = row.isEnabled
|
|
239
244
|
await requireScheduler().unregister(scheduledJobId)
|
|
240
245
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
246
|
+
try {
|
|
247
|
+
row.deletedAt = new Date()
|
|
248
|
+
row.isEnabled = false
|
|
249
|
+
await em.flush()
|
|
250
|
+
} catch (error) {
|
|
251
|
+
try {
|
|
252
|
+
await requireScheduler().register({
|
|
253
|
+
id: scheduledJobId,
|
|
254
|
+
name: buildScheduleName(row),
|
|
255
|
+
description: buildScheduleDescription(row),
|
|
256
|
+
scopeType: 'organization',
|
|
257
|
+
organizationId: scope.organizationId,
|
|
258
|
+
tenantId: scope.tenantId,
|
|
259
|
+
scheduleType: row.scheduleType,
|
|
260
|
+
scheduleValue: row.scheduleValue,
|
|
261
|
+
timezone: row.timezone,
|
|
262
|
+
targetType: 'queue',
|
|
263
|
+
targetQueue: 'data-sync-scheduled',
|
|
264
|
+
targetPayload: {
|
|
265
|
+
scheduleId: row.id,
|
|
266
|
+
scope,
|
|
267
|
+
},
|
|
268
|
+
requireFeature: 'data_sync.run',
|
|
269
|
+
sourceType: 'module',
|
|
270
|
+
sourceModule: 'data_sync',
|
|
271
|
+
isEnabled: wasEnabled,
|
|
272
|
+
})
|
|
273
|
+
} catch (compensationError) {
|
|
274
|
+
logger.error('Failed to restore scheduled job after a failed schedule delete', {
|
|
275
|
+
scheduledJobId,
|
|
276
|
+
scheduleId: row.id,
|
|
277
|
+
organizationId: scope.organizationId,
|
|
278
|
+
tenantId: scope.tenantId,
|
|
279
|
+
error: compensationError instanceof Error ? compensationError.message : String(compensationError),
|
|
280
|
+
})
|
|
281
|
+
getTelemetryRuntime()?.reportError(compensationError, {
|
|
282
|
+
module: 'data_sync',
|
|
283
|
+
code: 'data_sync.schedule_delete_compensation_failed',
|
|
284
|
+
attributes: {
|
|
285
|
+
scheduledJobId,
|
|
286
|
+
scheduleId: row.id,
|
|
287
|
+
organizationId: scope.organizationId,
|
|
288
|
+
tenantId: scope.tenantId,
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
throw error
|
|
293
|
+
}
|
|
294
|
+
|
|
244
295
|
return true
|
|
245
296
|
},
|
|
246
297
|
}
|
|
@@ -634,10 +634,16 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
634
634
|
},
|
|
635
635
|
|
|
636
636
|
async isCancellationRequested(jobId, tenantId, organizationId) {
|
|
637
|
+
// Forked EM, for the same reason touchJobHeartbeat forks: this is polled from the
|
|
638
|
+
// keepalive timer, which spans the producer's own transactional writes on the shared
|
|
639
|
+
// EM, so an unawaited poll would issue a SELECT into an open em.begin()/em.commit()
|
|
640
|
+
// window and interleave with its UnitOfWork. Every caller only reads the flag, so a
|
|
641
|
+
// fork costs nothing and no caller depends on joining an ambient transaction.
|
|
642
|
+
//
|
|
637
643
|
// disableIdentityMap forces a fresh read: a managed copy of the job in this
|
|
638
644
|
// EntityManager (loaded by updateProgress) must never mask a cancellation
|
|
639
645
|
// requested from another process.
|
|
640
|
-
const job = await findOneWithDecryption(em, ProgressJob, {
|
|
646
|
+
const job = await findOneWithDecryption(em.fork(), ProgressJob, {
|
|
641
647
|
id: jobId,
|
|
642
648
|
tenantId,
|
|
643
649
|
...(organizationId ? { organizationId } : {}),
|