@open-mercato/core 0.10.1-develop.7741.1.61a2652671 → 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.
Files changed (154) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/attachment_partition/index.js +2 -0
  3. package/dist/generated/entities/attachment_partition/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/helpers/integration/attachmentAccessFixtures.js +144 -0
  7. package/dist/helpers/integration/attachmentAccessFixtures.js.map +7 -0
  8. package/dist/modules/attachments/api/file/[id]/route.js +16 -10
  9. package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
  10. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +15 -9
  11. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  12. package/dist/modules/attachments/api/library/[id]/route.js +100 -56
  13. package/dist/modules/attachments/api/library/[id]/route.js.map +3 -3
  14. package/dist/modules/attachments/api/library/route.js +56 -17
  15. package/dist/modules/attachments/api/library/route.js.map +2 -2
  16. package/dist/modules/attachments/api/partitions/route.js +2 -0
  17. package/dist/modules/attachments/api/partitions/route.js.map +2 -2
  18. package/dist/modules/attachments/api/route.js +74 -34
  19. package/dist/modules/attachments/api/route.js.map +3 -3
  20. package/dist/modules/attachments/api/transfer/route.js +63 -37
  21. package/dist/modules/attachments/api/transfer/route.js.map +2 -2
  22. package/dist/modules/attachments/data/entities.js +3 -0
  23. package/dist/modules/attachments/data/entities.js.map +2 -2
  24. package/dist/modules/attachments/data/validators.js +41 -0
  25. package/dist/modules/attachments/data/validators.js.map +7 -0
  26. package/dist/modules/attachments/di.js +5 -2
  27. package/dist/modules/attachments/di.js.map +2 -2
  28. package/dist/modules/attachments/generators.js +29 -0
  29. package/dist/modules/attachments/generators.js.map +7 -0
  30. package/dist/modules/attachments/index.js +8 -1
  31. package/dist/modules/attachments/index.js.map +2 -2
  32. package/dist/modules/attachments/lib/access-errors.js +35 -0
  33. package/dist/modules/attachments/lib/access-errors.js.map +7 -0
  34. package/dist/modules/attachments/lib/access-mutation.js +23 -0
  35. package/dist/modules/attachments/lib/access-mutation.js.map +7 -0
  36. package/dist/modules/attachments/lib/access-protection.js +73 -0
  37. package/dist/modules/attachments/lib/access-protection.js.map +7 -0
  38. package/dist/modules/attachments/lib/access-query.js +74 -0
  39. package/dist/modules/attachments/lib/access-query.js.map +7 -0
  40. package/dist/modules/attachments/lib/access-registry.js +87 -0
  41. package/dist/modules/attachments/lib/access-registry.js.map +7 -0
  42. package/dist/modules/attachments/lib/access-runner.js +161 -0
  43. package/dist/modules/attachments/lib/access-runner.js.map +7 -0
  44. package/dist/modules/attachments/lib/access-types.js +1 -0
  45. package/dist/modules/attachments/lib/access-types.js.map +7 -0
  46. package/dist/modules/attachments/lib/attachment-service.js +14 -6
  47. package/dist/modules/attachments/lib/attachment-service.js.map +2 -2
  48. package/dist/modules/attachments/lib/createFromBuffer.js +2 -0
  49. package/dist/modules/attachments/lib/createFromBuffer.js.map +2 -2
  50. package/dist/modules/attachments/lib/partitions.js +3 -2
  51. package/dist/modules/attachments/lib/partitions.js.map +2 -2
  52. package/dist/modules/attachments/lib/scoped-upload-service.js +2 -0
  53. package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
  54. package/dist/modules/attachments/lib/target-access-service.js +12 -3
  55. package/dist/modules/attachments/lib/target-access-service.js.map +2 -2
  56. package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js +38 -0
  57. package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js.map +7 -0
  58. package/dist/modules/attachments/setup.js +10 -0
  59. package/dist/modules/attachments/setup.js.map +2 -2
  60. package/dist/modules/notifications/api/[id]/action/route.js +8 -0
  61. package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
  62. package/dist/modules/notifications/api/route.js +17 -0
  63. package/dist/modules/notifications/api/route.js.map +2 -2
  64. package/dist/modules/notifications/data/validators.js +1 -1
  65. package/dist/modules/notifications/data/validators.js.map +2 -2
  66. package/dist/modules/notifications/lib/notificationService.js +45 -3
  67. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  68. package/dist/modules/notifications/lib/routeHelpers.js +27 -0
  69. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  70. package/dist/modules/payment_gateways/lib/gateway-service.js +50 -34
  71. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  72. package/dist/modules/payment_gateways/lib/status-machine.js +10 -1
  73. package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
  74. package/dist/modules/payment_gateways/lib/webhook-processor.js +1 -1
  75. package/dist/modules/payment_gateways/lib/webhook-processor.js.map +2 -2
  76. package/dist/modules/staff/notifications.js +4 -2
  77. package/dist/modules/staff/notifications.js.map +2 -2
  78. package/dist/modules/workflows/data/entities.js.map +2 -2
  79. package/dist/modules/workflows/lib/activity-executor.js +18 -4
  80. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  81. package/dist/modules/workflows/lib/activity-types.js +2 -2
  82. package/dist/modules/workflows/lib/activity-types.js.map +2 -2
  83. package/dist/modules/workflows/lib/condition-handler.js +7 -1
  84. package/dist/modules/workflows/lib/condition-handler.js.map +2 -2
  85. package/dist/modules/workflows/lib/duration.js +13 -0
  86. package/dist/modules/workflows/lib/duration.js.map +2 -2
  87. package/dist/modules/workflows/lib/step-handler.js +9 -2
  88. package/dist/modules/workflows/lib/step-handler.js.map +2 -2
  89. package/dist/modules/workflows/lib/task-quick-action.js +13 -10
  90. package/dist/modules/workflows/lib/task-quick-action.js.map +2 -2
  91. package/dist/modules/workflows/lib/transition-handler.js +2 -1
  92. package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
  93. package/dist/modules/workflows/notifications.js +4 -0
  94. package/dist/modules/workflows/notifications.js.map +2 -2
  95. package/generated/entities/attachment_partition/index.ts +1 -0
  96. package/generated/entity-fields-registry.ts +1 -0
  97. package/package.json +7 -7
  98. package/src/helpers/integration/attachmentAccessFixtures.ts +141 -0
  99. package/src/modules/attachments/api/file/[id]/route.ts +12 -9
  100. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +10 -8
  101. package/src/modules/attachments/api/library/[id]/route.ts +87 -58
  102. package/src/modules/attachments/api/library/route.ts +40 -11
  103. package/src/modules/attachments/api/partitions/route.ts +2 -0
  104. package/src/modules/attachments/api/route.ts +68 -35
  105. package/src/modules/attachments/api/transfer/route.ts +65 -42
  106. package/src/modules/attachments/data/entities.ts +4 -0
  107. package/src/modules/attachments/data/validators.ts +36 -0
  108. package/src/modules/attachments/di.ts +4 -1
  109. package/src/modules/attachments/generators.ts +27 -0
  110. package/src/modules/attachments/i18n/de.json +4 -0
  111. package/src/modules/attachments/i18n/en.json +4 -0
  112. package/src/modules/attachments/i18n/es.json +4 -0
  113. package/src/modules/attachments/i18n/ko.json +4 -0
  114. package/src/modules/attachments/i18n/pl.json +4 -0
  115. package/src/modules/attachments/index.ts +18 -0
  116. package/src/modules/attachments/lib/access-errors.ts +32 -0
  117. package/src/modules/attachments/lib/access-mutation.ts +26 -0
  118. package/src/modules/attachments/lib/access-protection.ts +83 -0
  119. package/src/modules/attachments/lib/access-query.ts +86 -0
  120. package/src/modules/attachments/lib/access-registry.ts +117 -0
  121. package/src/modules/attachments/lib/access-runner.ts +183 -0
  122. package/src/modules/attachments/lib/access-types.ts +78 -0
  123. package/src/modules/attachments/lib/attachment-service.ts +8 -4
  124. package/src/modules/attachments/lib/createFromBuffer.ts +3 -0
  125. package/src/modules/attachments/lib/partitions.ts +3 -2
  126. package/src/modules/attachments/lib/scoped-upload-service.ts +2 -0
  127. package/src/modules/attachments/lib/target-access-service.ts +10 -3
  128. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +17 -0
  129. package/src/modules/attachments/migrations/Migration20261001120000_attachments.ts +35 -0
  130. package/src/modules/attachments/setup.ts +10 -0
  131. package/src/modules/notifications/api/[id]/action/route.ts +8 -0
  132. package/src/modules/notifications/api/route.ts +17 -0
  133. package/src/modules/notifications/data/validators.ts +1 -1
  134. package/src/modules/notifications/i18n/de.json +2 -0
  135. package/src/modules/notifications/i18n/en.json +2 -0
  136. package/src/modules/notifications/i18n/es.json +2 -0
  137. package/src/modules/notifications/i18n/ko.json +2 -0
  138. package/src/modules/notifications/i18n/pl.json +2 -0
  139. package/src/modules/notifications/lib/notificationService.ts +67 -1
  140. package/src/modules/notifications/lib/routeHelpers.ts +38 -0
  141. package/src/modules/payment_gateways/lib/gateway-service.ts +50 -33
  142. package/src/modules/payment_gateways/lib/status-machine.ts +19 -0
  143. package/src/modules/payment_gateways/lib/webhook-processor.ts +1 -1
  144. package/src/modules/staff/notifications.ts +2 -0
  145. package/src/modules/workflows/AGENTS.md +2 -2
  146. package/src/modules/workflows/data/entities.ts +6 -0
  147. package/src/modules/workflows/lib/activity-executor.ts +36 -4
  148. package/src/modules/workflows/lib/activity-types.ts +2 -2
  149. package/src/modules/workflows/lib/condition-handler.ts +16 -1
  150. package/src/modules/workflows/lib/duration.ts +25 -0
  151. package/src/modules/workflows/lib/step-handler.ts +16 -1
  152. package/src/modules/workflows/lib/task-quick-action.ts +19 -10
  153. package/src/modules/workflows/lib/transition-handler.ts +2 -1
  154. 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.js";
