create-nextblock 0.15.10 → 0.16.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 (266) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -0
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -2900
  30. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  31. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  32. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  33. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  34. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  35. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  37. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  38. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  39. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  40. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -552
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
  42. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  43. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  44. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  45. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +180 -3
  46. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
  47. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  48. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  49. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  50. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +12 -3
  51. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  52. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  53. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  54. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  55. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  56. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  58. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  59. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  60. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  61. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  63. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  64. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  65. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
  66. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  67. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  68. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  69. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  70. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  71. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  72. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  73. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
  74. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +248 -5
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +240 -5
  77. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  78. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  79. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  80. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  81. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  82. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  83. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  84. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  85. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  86. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  90. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  91. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  92. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  93. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  96. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  98. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  99. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  100. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  101. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  102. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  103. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
  104. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
  105. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
  106. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
  107. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
  108. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
  109. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
  110. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  111. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  112. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  113. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  114. package/templates/nextblock-template/app/layout.tsx +671 -671
  115. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  116. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  117. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  118. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  119. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  120. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  121. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  122. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  123. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  124. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  125. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  126. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  127. package/templates/nextblock-template/app/providers.tsx +96 -96
  128. package/templates/nextblock-template/app/robots.ts +123 -0
  129. package/templates/nextblock-template/app/sitemap.ts +1 -1
  130. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  131. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  132. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  133. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  134. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  135. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  136. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  137. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  138. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  139. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  140. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  141. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  142. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  143. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  144. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  145. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  146. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  147. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  148. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  149. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  150. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  151. package/templates/nextblock-template/components/Header.tsx +38 -38
  152. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  153. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  154. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  155. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  156. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  157. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  158. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  159. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  160. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  161. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  162. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  163. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  164. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  165. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  166. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  167. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  168. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  169. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  170. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  171. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  172. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  173. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  174. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  175. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  176. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  177. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  178. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  179. package/templates/nextblock-template/components/form-message.tsx +32 -32
  180. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
  187. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  188. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  189. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  190. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  191. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  192. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  193. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  194. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  195. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  196. package/templates/nextblock-template/docs/README.md +42 -42
  197. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  198. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  199. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  200. package/templates/nextblock-template/index.d.ts +7 -7
  201. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  202. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  203. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  204. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  205. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  206. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  207. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  208. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  209. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  210. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  211. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  212. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  213. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
  215. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  216. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  217. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  218. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  219. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  220. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  221. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  222. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  223. package/templates/nextblock-template/lib/search/types.ts +27 -27
  224. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
  231. package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -167
  239. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  240. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  241. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  242. package/templates/nextblock-template/lib/site-url.ts +102 -102
  243. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  244. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  245. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  246. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  247. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  248. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  249. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  250. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  251. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  252. package/templates/nextblock-template/next-env.d.ts +0 -1
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/proxy.ts +240 -20
  256. package/templates/nextblock-template/scripts/backup.js +115 -115
  257. package/templates/nextblock-template/scripts/restore.js +385 -385
  258. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  259. package/templates/nextblock-template/tailwind.config.js +25 -25
  260. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  261. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  262. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  263. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  264. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  265. package/tsconfig.base.json +3 -3
  266. package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
