@open-mercato/core 0.10.1-develop.7847.1.7d83dc5794 → 0.10.1-develop.7862.1.ee520f7662
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/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/audit_logs/api/audit-logs/actions/redo/route.js +5 -4
- package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js.map +3 -3
- 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/customers/widgets/dashboard/customer-todos/widget.client.js +1 -4
- package/dist/modules/customers/widgets/dashboard/customer-todos/widget.client.js.map +2 -2
- package/dist/modules/warranty_claims/backend/components/WarrantyWorkspace.js +8 -1
- package/dist/modules/warranty_claims/backend/components/WarrantyWorkspace.js.map +2 -2
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js +18 -3
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js.map +2 -2
- package/jest.config.cjs +1 -1
- package/package.json +8 -8
- 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/audit_logs/api/audit-logs/actions/redo/route.ts +3 -1
- package/src/modules/audit_logs/i18n/de.json +1 -0
- package/src/modules/audit_logs/i18n/en.json +1 -0
- package/src/modules/audit_logs/i18n/es.json +1 -0
- package/src/modules/audit_logs/i18n/ko.json +1 -0
- package/src/modules/audit_logs/i18n/pl.json +1 -0
- 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/customers/widgets/dashboard/customer-todos/widget.client.tsx +0 -3
- package/src/modules/sales/AGENTS.md +3 -3
- package/src/modules/warranty_claims/backend/components/WarrantyWorkspace.tsx +8 -1
- package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.tsx +22 -3
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"attachments.errors.quotaRecoveryUnsupported": "Storage driver cannot participate in quota recovery.",
|
|
14
14
|
"attachments.errors.quotaUnavailable": "Storage quota accounting is unavailable.",
|
|
15
15
|
"attachments.errors.selectOrganization": "Select a specific organization before uploading an attachment.",
|
|
16
|
+
"attachments.errors.storageConfigurationInvalid": "Attachment storage configuration is unavailable.",
|
|
16
17
|
"attachments.errors.storageFailed": "Failed to store the attachment file.",
|
|
17
18
|
"attachments.errors.storagePathExists": "The target storage path already exists.",
|
|
18
19
|
"attachments.errors.uploadFailed": "Attachment upload failed.",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"attachments.errors.quotaRecoveryUnsupported": "El controlador de almacenamiento no puede participar en la recuperación de cuota.",
|
|
14
14
|
"attachments.errors.quotaUnavailable": "La contabilidad de la cuota de almacenamiento no está disponible.",
|
|
15
15
|
"attachments.errors.selectOrganization": "Selecciona una organización específica antes de subir un adjunto.",
|
|
16
|
+
"attachments.errors.storageConfigurationInvalid": "La configuración del almacenamiento de archivos adjuntos no está disponible.",
|
|
16
17
|
"attachments.errors.storageFailed": "No se pudo almacenar el archivo adjunto.",
|
|
17
18
|
"attachments.errors.storagePathExists": "La ruta de almacenamiento de destino ya existe.",
|
|
18
19
|
"attachments.errors.uploadFailed": "Error al subir el archivo adjunto.",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"attachments.errors.quotaRecoveryUnsupported": "저장 용량 복구가 지원되지 않습니다.",
|
|
14
14
|
"attachments.errors.quotaUnavailable": "첨부파일 저장 용량 정보를 확인할 수 없습니다.",
|
|
15
15
|
"attachments.errors.selectOrganization": "첨부파일을 업로드하기 전에 특정 조직을 선택하세요.",
|
|
16
|
+
"attachments.errors.storageConfigurationInvalid": "첨부 파일 저장소 구성을 사용할 수 없습니다.",
|
|
16
17
|
"attachments.errors.storageFailed": "첨부 파일을 저장소에 기록하지 못했습니다.",
|
|
17
18
|
"attachments.errors.storagePathExists": "대상 저장 경로가 이미 존재합니다.",
|
|
18
19
|
"attachments.errors.uploadFailed": "첨부 파일 업로드에 실패했습니다.",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"attachments.errors.quotaRecoveryUnsupported": "Sterownik przechowywania nie może uczestniczyć w odzyskiwaniu limitu.",
|
|
14
14
|
"attachments.errors.quotaUnavailable": "Rozliczanie limitu przestrzeni dyskowej jest niedostępne.",
|
|
15
15
|
"attachments.errors.selectOrganization": "Wybierz konkretną organizację przed przesłaniem załącznika.",
|
|
16
|
+
"attachments.errors.storageConfigurationInvalid": "Konfiguracja magazynu załączników jest niedostępna.",
|
|
16
17
|
"attachments.errors.storageFailed": "Nie udało się zapisać pliku załącznika w magazynie.",
|
|
17
18
|
"attachments.errors.storagePathExists": "Docelowa ścieżka przechowywania już istnieje.",
|
|
18
19
|
"attachments.errors.uploadFailed": "Przesyłanie załącznika nie powiodło się.",
|
|
@@ -20,6 +20,18 @@ export {
|
|
|
20
20
|
type ReleaseScopedAttachmentInput,
|
|
21
21
|
} from './lib/attachment-service'
|
|
22
22
|
|
|
23
|
+
export {
|
|
24
|
+
AttachmentStorageConfigurationError,
|
|
25
|
+
getAttachmentStoragePolicy,
|
|
26
|
+
isAttachmentStorageConfigurationError,
|
|
27
|
+
registerStorageDriverValidator,
|
|
28
|
+
type AttachmentStoragePolicy,
|
|
29
|
+
type AttachmentStorageScope,
|
|
30
|
+
type AttachmentStorageValidationStage,
|
|
31
|
+
type AttachmentStorageValidationContext,
|
|
32
|
+
type AttachmentStorageValidator,
|
|
33
|
+
type AttachmentStorageConfigurationReason,
|
|
34
|
+
} from './lib/drivers/storageValidation'
|
|
23
35
|
export type {
|
|
24
36
|
AttachmentAccessAction,
|
|
25
37
|
AttachmentAccessDecision,
|
|
@@ -9,6 +9,7 @@ import { evaluateAttachmentAccess, type AttachmentAccessContext } from './access
|
|
|
9
9
|
import type { AttachmentAccessAction, AttachmentAccessRecord } from './access-types'
|
|
10
10
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
11
11
|
import { attachmentAccessErrorBody, throwAttachmentAccessError } from './access-errors'
|
|
12
|
+
import { assertAttachmentStoragePartitionExists } from './drivers/storageValidation'
|
|
12
13
|
import { readAttachmentMetadata } from './metadata'
|
|
13
14
|
|
|
14
15
|
export function requiresAttachmentAccessScan(partitions: readonly AttachmentPartition[]): boolean {
|
|
@@ -31,7 +32,10 @@ export async function assertAttachmentOwnerAccess(input: {
|
|
|
31
32
|
const partition = await findOneWithDecryption(input.em, AttachmentPartition, { code: input.attachment.partitionCode }, undefined, {
|
|
32
33
|
tenantId: input.attachment.tenantId, organizationId: input.attachment.organizationId,
|
|
33
34
|
})
|
|
34
|
-
if (!partition)
|
|
35
|
+
if (!partition) {
|
|
36
|
+
if (input.action === 'delete') assertAttachmentStoragePartitionExists(false)
|
|
37
|
+
await throwAttachmentAccessError(404)
|
|
38
|
+
}
|
|
35
39
|
if (input.persistProtection) await ensureAttachmentPartitionProtection(input.em, partition!)
|
|
36
40
|
const result = await evaluateAttachmentAccess({ ...input, partition: partition!, requireAuthForPublic: true })
|
|
37
41
|
if (!result.ok) await throwAttachmentAccessError(result.status)
|
|
@@ -3,6 +3,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
3
3
|
import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
|
|
4
4
|
import { Attachment, AttachmentPartition } from '../data/entities'
|
|
5
5
|
import { StorageDriverFactory } from './drivers'
|
|
6
|
+
import { assertAttachmentStoragePartitionExists } from './drivers/storageValidation'
|
|
6
7
|
import { ensureDefaultPartitions, resolveDefaultPartitionCode } from './partitions'
|
|
7
8
|
import { buildAttachmentFileUrl } from './imageUrls'
|
|
8
9
|
import { mergeAttachmentMetadata, upsertAssignment } from './metadata'
|
|
@@ -54,6 +55,7 @@ export async function createAttachmentFromBuffer(input: CreateAttachmentFromBuff
|
|
|
54
55
|
|
|
55
56
|
let partition = await em.findOne(AttachmentPartition, { code })
|
|
56
57
|
if (!partition) {
|
|
58
|
+
assertAttachmentStoragePartitionExists(false)
|
|
57
59
|
await ensureDefaultPartitions(em)
|
|
58
60
|
partition = await em.findOne(AttachmentPartition, { code })
|
|
59
61
|
}
|
|
@@ -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
|
+
}
|
|
@@ -15,6 +15,7 @@ import type { ActionLog } from '@open-mercato/core/modules/audit_logs/data/entit
|
|
|
15
15
|
import { z } from 'zod'
|
|
16
16
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
17
17
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
18
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
18
19
|
import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
|
|
19
20
|
import { authorizeAuditReplayWithEntityManager } from '@open-mercato/core/modules/audit_logs/lib/replayAuthorization'
|
|
20
21
|
import {
|
|
@@ -208,7 +209,8 @@ export async function POST(req: Request) {
|
|
|
208
209
|
return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
|
|
209
210
|
}
|
|
210
211
|
logger.error('Redo failed', { err })
|
|
211
|
-
|
|
212
|
+
const { translate } = await resolveTranslations()
|
|
213
|
+
return NextResponse.json({ error: translate('audit_logs.error.redo_failed', 'Redo failed') }, { status: 400 })
|
|
212
214
|
}
|
|
213
215
|
}
|
|
214
216
|
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"audit_logs.common.refreshing": "Aktualisiere…",
|
|
43
43
|
"audit_logs.error.load": "Audit-Logs konnten nicht geladen werden",
|
|
44
44
|
"audit_logs.error.redo": "Aktion kann nicht wiederholt werden",
|
|
45
|
+
"audit_logs.error.redo_failed": "Wiederholen fehlgeschlagen",
|
|
45
46
|
"audit_logs.error.undo": "Aktion konnte nicht rückgängig gemacht werden",
|
|
46
47
|
"audit_logs.error.undo_failed": "Rückgängigmachen fehlgeschlagen",
|
|
47
48
|
"audit_logs.filters.undoable_only": "Nur rückgängig machbar",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"audit_logs.common.refreshing": "Refreshing…",
|
|
43
43
|
"audit_logs.error.load": "Failed to load audit logs",
|
|
44
44
|
"audit_logs.error.redo": "Unable to redo action",
|
|
45
|
+
"audit_logs.error.redo_failed": "Redo failed",
|
|
45
46
|
"audit_logs.error.undo": "Unable to undo action",
|
|
46
47
|
"audit_logs.error.undo_failed": "Undo failed",
|
|
47
48
|
"audit_logs.filters.undoable_only": "Undoable only",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"audit_logs.common.refreshing": "Actualizando…",
|
|
43
43
|
"audit_logs.error.load": "No se pudieron cargar los registros",
|
|
44
44
|
"audit_logs.error.redo": "No se pudo rehacer la acción",
|
|
45
|
+
"audit_logs.error.redo_failed": "No se pudo rehacer la acción",
|
|
45
46
|
"audit_logs.error.undo": "No se pudo deshacer la acción",
|
|
46
47
|
"audit_logs.error.undo_failed": "No se pudo deshacer la acción",
|
|
47
48
|
"audit_logs.filters.undoable_only": "Solo deshacibles",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"audit_logs.common.refreshing": "새로고침 중…",
|
|
43
43
|
"audit_logs.error.load": "감사 로그를 불러오지 못했습니다",
|
|
44
44
|
"audit_logs.error.redo": "작업을 다시 실행할 수 없습니다",
|
|
45
|
+
"audit_logs.error.redo_failed": "다시 실행에 실패했습니다",
|
|
45
46
|
"audit_logs.error.undo": "작업을 실행 취소할 수 없습니다",
|
|
46
47
|
"audit_logs.error.undo_failed": "실행 취소에 실패했습니다",
|
|
47
48
|
"audit_logs.filters.undoable_only": "실행 취소 가능한 항목만",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"audit_logs.common.refreshing": "Odświeżanie…",
|
|
43
43
|
"audit_logs.error.load": "Nie udało się załadować logów",
|
|
44
44
|
"audit_logs.error.redo": "Nie udało się przywrócić działania",
|
|
45
|
+
"audit_logs.error.redo_failed": "Nie udało się przywrócić operacji",
|
|
45
46
|
"audit_logs.error.undo": "Nie udało się cofnąć działania",
|
|
46
47
|
"audit_logs.error.undo_failed": "Nie udało się cofnąć operacji",
|
|
47
48
|
"audit_logs.filters.undoable_only": "Tylko do cofnięcia",
|
|
@@ -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
|