5
+ import { throwAttachmentAccessError } from "../../../lib/access-errors.js";
6
+ import { createAttachmentAccessContext } from "../../../lib/access-runner.js";
7
+ import { assertAttachmentOwnerAccess } from "../../../lib/access-query.js";
8
+ import { withAttachmentAccessErrors } from "../../../lib/access-errors.js";
1
9
  import { NextResponse } from "next/server";
2
10
  import { z } from "zod";
3
11
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
@@ -9,7 +17,7 @@ import {
9
17
  normalizeAttachmentTags,
10
18
  readAttachmentMetadata
11
19
  } from "../../../lib/metadata.js";
12
- import { removeAttachmentRecord } from "../../../lib/storageReferences.js";
20
+ import { lockAttachmentStorageReferences, resolveAttachmentStorageRelease } from "../../../lib/storageReferences.js";
13
21
  import { splitCustomFieldPayload, loadCustomFieldValues } from "@open-mercato/shared/lib/crud/custom-fields";
14
22
  import { emitCrudSideEffects, setCustomFieldsIfAny } from "@open-mercato/shared/lib/commands/helpers";
15
23
  import { normalizeCustomFieldResponse } from "@open-mercato/shared/lib/custom-fields/normalize";
@@ -21,8 +29,6 @@ import {
21
29
  attachmentDetailResponseSchema,
22
30
  attachmentErrorSchema
23
31
  } from "../../openapi.js";
