@open-mercato/core 0.9.1-develop.7499.1.fcec456e4e → 0.9.1-develop.7515.1.e2712c8b00

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 (89) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/audit_logs/components/AuditLogsActions.js +1 -1
  3. package/dist/modules/audit_logs/components/AuditLogsActions.js.map +2 -2
  4. package/dist/modules/auth/frontend/reset.js +7 -3
  5. package/dist/modules/auth/frontend/reset.js.map +2 -2
  6. package/dist/modules/business_rules/api/rules/route.js +12 -1
  7. package/dist/modules/business_rules/api/rules/route.js.map +2 -2
  8. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +17 -2
  9. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  10. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js +3 -2
  11. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js.map +2 -2
  12. package/dist/modules/catalog/backend/catalog/products/create/page.js +1 -0
  13. package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
  14. package/dist/modules/catalog/components/products/productFormUtils.js +9 -0
  15. package/dist/modules/catalog/components/products/productFormUtils.js.map +2 -2
  16. package/dist/modules/customers/backend/customers/companies/page.js +32 -32
  17. package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
  18. package/dist/modules/customers/backend/customers/deals/page.js +22 -22
  19. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  20. package/dist/modules/customers/backend/customers/people/page.js +34 -34
  21. package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
  22. package/dist/modules/ecommerce/search.js +1 -2
  23. package/dist/modules/ecommerce/search.js.map +2 -2
  24. package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js +4 -7
  25. package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js.map +2 -2
  26. package/dist/modules/shipping_carriers/lib/shipping-service.js +29 -11
  27. package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
  28. package/dist/modules/shipping_carriers/lib/status-sync.js +2 -2
  29. package/dist/modules/shipping_carriers/lib/status-sync.js.map +2 -2
  30. package/dist/modules/shipping_carriers/workers/webhook-processor.js +60 -33
  31. package/dist/modules/shipping_carriers/workers/webhook-processor.js.map +3 -3
  32. package/dist/modules/staff/backend/staff/time-tracking/entries/page.js +10 -7
  33. package/dist/modules/staff/backend/staff/time-tracking/entries/page.js.map +2 -2
  34. package/dist/modules/warranty_claims/commands/claim-lines.js +89 -68
  35. package/dist/modules/warranty_claims/commands/claim-lines.js.map +2 -2
  36. package/dist/modules/warranty_claims/commands/claims.js +14 -13
  37. package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
  38. package/dist/modules/warranty_claims/commands/shared.js +8 -2
  39. package/dist/modules/warranty_claims/commands/shared.js.map +2 -2
  40. package/dist/modules/wms/api/interceptors.js +3 -0
  41. package/dist/modules/wms/api/interceptors.js.map +2 -2
  42. package/dist/modules/wms/commands/configuration.js +16 -16
  43. package/dist/modules/wms/commands/configuration.js.map +2 -2
  44. package/dist/modules/wms/events.js +1 -0
  45. package/dist/modules/wms/events.js.map +2 -2
  46. package/dist/modules/wms/lib/catalogInventoryProfileIntent.js +9 -0
  47. package/dist/modules/wms/lib/catalogInventoryProfileIntent.js.map +2 -2
  48. package/dist/modules/wms/widgets/injection/catalog-inventory-profile/widget.js +11 -3
  49. package/dist/modules/wms/widgets/injection/catalog-inventory-profile/widget.js.map +2 -2
  50. package/package.json +7 -7
  51. package/src/modules/audit_logs/components/AuditLogsActions.tsx +1 -1
  52. package/src/modules/auth/frontend/reset.tsx +8 -3
  53. package/src/modules/business_rules/api/rules/route.ts +11 -0
  54. package/src/modules/business_rules/i18n/de.json +1 -0
  55. package/src/modules/business_rules/i18n/en.json +1 -0
  56. package/src/modules/business_rules/i18n/es.json +1 -0
  57. package/src/modules/business_rules/i18n/ko.json +1 -0
  58. package/src/modules/business_rules/i18n/pl.json +1 -0
  59. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +20 -1
  60. package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +2 -1
  61. package/src/modules/catalog/backend/catalog/products/create/page.tsx +1 -0
  62. package/src/modules/catalog/components/products/productFormUtils.ts +9 -0
  63. package/src/modules/customers/backend/customers/companies/page.tsx +32 -32
  64. package/src/modules/customers/backend/customers/deals/page.tsx +22 -22
  65. package/src/modules/customers/backend/customers/people/page.tsx +34 -34
  66. package/src/modules/customers/i18n/de.json +12 -0
  67. package/src/modules/customers/i18n/en.json +12 -0
  68. package/src/modules/customers/i18n/es.json +12 -0
  69. package/src/modules/customers/i18n/ko.json +12 -0
  70. package/src/modules/customers/i18n/pl.json +12 -0
  71. package/src/modules/ecommerce/i18n/de.json +2 -1
  72. package/src/modules/ecommerce/i18n/en.json +2 -1
  73. package/src/modules/ecommerce/i18n/es.json +2 -1
  74. package/src/modules/ecommerce/i18n/ko.json +2 -1
  75. package/src/modules/ecommerce/i18n/pl.json +2 -1
  76. package/src/modules/ecommerce/search.ts +0 -1
  77. package/src/modules/shipping_carriers/api/webhook/[provider]/route.ts +4 -7
  78. package/src/modules/shipping_carriers/lib/shipping-service.ts +29 -11
  79. package/src/modules/shipping_carriers/lib/status-sync.ts +2 -2
  80. package/src/modules/shipping_carriers/workers/webhook-processor.ts +71 -39
  81. package/src/modules/staff/backend/staff/time-tracking/entries/page.tsx +20 -14
  82. package/src/modules/warranty_claims/commands/claim-lines.ts +107 -66
  83. package/src/modules/warranty_claims/commands/claims.ts +14 -13
  84. package/src/modules/warranty_claims/commands/shared.ts +7 -1
  85. package/src/modules/wms/api/interceptors.ts +9 -0
  86. package/src/modules/wms/commands/configuration.ts +28 -16
  87. package/src/modules/wms/events.ts +1 -0
  88. package/src/modules/wms/lib/catalogInventoryProfileIntent.ts +9 -0
  89. package/src/modules/wms/widgets/injection/catalog-inventory-profile/widget.ts +13 -4
