@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.
Files changed (115) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/bootstrap.js +16 -1
  3. package/dist/bootstrap.js.map +2 -2
  4. package/dist/generated/entities/attachment_quota_reservation/index.js +33 -0
  5. package/dist/generated/entities/attachment_quota_reservation/index.js.map +7 -0
  6. package/dist/generated/entities.ids.generated.js +2 -1
  7. package/dist/generated/entities.ids.generated.js.map +2 -2
  8. package/dist/generated/entity-fields-registry.js +17 -0
  9. package/dist/generated/entity-fields-registry.js.map +2 -2
  10. package/dist/modules/attachments/api/route.js +104 -8
  11. package/dist/modules/attachments/api/route.js.map +2 -2
  12. package/dist/modules/attachments/data/entities.js +67 -1
  13. package/dist/modules/attachments/data/entities.js.map +2 -2
  14. package/dist/modules/attachments/di.js +7 -1
  15. package/dist/modules/attachments/di.js.map +2 -2
  16. package/dist/modules/attachments/lib/drivers/index.js.map +1 -1
  17. package/dist/modules/attachments/lib/drivers/localDriver.js +19 -6
  18. package/dist/modules/attachments/lib/drivers/localDriver.js.map +2 -2
  19. package/dist/modules/attachments/lib/quota-recovery-queue.js +9 -0
  20. package/dist/modules/attachments/lib/quota-recovery-queue.js.map +7 -0
  21. package/dist/modules/attachments/lib/quota-service.js +224 -0
  22. package/dist/modules/attachments/lib/quota-service.js.map +7 -0
  23. package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js +13 -0
  24. package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js.map +7 -0
  25. package/dist/modules/attachments/workers/quota-recovery.js +45 -0
  26. package/dist/modules/attachments/workers/quota-recovery.js.map +7 -0
  27. package/dist/modules/auth/lib/setup-app.js +16 -2
  28. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  29. package/dist/modules/dashboards/services/widgetDataService.js +3 -4
  30. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  31. package/dist/modules/entities/api/definitions.js +2 -0
  32. package/dist/modules/entities/api/definitions.js.map +2 -2
  33. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
  34. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  35. package/dist/modules/entities/cli.js +193 -19
  36. package/dist/modules/entities/cli.js.map +2 -2
  37. package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
  38. package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
  39. package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
  40. package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
  41. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
  42. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
  43. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
  44. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
  45. package/dist/modules/messages/api/[id]/route.js +15 -0
  46. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  47. package/dist/modules/query_index/api/reindex.js +10 -0
  48. package/dist/modules/query_index/api/reindex.js.map +2 -2
  49. package/dist/modules/query_index/cli.js +125 -1
  50. package/dist/modules/query_index/cli.js.map +2 -2
  51. package/dist/modules/query_index/lib/coverage.js +19 -7
  52. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  53. package/dist/modules/query_index/lib/reindexer.js +43 -37
  54. package/dist/modules/query_index/lib/reindexer.js.map +2 -2
  55. package/dist/modules/query_index/lib/subscriber-scope.js +2 -3
  56. package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
  57. package/dist/modules/sales/commands/documents.js +137 -22
  58. package/dist/modules/sales/commands/documents.js.map +2 -2
  59. package/dist/modules/sales/data/validators.js +2 -0
  60. package/dist/modules/sales/data/validators.js.map +2 -2
  61. package/dist/modules/sales/workflows.js +10 -7
  62. package/dist/modules/sales/workflows.js.map +2 -2
  63. package/dist/modules/workflows/lib/activity-executor.js +27 -0
  64. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  65. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js +10 -0
  66. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js.map +7 -0
  67. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +10 -6
  68. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
  69. package/generated/entities/attachment_quota_reservation/index.ts +15 -0
  70. package/generated/entities.ids.generated.ts +2 -1
  71. package/generated/entity-fields-registry.ts +17 -0
  72. package/package.json +7 -7
  73. package/src/bootstrap.ts +17 -2
  74. package/src/modules/attachments/api/route.ts +114 -7
  75. package/src/modules/attachments/data/entities.ts +59 -0
  76. package/src/modules/attachments/di.ts +9 -1
  77. package/src/modules/attachments/i18n/de.json +3 -0
  78. package/src/modules/attachments/i18n/en.json +3 -0
  79. package/src/modules/attachments/i18n/es.json +3 -0
  80. package/src/modules/attachments/i18n/pl.json +3 -0
  81. package/src/modules/attachments/lib/drivers/index.ts +1 -1
  82. package/src/modules/attachments/lib/drivers/localDriver.ts +23 -8
  83. package/src/modules/attachments/lib/drivers/types.ts +5 -0
  84. package/src/modules/attachments/lib/quota-recovery-queue.ts +16 -0
  85. package/src/modules/attachments/lib/quota-service.ts +442 -0
  86. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +648 -120
  87. package/src/modules/attachments/migrations/Migration20260709234741_attachments.ts +12 -0
  88. package/src/modules/attachments/workers/quota-recovery.ts +68 -0
  89. package/src/modules/auth/lib/setup-app.ts +20 -2
  90. package/src/modules/dashboards/services/widgetDataService.ts +3 -6
  91. package/src/modules/entities/api/definitions.ts +4 -0
  92. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
  93. package/src/modules/entities/cli.ts +238 -21
  94. package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
  95. package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
  96. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
  97. package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
  98. package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
  99. package/src/modules/messages/api/[id]/route.ts +21 -0
  100. package/src/modules/query_index/api/reindex.ts +15 -0
  101. package/src/modules/query_index/cli.ts +180 -1
  102. package/src/modules/query_index/lib/coverage.ts +38 -8
  103. package/src/modules/query_index/lib/reindexer.ts +62 -40
  104. package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
  105. package/src/modules/sales/commands/documents.ts +237 -29
  106. package/src/modules/sales/data/validators.ts +2 -0
  107. package/src/modules/sales/workflows.ts +10 -7
  108. package/src/modules/workflows/i18n/de.json +1 -0
  109. package/src/modules/workflows/i18n/en.json +1 -0
  110. package/src/modules/workflows/i18n/es.json +1 -0
  111. package/src/modules/workflows/i18n/ko.json +1 -0
  112. package/src/modules/workflows/i18n/pl.json +1 -0
  113. package/src/modules/workflows/lib/activity-executor.ts +40 -1
  114. package/src/modules/workflows/widgets/injection/order-approval/context-keys.ts +18 -0
  115. 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.6842.1.8cd8a8883c",
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.6842.1.8cd8a8883c",
258
- "@open-mercato/shared": "0.6.7-develop.6842.1.8cd8a8883c",
259
- "@open-mercato/ui": "0.6.7-develop.6842.1.8cd8a8883c",
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.6842.1.8cd8a8883c",
265
- "@open-mercato/shared": "0.6.7-develop.6842.1.8cd8a8883c",
266
- "@open-mercato/ui": "0.6.7-develop.6842.1.8cd8a8883c",
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 { ModuleSubscriber } from '@open-mercato/shared/modules/registry'
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, { cache: cacheService, kms: kmsService })
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
- const tenantUsageBytes = await readTenantAttachmentUsageBytes(em, tenantId)
367
- if (willExceedAttachmentTenantQuota(tenantUsageBytes, file.size)) {
368
- return NextResponse.json({
369
- error: t('attachments.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.'),
370
- }, { status: 413 })
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
- return 0
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
- async store(payload: StoreFilePayload): Promise<StoredFile> {
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
- const relativePath = path.join(orgSegment, tenantSegment, storedName)
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 fs.unlink(absolutePath)
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
+ }