@open-mercato/core 0.10.1-develop.7736.1.88e07906bf → 0.10.1-develop.7748.1.d745ba61aa
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/api_key/index.js +2 -0
- package/dist/generated/entities/api_key/index.js.map +2 -2
- package/dist/generated/entities/attachment_partition/index.js +2 -0
- package/dist/generated/entities/attachment_partition/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/attachmentAccessFixtures.js +144 -0
- package/dist/helpers/integration/attachmentAccessFixtures.js.map +7 -0
- package/dist/modules/api_keys/data/entities.js +7 -0
- package/dist/modules/api_keys/data/entities.js.map +2 -2
- package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js +30 -0
- package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js.map +7 -0
- package/dist/modules/api_keys/services/apiKeyService.js +31 -9
- package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
- package/dist/modules/attachments/api/file/[id]/route.js +16 -10
- package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +15 -9
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/attachments/api/library/[id]/route.js +100 -56
- package/dist/modules/attachments/api/library/[id]/route.js.map +3 -3
- package/dist/modules/attachments/api/library/route.js +56 -17
- package/dist/modules/attachments/api/library/route.js.map +2 -2
- package/dist/modules/attachments/api/partitions/route.js +2 -0
- package/dist/modules/attachments/api/partitions/route.js.map +2 -2
- package/dist/modules/attachments/api/route.js +74 -34
- package/dist/modules/attachments/api/route.js.map +3 -3
- package/dist/modules/attachments/api/transfer/route.js +63 -37
- package/dist/modules/attachments/api/transfer/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +3 -0
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/data/validators.js +41 -0
- package/dist/modules/attachments/data/validators.js.map +7 -0
- package/dist/modules/attachments/di.js +5 -2
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/generators.js +29 -0
- package/dist/modules/attachments/generators.js.map +7 -0
- package/dist/modules/attachments/index.js +8 -1
- package/dist/modules/attachments/index.js.map +2 -2
- package/dist/modules/attachments/lib/access-errors.js +35 -0
- package/dist/modules/attachments/lib/access-errors.js.map +7 -0
- package/dist/modules/attachments/lib/access-mutation.js +23 -0
- package/dist/modules/attachments/lib/access-mutation.js.map +7 -0
- package/dist/modules/attachments/lib/access-protection.js +73 -0
- package/dist/modules/attachments/lib/access-protection.js.map +7 -0
- package/dist/modules/attachments/lib/access-query.js +74 -0
- package/dist/modules/attachments/lib/access-query.js.map +7 -0
- package/dist/modules/attachments/lib/access-registry.js +87 -0
- package/dist/modules/attachments/lib/access-registry.js.map +7 -0
- package/dist/modules/attachments/lib/access-runner.js +161 -0
- package/dist/modules/attachments/lib/access-runner.js.map +7 -0
- package/dist/modules/attachments/lib/access-types.js +1 -0
- package/dist/modules/attachments/lib/access-types.js.map +7 -0
- package/dist/modules/attachments/lib/attachment-service.js +14 -6
- package/dist/modules/attachments/lib/attachment-service.js.map +2 -2
- package/dist/modules/attachments/lib/createFromBuffer.js +2 -0
- package/dist/modules/attachments/lib/createFromBuffer.js.map +2 -2
- package/dist/modules/attachments/lib/partitions.js +3 -2
- package/dist/modules/attachments/lib/partitions.js.map +2 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js +2 -0
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
- package/dist/modules/attachments/lib/target-access-service.js +12 -3
- package/dist/modules/attachments/lib/target-access-service.js.map +2 -2
- package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js +38 -0
- package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js.map +7 -0
- package/dist/modules/attachments/setup.js +10 -0
- package/dist/modules/attachments/setup.js.map +2 -2
- package/dist/modules/notifications/api/[id]/action/route.js +8 -0
- package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
- package/dist/modules/notifications/api/route.js +17 -0
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/notifications/data/validators.js +1 -1
- package/dist/modules/notifications/data/validators.js.map +2 -2
- package/dist/modules/notifications/lib/notificationService.js +45 -3
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/notifications/lib/routeHelpers.js +27 -0
- package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +50 -34
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/status-machine.js +10 -1
- package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
- package/dist/modules/payment_gateways/lib/webhook-processor.js +1 -1
- package/dist/modules/payment_gateways/lib/webhook-processor.js.map +2 -2
- package/dist/modules/staff/notifications.js +4 -2
- package/dist/modules/staff/notifications.js.map +2 -2
- package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js +1 -1
- package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js.map +2 -2
- package/dist/modules/workflows/api/tasks/[id]/complete/route.js +7 -1
- package/dist/modules/workflows/api/tasks/[id]/complete/route.js.map +2 -2
- package/dist/modules/workflows/backend/tasks/[id]/page.js +2 -1
- package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
- package/dist/modules/workflows/data/entities.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +18 -4
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/activity-types.js +2 -2
- package/dist/modules/workflows/lib/activity-types.js.map +2 -2
- package/dist/modules/workflows/lib/condition-handler.js +7 -1
- package/dist/modules/workflows/lib/condition-handler.js.map +2 -2
- package/dist/modules/workflows/lib/duration.js +13 -0
- package/dist/modules/workflows/lib/duration.js.map +2 -2
- package/dist/modules/workflows/lib/instance-status.js +16 -0
- package/dist/modules/workflows/lib/instance-status.js.map +7 -0
- package/dist/modules/workflows/lib/step-handler.js +9 -2
- package/dist/modules/workflows/lib/step-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-handler.js +16 -8
- package/dist/modules/workflows/lib/task-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-quick-action.js +13 -10
- package/dist/modules/workflows/lib/task-quick-action.js.map +2 -2
- package/dist/modules/workflows/lib/task-sla.js +10 -0
- package/dist/modules/workflows/lib/task-sla.js.map +2 -2
- package/dist/modules/workflows/lib/transition-handler.js +2 -1
- package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js +29 -1
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/dist/modules/workflows/notifications.js +4 -0
- package/dist/modules/workflows/notifications.js.map +2 -2
- package/generated/entities/api_key/index.ts +1 -0
- package/generated/entities/attachment_partition/index.ts +1 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +7 -7
- package/src/helpers/integration/attachmentAccessFixtures.ts +141 -0
- package/src/modules/api_keys/data/entities.ts +25 -1
- package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +234 -68
- package/src/modules/api_keys/migrations/Migration20261012202110_api_keys.ts +47 -0
- package/src/modules/api_keys/services/apiKeyService.ts +55 -9
- package/src/modules/attachments/api/file/[id]/route.ts +12 -9
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +10 -8
- package/src/modules/attachments/api/library/[id]/route.ts +87 -58
- package/src/modules/attachments/api/library/route.ts +40 -11
- package/src/modules/attachments/api/partitions/route.ts +2 -0
- package/src/modules/attachments/api/route.ts +68 -35
- package/src/modules/attachments/api/transfer/route.ts +65 -42
- package/src/modules/attachments/data/entities.ts +4 -0
- package/src/modules/attachments/data/validators.ts +36 -0
- package/src/modules/attachments/di.ts +4 -1
- package/src/modules/attachments/generators.ts +27 -0
- package/src/modules/attachments/i18n/de.json +4 -0
- package/src/modules/attachments/i18n/en.json +4 -0
- package/src/modules/attachments/i18n/es.json +4 -0
- package/src/modules/attachments/i18n/ko.json +4 -0
- package/src/modules/attachments/i18n/pl.json +4 -0
- package/src/modules/attachments/index.ts +18 -0
- package/src/modules/attachments/lib/access-errors.ts +32 -0
- package/src/modules/attachments/lib/access-mutation.ts +26 -0
- package/src/modules/attachments/lib/access-protection.ts +83 -0
- package/src/modules/attachments/lib/access-query.ts +86 -0
- package/src/modules/attachments/lib/access-registry.ts +117 -0
- package/src/modules/attachments/lib/access-runner.ts +183 -0
- package/src/modules/attachments/lib/access-types.ts +78 -0
- package/src/modules/attachments/lib/attachment-service.ts +8 -4
- package/src/modules/attachments/lib/createFromBuffer.ts +3 -0
- package/src/modules/attachments/lib/partitions.ts +3 -2
- package/src/modules/attachments/lib/scoped-upload-service.ts +2 -0
- package/src/modules/attachments/lib/target-access-service.ts +10 -3
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +17 -0
- package/src/modules/attachments/migrations/Migration20261001120000_attachments.ts +35 -0
- package/src/modules/attachments/setup.ts +10 -0
- package/src/modules/auth/i18n/de.json +3 -0
- package/src/modules/auth/i18n/en.json +3 -0
- package/src/modules/auth/i18n/es.json +3 -0
- package/src/modules/auth/i18n/ko.json +3 -0
- package/src/modules/auth/i18n/pl.json +3 -0
- package/src/modules/notifications/api/[id]/action/route.ts +8 -0
- package/src/modules/notifications/api/route.ts +17 -0
- package/src/modules/notifications/data/validators.ts +1 -1
- package/src/modules/notifications/i18n/de.json +2 -0
- package/src/modules/notifications/i18n/en.json +2 -0
- package/src/modules/notifications/i18n/es.json +2 -0
- package/src/modules/notifications/i18n/ko.json +2 -0
- package/src/modules/notifications/i18n/pl.json +2 -0
- package/src/modules/notifications/lib/notificationService.ts +67 -1
- package/src/modules/notifications/lib/routeHelpers.ts +38 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +50 -33
- package/src/modules/payment_gateways/lib/status-machine.ts +19 -0
- package/src/modules/payment_gateways/lib/webhook-processor.ts +1 -1
- package/src/modules/staff/notifications.ts +2 -0
- package/src/modules/workflows/AGENTS.md +2 -2
- package/src/modules/workflows/api/portal/tasks/[id]/complete/route.ts +5 -1
- package/src/modules/workflows/api/tasks/[id]/complete/route.ts +7 -1
- package/src/modules/workflows/backend/tasks/[id]/page.tsx +6 -2
- package/src/modules/workflows/data/entities.ts +6 -0
- package/src/modules/workflows/i18n/de.json +2 -0
- package/src/modules/workflows/i18n/en.json +2 -0
- package/src/modules/workflows/i18n/es.json +2 -0
- package/src/modules/workflows/i18n/ko.json +2 -0
- package/src/modules/workflows/i18n/pl.json +2 -0
- package/src/modules/workflows/lib/activity-executor.ts +36 -4
- package/src/modules/workflows/lib/activity-types.ts +2 -2
- package/src/modules/workflows/lib/condition-handler.ts +16 -1
- package/src/modules/workflows/lib/duration.ts +25 -0
- package/src/modules/workflows/lib/instance-status.ts +29 -0
- package/src/modules/workflows/lib/step-handler.ts +16 -1
- package/src/modules/workflows/lib/task-handler.ts +18 -10
- package/src/modules/workflows/lib/task-quick-action.ts +19 -10
- package/src/modules/workflows/lib/task-sla.ts +13 -0
- package/src/modules/workflows/lib/transition-handler.ts +2 -1
- package/src/modules/workflows/lib/workflow-executor.ts +44 -1
- package/src/modules/workflows/notifications.ts +4 -0
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { createAttachmentAccessContext } from "../../lib/access-runner.js";
|
|
2
|
+
import { requiresAttachmentAccessScan, scanAuthorizedAttachments } from "../../lib/access-query.js";
|
|
3
|
+
import { withAttachmentAccessErrors } from "../../lib/access-errors.js";
|
|
1
4
|
import { NextResponse } from "next/server";
|
|
2
5
|
import { z } from "zod";
|
|
3
6
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
@@ -44,7 +47,7 @@ function formatDateValue(value) {
|
|
|
44
47
|
};
|
|
45
48
|
return toDate().toISOString();
|
|
46
49
|
}
|
|
47
|
-
async function
|
|
50
|
+
async function listAttachments(req) {
|
|
48
51
|
const auth = await getAuthFromRequest(req);
|
|
49
52
|
if (!auth || !auth.tenantId || !auth.orgId && !auth.isSuperAdmin) {
|
|
50
53
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
@@ -57,7 +60,8 @@ async function GET(req) {
|
|
|
57
60
|
const { page, pageSize, search, partition, tags, sortField, sortDir } = parsed.data;
|
|
58
61
|
const tagList = buildTagFilter(tags);
|
|
59
62
|
const offset = (page - 1) * pageSize;
|
|
60
|
-
const
|
|
63
|
+
const container = await createRequestContainer();
|
|
64
|
+
const { resolve } = container;
|
|
61
65
|
const em = resolve("em");
|
|
62
66
|
await ensureDefaultPartitions(em);
|
|
63
67
|
let queryEngine = null;
|
|
@@ -88,14 +92,42 @@ async function GET(req) {
|
|
|
88
92
|
createdAt: "a.created_at"
|
|
89
93
|
};
|
|
90
94
|
const orderColumn = orderMap[sortField ?? "createdAt"] ?? "a.created_at";
|
|
91
|
-
qb.orderBy({ [orderColumn]: sortDir === "asc" ? "asc" : "desc" });
|
|
95
|
+
qb.orderBy({ [orderColumn]: sortDir === "asc" ? "asc" : "desc", "a.id": "asc" });
|
|
92
96
|
qb.limit(pageSize).offset(offset);
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
const partitions = await em.find(AttachmentPartition, {}, { orderBy: { title: "asc" } });
|
|
98
|
+
const protectedScan = requiresAttachmentAccessScan(partitions);
|
|
99
|
+
const accessContext = createAttachmentAccessContext(container);
|
|
100
|
+
const deadline = Date.now() + 15e3;
|
|
101
|
+
let records;
|
|
102
|
+
let total;
|
|
103
|
+
let authorizedTags = null;
|
|
104
|
+
if (protectedScan) {
|
|
105
|
+
const scanned = await scanAuthorizedAttachments({
|
|
106
|
+
auth,
|
|
107
|
+
context: accessContext,
|
|
108
|
+
partitions,
|
|
109
|
+
offset,
|
|
110
|
+
limit: pageSize,
|
|
111
|
+
deadline,
|
|
112
|
+
loadBatch: (batchOffset, limit) => qb.clone().limit(limit).offset(batchOffset).getResultList()
|
|
113
|
+
});
|
|
114
|
+
records = scanned.records;
|
|
115
|
+
total = scanned.total;
|
|
116
|
+
const facetQuery = em.createQueryBuilder(Attachment, "a").where(baseFilter).orderBy({ "a.id": "asc" });
|
|
117
|
+
const facets = await scanAuthorizedAttachments({
|
|
118
|
+
auth,
|
|
119
|
+
context: accessContext,
|
|
120
|
+
partitions,
|
|
121
|
+
limit: 0,
|
|
122
|
+
collectTags: true,
|
|
123
|
+
deadline,
|
|
124
|
+
loadBatch: (batchOffset, limit) => facetQuery.clone().limit(limit).offset(batchOffset).getResultList()
|
|
125
|
+
});
|
|
126
|
+
authorizedTags = facets.tags;
|
|
127
|
+
} else {
|
|
128
|
+
;
|
|
129
|
+
[records, total] = await Promise.all([qb.getResultList(), countQb.getCount("a.id", true)]);
|
|
130
|
+
}
|
|
99
131
|
const partitionTitleByCode = partitions.reduce((acc, entry) => {
|
|
100
132
|
if (entry.code) acc[entry.code] = entry.title ?? entry.code;
|
|
101
133
|
return acc;
|
|
@@ -135,13 +167,16 @@ async function GET(req) {
|
|
|
135
167
|
assignments: applyAssignmentEnrichments(item.assignments ?? [], enrichments)
|
|
136
168
|
})) : items;
|
|
137
169
|
const totalPages = Math.max(1, Math.ceil(Number(total) / pageSize));
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
tagQuery =
|
|
170
|
+
let availableTags = authorizedTags ?? [];
|
|
171
|
+
if (!protectedScan) {
|
|
172
|
+
const db = em.getKysely();
|
|
173
|
+
let tagQuery = db.selectFrom("attachments").select(sql`distinct jsonb_array_elements_text(coalesce(storage_metadata->'tags', '[]'::jsonb))`.as("tag")).where("tenant_id", "=", auth.tenantId);
|
|
174
|
+
if (auth.orgId) {
|
|
175
|
+
tagQuery = tagQuery.where("organization_id", "=", auth.orgId);
|
|
176
|
+
}
|
|
177
|
+
const tagRows = await tagQuery.orderBy("tag", "asc").execute();
|
|
178
|
+
availableTags = tagRows.map((row) => typeof row.tag === "string" ? row.tag.trim() : "").filter((tag) => tag.length > 0);
|
|
142
179
|
}
|
|
143
|
-
const tagRows = await tagQuery.orderBy("tag", "asc").execute();
|
|
144
|
-
const availableTags = tagRows.map((row) => typeof row.tag === "string" ? row.tag.trim() : "").filter((tag) => tag.length > 0);
|
|
145
180
|
return NextResponse.json({
|
|
146
181
|
items: enrichedItems,
|
|
147
182
|
page,
|
|
@@ -155,8 +190,9 @@ async function GET(req) {
|
|
|
155
190
|
description: entry.description ?? null,
|
|
156
191
|
isPublic: entry.isPublic ?? false
|
|
157
192
|
}))
|
|
158
|
-
});
|
|
193
|
+
}, { headers: protectedScan ? { "Cache-Control": "private, no-store" } : void 0 });
|
|
159
194
|
}
|
|
195
|
+
const GET = withAttachmentAccessErrors(listAttachments);
|
|
160
196
|
const openApi = {
|
|
161
197
|
tag: attachmentsTag,
|
|
162
198
|
summary: "Attachment library management",
|
|
@@ -170,7 +206,10 @@ const openApi = {
|
|
|
170
206
|
],
|
|
171
207
|
errors: [
|
|
172
208
|
{ status: 400, description: "Invalid query parameters", schema: attachmentErrorSchema },
|
|
173
|
-
{ status: 401, description: "Unauthorized", schema: attachmentErrorSchema }
|
|
209
|
+
{ status: 401, description: "Unauthorized", schema: attachmentErrorSchema },
|
|
210
|
+
{ status: 403, description: "Owner policy denies access", schema: attachmentErrorSchema },
|
|
211
|
+
{ status: 404, description: "Attachment or owner is unavailable", schema: attachmentErrorSchema },
|
|
212
|
+
{ status: 504, description: "Owner authorization timed out", schema: attachmentErrorSchema }
|
|
174
213
|
]
|
|
175
214
|
}
|
|
176
215
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/attachments/api/library/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { sql } from 'kysely'\nimport { Attachment, AttachmentPartition } from '../../data/entities'\nimport { buildAttachmentImageUrl, slugifyAttachmentFileName } from '../../lib/imageUrls'\nimport { readAttachmentMetadata } from '../../lib/metadata'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport { applyAssignmentEnrichments, resolveAssignmentEnrichments } from '../../lib/assignmentDetails'\nimport { buildIlikeTerm } from '@open-mercato/shared/lib/db/buildIlikeTerm'\nimport { ensureDefaultPartitions } from '../../lib/partitions'\nimport {\n attachmentsTag,\n attachmentListQuerySchema as openApiListQuerySchema,\n attachmentListResponseSchema,\n attachmentErrorSchema,\n} from '../openapi'\n\nconst listQuerySchema = z.object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(25),\n search: z.string().optional(),\n partition: z.string().optional(),\n tags: z.string().optional(),\n sortField: z.enum(['fileName', 'fileSize', 'createdAt']).optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['attachments.view'] },\n}\n\nfunction buildTagFilter(raw?: string): string[] {\n if (!raw) return []\n return raw\n .split(',')\n .map((tag) => tag.trim())\n .filter((tag) => tag.length > 0)\n}\n\nfunction formatDateValue(value: unknown): string {\n const toDate = (): Date => {\n if (value instanceof Date) return value\n if (typeof value === 'string') {\n const parsed = new Date(value)\n if (!Number.isNaN(parsed.getTime())) return parsed\n }\n const fallback = new Date(value as any)\n if (!Number.isNaN(fallback.getTime())) return fallback\n return new Date()\n }\n return toDate().toISOString()\n}\n\
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AAEvC,SAAS,WAAW;AACpB,SAAS,YAAY,2BAA2B;AAChD,SAAS,yBAAyB,iCAAiC;AACnE,SAAS,8BAA8B;AAEvC,SAAS,4BAA4B,oCAAoC;AACzE,SAAS,sBAAsB;AAC/B,SAAS,+BAA+B;AACxC;AAAA,EACE;AAAA,EACA,6BAA6B;AAAA,EAC7B;AAAA,EACA;AAAA,OACK;AAEP,MAAM,kBAAkB,EAAE,OAAO;AAAA,EAC/B,MAAM,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,QAAQ,CAAC;AAAA,EACxC,UAAU,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,QAAQ,EAAE;AAAA,EACtD,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,EAC/B,MAAM,EAAE,OAAO,EAAE,SAAS;AAAA,EAC1B,WAAW,EAAE,KAAK,CAAC,YAAY,YAAY,WAAW,CAAC,EAAE,SAAS;AAAA,EAClE,SAAS,EAAE,KAAK,CAAC,OAAO,MAAM,CAAC,EAAE,SAAS;AAC5C,CAAC;AAEM,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,kBAAkB,EAAE;AAClE;AAEA,SAAS,eAAe,KAAwB;AAC9C,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,SAAO,IACJ,MAAM,GAAG,EACT,IAAI,CAAC,QAAQ,IAAI,KAAK,CAAC,EACvB,OAAO,CAAC,QAAQ,IAAI,SAAS,CAAC;AACnC;AAEA,SAAS,gBAAgB,OAAwB;AAC/C,QAAM,SAAS,MAAY;AACzB,QAAI,iBAAiB,KAAM,QAAO;AAClC,QAAI,OAAO,UAAU,UAAU;AAC7B,YAAM,SAAS,IAAI,KAAK,KAAK;AAC7B,UAAI,CAAC,OAAO,MAAM,OAAO,QAAQ,CAAC,EAAG,QAAO;AAAA,IAC9C;AACA,UAAM,WAAW,IAAI,KAAK,KAAY;AACtC,QAAI,CAAC,OAAO,MAAM,SAAS,QAAQ,CAAC,EAAG,QAAO;AAC9C,WAAO,oBAAI,KAAK;AAAA,EAClB;AACA,SAAO,OAAO,EAAE,YAAY;AAC9B;AAEA,
|
|
4
|
+
"sourcesContent": ["import { createAttachmentAccessContext } from '../../lib/access-runner'\nimport { requiresAttachmentAccessScan, scanAuthorizedAttachments } from '../../lib/access-query'\nimport { withAttachmentAccessErrors } from '../../lib/access-errors'\nimport { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { sql } from 'kysely'\nimport { Attachment, AttachmentPartition } from '../../data/entities'\nimport { buildAttachmentImageUrl, slugifyAttachmentFileName } from '../../lib/imageUrls'\nimport { readAttachmentMetadata } from '../../lib/metadata'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport { applyAssignmentEnrichments, resolveAssignmentEnrichments } from '../../lib/assignmentDetails'\nimport { buildIlikeTerm } from '@open-mercato/shared/lib/db/buildIlikeTerm'\nimport { ensureDefaultPartitions } from '../../lib/partitions'\nimport {\n attachmentsTag,\n attachmentListQuerySchema as openApiListQuerySchema,\n attachmentListResponseSchema,\n attachmentErrorSchema,\n} from '../openapi'\n\nconst listQuerySchema = z.object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(25),\n search: z.string().optional(),\n partition: z.string().optional(),\n tags: z.string().optional(),\n sortField: z.enum(['fileName', 'fileSize', 'createdAt']).optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['attachments.view'] },\n}\n\nfunction buildTagFilter(raw?: string): string[] {\n if (!raw) return []\n return raw\n .split(',')\n .map((tag) => tag.trim())\n .filter((tag) => tag.length > 0)\n}\n\nfunction formatDateValue(value: unknown): string {\n const toDate = (): Date => {\n if (value instanceof Date) return value\n if (typeof value === 'string') {\n const parsed = new Date(value)\n if (!Number.isNaN(parsed.getTime())) return parsed\n }\n const fallback = new Date(value as any)\n if (!Number.isNaN(fallback.getTime())) return fallback\n return new Date()\n }\n return toDate().toISOString()\n}\n\nasync function listAttachments(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const url = new URL(req.url)\n const parsed = listQuerySchema.safeParse(Object.fromEntries(url.searchParams.entries()))\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid query' }, { status: 400 })\n }\n\n const { page, pageSize, search, partition, tags, sortField, sortDir } = parsed.data\n const tagList = buildTagFilter(tags)\n const offset = (page - 1) * pageSize\n const container = await createRequestContainer()\n const { resolve } = container\n const em = resolve('em') as EntityManager\n await ensureDefaultPartitions(em)\n let queryEngine: QueryEngine | null = null\n try {\n queryEngine = resolve('queryEngine') as QueryEngine\n } catch {\n queryEngine = null\n }\n const qb = em.createQueryBuilder(Attachment, 'a')\n const baseFilter: Record<string, unknown> = { tenantId: auth.tenantId }\n if (auth.orgId) {\n baseFilter.organizationId = auth.orgId\n }\n qb.where(baseFilter)\n if (search && search.trim().length > 0) {\n qb.andWhere({ fileName: { $ilike: buildIlikeTerm(search.trim()) } })\n }\n if (partition && partition.trim().length > 0) {\n qb.andWhere({ partitionCode: partition.trim() })\n }\n if (tagList.length > 0) {\n qb.andWhere(`coalesce(a.storage_metadata->'tags', '[]'::jsonb) @> ?::jsonb`, [JSON.stringify(tagList)])\n }\n const countQb = qb.clone()\n const orderMap: Record<string, string> = {\n fileName: 'a.file_name',\n fileSize: 'a.file_size',\n createdAt: 'a.created_at',\n }\n const orderColumn = orderMap[sortField ?? 'createdAt'] ?? 'a.created_at'\n qb.orderBy({ [orderColumn]: sortDir === 'asc' ? 'asc' : 'desc', 'a.id': 'asc' })\n qb.limit(pageSize).offset(offset)\n\n const partitions = await em.find(AttachmentPartition, {}, { orderBy: { title: 'asc' } })\n const protectedScan = requiresAttachmentAccessScan(partitions)\n const accessContext = createAttachmentAccessContext(container)\n const deadline = Date.now() + 15_000\n let records: Attachment[]\n let total: number\n let authorizedTags: string[] | null = null\n if (protectedScan) {\n const scanned = await scanAuthorizedAttachments({\n auth, context: accessContext, partitions, offset, limit: pageSize, deadline,\n loadBatch: (batchOffset, limit) => qb.clone().limit(limit).offset(batchOffset).getResultList(),\n })\n records = scanned.records\n total = scanned.total\n const facetQuery = em.createQueryBuilder(Attachment, 'a').where(baseFilter).orderBy({ 'a.id': 'asc' })\n const facets = await scanAuthorizedAttachments({\n auth, context: accessContext, partitions, limit: 0, collectTags: true, deadline,\n loadBatch: (batchOffset, limit) => facetQuery.clone().limit(limit).offset(batchOffset).getResultList(),\n })\n authorizedTags = facets.tags\n } else {\n ;[records, total] = await Promise.all([qb.getResultList(), countQb.getCount('a.id', true)])\n }\n const partitionTitleByCode = partitions.reduce<Record<string, string>>((acc, entry) => {\n if (entry.code) acc[entry.code] = entry.title ?? entry.code\n return acc\n }, {})\n const items = records.map((record) => {\n const metadata = readAttachmentMetadata(record.storageMetadata)\n const fileName = record.fileName || ''\n const isImage = typeof record.mimeType === 'string' && record.mimeType.toLowerCase().startsWith('image/')\n const thumbnailUrl = isImage\n ? buildAttachmentImageUrl(record.id, {\n width: 200,\n height: 200,\n slug: slugifyAttachmentFileName(fileName),\n })\n : undefined\n return {\n id: record.id,\n fileName,\n fileSize: record.fileSize,\n mimeType: record.mimeType,\n partitionCode: record.partitionCode,\n partitionTitle: partitionTitleByCode[record.partitionCode] ?? null,\n url: record.url,\n createdAt: formatDateValue(record.createdAt),\n tags: metadata.tags ?? [],\n assignments: metadata.assignments ?? [],\n thumbnailUrl,\n content: record.content && record.content.trim() ? record.content : null,\n }\n })\n\n const allAssignments = items.flatMap((item) => item.assignments ?? [])\n const enrichments = await resolveAssignmentEnrichments(allAssignments, {\n queryEngine,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n const enrichedItems = enrichments.size\n ? items.map((item) => ({\n ...item,\n assignments: applyAssignmentEnrichments(item.assignments ?? [], enrichments),\n }))\n : items\n\n const totalPages = Math.max(1, Math.ceil(Number(total) / pageSize))\n let availableTags = authorizedTags ?? []\n if (!protectedScan) {\n const db = em.getKysely<any>() as any\n let tagQuery = db\n .selectFrom('attachments')\n .select(sql<string>`distinct jsonb_array_elements_text(coalesce(storage_metadata->'tags', '[]'::jsonb))`.as('tag'))\n .where('tenant_id', '=', auth.tenantId)\n if (auth.orgId) {\n tagQuery = tagQuery.where('organization_id', '=', auth.orgId)\n }\n const tagRows = await tagQuery.orderBy('tag', 'asc').execute() as Array<{ tag?: string | null }>\n availableTags = tagRows\n .map((row) => (typeof row.tag === 'string' ? row.tag.trim() : ''))\n .filter((tag) => tag.length > 0)\n }\n\n return NextResponse.json({\n items: enrichedItems,\n page,\n pageSize,\n total,\n totalPages,\n availableTags,\n partitions: partitions.map((entry) => ({\n code: entry.code,\n title: entry.title,\n description: entry.description ?? null,\n isPublic: entry.isPublic ?? false,\n })),\n }, { headers: protectedScan ? { 'Cache-Control': 'private, no-store' } : undefined })\n}\n\nexport const GET = withAttachmentAccessErrors(listAttachments)\n\nexport const openApi: OpenApiRouteDoc = {\n tag: attachmentsTag,\n summary: 'Attachment library management',\n methods: {\n GET: {\n summary: 'List attachments',\n description: 'Returns paginated list of attachments with optional filtering by search term, partition, and tags. Includes available tags and partitions.',\n query: openApiListQuerySchema,\n responses: [\n { status: 200, description: 'Attachments list with pagination and metadata', schema: attachmentListResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },\n { status: 404, description: 'Attachment or owner is unavailable', schema: attachmentErrorSchema },\n { status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["metadata"]
|
|
7
7
|
}
|
|
@@ -6,6 +6,7 @@ import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/
|
|
|
6
6
|
import { Attachment, AttachmentPartition } from "../../data/entities.js";
|
|
7
7
|
import { ensureDefaultPartitions, DEFAULT_ATTACHMENT_PARTITIONS, sanitizePartitionCode, isPartitionSettingsLocked } from "../../lib/partitions.js";
|
|
8
8
|
import { resolvePartitionEnvKey } from "../../lib/partitionEnv.js";
|
|
9
|
+
import { getDeclaredAttachmentAccessRequirements } from "../../lib/access-protection.js";
|
|
9
10
|
import { resolveDefaultAttachmentOcrEnabled } from "../../lib/ocrConfig.js";
|
|
10
11
|
import {
|
|
11
12
|
attachmentsTag,
|
|
@@ -113,6 +114,7 @@ async function POST(req) {
|
|
|
113
114
|
}
|
|
114
115
|
const entry = em.create(AttachmentPartition, {
|
|
115
116
|
code,
|
|
117
|
+
accessResolverRequirements: getDeclaredAttachmentAccessRequirements(code),
|
|
116
118
|
title: parsed.data.title.trim(),
|
|
117
119
|
description: parsed.data.description?.trim() ?? null,
|
|
118
120
|
storageDriver: parsed.data.storageDriver ?? "local",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/attachments/api/partitions/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { Attachment, AttachmentPartition } from '../../data/entities'\nimport { ensureDefaultPartitions, DEFAULT_ATTACHMENT_PARTITIONS, sanitizePartitionCode, isPartitionSettingsLocked } from '../../lib/partitions'\nimport { resolvePartitionEnvKey } from '../../lib/partitionEnv'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { resolveDefaultAttachmentOcrEnabled } from '../../lib/ocrConfig'\nimport {\n attachmentsTag,\n partitionCreateSchema,\n partitionUpdateSchema,\n partitionResponseSchema,\n partitionListResponseSchema,\n attachmentErrorSchema,\n} from '../openapi'\n\nconst deleteSchema = z.object({\n id: z.string().uuid(),\n})\n\nconst DEFAULT_CODES = new Set(DEFAULT_ATTACHMENT_PARTITIONS.map((entry) => entry.code))\n\ntype AuthLike = { sub?: string; tenantId?: string | null; orgId?: string | null; isSuperAdmin?: boolean } | null | undefined\n\nfunction isSuperAdmin(auth: AuthLike): boolean {\n return auth?.isSuperAdmin === true\n}\n\nfunction canManagePartition(auth: AuthLike, entry: AttachmentPartition): boolean {\n if (!auth?.tenantId) return false\n if (entry.tenantId && entry.tenantId === auth.tenantId) return true\n if (entry.tenantId == null && isSuperAdmin(auth)) return true\n return false\n}\n\nfunction partitionVisibilityFilter(auth: AuthLike) {\n if (isSuperAdmin(auth)) return {}\n return { $or: [{ tenantId: null }, { tenantId: auth?.tenantId ?? null }] }\n}\n\nfunction serializePartition(entry: AttachmentPartition) {\n return {\n id: entry.id,\n code: entry.code,\n title: entry.title,\n description: entry.description ?? null,\n isPublic: entry.isPublic ?? false,\n requiresOcr: entry.requiresOcr ?? resolveDefaultAttachmentOcrEnabled(),\n ocrModel: entry.ocrModel ?? null,\n storageDriver: entry.storageDriver ?? 'local',\n configJson: entry.configJson ?? null,\n createdAt: entry.createdAt instanceof Date ? entry.createdAt.toISOString() : null,\n updatedAt: entry.updatedAt instanceof Date ? entry.updatedAt.toISOString() : null,\n envKey: resolvePartitionEnvKey(entry.code),\n }\n}\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n POST: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n} as const\n\nasync function resolveEm() {\n const { resolve } = await createRequestContainer()\n return resolve('em') as EntityManager\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const em = await resolveEm()\n await ensureDefaultPartitions(em)\n const rows = await findWithDecryption<AttachmentPartition>(\n em,\n AttachmentPartition,\n partitionVisibilityFilter(auth),\n { orderBy: { createdAt: 'asc' } },\n { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },\n )\n return NextResponse.json({ items: rows.map((entry) => serializePartition(entry)) })\n}\n\nexport async function POST(req: Request) {\n if (isPartitionSettingsLocked()) {\n return NextResponse.json(\n { error: 'Attachment partitions are managed by the environment in demo/onboarding mode.' },\n { status: 403 },\n )\n }\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n let json: unknown = null\n try {\n json = await req.json()\n } catch {\n json = null\n }\n const parsed = partitionCreateSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })\n }\n const code = sanitizePartitionCode(parsed.data.code)\n if (!code) {\n return NextResponse.json({ error: 'Partition code is required.' }, { status: 400 })\n }\n const em = await resolveEm()\n await ensureDefaultPartitions(em)\n const exists = await findOneWithDecryption(\n em,\n AttachmentPartition,\n { code },\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },\n )\n if (exists) {\n return NextResponse.json({ error: 'Partition code already exists.' }, { status: 409 })\n }\n const entry = em.create(AttachmentPartition, {\n code,\n title: parsed.data.title.trim(),\n description: parsed.data.description?.trim() ?? null,\n storageDriver: parsed.data.storageDriver ?? 'local',\n configJson: parsed.data.configJson ?? null,\n isPublic: parsed.data.isPublic ?? false,\n requiresOcr:\n typeof parsed.data.requiresOcr === 'boolean'\n ? parsed.data.requiresOcr\n : resolveDefaultAttachmentOcrEnabled(),\n ocrModel: parsed.data.ocrModel?.trim() || null,\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n })\n await em.persist(entry).flush()\n return NextResponse.json({ item: serializePartition(entry) }, { status: 201 })\n}\n\nexport async function PUT(req: Request) {\n if (isPartitionSettingsLocked()) {\n return NextResponse.json(\n { error: 'Attachment partitions are managed by the environment in demo/onboarding mode.' },\n { status: 403 },\n )\n }\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n let json: unknown = null\n try {\n json = await req.json()\n } catch {\n json = null\n }\n const parsed = partitionUpdateSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })\n }\n const em = await resolveEm()\n const entry = await findOneWithDecryption(\n em,\n AttachmentPartition,\n { id: parsed.data.id },\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },\n )\n if (!entry || !canManagePartition(auth, entry)) {\n return NextResponse.json({ error: 'Partition not found' }, { status: 404 })\n }\n if (sanitizePartitionCode(parsed.data.code) !== entry.code) {\n return NextResponse.json({ error: 'Partition code cannot be changed.' }, { status: 400 })\n }\n entry.title = parsed.data.title.trim()\n entry.description = parsed.data.description?.trim() ?? null\n entry.isPublic = parsed.data.isPublic ?? false\n if (typeof parsed.data.requiresOcr === 'boolean') {\n entry.requiresOcr = parsed.data.requiresOcr\n }\n if (parsed.data.ocrModel !== undefined) {\n entry.ocrModel = parsed.data.ocrModel?.trim() || null\n }\n if (parsed.data.storageDriver !== undefined) {\n entry.storageDriver = parsed.data.storageDriver\n }\n if (parsed.data.configJson !== undefined) {\n entry.configJson = parsed.data.configJson ?? null\n }\n await em.persist(entry).flush()\n return NextResponse.json({ item: serializePartition(entry) })\n}\n\nexport async function DELETE(req: Request) {\n if (isPartitionSettingsLocked()) {\n return NextResponse.json(\n { error: 'Attachment partitions are managed by the environment in demo/onboarding mode.' },\n { status: 403 },\n )\n }\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const url = new URL(req.url)\n const id = url.searchParams.get('id')\n const parsed = deleteSchema.safeParse({ id })\n if (!parsed.success) {\n return NextResponse.json({ error: 'Partition id is required' }, { status: 400 })\n }\n const em = await resolveEm()\n const entry = await findOneWithDecryption(\n em,\n AttachmentPartition,\n { id: parsed.data.id },\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },\n )\n if (!entry || !canManagePartition(auth, entry)) {\n return NextResponse.json({ error: 'Partition not found' }, { status: 404 })\n }\n if (DEFAULT_CODES.has(entry.code)) {\n return NextResponse.json({ error: 'Default partitions cannot be removed.' }, { status: 400 })\n }\n const usageFilter: Record<string, unknown> = { partitionCode: entry.code }\n if (entry.tenantId) usageFilter.tenantId = entry.tenantId\n const usage = await em.count(Attachment, usageFilter)\n if (usage > 0) {\n return NextResponse.json({ error: 'Partition is in use and cannot be removed.' }, { status: 409 })\n }\n await em.remove(entry).flush()\n return NextResponse.json({ ok: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: attachmentsTag,\n summary: 'Attachment partition management',\n methods: {\n GET: {\n summary: 'List all attachment partitions',\n description: 'Returns all configured attachment partitions with storage settings, OCR configuration, and access control settings.',\n responses: [\n { status: 200, description: 'List of partitions', schema: partitionListResponseSchema },\n ],\n errors: [\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n ],\n },\n POST: {\n summary: 'Create new partition',\n description: 'Creates a new attachment partition with specified storage and OCR settings. Requires unique partition code.',\n requestBody: {\n contentType: 'application/json',\n schema: partitionCreateSchema,\n },\n responses: [\n { status: 201, description: 'Partition created successfully', schema: partitionResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid payload or partition code', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 403, description: 'Partitions locked in demo mode', schema: attachmentErrorSchema },\n { status: 409, description: 'Partition code already exists', schema: attachmentErrorSchema },\n ],\n },\n PUT: {\n summary: 'Update partition',\n description: 'Updates an existing partition. Partition code cannot be changed. Title, description, OCR settings, and access control can be modified.',\n requestBody: {\n contentType: 'application/json',\n schema: partitionUpdateSchema,\n },\n responses: [\n { status: 200, description: 'Partition updated successfully', schema: partitionResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid payload or code change attempt', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 403, description: 'Partitions locked in demo mode', schema: attachmentErrorSchema },\n { status: 404, description: 'Partition not found', schema: attachmentErrorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete partition',\n description: 'Deletes a partition. Default partitions cannot be deleted. Partitions with existing attachments cannot be deleted.',\n responses: [\n { status: 200, description: 'Partition deleted successfully', schema: z.object({ ok: z.literal(true) }) },\n ],\n errors: [\n { status: 400, description: 'Invalid ID or default partition deletion attempt', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 403, description: 'Partitions locked in demo mode', schema: attachmentErrorSchema },\n { status: 404, description: 'Partition not found', schema: attachmentErrorSchema },\n { status: 409, description: 'Partition in use', schema: attachmentErrorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,uBAAuB,0BAA0B;AAC1D,SAAS,YAAY,2BAA2B;AAChD,SAAS,yBAAyB,+BAA+B,uBAAuB,iCAAiC;AACzH,SAAS,8BAA8B;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { Attachment, AttachmentPartition } from '../../data/entities'\nimport { ensureDefaultPartitions, DEFAULT_ATTACHMENT_PARTITIONS, sanitizePartitionCode, isPartitionSettingsLocked } from '../../lib/partitions'\nimport { resolvePartitionEnvKey } from '../../lib/partitionEnv'\nimport { getDeclaredAttachmentAccessRequirements } from '../../lib/access-protection'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { resolveDefaultAttachmentOcrEnabled } from '../../lib/ocrConfig'\nimport {\n attachmentsTag,\n partitionCreateSchema,\n partitionUpdateSchema,\n partitionResponseSchema,\n partitionListResponseSchema,\n attachmentErrorSchema,\n} from '../openapi'\n\nconst deleteSchema = z.object({\n id: z.string().uuid(),\n})\n\nconst DEFAULT_CODES = new Set(DEFAULT_ATTACHMENT_PARTITIONS.map((entry) => entry.code))\n\ntype AuthLike = { sub?: string; tenantId?: string | null; orgId?: string | null; isSuperAdmin?: boolean } | null | undefined\n\nfunction isSuperAdmin(auth: AuthLike): boolean {\n return auth?.isSuperAdmin === true\n}\n\nfunction canManagePartition(auth: AuthLike, entry: AttachmentPartition): boolean {\n if (!auth?.tenantId) return false\n if (entry.tenantId && entry.tenantId === auth.tenantId) return true\n if (entry.tenantId == null && isSuperAdmin(auth)) return true\n return false\n}\n\nfunction partitionVisibilityFilter(auth: AuthLike) {\n if (isSuperAdmin(auth)) return {}\n return { $or: [{ tenantId: null }, { tenantId: auth?.tenantId ?? null }] }\n}\n\nfunction serializePartition(entry: AttachmentPartition) {\n return {\n id: entry.id,\n code: entry.code,\n title: entry.title,\n description: entry.description ?? null,\n isPublic: entry.isPublic ?? false,\n requiresOcr: entry.requiresOcr ?? resolveDefaultAttachmentOcrEnabled(),\n ocrModel: entry.ocrModel ?? null,\n storageDriver: entry.storageDriver ?? 'local',\n configJson: entry.configJson ?? null,\n createdAt: entry.createdAt instanceof Date ? entry.createdAt.toISOString() : null,\n updatedAt: entry.updatedAt instanceof Date ? entry.updatedAt.toISOString() : null,\n envKey: resolvePartitionEnvKey(entry.code),\n }\n}\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n POST: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n} as const\n\nasync function resolveEm() {\n const { resolve } = await createRequestContainer()\n return resolve('em') as EntityManager\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const em = await resolveEm()\n await ensureDefaultPartitions(em)\n const rows = await findWithDecryption<AttachmentPartition>(\n em,\n AttachmentPartition,\n partitionVisibilityFilter(auth),\n { orderBy: { createdAt: 'asc' } },\n { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },\n )\n return NextResponse.json({ items: rows.map((entry) => serializePartition(entry)) })\n}\n\nexport async function POST(req: Request) {\n if (isPartitionSettingsLocked()) {\n return NextResponse.json(\n { error: 'Attachment partitions are managed by the environment in demo/onboarding mode.' },\n { status: 403 },\n )\n }\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n let json: unknown = null\n try {\n json = await req.json()\n } catch {\n json = null\n }\n const parsed = partitionCreateSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })\n }\n const code = sanitizePartitionCode(parsed.data.code)\n if (!code) {\n return NextResponse.json({ error: 'Partition code is required.' }, { status: 400 })\n }\n const em = await resolveEm()\n await ensureDefaultPartitions(em)\n const exists = await findOneWithDecryption(\n em,\n AttachmentPartition,\n { code },\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },\n )\n if (exists) {\n return NextResponse.json({ error: 'Partition code already exists.' }, { status: 409 })\n }\n const entry = em.create(AttachmentPartition, {\n code,\n accessResolverRequirements: getDeclaredAttachmentAccessRequirements(code),\n title: parsed.data.title.trim(),\n description: parsed.data.description?.trim() ?? null,\n storageDriver: parsed.data.storageDriver ?? 'local',\n configJson: parsed.data.configJson ?? null,\n isPublic: parsed.data.isPublic ?? false,\n requiresOcr:\n typeof parsed.data.requiresOcr === 'boolean'\n ? parsed.data.requiresOcr\n : resolveDefaultAttachmentOcrEnabled(),\n ocrModel: parsed.data.ocrModel?.trim() || null,\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n })\n await em.persist(entry).flush()\n return NextResponse.json({ item: serializePartition(entry) }, { status: 201 })\n}\n\nexport async function PUT(req: Request) {\n if (isPartitionSettingsLocked()) {\n return NextResponse.json(\n { error: 'Attachment partitions are managed by the environment in demo/onboarding mode.' },\n { status: 403 },\n )\n }\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n let json: unknown = null\n try {\n json = await req.json()\n } catch {\n json = null\n }\n const parsed = partitionUpdateSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })\n }\n const em = await resolveEm()\n const entry = await findOneWithDecryption(\n em,\n AttachmentPartition,\n { id: parsed.data.id },\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },\n )\n if (!entry || !canManagePartition(auth, entry)) {\n return NextResponse.json({ error: 'Partition not found' }, { status: 404 })\n }\n if (sanitizePartitionCode(parsed.data.code) !== entry.code) {\n return NextResponse.json({ error: 'Partition code cannot be changed.' }, { status: 400 })\n }\n entry.title = parsed.data.title.trim()\n entry.description = parsed.data.description?.trim() ?? null\n entry.isPublic = parsed.data.isPublic ?? false\n if (typeof parsed.data.requiresOcr === 'boolean') {\n entry.requiresOcr = parsed.data.requiresOcr\n }\n if (parsed.data.ocrModel !== undefined) {\n entry.ocrModel = parsed.data.ocrModel?.trim() || null\n }\n if (parsed.data.storageDriver !== undefined) {\n entry.storageDriver = parsed.data.storageDriver\n }\n if (parsed.data.configJson !== undefined) {\n entry.configJson = parsed.data.configJson ?? null\n }\n await em.persist(entry).flush()\n return NextResponse.json({ item: serializePartition(entry) })\n}\n\nexport async function DELETE(req: Request) {\n if (isPartitionSettingsLocked()) {\n return NextResponse.json(\n { error: 'Attachment partitions are managed by the environment in demo/onboarding mode.' },\n { status: 403 },\n )\n }\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const url = new URL(req.url)\n const id = url.searchParams.get('id')\n const parsed = deleteSchema.safeParse({ id })\n if (!parsed.success) {\n return NextResponse.json({ error: 'Partition id is required' }, { status: 400 })\n }\n const em = await resolveEm()\n const entry = await findOneWithDecryption(\n em,\n AttachmentPartition,\n { id: parsed.data.id },\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },\n )\n if (!entry || !canManagePartition(auth, entry)) {\n return NextResponse.json({ error: 'Partition not found' }, { status: 404 })\n }\n if (DEFAULT_CODES.has(entry.code)) {\n return NextResponse.json({ error: 'Default partitions cannot be removed.' }, { status: 400 })\n }\n const usageFilter: Record<string, unknown> = { partitionCode: entry.code }\n if (entry.tenantId) usageFilter.tenantId = entry.tenantId\n const usage = await em.count(Attachment, usageFilter)\n if (usage > 0) {\n return NextResponse.json({ error: 'Partition is in use and cannot be removed.' }, { status: 409 })\n }\n await em.remove(entry).flush()\n return NextResponse.json({ ok: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: attachmentsTag,\n summary: 'Attachment partition management',\n methods: {\n GET: {\n summary: 'List all attachment partitions',\n description: 'Returns all configured attachment partitions with storage settings, OCR configuration, and access control settings.',\n responses: [\n { status: 200, description: 'List of partitions', schema: partitionListResponseSchema },\n ],\n errors: [\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n ],\n },\n POST: {\n summary: 'Create new partition',\n description: 'Creates a new attachment partition with specified storage and OCR settings. Requires unique partition code.',\n requestBody: {\n contentType: 'application/json',\n schema: partitionCreateSchema,\n },\n responses: [\n { status: 201, description: 'Partition created successfully', schema: partitionResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid payload or partition code', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 403, description: 'Partitions locked in demo mode', schema: attachmentErrorSchema },\n { status: 409, description: 'Partition code already exists', schema: attachmentErrorSchema },\n ],\n },\n PUT: {\n summary: 'Update partition',\n description: 'Updates an existing partition. Partition code cannot be changed. Title, description, OCR settings, and access control can be modified.',\n requestBody: {\n contentType: 'application/json',\n schema: partitionUpdateSchema,\n },\n responses: [\n { status: 200, description: 'Partition updated successfully', schema: partitionResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid payload or code change attempt', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 403, description: 'Partitions locked in demo mode', schema: attachmentErrorSchema },\n { status: 404, description: 'Partition not found', schema: attachmentErrorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete partition',\n description: 'Deletes a partition. Default partitions cannot be deleted. Partitions with existing attachments cannot be deleted.',\n responses: [\n { status: 200, description: 'Partition deleted successfully', schema: z.object({ ok: z.literal(true) }) },\n ],\n errors: [\n { status: 400, description: 'Invalid ID or default partition deletion attempt', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 403, description: 'Partitions locked in demo mode', schema: attachmentErrorSchema },\n { status: 404, description: 'Partition not found', schema: attachmentErrorSchema },\n { status: 409, description: 'Partition in use', schema: attachmentErrorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import { LockMode } from "@mikro-orm/core";
|
|
2
|
+
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
3
|
+
import { prepareAttachmentMutation } from "../lib/access-mutation.js";
|
|
4
|
+
import { throwAttachmentAccessError } from "../lib/access-errors.js";
|
|
5
|
+
import { createAttachmentAccessContext } from "../lib/access-runner.js";
|
|
6
|
+
import { assertAttachmentOwnerAccess, requiresAttachmentAccessScan, scanAuthorizedAttachments } from "../lib/access-query.js";
|
|
7
|
+
import { withAttachmentAccessErrors } from "../lib/access-errors.js";
|
|
1
8
|
import { NextResponse } from "next/server";
|
|
2
9
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
3
10
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
11
|
import { z } from "zod";
|
|
5
12
|
import { sql } from "kysely";
|
|
6
13
|
import { buildAttachmentFileUrl, buildAttachmentImageUrl, slugifyAttachmentFileName } from "../lib/imageUrls.js";
|
|
14
|
+
import { ensureAttachmentPartitionProtection } from "../lib/access-protection.js";
|
|
7
15
|
import { ensureDefaultPartitions, resolveDefaultPartitionCode, sanitizePartitionCode } from "../lib/partitions.js";
|
|
8
16
|
import { Attachment, AttachmentPartition } from "../data/entities.js";
|
|
9
17
|
import { extractAttachmentContent } from "../lib/textExtraction.js";
|
|
@@ -11,7 +19,7 @@ import { requestOcrProcessing } from "../lib/ocrQueue.js";
|
|
|
11
19
|
import { StorageDriverFactory } from "../lib/drivers/index.js";
|
|
12
20
|
import { OcrService, shouldUseLlmOcr } from "../lib/ocrService.js";
|
|
13
21
|
import { clearAttachmentThumbnailCache } from "../lib/thumbnailCache.js";
|
|
14
|
-
import {
|
|
22
|
+
import { lockAttachmentStorageReferences, resolveAttachmentStorageRelease } from "../lib/storageReferences.js";
|
|
15
23
|
import { assertAttachmentScopeInvariant } from "../lib/access.js";
|
|
16
24
|
import { resolveAttachmentRequestScope } from "../lib/requestScope.js";
|
|
17
25
|
import {
|
|
@@ -168,7 +176,7 @@ function parseFormAssignments(value) {
|
|
|
168
176
|
return [];
|
|
169
177
|
}
|
|
170
178
|
}
|
|
171
|
-
async function
|
|
179
|
+
async function listRecordAttachments(req) {
|
|
172
180
|
const auth = await getAuthFromRequest(req);
|
|
173
181
|
if (!auth || !auth.tenantId || !auth.orgId && !auth.isSuperAdmin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
174
182
|
const url = new URL(req.url);
|
|
@@ -191,27 +199,33 @@ async function GET(req) {
|
|
|
191
199
|
if (orgId) filter.organizationId = orgId;
|
|
192
200
|
const orderBy = { createdAt: "DESC", id: "DESC" };
|
|
193
201
|
const usePaging = typeof page === "number" && typeof pageSize === "number";
|
|
194
|
-
const total = usePaging ? await em.count(Attachment, filter) : null;
|
|
195
202
|
const currentPage = usePaging ? Math.max(1, page) : null;
|
|
196
203
|
const currentPageSize = usePaging ? pageSize : null;
|
|
197
|
-
const totalPages = usePaging && total !== null ? Math.max(1, Math.ceil(total / currentPageSize)) : null;
|
|
198
204
|
const pageOffset = usePaging ? (currentPage - 1) * currentPageSize : void 0;
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
205
|
+
const partitions = await em.find(AttachmentPartition, {});
|
|
206
|
+
const protectedScan = requiresAttachmentAccessScan(partitions);
|
|
207
|
+
const scope = { tenantId: auth.tenantId ?? null, organizationId: orgId ?? null };
|
|
208
|
+
let items;
|
|
209
|
+
let total;
|
|
210
|
+
if (protectedScan) {
|
|
211
|
+
const scanned = await scanAuthorizedAttachments({
|
|
212
|
+
auth: { ...auth, orgId },
|
|
213
|
+
context: createAttachmentAccessContext(container),
|
|
214
|
+
partitions,
|
|
215
|
+
offset: pageOffset,
|
|
216
|
+
limit: currentPageSize ?? ATTACHMENT_LIST_MAX_PAGE_SIZE,
|
|
217
|
+
loadBatch: (offset, limit) => findWithDecryption(em, Attachment, filter, { orderBy, offset, limit }, scope)
|
|
218
|
+
});
|
|
219
|
+
items = scanned.records;
|
|
220
|
+
total = usePaging ? scanned.total : null;
|
|
221
|
+
} else {
|
|
222
|
+
total = usePaging ? await em.count(Attachment, filter) : null;
|
|
223
|
+
items = await findWithDecryption(em, Attachment, filter, {
|
|
204
224
|
orderBy,
|
|
205
|
-
...usePaging ? {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
},
|
|
210
|
-
{
|
|
211
|
-
tenantId: auth.tenantId ?? null,
|
|
212
|
-
organizationId: orgId ?? null
|
|
213
|
-
}
|
|
214
|
-
);
|
|
225
|
+
...usePaging ? { limit: currentPageSize, offset: pageOffset } : { limit: ATTACHMENT_LIST_MAX_PAGE_SIZE }
|
|
226
|
+
}, scope);
|
|
227
|
+
}
|
|
228
|
+
const totalPages = usePaging && total !== null ? Math.max(1, Math.ceil(total / currentPageSize)) : null;
|
|
215
229
|
return NextResponse.json({
|
|
216
230
|
items: items.map((a) => {
|
|
217
231
|
const metadata2 = readAttachmentMetadata(a.storageMetadata);
|
|
@@ -239,8 +253,9 @@ async function GET(req) {
|
|
|
239
253
|
pageSize: currentPageSize,
|
|
240
254
|
totalPages
|
|
241
255
|
} : {}
|
|
242
|
-
});
|
|
256
|
+
}, { headers: protectedScan ? { "Cache-Control": "private, no-store" } : void 0 });
|
|
243
257
|
}
|
|
258
|
+
const GET = withAttachmentAccessErrors(listRecordAttachments);
|
|
244
259
|
async function POST(req) {
|
|
245
260
|
const { t } = await resolveTranslations();
|
|
246
261
|
const auth = await getAuthFromRequest(req);
|
|
@@ -403,6 +418,7 @@ async function POST(req) {
|
|
|
403
418
|
if (!partition) {
|
|
404
419
|
return NextResponse.json({ error: "Storage partition is not configured." }, { status: 400 });
|
|
405
420
|
}
|
|
421
|
+
await ensureAttachmentPartitionProtection(em, partition);
|
|
406
422
|
const requestedPublicOverride = typeof partitionOverride === "string" && partitionOverride.length > 0 && partition.code === partitionOverride && partition.isPublic === true && partition.code !== defaultPartitionCode && partition.code !== partitionFromField;
|
|
407
423
|
if (requestedPublicOverride) {
|
|
408
424
|
return NextResponse.json({ error: t("attachments.errors.publicPartitionBlocked", "Public storage partitions cannot be selected explicitly for this upload.") }, { status: 403 });
|
|
@@ -621,7 +637,7 @@ async function readTenantAttachmentUsageBytes(em, tenantId) {
|
|
|
621
637
|
throw new Error("Attachment quota accounting is unavailable.", { cause: error });
|
|
622
638
|
}
|
|
623
639
|
}
|
|
624
|
-
async function
|
|
640
|
+
async function deleteAttachment(req) {
|
|
625
641
|
const auth = await getAuthFromRequest(req);
|
|
626
642
|
if (!auth || !auth.tenantId || !auth.orgId && !auth.isSuperAdmin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
627
643
|
const url = new URL(req.url);
|
|
@@ -636,20 +652,37 @@ async function DELETE(req) {
|
|
|
636
652
|
const orgId = deleteScope.organizationId;
|
|
637
653
|
const deleteFilter = { id, tenantId: auth.tenantId };
|
|
638
654
|
if (orgId) deleteFilter.organizationId = orgId;
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
655
|
+
const scopedAuth = { ...auth, orgId };
|
|
656
|
+
const outcome = await em.transactional(async (tx) => {
|
|
657
|
+
const candidate = await findOneWithDecryption(tx, Attachment, deleteFilter, void 0, {
|
|
658
|
+
tenantId: auth.tenantId,
|
|
659
|
+
organizationId: orgId
|
|
660
|
+
});
|
|
661
|
+
if (!candidate) return throwAttachmentAccessError(404);
|
|
662
|
+
const referenceIds = await lockAttachmentStorageReferences(tx, candidate);
|
|
663
|
+
const removal = resolveAttachmentStorageRelease(referenceIds, candidate.id);
|
|
664
|
+
if (!removal.removed) return throwAttachmentAccessError(404);
|
|
665
|
+
const record2 = await findOneWithDecryption(tx, Attachment, deleteFilter, {
|
|
666
|
+
lockMode: LockMode.PESSIMISTIC_WRITE,
|
|
667
|
+
refresh: true
|
|
668
|
+
}, { tenantId: auth.tenantId, organizationId: orgId });
|
|
669
|
+
if (!record2) return throwAttachmentAccessError(404);
|
|
670
|
+
const guard = await prepareAttachmentMutation({ container, req, auth: scopedAuth, recordId: record2.id, operation: "delete" });
|
|
671
|
+
await assertAttachmentOwnerAccess({ em: tx, context: createAttachmentAccessContext(container), auth: scopedAuth, attachment: record2, action: "delete" });
|
|
672
|
+
const driver = record2.storagePath ? await storageDriverFactory.resolveForPartition(record2.partitionCode, {
|
|
673
|
+
tenantId: record2.tenantId ?? auth.tenantId,
|
|
674
|
+
organizationId: record2.organizationId ?? orgId ?? ""
|
|
675
|
+
}) : null;
|
|
676
|
+
tx.remove(record2);
|
|
677
|
+
await tx.flush();
|
|
678
|
+
return { record: record2, driver, guard, releaseStorage: removal.releaseStorage };
|
|
679
|
+
});
|
|
680
|
+
const { record } = outcome;
|
|
643
681
|
await clearAttachmentThumbnailCache(record.partitionCode, record.id).catch((error) => {
|
|
644
682
|
logger.error("Failed to cleanup cached thumbnails", { err: error });
|
|
645
683
|
});
|
|
646
|
-
if (
|
|
647
|
-
|
|
648
|
-
tenantId: record.tenantId ?? auth.tenantId,
|
|
649
|
-
organizationId: record.organizationId ?? orgId ?? ""
|
|
650
|
-
});
|
|
651
|
-
await delDriver.delete(record.partitionCode, record.storagePath);
|
|
652
|
-
}
|
|
684
|
+
if (outcome.driver && outcome.releaseStorage) await outcome.driver.delete(record.partitionCode, record.storagePath);
|
|
685
|
+
await outcome.guard.runAfterSuccess();
|
|
653
686
|
if (dataEngine) {
|
|
654
687
|
await emitCrudSideEffects({
|
|
655
688
|
dataEngine,
|
|
@@ -667,6 +700,7 @@ async function DELETE(req) {
|
|
|
667
700
|
}
|
|
668
701
|
return NextResponse.json({ ok: true });
|
|
669
702
|
}
|
|
703
|
+
const DELETE = withAttachmentAccessErrors(deleteAttachment);
|
|
670
704
|
const openApi = {
|
|
671
705
|
summary: "Manage entity attachments",
|
|
672
706
|
description: "Upload and list attachments associated with module entities and records.",
|
|
@@ -680,7 +714,10 @@ const openApi = {
|
|
|
680
714
|
],
|
|
681
715
|
errors: [
|
|
682
716
|
{ status: 400, description: "Missing entity or record identifiers", schema: errorSchema },
|
|
683
|
-
{ status: 401, description: "Unauthorized", schema: errorSchema }
|
|
717
|
+
{ status: 401, description: "Unauthorized", schema: errorSchema },
|
|
718
|
+
{ status: 403, description: "Owner policy denies access", schema: errorSchema },
|
|
719
|
+
{ status: 404, description: "Attachment or owner is unavailable", schema: errorSchema },
|
|
720
|
+
{ status: 504, description: "Owner authorization timed out", schema: errorSchema }
|
|
684
721
|
]
|
|
685
722
|
},
|
|
686
723
|
POST: {
|
|
@@ -709,7 +746,10 @@ const openApi = {
|
|
|
709
746
|
],
|
|
710
747
|
errors: [
|
|
711
748
|
{ status: 400, description: "Missing attachment identifier", schema: errorSchema },
|
|
712
|
-
{ status: 401, description: "Unauthorized", schema: errorSchema }
|
|
749
|
+
{ status: 401, description: "Unauthorized", schema: errorSchema },
|
|
750
|
+
{ status: 403, description: "Owner policy denies access", schema: errorSchema },
|
|
751
|
+
{ status: 404, description: "Attachment or owner is unavailable", schema: errorSchema },
|
|
752
|
+
{ status: 504, description: "Owner authorization timed out", schema: errorSchema }
|
|
713
753
|
]
|
|
714
754
|
}
|
|
715
755
|
}
|