@open-mercato/sync-akeneo 0.9.1-develop.7566.1.b9de0a5eb7 → 0.9.1-develop.7572.1.e9bdaac5f1

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.
@@ -3,6 +3,8 @@ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
3
3
  import { emitCrudSideEffects, setCustomFieldsIfAny } from "@open-mercato/shared/lib/commands/helpers";
4
4
  import { withAtomicFlush } from "@open-mercato/shared/lib/commands/flush";
5
5
  import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
6
+ import { isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
7
+ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
6
8
  import { SyncExternalIdMapping } from "@open-mercato/core/modules/integrations/data/entities";
7
9
  import {
8
10
  CatalogOffer,
@@ -20,9 +22,9 @@ import { normalizeEntityFieldsetConfig } from "@open-mercato/core/modules/entiti
20
22
  import { invalidateDefinitionsCache } from "@open-mercato/core/modules/entities/api/definitions.cache";
21
23
  import { Attachment } from "@open-mercato/core/modules/attachments/data/entities";
22
24
  import { buildAttachmentFileUrl, buildAttachmentImageUrl, slugifyAttachmentFileName } from "@open-mercato/core/modules/attachments/lib/imageUrls";
23
- import { deletePartitionFile, storePartitionFile } from "@open-mercato/core/modules/attachments/lib/storage";
24
- import { ensureDefaultPartitions, resolveDefaultPartitionCode } from "@open-mercato/core/modules/attachments/lib/partitions";
25
- import { mergeAttachmentMetadata } from "@open-mercato/core/modules/attachments/lib/metadata";
25
+ import {
26
+ isScopedAttachmentUploadError
27
+ } from "@open-mercato/core/modules/attachments/lib/scoped-upload-service";
26
28
  import { attachmentCrudEvents, attachmentCrudIndexer } from "@open-mercato/core/modules/attachments/lib/crud";
27
29
  import { emitCatalogQueryIndexEvent } from "@open-mercato/core/modules/catalog/commands/shared";
28
30
  import { SalesChannel } from "@open-mercato/core/modules/sales/data/entities";
@@ -35,8 +37,14 @@ import {
35
37
  slugifyAkeneoCode,
36
38
  buildDefaultAkeneoMapping
37
39
  } from "./shared.js";
40
+ import { isAkeneoResponseTooLargeError } from "./client.js";
38
41
  import { inferAkeneoProductMapping } from "./inference.js";
39
42
  import { normalizeMarkdownText } from "./markdown.js";
43
+ const REJECTED_MEDIA_UPLOAD_CODES = /* @__PURE__ */ new Set([
44
+ "dangerous_executable",
45
+ "max_upload_size",
46
+ "active_content"
47
+ ]);
40
48
  const PRODUCT_ENTITY_ID = "catalog:catalog_product";
41
49
  const VARIANT_ENTITY_ID = "catalog:catalog_product_variant";
42
50
  function readPreferredAkeneoValue(values, attributeCode, locale, channel) {
@@ -1748,40 +1756,61 @@ async function createAkeneoImporter(client, scope) {
1748
1756
  }
1749
1757
  }
1750
1758
  }
1759
+ async function recordRejectedMedia(asset, reason) {
1760
+ const integrationLogService = container.resolve("integrationLogService");
1761
+ await integrationLogService.write({
1762
+ integrationId: "sync_akeneo",
1763
+ level: "warn",
1764
+ code: "sync_akeneo.media_rejected",
1765
+ message: `Akeneo media ${asset.remote.codeOrUrl} was not imported: ${reason}`,
1766
+ scopeEntityType: asset.entityId,
1767
+ scopeEntityId: asset.recordId,
1768
+ payload: {
1769
+ externalId: asset.externalId,
1770
+ remoteCode: asset.remote.codeOrUrl,
1771
+ kind: asset.kind,
1772
+ reason
1773
+ }
1774
+ }, scope);
1775
+ }
1751
1776
  async function upsertAsset(asset) {
1752
1777
  const existingAttachmentId = await externalIdMappingService.lookupLocalId("sync_akeneo", "attachment", asset.externalId, scope);
1753
1778
  if (existingAttachmentId) return existingAttachmentId;
1754
- await ensureDefaultPartitions(em);
1755
- const binary = await client.downloadMediaFile(asset.remote.codeOrUrl);
1779
+ let binary;
1780
+ try {
1781
+ binary = await client.downloadMediaFile(asset.remote.codeOrUrl);
1782
+ } catch (error) {
1783
+ if (!isAkeneoResponseTooLargeError(error)) throw error;
1784
+ await recordRejectedMedia(asset, "max_upload_size");
1785
+ return null;
1786
+ }
1756
1787
  const fileName = asset.remote.fileNameHint ?? binary.fileName ?? `akeneo-${randomUUID().slice(0, 8)}`;
1757
- const partitionCode = resolveDefaultPartitionCode(asset.entityId);
1758
- const stored = await storePartitionFile({
1759
- partitionCode,
1760
- orgId: scope.organizationId,
1761
- tenantId: scope.tenantId,
1762
- fileName,
1763
- buffer: binary.buffer
1764
- });
1765
- const attachment = em.create(Attachment, {
1766
- entityId: asset.entityId,
1767
- recordId: asset.recordId,
1768
- organizationId: scope.organizationId,
1769
- tenantId: scope.tenantId,
1770
- partitionCode,
1771
- fileName,
1772
- mimeType: asset.remote.mimeTypeHint ?? binary.contentType ?? "application/octet-stream",
1773
- fileSize: binary.contentLength ?? binary.buffer.byteLength,
1774
- storageDriver: "local",
1775
- storagePath: stored.storagePath,
1776
- storageMetadata: mergeAttachmentMetadata(null, {
1777
- assignments: [{ type: asset.entityId, id: asset.recordId }]
1778
- }),
1779
- url: buildAttachmentFileUrl("pending"),
1780
- content: null
1781
- });
1782
- em.persist(attachment);
1783
- await em.flush();
1784
- attachment.url = asset.kind === "image" ? buildAttachmentImageUrl(attachment.id, { slug: slugifyAttachmentFileName(fileName) }) : buildAttachmentFileUrl(attachment.id);
1788
+ const uploadService = container.resolve("attachmentScopedUploadService");
1789
+ let attachment;
1790
+ try {
1791
+ attachment = await uploadService.upload({
1792
+ tenantId: scope.tenantId,
1793
+ organizationId: scope.organizationId,
1794
+ entityId: asset.entityId,
1795
+ recordId: asset.recordId,
1796
+ fileName,
1797
+ declaredMimeType: asset.remote.mimeTypeHint ?? binary.contentType,
1798
+ buffer: binary.buffer
1799
+ });
1800
+ } catch (error) {
1801
+ if (!isScopedAttachmentUploadError(error)) throw error;
1802
+ if (REJECTED_MEDIA_UPLOAD_CODES.has(error.code)) {
1803
+ await recordRejectedMedia(asset, error.code);
1804
+ return null;
1805
+ }
1806
+ const { translate } = await resolveTranslations();
1807
+ throw new Error(translate(
1808
+ "sync_akeneo.errors.mediaStorageRejected",
1809
+ "Akeneo media {code} was rejected by attachment storage ({reason}).",
1810
+ { code: asset.remote.codeOrUrl, reason: error.code }
1811
+ ));
1812
+ }
1813
+ attachment.url = asset.kind === "image" ? buildAttachmentImageUrl(attachment.id, { slug: slugifyAttachmentFileName(attachment.fileName) }) : buildAttachmentFileUrl(attachment.id);
1785
1814
  attachment.storageMetadata = {
1786
1815
  ...attachment.storageMetadata ?? {},
1787
1816
  source: "akeneo",
@@ -1790,16 +1819,37 @@ async function createAkeneoImporter(client, scope) {
1790
1819
  remoteCode: asset.remote.codeOrUrl
1791
1820
  };
1792
1821
  await em.flush();
1793
- await emitAttachmentCrudChange("created", attachment);
1822
+ await emitAttachmentCrudChange("updated", attachment);
1794
1823
  await externalIdMappingService.storeExternalIdMapping("sync_akeneo", "attachment", attachment.id, asset.externalId, scope);
1795
1824
  return attachment.id;
1796
1825
  }
1826
+ async function releaseImportedAttachment(attachment) {
1827
+ const attachmentService = container.resolve("attachmentService");
1828
+ if (!attachmentService.releaseScoped) {
1829
+ throw new Error("[internal] attachmentService.releaseScoped is not available");
1830
+ }
1831
+ try {
1832
+ await attachmentService.releaseScoped({
1833
+ attachmentId: attachment.id,
1834
+ tenantId: scope.tenantId,
1835
+ organizationId: scope.organizationId,
1836
+ expectedOwner: { entityId: attachment.entityId, recordId: attachment.recordId },
1837
+ expectedAssignment: { type: attachment.entityId, id: attachment.recordId },
1838
+ expectedPartitionCode: attachment.partitionCode
1839
+ });
1840
+ return true;
1841
+ } catch (error) {
1842
+ if (isCrudHttpError(error) && error.status === 409) return false;
1843
+ throw error;
1844
+ }
1845
+ }
1797
1846
  async function reconcileAttachments(params) {
1798
1847
  const desiredExternalIds = new Set(params.desiredAssets.map((asset) => asset.externalId));
1799
- const desiredImages = params.desiredAssets.filter((asset) => asset.kind === "image");
1800
- const desiredFiles = params.desiredAssets.filter((asset) => asset.kind === "file");
1801
- const desiredAttachmentIds = await Promise.all(params.desiredAssets.map((asset) => upsertAsset(asset)));
1802
- const heroAttachmentId = desiredImages.length > 0 ? desiredAttachmentIds[params.desiredAssets.findIndex((asset) => asset.externalId === desiredImages[0]?.externalId)] : null;
1848
+ let heroAttachmentId = null;
1849
+ for (const asset of params.desiredAssets) {
1850
+ const attachmentId = await upsertAsset(asset);
1851
+ if (!heroAttachmentId && attachmentId && asset.kind === "image") heroAttachmentId = attachmentId;
1852
+ }
1803
1853
  const heroAttachmentUrl = heroAttachmentId ? buildAttachmentImageUrl(heroAttachmentId) : null;
1804
1854
  const existing = await findWithDecryption(em, Attachment, {
1805
1855
  entityId: params.entityId,
@@ -1814,8 +1864,7 @@ async function createAkeneoImporter(client, scope) {
1814
1864
  if (!externalId || !kind) continue;
1815
1865
  const shouldDelete = kind === "image" ? params.reconciliation.deleteMissingMedia : params.reconciliation.deleteMissingAttachments;
1816
1866
  if (!shouldDelete || desiredExternalIds.has(externalId)) continue;
1817
- await deletePartitionFile(attachment.partitionCode, attachment.storagePath, attachment.storageDriver);
1818
- await em.remove(attachment).flush();
1867
+ if (!await releaseImportedAttachment(attachment)) continue;
1819
1868
  await emitAttachmentCrudChange("deleted", attachment);
1820
1869
  }
1821
1870
  return {