24
- import { createLogger } from "@open-mercato/shared/lib/logger";
25
- const logger = createLogger("attachments").child({ component: "library" });
26
32
  const updateSchema = z.object({
27
33
  tags: z.array(z.string()).optional(),
28
34
  assignments: z.array(
@@ -51,7 +57,7 @@ async function resolveAttachmentId(ctx) {
51
57
  return null;
52
58
  }
53
59
  }
54
- async function GET(req, ctx) {
60
+ async function getAttachment(req, ctx) {
55
61
  const auth = await getAuthFromRequest(req);
56
62
  if (!auth || !auth.tenantId || !auth.orgId && !auth.isSuperAdmin) {
57
63
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -60,7 +66,8 @@ async function GET(req, ctx) {
60
66
  if (!attachmentId) {
61
67
  return NextResponse.json({ error: "Attachment id is required" }, { status: 400 });
62
68
  }
63
- const { resolve } = await createRequestContainer();
69
+ const container = await createRequestContainer();
70
+ const { resolve } = container;
64
71
  const em = resolve("em");
65
72
  let queryEngine = null;
66
73
  try {
@@ -79,6 +86,7 @@ async function GET(req, ctx) {
79
86
  if (!record) {
80
87
  return NextResponse.json({ error: "Attachment not found" }, { status: 404 });
81
88
  }
89
+ await assertAttachmentOwnerAccess({ em, context: createAttachmentAccessContext(container), auth, attachment: record, action: "metadata" });
82
90
  const metadata2 = readAttachmentMetadata(record.storageMetadata);
83
91
  const partition = record.partitionCode ? await em.findOne(AttachmentPartition, { code: record.partitionCode }) : null;
84
92
  const customFieldValues = await loadCustomFieldValues({
@@ -110,9 +118,10 @@ async function GET(req, ctx) {
110
118
  content: record.content && record.content.trim() ? record.content : null,
111
119
  customFields
112
120
  }
113
- });
121
+ }, { headers: { "Cache-Control": "private, no-store" } });
114
122
  }
115
- async function PATCH(req, ctx) {
123
+ const GET = withAttachmentAccessErrors(getAttachment);
124
+ async function updateAttachment(req, ctx) {
116
125
  const auth = await getAuthFromRequest(req);
117
126
  if (!auth || !auth.tenantId || !auth.orgId) {
118
127
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -121,13 +130,14 @@ async function PATCH(req, ctx) {
121
130
  if (!attachmentId) {
122
131
  return NextResponse.json({ error: "Attachment id is required" }, { status: 400 });
123
132
  }
124
- const rawBody = await req.json().catch(() => null);
133
+ const rawBody = await readJsonSafe(req, null);
125
134
  const { base, custom } = splitCustomFieldPayload(rawBody);
126
135
  const parsed = updateSchema.safeParse(base);
127
136
  if (!parsed.success) {
128
137
  return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
129
138
  }
130
- const { resolve } = await createRequestContainer();
139
+ const container = await createRequestContainer();
140
+ const { resolve } = container;
131
141
  const em = resolve("em");
132
142
  let queryEngine = null;
133
143
  try {
@@ -141,32 +151,47 @@ async function PATCH(req, ctx) {
141
151
  tenantId: auth.tenantId,
142
152
  organizationId: auth.orgId
143
153
  };
144
- const record = await em.findOne(Attachment, patchFilter);
145
- if (!record) {
146
- return NextResponse.json({ error: "Attachment not found" }, { status: 404 });
147
- }
148
- const patch = {};
149
- if (parsed.data.tags) patch.tags = normalizeAttachmentTags(parsed.data.tags);
150
- if (parsed.data.assignments) patch.assignments = normalizeAttachmentAssignments(parsed.data.assignments);
151
- record.storageMetadata = mergeAttachmentMetadata(record.storageMetadata, patch);
152
- try {
153
- await em.transactional(async (tx) => {
154
- await tx.flush();
155
- if (dataEngine && custom && Object.keys(custom).length) {
156
- await setCustomFieldsIfAny({
157
- dataEngine,
158
- entityId: E.attachments.attachment,
159
- recordId: record.id,
160
- tenantId: record.tenantId ?? auth.tenantId ?? null,
161
- organizationId: record.organizationId ?? auth.orgId ?? null,
162
- values: custom
163
- });
164
- }
154
+ const accessContext = createAttachmentAccessContext(container);
155
+ const outcome = await em.transactional(async (tx) => {
156
+ const record2 = await findOneWithDecryption(tx, Attachment, patchFilter, {
157
+ lockMode: LockMode.PESSIMISTIC_WRITE,
158
+ refresh: true
159
+ }, { tenantId: auth.tenantId, organizationId: auth.orgId });
160
+ if (!record2) return throwAttachmentAccessError(404);
161
+ const guard = await prepareAttachmentMutation({
162
+ container,
163
+ req,
164
+ auth,
165
+ recordId: record2.id,
166
+ operation: "update",
167
+ payload: { ...parsed.data, customFields: custom }
165
168
  });
166
- } catch (error) {
167
- logger.error("Failed to persist custom attributes", { err: error });
168
- return NextResponse.json({ error: "Failed to save attachment attributes." }, { status: 500 });
169
- }
169
+ const effective = splitCustomFieldPayload(guard.modifiedPayload ?? { ...parsed.data, customFields: custom });
170
+ const effectiveParsed = updateSchema.safeParse(effective.base);
171
+ if (!effectiveParsed.success) return throwAttachmentAccessError(403);
172
+ const patch = {};
173
+ if (effectiveParsed.data.tags) patch.tags = normalizeAttachmentTags(effectiveParsed.data.tags);
174
+ if (effectiveParsed.data.assignments) patch.assignments = normalizeAttachmentAssignments(effectiveParsed.data.assignments);
175
+ const nextMetadata = mergeAttachmentMetadata(record2.storageMetadata, patch);
176
+ await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: record2, action: "reassign", persistProtection: true });
177
+ await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: { ...record2, storageMetadata: nextMetadata }, action: "reassign" });
178
+ await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: { ...record2, storageMetadata: nextMetadata }, action: "metadata" });
179
+ record2.storageMetadata = nextMetadata;
180
+ await tx.flush();
181
+ if (dataEngine && Object.keys(effective.custom).length) {
182
+ await setCustomFieldsIfAny({
183
+ dataEngine,
184
+ entityId: E.attachments.attachment,
185
+ recordId: record2.id,
186
+ tenantId: record2.tenantId ?? auth.tenantId,
187
+ organizationId: record2.organizationId ?? auth.orgId,
188
+ values: effective.custom
189
+ });
190
+ }
191
+ return { record: record2, custom: effective.custom, guard };
192
+ });
193
+ const { record } = outcome;
194
+ await outcome.guard.runAfterSuccess();
170
195
  if (dataEngine) {
171
196
  await emitCrudSideEffects({
172
197
  dataEngine,
@@ -196,11 +221,11 @@ async function PATCH(req, ctx) {
196
221
  id: record.id,
197
222
  tags: metadata2.tags ?? [],
198
223
  assignments: enrichedAssignments,
199
- customFields: normalizeCustomFieldResponse(custom ?? null)
224
+ customFields: normalizeCustomFieldResponse(outcome.custom ?? null)
200
225
  }
201
226
  });
202
227
  }
203
- async function DELETE(req, ctx) {
228
+ async function deleteAttachment(req, ctx) {
204
229
  const auth = await getAuthFromRequest(req);
205
230
  if (!auth || !auth.tenantId || !auth.orgId) {
206
231
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -209,7 +234,8 @@ async function DELETE(req, ctx) {
209
234
  if (!attachmentId) {
210
235
  return NextResponse.json({ error: "Attachment id is required" }, { status: 400 });
211
236
  }
212
- const { resolve } = await createRequestContainer();
237
+ const container = await createRequestContainer();
238
+ const { resolve } = container;
213
239
  const em = resolve("em");
214
240
  const dataEngine = resolve("dataEngine");
215
241
  const storageDriverFactory = resolve("storageDriverFactory");
@@ -218,23 +244,33 @@ async function DELETE(req, ctx) {
218
244
  tenantId: auth.tenantId,
219
245
  organizationId: auth.orgId
220
246
  };
221
- const record = await em.findOne(Attachment, deleteFilter);
222
- if (!record) {
223
- return NextResponse.json({ error: "Attachment not found" }, { status: 404 });
224
- }
225
- const deleteDriver = await storageDriverFactory.resolveForPartition(record.partitionCode, {
226
- tenantId: record.tenantId ?? auth.tenantId,
227
- organizationId: record.organizationId ?? auth.orgId
247
+ const outcome = await em.transactional(async (tx) => {
248
+ const candidate = await findOneWithDecryption(tx, Attachment, deleteFilter, void 0, {
249
+ tenantId: auth.tenantId,
250
+ organizationId: auth.orgId
251
+ });
252
+ if (!candidate) return throwAttachmentAccessError(404);
253
+ const referenceIds = await lockAttachmentStorageReferences(tx, candidate);
254
+ const removal = resolveAttachmentStorageRelease(referenceIds, candidate.id);
255
+ if (!removal.removed) return throwAttachmentAccessError(404);
256
+ const record2 = await findOneWithDecryption(tx, Attachment, deleteFilter, {
257
+ lockMode: LockMode.PESSIMISTIC_WRITE,
258
+ refresh: true
259
+ }, { tenantId: auth.tenantId, organizationId: auth.orgId });
260
+ if (!record2) return throwAttachmentAccessError(404);
261
+ const guard = await prepareAttachmentMutation({ container, req, auth, recordId: record2.id, operation: "delete" });
262
+ await assertAttachmentOwnerAccess({ em: tx, context: createAttachmentAccessContext(container), auth, attachment: record2, action: "delete" });
263
+ const driver = await storageDriverFactory.resolveForPartition(record2.partitionCode, {
264
+ tenantId: record2.tenantId ?? auth.tenantId,
265
+ organizationId: record2.organizationId ?? auth.orgId
266
+ });
267
+ tx.remove(record2);
268
+ await tx.flush();
269
+ return { record: record2, driver, guard, releaseStorage: removal.releaseStorage };
228
270
  });
229
- const recordPartitionCode = record.partitionCode;
230
- const recordStoragePath = record.storagePath;
231
- const removal = await removeAttachmentRecord(em, record);
232
- if (!removal.removed) {
233
- return NextResponse.json({ error: "Attachment not found" }, { status: 404 });
234
- }
235
- if (removal.releaseStorage) {
236
- await deleteDriver.delete(recordPartitionCode, recordStoragePath);
237
- }
271
+ const { record } = outcome;
272
+ if (outcome.releaseStorage) await outcome.driver.delete(record.partitionCode, record.storagePath);
273
+ await outcome.guard.runAfterSuccess();
238
274
  if (dataEngine) {
239
275
  await emitCrudSideEffects({
240
276
  dataEngine,
@@ -252,6 +288,8 @@ async function DELETE(req, ctx) {
252
288
  }
253
289
  return NextResponse.json({ ok: true });
254
290
  }
291
+ const PATCH = withAttachmentAccessErrors(updateAttachment);
292
+ const DELETE = withAttachmentAccessErrors(deleteAttachment);
255
293
  const openApi = {
256
294
  tag: attachmentsTag,
257
295
  summary: "Attachment detail management",
@@ -265,7 +303,9 @@ const openApi = {
265
303
  errors: [
266
304
  { status: 400, description: "Invalid attachment ID", schema: attachmentErrorSchema },
267
305
  { status: 401, description: "Unauthorized", schema: attachmentErrorSchema },
268
- { status: 404, description: "Attachment not found", schema: attachmentErrorSchema }
306
+ { status: 404, description: "Attachment not found", schema: attachmentErrorSchema },
307
+ { status: 403, description: "Owner policy denies access", schema: attachmentErrorSchema },
308
+ { status: 504, description: "Owner authorization timed out", schema: attachmentErrorSchema }
269
309
  ]
270
310
  },
271
311
  PATCH: {
@@ -282,7 +322,9 @@ const openApi = {
282
322
  { status: 400, description: "Invalid payload or attachment ID", schema: attachmentErrorSchema },
283
323
  { status: 401, description: "Unauthorized", schema: attachmentErrorSchema },
284
324
  { status: 404, description: "Attachment not found", schema: attachmentErrorSchema },
285
- { status: 500, description: "Failed to save attributes", schema: attachmentErrorSchema }
325
+ { status: 500, description: "Failed to save attributes", schema: attachmentErrorSchema },
326
+ { status: 403, description: "Owner policy denies access", schema: attachmentErrorSchema },
327
+ { status: 504, description: "Owner authorization timed out", schema: attachmentErrorSchema }
286
328
  ]
287
329
  },
288
330
  DELETE: {
@@ -294,7 +336,9 @@ const openApi = {
294
336
  errors: [
295
337
  { status: 400, description: "Invalid attachment ID", schema: attachmentErrorSchema },
296
338
  { status: 401, description: "Unauthorized", schema: attachmentErrorSchema },
297
- { status: 404, description: "Attachment not found", schema: attachmentErrorSchema }
339
+ { status: 404, description: "Attachment not found", schema: attachmentErrorSchema },
340
+ { status: 403, description: "Owner policy denies access", schema: attachmentErrorSchema },
341
+ { status: 504, description: "Owner authorization timed out", schema: attachmentErrorSchema }
298
342
  ]
299
343
  }
300
344
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/attachments/api/library/%5Bid%5D/route.ts"],
4
- "sourcesContent": ["import { NextRequest, 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 { Attachment, AttachmentPartition } from '../../../data/entities'\nimport {\n mergeAttachmentMetadata,\n normalizeAttachmentAssignments,\n normalizeAttachmentTags,\n readAttachmentMetadata,\n} from '../../../lib/metadata'\nimport type { StorageDriverFactory } from '../../../lib/drivers'\nimport { removeAttachmentRecord } from '../../../lib/storageReferences'\nimport { splitCustomFieldPayload, loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'\nimport { emitCrudSideEffects, setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'\nimport { normalizeCustomFieldResponse } from '@open-mercato/shared/lib/custom-fields/normalize'\nimport { E } from '#generated/entities.ids.generated'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { attachmentCrudEvents, attachmentCrudIndexer } from '../../../lib/crud'\nimport { applyAssignmentEnrichments, resolveAssignmentEnrichments } from '../../../lib/assignmentDetails'\nimport {\n attachmentsTag,\n attachmentDetailResponseSchema,\n attachmentErrorSchema,\n} from '../../openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('attachments').child({ component: 'library' })\n\nconst updateSchema = z.object({\n tags: z.array(z.string()).optional(),\n assignments: z\n .array(\n z.object({\n type: z.string().min(1),\n id: z.string().min(1),\n href: z.string().nullable().optional(),\n label: z.string().nullable().optional(),\n }),\n )\n .optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['attachments.view'] },\n PATCH: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n}\n\ntype RouteParams = { id: string }\ntype RouteContext = { params: Promise<RouteParams> }\n\nasync function resolveAttachmentId(ctx: RouteContext): Promise<string | null> {\n const params = ctx?.params\n try {\n const { id } = await params\n if (typeof id === 'string' && id.trim().length) {\n return id\n }\n return null\n } catch {\n return null\n }\n}\n\nexport async function GET(req: NextRequest, ctx: RouteContext) {\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 attachmentId = await resolveAttachmentId(ctx)\n if (!attachmentId) {\n return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })\n }\n const { resolve } = await createRequestContainer()\n const em = resolve('em') as EntityManager\n let queryEngine: QueryEngine | null = null\n try {\n queryEngine = resolve('queryEngine') as QueryEngine\n } catch {\n queryEngine = null\n }\n const findFilter: Record<string, unknown> = {\n id: attachmentId,\n tenantId: auth.tenantId,\n }\n if (auth.orgId) {\n findFilter.organizationId = auth.orgId\n }\n const record = await em.findOne(Attachment, findFilter)\n if (!record) {\n return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })\n }\n const metadata = readAttachmentMetadata(record.storageMetadata)\n const partition = record.partitionCode\n ? await em.findOne(AttachmentPartition, { code: record.partitionCode })\n : null\n const customFieldValues = await loadCustomFieldValues({\n em,\n entityId: E.attachments.attachment,\n recordIds: [record.id],\n tenantIdByRecord: { [record.id]: record.tenantId ?? auth.tenantId ?? null },\n organizationIdByRecord: { [record.id]: record.organizationId ?? auth.orgId ?? null },\n tenantFallbacks: [auth.tenantId ?? null].filter((value): value is string => !!value),\n })\n const customFields = normalizeCustomFieldResponse(customFieldValues[record.id])\n const assignments = metadata.assignments ?? []\n const enrichments = await resolveAssignmentEnrichments(assignments, {\n queryEngine,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n const enrichedAssignments = applyAssignmentEnrichments(assignments, enrichments)\n return NextResponse.json({\n item: {\n id: record.id,\n fileName: record.fileName,\n fileSize: record.fileSize,\n mimeType: record.mimeType,\n partitionCode: record.partitionCode,\n partitionTitle: partition?.title ?? null,\n tags: metadata.tags ?? [],\n assignments: enrichedAssignments,\n content: record.content && record.content.trim() ? record.content : null,\n customFields,\n },\n })\n}\n\nexport async function PATCH(req: NextRequest, ctx: RouteContext) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const attachmentId = await resolveAttachmentId(ctx)\n if (!attachmentId) {\n return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })\n }\n const rawBody = await req.json().catch(() => null)\n const { base, custom } = splitCustomFieldPayload(rawBody)\n const parsed = updateSchema.safeParse(base)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })\n }\n const { resolve } = await createRequestContainer()\n const em = resolve('em') as EntityManager\n let queryEngine: QueryEngine | null = null\n try {\n queryEngine = resolve('queryEngine') as QueryEngine\n } catch {\n queryEngine = null\n }\n const dataEngine = resolve('dataEngine') as DataEngine\n const patchFilter: Record<string, unknown> = {\n id: attachmentId,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }\n const record = await em.findOne(Attachment, patchFilter)\n if (!record) {\n return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })\n }\n const patch: Record<string, unknown> = {}\n if (parsed.data.tags) patch.tags = normalizeAttachmentTags(parsed.data.tags)\n if (parsed.data.assignments) patch.assignments = normalizeAttachmentAssignments(parsed.data.assignments)\n record.storageMetadata = mergeAttachmentMetadata(record.storageMetadata, patch)\n // Commit the metadata mutation and the custom-field write atomically so a\n // custom-field failure cannot leave the attachment metadata partially updated.\n try {\n await em.transactional(async (tx) => {\n await tx.flush()\n if (dataEngine && custom && Object.keys(custom).length) {\n await setCustomFieldsIfAny({\n dataEngine,\n entityId: E.attachments.attachment,\n recordId: record.id,\n tenantId: record.tenantId ?? auth.tenantId ?? null,\n organizationId: record.organizationId ?? auth.orgId ?? null,\n values: custom,\n })\n }\n })\n } catch (error) {\n logger.error('Failed to persist custom attributes', { err: error })\n return NextResponse.json({ error: 'Failed to save attachment attributes.' }, { status: 500 })\n }\n\n if (dataEngine) {\n await emitCrudSideEffects({\n dataEngine,\n action: 'updated',\n entity: record,\n identifiers: {\n id: record.id,\n organizationId: record.organizationId ?? auth.orgId ?? null,\n tenantId: record.tenantId ?? auth.tenantId ?? null,\n },\n events: attachmentCrudEvents,\n indexer: attachmentCrudIndexer,\n })\n await dataEngine.flushOrmEntityChanges()\n }\n\n const metadata = readAttachmentMetadata(record.storageMetadata)\n const assignments = metadata.assignments ?? []\n const enrichments = await resolveAssignmentEnrichments(assignments, {\n queryEngine,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n const enrichedAssignments = applyAssignmentEnrichments(assignments, enrichments)\n return NextResponse.json({\n ok: true,\n item: {\n id: record.id,\n tags: metadata.tags ?? [],\n assignments: enrichedAssignments,\n customFields: normalizeCustomFieldResponse(custom ?? null),\n },\n })\n}\n\nexport async function DELETE(req: NextRequest, ctx: RouteContext) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const attachmentId = await resolveAttachmentId(ctx)\n if (!attachmentId) {\n return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })\n }\n const { resolve } = await createRequestContainer()\n const em = resolve('em') as EntityManager\n const dataEngine = resolve('dataEngine') as DataEngine\n const storageDriverFactory = resolve('storageDriverFactory') as StorageDriverFactory\n const deleteFilter: Record<string, unknown> = {\n id: attachmentId,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }\n const record = await em.findOne(Attachment, deleteFilter)\n if (!record) {\n return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })\n }\n const deleteDriver = await storageDriverFactory.resolveForPartition(record.partitionCode, {\n tenantId: record.tenantId ?? auth.tenantId,\n organizationId: record.organizationId ?? auth.orgId,\n })\n // Commit the DB row removal before deleting the irreversible storage file so a\n // failed commit cannot leave a dangling record whose backing file is already gone.\n const recordPartitionCode = record.partitionCode\n const recordStoragePath = record.storagePath\n const removal = await removeAttachmentRecord(em, record)\n if (!removal.removed) {\n return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })\n }\n if (removal.releaseStorage) {\n await deleteDriver.delete(recordPartitionCode, recordStoragePath)\n }\n\n if (dataEngine) {\n await emitCrudSideEffects({\n dataEngine,\n action: 'deleted',\n entity: record,\n identifiers: {\n id: record.id,\n organizationId: record.organizationId ?? auth.orgId ?? null,\n tenantId: record.tenantId ?? auth.tenantId ?? null,\n },\n events: attachmentCrudEvents,\n indexer: attachmentCrudIndexer,\n })\n await dataEngine.flushOrmEntityChanges()\n }\n\n return NextResponse.json({ ok: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: attachmentsTag,\n summary: 'Attachment detail management',\n methods: {\n GET: {\n summary: 'Get attachment details',\n description: 'Returns complete details of an attachment including metadata, tags, assignments, and custom fields.',\n responses: [\n { status: 200, description: 'Attachment details', schema: attachmentDetailResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid attachment ID', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },\n ],\n },\n PATCH: {\n summary: 'Update attachment metadata',\n description: 'Updates attachment tags, assignments, and custom fields. Emits CRUD side effects for indexing and events.',\n requestBody: {\n contentType: 'application/json',\n schema: updateSchema,\n },\n responses: [\n { status: 200, description: 'Attachment updated successfully', schema: z.object({ ok: z.literal(true), item: z.any() }) },\n ],\n errors: [\n { status: 400, description: 'Invalid payload or attachment ID', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },\n { status: 500, description: 'Failed to save attributes', schema: attachmentErrorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete attachment',\n description: 'Permanently deletes an attachment file from storage and database. Emits CRUD side effects.',\n responses: [\n { status: 200, description: 'Attachment deleted successfully', schema: z.object({ ok: z.literal(true) }) },\n ],\n errors: [\n { status: 400, description: 'Invalid attachment ID', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
6
- "names": ["metadata"]
4
+ "sourcesContent": ["import { LockMode } from '@mikro-orm/core'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { prepareAttachmentMutation } from '../../../lib/access-mutation'\nimport { throwAttachmentAccessError } from '../../../lib/access-errors'\nimport { createAttachmentAccessContext } from '../../../lib/access-runner'\nimport { assertAttachmentOwnerAccess } from '../../../lib/access-query'\nimport { withAttachmentAccessErrors } from '../../../lib/access-errors'\nimport { NextRequest, 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 { Attachment, AttachmentPartition } from '../../../data/entities'\nimport {\n mergeAttachmentMetadata,\n normalizeAttachmentAssignments,\n normalizeAttachmentTags,\n readAttachmentMetadata,\n} from '../../../lib/metadata'\nimport type { StorageDriverFactory } from '../../../lib/drivers'\nimport { lockAttachmentStorageReferences, resolveAttachmentStorageRelease } from '../../../lib/storageReferences'\nimport { splitCustomFieldPayload, loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'\nimport { emitCrudSideEffects, setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'\nimport { normalizeCustomFieldResponse } from '@open-mercato/shared/lib/custom-fields/normalize'\nimport { E } from '#generated/entities.ids.generated'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { attachmentCrudEvents, attachmentCrudIndexer } from '../../../lib/crud'\nimport { applyAssignmentEnrichments, resolveAssignmentEnrichments } from '../../../lib/assignmentDetails'\nimport {\n attachmentsTag,\n attachmentDetailResponseSchema,\n attachmentErrorSchema,\n} from '../../openapi'\n\nconst updateSchema = z.object({\n tags: z.array(z.string()).optional(),\n assignments: z\n .array(\n z.object({\n type: z.string().min(1),\n id: z.string().min(1),\n href: z.string().nullable().optional(),\n label: z.string().nullable().optional(),\n }),\n )\n .optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['attachments.view'] },\n PATCH: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['attachments.manage'] },\n}\n\ntype RouteParams = { id: string }\ntype RouteContext = { params: Promise<RouteParams> }\n\nasync function resolveAttachmentId(ctx: RouteContext): Promise<string | null> {\n const params = ctx?.params\n try {\n const { id } = await params\n if (typeof id === 'string' && id.trim().length) {\n return id\n }\n return null\n } catch {\n return null\n }\n}\n\nasync function getAttachment(req: NextRequest, ctx: RouteContext) {\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 attachmentId = await resolveAttachmentId(ctx)\n if (!attachmentId) {\n return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })\n }\n const container = await createRequestContainer()\n const { resolve } = container\n const em = resolve('em') as EntityManager\n let queryEngine: QueryEngine | null = null\n try {\n queryEngine = resolve('queryEngine') as QueryEngine\n } catch {\n queryEngine = null\n }\n const findFilter: Record<string, unknown> = {\n id: attachmentId,\n tenantId: auth.tenantId,\n }\n if (auth.orgId) {\n findFilter.organizationId = auth.orgId\n }\n const record = await em.findOne(Attachment, findFilter)\n if (!record) {\n return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })\n }\n await assertAttachmentOwnerAccess({ em, context: createAttachmentAccessContext(container), auth, attachment: record, action: 'metadata' })\n const metadata = readAttachmentMetadata(record.storageMetadata)\n const partition = record.partitionCode\n ? await em.findOne(AttachmentPartition, { code: record.partitionCode })\n : null\n const customFieldValues = await loadCustomFieldValues({\n em,\n entityId: E.attachments.attachment,\n recordIds: [record.id],\n tenantIdByRecord: { [record.id]: record.tenantId ?? auth.tenantId ?? null },\n organizationIdByRecord: { [record.id]: record.organizationId ?? auth.orgId ?? null },\n tenantFallbacks: [auth.tenantId ?? null].filter((value): value is string => !!value),\n })\n const customFields = normalizeCustomFieldResponse(customFieldValues[record.id])\n const assignments = metadata.assignments ?? []\n const enrichments = await resolveAssignmentEnrichments(assignments, {\n queryEngine,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n const enrichedAssignments = applyAssignmentEnrichments(assignments, enrichments)\n return NextResponse.json({\n item: {\n id: record.id,\n fileName: record.fileName,\n fileSize: record.fileSize,\n mimeType: record.mimeType,\n partitionCode: record.partitionCode,\n partitionTitle: partition?.title ?? null,\n tags: metadata.tags ?? [],\n assignments: enrichedAssignments,\n content: record.content && record.content.trim() ? record.content : null,\n customFields,\n },\n }, { headers: { 'Cache-Control': 'private, no-store' } })\n}\n\nexport const GET = withAttachmentAccessErrors(getAttachment)\n\nasync function updateAttachment(req: NextRequest, ctx: RouteContext) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const attachmentId = await resolveAttachmentId(ctx)\n if (!attachmentId) {\n return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })\n }\n const rawBody = await readJsonSafe(req, null)\n const { base, custom } = splitCustomFieldPayload(rawBody)\n const parsed = updateSchema.safeParse(base)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })\n }\n const container = await createRequestContainer()\n const { resolve } = container\n const em = resolve('em') as EntityManager\n let queryEngine: QueryEngine | null = null\n try {\n queryEngine = resolve('queryEngine') as QueryEngine\n } catch {\n queryEngine = null\n }\n const dataEngine = resolve('dataEngine') as DataEngine\n const patchFilter: Record<string, unknown> = {\n id: attachmentId,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }\n const accessContext = createAttachmentAccessContext(container)\n const outcome = await em.transactional(async (tx) => {\n const record = await findOneWithDecryption(tx, Attachment, patchFilter, {\n lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true,\n }, { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!record) return throwAttachmentAccessError(404)\n const guard = await prepareAttachmentMutation({\n container, req, auth, recordId: record.id, operation: 'update',\n payload: { ...parsed.data, customFields: custom },\n })\n const effective = splitCustomFieldPayload(guard.modifiedPayload ?? { ...parsed.data, customFields: custom })\n const effectiveParsed = updateSchema.safeParse(effective.base)\n if (!effectiveParsed.success) return throwAttachmentAccessError(403)\n const patch: Record<string, unknown> = {}\n if (effectiveParsed.data.tags) patch.tags = normalizeAttachmentTags(effectiveParsed.data.tags)\n if (effectiveParsed.data.assignments) patch.assignments = normalizeAttachmentAssignments(effectiveParsed.data.assignments)\n const nextMetadata = mergeAttachmentMetadata(record.storageMetadata, patch)\n await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: record, action: 'reassign', persistProtection: true })\n await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: { ...record, storageMetadata: nextMetadata }, action: 'reassign' })\n await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: { ...record, storageMetadata: nextMetadata }, action: 'metadata' })\n record.storageMetadata = nextMetadata\n await tx.flush()\n if (dataEngine && Object.keys(effective.custom).length) {\n await setCustomFieldsIfAny({ dataEngine, entityId: E.attachments.attachment, recordId: record.id,\n tenantId: record.tenantId ?? auth.tenantId, organizationId: record.organizationId ?? auth.orgId,\n values: effective.custom,\n })\n }\n return { record, custom: effective.custom, guard }\n })\n const { record } = outcome\n await outcome.guard.runAfterSuccess()\n\n if (dataEngine) {\n await emitCrudSideEffects({\n dataEngine,\n action: 'updated',\n entity: record,\n identifiers: {\n id: record.id,\n organizationId: record.organizationId ?? auth.orgId ?? null,\n tenantId: record.tenantId ?? auth.tenantId ?? null,\n },\n events: attachmentCrudEvents,\n indexer: attachmentCrudIndexer,\n })\n await dataEngine.flushOrmEntityChanges()\n }\n\n const metadata = readAttachmentMetadata(record.storageMetadata)\n const assignments = metadata.assignments ?? []\n const enrichments = await resolveAssignmentEnrichments(assignments, {\n queryEngine,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n const enrichedAssignments = applyAssignmentEnrichments(assignments, enrichments)\n return NextResponse.json({\n ok: true,\n item: {\n id: record.id,\n tags: metadata.tags ?? [],\n assignments: enrichedAssignments,\n customFields: normalizeCustomFieldResponse(outcome.custom ?? null),\n },\n })\n}\n\nasync function deleteAttachment(req: NextRequest, ctx: RouteContext) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const attachmentId = await resolveAttachmentId(ctx)\n if (!attachmentId) {\n return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })\n }\n const container = await createRequestContainer()\n const { resolve } = container\n const em = resolve('em') as EntityManager\n const dataEngine = resolve('dataEngine') as DataEngine\n const storageDriverFactory = resolve('storageDriverFactory') as StorageDriverFactory\n const deleteFilter: Record<string, unknown> = {\n id: attachmentId,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }\n const outcome = await em.transactional(async (tx) => {\n const candidate = await findOneWithDecryption(tx, Attachment, deleteFilter, undefined, {\n tenantId: auth.tenantId, organizationId: auth.orgId,\n })\n if (!candidate) return throwAttachmentAccessError(404)\n const referenceIds = await lockAttachmentStorageReferences(tx, candidate)\n const removal = resolveAttachmentStorageRelease(referenceIds, candidate.id)\n if (!removal.removed) return throwAttachmentAccessError(404)\n const record = await findOneWithDecryption(tx, Attachment, deleteFilter, {\n lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true,\n }, { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!record) return throwAttachmentAccessError(404)\n const guard = await prepareAttachmentMutation({ container, req, auth, recordId: record.id, operation: 'delete' })\n await assertAttachmentOwnerAccess({ em: tx, context: createAttachmentAccessContext(container), auth, attachment: record, action: 'delete' })\n const driver = await storageDriverFactory.resolveForPartition(record.partitionCode, {\n tenantId: record.tenantId ?? auth.tenantId!, organizationId: record.organizationId ?? auth.orgId!,\n })\n tx.remove(record)\n await tx.flush()\n return { record, driver, guard, releaseStorage: removal.releaseStorage }\n })\n const { record } = outcome\n if (outcome.releaseStorage) await outcome.driver.delete(record.partitionCode, record.storagePath)\n await outcome.guard.runAfterSuccess()\n\n if (dataEngine) {\n await emitCrudSideEffects({\n dataEngine,\n action: 'deleted',\n entity: record,\n identifiers: {\n id: record.id,\n organizationId: record.organizationId ?? auth.orgId ?? null,\n tenantId: record.tenantId ?? auth.tenantId ?? null,\n },\n events: attachmentCrudEvents,\n indexer: attachmentCrudIndexer,\n })\n await dataEngine.flushOrmEntityChanges()\n }\n\n return NextResponse.json({ ok: true })\n}\n\nexport const PATCH = withAttachmentAccessErrors(updateAttachment)\nexport const DELETE = withAttachmentAccessErrors(deleteAttachment)\n\nexport const openApi: OpenApiRouteDoc = {\n tag: attachmentsTag,\n summary: 'Attachment detail management',\n methods: {\n GET: {\n summary: 'Get attachment details',\n description: 'Returns complete details of an attachment including metadata, tags, assignments, and custom fields.',\n responses: [\n { status: 200, description: 'Attachment details', schema: attachmentDetailResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid attachment ID', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },\n { status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },\n { status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },\n ],\n },\n PATCH: {\n summary: 'Update attachment metadata',\n description: 'Updates attachment tags, assignments, and custom fields. Emits CRUD side effects for indexing and events.',\n requestBody: {\n contentType: 'application/json',\n schema: updateSchema,\n },\n responses: [\n { status: 200, description: 'Attachment updated successfully', schema: z.object({ ok: z.literal(true), item: z.any() }) },\n ],\n errors: [\n { status: 400, description: 'Invalid payload or attachment ID', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },\n { status: 500, description: 'Failed to save attributes', schema: attachmentErrorSchema },\n { status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },\n { status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete attachment',\n description: 'Permanently deletes an attachment file from storage and database. Emits CRUD side effects.',\n responses: [\n { status: 200, description: 'Attachment deleted successfully', schema: z.object({ ok: z.literal(true) }) },\n ],\n errors: [\n { status: 400, description: 'Invalid attachment ID', schema: attachmentErrorSchema },\n { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },\n { status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },\n { status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },\n { status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["metadata", "record"]
7
7
  }
@@ -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 GET(req) {
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 { resolve } = await createRequestContainer();
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 partitionsPromise = em.find(
94
- AttachmentPartition,
95
- {},
96
- { orderBy: { title: "asc" }, fields: ["code", "title", "description"] }
97
- );
98
- const [records, total, partitions] = await Promise.all([qb.getResultList(), countQb.getCount("a.id", true), partitionsPromise]);
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
- const db = em.getKysely();
139
- let tagQuery = db.selectFrom("attachments").select(sql`distinct jsonb_array_elements_text(coalesce(storage_metadata->'tags', '[]'::jsonb))`.as("tag")).where("tenant_id", "=", auth.tenantId);
140
- if (auth.orgId) {
141
- tagQuery = tagQuery.where("organization_id", "=", auth.orgId);
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\nexport async function GET(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 { resolve } = await createRequestContainer()\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' })\n qb.limit(pageSize).offset(offset)\n\n const partitionsPromise = em.find(\n AttachmentPartition,\n {},\n { orderBy: { title: 'asc' }, fields: ['code', 'title', 'description'] as any },\n )\n const [records, total, partitions] = await Promise.all([qb.getResultList(), countQb.getCount('a.id', true), partitionsPromise])\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 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 const availableTags = tagRows\n .map((row) => (typeof row.tag === 'string' ? row.tag.trim() : ''))\n .filter((tag) => tag.length > 0)\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 })\n}\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 ],\n },\n },\n}\n"],
5
- "mappings": "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",
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",