@open-mercato/core 0.8.1-develop.7241.1.fad7b2ce2a → 0.8.1-develop.7261.1.5d42353f61

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 (87) 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/catalog/backend/catalog/products/[id]/page.js +11 -3
  13. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  14. package/dist/modules/customer_accounts/api/admin/users.js +27 -14
  15. package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
  16. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +9 -0
  17. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  18. package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/PortalUserDetailPageClient.js +8 -8
  19. package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/PortalUserDetailPageClient.js.map +2 -2
  20. package/dist/modules/customer_accounts/di.js +3 -3
  21. package/dist/modules/customer_accounts/di.js.map +2 -2
  22. package/dist/modules/customer_accounts/lib/customerAuth.js +18 -7
  23. package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
  24. package/dist/modules/customer_accounts/services/customerSessionService.js +23 -0
  25. package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
  26. package/dist/modules/customers/ai-tools/activities-tasks-pack.js +28 -24
  27. package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +2 -2
  28. package/dist/modules/customers/backend/customers/deals/map/page.js +10 -2
  29. package/dist/modules/customers/backend/customers/deals/map/page.js.map +2 -2
  30. package/dist/modules/customers/backend/customers/deals/page.js +10 -2
  31. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  32. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +41 -66
  33. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +2 -2
  34. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +6 -2
  35. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  36. package/dist/modules/customers/commands/companies.js +5 -0
  37. package/dist/modules/customers/commands/companies.js.map +2 -2
  38. package/dist/modules/customers/components/formConfig.js +3 -3
  39. package/dist/modules/customers/components/formConfig.js.map +2 -2
  40. package/dist/modules/customers/data/validators.js +3 -1
  41. package/dist/modules/customers/data/validators.js.map +2 -2
  42. package/dist/modules/data_sync/lib/sync-engine.js +3 -7
  43. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  44. package/dist/modules/data_sync/lib/sync-schedule-service.js +52 -3
  45. package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
  46. package/dist/modules/progress/lib/progressServiceImpl.js +1 -1
  47. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  48. package/package.json +7 -7
  49. package/src/modules/attachments/lib/storage.ts +24 -1
  50. package/src/modules/auth/api/reset/confirm.ts +3 -2
  51. package/src/modules/auth/api/reset.ts +2 -1
  52. package/src/modules/auth/di.ts +6 -6
  53. package/src/modules/auth/lib/tenantHint.ts +49 -0
  54. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +19 -5
  55. package/src/modules/catalog/i18n/de.json +1 -0
  56. package/src/modules/catalog/i18n/en.json +1 -0
  57. package/src/modules/catalog/i18n/es.json +1 -0
  58. package/src/modules/catalog/i18n/ko.json +1 -0
  59. package/src/modules/catalog/i18n/pl.json +1 -0
  60. package/src/modules/customer_accounts/api/admin/users.ts +30 -14
  61. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +11 -0
  62. package/src/modules/customer_accounts/backend/customer_accounts/users/[id]/PortalUserDetailPageClient.tsx +11 -11
  63. package/src/modules/customer_accounts/di.ts +3 -3
  64. package/src/modules/customer_accounts/i18n/de.json +1 -0
  65. package/src/modules/customer_accounts/i18n/en.json +1 -0
  66. package/src/modules/customer_accounts/i18n/es.json +1 -0
  67. package/src/modules/customer_accounts/i18n/ko.json +1 -0
  68. package/src/modules/customer_accounts/i18n/pl.json +1 -0
  69. package/src/modules/customer_accounts/lib/customerAuth.ts +36 -7
  70. package/src/modules/customer_accounts/services/customerSessionService.ts +28 -0
  71. package/src/modules/customers/ai-tools/activities-tasks-pack.ts +49 -39
  72. package/src/modules/customers/backend/customers/deals/map/page.tsx +13 -2
  73. package/src/modules/customers/backend/customers/deals/page.tsx +13 -2
  74. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +69 -56
  75. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +8 -2
  76. package/src/modules/customers/commands/companies.ts +8 -0
  77. package/src/modules/customers/components/formConfig.tsx +3 -3
  78. package/src/modules/customers/data/validators.ts +2 -1
  79. package/src/modules/customers/i18n/de.json +1 -0
  80. package/src/modules/customers/i18n/en.json +1 -0
  81. package/src/modules/customers/i18n/es.json +1 -0
  82. package/src/modules/customers/i18n/ko.json +1 -0
  83. package/src/modules/customers/i18n/pl.json +1 -0
  84. package/src/modules/data_sync/AGENTS.md +1 -1
  85. package/src/modules/data_sync/lib/sync-engine.ts +23 -12
  86. package/src/modules/data_sync/lib/sync-schedule-service.ts +54 -3
  87. package/src/modules/progress/lib/progressServiceImpl.ts +7 -1
@@ -59,7 +59,7 @@ import {
59
59
  type CustomerDictionaryKind,
60
60
  } from '../lib/dictionaries'
61
61
  import { normalizeCustomFieldSubmitValue } from './detail/customFieldUtils'
62
- import { CUSTOMER_PHONE_INVALID_MESSAGE_KEY } from '../data/validators'
62
+ import { CUSTOMER_PHONE_INVALID_MESSAGE_KEY, CUSTOMER_URL_INVALID_MESSAGE_KEY } from '../data/validators'
63
63
 
