create-nextblock 0.14.1 → 0.14.2

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 (241) hide show
  1. package/docker-template/.dockerignore +23 -23
  2. package/docker-template/.env.docker.example +69 -69
  3. package/docker-template/docker/db/init/99-jwt.sql +6 -6
  4. package/docker-template/docker/db/init/99-roles.sql +25 -25
  5. package/docker-template/docker/kong/kong.yml +112 -112
  6. package/docker-template/scripts/docker-setup.mjs +310 -310
  7. package/libs/db/tsconfig.lib.json +3 -3
  8. package/libs/editor/tsconfig.lib.json +3 -3
  9. package/libs/ui/tsconfig.lib.json +3 -3
  10. package/libs/utils/tsconfig.json +3 -3
  11. package/package.json +1 -1
  12. package/project.json +19 -19
  13. package/templates/nextblock-template/.browserslistrc +11 -11
  14. package/templates/nextblock-template/.dockerignore +23 -23
  15. package/templates/nextblock-template/README.md +34 -34
  16. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  17. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  18. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +46 -46
  19. package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -51
  20. package/templates/nextblock-template/app/ToasterProvider.tsx +17 -17
  21. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  22. package/templates/nextblock-template/app/actions/consent.ts +57 -57
  23. package/templates/nextblock-template/app/actions/postActions.ts +146 -146
  24. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +22 -22
  25. package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -153
  26. package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -96
  27. package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -48
  28. package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -44
  29. package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -33
  30. package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -63
  31. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3490 -3490
  32. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +5749 -5749
  33. package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +39 -39
  34. package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -92
  35. package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -43
  36. package/templates/nextblock-template/app/api/media/library/route.ts +69 -69
  37. package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -53
  38. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  39. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  40. package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +441 -441
  41. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  42. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  43. package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
  44. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  45. package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
  46. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +4 -2
  47. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +90 -90
  48. package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
  49. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  50. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  51. package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
  52. package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
  53. package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
  54. package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
  55. package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
  56. package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
  57. package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
  58. package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
  59. package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
  60. package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
  61. package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
  62. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  63. package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
  64. package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
  65. package/templates/nextblock-template/app/cms/layout.tsx +73 -73
  66. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  67. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  68. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
  69. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  70. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  71. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  72. package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -12
  73. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  74. package/templates/nextblock-template/app/cms/products/new/page.tsx +135 -135
  75. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  76. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  77. package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
  78. package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
  79. package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
  80. package/templates/nextblock-template/app/cms/revisions/service.ts +19 -19
  81. package/templates/nextblock-template/app/cms/revisions/utils.ts +132 -132
  82. package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
  83. package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
  84. package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
  85. package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +688 -688
  86. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  87. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  88. package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -28
  89. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  90. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  91. package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
  92. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  93. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  94. package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -21
  95. package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +23 -21
  96. package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -42
  97. package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -133
  98. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  99. package/templates/nextblock-template/app/cms/settings/packages/package-card.tsx +122 -122
  100. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +27 -27
  101. package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
  102. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +9 -7
  103. package/templates/nextblock-template/app/cms/settings/security/page.tsx +33 -33
  104. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  105. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  106. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  107. package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
  108. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  109. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  110. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  111. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  112. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  113. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  114. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  115. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  116. package/templates/nextblock-template/app/setup/SetupWizard.tsx +678 -678
  117. package/templates/nextblock-template/app/setup/layout.tsx +13 -13
  118. package/templates/nextblock-template/app/setup/page.tsx +111 -111
  119. package/templates/nextblock-template/app/sitemap.ts +130 -130
  120. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  121. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  122. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
  123. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  124. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  125. package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
  126. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  127. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  128. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  129. package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
  130. package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
  131. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
  132. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  133. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  134. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  135. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +249 -249
  136. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  137. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  138. package/templates/nextblock-template/components/form-message.tsx +32 -32
  139. package/templates/nextblock-template/components/media/YouTubeFacade.tsx +105 -105
  140. package/templates/nextblock-template/components/media/youtube-embed-replace.tsx +32 -32
  141. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
  142. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
  143. package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
  144. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
  145. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +471 -471
  146. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  147. package/templates/nextblock-template/components/theme-switcher.tsx +8 -8
  148. package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
  149. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  150. package/templates/nextblock-template/context/language-rest-client.ts +32 -32
  151. package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
  152. package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
  153. package/templates/nextblock-template/docker/kong/kong.yml +112 -112
  154. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  155. package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
  156. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +202 -202
  157. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +246 -246
  158. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +176 -176
  159. package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
  160. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
  161. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -173
  162. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +170 -170
  163. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +151 -151
  164. package/templates/nextblock-template/docs/README.md +39 -39
  165. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  166. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  167. package/templates/nextblock-template/index.d.ts +7 -7
  168. package/templates/nextblock-template/lib/app-secrets.ts +39 -39
  169. package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
  170. package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
  171. package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
  172. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  173. package/templates/nextblock-template/lib/blocks/README.md +13 -13
  174. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
  175. package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -2243
  176. package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
  177. package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
  178. package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
  179. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
  180. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
  181. package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
  182. package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
  183. package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
  184. package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
  185. package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
  186. package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
  187. package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
  188. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
  189. package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
  190. package/templates/nextblock-template/lib/email/branding.ts +76 -76
  191. package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
  192. package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
  193. package/templates/nextblock-template/lib/full-backup/server.ts +743 -743
  194. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
  195. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +54 -54
  196. package/templates/nextblock-template/lib/media/youtube.ts +99 -99
  197. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
  198. package/templates/nextblock-template/lib/onboarding/status.ts +222 -222
  199. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  200. package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
  201. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
  202. package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
  203. package/templates/nextblock-template/lib/privacy/types.ts +69 -69
  204. package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
  205. package/templates/nextblock-template/lib/promotions/server.ts +741 -741
  206. package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
  207. package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
  208. package/templates/nextblock-template/lib/search/types.ts +27 -27
  209. package/templates/nextblock-template/lib/setup/actions.ts +460 -460
  210. package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
  211. package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
  212. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +87 -87
  213. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
  214. package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
  215. package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
  216. package/templates/nextblock-template/lib/setup/types.ts +18 -18
  217. package/templates/nextblock-template/lib/site-url.ts +48 -48
  218. package/templates/nextblock-template/lib/storage/provider.ts +66 -66
  219. package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
  220. package/templates/nextblock-template/lib/updates/check-upstream.ts +441 -441
  221. package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
  222. package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
  223. package/templates/nextblock-template/package.json +1 -1
  224. package/templates/nextblock-template/postcss.config.js +6 -6
  225. package/templates/nextblock-template/scripts/backup.js +115 -115
  226. package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
  227. package/templates/nextblock-template/scripts/restore.js +385 -385
  228. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
  229. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
  230. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
  231. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
  232. package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
  233. package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
  234. package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
  235. package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
  236. package/templates/nextblock-template/tailwind.config.js +25 -25
  237. package/templates/nextblock-template/tools/build-migrate.mjs +209 -209
  238. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  239. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  240. package/templates/nextblock-template/types/jsdom.d.ts +6 -6
  241. package/tsconfig.base.json +3 -3
