@open-mercato/core 0.10.1-develop.7736.1.88e07906bf → 0.10.1-develop.7748.1.d745ba61aa
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/generated/entities/api_key/index.js +2 -0
- package/dist/generated/entities/api_key/index.js.map +2 -2
- package/dist/generated/entities/attachment_partition/index.js +2 -0
- package/dist/generated/entities/attachment_partition/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/attachmentAccessFixtures.js +144 -0
- package/dist/helpers/integration/attachmentAccessFixtures.js.map +7 -0
- package/dist/modules/api_keys/data/entities.js +7 -0
- package/dist/modules/api_keys/data/entities.js.map +2 -2
- package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js +30 -0
- package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js.map +7 -0
- package/dist/modules/api_keys/services/apiKeyService.js +31 -9
- package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
- package/dist/modules/attachments/api/file/[id]/route.js +16 -10
- package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +15 -9
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/attachments/api/library/[id]/route.js +100 -56
- package/dist/modules/attachments/api/library/[id]/route.js.map +3 -3
- package/dist/modules/attachments/api/library/route.js +56 -17
- package/dist/modules/attachments/api/library/route.js.map +2 -2
- package/dist/modules/attachments/api/partitions/route.js +2 -0
- package/dist/modules/attachments/api/partitions/route.js.map +2 -2
- package/dist/modules/attachments/api/route.js +74 -34
- package/dist/modules/attachments/api/route.js.map +3 -3
- package/dist/modules/attachments/api/transfer/route.js +63 -37
- package/dist/modules/attachments/api/transfer/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +3 -0
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/data/validators.js +41 -0
- package/dist/modules/attachments/data/validators.js.map +7 -0
- package/dist/modules/attachments/di.js +5 -2
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/generators.js +29 -0
- package/dist/modules/attachments/generators.js.map +7 -0
- package/dist/modules/attachments/index.js +8 -1
- package/dist/modules/attachments/index.js.map +2 -2
- package/dist/modules/attachments/lib/access-errors.js +35 -0
- package/dist/modules/attachments/lib/access-errors.js.map +7 -0
- package/dist/modules/attachments/lib/access-mutation.js +23 -0
- package/dist/modules/attachments/lib/access-mutation.js.map +7 -0
- package/dist/modules/attachments/lib/access-protection.js +73 -0
- package/dist/modules/attachments/lib/access-protection.js.map +7 -0
- package/dist/modules/attachments/lib/access-query.js +74 -0
- package/dist/modules/attachments/lib/access-query.js.map +7 -0
- package/dist/modules/attachments/lib/access-registry.js +87 -0
- package/dist/modules/attachments/lib/access-registry.js.map +7 -0
- package/dist/modules/attachments/lib/access-runner.js +161 -0
- package/dist/modules/attachments/lib/access-runner.js.map +7 -0
- package/dist/modules/attachments/lib/access-types.js +1 -0
- package/dist/modules/attachments/lib/access-types.js.map +7 -0
- package/dist/modules/attachments/lib/attachment-service.js +14 -6
- package/dist/modules/attachments/lib/attachment-service.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/partitions.js +3 -2
- package/dist/modules/attachments/lib/partitions.js.map +2 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js +2 -0
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
- package/dist/modules/attachments/lib/target-access-service.js +12 -3
- package/dist/modules/attachments/lib/target-access-service.js.map +2 -2
- package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js +38 -0
- package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js.map +7 -0
- package/dist/modules/attachments/setup.js +10 -0
- package/dist/modules/attachments/setup.js.map +2 -2
- package/dist/modules/notifications/api/[id]/action/route.js +8 -0
- package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
- package/dist/modules/notifications/api/route.js +17 -0
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/notifications/data/validators.js +1 -1
- package/dist/modules/notifications/data/validators.js.map +2 -2
- package/dist/modules/notifications/lib/notificationService.js +45 -3
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/notifications/lib/routeHelpers.js +27 -0
- package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +50 -34
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/status-machine.js +10 -1
- package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
- package/dist/modules/payment_gateways/lib/webhook-processor.js +1 -1
- package/dist/modules/payment_gateways/lib/webhook-processor.js.map +2 -2
- package/dist/modules/staff/notifications.js +4 -2
- package/dist/modules/staff/notifications.js.map +2 -2
- package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js +1 -1
- package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js.map +2 -2
- package/dist/modules/workflows/api/tasks/[id]/complete/route.js +7 -1
- package/dist/modules/workflows/api/tasks/[id]/complete/route.js.map +2 -2
- package/dist/modules/workflows/backend/tasks/[id]/page.js +2 -1
- package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
- package/dist/modules/workflows/data/entities.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +18 -4
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/activity-types.js +2 -2
- package/dist/modules/workflows/lib/activity-types.js.map +2 -2
- package/dist/modules/workflows/lib/condition-handler.js +7 -1
- package/dist/modules/workflows/lib/condition-handler.js.map +2 -2
- package/dist/modules/workflows/lib/duration.js +13 -0
- package/dist/modules/workflows/lib/duration.js.map +2 -2
- package/dist/modules/workflows/lib/instance-status.js +16 -0
- package/dist/modules/workflows/lib/instance-status.js.map +7 -0
- package/dist/modules/workflows/lib/step-handler.js +9 -2
- package/dist/modules/workflows/lib/step-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-handler.js +16 -8
- package/dist/modules/workflows/lib/task-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-quick-action.js +13 -10
- package/dist/modules/workflows/lib/task-quick-action.js.map +2 -2
- package/dist/modules/workflows/lib/task-sla.js +10 -0
- package/dist/modules/workflows/lib/task-sla.js.map +2 -2
- package/dist/modules/workflows/lib/transition-handler.js +2 -1
- package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js +29 -1
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/dist/modules/workflows/notifications.js +4 -0
- package/dist/modules/workflows/notifications.js.map +2 -2
- package/generated/entities/api_key/index.ts +1 -0
- package/generated/entities/attachment_partition/index.ts +1 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +7 -7
- package/src/helpers/integration/attachmentAccessFixtures.ts +141 -0
- package/src/modules/api_keys/data/entities.ts +25 -1
- package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +234 -68
- package/src/modules/api_keys/migrations/Migration20261012202110_api_keys.ts +47 -0
- package/src/modules/api_keys/services/apiKeyService.ts +55 -9
- package/src/modules/attachments/api/file/[id]/route.ts +12 -9
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +10 -8
- package/src/modules/attachments/api/library/[id]/route.ts +87 -58
- package/src/modules/attachments/api/library/route.ts +40 -11
- package/src/modules/attachments/api/partitions/route.ts +2 -0
- package/src/modules/attachments/api/route.ts +68 -35
- package/src/modules/attachments/api/transfer/route.ts +65 -42
- package/src/modules/attachments/data/entities.ts +4 -0
- package/src/modules/attachments/data/validators.ts +36 -0
- package/src/modules/attachments/di.ts +4 -1
- package/src/modules/attachments/generators.ts +27 -0
- package/src/modules/attachments/i18n/de.json +4 -0
- package/src/modules/attachments/i18n/en.json +4 -0
- package/src/modules/attachments/i18n/es.json +4 -0
- package/src/modules/attachments/i18n/ko.json +4 -0
- package/src/modules/attachments/i18n/pl.json +4 -0
- package/src/modules/attachments/index.ts +18 -0
- package/src/modules/attachments/lib/access-errors.ts +32 -0
- package/src/modules/attachments/lib/access-mutation.ts +26 -0
- package/src/modules/attachments/lib/access-protection.ts +83 -0
- package/src/modules/attachments/lib/access-query.ts +86 -0
- package/src/modules/attachments/lib/access-registry.ts +117 -0
- package/src/modules/attachments/lib/access-runner.ts +183 -0
- package/src/modules/attachments/lib/access-types.ts +78 -0
- package/src/modules/attachments/lib/attachment-service.ts +8 -4
- package/src/modules/attachments/lib/createFromBuffer.ts +3 -0
- package/src/modules/attachments/lib/partitions.ts +3 -2
- package/src/modules/attachments/lib/scoped-upload-service.ts +2 -0
- package/src/modules/attachments/lib/target-access-service.ts +10 -3
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +17 -0
- package/src/modules/attachments/migrations/Migration20261001120000_attachments.ts +35 -0
- package/src/modules/attachments/setup.ts +10 -0
- package/src/modules/auth/i18n/de.json +3 -0
- package/src/modules/auth/i18n/en.json +3 -0
- package/src/modules/auth/i18n/es.json +3 -0
- package/src/modules/auth/i18n/ko.json +3 -0
- package/src/modules/auth/i18n/pl.json +3 -0
- package/src/modules/notifications/api/[id]/action/route.ts +8 -0
- package/src/modules/notifications/api/route.ts +17 -0
- package/src/modules/notifications/data/validators.ts +1 -1
- package/src/modules/notifications/i18n/de.json +2 -0
- package/src/modules/notifications/i18n/en.json +2 -0
- package/src/modules/notifications/i18n/es.json +2 -0
- package/src/modules/notifications/i18n/ko.json +2 -0
- package/src/modules/notifications/i18n/pl.json +2 -0
- package/src/modules/notifications/lib/notificationService.ts +67 -1
- package/src/modules/notifications/lib/routeHelpers.ts +38 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +50 -33
- package/src/modules/payment_gateways/lib/status-machine.ts +19 -0
- package/src/modules/payment_gateways/lib/webhook-processor.ts +1 -1
- package/src/modules/staff/notifications.ts +2 -0
- package/src/modules/workflows/AGENTS.md +2 -2
- package/src/modules/workflows/api/portal/tasks/[id]/complete/route.ts +5 -1
- package/src/modules/workflows/api/tasks/[id]/complete/route.ts +7 -1
- package/src/modules/workflows/backend/tasks/[id]/page.tsx +6 -2
- package/src/modules/workflows/data/entities.ts +6 -0
- package/src/modules/workflows/i18n/de.json +2 -0
- package/src/modules/workflows/i18n/en.json +2 -0
- package/src/modules/workflows/i18n/es.json +2 -0
- package/src/modules/workflows/i18n/ko.json +2 -0
- package/src/modules/workflows/i18n/pl.json +2 -0
- package/src/modules/workflows/lib/activity-executor.ts +36 -4
- package/src/modules/workflows/lib/activity-types.ts +2 -2
- package/src/modules/workflows/lib/condition-handler.ts +16 -1
- package/src/modules/workflows/lib/duration.ts +25 -0
- package/src/modules/workflows/lib/instance-status.ts +29 -0
- package/src/modules/workflows/lib/step-handler.ts +16 -1
- package/src/modules/workflows/lib/task-handler.ts +18 -10
- package/src/modules/workflows/lib/task-quick-action.ts +19 -10
- package/src/modules/workflows/lib/task-sla.ts +13 -0
- package/src/modules/workflows/lib/transition-handler.ts +2 -1
- package/src/modules/workflows/lib/workflow-executor.ts +44 -1
- package/src/modules/workflows/notifications.ts +4 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/attachments/lib/scoped-upload-service.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { Attachment, AttachmentPartition } from '../data/entities'\nimport { attachmentCrudEvents, attachmentCrudIndexer } from './crud'\nimport type { StorageDriverFactory } from './drivers'\nimport { buildAttachmentFileUrl } from './imageUrls'\nimport { ensureDefaultPartitions, resolveDefaultPartitionCode } from './partitions'\nimport { extractAttachmentContent } from './textExtraction'\nimport { requestOcrProcessing } from './ocrQueue'\nimport { OcrService, shouldUseLlmOcr } from './ocrService'\nimport { assertAttachmentScopeInvariant } from './access'\nimport {\n mergeAttachmentMetadata,\n upsertAssignment,\n type AttachmentAssignment,\n} from './metadata'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n sanitizeUploadedFileName,\n} from './security'\nimport { resolveAttachmentMaxBytes } from './upload-limits'\nimport type { AttachmentQuotaService } from './quota-service'\n\nconst logger = createLogger('attachments')\n\nexport type ScopedAttachmentUploadErrorCode =\n | 'dangerous_executable'\n | 'max_upload_size'\n | 'active_content'\n | 'partition_unavailable'\n | 'quota_exceeded'\n | 'quota_target_exists'\n | 'quota_unavailable'\n | 'quota_recovery_unsupported'\n | 'storage_failed'\n | 'persistence_failed'\n\n// Use Symbol.for so the marker survives module duplication across bundle\n// boundaries. The production build emits this module into several server\n// chunks, so a consumer's `instanceof` compares against a different copy of the\n// class than the DI-resolved service threw from: the check silently fails, the\n// error escapes the caller's catch, and a deliberate 400/413 surfaces as a 500.\nconst SCOPED_ATTACHMENT_UPLOAD_ERROR_MARKER = Symbol.for('@open-mercato/ScopedAttachmentUploadError')\n\nexport class ScopedAttachmentUploadError extends Error {\n readonly [SCOPED_ATTACHMENT_UPLOAD_ERROR_MARKER] = true\n\n constructor(\n public readonly code: ScopedAttachmentUploadErrorCode,\n public readonly status: number,\n ) {\n super(code)\n this.name = 'ScopedAttachmentUploadError'\n }\n}\n\n/**\n * Bundle-safe check for `ScopedAttachmentUploadError`. Prefer this over\n * `instanceof` at every call site \u2014 the class is duplicated across server\n * chunks, so `instanceof` is only reliable inside this module.\n */\nexport function isScopedAttachmentUploadError(error: unknown): error is ScopedAttachmentUploadError {\n return Boolean(error)\n && typeof error === 'object'\n && (error as Record<symbol, unknown>)[SCOPED_ATTACHMENT_UPLOAD_ERROR_MARKER] === true\n}\n\nexport type ScopedAttachmentUploadInput = {\n tenantId: string\n organizationId: string\n entityId: string\n recordId: string\n fileName: string\n declaredMimeType?: string | null\n buffer: Buffer\n tags?: string[]\n assignments?: AttachmentAssignment[]\n partitionCode?: string | null\n maxBytes?: number\n /**\n * Reject a partition flagged public, so a module-owned upload (documents,\n * warranty claims) never lands where an anonymous reader can reach it. The\n * tenant/organization half of that guarantee is not this flag's job: the\n * partition lookup below already matches only a global partition or one owned\n * by this tenant and organization, whether or not the flag is set.\n */\n requirePrivatePartition?: boolean\n /**\n * Persists a module-owned link row inside the same transaction as the\n * Attachment, so a link failure cannot leave a committed orphan attachment.\n * The callback receives only the generated id, never the entity or the\n * storage implementation.\n */\n persistLink?: (tx: EntityManager, attachmentId: string) => Promise<void> | void\n}\n\ntype QuotaRecoveryScheduler = (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n) => Promise<void>\n\nexport class ScopedAttachmentUploadService {\n constructor(private readonly deps: {\n em: EntityManager\n dataEngine?: DataEngine | null\n storageDriverFactory: StorageDriverFactory\n attachmentQuotaService: AttachmentQuotaService\n attachmentQuotaRecoveryScheduler: QuotaRecoveryScheduler\n }) {}\n\n async upload(input: ScopedAttachmentUploadInput): Promise<Attachment> {\n const safeName = sanitizeUploadedFileName(input.fileName)\n if (hasDangerousExecutableExtension(safeName)) {\n throw new ScopedAttachmentUploadError('dangerous_executable', 400)\n }\n if (input.buffer.length > (input.maxBytes ?? resolveAttachmentMaxBytes())) {\n throw new ScopedAttachmentUploadError('max_upload_size', 413)\n }\n const mimeType = detectAttachmentMimeType(input.buffer, safeName, input.declaredMimeType ?? '')\n if (isActiveContentAttachment(input.buffer, safeName, mimeType)) {\n throw new ScopedAttachmentUploadError('active_content', 400)\n }\n\n const { em, storageDriverFactory, attachmentQuotaService, attachmentQuotaRecoveryScheduler } = this.deps\n await ensureDefaultPartitions(em)\n const partitionCode = input.partitionCode ?? resolveDefaultPartitionCode(input.entityId)\n const partition = await em.findOne(AttachmentPartition, {\n code: partitionCode,\n $or: [\n { tenantId: null, organizationId: null },\n { tenantId: input.tenantId, organizationId: input.organizationId },\n ],\n })\n if (!partition) throw new ScopedAttachmentUploadError('partition_unavailable', 400)\n if (input.requirePrivatePartition && partition.isPublic) {\n throw new ScopedAttachmentUploadError('partition_unavailable', 403)\n }\n\n const driver = await storageDriverFactory.resolveForPartition(partition.code, {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n })\n const preparedStoragePath = driver.prepareStoragePath?.({\n partitionCode: partition.code,\n orgId: input.organizationId,\n tenantId: input.tenantId,\n fileName: safeName,\n })\n if (!preparedStoragePath) {\n throw new ScopedAttachmentUploadError('quota_recovery_unsupported', 500)\n }\n\n let reservation: { id: string; leaseToken: string; expiresAt: Date } | null = null\n try {\n reservation = await attachmentQuotaService.reserve({\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n bytes: input.buffer.length,\n source: 'attachment',\n storageDriver: driver.key,\n storagePath: preparedStoragePath,\n partitionCode: partition.code,\n })\n await attachmentQuotaRecoveryScheduler({\n reservationId: reservation.id,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n await attachmentQuotaService.beginStorage(reservation.id, reservation.leaseToken)\n } catch (error) {\n if (reservation) {\n await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => undefined)\n }\n const code = (error as { code?: unknown })?.code\n if (code === 'quota_exceeded') throw new ScopedAttachmentUploadError('quota_exceeded', 413)\n if (code === 'quota_target_exists') throw new ScopedAttachmentUploadError('quota_target_exists', 409)\n throw new ScopedAttachmentUploadError('quota_unavailable', 500)\n }\n\n if (!reservation) throw new ScopedAttachmentUploadError('quota_unavailable', 500)\n\n let storedPath = preparedStoragePath\n try {\n const stored = await driver.store({\n partitionCode: partition.code,\n orgId: input.organizationId,\n tenantId: input.tenantId,\n fileName: safeName,\n buffer: input.buffer,\n storagePath: preparedStoragePath,\n })\n storedPath = stored.storagePath\n await attachmentQuotaService.markStored(reservation.id, reservation.leaseToken)\n } catch (error) {\n await this.compensateStorage(driver, partition.code, storedPath, reservation)\n logger.error('Scoped attachment storage failed', { err: error })\n throw new ScopedAttachmentUploadError('storage_failed', 500)\n }\n\n let extractedContent: string | null = null\n const wantsLlmOcr = partition.requiresOcr && shouldUseLlmOcr(mimeType, safeName)\n const ocrService = wantsLlmOcr ? new OcrService() : null\n const useLlmOcr = Boolean(wantsLlmOcr && ocrService?.available)\n if (partition.requiresOcr && !useLlmOcr) {\n try {\n const { filePath, cleanup } = await driver.toLocalPath(partition.code, storedPath)\n try {\n extractedContent = await extractAttachmentContent({ filePath, mimeType })\n } finally {\n await cleanup().catch(() => undefined)\n }\n } catch (error) {\n logger.error('Scoped attachment text extraction failed', { err: error })\n }\n }\n\n let assignments = input.assignments?.slice() ?? []\n assignments = upsertAssignment(assignments, { type: input.entityId, id: input.recordId })\n const metadata = mergeAttachmentMetadata(null, {\n assignments,\n tags: input.tags ?? [],\n })\n const attachmentId = randomUUID()\n let attachment!: Attachment\n\n try {\n await em.transactional(async (tx) => {\n attachment = tx.create(Attachment, {\n id: attachmentId,\n entityId: input.entityId,\n recordId: input.recordId,\n organizationId: input.organizationId,\n tenantId: input.tenantId,\n fileName: safeName,\n mimeType,\n fileSize: input.buffer.length,\n partitionCode: partition.code,\n storageDriver: partition.storageDriver || 'local',\n storagePath: storedPath,\n url: buildAttachmentFileUrl(attachmentId),\n content: extractedContent,\n storageMetadata: metadata,\n })\n assertAttachmentScopeInvariant({ tenantId: attachment.tenantId, organizationId: attachment.organizationId })\n await tx.persist(attachment).flush()\n await input.persistLink?.(tx, attachmentId)\n await attachmentQuotaService.completeAttachment(reservation.id, reservation.leaseToken, tx)\n })\n } catch (error) {\n await this.compensateStorage(driver, partition.code, storedPath, reservation)\n logger.error('Scoped attachment persistence failed', { err: error })\n // `persistLink` is where callers run their own authorization (the\n // documents module re-checks the edit capability inside this\n // transaction), so a deliberate HTTP error from it must reach the route\n // with its own status. Flattening it to 500 turns a documented 403 into\n // an apparent server fault.\n if (isCrudHttpError(error)) throw error\n throw new ScopedAttachmentUploadError('persistence_failed', 500)\n }\n\n if (useLlmOcr) {\n try {\n await requestOcrProcessing(em, attachment, driver, storedPath)\n } catch (error) {\n logger.error('Scoped attachment OCR scheduling failed', { err: error })\n }\n }\n if (this.deps.dataEngine) {\n await emitCrudSideEffects({\n dataEngine: this.deps.dataEngine,\n action: 'created',\n entity: attachment,\n identifiers: {\n id: attachment.id,\n organizationId: attachment.organizationId ?? null,\n tenantId: attachment.tenantId ?? null,\n },\n events: attachmentCrudEvents,\n indexer: attachmentCrudIndexer,\n })\n await this.deps.dataEngine.flushOrmEntityChanges()\n }\n return attachment\n }\n\n private async compensateStorage(\n driver: Awaited<ReturnType<StorageDriverFactory['resolveForPartition']>>,\n partitionCode: string,\n storagePath: string,\n reservation: { id: string; leaseToken: string },\n ): Promise<void> {\n try {\n await (driver.deleteStrict?.(partitionCode, storagePath) ?? driver.delete(partitionCode, storagePath))\n await this.deps.attachmentQuotaService.release(reservation.id, reservation.leaseToken)\n } catch (error) {\n logger.error('Scoped attachment compensation failed', { err: error })\n }\n }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { Attachment, AttachmentPartition } from '../data/entities'\nimport { attachmentCrudEvents, attachmentCrudIndexer } from './crud'\nimport type { StorageDriverFactory } from './drivers'\nimport { buildAttachmentFileUrl } from './imageUrls'\nimport { ensureAttachmentPartitionProtection } from './access-protection'\nimport { ensureDefaultPartitions, resolveDefaultPartitionCode } from './partitions'\nimport { extractAttachmentContent } from './textExtraction'\nimport { requestOcrProcessing } from './ocrQueue'\nimport { OcrService, shouldUseLlmOcr } from './ocrService'\nimport { assertAttachmentScopeInvariant } from './access'\nimport {\n mergeAttachmentMetadata,\n upsertAssignment,\n type AttachmentAssignment,\n} from './metadata'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n sanitizeUploadedFileName,\n} from './security'\nimport { resolveAttachmentMaxBytes } from './upload-limits'\nimport type { AttachmentQuotaService } from './quota-service'\n\nconst logger = createLogger('attachments')\n\nexport type ScopedAttachmentUploadErrorCode =\n | 'dangerous_executable'\n | 'max_upload_size'\n | 'active_content'\n | 'partition_unavailable'\n | 'quota_exceeded'\n | 'quota_target_exists'\n | 'quota_unavailable'\n | 'quota_recovery_unsupported'\n | 'storage_failed'\n | 'persistence_failed'\n\n// Use Symbol.for so the marker survives module duplication across bundle\n// boundaries. The production build emits this module into several server\n// chunks, so a consumer's `instanceof` compares against a different copy of the\n// class than the DI-resolved service threw from: the check silently fails, the\n// error escapes the caller's catch, and a deliberate 400/413 surfaces as a 500.\nconst SCOPED_ATTACHMENT_UPLOAD_ERROR_MARKER = Symbol.for('@open-mercato/ScopedAttachmentUploadError')\n\nexport class ScopedAttachmentUploadError extends Error {\n readonly [SCOPED_ATTACHMENT_UPLOAD_ERROR_MARKER] = true\n\n constructor(\n public readonly code: ScopedAttachmentUploadErrorCode,\n public readonly status: number,\n ) {\n super(code)\n this.name = 'ScopedAttachmentUploadError'\n }\n}\n\n/**\n * Bundle-safe check for `ScopedAttachmentUploadError`. Prefer this over\n * `instanceof` at every call site \u2014 the class is duplicated across server\n * chunks, so `instanceof` is only reliable inside this module.\n */\nexport function isScopedAttachmentUploadError(error: unknown): error is ScopedAttachmentUploadError {\n return Boolean(error)\n && typeof error === 'object'\n && (error as Record<symbol, unknown>)[SCOPED_ATTACHMENT_UPLOAD_ERROR_MARKER] === true\n}\n\nexport type ScopedAttachmentUploadInput = {\n tenantId: string\n organizationId: string\n entityId: string\n recordId: string\n fileName: string\n declaredMimeType?: string | null\n buffer: Buffer\n tags?: string[]\n assignments?: AttachmentAssignment[]\n partitionCode?: string | null\n maxBytes?: number\n /**\n * Reject a partition flagged public, so a module-owned upload (documents,\n * warranty claims) never lands where an anonymous reader can reach it. The\n * tenant/organization half of that guarantee is not this flag's job: the\n * partition lookup below already matches only a global partition or one owned\n * by this tenant and organization, whether or not the flag is set.\n */\n requirePrivatePartition?: boolean\n /**\n * Persists a module-owned link row inside the same transaction as the\n * Attachment, so a link failure cannot leave a committed orphan attachment.\n * The callback receives only the generated id, never the entity or the\n * storage implementation.\n */\n persistLink?: (tx: EntityManager, attachmentId: string) => Promise<void> | void\n}\n\ntype QuotaRecoveryScheduler = (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n) => Promise<void>\n\nexport class ScopedAttachmentUploadService {\n constructor(private readonly deps: {\n em: EntityManager\n dataEngine?: DataEngine | null\n storageDriverFactory: StorageDriverFactory\n attachmentQuotaService: AttachmentQuotaService\n attachmentQuotaRecoveryScheduler: QuotaRecoveryScheduler\n }) {}\n\n async upload(input: ScopedAttachmentUploadInput): Promise<Attachment> {\n const safeName = sanitizeUploadedFileName(input.fileName)\n if (hasDangerousExecutableExtension(safeName)) {\n throw new ScopedAttachmentUploadError('dangerous_executable', 400)\n }\n if (input.buffer.length > (input.maxBytes ?? resolveAttachmentMaxBytes())) {\n throw new ScopedAttachmentUploadError('max_upload_size', 413)\n }\n const mimeType = detectAttachmentMimeType(input.buffer, safeName, input.declaredMimeType ?? '')\n if (isActiveContentAttachment(input.buffer, safeName, mimeType)) {\n throw new ScopedAttachmentUploadError('active_content', 400)\n }\n\n const { em, storageDriverFactory, attachmentQuotaService, attachmentQuotaRecoveryScheduler } = this.deps\n await ensureDefaultPartitions(em)\n const partitionCode = input.partitionCode ?? resolveDefaultPartitionCode(input.entityId)\n const partition = await em.findOne(AttachmentPartition, {\n code: partitionCode,\n $or: [\n { tenantId: null, organizationId: null },\n { tenantId: input.tenantId, organizationId: input.organizationId },\n ],\n })\n if (!partition) throw new ScopedAttachmentUploadError('partition_unavailable', 400)\n if (input.requirePrivatePartition && partition.isPublic) {\n throw new ScopedAttachmentUploadError('partition_unavailable', 403)\n }\n\n await ensureAttachmentPartitionProtection(em, partition)\n const driver = await storageDriverFactory.resolveForPartition(partition.code, {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n })\n const preparedStoragePath = driver.prepareStoragePath?.({\n partitionCode: partition.code,\n orgId: input.organizationId,\n tenantId: input.tenantId,\n fileName: safeName,\n })\n if (!preparedStoragePath) {\n throw new ScopedAttachmentUploadError('quota_recovery_unsupported', 500)\n }\n\n let reservation: { id: string; leaseToken: string; expiresAt: Date } | null = null\n try {\n reservation = await attachmentQuotaService.reserve({\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n bytes: input.buffer.length,\n source: 'attachment',\n storageDriver: driver.key,\n storagePath: preparedStoragePath,\n partitionCode: partition.code,\n })\n await attachmentQuotaRecoveryScheduler({\n reservationId: reservation.id,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n await attachmentQuotaService.beginStorage(reservation.id, reservation.leaseToken)\n } catch (error) {\n if (reservation) {\n await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => undefined)\n }\n const code = (error as { code?: unknown })?.code\n if (code === 'quota_exceeded') throw new ScopedAttachmentUploadError('quota_exceeded', 413)\n if (code === 'quota_target_exists') throw new ScopedAttachmentUploadError('quota_target_exists', 409)\n throw new ScopedAttachmentUploadError('quota_unavailable', 500)\n }\n\n if (!reservation) throw new ScopedAttachmentUploadError('quota_unavailable', 500)\n\n let storedPath = preparedStoragePath\n try {\n const stored = await driver.store({\n partitionCode: partition.code,\n orgId: input.organizationId,\n tenantId: input.tenantId,\n fileName: safeName,\n buffer: input.buffer,\n storagePath: preparedStoragePath,\n })\n storedPath = stored.storagePath\n await attachmentQuotaService.markStored(reservation.id, reservation.leaseToken)\n } catch (error) {\n await this.compensateStorage(driver, partition.code, storedPath, reservation)\n logger.error('Scoped attachment storage failed', { err: error })\n throw new ScopedAttachmentUploadError('storage_failed', 500)\n }\n\n let extractedContent: string | null = null\n const wantsLlmOcr = partition.requiresOcr && shouldUseLlmOcr(mimeType, safeName)\n const ocrService = wantsLlmOcr ? new OcrService() : null\n const useLlmOcr = Boolean(wantsLlmOcr && ocrService?.available)\n if (partition.requiresOcr && !useLlmOcr) {\n try {\n const { filePath, cleanup } = await driver.toLocalPath(partition.code, storedPath)\n try {\n extractedContent = await extractAttachmentContent({ filePath, mimeType })\n } finally {\n await cleanup().catch(() => undefined)\n }\n } catch (error) {\n logger.error('Scoped attachment text extraction failed', { err: error })\n }\n }\n\n let assignments = input.assignments?.slice() ?? []\n assignments = upsertAssignment(assignments, { type: input.entityId, id: input.recordId })\n const metadata = mergeAttachmentMetadata(null, {\n assignments,\n tags: input.tags ?? [],\n })\n const attachmentId = randomUUID()\n let attachment!: Attachment\n\n try {\n await em.transactional(async (tx) => {\n attachment = tx.create(Attachment, {\n id: attachmentId,\n entityId: input.entityId,\n recordId: input.recordId,\n organizationId: input.organizationId,\n tenantId: input.tenantId,\n fileName: safeName,\n mimeType,\n fileSize: input.buffer.length,\n partitionCode: partition.code,\n storageDriver: partition.storageDriver || 'local',\n storagePath: storedPath,\n url: buildAttachmentFileUrl(attachmentId),\n content: extractedContent,\n storageMetadata: metadata,\n })\n assertAttachmentScopeInvariant({ tenantId: attachment.tenantId, organizationId: attachment.organizationId })\n await tx.persist(attachment).flush()\n await input.persistLink?.(tx, attachmentId)\n await attachmentQuotaService.completeAttachment(reservation.id, reservation.leaseToken, tx)\n })\n } catch (error) {\n await this.compensateStorage(driver, partition.code, storedPath, reservation)\n logger.error('Scoped attachment persistence failed', { err: error })\n // `persistLink` is where callers run their own authorization (the\n // documents module re-checks the edit capability inside this\n // transaction), so a deliberate HTTP error from it must reach the route\n // with its own status. Flattening it to 500 turns a documented 403 into\n // an apparent server fault.\n if (isCrudHttpError(error)) throw error\n throw new ScopedAttachmentUploadError('persistence_failed', 500)\n }\n\n if (useLlmOcr) {\n try {\n await requestOcrProcessing(em, attachment, driver, storedPath)\n } catch (error) {\n logger.error('Scoped attachment OCR scheduling failed', { err: error })\n }\n }\n if (this.deps.dataEngine) {\n await emitCrudSideEffects({\n dataEngine: this.deps.dataEngine,\n action: 'created',\n entity: attachment,\n identifiers: {\n id: attachment.id,\n organizationId: attachment.organizationId ?? null,\n tenantId: attachment.tenantId ?? null,\n },\n events: attachmentCrudEvents,\n indexer: attachmentCrudIndexer,\n })\n await this.deps.dataEngine.flushOrmEntityChanges()\n }\n return attachment\n }\n\n private async compensateStorage(\n driver: Awaited<ReturnType<StorageDriverFactory['resolveForPartition']>>,\n partitionCode: string,\n storagePath: string,\n reservation: { id: string; leaseToken: string },\n ): Promise<void> {\n try {\n await (driver.deleteStrict?.(partitionCode, storagePath) ?? driver.delete(partitionCode, storagePath))\n await this.deps.attachmentQuotaService.release(reservation.id, reservation.leaseToken)\n } catch (error) {\n logger.error('Scoped attachment compensation failed', { err: error })\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA;AAAA,SAAS,kBAAkB;AAG3B,SAAS,2BAA2B;AACpC,SAAS,uBAAuB;AAChC,SAAS,oBAAoB;AAC7B,SAAS,YAAY,2BAA2B;AAChD,SAAS,sBAAsB,6BAA6B;AAE5D,SAAS,8BAA8B;AACvC,SAAS,2CAA2C;AACpD,SAAS,yBAAyB,mCAAmC;AACrE,SAAS,gCAAgC;AACzC,SAAS,4BAA4B;AACrC,SAAS,YAAY,uBAAuB;AAC5C,SAAS,sCAAsC;AAC/C;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,iCAAiC;AAG1C,MAAM,SAAS,aAAa,aAAa;AAmBzC,MAAM,wCAAwC,uBAAO,IAAI,2CAA2C;AAE7F,MAAM,qCAAoC,YACrC,4CADqC,IAAM;AAAA,EAGrD,YACkB,MACA,QAChB;AACA,UAAM,IAAI;AAHM;AACA;AAJlB,SAAU,MAAyC;AAOjD,SAAK,OAAO;AAAA,EACd;AACF;AAOO,SAAS,8BAA8B,OAAsD;AAClG,SAAO,QAAQ,KAAK,KACf,OAAO,UAAU,YAChB,MAAkC,qCAAqC,MAAM;AACrF;AAoCO,MAAM,8BAA8B;AAAA,EACzC,YAA6B,MAM1B;AAN0B;AAAA,EAMzB;AAAA,EAEJ,MAAM,OAAO,OAAyD;AACpE,UAAM,WAAW,yBAAyB,MAAM,QAAQ;AACxD,QAAI,gCAAgC,QAAQ,GAAG;AAC7C,YAAM,IAAI,4BAA4B,wBAAwB,GAAG;AAAA,IACnE;AACA,QAAI,MAAM,OAAO,UAAU,MAAM,YAAY,0BAA0B,IAAI;AACzE,YAAM,IAAI,4BAA4B,mBAAmB,GAAG;AAAA,IAC9D;AACA,UAAM,WAAW,yBAAyB,MAAM,QAAQ,UAAU,MAAM,oBAAoB,EAAE;AAC9F,QAAI,0BAA0B,MAAM,QAAQ,UAAU,QAAQ,GAAG;AAC/D,YAAM,IAAI,4BAA4B,kBAAkB,GAAG;AAAA,IAC7D;AAEA,UAAM,EAAE,IAAI,sBAAsB,wBAAwB,iCAAiC,IAAI,KAAK;AACpG,UAAM,wBAAwB,EAAE;AAChC,UAAM,gBAAgB,MAAM,iBAAiB,4BAA4B,MAAM,QAAQ;AACvF,UAAM,YAAY,MAAM,GAAG,QAAQ,qBAAqB;AAAA,MACtD,MAAM;AAAA,MACN,KAAK;AAAA,QACH,EAAE,UAAU,MAAM,gBAAgB,KAAK;AAAA,QACvC,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe;AAAA,MACnE;AAAA,IACF,CAAC;AACD,QAAI,CAAC,UAAW,OAAM,IAAI,4BAA4B,yBAAyB,GAAG;AAClF,QAAI,MAAM,2BAA2B,UAAU,UAAU;AACvD,YAAM,IAAI,4BAA4B,yBAAyB,GAAG;AAAA,IACpE;AAEA,UAAM,oCAAoC,IAAI,SAAS;AACvD,UAAM,SAAS,MAAM,qBAAqB,oBAAoB,UAAU,MAAM;AAAA,MAC5E,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,IACxB,CAAC;AACD,UAAM,sBAAsB,OAAO,qBAAqB;AAAA,MACtD,eAAe,UAAU;AAAA,MACzB,OAAO,MAAM;AAAA,MACb,UAAU,MAAM;AAAA,MAChB,UAAU;AAAA,IACZ,CAAC;AACD,QAAI,CAAC,qBAAqB;AACxB,YAAM,IAAI,4BAA4B,8BAA8B,GAAG;AAAA,IACzE;AAEA,QAAI,cAA0E;AAC9E,QAAI;AACF,oBAAc,MAAM,uBAAuB,QAAQ;AAAA,QACjD,UAAU,MAAM;AAAA,QAChB,gBAAgB,MAAM;AAAA,QACtB,OAAO,MAAM,OAAO;AAAA,QACpB,QAAQ;AAAA,QACR,eAAe,OAAO;AAAA,QACtB,aAAa;AAAA,QACb,eAAe,UAAU;AAAA,MAC3B,CAAC;AACD,YAAM,iCAAiC;AAAA,QACrC,eAAe,YAAY;AAAA,QAC3B,UAAU,MAAM;AAAA,QAChB,gBAAgB,MAAM;AAAA,MACxB,GAAG,KAAK,IAAI,KAAO,YAAY,UAAU,QAAQ,IAAI,KAAK,IAAI,CAAC,CAAC;AAChE,YAAM,uBAAuB,aAAa,YAAY,IAAI,YAAY,UAAU;AAAA,IAClF,SAAS,OAAO;AACd,UAAI,aAAa;AACf,cAAM,uBAAuB,QAAQ,YAAY,IAAI,YAAY,UAAU,EAAE,MAAM,MAAM,MAAS;AAAA,MACpG;AACA,YAAM,OAAQ,OAA8B;AAC5C,UAAI,SAAS,iBAAkB,OAAM,IAAI,4BAA4B,kBAAkB,GAAG;AAC1F,UAAI,SAAS,sBAAuB,OAAM,IAAI,4BAA4B,uBAAuB,GAAG;AACpG,YAAM,IAAI,4BAA4B,qBAAqB,GAAG;AAAA,IAChE;AAEA,QAAI,CAAC,YAAa,OAAM,IAAI,4BAA4B,qBAAqB,GAAG;AAEhF,QAAI,aAAa;AACjB,QAAI;AACF,YAAM,SAAS,MAAM,OAAO,MAAM;AAAA,QAChC,eAAe,UAAU;AAAA,QACzB,OAAO,MAAM;AAAA,QACb,UAAU,MAAM;AAAA,QAChB,UAAU;AAAA,QACV,QAAQ,MAAM;AAAA,QACd,aAAa;AAAA,MACf,CAAC;AACD,mBAAa,OAAO;AACpB,YAAM,uBAAuB,WAAW,YAAY,IAAI,YAAY,UAAU;AAAA,IAChF,SAAS,OAAO;AACd,YAAM,KAAK,kBAAkB,QAAQ,UAAU,MAAM,YAAY,WAAW;AAC5E,aAAO,MAAM,oCAAoC,EAAE,KAAK,MAAM,CAAC;AAC/D,YAAM,IAAI,4BAA4B,kBAAkB,GAAG;AAAA,IAC7D;AAEA,QAAI,mBAAkC;AACtC,UAAM,cAAc,UAAU,eAAe,gBAAgB,UAAU,QAAQ;AAC/E,UAAM,aAAa,cAAc,IAAI,WAAW,IAAI;AACpD,UAAM,YAAY,QAAQ,eAAe,YAAY,SAAS;AAC9D,QAAI,UAAU,eAAe,CAAC,WAAW;AACvC,UAAI;AACF,cAAM,EAAE,UAAU,QAAQ,IAAI,MAAM,OAAO,YAAY,UAAU,MAAM,UAAU;AACjF,YAAI;AACF,6BAAmB,MAAM,yBAAyB,EAAE,UAAU,SAAS,CAAC;AAAA,QAC1E,UAAE;AACA,gBAAM,QAAQ,EAAE,MAAM,MAAM,MAAS;AAAA,QACvC;AAAA,MACF,SAAS,OAAO;AACd,eAAO,MAAM,4CAA4C,EAAE,KAAK,MAAM,CAAC;AAAA,MACzE;AAAA,IACF;AAEA,QAAI,cAAc,MAAM,aAAa,MAAM,KAAK,CAAC;AACjD,kBAAc,iBAAiB,aAAa,EAAE,MAAM,MAAM,UAAU,IAAI,MAAM,SAAS,CAAC;AACxF,UAAM,WAAW,wBAAwB,MAAM;AAAA,MAC7C;AAAA,MACA,MAAM,MAAM,QAAQ,CAAC;AAAA,IACvB,CAAC;AACD,UAAM,eAAe,WAAW;AAChC,QAAI;AAEJ,QAAI;AACF,YAAM,GAAG,cAAc,OAAO,OAAO;AACnC,qBAAa,GAAG,OAAO,YAAY;AAAA,UACjC,IAAI;AAAA,UACJ,UAAU,MAAM;AAAA,UAChB,UAAU,MAAM;AAAA,UAChB,gBAAgB,MAAM;AAAA,UACtB,UAAU,MAAM;AAAA,UAChB,UAAU;AAAA,UACV;AAAA,UACA,UAAU,MAAM,OAAO;AAAA,UACvB,eAAe,UAAU;AAAA,UACzB,eAAe,UAAU,iBAAiB;AAAA,UAC1C,aAAa;AAAA,UACb,KAAK,uBAAuB,YAAY;AAAA,UACxC,SAAS;AAAA,UACT,iBAAiB;AAAA,QACnB,CAAC;AACD,uCAA+B,EAAE,UAAU,WAAW,UAAU,gBAAgB,WAAW,eAAe,CAAC;AAC3G,cAAM,GAAG,QAAQ,UAAU,EAAE,MAAM;AACnC,cAAM,MAAM,cAAc,IAAI,YAAY;AAC1C,cAAM,uBAAuB,mBAAmB,YAAY,IAAI,YAAY,YAAY,EAAE;AAAA,MAC5F,CAAC;AAAA,IACH,SAAS,OAAO;AACd,YAAM,KAAK,kBAAkB,QAAQ,UAAU,MAAM,YAAY,WAAW;AAC5E,aAAO,MAAM,wCAAwC,EAAE,KAAK,MAAM,CAAC;AAMnE,UAAI,gBAAgB,KAAK,EAAG,OAAM;AAClC,YAAM,IAAI,4BAA4B,sBAAsB,GAAG;AAAA,IACjE;AAEA,QAAI,WAAW;AACb,UAAI;AACF,cAAM,qBAAqB,IAAI,YAAY,QAAQ,UAAU;AAAA,MAC/D,SAAS,OAAO;AACd,eAAO,MAAM,2CAA2C,EAAE,KAAK,MAAM,CAAC;AAAA,MACxE;AAAA,IACF;AACA,QAAI,KAAK,KAAK,YAAY;AACxB,YAAM,oBAAoB;AAAA,QACxB,YAAY,KAAK,KAAK;AAAA,QACtB,QAAQ;AAAA,QACR,QAAQ;AAAA,QACR,aAAa;AAAA,UACX,IAAI,WAAW;AAAA,UACf,gBAAgB,WAAW,kBAAkB;AAAA,UAC7C,UAAU,WAAW,YAAY;AAAA,QACnC;AAAA,QACA,QAAQ;AAAA,QACR,SAAS;AAAA,MACX,CAAC;AACD,YAAM,KAAK,KAAK,WAAW,sBAAsB;AAAA,IACnD;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAc,kBACZ,QACA,eACA,aACA,aACe;AACf,QAAI;AACF,aAAO,OAAO,eAAe,eAAe,WAAW,KAAK,OAAO,OAAO,eAAe,WAAW;AACpG,YAAM,KAAK,KAAK,uBAAuB,QAAQ,YAAY,IAAI,YAAY,UAAU;AAAA,IACvF,SAAS,OAAO;AACd,aAAO,MAAM,yCAAyC,EAAE,KAAK,MAAM,CAAC;AAAA,IACtE;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
2
2
|
import { Attachment, AttachmentPartition } from "../data/entities.js";
|
|
3
|
-
import {
|
|
3
|
+
import { evaluateAttachmentAccess } from "./access-runner.js";
|
|
4
4
|
import { readAttachmentMetadata } from "./metadata.js";
|
|
5
5
|
class AttachmentTargetAccessService {
|
|
6
|
-
constructor(em) {
|
|
6
|
+
constructor(em, resolveAccessContext) {
|
|
7
7
|
this.em = em;
|
|
8
|
+
this.resolveAccessContext = resolveAccessContext;
|
|
8
9
|
}
|
|
9
10
|
async canAccessLinkedTarget(input) {
|
|
10
11
|
if (!input.targets.length) return false;
|
|
@@ -28,7 +29,15 @@ class AttachmentTargetAccessService {
|
|
|
28
29
|
]
|
|
29
30
|
});
|
|
30
31
|
if (!partition) return false;
|
|
31
|
-
|
|
32
|
+
const access = await evaluateAttachmentAccess({
|
|
33
|
+
auth: input.auth,
|
|
34
|
+
attachment,
|
|
35
|
+
partition,
|
|
36
|
+
action: "read",
|
|
37
|
+
context: this.resolveAccessContext?.(),
|
|
38
|
+
requireAuthForPublic: true
|
|
39
|
+
});
|
|
40
|
+
if (!access.ok) return false;
|
|
32
41
|
const metadata = readAttachmentMetadata(attachment.storageMetadata);
|
|
33
42
|
return input.targets.some((target) => attachment.entityId === target.entityId && attachment.recordId === target.recordId || metadata.assignments?.some((assignment) => assignment.type === target.entityId && assignment.id === target.recordId) === true);
|
|
34
43
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/attachments/lib/target-access-service.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { Attachment, AttachmentPartition } from '../data/entities'\nimport {
|
|
5
|
-
"mappings": "AAEA,SAAS,6BAA6B;AACtC,SAAS,YAAY,2BAA2B;AAChD,SAAS,
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { Attachment, AttachmentPartition } from '../data/entities'\nimport { evaluateAttachmentAccess, type AttachmentAccessContext } from './access-runner'\nimport { readAttachmentMetadata } from './metadata'\n\nexport type AttachmentRecordTarget = {\n entityId: string\n recordId: string\n}\n\nexport type AttachmentTargetAccessInput = {\n attachmentId: string\n tenantId: string\n organizationId: string\n auth: Exclude<AuthContext, null>\n targets: AttachmentRecordTarget[]\n}\n\nexport class AttachmentTargetAccessService {\n constructor(\n private readonly em: EntityManager,\n private readonly resolveAccessContext?: (() => AttachmentAccessContext) | null,\n ) {}\n\n async canAccessLinkedTarget(input: AttachmentTargetAccessInput): Promise<boolean> {\n if (!input.targets.length) return false\n const attachment = await findOneWithDecryption(\n this.em,\n Attachment,\n {\n id: input.attachmentId,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n },\n {},\n { tenantId: input.tenantId, organizationId: input.organizationId },\n )\n if (!attachment) return false\n\n const partition = await this.em.findOne(AttachmentPartition, {\n code: attachment.partitionCode,\n $or: [\n { tenantId: null, organizationId: null },\n { tenantId: input.tenantId, organizationId: input.organizationId },\n ],\n })\n if (!partition) return false\n const access = await evaluateAttachmentAccess({\n auth: input.auth, attachment, partition, action: 'read',\n context: this.resolveAccessContext?.(), requireAuthForPublic: true,\n })\n if (!access.ok) return false\n\n const metadata = readAttachmentMetadata(attachment.storageMetadata)\n return input.targets.some((target) => (\n (attachment.entityId === target.entityId && attachment.recordId === target.recordId)\n || metadata.assignments?.some((assignment) => (\n assignment.type === target.entityId && assignment.id === target.recordId\n )) === true\n ))\n }\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,6BAA6B;AACtC,SAAS,YAAY,2BAA2B;AAChD,SAAS,gCAA8D;AACvE,SAAS,8BAA8B;AAehC,MAAM,8BAA8B;AAAA,EACzC,YACmB,IACA,sBACjB;AAFiB;AACA;AAAA,EAChB;AAAA,EAEH,MAAM,sBAAsB,OAAsD;AAChF,QAAI,CAAC,MAAM,QAAQ,OAAQ,QAAO;AAClC,UAAM,aAAa,MAAM;AAAA,MACvB,KAAK;AAAA,MACL;AAAA,MACA;AAAA,QACE,IAAI,MAAM;AAAA,QACV,UAAU,MAAM;AAAA,QAChB,gBAAgB,MAAM;AAAA,MACxB;AAAA,MACA,CAAC;AAAA,MACD,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe;AAAA,IACnE;AACA,QAAI,CAAC,WAAY,QAAO;AAExB,UAAM,YAAY,MAAM,KAAK,GAAG,QAAQ,qBAAqB;AAAA,MAC3D,MAAM,WAAW;AAAA,MACjB,KAAK;AAAA,QACH,EAAE,UAAU,MAAM,gBAAgB,KAAK;AAAA,QACvC,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe;AAAA,MACnE;AAAA,IACF,CAAC;AACD,QAAI,CAAC,UAAW,QAAO;AACvB,UAAM,SAAS,MAAM,yBAAyB;AAAA,MAC5C,MAAM,MAAM;AAAA,MAAM;AAAA,MAAY;AAAA,MAAW,QAAQ;AAAA,MACjD,SAAS,KAAK,uBAAuB;AAAA,MAAG,sBAAsB;AAAA,IAChE,CAAC;AACD,QAAI,CAAC,OAAO,GAAI,QAAO;AAEvB,UAAM,WAAW,uBAAuB,WAAW,eAAe;AAClE,WAAO,MAAM,QAAQ,KAAK,CAAC,WACxB,WAAW,aAAa,OAAO,YAAY,WAAW,aAAa,OAAO,YACxE,SAAS,aAAa,KAAK,CAAC,eAC7B,WAAW,SAAS,OAAO,YAAY,WAAW,OAAO,OAAO,QACjE,MAAM,IACR;AAAA,EACH;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Migration } from "@mikro-orm/migrations";
|
|
2
|
+
class Migration20261001120000_attachments extends Migration {
|
|
3
|
+
up() {
|
|
4
|
+
this.addSql('alter table "attachment_partitions" add column if not exists "access_resolver_requirements" jsonb null;');
|
|
5
|
+
this.addSql(`
|
|
6
|
+
update attachment_partitions as partition
|
|
7
|
+
set access_resolver_requirements = coalesce(partition.access_resolver_requirements, '[]'::jsonb)
|
|
8
|
+
|| '[{"resolverId":"documents.document-attachments","targetEntity":"documents:document"}]'::jsonb,
|
|
9
|
+
updated_at = now()
|
|
10
|
+
where (partition.access_resolver_requirements is null or jsonb_typeof(partition.access_resolver_requirements) = 'array')
|
|
11
|
+
and not coalesce(partition.access_resolver_requirements, '[]'::jsonb)
|
|
12
|
+
@> '[{"resolverId":"documents.document-attachments","targetEntity":"documents:document"}]'::jsonb
|
|
13
|
+
and exists (
|
|
14
|
+
select 1 from attachments as attachment
|
|
15
|
+
where attachment.partition_code = partition.code
|
|
16
|
+
and (
|
|
17
|
+
btrim(attachment.entity_id) = 'documents:document'
|
|
18
|
+
or exists (
|
|
19
|
+
select 1
|
|
20
|
+
from jsonb_array_elements(
|
|
21
|
+
case jsonb_typeof(attachment.storage_metadata->'assignments')
|
|
22
|
+
when 'array' then attachment.storage_metadata->'assignments'
|
|
23
|
+
when 'object' then jsonb_build_array(attachment.storage_metadata->'assignments')
|
|
24
|
+
else '[]'::jsonb
|
|
25
|
+
end
|
|
26
|
+
) as assignment
|
|
27
|
+
where jsonb_typeof(assignment) = 'object'
|
|
28
|
+
and btrim(assignment->>'type') = 'documents:document'
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
Migration20261001120000_attachments
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=Migration20261001120000_attachments.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/attachments/migrations/Migration20261001120000_attachments.ts"],
|
|
4
|
+
"sourcesContent": ["import { Migration } from '@mikro-orm/migrations'\n\nexport class Migration20261001120000_attachments extends Migration {\n override up(): void {\n this.addSql('alter table \"attachment_partitions\" add column if not exists \"access_resolver_requirements\" jsonb null;')\n this.addSql(`\n update attachment_partitions as partition\n set access_resolver_requirements = coalesce(partition.access_resolver_requirements, '[]'::jsonb)\n || '[{\"resolverId\":\"documents.document-attachments\",\"targetEntity\":\"documents:document\"}]'::jsonb,\n updated_at = now()\n where (partition.access_resolver_requirements is null or jsonb_typeof(partition.access_resolver_requirements) = 'array')\n and not coalesce(partition.access_resolver_requirements, '[]'::jsonb)\n @> '[{\"resolverId\":\"documents.document-attachments\",\"targetEntity\":\"documents:document\"}]'::jsonb\n and exists (\n select 1 from attachments as attachment\n where attachment.partition_code = partition.code\n and (\n btrim(attachment.entity_id) = 'documents:document'\n or exists (\n select 1\n from jsonb_array_elements(\n case jsonb_typeof(attachment.storage_metadata->'assignments')\n when 'array' then attachment.storage_metadata->'assignments'\n when 'object' then jsonb_build_array(attachment.storage_metadata->'assignments')\n else '[]'::jsonb\n end\n ) as assignment\n where jsonb_typeof(assignment) = 'object'\n and btrim(assignment->>'type') = 'documents:document'\n )\n )\n );\n `)\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,4CAA4C,UAAU;AAAA,EACxD,KAAW;AAClB,SAAK,OAAO,yGAAyG;AACrH,SAAK,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KA2BX;AAAA,EACH;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
+
import { syncAttachmentAccessProtection } from "./lib/access-protection.js";
|
|
2
|
+
import { ensureDefaultPartitions } from "./lib/partitions.js";
|
|
1
3
|
const setup = {
|
|
4
|
+
async onTenantCreated({ em }) {
|
|
5
|
+
await ensureDefaultPartitions(em);
|
|
6
|
+
await syncAttachmentAccessProtection(em);
|
|
7
|
+
},
|
|
8
|
+
async seedDefaults({ em }) {
|
|
9
|
+
await ensureDefaultPartitions(em);
|
|
10
|
+
await syncAttachmentAccessProtection(em);
|
|
11
|
+
},
|
|
2
12
|
defaultRoleFeatures: {
|
|
3
13
|
admin: ["attachments.*", "attachments.view", "attachments.manage"],
|
|
4
14
|
employee: ["attachments.view"]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/attachments/setup.ts"],
|
|
4
|
-
"sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n admin: ['attachments.*', 'attachments.view', 'attachments.manage'],\n employee: ['attachments.view'],\n },\n}\n\nexport default setup\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport { syncAttachmentAccessProtection } from './lib/access-protection'\nimport { ensureDefaultPartitions } from './lib/partitions'\n\nexport const setup: ModuleSetupConfig = {\n async onTenantCreated({ em }) {\n await ensureDefaultPartitions(em)\n await syncAttachmentAccessProtection(em)\n },\n async seedDefaults({ em }) {\n await ensureDefaultPartitions(em)\n await syncAttachmentAccessProtection(em)\n },\n defaultRoleFeatures: {\n admin: ['attachments.*', 'attachments.view', 'attachments.manage'],\n employee: ['attachments.view'],\n },\n}\n\nexport default setup\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,sCAAsC;AAC/C,SAAS,+BAA+B;AAEjC,MAAM,QAA2B;AAAA,EACtC,MAAM,gBAAgB,EAAE,GAAG,GAAG;AAC5B,UAAM,wBAAwB,EAAE;AAChC,UAAM,+BAA+B,EAAE;AAAA,EACzC;AAAA,EACA,MAAM,aAAa,EAAE,GAAG,GAAG;AACzB,UAAM,wBAAwB,EAAE;AAChC,UAAM,+BAA+B,EAAE;AAAA,EACzC;AAAA,EACA,qBAAqB;AAAA,IACnB,OAAO,CAAC,iBAAiB,oBAAoB,oBAAoB;AAAA,IACjE,UAAU,CAAC,kBAAkB;AAAA,EAC/B;AACF;AAEA,IAAO,gBAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -89,6 +89,14 @@ const openApi = {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
},
|
|
92
|
+
403: {
|
|
93
|
+
description: "The stored action command is not declared by the notification type with the same action id and command, or the user lacks the features the declared action requires (`code: notification_action_forbidden`)",
|
|
94
|
+
content: {
|
|
95
|
+
"application/json": {
|
|
96
|
+
schema: actionConflictResponseSchema
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
92
100
|
400: {
|
|
93
101
|
description: "Action not found or failed",
|
|
94
102
|
content: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/notifications/api/%5Bid%5D/action/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { executeActionSchema } from '../../../data/validators'\nimport { actionConflictResponseSchema, actionResultResponseSchema, errorResponseSchema } from '../../openapi'\nimport {\n NOTIFICATION_RESOURCE_KIND,\n notificationCrudErrorResponse,\n notificationValidationErrorResponse,\n resolveNotificationContext,\n runGuardedNotificationWrite,\n} from '../../../lib/routeHelpers'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveNotificationActionHref, toSafeNotificationHref } from '../../../lib/safeHref'\n\nexport const metadata = {\n POST: { requireAuth: true },\n}\n\nexport async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {\n const { id } = await params\n const { service, scope, ctx } = await resolveNotificationContext(req)\n\n const body = await req.json().catch(() => ({}))\n const parsed = executeActionSchema.safeParse(body)\n if (!parsed.success) {\n return notificationValidationErrorResponse(parsed.error)\n }\n const input = parsed.data\n\n try {\n const guarded = await runGuardedNotificationWrite(\n ctx.container,\n scope,\n req,\n {\n resourceKind: NOTIFICATION_RESOURCE_KIND,\n resourceId: id,\n operation: 'custom',\n payload: input as Record<string, unknown>,\n },\n () => service.executeAction(id, input, scope),\n )\n if (!guarded.ok) return guarded.response\n const { notification, result } = guarded.result\n\n const action = notification.actionData?.actions?.find((a) => a.id === input.actionId)\n const href = toSafeNotificationHref(resolveNotificationActionHref(action?.href, notification.sourceEntityId)) ?? undefined\n\n return Response.json({\n ok: true,\n result,\n href,\n })\n } catch (error) {\n const errorResponse = notificationCrudErrorResponse(error)\n if (errorResponse) return errorResponse\n\n const { t } = await resolveTranslations()\n const fallback = t('notifications.error.action', 'Failed to execute action')\n const message = error instanceof Error && error.message ? error.message : fallback\n return Response.json({ error: message }, { status: 400 })\n }\n}\n\nexport const openApi = {\n POST: {\n summary: 'Execute notification action',\n tags: ['Notifications'],\n parameters: [\n {\n name: 'id',\n in: 'path',\n required: true,\n schema: { type: 'string', format: 'uuid' },\n },\n ],\n requestBody: {\n required: true,\n content: {\n 'application/json': {\n schema: executeActionSchema,\n },\n },\n },\n responses: {\n 200: {\n description: 'Action executed successfully',\n content: {\n 'application/json': {\n schema: actionResultResponseSchema,\n },\n },\n },\n 409: {\n description: 'The notification action was already executed (`code: notification_action_already_executed`), or a mutation guard or the action command reported a conflict',\n content: {\n 'application/json': {\n schema: actionConflictResponseSchema,\n },\n },\n },\n 400: {\n description: 'Action not found or failed',\n content: {\n 'application/json': {\n schema: errorResponseSchema,\n },\n },\n },\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,2BAA2B;AACpC,SAAS,8BAA8B,4BAA4B,2BAA2B;AAC9F;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,2BAA2B;AACpC,SAAS,+BAA+B,8BAA8B;AAE/D,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,KAAK;AAC5B;AAEA,eAAsB,KAAK,KAAc,EAAE,OAAO,GAAwC;AACxF,QAAM,EAAE,GAAG,IAAI,MAAM;AACrB,QAAM,EAAE,SAAS,OAAO,IAAI,IAAI,MAAM,2BAA2B,GAAG;AAEpE,QAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC,EAAE;AAC9C,QAAM,SAAS,oBAAoB,UAAU,IAAI;AACjD,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,oCAAoC,OAAO,KAAK;AAAA,EACzD;AACA,QAAM,QAAQ,OAAO;AAErB,MAAI;AACF,UAAM,UAAU,MAAM;AAAA,MACpB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,MACA;AAAA,QACE,cAAc;AAAA,QACd,YAAY;AAAA,QACZ,WAAW;AAAA,QACX,SAAS;AAAA,MACX;AAAA,MACA,MAAM,QAAQ,cAAc,IAAI,OAAO,KAAK;AAAA,IAC9C;AACA,QAAI,CAAC,QAAQ,GAAI,QAAO,QAAQ;AAChC,UAAM,EAAE,cAAc,OAAO,IAAI,QAAQ;AAEzC,UAAM,SAAS,aAAa,YAAY,SAAS,KAAK,CAAC,MAAM,EAAE,OAAO,MAAM,QAAQ;AACpF,UAAM,OAAO,uBAAuB,8BAA8B,QAAQ,MAAM,aAAa,cAAc,CAAC,KAAK;AAEjH,WAAO,SAAS,KAAK;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF,CAAC;AAAA,EACH,SAAS,OAAO;AACd,UAAM,gBAAgB,8BAA8B,KAAK;AACzD,QAAI,cAAe,QAAO;AAE1B,UAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,UAAM,WAAW,EAAE,8BAA8B,0BAA0B;AAC3E,UAAM,UAAU,iBAAiB,SAAS,MAAM,UAAU,MAAM,UAAU;AAC1E,WAAO,SAAS,KAAK,EAAE,OAAO,QAAQ,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1D;AACF;AAEO,MAAM,UAAU;AAAA,EACrB,MAAM;AAAA,IACJ,SAAS;AAAA,IACT,MAAM,CAAC,eAAe;AAAA,IACtB,YAAY;AAAA,MACV;AAAA,QACE,MAAM;AAAA,QACN,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,QAAQ,EAAE,MAAM,UAAU,QAAQ,OAAO;AAAA,MAC3C;AAAA,IACF;AAAA,IACA,aAAa;AAAA,MACX,UAAU;AAAA,MACV,SAAS;AAAA,QACP,oBAAoB;AAAA,UAClB,QAAQ;AAAA,QACV;AAAA,MACF;AAAA,IACF;AAAA,IACA,WAAW;AAAA,MACT,KAAK;AAAA,QACH,aAAa;AAAA,QACb,SAAS;AAAA,UACP,oBAAoB;AAAA,YAClB,QAAQ;AAAA,UACV;AAAA,QACF;AAAA,MACF;AAAA,MACA,KAAK;AAAA,QACH,aAAa;AAAA,QACb,SAAS;AAAA,UACP,oBAAoB;AAAA,YAClB,QAAQ;AAAA,UACV;AAAA,QACF;AAAA,MACF;AAAA,MACA,KAAK;AAAA,QACH,aAAa;AAAA,QACb,SAAS;AAAA,UACP,oBAAoB;AAAA,YAClB,QAAQ;AAAA,UACV;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;",
|
|
4
|
+
"sourcesContent": ["import { executeActionSchema } from '../../../data/validators'\nimport { actionConflictResponseSchema, actionResultResponseSchema, errorResponseSchema } from '../../openapi'\nimport {\n NOTIFICATION_RESOURCE_KIND,\n notificationCrudErrorResponse,\n notificationValidationErrorResponse,\n resolveNotificationContext,\n runGuardedNotificationWrite,\n} from '../../../lib/routeHelpers'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveNotificationActionHref, toSafeNotificationHref } from '../../../lib/safeHref'\n\nexport const metadata = {\n POST: { requireAuth: true },\n}\n\nexport async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {\n const { id } = await params\n const { service, scope, ctx } = await resolveNotificationContext(req)\n\n const body = await req.json().catch(() => ({}))\n const parsed = executeActionSchema.safeParse(body)\n if (!parsed.success) {\n return notificationValidationErrorResponse(parsed.error)\n }\n const input = parsed.data\n\n try {\n const guarded = await runGuardedNotificationWrite(\n ctx.container,\n scope,\n req,\n {\n resourceKind: NOTIFICATION_RESOURCE_KIND,\n resourceId: id,\n operation: 'custom',\n payload: input as Record<string, unknown>,\n },\n () => service.executeAction(id, input, scope),\n )\n if (!guarded.ok) return guarded.response\n const { notification, result } = guarded.result\n\n const action = notification.actionData?.actions?.find((a) => a.id === input.actionId)\n const href = toSafeNotificationHref(resolveNotificationActionHref(action?.href, notification.sourceEntityId)) ?? undefined\n\n return Response.json({\n ok: true,\n result,\n href,\n })\n } catch (error) {\n const errorResponse = notificationCrudErrorResponse(error)\n if (errorResponse) return errorResponse\n\n const { t } = await resolveTranslations()\n const fallback = t('notifications.error.action', 'Failed to execute action')\n const message = error instanceof Error && error.message ? error.message : fallback\n return Response.json({ error: message }, { status: 400 })\n }\n}\n\nexport const openApi = {\n POST: {\n summary: 'Execute notification action',\n tags: ['Notifications'],\n parameters: [\n {\n name: 'id',\n in: 'path',\n required: true,\n schema: { type: 'string', format: 'uuid' },\n },\n ],\n requestBody: {\n required: true,\n content: {\n 'application/json': {\n schema: executeActionSchema,\n },\n },\n },\n responses: {\n 200: {\n description: 'Action executed successfully',\n content: {\n 'application/json': {\n schema: actionResultResponseSchema,\n },\n },\n },\n 409: {\n description: 'The notification action was already executed (`code: notification_action_already_executed`), or a mutation guard or the action command reported a conflict',\n content: {\n 'application/json': {\n schema: actionConflictResponseSchema,\n },\n },\n },\n 403: {\n description: 'The stored action command is not declared by the notification type with the same action id and command, or the user lacks the features the declared action requires (`code: notification_action_forbidden`)',\n content: {\n 'application/json': {\n schema: actionConflictResponseSchema,\n },\n },\n },\n 400: {\n description: 'Action not found or failed',\n content: {\n 'application/json': {\n schema: errorResponseSchema,\n },\n },\n },\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
isCrudCacheEnabled,
|
|
8
8
|
resolveCrudCache
|
|
9
9
|
} from "@open-mercato/shared/lib/crud/cache";
|
|
10
|
+
import { NOTIFICATION_ACTION_COMMAND_NOT_ALLOWED_ERROR_CODE } from "@open-mercato/shared/modules/notifications/types";
|
|
10
11
|
import { Notification } from "../data/entities.js";
|
|
11
12
|
import { listNotificationsSchema, createNotificationSchema } from "../data/validators.js";
|
|
12
13
|
import { toNotificationDto } from "../lib/notificationMapper.js";
|
|
@@ -19,12 +20,14 @@ import {
|
|
|
19
20
|
NOTIFICATION_RESOURCE_KIND,
|
|
20
21
|
notificationCrudErrorResponse,
|
|
21
22
|
notificationValidationErrorResponse,
|
|
23
|
+
rejectNotificationActionCommands,
|
|
22
24
|
resolveGuardedNotificationContext,
|
|
23
25
|
resolveNotificationContext,
|
|
24
26
|
runGuardedNotificationWrite,
|
|
25
27
|
TENANT_SCOPE_REQUIRED_ERROR_CODE
|
|
26
28
|
} from "../lib/routeHelpers.js";
|
|
27
29
|
import {
|
|
30
|
+
actionConflictResponseSchema,
|
|
28
31
|
buildNotificationsCrudOpenApi,
|
|
29
32
|
createPagedListResponseSchema,
|
|
30
33
|
notificationItemSchema,
|
|
@@ -162,6 +165,8 @@ async function POST(req) {
|
|
|
162
165
|
if (!parsed.success) {
|
|
163
166
|
return notificationValidationErrorResponse(parsed.error);
|
|
164
167
|
}
|
|
168
|
+
const commandRejection = await rejectNotificationActionCommands(parsed.data);
|
|
169
|
+
if (commandRejection) return commandRejection;
|
|
165
170
|
try {
|
|
166
171
|
const guarded = await runGuardedNotificationWrite(
|
|
167
172
|
ctx.container,
|
|
@@ -193,6 +198,7 @@ const notificationsCrudOpenApi = buildNotificationsCrudOpenApi({
|
|
|
193
198
|
}
|
|
194
199
|
});
|
|
195
200
|
const notificationsCrudGet = notificationsCrudOpenApi.methods?.GET ?? {};
|
|
201
|
+
const notificationsCrudPost = notificationsCrudOpenApi.methods?.POST ?? {};
|
|
196
202
|
const openApi = {
|
|
197
203
|
...notificationsCrudOpenApi,
|
|
198
204
|
methods: {
|
|
@@ -207,6 +213,17 @@ const openApi = {
|
|
|
207
213
|
schema: scopeErrorResponseSchema
|
|
208
214
|
}
|
|
209
215
|
]
|
|
216
|
+
},
|
|
217
|
+
POST: {
|
|
218
|
+
...notificationsCrudPost,
|
|
219
|
+
errors: [
|
|
220
|
+
...notificationsCrudPost.errors ?? [],
|
|
221
|
+
{
|
|
222
|
+
status: 400,
|
|
223
|
+
description: `Invalid payload, or an action carries a \`commandId\` (code: ${NOTIFICATION_ACTION_COMMAND_NOT_ALLOWED_ERROR_CODE})`,
|
|
224
|
+
schema: actionConflictResponseSchema
|
|
225
|
+
}
|
|
226
|
+
]
|
|
210
227
|
}
|
|
211
228
|
}
|
|
212
229
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/notifications/api/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { createHash } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport {\n buildCollectionTags,\n debugCrudCache,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'\nimport { Notification } from '../data/entities'\nimport { listNotificationsSchema, createNotificationSchema } from '../data/validators'\nimport { toNotificationDto } from '../lib/notificationMapper'\nimport { inAppVisibleFilter } from '../lib/notificationVisibility'\nimport {\n buildNotificationReadScopeWhere,\n getNotificationReadScopeTagOrganizationIds,\n} from '../lib/notificationScope'\nimport {\n NOTIFICATION_RESOURCE_KIND,\n notificationCrudErrorResponse,\n notificationValidationErrorResponse,\n resolveGuardedNotificationContext,\n resolveNotificationContext,\n runGuardedNotificationWrite,\n TENANT_SCOPE_REQUIRED_ERROR_CODE,\n} from '../lib/routeHelpers'\nimport {\n buildNotificationsCrudOpenApi,\n createPagedListResponseSchema,\n notificationItemSchema,\n scopeErrorResponseSchema,\n} from './openapi'\n\nexport const metadata = {\n GET: { requireAuth: true },\n POST: { requireAuth: true, requireFeatures: ['notifications.create'] },\n}\n\nconst NOTIFICATIONS_LIST_TTL_MS = 10_000\n// v2: the key gained the selected-organization scope (#4840). Bumping the version\n// retires v1 entries, which were shared across organizations for the same user.\nconst NOTIFICATIONS_LIST_CACHE_VERSION = 2\nconst NOTIFICATIONS_LIST_RESOURCE = 'notifications.notification'\n\ntype NotificationsListPayload = {\n items: ReturnType<typeof toNotificationDto>[]\n total: number\n page: number\n pageSize: number\n totalPages: number\n}\n\nexport function buildNotificationsListCacheKey(\n userId: string,\n organizationScope: { organizationId: string | null; organizationIds: string[] },\n input: z.infer<typeof listNotificationsSchema>,\n): string {\n const normalizedIds = Array.from(new Set(\n organizationScope.organizationIds.filter((value) => value.trim().length > 0),\n )).sort((left, right) => left.localeCompare(right))\n const scopeKey = normalizedIds.length === 0\n ? 'no-access'\n : `${organizationScope.organizationId ?? 'none'}:scope=${createHash('sha256')\n .update(normalizedIds.join('\\0'))\n .digest('hex')\n .slice(0, 16)}`\n const filterSignature = JSON.stringify({\n status: Array.isArray(input.status)\n ? [...input.status].sort((left, right) => left.localeCompare(right))\n : input.status ?? null,\n type: input.type ?? null,\n severity: input.severity ?? null,\n sourceEntityType: input.sourceEntityType ?? null,\n sourceEntityId: input.sourceEntityId ?? null,\n since: input.since ?? null,\n page: input.page,\n pageSize: input.pageSize,\n })\n return `notifications:list:v${NOTIFICATIONS_LIST_CACHE_VERSION}:u=${userId}:org=${scopeKey}:filters=${filterSignature}`\n}\n\nfunction isNotificationsListPayload(value: unknown): value is NotificationsListPayload {\n if (!value || typeof value !== 'object') return false\n const payload = value as Partial<NotificationsListPayload>\n return (\n Array.isArray(payload.items)\n && typeof payload.total === 'number'\n && typeof payload.page === 'number'\n && typeof payload.pageSize === 'number'\n && typeof payload.totalPages === 'number'\n )\n}\n\nexport async function GET(req: Request) {\n const resolved = await resolveGuardedNotificationContext(req)\n if (!resolved.ok) return resolved.response\n const { ctx, scope } = resolved\n const em = ctx.container.resolve('em') as EntityManager\n\n const url = new URL(req.url)\n const queryParams = Object.fromEntries(url.searchParams.entries())\n const input = listNotificationsSchema.parse(queryParams)\n const userId = scope.userId\n // Mirrors api/unread-count/route.ts: unrestricted and omitted legacy organization\n // scopes cannot be safely tagged for invalidation, because organization-specific\n // writes only invalidate their own collection tag. Leave those scopes uncached\n // rather than serving a stale tenant-wide list until the TTL expires.\n const cacheableOrganizationIds = Array.isArray(scope.organizationIds)\n ? scope.organizationIds\n : null\n const cache = userId && cacheableOrganizationIds && isCrudCacheEnabled()\n ? resolveCrudCache(ctx.container)\n : null\n const cacheKey = cache && userId && cacheableOrganizationIds\n ? buildNotificationsListCacheKey(\n userId,\n { organizationId: scope.organizationId, organizationIds: cacheableOrganizationIds },\n input,\n )\n : null\n\n if (cache && cacheKey) {\n try {\n const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))\n if (isNotificationsListPayload(cached)) {\n return Response.json(cached)\n }\n } catch (error) {\n debugCrudCache('notifications-list-cache-read-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n const filters: Record<string, unknown> = {\n recipientUserId: userId,\n tenantId: scope.tenantId,\n // Read scope AND in-app visibility: both fragments carry their own `$or`, so they must be\n // AND-composed instead of spread (a spread would silently drop one of them).\n // Hidden rows are those not delivered to the in-app channel (push-only / opted-out); they remain as records.\n $and: [\n buildNotificationReadScopeWhere(scope),\n inAppVisibleFilter(),\n ],\n }\n\n if (input.status) {\n filters.status = Array.isArray(input.status) ? { $in: input.status } : input.status\n } else {\n filters.status = { $ne: 'dismissed' }\n }\n if (input.type) {\n filters.type = input.type\n }\n if (input.severity) {\n filters.severity = input.severity\n }\n if (input.sourceEntityType) {\n filters.sourceEntityType = input.sourceEntityType\n }\n if (input.sourceEntityId) {\n filters.sourceEntityId = input.sourceEntityId\n }\n if (input.since) {\n filters.createdAt = { $gt: new Date(input.since) }\n }\n\n const [notifications, total] = await Promise.all([\n em.find(Notification, filters, {\n orderBy: { createdAt: 'desc' },\n limit: input.pageSize,\n offset: (input.page - 1) * input.pageSize,\n }),\n em.count(Notification, filters),\n ])\n\n const items = notifications.map(toNotificationDto)\n\n const payload: NotificationsListPayload = {\n items,\n total,\n page: input.page,\n pageSize: input.pageSize,\n totalPages: Math.ceil(total / input.pageSize),\n }\n\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(scope.tenantId, () =>\n cache.set(cacheKey, payload, {\n ttl: NOTIFICATIONS_LIST_TTL_MS,\n tags: buildCollectionTags(\n NOTIFICATIONS_LIST_RESOURCE,\n scope.tenantId,\n getNotificationReadScopeTagOrganizationIds(scope),\n ),\n }),\n )\n } catch (error) {\n debugCrudCache('notifications-list-cache-write-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n return Response.json(payload)\n}\n\nexport async function POST(req: Request) {\n const { service, scope, ctx } = await resolveNotificationContext(req)\n\n const body = await req.json().catch(() => ({}))\n const parsed = createNotificationSchema.safeParse(body)\n if (!parsed.success) {\n return notificationValidationErrorResponse(parsed.error)\n }\n\n try {\n const guarded = await runGuardedNotificationWrite(\n ctx.container,\n scope,\n req,\n {\n resourceKind: NOTIFICATION_RESOURCE_KIND,\n operation: 'create',\n payload: parsed.data as Record<string, unknown>,\n },\n () => service.create(parsed.data, scope),\n )\n if (!guarded.ok) return guarded.response\n\n return Response.json({ id: guarded.result.id }, { status: 201 })\n } catch (error) {\n const errorResponse = notificationCrudErrorResponse(error)\n if (errorResponse) return errorResponse\n throw error\n }\n}\n\nconst notificationsCrudOpenApi = buildNotificationsCrudOpenApi({\n resourceName: 'Notification',\n querySchema: listNotificationsSchema,\n listResponseSchema: createPagedListResponseSchema(notificationItemSchema),\n create: {\n schema: createNotificationSchema,\n responseSchema: z.object({ id: z.string().uuid() }),\n description: 'Creates a notification for a user.',\n },\n})\n\nconst notificationsCrudGet = notificationsCrudOpenApi.methods?.GET ?? {}\n\n// The CRUD factory documents errors only for DELETE, and POST already gets an auto-generated 403\n// from its `requireFeatures` metadata. GET is authenticated-only, so its tenant-scope rejection has\n// to be declared here to reach the generated spec.\nexport const openApi: OpenApiRouteDoc = {\n ...notificationsCrudOpenApi,\n methods: {\n ...notificationsCrudOpenApi.methods,\n GET: {\n ...notificationsCrudGet,\n errors: [\n ...(notificationsCrudGet.errors ?? []),\n {\n status: 403,\n description: `Request could not be resolved to a tenant scope (code: ${TENANT_SCOPE_REQUIRED_ERROR_CODE})`,\n schema: scopeErrorResponseSchema,\n },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { createHash } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport {\n buildCollectionTags,\n debugCrudCache,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'\nimport { NOTIFICATION_ACTION_COMMAND_NOT_ALLOWED_ERROR_CODE } from '@open-mercato/shared/modules/notifications/types'\nimport { Notification } from '../data/entities'\nimport { listNotificationsSchema, createNotificationSchema } from '../data/validators'\nimport { toNotificationDto } from '../lib/notificationMapper'\nimport { inAppVisibleFilter } from '../lib/notificationVisibility'\nimport {\n buildNotificationReadScopeWhere,\n getNotificationReadScopeTagOrganizationIds,\n} from '../lib/notificationScope'\nimport {\n NOTIFICATION_RESOURCE_KIND,\n notificationCrudErrorResponse,\n notificationValidationErrorResponse,\n rejectNotificationActionCommands,\n resolveGuardedNotificationContext,\n resolveNotificationContext,\n runGuardedNotificationWrite,\n TENANT_SCOPE_REQUIRED_ERROR_CODE,\n} from '../lib/routeHelpers'\nimport {\n actionConflictResponseSchema,\n buildNotificationsCrudOpenApi,\n createPagedListResponseSchema,\n notificationItemSchema,\n scopeErrorResponseSchema,\n} from './openapi'\n\nexport const metadata = {\n GET: { requireAuth: true },\n POST: { requireAuth: true, requireFeatures: ['notifications.create'] },\n}\n\nconst NOTIFICATIONS_LIST_TTL_MS = 10_000\n// v2: the key gained the selected-organization scope (#4840). Bumping the version\n// retires v1 entries, which were shared across organizations for the same user.\nconst NOTIFICATIONS_LIST_CACHE_VERSION = 2\nconst NOTIFICATIONS_LIST_RESOURCE = 'notifications.notification'\n\ntype NotificationsListPayload = {\n items: ReturnType<typeof toNotificationDto>[]\n total: number\n page: number\n pageSize: number\n totalPages: number\n}\n\nexport function buildNotificationsListCacheKey(\n userId: string,\n organizationScope: { organizationId: string | null; organizationIds: string[] },\n input: z.infer<typeof listNotificationsSchema>,\n): string {\n const normalizedIds = Array.from(new Set(\n organizationScope.organizationIds.filter((value) => value.trim().length > 0),\n )).sort((left, right) => left.localeCompare(right))\n const scopeKey = normalizedIds.length === 0\n ? 'no-access'\n : `${organizationScope.organizationId ?? 'none'}:scope=${createHash('sha256')\n .update(normalizedIds.join('\\0'))\n .digest('hex')\n .slice(0, 16)}`\n const filterSignature = JSON.stringify({\n status: Array.isArray(input.status)\n ? [...input.status].sort((left, right) => left.localeCompare(right))\n : input.status ?? null,\n type: input.type ?? null,\n severity: input.severity ?? null,\n sourceEntityType: input.sourceEntityType ?? null,\n sourceEntityId: input.sourceEntityId ?? null,\n since: input.since ?? null,\n page: input.page,\n pageSize: input.pageSize,\n })\n return `notifications:list:v${NOTIFICATIONS_LIST_CACHE_VERSION}:u=${userId}:org=${scopeKey}:filters=${filterSignature}`\n}\n\nfunction isNotificationsListPayload(value: unknown): value is NotificationsListPayload {\n if (!value || typeof value !== 'object') return false\n const payload = value as Partial<NotificationsListPayload>\n return (\n Array.isArray(payload.items)\n && typeof payload.total === 'number'\n && typeof payload.page === 'number'\n && typeof payload.pageSize === 'number'\n && typeof payload.totalPages === 'number'\n )\n}\n\nexport async function GET(req: Request) {\n const resolved = await resolveGuardedNotificationContext(req)\n if (!resolved.ok) return resolved.response\n const { ctx, scope } = resolved\n const em = ctx.container.resolve('em') as EntityManager\n\n const url = new URL(req.url)\n const queryParams = Object.fromEntries(url.searchParams.entries())\n const input = listNotificationsSchema.parse(queryParams)\n const userId = scope.userId\n // Mirrors api/unread-count/route.ts: unrestricted and omitted legacy organization\n // scopes cannot be safely tagged for invalidation, because organization-specific\n // writes only invalidate their own collection tag. Leave those scopes uncached\n // rather than serving a stale tenant-wide list until the TTL expires.\n const cacheableOrganizationIds = Array.isArray(scope.organizationIds)\n ? scope.organizationIds\n : null\n const cache = userId && cacheableOrganizationIds && isCrudCacheEnabled()\n ? resolveCrudCache(ctx.container)\n : null\n const cacheKey = cache && userId && cacheableOrganizationIds\n ? buildNotificationsListCacheKey(\n userId,\n { organizationId: scope.organizationId, organizationIds: cacheableOrganizationIds },\n input,\n )\n : null\n\n if (cache && cacheKey) {\n try {\n const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))\n if (isNotificationsListPayload(cached)) {\n return Response.json(cached)\n }\n } catch (error) {\n debugCrudCache('notifications-list-cache-read-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n const filters: Record<string, unknown> = {\n recipientUserId: userId,\n tenantId: scope.tenantId,\n // Read scope AND in-app visibility: both fragments carry their own `$or`, so they must be\n // AND-composed instead of spread (a spread would silently drop one of them).\n // Hidden rows are those not delivered to the in-app channel (push-only / opted-out); they remain as records.\n $and: [\n buildNotificationReadScopeWhere(scope),\n inAppVisibleFilter(),\n ],\n }\n\n if (input.status) {\n filters.status = Array.isArray(input.status) ? { $in: input.status } : input.status\n } else {\n filters.status = { $ne: 'dismissed' }\n }\n if (input.type) {\n filters.type = input.type\n }\n if (input.severity) {\n filters.severity = input.severity\n }\n if (input.sourceEntityType) {\n filters.sourceEntityType = input.sourceEntityType\n }\n if (input.sourceEntityId) {\n filters.sourceEntityId = input.sourceEntityId\n }\n if (input.since) {\n filters.createdAt = { $gt: new Date(input.since) }\n }\n\n const [notifications, total] = await Promise.all([\n em.find(Notification, filters, {\n orderBy: { createdAt: 'desc' },\n limit: input.pageSize,\n offset: (input.page - 1) * input.pageSize,\n }),\n em.count(Notification, filters),\n ])\n\n const items = notifications.map(toNotificationDto)\n\n const payload: NotificationsListPayload = {\n items,\n total,\n page: input.page,\n pageSize: input.pageSize,\n totalPages: Math.ceil(total / input.pageSize),\n }\n\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(scope.tenantId, () =>\n cache.set(cacheKey, payload, {\n ttl: NOTIFICATIONS_LIST_TTL_MS,\n tags: buildCollectionTags(\n NOTIFICATIONS_LIST_RESOURCE,\n scope.tenantId,\n getNotificationReadScopeTagOrganizationIds(scope),\n ),\n }),\n )\n } catch (error) {\n debugCrudCache('notifications-list-cache-write-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n return Response.json(payload)\n}\n\nexport async function POST(req: Request) {\n const { service, scope, ctx } = await resolveNotificationContext(req)\n\n const body = await req.json().catch(() => ({}))\n const parsed = createNotificationSchema.safeParse(body)\n if (!parsed.success) {\n return notificationValidationErrorResponse(parsed.error)\n }\n const commandRejection = await rejectNotificationActionCommands(parsed.data)\n if (commandRejection) return commandRejection\n\n try {\n const guarded = await runGuardedNotificationWrite(\n ctx.container,\n scope,\n req,\n {\n resourceKind: NOTIFICATION_RESOURCE_KIND,\n operation: 'create',\n payload: parsed.data as Record<string, unknown>,\n },\n () => service.create(parsed.data, scope),\n )\n if (!guarded.ok) return guarded.response\n\n return Response.json({ id: guarded.result.id }, { status: 201 })\n } catch (error) {\n const errorResponse = notificationCrudErrorResponse(error)\n if (errorResponse) return errorResponse\n throw error\n }\n}\n\nconst notificationsCrudOpenApi = buildNotificationsCrudOpenApi({\n resourceName: 'Notification',\n querySchema: listNotificationsSchema,\n listResponseSchema: createPagedListResponseSchema(notificationItemSchema),\n create: {\n schema: createNotificationSchema,\n responseSchema: z.object({ id: z.string().uuid() }),\n description: 'Creates a notification for a user.',\n },\n})\n\nconst notificationsCrudGet = notificationsCrudOpenApi.methods?.GET ?? {}\nconst notificationsCrudPost = notificationsCrudOpenApi.methods?.POST ?? {}\n\n// The CRUD factory documents errors only for DELETE, and POST already gets an auto-generated 403\n// from its `requireFeatures` metadata. GET is authenticated-only, so its tenant-scope rejection has\n// to be declared here to reach the generated spec.\nexport const openApi: OpenApiRouteDoc = {\n ...notificationsCrudOpenApi,\n methods: {\n ...notificationsCrudOpenApi.methods,\n GET: {\n ...notificationsCrudGet,\n errors: [\n ...(notificationsCrudGet.errors ?? []),\n {\n status: 403,\n description: `Request could not be resolved to a tenant scope (code: ${TENANT_SCOPE_REQUIRED_ERROR_CODE})`,\n schema: scopeErrorResponseSchema,\n },\n ],\n },\n POST: {\n ...notificationsCrudPost,\n errors: [\n ...(notificationsCrudPost.errors ?? []),\n {\n status: 400,\n description: `Invalid payload, or an action carries a \\`commandId\\` (code: ${NOTIFICATION_ACTION_COMMAND_NOT_ALLOWED_ERROR_CODE})`,\n schema: actionConflictResponseSchema,\n },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -12,7 +12,7 @@ const notificationActionSchema = z.object({
|
|
|
12
12
|
labelKey: z.string().optional(),
|
|
13
13
|
variant: z.enum(["default", "secondary", "destructive", "outline", "ghost"]).optional(),
|
|
14
14
|
icon: z.string().optional(),
|
|
15
|
-
commandId: z.string().optional(),
|
|
15
|
+
commandId: z.string().optional().describe("Refused by the HTTP create routes (400 notification_action_command_not_allowed); command actions are attached by server code from the notification type definition"),
|
|
16
16
|
href: safeRelativeHrefSchema.optional(),
|
|
17
17
|
confirmRequired: z.boolean().optional(),
|
|
18
18
|
confirmMessage: z.string().optional()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/notifications/data/validators.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\nimport { isSafeNotificationHref } from '../lib/safeHref'\n\nexport const notificationStatusSchema = z.enum(['unread', 'read', 'actioned', 'dismissed'])\nexport const notificationSeveritySchema = z.enum(['info', 'warning', 'success', 'error'])\n\nexport const safeRelativeHrefSchema = z.string().min(1).refine(\n (href) => isSafeNotificationHref(href),\n { message: 'Href must be a same-origin relative path starting with /' }\n)\n\nexport const notificationActionSchema = z.object({\n id: z.string().min(1),\n label: z.string().min(1),\n labelKey: z.string().optional(),\n variant: z.enum(['default', 'secondary', 'destructive', 'outline', 'ghost']).optional(),\n icon: z.string().optional(),\n commandId: z.string().optional(),\n href: safeRelativeHrefSchema.optional(),\n confirmRequired: z.boolean().optional(),\n confirmMessage: z.string().optional(),\n})\n\n/**\n * Flat per-provider push customization. Known keys are mapped onto each provider's native message\n * by the push adapters; unknown keys are accepted (passthrough) for providers that understand them.\n * See `communication_channels/lib/push-envelope.ts` (`PushOptions`).\n */\nexport const pushOptionsSchema = z\n .object({\n sound: z.string().optional(),\n badge: z.number().int().nonnegative().optional(),\n image: z.string().optional(),\n priority: z.enum(['high', 'normal']).optional(),\n channelId: z.string().optional(),\n body: z.string().optional(),\n })\n .catchall(z.unknown())\n\nconst baseNotificationFieldsSchema = z.object({\n type: z.string().min(1).max(100),\n titleKey: z.string().min(1).max(200).optional(),\n bodyKey: z.string().min(1).max(200).optional(),\n titleVariables: z.record(z.string(), z.string()).optional(),\n bodyVariables: z.record(z.string(), z.string()).optional(),\n title: z.string().min(1).max(500).optional(),\n body: z.string().max(2000).optional(),\n icon: z.string().max(100).optional(),\n severity: notificationSeveritySchema.optional().default('info'),\n actions: z.array(notificationActionSchema).optional(),\n primaryActionId: z.string().optional(),\n sourceModule: z.string().optional(),\n sourceEntityType: z.string().optional(),\n sourceEntityId: z.string().uuid().optional(),\n linkHref: safeRelativeHrefSchema.optional(),\n groupKey: z.string().optional(),\n expiresAt: z.string().datetime().optional(),\n // Arbitrary app-readable key/values delivered with the push (and visible to in-app clients).\n data: z.record(z.string(), z.string()).optional(),\n // Per-provider push customization (sound, badge, image, priority, channelId, body override).\n pushOptions: pushOptionsSchema.optional(),\n // Explicit per-send channel target. When present, delivery is restricted to these channels\n // (intersected with the type's eligibility, the registered strategies, and the recipient's\n // preferences). Absent \u2192 all registered channels (pre-Phase-7 behavior). Must be non-empty when\n // provided: an empty array would resolve to zero deliverable channels and silently black-hole the\n // notification (invisible + undelivered) \u2014 omit the field instead to target every channel.\n channels: z.array(z.string().min(1).max(32)).min(1).optional(),\n})\n\nconst titleRequiredRefinement = {\n refine: (data: { titleKey?: string; title?: string }) => data.titleKey || data.title,\n message: 'Either titleKey or title must be provided',\n} as const\n\nexport const createNotificationSchema = baseNotificationFieldsSchema\n .extend({ recipientUserId: z.string().uuid() })\n .refine(titleRequiredRefinement.refine, { message: titleRequiredRefinement.message })\n\nexport const createBatchNotificationSchema = baseNotificationFieldsSchema\n .extend({ recipientUserIds: z.array(z.string().uuid()).min(1).max(1000) })\n .refine(titleRequiredRefinement.refine, { message: titleRequiredRefinement.message })\n\nexport const createRoleNotificationSchema = baseNotificationFieldsSchema\n .extend({ roleId: z.string().uuid() })\n .refine(titleRequiredRefinement.refine, { message: titleRequiredRefinement.message })\n\nexport const createFeatureNotificationSchema = baseNotificationFieldsSchema\n .extend({ requiredFeature: z.string().min(1).max(100) })\n .refine(titleRequiredRefinement.refine, { message: titleRequiredRefinement.message })\n\nexport const listNotificationsSchema = z.object({\n status: z.union([notificationStatusSchema, z.array(notificationStatusSchema)]).optional(),\n type: z.string().optional(),\n severity: notificationSeveritySchema.optional(),\n sourceEntityType: z.string().optional(),\n sourceEntityId: z.string().uuid().optional(),\n since: z.string().datetime().optional(),\n page: z.coerce.number().int().min(1).optional().default(1),\n pageSize: z.coerce.number().int().min(1).max(100).optional().default(20),\n})\n\nexport const executeActionSchema = z.object({\n actionId: z.string().min(1),\n payload: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport const restoreNotificationSchema = z.object({\n status: z.enum(['read', 'unread']).optional(),\n})\n\nconst notificationDeliveryStrategySchema = z.object({\n enabled: z.boolean().optional(),\n})\n\nconst notificationDeliveryEmailSchema = notificationDeliveryStrategySchema.extend({\n from: z.string().trim().min(1).optional(),\n replyTo: z.string().trim().min(1).optional(),\n subjectPrefix: z.string().trim().min(1).optional(),\n})\n\nconst notificationDeliveryCustomSchema = notificationDeliveryStrategySchema.extend({\n config: z.unknown().optional(),\n})\n\nexport const notificationDeliveryConfigSchema = z.object({\n appUrl: z.string().url().optional(),\n panelPath: safeRelativeHrefSchema.optional(),\n strategies: z.object({\n database: notificationDeliveryStrategySchema.optional(),\n email: notificationDeliveryEmailSchema.optional(),\n custom: z.record(z.string(), notificationDeliveryCustomSchema).optional(),\n }).optional(),\n})\n\nexport type CreateNotificationInput = z.infer<typeof createNotificationSchema>\nexport type CreateBatchNotificationInput = z.infer<typeof createBatchNotificationSchema>\nexport type CreateRoleNotificationInput = z.infer<typeof createRoleNotificationSchema>\nexport type CreateFeatureNotificationInput = z.infer<typeof createFeatureNotificationSchema>\nexport type ListNotificationsInput = z.infer<typeof listNotificationsSchema>\nexport type ExecuteActionInput = z.infer<typeof executeActionSchema>\nexport type NotificationDeliveryConfigInput = z.infer<typeof notificationDeliveryConfigSchema>\n\n// Notification type catalogue (DB-mirrored read model)\nexport const notificationTypeItemSchema = z.object({\n id: z.string(),\n labelKey: z.string(),\n descriptionKey: z.string().nullable().optional(),\n // Free-form grouping label so a client can list/group types under a heading. Defaults to\n // the prefix before the first dot in the type id (`sales.order.created` \u2192 `sales`), so in\n // practice it is always populated; kept nullable to avoid narrowing the response contract.\n // This is the STABLE grouping key \u2014 clients group on it and display `categoryLabel`.\n category: z.string().nullable().optional(),\n // Localized heading for `category`, resolved server-side from\n // `notifications.categories.<key>` with a humanized fallback. Display-only: grouping on it\n // re-partitions the list whenever the locale changes.\n categoryLabel: z.string().nullable(),\n // Server-resolved display strings so clients without the app dictionary (the mobile app)\n // need no i18n bundle. `null` iff the corresponding `*Key` is null.\n label: z.string().nullable(),\n description: z.string().nullable(),\n // When true the type is delivered as a silent / content-available push.\n silent: z.boolean(),\n // Effective \"cannot be opted out of\" flag (operator override ?? code-declared); a preferences\n // UI should lock the type on when true.\n nonOptOut: z.boolean(),\n // Effective channel eligibility for the caller's tenant (stored override ?? code-declared\n // `type.channels`). `null` = no restriction (every registered channel). A channel outside\n // the set never delivers for this type in the tenant and users cannot opt into it \u2014\n // preference UIs lock the cell off.\n channels: z.array(z.string()).nullable(),\n // The raw tenant-stored override (`notification_type_overrides.channels`; `null` = inherit\n // code). Admin editors base PATCH payloads on the EFFECTIVE set, but this shows whether an\n // override exists at all.\n storedChannels: z.array(z.string()).nullable(),\n // The raw tenant-stored `nonOptOut` override (`null` = inherit the code-declared flag).\n storedNonOptOut: z.boolean().nullable(),\n // Optimistic-lock version of the tenant's override row (ISO timestamp; `null` when the\n // tenant stores no override yet). PATCH callers echo it back via the standard\n // `x-om-ext-optimistic-lock-expected-updated-at` header.\n updatedAt: z.string().nullable(),\n})\n\n// PATCH /api/notifications/types \u2014 operator override of a type's channel eligibility and/or\n// nonOptOut governance. Omitted fields stay untouched; `null` clears the stored override so\n// the code declaration applies again.\nexport const updateNotificationTypeSchema = z\n .object({\n id: z.string().min(1),\n // Mirror the create guard: a non-empty array restricts eligibility, `null` clears the override\n // (code declaration reapplies). An empty array is rejected \u2014 it would resolve to zero deliverable\n // channels and silently black-hole the type (invisible + undelivered); clear with `null` instead.\n channels: z.array(z.string().min(1)).min(1).nullable().optional(),\n nonOptOut: z.boolean().nullable().optional(),\n })\n .refine((value) => value.channels !== undefined || value.nonOptOut !== undefined, {\n message: 'At least one of channels or nonOptOut must be provided',\n })\n\n// Per-user channel preferences\nexport const notificationPreferenceItemSchema = z.object({\n notificationTypeId: z.string(),\n channel: z.string(),\n enabled: z.boolean(),\n})\n\nexport const updatePreferencesSchema = z.object({\n preferences: z\n .array(\n z.object({\n notificationTypeId: z.string().trim().min(1).max(128),\n channel: z.string().trim().min(1).max(32),\n enabled: z.boolean(),\n }),\n )\n .max(500),\n})\n\n// Admin-on-behalf preference management (target a specific user)\nexport const adminPreferencesQuerySchema = z.object({\n userId: z.string().uuid(),\n})\n\nexport const adminUpdatePreferencesSchema = updatePreferencesSchema.extend({\n userId: z.string().uuid(),\n})\n\nexport type NotificationTypeItem = z.infer<typeof notificationTypeItemSchema>\nexport type UpdateNotificationTypeInput = z.infer<typeof updateNotificationTypeSchema>\nexport type NotificationPreferenceItem = z.infer<typeof notificationPreferenceItemSchema>\nexport type UpdatePreferencesInput = z.infer<typeof updatePreferencesSchema>\nexport type AdminUpdatePreferencesInput = z.infer<typeof adminUpdatePreferencesSchema>\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAClB,SAAS,8BAA8B;AAEhC,MAAM,2BAA2B,EAAE,KAAK,CAAC,UAAU,QAAQ,YAAY,WAAW,CAAC;AACnF,MAAM,6BAA6B,EAAE,KAAK,CAAC,QAAQ,WAAW,WAAW,OAAO,CAAC;AAEjF,MAAM,yBAAyB,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE;AAAA,EACtD,CAAC,SAAS,uBAAuB,IAAI;AAAA,EACrC,EAAE,SAAS,2DAA2D;AACxE;AAEO,MAAM,2BAA2B,EAAE,OAAO;AAAA,EAC/C,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACpB,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACvB,UAAU,EAAE,OAAO,EAAE,SAAS;AAAA,EAC9B,SAAS,EAAE,KAAK,CAAC,WAAW,aAAa,eAAe,WAAW,OAAO,CAAC,EAAE,SAAS;AAAA,EACtF,MAAM,EAAE,OAAO,EAAE,SAAS;AAAA,EAC1B,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport { isSafeNotificationHref } from '../lib/safeHref'\n\nexport const notificationStatusSchema = z.enum(['unread', 'read', 'actioned', 'dismissed'])\nexport const notificationSeveritySchema = z.enum(['info', 'warning', 'success', 'error'])\n\nexport const safeRelativeHrefSchema = z.string().min(1).refine(\n (href) => isSafeNotificationHref(href),\n { message: 'Href must be a same-origin relative path starting with /' }\n)\n\nexport const notificationActionSchema = z.object({\n id: z.string().min(1),\n label: z.string().min(1),\n labelKey: z.string().optional(),\n variant: z.enum(['default', 'secondary', 'destructive', 'outline', 'ghost']).optional(),\n icon: z.string().optional(),\n commandId: z.string().optional().describe('Refused by the HTTP create routes (400 notification_action_command_not_allowed); command actions are attached by server code from the notification type definition'),\n href: safeRelativeHrefSchema.optional(),\n confirmRequired: z.boolean().optional(),\n confirmMessage: z.string().optional(),\n})\n\n/**\n * Flat per-provider push customization. Known keys are mapped onto each provider's native message\n * by the push adapters; unknown keys are accepted (passthrough) for providers that understand them.\n * See `communication_channels/lib/push-envelope.ts` (`PushOptions`).\n */\nexport const pushOptionsSchema = z\n .object({\n sound: z.string().optional(),\n badge: z.number().int().nonnegative().optional(),\n image: z.string().optional(),\n priority: z.enum(['high', 'normal']).optional(),\n channelId: z.string().optional(),\n body: z.string().optional(),\n })\n .catchall(z.unknown())\n\nconst baseNotificationFieldsSchema = z.object({\n type: z.string().min(1).max(100),\n titleKey: z.string().min(1).max(200).optional(),\n bodyKey: z.string().min(1).max(200).optional(),\n titleVariables: z.record(z.string(), z.string()).optional(),\n bodyVariables: z.record(z.string(), z.string()).optional(),\n title: z.string().min(1).max(500).optional(),\n body: z.string().max(2000).optional(),\n icon: z.string().max(100).optional(),\n severity: notificationSeveritySchema.optional().default('info'),\n actions: z.array(notificationActionSchema).optional(),\n primaryActionId: z.string().optional(),\n sourceModule: z.string().optional(),\n sourceEntityType: z.string().optional(),\n sourceEntityId: z.string().uuid().optional(),\n linkHref: safeRelativeHrefSchema.optional(),\n groupKey: z.string().optional(),\n expiresAt: z.string().datetime().optional(),\n // Arbitrary app-readable key/values delivered with the push (and visible to in-app clients).\n data: z.record(z.string(), z.string()).optional(),\n // Per-provider push customization (sound, badge, image, priority, channelId, body override).\n pushOptions: pushOptionsSchema.optional(),\n // Explicit per-send channel target. When present, delivery is restricted to these channels\n // (intersected with the type's eligibility, the registered strategies, and the recipient's\n // preferences). Absent \u2192 all registered channels (pre-Phase-7 behavior). Must be non-empty when\n // provided: an empty array would resolve to zero deliverable channels and silently black-hole the\n // notification (invisible + undelivered) \u2014 omit the field instead to target every channel.\n channels: z.array(z.string().min(1).max(32)).min(1).optional(),\n})\n\nconst titleRequiredRefinement = {\n refine: (data: { titleKey?: string; title?: string }) => data.titleKey || data.title,\n message: 'Either titleKey or title must be provided',\n} as const\n\nexport const createNotificationSchema = baseNotificationFieldsSchema\n .extend({ recipientUserId: z.string().uuid() })\n .refine(titleRequiredRefinement.refine, { message: titleRequiredRefinement.message })\n\nexport const createBatchNotificationSchema = baseNotificationFieldsSchema\n .extend({ recipientUserIds: z.array(z.string().uuid()).min(1).max(1000) })\n .refine(titleRequiredRefinement.refine, { message: titleRequiredRefinement.message })\n\nexport const createRoleNotificationSchema = baseNotificationFieldsSchema\n .extend({ roleId: z.string().uuid() })\n .refine(titleRequiredRefinement.refine, { message: titleRequiredRefinement.message })\n\nexport const createFeatureNotificationSchema = baseNotificationFieldsSchema\n .extend({ requiredFeature: z.string().min(1).max(100) })\n .refine(titleRequiredRefinement.refine, { message: titleRequiredRefinement.message })\n\nexport const listNotificationsSchema = z.object({\n status: z.union([notificationStatusSchema, z.array(notificationStatusSchema)]).optional(),\n type: z.string().optional(),\n severity: notificationSeveritySchema.optional(),\n sourceEntityType: z.string().optional(),\n sourceEntityId: z.string().uuid().optional(),\n since: z.string().datetime().optional(),\n page: z.coerce.number().int().min(1).optional().default(1),\n pageSize: z.coerce.number().int().min(1).max(100).optional().default(20),\n})\n\nexport const executeActionSchema = z.object({\n actionId: z.string().min(1),\n payload: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport const restoreNotificationSchema = z.object({\n status: z.enum(['read', 'unread']).optional(),\n})\n\nconst notificationDeliveryStrategySchema = z.object({\n enabled: z.boolean().optional(),\n})\n\nconst notificationDeliveryEmailSchema = notificationDeliveryStrategySchema.extend({\n from: z.string().trim().min(1).optional(),\n replyTo: z.string().trim().min(1).optional(),\n subjectPrefix: z.string().trim().min(1).optional(),\n})\n\nconst notificationDeliveryCustomSchema = notificationDeliveryStrategySchema.extend({\n config: z.unknown().optional(),\n})\n\nexport const notificationDeliveryConfigSchema = z.object({\n appUrl: z.string().url().optional(),\n panelPath: safeRelativeHrefSchema.optional(),\n strategies: z.object({\n database: notificationDeliveryStrategySchema.optional(),\n email: notificationDeliveryEmailSchema.optional(),\n custom: z.record(z.string(), notificationDeliveryCustomSchema).optional(),\n }).optional(),\n})\n\nexport type CreateNotificationInput = z.infer<typeof createNotificationSchema>\nexport type CreateBatchNotificationInput = z.infer<typeof createBatchNotificationSchema>\nexport type CreateRoleNotificationInput = z.infer<typeof createRoleNotificationSchema>\nexport type CreateFeatureNotificationInput = z.infer<typeof createFeatureNotificationSchema>\nexport type ListNotificationsInput = z.infer<typeof listNotificationsSchema>\nexport type ExecuteActionInput = z.infer<typeof executeActionSchema>\nexport type NotificationDeliveryConfigInput = z.infer<typeof notificationDeliveryConfigSchema>\n\n// Notification type catalogue (DB-mirrored read model)\nexport const notificationTypeItemSchema = z.object({\n id: z.string(),\n labelKey: z.string(),\n descriptionKey: z.string().nullable().optional(),\n // Free-form grouping label so a client can list/group types under a heading. Defaults to\n // the prefix before the first dot in the type id (`sales.order.created` \u2192 `sales`), so in\n // practice it is always populated; kept nullable to avoid narrowing the response contract.\n // This is the STABLE grouping key \u2014 clients group on it and display `categoryLabel`.\n category: z.string().nullable().optional(),\n // Localized heading for `category`, resolved server-side from\n // `notifications.categories.<key>` with a humanized fallback. Display-only: grouping on it\n // re-partitions the list whenever the locale changes.\n categoryLabel: z.string().nullable(),\n // Server-resolved display strings so clients without the app dictionary (the mobile app)\n // need no i18n bundle. `null` iff the corresponding `*Key` is null.\n label: z.string().nullable(),\n description: z.string().nullable(),\n // When true the type is delivered as a silent / content-available push.\n silent: z.boolean(),\n // Effective \"cannot be opted out of\" flag (operator override ?? code-declared); a preferences\n // UI should lock the type on when true.\n nonOptOut: z.boolean(),\n // Effective channel eligibility for the caller's tenant (stored override ?? code-declared\n // `type.channels`). `null` = no restriction (every registered channel). A channel outside\n // the set never delivers for this type in the tenant and users cannot opt into it \u2014\n // preference UIs lock the cell off.\n channels: z.array(z.string()).nullable(),\n // The raw tenant-stored override (`notification_type_overrides.channels`; `null` = inherit\n // code). Admin editors base PATCH payloads on the EFFECTIVE set, but this shows whether an\n // override exists at all.\n storedChannels: z.array(z.string()).nullable(),\n // The raw tenant-stored `nonOptOut` override (`null` = inherit the code-declared flag).\n storedNonOptOut: z.boolean().nullable(),\n // Optimistic-lock version of the tenant's override row (ISO timestamp; `null` when the\n // tenant stores no override yet). PATCH callers echo it back via the standard\n // `x-om-ext-optimistic-lock-expected-updated-at` header.\n updatedAt: z.string().nullable(),\n})\n\n// PATCH /api/notifications/types \u2014 operator override of a type's channel eligibility and/or\n// nonOptOut governance. Omitted fields stay untouched; `null` clears the stored override so\n// the code declaration applies again.\nexport const updateNotificationTypeSchema = z\n .object({\n id: z.string().min(1),\n // Mirror the create guard: a non-empty array restricts eligibility, `null` clears the override\n // (code declaration reapplies). An empty array is rejected \u2014 it would resolve to zero deliverable\n // channels and silently black-hole the type (invisible + undelivered); clear with `null` instead.\n channels: z.array(z.string().min(1)).min(1).nullable().optional(),\n nonOptOut: z.boolean().nullable().optional(),\n })\n .refine((value) => value.channels !== undefined || value.nonOptOut !== undefined, {\n message: 'At least one of channels or nonOptOut must be provided',\n })\n\n// Per-user channel preferences\nexport const notificationPreferenceItemSchema = z.object({\n notificationTypeId: z.string(),\n channel: z.string(),\n enabled: z.boolean(),\n})\n\nexport const updatePreferencesSchema = z.object({\n preferences: z\n .array(\n z.object({\n notificationTypeId: z.string().trim().min(1).max(128),\n channel: z.string().trim().min(1).max(32),\n enabled: z.boolean(),\n }),\n )\n .max(500),\n})\n\n// Admin-on-behalf preference management (target a specific user)\nexport const adminPreferencesQuerySchema = z.object({\n userId: z.string().uuid(),\n})\n\nexport const adminUpdatePreferencesSchema = updatePreferencesSchema.extend({\n userId: z.string().uuid(),\n})\n\nexport type NotificationTypeItem = z.infer<typeof notificationTypeItemSchema>\nexport type UpdateNotificationTypeInput = z.infer<typeof updateNotificationTypeSchema>\nexport type NotificationPreferenceItem = z.infer<typeof notificationPreferenceItemSchema>\nexport type UpdatePreferencesInput = z.infer<typeof updatePreferencesSchema>\nexport type AdminUpdatePreferencesInput = z.infer<typeof adminUpdatePreferencesSchema>\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -3,8 +3,11 @@ import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
|
3
3
|
import { invalidateCrudCache } from "@open-mercato/shared/lib/crud/cache";
|
|
4
4
|
import { Notification } from "../data/entities.js";
|
|
5
5
|
import {
|
|
6
|
-
NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE
|
|
6
|
+
NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE,
|
|
7
|
+
NOTIFICATION_ACTION_FORBIDDEN_ERROR_CODE
|
|
7
8
|
} from "@open-mercato/shared/modules/notifications/types";
|
|
9
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
10
|
+
import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
|
|
8
11
|
import { NOTIFICATION_EVENTS, NOTIFICATION_SSE_EVENTS } from "./events.js";
|
|
9
12
|
import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
10
13
|
import {
|
|
@@ -72,6 +75,20 @@ function actionAlreadyExecuted() {
|
|
|
72
75
|
code: NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE
|
|
73
76
|
});
|
|
74
77
|
}
|
|
78
|
+
async function actionForbidden() {
|
|
79
|
+
const { translate } = await resolveTranslations();
|
|
80
|
+
return new CrudHttpError(403, {
|
|
81
|
+
error: translate("notifications.error.actionForbidden", "You are not allowed to run this notification action"),
|
|
82
|
+
code: NOTIFICATION_ACTION_FORBIDDEN_ERROR_CODE
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function findDeclaredCommandAction(type, action) {
|
|
86
|
+
const definition = getNotificationType(type);
|
|
87
|
+
if (!definition) return null;
|
|
88
|
+
const declared = [...definition.actions, ...definition.optionalActions ?? []].find((candidate) => candidate.id === action.id);
|
|
89
|
+
if (!declared?.commandId || declared.commandId !== action.commandId) return null;
|
|
90
|
+
return declared;
|
|
91
|
+
}
|
|
75
92
|
function applyNotificationContent(notification, input, recipientUserId, ctx, channels) {
|
|
76
93
|
const actions = sanitizeNotificationActions(input.actions);
|
|
77
94
|
const linkHref = assertSafeNotificationHref(input.linkHref);
|
|
@@ -173,6 +190,25 @@ async function createOrRefreshNotification(em, input, recipientUserId, ctx, chan
|
|
|
173
190
|
}
|
|
174
191
|
function createNotificationService(deps) {
|
|
175
192
|
const { em: rootEm, eventBus, commandBus, container } = deps;
|
|
193
|
+
const userHoldsActionFeatures = async (requiredFeatures, scopeCtx) => {
|
|
194
|
+
if (!requiredFeatures || requiredFeatures.length === 0) return true;
|
|
195
|
+
if (!scopeCtx.userId || !container) return false;
|
|
196
|
+
try {
|
|
197
|
+
const rbacService = container.resolve("rbacService");
|
|
198
|
+
const allowed = await rbacService.userHasAllFeatures(
|
|
199
|
+
scopeCtx.userId,
|
|
200
|
+
requiredFeatures,
|
|
201
|
+
{ tenantId: scopeCtx.tenantId, organizationId: scopeCtx.organizationId ?? null }
|
|
202
|
+
);
|
|
203
|
+
return allowed === true;
|
|
204
|
+
} catch (err) {
|
|
205
|
+
getTelemetryRuntime()?.reportError(err, {
|
|
206
|
+
module: "notifications",
|
|
207
|
+
code: "notifications.action_feature_check_failed"
|
|
208
|
+
});
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
176
212
|
const resolveChannelsFor = async (content, recipientUserId, scopeCtx, preferences = createNotificationPreferenceService({ em: rootEm.fork() }), typeOverrides) => {
|
|
177
213
|
const registeredChannels = getNotificationDeliveryStrategies().map((strategy) => strategy.id);
|
|
178
214
|
if (registeredChannels.length === 0) return null;
|
|
@@ -471,6 +507,12 @@ function createNotificationService(deps) {
|
|
|
471
507
|
if (notification.status === "actioned" || notification.actionedAt != null) {
|
|
472
508
|
throw actionAlreadyExecuted();
|
|
473
509
|
}
|
|
510
|
+
if (action.commandId) {
|
|
511
|
+
const declared = findDeclaredCommandAction(notification.type, action);
|
|
512
|
+
if (!declared || !await userHoldsActionFeatures(declared.requiredFeatures, ctx)) {
|
|
513
|
+
throw await actionForbidden();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
474
516
|
const actionedAt = /* @__PURE__ */ new Date();
|
|
475
517
|
const previousStatus = notification.status;
|
|
476
518
|
const previousActionedAt = notification.actionedAt ?? null;
|
|
@@ -493,8 +535,8 @@ function createNotificationService(deps) {
|
|
|
493
535
|
let result = null;
|
|
494
536
|
if (action.commandId && commandBus && container) {
|
|
495
537
|
const commandInput = {
|
|
496
|
-
|
|
497
|
-
|
|
538
|
+
...input.payload,
|
|
539
|
+
id: notification.sourceEntityId
|
|
498
540
|
};
|
|
499
541
|
const commandCtx = {
|
|
500
542
|
container,
|