@@ -337,5 +337,6 @@
337
337
  "ecommerce.validation.pathPrefixInvalid": "Path prefix may contain only lowercase letters, digits, dashes and slashes, for example /shop.",
338
338
  "ecommerce.validation.slugInvalid": "Slug may contain only lowercase letters, digits and single dashes between them.",
339
339
  "ecommerce.validation.urlInvalid": "Enter a valid http(s) URL or a path starting with /.",
340
- "notifications.categories.ecommerce": "Ecommerce"
340
+ "notifications.categories.ecommerce": "Ecommerce",
341
+ "search.entityType.ecommerce.ecommerce_store": "Store"
341
342
  }
@@ -337,5 +337,6 @@
337
337
  "ecommerce.validation.pathPrefixInvalid": "El prefijo de ruta solo puede contener letras minúsculas, dígitos, guiones y barras, por ejemplo /shop.",
338
338
  "ecommerce.validation.slugInvalid": "El slug solo puede contener letras minúsculas, dígitos y guiones simples entre ellos.",
339
339
  "ecommerce.validation.urlInvalid": "Introduce una URL http(s) válida o una ruta que empiece por /.",
340
- "notifications.categories.ecommerce": "Comercio electrónico"
340
+ "notifications.categories.ecommerce": "Comercio electrónico",
341
+ "search.entityType.ecommerce.ecommerce_store": "Tienda"
341
342
  }
@@ -337,5 +337,6 @@
337
337
  "ecommerce.validation.pathPrefixInvalid": "경로 접두사에는 영문 소문자, 숫자, 하이픈, 슬래시만 사용할 수 있습니다(예: /shop).",
338
338
  "ecommerce.validation.slugInvalid": "슬러그에는 영문 소문자, 숫자와 그 사이의 단일 하이픈만 사용할 수 있습니다.",
339
339
  "ecommerce.validation.urlInvalid": "유효한 http(s) URL 또는 /로 시작하는 경로를 입력하세요.",
340
- "notifications.categories.ecommerce": "이커머스"
340
+ "notifications.categories.ecommerce": "이커머스",
341
+ "search.entityType.ecommerce.ecommerce_store": "스토어"
341
342
  }
@@ -337,5 +337,6 @@
337
337
  "ecommerce.validation.pathPrefixInvalid": "Prefiks ścieżki może zawierać tylko małe litery, cyfry, myślniki i ukośniki, np. /shop.",
338
338
  "ecommerce.validation.slugInvalid": "Slug może zawierać tylko małe litery, cyfry i pojedyncze myślniki między nimi.",
339
339
  "ecommerce.validation.urlInvalid": "Podaj prawidłowy adres URL http(s) lub ścieżkę zaczynającą się od /.",
340
- "notifications.categories.ecommerce": "E-commerce"
340
+ "notifications.categories.ecommerce": "E-commerce",
341
+ "search.entityType.ecommerce.ecommerce_store": "Sklep"
341
342
  }
@@ -39,7 +39,6 @@ function buildStorePresenter(
39
39
  title,
40
40
  subtitle: subtitleParts.length ? subtitleParts.join(' · ') : undefined,
41
41
  icon: 'store',
42
- badge,
43
42
  }
44
43
  }
45
44
 
