@open-mercato/core 0.6.7-develop.6842.1.8cd8a8883c → 0.6.7-develop.6859.1.e4727b568a
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 +2 -2
- package/dist/bootstrap.js +16 -1
- package/dist/bootstrap.js.map +2 -2
- package/dist/generated/entities/attachment_quota_reservation/index.js +33 -0
- package/dist/generated/entities/attachment_quota_reservation/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +2 -1
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +17 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/attachments/api/route.js +104 -8
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +67 -1
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/di.js +7 -1
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/lib/drivers/index.js.map +1 -1
- package/dist/modules/attachments/lib/drivers/localDriver.js +19 -6
- package/dist/modules/attachments/lib/drivers/localDriver.js.map +2 -2
- package/dist/modules/attachments/lib/quota-recovery-queue.js +9 -0
- package/dist/modules/attachments/lib/quota-recovery-queue.js.map +7 -0
- package/dist/modules/attachments/lib/quota-service.js +224 -0
- package/dist/modules/attachments/lib/quota-service.js.map +7 -0
- package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js +13 -0
- package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js.map +7 -0
- package/dist/modules/attachments/workers/quota-recovery.js +45 -0
- package/dist/modules/attachments/workers/quota-recovery.js.map +7 -0
- package/dist/modules/auth/lib/setup-app.js +16 -2
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/dashboards/services/widgetDataService.js +3 -4
- package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
- package/dist/modules/entities/api/definitions.js +2 -0
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
- package/dist/modules/entities/cli.js +193 -19
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
- package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
- package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
- package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
- package/dist/modules/messages/api/[id]/route.js +15 -0
- package/dist/modules/messages/api/[id]/route.js.map +2 -2
- package/dist/modules/query_index/api/reindex.js +10 -0
- package/dist/modules/query_index/api/reindex.js.map +2 -2
- package/dist/modules/query_index/cli.js +125 -1
- package/dist/modules/query_index/cli.js.map +2 -2
- package/dist/modules/query_index/lib/coverage.js +19 -7
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/lib/reindexer.js +43 -37
- package/dist/modules/query_index/lib/reindexer.js.map +2 -2
- package/dist/modules/query_index/lib/subscriber-scope.js +2 -3
- package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +137 -22
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/data/validators.js +2 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/workflows.js +10 -7
- package/dist/modules/sales/workflows.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +27 -0
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js +10 -0
- package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js.map +7 -0
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +10 -6
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
- package/generated/entities/attachment_quota_reservation/index.ts +15 -0
- package/generated/entities.ids.generated.ts +2 -1
- package/generated/entity-fields-registry.ts +17 -0
- package/package.json +7 -7
- package/src/bootstrap.ts +17 -2
- package/src/modules/attachments/api/route.ts +114 -7
- package/src/modules/attachments/data/entities.ts +59 -0
- package/src/modules/attachments/di.ts +9 -1
- package/src/modules/attachments/i18n/de.json +3 -0
- package/src/modules/attachments/i18n/en.json +3 -0
- package/src/modules/attachments/i18n/es.json +3 -0
- package/src/modules/attachments/i18n/pl.json +3 -0
- package/src/modules/attachments/lib/drivers/index.ts +1 -1
- package/src/modules/attachments/lib/drivers/localDriver.ts +23 -8
- package/src/modules/attachments/lib/drivers/types.ts +5 -0
- package/src/modules/attachments/lib/quota-recovery-queue.ts +16 -0
- package/src/modules/attachments/lib/quota-service.ts +442 -0
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +648 -120
- package/src/modules/attachments/migrations/Migration20260709234741_attachments.ts +12 -0
- package/src/modules/attachments/workers/quota-recovery.ts +68 -0
- package/src/modules/auth/lib/setup-app.ts +20 -2
- package/src/modules/dashboards/services/widgetDataService.ts +3 -6
- package/src/modules/entities/api/definitions.ts +4 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
- package/src/modules/entities/cli.ts +238 -21
- package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
- package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
- package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
- package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
- package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
- package/src/modules/messages/api/[id]/route.ts +21 -0
- package/src/modules/query_index/api/reindex.ts +15 -0
- package/src/modules/query_index/cli.ts +180 -1
- package/src/modules/query_index/lib/coverage.ts +38 -8
- package/src/modules/query_index/lib/reindexer.ts +62 -40
- package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
- package/src/modules/sales/commands/documents.ts +237 -29
- package/src/modules/sales/data/validators.ts +2 -0
- package/src/modules/sales/workflows.ts +10 -7
- package/src/modules/workflows/i18n/de.json +1 -0
- package/src/modules/workflows/i18n/en.json +1 -0
- package/src/modules/workflows/i18n/es.json +1 -0
- package/src/modules/workflows/i18n/ko.json +1 -0
- package/src/modules/workflows/i18n/pl.json +1 -0
- package/src/modules/workflows/lib/activity-executor.ts +40 -1
- package/src/modules/workflows/widgets/injection/order-approval/context-keys.ts +18 -0
- package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +15 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6859.1.e4727b568a",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -254,16 +254,16 @@
|
|
|
254
254
|
"zod": "^4.4.3"
|
|
255
255
|
},
|
|
256
256
|
"peerDependencies": {
|
|
257
|
-
"@open-mercato/ai-assistant": "0.6.7-develop.
|
|
258
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
259
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
257
|
+
"@open-mercato/ai-assistant": "0.6.7-develop.6859.1.e4727b568a",
|
|
258
|
+
"@open-mercato/shared": "0.6.7-develop.6859.1.e4727b568a",
|
|
259
|
+
"@open-mercato/ui": "0.6.7-develop.6859.1.e4727b568a",
|
|
260
260
|
"react": "^19.0.0",
|
|
261
261
|
"react-dom": "^19.0.0"
|
|
262
262
|
},
|
|
263
263
|
"devDependencies": {
|
|
264
|
-
"@open-mercato/ai-assistant": "0.6.7-develop.
|
|
265
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
266
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
264
|
+
"@open-mercato/ai-assistant": "0.6.7-develop.6859.1.e4727b568a",
|
|
265
|
+
"@open-mercato/shared": "0.6.7-develop.6859.1.e4727b568a",
|
|
266
|
+
"@open-mercato/ui": "0.6.7-develop.6859.1.e4727b568a",
|
|
267
267
|
"@testing-library/dom": "^10.4.1",
|
|
268
268
|
"@testing-library/jest-dom": "^7.0.0",
|
|
269
269
|
"@testing-library/react": "^16.3.1",
|
package/src/bootstrap.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AwilixContainer } from 'awilix'
|
|
2
2
|
import { asValue } from 'awilix'
|
|
3
|
-
import type
|
|
3
|
+
import { getDefaultEncryptionMaps, type ModuleSubscriber } from '@open-mercato/shared/modules/registry'
|
|
4
|
+
import type { ModuleEncryptionMap } from '@open-mercato/shared/modules/encryption'
|
|
4
5
|
import { createEventBus } from '@open-mercato/events/index'
|
|
5
6
|
import { setGlobalEventBus } from '@open-mercato/shared/modules/events'
|
|
6
7
|
import { createCacheService } from '@open-mercato/cache'
|
|
@@ -184,12 +185,26 @@ export async function bootstrap(container: AwilixContainer) {
|
|
|
184
185
|
// KMS + tenant encryption
|
|
185
186
|
const kmsService = createKmsService()
|
|
186
187
|
container.register({ kmsService: asValue(kmsService) })
|
|
188
|
+
let defaultEncryptionMaps: ModuleEncryptionMap[] = []
|
|
189
|
+
if (isTenantDataEncryptionEnabled()) {
|
|
190
|
+
try {
|
|
191
|
+
const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
|
|
192
|
+
defaultEncryptionMaps = getDefaultEncryptionMaps(getModules())
|
|
193
|
+
} catch (err) {
|
|
194
|
+
logger.error('Failed to load default encryption maps', { component: 'encryption', err })
|
|
195
|
+
throw err
|
|
196
|
+
}
|
|
197
|
+
}
|
|
187
198
|
try {
|
|
188
199
|
const em = container.resolve('em') as EntityManager
|
|
189
200
|
const cacheService = (() => {
|
|
190
201
|
try { return container.resolve('cache') as any } catch { return null }
|
|
191
202
|
})()
|
|
192
|
-
const tenantEncryptionService = new TenantDataEncryptionService(em, {
|
|
203
|
+
const tenantEncryptionService = new TenantDataEncryptionService(em, {
|
|
204
|
+
cache: cacheService,
|
|
205
|
+
kms: kmsService,
|
|
206
|
+
defaultEncryptionMaps,
|
|
207
|
+
})
|
|
193
208
|
container.register({ tenantEncryptionService: asValue(tenantEncryptionService) })
|
|
194
209
|
if (isTenantDataEncryptionEnabled() && kmsService.isHealthy()) {
|
|
195
210
|
try {
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from '../lib/upload-limits'
|
|
46
46
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
47
47
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
48
|
+
import type { AttachmentQuotaService } from '../lib/quota-service'
|
|
48
49
|
|
|
49
50
|
const logger = createLogger('attachments')
|
|
50
51
|
|
|
@@ -319,6 +320,20 @@ export async function POST(req: Request) {
|
|
|
319
320
|
const container = await createRequestContainer()
|
|
320
321
|
const em = container.resolve('em') as EntityManager
|
|
321
322
|
const dataEngine = container.resolve('dataEngine')
|
|
323
|
+
let attachmentQuotaService: AttachmentQuotaService | null = null
|
|
324
|
+
let attachmentQuotaRecoveryScheduler: ((
|
|
325
|
+
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
326
|
+
delayMs: number,
|
|
327
|
+
) => Promise<void>) | null = null
|
|
328
|
+
try {
|
|
329
|
+
attachmentQuotaService = container.resolve('attachmentQuotaService') as AttachmentQuotaService
|
|
330
|
+
attachmentQuotaRecoveryScheduler = container.resolve('attachmentQuotaRecoveryScheduler') as (
|
|
331
|
+
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
332
|
+
delayMs: number,
|
|
333
|
+
) => Promise<void>
|
|
334
|
+
} catch {
|
|
335
|
+
// Legacy fallback below when the quota service is not registered in this container.
|
|
336
|
+
}
|
|
322
337
|
const storageDriverFactory =
|
|
323
338
|
(container.resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)
|
|
324
339
|
const orgId = await resolveAttachmentOrganizationId(container, auth, req)
|
|
@@ -363,11 +378,20 @@ export async function POST(req: Request) {
|
|
|
363
378
|
error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),
|
|
364
379
|
}, { status: 413 })
|
|
365
380
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
381
|
+
if (!attachmentQuotaService) {
|
|
382
|
+
try {
|
|
383
|
+
const tenantUsageBytes = await readTenantAttachmentUsageBytes(em, tenantId)
|
|
384
|
+
if (willExceedAttachmentTenantQuota(tenantUsageBytes, file.size)) {
|
|
385
|
+
return NextResponse.json({
|
|
386
|
+
error: t('attachments.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.'),
|
|
387
|
+
}, { status: 413 })
|
|
388
|
+
}
|
|
389
|
+
} catch (error) {
|
|
390
|
+
logger.error('Attachment quota accounting failed', { err: error })
|
|
391
|
+
return NextResponse.json({
|
|
392
|
+
error: t('attachments.errors.quotaUnavailable', 'Storage quota accounting is unavailable.'),
|
|
393
|
+
}, { status: 500 })
|
|
394
|
+
}
|
|
371
395
|
}
|
|
372
396
|
const buf = Buffer.from(await file.arrayBuffer())
|
|
373
397
|
const safeName = sanitizeUploadedFileName(file.name)
|
|
@@ -409,6 +433,62 @@ export async function POST(req: Request) {
|
|
|
409
433
|
return NextResponse.json({ error: t('attachments.errors.publicPartitionBlocked', 'Public storage partitions cannot be selected explicitly for this upload.') }, { status: 403 })
|
|
410
434
|
}
|
|
411
435
|
const uploadDriver = await storageDriverFactory.resolveForPartition(partition.code, { tenantId, organizationId: orgId })
|
|
436
|
+
const preparedStoragePath = attachmentQuotaService
|
|
437
|
+
? uploadDriver.prepareStoragePath?.({
|
|
438
|
+
partitionCode: partition.code,
|
|
439
|
+
orgId,
|
|
440
|
+
tenantId,
|
|
441
|
+
fileName: safeName,
|
|
442
|
+
})
|
|
443
|
+
: undefined
|
|
444
|
+
if (attachmentQuotaService && !preparedStoragePath) {
|
|
445
|
+
return NextResponse.json({
|
|
446
|
+
error: t('attachments.errors.quotaRecoveryUnsupported', 'Storage driver cannot participate in quota recovery.'),
|
|
447
|
+
}, { status: 500 })
|
|
448
|
+
}
|
|
449
|
+
let quotaReservation: { id: string; leaseToken: string; expiresAt: Date } | null = null
|
|
450
|
+
if (attachmentQuotaService) {
|
|
451
|
+
try {
|
|
452
|
+
quotaReservation = await attachmentQuotaService.reserve({
|
|
453
|
+
tenantId,
|
|
454
|
+
organizationId: orgId,
|
|
455
|
+
bytes: buf.length,
|
|
456
|
+
source: 'attachment',
|
|
457
|
+
storageDriver: uploadDriver.key,
|
|
458
|
+
storagePath: preparedStoragePath!,
|
|
459
|
+
partitionCode: partition.code,
|
|
460
|
+
})
|
|
461
|
+
if (!attachmentQuotaRecoveryScheduler) throw new Error('Attachment quota recovery is unavailable.')
|
|
462
|
+
await attachmentQuotaRecoveryScheduler({
|
|
463
|
+
reservationId: quotaReservation.id,
|
|
464
|
+
tenantId,
|
|
465
|
+
organizationId: orgId,
|
|
466
|
+
}, Math.max(1_000, quotaReservation.expiresAt.getTime() - Date.now()))
|
|
467
|
+
if (typeof attachmentQuotaService.beginStorage === 'function') {
|
|
468
|
+
await attachmentQuotaService.beginStorage(quotaReservation.id, quotaReservation.leaseToken)
|
|
469
|
+
}
|
|
470
|
+
} catch (error) {
|
|
471
|
+
if (quotaReservation) {
|
|
472
|
+
await attachmentQuotaService.release(quotaReservation.id, quotaReservation.leaseToken).catch(() => {})
|
|
473
|
+
quotaReservation = null
|
|
474
|
+
}
|
|
475
|
+
const code = (error as { code?: unknown })?.code
|
|
476
|
+
if (code === 'quota_exceeded') {
|
|
477
|
+
return NextResponse.json({
|
|
478
|
+
error: t('attachments.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.'),
|
|
479
|
+
}, { status: 413 })
|
|
480
|
+
}
|
|
481
|
+
if (code === 'quota_target_exists') {
|
|
482
|
+
return NextResponse.json({
|
|
483
|
+
error: t('attachments.errors.storagePathExists', 'The target storage path already exists.'),
|
|
484
|
+
}, { status: 409 })
|
|
485
|
+
}
|
|
486
|
+
logger.error('Attachment quota reservation failed', { err: error })
|
|
487
|
+
return NextResponse.json({
|
|
488
|
+
error: t('attachments.errors.quotaUnavailable', 'Storage quota accounting is unavailable.'),
|
|
489
|
+
}, { status: 500 })
|
|
490
|
+
}
|
|
491
|
+
}
|
|
412
492
|
let storedPath: string
|
|
413
493
|
try {
|
|
414
494
|
const stored = await uploadDriver.store({
|
|
@@ -417,10 +497,25 @@ export async function POST(req: Request) {
|
|
|
417
497
|
tenantId,
|
|
418
498
|
fileName: safeName,
|
|
419
499
|
buffer: buf,
|
|
500
|
+
storagePath: preparedStoragePath,
|
|
420
501
|
})
|
|
421
502
|
storedPath = stored.storagePath
|
|
503
|
+
if (quotaReservation) {
|
|
504
|
+
await attachmentQuotaService!.markStored(quotaReservation.id, quotaReservation.leaseToken)
|
|
505
|
+
}
|
|
422
506
|
} catch (error) {
|
|
423
507
|
logger.error('Failed to persist file', { err: error })
|
|
508
|
+
if (preparedStoragePath) {
|
|
509
|
+
try {
|
|
510
|
+
await (uploadDriver.deleteStrict?.(partition.code, preparedStoragePath)
|
|
511
|
+
?? uploadDriver.delete(partition.code, preparedStoragePath))
|
|
512
|
+
if (quotaReservation) {
|
|
513
|
+
await attachmentQuotaService!.release(quotaReservation.id, quotaReservation.leaseToken)
|
|
514
|
+
}
|
|
515
|
+
} catch (cleanupError) {
|
|
516
|
+
logger.error('Failed to compensate attachment storage', { err: cleanupError })
|
|
517
|
+
}
|
|
518
|
+
}
|
|
424
519
|
return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })
|
|
425
520
|
}
|
|
426
521
|
|
|
@@ -485,9 +580,21 @@ export async function POST(req: Request) {
|
|
|
485
580
|
values: customFieldValues,
|
|
486
581
|
})
|
|
487
582
|
}
|
|
583
|
+
if (quotaReservation) {
|
|
584
|
+
await attachmentQuotaService!.completeAttachment(quotaReservation.id, quotaReservation.leaseToken, tx)
|
|
585
|
+
}
|
|
488
586
|
})
|
|
489
587
|
} catch (error) {
|
|
490
588
|
logger.error('Failed to persist attachment with custom attributes', { err: error })
|
|
589
|
+
try {
|
|
590
|
+
await (uploadDriver.deleteStrict?.(partition.code, storedPath)
|
|
591
|
+
?? uploadDriver.delete(partition.code, storedPath))
|
|
592
|
+
if (quotaReservation) {
|
|
593
|
+
await attachmentQuotaService!.release(quotaReservation.id, quotaReservation.leaseToken)
|
|
594
|
+
}
|
|
595
|
+
} catch (cleanupError) {
|
|
596
|
+
logger.error('Failed to compensate attachment persistence', { err: cleanupError })
|
|
597
|
+
}
|
|
491
598
|
return NextResponse.json({ error: 'Failed to save attachment attributes.' }, { status: 500 })
|
|
492
599
|
}
|
|
493
600
|
|
|
@@ -551,8 +658,8 @@ async function readTenantAttachmentUsageBytes(em: EntityManager, tenantId: strin
|
|
|
551
658
|
return Number.isFinite(parsed) ? parsed : 0
|
|
552
659
|
}
|
|
553
660
|
return 0
|
|
554
|
-
} catch {
|
|
555
|
-
|
|
661
|
+
} catch (error) {
|
|
662
|
+
throw new Error('Attachment quota accounting is unavailable.', { cause: error })
|
|
556
663
|
}
|
|
557
664
|
}
|
|
558
665
|
|
|
@@ -99,3 +99,62 @@ export class Attachment {
|
|
|
99
99
|
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
100
100
|
createdAt: Date = new Date()
|
|
101
101
|
}
|
|
102
|
+
|
|
103
|
+
@Entity({ tableName: 'attachment_quota_reservations' })
|
|
104
|
+
@Unique({
|
|
105
|
+
name: 'attachment_quota_reservations_scope_path_unique',
|
|
106
|
+
properties: ['tenantId', 'storageDriver', 'storagePath'],
|
|
107
|
+
})
|
|
108
|
+
@Index({
|
|
109
|
+
name: 'attachment_quota_reservations_tenant_status_idx',
|
|
110
|
+
properties: ['tenantId', 'status'],
|
|
111
|
+
})
|
|
112
|
+
export class AttachmentQuotaReservation {
|
|
113
|
+
[OptionalProps]?: 'actualBytes' | 'createdAt' | 'status' | 'updatedAt' | 'uploadTokenHash'
|
|
114
|
+
|
|
115
|
+
@PrimaryKey({ type: 'uuid' })
|
|
116
|
+
id!: string
|
|
117
|
+
|
|
118
|
+
@Property({ name: 'tenant_id', type: 'uuid' })
|
|
119
|
+
tenantId!: string
|
|
120
|
+
|
|
121
|
+
@Property({ name: 'organization_id', type: 'uuid' })
|
|
122
|
+
organizationId!: string
|
|
123
|
+
|
|
124
|
+
@Property({ name: 'reserved_bytes', type: 'bigint' })
|
|
125
|
+
reservedBytes!: number
|
|
126
|
+
|
|
127
|
+
@Property({ name: 'actual_bytes', type: 'bigint', nullable: true })
|
|
128
|
+
actualBytes?: number | null
|
|
129
|
+
|
|
130
|
+
@Property({ type: 'text', default: 'reserved' })
|
|
131
|
+
status: 'reserved' | 'storing' | 'stored' | 'recovering' | 'committed' = 'reserved'
|
|
132
|
+
|
|
133
|
+
@Property({ type: 'text' })
|
|
134
|
+
source!: string
|
|
135
|
+
|
|
136
|
+
@Property({ name: 'storage_driver', type: 'text' })
|
|
137
|
+
storageDriver!: string
|
|
138
|
+
|
|
139
|
+
@Property({ name: 'partition_code', type: 'text', nullable: true })
|
|
140
|
+
partitionCode?: string | null
|
|
141
|
+
|
|
142
|
+
@Property({ name: 'storage_path', type: 'text' })
|
|
143
|
+
storagePath!: string
|
|
144
|
+
|
|
145
|
+
@Property({ name: 'lease_token', type: 'uuid' })
|
|
146
|
+
leaseToken!: string
|
|
147
|
+
|
|
148
|
+
@Property({ name: 'upload_token_hash', type: 'text', nullable: true })
|
|
149
|
+
uploadTokenHash?: string | null
|
|
150
|
+
|
|
151
|
+
@Property({ name: 'expires_at', type: Date, nullable: true })
|
|
152
|
+
@Index({ name: 'attachment_quota_reservations_expires_idx' })
|
|
153
|
+
expiresAt?: Date | null
|
|
154
|
+
|
|
155
|
+
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
156
|
+
createdAt: Date = new Date()
|
|
157
|
+
|
|
158
|
+
@Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
|
|
159
|
+
updatedAt: Date = new Date()
|
|
160
|
+
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import { asFunction } from 'awilix'
|
|
1
|
+
import { asFunction, asValue } from 'awilix'
|
|
2
2
|
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
3
|
import { StorageDriverFactory } from './lib/drivers/driverFactory'
|
|
4
|
+
import { createAttachmentQuotaService } from './lib/quota-service'
|
|
5
|
+
import { scheduleAttachmentQuotaRecovery } from './lib/quota-recovery-queue'
|
|
4
6
|
|
|
5
7
|
export function register(container: AppContainer) {
|
|
6
8
|
container.register({
|
|
9
|
+
attachmentQuotaRecoveryScheduler: asValue(scheduleAttachmentQuotaRecovery),
|
|
10
|
+
attachmentQuotaService: asFunction(({ em }: { em: ConstructorParameters<typeof StorageDriverFactory>[0] }) =>
|
|
11
|
+
createAttachmentQuotaService(em),
|
|
12
|
+
)
|
|
13
|
+
.scoped()
|
|
14
|
+
.proxy(),
|
|
7
15
|
storageDriverFactory: asFunction(({ em }: { em: ConstructorParameters<typeof StorageDriverFactory>[0] }) =>
|
|
8
16
|
new StorageDriverFactory(em),
|
|
9
17
|
)
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
"attachments.errors.maxUploadSize": "Der Anhang überschreitet die maximal zulässige Upload-Größe.",
|
|
5
5
|
"attachments.errors.publicPartitionBlocked": "Öffentliche Speicherpartitionen können für diesen Upload nicht explizit ausgewählt werden.",
|
|
6
6
|
"attachments.errors.quotaExceeded": "Das Speicherlimit für Anhänge dieses Tenants wurde überschritten.",
|
|
7
|
+
"attachments.errors.quotaRecoveryUnsupported": "Der Speichertreiber kann nicht an der Kontingent-Wiederherstellung teilnehmen.",
|
|
8
|
+
"attachments.errors.quotaUnavailable": "Die Speicherkontingent-Abrechnung ist nicht verfügbar.",
|
|
7
9
|
"attachments.errors.selectOrganization": "Wählen Sie eine bestimmte Organisation aus, bevor Sie einen Anhang hochladen.",
|
|
10
|
+
"attachments.errors.storagePathExists": "Der Zielspeicherpfad existiert bereits.",
|
|
8
11
|
"attachments.library.actions.copied": "Link kopiert.",
|
|
9
12
|
"attachments.library.actions.copyError": "Link konnte nicht kopiert werden.",
|
|
10
13
|
"attachments.library.actions.copyUrl": "URL kopieren",
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
"attachments.errors.maxUploadSize": "Attachment exceeds the maximum upload size.",
|
|
5
5
|
"attachments.errors.publicPartitionBlocked": "Public storage partitions cannot be selected explicitly for this upload.",
|
|
6
6
|
"attachments.errors.quotaExceeded": "Attachment storage quota exceeded for this tenant.",
|
|
7
|
+
"attachments.errors.quotaRecoveryUnsupported": "Storage driver cannot participate in quota recovery.",
|
|
8
|
+
"attachments.errors.quotaUnavailable": "Storage quota accounting is unavailable.",
|
|
7
9
|
"attachments.errors.selectOrganization": "Select a specific organization before uploading an attachment.",
|
|
10
|
+
"attachments.errors.storagePathExists": "The target storage path already exists.",
|
|
8
11
|
"attachments.library.actions.copied": "Link copied.",
|
|
9
12
|
"attachments.library.actions.copyError": "Unable to copy link.",
|
|
10
13
|
"attachments.library.actions.copyUrl": "Copy URL",
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
"attachments.errors.maxUploadSize": "El archivo adjunto supera el tamaño máximo de carga permitido.",
|
|
5
5
|
"attachments.errors.publicPartitionBlocked": "No se pueden seleccionar explícitamente particiones de almacenamiento públicas para esta carga.",
|
|
6
6
|
"attachments.errors.quotaExceeded": "Se superó la cuota de almacenamiento de adjuntos para este tenant.",
|
|
7
|
+
"attachments.errors.quotaRecoveryUnsupported": "El controlador de almacenamiento no puede participar en la recuperación de cuota.",
|
|
8
|
+
"attachments.errors.quotaUnavailable": "La contabilidad de la cuota de almacenamiento no está disponible.",
|
|
7
9
|
"attachments.errors.selectOrganization": "Selecciona una organización específica antes de subir un adjunto.",
|
|
10
|
+
"attachments.errors.storagePathExists": "La ruta de almacenamiento de destino ya existe.",
|
|
8
11
|
"attachments.library.actions.copied": "Enlace copiado.",
|
|
9
12
|
"attachments.library.actions.copyError": "No se pudo copiar el enlace.",
|
|
10
13
|
"attachments.library.actions.copyUrl": "Copiar URL",
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
"attachments.errors.maxUploadSize": "Załącznik przekracza maksymalny dopuszczalny rozmiar przesyłania.",
|
|
5
5
|
"attachments.errors.publicPartitionBlocked": "Nie można jawnie wybrać publicznej partycji do tego przesyłania.",
|
|
6
6
|
"attachments.errors.quotaExceeded": "Przekroczono limit miejsca na załączniki dla tego tenantu.",
|
|
7
|
+
"attachments.errors.quotaRecoveryUnsupported": "Sterownik przechowywania nie może uczestniczyć w odzyskiwaniu limitu.",
|
|
8
|
+
"attachments.errors.quotaUnavailable": "Rozliczanie limitu przestrzeni dyskowej jest niedostępne.",
|
|
7
9
|
"attachments.errors.selectOrganization": "Wybierz konkretną organizację przed przesłaniem załącznika.",
|
|
10
|
+
"attachments.errors.storagePathExists": "Docelowa ścieżka przechowywania już istnieje.",
|
|
8
11
|
"attachments.library.actions.copied": "Link skopiowany.",
|
|
9
12
|
"attachments.library.actions.copyError": "Nie można skopiować linku.",
|
|
10
13
|
"attachments.library.actions.copyUrl": "Kopiuj URL",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from './types'
|
|
1
|
+
export type { PrepareFilePayload, StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from './types'
|
|
2
2
|
export { LocalStorageDriver } from './localDriver'
|
|
3
3
|
export { LegacyPublicStorageDriver } from './legacyPublicDriver'
|
|
4
4
|
export { StorageDriverFactory, registerExternalStorageDriver, registerExternalCredentialEnhancer } from './driverFactory'
|
|
@@ -3,7 +3,7 @@ import path from 'path'
|
|
|
3
3
|
import { randomUUID } from 'crypto'
|
|
4
4
|
import { resolvePartitionRoot } from '../storage'
|
|
5
5
|
import { resolveContainedPath } from '../pathContainment'
|
|
6
|
-
import type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from './types'
|
|
6
|
+
import type { PrepareFilePayload, StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from './types'
|
|
7
7
|
|
|
8
8
|
function sanitizeFileName(fileName: string): string {
|
|
9
9
|
if (!fileName) return 'file'
|
|
@@ -23,17 +23,21 @@ function resolveTenantSegment(tenantId: string | null | undefined): string {
|
|
|
23
23
|
export class LocalStorageDriver implements StorageDriver {
|
|
24
24
|
readonly key = 'local'
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
const root = resolvePartitionRoot(payload.partitionCode)
|
|
26
|
+
prepareStoragePath(payload: PrepareFilePayload): string {
|
|
28
27
|
const orgSegment = resolveOrgSegment(payload.orgId ?? null)
|
|
29
28
|
const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)
|
|
30
29
|
const safeName = sanitizeFileName(payload.fileName || 'file')
|
|
31
30
|
const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)
|
|
32
31
|
const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`
|
|
33
|
-
|
|
32
|
+
return path.join(orgSegment, tenantSegment, storedName).replace(/\\/g, '/')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async store(payload: StoreFilePayload): Promise<StoredFile> {
|
|
36
|
+
const root = resolvePartitionRoot(payload.partitionCode)
|
|
37
|
+
const relativePath = payload.storagePath ?? this.prepareStoragePath(payload)
|
|
34
38
|
const absolutePath = path.join(root, relativePath)
|
|
35
39
|
await fs.mkdir(path.dirname(absolutePath), { recursive: true })
|
|
36
|
-
await fs.writeFile(absolutePath, payload.buffer)
|
|
40
|
+
await fs.writeFile(absolutePath, payload.buffer, { flag: 'wx' })
|
|
37
41
|
return {
|
|
38
42
|
storagePath: relativePath.replace(/\\/g, '/'),
|
|
39
43
|
}
|
|
@@ -46,11 +50,22 @@ export class LocalStorageDriver implements StorageDriver {
|
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
async delete(partitionCode: string, storagePath: string): Promise<void> {
|
|
49
|
-
const absolutePath = this.resolveAbsolutePath(partitionCode, storagePath)
|
|
50
53
|
try {
|
|
51
|
-
await
|
|
54
|
+
await this.deleteStrict(partitionCode, storagePath)
|
|
52
55
|
} catch {
|
|
53
|
-
// best-effort removal
|
|
56
|
+
// Backward-compatible best-effort removal.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async deleteStrict(partitionCode: string, storagePath: string): Promise<void> {
|
|
61
|
+
const absolutePath = this.resolveAbsolutePath(partitionCode, storagePath)
|
|
62
|
+
try {
|
|
63
|
+
if (typeof fs.rm === 'function') await fs.rm(absolutePath, { force: true })
|
|
64
|
+
else await fs.unlink(absolutePath)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const code = (error as NodeJS.ErrnoException)?.code
|
|
67
|
+
const message = error instanceof Error ? error.message : ''
|
|
68
|
+
if (code !== 'ENOENT' && !message.includes('ENOENT')) throw error
|
|
54
69
|
}
|
|
55
70
|
}
|
|
56
71
|
|
|
@@ -4,8 +4,11 @@ export type StoreFilePayload = {
|
|
|
4
4
|
tenantId: string | null | undefined
|
|
5
5
|
fileName: string
|
|
6
6
|
buffer: Buffer
|
|
7
|
+
storagePath?: string
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
export type PrepareFilePayload = Omit<StoreFilePayload, 'buffer' | 'storagePath'>
|
|
11
|
+
|
|
9
12
|
export type StoredFile = {
|
|
10
13
|
storagePath: string
|
|
11
14
|
driverMeta?: Record<string, unknown> | null
|
|
@@ -18,9 +21,11 @@ export type ReadFileResult = {
|
|
|
18
21
|
|
|
19
22
|
export interface StorageDriver {
|
|
20
23
|
readonly key: string
|
|
24
|
+
prepareStoragePath?(payload: PrepareFilePayload): string
|
|
21
25
|
store(payload: StoreFilePayload): Promise<StoredFile>
|
|
22
26
|
read(partitionCode: string, storagePath: string): Promise<ReadFileResult>
|
|
23
27
|
delete(partitionCode: string, storagePath: string): Promise<void>
|
|
28
|
+
deleteStrict?(partitionCode: string, storagePath: string): Promise<void>
|
|
24
29
|
toLocalPath(
|
|
25
30
|
partitionCode: string,
|
|
26
31
|
storagePath: string,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createModuleQueue } from '@open-mercato/queue'
|
|
2
|
+
|
|
3
|
+
export type AttachmentQuotaRecoveryJob = {
|
|
4
|
+
reservationId: string
|
|
5
|
+
tenantId: string
|
|
6
|
+
organizationId: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const queue = createModuleQueue<AttachmentQuotaRecoveryJob>('attachments-quota-recovery', { concurrency: 2 })
|
|
10
|
+
|
|
11
|
+
export async function scheduleAttachmentQuotaRecovery(
|
|
12
|
+
payload: AttachmentQuotaRecoveryJob,
|
|
13
|
+
delayMs: number,
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
await queue.enqueue(payload, { delayMs: Math.max(1_000, delayMs) })
|
|
16
|
+
}
|