@open-mercato/core 0.10.1-develop.7671.1.9117e4b0b3 → 0.10.1-develop.7675.1.fb7858c0a2

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.
@@ -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