@@ -115,13 +115,10 @@ export async function POST(req: Request, { params }: { params: Promise<{ provide
115
115
  }
116
116
 
117
117
  await queue.enqueue({
118
- name: 'shipping-carrier-webhook',
119
- payload: {
120
- providerKey,
121
- event,
122
- shipmentId: shipment.id,
123
- scope: matchedScope,
124
- },
118
+ providerKey,
119
+ event,
120
+ shipmentId: shipment.id,
121
+ scope: matchedScope,
125
122
  })
126
123
 
127
124
  return NextResponse.json({ received: true, queued: true }, { status: 202 })
@@ -1,3 +1,4 @@
1
+ import { LockMode } from '@mikro-orm/core'
1
2
  import type { EntityManager } from '@mikro-orm/postgresql'
2
3
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
4
  import type { CredentialsService } from '../../integrations/lib/credentials-service'
@@ -200,7 +201,7 @@ export function createShippingCarrierService(deps: {
200
201
  trackingNumber: created.trackingNumber,
201
202
  organizationId: input.organizationId,
202
203
  tenantId: input.tenantId,
203
- })
204
+ }, { tenantId: input.tenantId, organizationId: input.organizationId })
204
205
  return shipment
205
206
  } catch (error: unknown) {
206
207
  // Release the claim only when the carrier was NOT successfully called, so a retry can
@@ -256,24 +257,41 @@ export function createShippingCarrierService(deps: {
256
257
  trackingNumber: shipment.trackingNumber,
257
258
  credentials,
258
259
  })
259
- const previousStatus = shipment.unifiedStatus
260
- shipment.trackingEvents = tracking.events
261
- shipment.lastPolledAt = new Date()
262
- const transitionApplied = syncShipmentStatus(shipment, tracking.status)
263
- await em.flush()
264
- if (transitionApplied) {
260
+ const outcome = await em.transactional(async (tx) => {
261
+ const locked = await findOneWithDecryption(
262
+ tx,
263
+ CarrierShipment,
264
+ {
265
+ id: shipment.id,
266
+ organizationId: scope.organizationId,
267
+ tenantId: scope.tenantId,
268
+ deletedAt: null,
269
+ },
270
+ { lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true },
271
+ scope,
272
+ )
273
+ if (!locked) return null
274
+ const previousStatus = locked.unifiedStatus
275
+ locked.trackingEvents = tracking.events
276
+ locked.lastPolledAt = new Date()
277
+ const applied = syncShipmentStatus(locked, tracking.status)
278
+ await tx.flush()
279
+ return { applied, previousStatus }
280
+ }, { clear: true })
281
+ if (outcome?.applied) {
265
282
  const eventPayload = {
266
283
  shipmentId: shipment.id,
267
284
  providerKey: input.providerKey,
268
- previousStatus,
285
+ previousStatus: outcome.previousStatus,
269
286
  newStatus: tracking.status,
270
287
  organizationId: input.organizationId,
271
288
  tenantId: input.tenantId,
272
289
  }
273
- await emitShippingEvent('shipping_carriers.shipment.status_changed', eventPayload)
290
+ const eventScope = { tenantId: input.tenantId, organizationId: input.organizationId }
291
+ await emitShippingEvent('shipping_carriers.shipment.status_changed', eventPayload, eventScope)
274
292
  if (TERMINAL_SHIPPING_STATUSES.has(tracking.status)) {
275
293
  const terminalEvent = getTerminalShippingEvent(tracking.status)
276
- if (terminalEvent) await emitShippingEvent(terminalEvent, eventPayload)
294
+ if (terminalEvent) await emitShippingEvent(terminalEvent, eventPayload, eventScope)
277
295
  }
278
296
  }
279
297
  return tracking
@@ -307,7 +325,7 @@ export function createShippingCarrierService(deps: {
307
325
  providerKey: input.providerKey,
308
326
  organizationId: input.organizationId,
309
327
  tenantId: input.tenantId,
310
- })
328
+ }, { tenantId: input.tenantId, organizationId: input.organizationId })
311
329
  return result
312
330
  },
313
331
 
@@ -29,8 +29,8 @@ export function isShipmentCancelNotAllowedError(error: unknown): error is Shipme
29
29
  }
30
30
 
