@open-mercato/core 0.6.7-develop.6751.1.ac823a3d26 → 0.6.7-develop.6758.1.697eade236
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/attachments/api/route.js +16 -2
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/lib/upload-limits.js +33 -0
- package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
- package/dist/modules/communication_channels/api/post/test-seed/route.js +52 -2
- package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
- package/dist/modules/currencies/di.js +4 -0
- package/dist/modules/currencies/di.js.map +2 -2
- package/dist/modules/currencies/services/baseCurrencyService.js +42 -0
- package/dist/modules/currencies/services/baseCurrencyService.js.map +7 -0
- package/dist/modules/customers/analytics.js +3 -1
- package/dist/modules/customers/analytics.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +5 -4
- package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
- package/dist/modules/customers/api/deals/summary/route.js +5 -4
- package/dist/modules/customers/api/deals/summary/route.js.map +2 -2
- package/dist/modules/customers/lib/optionalBaseCurrency.js +19 -0
- package/dist/modules/customers/lib/optionalBaseCurrency.js.map +7 -0
- package/dist/modules/dashboards/api/widgets/data/batch/route.js +8 -1
- package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/route.js +8 -1
- package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/schema.js +2 -1
- package/dist/modules/dashboards/api/widgets/data/schema.js.map +2 -2
- package/dist/modules/dashboards/components/UnlabelledAmountNotice.js +33 -0
- package/dist/modules/dashboards/components/UnlabelledAmountNotice.js.map +7 -0
- package/dist/modules/dashboards/lib/aggregations.js +17 -0
- package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
- package/dist/modules/dashboards/lib/formatters.js +84 -11
- package/dist/modules/dashboards/lib/formatters.js.map +2 -2
- package/dist/modules/dashboards/lib/optionalBaseCurrency.js +11 -0
- package/dist/modules/dashboards/lib/optionalBaseCurrency.js.map +7 -0
- package/dist/modules/dashboards/services/widgetDataService.js +86 -6
- package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
- package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js +26 -0
- package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js +9 -3
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +10 -4
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js +9 -3
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js +8 -3
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js +26 -18
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js +22 -14
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js +10 -4
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js.map +2 -2
- package/dist/modules/notifications/api/route.js +62 -3
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/sales/analytics.js +7 -3
- package/dist/modules/sales/analytics.js.map +2 -2
- package/dist/modules/sales/api/document-history/route.js +2 -1
- package/dist/modules/sales/api/document-history/route.js.map +2 -2
- package/dist/modules/sales/lib/historyHelpers.js +69 -1
- package/dist/modules/sales/lib/historyHelpers.js.map +2 -2
- package/dist/modules/sales/widgets/injection/document-history/widget.client.js +51 -2
- package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/api/route.ts +16 -2
- package/src/modules/attachments/lib/upload-limits.ts +35 -0
- package/src/modules/communication_channels/api/post/test-seed/route.ts +69 -2
- package/src/modules/currencies/di.ts +4 -1
- package/src/modules/currencies/services/baseCurrencyService.ts +59 -0
- package/src/modules/customers/analytics.ts +2 -0
- package/src/modules/customers/api/deals/aggregate/route.ts +5 -4
- package/src/modules/customers/api/deals/summary/route.ts +5 -4
- package/src/modules/customers/lib/optionalBaseCurrency.ts +34 -0
- package/src/modules/dashboards/api/widgets/data/batch/route.ts +8 -1
- package/src/modules/dashboards/api/widgets/data/route.ts +8 -1
- package/src/modules/dashboards/api/widgets/data/schema.ts +1 -0
- package/src/modules/dashboards/components/UnlabelledAmountNotice.tsx +43 -0
- package/src/modules/dashboards/i18n/de.json +2 -0
- package/src/modules/dashboards/i18n/en.json +2 -0
- package/src/modules/dashboards/i18n/es.json +2 -0
- package/src/modules/dashboards/i18n/pl.json +2 -0
- package/src/modules/dashboards/lib/aggregations.ts +35 -0
- package/src/modules/dashboards/lib/formatters.ts +137 -12
- package/src/modules/dashboards/lib/optionalBaseCurrency.ts +22 -0
- package/src/modules/dashboards/services/widgetDataService.ts +110 -4
- package/src/modules/dashboards/subscribers/invalidateWidgetDataCache.ts +32 -0
- package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.tsx +9 -3
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +9 -3
- package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.tsx +9 -3
- package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.tsx +7 -2
- package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx +25 -15
- package/src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx +21 -11
- package/src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx +9 -3
- package/src/modules/notifications/api/route.ts +88 -3
- package/src/modules/sales/analytics.ts +4 -0
- package/src/modules/sales/api/document-history/route.ts +1 -0
- package/src/modules/sales/i18n/de.json +19 -0
- package/src/modules/sales/i18n/en.json +19 -0
- package/src/modules/sales/i18n/es.json +19 -0
- package/src/modules/sales/i18n/pl.json +19 -0
- package/src/modules/sales/lib/historyHelpers.ts +88 -1
- package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +64 -3
package/.turbo/turbo-build.log
CHANGED
|
@@ -34,7 +34,9 @@ import {
|
|
|
34
34
|
sanitizeUploadedFileName
|
|
35
35
|
} from "../lib/security.js";
|
|
36
36
|
import {
|
|
37
|
+
isMultipartUploadLimitError,
|
|
37
38
|
isMultipartRequestWithinUploadLimit,
|
|
39
|
+
parseMultipartFormDataWithinUploadLimit,
|
|
38
40
|
resolveAttachmentMaxBytes,
|
|
39
41
|
willExceedAttachmentTenantQuota
|
|
40
42
|
} from "../lib/upload-limits.js";
|
|
@@ -248,9 +250,21 @@ async function POST(req) {
|
|
|
248
250
|
return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 });
|
|
249
251
|
}
|
|
250
252
|
if (!isMultipartRequestWithinUploadLimit(req.headers.get("content-length"))) {
|
|
251
|
-
return NextResponse.json({
|
|
253
|
+
return NextResponse.json({
|
|
254
|
+
error: t("attachments.errors.maxUploadSize", "Attachment exceeds the maximum upload size.")
|
|
255
|
+
}, { status: 413 });
|
|
256
|
+
}
|
|
257
|
+
let form;
|
|
258
|
+
try {
|
|
259
|
+
form = await parseMultipartFormDataWithinUploadLimit(req);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (isMultipartUploadLimitError(error)) {
|
|
262
|
+
return NextResponse.json({
|
|
263
|
+
error: t("attachments.errors.maxUploadSize", "Attachment exceeds the maximum upload size.")
|
|
264
|
+
}, { status: 413 });
|
|
265
|
+
}
|
|
266
|
+
throw error;
|
|
252
267
|
}
|
|
253
|
-
const form = await req.formData();
|
|
254
268
|
const formPayload = buildFormPayload(form);
|
|
255
269
|
const customFieldValues = splitCustomFieldPayload(formPayload).custom;
|
|
256
270
|
const entityId = String(form.get("entityId") || "");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/attachments/api/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { z } from 'zod'\nimport { sql } from 'kysely'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { buildAttachmentFileUrl, buildAttachmentImageUrl, slugifyAttachmentFileName } from '../lib/imageUrls'\nimport { ensureDefaultPartitions, resolveDefaultPartitionCode, sanitizePartitionCode } from '../lib/partitions'\nimport { Attachment, AttachmentPartition } from '../data/entities'\nimport { extractAttachmentContent } from '../lib/textExtraction'\nimport { requestOcrProcessing } from '../lib/ocrQueue'\nimport { StorageDriverFactory } from '../lib/drivers'\nimport { OcrService, shouldUseLlmOcr } from '../lib/ocrService'\nimport { clearAttachmentThumbnailCache } from '../lib/thumbnailCache'\nimport { assertAttachmentScopeInvariant } from '../lib/access'\nimport { resolveAttachmentOrganizationId } from '../lib/requestScope'\nimport {\n mergeAttachmentMetadata,\n normalizeAttachmentAssignments,\n normalizeAttachmentTags,\n readAttachmentMetadata,\n upsertAssignment,\n type AttachmentAssignment,\n} from '../lib/metadata'\nimport { randomUUID } from 'crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { splitCustomFieldPayload } from '@open-mercato/shared/lib/crud/custom-fields'\nimport { emitCrudSideEffects, setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { attachmentCrudEvents, attachmentCrudIndexer } from '../lib/crud'\nimport { E } from '#generated/entities.ids.generated'\nimport { resolveDefaultAttachmentOcrEnabled } from '../lib/ocrConfig'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n sanitizeUploadedFileName,\n} from '../lib/security'\nimport {\n isMultipartRequestWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '../lib/upload-limits'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('attachments')\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['attachments.view'] },\n POST: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n}\n\nconst attachmentQuerySchema = z.object({\n entityId: z.string().min(1).describe('Entity identifier that owns the attachments'),\n recordId: z.string().min(1).describe('Record identifier within the entity'),\n page: z.coerce.number().min(1).optional(),\n pageSize: z.coerce.number().min(1).max(100).optional(),\n})\n\nconst attachmentAssignmentSchema = z.object({\n type: z.string().describe('Assignment type identifier'),\n id: z.string().describe('Assignment record identifier'),\n href: z.string().nullable().optional().describe('Optional link to the related record'),\n label: z.string().nullable().optional().describe('Optional label for the assignment'),\n})\n\nconst attachmentItemSchema = z.object({\n id: z.string().describe('Attachment identifier'),\n url: z.string().describe('Public path to the stored asset'),\n fileName: z.string().describe('Original filename'),\n fileSize: z.number().int().nonnegative().describe('File size in bytes'),\n createdAt: z.string().describe('Upload timestamp (ISO 8601)'),\n mimeType: z.string().nullable().optional().describe('MIME type of the file'),\n thumbnailUrl: z.string().optional().describe('Helper route that renders a thumbnail'),\n partitionCode: z.string().optional().describe('Partition identifier'),\n tags: z.array(z.string()).optional().describe('Tags assigned to the attachment'),\n content: z.string().nullable().optional().describe('Extracted text or markdown content'),\n assignments: z.array(attachmentAssignmentSchema).optional().describe('Records that reference this attachment'),\n})\n\nconst attachmentListResponseSchema = z.object({\n items: z.array(attachmentItemSchema),\n total: z.number().int().nonnegative().optional(),\n page: z.number().int().min(1).optional(),\n pageSize: z.number().int().min(1).optional(),\n totalPages: z.number().int().min(1).optional(),\n})\n\nconst attachmentUploadBodySchema = z.object({\n entityId: z.string().min(1),\n recordId: z.string().min(1),\n fieldKey: z.string().optional(),\n file: z.string().min(1).describe('Binary file payload; supplied as multipart form-data'),\n customFields: z\n .string()\n .optional()\n .describe('JSON encoded map of custom field values collected from the upload form.'),\n})\n\nconst attachmentDeleteQuerySchema = z.object({\n id: z.string().uuid(),\n})\n\nconst uploadResponseSchema = z.object({\n ok: z.literal(true),\n item: z.object({\n id: z.string(),\n url: z.string(),\n fileName: z.string(),\n fileSize: z.number().int().nonnegative(),\n thumbnailUrl: z.string().optional(),\n content: z.string().nullable().optional(),\n tags: z.array(z.string()).optional(),\n assignments: z.array(attachmentAssignmentSchema).optional(),\n customFields: z.record(z.string(), z.unknown()).optional(),\n }),\n})\n\nconst errorSchema = z.object({\n error: z.string(),\n})\n\nconst LIBRARY_ENTITY_ID = 'attachments:library'\n\nfunction parseCustomFieldsEntry(value: FormDataEntryValue | null): Record<string, unknown> {\n if (!value) return {}\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (!trimmed) return {}\n try {\n const parsed = JSON.parse(trimmed)\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>\n }\n } catch {\n return {}\n }\n }\n if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof File)) {\n return { ...(value as Record<string, unknown>) }\n }\n return {}\n}\n\nfunction buildFormPayload(form: FormData): Record<string, unknown> {\n const payload: Record<string, unknown> = {}\n form.forEach((value, key) => {\n if (key === 'customFields') {\n payload.customFields = parseCustomFieldsEntry(value)\n return\n }\n payload[key] = value\n })\n return payload\n}\n\nfunction parseFormTags(value: FormDataEntryValue | null): string[] {\n if (!value) return []\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (!trimmed) return []\n try {\n const parsed = JSON.parse(trimmed)\n return normalizeAttachmentTags(parsed)\n } catch {\n return normalizeAttachmentTags(value)\n }\n }\n return []\n}\n\nfunction parseFormAssignments(value: FormDataEntryValue | null): AttachmentAssignment[] {\n if (!value) return []\n if (typeof value !== 'string') return []\n const trimmed = value.trim()\n if (!trimmed) return []\n try {\n const parsed = JSON.parse(trimmed)\n return normalizeAttachmentAssignments(parsed)\n } catch {\n return []\n }\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const url = new URL(req.url)\n const parsedQuery = attachmentQuerySchema.safeParse({\n entityId: url.searchParams.get('entityId') || '',\n recordId: url.searchParams.get('recordId') || '',\n page: url.searchParams.get('page') ?? undefined,\n pageSize: url.searchParams.get('pageSize') ?? undefined,\n })\n if (!parsedQuery.success) {\n return NextResponse.json({ error: 'entityId and recordId are required' }, { status: 400 })\n }\n const { entityId, recordId, page, pageSize } = parsedQuery.data\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const orgId = await resolveAttachmentOrganizationId(container, auth, req)\n const filter: Record<string, unknown> = { entityId, recordId, tenantId: auth.tenantId! }\n if (orgId) filter.organizationId = orgId\n const orderBy: Record<string, 'ASC' | 'DESC'> = { createdAt: 'DESC' }\n const usePaging = typeof page === 'number' && typeof pageSize === 'number'\n const total = usePaging ? await em.count(Attachment, filter) : null\n const currentPage = usePaging ? Math.max(1, page) : null\n const currentPageSize = usePaging ? pageSize : null\n const totalPages = usePaging && total !== null ? Math.max(1, Math.ceil(total / currentPageSize!)) : null\n const pageOffset = usePaging ? (Math.min(currentPage!, totalPages!) - 1) * currentPageSize! : undefined\n const items = await findWithDecryption(\n em,\n Attachment,\n filter,\n {\n orderBy,\n ...(usePaging\n ? {\n limit: currentPageSize!,\n offset: pageOffset,\n }\n : {}),\n },\n {\n tenantId: auth.tenantId ?? null,\n organizationId: orgId ?? null,\n },\n )\n return NextResponse.json({\n items: items.map((a: any) => {\n const metadata = readAttachmentMetadata(a.storageMetadata)\n return {\n id: a.id,\n url: a.url,\n fileName: a.fileName,\n fileSize: a.fileSize,\n createdAt: a.createdAt,\n mimeType: a.mimeType ?? null,\n partitionCode: a.partitionCode,\n content: a.content ?? null,\n thumbnailUrl: buildAttachmentImageUrl(a.id, {\n width: 320,\n height: 320,\n slug: slugifyAttachmentFileName(a.fileName),\n }),\n tags: metadata.tags ?? [],\n assignments: metadata.assignments ?? [],\n }\n }),\n ...(usePaging\n ? {\n total,\n page: Math.min(currentPage!, totalPages!),\n pageSize: currentPageSize,\n totalPages,\n }\n : {}),\n })\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n // A superadmin browsing with \"All organizations\" selected has no concrete\n // organization scope, but an attachment must be stored under exactly one\n // organization (the scope invariant forbids partial-null rows). Reject with a\n // clear, actionable error instead of a 401 \u2014 a 401 makes the client-side\n // fetch layer treat it as session expiry, show a misleading toast, and reset\n // the in-progress form (#3764).\n if (!auth.orgId) {\n return NextResponse.json({\n error: t('attachments.errors.selectOrganization', 'Select a specific organization before uploading an attachment.'),\n }, { status: 400 })\n }\n const tenantId = auth.tenantId\n\n const contentType = req.headers.get('content-type') || ''\n if (!contentType.toLowerCase().includes('multipart/form-data')) {\n return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })\n }\n if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {\n return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })\n }\n\n const form = await req.formData()\n const formPayload = buildFormPayload(form)\n const customFieldValues = splitCustomFieldPayload(formPayload).custom\n const entityId = String(form.get('entityId') || '')\n const recordId = String(form.get('recordId') || '')\n const fieldKey = String(form.get('fieldKey') || '')\n const file = form.get('file') as unknown as File | null\n if (!entityId || !recordId || !file) return NextResponse.json({ error: 'entityId, recordId and file are required' }, { status: 400 })\n const partitionOverrideRaw = form.get('partitionCode')\n const partitionOverride =\n typeof partitionOverrideRaw === 'string' && partitionOverrideRaw.trim().length > 0\n ? sanitizePartitionCode(partitionOverrideRaw)\n : null\n const tags = parseFormTags(form.get('tags'))\n const assignmentsFromForm = parseFormAssignments(form.get('assignments'))\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const dataEngine = container.resolve('dataEngine')\n const storageDriverFactory =\n (container.resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)\n const orgId = await resolveAttachmentOrganizationId(container, auth, req)\n if (!orgId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n await ensureDefaultPartitions(em)\n // Optional per-field validations\n let partitionFromField: string | null = null\n let fieldMaxAttachmentSizeMb: number | null = null\n if (fieldKey) {\n try {\n const { CustomFieldDef } = await import('@open-mercato/core/modules/entities/data/entities')\n const def = await em.findOne(CustomFieldDef, {\n entityId,\n key: fieldKey,\n $and: [\n { $or: [ { tenantId: auth.tenantId }, { tenantId: null } ] },\n ],\n isActive: true,\n })\n const cfg = (def as any)?.configJson || {}\n const ext = (file.name || '').split('.').pop()?.toLowerCase() || ''\n if (Array.isArray(cfg.acceptExtensions) && cfg.acceptExtensions.length) {\n const allowed = new Set((cfg.acceptExtensions as any[]).map((x: any) => String(x).toLowerCase().replace(/^\\./, '')))\n if (!allowed.has(ext)) return NextResponse.json({ error: 'File type not allowed' }, { status: 400 })\n }\n if (typeof cfg.maxAttachmentSizeMb === 'number' && cfg.maxAttachmentSizeMb > 0) {\n fieldMaxAttachmentSizeMb = cfg.maxAttachmentSizeMb\n }\n if (typeof cfg.partitionCode === 'string' && cfg.partitionCode.trim().length > 0) {\n partitionFromField = sanitizePartitionCode(cfg.partitionCode)\n }\n } catch {}\n }\n if (hasDangerousExecutableExtension(file.name)) {\n return NextResponse.json({\n error: t('attachments.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.'),\n }, { status: 400 })\n }\n const effectiveMaxBytes = resolveAttachmentMaxBytes(fieldMaxAttachmentSizeMb)\n if (file.size > effectiveMaxBytes) {\n return NextResponse.json({\n error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),\n }, { status: 413 })\n }\n const tenantUsageBytes = await readTenantAttachmentUsageBytes(em, tenantId)\n if (willExceedAttachmentTenantQuota(tenantUsageBytes, file.size)) {\n return NextResponse.json({\n error: t('attachments.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.'),\n }, { status: 413 })\n }\n const buf = Buffer.from(await file.arrayBuffer())\n const safeName = sanitizeUploadedFileName(file.name)\n const fileMimeType = detectAttachmentMimeType(buf, safeName, (file as any).type)\n if (isActiveContentAttachment(buf, safeName, fileMimeType)) {\n return NextResponse.json({ error: t('attachments.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })\n }\n const defaultPartitionCode = resolveDefaultPartitionCode(entityId)\n const resolvedPartitionCode = partitionOverride ?? partitionFromField ?? defaultPartitionCode\n const partitionCodeCandidates = Array.from(\n new Set(\n [partitionOverride, partitionFromField, resolvedPartitionCode].filter(\n (code): code is string => typeof code === 'string' && code.length > 0,\n ),\n ),\n )\n let partition: AttachmentPartition | null = null\n for (const code of partitionCodeCandidates) {\n const record = await em.findOne(AttachmentPartition, { code })\n if (record) {\n partition = record\n break\n }\n }\n if (!partition) {\n partition = await em.findOne(AttachmentPartition, { code: defaultPartitionCode })\n }\n if (!partition) {\n return NextResponse.json({ error: 'Storage partition is not configured.' }, { status: 400 })\n }\n const requestedPublicOverride =\n typeof partitionOverride === 'string' &&\n partitionOverride.length > 0 &&\n partition.code === partitionOverride &&\n partition.isPublic === true &&\n partition.code !== defaultPartitionCode &&\n partition.code !== partitionFromField\n if (requestedPublicOverride) {\n return NextResponse.json({ error: t('attachments.errors.publicPartitionBlocked', 'Public storage partitions cannot be selected explicitly for this upload.') }, { status: 403 })\n }\n const uploadDriver = await storageDriverFactory.resolveForPartition(partition.code, { tenantId, organizationId: orgId })\n let storedPath: string\n try {\n const stored = await uploadDriver.store({\n partitionCode: partition.code,\n orgId,\n tenantId,\n fileName: safeName,\n buffer: buf,\n })\n storedPath = stored.storagePath\n } catch (error) {\n logger.error('Failed to persist file', { err: error })\n return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })\n }\n\n const requiresOcr =\n typeof (partition as any).requiresOcr === 'boolean'\n ? Boolean((partition as any).requiresOcr)\n : resolveDefaultAttachmentOcrEnabled()\n let extractedContent: string | null = null\n const wantsLlmOcr = requiresOcr && shouldUseLlmOcr(fileMimeType, safeName)\n const ocrService = wantsLlmOcr ? new OcrService() : null\n const useLlmOcr = Boolean(wantsLlmOcr && ocrService?.available)\n\n if (requiresOcr && !useLlmOcr) {\n const { filePath: localPath, cleanup } = await uploadDriver.toLocalPath(partition.code, storedPath)\n try {\n extractedContent = await extractAttachmentContent({\n filePath: localPath,\n mimeType: fileMimeType,\n })\n } catch (error) {\n logger.error('Failed to extract attachment content', { err: error })\n } finally {\n await cleanup().catch(() => {})\n }\n }\n\n let assignments = assignmentsFromForm.slice()\n if (entityId !== LIBRARY_ENTITY_ID) {\n assignments = upsertAssignment(assignments, { type: entityId, id: recordId })\n }\n const metadata = mergeAttachmentMetadata(null, { assignments, tags })\n const attachmentId = randomUUID()\n assertAttachmentScopeInvariant({ tenantId: auth.tenantId, organizationId: orgId })\n const att = em.create(Attachment, {\n id: attachmentId,\n entityId,\n recordId,\n organizationId: orgId,\n tenantId: auth.tenantId!,\n fileName: safeName,\n mimeType: fileMimeType,\n fileSize: buf.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 // Persist the attachment row and its custom-field values atomically so a\n // custom-field failure cannot leave behind a committed orphan attachment.\n try {\n await em.transactional(async (tx) => {\n await tx.persist(att).flush()\n if (dataEngine) {\n await setCustomFieldsIfAny({\n dataEngine,\n entityId: E.attachments.attachment,\n recordId: attachmentId,\n tenantId,\n organizationId: orgId,\n values: customFieldValues,\n })\n }\n })\n } catch (error) {\n logger.error('Failed to persist attachment with custom attributes', { err: error })\n return NextResponse.json({ error: 'Failed to save attachment attributes.' }, { status: 500 })\n }\n\n if (useLlmOcr) {\n requestOcrProcessing(em, att, uploadDriver, storedPath).catch((error) => {\n logger.error('Failed to queue OCR processing', { err: error })\n })\n } else if (wantsLlmOcr) {\n logger.warn('OCR requested but OPENAI_API_KEY not configured, falling back to text extraction when available')\n }\n\n if (dataEngine) {\n await emitCrudSideEffects({\n dataEngine,\n action: 'created',\n entity: att,\n identifiers: {\n id: att.id,\n organizationId: att.organizationId ?? null,\n tenantId: att.tenantId ?? null,\n },\n events: attachmentCrudEvents,\n indexer: attachmentCrudIndexer,\n })\n await dataEngine.flushOrmEntityChanges()\n }\n\n return NextResponse.json({\n ok: true,\n item: {\n id: attachmentId,\n url: att.url,\n fileName: safeName,\n fileSize: buf.length,\n partitionCode: partition.code,\n thumbnailUrl: buildAttachmentImageUrl(attachmentId, {\n width: 320,\n height: 320,\n slug: slugifyAttachmentFileName(safeName),\n }),\n content: extractedContent ?? null,\n tags: metadata.tags ?? [],\n assignments: metadata.assignments ?? [],\n customFields: Object.keys(customFieldValues).length ? customFieldValues : undefined,\n },\n })\n}\n\nasync function readTenantAttachmentUsageBytes(em: EntityManager, tenantId: string): Promise<number> {\n try {\n const db = em.getKysely<any>() as any\n const row = await db\n .selectFrom('attachments')\n .select(sql<string>`sum(file_size)`.as('total_size'))\n .where('tenant_id', '=', tenantId)\n .executeTakeFirst() as { total_size: string | number | null } | undefined\n const total = row?.total_size\n if (typeof total === 'number') return Number.isFinite(total) ? total : 0\n if (typeof total === 'string') {\n const parsed = Number(total)\n return Number.isFinite(parsed) ? parsed : 0\n }\n return 0\n } catch {\n return 0\n }\n}\n\nexport async function DELETE(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const url = new URL(req.url)\n const id = url.searchParams.get('id') || ''\n if (!id) return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const dataEngine = container.resolve('dataEngine')\n const storageDriverFactory =\n (container.resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)\n // Resolve the currently selected organization (#3765) so a multi-org admin who\n // switched the header org deletes within that org, not their pinned home org.\n // A superadmin browsing with \"All organizations\" selected has no concrete org,\n // so resolution returns null and the delete falls back to a tenant-only scope\n // (#3764) \u2014 letting them remove any attachment in the tenant.\n const orgId = await resolveAttachmentOrganizationId(container, auth, req)\n const deleteFilter: Record<string, unknown> = { id, tenantId: auth.tenantId! }\n if (orgId) deleteFilter.organizationId = orgId\n const record = await em.findOne(Attachment, deleteFilter)\n if (!record) return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })\n await em.remove(record).flush()\n await clearAttachmentThumbnailCache(record.partitionCode, record.id).catch((error) => {\n logger.error('Failed to cleanup cached thumbnails', { err: error })\n })\n if (record.storagePath) {\n const delDriver = await storageDriverFactory.resolveForPartition(record.partitionCode, {\n tenantId: record.tenantId ?? auth.tenantId!,\n organizationId: record.organizationId ?? orgId ?? '',\n })\n await delDriver.delete(record.partitionCode, record.storagePath)\n }\n if (dataEngine) {\n await emitCrudSideEffects({\n dataEngine,\n action: 'deleted',\n entity: record,\n identifiers: {\n id: record.id,\n organizationId: record.organizationId ?? null,\n tenantId: record.tenantId ?? null,\n },\n events: attachmentCrudEvents,\n indexer: attachmentCrudIndexer,\n })\n await dataEngine.flushOrmEntityChanges()\n }\n return NextResponse.json({ ok: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Manage entity attachments',\n description: 'Upload and list attachments associated with module entities and records.',\n methods: {\n GET: {\n summary: 'List attachments for a record',\n description: 'Returns uploaded attachments for the given entity record, ordered by newest first.',\n query: attachmentQuerySchema,\n responses: [\n { status: 200, description: 'Attachments found for the record', schema: attachmentListResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Missing entity or record identifiers', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n POST: {\n summary: 'Upload attachment',\n description: 'Uploads a new attachment using multipart form-data and stores metadata for later retrieval.',\n requestBody: {\n contentType: 'multipart/form-data',\n schema: attachmentUploadBodySchema,\n },\n responses: [\n { status: 200, description: 'Attachment stored successfully', schema: uploadResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Payload validation error', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 403, description: 'Attachment violates field constraints', schema: errorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete attachment',\n description: 'Removes an uploaded attachment and deletes the stored asset.',\n query: attachmentDeleteQuerySchema,\n responses: [\n { status: 200, description: 'Attachment deleted', schema: z.object({ ok: z.literal(true) }) },\n { status: 404, description: 'Attachment not found', schema: errorSchema },\n ],\n errors: [\n { status: 400, description: 'Missing attachment identifier', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { z } from 'zod'\nimport { sql } from 'kysely'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { buildAttachmentFileUrl, buildAttachmentImageUrl, slugifyAttachmentFileName } from '../lib/imageUrls'\nimport { ensureDefaultPartitions, resolveDefaultPartitionCode, sanitizePartitionCode } from '../lib/partitions'\nimport { Attachment, AttachmentPartition } from '../data/entities'\nimport { extractAttachmentContent } from '../lib/textExtraction'\nimport { requestOcrProcessing } from '../lib/ocrQueue'\nimport { StorageDriverFactory } from '../lib/drivers'\nimport { OcrService, shouldUseLlmOcr } from '../lib/ocrService'\nimport { clearAttachmentThumbnailCache } from '../lib/thumbnailCache'\nimport { assertAttachmentScopeInvariant } from '../lib/access'\nimport { resolveAttachmentOrganizationId } from '../lib/requestScope'\nimport {\n mergeAttachmentMetadata,\n normalizeAttachmentAssignments,\n normalizeAttachmentTags,\n readAttachmentMetadata,\n upsertAssignment,\n type AttachmentAssignment,\n} from '../lib/metadata'\nimport { randomUUID } from 'crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { splitCustomFieldPayload } from '@open-mercato/shared/lib/crud/custom-fields'\nimport { emitCrudSideEffects, setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { attachmentCrudEvents, attachmentCrudIndexer } from '../lib/crud'\nimport { E } from '#generated/entities.ids.generated'\nimport { resolveDefaultAttachmentOcrEnabled } from '../lib/ocrConfig'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n sanitizeUploadedFileName,\n} from '../lib/security'\nimport {\n isMultipartUploadLimitError,\n isMultipartRequestWithinUploadLimit,\n parseMultipartFormDataWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '../lib/upload-limits'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('attachments')\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['attachments.view'] },\n POST: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n}\n\nconst attachmentQuerySchema = z.object({\n entityId: z.string().min(1).describe('Entity identifier that owns the attachments'),\n recordId: z.string().min(1).describe('Record identifier within the entity'),\n page: z.coerce.number().min(1).optional(),\n pageSize: z.coerce.number().min(1).max(100).optional(),\n})\n\nconst attachmentAssignmentSchema = z.object({\n type: z.string().describe('Assignment type identifier'),\n id: z.string().describe('Assignment record identifier'),\n href: z.string().nullable().optional().describe('Optional link to the related record'),\n label: z.string().nullable().optional().describe('Optional label for the assignment'),\n})\n\nconst attachmentItemSchema = z.object({\n id: z.string().describe('Attachment identifier'),\n url: z.string().describe('Public path to the stored asset'),\n fileName: z.string().describe('Original filename'),\n fileSize: z.number().int().nonnegative().describe('File size in bytes'),\n createdAt: z.string().describe('Upload timestamp (ISO 8601)'),\n mimeType: z.string().nullable().optional().describe('MIME type of the file'),\n thumbnailUrl: z.string().optional().describe('Helper route that renders a thumbnail'),\n partitionCode: z.string().optional().describe('Partition identifier'),\n tags: z.array(z.string()).optional().describe('Tags assigned to the attachment'),\n content: z.string().nullable().optional().describe('Extracted text or markdown content'),\n assignments: z.array(attachmentAssignmentSchema).optional().describe('Records that reference this attachment'),\n})\n\nconst attachmentListResponseSchema = z.object({\n items: z.array(attachmentItemSchema),\n total: z.number().int().nonnegative().optional(),\n page: z.number().int().min(1).optional(),\n pageSize: z.number().int().min(1).optional(),\n totalPages: z.number().int().min(1).optional(),\n})\n\nconst attachmentUploadBodySchema = z.object({\n entityId: z.string().min(1),\n recordId: z.string().min(1),\n fieldKey: z.string().optional(),\n file: z.string().min(1).describe('Binary file payload; supplied as multipart form-data'),\n customFields: z\n .string()\n .optional()\n .describe('JSON encoded map of custom field values collected from the upload form.'),\n})\n\nconst attachmentDeleteQuerySchema = z.object({\n id: z.string().uuid(),\n})\n\nconst uploadResponseSchema = z.object({\n ok: z.literal(true),\n item: z.object({\n id: z.string(),\n url: z.string(),\n fileName: z.string(),\n fileSize: z.number().int().nonnegative(),\n thumbnailUrl: z.string().optional(),\n content: z.string().nullable().optional(),\n tags: z.array(z.string()).optional(),\n assignments: z.array(attachmentAssignmentSchema).optional(),\n customFields: z.record(z.string(), z.unknown()).optional(),\n }),\n})\n\nconst errorSchema = z.object({\n error: z.string(),\n})\n\nconst LIBRARY_ENTITY_ID = 'attachments:library'\n\nfunction parseCustomFieldsEntry(value: FormDataEntryValue | null): Record<string, unknown> {\n if (!value) return {}\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (!trimmed) return {}\n try {\n const parsed = JSON.parse(trimmed)\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>\n }\n } catch {\n return {}\n }\n }\n if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof File)) {\n return { ...(value as Record<string, unknown>) }\n }\n return {}\n}\n\nfunction buildFormPayload(form: FormData): Record<string, unknown> {\n const payload: Record<string, unknown> = {}\n form.forEach((value, key) => {\n if (key === 'customFields') {\n payload.customFields = parseCustomFieldsEntry(value)\n return\n }\n payload[key] = value\n })\n return payload\n}\n\nfunction parseFormTags(value: FormDataEntryValue | null): string[] {\n if (!value) return []\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (!trimmed) return []\n try {\n const parsed = JSON.parse(trimmed)\n return normalizeAttachmentTags(parsed)\n } catch {\n return normalizeAttachmentTags(value)\n }\n }\n return []\n}\n\nfunction parseFormAssignments(value: FormDataEntryValue | null): AttachmentAssignment[] {\n if (!value) return []\n if (typeof value !== 'string') return []\n const trimmed = value.trim()\n if (!trimmed) return []\n try {\n const parsed = JSON.parse(trimmed)\n return normalizeAttachmentAssignments(parsed)\n } catch {\n return []\n }\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const url = new URL(req.url)\n const parsedQuery = attachmentQuerySchema.safeParse({\n entityId: url.searchParams.get('entityId') || '',\n recordId: url.searchParams.get('recordId') || '',\n page: url.searchParams.get('page') ?? undefined,\n pageSize: url.searchParams.get('pageSize') ?? undefined,\n })\n if (!parsedQuery.success) {\n return NextResponse.json({ error: 'entityId and recordId are required' }, { status: 400 })\n }\n const { entityId, recordId, page, pageSize } = parsedQuery.data\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const orgId = await resolveAttachmentOrganizationId(container, auth, req)\n const filter: Record<string, unknown> = { entityId, recordId, tenantId: auth.tenantId! }\n if (orgId) filter.organizationId = orgId\n const orderBy: Record<string, 'ASC' | 'DESC'> = { createdAt: 'DESC' }\n const usePaging = typeof page === 'number' && typeof pageSize === 'number'\n const total = usePaging ? await em.count(Attachment, filter) : null\n const currentPage = usePaging ? Math.max(1, page) : null\n const currentPageSize = usePaging ? pageSize : null\n const totalPages = usePaging && total !== null ? Math.max(1, Math.ceil(total / currentPageSize!)) : null\n const pageOffset = usePaging ? (Math.min(currentPage!, totalPages!) - 1) * currentPageSize! : undefined\n const items = await findWithDecryption(\n em,\n Attachment,\n filter,\n {\n orderBy,\n ...(usePaging\n ? {\n limit: currentPageSize!,\n offset: pageOffset,\n }\n : {}),\n },\n {\n tenantId: auth.tenantId ?? null,\n organizationId: orgId ?? null,\n },\n )\n return NextResponse.json({\n items: items.map((a: any) => {\n const metadata = readAttachmentMetadata(a.storageMetadata)\n return {\n id: a.id,\n url: a.url,\n fileName: a.fileName,\n fileSize: a.fileSize,\n createdAt: a.createdAt,\n mimeType: a.mimeType ?? null,\n partitionCode: a.partitionCode,\n content: a.content ?? null,\n thumbnailUrl: buildAttachmentImageUrl(a.id, {\n width: 320,\n height: 320,\n slug: slugifyAttachmentFileName(a.fileName),\n }),\n tags: metadata.tags ?? [],\n assignments: metadata.assignments ?? [],\n }\n }),\n ...(usePaging\n ? {\n total,\n page: Math.min(currentPage!, totalPages!),\n pageSize: currentPageSize,\n totalPages,\n }\n : {}),\n })\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n // A superadmin browsing with \"All organizations\" selected has no concrete\n // organization scope, but an attachment must be stored under exactly one\n // organization (the scope invariant forbids partial-null rows). Reject with a\n // clear, actionable error instead of a 401 \u2014 a 401 makes the client-side\n // fetch layer treat it as session expiry, show a misleading toast, and reset\n // the in-progress form (#3764).\n if (!auth.orgId) {\n return NextResponse.json({\n error: t('attachments.errors.selectOrganization', 'Select a specific organization before uploading an attachment.'),\n }, { status: 400 })\n }\n const tenantId = auth.tenantId\n\n const contentType = req.headers.get('content-type') || ''\n if (!contentType.toLowerCase().includes('multipart/form-data')) {\n return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })\n }\n if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {\n return NextResponse.json({\n error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),\n }, { status: 413 })\n }\n\n let form: FormData\n try {\n form = await parseMultipartFormDataWithinUploadLimit(req)\n } catch (error) {\n if (isMultipartUploadLimitError(error)) {\n return NextResponse.json({\n error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),\n }, { status: 413 })\n }\n throw error\n }\n const formPayload = buildFormPayload(form)\n const customFieldValues = splitCustomFieldPayload(formPayload).custom\n const entityId = String(form.get('entityId') || '')\n const recordId = String(form.get('recordId') || '')\n const fieldKey = String(form.get('fieldKey') || '')\n const file = form.get('file') as unknown as File | null\n if (!entityId || !recordId || !file) return NextResponse.json({ error: 'entityId, recordId and file are required' }, { status: 400 })\n const partitionOverrideRaw = form.get('partitionCode')\n const partitionOverride =\n typeof partitionOverrideRaw === 'string' && partitionOverrideRaw.trim().length > 0\n ? sanitizePartitionCode(partitionOverrideRaw)\n : null\n const tags = parseFormTags(form.get('tags'))\n const assignmentsFromForm = parseFormAssignments(form.get('assignments'))\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const dataEngine = container.resolve('dataEngine')\n const storageDriverFactory =\n (container.resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)\n const orgId = await resolveAttachmentOrganizationId(container, auth, req)\n if (!orgId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n await ensureDefaultPartitions(em)\n // Optional per-field validations\n let partitionFromField: string | null = null\n let fieldMaxAttachmentSizeMb: number | null = null\n if (fieldKey) {\n try {\n const { CustomFieldDef } = await import('@open-mercato/core/modules/entities/data/entities')\n const def = await em.findOne(CustomFieldDef, {\n entityId,\n key: fieldKey,\n $and: [\n { $or: [ { tenantId: auth.tenantId }, { tenantId: null } ] },\n ],\n isActive: true,\n })\n const cfg = (def as any)?.configJson || {}\n const ext = (file.name || '').split('.').pop()?.toLowerCase() || ''\n if (Array.isArray(cfg.acceptExtensions) && cfg.acceptExtensions.length) {\n const allowed = new Set((cfg.acceptExtensions as any[]).map((x: any) => String(x).toLowerCase().replace(/^\\./, '')))\n if (!allowed.has(ext)) return NextResponse.json({ error: 'File type not allowed' }, { status: 400 })\n }\n if (typeof cfg.maxAttachmentSizeMb === 'number' && cfg.maxAttachmentSizeMb > 0) {\n fieldMaxAttachmentSizeMb = cfg.maxAttachmentSizeMb\n }\n if (typeof cfg.partitionCode === 'string' && cfg.partitionCode.trim().length > 0) {\n partitionFromField = sanitizePartitionCode(cfg.partitionCode)\n }\n } catch {}\n }\n if (hasDangerousExecutableExtension(file.name)) {\n return NextResponse.json({\n error: t('attachments.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.'),\n }, { status: 400 })\n }\n const effectiveMaxBytes = resolveAttachmentMaxBytes(fieldMaxAttachmentSizeMb)\n if (file.size > effectiveMaxBytes) {\n return NextResponse.json({\n error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),\n }, { status: 413 })\n }\n const tenantUsageBytes = await readTenantAttachmentUsageBytes(em, tenantId)\n if (willExceedAttachmentTenantQuota(tenantUsageBytes, file.size)) {\n return NextResponse.json({\n error: t('attachments.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.'),\n }, { status: 413 })\n }\n const buf = Buffer.from(await file.arrayBuffer())\n const safeName = sanitizeUploadedFileName(file.name)\n const fileMimeType = detectAttachmentMimeType(buf, safeName, (file as any).type)\n if (isActiveContentAttachment(buf, safeName, fileMimeType)) {\n return NextResponse.json({ error: t('attachments.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })\n }\n const defaultPartitionCode = resolveDefaultPartitionCode(entityId)\n const resolvedPartitionCode = partitionOverride ?? partitionFromField ?? defaultPartitionCode\n const partitionCodeCandidates = Array.from(\n new Set(\n [partitionOverride, partitionFromField, resolvedPartitionCode].filter(\n (code): code is string => typeof code === 'string' && code.length > 0,\n ),\n ),\n )\n let partition: AttachmentPartition | null = null\n for (const code of partitionCodeCandidates) {\n const record = await em.findOne(AttachmentPartition, { code })\n if (record) {\n partition = record\n break\n }\n }\n if (!partition) {\n partition = await em.findOne(AttachmentPartition, { code: defaultPartitionCode })\n }\n if (!partition) {\n return NextResponse.json({ error: 'Storage partition is not configured.' }, { status: 400 })\n }\n const requestedPublicOverride =\n typeof partitionOverride === 'string' &&\n partitionOverride.length > 0 &&\n partition.code === partitionOverride &&\n partition.isPublic === true &&\n partition.code !== defaultPartitionCode &&\n partition.code !== partitionFromField\n if (requestedPublicOverride) {\n return NextResponse.json({ error: t('attachments.errors.publicPartitionBlocked', 'Public storage partitions cannot be selected explicitly for this upload.') }, { status: 403 })\n }\n const uploadDriver = await storageDriverFactory.resolveForPartition(partition.code, { tenantId, organizationId: orgId })\n let storedPath: string\n try {\n const stored = await uploadDriver.store({\n partitionCode: partition.code,\n orgId,\n tenantId,\n fileName: safeName,\n buffer: buf,\n })\n storedPath = stored.storagePath\n } catch (error) {\n logger.error('Failed to persist file', { err: error })\n return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })\n }\n\n const requiresOcr =\n typeof (partition as any).requiresOcr === 'boolean'\n ? Boolean((partition as any).requiresOcr)\n : resolveDefaultAttachmentOcrEnabled()\n let extractedContent: string | null = null\n const wantsLlmOcr = requiresOcr && shouldUseLlmOcr(fileMimeType, safeName)\n const ocrService = wantsLlmOcr ? new OcrService() : null\n const useLlmOcr = Boolean(wantsLlmOcr && ocrService?.available)\n\n if (requiresOcr && !useLlmOcr) {\n const { filePath: localPath, cleanup } = await uploadDriver.toLocalPath(partition.code, storedPath)\n try {\n extractedContent = await extractAttachmentContent({\n filePath: localPath,\n mimeType: fileMimeType,\n })\n } catch (error) {\n logger.error('Failed to extract attachment content', { err: error })\n } finally {\n await cleanup().catch(() => {})\n }\n }\n\n let assignments = assignmentsFromForm.slice()\n if (entityId !== LIBRARY_ENTITY_ID) {\n assignments = upsertAssignment(assignments, { type: entityId, id: recordId })\n }\n const metadata = mergeAttachmentMetadata(null, { assignments, tags })\n const attachmentId = randomUUID()\n assertAttachmentScopeInvariant({ tenantId: auth.tenantId, organizationId: orgId })\n const att = em.create(Attachment, {\n id: attachmentId,\n entityId,\n recordId,\n organizationId: orgId,\n tenantId: auth.tenantId!,\n fileName: safeName,\n mimeType: fileMimeType,\n fileSize: buf.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 // Persist the attachment row and its custom-field values atomically so a\n // custom-field failure cannot leave behind a committed orphan attachment.\n try {\n await em.transactional(async (tx) => {\n await tx.persist(att).flush()\n if (dataEngine) {\n await setCustomFieldsIfAny({\n dataEngine,\n entityId: E.attachments.attachment,\n recordId: attachmentId,\n tenantId,\n organizationId: orgId,\n values: customFieldValues,\n })\n }\n })\n } catch (error) {\n logger.error('Failed to persist attachment with custom attributes', { err: error })\n return NextResponse.json({ error: 'Failed to save attachment attributes.' }, { status: 500 })\n }\n\n if (useLlmOcr) {\n requestOcrProcessing(em, att, uploadDriver, storedPath).catch((error) => {\n logger.error('Failed to queue OCR processing', { err: error })\n })\n } else if (wantsLlmOcr) {\n logger.warn('OCR requested but OPENAI_API_KEY not configured, falling back to text extraction when available')\n }\n\n if (dataEngine) {\n await emitCrudSideEffects({\n dataEngine,\n action: 'created',\n entity: att,\n identifiers: {\n id: att.id,\n organizationId: att.organizationId ?? null,\n tenantId: att.tenantId ?? null,\n },\n events: attachmentCrudEvents,\n indexer: attachmentCrudIndexer,\n })\n await dataEngine.flushOrmEntityChanges()\n }\n\n return NextResponse.json({\n ok: true,\n item: {\n id: attachmentId,\n url: att.url,\n fileName: safeName,\n fileSize: buf.length,\n partitionCode: partition.code,\n thumbnailUrl: buildAttachmentImageUrl(attachmentId, {\n width: 320,\n height: 320,\n slug: slugifyAttachmentFileName(safeName),\n }),\n content: extractedContent ?? null,\n tags: metadata.tags ?? [],\n assignments: metadata.assignments ?? [],\n customFields: Object.keys(customFieldValues).length ? customFieldValues : undefined,\n },\n })\n}\n\nasync function readTenantAttachmentUsageBytes(em: EntityManager, tenantId: string): Promise<number> {\n try {\n const db = em.getKysely<any>() as any\n const row = await db\n .selectFrom('attachments')\n .select(sql<string>`sum(file_size)`.as('total_size'))\n .where('tenant_id', '=', tenantId)\n .executeTakeFirst() as { total_size: string | number | null } | undefined\n const total = row?.total_size\n if (typeof total === 'number') return Number.isFinite(total) ? total : 0\n if (typeof total === 'string') {\n const parsed = Number(total)\n return Number.isFinite(parsed) ? parsed : 0\n }\n return 0\n } catch {\n return 0\n }\n}\n\nexport async function DELETE(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n const url = new URL(req.url)\n const id = url.searchParams.get('id') || ''\n if (!id) return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const dataEngine = container.resolve('dataEngine')\n const storageDriverFactory =\n (container.resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)\n // Resolve the currently selected organization (#3765) so a multi-org admin who\n // switched the header org deletes within that org, not their pinned home org.\n // A superadmin browsing with \"All organizations\" selected has no concrete org,\n // so resolution returns null and the delete falls back to a tenant-only scope\n // (#3764) \u2014 letting them remove any attachment in the tenant.\n const orgId = await resolveAttachmentOrganizationId(container, auth, req)\n const deleteFilter: Record<string, unknown> = { id, tenantId: auth.tenantId! }\n if (orgId) deleteFilter.organizationId = orgId\n const record = await em.findOne(Attachment, deleteFilter)\n if (!record) return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })\n await em.remove(record).flush()\n await clearAttachmentThumbnailCache(record.partitionCode, record.id).catch((error) => {\n logger.error('Failed to cleanup cached thumbnails', { err: error })\n })\n if (record.storagePath) {\n const delDriver = await storageDriverFactory.resolveForPartition(record.partitionCode, {\n tenantId: record.tenantId ?? auth.tenantId!,\n organizationId: record.organizationId ?? orgId ?? '',\n })\n await delDriver.delete(record.partitionCode, record.storagePath)\n }\n if (dataEngine) {\n await emitCrudSideEffects({\n dataEngine,\n action: 'deleted',\n entity: record,\n identifiers: {\n id: record.id,\n organizationId: record.organizationId ?? null,\n tenantId: record.tenantId ?? null,\n },\n events: attachmentCrudEvents,\n indexer: attachmentCrudIndexer,\n })\n await dataEngine.flushOrmEntityChanges()\n }\n return NextResponse.json({ ok: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Manage entity attachments',\n description: 'Upload and list attachments associated with module entities and records.',\n methods: {\n GET: {\n summary: 'List attachments for a record',\n description: 'Returns uploaded attachments for the given entity record, ordered by newest first.',\n query: attachmentQuerySchema,\n responses: [\n { status: 200, description: 'Attachments found for the record', schema: attachmentListResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Missing entity or record identifiers', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n POST: {\n summary: 'Upload attachment',\n description: 'Uploads a new attachment using multipart form-data and stores metadata for later retrieval.',\n requestBody: {\n contentType: 'multipart/form-data',\n schema: attachmentUploadBodySchema,\n },\n responses: [\n { status: 200, description: 'Attachment stored successfully', schema: uploadResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Payload validation error', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 403, description: 'Attachment violates field constraints', schema: errorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete attachment',\n description: 'Removes an uploaded attachment and deletes the stored asset.',\n query: attachmentDeleteQuerySchema,\n responses: [\n { status: 200, description: 'Attachment deleted', schema: z.object({ ok: z.literal(true) }) },\n { status: 404, description: 'Attachment not found', schema: errorSchema },\n ],\n errors: [\n { status: 400, description: 'Missing attachment identifier', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["metadata"]
|
|
7
7
|
}
|
|
@@ -39,11 +39,44 @@ function isMultipartRequestWithinUploadLimit(contentLengthHeader) {
|
|
|
39
39
|
if (!Number.isFinite(contentLength) || contentLength <= 0) return true;
|
|
40
40
|
return contentLength <= resolveDefaultAttachmentMaxUploadBytes() + MULTIPART_CONTENT_LENGTH_OVERHEAD_BYTES;
|
|
41
41
|
}
|
|
42
|
+
class MultipartUploadLimitError extends Error {
|
|
43
|
+
constructor() {
|
|
44
|
+
super("Multipart request exceeds the maximum upload size.");
|
|
45
|
+
this.name = "MultipartUploadLimitError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function isMultipartUploadLimitError(error) {
|
|
49
|
+
return error instanceof MultipartUploadLimitError;
|
|
50
|
+
}
|
|
51
|
+
async function parseMultipartFormDataWithinUploadLimit(request) {
|
|
52
|
+
if (!isMultipartRequestWithinUploadLimit(request.headers.get("content-length"))) {
|
|
53
|
+
throw new MultipartUploadLimitError();
|
|
54
|
+
}
|
|
55
|
+
if (!request.body) return request.formData();
|
|
56
|
+
const maxBytes = resolveDefaultAttachmentMaxUploadBytes() + MULTIPART_CONTENT_LENGTH_OVERHEAD_BYTES;
|
|
57
|
+
let consumedBytes = 0;
|
|
58
|
+
const boundedBody = request.body.pipeThrough(new TransformStream({
|
|
59
|
+
transform(chunk, controller) {
|
|
60
|
+
consumedBytes += chunk.byteLength;
|
|
61
|
+
if (consumedBytes > maxBytes) {
|
|
62
|
+
controller.error(new MultipartUploadLimitError());
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
controller.enqueue(chunk);
|
|
66
|
+
}
|
|
67
|
+
}));
|
|
68
|
+
const contentType = request.headers.get("content-type");
|
|
69
|
+
const headers = contentType ? { "content-type": contentType } : void 0;
|
|
70
|
+
return new Response(boundedBody, { headers }).formData();
|
|
71
|
+
}
|
|
42
72
|
function willExceedAttachmentTenantQuota(currentUsageBytes, incomingFileBytes) {
|
|
43
73
|
return currentUsageBytes + incomingFileBytes > resolveAttachmentTenantQuotaBytes();
|
|
44
74
|
}
|
|
45
75
|
export {
|
|
76
|
+
MultipartUploadLimitError,
|
|
46
77
|
isMultipartRequestWithinUploadLimit,
|
|
78
|
+
isMultipartUploadLimitError,
|
|
79
|
+
parseMultipartFormDataWithinUploadLimit,
|
|
47
80
|
resolveAttachmentMaxBytes,
|
|
48
81
|
resolveAttachmentTenantQuotaBytes,
|
|
49
82
|
resolveDefaultAttachmentMaxUploadBytes,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/attachments/lib/upload-limits.ts"],
|
|
4
|
-
"sourcesContent": ["const DEFAULT_ATTACHMENT_MAX_UPLOAD_MB = 25\nconst DEFAULT_ATTACHMENT_TENANT_QUOTA_MB = 512\nconst MULTIPART_CONTENT_LENGTH_OVERHEAD_BYTES = 1024 * 1024\nconst ATTACHMENT_MAX_UPLOAD_MB_ENV_KEYS = [\n 'OM_ATTACHMENT_MAX_UPLOAD_MB',\n 'OPENMERCATO_ATTACHMENT_MAX_UPLOAD_MB',\n] as const\nconst ATTACHMENT_TENANT_QUOTA_MB_ENV_KEYS = [\n 'OM_ATTACHMENT_TENANT_QUOTA_MB',\n 'OPENMERCATO_ATTACHMENT_TENANT_QUOTA_MB',\n] as const\n\nfunction readEnvValue(names: readonly string[]): string | undefined {\n return names.map((name) => process.env[name]).find((value) => typeof value === 'string')\n}\n\nfunction parseMegabytesEnv(names: readonly string[], fallbackMb: number): number {\n const raw = readEnvValue(names)\n const parsed = raw ? Number(raw) : NaN\n if (Number.isFinite(parsed) && parsed > 0) {\n return parsed\n }\n return fallbackMb\n}\n\nexport function resolveDefaultAttachmentMaxUploadBytes(): number {\n return Math.floor(parseMegabytesEnv(ATTACHMENT_MAX_UPLOAD_MB_ENV_KEYS, DEFAULT_ATTACHMENT_MAX_UPLOAD_MB) * 1024 * 1024)\n}\n\nexport function resolveAttachmentTenantQuotaBytes(): number {\n return Math.floor(parseMegabytesEnv(ATTACHMENT_TENANT_QUOTA_MB_ENV_KEYS, DEFAULT_ATTACHMENT_TENANT_QUOTA_MB) * 1024 * 1024)\n}\n\nexport function resolveAttachmentMaxBytes(fieldMaxAttachmentSizeMb?: number | null): number {\n const defaultMaxBytes = resolveDefaultAttachmentMaxUploadBytes()\n if (typeof fieldMaxAttachmentSizeMb !== 'number' || fieldMaxAttachmentSizeMb <= 0) {\n return defaultMaxBytes\n }\n return Math.min(defaultMaxBytes, Math.floor(fieldMaxAttachmentSizeMb * 1024 * 1024))\n}\n\nexport function isMultipartRequestWithinUploadLimit(contentLengthHeader: string | null): boolean {\n if (!contentLengthHeader) return true\n const contentLength = Number(contentLengthHeader)\n if (!Number.isFinite(contentLength) || contentLength <= 0) return true\n return contentLength <= (resolveDefaultAttachmentMaxUploadBytes() + MULTIPART_CONTENT_LENGTH_OVERHEAD_BYTES)\n}\n\nexport function willExceedAttachmentTenantQuota(currentUsageBytes: number, incomingFileBytes: number): boolean {\n return (currentUsageBytes + incomingFileBytes) > resolveAttachmentTenantQuotaBytes()\n}\n"],
|
|
5
|
-
"mappings": "AAAA,MAAM,mCAAmC;AACzC,MAAM,qCAAqC;AAC3C,MAAM,0CAA0C,OAAO;AACvD,MAAM,oCAAoC;AAAA,EACxC;AAAA,EACA;AACF;AACA,MAAM,sCAAsC;AAAA,EAC1C;AAAA,EACA;AACF;AAEA,SAAS,aAAa,OAA8C;AAClE,SAAO,MAAM,IAAI,CAAC,SAAS,QAAQ,IAAI,IAAI,CAAC,EAAE,KAAK,CAAC,UAAU,OAAO,UAAU,QAAQ;AACzF;AAEA,SAAS,kBAAkB,OAA0B,YAA4B;AAC/E,QAAM,MAAM,aAAa,KAAK;AAC9B,QAAM,SAAS,MAAM,OAAO,GAAG,IAAI;AACnC,MAAI,OAAO,SAAS,MAAM,KAAK,SAAS,GAAG;AACzC,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAEO,SAAS,yCAAiD;AAC/D,SAAO,KAAK,MAAM,kBAAkB,mCAAmC,gCAAgC,IAAI,OAAO,IAAI;AACxH;AAEO,SAAS,oCAA4C;AAC1D,SAAO,KAAK,MAAM,kBAAkB,qCAAqC,kCAAkC,IAAI,OAAO,IAAI;AAC5H;AAEO,SAAS,0BAA0B,0BAAkD;AAC1F,QAAM,kBAAkB,uCAAuC;AAC/D,MAAI,OAAO,6BAA6B,YAAY,4BAA4B,GAAG;AACjF,WAAO;AAAA,EACT;AACA,SAAO,KAAK,IAAI,iBAAiB,KAAK,MAAM,2BAA2B,OAAO,IAAI,CAAC;AACrF;AAEO,SAAS,oCAAoC,qBAA6C;AAC/F,MAAI,CAAC,oBAAqB,QAAO;AACjC,QAAM,gBAAgB,OAAO,mBAAmB;AAChD,MAAI,CAAC,OAAO,SAAS,aAAa,KAAK,iBAAiB,EAAG,QAAO;AAClE,SAAO,iBAAkB,uCAAuC,IAAI;AACtE;AAEO,SAAS,gCAAgC,mBAA2B,mBAAoC;AAC7G,SAAQ,oBAAoB,oBAAqB,kCAAkC;AACrF;",
|
|
4
|
+
"sourcesContent": ["const DEFAULT_ATTACHMENT_MAX_UPLOAD_MB = 25\nconst DEFAULT_ATTACHMENT_TENANT_QUOTA_MB = 512\nconst MULTIPART_CONTENT_LENGTH_OVERHEAD_BYTES = 1024 * 1024\nconst ATTACHMENT_MAX_UPLOAD_MB_ENV_KEYS = [\n 'OM_ATTACHMENT_MAX_UPLOAD_MB',\n 'OPENMERCATO_ATTACHMENT_MAX_UPLOAD_MB',\n] as const\nconst ATTACHMENT_TENANT_QUOTA_MB_ENV_KEYS = [\n 'OM_ATTACHMENT_TENANT_QUOTA_MB',\n 'OPENMERCATO_ATTACHMENT_TENANT_QUOTA_MB',\n] as const\n\nfunction readEnvValue(names: readonly string[]): string | undefined {\n return names.map((name) => process.env[name]).find((value) => typeof value === 'string')\n}\n\nfunction parseMegabytesEnv(names: readonly string[], fallbackMb: number): number {\n const raw = readEnvValue(names)\n const parsed = raw ? Number(raw) : NaN\n if (Number.isFinite(parsed) && parsed > 0) {\n return parsed\n }\n return fallbackMb\n}\n\nexport function resolveDefaultAttachmentMaxUploadBytes(): number {\n return Math.floor(parseMegabytesEnv(ATTACHMENT_MAX_UPLOAD_MB_ENV_KEYS, DEFAULT_ATTACHMENT_MAX_UPLOAD_MB) * 1024 * 1024)\n}\n\nexport function resolveAttachmentTenantQuotaBytes(): number {\n return Math.floor(parseMegabytesEnv(ATTACHMENT_TENANT_QUOTA_MB_ENV_KEYS, DEFAULT_ATTACHMENT_TENANT_QUOTA_MB) * 1024 * 1024)\n}\n\nexport function resolveAttachmentMaxBytes(fieldMaxAttachmentSizeMb?: number | null): number {\n const defaultMaxBytes = resolveDefaultAttachmentMaxUploadBytes()\n if (typeof fieldMaxAttachmentSizeMb !== 'number' || fieldMaxAttachmentSizeMb <= 0) {\n return defaultMaxBytes\n }\n return Math.min(defaultMaxBytes, Math.floor(fieldMaxAttachmentSizeMb * 1024 * 1024))\n}\n\nexport function isMultipartRequestWithinUploadLimit(contentLengthHeader: string | null): boolean {\n if (!contentLengthHeader) return true\n const contentLength = Number(contentLengthHeader)\n if (!Number.isFinite(contentLength) || contentLength <= 0) return true\n return contentLength <= (resolveDefaultAttachmentMaxUploadBytes() + MULTIPART_CONTENT_LENGTH_OVERHEAD_BYTES)\n}\n\nexport class MultipartUploadLimitError extends Error {\n constructor() {\n super('Multipart request exceeds the maximum upload size.')\n this.name = 'MultipartUploadLimitError'\n }\n}\n\nexport function isMultipartUploadLimitError(error: unknown): error is MultipartUploadLimitError {\n return error instanceof MultipartUploadLimitError\n}\n\nexport async function parseMultipartFormDataWithinUploadLimit(request: Request): Promise<FormData> {\n if (!isMultipartRequestWithinUploadLimit(request.headers.get('content-length'))) {\n throw new MultipartUploadLimitError()\n }\n\n if (!request.body) return request.formData()\n\n const maxBytes = resolveDefaultAttachmentMaxUploadBytes() + MULTIPART_CONTENT_LENGTH_OVERHEAD_BYTES\n let consumedBytes = 0\n const boundedBody = request.body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({\n transform(chunk, controller) {\n consumedBytes += chunk.byteLength\n if (consumedBytes > maxBytes) {\n controller.error(new MultipartUploadLimitError())\n return\n }\n controller.enqueue(chunk)\n },\n }))\n const contentType = request.headers.get('content-type')\n const headers = contentType ? { 'content-type': contentType } : undefined\n return new Response(boundedBody, { headers }).formData()\n}\n\nexport function willExceedAttachmentTenantQuota(currentUsageBytes: number, incomingFileBytes: number): boolean {\n return (currentUsageBytes + incomingFileBytes) > resolveAttachmentTenantQuotaBytes()\n}\n"],
|
|
5
|
+
"mappings": "AAAA,MAAM,mCAAmC;AACzC,MAAM,qCAAqC;AAC3C,MAAM,0CAA0C,OAAO;AACvD,MAAM,oCAAoC;AAAA,EACxC;AAAA,EACA;AACF;AACA,MAAM,sCAAsC;AAAA,EAC1C;AAAA,EACA;AACF;AAEA,SAAS,aAAa,OAA8C;AAClE,SAAO,MAAM,IAAI,CAAC,SAAS,QAAQ,IAAI,IAAI,CAAC,EAAE,KAAK,CAAC,UAAU,OAAO,UAAU,QAAQ;AACzF;AAEA,SAAS,kBAAkB,OAA0B,YAA4B;AAC/E,QAAM,MAAM,aAAa,KAAK;AAC9B,QAAM,SAAS,MAAM,OAAO,GAAG,IAAI;AACnC,MAAI,OAAO,SAAS,MAAM,KAAK,SAAS,GAAG;AACzC,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAEO,SAAS,yCAAiD;AAC/D,SAAO,KAAK,MAAM,kBAAkB,mCAAmC,gCAAgC,IAAI,OAAO,IAAI;AACxH;AAEO,SAAS,oCAA4C;AAC1D,SAAO,KAAK,MAAM,kBAAkB,qCAAqC,kCAAkC,IAAI,OAAO,IAAI;AAC5H;AAEO,SAAS,0BAA0B,0BAAkD;AAC1F,QAAM,kBAAkB,uCAAuC;AAC/D,MAAI,OAAO,6BAA6B,YAAY,4BAA4B,GAAG;AACjF,WAAO;AAAA,EACT;AACA,SAAO,KAAK,IAAI,iBAAiB,KAAK,MAAM,2BAA2B,OAAO,IAAI,CAAC;AACrF;AAEO,SAAS,oCAAoC,qBAA6C;AAC/F,MAAI,CAAC,oBAAqB,QAAO;AACjC,QAAM,gBAAgB,OAAO,mBAAmB;AAChD,MAAI,CAAC,OAAO,SAAS,aAAa,KAAK,iBAAiB,EAAG,QAAO;AAClE,SAAO,iBAAkB,uCAAuC,IAAI;AACtE;AAEO,MAAM,kCAAkC,MAAM;AAAA,EACnD,cAAc;AACZ,UAAM,oDAAoD;AAC1D,SAAK,OAAO;AAAA,EACd;AACF;AAEO,SAAS,4BAA4B,OAAoD;AAC9F,SAAO,iBAAiB;AAC1B;AAEA,eAAsB,wCAAwC,SAAqC;AACjG,MAAI,CAAC,oCAAoC,QAAQ,QAAQ,IAAI,gBAAgB,CAAC,GAAG;AAC/E,UAAM,IAAI,0BAA0B;AAAA,EACtC;AAEA,MAAI,CAAC,QAAQ,KAAM,QAAO,QAAQ,SAAS;AAE3C,QAAM,WAAW,uCAAuC,IAAI;AAC5D,MAAI,gBAAgB;AACpB,QAAM,cAAc,QAAQ,KAAK,YAAY,IAAI,gBAAwC;AAAA,IACvF,UAAU,OAAO,YAAY;AAC3B,uBAAiB,MAAM;AACvB,UAAI,gBAAgB,UAAU;AAC5B,mBAAW,MAAM,IAAI,0BAA0B,CAAC;AAChD;AAAA,MACF;AACA,iBAAW,QAAQ,KAAK;AAAA,IAC1B;AAAA,EACF,CAAC,CAAC;AACF,QAAM,cAAc,QAAQ,QAAQ,IAAI,cAAc;AACtD,QAAM,UAAU,cAAc,EAAE,gBAAgB,YAAY,IAAI;AAChE,SAAO,IAAI,SAAS,aAAa,EAAE,QAAQ,CAAC,EAAE,SAAS;AACzD;AAEO,SAAS,gCAAgC,mBAA2B,mBAAoC;AAC7G,SAAQ,oBAAoB,oBAAqB,kCAAkC;AACrF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -3,7 +3,14 @@ import { z } from "zod";
|
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
5
|
import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
|
|
6
|
-
import {
|
|
6
|
+
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
7
|
+
import {
|
|
8
|
+
ChannelThreadMapping,
|
|
9
|
+
CommunicationChannel,
|
|
10
|
+
ExternalConversation,
|
|
11
|
+
MessageChannelLink
|
|
12
|
+
} from "../../../data/entities.js";
|
|
13
|
+
import { ChannelAccessDeniedError, assertCanManageChannel } from "../../../lib/access-control.js";
|
|
7
14
|
import {
|
|
8
15
|
COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID
|
|
9
16
|
} from "../../../commands/connect-credential-channel.js";
|
|
@@ -122,6 +129,46 @@ async function POST(req) {
|
|
|
122
129
|
}
|
|
123
130
|
const em = container.resolve("em").fork();
|
|
124
131
|
const providerKey = body.providerKey ?? TEST_SEED_PROVIDER_KEY;
|
|
132
|
+
const ownedChannel = await findOneWithDecryption(
|
|
133
|
+
em,
|
|
134
|
+
CommunicationChannel,
|
|
135
|
+
{
|
|
136
|
+
id: body.channelId,
|
|
137
|
+
tenantId,
|
|
138
|
+
organizationId,
|
|
139
|
+
deletedAt: null
|
|
140
|
+
},
|
|
141
|
+
void 0,
|
|
142
|
+
{ tenantId, organizationId }
|
|
143
|
+
);
|
|
144
|
+
if (!ownedChannel) {
|
|
145
|
+
return NextResponse.json({ error: "Channel not found" }, { status: 404 });
|
|
146
|
+
}
|
|
147
|
+
let userFeatures = [];
|
|
148
|
+
try {
|
|
149
|
+
const rbac = container.resolve("rbacService");
|
|
150
|
+
const acl = await rbac.loadAcl(userId, { tenantId, organizationId });
|
|
151
|
+
userFeatures = acl?.isSuperAdmin ? ["*"] : Array.isArray(acl?.features) ? acl.features : [];
|
|
152
|
+
} catch {
|
|
153
|
+
userFeatures = [];
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
assertCanManageChannel(
|
|
157
|
+
{ userId: ownedChannel.userId },
|
|
158
|
+
userId,
|
|
159
|
+
userFeatures,
|
|
160
|
+
"communication_channels.manage"
|
|
161
|
+
);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err instanceof ChannelAccessDeniedError) {
|
|
164
|
+
return NextResponse.json({ error: "Channel not found" }, { status: 404 });
|
|
165
|
+
}
|
|
166
|
+
const status = err.statusCode ?? 403;
|
|
167
|
+
return NextResponse.json(
|
|
168
|
+
{ error: err instanceof Error ? err.message : "Access denied" },
|
|
169
|
+
{ status }
|
|
170
|
+
);
|
|
171
|
+
}
|
|
125
172
|
const conversation = em.create(ExternalConversation, {
|
|
126
173
|
channelId: body.channelId,
|
|
127
174
|
externalConversationId: `inbound-seed:${Date.now()}:${Math.random().toString(16).slice(2, 8)}`,
|
|
@@ -221,7 +268,10 @@ const openApi = {
|
|
|
221
268
|
responses: [
|
|
222
269
|
{ status: 201, description: "Channel seeded / inbound message emitted" },
|
|
223
270
|
{ status: 401, description: "Unauthorized" },
|
|
224
|
-
{
|
|
271
|
+
{
|
|
272
|
+
status: 404,
|
|
273
|
+
description: "Test channel seeding disabled (production default), or the requested channel does not belong to the caller"
|
|
274
|
+
},
|
|
225
275
|
{ status: 422, description: "Invalid request body" },
|
|
226
276
|
{ status: 500, description: "Seed failed" }
|
|
227
277
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/communication_channels/api/post/test-seed/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { ChannelThreadMapping, ExternalConversation, MessageChannelLink } from '../../../data/entities'\nimport {\n COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID,\n type ConnectCredentialChannelInput,\n type ConnectCredentialChannelResult,\n} from '../../../commands/connect-credential-channel'\nimport { emitCommunicationChannelsEvent } from '../../../events'\nimport {\n TEST_SEED_PROVIDER_KEY,\n ensureTestSeedAdapterRegistered,\n isTestChannelSeedingEnabled,\n} from '../../../lib/test-seed'\n\n/**\n * TEST-ONLY channel seeding endpoint.\n *\n * Gated by `OM_ENABLE_TEST_CHANNEL_SEEDING` \u2014 when the flag is unset (the\n * production default) every request returns 404, so this route is invisible and\n * inert in production. See `lib/test-seed.ts` for the full rationale.\n *\n * Two actions, both scoped to the caller's tenant/org:\n * - `connect-channel`: connect a network-free `__test_seed__` channel owned by\n * the caller (delegates to the real connect-credential command so the channel\n * persists credentials + lands in `status='connected'`). Enables the outbound\n * compose \u2192 deliver \u2192 `.sent` chain to complete in CI.\n * - `emit-inbound`: insert an inbound `MessageChannelLink` (+ a `messages.message`\n * row for threading) and emit `communication_channels.message.received` so the\n * customers link-channel-message subscriber runs against real Postgres. Enables\n * the inbound auto-link tests (TC-CRM-EMAIL-002..005).\n */\nexport const metadata = {\n path: '/communication_channels/test-seed',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst addressObjectSchema = z.object({ address: z.string(), name: z.string().optional() })\nconst addressFieldSchema = z.union([\n z.string(),\n addressObjectSchema,\n z.array(z.union([z.string(), addressObjectSchema])),\n])\n\nconst connectChannelSchema = z.object({\n action: z.literal('connect-channel'),\n displayName: z.string().min(1).max(255).optional(),\n externalIdentifier: z.string().min(1).max(255).optional(),\n})\n\nconst emitInboundSchema = z.object({\n action: z.literal('emit-inbound'),\n /** Channel that owns the inbound message; controls authorUserId + default visibility. */\n channelId: z.string().uuid(),\n /** Provider key persisted on the link (defaults to the stub provider). */\n providerKey: z.string().min(1).max(64).optional(),\n /** Normalized inbound addresses (stored under channelPayload). */\n from: addressFieldSchema.optional(),\n to: addressFieldSchema.optional(),\n cc: addressFieldSchema.optional(),\n subject: z.string().max(500).optional(),\n bodyText: z.string().max(200_000).optional(),\n /** RFC2822 Message-ID of this inbound message (for In-Reply-To matching). */\n messageId: z.string().max(500).optional(),\n /** RFC2822 In-Reply-To header (threading-inheritance fallback). */\n inReplyTo: z.string().max(500).optional(),\n references: z.array(z.string().max(500)).max(50).optional(),\n /**\n * Open Mercato `messages.message` thread id this inbound message belongs to.\n * When set, a `messages.message` row is created with this `threadId` so the\n * hub-thread inheritance join can resolve a Person from a sibling message.\n */\n messageThreadId: z.string().uuid().optional(),\n /**\n * Test-only: also create a `ChannelThreadMapping` for the seeded thread. The\n * reaction (`/messages/[id]/reactions`) and thread-assign\n * (`/threads/[id]/assign`) routes resolve the owning channel through this\n * mapping and return 409/404 without it. Opt-in so the existing CRM-link\n * seeds (which don't need a mapping) keep their current mapping-free shape.\n */\n createThreadMapping: z.boolean().optional(),\n})\n\nconst bodySchema = z.discriminatedUnion('action', [connectChannelSchema, emitInboundSchema])\n\nexport async function POST(req: Request): Promise<Response> {\n // Fail-closed: invisible in production. Mirrors an unknown route (404) rather\n // than 403 so the surface leaks nothing when the flag is off.\n if (!isTestChannelSeedingEnabled()) {\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof bodySchema>\n try {\n body = bodySchema.parse(await readJsonSafe(req, null))\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n // Defensive: make sure the stub adapter is registered for this process even if\n // a worker-only node skipped module di registration.\n ensureTestSeedAdapterRegistered()\n\n const tenantId = auth.tenantId as string\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n const userId = auth.sub as string\n\n if (body.action === 'connect-channel') {\n const stamp = Date.now()\n const commandBus = container.resolve('commandBus') as CommandBus\n const input: ConnectCredentialChannelInput = {\n providerKey: TEST_SEED_PROVIDER_KEY,\n displayName: body.displayName ?? `Test Seed Channel ${stamp}`,\n credentials: {\n username: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,\n fromAddress: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,\n },\n userId,\n scope: { tenantId, organizationId },\n }\n const { result } = await commandBus.execute<\n ConnectCredentialChannelInput,\n ConnectCredentialChannelResult\n >(COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID, {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: organizationId,\n organizationIds: organizationId ? [organizationId] : null,\n },\n })\n if (result.status !== 'connected') {\n return NextResponse.json(\n { error: '[internal] test-seed connect failed', detail: result },\n { status: 500 },\n )\n }\n return NextResponse.json(\n { channelId: result.channelId, externalIdentifier: result.externalIdentifier },\n { status: 201 },\n )\n }\n\n // action === 'emit-inbound'\n const em = (container.resolve('em') as EntityManager).fork()\n const providerKey = body.providerKey ?? TEST_SEED_PROVIDER_KEY\n\n // A MessageChannelLink requires a non-null external_conversation_id (FK) and\n // message_id. Create a synthetic conversation + (optionally threaded) message\n // so the link is shaped like a real inbound row the subscriber can consume.\n const conversation = em.create(ExternalConversation, {\n channelId: body.channelId,\n externalConversationId: `inbound-seed:${Date.now()}:${Math.random().toString(16).slice(2, 8)}`,\n subject: body.subject ?? null,\n tenantId,\n organizationId,\n lastMessageAt: new Date(),\n })\n em.persist(conversation)\n await em.flush()\n\n // Insert the platform `messages.message` row via raw SQL rather than importing\n // the messages module's entity class (cross-module ORM coupling rule). Only\n // `thread_id` matters for the hub-thread inheritance join (TC-CRM-EMAIL-005);\n // the rest satisfy NOT NULL constraints.\n const messageRows = (await em.getConnection().execute(\n `INSERT INTO messages\n (type, thread_id, sender_user_id, subject, body, body_format, priority, status,\n is_draft, sent_at, visibility, source_entity_type, source_entity_id,\n tenant_id, organization_id, created_at, updated_at)\n VALUES\n (?, ?, ?, ?, ?, 'text', 'normal', 'sent',\n false, now(), 'public', 'communication_channels.test_seed_inbound', ?,\n ?, ?, now(), now())\n RETURNING id`,\n [\n `channel.${providerKey}`,\n body.messageThreadId ?? null,\n userId,\n body.subject ?? '(no subject)',\n body.bodyText ?? '',\n body.channelId,\n tenantId,\n organizationId,\n ],\n )) as Array<{ id: string }>\n const messageId = messageRows[0]?.id\n if (!messageId) {\n return NextResponse.json({ error: '[internal] failed to seed message row' }, { status: 500 })\n }\n\n const link = em.create(MessageChannelLink, {\n messageId,\n externalConversationId: conversation.id,\n providerKey,\n channelType: 'email',\n direction: 'inbound',\n deliveryStatus: 'delivered',\n channelPayload: {\n ...(body.from !== undefined ? { from: body.from } : {}),\n ...(body.to !== undefined ? { to: body.to } : {}),\n ...(body.cc !== undefined ? { cc: body.cc } : {}),\n ...(body.subject !== undefined ? { subject: body.subject } : {}),\n ...(body.bodyText !== undefined ? { text: body.bodyText } : {}),\n ...(body.inReplyTo !== undefined ? { inReplyTo: body.inReplyTo } : {}),\n ...(body.references !== undefined ? { references: body.references } : {}),\n },\n channelContentType: 'text/plain',\n channelMetadata: {\n ...(body.messageId !== undefined ? { messageId: body.messageId } : {}),\n },\n tenantId,\n organizationId,\n })\n em.persist(link)\n await em.flush()\n\n // Optionally mirror `ingest-inbound-message`: a real inbound message always\n // lands a ChannelThreadMapping that the reaction + thread-assign routes use to\n // resolve the owning channel. Seeded inbound messages skip it by default; opt\n // in for tests that exercise those routes. Keyed by `messageThreadId ?? messageId`\n // to match how those commands resolve the mapping (`message.threadId ?? message.id`).\n if (body.createThreadMapping) {\n const mapping = em.create(ChannelThreadMapping, {\n externalConversationId: conversation.id,\n messageThreadId: body.messageThreadId ?? messageId,\n channelId: body.channelId,\n providerKey,\n externalThreadRef: conversation.externalConversationId,\n tenantId,\n organizationId,\n })\n em.persist(mapping)\n await em.flush()\n }\n\n // Emit the hub event through the real event bus so the persistent customers\n // link-channel-message-received subscriber is enqueued to the `events` queue.\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.received',\n {\n channelLinkId: link.id,\n channelId: body.channelId,\n providerKey,\n direction: 'inbound',\n tenantId,\n organizationId,\n },\n { persistent: true },\n )\n\n return NextResponse.json(\n { channelLinkId: link.id, messageId, conversationId: conversation.id },\n { status: 201 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Test-only: seed a connected channel or emit an inbound message (env-gated)',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 201, description: 'Channel seeded / inbound message emitted' },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Test channel seeding disabled (production default)' },\n { status: 422, description: 'Invalid request body' },\n { status: 500, description: 'Seed failed' },\n ],\n },\n },\n}\n\nexport default POST\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AAEvC,SAAS,oBAAoB;AAC7B,SAAS,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalConversation,\n MessageChannelLink,\n} from '../../../data/entities'\nimport { ChannelAccessDeniedError, assertCanManageChannel } from '../../../lib/access-control'\nimport {\n COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID,\n type ConnectCredentialChannelInput,\n type ConnectCredentialChannelResult,\n} from '../../../commands/connect-credential-channel'\nimport { emitCommunicationChannelsEvent } from '../../../events'\nimport {\n TEST_SEED_PROVIDER_KEY,\n ensureTestSeedAdapterRegistered,\n isTestChannelSeedingEnabled,\n} from '../../../lib/test-seed'\n\n/**\n * TEST-ONLY channel seeding endpoint.\n *\n * Gated by `OM_ENABLE_TEST_CHANNEL_SEEDING` \u2014 when the flag is unset (the\n * production default) every request returns 404, so this route is invisible and\n * inert in production. See `lib/test-seed.ts` for the full rationale.\n *\n * Two actions, both scoped to the caller's tenant/org:\n * - `connect-channel`: connect a network-free `__test_seed__` channel owned by\n * the caller (delegates to the real connect-credential command so the channel\n * persists credentials + lands in `status='connected'`). Enables the outbound\n * compose \u2192 deliver \u2192 `.sent` chain to complete in CI.\n * - `emit-inbound`: insert an inbound `MessageChannelLink` (+ a `messages.message`\n * row for threading) and emit `communication_channels.message.received` so the\n * customers link-channel-message subscriber runs against real Postgres. Enables\n * the inbound auto-link tests (TC-CRM-EMAIL-002..005).\n */\ntype RbacServiceLike = {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>\n}\n\nexport const metadata = {\n path: '/communication_channels/test-seed',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst addressObjectSchema = z.object({ address: z.string(), name: z.string().optional() })\nconst addressFieldSchema = z.union([\n z.string(),\n addressObjectSchema,\n z.array(z.union([z.string(), addressObjectSchema])),\n])\n\nconst connectChannelSchema = z.object({\n action: z.literal('connect-channel'),\n displayName: z.string().min(1).max(255).optional(),\n externalIdentifier: z.string().min(1).max(255).optional(),\n})\n\nconst emitInboundSchema = z.object({\n action: z.literal('emit-inbound'),\n /** Channel that owns the inbound message; controls authorUserId + default visibility. */\n channelId: z.string().uuid(),\n /** Provider key persisted on the link (defaults to the stub provider). */\n providerKey: z.string().min(1).max(64).optional(),\n /** Normalized inbound addresses (stored under channelPayload). */\n from: addressFieldSchema.optional(),\n to: addressFieldSchema.optional(),\n cc: addressFieldSchema.optional(),\n subject: z.string().max(500).optional(),\n bodyText: z.string().max(200_000).optional(),\n /** RFC2822 Message-ID of this inbound message (for In-Reply-To matching). */\n messageId: z.string().max(500).optional(),\n /** RFC2822 In-Reply-To header (threading-inheritance fallback). */\n inReplyTo: z.string().max(500).optional(),\n references: z.array(z.string().max(500)).max(50).optional(),\n /**\n * Open Mercato `messages.message` thread id this inbound message belongs to.\n * When set, a `messages.message` row is created with this `threadId` so the\n * hub-thread inheritance join can resolve a Person from a sibling message.\n */\n messageThreadId: z.string().uuid().optional(),\n /**\n * Test-only: also create a `ChannelThreadMapping` for the seeded thread. The\n * reaction (`/messages/[id]/reactions`) and thread-assign\n * (`/threads/[id]/assign`) routes resolve the owning channel through this\n * mapping and return 409/404 without it. Opt-in so the existing CRM-link\n * seeds (which don't need a mapping) keep their current mapping-free shape.\n */\n createThreadMapping: z.boolean().optional(),\n})\n\nconst bodySchema = z.discriminatedUnion('action', [connectChannelSchema, emitInboundSchema])\n\nexport async function POST(req: Request): Promise<Response> {\n // Fail-closed: invisible in production. Mirrors an unknown route (404) rather\n // than 403 so the surface leaks nothing when the flag is off.\n if (!isTestChannelSeedingEnabled()) {\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof bodySchema>\n try {\n body = bodySchema.parse(await readJsonSafe(req, null))\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n // Defensive: make sure the stub adapter is registered for this process even if\n // a worker-only node skipped module di registration.\n ensureTestSeedAdapterRegistered()\n\n const tenantId = auth.tenantId as string\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n const userId = auth.sub as string\n\n if (body.action === 'connect-channel') {\n const stamp = Date.now()\n const commandBus = container.resolve('commandBus') as CommandBus\n const input: ConnectCredentialChannelInput = {\n providerKey: TEST_SEED_PROVIDER_KEY,\n displayName: body.displayName ?? `Test Seed Channel ${stamp}`,\n credentials: {\n username: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,\n fromAddress: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,\n },\n userId,\n scope: { tenantId, organizationId },\n }\n const { result } = await commandBus.execute<\n ConnectCredentialChannelInput,\n ConnectCredentialChannelResult\n >(COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID, {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: organizationId,\n organizationIds: organizationId ? [organizationId] : null,\n },\n })\n if (result.status !== 'connected') {\n return NextResponse.json(\n { error: '[internal] test-seed connect failed', detail: result },\n { status: 500 },\n )\n }\n return NextResponse.json(\n { channelId: result.channelId, externalIdentifier: result.externalIdentifier },\n { status: 201 },\n )\n }\n\n // action === 'emit-inbound'\n const em = (container.resolve('em') as EntityManager).fork()\n const providerKey = body.providerKey ?? TEST_SEED_PROVIDER_KEY\n\n // `channelId` is caller-supplied, so confirm it names a channel this tenant/org\n // actually owns before seeding rows that reference it. Mirrors the ownership\n // lookup every other per-channel route performs (see channels/[id]/test-send).\n const ownedChannel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: body.channelId,\n tenantId,\n organizationId,\n deletedAt: null,\n },\n undefined,\n { tenantId, organizationId },\n )\n if (!ownedChannel) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n\n // Tenant/org scope alone does not authorize: `connect_user_channel` is granted\n // broadly, so a same-tenant caller could otherwise seed against a colleague's\n // personal mailbox. Enforce the module's owner-only contract \u2014 personal\n // channels are owner-restricted, shared channels need `manage`.\n let userFeatures: string[] = []\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike\n const acl = await rbac.loadAcl(userId, { tenantId, organizationId })\n userFeatures = acl?.isSuperAdmin ? ['*'] : Array.isArray(acl?.features) ? acl.features : []\n } catch {\n userFeatures = []\n }\n try {\n assertCanManageChannel(\n { userId: ownedChannel.userId },\n userId,\n userFeatures,\n 'communication_channels.manage',\n )\n } catch (err) {\n if (err instanceof ChannelAccessDeniedError) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n const status = (err as { statusCode?: number }).statusCode ?? 403\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Access denied' },\n { status },\n )\n }\n\n // A MessageChannelLink requires a non-null external_conversation_id (FK) and\n // message_id. Create a synthetic conversation + (optionally threaded) message\n // so the link is shaped like a real inbound row the subscriber can consume.\n const conversation = em.create(ExternalConversation, {\n channelId: body.channelId,\n externalConversationId: `inbound-seed:${Date.now()}:${Math.random().toString(16).slice(2, 8)}`,\n subject: body.subject ?? null,\n tenantId,\n organizationId,\n lastMessageAt: new Date(),\n })\n em.persist(conversation)\n await em.flush()\n\n // Insert the platform `messages.message` row via raw SQL rather than importing\n // the messages module's entity class (cross-module ORM coupling rule). Only\n // `thread_id` matters for the hub-thread inheritance join (TC-CRM-EMAIL-005);\n // the rest satisfy NOT NULL constraints.\n const messageRows = (await em.getConnection().execute(\n `INSERT INTO messages\n (type, thread_id, sender_user_id, subject, body, body_format, priority, status,\n is_draft, sent_at, visibility, source_entity_type, source_entity_id,\n tenant_id, organization_id, created_at, updated_at)\n VALUES\n (?, ?, ?, ?, ?, 'text', 'normal', 'sent',\n false, now(), 'public', 'communication_channels.test_seed_inbound', ?,\n ?, ?, now(), now())\n RETURNING id`,\n [\n `channel.${providerKey}`,\n body.messageThreadId ?? null,\n userId,\n body.subject ?? '(no subject)',\n body.bodyText ?? '',\n body.channelId,\n tenantId,\n organizationId,\n ],\n )) as Array<{ id: string }>\n const messageId = messageRows[0]?.id\n if (!messageId) {\n return NextResponse.json({ error: '[internal] failed to seed message row' }, { status: 500 })\n }\n\n const link = em.create(MessageChannelLink, {\n messageId,\n externalConversationId: conversation.id,\n providerKey,\n channelType: 'email',\n direction: 'inbound',\n deliveryStatus: 'delivered',\n channelPayload: {\n ...(body.from !== undefined ? { from: body.from } : {}),\n ...(body.to !== undefined ? { to: body.to } : {}),\n ...(body.cc !== undefined ? { cc: body.cc } : {}),\n ...(body.subject !== undefined ? { subject: body.subject } : {}),\n ...(body.bodyText !== undefined ? { text: body.bodyText } : {}),\n ...(body.inReplyTo !== undefined ? { inReplyTo: body.inReplyTo } : {}),\n ...(body.references !== undefined ? { references: body.references } : {}),\n },\n channelContentType: 'text/plain',\n channelMetadata: {\n ...(body.messageId !== undefined ? { messageId: body.messageId } : {}),\n },\n tenantId,\n organizationId,\n })\n em.persist(link)\n await em.flush()\n\n // Optionally mirror `ingest-inbound-message`: a real inbound message always\n // lands a ChannelThreadMapping that the reaction + thread-assign routes use to\n // resolve the owning channel. Seeded inbound messages skip it by default; opt\n // in for tests that exercise those routes. Keyed by `messageThreadId ?? messageId`\n // to match how those commands resolve the mapping (`message.threadId ?? message.id`).\n if (body.createThreadMapping) {\n const mapping = em.create(ChannelThreadMapping, {\n externalConversationId: conversation.id,\n messageThreadId: body.messageThreadId ?? messageId,\n channelId: body.channelId,\n providerKey,\n externalThreadRef: conversation.externalConversationId,\n tenantId,\n organizationId,\n })\n em.persist(mapping)\n await em.flush()\n }\n\n // Emit the hub event through the real event bus so the persistent customers\n // link-channel-message-received subscriber is enqueued to the `events` queue.\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.received',\n {\n channelLinkId: link.id,\n channelId: body.channelId,\n providerKey,\n direction: 'inbound',\n tenantId,\n organizationId,\n },\n { persistent: true },\n )\n\n return NextResponse.json(\n { channelLinkId: link.id, messageId, conversationId: conversation.id },\n { status: 201 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Test-only: seed a connected channel or emit an inbound message (env-gated)',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 201, description: 'Channel seeded / inbound message emitted' },\n { status: 401, description: 'Unauthorized' },\n {\n status: 404,\n description:\n 'Test channel seeding disabled (production default), or the requested channel does not belong to the caller',\n },\n { status: 422, description: 'Invalid request body' },\n { status: 500, description: 'Seed failed' },\n ],\n },\n },\n}\n\nexport default POST\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ import { RateFetchingService } from "./services/rateFetchingService.js";
|
|
|
2
2
|
import { ExchangeRateService } from "./services/exchangeRateService.js";
|
|
3
3
|
import { NBPProvider } from "./services/providers/nbp.js";
|
|
4
4
|
import { RaiffeisenPolandProvider } from "./services/providers/raiffeisen.js";
|
|
5
|
+
import { BaseCurrencyService } from "./services/baseCurrencyService.js";
|
|
5
6
|
function register(container) {
|
|
6
7
|
container.register({
|
|
7
8
|
rateFetchingService: {
|
|
@@ -19,6 +20,9 @@ function register(container) {
|
|
|
19
20
|
const rateFetchingService = c.resolve("rateFetchingService");
|
|
20
21
|
return new ExchangeRateService(em, rateFetchingService);
|
|
21
22
|
}
|
|
23
|
+
},
|
|
24
|
+
baseCurrencyService: {
|
|
25
|
+
resolve: (c) => new BaseCurrencyService(c.resolve("em"))
|
|
22
26
|
}
|
|
23
27
|
});
|
|
24
28
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/currencies/di.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { RateFetchingService } from './services/rateFetchingService'\nimport { ExchangeRateService } from './services/exchangeRateService'\nimport { NBPProvider } from './services/providers/nbp'\nimport { RaiffeisenPolandProvider } from './services/providers/raiffeisen'\n\nexport function register(container: AppContainer) {\n container.register({\n rateFetchingService: {\n resolve: (c) => {\n const em = c.resolve<EntityManager>('em')\n const service = new RateFetchingService(em)\n \n // Register default providers\n service.registerProvider(new NBPProvider())\n service.registerProvider(new RaiffeisenPolandProvider())\n \n return service\n },\n },\n exchangeRateService: {\n resolve: (c) => {\n const em = c.resolve<EntityManager>('em')\n const rateFetchingService = c.resolve<RateFetchingService>('rateFetchingService')\n return new ExchangeRateService(em, rateFetchingService)\n },\n },\n })\n}\n
|
|
5
|
-
"mappings": "AAEA,SAAS,2BAA2B;AACpC,SAAS,2BAA2B;AACpC,SAAS,mBAAmB;AAC5B,SAAS,gCAAgC;
|
|
4
|
+
"sourcesContent": ["import type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { RateFetchingService } from './services/rateFetchingService'\nimport { ExchangeRateService } from './services/exchangeRateService'\nimport { NBPProvider } from './services/providers/nbp'\nimport { RaiffeisenPolandProvider } from './services/providers/raiffeisen'\nimport { BaseCurrencyService } from './services/baseCurrencyService'\n\nexport function register(container: AppContainer) {\n container.register({\n rateFetchingService: {\n resolve: (c) => {\n const em = c.resolve<EntityManager>('em')\n const service = new RateFetchingService(em)\n \n // Register default providers\n service.registerProvider(new NBPProvider())\n service.registerProvider(new RaiffeisenPolandProvider())\n \n return service\n },\n },\n exchangeRateService: {\n resolve: (c) => {\n const em = c.resolve<EntityManager>('em')\n const rateFetchingService = c.resolve<RateFetchingService>('rateFetchingService')\n return new ExchangeRateService(em, rateFetchingService)\n },\n },\n baseCurrencyService: {\n resolve: (c) => new BaseCurrencyService(c.resolve<EntityManager>('em')),\n },\n })\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,2BAA2B;AACpC,SAAS,2BAA2B;AACpC,SAAS,mBAAmB;AAC5B,SAAS,gCAAgC;AACzC,SAAS,2BAA2B;AAE7B,SAAS,SAAS,WAAyB;AAChD,YAAU,SAAS;AAAA,IACjB,qBAAqB;AAAA,MACnB,SAAS,CAAC,MAAM;AACd,cAAM,KAAK,EAAE,QAAuB,IAAI;AACxC,cAAM,UAAU,IAAI,oBAAoB,EAAE;AAG1C,gBAAQ,iBAAiB,IAAI,YAAY,CAAC;AAC1C,gBAAQ,iBAAiB,IAAI,yBAAyB,CAAC;AAEvD,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IACA,qBAAqB;AAAA,MACnB,SAAS,CAAC,MAAM;AACd,cAAM,KAAK,EAAE,QAAuB,IAAI;AACxC,cAAM,sBAAsB,EAAE,QAA6B,qBAAqB;AAChF,eAAO,IAAI,oBAAoB,IAAI,mBAAmB;AAAA,MACxD;AAAA,IACF;AAAA,IACA,qBAAqB;AAAA,MACnB,SAAS,CAAC,MAAM,IAAI,oBAAoB,EAAE,QAAuB,IAAI,CAAC;AAAA,IACxE;AAAA,EACF,CAAC;AACH;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
|
+
const logger = createLogger("currencies").child({ component: "base-currency-service" });
|
|
3
|
+
class BaseCurrencyService {
|
|
4
|
+
constructor(em) {
|
|
5
|
+
this.em = em;
|
|
6
|
+
}
|
|
7
|
+
async resolveBaseCurrency(scope) {
|
|
8
|
+
const organizationIds = [...new Set(scope.organizationIds)];
|
|
9
|
+
if (organizationIds.length === 0) return { status: "ambiguous" };
|
|
10
|
+
let rows;
|
|
11
|
+
try {
|
|
12
|
+
const result = await this.em.getConnection().execute(
|
|
13
|
+
"SELECT organization_id, code FROM currencies WHERE tenant_id = ? AND organization_id = ANY(?::uuid[]) AND is_base = true AND deleted_at IS NULL",
|
|
14
|
+
[scope.tenantId, `{${organizationIds.join(",")}}`]
|
|
15
|
+
);
|
|
16
|
+
rows = Array.isArray(result) ? result : [];
|
|
17
|
+
} catch (err) {
|
|
18
|
+
logger.warn("Base currency lookup unavailable", { err });
|
|
19
|
+
return { status: "unavailable" };
|
|
20
|
+
}
|
|
21
|
+
const expectedOrganizations = new Set(organizationIds);
|
|
22
|
+
const resolvedOrganizations = /* @__PURE__ */ new Set();
|
|
23
|
+
const codes = /* @__PURE__ */ new Set();
|
|
24
|
+
for (const row of rows) {
|
|
25
|
+
const organizationId = typeof row.organization_id === "string" ? row.organization_id : "";
|
|
26
|
+
const code2 = typeof row.code === "string" ? row.code.trim().toUpperCase() : "";
|
|
27
|
+
if (!expectedOrganizations.has(organizationId) || resolvedOrganizations.has(organizationId) || !code2) {
|
|
28
|
+
return { status: "ambiguous" };
|
|
29
|
+
}
|
|
30
|
+
resolvedOrganizations.add(organizationId);
|
|
31
|
+
codes.add(code2);
|
|
32
|
+
}
|
|
33
|
+
if (resolvedOrganizations.size !== expectedOrganizations.size) return { status: "missing" };
|
|
34
|
+
if (codes.size !== 1) return { status: "ambiguous" };
|
|
35
|
+
const [code] = codes;
|
|
36
|
+
return code ? { status: "resolved", code } : { status: "ambiguous" };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export {
|
|
40
|
+
BaseCurrencyService
|
|
41
|
+
};
|
|
42
|
+
//# sourceMappingURL=baseCurrencyService.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/currencies/services/baseCurrencyService.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('currencies').child({ component: 'base-currency-service' })\n\nexport type BaseCurrencyResolution =\n | { status: 'resolved'; code: string }\n | { status: 'missing' }\n | { status: 'ambiguous' }\n | { status: 'unavailable' }\n\nexport type BaseCurrencyScope = {\n tenantId: string\n organizationIds: string[]\n}\n\nexport class BaseCurrencyService {\n constructor(private readonly em: EntityManager) {}\n\n async resolveBaseCurrency(scope: BaseCurrencyScope): Promise<BaseCurrencyResolution> {\n const organizationIds = [...new Set(scope.organizationIds)]\n if (organizationIds.length === 0) return { status: 'ambiguous' }\n\n let rows: Array<Record<string, unknown>>\n try {\n const result = await this.em.getConnection().execute(\n 'SELECT organization_id, code FROM currencies WHERE tenant_id = ? AND organization_id = ANY(?::uuid[]) AND is_base = true AND deleted_at IS NULL',\n [scope.tenantId, `{${organizationIds.join(',')}}`],\n )\n rows = (Array.isArray(result) ? result : []) as Array<Record<string, unknown>>\n } catch (err) {\n logger.warn('Base currency lookup unavailable', { err })\n return { status: 'unavailable' }\n }\n\n const expectedOrganizations = new Set(organizationIds)\n const resolvedOrganizations = new Set<string>()\n const codes = new Set<string>()\n\n for (const row of rows) {\n const organizationId = typeof row.organization_id === 'string' ? row.organization_id : ''\n const code = typeof row.code === 'string' ? row.code.trim().toUpperCase() : ''\n if (\n !expectedOrganizations.has(organizationId)\n || resolvedOrganizations.has(organizationId)\n || !code\n ) {\n return { status: 'ambiguous' }\n }\n resolvedOrganizations.add(organizationId)\n codes.add(code)\n }\n\n if (resolvedOrganizations.size !== expectedOrganizations.size) return { status: 'missing' }\n if (codes.size !== 1) return { status: 'ambiguous' }\n const [code] = codes\n return code ? { status: 'resolved', code } : { status: 'ambiguous' }\n }\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,YAAY,EAAE,MAAM,EAAE,WAAW,wBAAwB,CAAC;AAa/E,MAAM,oBAAoB;AAAA,EAC/B,YAA6B,IAAmB;AAAnB;AAAA,EAAoB;AAAA,EAEjD,MAAM,oBAAoB,OAA2D;AACnF,UAAM,kBAAkB,CAAC,GAAG,IAAI,IAAI,MAAM,eAAe,CAAC;AAC1D,QAAI,gBAAgB,WAAW,EAAG,QAAO,EAAE,QAAQ,YAAY;AAE/D,QAAI;AACJ,QAAI;AACF,YAAM,SAAS,MAAM,KAAK,GAAG,cAAc,EAAE;AAAA,QAC3C;AAAA,QACA,CAAC,MAAM,UAAU,IAAI,gBAAgB,KAAK,GAAG,CAAC,GAAG;AAAA,MACnD;AACA,aAAQ,MAAM,QAAQ,MAAM,IAAI,SAAS,CAAC;AAAA,IAC5C,SAAS,KAAK;AACZ,aAAO,KAAK,oCAAoC,EAAE,IAAI,CAAC;AACvD,aAAO,EAAE,QAAQ,cAAc;AAAA,IACjC;AAEA,UAAM,wBAAwB,IAAI,IAAI,eAAe;AACrD,UAAM,wBAAwB,oBAAI,IAAY;AAC9C,UAAM,QAAQ,oBAAI,IAAY;AAE9B,eAAW,OAAO,MAAM;AACtB,YAAM,iBAAiB,OAAO,IAAI,oBAAoB,WAAW,IAAI,kBAAkB;AACvF,YAAMA,QAAO,OAAO,IAAI,SAAS,WAAW,IAAI,KAAK,KAAK,EAAE,YAAY,IAAI;AAC5E,UACE,CAAC,sBAAsB,IAAI,cAAc,KACtC,sBAAsB,IAAI,cAAc,KACxC,CAACA,OACJ;AACA,eAAO,EAAE,QAAQ,YAAY;AAAA,MAC/B;AACA,4BAAsB,IAAI,cAAc;AACxC,YAAM,IAAIA,KAAI;AAAA,IAChB;AAEA,QAAI,sBAAsB,SAAS,sBAAsB,KAAM,QAAO,EAAE,QAAQ,UAAU;AAC1F,QAAI,MAAM,SAAS,EAAG,QAAO,EAAE,QAAQ,YAAY;AACnD,UAAM,CAAC,IAAI,IAAI;AACf,WAAO,OAAO,EAAE,QAAQ,YAAY,KAAK,IAAI,EAAE,QAAQ,YAAY;AAAA,EACrE;AACF;",
|
|
6
|
+
"names": ["code"]
|
|
7
|
+
}
|
|
@@ -23,11 +23,13 @@ const analyticsConfig = {
|
|
|
23
23
|
entityConfig: {
|
|
24
24
|
tableName: "customer_deals",
|
|
25
25
|
dateField: "created_at",
|
|
26
|
-
defaultScopeFields: ["tenant_id", "organization_id"]
|
|
26
|
+
defaultScopeFields: ["tenant_id", "organization_id"],
|
|
27
|
+
currencyField: "valueCurrency"
|
|
27
28
|
},
|
|
28
29
|
fieldMappings: {
|
|
29
30
|
id: { dbColumn: "id", type: "uuid" },
|
|
30
31
|
valueAmount: { dbColumn: "value_amount", type: "numeric" },
|
|
32
|
+
valueCurrency: { dbColumn: "value_currency", type: "text" },
|
|
31
33
|
status: { dbColumn: "status", type: "text" },
|
|
32
34
|
closureOutcome: { dbColumn: "closure_outcome", type: "text" },
|
|
33
35
|
pipelineStage: { dbColumn: "pipeline_stage", type: "text" },
|