@@ -1,489 +1,489 @@
1
- "use server";
2
-
3
- import { createClient, getServiceRoleSupabaseClient, getProfileWithRoleServerSide } from "@nextblock-cms/db/server";
4
- import { revalidatePath } from "next/cache";
5
- import { cookies, headers } from "next/headers";
6
- import { sendEmail } from "./email";
7
-
8
- export interface SubmitInteractionInput {
9
- type: "review" | "comment";
10
- content: string;
11
- rating?: number;
12
- productId?: string;
13
- postId?: number;
14
- }
15
-
16
- /**
17
- * Submits a new comment or review. Default status is 'pending' for moderation.
18
- */
19
- export async function submitInteraction(input: SubmitInteractionInput) {
20
- const supabase = createClient();
21
-
22
- // 1. Authenticate user
23
- const { data: { user }, error: authError } = await supabase.auth.getUser();
24
- if (authError || !user) {
25
- return { error: "You must be logged in to submit a review or comment." };
26
- }
27
-
28
- // 2. Validate inputs
29
- if (!input.content || input.content.trim().length < 5) {
30
- return { error: "Content must be at least 5 characters long." };
31
- }
32
-
33
- if (input.type === "review") {
34
- if (!input.productId) {
35
- return { error: "Product ID is required for a review." };
36
- }
37
- if (!input.rating || input.rating < 1 || input.rating > 5) {
38
- return { error: "Rating must be between 1 and 5 stars." };
39
- }
40
- } else if (input.type === "comment") {
41
- if (!input.postId) {
42
- return { error: "Post ID is required for a comment." };
43
- }
44
- } else {
45
- return { error: "Invalid interaction type." };
46
- }
47
-
48
- try {
49
- // 3. Insert interaction
50
- const { data, error } = await supabase
51
- .from("cms_interactions" as any)
52
- .insert({
53
- type: input.type,
54
- status: "pending",
55
- content: input.content.trim(),
56
- rating: input.type === "review" ? input.rating : null,
57
- user_id: user.id,
58
- product_id: input.type === "review" ? input.productId : null,
59
- post_id: input.type === "comment" ? input.postId : null,
60
- reactions: {},
61
- })
62
- .select()
63
- .single();
64
-
65
- if (error) {
66
- console.error("Error inserting interaction:", error);
67
- return { error: `Failed to submit: ${error.message}` };
68
- }
69
-
70
- // 4. Revalidate moderation panel path
71
- revalidatePath("/cms/interactions");
72
-
73
- // 5. Send email notification asynchronously if emails are configured
74
- try {
75
- const admin = getServiceRoleSupabaseClient();
76
- if (admin) {
77
- const { data: config } = await admin
78
- .from("site_settings")
79
- .select("value")
80
- .eq("key", "interactions_notification_emails")
81
- .maybeSingle();
82
-
83
- const emailsString = (config?.value as any)?.emails || "";
84
-
85
- if (emailsString) {
86
- const host = (await headers()).get("host");
87
- const protocol = host?.includes("localhost") || host?.includes("127.0.0.1") ? "http" : "https";
88
- const origin = `${protocol}://${host}`;
89
-
90
- const capitalizedType = input.type.charAt(0).toUpperCase() + input.type.slice(1);
91
- const subject = `New Pending ${capitalizedType} Submitted`;
92
-
93
- const html = `
94
- <div style="font-family: sans-serif; padding: 20px; color: #333; max-width: 600px; margin: 0 auto; border: 1px solid #eee; border-radius: 8px;">
95
- {{brand_header}}
96
- <h2 style="color: #6366f1; margin-top: 0;">New Pending ${capitalizedType} Submitted</h2>
97
- <p>Hello,</p>
98
- <p>A new content interaction has been submitted and is currently <strong>pending moderation</strong>.</p>
99
- <hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;" />
100
- <div style="background-color: #f9fafb; padding: 15px; border-radius: 6px; margin-bottom: 20px;">
101
- <p style="margin: 0 0 8px 0;"><strong>Type:</strong> ${capitalizedType}</p>
102
- ${input.type === "review" && input.rating ? `<p style="margin: 0 0 8px 0;"><strong>Rating:</strong> ${input.rating} / 5</p>` : ""}
103
- <p style="margin: 0 0 8px 0;"><strong>Content:</strong></p>
104
- <blockquote style="margin: 0; padding-left: 10px; border-left: 3px solid #6366f1; color: #555; font-style: italic;">
105
- ${escapeHtml(input.content.trim())}
106
- </blockquote>
107
- </div>
108
- <p>Please log in to the moderation dashboard to approve or deny this interaction:</p>
109
- <p style="margin-top: 20px;">
110
- <a href="${origin}/cms/interactions" style="background-color: #6366f1; color: white; padding: 10px 20px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">
111
- Go to Moderation Dashboard
112
- </a>
113
- </p>
114
- <hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;" />
115
- <p style="font-size: 11px; color: #888;">This is an automated notification from your CMS.</p>
116
- </div>
117
- `;
118
-
119
- const text = `
120
- New Pending ${capitalizedType} Submitted
121
-
122
- A new ${input.type} has been submitted and is currently pending moderation.
123
-
124
- Type: ${capitalizedType}
125
- ${input.type === "review" && input.rating ? `Rating: ${input.rating} / 5\n` : ""}
126
- Content: "${input.content.trim()}"
127
-
128
- Moderation Dashboard: ${origin}/cms/interactions
129
- `;
130
-
131
- sendEmail({
132
- to: emailsString,
133
- subject,
134
- text,
135
- html,
136
- }).catch((err) =>
137
- console.error("Failed to send pending interaction email notification:", err)
138
- );
139
- }
140
- }
141
- } catch (emailErr) {
142
- console.error("Failed to process email notifications:", emailErr);
143
- }
144
-
145
- return { success: true, data };
146
- } catch (err: any) {
147
- console.error("Submit interaction failed:", err);
148
- return { error: err.message || "An unexpected error occurred." };
149
- }
150
- }
151
-
152
- /**
153
- * Toggles a reaction (like) on a comment or review. Rate-limited and validated using cookies.
154
- */
155
- export async function toggleReaction(interactionId: string, reactionType = "likes") {
156
- if (!interactionId) return { error: "Interaction ID is required." };
157
-
158
- try {
159
- // Rate limit / duplicate prevention using cookies
160
- const cookieStore = await cookies();
161
- const reactedCookie = cookieStore.get("reacted_interactions")?.value;
162
- let reactedList: string[] = [];
163
-
164
- try {
165
- if (reactedCookie) {
166
- reactedList = JSON.parse(reactedCookie);
167
- }
168
- } catch {
169
- reactedList = [];
170
- }
171
-
172
- const hasReacted = reactedList.includes(interactionId);
173
-
174
- // Call service role client since visitors don't have update RLS policies
175
- const admin = getServiceRoleSupabaseClient();
176
-
177
- // Fetch current reactions
178
- const { data: interaction, error: fetchError } = await admin
179
- .from("cms_interactions")
180
- .select("reactions, type, product_id, post_id, products(slug), posts(slug)")
181
- .eq("id", interactionId)
182
- .single();
183
-
184
- if (fetchError || !interaction) {
185
- return { error: "Interaction not found." };
186
- }
187
-
188
- const reactions = (interaction.reactions as Record<string, number>) || {};
189
- const currentCount = reactions[reactionType] || 0;
190
- const newCount = hasReacted ? Math.max(0, currentCount - 1) : currentCount + 1;
191
- reactions[reactionType] = newCount;
192
-
193
- // Save back to db
194
- const { error: updateError } = await admin
195
- .from("cms_interactions")
196
- .update({ reactions })
197
- .eq("id", interactionId);
198
-
199
- if (updateError) {
200
- console.error("Error updating reactions:", updateError);
201
- return { error: "Failed to update reaction." };
202
- }
203
-
204
- // Update the cookie
205
- if (hasReacted) {
206
- reactedList = reactedList.filter(id => id !== interactionId);
207
- } else {
208
- reactedList.push(interactionId);
209
- }
210
-
211
- cookieStore.set("reacted_interactions", JSON.stringify(reactedList), {
212
- maxAge: 60 * 60 * 24 * 365, // 1 year
213
- httpOnly: true,
214
- path: "/",
215
- sameSite: "lax",
216
- });
217
-
218
- // Revalidate paths to reflect reaction count updates
219
- const resolvedProduct = interaction.products as any;
220
- const resolvedPost = interaction.posts as any;
221
-
222
- if (interaction.product_id && resolvedProduct?.slug) {
223
- revalidatePath(`/product/${resolvedProduct.slug}`);
224
- } else if (interaction.post_id && resolvedPost?.slug) {
225
- revalidatePath(`/article/${resolvedPost.slug}`);
226
- }
227
- revalidatePath("/cms/interactions");
228
-
229
- return { success: true, count: newCount, hasReacted: !hasReacted };
230
- } catch (err: any) {
231
- console.error("Toggle reaction failed:", err);
232
- return { error: err.message || "An unexpected error occurred." };
233
- }
234
- }
235
-
236
- /**
237
- * Updates an interaction's status (approved or denied). Admin/Moderator only.
238
- */
239
- /**
240
- * Visitor text goes straight into an HTML mail body below. Without this a review
241
- * containing markup renders as live HTML inside the moderator's mail client.
242
- */
243
- function escapeHtml(value: string): string {
244
- return value
245
- .replace(/&/g, '&amp;')
246
- .replace(/</g, '&lt;')
247
- .replace(/>/g, '&gt;')
248
- .replace(/"/g, '&quot;')
249
- .replace(/'/g, '&#39;');
250
- }
251
-
252
- /**
253
- * Publish a staff answer under a review or a post comment.
254
- *
255
- * This is the PUBLIC lane of the Messages inbox, and it is a different shape from a
256
- * private thread on purpose: the parent is already visible on the site and already
257
- * belongs to a registered account, so the reply is published content, not a private
258
- * message. Putting it behind a token link would hide a public answer.
259
- *
260
- * A reply is stored as `type: 'comment'` with a NULL rating, carrying the PARENT's
261
- * target. That is not a shortcut — it is the only shape the existing constraints admit
262
- * (`check_rating_only_for_review` is an exhaustive OR over 'review' and 'comment'), and
263
- * it is what keeps the reply out of `update_product_ratings`, which aggregates only
264
- * `type='review' AND status='approved'`. A reply must never move a product's stars.
265
- */
266
- export async function replyToInteraction(parentId: string, content: string) {
267
- const supabase = createClient();
268
-
269
- const { data: { user }, error: authError } = await supabase.auth.getUser();
270
- if (authError || !user) {
271
- return { error: "Not authenticated" };
272
- }
273
-
274
- const profile = await getProfileWithRoleServerSide(user.id);
275
- if (!profile || profile.role !== "ADMIN") {
276
- return { error: "Unauthorized. Admin permissions required to reply." };
277
- }
278
-
279
- const body = content.trim().slice(0, 5000);
280
- if (body.length < 2) {
281
- return { error: "Write a reply before sending." };
282
- }
283
-
284
- try {
285
- const admin = getServiceRoleSupabaseClient();
286
-
287
- const { data: parent, error: parentError } = await admin
288
- .from("cms_interactions")
289
- .select("id, product_id, post_id, parent_id, products(slug), posts(slug)")
290
- .eq("id", parentId)
291
- .single();
292
-
293
- if (parentError || !parent) {
294
- return { error: "That review or comment no longer exists." };
295
- }
296
-
297
- // One level only. The schema enforces this too; failing here gives a better message.
298
- if (parent.parent_id) {
299
- return { error: "You can only reply to a top-level review or comment." };
300
- }
301
-
302
- const { error: insertError } = await admin.from("cms_interactions").insert({
303
- type: "comment",
304
- status: "approved",
305
- content: body,
306
- rating: null,
307
- user_id: user.id,
308
- parent_id: parent.id,
309
- product_id: parent.product_id,
310
- post_id: parent.post_id,
311
- });
312
-
313
- if (insertError) {
314
- console.error("Error publishing reply:", insertError.message);
315
- return { error: "Could not publish that reply." };
316
- }
317
-
318
- const productSlug = (parent.products as { slug?: string } | null)?.slug;
319
- const postSlug = (parent.posts as { slug?: string } | null)?.slug;
320
- if (productSlug) revalidatePath(`/product/${productSlug}`);
321
- if (postSlug) revalidatePath(`/article/${postSlug}`);
322
- revalidatePath("/cms/messages");
323
- revalidatePath("/cms/interactions");
324
-
325
- return { success: true };
326
- } catch (error) {
327
- console.error("replyToInteraction failed:", error);
328
- return { error: "Could not publish that reply." };
329
- }
330
- }
331
-
332
- export async function updateInteractionStatus(interactionId: string, status: "approved" | "denied") {
333
- const supabase = createClient();
334
-
335
- // 1. Authenticate user
336
- const { data: { user }, error: authError } = await supabase.auth.getUser();
337
- if (authError || !user) {
338
- return { error: "Not authenticated" };
339
- }
340
-
341
- // 2. Authorize as Admin or Writer
342
- const profile = await getProfileWithRoleServerSide(user.id);
343
- if (!profile || (profile.role !== "ADMIN" && profile.role !== "WRITER")) {
344
- return { error: "Unauthorized. Admin or Writer permissions required." };
345
- }
346
-
347
- // 3. Admin-only rule for denying/approving if strict
348
- if (profile.role !== "ADMIN") {
349
- // If writers are not allowed to moderate, block it. The spec says:
350
- // "Admin-only permission action to switch states between approved or denied."
351
- // So let's enforce STRICT Admin only for status updates.
352
- return { error: "Unauthorized. Admin permissions required to moderate." };
353
- }
354
-
355
- try {
356
- const admin = getServiceRoleSupabaseClient();
357
-
358
- // Fetch interaction details for path revalidation
359
- const { data: interaction, error: fetchError } = await admin
360
- .from("cms_interactions")
361
- .select("product_id, post_id, products(slug), posts(slug)")
362
- .eq("id", interactionId)
363
- .single();
364
-
365
- if (fetchError || !interaction) {
366
- return { error: "Interaction not found." };
367
- }
368
-
369
- // 4. Update status
370
- const { error: updateError } = await admin
371
- .from("cms_interactions")
372
- .update({ status })
373
- .eq("id", interactionId);
374
-
375
- if (updateError) {
376
- console.error("Error updating status:", updateError);
377
- return { error: `Failed to update status: ${updateError.message}` };
378
- }
379
-
380
- // 5. Revalidate paths
381
- const resolvedProduct = interaction.products as any;
382
- const resolvedPost = interaction.posts as any;
383
-
384
- if (interaction.product_id && resolvedProduct?.slug) {
385
- revalidatePath(`/product/${resolvedProduct.slug}`);
386
- } else if (interaction.post_id && resolvedPost?.slug) {
387
- revalidatePath(`/article/${resolvedPost.slug}`);
388
- }
389
- revalidatePath("/cms/interactions");
390
-
391
- return { success: true };
392
- } catch (err: any) {
393
- console.error("Update interaction status failed:", err);
394
- return { error: err.message || "An unexpected error occurred." };
395
- }
396
- }
397
-
398
- /**
399
- * Fetches the interactions notification emails from site_settings.
400
- */
401
- export async function getNotificationEmails() {
402
- const supabase = createClient();
403
-
404
- // Authenticate & authorize
405
- const { data: { user } } = await supabase.auth.getUser();
406
- if (!user) return { error: "Not authenticated" };
407
-
408
- const profile = await getProfileWithRoleServerSide(user.id);
409
- if (!profile || profile.role !== "ADMIN") {
410
- return { error: "Unauthorized. Admin role required." };
411
- }
412
-
413
- try {
414
- const { data, error } = await supabase
415
- .from("site_settings")
416
- .select("value")
417
- .eq("key", "interactions_notification_emails")
418
- .maybeSingle();
419
-
420
- if (error) throw error;
421
-
422
- return { success: true, emails: (data?.value as any)?.emails || "" };
423
- } catch (err: any) {
424
- console.error("Failed to fetch notification emails:", err);
425
- return { error: err.message || "Failed to fetch settings." };
426
- }
427
- }
428
-
429
- /**
430
- * Saves the interactions notification emails to site_settings.
431
- */
432
- export async function saveNotificationEmails(emails: string) {
433
- const supabase = createClient();
434
-
435
- // Authenticate & authorize
436
- const { data: { user } } = await supabase.auth.getUser();
437
- if (!user) return { error: "Not authenticated" };
438
-
439
- const profile = await getProfileWithRoleServerSide(user.id);
440
- if (!profile || profile.role !== "ADMIN") {
441
- return { error: "Unauthorized. Admin role required." };
442
- }
443
-
444
- // Validate every address, dedupe (case-insensitive), and normalize to lowercase.
445
- // Mirrors the client-side check so a crafted/legacy payload can't persist junk.
446
- const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
447
- const tokens = emails
448
- .split(",")
449
- .map((e) => e.trim())
450
- .filter(Boolean);
451
-
452
- const seen = new Set<string>();
453
- const valid: string[] = [];
454
- const invalid: string[] = [];
455
- for (const token of tokens) {
456
- const lower = token.toLowerCase();
457
- if (!emailRe.test(lower)) {
458
- invalid.push(token);
459
- continue;
460
- }
461
- if (seen.has(lower)) continue;
462
- seen.add(lower);
463
- valid.push(lower);
464
- }
465
-
466
- if (invalid.length > 0) {
467
- return {
468
- error: `Invalid email address${invalid.length > 1 ? "es" : ""}: ${invalid.join(", ")}`,
469
- };
470
- }
471
-
472
- const cleaned = valid.join(", ");
473
-
474
- try {
475
- const { error } = await supabase
476
- .from("site_settings")
477
- .upsert({
478
- key: "interactions_notification_emails",
479
- value: { emails: cleaned },
480
- });
481
-
482
- if (error) throw error;
483
-
484
- return { success: true, emails: cleaned };
485
- } catch (err: any) {
486
- console.error("Failed to save notification emails:", err);
487
- return { error: err.message || "Failed to save settings." };
488
- }
489
- }
1
+ "use server";
2
+
3
+ import { createClient, getServiceRoleSupabaseClient, getProfileWithRoleServerSide } from "@nextblock-cms/db/server";
4
+ import { revalidatePath } from "next/cache";
5
+ import { cookies, headers } from "next/headers";
6
+ import { sendEmail } from "./email";
7
+
8
+ export interface SubmitInteractionInput {
9
+ type: "review" | "comment";
10
+ content: string;
11
+ rating?: number;
12
+ productId?: string;
13
+ postId?: number;
14
+ }
15
+
16
+ /**
17
+ * Submits a new comment or review. Default status is 'pending' for moderation.
18
+ */
19
+ export async function submitInteraction(input: SubmitInteractionInput) {
20
+ const supabase = createClient();
21
+
22
+ // 1. Authenticate user
23
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
24
+ if (authError || !user) {
25
+ return { error: "You must be logged in to submit a review or comment." };
26
+ }
27
+
28
+ // 2. Validate inputs
29
+ if (!input.content || input.content.trim().length < 5) {
30
+ return { error: "Content must be at least 5 characters long." };
31
+ }
32
+
33
+ if (input.type === "review") {
34
+ if (!input.productId) {
35
+ return { error: "Product ID is required for a review." };
36
+ }
37
+ if (!input.rating || input.rating < 1 || input.rating > 5) {
38
+ return { error: "Rating must be between 1 and 5 stars." };
39
+ }
40
+ } else if (input.type === "comment") {
41
+ if (!input.postId) {
42
+ return { error: "Post ID is required for a comment." };
43
+ }
44
+ } else {
45
+ return { error: "Invalid interaction type." };
46
+ }
47
+
48
+ try {
49
+ // 3. Insert interaction
50
+ const { data, error } = await supabase
51
+ .from("cms_interactions" as any)
52
+ .insert({
53
+ type: input.type,
54
+ status: "pending",
55
+ content: input.content.trim(),
56
+ rating: input.type === "review" ? input.rating : null,
57
+ user_id: user.id,
58
+ product_id: input.type === "review" ? input.productId : null,
59
+ post_id: input.type === "comment" ? input.postId : null,
60
+ reactions: {},
61
+ })
62
+ .select()
63
+ .single();
64
+
65
+ if (error) {
66
+ console.error("Error inserting interaction:", error);
67
+ return { error: `Failed to submit: ${error.message}` };
68
+ }
69
+
70
+ // 4. Revalidate moderation panel path
71
+ revalidatePath("/cms/interactions");
72
+
73
+ // 5. Send email notification asynchronously if emails are configured
74
+ try {
75
+ const admin = getServiceRoleSupabaseClient();
76
+ if (admin) {
77
+ const { data: config } = await admin
78
+ .from("site_settings")
79
+ .select("value")
80
+ .eq("key", "interactions_notification_emails")
81
+ .maybeSingle();
82
+
83
+ const emailsString = (config?.value as any)?.emails || "";
84
+
85
+ if (emailsString) {
86
+ const host = (await headers()).get("host");
87
+ const protocol = host?.includes("localhost") || host?.includes("127.0.0.1") ? "http" : "https";
88
+ const origin = `${protocol}://${host}`;
89
+
90
+ const capitalizedType = input.type.charAt(0).toUpperCase() + input.type.slice(1);
91
+ const subject = `New Pending ${capitalizedType} Submitted`;
92
+
93
+ const html = `
94
+ <div style="font-family: sans-serif; padding: 20px; color: #333; max-width: 600px; margin: 0 auto; border: 1px solid #eee; border-radius: 8px;">
95
+ {{brand_header}}
96
+ <h2 style="color: #6366f1; margin-top: 0;">New Pending ${capitalizedType} Submitted</h2>
97
+ <p>Hello,</p>
98
+ <p>A new content interaction has been submitted and is currently <strong>pending moderation</strong>.</p>
99
+ <hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;" />
100
+ <div style="background-color: #f9fafb; padding: 15px; border-radius: 6px; margin-bottom: 20px;">
101
+ <p style="margin: 0 0 8px 0;"><strong>Type:</strong> ${capitalizedType}</p>
102
+ ${input.type === "review" && input.rating ? `<p style="margin: 0 0 8px 0;"><strong>Rating:</strong> ${input.rating} / 5</p>` : ""}
103
+ <p style="margin: 0 0 8px 0;"><strong>Content:</strong></p>
104
+ <blockquote style="margin: 0; padding-left: 10px; border-left: 3px solid #6366f1; color: #555; font-style: italic;">
105
+ ${escapeHtml(input.content.trim())}
106
+ </blockquote>
107
+ </div>
108
+ <p>Please log in to the moderation dashboard to approve or deny this interaction:</p>
109
+ <p style="margin-top: 20px;">
110
+ <a href="${origin}/cms/interactions" style="background-color: #6366f1; color: white; padding: 10px 20px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">
111
+ Go to Moderation Dashboard
112
+ </a>
113
+ </p>
114
+ <hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;" />
115
+ <p style="font-size: 11px; color: #888;">This is an automated notification from your CMS.</p>
116
+ </div>
117
+ `;
118
+
119
+ const text = `
120
+ New Pending ${capitalizedType} Submitted
121
+
122
+ A new ${input.type} has been submitted and is currently pending moderation.
123
+
124
+ Type: ${capitalizedType}
125
+ ${input.type === "review" && input.rating ? `Rating: ${input.rating} / 5\n` : ""}
126
+ Content: "${input.content.trim()}"
127
+
128
+ Moderation Dashboard: ${origin}/cms/interactions
129
+ `;
130
+
131
+ sendEmail({
132
+ to: emailsString,
133
+ subject,
134
+ text,
135
+ html,
136
+ }).catch((err) =>
137
+ console.error("Failed to send pending interaction email notification:", err)
138
+ );
139
+ }
140
+ }
141
+ } catch (emailErr) {
142
+ console.error("Failed to process email notifications:", emailErr);
143
+ }
144
+
145
+ return { success: true, data };
146
+ } catch (err: any) {
147
+ console.error("Submit interaction failed:", err);
148
+ return { error: err.message || "An unexpected error occurred." };
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Toggles a reaction (like) on a comment or review. Rate-limited and validated using cookies.
154
+ */
155
+ export async function toggleReaction(interactionId: string, reactionType = "likes") {
156
+ if (!interactionId) return { error: "Interaction ID is required." };
157
+
158
+ try {
159
+ // Rate limit / duplicate prevention using cookies
160
+ const cookieStore = await cookies();
161
+ const reactedCookie = cookieStore.get("reacted_interactions")?.value;
162
+ let reactedList: string[] = [];
163
+
164
+ try {
165
+ if (reactedCookie) {
166
+ reactedList = JSON.parse(reactedCookie);
167
+ }
168
+ } catch {
169
+ reactedList = [];
170
+ }
171
+
172
+ const hasReacted = reactedList.includes(interactionId);
173
+
174
+ // Call service role client since visitors don't have update RLS policies
175
+ const admin = getServiceRoleSupabaseClient();
176
+
177
+ // Fetch current reactions
178
+ const { data: interaction, error: fetchError } = await admin
179
+ .from("cms_interactions")
180
+ .select("reactions, type, product_id, post_id, products(slug), posts(slug)")
181
+ .eq("id", interactionId)
182
+ .single();
183
+
184
+ if (fetchError || !interaction) {
185
+ return { error: "Interaction not found." };
186
+ }
187
+
188
+ const reactions = (interaction.reactions as Record<string, number>) || {};
189
+ const currentCount = reactions[reactionType] || 0;
190
+ const newCount = hasReacted ? Math.max(0, currentCount - 1) : currentCount + 1;
191
+ reactions[reactionType] = newCount;
192
+
193
+ // Save back to db
194
+ const { error: updateError } = await admin
195
+ .from("cms_interactions")
196
+ .update({ reactions })
197
+ .eq("id", interactionId);
198
+
199
+ if (updateError) {
200
+ console.error("Error updating reactions:", updateError);
201
+ return { error: "Failed to update reaction." };
202
+ }
203
+
204
+ // Update the cookie
205
+ if (hasReacted) {
206
+ reactedList = reactedList.filter(id => id !== interactionId);
207
+ } else {
208
+ reactedList.push(interactionId);
209
+ }
210
+
211
+ cookieStore.set("reacted_interactions", JSON.stringify(reactedList), {
212
+ maxAge: 60 * 60 * 24 * 365, // 1 year
213
+ httpOnly: true,
214
+ path: "/",
215
+ sameSite: "lax",
216
+ });
217
+
218
+ // Revalidate paths to reflect reaction count updates
219
+ const resolvedProduct = interaction.products as any;
220
+ const resolvedPost = interaction.posts as any;
221
+
222
+ if (interaction.product_id && resolvedProduct?.slug) {
223
+ revalidatePath(`/product/${resolvedProduct.slug}`);
224
+ } else if (interaction.post_id && resolvedPost?.slug) {
225
+ revalidatePath(`/article/${resolvedPost.slug}`);
226
+ }
227
+ revalidatePath("/cms/interactions");
228
+
229
+ return { success: true, count: newCount, hasReacted: !hasReacted };
230
+ } catch (err: any) {
231
+ console.error("Toggle reaction failed:", err);
232
+ return { error: err.message || "An unexpected error occurred." };
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Updates an interaction's status (approved or denied). Admin/Moderator only.
238
+ */
239
+ /**
240
+ * Visitor text goes straight into an HTML mail body below. Without this a review
241
+ * containing markup renders as live HTML inside the moderator's mail client.
242
+ */
243
+ function escapeHtml(value: string): string {
244
+ return value
245
+ .replace(/&/g, '&amp;')
246
+ .replace(/</g, '&lt;')
247
+ .replace(/>/g, '&gt;')
248
+ .replace(/"/g, '&quot;')
249
+ .replace(/'/g, '&#39;');
250
+ }
251
+
252
+ /**
253
+ * Publish a staff answer under a review or a post comment.
254
+ *
255
+ * This is the PUBLIC lane of the Messages inbox, and it is a different shape from a
256
+ * private thread on purpose: the parent is already visible on the site and already
257
+ * belongs to a registered account, so the reply is published content, not a private
258
+ * message. Putting it behind a token link would hide a public answer.
259
+ *
260
+ * A reply is stored as `type: 'comment'` with a NULL rating, carrying the PARENT's
261
+ * target. That is not a shortcut — it is the only shape the existing constraints admit
262
+ * (`check_rating_only_for_review` is an exhaustive OR over 'review' and 'comment'), and
263
+ * it is what keeps the reply out of `update_product_ratings`, which aggregates only
264
+ * `type='review' AND status='approved'`. A reply must never move a product's stars.
265
+ */
266
+ export async function replyToInteraction(parentId: string, content: string) {
267
+ const supabase = createClient();
268
+
269
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
270
+ if (authError || !user) {
271
+ return { error: "Not authenticated" };
272
+ }
273
+
274
+ const profile = await getProfileWithRoleServerSide(user.id);
275
+ if (!profile || profile.role !== "ADMIN") {
276
+ return { error: "Unauthorized. Admin permissions required to reply." };
277
+ }
278
+
279
+ const body = content.trim().slice(0, 5000);
280
+ if (body.length < 2) {
281
+ return { error: "Write a reply before sending." };
282
+ }
283
+
284
+ try {
285
+ const admin = getServiceRoleSupabaseClient();
286
+
287
+ const { data: parent, error: parentError } = await admin
288
+ .from("cms_interactions")
289
+ .select("id, product_id, post_id, parent_id, products(slug), posts(slug)")
290
+ .eq("id", parentId)
291
+ .single();
292
+
293
+ if (parentError || !parent) {
294
+ return { error: "That review or comment no longer exists." };
295
+ }
296
+
297
+ // One level only. The schema enforces this too; failing here gives a better message.
298
+ if (parent.parent_id) {
299
+ return { error: "You can only reply to a top-level review or comment." };
300
+ }
301
+
302
+ const { error: insertError } = await admin.from("cms_interactions").insert({
303
+ type: "comment",
304
+ status: "approved",
305
+ content: body,
306
+ rating: null,
307
+ user_id: user.id,
308
+ parent_id: parent.id,
309
+ product_id: parent.product_id,
310
+ post_id: parent.post_id,
311
+ });
312
+
313
+ if (insertError) {
314
+ console.error("Error publishing reply:", insertError.message);
315
+ return { error: "Could not publish that reply." };
316
+ }
317
+
318
+ const productSlug = (parent.products as { slug?: string } | null)?.slug;
319
+ const postSlug = (parent.posts as { slug?: string } | null)?.slug;
320
+ if (productSlug) revalidatePath(`/product/${productSlug}`);
321
+ if (postSlug) revalidatePath(`/article/${postSlug}`);
322
+ revalidatePath("/cms/messages");
323
+ revalidatePath("/cms/interactions");
324
+
325
+ return { success: true };
326
+ } catch (error) {
327
+ console.error("replyToInteraction failed:", error);
328
+ return { error: "Could not publish that reply." };
329
+ }
330
+ }
331
+
332
+ export async function updateInteractionStatus(interactionId: string, status: "approved" | "denied") {
333
+ const supabase = createClient();
334
+
335
+ // 1. Authenticate user
336
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
337
+ if (authError || !user) {
338
+ return { error: "Not authenticated" };
339
+ }
340
+
341
+ // 2. Authorize as Admin or Writer
342
+ const profile = await getProfileWithRoleServerSide(user.id);
343
+ if (!profile || (profile.role !== "ADMIN" && profile.role !== "WRITER")) {
344
+ return { error: "Unauthorized. Admin or Writer permissions required." };
345
+ }
346
+
347
+ // 3. Admin-only rule for denying/approving if strict
348
+ if (profile.role !== "ADMIN") {
349
+ // If writers are not allowed to moderate, block it. The spec says:
350
+ // "Admin-only permission action to switch states between approved or denied."
351
+ // So let's enforce STRICT Admin only for status updates.
352
+ return { error: "Unauthorized. Admin permissions required to moderate." };
353
+ }
354
+
355
+ try {
356
+ const admin = getServiceRoleSupabaseClient();
357
+
358
+ // Fetch interaction details for path revalidation
359
+ const { data: interaction, error: fetchError } = await admin
360
+ .from("cms_interactions")
361
+ .select("product_id, post_id, products(slug), posts(slug)")
362
+ .eq("id", interactionId)
363
+ .single();
364
+
365
+ if (fetchError || !interaction) {
366
+ return { error: "Interaction not found." };
367
+ }
368
+
369
+ // 4. Update status
370
+ const { error: updateError } = await admin
371
+ .from("cms_interactions")
372
+ .update({ status })
373
+ .eq("id", interactionId);
374
+
375
+ if (updateError) {
376
+ console.error("Error updating status:", updateError);
377
+ return { error: `Failed to update status: ${updateError.message}` };
378
+ }
379
+
380
+ // 5. Revalidate paths
381
+ const resolvedProduct = interaction.products as any;
382
+ const resolvedPost = interaction.posts as any;
383
+
384
+ if (interaction.product_id && resolvedProduct?.slug) {
385
+ revalidatePath(`/product/${resolvedProduct.slug}`);
386
+ } else if (interaction.post_id && resolvedPost?.slug) {
387
+ revalidatePath(`/article/${resolvedPost.slug}`);
388
+ }
389
+ revalidatePath("/cms/interactions");
390
+
391
+ return { success: true };
392
+ } catch (err: any) {
393
+ console.error("Update interaction status failed:", err);
394
+ return { error: err.message || "An unexpected error occurred." };
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Fetches the interactions notification emails from site_settings.
400
+ */
401
+ export async function getNotificationEmails() {
402
+ const supabase = createClient();
403
+
404
+ // Authenticate & authorize
405
+ const { data: { user } } = await supabase.auth.getUser();
406
+ if (!user) return { error: "Not authenticated" };
407
+
408
+ const profile = await getProfileWithRoleServerSide(user.id);
409
+ if (!profile || profile.role !== "ADMIN") {
410
+ return { error: "Unauthorized. Admin role required." };
411
+ }
412
+
413
+ try {
414
+ const { data, error } = await supabase
415
+ .from("site_settings")
416
+ .select("value")
417
+ .eq("key", "interactions_notification_emails")
418
+ .maybeSingle();
419
+
420
+ if (error) throw error;
421
+
422
+ return { success: true, emails: (data?.value as any)?.emails || "" };
423
+ } catch (err: any) {
424
+ console.error("Failed to fetch notification emails:", err);
425
+ return { error: err.message || "Failed to fetch settings." };
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Saves the interactions notification emails to site_settings.
431
+ */
432
+ export async function saveNotificationEmails(emails: string) {
433
+ const supabase = createClient();
434
+
435
+ // Authenticate & authorize
436
+ const { data: { user } } = await supabase.auth.getUser();
437
+ if (!user) return { error: "Not authenticated" };
438
+
439
+ const profile = await getProfileWithRoleServerSide(user.id);
440
+ if (!profile || profile.role !== "ADMIN") {
441
+ return { error: "Unauthorized. Admin role required." };
442
+ }
443
+
444
+ // Validate every address, dedupe (case-insensitive), and normalize to lowercase.
445
+ // Mirrors the client-side check so a crafted/legacy payload can't persist junk.
446
+ const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
447
+ const tokens = emails
448
+ .split(",")
449
+ .map((e) => e.trim())
450
+ .filter(Boolean);
451
+
452
+ const seen = new Set<string>();
453
+ const valid: string[] = [];
454
+ const invalid: string[] = [];
455
+ for (const token of tokens) {
456
+ const lower = token.toLowerCase();
457
+ if (!emailRe.test(lower)) {
458
+ invalid.push(token);
459
+ continue;
460
+ }
461
+ if (seen.has(lower)) continue;
462
+ seen.add(lower);
463
+ valid.push(lower);
464
+ }
465
+
466
+ if (invalid.length > 0) {
467
+ return {
468
+ error: `Invalid email address${invalid.length > 1 ? "es" : ""}: ${invalid.join(", ")}`,
469
+ };
470
+ }
471
+
472
+ const cleaned = valid.join(", ");
473
+
474
+ try {
475
+ const { error } = await supabase
476
+ .from("site_settings")
477
+ .upsert({
478
+ key: "interactions_notification_emails",
479
+ value: { emails: cleaned },
480
+ });
481
+
482
+ if (error) throw error;
483
+
484
+ return { success: true, emails: cleaned };
485
+ } catch (err: any) {
486
+ console.error("Failed to save notification emails:", err);
487
+ return { error: err.message || "Failed to save settings." };
488
+ }
489
+ }