create-nextblock 0.16.1 → 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 (258) 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 -221
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
  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 -3092
  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 -558
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  42. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  43. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  44. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
  45. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  46. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  47. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  48. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  49. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  50. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  51. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  52. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  53. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  54. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  55. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  56. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  57. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  58. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  59. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  60. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  61. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  62. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  63. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  64. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  65. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  66. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  67. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  68. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  69. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  70. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
  71. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -618
  72. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  73. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  74. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  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/revisions/actions.ts +332 -332
  78. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  79. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  80. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  81. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  82. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  83. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  84. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  85. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  86. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  87. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  88. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  89. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  90. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  91. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  92. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  93. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  94. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  95. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  96. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  97. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  98. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  99. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  100. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  101. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  102. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  103. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  104. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  105. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  106. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  107. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  108. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  109. package/templates/nextblock-template/app/layout.tsx +671 -671
  110. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  111. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  112. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  113. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  114. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  115. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  116. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  117. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  118. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  119. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  120. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  121. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  122. package/templates/nextblock-template/app/providers.tsx +96 -96
  123. package/templates/nextblock-template/app/robots.ts +123 -123
  124. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  125. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  126. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  127. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  128. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  129. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  130. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  131. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  132. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  133. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  134. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  135. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  136. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  137. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  138. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  139. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  140. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  141. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  142. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  143. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  144. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  145. package/templates/nextblock-template/components/Header.tsx +38 -38
  146. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  147. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  148. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  149. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  150. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  151. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  152. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  153. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  154. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  155. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  156. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  157. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  158. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  159. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  160. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  161. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  162. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  163. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  164. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  165. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  166. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  167. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  168. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  169. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  170. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  171. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  172. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  173. package/templates/nextblock-template/components/form-message.tsx +32 -32
  174. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
  175. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -244
  176. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  177. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  178. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  179. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  180. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  181. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  182. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  183. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  184. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  185. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  186. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  187. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  188. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  189. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  190. package/templates/nextblock-template/docs/README.md +42 -42
  191. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  192. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  193. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  194. package/templates/nextblock-template/index.d.ts +7 -7
  195. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  196. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  197. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  198. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  199. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  200. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  201. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  202. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  203. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  204. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  205. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  206. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  207. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
  208. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  209. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  210. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  211. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  212. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  213. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  214. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  215. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  216. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  217. package/templates/nextblock-template/lib/search/types.ts +27 -27
  218. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
  219. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  220. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  221. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  222. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  223. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -140
  224. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -350
  225. package/templates/nextblock-template/lib/seo/page-document.ts +412 -412
  226. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  227. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  228. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  229. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  230. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  231. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  232. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -177
  233. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  234. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  235. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  236. package/templates/nextblock-template/lib/site-url.ts +102 -102
  237. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  238. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  239. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  240. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  241. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  242. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  243. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  244. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  245. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  246. package/templates/nextblock-template/next-env.d.ts +1 -2
  247. package/templates/nextblock-template/package.json +1 -1
  248. package/templates/nextblock-template/postcss.config.js +6 -6
  249. package/templates/nextblock-template/scripts/backup.js +115 -115
  250. package/templates/nextblock-template/scripts/restore.js +385 -385
  251. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  252. package/templates/nextblock-template/tailwind.config.js +25 -25
  253. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  254. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  255. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  256. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  257. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  258. package/tsconfig.base.json +3 -3
