@open-mercato/core 0.10.1-develop.7741.1.61a2652671 → 0.10.1-develop.7749.1.ea3edf75e3
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/attachment_partition/index.js +2 -0
- package/dist/generated/entities/attachment_partition/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -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/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/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/step-handler.js +9 -2
- package/dist/modules/workflows/lib/step-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/transition-handler.js +2 -1
- package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
- package/dist/modules/workflows/notifications.js +4 -0
- package/dist/modules/workflows/notifications.js.map +2 -2
- package/generated/entities/attachment_partition/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/helpers/integration/attachmentAccessFixtures.ts +141 -0
- 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/data/entities.ts +6 -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/step-handler.ts +16 -1
- package/src/modules/workflows/lib/task-quick-action.ts +19 -10
- package/src/modules/workflows/lib/transition-handler.ts +2 -1
- package/src/modules/workflows/notifications.ts +4 -0
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { LockMode } from '@mikro-orm/core'
|
|
2
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
|
|
4
|
+
import { prepareAttachmentMutation } from '../../../lib/access-mutation'
|
|
5
|
+
import { throwAttachmentAccessError } from '../../../lib/access-errors'
|
|
6
|
+
import { createAttachmentAccessContext } from '../../../lib/access-runner'
|
|
7
|
+
import { assertAttachmentOwnerAccess } from '../../../lib/access-query'
|
|
8
|
+
import { withAttachmentAccessErrors } from '../../../lib/access-errors'
|
|
1
9
|
import { NextRequest, NextResponse } from 'next/server'
|
|
2
10
|
import { z } from 'zod'
|
|
3
11
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
@@ -12,7 +20,7 @@ import {
|
|
|
12
20
|
readAttachmentMetadata,
|
|
13
21
|
} from '../../../lib/metadata'
|
|
14
22
|
import type { StorageDriverFactory } from '../../../lib/drivers'
|
|
15
|
-
import {
|
|
23
|
+
import { lockAttachmentStorageReferences, resolveAttachmentStorageRelease } from '../../../lib/storageReferences'
|
|
16
24
|
import { splitCustomFieldPayload, loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
17
25
|
import { emitCrudSideEffects, setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'
|
|
18
26
|
import { normalizeCustomFieldResponse } from '@open-mercato/shared/lib/custom-fields/normalize'
|
|
@@ -26,9 +34,6 @@ import {
|
|
|
26
34
|
attachmentDetailResponseSchema,
|
|
27
35
|
attachmentErrorSchema,
|
|
28
36
|
} from '../../openapi'
|
|
29
|
-
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
30
|
-
|
|
31
|
-
const logger = createLogger('attachments').child({ component: 'library' })
|
|
32
37
|
|
|
33
38
|
const updateSchema = z.object({
|
|
34
39
|
tags: z.array(z.string()).optional(),
|
|
@@ -66,7 +71,7 @@ async function resolveAttachmentId(ctx: RouteContext): Promise<string | null> {
|
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
|
|
74
|
+
async function getAttachment(req: NextRequest, ctx: RouteContext) {
|
|
70
75
|
const auth = await getAuthFromRequest(req)
|
|
71
76
|
if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) {
|
|
72
77
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
@@ -75,7 +80,8 @@ export async function GET(req: NextRequest, ctx: RouteContext) {
|
|
|
75
80
|
if (!attachmentId) {
|
|
76
81
|
return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })
|
|
77
82
|
}
|
|
78
|
-
const
|
|
83
|
+
const container = await createRequestContainer()
|
|
84
|
+
const { resolve } = container
|
|
79
85
|
const em = resolve('em') as EntityManager
|
|
80
86
|
let queryEngine: QueryEngine | null = null
|
|
81
87
|
try {
|
|
@@ -94,6 +100,7 @@ export async function GET(req: NextRequest, ctx: RouteContext) {
|
|
|
94
100
|
if (!record) {
|
|
95
101
|
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
|
|
96
102
|
}
|
|
103
|
+
await assertAttachmentOwnerAccess({ em, context: createAttachmentAccessContext(container), auth, attachment: record, action: 'metadata' })
|
|
97
104
|
const metadata = readAttachmentMetadata(record.storageMetadata)
|
|
98
105
|
const partition = record.partitionCode
|
|
99
106
|
? await em.findOne(AttachmentPartition, { code: record.partitionCode })
|
|
@@ -127,10 +134,12 @@ export async function GET(req: NextRequest, ctx: RouteContext) {
|
|
|
127
134
|
content: record.content && record.content.trim() ? record.content : null,
|
|
128
135
|
customFields,
|
|
129
136
|
},
|
|
130
|
-
})
|
|
137
|
+
}, { headers: { 'Cache-Control': 'private, no-store' } })
|
|
131
138
|
}
|
|
132
139
|
|
|
133
|
-
export
|
|
140
|
+
export const GET = withAttachmentAccessErrors(getAttachment)
|
|
141
|
+
|
|
142
|
+
async function updateAttachment(req: NextRequest, ctx: RouteContext) {
|
|
134
143
|
const auth = await getAuthFromRequest(req)
|
|
135
144
|
if (!auth || !auth.tenantId || !auth.orgId) {
|
|
136
145
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
@@ -139,13 +148,14 @@ export async function PATCH(req: NextRequest, ctx: RouteContext) {
|
|
|
139
148
|
if (!attachmentId) {
|
|
140
149
|
return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })
|
|
141
150
|
}
|
|
142
|
-
const rawBody = await req
|
|
151
|
+
const rawBody = await readJsonSafe(req, null)
|
|
143
152
|
const { base, custom } = splitCustomFieldPayload(rawBody)
|
|
144
153
|
const parsed = updateSchema.safeParse(base)
|
|
145
154
|
if (!parsed.success) {
|
|
146
155
|
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
|
147
156
|
}
|
|
148
|
-
const
|
|
157
|
+
const container = await createRequestContainer()
|
|
158
|
+
const { resolve } = container
|
|
149
159
|
const em = resolve('em') as EntityManager
|
|
150
160
|
let queryEngine: QueryEngine | null = null
|
|
151
161
|
try {
|
|
@@ -159,34 +169,38 @@ export async function PATCH(req: NextRequest, ctx: RouteContext) {
|
|
|
159
169
|
tenantId: auth.tenantId,
|
|
160
170
|
organizationId: auth.orgId,
|
|
161
171
|
}
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// custom-field failure cannot leave the attachment metadata partially updated.
|
|
172
|
-
try {
|
|
173
|
-
await em.transactional(async (tx) => {
|
|
174
|
-
await tx.flush()
|
|
175
|
-
if (dataEngine && custom && Object.keys(custom).length) {
|
|
176
|
-
await setCustomFieldsIfAny({
|
|
177
|
-
dataEngine,
|
|
178
|
-
entityId: E.attachments.attachment,
|
|
179
|
-
recordId: record.id,
|
|
180
|
-
tenantId: record.tenantId ?? auth.tenantId ?? null,
|
|
181
|
-
organizationId: record.organizationId ?? auth.orgId ?? null,
|
|
182
|
-
values: custom,
|
|
183
|
-
})
|
|
184
|
-
}
|
|
172
|
+
const accessContext = createAttachmentAccessContext(container)
|
|
173
|
+
const outcome = await em.transactional(async (tx) => {
|
|
174
|
+
const record = await findOneWithDecryption(tx, Attachment, patchFilter, {
|
|
175
|
+
lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true,
|
|
176
|
+
}, { tenantId: auth.tenantId, organizationId: auth.orgId })
|
|
177
|
+
if (!record) return throwAttachmentAccessError(404)
|
|
178
|
+
const guard = await prepareAttachmentMutation({
|
|
179
|
+
container, req, auth, recordId: record.id, operation: 'update',
|
|
180
|
+
payload: { ...parsed.data, customFields: custom },
|
|
185
181
|
})
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
182
|
+
const effective = splitCustomFieldPayload(guard.modifiedPayload ?? { ...parsed.data, customFields: custom })
|
|
183
|
+
const effectiveParsed = updateSchema.safeParse(effective.base)
|
|
184
|
+
if (!effectiveParsed.success) return throwAttachmentAccessError(403)
|
|
185
|
+
const patch: Record<string, unknown> = {}
|
|
186
|
+
if (effectiveParsed.data.tags) patch.tags = normalizeAttachmentTags(effectiveParsed.data.tags)
|
|
187
|
+
if (effectiveParsed.data.assignments) patch.assignments = normalizeAttachmentAssignments(effectiveParsed.data.assignments)
|
|
188
|
+
const nextMetadata = mergeAttachmentMetadata(record.storageMetadata, patch)
|
|
189
|
+
await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: record, action: 'reassign', persistProtection: true })
|
|
190
|
+
await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: { ...record, storageMetadata: nextMetadata }, action: 'reassign' })
|
|
191
|
+
await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: { ...record, storageMetadata: nextMetadata }, action: 'metadata' })
|
|
192
|
+
record.storageMetadata = nextMetadata
|
|
193
|
+
await tx.flush()
|
|
194
|
+
if (dataEngine && Object.keys(effective.custom).length) {
|
|
195
|
+
await setCustomFieldsIfAny({ dataEngine, entityId: E.attachments.attachment, recordId: record.id,
|
|
196
|
+
tenantId: record.tenantId ?? auth.tenantId, organizationId: record.organizationId ?? auth.orgId,
|
|
197
|
+
values: effective.custom,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
return { record, custom: effective.custom, guard }
|
|
201
|
+
})
|
|
202
|
+
const { record } = outcome
|
|
203
|
+
await outcome.guard.runAfterSuccess()
|
|
190
204
|
|
|
191
205
|
if (dataEngine) {
|
|
192
206
|
await emitCrudSideEffects({
|
|
@@ -218,12 +232,12 @@ export async function PATCH(req: NextRequest, ctx: RouteContext) {
|
|
|
218
232
|
id: record.id,
|
|
219
233
|
tags: metadata.tags ?? [],
|
|
220
234
|
assignments: enrichedAssignments,
|
|
221
|
-
customFields: normalizeCustomFieldResponse(custom ?? null),
|
|
235
|
+
customFields: normalizeCustomFieldResponse(outcome.custom ?? null),
|
|
222
236
|
},
|
|
223
237
|
})
|
|
224
238
|
}
|
|
225
239
|
|
|
226
|
-
|
|
240
|
+
async function deleteAttachment(req: NextRequest, ctx: RouteContext) {
|
|
227
241
|
const auth = await getAuthFromRequest(req)
|
|
228
242
|
if (!auth || !auth.tenantId || !auth.orgId) {
|
|
229
243
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
@@ -232,7 +246,8 @@ export async function DELETE(req: NextRequest, ctx: RouteContext) {
|
|
|
232
246
|
if (!attachmentId) {
|
|
233
247
|
return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })
|
|
234
248
|
}
|
|
235
|
-
const
|
|
249
|
+
const container = await createRequestContainer()
|
|
250
|
+
const { resolve } = container
|
|
236
251
|
const em = resolve('em') as EntityManager
|
|
237
252
|
const dataEngine = resolve('dataEngine') as DataEngine
|
|
238
253
|
const storageDriverFactory = resolve('storageDriverFactory') as StorageDriverFactory
|
|
@@ -241,25 +256,30 @@ export async function DELETE(req: NextRequest, ctx: RouteContext) {
|
|
|
241
256
|
tenantId: auth.tenantId,
|
|
242
257
|
organizationId: auth.orgId,
|
|
243
258
|
}
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
259
|
+
const outcome = await em.transactional(async (tx) => {
|
|
260
|
+
const candidate = await findOneWithDecryption(tx, Attachment, deleteFilter, undefined, {
|
|
261
|
+
tenantId: auth.tenantId, organizationId: auth.orgId,
|
|
262
|
+
})
|
|
263
|
+
if (!candidate) return throwAttachmentAccessError(404)
|
|
264
|
+
const referenceIds = await lockAttachmentStorageReferences(tx, candidate)
|
|
265
|
+
const removal = resolveAttachmentStorageRelease(referenceIds, candidate.id)
|
|
266
|
+
if (!removal.removed) return throwAttachmentAccessError(404)
|
|
267
|
+
const record = await findOneWithDecryption(tx, Attachment, deleteFilter, {
|
|
268
|
+
lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true,
|
|
269
|
+
}, { tenantId: auth.tenantId, organizationId: auth.orgId })
|
|
270
|
+
if (!record) return throwAttachmentAccessError(404)
|
|
271
|
+
const guard = await prepareAttachmentMutation({ container, req, auth, recordId: record.id, operation: 'delete' })
|
|
272
|
+
await assertAttachmentOwnerAccess({ em: tx, context: createAttachmentAccessContext(container), auth, attachment: record, action: 'delete' })
|
|
273
|
+
const driver = await storageDriverFactory.resolveForPartition(record.partitionCode, {
|
|
274
|
+
tenantId: record.tenantId ?? auth.tenantId!, organizationId: record.organizationId ?? auth.orgId!,
|
|
275
|
+
})
|
|
276
|
+
tx.remove(record)
|
|
277
|
+
await tx.flush()
|
|
278
|
+
return { record, driver, guard, releaseStorage: removal.releaseStorage }
|
|
251
279
|
})
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const recordStoragePath = record.storagePath
|
|
256
|
-
const removal = await removeAttachmentRecord(em, record)
|
|
257
|
-
if (!removal.removed) {
|
|
258
|
-
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
|
|
259
|
-
}
|
|
260
|
-
if (removal.releaseStorage) {
|
|
261
|
-
await deleteDriver.delete(recordPartitionCode, recordStoragePath)
|
|
262
|
-
}
|
|
280
|
+
const { record } = outcome
|
|
281
|
+
if (outcome.releaseStorage) await outcome.driver.delete(record.partitionCode, record.storagePath)
|
|
282
|
+
await outcome.guard.runAfterSuccess()
|
|
263
283
|
|
|
264
284
|
if (dataEngine) {
|
|
265
285
|
await emitCrudSideEffects({
|
|
@@ -280,6 +300,9 @@ export async function DELETE(req: NextRequest, ctx: RouteContext) {
|
|
|
280
300
|
return NextResponse.json({ ok: true })
|
|
281
301
|
}
|
|
282
302
|
|
|
303
|
+
export const PATCH = withAttachmentAccessErrors(updateAttachment)
|
|
304
|
+
export const DELETE = withAttachmentAccessErrors(deleteAttachment)
|
|
305
|
+
|
|
283
306
|
export const openApi: OpenApiRouteDoc = {
|
|
284
307
|
tag: attachmentsTag,
|
|
285
308
|
summary: 'Attachment detail management',
|
|
@@ -294,6 +317,8 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
294
317
|
{ status: 400, description: 'Invalid attachment ID', schema: attachmentErrorSchema },
|
|
295
318
|
{ status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
|
|
296
319
|
{ status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },
|
|
320
|
+
{ status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
|
|
321
|
+
{ status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
|
|
297
322
|
],
|
|
298
323
|
},
|
|
299
324
|
PATCH: {
|
|
@@ -311,6 +336,8 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
311
336
|
{ status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
|
|
312
337
|
{ status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },
|
|
313
338
|
{ status: 500, description: 'Failed to save attributes', schema: attachmentErrorSchema },
|
|
339
|
+
{ status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
|
|
340
|
+
{ status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
|
|
314
341
|
],
|
|
315
342
|
},
|
|
316
343
|
DELETE: {
|
|
@@ -323,6 +350,8 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
323
350
|
{ status: 400, description: 'Invalid attachment ID', schema: attachmentErrorSchema },
|
|
324
351
|
{ status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
|
|
325
352
|
{ status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },
|
|
353
|
+
{ status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
|
|
354
|
+
{ status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
|
|
326
355
|
],
|
|
327
356
|
},
|
|
328
357
|
},
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { createAttachmentAccessContext } from '../../lib/access-runner'
|
|
2
|
+
import { requiresAttachmentAccessScan, scanAuthorizedAttachments } from '../../lib/access-query'
|
|
3
|
+
import { withAttachmentAccessErrors } from '../../lib/access-errors'
|
|
1
4
|
import { NextResponse } from 'next/server'
|
|
2
5
|
import { z } from 'zod'
|
|
3
6
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
@@ -55,7 +58,7 @@ function formatDateValue(value: unknown): string {
|
|
|
55
58
|
return toDate().toISOString()
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
async function listAttachments(req: Request) {
|
|
59
62
|
const auth = await getAuthFromRequest(req)
|
|
60
63
|
if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) {
|
|
61
64
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
@@ -69,7 +72,8 @@ export async function GET(req: Request) {
|
|
|
69
72
|
const { page, pageSize, search, partition, tags, sortField, sortDir } = parsed.data
|
|
70
73
|
const tagList = buildTagFilter(tags)
|
|
71
74
|
const offset = (page - 1) * pageSize
|
|
72
|
-
const
|
|
75
|
+
const container = await createRequestContainer()
|
|
76
|
+
const { resolve } = container
|
|
73
77
|
const em = resolve('em') as EntityManager
|
|
74
78
|
await ensureDefaultPartitions(em)
|
|
75
79
|
let queryEngine: QueryEngine | null = null
|
|
@@ -100,15 +104,32 @@ export async function GET(req: Request) {
|
|
|
100
104
|
createdAt: 'a.created_at',
|
|
101
105
|
}
|
|
102
106
|
const orderColumn = orderMap[sortField ?? 'createdAt'] ?? 'a.created_at'
|
|
103
|
-
qb.orderBy({ [orderColumn]: sortDir === 'asc' ? 'asc' : 'desc' })
|
|
107
|
+
qb.orderBy({ [orderColumn]: sortDir === 'asc' ? 'asc' : 'desc', 'a.id': 'asc' })
|
|
104
108
|
qb.limit(pageSize).offset(offset)
|
|
105
109
|
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
const partitions = await em.find(AttachmentPartition, {}, { orderBy: { title: 'asc' } })
|
|
111
|
+
const protectedScan = requiresAttachmentAccessScan(partitions)
|
|
112
|
+
const accessContext = createAttachmentAccessContext(container)
|
|
113
|
+
const deadline = Date.now() + 15_000
|
|
114
|
+
let records: Attachment[]
|
|
115
|
+
let total: number
|
|
116
|
+
let authorizedTags: string[] | null = null
|
|
117
|
+
if (protectedScan) {
|
|
118
|
+
const scanned = await scanAuthorizedAttachments({
|
|
119
|
+
auth, context: accessContext, partitions, offset, limit: pageSize, deadline,
|
|
120
|
+
loadBatch: (batchOffset, limit) => qb.clone().limit(limit).offset(batchOffset).getResultList(),
|
|
121
|
+
})
|
|
122
|
+
records = scanned.records
|
|
123
|
+
total = scanned.total
|
|
124
|
+
const facetQuery = em.createQueryBuilder(Attachment, 'a').where(baseFilter).orderBy({ 'a.id': 'asc' })
|
|
125
|
+
const facets = await scanAuthorizedAttachments({
|
|
126
|
+
auth, context: accessContext, partitions, limit: 0, collectTags: true, deadline,
|
|
127
|
+
loadBatch: (batchOffset, limit) => facetQuery.clone().limit(limit).offset(batchOffset).getResultList(),
|
|
128
|
+
})
|
|
129
|
+
authorizedTags = facets.tags
|
|
130
|
+
} else {
|
|
131
|
+
;[records, total] = await Promise.all([qb.getResultList(), countQb.getCount('a.id', true)])
|
|
132
|
+
}
|
|
112
133
|
const partitionTitleByCode = partitions.reduce<Record<string, string>>((acc, entry) => {
|
|
113
134
|
if (entry.code) acc[entry.code] = entry.title ?? entry.code
|
|
114
135
|
return acc
|
|
@@ -154,6 +175,8 @@ export async function GET(req: Request) {
|
|
|
154
175
|
: items
|
|
155
176
|
|
|
156
177
|
const totalPages = Math.max(1, Math.ceil(Number(total) / pageSize))
|
|
178
|
+
let availableTags = authorizedTags ?? []
|
|
179
|
+
if (!protectedScan) {
|
|
157
180
|
const db = em.getKysely<any>() as any
|
|
158
181
|
let tagQuery = db
|
|
159
182
|
.selectFrom('attachments')
|
|
@@ -163,9 +186,10 @@ export async function GET(req: Request) {
|
|
|
163
186
|
tagQuery = tagQuery.where('organization_id', '=', auth.orgId)
|
|
164
187
|
}
|
|
165
188
|
const tagRows = await tagQuery.orderBy('tag', 'asc').execute() as Array<{ tag?: string | null }>
|
|
166
|
-
|
|
189
|
+
availableTags = tagRows
|
|
167
190
|
.map((row) => (typeof row.tag === 'string' ? row.tag.trim() : ''))
|
|
168
191
|
.filter((tag) => tag.length > 0)
|
|
192
|
+
}
|
|
169
193
|
|
|
170
194
|
return NextResponse.json({
|
|
171
195
|
items: enrichedItems,
|
|
@@ -180,9 +204,11 @@ export async function GET(req: Request) {
|
|
|
180
204
|
description: entry.description ?? null,
|
|
181
205
|
isPublic: entry.isPublic ?? false,
|
|
182
206
|
})),
|
|
183
|
-
})
|
|
207
|
+
}, { headers: protectedScan ? { 'Cache-Control': 'private, no-store' } : undefined })
|
|
184
208
|
}
|
|
185
209
|
|
|
210
|
+
export const GET = withAttachmentAccessErrors(listAttachments)
|
|
211
|
+
|
|
186
212
|
export const openApi: OpenApiRouteDoc = {
|
|
187
213
|
tag: attachmentsTag,
|
|
188
214
|
summary: 'Attachment library management',
|
|
@@ -197,6 +223,9 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
197
223
|
errors: [
|
|
198
224
|
{ status: 400, description: 'Invalid query parameters', schema: attachmentErrorSchema },
|
|
199
225
|
{ status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
|
|
226
|
+
{ status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
|
|
227
|
+
{ status: 404, description: 'Attachment or owner is unavailable', schema: attachmentErrorSchema },
|
|
228
|
+
{ status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
|
|
200
229
|
],
|
|
201
230
|
},
|
|
202
231
|
},
|
|
@@ -7,6 +7,7 @@ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/
|
|
|
7
7
|
import { Attachment, AttachmentPartition } from '../../data/entities'
|
|
8
8
|
import { ensureDefaultPartitions, DEFAULT_ATTACHMENT_PARTITIONS, sanitizePartitionCode, isPartitionSettingsLocked } from '../../lib/partitions'
|
|
9
9
|
import { resolvePartitionEnvKey } from '../../lib/partitionEnv'
|
|
10
|
+
import { getDeclaredAttachmentAccessRequirements } from '../../lib/access-protection'
|
|
10
11
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
11
12
|
import { resolveDefaultAttachmentOcrEnabled } from '../../lib/ocrConfig'
|
|
12
13
|
import {
|
|
@@ -127,6 +128,7 @@ export async function POST(req: Request) {
|
|
|
127
128
|
}
|
|
128
129
|
const entry = em.create(AttachmentPartition, {
|
|
129
130
|
code,
|
|
131
|
+
accessResolverRequirements: getDeclaredAttachmentAccessRequirements(code),
|
|
130
132
|
title: parsed.data.title.trim(),
|
|
131
133
|
description: parsed.data.description?.trim() ?? null,
|
|
132
134
|
storageDriver: parsed.data.storageDriver ?? 'local',
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { LockMode } from '@mikro-orm/core'
|
|
2
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
|
+
import { prepareAttachmentMutation } from '../lib/access-mutation'
|
|
4
|
+
import { throwAttachmentAccessError } from '../lib/access-errors'
|
|
5
|
+
import { createAttachmentAccessContext } from '../lib/access-runner'
|
|
6
|
+
import { assertAttachmentOwnerAccess, requiresAttachmentAccessScan, scanAuthorizedAttachments } from '../lib/access-query'
|
|
7
|
+
import { withAttachmentAccessErrors } from '../lib/access-errors'
|
|
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'
|
|
@@ -5,6 +12,7 @@ import { z } from 'zod'
|
|
|
5
12
|
import { sql } from 'kysely'
|
|
6
13
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
7
14
|
import { buildAttachmentFileUrl, buildAttachmentImageUrl, slugifyAttachmentFileName } from '../lib/imageUrls'
|
|
15
|
+
import { ensureAttachmentPartitionProtection } from '../lib/access-protection'
|
|
8
16
|
import { ensureDefaultPartitions, resolveDefaultPartitionCode, sanitizePartitionCode } from '../lib/partitions'
|
|
9
17
|
import { Attachment, AttachmentPartition } from '../data/entities'
|
|
10
18
|
import { extractAttachmentContent } from '../lib/textExtraction'
|
|
@@ -12,7 +20,7 @@ import { requestOcrProcessing } from '../lib/ocrQueue'
|
|
|
12
20
|
import { StorageDriverFactory } from '../lib/drivers'
|
|
13
21
|
import { OcrService, shouldUseLlmOcr } from '../lib/ocrService'
|
|
14
22
|
import { clearAttachmentThumbnailCache } from '../lib/thumbnailCache'
|
|
15
|
-
import {
|
|
23
|
+
import { lockAttachmentStorageReferences, resolveAttachmentStorageRelease } from '../lib/storageReferences'
|
|
16
24
|
import { assertAttachmentScopeInvariant } from '../lib/access'
|
|
17
25
|
import { resolveAttachmentRequestScope } from '../lib/requestScope'
|
|
18
26
|
import {
|
|
@@ -193,7 +201,7 @@ function parseFormAssignments(value: FormDataEntryValue | null): AttachmentAssig
|
|
|
193
201
|
}
|
|
194
202
|
}
|
|
195
203
|
|
|
196
|
-
|
|
204
|
+
async function listRecordAttachments(req: Request) {
|
|
197
205
|
const auth = await getAuthFromRequest(req)
|
|
198
206
|
if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
199
207
|
const url = new URL(req.url)
|
|
@@ -217,29 +225,29 @@ export async function GET(req: Request) {
|
|
|
217
225
|
if (orgId) filter.organizationId = orgId
|
|
218
226
|
const orderBy: Record<string, 'ASC' | 'DESC'> = { createdAt: 'DESC', id: 'DESC' }
|
|
219
227
|
const usePaging = typeof page === 'number' && typeof pageSize === 'number'
|
|
220
|
-
const total = usePaging ? await em.count(Attachment, filter) : null
|
|
221
228
|
const currentPage = usePaging ? Math.max(1, page) : null
|
|
222
229
|
const currentPageSize = usePaging ? pageSize : null
|
|
223
|
-
const totalPages = usePaging && total !== null ? Math.max(1, Math.ceil(total / currentPageSize!)) : null
|
|
224
230
|
const pageOffset = usePaging ? (currentPage! - 1) * currentPageSize! : undefined
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
231
|
+
const partitions = await em.find(AttachmentPartition, {})
|
|
232
|
+
const protectedScan = requiresAttachmentAccessScan(partitions)
|
|
233
|
+
const scope = { tenantId: auth.tenantId ?? null, organizationId: orgId ?? null }
|
|
234
|
+
let items: Attachment[]
|
|
235
|
+
let total: number | null
|
|
236
|
+
if (protectedScan) {
|
|
237
|
+
const scanned = await scanAuthorizedAttachments({
|
|
238
|
+
auth: { ...auth, orgId }, context: createAttachmentAccessContext(container), partitions,
|
|
239
|
+
offset: pageOffset, limit: currentPageSize ?? ATTACHMENT_LIST_MAX_PAGE_SIZE,
|
|
240
|
+
loadBatch: (offset, limit) => findWithDecryption(em, Attachment, filter, { orderBy, offset, limit }, scope),
|
|
241
|
+
})
|
|
242
|
+
items = scanned.records
|
|
243
|
+
total = usePaging ? scanned.total : null
|
|
244
|
+
} else {
|
|
245
|
+
total = usePaging ? await em.count(Attachment, filter) : null
|
|
246
|
+
items = await findWithDecryption(em, Attachment, filter, {
|
|
247
|
+
orderBy, ...(usePaging ? { limit: currentPageSize!, offset: pageOffset } : { limit: ATTACHMENT_LIST_MAX_PAGE_SIZE }),
|
|
248
|
+
}, scope)
|
|
249
|
+
}
|
|
250
|
+
const totalPages = usePaging && total !== null ? Math.max(1, Math.ceil(total / currentPageSize!)) : null
|
|
243
251
|
return NextResponse.json({
|
|
244
252
|
items: items.map((a: any) => {
|
|
245
253
|
const metadata = readAttachmentMetadata(a.storageMetadata)
|
|
@@ -269,9 +277,11 @@ export async function GET(req: Request) {
|
|
|
269
277
|
totalPages,
|
|
270
278
|
}
|
|
271
279
|
: {}),
|
|
272
|
-
})
|
|
280
|
+
}, { headers: protectedScan ? { 'Cache-Control': 'private, no-store' } : undefined })
|
|
273
281
|
}
|
|
274
282
|
|
|
283
|
+
export const GET = withAttachmentAccessErrors(listRecordAttachments)
|
|
284
|
+
|
|
275
285
|
export async function POST(req: Request) {
|
|
276
286
|
const { t } = await resolveTranslations()
|
|
277
287
|
const auth = await getAuthFromRequest(req)
|
|
@@ -458,6 +468,7 @@ export async function POST(req: Request) {
|
|
|
458
468
|
if (!partition) {
|
|
459
469
|
return NextResponse.json({ error: 'Storage partition is not configured.' }, { status: 400 })
|
|
460
470
|
}
|
|
471
|
+
await ensureAttachmentPartitionProtection(em, partition)
|
|
461
472
|
const requestedPublicOverride =
|
|
462
473
|
typeof partitionOverride === 'string' &&
|
|
463
474
|
partitionOverride.length > 0 &&
|
|
@@ -703,7 +714,7 @@ async function readTenantAttachmentUsageBytes(em: EntityManager, tenantId: strin
|
|
|
703
714
|
}
|
|
704
715
|
}
|
|
705
716
|
|
|
706
|
-
|
|
717
|
+
async function deleteAttachment(req: Request) {
|
|
707
718
|
const auth = await getAuthFromRequest(req)
|
|
708
719
|
if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
709
720
|
const url = new URL(req.url)
|
|
@@ -724,20 +735,34 @@ export async function DELETE(req: Request) {
|
|
|
724
735
|
const orgId = deleteScope.organizationId
|
|
725
736
|
const deleteFilter: Record<string, unknown> = { id, tenantId: auth.tenantId! }
|
|
726
737
|
if (orgId) deleteFilter.organizationId = orgId
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
738
|
+
const scopedAuth = { ...auth, orgId }
|
|
739
|
+
const outcome = await em.transactional(async (tx) => {
|
|
740
|
+
const candidate = await findOneWithDecryption(tx, Attachment, deleteFilter, undefined, {
|
|
741
|
+
tenantId: auth.tenantId, organizationId: orgId,
|
|
742
|
+
})
|
|
743
|
+
if (!candidate) return throwAttachmentAccessError(404)
|
|
744
|
+
const referenceIds = await lockAttachmentStorageReferences(tx, candidate)
|
|
745
|
+
const removal = resolveAttachmentStorageRelease(referenceIds, candidate.id)
|
|
746
|
+
if (!removal.removed) return throwAttachmentAccessError(404)
|
|
747
|
+
const record = await findOneWithDecryption(tx, Attachment, deleteFilter, {
|
|
748
|
+
lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true,
|
|
749
|
+
}, { tenantId: auth.tenantId, organizationId: orgId })
|
|
750
|
+
if (!record) return throwAttachmentAccessError(404)
|
|
751
|
+
const guard = await prepareAttachmentMutation({ container, req, auth: scopedAuth, recordId: record.id, operation: 'delete' })
|
|
752
|
+
await assertAttachmentOwnerAccess({ em: tx, context: createAttachmentAccessContext(container), auth: scopedAuth, attachment: record, action: 'delete' })
|
|
753
|
+
const driver = record.storagePath ? await storageDriverFactory.resolveForPartition(record.partitionCode, {
|
|
754
|
+
tenantId: record.tenantId ?? auth.tenantId!, organizationId: record.organizationId ?? orgId ?? '',
|
|
755
|
+
}) : null
|
|
756
|
+
tx.remove(record)
|
|
757
|
+
await tx.flush()
|
|
758
|
+
return { record, driver, guard, releaseStorage: removal.releaseStorage }
|
|
759
|
+
})
|
|
760
|
+
const { record } = outcome
|
|
731
761
|
await clearAttachmentThumbnailCache(record.partitionCode, record.id).catch((error) => {
|
|
732
762
|
logger.error('Failed to cleanup cached thumbnails', { err: error })
|
|
733
763
|
})
|
|
734
|
-
if (
|
|
735
|
-
|
|
736
|
-
tenantId: record.tenantId ?? auth.tenantId!,
|
|
737
|
-
organizationId: record.organizationId ?? orgId ?? '',
|
|
738
|
-
})
|
|
739
|
-
await delDriver.delete(record.partitionCode, record.storagePath)
|
|
740
|
-
}
|
|
764
|
+
if (outcome.driver && outcome.releaseStorage) await outcome.driver.delete(record.partitionCode, record.storagePath)
|
|
765
|
+
await outcome.guard.runAfterSuccess()
|
|
741
766
|
if (dataEngine) {
|
|
742
767
|
await emitCrudSideEffects({
|
|
743
768
|
dataEngine,
|
|
@@ -756,6 +781,8 @@ export async function DELETE(req: Request) {
|
|
|
756
781
|
return NextResponse.json({ ok: true })
|
|
757
782
|
}
|
|
758
783
|
|
|
784
|
+
export const DELETE = withAttachmentAccessErrors(deleteAttachment)
|
|
785
|
+
|
|
759
786
|
export const openApi: OpenApiRouteDoc = {
|
|
760
787
|
summary: 'Manage entity attachments',
|
|
761
788
|
description: 'Upload and list attachments associated with module entities and records.',
|
|
@@ -770,6 +797,9 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
770
797
|
errors: [
|
|
771
798
|
{ status: 400, description: 'Missing entity or record identifiers', schema: errorSchema },
|
|
772
799
|
{ status: 401, description: 'Unauthorized', schema: errorSchema },
|
|
800
|
+
{ status: 403, description: 'Owner policy denies access', schema: errorSchema },
|
|
801
|
+
{ status: 404, description: 'Attachment or owner is unavailable', schema: errorSchema },
|
|
802
|
+
{ status: 504, description: 'Owner authorization timed out', schema: errorSchema },
|
|
773
803
|
],
|
|
774
804
|
},
|
|
775
805
|
POST: {
|
|
@@ -799,6 +829,9 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
799
829
|
errors: [
|
|
800
830
|
{ status: 400, description: 'Missing attachment identifier', schema: errorSchema },
|
|
801
831
|
{ status: 401, description: 'Unauthorized', schema: errorSchema },
|
|
832
|
+
{ status: 403, description: 'Owner policy denies access', schema: errorSchema },
|
|
833
|
+
{ status: 404, description: 'Attachment or owner is unavailable', schema: errorSchema },
|
|
834
|
+
{ status: 504, description: 'Owner authorization timed out', schema: errorSchema },
|
|
802
835
|
],
|
|
803
836
|
},
|
|
804
837
|
},
|