@open-mercato/core 0.10.1-develop.7670.1.7b5ad74924 → 0.10.1-develop.7672.1.c2e4c0b5df

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7670.1.7b5ad74924",
3
+ "version": "0.10.1-develop.7672.1.c2e4c0b5df",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -265,16 +265,16 @@
265
265
  "zod": "^4.4.3"
266
266
  },
267
267
  "peerDependencies": {
268
- "@open-mercato/ai-assistant": "0.10.1-develop.7670.1.7b5ad74924",
269
- "@open-mercato/shared": "0.10.1-develop.7670.1.7b5ad74924",
270
- "@open-mercato/ui": "0.10.1-develop.7670.1.7b5ad74924",
268
+ "@open-mercato/ai-assistant": "0.10.1-develop.7672.1.c2e4c0b5df",
269
+ "@open-mercato/shared": "0.10.1-develop.7672.1.c2e4c0b5df",
270
+ "@open-mercato/ui": "0.10.1-develop.7672.1.c2e4c0b5df",
271
271
  "react": "^19.0.0",
272
272
  "react-dom": "^19.0.0"
273
273
  },
274
274
  "devDependencies": {
275
- "@open-mercato/ai-assistant": "0.10.1-develop.7670.1.7b5ad74924",
276
- "@open-mercato/shared": "0.10.1-develop.7670.1.7b5ad74924",
277
- "@open-mercato/ui": "0.10.1-develop.7670.1.7b5ad74924",
275
+ "@open-mercato/ai-assistant": "0.10.1-develop.7672.1.c2e4c0b5df",
276
+ "@open-mercato/shared": "0.10.1-develop.7672.1.c2e4c0b5df",
277
+ "@open-mercato/ui": "0.10.1-develop.7672.1.c2e4c0b5df",
278
278
  "@testing-library/dom": "^10.4.1",
279
279
  "@testing-library/jest-dom": "^7.0.1",
280
280
  "@testing-library/react": "^16.3.3",
@@ -1,9 +1,12 @@
1
1
  import { registerCommand } from '@open-mercato/shared/lib/commands'
2
- import type { CommandHandler } from '@open-mercato/shared/lib/commands'
2
+ import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
3
3
  import { emitCrudSideEffects, emitCrudUndoSideEffects, buildChanges, requireId } from '@open-mercato/shared/lib/commands/helpers'
4
4
  import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
5
5
  import type { EntityManager } from '@mikro-orm/postgresql'
6
- import { CustomerTag, CustomerTagAssignment } from '../data/entities'
6
+ import { LockMode, type FilterQuery } from '@mikro-orm/core'
7
+ import { buildScopedWhere } from '@open-mercato/shared/lib/api/crud'
8
+ import { isUnrestrictedOrganizationScope } from '@open-mercato/shared/lib/auth/organizationAccess'
9
+ import { CustomerEntity, CustomerTag, CustomerTagAssignment } from '../data/entities'
7
10
  import {
8
11
  tagCreateSchema,
9
12
  tagUpdateSchema,
@@ -19,11 +22,13 @@ import {
19
22
  requireCustomerEntity,
20
23
  ensureSameScope,
21
24
  loadEntityTagIds,
25
+ emitQueryIndexUpsertEvents,
22
26
  } from './shared'
23
27
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
24
- import { CrudHttpError, notFound } from '@open-mercato/shared/lib/crud/errors'
28
+ import { CrudHttpError, forbidden, notFound } from '@open-mercato/shared/lib/crud/errors'
25
29
  import type { CrudEventsConfig, CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'
26
- import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
30
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
31
+ import { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'
27
32
  import { emitCustomersEvent } from '../events'
28
33
  import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
29
34
  import { makeCreateRedo } from '@open-mercato/shared/lib/commands/redo'
@@ -56,6 +61,15 @@ type TagSnapshot = {
56
61
  label: string
57
62
  color: string | null
58
63
  description: string | null
64
+ assignments?: TagMembershipSnapshot[]
65
+ }
66
+
67
+ type TagMembershipSnapshot = {
68
+ id: string
69
+ entityId: string
70
+ organizationId: string
71
+ tenantId: string
72
+ createdAt: string
59
73
  }
60
74
 
61
75
  type TagUndoPayload = {
@@ -74,8 +88,40 @@ type TagAssignmentUndoPayload = {
74
88
  before?: TagAssignmentSnapshot | null
75
89
  }
76
90
 
77
- async function loadTagSnapshot(em: EntityManager, id: string): Promise<TagSnapshot | null> {
78
- const tag = await em.findOne(CustomerTag, { id })
91
+ function tagDeleteLookup(id: string, ctx: CommandRuntimeContext) {
92
+ const isSuperAdmin = ctx.auth?.isSuperAdmin === true
93
+ const organizationScope = ctx.organizationScope
94
+ const tenantId = ctx.auth ? (ctx.auth.tenantId ?? null) : (organizationScope?.tenantId ?? null)
95
+ if (ctx.auth && !isSuperAdmin && (!tenantId || (organizationScope && !organizationScope.tenantId))) {
96
+ throw forbidden()
97
+ }
98
+ let organizationIds: string[] | undefined
99
+ if (organizationScope) {
100
+ if (!isUnrestrictedOrganizationScope({ isSuperAdmin, allowedOrganizationIds: organizationScope.allowedIds })) {
101
+ organizationIds = organizationScope.allowedIds ?? []
102
+ }
103
+ } else if (!isSuperAdmin) {
104
+ const organizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId
105
+ if (organizationId) organizationIds = [organizationId]
106
+ }
107
+ return {
108
+ where: buildScopedWhere({ id }, {
109
+ tenantId: tenantId ?? undefined,
110
+ organizationIds,
111
+ softDeleteField: null,
112
+ }) as FilterQuery<CustomerTag>,
113
+ scope: { tenantId, organizationId: organizationIds?.length === 1 ? organizationIds[0] : null },
114
+ }
115
+ }
116
+
117
+ async function loadTagSnapshot(
118
+ em: EntityManager,
119
+ id: string,
120
+ lookup?: ReturnType<typeof tagDeleteLookup>,
121
+ ): Promise<TagSnapshot | null> {
122
+ const tag = lookup
123
+ ? await findOneWithDecryption(em, CustomerTag, lookup.where, {}, lookup.scope)
124
+ : await em.findOne(CustomerTag, { id })
79
125
  if (!tag) return null
80
126
  return {
81
127
  id: tag.id,
@@ -88,6 +134,46 @@ async function loadTagSnapshot(em: EntityManager, id: string): Promise<TagSnapsh
88
134
  }
89
135
  }
90
136
 
137
+ async function loadTagAssignments(em: EntityManager, tag: CustomerTag) {
138
+ const scope = { tenantId: tag.tenantId, organizationId: tag.organizationId }
139
+ return findWithDecryption(em, CustomerTagAssignment, { tag: tag.id, ...scope }, {}, scope)
140
+ }
141
+
142
+ async function refreshTagMemberships(
143
+ ctx: CommandRuntimeContext,
144
+ tag: CustomerTag,
145
+ assignments: CustomerTagAssignment[],
146
+ action: 'created' | 'deleted',
147
+ ) {
148
+ const dataEngine = ctx.container.resolve<DataEngine>('dataEngine')
149
+ for (const assignment of assignments) {
150
+ await emitCrudSideEffects({
151
+ dataEngine,
152
+ action,
153
+ entity: assignment,
154
+ identifiers: { id: assignment.id, tenantId: tag.tenantId, organizationId: tag.organizationId },
155
+ indexer: { entityType: E.customers.customer_tag_assignment },
156
+ })
157
+ await emitCustomersEvent(action === 'created' ? 'customers.tag.assigned' : 'customers.tag.removed', {
158
+ id: assignment.id,
159
+ tagId: tag.id,
160
+ entityId: assignment.entity.id,
161
+ organizationId: tag.organizationId,
162
+ tenantId: tag.tenantId,
163
+ }, { persistent: true })
164
+ }
165
+ await emitQueryIndexUpsertEvents(ctx, assignments.map((assignment) => ({
166
+ entityType: E.customers.customer_entity,
167
+ recordId: assignment.entity.id,
168
+ tenantId: tag.tenantId,
169
+ organizationId: tag.organizationId,
170
+ })))
171
+ await invalidateCrudCache(ctx.container, 'customers.tagAssignment', {
172
+ tenantId: tag.tenantId,
173
+ organizationId: tag.organizationId,
174
+ }, tag.tenantId, `customers.tags.delete.${action}`, ['customers.person', 'customers.company'])
175
+ }
176
+
91
177
  const createTagCommand: CommandHandler<TagCreateInput, { tagId: string }> = {
92
178
  id: 'customers.tags.create',
93
179
  async execute(rawInput, ctx) {
@@ -290,39 +376,64 @@ const updateTagCommand: CommandHandler<TagUpdateInput, { tagId: string }> = {
290
376
 
291
377
  const deleteTagCommand: CommandHandler<{ body?: Record<string, unknown>; query?: Record<string, unknown> }, { tagId: string }> = {
292
378
  id: 'customers.tags.delete',
379
+ prepareSnapshotInsideTransaction: true,
293
380
  async prepare(input, ctx) {
294
381
  const id = requireId(input, 'Tag id required')
295
- const em = (ctx.container.resolve('em') as EntityManager)
296
- const snapshot = await loadTagSnapshot(em, id)
297
- return snapshot ? { before: snapshot } : {}
382
+ const em = ctx.transactionalEm ?? (ctx.container.resolve('em') as EntityManager).fork()
383
+ const snapshot = await loadTagSnapshot(em, id, tagDeleteLookup(id, ctx))
384
+ if (!snapshot) return {}
385
+ ensureTenantScope(ctx, snapshot.tenantId)
386
+ ensureOrganizationScope(ctx, snapshot.organizationId)
387
+ const assignments = await findWithDecryption(em, CustomerTagAssignment, {
388
+ tag: id,
389
+ tenantId: snapshot.tenantId,
390
+ organizationId: snapshot.organizationId,
391
+ }, {}, snapshot)
392
+ snapshot.assignments = assignments.map((assignment) => ({
393
+ id: assignment.id,
394
+ entityId: assignment.entity.id,
395
+ tenantId: assignment.tenantId,
396
+ organizationId: assignment.organizationId,
397
+ createdAt: assignment.createdAt.toISOString(),
398
+ }))
399
+ return { before: snapshot }
298
400
  },
299
401
  async execute(input, ctx) {
300
402
  const id = requireId(input, 'Tag id required')
403
+ const lookup = tagDeleteLookup(id, ctx)
301
404
  const em = (ctx.container.resolve('em') as EntityManager).fork()
302
- const tag = await em.findOne(CustomerTag, { id })
303
- if (!tag) throw notFound('Tag not found')
304
- ensureTenantScope(ctx, tag.tenantId)
305
- ensureOrganizationScope(ctx, tag.organizationId)
405
+ let tag: CustomerTag | null = null
406
+ let assignments: CustomerTagAssignment[] = []
306
407
  await withAtomicFlush(em, [
307
408
  async () => {
308
- await em.nativeDelete(CustomerTagAssignment, { tag })
409
+ tag = await findOneWithDecryption(em, CustomerTag, lookup.where, { lockMode: LockMode.PESSIMISTIC_WRITE }, lookup.scope)
410
+ if (!tag) throw notFound('Tag not found')
411
+ ensureTenantScope(ctx, tag.tenantId)
412
+ ensureOrganizationScope(ctx, tag.organizationId)
413
+ await ctx.beforeTransactionalWrite?.(em, input)
414
+ assignments = await loadTagAssignments(em, tag)
415
+ await em.nativeDelete(CustomerTagAssignment, { tag, tenantId: tag.tenantId, organizationId: tag.organizationId })
309
416
  em.remove(tag)
310
417
  },
311
- ], { transaction: true })
418
+ ], { transaction: true, label: 'customers.tags.delete' })
419
+ if (!tag) throw notFound('Tag not found')
420
+ const deletedTag = tag as CustomerTag
312
421
 
313
422
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
314
423
  await emitCrudSideEffects({
315
424
  dataEngine: de,
316
425
  action: 'deleted',
317
- entity: tag,
426
+ entity: deletedTag,
318
427
  identifiers: {
319
- id: tag.id,
320
- organizationId: tag.organizationId,
321
- tenantId: tag.tenantId,
428
+ id: deletedTag.id,
429
+ organizationId: deletedTag.organizationId,
430
+ tenantId: deletedTag.tenantId,
322
431
  },
323
432
  events: tagCrudEvents,
433
+ indexer: tagCrudIndexer,
324
434
  })
325
- return { tagId: tag.id }
435
+ await refreshTagMemberships(ctx, deletedTag, assignments, 'deleted')
436
+ return { tagId: deletedTag.id }
326
437
  },
327
438
  buildLog: async ({ snapshots }) => {
328
439
  const before = snapshots.before as TagSnapshot | undefined
@@ -334,6 +445,7 @@ const deleteTagCommand: CommandHandler<{ body?: Record<string, unknown>; query?:
334
445
  resourceId: before.id,
335
446
  tenantId: before.tenantId,
336
447
  organizationId: before.organizationId,
448
+ context: { cacheAliases: ['customers.tagAssignment', 'customers.person', 'customers.company'] },
337
449
  snapshotBefore: before,
338
450
  payload: {
339
451
  undo: {
@@ -346,31 +458,51 @@ const deleteTagCommand: CommandHandler<{ body?: Record<string, unknown>; query?:
346
458
  const payload = extractUndoPayload<TagUndoPayload>(logEntry)
347
459
  const before = payload?.before
348
460
  if (!before) return
461
+ ensureTenantScope(ctx, before.tenantId)
462
+ ensureOrganizationScope(ctx, before.organizationId)
349
463
  const em = (ctx.container.resolve('em') as EntityManager).fork()
350
- let tag = await em.findOne(CustomerTag, { id: before.id })
351
- if (!tag) {
352
- tag = em.create(CustomerTag, {
353
- id: before.id,
354
- organizationId: before.organizationId,
355
- tenantId: before.tenantId,
356
- slug: before.slug,
357
- label: before.label,
358
- color: before.color,
359
- description: before.description,
360
- })
361
- em.persist(tag)
362
- } else {
363
- tag.slug = before.slug
364
- tag.label = before.label
365
- tag.color = before.color
366
- tag.description = before.description
367
- }
368
- const restoredTag = tag
464
+ const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
465
+ const existingTag = await findOneWithDecryption(em, CustomerTag, { id: before.id, ...scope }, {}, scope)
466
+ const tag = existingTag ?? em.create(CustomerTag, { id: before.id, ...scope, slug: before.slug, label: before.label })
467
+ const restoredAssignments: CustomerTagAssignment[] = []
369
468
  await withAtomicFlush(em, [
370
469
  () => {
371
- em.persist(restoredTag)
470
+ tag.slug = before.slug
471
+ tag.label = before.label
472
+ tag.color = before.color
473
+ tag.description = before.description
474
+ em.persist(tag)
372
475
  },
373
- ], { transaction: true })
476
+ async () => {
477
+ const assignments = (before.assignments ?? []).filter((assignment) =>
478
+ assignment.tenantId === before.tenantId && assignment.organizationId === before.organizationId,
479
+ )
480
+ if (!assignments.length) return
481
+ const customers = await findWithDecryption(em, CustomerEntity, {
482
+ id: { $in: assignments.map((assignment) => assignment.entityId) },
483
+ ...scope,
484
+ deletedAt: null,
485
+ kind: { $in: ['person', 'company'] },
486
+ }, {}, scope)
487
+ const existingAssignments = await loadTagAssignments(em, tag)
488
+ const assignedEntityIds = new Set(existingAssignments.map((assignment) => assignment.entity.id))
489
+ const customersById = new Map(customers.map((customer) => [customer.id, customer]))
490
+ for (const snapshot of assignments) {
491
+ const entity = customersById.get(snapshot.entityId)
492
+ if (!entity || assignedEntityIds.has(entity.id)) continue
493
+ const assignment = em.create(CustomerTagAssignment, {
494
+ id: snapshot.id,
495
+ tag,
496
+ entity,
497
+ ...scope,
498
+ createdAt: new Date(snapshot.createdAt),
499
+ })
500
+ em.persist(assignment)
501
+ restoredAssignments.push(assignment)
502
+ assignedEntityIds.add(entity.id)
503
+ }
504
+ },
505
+ ], { transaction: true, label: 'customers.tags.delete.undo' })
374
506
 
375
507
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
376
508
  await emitCrudUndoSideEffects({
@@ -378,13 +510,14 @@ const deleteTagCommand: CommandHandler<{ body?: Record<string, unknown>; query?:
378
510
  action: 'created',
379
511
  entity: tag,
380
512
  identifiers: {
381
- id: tag.id,
382
- organizationId: tag.organizationId,
383
- tenantId: tag.tenantId,
513
+ id: before.id,
514
+ organizationId: before.organizationId,
515
+ tenantId: before.tenantId,
384
516
  },
385
517
  events: tagCrudEvents,
386
518
  indexer: tagCrudIndexer,
387
519
  })
520
+ await refreshTagMemberships(ctx, tag, restoredAssignments, 'created')
388
521
  },
389
522
  }
390
523
 
@@ -1422,7 +1422,7 @@ function assertClaimUpdateFields(claim: WarrantyClaim, input: ClaimUpdateInput):
1422
1422
 
1423
1423
  function applyClaimUpdate(claim: WarrantyClaim, input: ClaimUpdateInput, customerName: string | null, orderNumber: string | null): void {
1424
1424
  if (hasOwn(input, 'customerId')) claim.customerId = input.customerId ?? null
1425
- if (hasOwn(input, 'customerName')) claim.customerName = customerName
1425
+ if (hasOwn(input, 'customerId') || hasOwn(input, 'customerName')) claim.customerName = customerName
1426
1426
  if (hasOwn(input, 'orderId')) {
1427
1427
  claim.orderId = input.orderId ?? null
1428
1428
  claim.orderNumber = orderNumber
@@ -1673,7 +1673,7 @@ const updateClaimCommand: CommandHandler<ClaimUpdateInput, { claimId: string }>
1673
1673
  const customerChanged = hasOwn(input, 'customerId') && (input.customerId ?? null) !== (claim.customerId ?? null)
1674
1674
  const shouldRefreshCustomerName = hasOwn(input, 'customerId') || hasOwn(input, 'customerName')
1675
1675
  const customerName = shouldRefreshCustomerName
1676
- ? await resolveCustomerName(ctx, input.customerId ?? claim.customerId, scope, input.customerName ?? claim.customerName, { strict: customerChanged })
1676
+ ? await resolveCustomerName(ctx, effectiveCustomerId, scope, customerChanged ? input.customerName : (input.customerName ?? claim.customerName), { strict: customerChanged })
1677
1677
  : claim.customerName ?? null
1678
1678
  const orderNumber = hasOwn(input, 'orderId')
1679
1679
  ? await resolveOrderNumber(ctx, input.orderId, scope, (input.orderId ?? null) === (claim.orderId ?? null) ? claim.orderNumber : null)