64
64
  export const metadata = {
65
65
  navHidden: true,
@@ -1199,7 +1199,7 @@ export const createCompanyFormSchema = () =>
1199
1199
  websiteUrl: z
1200
1200
  .string()
1201
1201
  .trim()
1202
- .url()
1202
+ .url(CUSTOMER_URL_INVALID_MESSAGE_KEY)
1203
1203
  .optional()
1204
1204
  .or(z.literal(''))
1205
1205
  .transform((val) => (val === '' ? undefined : val))
@@ -1559,7 +1559,7 @@ const clearableUrlField = () =>
1559
1559
  z
1560
1560
  .string()
1561
1561
  .trim()
1562
- .url()
1562
+ .url(CUSTOMER_URL_INVALID_MESSAGE_KEY)
1563
1563
  .optional()
1564
1564
  .or(z.literal(''))
1565
1565
  .transform((val) => (val === '' ? null : val))
@@ -6,6 +6,7 @@ import { dictionaryEntrySortModeSchema } from '@open-mercato/core/modules/dictio
6
6
  const uuid = () => z.string().uuid()
7
7
 
8
8
  export const CUSTOMER_PHONE_INVALID_MESSAGE_KEY = 'customers.people.form.primaryPhone.invalid'
9
+ export const CUSTOMER_URL_INVALID_MESSAGE_KEY = 'customers.people.form.websiteUrl.invalid'
9
10
  export const ACTIVITY_DATE_REQUIRED_MESSAGE_KEY = 'customers.activities.errors.dateRequired'
10
11
  export const ACTIVITY_TIME_REQUIRED_MESSAGE_KEY = 'customers.activities.errors.timeRequired'
11
12
  export const ACTIVITY_PHONE_REQUIRED_MESSAGE_KEY = 'customers.activities.errors.phoneRequired'
@@ -41,7 +42,7 @@ const clearableEmailSchema = z.preprocess(
41
42
 
42
43
  const clearableUrlSchema = z.preprocess(
43
44
  emptyStringToNull,
44
- z.string().url().max(300).nullable().optional(),
45
+ z.string().url(CUSTOMER_URL_INVALID_MESSAGE_KEY).max(300).nullable().optional(),
45
46
  )
46
47
 
47
48
  // Domain is a plain (non-URL) string that maps to a nullable column, so blanking
@@ -2352,6 +2352,7 @@
2352
2352
  "customers.people.form.twitterUrl": "X / Twitter",
2353
2353
  "customers.people.form.twitterUrl.placeholder": "https://x.com/beispiel",
2354
2354
  "customers.people.form.updateSuccess": "Person aktualisiert.",
2355
+ "customers.people.form.websiteUrl.invalid": "Geben Sie eine gültige URL ein (z. B. https://example.com)",
2355
2356
  "customers.people.list.actions.delete": "Löschen",
2356
2357
  "customers.people.list.actions.new": "Neue Person",
2357
2358
  "customers.people.list.actions.openInNewTab": "In neuem Tab öffnen",
@@ -2352,6 +2352,7 @@
2352
2352
  "customers.people.form.twitterUrl": "X / Twitter",
2353
2353
  "customers.people.form.twitterUrl.placeholder": "https://x.com/example",
2354
2354
  "customers.people.form.updateSuccess": "Person updated.",
2355
+ "customers.people.form.websiteUrl.invalid": "Enter a valid URL (e.g. https://example.com)",
2355
2356
  "customers.people.list.actions.delete": "Delete",
2356
2357
  "customers.people.list.actions.new": "New Person",
2357
2358
  "customers.people.list.actions.openInNewTab": "Open in new tab",
@@ -2352,6 +2352,7 @@
2352
2352
  "customers.people.form.twitterUrl": "X / Twitter",
2353
2353
  "customers.people.form.twitterUrl.placeholder": "https://x.com/ejemplo",
2354
2354
  "customers.people.form.updateSuccess": "Persona actualizada.",
2355
+ "customers.people.form.websiteUrl.invalid": "Introduce una URL válida (p. ej., https://example.com)",
2355
2356
  "customers.people.list.actions.delete": "Eliminar",
2356
2357
  "customers.people.list.actions.new": "Nueva persona",
2357
2358
  "customers.people.list.actions.openInNewTab": "Abrir en nueva pestaña",
@@ -2352,6 +2352,7 @@
2352
2352
  "customers.people.form.twitterUrl": "X / Twitter",
2353
2353
  "customers.people.form.twitterUrl.placeholder": "https://x.com/example",
2354
2354
  "customers.people.form.updateSuccess": "연락처가 업데이트되었습니다.",
2355
+ "customers.people.form.websiteUrl.invalid": "올바른 URL을 입력하세요 (예: https://example.com)",
2355
2356
  "customers.people.list.actions.delete": "삭제",
2356
2357
  "customers.people.list.actions.new": "새 연락처",
2357
2358
  "customers.people.list.actions.openInNewTab": "새 탭에서 열기",
@@ -2352,6 +2352,7 @@
2352
2352
  "customers.people.form.twitterUrl": "X / Twitter",
2353
2353
  "customers.people.form.twitterUrl.placeholder": "https://x.com/przyklad",
2354
2354
  "customers.people.form.updateSuccess": "Osoba została zaktualizowana.",
2355
+ "customers.people.form.websiteUrl.invalid": "Podaj prawidłowy adres URL (np. https://example.com)",
2355
2356
  "customers.people.list.actions.delete": "Usuń",
2356
2357
  "customers.people.list.actions.new": "Nowa osoba",
2357
2358
  "customers.people.list.actions.openInNewTab": "Otwórz w nowej karcie",
@@ -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 } : {}),