@open-mercato/core 0.10.1-develop.7847.1.7d83dc5794 → 0.10.1-develop.7857.1.259928eb85

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 (64) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/api/file/[id]/route.js +6 -1
  3. package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
  4. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +6 -1
  5. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  6. package/dist/modules/attachments/api/library/[id]/route.js +16 -1
  7. package/dist/modules/attachments/api/library/[id]/route.js.map +2 -2
  8. package/dist/modules/attachments/api/route.js +11 -3
  9. package/dist/modules/attachments/api/route.js.map +2 -2
  10. package/dist/modules/attachments/cli.js +2 -0
  11. package/dist/modules/attachments/cli.js.map +2 -2
  12. package/dist/modules/attachments/data/validators.js +11 -0
  13. package/dist/modules/attachments/data/validators.js.map +2 -2
  14. package/dist/modules/attachments/index.js +10 -0
  15. package/dist/modules/attachments/index.js.map +2 -2
  16. package/dist/modules/attachments/lib/access-query.js +5 -1
  17. package/dist/modules/attachments/lib/access-query.js.map +2 -2
  18. package/dist/modules/attachments/lib/createFromBuffer.js +2 -0
  19. package/dist/modules/attachments/lib/createFromBuffer.js.map +2 -2
  20. package/dist/modules/attachments/lib/drivers/driverFactory.js +54 -33
  21. package/dist/modules/attachments/lib/drivers/driverFactory.js.map +2 -2
  22. package/dist/modules/attachments/lib/drivers/index.js +11 -1
  23. package/dist/modules/attachments/lib/drivers/index.js.map +2 -2
  24. package/dist/modules/attachments/lib/drivers/storageValidation.js +85 -0
  25. package/dist/modules/attachments/lib/drivers/storageValidation.js.map +7 -0
  26. package/dist/modules/attachments/lib/scoped-upload-service.js +3 -1
  27. package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
  28. package/dist/modules/attachments/lib/storageErrors.js +22 -0
  29. package/dist/modules/attachments/lib/storageErrors.js.map +7 -0
  30. package/dist/modules/customers/api/companies/[id]/route.js +4 -9
  31. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  32. package/dist/modules/customers/api/people/[id]/route.js +4 -9
  33. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  34. package/dist/modules/customers/components/detail/TaskDialog.js +2 -0
  35. package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
  36. package/dist/modules/customers/lib/todoCompatibility.js +23 -0
  37. package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
  38. package/jest.config.cjs +1 -1
  39. package/package.json +8 -8
  40. package/src/modules/attachments/api/file/[id]/route.ts +7 -1
  41. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +7 -1
  42. package/src/modules/attachments/api/library/[id]/route.ts +14 -1
  43. package/src/modules/attachments/api/route.ts +11 -3
  44. package/src/modules/attachments/cli.ts +2 -0
  45. package/src/modules/attachments/data/validators.ts +11 -0
  46. package/src/modules/attachments/i18n/de.json +1 -0
  47. package/src/modules/attachments/i18n/en.json +1 -0
  48. package/src/modules/attachments/i18n/es.json +1 -0
  49. package/src/modules/attachments/i18n/ko.json +1 -0
  50. package/src/modules/attachments/i18n/pl.json +1 -0
  51. package/src/modules/attachments/index.ts +12 -0
  52. package/src/modules/attachments/lib/access-query.ts +5 -1
  53. package/src/modules/attachments/lib/createFromBuffer.ts +2 -0
  54. package/src/modules/attachments/lib/drivers/driverFactory.ts +79 -55
  55. package/src/modules/attachments/lib/drivers/index.ts +13 -0
  56. package/src/modules/attachments/lib/drivers/storageValidation.ts +114 -0
  57. package/src/modules/attachments/lib/scoped-upload-service.ts +3 -1
  58. package/src/modules/attachments/lib/storageErrors.ts +21 -0
  59. package/src/modules/customer_accounts/AGENTS.md +1 -1
  60. package/src/modules/customers/api/companies/[id]/route.ts +6 -13
  61. package/src/modules/customers/api/people/[id]/route.ts +6 -13
  62. package/src/modules/customers/components/detail/TaskDialog.tsx +2 -0
  63. package/src/modules/customers/lib/todoCompatibility.ts +28 -0
  64. package/src/modules/sales/AGENTS.md +3 -3
