@open-mercato/core 0.6.1-develop.3219.1.ae5c8a5935 → 0.6.1-develop.3246.1.dbef9d7392

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 (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/cli.js +91 -8
  3. package/dist/modules/auth/cli.js.map +2 -2
  4. package/dist/modules/auth/lib/setup-app.js +21 -0
  5. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  6. package/dist/modules/customers/api/companies/route.js +11 -1
  7. package/dist/modules/customers/api/companies/route.js.map +2 -2
  8. package/dist/modules/customers/api/people/route.js +11 -1
  9. package/dist/modules/customers/api/people/route.js.map +2 -2
  10. package/dist/modules/customers/backend/customers/companies/page.js +78 -35
  11. package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
  12. package/dist/modules/customers/backend/customers/deals/page.js +74 -30
  13. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  14. package/dist/modules/customers/backend/customers/people/page.js +78 -35
  15. package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
  16. package/dist/modules/customers/commands/people.js +20 -0
  17. package/dist/modules/customers/commands/people.js.map +2 -2
  18. package/package.json +5 -5
  19. package/src/modules/auth/AGENTS.md +23 -0
  20. package/src/modules/auth/cli.ts +117 -8
  21. package/src/modules/auth/lib/setup-app.ts +28 -0
  22. package/src/modules/customers/api/companies/route.ts +10 -0
  23. package/src/modules/customers/api/people/route.ts +10 -0
  24. package/src/modules/customers/backend/customers/companies/page.tsx +95 -35
  25. package/src/modules/customers/backend/customers/deals/page.tsx +91 -30
  26. package/src/modules/customers/backend/customers/people/page.tsx +96 -35
  27. package/src/modules/customers/commands/people.ts +30 -0
  28. package/src/modules/customers/i18n/de.json +8 -0
  29. package/src/modules/customers/i18n/en.json +8 -0
  30. package/src/modules/customers/i18n/es.json +8 -0
  31. package/src/modules/customers/i18n/pl.json +8 -0
@@ -5,7 +5,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
5
5
  import { User, Role, UserRole } from '@open-mercato/core/modules/auth/data/entities'
6
6
  import { Tenant, Organization } from '@open-mercato/core/modules/directory/data/entities'
7
7
  import { rebuildHierarchyForTenant } from '@open-mercato/core/modules/directory/lib/hierarchy'
8
- import { ensureRoles, setupInitialTenant, ensureDefaultRoleAcls, ensureCustomRoleAcls } from './lib/setup-app'
8
+ import { ensureRoles, setupInitialTenant, ensureDefaultRoleAcls, ensureCustomRoleAcls, OrgSlugExistsError } from './lib/setup-app'
9
9
  import { normalizeTenantId } from './lib/tenantAccess'
10
10
  import { computeEmailHash } from './lib/emailHash'
11
11
  import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
@@ -403,6 +403,8 @@ const addOrganization: ModuleCli = {
403
403
  },
404
404
  }
405
405
 