31
31
  const VALID_SHIPPING_TRANSITIONS: Record<string, UnifiedShipmentStatus[]> = {
32
- label_created: ['picked_up', 'in_transit', 'cancelled'],
33
- picked_up: ['in_transit', 'cancelled'],
32
+ label_created: ['picked_up', 'in_transit', 'out_for_delivery', 'delivered', 'failed_delivery', 'returned', 'cancelled'],
33
+ picked_up: ['in_transit', 'out_for_delivery', 'delivered', 'failed_delivery', 'returned', 'cancelled'],
34
34
  in_transit: ['out_for_delivery', 'delivered', 'returned', 'failed_delivery'],
35
35
  out_for_delivery: ['delivered', 'returned', 'failed_delivery'],
36
36
  failed_delivery: ['in_transit', 'out_for_delivery', 'delivered', 'returned', 'cancelled'],
@@ -1,9 +1,10 @@
1
+ import { LockMode } from '@mikro-orm/core'
1
2
  import type { EntityManager } from '@mikro-orm/postgresql'
2
3
  import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
3
4
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
5
  import { CarrierShipment } from '../data/entities'
5
6
  import { emitShippingEvent } from '../events'
6
- import type { ShippingWebhookEvent } from '../lib/adapter'
7
+ import type { ShippingWebhookEvent, UnifiedShipmentStatus } from '../lib/adapter'
7
8
  import { getShippingAdapter } from '../lib/adapter-registry'
8
9
  import { getTerminalShippingEvent, syncShipmentStatus, TERMINAL_SHIPPING_STATUSES } from '../lib/status-sync'
9
10
  import { claimWebhookProcessing, releaseWebhookClaim } from '../lib/webhook-utils'
@@ -21,6 +22,16 @@ type WebhookJobPayload = {
21
22
  } | null
22
23
  }
23
24
 
25
+ type LegacyWebhookJobEnvelope = {
26
+ name?: string
27
+ payload: WebhookJobPayload
28
+ }
29
+
30
+ function readWebhookJobPayload(data: WebhookJobPayload | LegacyWebhookJobEnvelope): WebhookJobPayload {
31
+ if ('providerKey' in data) return data
32
+ return data.payload
33
+ }
34
+
24
35
  type HandlerContext = JobContext & {
25
36
  resolve: <T = unknown>(name: string) => T
26
37
  }
@@ -31,24 +42,25 @@ export const metadata: WorkerMeta = {
31
42
  concurrency: 5,
32
43
  }
33
44
 