@@ -3,40 +3,48 @@ import type { StorageDriver } from './types'
3
3
  import { LocalStorageDriver } from './localDriver'
4
4
  import { LegacyPublicStorageDriver } from './legacyPublicDriver'
5
5
  import { AttachmentPartition } from '../../data/entities'
6
- import { createLogger } from '@open-mercato/shared/lib/logger'
7
-
8
- const logger = createLogger('attachments').child({ component: 'storage-driver-factory' })
9
-
10
- type DriverScope = { tenantId: string; organizationId: string }
6
+ import {
7
+ AttachmentStorageConfigurationError,
8
+ assertAttachmentStoragePartitionExists,
9
+ getAttachmentStoragePolicy,
10
+ validateAttachmentStorageConfiguration,
11
+ type AttachmentStorageScope,
12
+ type AttachmentStorageValidationStage,
13
+ } from './storageValidation'
14
+
15
+ type DriverScope = AttachmentStorageScope
11
16
  type CredentialEnhancer = (config: Record<string, unknown>, scope: DriverScope) => Promise<Record<string, unknown>>
17
+ type DriverFactory = (config: Record<string, unknown>) => StorageDriver
12
18
 
13
- type DriverRegistration = { factory: (config: Record<string, unknown>) => StorageDriver }
14
- type EnhancerRegistration = { enhancer: CredentialEnhancer }
19
+ type ModuleRegistry = {
20
+ drivers: Map<string, DriverFactory>
21
+ enhancers: Map<string, CredentialEnhancer>
22
+ }
23
+ const REGISTRY_KEY = Symbol.for('@open-mercato/AttachmentStorageDrivers')
15
24
 
16
- const moduleDriverRegistry = new Map<string, DriverRegistration>()
17
- const moduleEnhancerRegistry = new Map<string, EnhancerRegistration>()
25
+ function moduleRegistry(): ModuleRegistry {
26
+ const registry = globalThis as typeof globalThis & { [REGISTRY_KEY]?: ModuleRegistry }
27
+ return registry[REGISTRY_KEY] ??= { drivers: new Map(), enhancers: new Map() }
28
+ }
18
29
 