@@ -1,404 +1,404 @@
1
- 'use server';
2
-
3
- import { revalidatePath } from 'next/cache';
4
- import { createClient, getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
5
-
6
- import { requireAdminSupabaseClient } from './require-admin';
7
- import { ensureThreadToken, sendThreadNotice } from '../../../lib/messages/threads';
8
- import { invalidateEmailConfigCache } from '../../../lib/config/email-settings';
9
- import { resolveSiteUrl } from '../../../lib/site-url';
10
-
11
- export interface MessageActionState {
12
- success: boolean;
13
- message: string;
14
- }
15
-
16
- const MAX_REPLY_LENGTH = 5000;
17
-
18
- function failure(error: unknown, fallback: string): MessageActionState {
19
- const message = error instanceof Error ? error.message : fallback;
20
- return { success: false, message };
21
- }
22
-
23
- /**
24
- * Answer a private conversation.
25
- *
26
- * This is the ONLY place a visitor thread token is minted. Deferring it to the first
27
- * reply means a store that receives a hundred enquiries and answers three has three
28
- * live credentials rather than a hundred — and an enquiry nobody ever answered never
29
- * had one at all.
30
- */
31
- export async function replyToThread(
32
- threadId: string,
33
- body: string
34
- ): Promise<MessageActionState> {
35
- try {
36
- const { userId, fullName } = await requireAdminSupabaseClient();
37
-
38
- const reply = body.trim().slice(0, MAX_REPLY_LENGTH);
39
- if (!reply) return { success: false, message: 'Write a reply before sending.' };
40
-
41
- const service = getServiceRoleSupabaseClient();
42
-
43
- const { data: thread } = await service
44
- .from('message_threads')
45
- .select('id, source, subject_label, sender_name, sender_email, status')
46
- .eq('id', threadId)
47
- .maybeSingle();
48
-
49
- if (!thread) return { success: false, message: 'That conversation no longer exists.' };
50
-
51
- const { data: message, error: messageError } = await service
52
- .from('thread_messages')
53
- .insert({
54
- thread_id: threadId,
55
- direction: 'outbound',
56
- body: reply,
57
- author_id: userId,
58
- author_name: fullName,
59
- })
60
- .select('id')
61
- .single();
62
-
63
- if (messageError || !message) {
64
- console.error('[messages] Reply insert failed:', messageError?.message);
65
- return { success: false, message: 'Could not save your reply.' };
66
- }
67
-
68
- const now = new Date().toISOString();
69
- await service
70
- .from('message_threads')
71
- .update({
72
- last_message_at: now,
73
- unread_for_admin: false,
74
- unread_for_visitor: true,
75
- // Answering reopens a closed conversation — otherwise the visitor could reply
76
- // to a thread the CMS still shows as done.
77
- status: 'open',
78
- })
79
- .eq('id', threadId);
80
-
81
- const { token, hasExistingToken } = await ensureThreadToken(threadId);
82
-
83
- // AWAITED, not deferred to after().
84
- //
85
- // The public paths defer their notifications because a visitor should not wait on an
86
- // SMTP round trip. An admin reply is the opposite: someone clicked Send and is owed a
87
- // truthful answer. Deferring it also raced the page refresh — the row is written with
88
- // email_delivered at its default of false, so the UI re-rendered before the send
89
- // finished and reported "Not delivered" for a reply that was about to go out fine.
90
- await sendThreadNotice({
91
- threadId,
92
- source: thread.source as 'product_inquiry' | 'contact_form',
93
- messageId: message.id,
94
- subjectLabel: thread.subject_label,
95
- senderName: thread.sender_name,
96
- senderEmail: thread.sender_email,
97
- body: reply,
98
- token,
99
- hasExistingToken,
100
- });
101
-
102
- const { data: delivery } = await service
103
- .from('thread_messages')
104
- .select('email_delivered, email_error')
105
- .eq('id', message.id)
106
- .maybeSingle();
107
-
108
- revalidatePath('/cms/messages');
109
-
110
- if (!thread.sender_email) {
111
- return {
112
- success: true,
113
- message: 'Reply saved. This sender left no email address, so they will not be notified.',
114
- };
115
- }
116
-
117
- return delivery?.email_delivered
118
- ? { success: true, message: 'Reply sent.' }
119
- : {
120
- // The reply itself is safely stored; only delivery failed. Say exactly that,
121
- // rather than "sent" over a failure or "failed" over a saved reply.
122
- success: false,
123
- message: `Reply saved, but it could not be delivered. ${delivery?.email_error ?? ''}`.trim(),
124
- };
125
- } catch (error) {
126
- return failure(error, 'Could not send that reply.');
127
- }
128
- }
129
-
130
- /**
131
- * Try again to deliver a reply that was written but never sent.
132
- *
133
- * The whole design treats the stored row as the record and delivery as best-effort, so
134
- * an SMTP outage or a misconfiguration leaves real replies sitting unsent. Without this
135
- * the only way to deliver one is to retype it as a new reply, which also re-notifies the
136
- * visitor about a message they may already have.
137
- *
138
- * Drops the SMTP config cache first: the usual reason a retry is being attempted is that
139
- * the settings were just corrected, and a cached transport would fail the same way.
140
- */
141
- export async function retryThreadDelivery(messageId: string): Promise<MessageActionState> {
142
- try {
143
- await requireAdminSupabaseClient();
144
- const service = getServiceRoleSupabaseClient();
145
-
146
- const { data: message } = await service
147
- .from('thread_messages')
148
- .select('id, thread_id, body, direction, email_delivered')
149
- .eq('id', messageId)
150
- .maybeSingle();
151
-
152
- if (!message) return { success: false, message: 'That reply no longer exists.' };
153
- if (message.direction !== 'outbound') {
154
- return { success: false, message: 'Only replies you sent can be re-delivered.' };
155
- }
156
- if (message.email_delivered) {
157
- return { success: false, message: 'That reply was already delivered.' };
158
- }
159
-
160
- const { data: thread } = await service
161
- .from('message_threads')
162
- .select('id, source, subject_label, sender_name, sender_email')
163
- .eq('id', message.thread_id)
164
- .maybeSingle();
165
-
166
- if (!thread) return { success: false, message: 'That conversation no longer exists.' };
167
- if (!thread.sender_email) {
168
- return { success: false, message: 'This sender left no email address, so there is nowhere to deliver it.' };
169
- }
170
-
171
- invalidateEmailConfigCache();
172
-
173
- const { token, hasExistingToken } = await ensureThreadToken(thread.id);
174
-
175
- // Awaited rather than deferred: the admin clicked a button and is waiting to learn
176
- // whether it worked this time.
177
- await sendThreadNotice({
178
- threadId: thread.id,
179
- source: thread.source as 'product_inquiry' | 'contact_form',
180
- messageId: message.id,
181
- subjectLabel: thread.subject_label,
182
- senderName: thread.sender_name,
183
- senderEmail: thread.sender_email,
184
- body: message.body,
185
- token,
186
- hasExistingToken,
187
- });
188
-
189
- const { data: after } = await service
190
- .from('thread_messages')
191
- .select('email_delivered, email_error')
192
- .eq('id', message.id)
193
- .maybeSingle();
194
-
195
- revalidatePath('/cms/messages');
196
- return after?.email_delivered
197
- ? { success: true, message: 'Delivered.' }
198
- : { success: false, message: after?.email_error || 'Still could not deliver that reply.' };
199
- } catch (error) {
200
- return failure(error, 'Could not re-send that reply.');
201
- }
202
- }
203
-
204
- /** Open or close a conversation. Mirrors the flag onto the originating enquiry row. */
205
- export async function setThreadStatus(
206
- threadId: string,
207
- status: 'open' | 'closed'
208
- ): Promise<MessageActionState> {
209
- try {
210
- await requireAdminSupabaseClient();
211
- const service = getServiceRoleSupabaseClient();
212
-
213
- const { data: thread } = await service
214
- .from('message_threads')
215
- .select('source, subject_id')
216
- .eq('id', threadId)
217
- .maybeSingle();
218
-
219
- const { error } = await service
220
- .from('message_threads')
221
- .update({ status, unread_for_admin: false })
222
- .eq('id', threadId);
223
-
224
- if (error) return { success: false, message: 'Could not update that conversation.' };
225
-
226
- // Keep the enquiry list truthful — it has its own is_resolved flag.
227
- if (thread?.source === 'product_inquiry' && thread.subject_id) {
228
- await service
229
- .from('product_inquiries')
230
- .update({ is_resolved: status === 'closed' })
231
- .eq('id', thread.subject_id);
232
- }
233
-
234
- revalidatePath('/cms/messages');
235
- return { success: true, message: status === 'closed' ? 'Marked as handled.' : 'Reopened.' };
236
- } catch (error) {
237
- return failure(error, 'Could not update that conversation.');
238
- }
239
- }
240
-
241
- export async function markThreadRead(threadId: string): Promise<MessageActionState> {
242
- try {
243
- await requireAdminSupabaseClient();
244
- const service = getServiceRoleSupabaseClient();
245
- await service.from('message_threads').update({ unread_for_admin: false }).eq('id', threadId);
246
- revalidatePath('/cms/messages');
247
- return { success: true, message: '' };
248
- } catch (error) {
249
- return failure(error, 'Could not update that conversation.');
250
- }
251
- }
252
-
253
- /**
254
- * Kill a visitor's link without destroying the conversation.
255
- *
256
- * The row is kept deliberately: its hash stays in the unique index, so a revoked token
257
- * can never be re-minted onto a different thread, and the history survives. Thread rows
258
- * cannot be deleted at all — `thread_messages` is append-only and the cascade would
259
- * trip its trigger — so "delete" in this UI means closed plus revoked.
260
- */
261
- export async function revokeThreadLink(threadId: string): Promise<MessageActionState> {
262
- try {
263
- await requireAdminSupabaseClient();
264
- const service = getServiceRoleSupabaseClient();
265
- const { error } = await service
266
- .from('message_threads')
267
- .update({ token_revoked_at: new Date().toISOString() })
268
- .eq('id', threadId);
269
-
270
- if (error) return { success: false, message: 'Could not revoke that link.' };
271
-
272
- revalidatePath('/cms/messages');
273
- return {
274
- success: true,
275
- message: 'Link revoked. The next reply you send will issue a fresh one.',
276
- };
277
- } catch (error) {
278
- return failure(error, 'Could not revoke that link.');
279
- }
280
- }
281
-
282
- /**
283
- * Mint a fresh visitor link and hand it back to the admin, without sending anything.
284
- *
285
- * The notification email is best-effort by design, and a recipient's own filtering can
286
- * remove a message after it has been accepted by every hop — Microsoft's ZAP does exactly
287
- * that, silently and with no bounce. When that happens the conversation is not broken,
288
- * only the delivery is: the thread, the reply and a valid link all still exist.
289
- *
290
- * This is the manual channel. The admin copies the link and sends it however actually
291
- * works — their own mail client, a chat message, over the phone. Same rotation rule as a
292
- * reply: minting a new one retires the previous link.
293
- */
294
- export async function createVisitorLink(
295
- threadId: string
296
- ): Promise<MessageActionState & { url?: string }> {
297
- try {
298
- await requireAdminSupabaseClient();
299
-
300
- const { token } = await ensureThreadToken(threadId);
301
- if (!token) {
302
- return { success: false, message: 'Could not create a link for that conversation.' };
303
- }
304
-
305
- revalidatePath('/cms/messages');
306
- return {
307
- success: true,
308
- message: 'Link copied. It replaces any link sent earlier.',
309
- url: `${resolveSiteUrl()}/thread/${token}`,
310
- };
311
- } catch (error) {
312
- return failure(error, 'Could not create a link for that conversation.');
313
- }
314
- }
315
-
316
- /**
317
- * Save where a contact form's submissions go, plus the server-trusted field manifest.
318
- * Called from the form block editor — the address lives here, never in block content.
319
- */
320
- export async function saveFormEndpoint(
321
- formKey: string,
322
- label: string,
323
- recipientEmail: string,
324
- fields: Array<{ temp_id?: string; label?: string; field_type?: string }>
325
- ): Promise<MessageActionState> {
326
- try {
327
- await requireAdminSupabaseClient();
328
-
329
- const email = recipientEmail.trim();
330
- if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
331
- return { success: false, message: "That doesn't look like a valid email address." };
332
- }
333
-
334
- const service = getServiceRoleSupabaseClient();
335
- const { error } = await service.from('form_endpoints').upsert({
336
- form_key: formKey,
337
- label: label.trim() || 'Contact form',
338
- recipient_email: email || null,
339
- fields: fields.map((field) => ({
340
- temp_id: field.temp_id,
341
- label: field.label,
342
- field_type: field.field_type,
343
- })),
344
- });
345
-
346
- if (error) {
347
- console.error('[messages] Could not save form endpoint:', error.message);
348
- return { success: false, message: 'Could not save the form settings.' };
349
- }
350
-
351
- return {
352
- success: true,
353
- message: email
354
- ? 'Saved. Submissions will be emailed there and stored in Messages.'
355
- : 'Saved. Submissions will use your site contact address and are stored in Messages.',
356
- };
357
- } catch (error) {
358
- return failure(error, 'Could not save the form settings.');
359
- }
360
- }
361
-
362
- /**
363
- * Current endpoint settings for the block editor.
364
- *
365
- * Readable by WRITERs as well as ADMINs, matching form_endpoints_editor_read_policy —
366
- * WRITERs can edit pages and therefore open this block, and showing them an empty field
367
- * would read as "no address configured" rather than "you may not change this". Writing
368
- * stays ADMIN-only; `canEdit` tells the UI which to render.
369
- */
370
- export async function getFormEndpointForEditor(
371
- formKey: string
372
- ): Promise<{ label: string; recipientEmail: string; canEdit: boolean } | null> {
373
- try {
374
- const supabase = createClient();
375
- const {
376
- data: { user },
377
- } = await supabase.auth.getUser();
378
- if (!user) return null;
379
-
380
- const { data: profile } = await supabase
381
- .from('profiles')
382
- .select('role')
383
- .eq('id', user.id)
384
- .maybeSingle();
385
- if (profile?.role !== 'ADMIN' && profile?.role !== 'WRITER') return null;
386
-
387
- const service = getServiceRoleSupabaseClient();
388
- const { data } = await service
389
- .from('form_endpoints')
390
- .select('label, recipient_email')
391
- .eq('form_key', formKey)
392
- .maybeSingle();
393
-
394
- const canEdit = profile.role === 'ADMIN';
395
- if (!data) return { label: '', recipientEmail: '', canEdit };
396
- return {
397
- label: data.label ?? '',
398
- recipientEmail: data.recipient_email ?? '',
399
- canEdit,
400
- };
401
- } catch {
402
- return null;
403
- }
404
- }
1
+ 'use server';
2
+
3
+ import { revalidatePath } from 'next/cache';
4
+ import { createClient, getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
5
+
6
+ import { requireAdminSupabaseClient } from './require-admin';
7
+ import { ensureThreadToken, sendThreadNotice } from '../../../lib/messages/threads';
8
+ import { invalidateEmailConfigCache } from '../../../lib/config/email-settings';
9
+ import { resolveSiteUrl } from '../../../lib/site-url';
10
+
11
+ export interface MessageActionState {
12
+ success: boolean;
13
+ message: string;
14
+ }
15
+
16
+ const MAX_REPLY_LENGTH = 5000;
17
+
18
+ function failure(error: unknown, fallback: string): MessageActionState {
19
+ const message = error instanceof Error ? error.message : fallback;
20
+ return { success: false, message };
21
+ }
22
+
23
+ /**
24
+ * Answer a private conversation.
25
+ *
26
+ * This is the ONLY place a visitor thread token is minted. Deferring it to the first
27
+ * reply means a store that receives a hundred enquiries and answers three has three
28
+ * live credentials rather than a hundred — and an enquiry nobody ever answered never
29
+ * had one at all.
30
+ */
31
+ export async function replyToThread(
32
+ threadId: string,
33
+ body: string
34
+ ): Promise<MessageActionState> {
35
+ try {
36
+ const { userId, fullName } = await requireAdminSupabaseClient();
37
+
38
+ const reply = body.trim().slice(0, MAX_REPLY_LENGTH);
39
+ if (!reply) return { success: false, message: 'Write a reply before sending.' };
40
+
41
+ const service = getServiceRoleSupabaseClient();
42
+
43
+ const { data: thread } = await service
44
+ .from('message_threads')
45
+ .select('id, source, subject_label, sender_name, sender_email, status')
46
+ .eq('id', threadId)
47
+ .maybeSingle();
48
+
49
+ if (!thread) return { success: false, message: 'That conversation no longer exists.' };
50
+
51
+ const { data: message, error: messageError } = await service
52
+ .from('thread_messages')
53
+ .insert({
54
+ thread_id: threadId,
55
+ direction: 'outbound',
56
+ body: reply,
57
+ author_id: userId,
58
+ author_name: fullName,
59
+ })
60
+ .select('id')
61
+ .single();
62
+
63
+ if (messageError || !message) {
64
+ console.error('[messages] Reply insert failed:', messageError?.message);
65
+ return { success: false, message: 'Could not save your reply.' };
66
+ }
67
+
68
+ const now = new Date().toISOString();
69
+ await service
70
+ .from('message_threads')
71
+ .update({
72
+ last_message_at: now,
73
+ unread_for_admin: false,
74
+ unread_for_visitor: true,
75
+ // Answering reopens a closed conversation — otherwise the visitor could reply
76
+ // to a thread the CMS still shows as done.
77
+ status: 'open',
78
+ })
79
+ .eq('id', threadId);
80
+
81
+ const { token, hasExistingToken } = await ensureThreadToken(threadId);
82
+
83
+ // AWAITED, not deferred to after().
84
+ //
85
+ // The public paths defer their notifications because a visitor should not wait on an
86
+ // SMTP round trip. An admin reply is the opposite: someone clicked Send and is owed a
87
+ // truthful answer. Deferring it also raced the page refresh — the row is written with
88
+ // email_delivered at its default of false, so the UI re-rendered before the send
89
+ // finished and reported "Not delivered" for a reply that was about to go out fine.
90
+ await sendThreadNotice({
91
+ threadId,
92
+ source: thread.source as 'product_inquiry' | 'contact_form',
93
+ messageId: message.id,
94
+ subjectLabel: thread.subject_label,
95
+ senderName: thread.sender_name,
96
+ senderEmail: thread.sender_email,
97
+ body: reply,
98
+ token,
99
+ hasExistingToken,
100
+ });
101
+
102
+ const { data: delivery } = await service
103
+ .from('thread_messages')
104
+ .select('email_delivered, email_error')
105
+ .eq('id', message.id)
106
+ .maybeSingle();
107
+
108
+ revalidatePath('/cms/messages');
109
+
110
+ if (!thread.sender_email) {
111
+ return {
112
+ success: true,
113
+ message: 'Reply saved. This sender left no email address, so they will not be notified.',
114
+ };
115
+ }
116
+
117
+ return delivery?.email_delivered
118
+ ? { success: true, message: 'Reply sent.' }
119
+ : {
120
+ // The reply itself is safely stored; only delivery failed. Say exactly that,
121
+ // rather than "sent" over a failure or "failed" over a saved reply.
122
+ success: false,
123
+ message: `Reply saved, but it could not be delivered. ${delivery?.email_error ?? ''}`.trim(),
124
+ };
125
+ } catch (error) {
126
+ return failure(error, 'Could not send that reply.');
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Try again to deliver a reply that was written but never sent.
132
+ *
133
+ * The whole design treats the stored row as the record and delivery as best-effort, so
134
+ * an SMTP outage or a misconfiguration leaves real replies sitting unsent. Without this
135
+ * the only way to deliver one is to retype it as a new reply, which also re-notifies the
136
+ * visitor about a message they may already have.
137
+ *
138
+ * Drops the SMTP config cache first: the usual reason a retry is being attempted is that
139
+ * the settings were just corrected, and a cached transport would fail the same way.
140
+ */
141
+ export async function retryThreadDelivery(messageId: string): Promise<MessageActionState> {
142
+ try {
143
+ await requireAdminSupabaseClient();
144
+ const service = getServiceRoleSupabaseClient();
145
+
146
+ const { data: message } = await service
147
+ .from('thread_messages')
148
+ .select('id, thread_id, body, direction, email_delivered')
149
+ .eq('id', messageId)
150
+ .maybeSingle();
151
+
152
+ if (!message) return { success: false, message: 'That reply no longer exists.' };
153
+ if (message.direction !== 'outbound') {
154
+ return { success: false, message: 'Only replies you sent can be re-delivered.' };
155
+ }
156
+ if (message.email_delivered) {
157
+ return { success: false, message: 'That reply was already delivered.' };
158
+ }
159
+
160
+ const { data: thread } = await service
161
+ .from('message_threads')
162
+ .select('id, source, subject_label, sender_name, sender_email')
163
+ .eq('id', message.thread_id)
164
+ .maybeSingle();
165
+
166
+ if (!thread) return { success: false, message: 'That conversation no longer exists.' };
167
+ if (!thread.sender_email) {
168
+ return { success: false, message: 'This sender left no email address, so there is nowhere to deliver it.' };
169
+ }
170
+
171
+ invalidateEmailConfigCache();
172
+
173
+ const { token, hasExistingToken } = await ensureThreadToken(thread.id);
174
+
175
+ // Awaited rather than deferred: the admin clicked a button and is waiting to learn
176
+ // whether it worked this time.
177
+ await sendThreadNotice({
178
+ threadId: thread.id,
179
+ source: thread.source as 'product_inquiry' | 'contact_form',
180
+ messageId: message.id,
181
+ subjectLabel: thread.subject_label,
182
+ senderName: thread.sender_name,
183
+ senderEmail: thread.sender_email,
184
+ body: message.body,
185
+ token,
186
+ hasExistingToken,
187
+ });
188
+
189
+ const { data: after } = await service
190
+ .from('thread_messages')
191
+ .select('email_delivered, email_error')
192
+ .eq('id', message.id)
193
+ .maybeSingle();
194
+
195
+ revalidatePath('/cms/messages');
196
+ return after?.email_delivered
197
+ ? { success: true, message: 'Delivered.' }
198
+ : { success: false, message: after?.email_error || 'Still could not deliver that reply.' };
199
+ } catch (error) {
200
+ return failure(error, 'Could not re-send that reply.');
201
+ }
202
+ }
203
+
204
+ /** Open or close a conversation. Mirrors the flag onto the originating enquiry row. */
205
+ export async function setThreadStatus(
206
+ threadId: string,
207
+ status: 'open' | 'closed'
208
+ ): Promise<MessageActionState> {
209
+ try {
210
+ await requireAdminSupabaseClient();
211
+ const service = getServiceRoleSupabaseClient();
212
+
213
+ const { data: thread } = await service
214
+ .from('message_threads')
215
+ .select('source, subject_id')
216
+ .eq('id', threadId)
217
+ .maybeSingle();
218
+
219
+ const { error } = await service
220
+ .from('message_threads')
221
+ .update({ status, unread_for_admin: false })
222
+ .eq('id', threadId);
223
+
224
+ if (error) return { success: false, message: 'Could not update that conversation.' };
225
+
226
+ // Keep the enquiry list truthful — it has its own is_resolved flag.
227
+ if (thread?.source === 'product_inquiry' && thread.subject_id) {
228
+ await service
229
+ .from('product_inquiries')
230
+ .update({ is_resolved: status === 'closed' })
231
+ .eq('id', thread.subject_id);
232
+ }
233
+
234
+ revalidatePath('/cms/messages');
235
+ return { success: true, message: status === 'closed' ? 'Marked as handled.' : 'Reopened.' };
236
+ } catch (error) {
237
+ return failure(error, 'Could not update that conversation.');
238
+ }
239
+ }
240
+
241
+ export async function markThreadRead(threadId: string): Promise<MessageActionState> {
242
+ try {
243
+ await requireAdminSupabaseClient();
244
+ const service = getServiceRoleSupabaseClient();
245
+ await service.from('message_threads').update({ unread_for_admin: false }).eq('id', threadId);
246
+ revalidatePath('/cms/messages');
247
+ return { success: true, message: '' };
248
+ } catch (error) {
249
+ return failure(error, 'Could not update that conversation.');
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Kill a visitor's link without destroying the conversation.
255
+ *
256
+ * The row is kept deliberately: its hash stays in the unique index, so a revoked token
257
+ * can never be re-minted onto a different thread, and the history survives. Thread rows
258
+ * cannot be deleted at all — `thread_messages` is append-only and the cascade would
259
+ * trip its trigger — so "delete" in this UI means closed plus revoked.
260
+ */
261
+ export async function revokeThreadLink(threadId: string): Promise<MessageActionState> {
262
+ try {
263
+ await requireAdminSupabaseClient();
264
+ const service = getServiceRoleSupabaseClient();
265
+ const { error } = await service
266
+ .from('message_threads')
267
+ .update({ token_revoked_at: new Date().toISOString() })
268
+ .eq('id', threadId);
269
+
270
+ if (error) return { success: false, message: 'Could not revoke that link.' };
271
+
272
+ revalidatePath('/cms/messages');
273
+ return {
274
+ success: true,
275
+ message: 'Link revoked. The next reply you send will issue a fresh one.',
276
+ };
277
+ } catch (error) {
278
+ return failure(error, 'Could not revoke that link.');
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Mint a fresh visitor link and hand it back to the admin, without sending anything.
284
+ *
285
+ * The notification email is best-effort by design, and a recipient's own filtering can
286
+ * remove a message after it has been accepted by every hop — Microsoft's ZAP does exactly
287
+ * that, silently and with no bounce. When that happens the conversation is not broken,
288
+ * only the delivery is: the thread, the reply and a valid link all still exist.
289
+ *
290
+ * This is the manual channel. The admin copies the link and sends it however actually
291
+ * works — their own mail client, a chat message, over the phone. Same rotation rule as a
292
+ * reply: minting a new one retires the previous link.
293
+ */
294
+ export async function createVisitorLink(
295
+ threadId: string
296
+ ): Promise<MessageActionState & { url?: string }> {
297
+ try {
298
+ await requireAdminSupabaseClient();
299
+
300
+ const { token } = await ensureThreadToken(threadId);
301
+ if (!token) {
302
+ return { success: false, message: 'Could not create a link for that conversation.' };
303
+ }
304
+
305
+ revalidatePath('/cms/messages');
306
+ return {
307
+ success: true,
308
+ message: 'Link copied. It replaces any link sent earlier.',
309
+ url: `${resolveSiteUrl()}/thread/${token}`,
310
+ };
311
+ } catch (error) {
312
+ return failure(error, 'Could not create a link for that conversation.');
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Save where a contact form's submissions go, plus the server-trusted field manifest.
318
+ * Called from the form block editor — the address lives here, never in block content.
319
+ */
320
+ export async function saveFormEndpoint(
321
+ formKey: string,
322
+ label: string,
323
+ recipientEmail: string,
324
+ fields: Array<{ temp_id?: string; label?: string; field_type?: string }>
325
+ ): Promise<MessageActionState> {
326
+ try {
327
+ await requireAdminSupabaseClient();
328
+
329
+ const email = recipientEmail.trim();
330
+ if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
331
+ return { success: false, message: "That doesn't look like a valid email address." };
332
+ }
333
+
334
+ const service = getServiceRoleSupabaseClient();
335
+ const { error } = await service.from('form_endpoints').upsert({
336
+ form_key: formKey,
337
+ label: label.trim() || 'Contact form',
338
+ recipient_email: email || null,
339
+ fields: fields.map((field) => ({
340
+ temp_id: field.temp_id,
341
+ label: field.label,
342
+ field_type: field.field_type,
343
+ })),
344
+ });
345
+
346
+ if (error) {
347
+ console.error('[messages] Could not save form endpoint:', error.message);
348
+ return { success: false, message: 'Could not save the form settings.' };
349
+ }
350
+
351
+ return {
352
+ success: true,
353
+ message: email
354
+ ? 'Saved. Submissions will be emailed there and stored in Messages.'
355
+ : 'Saved. Submissions will use your site contact address and are stored in Messages.',
356
+ };
357
+ } catch (error) {
358
+ return failure(error, 'Could not save the form settings.');
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Current endpoint settings for the block editor.
364
+ *
365
+ * Readable by WRITERs as well as ADMINs, matching form_endpoints_editor_read_policy —
366
+ * WRITERs can edit pages and therefore open this block, and showing them an empty field
367
+ * would read as "no address configured" rather than "you may not change this". Writing
368
+ * stays ADMIN-only; `canEdit` tells the UI which to render.
369
+ */
370
+ export async function getFormEndpointForEditor(
371
+ formKey: string
372
+ ): Promise<{ label: string; recipientEmail: string; canEdit: boolean } | null> {
373
+ try {
374
+ const supabase = createClient();
375
+ const {
376
+ data: { user },
377
+ } = await supabase.auth.getUser();
378
+ if (!user) return null;
379
+
380
+ const { data: profile } = await supabase
381
+ .from('profiles')
382
+ .select('role')
383
+ .eq('id', user.id)
384
+ .maybeSingle();
385
+ if (profile?.role !== 'ADMIN' && profile?.role !== 'WRITER') return null;
386
+
387
+ const service = getServiceRoleSupabaseClient();
388
+ const { data } = await service
389
+ .from('form_endpoints')
390
+ .select('label, recipient_email')
391
+ .eq('form_key', formKey)
392
+ .maybeSingle();
393
+
394
+ const canEdit = profile.role === 'ADMIN';
395
+ if (!data) return { label: '', recipientEmail: '', canEdit };
396
+ return {
397
+ label: data.label ?? '',
398
+ recipientEmail: data.recipient_email ?? '',
399
+ canEdit,
400
+ };
401
+ } catch {
402
+ return null;
403
+ }
404
+ }