@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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/lib/storage.js +15 -1
  3. package/dist/modules/attachments/lib/storage.js.map +2 -2
  4. package/dist/modules/auth/api/reset/confirm.js +3 -2
  5. package/dist/modules/auth/api/reset/confirm.js.map +2 -2
  6. package/dist/modules/auth/api/reset.js +2 -1
  7. package/dist/modules/auth/api/reset.js.map +2 -2
  8. package/dist/modules/auth/di.js +6 -6
  9. package/dist/modules/auth/di.js.map +2 -2
  10. package/dist/modules/auth/lib/tenantHint.js +29 -0
  11. package/dist/modules/auth/lib/tenantHint.js.map +7 -0
  12. package/dist/modules/customer_accounts/di.js +3 -3
  13. package/dist/modules/customer_accounts/di.js.map +2 -2
  14. package/dist/modules/customer_accounts/lib/customerAuth.js +18 -7
  15. package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
  16. package/dist/modules/customer_accounts/services/customerSessionService.js +23 -0
  17. package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
  18. package/dist/modules/customers/ai-tools/activities-tasks-pack.js +28 -24
  19. package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +2 -2
  20. package/dist/modules/customers/commands/companies.js +5 -0
  21. package/dist/modules/customers/commands/companies.js.map +2 -2
  22. package/dist/modules/data_sync/lib/sync-engine.js +3 -7
  23. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  24. package/dist/modules/data_sync/lib/sync-schedule-service.js +52 -3
  25. package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
  26. package/dist/modules/progress/lib/progressServiceImpl.js +1 -1
  27. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  28. package/package.json +7 -7
  29. package/src/modules/attachments/lib/storage.ts +24 -1
  30. package/src/modules/auth/api/reset/confirm.ts +3 -2
  31. package/src/modules/auth/api/reset.ts +2 -1
  32. package/src/modules/auth/di.ts +6 -6
  33. package/src/modules/auth/lib/tenantHint.ts +49 -0
  34. package/src/modules/customer_accounts/di.ts +3 -3
  35. package/src/modules/customer_accounts/lib/customerAuth.ts +36 -7
  36. package/src/modules/customer_accounts/services/customerSessionService.ts +28 -0
  37. package/src/modules/customers/ai-tools/activities-tasks-pack.ts +49 -39
  38. package/src/modules/customers/commands/companies.ts +8 -0
  39. package/src/modules/data_sync/AGENTS.md +1 -1
  40. package/src/modules/data_sync/lib/sync-engine.ts +23 -12
  41. package/src/modules/data_sync/lib/sync-schedule-service.ts +54 -3
  42. 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 a non-null
81
- * `entity_id` (the timeline owner) so this helper resolves the deal's
82
- * first linked person, then falls back to its first linked company. When
83
- * neither exists, the caller MUST instruct the operator to link a contact
84
- * before commenting on the deal.
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 resolveDealCommentEntityId(
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, tenantId } as never,
95
- { populate: ['personEntity'] as never },
99
+ { deal: dealId } as never,
100
+ {
101
+ populate: ['person'] as never,
102
+ orderBy: { isPrimary: 'DESC', createdAt: 'ASC', id: 'ASC' },
103
+ },
96
104
  )
97
- if (personLink) {
98
- const linked = (personLink as unknown as { personEntity?: { id?: string | null } | null }).personEntity
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, tenantId } as never,
106
- { populate: ['companyEntity'] as never },
109
+ { deal: dealId } as never,
110
+ {
111
+ populate: ['company'] as never,
112
+ orderBy: { createdAt: 'ASC', id: 'ASC' },
113
+ },
107
114
  )
108
- if (companyLink) {
109
- const linked = (companyLink as unknown as { companyEntity?: { id?: string | null } | null }).companyEntity
110
- if (linked && typeof linked === 'object' && typeof linked.id === 'string') return linked.id
111
- const raw = (companyLink as unknown as { companyEntity?: unknown }).companyEntity
112
- if (typeof raw === 'string') return raw
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 resolveDealCommentEntityId(em, ctx, tenantId, deal.id)
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
- const dealEntity = (deal as unknown as { entity?: unknown }).entity
660
- const entityId =
661
- dealEntity && typeof dealEntity === 'object'
662
- ? (dealEntity as { id?: string | null }).id ?? null
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(`Deal "${deal.id}" has no associated person/company; cannot attach an activity.`)
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 batches can outlast the 60s stale-job sweep — keep the `withHeartbeat` wrapper around `streamImport`/`streamExport` when touching the batch loops.
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 timer = setInterval(tick, intervalMs)
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. Swallows its own errors because it runs on a timer, where an
275
- // unhandled rejection is fatal, and stops polling once it has aborted.
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
- row.deletedAt = new Date()
242
- row.isEnabled = false
243
- await em.flush()
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 } : {}),