19
- export function registerExternalStorageDriver(
20
- key: string,
21
- factory: (config: Record<string, unknown>) => StorageDriver,
22
- ): void {
23
- moduleDriverRegistry.set(key, { factory })
30
+ export function registerExternalStorageDriver(key: string, factory: DriverFactory): void {
31
+ moduleRegistry().drivers.set(key, factory)
24
32
  }
25
33
 
26
34
  export function registerExternalCredentialEnhancer(key: string, enhancer: CredentialEnhancer): void {
27
- moduleEnhancerRegistry.set(key, { enhancer })
35
+ moduleRegistry().enhancers.set(key, enhancer)
28
36
  }
29
37
 
30
38
  export class StorageDriverFactory {
31
39
  private readonly cache = new Map<string, StorageDriver>()
32
- private readonly localDriver = new LocalStorageDriver()
33
- private readonly legacyPublicDriver = new LegacyPublicStorageDriver()
34
- private readonly externalDrivers = new Map<string, (config: Record<string, unknown>) => StorageDriver>()
40
+ private localDriver?: LocalStorageDriver
41
+ private legacyPublicDriver?: LegacyPublicStorageDriver
42
+ private readonly externalDrivers = new Map<string, DriverFactory>()
35
43
  private readonly credentialEnhancers = new Map<string, CredentialEnhancer>()
36
44
 
37
45
  constructor(private readonly em: EntityManager) {}
38
46
 
39
- registerDriver(key: string, factory: (config: Record<string, unknown>) => StorageDriver): void {
47
+ registerDriver(key: string, factory: DriverFactory): void {
40
48
  this.externalDrivers.set(key, factory)
41
49
  }
42
50
 
@@ -44,51 +52,67 @@ export class StorageDriverFactory {
44
52
  this.credentialEnhancers.set(key, enhancer)
45
53
  }
46
54
 
47
- resolveForAttachment(
48
- storageDriver: string,
49
- configJson?: Record<string, unknown> | null,
50
- ): StorageDriver {
51
- switch (storageDriver) {
52
- case 'legacyPublic':
53
- return this.legacyPublicDriver
54
-
55
- case 'local':
56
- return this.localDriver
57
-
58
- default: {
59
- const externalFactory =
60
- this.externalDrivers.get(storageDriver) ?? moduleDriverRegistry.get(storageDriver)?.factory
61
- if (externalFactory) {
62
- const cacheKey = `${storageDriver}:${JSON.stringify(configJson ?? {})}`
63
- const cached = this.cache.get(cacheKey)
64
- if (cached) return cached
65
- const driver = externalFactory(configJson ?? {})
66
- this.cache.set(cacheKey, driver)
67
- return driver
68
- }
69
- return this.localDriver
70
- }
71
- }
55
+ resolveForAttachment(storageDriver: string, configJson?: Record<string, unknown> | null): StorageDriver {
56
+ const config = configJson ?? {}
57
+ this.validate(storageDriver, config, 'configured')
58
+ this.validate(storageDriver, config, 'resolved')
59
+ return this.resolveValidated(storageDriver, config)
72
60
  }
73
61
 
74
62
  async resolveForPartition(partitionCode: string, scope?: DriverScope): Promise<StorageDriver> {
63
+ getAttachmentStoragePolicy()
75
64
  const partition = await this.em.findOne(AttachmentPartition, { code: partitionCode })
76
- if (!partition) return this.localDriver
65
+ assertAttachmentStoragePartitionExists(Boolean(partition))
66
+ if (!partition) return this.localDriver ??= new LocalStorageDriver()
77
67
 
78
- const driverKey = partition.storageDriver ?? 'local'
79
- logger.debug('Resolving storage driver for partition', { partition: partitionCode, driverKey, moduleDrivers: [...moduleDriverRegistry.keys()], instanceDrivers: [...this.externalDrivers.keys()] })
80
68
  let config: Record<string, unknown> = partition.configJson ?? {}
81
-
82
- const activeEnhancer =
83
- this.credentialEnhancers.get(driverKey) ?? moduleEnhancerRegistry.get(driverKey)?.enhancer
69
+ this.validate(partition.storageDriver, config, 'configured', partitionCode, scope)
70
+ const driverKey = partition.storageDriver ?? 'local'
71
+ const activeEnhancer = this.credentialEnhancers.get(driverKey) ?? moduleRegistry().enhancers.get(driverKey)
84
72
  if (scope && activeEnhancer) {
85
- config = await activeEnhancer(config, scope)
86
- // Skip shared cache for scope-enhanced configs (credentials are per-tenant)
87
- const externalFactory =
88
- this.externalDrivers.get(driverKey) ?? moduleDriverRegistry.get(driverKey)?.factory
73
+ try {
74
+ config = await activeEnhancer(config, scope)
75
+ } catch (error) {
76
+ if (getAttachmentStoragePolicy() === 'strict') {
77
+ throw new AttachmentStorageConfigurationError('enhancement_failed')
78
+ }
79
+ throw error
80
+ }
81
+ this.validate(driverKey, config, 'resolved', partitionCode, scope)
82
+ const externalFactory = this.externalDrivers.get(driverKey) ?? moduleRegistry().drivers.get(driverKey)
89
83
  if (externalFactory) return externalFactory(config)
84
+ } else {
85
+ this.validate(driverKey, config, 'resolved', partitionCode, scope)
90
86
  }
87
+ return this.resolveValidated(driverKey, config)
88
+ }
89
+
90
+ private validate(
91
+ driverKey: unknown,
92
+ config: unknown,
93
+ stage: AttachmentStorageValidationStage,
94
+ partitionCode?: string,
95
+ scope?: DriverScope,
96
+ ): void {
97
+ const registered = typeof driverKey === 'string' && (
98
+ driverKey === 'local' || driverKey === 'legacyPublic'
99
+ || this.externalDrivers.has(driverKey) || moduleRegistry().drivers.has(driverKey)
100
+ )
101
+ validateAttachmentStorageConfiguration({ driverKey, config, stage, partitionCode, scope }, registered)
102
+ }
91
103
 
92
- return this.resolveForAttachment(driverKey, config)
104
+ private resolveValidated(driverKey: string, config: Record<string, unknown>): StorageDriver {
105
+ if (driverKey === 'legacyPublic') return this.legacyPublicDriver ??= new LegacyPublicStorageDriver()
106
+ if (driverKey === 'local') return this.localDriver ??= new LocalStorageDriver()
107
+ const externalFactory = this.externalDrivers.get(driverKey) ?? moduleRegistry().drivers.get(driverKey)
108
+ if (externalFactory) {
109
+ const cacheKey = `${driverKey}:${JSON.stringify(config)}`
110
+ const cached = this.cache.get(cacheKey)
111
+ if (cached) return cached
112
+ const driver = externalFactory(config)
113
+ this.cache.set(cacheKey, driver)
114
+ return driver
115
+ }
116
+ return this.localDriver ??= new LocalStorageDriver()
93
117
  }
94
118
  }
@@ -2,3 +2,16 @@ export type { PrepareFilePayload, StorageDriver, StoreFilePayload, StoredFile, R
2
2
  export { LocalStorageDriver } from './localDriver'
3
3
  export { LegacyPublicStorageDriver } from './legacyPublicDriver'
4
4
  export { StorageDriverFactory, registerExternalStorageDriver, registerExternalCredentialEnhancer } from './driverFactory'
5
+
6
+ export {
7
+ AttachmentStorageConfigurationError,
8
+ getAttachmentStoragePolicy,
9
+ isAttachmentStorageConfigurationError,
10
+ registerStorageDriverValidator,
11
+ type AttachmentStoragePolicy,
12
+ type AttachmentStorageScope,
13
+ type AttachmentStorageValidationStage,
14
+ type AttachmentStorageValidationContext,
15
+ type AttachmentStorageValidator,
16
+ type AttachmentStorageConfigurationReason,
17
+ } from './storageValidation'
@@ -0,0 +1,114 @@
1
+ import type { z } from 'zod'
2
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
3
+ import { attachmentStorageConfigurationSchema, attachmentStorageScopeSchema } from '../../data/validators'
4
+
5
+ export type AttachmentStoragePolicy = 'legacy' | 'strict'
6
+ export type AttachmentStorageScope = z.infer<typeof attachmentStorageScopeSchema>
7
+ export type AttachmentStorageValidationStage = 'configured' | 'resolved'
8
+ export type AttachmentStorageValidationContext = {
9
+ readonly driverKey: string
10
+ readonly config: Readonly<Record<string, unknown>>
11
+ readonly partitionCode?: string
12
+ readonly scope?: Readonly<AttachmentStorageScope>
13
+ readonly stage: AttachmentStorageValidationStage
14
+ }
15
+ export type AttachmentStorageValidator = (context: AttachmentStorageValidationContext) => boolean
16
+ export type AttachmentStorageConfigurationReason =
17
+ | 'invalid_policy'
18
+ | 'partition_missing'
19
+ | 'driver_missing'
20
+ | 'unknown_driver'
21
+ | 'invalid_config'
22
+ | 'invalid_scope'
23
+ | 'validator_missing'
24
+ | 'validator_rejected'
25
+ | 'enhancement_failed'
26
+
27
+ const ERROR_MARKER = Symbol.for('@open-mercato/AttachmentStorageConfigurationError')
28
+ const VALIDATORS_KEY = Symbol.for('@open-mercato/AttachmentStorageValidators')
29
+ type ValidatorRegistry = Map<string, AttachmentStorageValidator>
30
+
31
+ function validators(): ValidatorRegistry {
32
+ const registry = globalThis as typeof globalThis & { [VALIDATORS_KEY]?: ValidatorRegistry }
33
+ return registry[VALIDATORS_KEY] ??= new Map()
34
+ }
35
+
36
+ export class AttachmentStorageConfigurationError extends CrudHttpError {
37
+ readonly [ERROR_MARKER] = true
38
+ readonly code = 'ATTACHMENT_STORAGE_CONFIGURATION_INVALID'
39
+
40
+ constructor(readonly reason: AttachmentStorageConfigurationReason) {
41
+ super(503, {
42
+ error: 'attachments.errors.storageConfigurationInvalid',
43
+ code: 'ATTACHMENT_STORAGE_CONFIGURATION_INVALID',
44
+ reason,
45
+ })
46
+ this.name = 'AttachmentStorageConfigurationError'
47
+ }
48
+ }
49
+
50
+ export function isAttachmentStorageConfigurationError(error: unknown): error is AttachmentStorageConfigurationError {
51
+ return Boolean(error) && typeof error === 'object'
52
+ && (error as Record<symbol, unknown>)[ERROR_MARKER] === true
53
+ }
54
+
55
+ export function getAttachmentStoragePolicy(): AttachmentStoragePolicy {
56
+ const policy = process.env.OM_ATTACHMENT_STORAGE_POLICY?.trim()
57
+ if (!policy || policy === 'legacy') return 'legacy'
58
+ if (policy === 'strict') return 'strict'
59
+ throw new AttachmentStorageConfigurationError('invalid_policy')
60
+ }
61
+
62
+ export function registerStorageDriverValidator(key: string, validator: AttachmentStorageValidator): () => void {
63
+ const registry = validators()
64
+ registry.set(key, validator)
65
+ return () => {
66
+ if (registry.get(key) === validator) registry.delete(key)
67
+ }
68
+ }
69
+
70
+ export function assertAttachmentStoragePartitionExists(exists: boolean): void {
71
+ if (getAttachmentStoragePolicy() === 'strict' && !exists) {
72
+ throw new AttachmentStorageConfigurationError('partition_missing')
73
+ }
74
+ }
75
+
76
+ export function validateAttachmentStorageConfiguration(
77
+ context: Omit<AttachmentStorageValidationContext, 'driverKey' | 'config'> & {
78
+ driverKey: unknown
79
+ config: unknown
80
+ },
81
+ registered: boolean,
82
+ ): void {
83
+ if (getAttachmentStoragePolicy() === 'legacy') return
84
+ if (typeof context.driverKey !== 'string' || !context.driverKey.trim()) {
85
+ throw new AttachmentStorageConfigurationError('driver_missing')
86
+ }
87
+ if (!registered) throw new AttachmentStorageConfigurationError('unknown_driver')
88
+ if (!attachmentStorageConfigurationSchema.safeParse(context.config).success) {
89
+ throw new AttachmentStorageConfigurationError('invalid_config')
90
+ }
91
+ if (context.scope && !attachmentStorageScopeSchema.safeParse(context.scope).success) {
92
+ throw new AttachmentStorageConfigurationError('invalid_scope')
93
+ }
94
+ const validator = validators().get(context.driverKey)
95
+ if (!validator) {
96
+ if (context.driverKey === 'local' || context.driverKey === 'legacyPublic') return
97
+ throw new AttachmentStorageConfigurationError('validator_missing')
98
+ }
99
+ try {
100
+ const accepted: unknown = validator({
101
+ ...context,
102
+ driverKey: context.driverKey,
103
+ config: context.config as Record<string, unknown>,
104
+ })
105
+ if (accepted !== true) {
106
+ if (accepted && typeof accepted === 'object' && 'then' in accepted) {
107
+ void Promise.resolve(accepted).catch(() => undefined)
108
+ }
109
+ throw new AttachmentStorageConfigurationError('validator_rejected')
110
+ }
111
+ } catch {
112
+ throw new AttachmentStorageConfigurationError('validator_rejected')
113
+ }
114
+ }
@@ -7,6 +7,7 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
7
7
  import { Attachment, AttachmentPartition } from '../data/entities'
8
8
  import { attachmentCrudEvents, attachmentCrudIndexer } from './crud'
9
9
  import type { StorageDriverFactory } from './drivers'
10
+ import { assertAttachmentStoragePartitionExists, getAttachmentStoragePolicy } from './drivers/storageValidation'
10
11
  import { buildAttachmentFileUrl } from './imageUrls'
11
12
  import { ensureAttachmentPartitionProtection } from './access-protection'
12
13
  import { ensureDefaultPartitions, resolveDefaultPartitionCode } from './partitions'
@@ -129,7 +130,7 @@ export class ScopedAttachmentUploadService {
129
130
  }
130
131
 
131
132
  const { em, storageDriverFactory, attachmentQuotaService, attachmentQuotaRecoveryScheduler } = this.deps
132
- await ensureDefaultPartitions(em)
133
+ if (getAttachmentStoragePolicy() === 'legacy') await ensureDefaultPartitions(em)
133
134
  const partitionCode = input.partitionCode ?? resolveDefaultPartitionCode(input.entityId)
134
135
  const partition = await em.findOne(AttachmentPartition, {
135
136
  code: partitionCode,
@@ -138,6 +139,7 @@ export class ScopedAttachmentUploadService {
138
139
  { tenantId: input.tenantId, organizationId: input.organizationId },
139
140
  ],
140
141
  })
142
+ assertAttachmentStoragePartitionExists(Boolean(partition))
141
143
  if (!partition) throw new ScopedAttachmentUploadError('partition_unavailable', 400)
142
144
  if (input.requirePrivatePartition && partition.isPublic) {
143
145
  throw new ScopedAttachmentUploadError('partition_unavailable', 403)
@@ -0,0 +1,21 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
3
+ import { isAttachmentStorageConfigurationError } from './drivers/storageValidation'
4
+
5
+ export function withAttachmentStorageErrors<Args extends unknown[]>(
6
+ handler: (...args: Args) => Promise<Response>,
7
+ ): (...args: Args) => Promise<Response> {
8
+ return async (...args) => {
9
+ try {
10
+ return await handler(...args)
11
+ } catch (error) {
12
+ if (!isAttachmentStorageConfigurationError(error)) throw error
13
+ const { t } = await resolveTranslations()
14
+ return NextResponse.json({
15
+ error: t('attachments.errors.storageConfigurationInvalid', 'Attachment storage configuration is unavailable.'),
16
+ code: error.code,
17
+ reason: error.reason,
18
+ }, { status: 503 })
19
+ }
20
+ }
21
+ }
@@ -319,7 +319,7 @@ Both link to `/backend/customer_accounts/{sourceEntityId}` for staff review.
319
319
  | Spot ID | Widget | Purpose |
320
320
  |---------|--------|---------|
321
321
  | `customers.person` | `account-status` | Alias for hosts requesting the bare `customers.person` entity spot |
322
- | `crud-form:customers.person` | `account-status` | Shows portal account status on the person v2 detail page (`people-v2/[id]`) |
322
+ | `crud-form:customers.person` | `account-status` | Person detail/create, company/sales dialogs; hidden without `recordId` |
323
323
  | `customers.company` | `company-users` | Alias for hosts requesting the bare `customers.company` entity spot |
324
324
  | `crud-form:customers.company` | `company-users` | Shows portal users on every company surface that publishes the host — the company v2 detail page (`companies-v2/[id]`), the company create page (`companies/create`), and the sales document "Create company" quick dialog — where it has no `recordId` and renders its empty state |
325
325
 
@@ -38,6 +38,7 @@ import {
38
38
  mapInteractionRecordToTodoSummary,
39
39
  } from '../../../lib/interactionCompatibility'
40
40
  import { resolveCustomerInteractionFeatureFlags } from '../../../lib/interactionFeatureFlags'
41
+ import { countCustomerTodos } from '../../../lib/todoCompatibility'
41
42
  import { hydrateCanonicalInteractions } from '../../../lib/interactionReadModel'
42
43
  import { buildEmailVisibilityMikroFilter } from '../../../lib/visibilityFilter'
43
44
  import { listGrantsForViewer, listSharedChannelIds } from '../../../lib/conversationShares'
@@ -895,19 +896,11 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
895
896
  deletedAt: null,
896
897
  ...emailVisibilityFilter,
897
898
  }),
898
- interactionFlags.unified
899
- ? em.count(CustomerInteraction, {
900
- entity: company.id,
901
- organizationId: company.organizationId,
902
- tenantId: company.tenantId,
903
- deletedAt: null,
904
- interactionType: 'task',
905
- })
906
- : em.count(CustomerTodoLink, {
907
- entity: company.id,
908
- organizationId: company.organizationId,
909
- tenantId: company.tenantId,
910
- }),
899
+ countCustomerTodos(em, {
900
+ entityId: company.id,
901
+ organizationId: company.organizationId,
902
+ tenantId: company.tenantId,
903
+ }, interactionFlags.unified),
911
904
  includeComments
912
905
  ? Promise.resolve(comments.length)
913
906
  : em.count(CustomerComment, {
@@ -34,6 +34,7 @@ import {
34
34
  mapInteractionRecordToTodoSummary,
35
35
  } from '../../../lib/interactionCompatibility'
36
36
  import { resolveCustomerInteractionFeatureFlags } from '../../../lib/interactionFeatureFlags'
37
+ import { countCustomerTodos } from '../../../lib/todoCompatibility'
37
38
  import { hydrateCanonicalInteractions } from '../../../lib/interactionReadModel'
38
39
  import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
39
40
  import type { EntityId } from '@open-mercato/shared/modules/entities'
@@ -878,19 +879,11 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
878
879
  deletedAt: null,
879
880
  ...emailVisibilityFilter,
880
881
  }),
881
- interactionFlags.unified
882
- ? em.count(CustomerInteraction, {
883
- entity: person.id,
884
- organizationId: person.organizationId,
885
- tenantId: person.tenantId,
886
- deletedAt: null,
887
- interactionType: 'task',
888
- })
889
- : em.count(CustomerTodoLink, {
890
- entity: person.id,
891
- organizationId: person.organizationId,
892
- tenantId: person.tenantId,
893
- }),
882
+ countCustomerTodos(em, {
883
+ entityId: person.id,
884
+ organizationId: person.organizationId,
885
+ tenantId: person.tenantId,
886
+ }, interactionFlags.unified),
894
887
  includeAddresses
895
888
  ? Promise.resolve(addresses.length)
896
889
  : em.count(CustomerAddress, {
@@ -45,6 +45,8 @@ export function TaskDialog({ open, mode, onOpenChange, initialValues, onSubmit,
45
45
  event.currentTarget.querySelector('form')?.requestSubmit()
46
46
  }
47
47
  if (event.key === 'Escape') {
48
+ if (event.defaultPrevented) return
49
+ if (event.target instanceof Node && !event.currentTarget.contains(event.target)) return
48
50
  event.preventDefault()
49
51
  handleCancel()
50
52
  }
@@ -10,6 +10,7 @@ import {
10
10
  import type { InteractionRecord } from './interactionCompatibility'
11
11
  import {
12
12
  CUSTOMER_INTERACTION_TASK_SOURCE,
13
+ CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE,
13
14
  EXAMPLE_TODO_SOURCE,
14
15
  resolveExampleIntegrationHref,
15
16
  } from './interactionCompatibility'
@@ -18,6 +19,33 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
18
19
 
19
20
  const logger = createLogger('customers')
20
21
 
22
+ export async function countCustomerTodos(
23
+ em: EntityManager,
24
+ scope: { entityId: string; tenantId: string; organizationId: string },
25
+ unified: boolean,
26
+ ): Promise<number> {
27
+ const entityScope = {
28
+ entity: scope.entityId,
29
+ tenantId: scope.tenantId,
30
+ organizationId: scope.organizationId,
31
+ }
32
+ if (unified) {
33
+ return em.count(CustomerInteraction, { ...entityScope, interactionType: 'task', deletedAt: null })
34
+ }
35
+
36
+ const adapterScope = {
37
+ ...entityScope,
38
+ interactionType: 'task',
39
+ source: CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE,
40
+ }
41
+ const adapterIds = em.createQueryBuilder(CustomerInteraction).select('id').where(adapterScope)
42
+ const [legacyCount, adapterCount] = await Promise.all([
43
+ em.count(CustomerTodoLink, { ...entityScope, todoId: { $nin: adapterIds } }),
44
+ em.count(CustomerInteraction, { ...adapterScope, deletedAt: null }),
45
+ ])
46
+ return legacyCount + adapterCount
47
+ }
48
+
21
49
  export type CustomerTodoRow = {
22
50
  id: string
23
51
  todoId: string
@@ -5,7 +5,7 @@ Use the sales module for orders, quotes, invoices, shipments, and payments. This
5
5
  ## Always
6
6
 
7
7
  1. **MUST use `salesCalculationService` from DI** for document math.
8
- 2. **MUST follow document flow**: Quote → Order → Invoice — no skipping steps
8
+ 2. **MUST follow document flow**: Quote → Order → Invoice (quote optional)
9
9
  3. **MUST scope all documents to a channel** — channel selection affects pricing, numbering, and visibility
10
10
 
11
11
  ## Ask First
@@ -36,7 +36,7 @@ Quote → Order → Invoice
36
36
  Shipments + Payments
37
37
  ```
38
38
 
39
- - Quotes convert to orders — MUST NOT create orders without a source quote (unless configured)
39
+ - Quotes convert via `sales.quotes.convert_to_order`; orders MAY also be created without a quote
40
40
  - Orders track shipments and payments independently
41
41
  - Each entity has its own status workflow — MUST NOT skip workflow states
42
42
 
@@ -56,7 +56,7 @@ const calcService = container.resolve('salesCalculationService')
56
56
 
57
57
  ### Core Entities
58
58
  - **Sales Orders** — confirmed customer orders. MUST have a channel and at least one line
59
- - **Sales Quotes** — proposed orders. MUST track conversion status
59
+ - **Sales Quotes** — proposed orders; deleted on conversion
60
60
  - **Order/Quote Lines** — individual items. MUST reference valid products
61
61
  - **Adjustments** — discounts/surcharges. MUST use registered `AdjustmentKind`
62
62