@open-mercato/core 0.10.1-develop.7841.1.af87ac614d → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/api_keys/api/keys/route.js +15 -1
- package/dist/modules/api_keys/api/keys/route.js.map +2 -2
- package/dist/modules/attachments/api/file/[id]/route.js +6 -1
- package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +6 -1
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/attachments/api/library/[id]/route.js +16 -1
- package/dist/modules/attachments/api/library/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/route.js +11 -3
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/cli.js +2 -0
- package/dist/modules/attachments/cli.js.map +2 -2
- package/dist/modules/attachments/data/validators.js +11 -0
- package/dist/modules/attachments/data/validators.js.map +2 -2
- package/dist/modules/attachments/index.js +10 -0
- package/dist/modules/attachments/index.js.map +2 -2
- package/dist/modules/attachments/lib/access-query.js +5 -1
- package/dist/modules/attachments/lib/access-query.js.map +2 -2
- package/dist/modules/attachments/lib/createFromBuffer.js +2 -0
- package/dist/modules/attachments/lib/createFromBuffer.js.map +2 -2
- package/dist/modules/attachments/lib/drivers/driverFactory.js +54 -33
- package/dist/modules/attachments/lib/drivers/driverFactory.js.map +2 -2
- package/dist/modules/attachments/lib/drivers/index.js +11 -1
- package/dist/modules/attachments/lib/drivers/index.js.map +2 -2
- package/dist/modules/attachments/lib/drivers/storageValidation.js +85 -0
- package/dist/modules/attachments/lib/drivers/storageValidation.js.map +7 -0
- package/dist/modules/attachments/lib/scoped-upload-service.js +3 -1
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
- package/dist/modules/attachments/lib/storageErrors.js +22 -0
- package/dist/modules/attachments/lib/storageErrors.js.map +7 -0
- package/dist/modules/auth/api/users/route.js +16 -1
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/backend/users/page.js +13 -2
- package/dist/modules/auth/backend/users/page.js.map +2 -2
- package/dist/modules/catalog/commands/products.js +4 -2
- package/dist/modules/catalog/commands/products.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +4 -9
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/route.js +4 -9
- package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
- package/dist/modules/customers/components/detail/TaskDialog.js +2 -0
- package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
- package/dist/modules/customers/lib/todoCompatibility.js +23 -0
- package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
- package/dist/modules/directory/api/organization-branding/route.js +6 -9
- package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
- package/dist/modules/directory/backend/directory/branding/page.js +15 -3
- package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
- package/dist/modules/wms/api/listEnrichers.js +3 -1
- package/dist/modules/wms/api/listEnrichers.js.map +2 -2
- package/dist/modules/wms/index.js +1 -1
- package/dist/modules/wms/index.js.map +2 -2
- package/jest.config.cjs +1 -1
- package/package.json +8 -8
- package/src/modules/api_keys/api/keys/route.ts +15 -1
- package/src/modules/api_keys/i18n/de.json +1 -0
- package/src/modules/api_keys/i18n/en.json +1 -0
- package/src/modules/api_keys/i18n/es.json +1 -0
- package/src/modules/api_keys/i18n/ko.json +1 -0
- package/src/modules/api_keys/i18n/pl.json +1 -0
- package/src/modules/attachments/api/file/[id]/route.ts +7 -1
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +7 -1
- package/src/modules/attachments/api/library/[id]/route.ts +14 -1
- package/src/modules/attachments/api/route.ts +11 -3
- package/src/modules/attachments/cli.ts +2 -0
- package/src/modules/attachments/data/validators.ts +11 -0
- package/src/modules/attachments/i18n/de.json +1 -0
- package/src/modules/attachments/i18n/en.json +1 -0
- package/src/modules/attachments/i18n/es.json +1 -0
- package/src/modules/attachments/i18n/ko.json +1 -0
- package/src/modules/attachments/i18n/pl.json +1 -0
- package/src/modules/attachments/index.ts +12 -0
- package/src/modules/attachments/lib/access-query.ts +5 -1
- package/src/modules/attachments/lib/createFromBuffer.ts +2 -0
- package/src/modules/attachments/lib/drivers/driverFactory.ts +79 -55
- package/src/modules/attachments/lib/drivers/index.ts +13 -0
- package/src/modules/attachments/lib/drivers/storageValidation.ts +114 -0
- package/src/modules/attachments/lib/scoped-upload-service.ts +3 -1
- package/src/modules/attachments/lib/storageErrors.ts +21 -0
- package/src/modules/auth/api/users/route.ts +18 -1
- package/src/modules/auth/backend/users/page.tsx +14 -2
- package/src/modules/auth/i18n/de.json +1 -0
- package/src/modules/auth/i18n/en.json +1 -0
- package/src/modules/auth/i18n/es.json +1 -0
- package/src/modules/auth/i18n/ko.json +1 -0
- package/src/modules/auth/i18n/pl.json +1 -0
- package/src/modules/catalog/commands/products.ts +4 -1
- package/src/modules/customer_accounts/AGENTS.md +1 -1
- package/src/modules/customers/api/companies/[id]/route.ts +6 -13
- package/src/modules/customers/api/people/[id]/route.ts +6 -13
- package/src/modules/customers/components/detail/TaskDialog.tsx +2 -0
- package/src/modules/customers/lib/todoCompatibility.ts +28 -0
- package/src/modules/directory/api/organization-branding/route.ts +5 -8
- package/src/modules/directory/backend/directory/branding/page.tsx +15 -3
- package/src/modules/sales/AGENTS.md +3 -3
- package/src/modules/wms/api/listEnrichers.ts +5 -2
- package/src/modules/wms/index.ts +1 -1
|
@@ -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 {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
33
|
-
private
|
|
34
|
-
private readonly externalDrivers = new Map<string,
|
|
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:
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -255,6 +255,23 @@ export async function GET(req: Request) {
|
|
|
255
255
|
if (superAdminUserIds.size) {
|
|
256
256
|
filters.push({ id: { $nin: Array.from(superAdminUserIds) as any } })
|
|
257
257
|
}
|
|
258
|
+
// Browsing follows the topbar organization selection; explicit `?id=`/`?ids=` lookups stay
|
|
259
|
+
// tenant-wide so callers resolving a known user (author labels, assignee names) keep working.
|
|
260
|
+
if (userIdFilter.kind !== 'ids') {
|
|
261
|
+
const scope = await resolveOrganizationScopeForRequest({
|
|
262
|
+
container,
|
|
263
|
+
auth,
|
|
264
|
+
request: req,
|
|
265
|
+
tenantId: actorTenantId,
|
|
266
|
+
})
|
|
267
|
+
effectiveSelectedOrganizationId = scope.selectedId
|
|
268
|
+
if (Array.isArray(scope.filterIds)) {
|
|
269
|
+
if (scope.filterIds.length === 0) {
|
|
270
|
+
return NextResponse.json({ items: [], total: 0, totalPages: 1, isSuperAdmin })
|
|
271
|
+
}
|
|
272
|
+
effectiveOrganizationIds = scope.filterIds
|
|
273
|
+
}
|
|
274
|
+
}
|
|
258
275
|
} else {
|
|
259
276
|
const selectedTenantId = getSelectedTenantFromRequest(req)
|
|
260
277
|
if (typeof selectedTenantId === 'string' && selectedTenantId.trim().length > 0) {
|
|
@@ -726,7 +743,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
726
743
|
GET: {
|
|
727
744
|
summary: 'List users',
|
|
728
745
|
description:
|
|
729
|
-
'Non-superadmins with auth.users.list
|
|
746
|
+
'Non-superadmins with auth.users.list browse users in the selected organization and its descendants, bounded by their tenant and organization visibility, excluding superadmin accounts. Selecting all organizations remains bounded by organization visibility. Explicit id/ids lookups retain tenant-wide staff-reference visibility, excluding superadmin accounts. Directory visibility does not grant access to organization-owned records. Super administrators may scope the response via the topbar tenant and organization selection; without a selected tenant, their list can span tenants. Search matches email, organization name, and role name. Pass organizationId for an exact organization filter, or scopeToActiveOrganization=1 to restrict results to the caller\'s exact active organization (used by recipient/assignee pickers); when the caller has no active organization, this selects only users with no organization. Both filters apply when supplied together. These optional list filters further narrow the effective scope. Pass ids=<uuid>,<uuid> (max 100) to resolve a known set of users in one request, for example to label a list of foreign keys; it intersects with id and roleId, and a supplied ids value that contains no valid identifier matches nothing.',
|
|
730
747
|
query: querySchema,
|
|
731
748
|
responses: [
|
|
732
749
|
{ status: 200, description: 'User collection', schema: userListResponseSchema },
|
|
@@ -21,6 +21,7 @@ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/u
|
|
|
21
21
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
22
22
|
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
23
23
|
import { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'
|
|
24
|
+
import { ErrorMessage } from '@open-mercato/ui/backend/detail'
|
|
24
25
|
|
|
25
26
|
type Row = {
|
|
26
27
|
id: string
|
|
@@ -348,7 +349,7 @@ export default function UsersListPage() {
|
|
|
348
349
|
return p.toString()
|
|
349
350
|
}, [filterValues.name, page, search, organizationId, normalizedRoleIds])
|
|
350
351
|
|
|
351
|
-
const { data: usersData, isLoading } = useQuery({
|
|
352
|
+
const { data: usersData, isLoading, isPending, isFetching, isError, errorUpdatedAt, refetch } = useQuery({
|
|
352
353
|
queryKey: ['users', params, scopeVersion],
|
|
353
354
|
queryFn: async () => {
|
|
354
355
|
const call = await apiCall<{ items: Row[]; total: number; totalPages: number; totalIsCapped?: boolean; isSuperAdmin?: boolean }>(
|
|
@@ -361,6 +362,7 @@ export default function UsersListPage() {
|
|
|
361
362
|
},
|
|
362
363
|
})
|
|
363
364
|
|
|
365
|
+
const hasLoadError = isError || (isPending && errorUpdatedAt > 0)
|
|
364
366
|
const rows = usersData?.items || []
|
|
365
367
|
const total = usersData?.total || 0
|
|
366
368
|
const totalPages = usersData?.totalPages || 1
|
|
@@ -459,7 +461,17 @@ export default function UsersListPage() {
|
|
|
459
461
|
]} />
|
|
460
462
|
)}
|
|
461
463
|
pagination={{ page, pageSize: 50, total, totalPages, totalIsCapped, onPageChange: setPage }}
|
|
462
|
-
isLoading={isLoading}
|
|
464
|
+
isLoading={isLoading && !hasLoadError}
|
|
465
|
+
error={hasLoadError ? (
|
|
466
|
+
<ErrorMessage
|
|
467
|
+
label={t('auth.users.list.error.load', 'Failed to load users')}
|
|
468
|
+
action={(
|
|
469
|
+
<Button type="button" variant="outline" disabled={isFetching} onClick={() => { void refetch() }}>
|
|
470
|
+
{t('auth.users.list.actions.retry', 'Retry')}
|
|
471
|
+
</Button>
|
|
472
|
+
)}
|
|
473
|
+
/>
|
|
474
|
+
) : null}
|
|
463
475
|
/>
|
|
464
476
|
</PageBody>
|
|
465
477
|
{ConfirmDialogElement}
|
|
@@ -710,6 +710,7 @@
|
|
|
710
710
|
"auth.users.form.loading": "Benutzerdaten werden geladen...",
|
|
711
711
|
"auth.users.form.title.create": "Benutzer erstellen",
|
|
712
712
|
"auth.users.form.title.edit": "Benutzer bearbeiten",
|
|
713
|
+
"auth.users.list.actions.retry": "Erneut versuchen",
|
|
713
714
|
"auth.users.list.columns.email": "E-Mail",
|
|
714
715
|
"auth.users.list.columns.name": "Anzeigename",
|
|
715
716
|
"auth.users.list.columns.organization": "Organisation",
|
|
@@ -710,6 +710,7 @@
|
|
|
710
710
|
"auth.users.form.loading": "Loading user data...",
|
|
711
711
|
"auth.users.form.title.create": "Create User",
|
|
712
712
|
"auth.users.form.title.edit": "Edit User",
|
|
713
|
+
"auth.users.list.actions.retry": "Retry",
|
|
713
714
|
"auth.users.list.columns.email": "Email",
|
|
714
715
|
"auth.users.list.columns.name": "Display name",
|
|
715
716
|
"auth.users.list.columns.organization": "Organization",
|
|
@@ -710,6 +710,7 @@
|
|
|
710
710
|
"auth.users.form.loading": "Cargando datos del usuario...",
|
|
711
711
|
"auth.users.form.title.create": "Crear usuario",
|
|
712
712
|
"auth.users.form.title.edit": "Editar usuario",
|
|
713
|
+
"auth.users.list.actions.retry": "Reintentar",
|
|
713
714
|
"auth.users.list.columns.email": "Correo electrónico",
|
|
714
715
|
"auth.users.list.columns.name": "Nombre visible",
|
|
715
716
|
"auth.users.list.columns.organization": "Organización",
|
|
@@ -710,6 +710,7 @@
|
|
|
710
710
|
"auth.users.form.loading": "사용자 데이터를 불러오는 중...",
|
|
711
711
|
"auth.users.form.title.create": "사용자 생성",
|
|
712
712
|
"auth.users.form.title.edit": "사용자 편집",
|
|
713
|
+
"auth.users.list.actions.retry": "다시 시도",
|
|
713
714
|
"auth.users.list.columns.email": "이메일",
|
|
714
715
|
"auth.users.list.columns.name": "표시 이름",
|
|
715
716
|
"auth.users.list.columns.organization": "조직",
|
|
@@ -710,6 +710,7 @@
|
|
|
710
710
|
"auth.users.form.loading": "Ładowanie danych użytkownika...",
|
|
711
711
|
"auth.users.form.title.create": "Utwórz użytkownika",
|
|
712
712
|
"auth.users.form.title.edit": "Edytuj użytkownika",
|
|
713
|
+
"auth.users.list.actions.retry": "Spróbuj ponownie",
|
|
713
714
|
"auth.users.list.columns.email": "Email",
|
|
714
715
|
"auth.users.list.columns.name": "Nazwa wyświetlana",
|
|
715
716
|
"auth.users.list.columns.organization": "Organizacja",
|
|
@@ -688,6 +688,7 @@ function ensureSchemaName(
|
|
|
688
688
|
|
|
689
689
|
async function assignOptionSchemaTemplate(
|
|
690
690
|
em: EntityManager,
|
|
691
|
+
lookupEm: EntityManager,
|
|
691
692
|
product: CatalogProduct,
|
|
692
693
|
schema: CatalogProductOptionSchema,
|
|
693
694
|
preferredName?: string | null,
|
|
@@ -703,7 +704,7 @@ async function assignOptionSchemaTemplate(
|
|
|
703
704
|
});
|
|
704
705
|
let template = product.optionSchemaTemplate ?? null;
|
|
705
706
|
if (!template) {
|
|
706
|
-
template = await
|
|
707
|
+
template = await lookupEm.findOne(CatalogOptionSchemaTemplate, {
|
|
707
708
|
organizationId: product.organizationId,
|
|
708
709
|
tenantId: product.tenantId,
|
|
709
710
|
code: templateCode,
|
|
@@ -1606,6 +1607,7 @@ const createProductCommand: CommandHandler<
|
|
|
1606
1607
|
record.optionSchemaTemplate = optionSchemaTemplate;
|
|
1607
1608
|
} else if (optionSchemaDefinition) {
|
|
1608
1609
|
optionSchemaTemplate = await assignOptionSchemaTemplate(
|
|
1610
|
+
em,
|
|
1609
1611
|
em,
|
|
1610
1612
|
record,
|
|
1611
1613
|
optionSchemaDefinition,
|
|
@@ -2029,6 +2031,7 @@ const updateProductCommand: CommandHandler<
|
|
|
2029
2031
|
if (optionSchemaDefinition) {
|
|
2030
2032
|
await assignOptionSchemaTemplate(
|
|
2031
2033
|
em,
|
|
2034
|
+
lookupEm,
|
|
2032
2035
|
record,
|
|
2033
2036
|
optionSchemaDefinition,
|
|
2034
2037
|
optionSchemaDefinition.name ?? parsed.title ?? record.title,
|
|
@@ -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` |
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|