34
- export default async function handle(job: QueuedJob<WebhookJobPayload>, ctx: HandlerContext): Promise<void> {
45
+ export default async function handle(job: QueuedJob<WebhookJobPayload | LegacyWebhookJobEnvelope>, ctx: HandlerContext): Promise<void> {
46
+ const payload = readWebhookJobPayload(job.payload)
35
47
  try {
36
48
  const em = ctx.resolve<EntityManager>('em')
37
- const adapter = getShippingAdapter(job.payload.providerKey)
49
+ const adapter = getShippingAdapter(payload.providerKey)
38
50
  if (!adapter) return
39
51
 
40
- const shipment = job.payload.shipmentId && job.payload.scope
52
+ const shipment = payload.shipmentId && payload.scope
41
53
  ? await findOneWithDecryption(
42
54
  em,
43
55
  CarrierShipment,
44
56
  {
45
- id: job.payload.shipmentId,
46
- organizationId: job.payload.scope.organizationId,
47
- tenantId: job.payload.scope.tenantId,
57
+ id: payload.shipmentId,
58
+ organizationId: payload.scope.organizationId,
59
+ tenantId: payload.scope.tenantId,
48
60
  deletedAt: null,
49
61
  },
50
62
  undefined,
51
- job.payload.scope,
63
+ payload.scope,
52
64
  )
53
65
  : null
54
66
  if (!shipment) return
@@ -56,53 +68,73 @@ export default async function handle(job: QueuedJob<WebhookJobPayload>, ctx: Han
56
68
  const scope = { organizationId: shipment.organizationId, tenantId: shipment.tenantId }
57
69
  const claimed = await claimWebhookProcessing(
58
70
  em,
59
- job.payload.event.idempotencyKey,
60
- job.payload.providerKey,
71
+ payload.event.idempotencyKey,
72
+ payload.providerKey,
61
73
  scope,
62
- job.payload.event.eventType,
74
+ payload.event.eventType,
63
75
  )
64
76
  if (!claimed) {
65
77
  return
66
78
  }
67
79
 
80
+ const carrierStatus = typeof payload.event.data.status === 'string'
81
+ ? payload.event.data.status
82
+ : payload.event.eventType
83
+
84
+ let transition: { previousStatus: string; newStatus: UnifiedShipmentStatus } | null = null
68
85
  try {
69
- const carrierStatus = typeof job.payload.event.data.status === 'string'
70
- ? job.payload.event.data.status
71
- : job.payload.event.eventType
72
86
  const unifiedStatus = adapter.mapStatus(carrierStatus)
73
- shipment.carrierStatus = carrierStatus
74
- shipment.lastWebhookAt = new Date()
75
-
76
- const transitionApplied = syncShipmentStatus(shipment, unifiedStatus)
77
- if (!transitionApplied) return
78
-
79
- await em.flush()
80
-
81
- const eventPayload = {
82
- shipmentId: shipment.id,
83
- providerKey: job.payload.providerKey,
84
- previousStatus: carrierStatus,
85
- newStatus: unifiedStatus,
86
- organizationId: shipment.organizationId,
87
- tenantId: shipment.tenantId,
88
- }
89
- await emitShippingEvent('shipping_carriers.shipment.status_changed', eventPayload)
90
- if (TERMINAL_SHIPPING_STATUSES.has(unifiedStatus)) {
91
- const terminalEvent = getTerminalShippingEvent(unifiedStatus)
92
- if (!terminalEvent) return
93
- await emitShippingEvent(terminalEvent, eventPayload)
94
- }
87
+ transition = await em.transactional(async (tx) => {
88
+ const locked = await findOneWithDecryption(
89
+ tx,
90
+ CarrierShipment,
91
+ {
92
+ id: shipment.id,
93
+ organizationId: scope.organizationId,
94
+ tenantId: scope.tenantId,
95
+ deletedAt: null,
96
+ },
97
+ { lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true },
98
+ scope,
99
+ )
100
+ if (!locked) return null
101
+ const previousStatus = locked.unifiedStatus
102
+ if (!syncShipmentStatus(locked, unifiedStatus)) return null
103
+ locked.carrierStatus = carrierStatus
104
+ locked.lastWebhookAt = new Date()
105
+ await tx.flush()
106
+ return { previousStatus, newStatus: unifiedStatus }
107
+ }, { clear: true })
95
108
  } catch (error) {
96
109
  await releaseWebhookClaim(
97
110
  em,
98
- job.payload.event.idempotencyKey,
99
- job.payload.providerKey,
111
+ payload.event.idempotencyKey,
112
+ payload.providerKey,
100
113
  scope,
101
114
  )
102
115
  throw error
103
116
  }
117
+ if (!transition) return
118
+
119
+ const unifiedStatus = transition.newStatus
120
+ const eventPayload = {
121
+ shipmentId: shipment.id,
122
+ providerKey: payload.providerKey,
123
+ previousStatus: transition.previousStatus,
124
+ newStatus: unifiedStatus,
125
+ carrierStatus,
126
+ organizationId: shipment.organizationId,
127
+ tenantId: shipment.tenantId,
128
+ }
129
+ const eventScope = { tenantId: shipment.tenantId, organizationId: shipment.organizationId }
130
+ await emitShippingEvent('shipping_carriers.shipment.status_changed', eventPayload, eventScope)
131
+ if (TERMINAL_SHIPPING_STATUSES.has(unifiedStatus)) {
132
+ const terminalEvent = getTerminalShippingEvent(unifiedStatus)
133
+ if (!terminalEvent) return
134
+ await emitShippingEvent(terminalEvent, eventPayload, eventScope)
135
+ }
104
136
  } catch (error) {
105
- logger.error('Job processing failed', { providerKey: job.payload.providerKey, shipmentId: job.payload.shipmentId, err: error })
137
+ logger.error('Job processing failed', { providerKey: payload.providerKey, shipmentId: payload.shipmentId, err: error })
106
138
  throw error
107
139
  }
108
140
  }
@@ -353,7 +353,7 @@ export default function TimeTrackingEntriesPage() {
353
353
  }
354
354
  }
355
355
  const [selfRes, projectsRes, tagsRes, peopleRes] = await Promise.all([
356
- safeCall<{ member?: { id?: string } | null }>('/api/staff/team-members/self'),
356
+ safeCall<{ member?: { id?: string; displayName?: string } | null }>('/api/staff/team-members/self'),
357
357
  safeCall<{ items?: Array<Record<string, unknown>> }>('/api/staff/timesheets/time-projects?page=1&pageSize=100'),
358
358
  safeCall<{ items?: Array<Record<string, unknown>> }>('/api/staff/timesheets/tags?page=1&pageSize=100'),
359
359
  safeCall<{ items?: Array<Record<string, unknown>> }>('/api/staff/team-members?page=1&pageSize=100'),
@@ -399,19 +399,25 @@ export default function TimeTrackingEntriesPage() {
399
399
  )
400
400
 
401
401
  const people = peopleRes?.ok && Array.isArray(peopleRes.result?.items) ? peopleRes.result.items : []
402
- setPeopleOptions(
403
- people
404
- .map((row: Record<string, unknown>) => {
405
- const id = typeof row.id === 'string' ? row.id : null
406
- const label = typeof row.display_name === 'string'
407
- ? row.display_name
408
- : typeof row.displayName === 'string'
409
- ? row.displayName
410
- : null
411
- return id && label ? { value: id, label } : null
412
- })
413
- .filter((option): option is { value: string; label: string } => option !== null),
414
- )
402
+ const personOptions = people
403
+ .map((row: Record<string, unknown>) => {
404
+ const id = typeof row.id === 'string' ? row.id : null
405
+ const label = typeof row.display_name === 'string'
406
+ ? row.display_name
407
+ : typeof row.displayName === 'string'
408
+ ? row.displayName
409
+ : null
410
+ return id && label ? { value: id, label } : null
411
+ })
412
+ .filter((option): option is { value: string; label: string } => option !== null)
413
+ // The team list is closed to a caller without team-member access, yet the
414
+ // self filter is seeded for everyone — name it from the self lookup so its
415
+ // chip never falls back to the raw member id.
416
+ const selfName = selfRes?.ok ? selfRes.result?.member?.displayName : undefined
417
+ if (selfId && selfName && !personOptions.some((option) => option.value === selfId)) {
418
+ personOptions.push({ value: selfId, label: selfName })
419
+ }
420
+ setPeopleOptions(personOptions)
415
421
  })()
416
422
  return () => { cancelled = true }
417
423
  }, [scopeVersion])
