create-nextblock 0.16.1 → 0.16.3

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 (264) 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/route.ts +4 -4
  30. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4542 -2904
  31. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  32. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  33. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  34. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  35. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  37. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  38. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  39. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  40. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  41. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
  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 +406 -406
  46. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  47. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  48. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  49. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  50. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  51. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  52. package/templates/nextblock-template/app/cms/components/SeoScoreBadge.tsx +40 -0
  53. package/templates/nextblock-template/app/cms/components/TablePagination.tsx +136 -0
  54. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  55. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  56. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  58. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  59. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  60. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  61. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  63. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  64. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  65. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  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/components/PageForm.tsx +649 -649
  74. package/templates/nextblock-template/app/cms/pages/page.tsx +273 -227
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +2 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +624 -618
  77. package/templates/nextblock-template/app/cms/posts/page.tsx +251 -194
  78. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  79. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  80. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  81. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  82. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  83. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  84. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  85. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  86. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  90. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  91. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  92. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  93. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  96. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  98. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  99. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  100. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  101. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  102. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  103. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  104. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  105. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  106. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  107. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  108. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  109. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  110. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  111. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  112. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  113. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  114. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  115. package/templates/nextblock-template/app/layout.tsx +671 -671
  116. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  117. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  118. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  119. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  120. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  121. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  122. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  123. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  124. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  125. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  126. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  127. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  128. package/templates/nextblock-template/app/providers.tsx +96 -96
  129. package/templates/nextblock-template/app/robots.ts +123 -123
  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 -137
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +251 -244
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  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 -86
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  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 -154
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +157 -140
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +384 -350
  231. package/templates/nextblock-template/lib/seo/page-document.ts +463 -412
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +37 -22
  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 +1 -2
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/scripts/backup.js +115 -115
  256. package/templates/nextblock-template/scripts/restore.js +385 -385
  257. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  258. package/templates/nextblock-template/tailwind.config.js +25 -25
  259. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  260. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  261. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  262. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  263. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  264. 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
+ }