@@ -1,2243 +1,2243 @@
1
- import "server-only";
2
-
3
- import { revalidatePath, revalidateTag } from "next/cache";
4
- import { v4 as uuidv4 } from "uuid";
5
- import type { Json } from "@nextblock-cms/db";
6
- import {
7
- createClient,
8
- getServiceRoleSupabaseClient,
9
- verifyPackageOnline,
10
- } from "@nextblock-cms/db/server";
11
- import { syncCategoriesForTranslationGroup } from "@nextblock-cms/ecommerce/server";
12
- import {
13
- buildCustomBlockCopySlug,
14
- customBlockDefinitionCreateSchema,
15
- } from "@nextblock-cms/utils";
16
-
17
- import {
18
- CUSTOM_BLOCK_DEFINITIONS_CACHE_TAG,
19
- getCustomBlockDefinitionCacheTag,
20
- } from "../custom-block-definitions";
21
-
22
- import {
23
- createPageRevision,
24
- createPostRevision,
25
- } from "../../app/cms/revisions/service";
26
- import {
27
- getFullPageContent,
28
- getFullPostContent,
29
- type FullPageContent,
30
- type FullPostContent,
31
- } from "../../app/cms/revisions/utils";
32
- import {
33
- getCsvHeaders,
34
- getTemplateCsv,
35
- majorToMinor,
36
- makeUniqueSku,
37
- makeUniqueSlug,
38
- minorToMajor,
39
- normalizeBlocksFromFields,
40
- parseBoolean,
41
- parseCsv,
42
- parseJsonField,
43
- parseNullableNumber,
44
- parseNumber,
45
- priceMapMajorToMinor,
46
- priceMapMinorToMajor,
47
- salePriceMapMajorToMinor,
48
- salePriceMapMinorToMajor,
49
- splitList,
50
- stringifyCsv,
51
- toJsonCell,
52
- } from "./csv";
53
- import type {
54
- BackupBlockRecord,
55
- BackupCustomBlockRecord,
56
- BackupPageRecord,
57
- BackupPostRecord,
58
- BackupProductRecord,
59
- BackupProductVariantRecord,
60
- CmsBackupBundleV1,
61
- CmsContentType,
62
- CmsImportConflictMode,
63
- CmsImportMessage,
64
- CmsImportOptions,
65
- CmsImportPreviewItem,
66
- CmsImportSummary,
67
- } from "./types";
68
-
69
- type SupabaseAny = ReturnType<typeof getServiceRoleSupabaseClient>;
70
- type CsvLikeRow = Record<string, string>;
71
- type ImportSourceRow = CsvLikeRow | BackupPageRecord | BackupPostRecord | BackupProductRecord;
72
-
73
- const PAGE_STATUSES = new Set(["draft", "published", "archived"]);
74
- const PRODUCT_STATUSES = new Set(["draft", "active", "archived"]);
75
- const PRODUCT_TYPES = new Set(["physical", "digital"]);
76
- const PAYMENT_PROVIDERS = new Set(["stripe", "freemius"]);
77
-
78
- interface TransferAuth {
79
- userId: string;
80
- role: string;
81
- supabase: SupabaseAny;
82
- }
83
-
84
- interface LanguageRecord {
85
- id: number;
86
- code: string;
87
- name: string | null;
88
- }
89
-
90
- interface ExistingContentRecord {
91
- id: number;
92
- language_id: number;
93
- slug: string;
94
- title: string;
95
- status: string;
96
- version?: number | null;
97
- translation_group_id?: string | null;
98
- }
99
-
100
- interface ExistingProductRecord {
101
- id: string;
102
- language_id: number;
103
- slug: string;
104
- sku: string;
105
- title: string;
106
- status: string;
107
- translation_group_id?: string | null;
108
- }
109
-
110
- interface PreparedContentImport {
111
- contentType: "pages" | "posts";
112
- rowNumber: number;
113
- action: "create" | "update";
114
- targetId: number | null;
115
- meta: Record<string, unknown>;
116
- blocks: BackupBlockRecord[];
117
- replaceBlocks: boolean;
118
- oldSlug?: string | null;
119
- }
120
-
121
- interface PreparedProductImport {
122
- contentType: "products";
123
- rowNumber: number;
124
- action: "create" | "update";
125
- targetId: string | null;
126
- meta: Record<string, unknown>;
127
- blocks: BackupBlockRecord[];
128
- categoryIds: string[];
129
- mediaIds: string[];
130
- variants: BackupProductVariantRecord[];
131
- replaceBlocks: boolean;
132
- replaceMedia: boolean;
133
- replaceVariants: boolean;
134
- syncCategories: boolean;
135
- oldSlug?: string | null;
136
- }
137
-
138
- type PreparedImport = PreparedContentImport | PreparedProductImport;
139
-
140
- function emptySummary(): CmsImportSummary {
141
- return {
142
- success: true,
143
- totalRows: 0,
144
- created: 0,
145
- updated: 0,
146
- skipped: 0,
147
- errors: [],
148
- warnings: [],
149
- preview: [],
150
- };
151
- }
152
-
153
- function addMessage(
154
- messages: CmsImportMessage[],
155
- row: number,
156
- message: string,
157
- type: "error" | "warning" = "error"
158
- ) {
159
- messages.push({ row, type, message });
160
- }
161
-
162
- function normalizeNullableString(value: unknown) {
163
- const text = typeof value === "string" ? value.trim() : "";
164
- return text ? text : null;
165
- }
166
-
167
- function normalizeRequiredString(value: unknown) {
168
- return typeof value === "string" ? value.trim() : "";
169
- }
170
-
171
- function normalizeId(value: unknown) {
172
- const text = normalizeRequiredString(value);
173
- return text || null;
174
- }
175
-
176
- function rowHasOwnField(row: ImportSourceRow, fieldName: string) {
177
- return Object.prototype.hasOwnProperty.call(row, fieldName);
178
- }
179
-
180
- function rowHasImportValue(row: ImportSourceRow, fieldName: string) {
181
- if (!rowHasOwnField(row, fieldName)) return false;
182
-
183
- const value = (row as Record<string, unknown>)[fieldName];
184
- if (value === null || value === undefined) return false;
185
- if (Array.isArray(value)) return value.length > 0;
186
- if (typeof value === "object") return true;
187
- return normalizeRequiredString(value) !== "";
188
- }
189
-
190
- function canPreserveBlankFields(options: CmsImportOptions, target: unknown) {
191
- return Boolean(
192
- options.ignoreBlankFields &&
193
- options.conflictMode === "overwrite_existing" &&
194
- options.applyMode === "live" &&
195
- target
196
- );
197
- }
198
-
199
- function shouldPreserveBlankField(
200
- row: ImportSourceRow,
201
- fieldName: string,
202
- options: CmsImportOptions,
203
- target: unknown
204
- ) {
205
- return canPreserveBlankFields(options, target) && !rowHasImportValue(row, fieldName);
206
- }
207
-
208
- function setMetaValue(
209
- meta: Record<string, unknown>,
210
- key: string,
211
- value: unknown,
212
- shouldSet = true
213
- ) {
214
- if (shouldSet) {
215
- meta[key] = value;
216
- }
217
- }
218
-
219
- function readRequiredNumber(
220
- value: unknown,
221
- fieldName: string,
222
- rowNumber: number,
223
- errors: CmsImportMessage[]
224
- ) {
225
- const raw = typeof value === "number" ? String(value) : normalizeRequiredString(value);
226
- if (!raw) {
227
- addMessage(errors, rowNumber, `${fieldName} is required.`);
228
- return null;
229
- }
230
-
231
- const parsed = Number(raw);
232
- if (!Number.isFinite(parsed)) {
233
- addMessage(errors, rowNumber, `${fieldName} must be a valid number.`);
234
- return null;
235
- }
236
- if (parsed < 0) {
237
- addMessage(errors, rowNumber, `${fieldName} must be zero or greater.`);
238
- return null;
239
- }
240
-
241
- return parsed;
242
- }
243
-
244
- function readOptionalDate(
245
- value: unknown,
246
- fieldName: string,
247
- rowNumber: number,
248
- errors: CmsImportMessage[]
249
- ) {
250
- const raw = normalizeNullableString(value);
251
- if (!raw) return null;
252
-
253
- const date = new Date(raw);
254
- if (Number.isNaN(date.getTime())) {
255
- addMessage(errors, rowNumber, `${fieldName} must be a valid date.`);
256
- return null;
257
- }
258
-
259
- return date.toISOString();
260
- }
261
-
262
- function isRecord(value: unknown): value is Record<string, unknown> {
263
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
264
- }
265
-
266
- async function requireTransferUser(adminOnly = false): Promise<TransferAuth> {
267
- const authClient = createClient();
268
- const {
269
- data: { user },
270
- } = await authClient.auth.getUser();
271
-
272
- if (!user) {
273
- throw new Error("User not authenticated.");
274
- }
275
-
276
- const { data: profile, error } = await authClient
277
- .from("profiles")
278
- .select("role")
279
- .eq("id", user.id)
280
- .single();
281
-
282
- if (error || !profile) {
283
- throw new Error("Could not verify CMS permissions.");
284
- }
285
-
286
- const role = profile.role;
287
- const allowed = adminOnly ? role === "ADMIN" : role === "ADMIN" || role === "WRITER";
288
- if (!allowed) {
289
- throw new Error(adminOnly ? "Admin role required." : "Writer or admin role required.");
290
- }
291
-
292
- return {
293
- userId: user.id,
294
- role,
295
- supabase: getServiceRoleSupabaseClient(),
296
- };
297
- }
298
-
299
- async function loadLanguages(supabase: SupabaseAny) {
300
- const { data, error } = await supabase
301
- .from("languages")
302
- .select("id, code, name")
303
- .order("id", { ascending: true });
304
-
305
- if (error) {
306
- throw new Error(`Failed to load languages: ${error.message}`);
307
- }
308
-
309
- const languages = (data || []) as LanguageRecord[];
310
- const byCode = new Map(languages.map((language) => [language.code.toLowerCase(), language]));
311
- const byId = new Map(languages.map((language) => [language.id, language]));
312
-
313
- return { languages, byCode, byId };
314
- }
315
-
316
- async function isEcommerceContentAvailable(supabase: SupabaseAny) {
317
- return verifyPackageOnline("ecommerce", supabase).catch(() => false);
318
- }
319
-
320
- async function loadMediaMaps(supabase: SupabaseAny) {
321
- const { data, error } = await supabase
322
- .from("media")
323
- .select("id, object_key");
324
-
325
- if (error) {
326
- throw new Error(`Failed to load media references: ${error.message}`);
327
- }
328
-
329
- const byId = new Map<string, { id: string; object_key: string | null }>();
330
- const byObjectKey = new Map<string, { id: string; object_key: string | null }>();
331
-
332
- for (const media of data || []) {
333
- const id = String((media as any).id);
334
- const objectKey = typeof (media as any).object_key === "string" ? (media as any).object_key : null;
335
- byId.set(id, { id, object_key: objectKey });
336
- if (objectKey) {
337
- byObjectKey.set(objectKey, { id, object_key: objectKey });
338
- }
339
- }
340
-
341
- return { byId, byObjectKey };
342
- }
343
-
344
- function resolveMediaId(params: {
345
- id?: string | null;
346
- objectKey?: string | null;
347
- rowNumber: number;
348
- fieldLabel: string;
349
- mediaById: Map<string, { id: string; object_key: string | null }>;
350
- mediaByObjectKey: Map<string, { id: string; object_key: string | null }>;
351
- warnings: CmsImportMessage[];
352
- }) {
353
- const id = normalizeId(params.id);
354
- const objectKey = normalizeId(params.objectKey);
355
-
356
- if (id) {
357
- if (params.mediaById.has(id)) {
358
- return id;
359
- }
360
-
361
- addMessage(params.warnings, params.rowNumber, `${params.fieldLabel} "${id}" was not found and will be skipped.`, "warning");
362
- return null;
363
- }
364
-
365
- if (objectKey) {
366
- const media = params.mediaByObjectKey.get(objectKey);
367
- if (media) {
368
- return media.id;
369
- }
370
-
371
- addMessage(params.warnings, params.rowNumber, `${params.fieldLabel} object key "${objectKey}" was not found and will be skipped.`, "warning");
372
- }
373
-
374
- return null;
375
- }
376
-
377
- function getOptionalLanguageId(
378
- row: ImportSourceRow,
379
- rowNumber: number,
380
- languagesByCode: Map<string, LanguageRecord>,
381
- errors: CmsImportMessage[]
382
- ) {
383
- const languageCode = normalizeRequiredString((row as any).language_code).toLowerCase();
384
- if (!languageCode) return null;
385
-
386
- const language = languagesByCode.get(languageCode);
387
- if (!language) {
388
- addMessage(errors, rowNumber, `Language "${languageCode}" does not exist.`);
389
- return null;
390
- }
391
-
392
- return language.id;
393
- }
394
-
395
- function parseBlocksForContent(
396
- row: ImportSourceRow,
397
- rowNumber: number,
398
- languageId: number,
399
- errors: CmsImportMessage[]
400
- ) {
401
- const localErrors: Array<{ row: number; message: string }> = [];
402
- const backupBlocks = Array.isArray((row as BackupPageRecord).blocks)
403
- ? (row as BackupPageRecord).blocks
404
- : undefined;
405
- const parsedBlocks =
406
- backupBlocks ??
407
- parseJsonField<unknown>(
408
- (row as CsvLikeRow).blocks_json,
409
- "blocks_json",
410
- rowNumber,
411
- localErrors
412
- );
413
-
414
- const blocks = normalizeBlocksFromFields({
415
- blocksJson: parsedBlocks,
416
- html: (row as CsvLikeRow).content_html,
417
- languageId,
418
- rowNumber,
419
- errors: localErrors,
420
- });
421
-
422
- for (const error of localErrors) {
423
- addMessage(errors, error.row, error.message);
424
- }
425
-
426
- return blocks;
427
- }
428
-
429
- function parseBlocksForProduct(
430
- row: ImportSourceRow,
431
- rowNumber: number,
432
- languageId: number,
433
- errors: CmsImportMessage[]
434
- ) {
435
- const localErrors: Array<{ row: number; message: string }> = [];
436
- const backupBlocks = Array.isArray((row as BackupProductRecord).description_blocks)
437
- ? (row as BackupProductRecord).description_blocks
438
- : undefined;
439
- const parsedBlocks =
440
- backupBlocks ??
441
- parseJsonField<unknown>(
442
- (row as CsvLikeRow).description_blocks_json,
443
- "description_blocks_json",
444
- rowNumber,
445
- localErrors
446
- );
447
-
448
- const blocks = normalizeBlocksFromFields({
449
- blocksJson: parsedBlocks,
450
- html: (row as CsvLikeRow).description_html,
451
- languageId,
452
- rowNumber,
453
- errors: localErrors,
454
- });
455
-
456
- for (const error of localErrors) {
457
- addMessage(errors, error.row, error.message);
458
- }
459
-
460
- return blocks;
461
- }
462
-
463
- async function loadExistingContent(supabase: SupabaseAny, contentType: "pages" | "posts") {
464
- const table = contentType === "pages" ? "pages" : "posts";
465
- const { data, error } = await supabase
466
- .from(table)
467
- .select("id, language_id, slug, title, status, version, translation_group_id");
468
-
469
- if (error) {
470
- throw new Error(`Failed to load existing ${contentType}: ${error.message}`);
471
- }
472
-
473
- const byId = new Map<number, ExistingContentRecord>();
474
- const bySlug = new Map<string, ExistingContentRecord>();
475
-
476
- for (const item of (data || []) as ExistingContentRecord[]) {
477
- byId.set(Number(item.id), item);
478
- bySlug.set(`${item.language_id}:${item.slug}`, item);
479
- }
480
-
481
- return { byId, bySlug };
482
- }
483
-
484
- async function loadExistingProducts(supabase: SupabaseAny) {
485
- const { data, error } = await supabase
486
- .from("products")
487
- .select("id, language_id, slug, sku, title, status, translation_group_id");
488
-
489
- if (error) {
490
- throw new Error(`Failed to load existing products: ${error.message}`);
491
- }
492
-
493
- const byId = new Map<string, ExistingProductRecord>();
494
- const bySlug = new Map<string, ExistingProductRecord>();
495
- const bySku = new Map<string, ExistingProductRecord>();
496
-
497
- for (const item of (data || []) as ExistingProductRecord[]) {
498
- byId.set(item.id, item);
499
- bySlug.set(`${item.language_id}:${item.slug}`, item);
500
- bySku.set(`${item.language_id}:${item.sku}`, item);
501
- }
502
-
503
- return { byId, bySlug, bySku };
504
- }
505
-
506
- function createPreview(
507
- prepared: PreparedImport[],
508
- summary: CmsImportSummary
509
- ) {
510
- summary.preview = prepared.map((item) => ({
511
- row: item.rowNumber,
512
- action: item.action,
513
- identifier:
514
- item.contentType === "products"
515
- ? String(item.meta.sku || item.meta.slug || item.meta.title || item.targetId || "Product")
516
- : String(item.meta.slug || item.meta.title || item.targetId || "Content"),
517
- contentType: item.contentType,
518
- })) satisfies CmsImportPreviewItem[];
519
- summary.created = prepared.filter((item) => item.action === "create").length;
520
- summary.updated = prepared.filter((item) => item.action === "update").length;
521
- summary.skipped = 0;
522
- }
523
-
524
- async function prepareContentImports(params: {
525
- supabase: SupabaseAny;
526
- contentType: "pages" | "posts";
527
- rows: ImportSourceRow[];
528
- options: CmsImportOptions;
529
- }) {
530
- const summary = emptySummary();
531
- summary.totalRows = params.rows.length;
532
- const prepared: PreparedContentImport[] = [];
533
- const { byCode: languagesByCode } = await loadLanguages(params.supabase);
534
- const mediaMaps = await loadMediaMaps(params.supabase);
535
- const existing = await loadExistingContent(params.supabase, params.contentType);
536
- const slugSets = new Map<number, Set<string>>();
537
- const translationGroupMap = new Map<string, string>();
538
-
539
- for (const item of existing.byId.values()) {
540
- const set = slugSets.get(item.language_id) ?? new Set<string>();
541
- set.add(item.slug);
542
- slugSets.set(item.language_id, set);
543
- }
544
-
545
- for (const [index, row] of params.rows.entries()) {
546
- const rowNumber = index + 2;
547
- const numericId = Number((row as any).id);
548
- const matchedById =
549
- params.options.conflictMode === "overwrite_existing" && Number.isFinite(numericId)
550
- ? existing.byId.get(numericId)
551
- : undefined;
552
- let languageId = getOptionalLanguageId(row, rowNumber, languagesByCode, summary.errors);
553
- if (!languageId && matchedById) {
554
- languageId = matchedById.language_id;
555
- }
556
-
557
- const incomingSlug = normalizeRequiredString((row as any).slug);
558
- const matchedBySlug =
559
- params.options.conflictMode === "overwrite_existing" && languageId && incomingSlug
560
- ? existing.bySlug.get(`${languageId}:${incomingSlug}`)
561
- : undefined;
562
- const target = matchedById ?? matchedBySlug ?? null;
563
- const action = target ? "update" : "create";
564
- const preserveBlanks = canPreserveBlankFields(params.options, target);
565
- const preserveField = (fieldName: string) =>
566
- shouldPreserveBlankField(row, fieldName, params.options, target);
567
- const title = normalizeRequiredString((row as any).title);
568
- const status = preserveField("status")
569
- ? ""
570
- : normalizeRequiredString((row as any).status || "draft");
571
-
572
- if (!languageId && !rowHasImportValue(row, "language_code")) {
573
- addMessage(summary.errors, rowNumber, "language_code is required.");
574
- }
575
- if (!title && !preserveField("title")) addMessage(summary.errors, rowNumber, "title is required.");
576
- if (!incomingSlug && !preserveField("slug")) addMessage(summary.errors, rowNumber, "slug is required.");
577
- if (!preserveField("status") && !PAGE_STATUSES.has(status)) {
578
- addMessage(summary.errors, rowNumber, `status must be one of: ${Array.from(PAGE_STATUSES).join(", ")}.`);
579
- }
580
- if (
581
- !languageId ||
582
- (!title && !preserveField("title")) ||
583
- (!incomingSlug && !preserveField("slug")) ||
584
- (!preserveField("status") && !PAGE_STATUSES.has(status))
585
- ) {
586
- continue;
587
- }
588
-
589
- const slugSet = slugSets.get(languageId) ?? new Set<string>();
590
- const shouldUpdateSlug = !preserveField("slug");
591
- const slug = shouldUpdateSlug
592
- ? params.options.conflictMode === "create_new" || action === "create"
593
- ? makeUniqueSlug(incomingSlug, slugSet)
594
- : incomingSlug
595
- : target?.slug ?? incomingSlug;
596
- slugSets.set(languageId, slugSet);
597
-
598
- const slugOwner = existing.bySlug.get(`${languageId}:${slug}`);
599
- if (action === "update" && shouldUpdateSlug && slugOwner && slugOwner.id !== target?.id) {
600
- addMessage(summary.errors, rowNumber, `Slug "${slug}" already belongs to another ${params.contentType.slice(0, -1)} in this language.`);
601
- continue;
602
- }
603
-
604
- const importedGroupId = normalizeId((row as any).translation_group_id);
605
- const translationGroupId =
606
- action === "update"
607
- ? importedGroupId || target?.translation_group_id || uuidv4()
608
- : importedGroupId
609
- ? translationGroupMap.get(importedGroupId) ?? translationGroupMap.set(importedGroupId, uuidv4()).get(importedGroupId)
610
- : uuidv4();
611
-
612
- const shouldResolveFeatureImage =
613
- !preserveBlanks ||
614
- rowHasImportValue(row, "feature_image_id") ||
615
- rowHasImportValue(row, "feature_image_object_key");
616
- const featureImageId = shouldResolveFeatureImage
617
- ? resolveMediaId({
618
- id: normalizeId((row as any).feature_image_id),
619
- objectKey: normalizeId((row as any).feature_image_object_key),
620
- rowNumber,
621
- fieldLabel: "Feature image",
622
- mediaById: mediaMaps.byId,
623
- mediaByObjectKey: mediaMaps.byObjectKey,
624
- warnings: summary.warnings,
625
- })
626
- : undefined;
627
-
628
- const replaceBlocks =
629
- !preserveBlanks ||
630
- rowHasImportValue(row, "blocks_json") ||
631
- rowHasImportValue(row, "content_html") ||
632
- Array.isArray((row as BackupPageRecord).blocks);
633
- const blocks = replaceBlocks
634
- ? parseBlocksForContent(row, rowNumber, languageId, summary.errors)
635
- : [];
636
- const meta: Record<string, unknown> = {};
637
- setMetaValue(meta, "language_id", languageId, !preserveField("language_code"));
638
- setMetaValue(meta, "title", title, !preserveField("title"));
639
- setMetaValue(meta, "slug", slug, shouldUpdateSlug);
640
- setMetaValue(meta, "status", status, !preserveField("status"));
641
- setMetaValue(
642
- meta,
643
- "meta_title",
644
- normalizeNullableString((row as any).meta_title),
645
- !preserveField("meta_title")
646
- );
647
- setMetaValue(
648
- meta,
649
- "meta_description",
650
- normalizeNullableString((row as any).meta_description),
651
- !preserveField("meta_description")
652
- );
653
- setMetaValue(meta, "feature_image_id", featureImageId, shouldResolveFeatureImage);
654
- setMetaValue(
655
- meta,
656
- "translation_group_id",
657
- translationGroupId,
658
- !preserveField("translation_group_id")
659
- );
660
-
661
- if (params.contentType === "posts") {
662
- setMetaValue(meta, "label", normalizeNullableString((row as any).label), !preserveField("label"));
663
- setMetaValue(meta, "excerpt", normalizeNullableString((row as any).excerpt), !preserveField("excerpt"));
664
- setMetaValue(meta, "subtitle", normalizeNullableString((row as any).subtitle), !preserveField("subtitle"));
665
- setMetaValue(
666
- meta,
667
- "published_at",
668
- readOptionalDate((row as any).published_at, "published_at", rowNumber, summary.errors),
669
- !preserveField("published_at")
670
- );
671
- }
672
-
673
- prepared.push({
674
- contentType: params.contentType,
675
- rowNumber,
676
- action,
677
- targetId: target?.id ?? null,
678
- meta,
679
- blocks,
680
- replaceBlocks,
681
- oldSlug: target?.slug ?? null,
682
- });
683
- }
684
-
685
- createPreview(prepared, summary);
686
- summary.success = summary.errors.length === 0;
687
- return { summary, prepared };
688
- }
689
-
690
- async function loadCategories(supabase: SupabaseAny) {
691
- const { data, error } = await supabase
692
- .from("categories" as any)
693
- .select("id, slug");
694
-
695
- if (error) {
696
- throw new Error(`Failed to load product categories: ${error.message}`);
697
- }
698
-
699
- return new Map((data || []).map((category: any) => [String(category.slug), String(category.id)]));
700
- }
701
-
702
- function normalizePriceMapValue(value: unknown) {
703
- return isRecord(value) ? (value as Record<string, number>) : {};
704
- }
705
-
706
- function normalizeSalePriceMapValue(value: unknown) {
707
- return isRecord(value) ? (value as Record<string, number | null>) : {};
708
- }
709
-
710
- async function prepareProductImports(params: {
711
- supabase: SupabaseAny;
712
- rows: ImportSourceRow[];
713
- options: CmsImportOptions;
714
- }) {
715
- const summary = emptySummary();
716
- summary.totalRows = params.rows.length;
717
- const prepared: PreparedProductImport[] = [];
718
- const { byCode: languagesByCode } = await loadLanguages(params.supabase);
719
- const mediaMaps = await loadMediaMaps(params.supabase);
720
- const categoryMap = await loadCategories(params.supabase);
721
- const existing = await loadExistingProducts(params.supabase);
722
- const slugSets = new Map<number, Set<string>>();
723
- const skuSets = new Map<number, Set<string>>();
724
- const translationGroupMap = new Map<string, string>();
725
-
726
- for (const item of existing.byId.values()) {
727
- const slugSet = slugSets.get(item.language_id) ?? new Set<string>();
728
- const skuSet = skuSets.get(item.language_id) ?? new Set<string>();
729
- slugSet.add(item.slug);
730
- skuSet.add(item.sku);
731
- slugSets.set(item.language_id, slugSet);
732
- skuSets.set(item.language_id, skuSet);
733
- }
734
-
735
- for (const [index, row] of params.rows.entries()) {
736
- const rowNumber = index + 2;
737
- const rowId = normalizeId((row as any).id);
738
- const matchedById =
739
- params.options.conflictMode === "overwrite_existing" && rowId
740
- ? existing.byId.get(rowId)
741
- : undefined;
742
- let languageId = getOptionalLanguageId(row, rowNumber, languagesByCode, summary.errors);
743
- if (!languageId && matchedById) {
744
- languageId = matchedById.language_id;
745
- }
746
-
747
- const incomingSlug = normalizeRequiredString((row as any).slug);
748
- const incomingSku = normalizeRequiredString((row as any).sku);
749
- const matchedBySku =
750
- params.options.conflictMode === "overwrite_existing" && languageId && incomingSku
751
- ? existing.bySku.get(`${languageId}:${incomingSku}`)
752
- : undefined;
753
- const matchedBySlug =
754
- params.options.conflictMode === "overwrite_existing" && languageId && incomingSlug
755
- ? existing.bySlug.get(`${languageId}:${incomingSlug}`)
756
- : undefined;
757
-
758
- if (matchedBySku && matchedBySlug && matchedBySku.id !== matchedBySlug.id) {
759
- addMessage(summary.errors, rowNumber, `SKU "${incomingSku}" and slug "${incomingSlug}" match different products.`);
760
- continue;
761
- }
762
-
763
- const target = matchedById ?? matchedBySku ?? matchedBySlug ?? null;
764
- const action = target ? "update" : "create";
765
- const preserveBlanks = canPreserveBlankFields(params.options, target);
766
- const preserveField = (fieldName: string) =>
767
- shouldPreserveBlankField(row, fieldName, params.options, target);
768
- const title = normalizeRequiredString((row as any).title);
769
- const productType = preserveField("product_type")
770
- ? ""
771
- : normalizeRequiredString((row as any).product_type || "physical");
772
- const paymentProvider = preserveField("payment_provider")
773
- ? ""
774
- : normalizeRequiredString(
775
- (row as any).payment_provider || (productType === "digital" ? "freemius" : "stripe")
776
- );
777
- const status = preserveField("status")
778
- ? ""
779
- : normalizeRequiredString((row as any).status || "draft");
780
- const price = preserveField("price")
781
- ? undefined
782
- : readRequiredNumber((row as any).price, "price", rowNumber, summary.errors);
783
- const stock = preserveField("stock")
784
- ? undefined
785
- : readRequiredNumber((row as any).stock, "stock", rowNumber, summary.errors);
786
- const updatesProductType = !preserveField("product_type");
787
- const updatesPaymentProvider = !preserveField("payment_provider");
788
-
789
- if (!languageId && !rowHasImportValue(row, "language_code")) {
790
- addMessage(summary.errors, rowNumber, "language_code is required.");
791
- }
792
- if (!title && !preserveField("title")) addMessage(summary.errors, rowNumber, "title is required.");
793
- if (!incomingSlug && !preserveField("slug")) addMessage(summary.errors, rowNumber, "slug is required.");
794
- if (!incomingSku && !preserveField("sku")) addMessage(summary.errors, rowNumber, "sku is required.");
795
- if (updatesProductType && !PRODUCT_TYPES.has(productType)) {
796
- addMessage(summary.errors, rowNumber, "product_type must be physical or digital.");
797
- }
798
- if (updatesPaymentProvider && !PAYMENT_PROVIDERS.has(paymentProvider)) {
799
- addMessage(summary.errors, rowNumber, "payment_provider must be stripe or freemius.");
800
- }
801
- if (preserveBlanks && updatesProductType !== updatesPaymentProvider) {
802
- addMessage(summary.errors, rowNumber, "product_type and payment_provider must be imported together when ignoring blank fields.");
803
- }
804
- if (updatesProductType && updatesPaymentProvider && productType === "physical" && paymentProvider !== "stripe") {
805
- addMessage(summary.errors, rowNumber, "physical products must use stripe.");
806
- }
807
- if (updatesProductType && updatesPaymentProvider && productType === "digital" && paymentProvider !== "freemius") {
808
- addMessage(summary.errors, rowNumber, "digital products must use freemius.");
809
- }
810
- if (!preserveField("status") && !PRODUCT_STATUSES.has(status)) {
811
- addMessage(summary.errors, rowNumber, `status must be one of: ${Array.from(PRODUCT_STATUSES).join(", ")}.`);
812
- }
813
- if (
814
- !languageId ||
815
- (!title && !preserveField("title")) ||
816
- (!incomingSlug && !preserveField("slug")) ||
817
- (!incomingSku && !preserveField("sku")) ||
818
- (updatesProductType && !PRODUCT_TYPES.has(productType)) ||
819
- (updatesPaymentProvider && !PAYMENT_PROVIDERS.has(paymentProvider)) ||
820
- (preserveBlanks && updatesProductType !== updatesPaymentProvider) ||
821
- (updatesProductType && updatesPaymentProvider && productType === "physical" && paymentProvider !== "stripe") ||
822
- (updatesProductType && updatesPaymentProvider && productType === "digital" && paymentProvider !== "freemius") ||
823
- (!preserveField("status") && !PRODUCT_STATUSES.has(status)) ||
824
- price === null ||
825
- stock === null
826
- ) {
827
- continue;
828
- }
829
-
830
- const slugSet = slugSets.get(languageId) ?? new Set<string>();
831
- const skuSet = skuSets.get(languageId) ?? new Set<string>();
832
- const shouldUpdateSlug = !preserveField("slug");
833
- const shouldUpdateSku = !preserveField("sku");
834
- const slug = shouldUpdateSlug
835
- ? params.options.conflictMode === "create_new" || action === "create"
836
- ? makeUniqueSlug(incomingSlug, slugSet)
837
- : incomingSlug
838
- : target?.slug ?? incomingSlug;
839
- const sku = shouldUpdateSku
840
- ? params.options.conflictMode === "create_new" || action === "create"
841
- ? makeUniqueSku(incomingSku, skuSet)
842
- : incomingSku
843
- : target?.sku ?? incomingSku;
844
- slugSets.set(languageId, slugSet);
845
- skuSets.set(languageId, skuSet);
846
-
847
- const slugOwner = existing.bySlug.get(`${languageId}:${slug}`);
848
- const skuOwner = existing.bySku.get(`${languageId}:${sku}`);
849
- if (action === "update" && shouldUpdateSlug && slugOwner && slugOwner.id !== target?.id) {
850
- addMessage(summary.errors, rowNumber, `Slug "${slug}" already belongs to another product in this language.`);
851
- continue;
852
- }
853
- if (action === "update" && shouldUpdateSku && skuOwner && skuOwner.id !== target?.id) {
854
- addMessage(summary.errors, rowNumber, `SKU "${sku}" already belongs to another product in this language.`);
855
- continue;
856
- }
857
-
858
- const localErrors: Array<{ row: number; message: string }> = [];
859
- const prices =
860
- preserveField("prices_json")
861
- ? undefined
862
- : (row as BackupProductRecord).prices ??
863
- parseJsonField<Record<string, number>>((row as CsvLikeRow).prices_json, "prices_json", rowNumber, localErrors) ??
864
- {};
865
- const salePrices =
866
- preserveField("sale_prices_json")
867
- ? undefined
868
- : (row as BackupProductRecord).sale_prices ??
869
- parseJsonField<Record<string, number | null>>((row as CsvLikeRow).sale_prices_json, "sale_prices_json", rowNumber, localErrors) ??
870
- {};
871
- const descriptionJson =
872
- preserveField("description_json")
873
- ? undefined
874
- : (row as BackupProductRecord).description_json ??
875
- parseJsonField<Json>((row as CsvLikeRow).description_json, "description_json", rowNumber, localErrors);
876
- const replaceVariants =
877
- !preserveBlanks ||
878
- rowHasImportValue(row, "variants_json") ||
879
- Array.isArray((row as BackupProductRecord).variants);
880
- const rawVariants =
881
- replaceVariants
882
- ? (row as BackupProductRecord).variants ??
883
- parseJsonField<BackupProductVariantRecord[]>((row as CsvLikeRow).variants_json, "variants_json", rowNumber, localErrors) ??
884
- []
885
- : [];
886
-
887
- for (const error of localErrors) {
888
- addMessage(summary.errors, error.row, error.message);
889
- }
890
- if (localErrors.length > 0) continue;
891
- if (replaceVariants && !Array.isArray(rawVariants)) {
892
- addMessage(summary.errors, rowNumber, "variants_json must be an array.");
893
- continue;
894
- }
895
-
896
- const syncCategories =
897
- !preserveBlanks ||
898
- rowHasImportValue(row, "category_slugs") ||
899
- Array.isArray((row as BackupProductRecord).category_slugs);
900
- const categorySlugs = syncCategories
901
- ? Array.isArray((row as BackupProductRecord).category_slugs)
902
- ? ((row as BackupProductRecord).category_slugs || [])
903
- : splitList((row as CsvLikeRow).category_slugs)
904
- : [];
905
- const categoryIds: string[] = [];
906
- for (const categorySlug of categorySlugs) {
907
- const categoryId = categoryMap.get(categorySlug);
908
- if (!categoryId) {
909
- addMessage(summary.errors, rowNumber, `Category slug "${categorySlug}" does not exist.`);
910
- } else {
911
- categoryIds.push(categoryId);
912
- }
913
- }
914
- if (summary.errors.some((error) => error.row === rowNumber)) continue;
915
-
916
- const replaceMedia =
917
- !preserveBlanks ||
918
- rowHasImportValue(row, "media_ids") ||
919
- rowHasImportValue(row, "media_object_keys") ||
920
- Array.isArray((row as BackupProductRecord).media_ids) ||
921
- Array.isArray((row as BackupProductRecord).media_object_keys);
922
- const explicitMediaIds = replaceMedia
923
- ? Array.isArray((row as BackupProductRecord).media_ids)
924
- ? ((row as BackupProductRecord).media_ids || [])
925
- : splitList((row as CsvLikeRow).media_ids)
926
- : [];
927
- const mediaObjectKeys = replaceMedia
928
- ? Array.isArray((row as BackupProductRecord).media_object_keys)
929
- ? ((row as BackupProductRecord).media_object_keys || [])
930
- : splitList((row as CsvLikeRow).media_object_keys)
931
- : [];
932
- const mediaIds = new Set<string>();
933
- for (const mediaId of explicitMediaIds) {
934
- const resolved = resolveMediaId({
935
- id: mediaId,
936
- rowNumber,
937
- fieldLabel: "Product media",
938
- mediaById: mediaMaps.byId,
939
- mediaByObjectKey: mediaMaps.byObjectKey,
940
- warnings: summary.warnings,
941
- });
942
- if (resolved) mediaIds.add(resolved);
943
- }
944
- for (const objectKey of mediaObjectKeys) {
945
- const resolved = resolveMediaId({
946
- objectKey,
947
- rowNumber,
948
- fieldLabel: "Product media",
949
- mediaById: mediaMaps.byId,
950
- mediaByObjectKey: mediaMaps.byObjectKey,
951
- warnings: summary.warnings,
952
- });
953
- if (resolved) mediaIds.add(resolved);
954
- }
955
-
956
- const variants = rawVariants.map((variant) => {
957
- const mainMediaId = resolveMediaId({
958
- id: variant.main_media_id,
959
- objectKey: variant.main_media_object_key,
960
- rowNumber,
961
- fieldLabel: `Variant media for ${variant.sku}`,
962
- mediaById: mediaMaps.byId,
963
- mediaByObjectKey: mediaMaps.byObjectKey,
964
- warnings: summary.warnings,
965
- });
966
-
967
- return {
968
- ...variant,
969
- main_media_id: mainMediaId,
970
- prices: normalizePriceMapValue(variant.prices),
971
- sale_prices: normalizeSalePriceMapValue(variant.sale_prices),
972
- attribute_term_ids: Array.isArray(variant.attribute_term_ids) ? variant.attribute_term_ids : [],
973
- };
974
- });
975
-
976
- const importedGroupId = normalizeId((row as any).translation_group_id);
977
- const translationGroupId =
978
- action === "update"
979
- ? importedGroupId || target?.translation_group_id || uuidv4()
980
- : importedGroupId
981
- ? translationGroupMap.get(importedGroupId) ?? translationGroupMap.set(importedGroupId, uuidv4()).get(importedGroupId)
982
- : uuidv4();
983
-
984
- const replaceBlocks =
985
- !preserveBlanks ||
986
- rowHasImportValue(row, "description_blocks_json") ||
987
- rowHasImportValue(row, "description_html") ||
988
- Array.isArray((row as BackupProductRecord).description_blocks);
989
- const blocks = replaceBlocks
990
- ? parseBlocksForProduct(row, rowNumber, languageId, summary.errors)
991
- : [];
992
- const meta: Record<string, unknown> = {};
993
- setMetaValue(meta, "language_id", languageId, !preserveField("language_code"));
994
- setMetaValue(
995
- meta,
996
- "translation_group_id",
997
- translationGroupId,
998
- !preserveField("translation_group_id")
999
- );
1000
- setMetaValue(meta, "product_type", productType, updatesProductType);
1001
- setMetaValue(meta, "payment_provider", paymentProvider, updatesPaymentProvider);
1002
- setMetaValue(meta, "title", title, !preserveField("title"));
1003
- setMetaValue(meta, "slug", slug, shouldUpdateSlug);
1004
- setMetaValue(meta, "sku", sku, shouldUpdateSku);
1005
- setMetaValue(meta, "upc", normalizeNullableString((row as any).upc), !preserveField("upc"));
1006
- setMetaValue(meta, "status", status, !preserveField("status"));
1007
- setMetaValue(meta, "price", price, !preserveField("price"));
1008
- setMetaValue(meta, "prices", prices, !preserveField("prices_json"));
1009
- setMetaValue(
1010
- meta,
1011
- "sale_price",
1012
- parseNullableNumber(String((row as any).sale_price ?? "")),
1013
- !preserveField("sale_price")
1014
- );
1015
- setMetaValue(meta, "sale_prices", salePrices, !preserveField("sale_prices_json"));
1016
- setMetaValue(meta, "stock", stock, !preserveField("stock"));
1017
- setMetaValue(
1018
- meta,
1019
- "is_taxable",
1020
- parseBoolean(String((row as any).is_taxable ?? ""), true),
1021
- !preserveField("is_taxable")
1022
- );
1023
- setMetaValue(meta, "meta_title", normalizeNullableString((row as any).meta_title), !preserveField("meta_title"));
1024
- setMetaValue(
1025
- meta,
1026
- "meta_description",
1027
- normalizeNullableString((row as any).meta_description),
1028
- !preserveField("meta_description")
1029
- );
1030
- setMetaValue(
1031
- meta,
1032
- "short_description",
1033
- normalizeNullableString((row as any).short_description),
1034
- !preserveField("short_description")
1035
- );
1036
- setMetaValue(
1037
- meta,
1038
- "description_json",
1039
- descriptionJson ?? null,
1040
- !preserveField("description_json")
1041
- );
1042
- setMetaValue(
1043
- meta,
1044
- "freemius_product_id",
1045
- normalizeNullableString((row as any).freemius_product_id),
1046
- !preserveField("freemius_product_id")
1047
- );
1048
- setMetaValue(
1049
- meta,
1050
- "freemius_plan_id",
1051
- normalizeNullableString((row as any).freemius_plan_id),
1052
- !preserveField("freemius_plan_id")
1053
- );
1054
- setMetaValue(
1055
- meta,
1056
- "trial_period_days",
1057
- parseNumber(String((row as any).trial_period_days ?? ""), 0),
1058
- !preserveField("trial_period_days")
1059
- );
1060
- setMetaValue(
1061
- meta,
1062
- "trial_requires_payment_method",
1063
- parseBoolean(String((row as any).trial_requires_payment_method ?? ""), false),
1064
- !preserveField("trial_requires_payment_method")
1065
- );
1066
- setMetaValue(meta, "product_media", Array.from(mediaIds).map((media_id) => ({ media_id })), replaceMedia);
1067
- setMetaValue(meta, "category_ids", categoryIds, syncCategories);
1068
- setMetaValue(meta, "variants", variants, replaceVariants);
1069
-
1070
- prepared.push({
1071
- contentType: "products",
1072
- rowNumber,
1073
- action,
1074
- targetId: target?.id ?? null,
1075
- meta,
1076
- blocks,
1077
- categoryIds,
1078
- mediaIds: Array.from(mediaIds),
1079
- variants,
1080
- replaceBlocks,
1081
- replaceMedia,
1082
- replaceVariants,
1083
- syncCategories,
1084
- oldSlug: target?.slug ?? null,
1085
- });
1086
- }
1087
-
1088
- createPreview(prepared, summary);
1089
- summary.success = summary.errors.length === 0;
1090
- return { summary, prepared };
1091
- }
1092
-
1093
- function buildBlockPayload(block: BackupBlockRecord, parent: { type: CmsContentType; id: number | string }, index: number) {
1094
- return {
1095
- page_id: parent.type === "pages" ? parent.id : null,
1096
- post_id: parent.type === "posts" ? parent.id : null,
1097
- product_id: parent.type === "products" ? parent.id : null,
1098
- language_id: block.language_id,
1099
- block_type: block.block_type,
1100
- content: block.content,
1101
- order: typeof block.order === "number" && Number.isFinite(block.order) ? block.order : index,
1102
- };
1103
- }
1104
-
1105
- async function replaceBlocks(
1106
- supabase: SupabaseAny,
1107
- contentType: CmsContentType,
1108
- parentId: number | string,
1109
- blocks: BackupBlockRecord[]
1110
- ) {
1111
- const column = contentType === "pages" ? "page_id" : contentType === "posts" ? "post_id" : "product_id";
1112
- const { error: deleteError } = await supabase.from("blocks").delete().eq(column, parentId as any);
1113
- if (deleteError) {
1114
- throw new Error(`Failed to clear ${contentType} blocks: ${deleteError.message}`);
1115
- }
1116
-
1117
- if (blocks.length === 0) return;
1118
-
1119
- const payload = blocks.map((block, index) => buildBlockPayload(block, { type: contentType, id: parentId }, index));
1120
- const { error: insertError } = await supabase.from("blocks").insert(payload as any);
1121
- if (insertError) {
1122
- throw new Error(`Failed to insert ${contentType} blocks: ${insertError.message}`);
1123
- }
1124
- }
1125
-
1126
- async function applyContentImport(params: {
1127
- auth: TransferAuth;
1128
- item: PreparedContentImport;
1129
- options: CmsImportOptions;
1130
- }) {
1131
- const { auth, item, options } = params;
1132
- const table = item.contentType === "pages" ? "pages" : "posts";
1133
- const parentType = item.contentType === "pages" ? "page" : "post";
1134
- let parentId = item.targetId;
1135
- let previousContent: FullPageContent | FullPostContent | null = null;
1136
-
1137
- if (options.applyMode === "live" && parentId) {
1138
- previousContent =
1139
- item.contentType === "pages"
1140
- ? await getFullPageContent(parentId)
1141
- : await getFullPostContent(parentId);
1142
- }
1143
-
1144
- if (!parentId) {
1145
- const insertMeta = {
1146
- ...item.meta,
1147
- status: options.applyMode === "draft" ? "draft" : item.meta.status,
1148
- author_id: auth.userId,
1149
- };
1150
- const { data, error } = await auth.supabase
1151
- .from(table)
1152
- .insert(insertMeta as any)
1153
- .select("id")
1154
- .single();
1155
-
1156
- if (error || !data) {
1157
- throw new Error(`Failed to create ${item.contentType.slice(0, -1)} from row ${item.rowNumber}: ${error?.message ?? "unknown error"}`);
1158
- }
1159
-
1160
- parentId = Number((data as any).id);
1161
- }
1162
-
1163
- if (options.applyMode === "draft") {
1164
- const { error } = await (auth.supabase as any)
1165
- .from("content_drafts")
1166
- .upsert(
1167
- {
1168
- parent_type: parentType,
1169
- parent_id: parentId,
1170
- author_id: auth.userId,
1171
- meta: item.meta,
1172
- blocks: item.blocks,
1173
- updated_at: new Date().toISOString(),
1174
- },
1175
- { onConflict: "parent_type,parent_id" }
1176
- );
1177
-
1178
- if (error) {
1179
- throw new Error(`Failed to save draft from row ${item.rowNumber}: ${error.message}`);
1180
- }
1181
- } else {
1182
- if (Object.keys(item.meta).length > 0) {
1183
- const { error } = await auth.supabase
1184
- .from(table)
1185
- .update(item.meta as any)
1186
- .eq("id", parentId);
1187
-
1188
- if (error) {
1189
- throw new Error(`Failed to update ${item.contentType.slice(0, -1)} from row ${item.rowNumber}: ${error.message}`);
1190
- }
1191
- }
1192
-
1193
- if (item.replaceBlocks) {
1194
- await replaceBlocks(auth.supabase, item.contentType, parentId, item.blocks);
1195
- }
1196
- await (auth.supabase as any)
1197
- .from("content_drafts")
1198
- .delete()
1199
- .eq("parent_type", parentType)
1200
- .eq("parent_id", parentId);
1201
-
1202
- if (previousContent) {
1203
- const nextContent =
1204
- item.contentType === "pages"
1205
- ? await getFullPageContent(parentId)
1206
- : await getFullPostContent(parentId);
1207
- if (nextContent) {
1208
- if (item.contentType === "pages") {
1209
- await createPageRevision(parentId, auth.userId, previousContent as FullPageContent, nextContent as FullPageContent);
1210
- } else {
1211
- await createPostRevision(parentId, auth.userId, previousContent as FullPostContent, nextContent as FullPostContent);
1212
- }
1213
- }
1214
- }
1215
- }
1216
-
1217
- revalidatePath(item.contentType === "pages" ? "/cms/pages" : "/cms/posts");
1218
- const slug = typeof item.meta.slug === "string" ? item.meta.slug : null;
1219
- if (item.contentType === "pages") {
1220
- if (slug) revalidatePath(`/${slug}`);
1221
- if (item.oldSlug && item.oldSlug !== slug) revalidatePath(`/${item.oldSlug}`);
1222
- } else {
1223
- revalidatePath("/articles");
1224
- if (slug) revalidatePath(`/article/${slug}`);
1225
- if (item.oldSlug && item.oldSlug !== slug) revalidatePath(`/article/${item.oldSlug}`);
1226
- }
1227
- }
1228
-
1229
- function toLiveProductPayload(meta: Record<string, unknown>) {
1230
- const payload: Record<string, unknown> = {
1231
- updated_at: new Date().toISOString(),
1232
- };
1233
- const hasKey = (key: string) => Object.prototype.hasOwnProperty.call(meta, key);
1234
- const copy = (key: string) => {
1235
- if (hasKey(key)) payload[key] = meta[key];
1236
- };
1237
-
1238
- copy("language_id");
1239
- copy("translation_group_id");
1240
- copy("product_type");
1241
- copy("payment_provider");
1242
- copy("title");
1243
- copy("slug");
1244
- copy("sku");
1245
- copy("upc");
1246
- copy("status");
1247
- if (hasKey("price")) payload.price = majorToMinor(meta.price as number) ?? 0;
1248
- if (hasKey("prices")) payload.prices = priceMapMajorToMinor(meta.prices as Record<string, number>);
1249
- if (hasKey("sale_price")) payload.sale_price = majorToMinor(meta.sale_price as number | null);
1250
- if (hasKey("sale_prices")) {
1251
- payload.sale_prices = salePriceMapMajorToMinor(meta.sale_prices as Record<string, number | null>);
1252
- }
1253
- copy("stock");
1254
- copy("is_taxable");
1255
- copy("meta_title");
1256
- copy("meta_description");
1257
- copy("short_description");
1258
- copy("description_json");
1259
- copy("freemius_product_id");
1260
- copy("freemius_plan_id");
1261
- copy("trial_period_days");
1262
- copy("trial_requires_payment_method");
1263
-
1264
- return payload;
1265
- }
1266
-
1267
- async function replaceProductMedia(supabase: SupabaseAny, productId: string, mediaIds: string[]) {
1268
- const { error: deleteError } = await supabase
1269
- .from("product_media")
1270
- .delete()
1271
- .eq("product_id", productId);
1272
- if (deleteError) {
1273
- throw new Error(`Failed to clear product media: ${deleteError.message}`);
1274
- }
1275
-
1276
- if (mediaIds.length === 0) return;
1277
-
1278
- const { error } = await supabase.from("product_media").insert(
1279
- mediaIds.map((mediaId, index) => ({
1280
- product_id: productId,
1281
- media_id: mediaId,
1282
- sort_order: index,
1283
- })) as any
1284
- );
1285
- if (error) {
1286
- throw new Error(`Failed to insert product media: ${error.message}`);
1287
- }
1288
- }
1289
-
1290
- async function replaceProductVariants(
1291
- supabase: SupabaseAny,
1292
- productId: string,
1293
- variants: BackupProductVariantRecord[]
1294
- ) {
1295
- const { error: deleteError } = await supabase
1296
- .from("product_variants")
1297
- .delete()
1298
- .eq("product_id", productId);
1299
- if (deleteError) {
1300
- throw new Error(`Failed to clear product variants: ${deleteError.message}`);
1301
- }
1302
-
1303
- for (const variant of variants) {
1304
- const { data, error } = await supabase
1305
- .from("product_variants")
1306
- .insert({
1307
- product_id: productId,
1308
- sku: variant.sku,
1309
- upc: variant.upc ?? null,
1310
- price: majorToMinor(variant.price) ?? 0,
1311
- prices: priceMapMajorToMinor(variant.prices),
1312
- sale_price: majorToMinor(variant.sale_price ?? null),
1313
- sale_prices: salePriceMapMajorToMinor(variant.sale_prices),
1314
- stock_quantity: variant.stock_quantity ?? 0,
1315
- main_media_id: variant.main_media_id ?? null,
1316
- } as any)
1317
- .select("id")
1318
- .single();
1319
-
1320
- if (error || !data) {
1321
- throw new Error(`Failed to insert product variant "${variant.sku}": ${error?.message ?? "unknown error"}`);
1322
- }
1323
-
1324
- const termIds = Array.isArray(variant.attribute_term_ids) ? variant.attribute_term_ids : [];
1325
- if (termIds.length > 0) {
1326
- const { error: mappingError } = await supabase
1327
- .from("variant_attribute_mapping")
1328
- .insert(
1329
- termIds.map((termId) => ({
1330
- variant_id: (data as any).id,
1331
- attribute_term_id: termId,
1332
- })) as any
1333
- );
1334
-
1335
- if (mappingError) {
1336
- throw new Error(`Failed to insert variant attributes for "${variant.sku}": ${mappingError.message}`);
1337
- }
1338
- }
1339
- }
1340
- }
1341
-
1342
- async function applyProductImport(params: {
1343
- auth: TransferAuth;
1344
- item: PreparedProductImport;
1345
- options: CmsImportOptions;
1346
- }) {
1347
- const { auth, item, options } = params;
1348
- let productId = item.targetId;
1349
-
1350
- if (!productId) {
1351
- const payload = {
1352
- ...toLiveProductPayload(item.meta),
1353
- status: options.applyMode === "draft" ? "draft" : item.meta.status,
1354
- metadata: {},
1355
- created_at: new Date().toISOString(),
1356
- };
1357
- const { data, error } = await auth.supabase
1358
- .from("products")
1359
- .insert(payload as any)
1360
- .select("id")
1361
- .single();
1362
-
1363
- if (error || !data) {
1364
- throw new Error(`Failed to create product from row ${item.rowNumber}: ${error?.message ?? "unknown error"}`);
1365
- }
1366
-
1367
- productId = String((data as any).id);
1368
- }
1369
-
1370
- if (options.applyMode === "draft") {
1371
- const { error } = await (auth.supabase as any)
1372
- .from("product_drafts")
1373
- .upsert(
1374
- {
1375
- product_id: productId,
1376
- author_id: auth.userId,
1377
- meta: item.meta,
1378
- blocks: item.blocks,
1379
- updated_at: new Date().toISOString(),
1380
- },
1381
- { onConflict: "product_id" }
1382
- );
1383
-
1384
- if (error) {
1385
- throw new Error(`Failed to save product draft from row ${item.rowNumber}: ${error.message}`);
1386
- }
1387
- } else {
1388
- const { error } = await auth.supabase
1389
- .from("products")
1390
- .update(toLiveProductPayload(item.meta) as any)
1391
- .eq("id", productId);
1392
-
1393
- if (error) {
1394
- throw new Error(`Failed to update product from row ${item.rowNumber}: ${error.message}`);
1395
- }
1396
-
1397
- if (item.replaceBlocks) {
1398
- await replaceBlocks(auth.supabase, "products", productId, item.blocks);
1399
- }
1400
- if (item.replaceMedia) {
1401
- await replaceProductMedia(auth.supabase, productId, item.mediaIds);
1402
- }
1403
- if (item.replaceVariants) {
1404
- await replaceProductVariants(auth.supabase, productId, item.variants);
1405
- }
1406
- if (item.syncCategories) {
1407
- await syncCategoriesForTranslationGroup(auth.supabase as any, productId, item.categoryIds);
1408
- }
1409
- await (auth.supabase as any).from("product_drafts").delete().eq("product_id", productId);
1410
- }
1411
-
1412
- revalidatePath("/cms/products");
1413
- const slug = typeof item.meta.slug === "string" ? item.meta.slug : null;
1414
- if (slug) revalidatePath(`/product/${slug}`);
1415
- if (item.oldSlug && item.oldSlug !== slug) revalidatePath(`/product/${item.oldSlug}`);
1416
- }
1417
-
1418
- async function applyPreparedImports(
1419
- auth: TransferAuth,
1420
- prepared: PreparedImport[],
1421
- options: CmsImportOptions
1422
- ) {
1423
- for (const item of prepared) {
1424
- if (item.contentType === "products") {
1425
- await applyProductImport({ auth, item, options });
1426
- } else {
1427
- await applyContentImport({ auth, item, options });
1428
- }
1429
- }
1430
- }
1431
-
1432
- async function prepareImports(params: {
1433
- supabase: SupabaseAny;
1434
- contentType: CmsContentType;
1435
- rows: ImportSourceRow[];
1436
- options: CmsImportOptions;
1437
- }) {
1438
- if (params.options.ignoreBlankFields) {
1439
- const summary = emptySummary();
1440
- summary.totalRows = params.rows.length;
1441
- if (params.options.conflictMode !== "overwrite_existing") {
1442
- addMessage(summary.errors, 0, "Ignore blank fields can only be used with overwrite imports.");
1443
- }
1444
- if (params.options.applyMode !== "live") {
1445
- addMessage(summary.errors, 0, "Ignore blank fields can only be used with live imports.");
1446
- }
1447
- if (summary.errors.length > 0) {
1448
- summary.success = false;
1449
- return { summary, prepared: [] as PreparedImport[] };
1450
- }
1451
- }
1452
-
1453
- if (params.contentType === "products") {
1454
- if (params.rows.length === 0) {
1455
- const summary = emptySummary();
1456
- summary.totalRows = 0;
1457
- return { summary, prepared: [] as PreparedImport[] };
1458
- }
1459
-
1460
- const ecommerceAvailable = await isEcommerceContentAvailable(params.supabase);
1461
- if (!ecommerceAvailable) {
1462
- const summary = emptySummary();
1463
- summary.totalRows = params.rows.length;
1464
- if (params.options.skipUnavailableProductContent) {
1465
- summary.skipped = params.rows.length;
1466
- addMessage(
1467
- summary.warnings,
1468
- 0,
1469
- "Products were skipped because the ecommerce package is not active.",
1470
- "warning"
1471
- );
1472
- } else {
1473
- addMessage(summary.errors, 0, "The ecommerce package must be active to import products.");
1474
- }
1475
- summary.success = summary.errors.length === 0;
1476
- return { summary, prepared: [] as PreparedImport[] };
1477
- }
1478
-
1479
- return prepareProductImports({
1480
- supabase: params.supabase,
1481
- rows: params.rows,
1482
- options: params.options,
1483
- });
1484
- }
1485
-
1486
- return prepareContentImports({
1487
- supabase: params.supabase,
1488
- contentType: params.contentType,
1489
- rows: params.rows,
1490
- options: params.options,
1491
- });
1492
- }
1493
-
1494
- export function buildCsvTemplate(contentType: CmsContentType) {
1495
- return getTemplateCsv(contentType);
1496
- }
1497
-
1498
- export async function dryRunCsvImport(params: {
1499
- contentType: CmsContentType;
1500
- csv: string;
1501
- options: CmsImportOptions;
1502
- }) {
1503
- const auth = await requireTransferUser(false);
1504
- const parsed = parseCsv(params.csv);
1505
- const { summary } = await prepareImports({
1506
- supabase: auth.supabase,
1507
- contentType: params.contentType,
1508
- rows: parsed.rows,
1509
- options: params.options,
1510
- });
1511
-
1512
- for (const parseError of parsed.errors) {
1513
- addMessage(summary.errors, parseError.row, parseError.message);
1514
- }
1515
- summary.success = summary.errors.length === 0;
1516
- return summary;
1517
- }
1518
-
1519
- export async function applyCsvImport(params: {
1520
- contentType: CmsContentType;
1521
- csv: string;
1522
- options: CmsImportOptions;
1523
- }) {
1524
- const auth = await requireTransferUser(false);
1525
- const parsed = parseCsv(params.csv);
1526
- const { summary, prepared } = await prepareImports({
1527
- supabase: auth.supabase,
1528
- contentType: params.contentType,
1529
- rows: parsed.rows,
1530
- options: params.options,
1531
- });
1532
-
1533
- for (const parseError of parsed.errors) {
1534
- addMessage(summary.errors, parseError.row, parseError.message);
1535
- }
1536
- summary.success = summary.errors.length === 0;
1537
- if (!summary.success) {
1538
- return summary;
1539
- }
1540
-
1541
- await applyPreparedImports(auth, prepared, params.options);
1542
- return summary;
1543
- }
1544
-
1545
- async function fetchBlocksByParent(supabase: SupabaseAny, parentColumn: "page_id" | "post_id" | "product_id", ids: Array<number | string>) {
1546
- if (ids.length === 0) {
1547
- return new Map<string, BackupBlockRecord[]>();
1548
- }
1549
-
1550
- const { data, error } = await supabase
1551
- .from("blocks")
1552
- .select("id, page_id, post_id, product_id, language_id, block_type, content, order")
1553
- .in(parentColumn, ids as any)
1554
- .order("order", { ascending: true });
1555
-
1556
- if (error) {
1557
- throw new Error(`Failed to load blocks: ${error.message}`);
1558
- }
1559
-
1560
- const map = new Map<string, BackupBlockRecord[]>();
1561
- for (const block of data || []) {
1562
- const parentId = String((block as any)[parentColumn]);
1563
- const list = map.get(parentId) ?? [];
1564
- list.push({
1565
- language_id: (block as any).language_id,
1566
- block_type: (block as any).block_type,
1567
- content: (block as any).content,
1568
- order: (block as any).order,
1569
- });
1570
- map.set(parentId, list);
1571
- }
1572
-
1573
- return map;
1574
- }
1575
-
1576
- async function exportPages(supabase: SupabaseAny, languageId?: number) {
1577
- let query = supabase
1578
- .from("pages")
1579
- .select("*, languages(code), media(object_key)")
1580
- .order("created_at", { ascending: false });
1581
-
1582
- if (languageId) query = query.eq("language_id", languageId);
1583
-
1584
- const { data, error } = await query;
1585
- if (error) throw new Error(`Failed to export pages: ${error.message}`);
1586
-
1587
- const pages = data || [];
1588
- const blockMap = await fetchBlocksByParent(
1589
- supabase,
1590
- "page_id",
1591
- pages.map((page: any) => page.id)
1592
- );
1593
-
1594
- return pages.map((page: any): BackupPageRecord => ({
1595
- id: page.id,
1596
- translation_group_id: page.translation_group_id,
1597
- language_code: page.languages?.code || "",
1598
- title: page.title,
1599
- slug: page.slug,
1600
- status: page.status,
1601
- meta_title: page.meta_title,
1602
- meta_description: page.meta_description,
1603
- feature_image_id: page.feature_image_id,
1604
- feature_image_object_key: page.media?.object_key ?? null,
1605
- blocks: blockMap.get(String(page.id)) || [],
1606
- }));
1607
- }
1608
-
1609
- async function exportPosts(supabase: SupabaseAny, languageId?: number) {
1610
- let query = supabase
1611
- .from("posts")
1612
- .select("*, languages(code), media(object_key)")
1613
- .order("created_at", { ascending: false });
1614
-
1615
- if (languageId) query = query.eq("language_id", languageId);
1616
-
1617
- const { data, error } = await query;
1618
- if (error) throw new Error(`Failed to export posts: ${error.message}`);
1619
-
1620
- const posts = data || [];
1621
- const blockMap = await fetchBlocksByParent(
1622
- supabase,
1623
- "post_id",
1624
- posts.map((post: any) => post.id)
1625
- );
1626
-
1627
- return posts.map((post: any): BackupPostRecord => ({
1628
- id: post.id,
1629
- translation_group_id: post.translation_group_id,
1630
- language_code: post.languages?.code || "",
1631
- title: post.title,
1632
- slug: post.slug,
1633
- status: post.status,
1634
- meta_title: post.meta_title,
1635
- meta_description: post.meta_description,
1636
- feature_image_id: post.feature_image_id,
1637
- feature_image_object_key: post.media?.object_key ?? null,
1638
- label: post.label,
1639
- excerpt: post.excerpt,
1640
- subtitle: post.subtitle,
1641
- published_at: post.published_at,
1642
- blocks: blockMap.get(String(post.id)) || [],
1643
- }));
1644
- }
1645
-
1646
- async function exportProducts(supabase: SupabaseAny, languageId?: number) {
1647
- const ecommerceAvailable = await isEcommerceContentAvailable(supabase);
1648
- if (!ecommerceAvailable) {
1649
- return [];
1650
- }
1651
-
1652
- let query = supabase
1653
- .from("products")
1654
- .select("*, languages(code)")
1655
- .order("created_at", { ascending: false });
1656
-
1657
- if (languageId) query = query.eq("language_id", languageId);
1658
-
1659
- const { data, error } = await query;
1660
- if (error) throw new Error(`Failed to export products: ${error.message}`);
1661
-
1662
- const products = data || [];
1663
- if (products.length === 0) {
1664
- return [];
1665
- }
1666
-
1667
- const productIds = products.map((product: any) => String(product.id));
1668
- const blockMap = await fetchBlocksByParent(supabase, "product_id", productIds);
1669
-
1670
- const [mediaResult, categoryResult, variantResult] = await Promise.all([
1671
- supabase
1672
- .from("product_media")
1673
- .select("product_id, media_id, sort_order, media(object_key)")
1674
- .in("product_id", productIds),
1675
- supabase
1676
- .from("product_categories" as any)
1677
- .select("product_id, category:categories(slug)")
1678
- .in("product_id", productIds),
1679
- supabase
1680
- .from("product_variants")
1681
- .select("id, product_id, sku, upc, price, prices, sale_price, sale_prices, stock_quantity, main_media_id, media:main_media_id(object_key), variant_attribute_mapping(attribute_term_id)")
1682
- .in("product_id", productIds),
1683
- ]);
1684
-
1685
- if (mediaResult.error) throw new Error(`Failed to export product media: ${mediaResult.error.message}`);
1686
- if (categoryResult.error) throw new Error(`Failed to export product categories: ${categoryResult.error.message}`);
1687
- if (variantResult.error) throw new Error(`Failed to export product variants: ${variantResult.error.message}`);
1688
-
1689
- const mediaMap = new Map<string, Array<{ id: string; objectKey: string | null; sortOrder: number }>>();
1690
- for (const media of mediaResult.data || []) {
1691
- const productId = String((media as any).product_id);
1692
- const list = mediaMap.get(productId) ?? [];
1693
- list.push({
1694
- id: String((media as any).media_id),
1695
- objectKey: (media as any).media?.object_key ?? null,
1696
- sortOrder: Number((media as any).sort_order ?? 0),
1697
- });
1698
- mediaMap.set(productId, list);
1699
- }
1700
-
1701
- const categoryMap = new Map<string, string[]>();
1702
- for (const categoryRow of categoryResult.data || []) {
1703
- const productId = String((categoryRow as any).product_id);
1704
- const list = categoryMap.get(productId) ?? [];
1705
- if ((categoryRow as any).category?.slug) list.push((categoryRow as any).category.slug);
1706
- categoryMap.set(productId, list);
1707
- }
1708
-
1709
- const variantMap = new Map<string, BackupProductVariantRecord[]>();
1710
- for (const variant of variantResult.data || []) {
1711
- const productId = String((variant as any).product_id);
1712
- const list = variantMap.get(productId) ?? [];
1713
- list.push({
1714
- id: (variant as any).id,
1715
- sku: (variant as any).sku,
1716
- upc: (variant as any).upc,
1717
- price: minorToMajor((variant as any).price) ?? 0,
1718
- prices: priceMapMinorToMajor((variant as any).prices),
1719
- sale_price: minorToMajor((variant as any).sale_price),
1720
- sale_prices: salePriceMapMinorToMajor((variant as any).sale_prices),
1721
- stock_quantity: Number((variant as any).stock_quantity ?? 0),
1722
- main_media_id: (variant as any).main_media_id,
1723
- main_media_object_key: (variant as any).media?.object_key ?? null,
1724
- attribute_term_ids: ((variant as any).variant_attribute_mapping || [])
1725
- .map((mapping: any) => mapping.attribute_term_id)
1726
- .filter(Boolean),
1727
- });
1728
- variantMap.set(productId, list);
1729
- }
1730
-
1731
- return products.map((product: any): BackupProductRecord => {
1732
- const media = (mediaMap.get(String(product.id)) || []).sort((a, b) => a.sortOrder - b.sortOrder);
1733
- return {
1734
- id: product.id,
1735
- translation_group_id: product.translation_group_id,
1736
- language_code: product.languages?.code || "",
1737
- product_type: product.product_type,
1738
- payment_provider: product.payment_provider,
1739
- title: product.title,
1740
- slug: product.slug,
1741
- sku: product.sku,
1742
- upc: product.upc,
1743
- status: product.status,
1744
- price: minorToMajor(product.price) ?? 0,
1745
- prices: priceMapMinorToMajor(product.prices),
1746
- sale_price: minorToMajor(product.sale_price),
1747
- sale_prices: salePriceMapMinorToMajor(product.sale_prices),
1748
- stock: Number(product.stock ?? 0),
1749
- is_taxable: Boolean(product.is_taxable),
1750
- meta_title: product.meta_title,
1751
- meta_description: product.meta_description,
1752
- short_description: product.short_description,
1753
- description_json: product.description_json,
1754
- description_blocks: blockMap.get(String(product.id)) || [],
1755
- category_slugs: categoryMap.get(String(product.id)) || [],
1756
- media_ids: media.map((item) => item.id),
1757
- media_object_keys: media.map((item) => item.objectKey).filter(Boolean) as string[],
1758
- variants: variantMap.get(String(product.id)) || [],
1759
- freemius_product_id: product.freemius_product_id,
1760
- freemius_plan_id: product.freemius_plan_id,
1761
- trial_period_days: Number(product.trial_period_days ?? 0),
1762
- trial_requires_payment_method: Boolean(product.trial_requires_payment_method),
1763
- };
1764
- });
1765
- }
1766
-
1767
- function pageToCsvRow(page: BackupPageRecord) {
1768
- const firstText = page.blocks.find((block) => block.block_type === "text" && isRecord(block.content));
1769
- return {
1770
- ...page,
1771
- content_html: isRecord(firstText?.content) ? String(firstText?.content.html_content ?? "") : "",
1772
- blocks_json: toJsonCell(page.blocks),
1773
- };
1774
- }
1775
-
1776
- function postToCsvRow(post: BackupPostRecord) {
1777
- return {
1778
- ...pageToCsvRow(post),
1779
- label: post.label ?? "",
1780
- excerpt: post.excerpt ?? "",
1781
- subtitle: post.subtitle ?? "",
1782
- published_at: post.published_at ?? "",
1783
- };
1784
- }
1785
-
1786
- function productToCsvRow(product: BackupProductRecord) {
1787
- const firstText = product.description_blocks.find((block) => block.block_type === "text" && isRecord(block.content));
1788
- return {
1789
- id: product.id ?? "",
1790
- translation_group_id: product.translation_group_id ?? "",
1791
- language_code: product.language_code,
1792
- product_type: product.product_type,
1793
- payment_provider: product.payment_provider,
1794
- title: product.title,
1795
- slug: product.slug,
1796
- sku: product.sku,
1797
- upc: product.upc ?? "",
1798
- status: product.status,
1799
- price: product.price,
1800
- prices_json: toJsonCell(product.prices),
1801
- sale_price: product.sale_price ?? "",
1802
- sale_prices_json: toJsonCell(product.sale_prices),
1803
- stock: product.stock,
1804
- is_taxable: String(product.is_taxable),
1805
- meta_title: product.meta_title ?? "",
1806
- meta_description: product.meta_description ?? "",
1807
- short_description: product.short_description ?? "",
1808
- description_html: isRecord(firstText?.content) ? String(firstText?.content.html_content ?? "") : "",
1809
- description_blocks_json: toJsonCell(product.description_blocks),
1810
- description_json: toJsonCell(product.description_json),
1811
- category_slugs: (product.category_slugs || []).join(";"),
1812
- media_ids: (product.media_ids || []).join(";"),
1813
- media_object_keys: (product.media_object_keys || []).join(";"),
1814
- variants_json: toJsonCell(product.variants),
1815
- freemius_product_id: product.freemius_product_id ?? "",
1816
- freemius_plan_id: product.freemius_plan_id ?? "",
1817
- trial_period_days: product.trial_period_days ?? 0,
1818
- trial_requires_payment_method: String(Boolean(product.trial_requires_payment_method)),
1819
- };
1820
- }
1821
-
1822
- export async function exportCsv(params: {
1823
- contentType: CmsContentType;
1824
- languageId?: number;
1825
- }) {
1826
- const auth = await requireTransferUser(false);
1827
- const records =
1828
- params.contentType === "pages"
1829
- ? await exportPages(auth.supabase, params.languageId)
1830
- : params.contentType === "posts"
1831
- ? await exportPosts(auth.supabase, params.languageId)
1832
- : await exportProducts(auth.supabase, params.languageId);
1833
-
1834
- const rows =
1835
- params.contentType === "pages"
1836
- ? (records as BackupPageRecord[]).map(pageToCsvRow)
1837
- : params.contentType === "posts"
1838
- ? (records as BackupPostRecord[]).map(postToCsvRow)
1839
- : (records as BackupProductRecord[]).map(productToCsvRow);
1840
-
1841
- return stringifyCsv(rows, getCsvHeaders(params.contentType));
1842
- }
1843
-
1844
- export async function exportBackupBundle() {
1845
- const auth = await requireTransferUser(true);
1846
- const { languages } = await loadLanguages(auth.supabase);
1847
- const bundle: CmsBackupBundleV1 = {
1848
- version: 1,
1849
- exported_at: new Date().toISOString(),
1850
- languages,
1851
- content: {
1852
- pages: await exportPages(auth.supabase),
1853
- posts: await exportPosts(auth.supabase),
1854
- products: await exportProducts(auth.supabase),
1855
- },
1856
- custom_blocks: await exportCustomBlockRecords(auth.supabase),
1857
- };
1858
-
1859
- return JSON.stringify(bundle, null, 2);
1860
- }
1861
-
1862
- function parseBundle(content: string): CmsBackupBundleV1 {
1863
- const parsed = JSON.parse(content) as CmsBackupBundleV1;
1864
- if (!parsed || parsed.version !== 1 || !isRecord(parsed.content)) {
1865
- throw new Error("Backup bundle must be a NextBlock content backup with version 1.");
1866
- }
1867
-
1868
- return parsed;
1869
- }
1870
-
1871
- export async function dryRunBundleImport(params: {
1872
- bundleJson: string;
1873
- contentTypes: CmsContentType[];
1874
- options: CmsImportOptions;
1875
- includeBlocks?: boolean;
1876
- }) {
1877
- const auth = await requireTransferUser(true);
1878
- const bundle = parseBundle(params.bundleJson);
1879
- const summary = emptySummary();
1880
-
1881
- for (const contentType of params.contentTypes) {
1882
- const rows = bundle.content[contentType] || [];
1883
- const result = await prepareImports({
1884
- supabase: auth.supabase,
1885
- contentType,
1886
- rows: rows as ImportSourceRow[],
1887
- options: { ...params.options, skipUnavailableProductContent: true },
1888
- });
1889
-
1890
- mergeBundleSummary(summary, result.summary);
1891
- }
1892
-
1893
- if (params.includeBlocks) {
1894
- const blockResult = await prepareBlocksLibraryImport({
1895
- supabase: auth.supabase,
1896
- records: Array.isArray(bundle.custom_blocks) ? bundle.custom_blocks : [],
1897
- options: { conflictMode: params.options.conflictMode },
1898
- });
1899
- mergeBundleSummary(summary, blockResult.summary);
1900
- }
1901
-
1902
- summary.success = summary.errors.length === 0;
1903
- return summary;
1904
- }
1905
-
1906
- export async function applyBundleImport(params: {
1907
- bundleJson: string;
1908
- contentTypes: CmsContentType[];
1909
- options: CmsImportOptions;
1910
- includeBlocks?: boolean;
1911
- }) {
1912
- const auth = await requireTransferUser(true);
1913
- const bundle = parseBundle(params.bundleJson);
1914
- const summary = emptySummary();
1915
- const allPrepared: PreparedImport[] = [];
1916
- let preparedBlocks: PreparedBlockImport[] = [];
1917
-
1918
- for (const contentType of params.contentTypes) {
1919
- const rows = bundle.content[contentType] || [];
1920
- const result = await prepareImports({
1921
- supabase: auth.supabase,
1922
- contentType,
1923
- rows: rows as ImportSourceRow[],
1924
- options: { ...params.options, skipUnavailableProductContent: true },
1925
- });
1926
-
1927
- mergeBundleSummary(summary, result.summary);
1928
- allPrepared.push(...(result.prepared as PreparedImport[]));
1929
- }
1930
-
1931
- if (params.includeBlocks) {
1932
- const blockResult = await prepareBlocksLibraryImport({
1933
- supabase: auth.supabase,
1934
- records: Array.isArray(bundle.custom_blocks) ? bundle.custom_blocks : [],
1935
- options: { conflictMode: params.options.conflictMode },
1936
- });
1937
- mergeBundleSummary(summary, blockResult.summary);
1938
- preparedBlocks = blockResult.prepared;
1939
- }
1940
-
1941
- summary.success = summary.errors.length === 0;
1942
- if (!summary.success) {
1943
- return summary;
1944
- }
1945
-
1946
- await applyPreparedImports(auth, allPrepared, params.options);
1947
- if (preparedBlocks.length > 0) {
1948
- await applyPreparedBlockImports(auth.supabase, preparedBlocks);
1949
- }
1950
- return summary;
1951
- }
1952
-
1953
- function mergeBundleSummary(target: CmsImportSummary, source: CmsImportSummary) {
1954
- target.totalRows += source.totalRows;
1955
- target.created += source.created;
1956
- target.updated += source.updated;
1957
- target.skipped += source.skipped;
1958
- target.errors.push(...source.errors);
1959
- target.warnings.push(...source.warnings);
1960
- target.preview.push(...source.preview);
1961
- }
1962
-
1963
- // ---------------------------------------------------------------------------
1964
- // Blocks Library (custom block definitions) transfer
1965
- //
1966
- // Custom blocks are nested JSON (fields + layout_schema), so they use the same
1967
- // JSON bundle approach as the content backup rather than CSV. The Blocks Library
1968
- // page exports/imports a dedicated bundle, and the content backup embeds the
1969
- // same records under `custom_blocks`.
1970
- // ---------------------------------------------------------------------------
1971
-
1972
- export const BLOCKS_LIBRARY_BUNDLE_TYPE = "nextblock-blocks-library-backup";
1973
- export const BLOCKS_LIBRARY_BUNDLE_VERSION = 1;
1974
-
1975
- const CUSTOM_BLOCK_DEFINITION_SELECT =
1976
- "id, slug, name, description, fields, layout_schema, is_original";
1977
-
1978
- export interface BlocksLibraryImportOptions {
1979
- conflictMode: CmsImportConflictMode;
1980
- }
1981
-
1982
- interface PreparedBlockImport {
1983
- rowNumber: number;
1984
- action: "create" | "update";
1985
- targetId: string | null;
1986
- slug: string;
1987
- payload: {
1988
- slug: string;
1989
- name: string;
1990
- description: string;
1991
- fields: Json;
1992
- layout_schema: Json;
1993
- is_original: boolean;
1994
- };
1995
- }
1996
-
1997
- function toJson(value: unknown): Json {
1998
- return JSON.parse(JSON.stringify(value)) as Json;
1999
- }
2000
-
2001
- async function exportCustomBlockRecords(supabase: SupabaseAny): Promise<BackupCustomBlockRecord[]> {
2002
- const { data, error } = await supabase
2003
- .from("custom_block_definitions")
2004
- .select(CUSTOM_BLOCK_DEFINITION_SELECT)
2005
- .order("name", { ascending: true });
2006
-
2007
- if (error) {
2008
- throw new Error(`Failed to export custom block definitions: ${error.message}`);
2009
- }
2010
-
2011
- return (data || []).map((row: any) => ({
2012
- slug: row.slug,
2013
- name: row.name,
2014
- description: typeof row.description === "string" ? row.description : "",
2015
- fields: row.fields,
2016
- layout_schema: row.layout_schema,
2017
- is_original: Boolean(row.is_original),
2018
- }));
2019
- }
2020
-
2021
- function extractBlocksLibraryRecords(bundleJson: string): BackupCustomBlockRecord[] {
2022
- let parsed: unknown;
2023
- try {
2024
- parsed = JSON.parse(bundleJson);
2025
- } catch {
2026
- throw new Error("Blocks library file is not valid JSON.");
2027
- }
2028
-
2029
- if (!isRecord(parsed)) {
2030
- throw new Error("Blocks library file must be a JSON object.");
2031
- }
2032
-
2033
- // Accept a dedicated blocks-library bundle (`blocks`) or a full content
2034
- // backup bundle that carries the same records under `custom_blocks`.
2035
- const raw = Array.isArray((parsed as any).blocks)
2036
- ? (parsed as any).blocks
2037
- : Array.isArray((parsed as any).custom_blocks)
2038
- ? (parsed as any).custom_blocks
2039
- : null;
2040
-
2041
- if (!raw) {
2042
- throw new Error('Blocks library file is missing its "blocks" array.');
2043
- }
2044
-
2045
- return raw as BackupCustomBlockRecord[];
2046
- }
2047
-
2048
- async function prepareBlocksLibraryImport(params: {
2049
- supabase: SupabaseAny;
2050
- records: BackupCustomBlockRecord[];
2051
- options: BlocksLibraryImportOptions;
2052
- }): Promise<{ summary: CmsImportSummary; prepared: PreparedBlockImport[] }> {
2053
- const summary = emptySummary();
2054
- summary.totalRows = params.records.length;
2055
- const prepared: PreparedBlockImport[] = [];
2056
-
2057
- const { data, error } = await params.supabase
2058
- .from("custom_block_definitions")
2059
- .select("id, slug");
2060
- if (error) {
2061
- addMessage(summary.errors, 0, `Failed to load existing custom blocks: ${error.message}`);
2062
- summary.success = false;
2063
- return { summary, prepared };
2064
- }
2065
-
2066
- const existingBySlug = new Map<string, string>();
2067
- const knownSlugs = new Set<string>();
2068
- for (const row of data || []) {
2069
- existingBySlug.set(String((row as any).slug), String((row as any).id));
2070
- knownSlugs.add(String((row as any).slug));
2071
- }
2072
- const seenSlugs = new Set<string>();
2073
-
2074
- for (const [index, record] of params.records.entries()) {
2075
- const rowNumber = index + 1;
2076
- if (!isRecord(record)) {
2077
- addMessage(summary.errors, rowNumber, "Block entry must be an object.");
2078
- continue;
2079
- }
2080
-
2081
- const parsedDefinition = customBlockDefinitionCreateSchema.safeParse({
2082
- description: record.description,
2083
- fields: record.fields,
2084
- is_original: record.is_original,
2085
- layout_schema: record.layout_schema,
2086
- name: record.name,
2087
- slug: record.slug,
2088
- });
2089
-
2090
- const label =
2091
- (typeof record.slug === "string" && record.slug) ||
2092
- (typeof record.name === "string" && record.name) ||
2093
- `entry ${rowNumber}`;
2094
-
2095
- if (!parsedDefinition.success) {
2096
- for (const issue of parsedDefinition.error.issues) {
2097
- const path = issue.path.join(".");
2098
- addMessage(
2099
- summary.errors,
2100
- rowNumber,
2101
- `Block "${label}": ${path ? `${path}: ` : ""}${issue.message}`
2102
- );
2103
- }
2104
- continue;
2105
- }
2106
-
2107
- const definition = parsedDefinition.data;
2108
- const existingId = existingBySlug.get(definition.slug) ?? null;
2109
-
2110
- let action: "create" | "update";
2111
- let targetId: string | null;
2112
- let slug = definition.slug;
2113
- let isOriginal = definition.is_original;
2114
-
2115
- if (params.options.conflictMode === "overwrite_existing" && existingId) {
2116
- action = "update";
2117
- targetId = existingId;
2118
- } else {
2119
- action = "create";
2120
- targetId = null;
2121
- // "Create new copies" always clones; "Overwrite" still needs a fresh slug
2122
- // when it collides with an existing block or another row in this import.
2123
- const mustRename =
2124
- params.options.conflictMode === "create_new" ||
2125
- knownSlugs.has(slug) ||
2126
- seenSlugs.has(slug);
2127
- if (mustRename) {
2128
- slug = buildCustomBlockCopySlug(slug, new Set([...knownSlugs, ...seenSlugs]));
2129
- isOriginal = false;
2130
- }
2131
- }
2132
-
2133
- seenSlugs.add(slug);
2134
- knownSlugs.add(slug);
2135
-
2136
- prepared.push({
2137
- rowNumber,
2138
- action,
2139
- targetId,
2140
- slug,
2141
- payload: {
2142
- slug,
2143
- name: definition.name,
2144
- description: definition.description,
2145
- fields: toJson(definition.fields),
2146
- layout_schema: toJson(definition.layout_schema),
2147
- is_original: isOriginal,
2148
- },
2149
- });
2150
- }
2151
-
2152
- summary.created = prepared.filter((item) => item.action === "create").length;
2153
- summary.updated = prepared.filter((item) => item.action === "update").length;
2154
- summary.success = summary.errors.length === 0;
2155
- return { summary, prepared };
2156
- }
2157
-
2158
- async function applyPreparedBlockImports(
2159
- supabase: SupabaseAny,
2160
- prepared: PreparedBlockImport[]
2161
- ): Promise<void> {
2162
- if (prepared.length === 0) return;
2163
- const touched: Array<{ id: string; slug: string }> = [];
2164
-
2165
- for (const item of prepared) {
2166
- const writer =
2167
- item.action === "update" && item.targetId
2168
- ? supabase
2169
- .from("custom_block_definitions")
2170
- .update(item.payload as any)
2171
- .eq("id", item.targetId)
2172
- .select("id, slug")
2173
- .single()
2174
- : supabase
2175
- .from("custom_block_definitions")
2176
- .insert(item.payload as any)
2177
- .select("id, slug")
2178
- .single();
2179
-
2180
- const { data, error } = await writer;
2181
- if (error || !data) {
2182
- throw new Error(
2183
- `Failed to ${item.action} custom block "${item.slug}": ${error?.message ?? "unknown error"}`
2184
- );
2185
- }
2186
- touched.push({ id: String((data as any).id), slug: String((data as any).slug) });
2187
- }
2188
-
2189
- revalidateTag(CUSTOM_BLOCK_DEFINITIONS_CACHE_TAG, "max");
2190
- for (const item of touched) {
2191
- revalidateTag(getCustomBlockDefinitionCacheTag(item.id), "max");
2192
- revalidateTag(getCustomBlockDefinitionCacheTag(item.slug), "max");
2193
- }
2194
- revalidatePath("/cms/blocks");
2195
- revalidatePath("/cms/custom-blocks");
2196
- }
2197
-
2198
- export async function exportBlocksLibraryBundle() {
2199
- const auth = await requireTransferUser(false);
2200
- const blocks = await exportCustomBlockRecords(auth.supabase);
2201
- return JSON.stringify(
2202
- {
2203
- type: BLOCKS_LIBRARY_BUNDLE_TYPE,
2204
- version: BLOCKS_LIBRARY_BUNDLE_VERSION,
2205
- exported_at: new Date().toISOString(),
2206
- blocks,
2207
- },
2208
- null,
2209
- 2
2210
- );
2211
- }
2212
-
2213
- export async function dryRunBlocksLibraryImport(params: {
2214
- bundleJson: string;
2215
- options: BlocksLibraryImportOptions;
2216
- }) {
2217
- const auth = await requireTransferUser(false);
2218
- const records = extractBlocksLibraryRecords(params.bundleJson);
2219
- const { summary } = await prepareBlocksLibraryImport({
2220
- supabase: auth.supabase,
2221
- records,
2222
- options: params.options,
2223
- });
2224
- return summary;
2225
- }
2226
-
2227
- export async function applyBlocksLibraryImport(params: {
2228
- bundleJson: string;
2229
- options: BlocksLibraryImportOptions;
2230
- }) {
2231
- const auth = await requireTransferUser(false);
2232
- const records = extractBlocksLibraryRecords(params.bundleJson);
2233
- const { summary, prepared } = await prepareBlocksLibraryImport({
2234
- supabase: auth.supabase,
2235
- records,
2236
- options: params.options,
2237
- });
2238
- if (!summary.success) {
2239
- return summary;
2240
- }
2241
- await applyPreparedBlockImports(auth.supabase, prepared);
2242
- return summary;
2243
- }
1
+ import "server-only";
2
+
3
+ import { revalidatePath, revalidateTag } from "next/cache";
4
+ import { v4 as uuidv4 } from "uuid";
5
+ import type { Json } from "@nextblock-cms/db";
6
+ import {
7
+ createClient,
8
+ getServiceRoleSupabaseClient,
9
+ verifyPackageOnline,
10
+ } from "@nextblock-cms/db/server";
11
+ import { syncCategoriesForTranslationGroup } from "@nextblock-cms/ecommerce/server";
12
+ import {
13
+ buildCustomBlockCopySlug,
14
+ customBlockDefinitionCreateSchema,
15
+ } from "@nextblock-cms/utils";
16
+
17
+ import {
18
+ CUSTOM_BLOCK_DEFINITIONS_CACHE_TAG,
19
+ getCustomBlockDefinitionCacheTag,
20
+ } from "../custom-block-definitions";
21
+
22
+ import {
23
+ createPageRevision,
24
+ createPostRevision,
25
+ } from "../../app/cms/revisions/service";
26
+ import {
27
+ getFullPageContent,
28
+ getFullPostContent,
29
+ type FullPageContent,
30
+ type FullPostContent,
31
+ } from "../../app/cms/revisions/utils";
32
+ import {
33
+ getCsvHeaders,
34
+ getTemplateCsv,
35
+ majorToMinor,
36
+ makeUniqueSku,
37
+ makeUniqueSlug,
38
+ minorToMajor,
39
+ normalizeBlocksFromFields,
40
+ parseBoolean,
41
+ parseCsv,
42
+ parseJsonField,
43
+ parseNullableNumber,
44
+ parseNumber,
45
+ priceMapMajorToMinor,
46
+ priceMapMinorToMajor,
47
+ salePriceMapMajorToMinor,
48
+ salePriceMapMinorToMajor,
49
+ splitList,
50
+ stringifyCsv,
51
+ toJsonCell,
52
+ } from "./csv";
53
+ import type {
54
+ BackupBlockRecord,
55
+ BackupCustomBlockRecord,
56
+ BackupPageRecord,
57
+ BackupPostRecord,
58
+ BackupProductRecord,
59
+ BackupProductVariantRecord,
60
+ CmsBackupBundleV1,
61
+ CmsContentType,
62
+ CmsImportConflictMode,
63
+ CmsImportMessage,
64
+ CmsImportOptions,
65
+ CmsImportPreviewItem,
66
+ CmsImportSummary,
67
+ } from "./types";
68
+
69
+ type SupabaseAny = ReturnType<typeof getServiceRoleSupabaseClient>;
70
+ type CsvLikeRow = Record<string, string>;
71
+ type ImportSourceRow = CsvLikeRow | BackupPageRecord | BackupPostRecord | BackupProductRecord;
72
+
73
+ const PAGE_STATUSES = new Set(["draft", "published", "archived"]);
74
+ const PRODUCT_STATUSES = new Set(["draft", "active", "archived"]);
75
+ const PRODUCT_TYPES = new Set(["physical", "digital"]);
76
+ const PAYMENT_PROVIDERS = new Set(["stripe", "freemius"]);
77
+
78
+ interface TransferAuth {
79
+ userId: string;
80
+ role: string;
81
+ supabase: SupabaseAny;
82
+ }
83
+
84
+ interface LanguageRecord {
85
+ id: number;
86
+ code: string;
87
+ name: string | null;
88
+ }
89
+
90
+ interface ExistingContentRecord {
91
+ id: number;
92
+ language_id: number;
93
+ slug: string;
94
+ title: string;
95
+ status: string;
96
+ version?: number | null;
97
+ translation_group_id?: string | null;
98
+ }
99
+
100
+ interface ExistingProductRecord {
101
+ id: string;
102
+ language_id: number;
103
+ slug: string;
104
+ sku: string;
105
+ title: string;
106
+ status: string;
107
+ translation_group_id?: string | null;
108
+ }
109
+
110
+ interface PreparedContentImport {
111
+ contentType: "pages" | "posts";
112
+ rowNumber: number;
113
+ action: "create" | "update";
114
+ targetId: number | null;
115
+ meta: Record<string, unknown>;
116
+ blocks: BackupBlockRecord[];
117
+ replaceBlocks: boolean;
118
+ oldSlug?: string | null;
119
+ }
120
+
121
+ interface PreparedProductImport {
122
+ contentType: "products";
123
+ rowNumber: number;
124
+ action: "create" | "update";
125
+ targetId: string | null;
126
+ meta: Record<string, unknown>;
127
+ blocks: BackupBlockRecord[];
128
+ categoryIds: string[];
129
+ mediaIds: string[];
130
+ variants: BackupProductVariantRecord[];
131
+ replaceBlocks: boolean;
132
+ replaceMedia: boolean;
133
+ replaceVariants: boolean;
134
+ syncCategories: boolean;
135
+ oldSlug?: string | null;
136
+ }
137
+
138
+ type PreparedImport = PreparedContentImport | PreparedProductImport;
139
+
140
+ function emptySummary(): CmsImportSummary {
141
+ return {
142
+ success: true,
143
+ totalRows: 0,
144
+ created: 0,
145
+ updated: 0,
146
+ skipped: 0,
147
+ errors: [],
148
+ warnings: [],
149
+ preview: [],
150
+ };
151
+ }
152
+
153
+ function addMessage(
154
+ messages: CmsImportMessage[],
155
+ row: number,
156
+ message: string,
157
+ type: "error" | "warning" = "error"
158
+ ) {
159
+ messages.push({ row, type, message });
160
+ }
161
+
162
+ function normalizeNullableString(value: unknown) {
163
+ const text = typeof value === "string" ? value.trim() : "";
164
+ return text ? text : null;
165
+ }
166
+
167
+ function normalizeRequiredString(value: unknown) {
168
+ return typeof value === "string" ? value.trim() : "";
169
+ }
170
+
171
+ function normalizeId(value: unknown) {
172
+ const text = normalizeRequiredString(value);
173
+ return text || null;
174
+ }
175
+
176
+ function rowHasOwnField(row: ImportSourceRow, fieldName: string) {
177
+ return Object.prototype.hasOwnProperty.call(row, fieldName);
178
+ }
179
+
180
+ function rowHasImportValue(row: ImportSourceRow, fieldName: string) {
181
+ if (!rowHasOwnField(row, fieldName)) return false;
182
+
183
+ const value = (row as Record<string, unknown>)[fieldName];
184
+ if (value === null || value === undefined) return false;
185
+ if (Array.isArray(value)) return value.length > 0;
186
+ if (typeof value === "object") return true;
187
+ return normalizeRequiredString(value) !== "";
188
+ }
189
+
190
+ function canPreserveBlankFields(options: CmsImportOptions, target: unknown) {
191
+ return Boolean(
192
+ options.ignoreBlankFields &&
193
+ options.conflictMode === "overwrite_existing" &&
194
+ options.applyMode === "live" &&
195
+ target
196
+ );
197
+ }
198
+
199
+ function shouldPreserveBlankField(
200
+ row: ImportSourceRow,
201
+ fieldName: string,
202
+ options: CmsImportOptions,
203
+ target: unknown
204
+ ) {
205
+ return canPreserveBlankFields(options, target) && !rowHasImportValue(row, fieldName);
206
+ }
207
+
208
+ function setMetaValue(
209
+ meta: Record<string, unknown>,
210
+ key: string,
211
+ value: unknown,
212
+ shouldSet = true
213
+ ) {
214
+ if (shouldSet) {
215
+ meta[key] = value;
216
+ }
217
+ }
218
+
219
+ function readRequiredNumber(
220
+ value: unknown,
221
+ fieldName: string,
222
+ rowNumber: number,
223
+ errors: CmsImportMessage[]
224
+ ) {
225
+ const raw = typeof value === "number" ? String(value) : normalizeRequiredString(value);
226
+ if (!raw) {
227
+ addMessage(errors, rowNumber, `${fieldName} is required.`);
228
+ return null;
229
+ }
230
+
231
+ const parsed = Number(raw);
232
+ if (!Number.isFinite(parsed)) {
233
+ addMessage(errors, rowNumber, `${fieldName} must be a valid number.`);
234
+ return null;
235
+ }
236
+ if (parsed < 0) {
237
+ addMessage(errors, rowNumber, `${fieldName} must be zero or greater.`);
238
+ return null;
239
+ }
240
+
241
+ return parsed;
242
+ }
243
+
244
+ function readOptionalDate(
245
+ value: unknown,
246
+ fieldName: string,
247
+ rowNumber: number,
248
+ errors: CmsImportMessage[]
249
+ ) {
250
+ const raw = normalizeNullableString(value);
251
+ if (!raw) return null;
252
+
253
+ const date = new Date(raw);
254
+ if (Number.isNaN(date.getTime())) {
255
+ addMessage(errors, rowNumber, `${fieldName} must be a valid date.`);
256
+ return null;
257
+ }
258
+
259
+ return date.toISOString();
260
+ }
261
+
262
+ function isRecord(value: unknown): value is Record<string, unknown> {
263
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
264
+ }
265
+
266
+ async function requireTransferUser(adminOnly = false): Promise<TransferAuth> {
267
+ const authClient = createClient();
268
+ const {
269
+ data: { user },
270
+ } = await authClient.auth.getUser();
271
+
272
+ if (!user) {
273
+ throw new Error("User not authenticated.");
274
+ }
275
+
276
+ const { data: profile, error } = await authClient
277
+ .from("profiles")
278
+ .select("role")
279
+ .eq("id", user.id)
280
+ .single();
281
+
282
+ if (error || !profile) {
283
+ throw new Error("Could not verify CMS permissions.");
284
+ }
285
+
286
+ const role = profile.role;
287
+ const allowed = adminOnly ? role === "ADMIN" : role === "ADMIN" || role === "WRITER";
288
+ if (!allowed) {
289
+ throw new Error(adminOnly ? "Admin role required." : "Writer or admin role required.");
290
+ }
291
+
292
+ return {
293
+ userId: user.id,
294
+ role,
295
+ supabase: getServiceRoleSupabaseClient(),
296
+ };
297
+ }
298
+
299
+ async function loadLanguages(supabase: SupabaseAny) {
300
+ const { data, error } = await supabase
301
+ .from("languages")
302
+ .select("id, code, name")
303
+ .order("id", { ascending: true });
304
+
305
+ if (error) {
306
+ throw new Error(`Failed to load languages: ${error.message}`);
307
+ }
308
+
309
+ const languages = (data || []) as LanguageRecord[];
310
+ const byCode = new Map(languages.map((language) => [language.code.toLowerCase(), language]));
311
+ const byId = new Map(languages.map((language) => [language.id, language]));
312
+
313
+ return { languages, byCode, byId };
314
+ }
315
+
316
+ async function isEcommerceContentAvailable(supabase: SupabaseAny) {
317
+ return verifyPackageOnline("ecommerce", supabase).catch(() => false);
318
+ }
319
+
320
+ async function loadMediaMaps(supabase: SupabaseAny) {
321
+ const { data, error } = await supabase
322
+ .from("media")
323
+ .select("id, object_key");
324
+
325
+ if (error) {
326
+ throw new Error(`Failed to load media references: ${error.message}`);
327
+ }
328
+
329
+ const byId = new Map<string, { id: string; object_key: string | null }>();
330
+ const byObjectKey = new Map<string, { id: string; object_key: string | null }>();
331
+
332
+ for (const media of data || []) {
333
+ const id = String((media as any).id);
334
+ const objectKey = typeof (media as any).object_key === "string" ? (media as any).object_key : null;
335
+ byId.set(id, { id, object_key: objectKey });
336
+ if (objectKey) {
337
+ byObjectKey.set(objectKey, { id, object_key: objectKey });
338
+ }
339
+ }
340
+
341
+ return { byId, byObjectKey };
342
+ }
343
+
344
+ function resolveMediaId(params: {
345
+ id?: string | null;
346
+ objectKey?: string | null;
347
+ rowNumber: number;
348
+ fieldLabel: string;
349
+ mediaById: Map<string, { id: string; object_key: string | null }>;
350
+ mediaByObjectKey: Map<string, { id: string; object_key: string | null }>;
351
+ warnings: CmsImportMessage[];
352
+ }) {
353
+ const id = normalizeId(params.id);
354
+ const objectKey = normalizeId(params.objectKey);
355
+
356
+ if (id) {
357
+ if (params.mediaById.has(id)) {
358
+ return id;
359
+ }
360
+
361
+ addMessage(params.warnings, params.rowNumber, `${params.fieldLabel} "${id}" was not found and will be skipped.`, "warning");
362
+ return null;
363
+ }
364
+
365
+ if (objectKey) {
366
+ const media = params.mediaByObjectKey.get(objectKey);
367
+ if (media) {
368
+ return media.id;
369
+ }
370
+
371
+ addMessage(params.warnings, params.rowNumber, `${params.fieldLabel} object key "${objectKey}" was not found and will be skipped.`, "warning");
372
+ }
373
+
374
+ return null;
375
+ }
376
+
377
+ function getOptionalLanguageId(
378
+ row: ImportSourceRow,
379
+ rowNumber: number,
380
+ languagesByCode: Map<string, LanguageRecord>,
381
+ errors: CmsImportMessage[]
382
+ ) {
383
+ const languageCode = normalizeRequiredString((row as any).language_code).toLowerCase();
384
+ if (!languageCode) return null;
385
+
386
+ const language = languagesByCode.get(languageCode);
387
+ if (!language) {
388
+ addMessage(errors, rowNumber, `Language "${languageCode}" does not exist.`);
389
+ return null;
390
+ }
391
+
392
+ return language.id;
393
+ }
394
+
395
+ function parseBlocksForContent(
396
+ row: ImportSourceRow,
397
+ rowNumber: number,
398
+ languageId: number,
399
+ errors: CmsImportMessage[]
400
+ ) {
401
+ const localErrors: Array<{ row: number; message: string }> = [];
402
+ const backupBlocks = Array.isArray((row as BackupPageRecord).blocks)
403
+ ? (row as BackupPageRecord).blocks
404
+ : undefined;
405
+ const parsedBlocks =
406
+ backupBlocks ??
407
+ parseJsonField<unknown>(
408
+ (row as CsvLikeRow).blocks_json,
409
+ "blocks_json",
410
+ rowNumber,
411
+ localErrors
412
+ );
413
+
414
+ const blocks = normalizeBlocksFromFields({
415
+ blocksJson: parsedBlocks,
416
+ html: (row as CsvLikeRow).content_html,
417
+ languageId,
418
+ rowNumber,
419
+ errors: localErrors,
420
+ });
421
+
422
+ for (const error of localErrors) {
423
+ addMessage(errors, error.row, error.message);
424
+ }
425
+
426
+ return blocks;
427
+ }
428
+
429
+ function parseBlocksForProduct(
430
+ row: ImportSourceRow,
431
+ rowNumber: number,
432
+ languageId: number,
433
+ errors: CmsImportMessage[]
434
+ ) {
435
+ const localErrors: Array<{ row: number; message: string }> = [];
436
+ const backupBlocks = Array.isArray((row as BackupProductRecord).description_blocks)
437
+ ? (row as BackupProductRecord).description_blocks
438
+ : undefined;
439
+ const parsedBlocks =
440
+ backupBlocks ??
441
+ parseJsonField<unknown>(
442
+ (row as CsvLikeRow).description_blocks_json,
443
+ "description_blocks_json",
444
+ rowNumber,
445
+ localErrors
446
+ );
447
+
448
+ const blocks = normalizeBlocksFromFields({
449
+ blocksJson: parsedBlocks,
450
+ html: (row as CsvLikeRow).description_html,
451
+ languageId,
452
+ rowNumber,
453
+ errors: localErrors,
454
+ });
455
+
456
+ for (const error of localErrors) {
457
+ addMessage(errors, error.row, error.message);
458
+ }
459
+
460
+ return blocks;
461
+ }
462
+
463
+ async function loadExistingContent(supabase: SupabaseAny, contentType: "pages" | "posts") {
464
+ const table = contentType === "pages" ? "pages" : "posts";
465
+ const { data, error } = await supabase
466
+ .from(table)
467
+ .select("id, language_id, slug, title, status, version, translation_group_id");
468
+
469
+ if (error) {
470
+ throw new Error(`Failed to load existing ${contentType}: ${error.message}`);
471
+ }
472
+
473
+ const byId = new Map<number, ExistingContentRecord>();
474
+ const bySlug = new Map<string, ExistingContentRecord>();
475
+
476
+ for (const item of (data || []) as ExistingContentRecord[]) {
477
+ byId.set(Number(item.id), item);
478
+ bySlug.set(`${item.language_id}:${item.slug}`, item);
479
+ }
480
+
481
+ return { byId, bySlug };
482
+ }
483
+
484
+ async function loadExistingProducts(supabase: SupabaseAny) {
485
+ const { data, error } = await supabase
486
+ .from("products")
487
+ .select("id, language_id, slug, sku, title, status, translation_group_id");
488
+
489
+ if (error) {
490
+ throw new Error(`Failed to load existing products: ${error.message}`);
491
+ }
492
+
493
+ const byId = new Map<string, ExistingProductRecord>();
494
+ const bySlug = new Map<string, ExistingProductRecord>();
495
+ const bySku = new Map<string, ExistingProductRecord>();
496
+
497
+ for (const item of (data || []) as ExistingProductRecord[]) {
498
+ byId.set(item.id, item);
499
+ bySlug.set(`${item.language_id}:${item.slug}`, item);
500
+ bySku.set(`${item.language_id}:${item.sku}`, item);
501
+ }
502
+
503
+ return { byId, bySlug, bySku };
504
+ }
505
+
506
+ function createPreview(
507
+ prepared: PreparedImport[],
508
+ summary: CmsImportSummary
509
+ ) {
510
+ summary.preview = prepared.map((item) => ({
511
+ row: item.rowNumber,
512
+ action: item.action,
513
+ identifier:
514
+ item.contentType === "products"
515
+ ? String(item.meta.sku || item.meta.slug || item.meta.title || item.targetId || "Product")
516
+ : String(item.meta.slug || item.meta.title || item.targetId || "Content"),
517
+ contentType: item.contentType,
518
+ })) satisfies CmsImportPreviewItem[];
519
+ summary.created = prepared.filter((item) => item.action === "create").length;
520
+ summary.updated = prepared.filter((item) => item.action === "update").length;
521
+ summary.skipped = 0;
522
+ }
523
+
524
+ async function prepareContentImports(params: {
525
+ supabase: SupabaseAny;
526
+ contentType: "pages" | "posts";
527
+ rows: ImportSourceRow[];
528
+ options: CmsImportOptions;
529
+ }) {
530
+ const summary = emptySummary();
531
+ summary.totalRows = params.rows.length;
532
+ const prepared: PreparedContentImport[] = [];
533
+ const { byCode: languagesByCode } = await loadLanguages(params.supabase);
534
+ const mediaMaps = await loadMediaMaps(params.supabase);
535
+ const existing = await loadExistingContent(params.supabase, params.contentType);
536
+ const slugSets = new Map<number, Set<string>>();
537
+ const translationGroupMap = new Map<string, string>();
538
+
539
+ for (const item of existing.byId.values()) {
540
+ const set = slugSets.get(item.language_id) ?? new Set<string>();
541
+ set.add(item.slug);
542
+ slugSets.set(item.language_id, set);
543
+ }
544
+
545
+ for (const [index, row] of params.rows.entries()) {
546
+ const rowNumber = index + 2;
547
+ const numericId = Number((row as any).id);
548
+ const matchedById =
549
+ params.options.conflictMode === "overwrite_existing" && Number.isFinite(numericId)
550
+ ? existing.byId.get(numericId)
551
+ : undefined;
552
+ let languageId = getOptionalLanguageId(row, rowNumber, languagesByCode, summary.errors);
553
+ if (!languageId && matchedById) {
554
+ languageId = matchedById.language_id;
555
+ }
556
+
557
+ const incomingSlug = normalizeRequiredString((row as any).slug);
558
+ const matchedBySlug =
559
+ params.options.conflictMode === "overwrite_existing" && languageId && incomingSlug
560
+ ? existing.bySlug.get(`${languageId}:${incomingSlug}`)
561
+ : undefined;
562
+ const target = matchedById ?? matchedBySlug ?? null;
563
+ const action = target ? "update" : "create";
564
+ const preserveBlanks = canPreserveBlankFields(params.options, target);
565
+ const preserveField = (fieldName: string) =>
566
+ shouldPreserveBlankField(row, fieldName, params.options, target);
567
+ const title = normalizeRequiredString((row as any).title);
568
+ const status = preserveField("status")
569
+ ? ""
570
+ : normalizeRequiredString((row as any).status || "draft");
571
+
572
+ if (!languageId && !rowHasImportValue(row, "language_code")) {
573
+ addMessage(summary.errors, rowNumber, "language_code is required.");
574
+ }
575
+ if (!title && !preserveField("title")) addMessage(summary.errors, rowNumber, "title is required.");
576
+ if (!incomingSlug && !preserveField("slug")) addMessage(summary.errors, rowNumber, "slug is required.");
577
+ if (!preserveField("status") && !PAGE_STATUSES.has(status)) {
578
+ addMessage(summary.errors, rowNumber, `status must be one of: ${Array.from(PAGE_STATUSES).join(", ")}.`);
579
+ }
580
+ if (
581
+ !languageId ||
582
+ (!title && !preserveField("title")) ||
583
+ (!incomingSlug && !preserveField("slug")) ||
584
+ (!preserveField("status") && !PAGE_STATUSES.has(status))
585
+ ) {
586
+ continue;
587
+ }
588
+
589
+ const slugSet = slugSets.get(languageId) ?? new Set<string>();
590
+ const shouldUpdateSlug = !preserveField("slug");
591
+ const slug = shouldUpdateSlug
592
+ ? params.options.conflictMode === "create_new" || action === "create"
593
+ ? makeUniqueSlug(incomingSlug, slugSet)
594
+ : incomingSlug
595
+ : target?.slug ?? incomingSlug;
596
+ slugSets.set(languageId, slugSet);
597
+
598
+ const slugOwner = existing.bySlug.get(`${languageId}:${slug}`);
599
+ if (action === "update" && shouldUpdateSlug && slugOwner && slugOwner.id !== target?.id) {
600
+ addMessage(summary.errors, rowNumber, `Slug "${slug}" already belongs to another ${params.contentType.slice(0, -1)} in this language.`);
601
+ continue;
602
+ }
603
+
604
+ const importedGroupId = normalizeId((row as any).translation_group_id);
605
+ const translationGroupId =
606
+ action === "update"
607
+ ? importedGroupId || target?.translation_group_id || uuidv4()
608
+ : importedGroupId
609
+ ? translationGroupMap.get(importedGroupId) ?? translationGroupMap.set(importedGroupId, uuidv4()).get(importedGroupId)
610
+ : uuidv4();
611
+
612
+ const shouldResolveFeatureImage =
613
+ !preserveBlanks ||
614
+ rowHasImportValue(row, "feature_image_id") ||
615
+ rowHasImportValue(row, "feature_image_object_key");
616
+ const featureImageId = shouldResolveFeatureImage
617
+ ? resolveMediaId({
618
+ id: normalizeId((row as any).feature_image_id),
619
+ objectKey: normalizeId((row as any).feature_image_object_key),
620
+ rowNumber,
621
+ fieldLabel: "Feature image",
622
+ mediaById: mediaMaps.byId,
623
+ mediaByObjectKey: mediaMaps.byObjectKey,
624
+ warnings: summary.warnings,
625
+ })
626
+ : undefined;
627
+
628
+ const replaceBlocks =
629
+ !preserveBlanks ||
630
+ rowHasImportValue(row, "blocks_json") ||
631
+ rowHasImportValue(row, "content_html") ||
632
+ Array.isArray((row as BackupPageRecord).blocks);
633
+ const blocks = replaceBlocks
634
+ ? parseBlocksForContent(row, rowNumber, languageId, summary.errors)
635
+ : [];
636
+ const meta: Record<string, unknown> = {};
637
+ setMetaValue(meta, "language_id", languageId, !preserveField("language_code"));
638
+ setMetaValue(meta, "title", title, !preserveField("title"));
639
+ setMetaValue(meta, "slug", slug, shouldUpdateSlug);
640
+ setMetaValue(meta, "status", status, !preserveField("status"));
641
+ setMetaValue(
642
+ meta,
643
+ "meta_title",
644
+ normalizeNullableString((row as any).meta_title),
645
+ !preserveField("meta_title")
646
+ );
647
+ setMetaValue(
648
+ meta,
649
+ "meta_description",
650
+ normalizeNullableString((row as any).meta_description),
651
+ !preserveField("meta_description")
652
+ );
653
+ setMetaValue(meta, "feature_image_id", featureImageId, shouldResolveFeatureImage);
654
+ setMetaValue(
655
+ meta,
656
+ "translation_group_id",
657
+ translationGroupId,
658
+ !preserveField("translation_group_id")
659
+ );
660
+
661
+ if (params.contentType === "posts") {
662
+ setMetaValue(meta, "label", normalizeNullableString((row as any).label), !preserveField("label"));
663
+ setMetaValue(meta, "excerpt", normalizeNullableString((row as any).excerpt), !preserveField("excerpt"));
664
+ setMetaValue(meta, "subtitle", normalizeNullableString((row as any).subtitle), !preserveField("subtitle"));
665
+ setMetaValue(
666
+ meta,
667
+ "published_at",
668
+ readOptionalDate((row as any).published_at, "published_at", rowNumber, summary.errors),
669
+ !preserveField("published_at")
670
+ );
671
+ }
672
+
673
+ prepared.push({
674
+ contentType: params.contentType,
675
+ rowNumber,
676
+ action,
677
+ targetId: target?.id ?? null,
678
+ meta,
679
+ blocks,
680
+ replaceBlocks,
681
+ oldSlug: target?.slug ?? null,
682
+ });
683
+ }
684
+
685
+ createPreview(prepared, summary);
686
+ summary.success = summary.errors.length === 0;
687
+ return { summary, prepared };
688
+ }
689
+
690
+ async function loadCategories(supabase: SupabaseAny) {
691
+ const { data, error } = await supabase
692
+ .from("categories" as any)
693
+ .select("id, slug");
694
+
695
+ if (error) {
696
+ throw new Error(`Failed to load product categories: ${error.message}`);
697
+ }
698
+
699
+ return new Map((data || []).map((category: any) => [String(category.slug), String(category.id)]));
700
+ }
701
+
702
+ function normalizePriceMapValue(value: unknown) {
703
+ return isRecord(value) ? (value as Record<string, number>) : {};
704
+ }
705
+
706
+ function normalizeSalePriceMapValue(value: unknown) {
707
+ return isRecord(value) ? (value as Record<string, number | null>) : {};
708
+ }
709
+
710
+ async function prepareProductImports(params: {
711
+ supabase: SupabaseAny;
712
+ rows: ImportSourceRow[];
713
+ options: CmsImportOptions;
714
+ }) {
715
+ const summary = emptySummary();
716
+ summary.totalRows = params.rows.length;
717
+ const prepared: PreparedProductImport[] = [];
718
+ const { byCode: languagesByCode } = await loadLanguages(params.supabase);
719
+ const mediaMaps = await loadMediaMaps(params.supabase);
720
+ const categoryMap = await loadCategories(params.supabase);
721
+ const existing = await loadExistingProducts(params.supabase);
722
+ const slugSets = new Map<number, Set<string>>();
723
+ const skuSets = new Map<number, Set<string>>();
724
+ const translationGroupMap = new Map<string, string>();
725
+
726
+ for (const item of existing.byId.values()) {
727
+ const slugSet = slugSets.get(item.language_id) ?? new Set<string>();
728
+ const skuSet = skuSets.get(item.language_id) ?? new Set<string>();
729
+ slugSet.add(item.slug);
730
+ skuSet.add(item.sku);
731
+ slugSets.set(item.language_id, slugSet);
732
+ skuSets.set(item.language_id, skuSet);
733
+ }
734
+
735
+ for (const [index, row] of params.rows.entries()) {
736
+ const rowNumber = index + 2;
737
+ const rowId = normalizeId((row as any).id);
738
+ const matchedById =
739
+ params.options.conflictMode === "overwrite_existing" && rowId
740
+ ? existing.byId.get(rowId)
741
+ : undefined;
742
+ let languageId = getOptionalLanguageId(row, rowNumber, languagesByCode, summary.errors);
743
+ if (!languageId && matchedById) {
744
+ languageId = matchedById.language_id;
745
+ }
746
+
747
+ const incomingSlug = normalizeRequiredString((row as any).slug);
748
+ const incomingSku = normalizeRequiredString((row as any).sku);
749
+ const matchedBySku =
750
+ params.options.conflictMode === "overwrite_existing" && languageId && incomingSku
751
+ ? existing.bySku.get(`${languageId}:${incomingSku}`)
752
+ : undefined;
753
+ const matchedBySlug =
754
+ params.options.conflictMode === "overwrite_existing" && languageId && incomingSlug
755
+ ? existing.bySlug.get(`${languageId}:${incomingSlug}`)
756
+ : undefined;
757
+
758
+ if (matchedBySku && matchedBySlug && matchedBySku.id !== matchedBySlug.id) {
759
+ addMessage(summary.errors, rowNumber, `SKU "${incomingSku}" and slug "${incomingSlug}" match different products.`);
760
+ continue;
761
+ }
762
+
763
+ const target = matchedById ?? matchedBySku ?? matchedBySlug ?? null;
764
+ const action = target ? "update" : "create";
765
+ const preserveBlanks = canPreserveBlankFields(params.options, target);
766
+ const preserveField = (fieldName: string) =>
767
+ shouldPreserveBlankField(row, fieldName, params.options, target);
768
+ const title = normalizeRequiredString((row as any).title);
769
+ const productType = preserveField("product_type")
770
+ ? ""
771
+ : normalizeRequiredString((row as any).product_type || "physical");
772
+ const paymentProvider = preserveField("payment_provider")
773
+ ? ""
774
+ : normalizeRequiredString(
775
+ (row as any).payment_provider || (productType === "digital" ? "freemius" : "stripe")
776
+ );
777
+ const status = preserveField("status")
778
+ ? ""
779
+ : normalizeRequiredString((row as any).status || "draft");
780
+ const price = preserveField("price")
781
+ ? undefined
782
+ : readRequiredNumber((row as any).price, "price", rowNumber, summary.errors);
783
+ const stock = preserveField("stock")
784
+ ? undefined
785
+ : readRequiredNumber((row as any).stock, "stock", rowNumber, summary.errors);
786
+ const updatesProductType = !preserveField("product_type");
787
+ const updatesPaymentProvider = !preserveField("payment_provider");
788
+
789
+ if (!languageId && !rowHasImportValue(row, "language_code")) {
790
+ addMessage(summary.errors, rowNumber, "language_code is required.");
791
+ }
792
+ if (!title && !preserveField("title")) addMessage(summary.errors, rowNumber, "title is required.");
793
+ if (!incomingSlug && !preserveField("slug")) addMessage(summary.errors, rowNumber, "slug is required.");
794
+ if (!incomingSku && !preserveField("sku")) addMessage(summary.errors, rowNumber, "sku is required.");
795
+ if (updatesProductType && !PRODUCT_TYPES.has(productType)) {
796
+ addMessage(summary.errors, rowNumber, "product_type must be physical or digital.");
797
+ }
798
+ if (updatesPaymentProvider && !PAYMENT_PROVIDERS.has(paymentProvider)) {
799
+ addMessage(summary.errors, rowNumber, "payment_provider must be stripe or freemius.");
800
+ }
801
+ if (preserveBlanks && updatesProductType !== updatesPaymentProvider) {
802
+ addMessage(summary.errors, rowNumber, "product_type and payment_provider must be imported together when ignoring blank fields.");
803
+ }
804
+ if (updatesProductType && updatesPaymentProvider && productType === "physical" && paymentProvider !== "stripe") {
805
+ addMessage(summary.errors, rowNumber, "physical products must use stripe.");
806
+ }
807
+ if (updatesProductType && updatesPaymentProvider && productType === "digital" && paymentProvider !== "freemius") {
808
+ addMessage(summary.errors, rowNumber, "digital products must use freemius.");
809
+ }
810
+ if (!preserveField("status") && !PRODUCT_STATUSES.has(status)) {
811
+ addMessage(summary.errors, rowNumber, `status must be one of: ${Array.from(PRODUCT_STATUSES).join(", ")}.`);
812
+ }
813
+ if (
814
+ !languageId ||
815
+ (!title && !preserveField("title")) ||
816
+ (!incomingSlug && !preserveField("slug")) ||
817
+ (!incomingSku && !preserveField("sku")) ||
818
+ (updatesProductType && !PRODUCT_TYPES.has(productType)) ||
819
+ (updatesPaymentProvider && !PAYMENT_PROVIDERS.has(paymentProvider)) ||
820
+ (preserveBlanks && updatesProductType !== updatesPaymentProvider) ||
821
+ (updatesProductType && updatesPaymentProvider && productType === "physical" && paymentProvider !== "stripe") ||
822
+ (updatesProductType && updatesPaymentProvider && productType === "digital" && paymentProvider !== "freemius") ||
823
+ (!preserveField("status") && !PRODUCT_STATUSES.has(status)) ||
824
+ price === null ||
825
+ stock === null
826
+ ) {
827
+ continue;
828
+ }
829
+
830
+ const slugSet = slugSets.get(languageId) ?? new Set<string>();
831
+ const skuSet = skuSets.get(languageId) ?? new Set<string>();
832
+ const shouldUpdateSlug = !preserveField("slug");
833
+ const shouldUpdateSku = !preserveField("sku");
834
+ const slug = shouldUpdateSlug
835
+ ? params.options.conflictMode === "create_new" || action === "create"
836
+ ? makeUniqueSlug(incomingSlug, slugSet)
837
+ : incomingSlug
838
+ : target?.slug ?? incomingSlug;
839
+ const sku = shouldUpdateSku
840
+ ? params.options.conflictMode === "create_new" || action === "create"
841
+ ? makeUniqueSku(incomingSku, skuSet)
842
+ : incomingSku
843
+ : target?.sku ?? incomingSku;
844
+ slugSets.set(languageId, slugSet);
845
+ skuSets.set(languageId, skuSet);
846
+
847
+ const slugOwner = existing.bySlug.get(`${languageId}:${slug}`);
848
+ const skuOwner = existing.bySku.get(`${languageId}:${sku}`);
849
+ if (action === "update" && shouldUpdateSlug && slugOwner && slugOwner.id !== target?.id) {
850
+ addMessage(summary.errors, rowNumber, `Slug "${slug}" already belongs to another product in this language.`);
851
+ continue;
852
+ }
853
+ if (action === "update" && shouldUpdateSku && skuOwner && skuOwner.id !== target?.id) {
854
+ addMessage(summary.errors, rowNumber, `SKU "${sku}" already belongs to another product in this language.`);
855
+ continue;
856
+ }
857
+
858
+ const localErrors: Array<{ row: number; message: string }> = [];
859
+ const prices =
860
+ preserveField("prices_json")
861
+ ? undefined
862
+ : (row as BackupProductRecord).prices ??
863
+ parseJsonField<Record<string, number>>((row as CsvLikeRow).prices_json, "prices_json", rowNumber, localErrors) ??
864
+ {};
865
+ const salePrices =
866
+ preserveField("sale_prices_json")
867
+ ? undefined
868
+ : (row as BackupProductRecord).sale_prices ??
869
+ parseJsonField<Record<string, number | null>>((row as CsvLikeRow).sale_prices_json, "sale_prices_json", rowNumber, localErrors) ??
870
+ {};
871
+ const descriptionJson =
872
+ preserveField("description_json")
873
+ ? undefined
874
+ : (row as BackupProductRecord).description_json ??
875
+ parseJsonField<Json>((row as CsvLikeRow).description_json, "description_json", rowNumber, localErrors);
876
+ const replaceVariants =
877
+ !preserveBlanks ||
878
+ rowHasImportValue(row, "variants_json") ||
879
+ Array.isArray((row as BackupProductRecord).variants);
880
+ const rawVariants =
881
+ replaceVariants
882
+ ? (row as BackupProductRecord).variants ??
883
+ parseJsonField<BackupProductVariantRecord[]>((row as CsvLikeRow).variants_json, "variants_json", rowNumber, localErrors) ??
884
+ []
885
+ : [];
886
+
887
+ for (const error of localErrors) {
888
+ addMessage(summary.errors, error.row, error.message);
889
+ }
890
+ if (localErrors.length > 0) continue;
891
+ if (replaceVariants && !Array.isArray(rawVariants)) {
892
+ addMessage(summary.errors, rowNumber, "variants_json must be an array.");
893
+ continue;
894
+ }
895
+
896
+ const syncCategories =
897
+ !preserveBlanks ||
898
+ rowHasImportValue(row, "category_slugs") ||
899
+ Array.isArray((row as BackupProductRecord).category_slugs);
900
+ const categorySlugs = syncCategories
901
+ ? Array.isArray((row as BackupProductRecord).category_slugs)
902
+ ? ((row as BackupProductRecord).category_slugs || [])
903
+ : splitList((row as CsvLikeRow).category_slugs)
904
+ : [];
905
+ const categoryIds: string[] = [];
906
+ for (const categorySlug of categorySlugs) {
907
+ const categoryId = categoryMap.get(categorySlug);
908
+ if (!categoryId) {
909
+ addMessage(summary.errors, rowNumber, `Category slug "${categorySlug}" does not exist.`);
910
+ } else {
911
+ categoryIds.push(categoryId);
912
+ }
913
+ }
914
+ if (summary.errors.some((error) => error.row === rowNumber)) continue;
915
+
916
+ const replaceMedia =
917
+ !preserveBlanks ||
918
+ rowHasImportValue(row, "media_ids") ||
919
+ rowHasImportValue(row, "media_object_keys") ||
920
+ Array.isArray((row as BackupProductRecord).media_ids) ||
921
+ Array.isArray((row as BackupProductRecord).media_object_keys);
922
+ const explicitMediaIds = replaceMedia
923
+ ? Array.isArray((row as BackupProductRecord).media_ids)
924
+ ? ((row as BackupProductRecord).media_ids || [])
925
+ : splitList((row as CsvLikeRow).media_ids)
926
+ : [];
927
+ const mediaObjectKeys = replaceMedia
928
+ ? Array.isArray((row as BackupProductRecord).media_object_keys)
929
+ ? ((row as BackupProductRecord).media_object_keys || [])
930
+ : splitList((row as CsvLikeRow).media_object_keys)
931
+ : [];
932
+ const mediaIds = new Set<string>();
933
+ for (const mediaId of explicitMediaIds) {
934
+ const resolved = resolveMediaId({
935
+ id: mediaId,
936
+ rowNumber,
937
+ fieldLabel: "Product media",
938
+ mediaById: mediaMaps.byId,
939
+ mediaByObjectKey: mediaMaps.byObjectKey,
940
+ warnings: summary.warnings,
941
+ });
942
+ if (resolved) mediaIds.add(resolved);
943
+ }
944
+ for (const objectKey of mediaObjectKeys) {
945
+ const resolved = resolveMediaId({
946
+ objectKey,
947
+ rowNumber,
948
+ fieldLabel: "Product media",
949
+ mediaById: mediaMaps.byId,
950
+ mediaByObjectKey: mediaMaps.byObjectKey,
951
+ warnings: summary.warnings,
952
+ });
953
+ if (resolved) mediaIds.add(resolved);
954
+ }
955
+
956
+ const variants = rawVariants.map((variant) => {
957
+ const mainMediaId = resolveMediaId({
958
+ id: variant.main_media_id,
959
+ objectKey: variant.main_media_object_key,
960
+ rowNumber,
961
+ fieldLabel: `Variant media for ${variant.sku}`,
962
+ mediaById: mediaMaps.byId,
963
+ mediaByObjectKey: mediaMaps.byObjectKey,
964
+ warnings: summary.warnings,
965
+ });
966
+
967
+ return {
968
+ ...variant,
969
+ main_media_id: mainMediaId,
970
+ prices: normalizePriceMapValue(variant.prices),
971
+ sale_prices: normalizeSalePriceMapValue(variant.sale_prices),
972
+ attribute_term_ids: Array.isArray(variant.attribute_term_ids) ? variant.attribute_term_ids : [],
973
+ };
974
+ });
975
+
976
+ const importedGroupId = normalizeId((row as any).translation_group_id);
977
+ const translationGroupId =
978
+ action === "update"
979
+ ? importedGroupId || target?.translation_group_id || uuidv4()
980
+ : importedGroupId
981
+ ? translationGroupMap.get(importedGroupId) ?? translationGroupMap.set(importedGroupId, uuidv4()).get(importedGroupId)
982
+ : uuidv4();
983
+
984
+ const replaceBlocks =
985
+ !preserveBlanks ||
986
+ rowHasImportValue(row, "description_blocks_json") ||
987
+ rowHasImportValue(row, "description_html") ||
988
+ Array.isArray((row as BackupProductRecord).description_blocks);
989
+ const blocks = replaceBlocks
990
+ ? parseBlocksForProduct(row, rowNumber, languageId, summary.errors)
991
+ : [];
992
+ const meta: Record<string, unknown> = {};
993
+ setMetaValue(meta, "language_id", languageId, !preserveField("language_code"));
994
+ setMetaValue(
995
+ meta,
996
+ "translation_group_id",
997
+ translationGroupId,
998
+ !preserveField("translation_group_id")
999
+ );
1000
+ setMetaValue(meta, "product_type", productType, updatesProductType);
1001
+ setMetaValue(meta, "payment_provider", paymentProvider, updatesPaymentProvider);
1002
+ setMetaValue(meta, "title", title, !preserveField("title"));
1003
+ setMetaValue(meta, "slug", slug, shouldUpdateSlug);
1004
+ setMetaValue(meta, "sku", sku, shouldUpdateSku);
1005
+ setMetaValue(meta, "upc", normalizeNullableString((row as any).upc), !preserveField("upc"));
1006
+ setMetaValue(meta, "status", status, !preserveField("status"));
1007
+ setMetaValue(meta, "price", price, !preserveField("price"));
1008
+ setMetaValue(meta, "prices", prices, !preserveField("prices_json"));
1009
+ setMetaValue(
1010
+ meta,
1011
+ "sale_price",
1012
+ parseNullableNumber(String((row as any).sale_price ?? "")),
1013
+ !preserveField("sale_price")
1014
+ );
1015
+ setMetaValue(meta, "sale_prices", salePrices, !preserveField("sale_prices_json"));
1016
+ setMetaValue(meta, "stock", stock, !preserveField("stock"));
1017
+ setMetaValue(
1018
+ meta,
1019
+ "is_taxable",
1020
+ parseBoolean(String((row as any).is_taxable ?? ""), true),
1021
+ !preserveField("is_taxable")
1022
+ );
1023
+ setMetaValue(meta, "meta_title", normalizeNullableString((row as any).meta_title), !preserveField("meta_title"));
1024
+ setMetaValue(
1025
+ meta,
1026
+ "meta_description",
1027
+ normalizeNullableString((row as any).meta_description),
1028
+ !preserveField("meta_description")
1029
+ );
1030
+ setMetaValue(
1031
+ meta,
1032
+ "short_description",
1033
+ normalizeNullableString((row as any).short_description),
1034
+ !preserveField("short_description")
1035
+ );
1036
+ setMetaValue(
1037
+ meta,
1038
+ "description_json",
1039
+ descriptionJson ?? null,
1040
+ !preserveField("description_json")
1041
+ );
1042
+ setMetaValue(
1043
+ meta,
1044
+ "freemius_product_id",
1045
+ normalizeNullableString((row as any).freemius_product_id),
1046
+ !preserveField("freemius_product_id")
1047
+ );
1048
+ setMetaValue(
1049
+ meta,
1050
+ "freemius_plan_id",
1051
+ normalizeNullableString((row as any).freemius_plan_id),
1052
+ !preserveField("freemius_plan_id")
1053
+ );
1054
+ setMetaValue(
1055
+ meta,
1056
+ "trial_period_days",
1057
+ parseNumber(String((row as any).trial_period_days ?? ""), 0),
1058
+ !preserveField("trial_period_days")
1059
+ );
1060
+ setMetaValue(
1061
+ meta,
1062
+ "trial_requires_payment_method",
1063
+ parseBoolean(String((row as any).trial_requires_payment_method ?? ""), false),
1064
+ !preserveField("trial_requires_payment_method")
1065
+ );
1066
+ setMetaValue(meta, "product_media", Array.from(mediaIds).map((media_id) => ({ media_id })), replaceMedia);
1067
+ setMetaValue(meta, "category_ids", categoryIds, syncCategories);
1068
+ setMetaValue(meta, "variants", variants, replaceVariants);
1069
+
1070
+ prepared.push({
1071
+ contentType: "products",
1072
+ rowNumber,
1073
+ action,
1074
+ targetId: target?.id ?? null,
1075
+ meta,
1076
+ blocks,
1077
+ categoryIds,
1078
+ mediaIds: Array.from(mediaIds),
1079
+ variants,
1080
+ replaceBlocks,
1081
+ replaceMedia,
1082
+ replaceVariants,
1083
+ syncCategories,
1084
+ oldSlug: target?.slug ?? null,
1085
+ });
1086
+ }
1087
+
1088
+ createPreview(prepared, summary);
1089
+ summary.success = summary.errors.length === 0;
1090
+ return { summary, prepared };
1091
+ }
1092
+
1093
+ function buildBlockPayload(block: BackupBlockRecord, parent: { type: CmsContentType; id: number | string }, index: number) {
1094
+ return {
1095
+ page_id: parent.type === "pages" ? parent.id : null,
1096
+ post_id: parent.type === "posts" ? parent.id : null,
1097
+ product_id: parent.type === "products" ? parent.id : null,
1098
+ language_id: block.language_id,
1099
+ block_type: block.block_type,
1100
+ content: block.content,
1101
+ order: typeof block.order === "number" && Number.isFinite(block.order) ? block.order : index,
1102
+ };
1103
+ }
1104
+
1105
+ async function replaceBlocks(
1106
+ supabase: SupabaseAny,
1107
+ contentType: CmsContentType,
1108
+ parentId: number | string,
1109
+ blocks: BackupBlockRecord[]
1110
+ ) {
1111
+ const column = contentType === "pages" ? "page_id" : contentType === "posts" ? "post_id" : "product_id";
1112
+ const { error: deleteError } = await supabase.from("blocks").delete().eq(column, parentId as any);
1113
+ if (deleteError) {
1114
+ throw new Error(`Failed to clear ${contentType} blocks: ${deleteError.message}`);
1115
+ }
1116
+
1117
+ if (blocks.length === 0) return;
1118
+
1119
+ const payload = blocks.map((block, index) => buildBlockPayload(block, { type: contentType, id: parentId }, index));
1120
+ const { error: insertError } = await supabase.from("blocks").insert(payload as any);
1121
+ if (insertError) {
1122
+ throw new Error(`Failed to insert ${contentType} blocks: ${insertError.message}`);
1123
+ }
1124
+ }
1125
+
1126
+ async function applyContentImport(params: {
1127
+ auth: TransferAuth;
1128
+ item: PreparedContentImport;
1129
+ options: CmsImportOptions;
1130
+ }) {
1131
+ const { auth, item, options } = params;
1132
+ const table = item.contentType === "pages" ? "pages" : "posts";
1133
+ const parentType = item.contentType === "pages" ? "page" : "post";
1134
+ let parentId = item.targetId;
1135
+ let previousContent: FullPageContent | FullPostContent | null = null;
1136
+
1137
+ if (options.applyMode === "live" && parentId) {
1138
+ previousContent =
1139
+ item.contentType === "pages"
1140
+ ? await getFullPageContent(parentId)
1141
+ : await getFullPostContent(parentId);
1142
+ }
1143
+
1144
+ if (!parentId) {
1145
+ const insertMeta = {
1146
+ ...item.meta,
1147
+ status: options.applyMode === "draft" ? "draft" : item.meta.status,
1148
+ author_id: auth.userId,
1149
+ };
1150
+ const { data, error } = await auth.supabase
1151
+ .from(table)
1152
+ .insert(insertMeta as any)
1153
+ .select("id")
1154
+ .single();
1155
+
1156
+ if (error || !data) {
1157
+ throw new Error(`Failed to create ${item.contentType.slice(0, -1)} from row ${item.rowNumber}: ${error?.message ?? "unknown error"}`);
1158
+ }
1159
+
1160
+ parentId = Number((data as any).id);
1161
+ }
1162
+
1163
+ if (options.applyMode === "draft") {
1164
+ const { error } = await (auth.supabase as any)
1165
+ .from("content_drafts")
1166
+ .upsert(
1167
+ {
1168
+ parent_type: parentType,
1169
+ parent_id: parentId,
1170
+ author_id: auth.userId,
1171
+ meta: item.meta,
1172
+ blocks: item.blocks,
1173
+ updated_at: new Date().toISOString(),
1174
+ },
1175
+ { onConflict: "parent_type,parent_id" }
1176
+ );
1177
+
1178
+ if (error) {
1179
+ throw new Error(`Failed to save draft from row ${item.rowNumber}: ${error.message}`);
1180
+ }
1181
+ } else {
1182
+ if (Object.keys(item.meta).length > 0) {
1183
+ const { error } = await auth.supabase
1184
+ .from(table)
1185
+ .update(item.meta as any)
1186
+ .eq("id", parentId);
1187
+
1188
+ if (error) {
1189
+ throw new Error(`Failed to update ${item.contentType.slice(0, -1)} from row ${item.rowNumber}: ${error.message}`);
1190
+ }
1191
+ }
1192
+
1193
+ if (item.replaceBlocks) {
1194
+ await replaceBlocks(auth.supabase, item.contentType, parentId, item.blocks);
1195
+ }
1196
+ await (auth.supabase as any)
1197
+ .from("content_drafts")
1198
+ .delete()
1199
+ .eq("parent_type", parentType)
1200
+ .eq("parent_id", parentId);
1201
+
1202
+ if (previousContent) {
1203
+ const nextContent =
1204
+ item.contentType === "pages"
1205
+ ? await getFullPageContent(parentId)
1206
+ : await getFullPostContent(parentId);
1207
+ if (nextContent) {
1208
+ if (item.contentType === "pages") {
1209
+ await createPageRevision(parentId, auth.userId, previousContent as FullPageContent, nextContent as FullPageContent);
1210
+ } else {
1211
+ await createPostRevision(parentId, auth.userId, previousContent as FullPostContent, nextContent as FullPostContent);
1212
+ }
1213
+ }
1214
+ }
1215
+ }
1216
+
1217
+ revalidatePath(item.contentType === "pages" ? "/cms/pages" : "/cms/posts");
1218
+ const slug = typeof item.meta.slug === "string" ? item.meta.slug : null;
1219
+ if (item.contentType === "pages") {
1220
+ if (slug) revalidatePath(`/${slug}`);
1221
+ if (item.oldSlug && item.oldSlug !== slug) revalidatePath(`/${item.oldSlug}`);
1222
+ } else {
1223
+ revalidatePath("/articles");
1224
+ if (slug) revalidatePath(`/article/${slug}`);
1225
+ if (item.oldSlug && item.oldSlug !== slug) revalidatePath(`/article/${item.oldSlug}`);
1226
+ }
1227
+ }
1228
+
1229
+ function toLiveProductPayload(meta: Record<string, unknown>) {
1230
+ const payload: Record<string, unknown> = {
1231
+ updated_at: new Date().toISOString(),
1232
+ };
1233
+ const hasKey = (key: string) => Object.prototype.hasOwnProperty.call(meta, key);
1234
+ const copy = (key: string) => {
1235
+ if (hasKey(key)) payload[key] = meta[key];
1236
+ };
1237
+
1238
+ copy("language_id");
1239
+ copy("translation_group_id");
1240
+ copy("product_type");
1241
+ copy("payment_provider");
1242
+ copy("title");
1243
+ copy("slug");
1244
+ copy("sku");
1245
+ copy("upc");
1246
+ copy("status");
1247
+ if (hasKey("price")) payload.price = majorToMinor(meta.price as number) ?? 0;
1248
+ if (hasKey("prices")) payload.prices = priceMapMajorToMinor(meta.prices as Record<string, number>);
1249
+ if (hasKey("sale_price")) payload.sale_price = majorToMinor(meta.sale_price as number | null);
1250
+ if (hasKey("sale_prices")) {
1251
+ payload.sale_prices = salePriceMapMajorToMinor(meta.sale_prices as Record<string, number | null>);
1252
+ }
1253
+ copy("stock");
1254
+ copy("is_taxable");
1255
+ copy("meta_title");
1256
+ copy("meta_description");
1257
+ copy("short_description");
1258
+ copy("description_json");
1259
+ copy("freemius_product_id");
1260
+ copy("freemius_plan_id");
1261
+ copy("trial_period_days");
1262
+ copy("trial_requires_payment_method");
1263
+
1264
+ return payload;
1265
+ }
1266
+
1267
+ async function replaceProductMedia(supabase: SupabaseAny, productId: string, mediaIds: string[]) {
1268
+ const { error: deleteError } = await supabase
1269
+ .from("product_media")
1270
+ .delete()
1271
+ .eq("product_id", productId);
1272
+ if (deleteError) {
1273
+ throw new Error(`Failed to clear product media: ${deleteError.message}`);
1274
+ }
1275
+
1276
+ if (mediaIds.length === 0) return;
1277
+
1278
+ const { error } = await supabase.from("product_media").insert(
1279
+ mediaIds.map((mediaId, index) => ({
1280
+ product_id: productId,
1281
+ media_id: mediaId,
1282
+ sort_order: index,
1283
+ })) as any
1284
+ );
1285
+ if (error) {
1286
+ throw new Error(`Failed to insert product media: ${error.message}`);
1287
+ }
1288
+ }
1289
+
1290
+ async function replaceProductVariants(
1291
+ supabase: SupabaseAny,
1292
+ productId: string,
1293
+ variants: BackupProductVariantRecord[]
1294
+ ) {
1295
+ const { error: deleteError } = await supabase
1296
+ .from("product_variants")
1297
+ .delete()
1298
+ .eq("product_id", productId);
1299
+ if (deleteError) {
1300
+ throw new Error(`Failed to clear product variants: ${deleteError.message}`);
1301
+ }
1302
+
1303
+ for (const variant of variants) {
1304
+ const { data, error } = await supabase
1305
+ .from("product_variants")
1306
+ .insert({
1307
+ product_id: productId,
1308
+ sku: variant.sku,
1309
+ upc: variant.upc ?? null,
1310
+ price: majorToMinor(variant.price) ?? 0,
1311
+ prices: priceMapMajorToMinor(variant.prices),
1312
+ sale_price: majorToMinor(variant.sale_price ?? null),
1313
+ sale_prices: salePriceMapMajorToMinor(variant.sale_prices),
1314
+ stock_quantity: variant.stock_quantity ?? 0,
1315
+ main_media_id: variant.main_media_id ?? null,
1316
+ } as any)
1317
+ .select("id")
1318
+ .single();
1319
+
1320
+ if (error || !data) {
1321
+ throw new Error(`Failed to insert product variant "${variant.sku}": ${error?.message ?? "unknown error"}`);
1322
+ }
1323
+
1324
+ const termIds = Array.isArray(variant.attribute_term_ids) ? variant.attribute_term_ids : [];
1325
+ if (termIds.length > 0) {
1326
+ const { error: mappingError } = await supabase
1327
+ .from("variant_attribute_mapping")
1328
+ .insert(
1329
+ termIds.map((termId) => ({
1330
+ variant_id: (data as any).id,
1331
+ attribute_term_id: termId,
1332
+ })) as any
1333
+ );
1334
+
1335
+ if (mappingError) {
1336
+ throw new Error(`Failed to insert variant attributes for "${variant.sku}": ${mappingError.message}`);
1337
+ }
1338
+ }
1339
+ }
1340
+ }
1341
+
1342
+ async function applyProductImport(params: {
1343
+ auth: TransferAuth;
1344
+ item: PreparedProductImport;
1345
+ options: CmsImportOptions;
1346
+ }) {
1347
+ const { auth, item, options } = params;
1348
+ let productId = item.targetId;
1349
+
1350
+ if (!productId) {
1351
+ const payload = {
1352
+ ...toLiveProductPayload(item.meta),
1353
+ status: options.applyMode === "draft" ? "draft" : item.meta.status,
1354
+ metadata: {},
1355
+ created_at: new Date().toISOString(),
1356
+ };
1357
+ const { data, error } = await auth.supabase
1358
+ .from("products")
1359
+ .insert(payload as any)
1360
+ .select("id")
1361
+ .single();
1362
+
1363
+ if (error || !data) {
1364
+ throw new Error(`Failed to create product from row ${item.rowNumber}: ${error?.message ?? "unknown error"}`);
1365
+ }
1366
+
1367
+ productId = String((data as any).id);
1368
+ }
1369
+
1370
+ if (options.applyMode === "draft") {
1371
+ const { error } = await (auth.supabase as any)
1372
+ .from("product_drafts")
1373
+ .upsert(
1374
+ {
1375
+ product_id: productId,
1376
+ author_id: auth.userId,
1377
+ meta: item.meta,
1378
+ blocks: item.blocks,
1379
+ updated_at: new Date().toISOString(),
1380
+ },
1381
+ { onConflict: "product_id" }
1382
+ );
1383
+
1384
+ if (error) {
1385
+ throw new Error(`Failed to save product draft from row ${item.rowNumber}: ${error.message}`);
1386
+ }
1387
+ } else {
1388
+ const { error } = await auth.supabase
1389
+ .from("products")
1390
+ .update(toLiveProductPayload(item.meta) as any)
1391
+ .eq("id", productId);
1392
+
1393
+ if (error) {
1394
+ throw new Error(`Failed to update product from row ${item.rowNumber}: ${error.message}`);
1395
+ }
1396
+
1397
+ if (item.replaceBlocks) {
1398
+ await replaceBlocks(auth.supabase, "products", productId, item.blocks);
1399
+ }
1400
+ if (item.replaceMedia) {
1401
+ await replaceProductMedia(auth.supabase, productId, item.mediaIds);
1402
+ }
1403
+ if (item.replaceVariants) {
1404
+ await replaceProductVariants(auth.supabase, productId, item.variants);
1405
+ }
1406
+ if (item.syncCategories) {
1407
+ await syncCategoriesForTranslationGroup(auth.supabase as any, productId, item.categoryIds);
1408
+ }
1409
+ await (auth.supabase as any).from("product_drafts").delete().eq("product_id", productId);
1410
+ }
1411
+
1412
+ revalidatePath("/cms/products");
1413
+ const slug = typeof item.meta.slug === "string" ? item.meta.slug : null;
1414
+ if (slug) revalidatePath(`/product/${slug}`);
1415
+ if (item.oldSlug && item.oldSlug !== slug) revalidatePath(`/product/${item.oldSlug}`);
1416
+ }
1417
+
1418
+ async function applyPreparedImports(
1419
+ auth: TransferAuth,
1420
+ prepared: PreparedImport[],
1421
+ options: CmsImportOptions
1422
+ ) {
1423
+ for (const item of prepared) {
1424
+ if (item.contentType === "products") {
1425
+ await applyProductImport({ auth, item, options });
1426
+ } else {
1427
+ await applyContentImport({ auth, item, options });
1428
+ }
1429
+ }
1430
+ }
1431
+
1432
+ async function prepareImports(params: {
1433
+ supabase: SupabaseAny;
1434
+ contentType: CmsContentType;
1435
+ rows: ImportSourceRow[];
1436
+ options: CmsImportOptions;
1437
+ }) {
1438
+ if (params.options.ignoreBlankFields) {
1439
+ const summary = emptySummary();
1440
+ summary.totalRows = params.rows.length;
1441
+ if (params.options.conflictMode !== "overwrite_existing") {
1442
+ addMessage(summary.errors, 0, "Ignore blank fields can only be used with overwrite imports.");
1443
+ }
1444
+ if (params.options.applyMode !== "live") {
1445
+ addMessage(summary.errors, 0, "Ignore blank fields can only be used with live imports.");
1446
+ }
1447
+ if (summary.errors.length > 0) {
1448
+ summary.success = false;
1449
+ return { summary, prepared: [] as PreparedImport[] };
1450
+ }
1451
+ }
1452
+
1453
+ if (params.contentType === "products") {
1454
+ if (params.rows.length === 0) {
1455
+ const summary = emptySummary();
1456
+ summary.totalRows = 0;
1457
+ return { summary, prepared: [] as PreparedImport[] };
1458
+ }
1459
+
1460
+ const ecommerceAvailable = await isEcommerceContentAvailable(params.supabase);
1461
+ if (!ecommerceAvailable) {
1462
+ const summary = emptySummary();
1463
+ summary.totalRows = params.rows.length;
1464
+ if (params.options.skipUnavailableProductContent) {
1465
+ summary.skipped = params.rows.length;
1466
+ addMessage(
1467
+ summary.warnings,
1468
+ 0,
1469
+ "Products were skipped because the ecommerce package is not active.",
1470
+ "warning"
1471
+ );
1472
+ } else {
1473
+ addMessage(summary.errors, 0, "The ecommerce package must be active to import products.");
1474
+ }
1475
+ summary.success = summary.errors.length === 0;
1476
+ return { summary, prepared: [] as PreparedImport[] };
1477
+ }
1478
+
1479
+ return prepareProductImports({
1480
+ supabase: params.supabase,
1481
+ rows: params.rows,
1482
+ options: params.options,
1483
+ });
1484
+ }
1485
+
1486
+ return prepareContentImports({
1487
+ supabase: params.supabase,
1488
+ contentType: params.contentType,
1489
+ rows: params.rows,
1490
+ options: params.options,
1491
+ });
1492
+ }
1493
+
1494
+ export function buildCsvTemplate(contentType: CmsContentType) {
1495
+ return getTemplateCsv(contentType);
1496
+ }
1497
+
1498
+ export async function dryRunCsvImport(params: {
1499
+ contentType: CmsContentType;
1500
+ csv: string;
1501
+ options: CmsImportOptions;
1502
+ }) {
1503
+ const auth = await requireTransferUser(false);
1504
+ const parsed = parseCsv(params.csv);
1505
+ const { summary } = await prepareImports({
1506
+ supabase: auth.supabase,
1507
+ contentType: params.contentType,
1508
+ rows: parsed.rows,
1509
+ options: params.options,
1510
+ });
1511
+
1512
+ for (const parseError of parsed.errors) {
1513
+ addMessage(summary.errors, parseError.row, parseError.message);
1514
+ }
1515
+ summary.success = summary.errors.length === 0;
1516
+ return summary;
1517
+ }
1518
+
1519
+ export async function applyCsvImport(params: {
1520
+ contentType: CmsContentType;
1521
+ csv: string;
1522
+ options: CmsImportOptions;
1523
+ }) {
1524
+ const auth = await requireTransferUser(false);
1525
+ const parsed = parseCsv(params.csv);
1526
+ const { summary, prepared } = await prepareImports({
1527
+ supabase: auth.supabase,
1528
+ contentType: params.contentType,
1529
+ rows: parsed.rows,
1530
+ options: params.options,
1531
+ });
1532
+
1533
+ for (const parseError of parsed.errors) {
1534
+ addMessage(summary.errors, parseError.row, parseError.message);
1535
+ }
1536
+ summary.success = summary.errors.length === 0;
1537
+ if (!summary.success) {
1538
+ return summary;
1539
+ }
1540
+
1541
+ await applyPreparedImports(auth, prepared, params.options);
1542
+ return summary;
1543
+ }
1544
+
1545
+ async function fetchBlocksByParent(supabase: SupabaseAny, parentColumn: "page_id" | "post_id" | "product_id", ids: Array<number | string>) {
1546
+ if (ids.length === 0) {
1547
+ return new Map<string, BackupBlockRecord[]>();
1548
+ }
1549
+
1550
+ const { data, error } = await supabase
1551
+ .from("blocks")
1552
+ .select("id, page_id, post_id, product_id, language_id, block_type, content, order")
1553
+ .in(parentColumn, ids as any)
1554
+ .order("order", { ascending: true });
1555
+
1556
+ if (error) {
1557
+ throw new Error(`Failed to load blocks: ${error.message}`);
1558
+ }
1559
+
1560
+ const map = new Map<string, BackupBlockRecord[]>();
1561
+ for (const block of data || []) {
1562
+ const parentId = String((block as any)[parentColumn]);
1563
+ const list = map.get(parentId) ?? [];
1564
+ list.push({
1565
+ language_id: (block as any).language_id,
1566
+ block_type: (block as any).block_type,
1567
+ content: (block as any).content,
1568
+ order: (block as any).order,
1569
+ });
1570
+ map.set(parentId, list);
1571
+ }
1572
+
1573
+ return map;
1574
+ }
1575
+
1576
+ async function exportPages(supabase: SupabaseAny, languageId?: number) {
1577
+ let query = supabase
1578
+ .from("pages")
1579
+ .select("*, languages(code), media(object_key)")
1580
+ .order("created_at", { ascending: false });
1581
+
1582
+ if (languageId) query = query.eq("language_id", languageId);
1583
+
1584
+ const { data, error } = await query;
1585
+ if (error) throw new Error(`Failed to export pages: ${error.message}`);
1586
+
1587
+ const pages = data || [];
1588
+ const blockMap = await fetchBlocksByParent(
1589
+ supabase,
1590
+ "page_id",
1591
+ pages.map((page: any) => page.id)
1592
+ );
1593
+
1594
+ return pages.map((page: any): BackupPageRecord => ({
1595
+ id: page.id,
1596
+ translation_group_id: page.translation_group_id,
1597
+ language_code: page.languages?.code || "",
1598
+ title: page.title,
1599
+ slug: page.slug,
1600
+ status: page.status,
1601
+ meta_title: page.meta_title,
1602
+ meta_description: page.meta_description,
1603
+ feature_image_id: page.feature_image_id,
1604
+ feature_image_object_key: page.media?.object_key ?? null,
1605
+ blocks: blockMap.get(String(page.id)) || [],
1606
+ }));
1607
+ }
1608
+
1609
+ async function exportPosts(supabase: SupabaseAny, languageId?: number) {
1610
+ let query = supabase
1611
+ .from("posts")
1612
+ .select("*, languages(code), media(object_key)")
1613
+ .order("created_at", { ascending: false });
1614
+
1615
+ if (languageId) query = query.eq("language_id", languageId);
1616
+
1617
+ const { data, error } = await query;
1618
+ if (error) throw new Error(`Failed to export posts: ${error.message}`);
1619
+
1620
+ const posts = data || [];
1621
+ const blockMap = await fetchBlocksByParent(
1622
+ supabase,
1623
+ "post_id",
1624
+ posts.map((post: any) => post.id)
1625
+ );
1626
+
1627
+ return posts.map((post: any): BackupPostRecord => ({
1628
+ id: post.id,
1629
+ translation_group_id: post.translation_group_id,
1630
+ language_code: post.languages?.code || "",
1631
+ title: post.title,
1632
+ slug: post.slug,
1633
+ status: post.status,
1634
+ meta_title: post.meta_title,
1635
+ meta_description: post.meta_description,
1636
+ feature_image_id: post.feature_image_id,
1637
+ feature_image_object_key: post.media?.object_key ?? null,
1638
+ label: post.label,
1639
+ excerpt: post.excerpt,
1640
+ subtitle: post.subtitle,
1641
+ published_at: post.published_at,
1642
+ blocks: blockMap.get(String(post.id)) || [],
1643
+ }));
1644
+ }
1645
+
1646
+ async function exportProducts(supabase: SupabaseAny, languageId?: number) {
1647
+ const ecommerceAvailable = await isEcommerceContentAvailable(supabase);
1648
+ if (!ecommerceAvailable) {
1649
+ return [];
1650
+ }
1651
+
1652
+ let query = supabase
1653
+ .from("products")
1654
+ .select("*, languages(code)")
1655
+ .order("created_at", { ascending: false });
1656
+
1657
+ if (languageId) query = query.eq("language_id", languageId);
1658
+
1659
+ const { data, error } = await query;
1660
+ if (error) throw new Error(`Failed to export products: ${error.message}`);
1661
+
1662
+ const products = data || [];
1663
+ if (products.length === 0) {
1664
+ return [];
1665
+ }
1666
+
1667
+ const productIds = products.map((product: any) => String(product.id));
1668
+ const blockMap = await fetchBlocksByParent(supabase, "product_id", productIds);
1669
+
1670
+ const [mediaResult, categoryResult, variantResult] = await Promise.all([
1671
+ supabase
1672
+ .from("product_media")
1673
+ .select("product_id, media_id, sort_order, media(object_key)")
1674
+ .in("product_id", productIds),
1675
+ supabase
1676
+ .from("product_categories" as any)
1677
+ .select("product_id, category:categories(slug)")
1678
+ .in("product_id", productIds),
1679
+ supabase
1680
+ .from("product_variants")
1681
+ .select("id, product_id, sku, upc, price, prices, sale_price, sale_prices, stock_quantity, main_media_id, media:main_media_id(object_key), variant_attribute_mapping(attribute_term_id)")
1682
+ .in("product_id", productIds),
1683
+ ]);
1684
+
1685
+ if (mediaResult.error) throw new Error(`Failed to export product media: ${mediaResult.error.message}`);
1686
+ if (categoryResult.error) throw new Error(`Failed to export product categories: ${categoryResult.error.message}`);
1687
+ if (variantResult.error) throw new Error(`Failed to export product variants: ${variantResult.error.message}`);
1688
+
1689
+ const mediaMap = new Map<string, Array<{ id: string; objectKey: string | null; sortOrder: number }>>();
1690
+ for (const media of mediaResult.data || []) {
1691
+ const productId = String((media as any).product_id);
1692
+ const list = mediaMap.get(productId) ?? [];
1693
+ list.push({
1694
+ id: String((media as any).media_id),
1695
+ objectKey: (media as any).media?.object_key ?? null,
1696
+ sortOrder: Number((media as any).sort_order ?? 0),
1697
+ });
1698
+ mediaMap.set(productId, list);
1699
+ }
1700
+
1701
+ const categoryMap = new Map<string, string[]>();
1702
+ for (const categoryRow of categoryResult.data || []) {
1703
+ const productId = String((categoryRow as any).product_id);
1704
+ const list = categoryMap.get(productId) ?? [];
1705
+ if ((categoryRow as any).category?.slug) list.push((categoryRow as any).category.slug);
1706
+ categoryMap.set(productId, list);
1707
+ }
1708
+
1709
+ const variantMap = new Map<string, BackupProductVariantRecord[]>();
1710
+ for (const variant of variantResult.data || []) {
1711
+ const productId = String((variant as any).product_id);
1712
+ const list = variantMap.get(productId) ?? [];
1713
+ list.push({
1714
+ id: (variant as any).id,
1715
+ sku: (variant as any).sku,
1716
+ upc: (variant as any).upc,
1717
+ price: minorToMajor((variant as any).price) ?? 0,
1718
+ prices: priceMapMinorToMajor((variant as any).prices),
1719
+ sale_price: minorToMajor((variant as any).sale_price),
1720
+ sale_prices: salePriceMapMinorToMajor((variant as any).sale_prices),
1721
+ stock_quantity: Number((variant as any).stock_quantity ?? 0),
1722
+ main_media_id: (variant as any).main_media_id,
1723
+ main_media_object_key: (variant as any).media?.object_key ?? null,
1724
+ attribute_term_ids: ((variant as any).variant_attribute_mapping || [])
1725
+ .map((mapping: any) => mapping.attribute_term_id)
1726
+ .filter(Boolean),
1727
+ });
1728
+ variantMap.set(productId, list);
1729
+ }
1730
+
1731
+ return products.map((product: any): BackupProductRecord => {
1732
+ const media = (mediaMap.get(String(product.id)) || []).sort((a, b) => a.sortOrder - b.sortOrder);
1733
+ return {
1734
+ id: product.id,
1735
+ translation_group_id: product.translation_group_id,
1736
+ language_code: product.languages?.code || "",
1737
+ product_type: product.product_type,
1738
+ payment_provider: product.payment_provider,
1739
+ title: product.title,
1740
+ slug: product.slug,
1741
+ sku: product.sku,
1742
+ upc: product.upc,
1743
+ status: product.status,
1744
+ price: minorToMajor(product.price) ?? 0,
1745
+ prices: priceMapMinorToMajor(product.prices),
1746
+ sale_price: minorToMajor(product.sale_price),
1747
+ sale_prices: salePriceMapMinorToMajor(product.sale_prices),
1748
+ stock: Number(product.stock ?? 0),
1749
+ is_taxable: Boolean(product.is_taxable),
1750
+ meta_title: product.meta_title,
1751
+ meta_description: product.meta_description,
1752
+ short_description: product.short_description,
1753
+ description_json: product.description_json,
1754
+ description_blocks: blockMap.get(String(product.id)) || [],
1755
+ category_slugs: categoryMap.get(String(product.id)) || [],
1756
+ media_ids: media.map((item) => item.id),
1757
+ media_object_keys: media.map((item) => item.objectKey).filter(Boolean) as string[],
1758
+ variants: variantMap.get(String(product.id)) || [],
1759
+ freemius_product_id: product.freemius_product_id,
1760
+ freemius_plan_id: product.freemius_plan_id,
1761
+ trial_period_days: Number(product.trial_period_days ?? 0),
1762
+ trial_requires_payment_method: Boolean(product.trial_requires_payment_method),
1763
+ };
1764
+ });
1765
+ }
1766
+
1767
+ function pageToCsvRow(page: BackupPageRecord) {
1768
+ const firstText = page.blocks.find((block) => block.block_type === "text" && isRecord(block.content));
1769
+ return {
1770
+ ...page,
1771
+ content_html: isRecord(firstText?.content) ? String(firstText?.content.html_content ?? "") : "",
1772
+ blocks_json: toJsonCell(page.blocks),
1773
+ };
1774
+ }
1775
+
1776
+ function postToCsvRow(post: BackupPostRecord) {
1777
+ return {
1778
+ ...pageToCsvRow(post),
1779
+ label: post.label ?? "",
1780
+ excerpt: post.excerpt ?? "",
1781
+ subtitle: post.subtitle ?? "",
1782
+ published_at: post.published_at ?? "",
1783
+ };
1784
+ }
1785
+
1786
+ function productToCsvRow(product: BackupProductRecord) {
1787
+ const firstText = product.description_blocks.find((block) => block.block_type === "text" && isRecord(block.content));
1788
+ return {
1789
+ id: product.id ?? "",
1790
+ translation_group_id: product.translation_group_id ?? "",
1791
+ language_code: product.language_code,
1792
+ product_type: product.product_type,
1793
+ payment_provider: product.payment_provider,
1794
+ title: product.title,
1795
+ slug: product.slug,
1796
+ sku: product.sku,
1797
+ upc: product.upc ?? "",
1798
+ status: product.status,
1799
+ price: product.price,
1800
+ prices_json: toJsonCell(product.prices),
1801
+ sale_price: product.sale_price ?? "",
1802
+ sale_prices_json: toJsonCell(product.sale_prices),
1803
+ stock: product.stock,
1804
+ is_taxable: String(product.is_taxable),
1805
+ meta_title: product.meta_title ?? "",
1806
+ meta_description: product.meta_description ?? "",
1807
+ short_description: product.short_description ?? "",
1808
+ description_html: isRecord(firstText?.content) ? String(firstText?.content.html_content ?? "") : "",
1809
+ description_blocks_json: toJsonCell(product.description_blocks),
1810
+ description_json: toJsonCell(product.description_json),
1811
+ category_slugs: (product.category_slugs || []).join(";"),
1812
+ media_ids: (product.media_ids || []).join(";"),
1813
+ media_object_keys: (product.media_object_keys || []).join(";"),
1814
+ variants_json: toJsonCell(product.variants),
1815
+ freemius_product_id: product.freemius_product_id ?? "",
1816
+ freemius_plan_id: product.freemius_plan_id ?? "",
1817
+ trial_period_days: product.trial_period_days ?? 0,
1818
+ trial_requires_payment_method: String(Boolean(product.trial_requires_payment_method)),
1819
+ };
1820
+ }
1821
+
1822
+ export async function exportCsv(params: {
1823
+ contentType: CmsContentType;
1824
+ languageId?: number;
1825
+ }) {
1826
+ const auth = await requireTransferUser(false);
1827
+ const records =
1828
+ params.contentType === "pages"
1829
+ ? await exportPages(auth.supabase, params.languageId)
1830
+ : params.contentType === "posts"
1831
+ ? await exportPosts(auth.supabase, params.languageId)
1832
+ : await exportProducts(auth.supabase, params.languageId);
1833
+
1834
+ const rows =
1835
+ params.contentType === "pages"
1836
+ ? (records as BackupPageRecord[]).map(pageToCsvRow)
1837
+ : params.contentType === "posts"
1838
+ ? (records as BackupPostRecord[]).map(postToCsvRow)
1839
+ : (records as BackupProductRecord[]).map(productToCsvRow);
1840
+
1841
+ return stringifyCsv(rows, getCsvHeaders(params.contentType));
1842
+ }
1843
+
1844
+ export async function exportBackupBundle() {
1845
+ const auth = await requireTransferUser(true);
1846
+ const { languages } = await loadLanguages(auth.supabase);
1847
+ const bundle: CmsBackupBundleV1 = {
1848
+ version: 1,
1849
+ exported_at: new Date().toISOString(),
1850
+ languages,
1851
+ content: {
1852
+ pages: await exportPages(auth.supabase),
1853
+ posts: await exportPosts(auth.supabase),
1854
+ products: await exportProducts(auth.supabase),
1855
+ },
1856
+ custom_blocks: await exportCustomBlockRecords(auth.supabase),
1857
+ };
1858
+
1859
+ return JSON.stringify(bundle, null, 2);
1860
+ }
1861
+
1862
+ function parseBundle(content: string): CmsBackupBundleV1 {
1863
+ const parsed = JSON.parse(content) as CmsBackupBundleV1;
1864
+ if (!parsed || parsed.version !== 1 || !isRecord(parsed.content)) {
1865
+ throw new Error("Backup bundle must be a NextBlock content backup with version 1.");
1866
+ }
1867
+
1868
+ return parsed;
1869
+ }
1870
+
1871
+ export async function dryRunBundleImport(params: {
1872
+ bundleJson: string;
1873
+ contentTypes: CmsContentType[];
1874
+ options: CmsImportOptions;
1875
+ includeBlocks?: boolean;
1876
+ }) {
1877
+ const auth = await requireTransferUser(true);
1878
+ const bundle = parseBundle(params.bundleJson);
1879
+ const summary = emptySummary();
1880
+
1881
+ for (const contentType of params.contentTypes) {
1882
+ const rows = bundle.content[contentType] || [];
1883
+ const result = await prepareImports({
1884
+ supabase: auth.supabase,
1885
+ contentType,
1886
+ rows: rows as ImportSourceRow[],
1887
+ options: { ...params.options, skipUnavailableProductContent: true },
1888
+ });
1889
+
1890
+ mergeBundleSummary(summary, result.summary);
1891
+ }
1892
+
1893
+ if (params.includeBlocks) {
1894
+ const blockResult = await prepareBlocksLibraryImport({
1895
+ supabase: auth.supabase,
1896
+ records: Array.isArray(bundle.custom_blocks) ? bundle.custom_blocks : [],
1897
+ options: { conflictMode: params.options.conflictMode },
1898
+ });
1899
+ mergeBundleSummary(summary, blockResult.summary);
1900
+ }
1901
+
1902
+ summary.success = summary.errors.length === 0;
1903
+ return summary;
1904
+ }
1905
+
1906
+ export async function applyBundleImport(params: {
1907
+ bundleJson: string;
1908
+ contentTypes: CmsContentType[];
1909
+ options: CmsImportOptions;
1910
+ includeBlocks?: boolean;
1911
+ }) {
1912
+ const auth = await requireTransferUser(true);
1913
+ const bundle = parseBundle(params.bundleJson);
1914
+ const summary = emptySummary();
1915
+ const allPrepared: PreparedImport[] = [];
1916
+ let preparedBlocks: PreparedBlockImport[] = [];
1917
+
1918
+ for (const contentType of params.contentTypes) {
1919
+ const rows = bundle.content[contentType] || [];
1920
+ const result = await prepareImports({
1921
+ supabase: auth.supabase,
1922
+ contentType,
1923
+ rows: rows as ImportSourceRow[],
1924
+ options: { ...params.options, skipUnavailableProductContent: true },
1925
+ });
1926
+
1927
+ mergeBundleSummary(summary, result.summary);
1928
+ allPrepared.push(...(result.prepared as PreparedImport[]));
1929
+ }
1930
+
1931
+ if (params.includeBlocks) {
1932
+ const blockResult = await prepareBlocksLibraryImport({
1933
+ supabase: auth.supabase,
1934
+ records: Array.isArray(bundle.custom_blocks) ? bundle.custom_blocks : [],
1935
+ options: { conflictMode: params.options.conflictMode },
1936
+ });
1937
+ mergeBundleSummary(summary, blockResult.summary);
1938
+ preparedBlocks = blockResult.prepared;
1939
+ }
1940
+
1941
+ summary.success = summary.errors.length === 0;
1942
+ if (!summary.success) {
1943
+ return summary;
1944
+ }
1945
+
1946
+ await applyPreparedImports(auth, allPrepared, params.options);
1947
+ if (preparedBlocks.length > 0) {
1948
+ await applyPreparedBlockImports(auth.supabase, preparedBlocks);
1949
+ }
1950
+ return summary;
1951
+ }
1952
+
1953
+ function mergeBundleSummary(target: CmsImportSummary, source: CmsImportSummary) {
1954
+ target.totalRows += source.totalRows;
1955
+ target.created += source.created;
1956
+ target.updated += source.updated;
1957
+ target.skipped += source.skipped;
1958
+ target.errors.push(...source.errors);
1959
+ target.warnings.push(...source.warnings);
1960
+ target.preview.push(...source.preview);
1961
+ }
1962
+
1963
+ // ---------------------------------------------------------------------------
1964
+ // Blocks Library (custom block definitions) transfer
1965
+ //
1966
+ // Custom blocks are nested JSON (fields + layout_schema), so they use the same
1967
+ // JSON bundle approach as the content backup rather than CSV. The Blocks Library
1968
+ // page exports/imports a dedicated bundle, and the content backup embeds the
1969
+ // same records under `custom_blocks`.
1970
+ // ---------------------------------------------------------------------------
1971
+
1972
+ export const BLOCKS_LIBRARY_BUNDLE_TYPE = "nextblock-blocks-library-backup";
1973
+ export const BLOCKS_LIBRARY_BUNDLE_VERSION = 1;
1974
+
1975
+ const CUSTOM_BLOCK_DEFINITION_SELECT =
1976
+ "id, slug, name, description, fields, layout_schema, is_original";
1977
+
1978
+ export interface BlocksLibraryImportOptions {
1979
+ conflictMode: CmsImportConflictMode;
1980
+ }
1981
+
1982
+ interface PreparedBlockImport {
1983
+ rowNumber: number;
1984
+ action: "create" | "update";
1985
+ targetId: string | null;
1986
+ slug: string;
1987
+ payload: {
1988
+ slug: string;
1989
+ name: string;
1990
+ description: string;
1991
+ fields: Json;
1992
+ layout_schema: Json;
1993
+ is_original: boolean;
1994
+ };
1995
+ }
1996
+
1997
+ function toJson(value: unknown): Json {
1998
+ return JSON.parse(JSON.stringify(value)) as Json;
1999
+ }
2000
+
2001
+ async function exportCustomBlockRecords(supabase: SupabaseAny): Promise<BackupCustomBlockRecord[]> {
2002
+ const { data, error } = await supabase
2003
+ .from("custom_block_definitions")
2004
+ .select(CUSTOM_BLOCK_DEFINITION_SELECT)
2005
+ .order("name", { ascending: true });
2006
+
2007
+ if (error) {
2008
+ throw new Error(`Failed to export custom block definitions: ${error.message}`);
2009
+ }
2010
+
2011
+ return (data || []).map((row: any) => ({
2012
+ slug: row.slug,
2013
+ name: row.name,
2014
+ description: typeof row.description === "string" ? row.description : "",
2015
+ fields: row.fields,
2016
+ layout_schema: row.layout_schema,
2017
+ is_original: Boolean(row.is_original),
2018
+ }));
2019
+ }
2020
+
2021
+ function extractBlocksLibraryRecords(bundleJson: string): BackupCustomBlockRecord[] {
2022
+ let parsed: unknown;
2023
+ try {
2024
+ parsed = JSON.parse(bundleJson);
2025
+ } catch {
2026
+ throw new Error("Blocks library file is not valid JSON.");
2027
+ }
2028
+
2029
+ if (!isRecord(parsed)) {
2030
+ throw new Error("Blocks library file must be a JSON object.");
2031
+ }
2032
+
2033
+ // Accept a dedicated blocks-library bundle (`blocks`) or a full content
2034
+ // backup bundle that carries the same records under `custom_blocks`.
2035
+ const raw = Array.isArray((parsed as any).blocks)
2036
+ ? (parsed as any).blocks
2037
+ : Array.isArray((parsed as any).custom_blocks)
2038
+ ? (parsed as any).custom_blocks
2039
+ : null;
2040
+
2041
+ if (!raw) {
2042
+ throw new Error('Blocks library file is missing its "blocks" array.');
2043
+ }
2044
+
2045
+ return raw as BackupCustomBlockRecord[];
2046
+ }
2047
+
2048
+ async function prepareBlocksLibraryImport(params: {
2049
+ supabase: SupabaseAny;
2050
+ records: BackupCustomBlockRecord[];
2051
+ options: BlocksLibraryImportOptions;
2052
+ }): Promise<{ summary: CmsImportSummary; prepared: PreparedBlockImport[] }> {
2053
+ const summary = emptySummary();
2054
+ summary.totalRows = params.records.length;
2055
+ const prepared: PreparedBlockImport[] = [];
2056
+
2057
+ const { data, error } = await params.supabase
2058
+ .from("custom_block_definitions")
2059
+ .select("id, slug");
2060
+ if (error) {
2061
+ addMessage(summary.errors, 0, `Failed to load existing custom blocks: ${error.message}`);
2062
+ summary.success = false;
2063
+ return { summary, prepared };
2064
+ }
2065
+
2066
+ const existingBySlug = new Map<string, string>();
2067
+ const knownSlugs = new Set<string>();
2068
+ for (const row of data || []) {
2069
+ existingBySlug.set(String((row as any).slug), String((row as any).id));
2070
+ knownSlugs.add(String((row as any).slug));
2071
+ }
2072
+ const seenSlugs = new Set<string>();
2073
+
2074
+ for (const [index, record] of params.records.entries()) {
2075
+ const rowNumber = index + 1;
2076
+ if (!isRecord(record)) {
2077
+ addMessage(summary.errors, rowNumber, "Block entry must be an object.");
2078
+ continue;
2079
+ }
2080
+
2081
+ const parsedDefinition = customBlockDefinitionCreateSchema.safeParse({
2082
+ description: record.description,
2083
+ fields: record.fields,
2084
+ is_original: record.is_original,
2085
+ layout_schema: record.layout_schema,
2086
+ name: record.name,
2087
+ slug: record.slug,
2088
+ });
2089
+
2090
+ const label =
2091
+ (typeof record.slug === "string" && record.slug) ||
2092
+ (typeof record.name === "string" && record.name) ||
2093
+ `entry ${rowNumber}`;
2094
+
2095
+ if (!parsedDefinition.success) {
2096
+ for (const issue of parsedDefinition.error.issues) {
2097
+ const path = issue.path.join(".");
2098
+ addMessage(
2099
+ summary.errors,
2100
+ rowNumber,
2101
+ `Block "${label}": ${path ? `${path}: ` : ""}${issue.message}`
2102
+ );
2103
+ }
2104
+ continue;
2105
+ }
2106
+
2107
+ const definition = parsedDefinition.data;
2108
+ const existingId = existingBySlug.get(definition.slug) ?? null;
2109
+
2110
+ let action: "create" | "update";
2111
+ let targetId: string | null;
2112
+ let slug = definition.slug;
2113
+ let isOriginal = definition.is_original;
2114
+
2115
+ if (params.options.conflictMode === "overwrite_existing" && existingId) {
2116
+ action = "update";
2117
+ targetId = existingId;
2118
+ } else {
2119
+ action = "create";
2120
+ targetId = null;
2121
+ // "Create new copies" always clones; "Overwrite" still needs a fresh slug
2122
+ // when it collides with an existing block or another row in this import.
2123
+ const mustRename =
2124
+ params.options.conflictMode === "create_new" ||
2125
+ knownSlugs.has(slug) ||
2126
+ seenSlugs.has(slug);
2127
+ if (mustRename) {
2128
+ slug = buildCustomBlockCopySlug(slug, new Set([...knownSlugs, ...seenSlugs]));
2129
+ isOriginal = false;
2130
+ }
2131
+ }
2132
+
2133
+ seenSlugs.add(slug);
2134
+ knownSlugs.add(slug);
2135
+
2136
+ prepared.push({
2137
+ rowNumber,
2138
+ action,
2139
+ targetId,
2140
+ slug,
2141
+ payload: {
2142
+ slug,
2143
+ name: definition.name,
2144
+ description: definition.description,
2145
+ fields: toJson(definition.fields),
2146
+ layout_schema: toJson(definition.layout_schema),
2147
+ is_original: isOriginal,
2148
+ },
2149
+ });
2150
+ }
2151
+
2152
+ summary.created = prepared.filter((item) => item.action === "create").length;
2153
+ summary.updated = prepared.filter((item) => item.action === "update").length;
2154
+ summary.success = summary.errors.length === 0;
2155
+ return { summary, prepared };
2156
+ }
2157
+
2158
+ async function applyPreparedBlockImports(
2159
+ supabase: SupabaseAny,
2160
+ prepared: PreparedBlockImport[]
2161
+ ): Promise<void> {
2162
+ if (prepared.length === 0) return;
2163
+ const touched: Array<{ id: string; slug: string }> = [];
2164
+
2165
+ for (const item of prepared) {
2166
+ const writer =
2167
+ item.action === "update" && item.targetId
2168
+ ? supabase
2169
+ .from("custom_block_definitions")
2170
+ .update(item.payload as any)
2171
+ .eq("id", item.targetId)
2172
+ .select("id, slug")
2173
+ .single()
2174
+ : supabase
2175
+ .from("custom_block_definitions")
2176
+ .insert(item.payload as any)
2177
+ .select("id, slug")
2178
+ .single();
2179
+
2180
+ const { data, error } = await writer;
2181
+ if (error || !data) {
2182
+ throw new Error(
2183
+ `Failed to ${item.action} custom block "${item.slug}": ${error?.message ?? "unknown error"}`
2184
+ );
2185
+ }
2186
+ touched.push({ id: String((data as any).id), slug: String((data as any).slug) });
2187
+ }
2188
+
2189
+ revalidateTag(CUSTOM_BLOCK_DEFINITIONS_CACHE_TAG, "max");
2190
+ for (const item of touched) {
2191
+ revalidateTag(getCustomBlockDefinitionCacheTag(item.id), "max");
2192
+ revalidateTag(getCustomBlockDefinitionCacheTag(item.slug), "max");
2193
+ }
2194
+ revalidatePath("/cms/blocks");
2195
+ revalidatePath("/cms/custom-blocks");
2196
+ }
2197
+
2198
+ export async function exportBlocksLibraryBundle() {
2199
+ const auth = await requireTransferUser(false);
2200
+ const blocks = await exportCustomBlockRecords(auth.supabase);
2201
+ return JSON.stringify(
2202
+ {
2203
+ type: BLOCKS_LIBRARY_BUNDLE_TYPE,
2204
+ version: BLOCKS_LIBRARY_BUNDLE_VERSION,
2205
+ exported_at: new Date().toISOString(),
2206
+ blocks,
2207
+ },
2208
+ null,
2209
+ 2
2210
+ );
2211
+ }
2212
+
2213
+ export async function dryRunBlocksLibraryImport(params: {
2214
+ bundleJson: string;
2215
+ options: BlocksLibraryImportOptions;
2216
+ }) {
2217
+ const auth = await requireTransferUser(false);
2218
+ const records = extractBlocksLibraryRecords(params.bundleJson);
2219
+ const { summary } = await prepareBlocksLibraryImport({
2220
+ supabase: auth.supabase,
2221
+ records,
2222
+ options: params.options,
2223
+ });
2224
+ return summary;
2225
+ }
2226
+
2227
+ export async function applyBlocksLibraryImport(params: {
2228
+ bundleJson: string;
2229
+ options: BlocksLibraryImportOptions;
2230
+ }) {
2231
+ const auth = await requireTransferUser(false);
2232
+ const records = extractBlocksLibraryRecords(params.bundleJson);
2233
+ const { summary, prepared } = await prepareBlocksLibraryImport({
2234
+ supabase: auth.supabase,
2235
+ records,
2236
+ options: params.options,
2237
+ });
2238
+ if (!summary.success) {
2239
+ return summary;
2240
+ }
2241
+ await applyPreparedBlockImports(auth.supabase, prepared);
2242
+ return summary;
2243
+ }