@@ -43,6 +43,7 @@ import {
43
43
  ensureTenantScope,
44
44
  extractUndoPayload,
45
45
  requireScopedClaim,
46
+ throwUndoRefusal,
46
47
  type WarrantyClaimScope,
47
48
  } from './shared'
48
49
  import { assertPendingClaimQuantitiesWithinSold, validateClaimReferences } from './claims'
@@ -135,6 +136,31 @@ const mutableParentStatuses = new Set([
135
136
  'inspecting',
136
137
  ])
137
138
 
139
+ const receivingParentStatuses = new Set(['received', 'inspecting'])
140
+
141
+ type LineUndoParentRule = {
142
+ allowedParentStatuses: ReadonlySet<string>
143
+ lockedKey: string
144
+ lockedFallback: string
145
+ }
146
+
147
+ type LineUndoState = {
148
+ claim: WarrantyClaim
149
+ line: WarrantyClaimLine
150
+ }
151
+
152
+ const mutableParentUndoRule: LineUndoParentRule = {
153
+ allowedParentStatuses: mutableParentStatuses,
154
+ lockedKey: 'warranty_claims.errors.lineLocked',
155
+ lockedFallback: 'This line is locked in the current claim status.',
156
+ }
157
+
158
+ const receivingParentUndoRule: LineUndoParentRule = {
159
+ allowedParentStatuses: receivingParentStatuses,
160
+ lockedKey: 'warranty_claims.errors.receivingStatusInvalid',
161
+ lockedFallback: 'Receiving details can only be saved when the claim is received or under inspection.',
162
+ }
163
+
138
164
  function parseCommandInput<T>(schema: z.ZodType<T>, rawInput: unknown): T {
139
165
  const result = schema.safeParse(rawInput ?? {})
140
166
  if (!result.success) {
@@ -482,6 +508,37 @@ async function recomputeClaimRollups(em: EntityManager, claim: WarrantyClaim): P
482
508
  claim.updatedAt = new Date()
483
509
  }
484
510
 
511
+ function refuseLineUndoConflict(): Promise<never> {
512
+ return throwUndoRefusal('warranty_claims.errors.conflict', 'This claim changed while you were editing it.')
513
+ }
514
+
515
+ async function requireCurrentLineUndoState(
516
+ em: EntityManager,
517
+ after: LineSnapshot | null | undefined,
518
+ rule: LineUndoParentRule,
519
+ ): Promise<LineUndoState> {
520
+ if (!after) return refuseLineUndoConflict()
521
+ const scope: WarrantyClaimScope = { tenantId: after.tenantId, organizationId: after.organizationId }
522
+ const claim = await findOneWithDecryption(
523
+ em,
524
+ WarrantyClaim,
525
+ { id: after.claimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
526
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
527
+ scope,
528
+ )
529
+ if (!claim) return refuseLineUndoConflict()
530
+ if (!rule.allowedParentStatuses.has(claim.status)) return throwUndoRefusal(rule.lockedKey, rule.lockedFallback)
531
+ const line = await findOneWithDecryption(
532
+ em,
533
+ WarrantyClaimLine,
534
+ { id: after.id, tenantId: scope.tenantId, organizationId: scope.organizationId },
535
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
536
+ scope,
537
+ )
538
+ if (!line || !after.updatedAt || toIso(line.updatedAt) !== after.updatedAt) return refuseLineUndoConflict()
539
+ return { claim, line }
540
+ }
541
+
485
542
  async function requireScopedLine(
486
543
  em: EntityManager,
487
544
  lineId: string,
@@ -654,22 +711,20 @@ const createClaimLineCommand: CommandHandler<ClaimLineCreateInput, { lineId: str
654
711
  buildLog: async ({ result, snapshots }) => buildLineLog('warranty_claims.audit.claim_line.create', result.lineId, snapshots),
655
712
  undo: async ({ logEntry, ctx }) => {
656
713
  const payload = extractUndoPayload<LineUndoPayload>(logEntry)
657
- const after = payload?.after
658
- if (!after) return
659
- const scope = { tenantId: after.tenantId, organizationId: after.organizationId }
660
714
  const em = (ctx.container.resolve('em') as EntityManager).fork()
661
- const line = await findOneWithDecryption(em, WarrantyClaimLine, { id: after.id, tenantId: scope.tenantId, organizationId: scope.organizationId }, { populate: ['claim'] }, scope)
662
- const claim = line ? claimOf(line) : null
663
- if (!line || !claim) return
715
+ let state!: LineUndoState
664
716
  await withAtomicFlush(em, [
717
+ async () => {
718
+ state = await requireCurrentLineUndoState(em, payload?.after, mutableParentUndoRule)
719
+ },
665
720
  () => {
666
- line.deletedAt = new Date()
667
- line.updatedAt = new Date()
721
+ state.line.deletedAt = new Date()
722
+ state.line.updatedAt = new Date()
668
723
  },
669
- () => recomputeClaimRollups(em, claim),
724
+ () => recomputeClaimRollups(em, state.claim),
670
725
  ], { transaction: true, label: 'warranty_claims.claim_line.create.undo' })
671
- await emitClaimUpdated(ctx, claim)
672
- await emitLineUndoCrud(ctx, 'deleted', line)
726
+ await emitClaimUpdated(ctx, state.claim)
727
+ await emitLineUndoCrud(ctx, 'deleted', state.line)
673
728
  },
674
729
  }
675
730
 
@@ -744,17 +799,17 @@ const updateClaimLineCommand: CommandHandler<ClaimLineUpdateInput, { lineId: str
744
799
  const payload = extractUndoPayload<LineUndoPayload>(logEntry)
745
800
  const before = payload?.before
746
801
  if (!before) return
747
- const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
748
802
  const em = (ctx.container.resolve('em') as EntityManager).fork()
749
- const line = await findOneWithDecryption(em, WarrantyClaimLine, { id: before.id, tenantId: scope.tenantId, organizationId: scope.organizationId }, { populate: ['claim'] }, scope)
750
- if (!line) return
751
- const claim = claimOf(line) ?? await requireScopedClaim(em, before.claimId, scope)
803
+ let state!: LineUndoState
752
804
  await withAtomicFlush(em, [
753
- () => restoreLineFromSnapshot(line, before),
754
- () => recomputeClaimRollups(em, claim),
805
+ async () => {
806
+ state = await requireCurrentLineUndoState(em, payload?.after, mutableParentUndoRule)
807
+ },
808
+ () => restoreLineFromSnapshot(state.line, before),
809
+ () => recomputeClaimRollups(em, state.claim),
755
810
  ], { transaction: true, label: 'warranty_claims.claim_line.update.undo' })
756
- await emitClaimUpdated(ctx, claim)
757
- await emitLineUndoCrud(ctx, 'updated', line)
811
+ await emitClaimUpdated(ctx, state.claim)
812
+ await emitLineUndoCrud(ctx, 'updated', state.line)
758
813
  },
759
814
  }
760
815
 
@@ -800,35 +855,20 @@ const deleteClaimLineCommand: CommandHandler<LineDeleteInput, { lineId: string;
800
855
  const payload = extractUndoPayload<LineUndoPayload>(logEntry)
801
856
  const before = payload?.before
802
857
  if (!before) return
803
- const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
804
858
  const em = (ctx.container.resolve('em') as EntityManager).fork()
805
- let line = await findOneWithDecryption(em, WarrantyClaimLine, { id: before.id, tenantId: scope.tenantId, organizationId: scope.organizationId }, { populate: ['claim'] }, scope)
806
- const claim = (line ? claimOf(line) : null) ?? await requireScopedClaim(em, before.claimId, scope)
859
+ let state!: LineUndoState
807
860
  await withAtomicFlush(em, [
861
+ async () => {
862
+ state = await requireCurrentLineUndoState(em, payload?.after, mutableParentUndoRule)
863
+ },
808
864
  () => {
809
- if (!line) {
810
- line = em.create(WarrantyClaimLine, {
811
- id: before.id,
812
- claim,
813
- organizationId: before.organizationId,
814
- tenantId: before.tenantId,
815
- lineNo: before.lineNo,
816
- qtyClaimed: before.qtyClaimed,
817
- lineStatus: before.lineStatus,
818
- warrantyStatus: before.warrantyStatus,
819
- createdAt: new Date(),
820
- updatedAt: new Date(),
821
- })
822
- em.persist(line)
823
- }
824
- restoreLineFromSnapshot(line, before)
825
- line.deletedAt = null
865
+ restoreLineFromSnapshot(state.line, before)
866
+ state.line.deletedAt = null
826
867
  },
827
- () => recomputeClaimRollups(em, claim),
868
+ () => recomputeClaimRollups(em, state.claim),
828
869
  ], { transaction: true, label: 'warranty_claims.claim_line.delete.undo' })
829
- if (!line) return
830
- await emitClaimUpdated(ctx, claim)
831
- await emitLineUndoCrud(ctx, 'created', line)
870
+ await emitClaimUpdated(ctx, state.claim)
871
+ await emitLineUndoCrud(ctx, 'created', state.line)
832
872
  },
833
873
  }
834
874
 
@@ -916,17 +956,17 @@ const receiveClaimLineCommand: CommandHandler<ClaimLineReceiveInput, { lineId: s
916
956
  const payload = extractUndoPayload<LineUndoPayload>(logEntry)
917
957
  const before = payload?.before
918
958
  if (!before) return
919
- const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
920
959
  const em = (ctx.container.resolve('em') as EntityManager).fork()
921
- const line = await findOneWithDecryption(em, WarrantyClaimLine, { id: before.id, tenantId: scope.tenantId, organizationId: scope.organizationId }, { populate: ['claim'] }, scope)
922
- if (!line) return
923
- const claim = await requireScopedClaim(em, before.claimId, scope)
960
+ let state!: LineUndoState
924
961
  await withAtomicFlush(em, [
925
- () => restoreLineFromSnapshot(line, before),
926
- () => recomputeClaimRollups(em, claim),
962
+ async () => {
963
+ state = await requireCurrentLineUndoState(em, payload?.after, receivingParentUndoRule)
964
+ },
965
+ () => restoreLineFromSnapshot(state.line, before),
966
+ () => recomputeClaimRollups(em, state.claim),
927
967
  ], { transaction: true, label: 'warranty_claims.claim_line.receive.undo' })
928
- await emitClaimUpdated(ctx, claim)
929
- await emitLineUndoCrud(ctx, 'updated', line)
968
+ await emitClaimUpdated(ctx, state.claim)
969
+ await emitLineUndoCrud(ctx, 'updated', state.line)
930
970
  },
931
971
  }