406
+ const SETUP_USAGE = 'Usage: mercato auth setup --orgName <name> --email <email> --password <password> [--orgSlug <slug>] [--roles superadmin,admin,employee] [--skip-password-policy] [--with-examples] [--json]'
407
+
406
408
  const setupApp: ModuleCli = {
407
409
  command: 'setup',
408
410
  async run(rest) {
@@ -414,6 +416,17 @@ const setupApp: ModuleCli = {
414
416
  : undefined
415
417
  const email = typeof args.email === 'string' ? args.email : undefined
416
418
  const password = typeof args.password === 'string' ? args.password : undefined
419
+ const orgSlugRaw = typeof args.orgSlug === 'string'
420
+ ? args.orgSlug
421
+ : typeof args.slug === 'string'
422
+ ? args.slug
423
+ : undefined
424
+ const orgSlug = orgSlugRaw && orgSlugRaw.trim().length ? orgSlugRaw.trim() : undefined
425
+ if (orgSlug !== undefined && !ORG_SLUG_PATTERN.test(orgSlug)) {
426
+ process.stderr.write(`Invalid --orgSlug: must match ${ORG_SLUG_PATTERN.source} (lowercase, digits, dashes; 1-63 chars; cannot start or end with a dash)\n`)
427
+ process.exitCode = 2
428
+ return
429
+ }
417
430
  const rolesCsv = typeof args.roles === 'string'
418
431
  ? args.roles.trim()
419
432
  : 'superadmin,admin,employee'
@@ -425,29 +438,98 @@ const setupApp: ModuleCli = {
425
438
  const skipPasswordPolicy = typeof skipPasswordPolicyRaw === 'boolean'
426
439
  ? skipPasswordPolicyRaw
427
440
  : parseBooleanToken(typeof skipPasswordPolicyRaw === 'string' ? skipPasswordPolicyRaw : null) ?? false
441
+ const withExamplesRaw = args['with-examples'] ?? args.withExamples
442
+ const withExamples = typeof withExamplesRaw === 'boolean'
443
+ ? withExamplesRaw
444
+ : parseBooleanToken(typeof withExamplesRaw === 'string' ? withExamplesRaw : null) ?? false
445
+ const jsonModeRaw = args.json
446
+ const jsonMode = typeof jsonModeRaw === 'boolean'
447
+ ? jsonModeRaw
448
+ : parseBooleanToken(typeof jsonModeRaw === 'string' ? jsonModeRaw : null) ?? false
428
449
  if (!orgName || !email || !password) {
429
- console.error('Usage: mercato auth setup --orgName <name> --email <email> --password <password> [--roles superadmin,admin,employee] [--skip-password-policy]')
450
+ process.stderr.write(`${SETUP_USAGE}\n`)
451
+ process.exitCode = 2
452
+ return
453
+ }
454
+ if (!skipPasswordPolicy && !ensurePasswordPolicy(password)) {
455
+ process.exitCode = 2
430
456
  return
431
457
  }
432
- if (!skipPasswordPolicy && !ensurePasswordPolicy(password)) return
433
- if (skipPasswordPolicy) {
458
+ if (skipPasswordPolicy && !jsonMode) {
434
459
  console.warn('⚠️ Password policy validation skipped for setup.')
435
460
  }
436
- const { resolve } = await createRequestContainer()
437
- const em = resolve<EntityManager>('em')
461
+ let restoreConsole: (() => void) | null = null
462
+ if (jsonMode) {
463
+ // Force quiet mode so module bootstrap / lifecycle hooks cannot pollute
464
+ // stdout. The JSON contract relies on a single line, so any console.log
465
+ // from setupInitialTenant or seedExamples would otherwise break the
466
+ // consumer's `jq` pipe. We additionally rebind console.log/info to a
467
+ // no-op for the duration of the call because OM_CLI_QUIET is honored
468
+ // unevenly across module setup hooks. Rebind AFTER early-return
469
+ // validation so the missing-args and password-policy paths can't
470
+ // leak the rebinding into a long-lived process (e.g. test runner).
471
+ process.env.OM_CLI_QUIET = process.env.OM_CLI_QUIET ?? '1'
472
+ const originalLog = console.log
473
+ const originalInfo = console.info
474
+ console.log = () => undefined
475
+ console.info = () => undefined
476
+ restoreConsole = () => {
477
+ console.log = originalLog
478
+ console.info = originalInfo
479
+ }
480
+ }
481
+ const container = await createRequestContainer()
482
+ const em = container.resolve<EntityManager>('em')
438
483
  const roleNames = rolesCsv
439
484
  ? rolesCsv.split(',').map((s) => s.trim()).filter(Boolean)
440
485
  : undefined
441
486
 
442
487
  try {
488
+ const modules = getCliModules()
443
489
  const result = await setupInitialTenant(em, {
444
490
  orgName,
491
+ orgSlug,
445
492
  roleNames,
446
493
  primaryUser: { email, password, confirm: true },
447
494
  includeDerivedUsers: true,
448
- modules: getCliModules(),
495
+ // When the caller passes an explicit slug, treat it as a "fresh tenant"
496
+ // signal — silent reuse of an existing user's tenant defeats the point
497
+ // of slugging the new tenant for downstream tooling.
498
+ failIfUserExists: orgSlug !== undefined,
499
+ modules,
449
500
  })
450
501
 
502
+ if (withExamples) {
503
+ const seedCtx = {
504
+ em,
505
+ tenantId: result.tenantId,
506
+ organizationId: result.organizationId,
507
+ container,
508
+ }
509
+ for (const mod of modules) {
510
+ if (mod.setup?.seedExamples) {
511
+ await mod.setup.seedExamples(seedCtx as any)
512
+ }
513
+ }
514
+ }
515
+
516
+ if (jsonMode) {
517
+ const adminSnapshot = pickAdminSnapshot(result.users, email)
518
+ const adminUserId = adminSnapshot && adminSnapshot.user.id != null
519
+ ? String(adminSnapshot.user.id)
520
+ : null
521
+ const payload = {
522
+ tenantId: result.tenantId,
523
+ organizationId: result.organizationId,
524
+ adminUserId,
525
+ adminEmail: email,
526
+ reusedExistingUser: result.reusedExistingUser,
527
+ }
528
+ if (restoreConsole) restoreConsole()
529
+ process.stdout.write(`${JSON.stringify(payload)}\n`)
530
+ return
531
+ }
532
+
451
533
  if (result.reusedExistingUser) {
452
534
  console.log('⚠️ Existing initial user detected during setup.')
453
535
  console.log(`⚠️ Email: ${email}`)
@@ -470,15 +552,42 @@ const setupApp: ModuleCli = {
470
552
 
471
553
  if (env.NODE_ENV !== 'test') console.log('✅ Setup complete:', { tenantId: result.tenantId, organizationId: result.organizationId })
472
554
  } catch (err) {
555
+ if (restoreConsole) restoreConsole()
556
+ if (err instanceof OrgSlugExistsError) {
557
+ process.stderr.write(`${err.message}\n`)
558
+ process.exitCode = 1
559
+ return
560
+ }
473
561
  if (err instanceof Error && err.message === 'USER_EXISTS') {
474
- console.error('Setup aborted: user already exists with the provided email.')
562
+ process.stderr.write('Setup aborted: user already exists with the provided email.\n')
563
+ process.exitCode = 1
475
564
  return
476
565
  }
477
566
  throw err
567
+ } finally {
568
+ if (restoreConsole) restoreConsole()
478
569
  }
479
570
  },
480
571
  }
481
572
 
573
+ const ORG_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
574
+
575
+ function pickAdminSnapshot(
576
+ users: Array<{ user: { id: string; email: string }; roles: string[]; created: boolean }>,
577
+ adminEmail: string,
578
+ ): { user: { id: string; email: string } } | undefined {
579
+ const normalizedEmail = adminEmail.toLowerCase()
580
+ const exact = users.find(
581
+ (snapshot) =>
582
+ snapshot.created &&
583
+ snapshot.roles.includes('superadmin') &&
584
+ typeof snapshot.user.email === 'string' &&
585
+ snapshot.user.email.toLowerCase() === normalizedEmail,
586
+ )
587
+ if (exact) return exact
588
+ return users.find((snapshot) => snapshot.roles.includes('superadmin'))
589
+ }
590
+
482
591
  const listOrganizations: ModuleCli = {
483
592
  command: 'list-orgs',
484
593
  async run() {
@@ -90,6 +90,20 @@ export type SetupInitialTenantOptions = {
90
90
  includeSuperadminRole?: boolean
91
91
  /** Optional list of enabled modules. When provided, module setup hooks are called. */
92
92
  modules?: Module[]
93
+ /**
94
+ * Optional global slug to persist on the new Organization. When set, a
95
+ * pre-flight uniqueness check across all tenants throws OrgSlugExistsError
96
+ * on collision so scriptable callers can fail loudly instead of silently
97
+ * reusing or clobbering an existing organization.
98
+ */
99
+ orgSlug?: string
100
+ }
101
+
102
+ export class OrgSlugExistsError extends Error {
103
+ constructor(public readonly slug: string) {
104
+ super(`ORG_SLUG_EXISTS: an organization with slug "${slug}" already exists`)
105
+ this.name = 'OrgSlugExistsError'
106
+ }
93
107
  }
94
108
 
95
109
  export type SetupInitialTenantResult = {
@@ -131,6 +145,19 @@ export async function setupInitialTenant(
131
145
  throw new Error('USER_EXISTS')
132
146
  }
133
147
 
148
+ if (options.orgSlug) {
149
+ const slugConflict = await findOneWithDecryption(
150
+ em,
151
+ Organization,
152
+ { slug: options.orgSlug },
153
+ {},
154
+ { tenantId: null, organizationId: null },
155
+ )
156
+ if (slugConflict) {
157
+ throw new OrgSlugExistsError(options.orgSlug)
158
+ }
159
+ }
160
+
134
161
  let tenantId: string | undefined
135
162
  let organizationId: string | undefined
136
163
  let reusedExistingUser = false
@@ -206,6 +233,7 @@ export async function setupInitialTenant(
206
233
 
207
234
  const organization = tem.create(Organization, {
208
235
  name: options.orgName,
236
+ slug: options.orgSlug ?? null,
209
237
  tenant,
210
238
  isActive: true,
211
239
  depth: 0,
@@ -528,5 +528,15 @@ export const openApi = createCustomersCrudOpenApi({
528
528
  schema: z.object({ id: z.string().uuid() }),
529
529
  responseSchema: defaultOkResponseSchema,
530
530
  description: 'Deletes a company by id. The identifier can be provided via body or query.',
531
+ errors: [
532
+ {
533
+ status: 422,
534
+ description: 'Company has dependent records (people, deals, or direct staff); unlink or reassign before delete.',
535
+ schema: z.object({
536
+ error: z.string(),
537
+ code: z.literal('COMPANY_HAS_DEPENDENTS'),
538
+ }),
539
+ },
540
+ ],
531
541
  },
532
542
  })
@@ -559,5 +559,15 @@ export const openApi = createCustomersCrudOpenApi({
559
559
  schema: z.object({ id: z.string().uuid() }),
560
560
  responseSchema: defaultOkResponseSchema,
561
561
  description: 'Deletes a person by id. Request body or query may provide the identifier.',
562
+ errors: [
563
+ {
564
+ status: 422,
565
+ description: 'Person has dependent records (e.g. linked deals); unlink or reassign before delete.',
566
+ schema: z.object({
567
+ error: z.string(),
568
+ code: z.literal('PERSON_HAS_DEPENDENTS'),
569
+ }),
570
+ },
571
+ ],
562
572
  },
563
573
  })
@@ -10,6 +10,9 @@ import { Button } from '@open-mercato/ui/primitives/button'
10
10
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
11
11
  import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
12
12
  import { buildCrudExportUrl } from '@open-mercato/ui/backend/utils/crud'
13
+ import { groupBulkDeleteFailures, runBulkDelete } from '@open-mercato/ui/backend/utils/bulkDelete'
14
+ import { coalesceLastOperations } from '@open-mercato/ui/backend/operations/store'
15
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
13
16
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
14
17
  import { E } from '#generated/entities.ids.generated'
15
18
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
@@ -161,6 +164,26 @@ export default function CustomersCompaniesPage() {
161
164
  setPageSize(newSize)
162
165
  setPage(1)
163
166
  }, [])
167
+
168
+ const bulkMutationContextId = 'customers-companies-list:bulk-delete'
169
+ const { runMutation: runBulkMutation, retryLastMutation: retryBulkMutation } = useGuardedMutation<{
170
+ formId: string
171
+ resourceKind: string
172
+ retryLastMutation: () => Promise<boolean>
173
+ }>({
174
+ contextId: bulkMutationContextId,
175
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
176
+ })
177
+ const singleMutationContextId = 'customers-companies-list:single-delete'
178
+ const { runMutation: runSingleMutation, retryLastMutation: retrySingleMutation } = useGuardedMutation<{
179
+ formId: string
180
+ resourceKind: string
181
+ resourceId: string
182
+ retryLastMutation: () => Promise<boolean>
183
+ }>({
184
+ contextId: singleMutationContextId,
185
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
186
+ })
164
187
  const fetchDictionaryEntries = React.useCallback(async (kind: DictionaryKindKey) => {
165
188
  try {
166
189
  const data = await ensureCustomerDictionary(queryClient, kind, scopeVersion)
@@ -460,14 +483,24 @@ export default function CustomersCompaniesPage() {
460
483
  })
461
484
  if (!confirmed) return
462
485
  try {
463
- await apiCallOrThrow(
464
- `/api/customers/companies?id=${encodeURIComponent(company.id)}`,
465
- {
466
- method: 'DELETE',
467
- headers: { 'content-type': 'application/json' },
486
+ await runSingleMutation({
487
+ operation: async () => {
488
+ await apiCallOrThrow(
489
+ `/api/customers/companies?id=${encodeURIComponent(company.id)}`,
490
+ {
491
+ method: 'DELETE',
492
+ headers: { 'content-type': 'application/json' },
493
+ },
494
+ { errorMessage: t('customers.companies.list.deleteError') },
495
+ )
468
496
  },
469
- { errorMessage: t('customers.companies.list.deleteError') },
470
- )
497
+ context: {
498
+ formId: singleMutationContextId,
499
+ resourceKind: 'customers.company',
500
+ resourceId: company.id,
501
+ retryLastMutation: retrySingleMutation,
502
+ },
503
+ })
471
504
  setRows((prev) => prev.filter((row) => row.id !== company.id))
472
505
  setTotal((prev) => Math.max(prev - 1, 0))
473
506
  handleRefresh()
@@ -476,7 +509,7 @@ export default function CustomersCompaniesPage() {
476
509
  const message = err instanceof Error ? err.message : t('customers.companies.list.deleteError')
477
510
  flash(message, 'error')
478
511
  }
479
- }, [confirm, handleRefresh, t])
512
+ }, [confirm, handleRefresh, retrySingleMutation, runSingleMutation, singleMutationContextId, t])
480
513
 
481
514
  const handleBulkDelete = React.useCallback(async (selectedRows: CompanyRow[]) => {
482
515
  const confirmed = await confirm({
@@ -485,44 +518,71 @@ export default function CustomersCompaniesPage() {
485
518
  variant: 'destructive',
486
519
  })
487
520
  if (!confirmed) return false
488
- let deletedCount = 0
489
- const failedIds: string[] = []
490
- for (const row of selectedRows) {
491
- try {
492
- await apiCallOrThrow(`/api/customers/companies?id=${encodeURIComponent(row.id)}`, {
493
- method: 'DELETE',
494
- headers: { 'content-type': 'application/json' },
521
+
522
+ const { succeeded, failures } = await runBulkMutation({
523
+ operation: async () =>
524
+ runBulkDelete(
525
+ selectedRows,
526
+ async (row) => {
527
+ await apiCallOrThrow(`/api/customers/companies?id=${encodeURIComponent(row.id)}`, {
528
+ method: 'DELETE',
529
+ headers: { 'content-type': 'application/json' },
530
+ })
531
+ },
532
+ {
533
+ fallbackErrorMessage: t('customers.companies.list.deleteError', 'Failed to delete company.'),
534
+ logTag: 'customers.companies.list',
535
+ },
536
+ ),
537
+ context: {
538
+ formId: bulkMutationContextId,
539
+ resourceKind: 'customers.company',
540
+ retryLastMutation: retryBulkMutation,
541
+ },
542
+ })
543
+
544
+ if (succeeded.length > 0) {
545
+ const succeededIds = new Set(succeeded.map((r) => r.id))
546
+ setRows((prev) => prev.filter((r) => !succeededIds.has(r.id)))
547
+ setTotal((prev) => Math.max(0, prev - succeeded.length))
548
+ setReloadToken((prev) => prev + 1)
549
+ if (succeeded.length > 1) {
550
+ coalesceLastOperations(succeeded.length, {
551
+ commandId: 'customers.companies.delete',
552
+ actionLabel: t('customers.companies.list.bulkDelete.operationLabel', 'Delete {count} companies', { count: succeeded.length }),
553
+ resourceKind: 'customers.company',
495
554
  })
496
- deletedCount++
497
- } catch (err) {
498
- failedIds.push(row.id)
499
- console.warn('[customers.companies.list] bulk delete failed', row.id, err)
500
555
  }
501
- }
502
- if (deletedCount > 0) {
503
- setRows((prev) => {
504
- const succeeded = new Set(selectedRows.map((r) => r.id).filter((id) => !failedIds.includes(id)))
505
- return prev.filter((r) => !succeeded.has(r.id))
506
- })
507
- setTotal((prev) => Math.max(0, prev - deletedCount))
508
- if (failedIds.length === 0) {
509
- flash(t('customers.companies.list.bulkDelete.success', '{count} companies deleted', { count: deletedCount }), 'success')
556
+ if (failures.length === 0) {
557
+ flash(
558
+ t('customers.companies.list.bulkDelete.success', '{count} companies deleted', { count: succeeded.length }),
559
+ 'success',
560
+ )
510
561
  } else {
511
562
  flash(
512
563
  t('customers.companies.list.bulkDelete.partial', '{deleted} of {total} companies deleted; {failed} failed', {
513
- deleted: deletedCount,
564
+ deleted: succeeded.length,
514
565
  total: selectedRows.length,
515
- failed: failedIds.length,
566
+ failed: failures.length,
516
567
  }),
517
568
  'warning',
518
569
  )
519
570
  }
520
- setReloadToken((prev) => prev + 1)
521
- } else if (failedIds.length > 0) {
522
- flash(t('customers.companies.list.bulkDelete.failed', 'Failed to delete {count} companies', { count: failedIds.length }), 'error')
523
571
  }
524
- return deletedCount > 0
525
- }, [confirm, t])
572
+
573
+ for (const group of groupBulkDeleteFailures(failures)) {
574
+ const message = group.count === 1
575
+ ? group.sampleMessage
576
+ : t(
577
+ 'customers.companies.list.bulkDelete.failedGroup',
578
+ '{count} companies could not be deleted: {message}',
579
+ { count: group.count, message: group.sampleMessage },
580
+ )
581
+ flash(message, 'error')
582
+ }
583
+
584
+ return succeeded.length > 0
585
+ }, [bulkMutationContextId, confirm, retryBulkMutation, runBulkMutation, t])
526
586
 
527
587
  const handleFiltersApply = React.useCallback((values: FilterValues) => {
528
588
  const next: FilterValues = {}
@@ -12,6 +12,9 @@ import type { AdvancedFilterState } from '@open-mercato/shared/lib/query/advance
12
12
  import { deserializeAdvancedFilter, serializeAdvancedFilter } from '@open-mercato/shared/lib/query/advanced-filter'
13
13
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
14
14
  import { buildCrudExportUrl, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
15
+ import { groupBulkDeleteFailures, runBulkDelete } from '@open-mercato/ui/backend/utils/bulkDelete'
16
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
17
+ import { coalesceLastOperations } from '@open-mercato/ui/backend/operations/store'
15
18
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
16
19
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
17
20
  import { Button } from '@open-mercato/ui/primitives/button'
@@ -719,6 +722,26 @@ export default function CustomersDealsPage() {
719
722
  setReloadToken((token) => token + 1)
720
723
  }, [queryClient])
721
724
 
725
+ const bulkMutationContextId = 'customers-deals-list:bulk-delete'
726
+ const { runMutation: runBulkMutation, retryLastMutation: retryBulkMutation } = useGuardedMutation<{
727
+ formId: string
728
+ resourceKind: string
729
+ retryLastMutation: () => Promise<boolean>
730
+ }>({
731
+ contextId: bulkMutationContextId,
732
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
733
+ })
734
+ const singleMutationContextId = 'customers-deals-list:single-delete'
735
+ const { runMutation: runSingleMutation, retryLastMutation: retrySingleMutation } = useGuardedMutation<{
736
+ formId: string
737
+ resourceKind: string
738
+ resourceId: string
739
+ retryLastMutation: () => Promise<boolean>
740
+ }>({
741
+ contextId: singleMutationContextId,
742
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
743
+ })
744
+
722
745
  const handleDeleteDeal = React.useCallback(
723
746
  async (dealId: string) => {
724
747
  if (pendingDeleteId) return
@@ -732,9 +755,19 @@ export default function CustomersDealsPage() {
732
755
  if (!confirmed) return
733
756
  setPendingDeleteId(dealId)
734
757
  try {
735
- await deleteCrud('customers/deals', {
736
- body: { id: dealId },
737
- errorMessage: t('customers.deals.list.deleteError', 'Failed to delete deal.'),
758
+ await runSingleMutation({
759
+ operation: async () => {
760
+ await deleteCrud('customers/deals', {
761
+ body: { id: dealId },
762
+ errorMessage: t('customers.deals.list.deleteError', 'Failed to delete deal.'),
763
+ })
764
+ },
765
+ context: {
766
+ formId: singleMutationContextId,
767
+ resourceKind: 'customers.deal',
768
+ resourceId: dealId,
769
+ retryLastMutation: retrySingleMutation,
770
+ },
738
771
  })
739
772
  flash(t('customers.deals.list.deleteSuccess', 'Deal deleted.'), 'success')
740
773
  setRows((prev) => prev.filter((row) => row.id !== dealId))
@@ -750,7 +783,7 @@ export default function CustomersDealsPage() {
750
783
  setPendingDeleteId(null)
751
784
  }
752
785
  },
753
- [confirm, handleRefresh, pendingDeleteId, t],
786
+ [confirm, handleRefresh, pendingDeleteId, retrySingleMutation, runSingleMutation, singleMutationContextId, t],
754
787
  )
755
788
 
756
789
  const handlePageSizeChange = React.useCallback((newSize: number) => {
@@ -765,43 +798,71 @@ export default function CustomersDealsPage() {
765
798
  variant: 'destructive',
766
799
  })
767
800
  if (!confirmed) return false
768
- let deletedCount = 0
769
- const failedIds: string[] = []
770
- for (const row of selectedRows) {
771
- try {
772
- await deleteCrud('customers/deals', {
773
- body: { id: row.id },
774
- errorMessage: t('customers.deals.list.deleteError', 'Failed to delete deal.'),
801
+
802
+ const { succeeded, failures } = await runBulkMutation({
803
+ operation: async () =>
804
+ runBulkDelete(
805
+ selectedRows,
806
+ async (row) => {
807
+ await deleteCrud('customers/deals', {
808
+ body: { id: row.id },
809
+ errorMessage: t('customers.deals.list.deleteError', 'Failed to delete deal.'),
810
+ })
811
+ },
812
+ {
813
+ fallbackErrorMessage: t('customers.deals.list.deleteError', 'Failed to delete deal.'),
814
+ logTag: 'customers.deals.list',
815
+ },
816
+ ),
817
+ context: {
818
+ formId: bulkMutationContextId,
819
+ resourceKind: 'customers.deal',
820
+ retryLastMutation: retryBulkMutation,
821
+ },
822
+ })
823
+
824
+ if (succeeded.length > 0) {
825
+ const succeededIds = new Set(succeeded.map((r) => r.id))
826
+ setRows((prev) => prev.filter((r) => !succeededIds.has(r.id)))
827
+ setTotal((prev) => Math.max(0, prev - succeeded.length))
828
+ setReloadToken((prev) => prev + 1)
829
+ if (succeeded.length > 1) {
830
+ coalesceLastOperations(succeeded.length, {
831
+ commandId: 'customers.deals.delete',
832
+ actionLabel: t('customers.deals.list.bulkDelete.operationLabel', 'Delete {count} deals', { count: succeeded.length }),
833
+ resourceKind: 'customers.deal',
775
834
  })
776
- deletedCount++
777
- } catch (err) {
778
- failedIds.push(row.id)
779
- console.warn('[customers.deals.list] bulk delete failed', row.id, err)
780
835
  }
781
- }
782
- if (deletedCount > 0) {
783
- setRows((prev) => {
784
- const succeeded = new Set(selectedRows.map((r) => r.id).filter((id) => !failedIds.includes(id)))
785
- return prev.filter((r) => !succeeded.has(r.id))
786
- })
787
- setTotal((prev) => Math.max(0, prev - deletedCount))
788
- if (failedIds.length === 0) {
789
- flash(t('customers.deals.list.bulkDelete.success', '{count} deals deleted', { count: deletedCount }), 'success')
836
+ if (failures.length === 0) {
837
+ flash(
838
+ t('customers.deals.list.bulkDelete.success', '{count} deals deleted', { count: succeeded.length }),
839
+ 'success',
840
+ )
790
841
  } else {
791
842
  flash(
792
843
  t('customers.deals.list.bulkDelete.partial', '{deleted} of {total} deals deleted; {failed} failed', {
793
- deleted: deletedCount,
844
+ deleted: succeeded.length,
794
845
  total: selectedRows.length,
795
- failed: failedIds.length,
846
+ failed: failures.length,
796
847
  }),
797
848
  'warning',
798
849
  )
799
850
  }
800
- } else if (failedIds.length > 0) {
801
- flash(t('customers.deals.list.bulkDelete.failed', 'Failed to delete {count} deals', { count: failedIds.length }), 'error')
802
851
  }
803
- return deletedCount > 0
804
- }, [confirm, t])
852
+
853
+ for (const group of groupBulkDeleteFailures(failures)) {
854
+ const message = group.count === 1
855
+ ? group.sampleMessage
856
+ : t(
857
+ 'customers.deals.list.bulkDelete.failedGroup',
858
+ '{count} deals could not be deleted: {message}',
859
+ { count: group.count, message: group.sampleMessage },
860
+ )
861
+ flash(message, 'error')
862
+ }
863
+
864
+ return succeeded.length > 0
865
+ }, [bulkMutationContextId, confirm, retryBulkMutation, runBulkMutation, t])
805
866
 
806
867
  const personOptions = peopleState.options
807
868
  const companyOptions = companiesState.options