932
972
 
@@ -980,17 +1020,17 @@ const releaseClaimLineQuarantineCommand: CommandHandler<ClaimLineReleaseQuaranti
980
1020
  const payload = extractUndoPayload<LineUndoPayload>(logEntry)
981
1021
  const before = payload?.before
982
1022
  if (!before) return
983
- const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
984
1023
  const em = (ctx.container.resolve('em') as EntityManager).fork()
985
- const line = await findOneWithDecryption(em, WarrantyClaimLine, { id: before.id, tenantId: scope.tenantId, organizationId: scope.organizationId }, { populate: ['claim'] }, scope)
986
- if (!line) return
987
- const claim = await requireScopedClaim(em, before.claimId, scope)
1024
+ let state!: LineUndoState
988
1025
  await withAtomicFlush(em, [
989
- () => restoreLineFromSnapshot(line, before),
990
- () => recomputeClaimRollups(em, claim),
1026
+ async () => {
1027
+ state = await requireCurrentLineUndoState(em, payload?.after, receivingParentUndoRule)
1028
+ },
1029
+ () => restoreLineFromSnapshot(state.line, before),
1030
+ () => recomputeClaimRollups(em, state.claim),
991
1031
  ], { transaction: true, label: 'warranty_claims.claim_line.release_quarantine.undo' })
992
- await emitClaimUpdated(ctx, claim)
993
- await emitLineUndoCrud(ctx, 'updated', line)
1032
+ await emitClaimUpdated(ctx, state.claim)
1033
+ await emitLineUndoCrud(ctx, 'updated', state.line)
994
1034
  },
995
1035
  }
996
1036
 
@@ -1043,16 +1083,17 @@ const setClaimLineAssessmentCommand: CommandHandler<LineSetAssessmentInput, { li
1043
1083
  const payload = extractUndoPayload<LineUndoPayload>(logEntry)
1044
1084
  const before = payload?.before
1045
1085
  if (!before) return
1046
- const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
1047
1086
  const em = (ctx.container.resolve('em') as EntityManager).fork()
1048
- const line = await findOneWithDecryption(em, WarrantyClaimLine, { id: before.id, tenantId: scope.tenantId, organizationId: scope.organizationId }, { populate: ['claim'] }, scope)
1049
- if (!line) return
1050
- const claim = claimOf(line) ?? await requireScopedClaim(em, before.claimId, scope)
1087
+ let state!: LineUndoState
1051
1088
  await withAtomicFlush(em, [
1052
- () => restoreLineFromSnapshot(line, before),
1089
+ async () => {
1090
+ state = await requireCurrentLineUndoState(em, payload?.after, mutableParentUndoRule)
1091
+ },
1092
+ () => restoreLineFromSnapshot(state.line, before),
1093
+ () => recomputeClaimRollups(em, state.claim),
1053
1094
  ], { transaction: true, label: 'warranty_claims.claim_line.set_assessment.undo' })
1054
- await emitClaimUpdated(ctx, claim)
1055
- await emitLineUndoCrud(ctx, 'updated', line)
1095
+ await emitClaimUpdated(ctx, state.claim)
1096
+ await emitLineUndoCrud(ctx, 'updated', state.line)
1056
1097
  },
1057
1098
  }
1058
1099