create-nextblock 0.15.8 → 0.15.9
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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/ToasterProvider.tsx +26 -17
- package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -0
- package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -0
- package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -0
- package/templates/nextblock-template/app/actions/email.ts +241 -110
- package/templates/nextblock-template/app/actions/formActions.ts +245 -116
- package/templates/nextblock-template/app/actions/interactions.ts +489 -396
- package/templates/nextblock-template/app/actions/threadActions.ts +166 -0
- package/templates/nextblock-template/app/api/checkout/route.ts +162 -146
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +664 -1
- package/templates/nextblock-template/app/checkout/page.tsx +57 -52
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +552 -529
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -181
- package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -0
- package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -0
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -528
- package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -0
- package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -0
- package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -51
- package/templates/nextblock-template/app/cms/layout.tsx +101 -73
- package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -0
- package/templates/nextblock-template/app/cms/messages/actions.ts +404 -0
- package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -0
- package/templates/nextblock-template/app/cms/messages/page.tsx +87 -0
- package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -0
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -362
- package/templates/nextblock-template/app/cms/revisions/service.ts +20 -0
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -185
- package/templates/nextblock-template/app/layout.tsx +671 -671
- package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -482
- package/templates/nextblock-template/app/providers.tsx +96 -96
- package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -0
- package/templates/nextblock-template/app/thread/[token]/route.ts +57 -0
- package/templates/nextblock-template/app/thread/layout.tsx +15 -0
- package/templates/nextblock-template/app/thread/page.tsx +98 -0
- package/templates/nextblock-template/components/BlockRenderer.tsx +312 -296
- package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -0
- package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -369
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -419
- package/templates/nextblock-template/components/StaffReplies.tsx +102 -0
- package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -17
- package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -19
- package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -22
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -381
- package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -92
- package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -31
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -600
- package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -0
- package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -0
- package/templates/nextblock-template/docs/README.md +42 -41
- package/templates/nextblock-template/docs/assets/lighthouse-scores.png +0 -0
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +22 -2
- package/templates/nextblock-template/lib/blocks/blockColors.ts +44 -1
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -753
- package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -0
- package/templates/nextblock-template/lib/cms/payments-reminder.ts +98 -0
- package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -0
- package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -0
- package/templates/nextblock-template/lib/config/email-settings.ts +323 -254
- package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -0
- package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -0
- package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -0
- package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -0
- package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -0
- package/templates/nextblock-template/lib/messages/thread-token.ts +157 -0
- package/templates/nextblock-template/lib/messages/threads.ts +579 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +20 -0
- package/templates/nextblock-template/lib/site-url.test.ts +89 -0
- package/templates/nextblock-template/lib/site-url.ts +102 -48
- package/templates/nextblock-template/package.json +1 -1
|
@@ -143,5 +143,25 @@ export const MIGRATIONS_BUNDLE: BundledMigration[] = [
|
|
|
143
143
|
"version": "00000000000025",
|
|
144
144
|
"name": "00000000000025_rebrand_nextblock_dev.sql",
|
|
145
145
|
"sql": "-- 00000000000025_rebrand_nextblock_dev.sql\n-- Domain rebrand: the retired .ca domain -> nextblock.dev.\n--\n-- Two halves to this change, because two populations need different treatment:\n--\n-- Fresh installs -- migrations 00000000000003_baseline_seed and\n-- 00000000000006/007/009_home_live_demo_promo* were rewritten in place\n-- to seed nextblock.dev directly. Safe to edit despite the append-only\n-- rule: Supabase tracks migration history by version string with no\n-- checksum, so an already-applied file is never re-read or replayed.\n-- Those four are seed/content only -- no schema, no constraints.\n--\n-- Existing installs -- prod, sandbox, and any deployed fork already seeded the old domain\n-- from the pre-rewrite versions of those files. They never replay, so\n-- this migration corrects their data forward.\n--\n-- Both paths converge on nextblock.dev. On a fresh install this migration matches nothing\n-- and is a no-op, which is also what makes it safe to re-run.\n--\n-- Also flips the sandbox demo account address. The account itself is created by\n-- apps/nextblock/app/api/cron/reset-sandbox/route.ts (now 'demo@nextblock.dev'); the legacy\n-- demo account is NOT removed here -- the reset route never deletes auth users and never\n-- truncates public.profiles, so it must be deleted by hand in the Supabase Auth dashboard\n-- or it survives as a working ADMIN.\n--\n-- NOTE: keep the word \"sandbox\" OUT of this filename -- generate-sandbox-reset.ts excludes any\n-- migration whose filename contains \"sandbox\" from the sandbox reset bundle.\n\nDO $body$\nDECLARE\n -- Assembled from two halves on purpose: it is the one string this rebrand is meant to\n -- erase, and spelling it out here would leave the repo-wide grep with a permanent hit\n -- inside the very migration that removes it. Resolves to the retired domain at runtime.\n legacy_domain constant text := 'nextblock' || '.ca';\nBEGIN\n -- 1. Seeded UI strings (e.g. the \"Purchase at ...\" link on the sandbox checkout panel).\n UPDATE public.translations\n SET translations = replace(translations::text, legacy_domain, 'nextblock.dev')::jsonb,\n updated_at = now()\n WHERE translations::text LIKE '%' || legacy_domain || '%';\n\n -- 2. Page/post content -- the home-page \"Live Demo\" promo carries the demo login address.\n UPDATE public.blocks\n SET content = replace(content::text, legacy_domain, 'nextblock.dev')::jsonb,\n updated_at = now()\n WHERE content::text LIKE '%' || legacy_domain || '%';\n\n -- 3. Site settings -- invoice branding on the sandbox carries the billing address.\n UPDATE public.site_settings\n SET value = replace(value::text, legacy_domain, 'nextblock.dev')::jsonb\n WHERE value::text LIKE '%' || legacy_domain || '%';\nEND\n$body$;\n"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"version": "00000000000026",
|
|
149
|
+
"name": "00000000000026_product_inquiries.sql",
|
|
150
|
+
"sql": "-- Purchase enquiries raised when the store cannot take payment.\n--\n-- A NextBlock store can be fully built — products, prices, images, pages — while its\n-- Stripe or Freemius credentials are still missing. Until this migration the only\n-- symptom was a shopper reaching checkout and receiving the payment provider's raw\n-- rejection (\"Invalid API Key provided: sk_test_*ummy\"), which tells them nothing and\n-- loses the sale outright.\n--\n-- The storefront now offers those shoppers an enquiry form in place of Add-to-Cart,\n-- and every submission lands here. The row is the product, not the email: SMTP is a\n-- separate piece of setup that the same half-configured store has usually also not\n-- done, so a notification that cannot be sent must never be the only record. The\n-- `email_delivered` flag records whether the owner was successfully notified; false\n-- means \"read this in the CMS, nobody got a mail about it\".\n--\n-- SECURITY POSTURE. Rows hold visitor-supplied PII (name, email, free text) plus a\n-- masked IP. There is deliberately NO anon policy of any kind: anonymous visitors\n-- neither read nor write this table directly. The public server action inserts with\n-- the service-role client after bot-protection and throttle checks, exactly as\n-- `privacy_consent_logs` does — that keeps the insert shape server-controlled and\n-- means a leaked anon key cannot enumerate or seed enquiries. ADMINs read; nobody\n-- else does, because these are sales leads and personal data.\n--\n-- `product_id` is a PLAIN uuid with NO foreign key, following site_script_revisions:\n-- an enquiry is evidence that someone wanted a product, and deleting the product\n-- should not delete or rewrite that evidence. `product_slug` and `product_title` are\n-- denormalised so a deleted product is still identifiable in the list.\n--\n-- Forward-only and idempotent.\n\nCREATE TABLE IF NOT EXISTS public.product_inquiries (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n -- No FK by design (see header). Null when the product was deleted after the fact.\n product_id uuid,\n product_slug text,\n product_title text,\n sender_name text NOT NULL,\n sender_email text NOT NULL,\n message text NOT NULL,\n -- Which language the visitor was browsing in, so the owner can reply in kind.\n locale text,\n ip_masked text,\n user_agent text,\n -- False when SMTP was unconfigured or the send failed: the CMS list is then the\n -- only place this enquiry exists.\n email_delivered boolean DEFAULT false NOT NULL,\n is_resolved boolean DEFAULT false NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT product_inquiries_pkey PRIMARY KEY (id),\n CONSTRAINT product_inquiries_sender_name_not_blank CHECK ((char_length(btrim(sender_name)) > 0)),\n CONSTRAINT product_inquiries_sender_email_not_blank CHECK ((char_length(btrim(sender_email)) > 0)),\n CONSTRAINT product_inquiries_message_not_blank CHECK ((char_length(btrim(message)) > 0))\n);\n\nCOMMENT ON TABLE public.product_inquiries IS 'Visitor purchase enquiries raised when the store cannot take payment. Written by the service role from a public server action; read by ADMINs only.';\n\nCOMMENT ON COLUMN public.product_inquiries.product_id IS 'Plain uuid, no FK: an enquiry outlives the product it was about.';\n\nCOMMENT ON COLUMN public.product_inquiries.ip_masked IS 'Partially masked IP (e.g. 203.0.113.x) - never store a full address. Also backs the per-IP submission throttle.';\n\nCOMMENT ON COLUMN public.product_inquiries.email_delivered IS 'False when the owner notification could not be sent (e.g. SMTP unconfigured); the stored row is then the only record.';\n\n-- Newest-first is the only listing order the CMS needs.\nCREATE INDEX IF NOT EXISTS product_inquiries_created_idx\n ON public.product_inquiries USING btree (created_at DESC);\n\n-- Backs the throttle lookup in the public server action: count recent rows per IP.\nCREATE INDEX IF NOT EXISTS product_inquiries_ip_created_idx\n ON public.product_inquiries USING btree (ip_masked, created_at DESC);\n\n-- Lets the CMS count outstanding enquiries without scanning resolved history.\nCREATE INDEX IF NOT EXISTS product_inquiries_unresolved_idx\n ON public.product_inquiries USING btree (created_at DESC)\n WHERE (is_resolved = false);\n\nALTER TABLE public.product_inquiries ENABLE ROW LEVEL SECURITY;\n\n-- anon is deliberately absent: the public path goes through the service role.\nGRANT SELECT, UPDATE ON TABLE public.product_inquiries TO authenticated;\nGRANT ALL ON TABLE public.product_inquiries TO service_role;\n\nDROP POLICY IF EXISTS product_inquiries_admin_read_policy ON public.product_inquiries;\nCREATE POLICY product_inquiries_admin_read_policy ON public.product_inquiries FOR SELECT TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));\n\n-- ADMINs may only flip the resolved flag; the enquiry content itself is a record.\nDROP POLICY IF EXISTS product_inquiries_admin_update_policy ON public.product_inquiries;\nCREATE POLICY product_inquiries_admin_update_policy ON public.product_inquiries FOR UPDATE TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)) WITH CHECK ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS product_inquiries_service_role_policy ON public.product_inquiries;\nCREATE POLICY product_inquiries_service_role_policy ON public.product_inquiries TO service_role USING (true) WITH CHECK (true);\n\n-- Where enquiry notifications are sent. Kept in the PUBLIC settings row rather than the\n-- secret one because it is an address, not a credential — but it is never rendered to\n-- the storefront: the public form posts a product id and the server resolves the\n-- recipient. Empty string means \"fall back\", see resolveSellerContactEmail().\nINSERT INTO public.site_settings (key, value)\nVALUES ('store_contact', '{\"contactEmail\": \"\"}'::jsonb)\nON CONFLICT (key) DO NOTHING;\n\n-- Storefront copy for the enquiry flow. Public-facing, so it must be translatable;\n-- the components pass an English literal as the fallback, so an install that never\n-- runs this seed still renders correctly.\nINSERT INTO public.translations (key, translations, created_at, updated_at) VALUES\n ('ecommerce.contact_seller', '{\"en\": \"Contact the seller\", \"fr\": \"Contacter le vendeur\"}', now(), now()),\n ('ecommerce.contact_seller_heading', '{\"en\": \"Interested in this product?\", \"fr\": \"Ce produit vous intéresse ?\"}', now(), now()),\n ('ecommerce.contact_seller_intro', '{\"en\": \"Online ordering isn''t available for this item yet. Send the seller a message and they''ll get back to you about buying it.\", \"fr\": \"La commande en ligne n''est pas encore disponible pour cet article. Envoyez un message au vendeur et il vous répondra au sujet de son achat.\"}', now(), now()),\n ('ecommerce.contact_seller_name', '{\"en\": \"Your name\", \"fr\": \"Votre nom\"}', now(), now()),\n ('ecommerce.contact_seller_email', '{\"en\": \"Your email\", \"fr\": \"Votre courriel\"}', now(), now()),\n ('ecommerce.contact_seller_message', '{\"en\": \"Message\", \"fr\": \"Message\"}', now(), now()),\n ('ecommerce.contact_seller_send', '{\"en\": \"Send message\", \"fr\": \"Envoyer le message\"}', now(), now()),\n ('ecommerce.contact_seller_sending', '{\"en\": \"Sending...\", \"fr\": \"Envoi...\"}', now(), now()),\n ('ecommerce.contact_seller_sent', '{\"en\": \"Thanks - your message has been sent to the seller. They''ll reply to the email address you gave.\", \"fr\": \"Merci - votre message a été envoyé au vendeur. Il répondra à l''adresse courriel que vous avez indiquée.\"}', now(), now()),\n ('ecommerce.contact_seller_error', '{\"en\": \"Sorry, your message couldn''t be sent. Please try again in a moment.\", \"fr\": \"Désolé, votre message n''a pas pu être envoyé. Veuillez réessayer dans un instant.\"}', now(), now()),\n ('ecommerce.contact_seller_invalid', '{\"en\": \"Please check your name, email address and message, then try again.\", \"fr\": \"Veuillez vérifier votre nom, votre adresse courriel et votre message, puis réessayer.\"}', now(), now()),\n ('ecommerce.contact_seller_throttled', '{\"en\": \"You''ve sent several messages already. Please wait a few minutes before sending another.\", \"fr\": \"Vous avez déjà envoyé plusieurs messages. Veuillez patienter quelques minutes avant d''en envoyer un autre.\"}', now(), now()),\n ('ecommerce.not_available_for_purchase', '{\"en\": \"Not available for online purchase\", \"fr\": \"Non disponible à l''achat en ligne\"}', now(), now()),\n ('ecommerce.checkout_payments_unavailable', '{\"en\": \"This store is not able to take payments right now. Please contact the seller to complete your purchase.\", \"fr\": \"Cette boutique ne peut pas accepter de paiements pour le moment. Veuillez contacter le vendeur pour finaliser votre achat.\"}', now(), now())\nON CONFLICT (key) DO NOTHING;\n"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"version": "00000000000027",
|
|
154
|
+
"name": "00000000000027_message_threads.sql",
|
|
155
|
+
"sql": "-- Private conversations with anonymous visitors, and the end of the exposed form address.\n--\n-- Two problems, one shape.\n--\n-- FIRST: a contact-form block stores its destination address in the block's own\n-- content, and `FormBlockRenderer` is a \"use client\" component that receives that\n-- content wholesale. Everything crossing that boundary is serialized into the RSC\n-- payload, so the shop owner's inbox is published in the markup of every page\n-- carrying a form. `form_endpoints` moves the address server-side and leaves behind a\n-- `form_key` — an opaque handle that grants nothing and is safe to serialize.\n--\n-- SECOND: a visitor who sends a message has nowhere to receive an answer. They are\n-- anonymous — `cms_interactions` cannot hold them, because its `user_id` is NOT NULL\n-- with a foreign key to `profiles`. So this is a separate lane: `message_threads` plus\n-- `thread_messages`, reached by a tokenised link rather than an account.\n--\n-- WHY REVIEWS AND COMMENTS ARE NOT HERE. They are already public, already tied to a\n-- registered account, and a staff answer to them is published content, not a private\n-- reply. That lane stays in `cms_interactions` and is handled by migration 28. One\n-- inbox reads both; the storage stays honest about the difference.\n--\n-- SECURITY POSTURE. Rows hold visitor PII (name, email, free text) and a masked IP.\n-- There is NO anon grant and NO anon policy: the tokenised visitor page verifies the\n-- token in application code and then reads with the service role, the same posture the\n-- MCP token route takes against `mcp_access_tokens`. ADMINs read; nobody else does.\n--\n-- `token_hash` stores only a SHA-256 of the visitor's token. The plaintext exists long\n-- enough to be placed in one outbound email and is never written down. A token is\n-- minted on the FIRST ADMIN REPLY, never at submission — a store that receives a\n-- hundred enquiries and answers three has three live credentials, not a hundred.\n--\n-- `source` is a text column with a CHECK rather than an enum, deliberately.\n-- `interaction_type` in this same schema is the cautionary tale: a two-value enum\n-- guarded by an exhaustive CHECK, where adding a third value needs ALTER TYPE ... ADD\n-- VALUE — and PostgreSQL forbids using a value added in the transaction that added it,\n-- which is exactly one migration file. A CHECK is replaceable in one statement.\n--\n-- Forward-only and idempotent.\n\n-- ---------------------------------------------------------------------------\n-- 1. form_endpoints — where a contact-form block's mail goes, and what its fields\n-- are called. `fields` is a SERVER-SIDE snapshot of the field manifest so the\n-- notification email renders labels the browser did not supply; the same rule\n-- the product-enquiry action follows when it looks the product title up itself.\n-- ---------------------------------------------------------------------------\nCREATE TABLE IF NOT EXISTS public.form_endpoints (\n form_key uuid NOT NULL,\n label text DEFAULT 'Contact form' NOT NULL,\n -- NULL/empty means \"fall back to the resolver ladder\", like store_contact.\n recipient_email text,\n fields jsonb DEFAULT '[]'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT form_endpoints_pkey PRIMARY KEY (form_key),\n CONSTRAINT form_endpoints_label_not_blank CHECK ((char_length(btrim(label)) > 0)),\n CONSTRAINT form_endpoints_fields_array CHECK ((jsonb_typeof(fields) = 'array'))\n);\n\nCOMMENT ON TABLE public.form_endpoints IS 'Server-side destination and field manifest for a contact-form block, keyed by the non-secret form_key carried in block content. The address never reaches the browser.';\n\nCOMMENT ON COLUMN public.form_endpoints.form_key IS 'Non-secret opaque handle. Safe in the RSC payload: it grants nothing, it is only a lookup key.';\n\nCOMMENT ON COLUMN public.form_endpoints.fields IS 'Snapshot of [{temp_id,label,field_type}] written by the CMS editor, so labels in emails and the inbox are server-trusted.';\n\nDROP TRIGGER IF EXISTS set_form_endpoints_updated_at ON public.form_endpoints;\nCREATE TRIGGER set_form_endpoints_updated_at BEFORE UPDATE ON public.form_endpoints\n FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();\n\nALTER TABLE public.form_endpoints ENABLE ROW LEVEL SECURITY;\n\n-- anon deliberately absent: the public submit path goes through the service role.\nGRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.form_endpoints TO authenticated;\nGRANT ALL ON TABLE public.form_endpoints TO service_role;\n\nDROP POLICY IF EXISTS form_endpoints_editor_read_policy ON public.form_endpoints;\nCREATE POLICY form_endpoints_editor_read_policy ON public.form_endpoints FOR SELECT TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])));\n\nDROP POLICY IF EXISTS form_endpoints_admin_write_policy ON public.form_endpoints;\nCREATE POLICY form_endpoints_admin_write_policy ON public.form_endpoints FOR ALL TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)) WITH CHECK ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS form_endpoints_service_role_policy ON public.form_endpoints;\nCREATE POLICY form_endpoints_service_role_policy ON public.form_endpoints TO service_role USING (true) WITH CHECK (true);\n\n-- ---------------------------------------------------------------------------\n-- 2. message_threads — the private-lane spine and the only home of a thread token.\n-- `subject_id` and `form_key` are PLAIN uuids with NO foreign key: a conversation\n-- outlives the product or the form block it started from.\n-- ---------------------------------------------------------------------------\nCREATE TABLE IF NOT EXISTS public.message_threads (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source text NOT NULL,\n -- product_inquiries.id when source = 'product_inquiry'.\n subject_id uuid,\n -- form_endpoints.form_key when source = 'contact_form'.\n form_key uuid,\n -- Denormalised so a deleted product or removed form block stays identifiable.\n subject_label text DEFAULT 'Message' NOT NULL,\n sender_name text,\n -- NULL when a contact form collected no email field. Such a thread can never be\n -- answered, and the CMS says so rather than failing silently at reply time.\n sender_email text,\n locale text,\n -- Submitted field map for a contact form: {temp_id: value}.\n fields jsonb DEFAULT '{}'::jsonb NOT NULL,\n status text DEFAULT 'open' NOT NULL,\n unread_for_admin boolean DEFAULT true NOT NULL,\n unread_for_visitor boolean DEFAULT false NOT NULL,\n -- SHA-256 hex of the visitor's token; NULL until the first admin reply.\n token_hash text,\n token_expires_at timestamp with time zone,\n token_revoked_at timestamp with time zone,\n token_last_used_at timestamp with time zone,\n last_message_at timestamp with time zone DEFAULT now() NOT NULL,\n ip_masked text,\n user_agent text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT message_threads_pkey PRIMARY KEY (id),\n CONSTRAINT message_threads_token_hash_key UNIQUE (token_hash),\n CONSTRAINT message_threads_source_check\n CHECK ((source = ANY (ARRAY['product_inquiry'::text, 'contact_form'::text]))),\n CONSTRAINT message_threads_status_check\n CHECK ((status = ANY (ARRAY['open'::text, 'closed'::text]))),\n CONSTRAINT message_threads_subject_check CHECK (\n (((source = 'product_inquiry'::text) AND (subject_id IS NOT NULL)) OR\n ((source = 'contact_form'::text) AND (form_key IS NOT NULL)))),\n CONSTRAINT message_threads_fields_object CHECK ((jsonb_typeof(fields) = 'object'))\n);\n\nCOMMENT ON TABLE public.message_threads IS 'Private conversations with anonymous visitors (product enquiries and contact-form submissions). Written by the service role from public server actions; read by ADMINs only. Public reviews and comments are NOT here - they live in cms_interactions.';\n\nCOMMENT ON COLUMN public.message_threads.token_hash IS 'SHA-256 hex of the visitor thread token. The raw token is never stored; it is minted on the first admin reply and mailed once.';\n\nCOMMENT ON COLUMN public.message_threads.subject_id IS 'product_inquiries.id. Plain uuid, no FK: a conversation outlives the enquiry record it grew from.';\n\nCREATE INDEX IF NOT EXISTS message_threads_last_message_idx\n ON public.message_threads USING btree (last_message_at DESC);\n\nCREATE INDEX IF NOT EXISTS message_threads_source_last_idx\n ON public.message_threads USING btree (source, last_message_at DESC);\n\n-- Backs the CMS nav unread badge without scanning read history.\nCREATE INDEX IF NOT EXISTS message_threads_unread_idx\n ON public.message_threads USING btree (last_message_at DESC)\n WHERE (unread_for_admin = true);\n\n-- Idempotent backfill / dedupe of enquiry threads.\nCREATE INDEX IF NOT EXISTS message_threads_subject_idx\n ON public.message_threads USING btree (subject_id) WHERE (subject_id IS NOT NULL);\n\n-- No separate token index: message_threads_token_hash_key is already the unique\n-- btree the /thread lookup probes.\n\nDROP TRIGGER IF EXISTS set_message_threads_updated_at ON public.message_threads;\nCREATE TRIGGER set_message_threads_updated_at BEFORE UPDATE ON public.message_threads\n FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();\n\nALTER TABLE public.message_threads ENABLE ROW LEVEL SECURITY;\n\nGRANT SELECT, UPDATE ON TABLE public.message_threads TO authenticated;\nGRANT ALL ON TABLE public.message_threads TO service_role;\n\nDROP POLICY IF EXISTS message_threads_admin_read_policy ON public.message_threads;\nCREATE POLICY message_threads_admin_read_policy ON public.message_threads FOR SELECT TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS message_threads_admin_update_policy ON public.message_threads;\nCREATE POLICY message_threads_admin_update_policy ON public.message_threads FOR UPDATE TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)) WITH CHECK ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS message_threads_service_role_policy ON public.message_threads;\nCREATE POLICY message_threads_service_role_policy ON public.message_threads TO service_role USING (true) WITH CHECK (true);\n\n-- ---------------------------------------------------------------------------\n-- 3. thread_messages — one turn of a private conversation.\n-- ---------------------------------------------------------------------------\nCREATE TABLE IF NOT EXISTS public.thread_messages (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n thread_id uuid NOT NULL,\n direction text NOT NULL,\n body text NOT NULL,\n -- profiles.id for an outbound reply. No FK: a departing admin must not take the\n -- conversation with them.\n author_id uuid,\n author_name text,\n -- False when SMTP was unconfigured or the send failed. sendEmail() throws when\n -- unconfigured, so this row - not the mail - is the record.\n email_delivered boolean DEFAULT false NOT NULL,\n email_error text,\n ip_masked text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT thread_messages_pkey PRIMARY KEY (id),\n CONSTRAINT thread_messages_direction_check\n CHECK ((direction = ANY (ARRAY['inbound'::text, 'outbound'::text]))),\n CONSTRAINT thread_messages_body_not_blank CHECK ((char_length(btrim(body)) > 0))\n);\n\nCOMMENT ON TABLE public.thread_messages IS 'Turns of a private conversation. Content is append-only; only the delivery flags may change after insert.';\n\n-- A real foreign key here, unlike the outward pointers above: a message has no\n-- meaning without its thread.\nDO $rb$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint\n WHERE conname = 'thread_messages_thread_id_fkey'\n AND conrelid = 'public.thread_messages'::regclass) THEN\n ALTER TABLE ONLY public.thread_messages\n ADD CONSTRAINT thread_messages_thread_id_fkey FOREIGN KEY (thread_id)\n REFERENCES public.message_threads(id) ON DELETE CASCADE;\n END IF;\nEND $rb$;\n\nCREATE INDEX IF NOT EXISTS thread_messages_thread_created_idx\n ON public.thread_messages USING btree (thread_id, created_at);\n\n-- Backs the per-IP reply throttle on the public thread page.\nCREATE INDEX IF NOT EXISTS thread_messages_ip_created_idx\n ON public.thread_messages USING btree (ip_masked, created_at DESC);\n\n-- Message TEXT is append-only — not the whole row, because email_delivered has to be\n-- settable after the send completes. The trigger blocks rewrites of the load-bearing\n-- columns only, and it binds the service role too, which otherwise bypasses RLS.\nCREATE OR REPLACE FUNCTION public.prevent_thread_message_rewrite() RETURNS trigger\n LANGUAGE plpgsql SET search_path = '' AS $$\nBEGIN\n IF TG_OP = 'DELETE' THEN\n RAISE EXCEPTION 'thread_messages is append-only; DELETE is not permitted'\n USING ERRCODE = 'restrict_violation';\n END IF;\n IF NEW.body IS DISTINCT FROM OLD.body\n OR NEW.direction IS DISTINCT FROM OLD.direction\n OR NEW.thread_id IS DISTINCT FROM OLD.thread_id\n OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN\n RAISE EXCEPTION 'thread_messages content is append-only; only delivery flags may change'\n USING ERRCODE = 'restrict_violation';\n END IF;\n RETURN NEW;\nEND;\n$$;\n\nDROP TRIGGER IF EXISTS trg_thread_messages_append_only ON public.thread_messages;\nCREATE TRIGGER trg_thread_messages_append_only BEFORE UPDATE OR DELETE ON public.thread_messages\n FOR EACH ROW EXECUTE FUNCTION public.prevent_thread_message_rewrite();\n\n-- NB: the ON DELETE CASCADE above fires this trigger, so deleting a thread is\n-- impossible by design. \"Delete\" in the CMS means status='closed' plus a revoked\n-- token, and the UI says so.\n\nALTER TABLE public.thread_messages ENABLE ROW LEVEL SECURITY;\n\nGRANT SELECT ON TABLE public.thread_messages TO authenticated;\nGRANT ALL ON TABLE public.thread_messages TO service_role;\n\nDROP POLICY IF EXISTS thread_messages_admin_read_policy ON public.thread_messages;\nCREATE POLICY thread_messages_admin_read_policy ON public.thread_messages FOR SELECT TO authenticated USING ((( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS thread_messages_service_role_policy ON public.thread_messages;\nCREATE POLICY thread_messages_service_role_policy ON public.thread_messages TO service_role USING (true) WITH CHECK (true);\n\n-- ---------------------------------------------------------------------------\n-- 4. Backfill: every existing enquiry becomes a thread whose first inbound turn is\n-- the original message. product_inquiries is KEPT and kept written — it is the\n-- enquiry's own record and owns the ip_masked the submission throttle counts.\n-- The thread is a conversation ABOUT the enquiry, not a replacement for it.\n-- ---------------------------------------------------------------------------\nINSERT INTO public.message_threads\n (source, subject_id, subject_label, sender_name, sender_email, locale,\n ip_masked, user_agent, unread_for_admin, status, last_message_at, created_at)\nSELECT 'product_inquiry', i.id, COALESCE(i.product_title, 'Product enquiry'),\n i.sender_name, i.sender_email, i.locale, i.ip_masked, i.user_agent,\n NOT i.is_resolved, CASE WHEN i.is_resolved THEN 'closed' ELSE 'open' END,\n i.created_at, i.created_at\nFROM public.product_inquiries i\nWHERE NOT EXISTS (SELECT 1 FROM public.message_threads t WHERE t.subject_id = i.id);\n\nINSERT INTO public.thread_messages (thread_id, direction, body, email_delivered, ip_masked, created_at)\nSELECT t.id, 'inbound', i.message, i.email_delivered, i.ip_masked, i.created_at\nFROM public.product_inquiries i\nJOIN public.message_threads t ON t.subject_id = i.id\nWHERE NOT EXISTS (SELECT 1 FROM public.thread_messages m WHERE m.thread_id = t.id);\n\n-- ---------------------------------------------------------------------------\n-- 5. The form-block data migration.\n-- Rewrites every stored form block: mints a form_key, moves recipient_email into\n-- form_endpoints, snapshots the field manifest, and DELETES recipient_email from\n-- the jsonb. Three shapes have to be handled, because a form nested inside a\n-- section has no blocks row of its own — it lives in the section's content:\n-- a) blocks.content where block_type = 'form'\n-- b) blocks.content where block_type = 'section' (column_blocks, slides)\n-- c) content_drafts.blocks / product_drafts.blocks (jsonb arrays of snapshots)\n-- ---------------------------------------------------------------------------\nCREATE OR REPLACE FUNCTION public.nb_migrate_form_columns(p_cols jsonb)\nRETURNS jsonb LANGUAGE plpgsql VOLATILE SET search_path = '' AS $fn$\nDECLARE v_col jsonb; v_blk jsonb; v_new_cols jsonb := '[]'::jsonb; v_new_col jsonb;\nBEGIN\n FOR v_col IN SELECT * FROM jsonb_array_elements(p_cols) LOOP\n v_new_col := '[]'::jsonb;\n FOR v_blk IN SELECT * FROM jsonb_array_elements(coalesce(v_col, '[]'::jsonb)) LOOP\n v_new_col := v_new_col || jsonb_build_array(\n v_blk || jsonb_build_object('content', public.nb_migrate_form_content(v_blk->'content')));\n END LOOP;\n v_new_cols := v_new_cols || jsonb_build_array(v_new_col);\n END LOOP;\n RETURN v_new_cols;\nEND;\n$fn$;\n\nCREATE OR REPLACE FUNCTION public.nb_migrate_form_content(p_content jsonb)\nRETURNS jsonb LANGUAGE plpgsql VOLATILE SET search_path = '' AS $fn$\nDECLARE\n v_key uuid; v_email text; v_fields jsonb; v_slide jsonb; v_new_slides jsonb;\nBEGIN\n IF p_content IS NULL OR jsonb_typeof(p_content) <> 'object' THEN\n RETURN p_content;\n END IF;\n\n -- Leaf: this object IS a form block's content.\n IF (p_content ? 'recipient_email') AND (p_content ? 'fields') THEN\n v_key := gen_random_uuid();\n v_email := nullif(btrim(coalesce(p_content->>'recipient_email','')), '');\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'temp_id', f->>'temp_id', 'label', f->>'label', 'field_type', f->>'field_type')), '[]'::jsonb)\n INTO v_fields\n FROM jsonb_array_elements(coalesce(p_content->'fields', '[]'::jsonb)) f;\n INSERT INTO public.form_endpoints (form_key, label, recipient_email, fields)\n VALUES (v_key, 'Contact form', v_email, v_fields)\n ON CONFLICT (form_key) DO NOTHING;\n RETURN (p_content - 'recipient_email') || jsonb_build_object('form_key', v_key::text);\n END IF;\n\n -- Container: a section's slides, each with column_blocks.\n IF (p_content ? 'slides') AND jsonb_typeof(p_content->'slides') = 'array' THEN\n v_new_slides := '[]'::jsonb;\n FOR v_slide IN SELECT * FROM jsonb_array_elements(p_content->'slides') LOOP\n v_new_slides := v_new_slides || jsonb_build_array(\n v_slide || jsonb_build_object('column_blocks',\n public.nb_migrate_form_columns(coalesce(v_slide->'column_blocks','[]'::jsonb))));\n END LOOP;\n p_content := p_content || jsonb_build_object('slides', v_new_slides);\n END IF;\n\n -- Container: a standard section's column_blocks.\n IF (p_content ? 'column_blocks') AND jsonb_typeof(p_content->'column_blocks') = 'array' THEN\n p_content := p_content || jsonb_build_object('column_blocks',\n public.nb_migrate_form_columns(p_content->'column_blocks'));\n END IF;\n\n RETURN p_content;\nEND;\n$fn$;\n\n-- The LIKE guard keeps this from rewriting (and bumping updated_at on) every block.\nUPDATE public.blocks\n SET content = public.nb_migrate_form_content(content)\n WHERE content::text LIKE '%recipient_email%';\n\nUPDATE public.content_drafts d\n SET blocks = (SELECT coalesce(jsonb_agg(b || jsonb_build_object(\n 'content', public.nb_migrate_form_content(b->'content'))), '[]'::jsonb)\n FROM jsonb_array_elements(d.blocks) b)\n WHERE d.blocks::text LIKE '%recipient_email%';\n\nUPDATE public.product_drafts d\n SET blocks = (SELECT coalesce(jsonb_agg(b || jsonb_build_object(\n 'content', public.nb_migrate_form_content(b->'content'))), '[]'::jsonb)\n FROM jsonb_array_elements(d.blocks) b)\n WHERE d.blocks::text LIKE '%recipient_email%';\n\n-- One-shot helpers. Dropping them keeps the public schema (and db:types) clean.\nDROP FUNCTION IF EXISTS public.nb_migrate_form_content(jsonb);\nDROP FUNCTION IF EXISTS public.nb_migrate_form_columns(jsonb);\n\n-- ---------------------------------------------------------------------------\n-- 6. Seeds. Thread-page copy is public-facing so it must be translatable; the\n-- components pass an English literal as the fallback, so an install that never\n-- runs this seed still renders correctly.\n-- ---------------------------------------------------------------------------\nINSERT INTO public.site_settings (key, value)\nVALUES ('forms_contact', '{\"contactEmail\": \"\"}'::jsonb)\nON CONFLICT (key) DO NOTHING;\n\nINSERT INTO public.translations (key, translations, created_at, updated_at) VALUES\n ('thread.heading', '{\"en\": \"Your conversation\", \"fr\": \"Votre conversation\"}', now(), now()),\n ('thread.reply_label', '{\"en\": \"Write a reply\", \"fr\": \"Écrire une réponse\"}', now(), now()),\n ('thread.send', '{\"en\": \"Send reply\", \"fr\": \"Envoyer la réponse\"}', now(), now()),\n ('thread.sending', '{\"en\": \"Sending...\", \"fr\": \"Envoi...\"}', now(), now()),\n ('thread.sent', '{\"en\": \"Thanks - your reply has been sent.\", \"fr\": \"Merci - votre réponse a été envoyée.\"}', now(), now()),\n ('thread.error', '{\"en\": \"Sorry, your reply couldn''t be sent. Please try again in a moment.\", \"fr\": \"Désolé, votre réponse n''a pas pu être envoyée. Veuillez réessayer dans un instant.\"}', now(), now()),\n ('thread.throttled', '{\"en\": \"You''ve sent several replies already. Please wait a few minutes.\", \"fr\": \"Vous avez déjà envoyé plusieurs réponses. Veuillez patienter quelques minutes.\"}', now(), now()),\n ('thread.closed', '{\"en\": \"This conversation has been closed.\", \"fr\": \"Cette conversation est fermée.\"}', now(), now()),\n ('thread.invalid', '{\"en\": \"This link has expired or is no longer valid. If you still need help, please contact us again from our website.\", \"fr\": \"Ce lien a expiré ou n''est plus valide. Si vous avez encore besoin d''aide, contactez-nous de nouveau depuis notre site.\"}', now(), now()),\n ('thread.you', '{\"en\": \"You\", \"fr\": \"Vous\"}', now(), now()),\n ('forms.submission_stored', '{\"en\": \"Thanks - your message has been received.\", \"fr\": \"Merci - votre message a bien été reçu.\"}', now(), now())\nON CONFLICT (key) DO NOTHING;\n"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"version": "00000000000028",
|
|
159
|
+
"name": "00000000000028_interaction_replies.sql",
|
|
160
|
+
"sql": "-- Staff replies on reviews and post comments, and the indexes cms_interactions never had.\n--\n-- WHY THIS IS A DIFFERENT SHAPE FROM MIGRATION 27, AND WHY IT NEEDS NO TOKEN.\n-- `cms_interactions.user_id` is NOT NULL with a foreign key to `profiles`: every review\n-- and comment already comes from a signed-in account with a reachable address, and the\n-- content already renders publicly on /product/{slug} and /article/{slug}. A staff\n-- answer there is PUBLISHED CONTENT plus moderation, not a private conversation.\n-- Routing it through the tokenised thread page would hide a public reply behind a\n-- secret link. So: one inbox in the CMS, two storage models underneath.\n--\n-- THE ONE CONSTRAINT THAT DICTATES THE MODELLING.\n-- `check_rating_only_for_review` is an exhaustive OR over ('review','comment'):\n-- (type='review' AND rating IS NOT NULL AND rating BETWEEN 1 AND 5)\n-- OR (type='comment' AND rating IS NULL)\n-- A type='review' row with a NULL rating is rejected, so a reply to a review cannot be\n-- a 'review' row. Adding a 'reply' value to interaction_type would ALSO violate it —\n-- the OR covers no third value — and PostgreSQL forbids using an enum value in the\n-- transaction that added it, which is exactly one migration file.\n--\n-- THEREFORE a reply is a `type='comment'` row carrying the PARENT's target and a NULL\n-- rating: a combination the existing constraints already accept, unchanged. The bonus\n-- is decisive — `update_product_ratings()` aggregates only\n-- WHERE product_id = ? AND type='review' AND status='approved'\n-- so a reply can never move products.average_rating or products.total_reviews. The new\n-- CHECK below pins that invariant in the schema instead of trusting the action to\n-- remember it.\n--\n-- No new policies are needed. `cms_interactions_insert_policy` already admits an\n-- ADMIN/WRITER self-insert with status='approved'\n-- (auth.uid() = user_id AND (status='pending' OR role IN ('ADMIN','WRITER')))\n-- which is precisely a published staff reply.\n--\n-- Forward-only and idempotent.\n\nALTER TABLE public.cms_interactions ADD COLUMN IF NOT EXISTS parent_id uuid;\n\nCOMMENT ON COLUMN public.cms_interactions.parent_id IS 'Set on a staff reply: points at the review or comment being answered. Replies are always type=comment with a NULL rating, and carry the parent''s product_id/post_id so check_product_or_post still holds.';\n\nDO $rb$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint\n WHERE conname = 'cms_interactions_parent_id_fkey'\n AND conrelid = 'public.cms_interactions'::regclass) THEN\n ALTER TABLE ONLY public.cms_interactions\n ADD CONSTRAINT cms_interactions_parent_id_fkey FOREIGN KEY (parent_id)\n REFERENCES public.cms_interactions(id) ON DELETE CASCADE;\n END IF;\nEND $rb$;\n\n-- One level only, never itself, no rating, always a comment. Keeps the reply\n-- invisible to the ratings trigger and to the existing storefront review query.\nDO $rb$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint\n WHERE conname = 'cms_interactions_reply_check'\n AND conrelid = 'public.cms_interactions'::regclass) THEN\n ALTER TABLE public.cms_interactions\n ADD CONSTRAINT cms_interactions_reply_check CHECK (\n ((parent_id IS NULL) OR\n ((parent_id <> id) AND (rating IS NULL) AND (type = 'comment'::public.interaction_type))));\n END IF;\nEND $rb$;\n\n-- Fetch every reply for a page of parents in one probe.\nCREATE INDEX IF NOT EXISTS cms_interactions_parent_idx\n ON public.cms_interactions USING btree (parent_id, created_at)\n WHERE (parent_id IS NOT NULL);\n\n-- cms_interactions has had ZERO secondary indexes since the baseline, while both\n-- public renderers filter on exactly these predicates on every product and article\n-- page. The inbox multiplies read volume on this table, so they go in now.\nCREATE INDEX IF NOT EXISTS cms_interactions_product_type_status_idx\n ON public.cms_interactions USING btree (product_id, type, status, created_at DESC)\n WHERE (product_id IS NOT NULL);\n\nCREATE INDEX IF NOT EXISTS cms_interactions_post_type_status_idx\n ON public.cms_interactions USING btree (post_id, type, status, created_at DESC)\n WHERE (post_id IS NOT NULL);\n\n-- Inbox listing: newest top-level items, replies excluded.\nCREATE INDEX IF NOT EXISTS cms_interactions_inbox_idx\n ON public.cms_interactions USING btree (created_at DESC)\n WHERE (parent_id IS NULL);\n\nINSERT INTO public.translations (key, translations, created_at, updated_at) VALUES\n ('interactions.staff_reply', '{\"en\": \"Reply from the team\", \"fr\": \"Réponse de l''équipe\"}', now(), now()),\n ('interactions.staff_badge', '{\"en\": \"Staff\", \"fr\": \"Équipe\"}', now(), now())\nON CONFLICT (key) DO NOTHING;\n"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"version": "00000000000029",
|
|
164
|
+
"name": "00000000000029_form_endpoints_default_empty.sql",
|
|
165
|
+
"sql": "-- A contact form has no address of its own by default.\n--\n-- The starter content ships a contact page addressed to `contact@example.com`, and\n-- migration 27 faithfully carried that placeholder into `form_endpoints`. Faithful was\n-- the wrong call. `example.com` is reserved by RFC 2606 precisely so it can never be\n-- registered, so the address is not merely unhelpful — it is guaranteed undeliverable,\n-- while looking like a real setting to every layer downstream. The visitor is thanked,\n-- the relay accepts the message, and nobody is ever notified. Nothing errors, so an\n-- install can run that way indefinitely.\n--\n-- Since the messaging system arrived, a per-form address is not something an operator\n-- needs to think about at all. Submissions are stored as threads and answered in\n-- CMS → Messages; the notification address is a single site-wide setting, and a form\n-- only carries its own address when someone deliberately wants that form routed\n-- elsewhere (a careers form to HR, say).\n--\n-- So the default becomes NULL, meaning \"use the site contact address\", which resolves\n-- to the address set in CMS → Messages and finally to the first admin's own login. An\n-- install therefore reaches a real human out of the box without configuring anything.\n--\n-- The sandbox is unaffected: `resolveFormRecipient` overrides everything with\n-- SANDBOX_CONTACT_EMAIL when NEXT_PUBLIC_IS_SANDBOX is set, so the hosted demo keeps\n-- routing to the operator's own inbox without storing an address here.\n--\n-- Forward-only and idempotent.\n\n-- Reserved domains from RFC 2606 / RFC 6761. Matching on the domain rather than the\n-- exact seeded string also catches an operator who typed their own placeholder.\nUPDATE public.form_endpoints\n SET recipient_email = NULL\n WHERE recipient_email IS NOT NULL\n AND (\n lower(recipient_email) LIKE '%@example.com'\n OR lower(recipient_email) LIKE '%@example.org'\n OR lower(recipient_email) LIKE '%@example.net'\n OR lower(recipient_email) LIKE '%@example.edu'\n OR lower(recipient_email) LIKE '%.example'\n OR lower(recipient_email) LIKE '%.invalid'\n OR lower(recipient_email) LIKE '%.test'\n OR lower(recipient_email) LIKE '%.localhost'\n OR lower(recipient_email) LIKE '%.local'\n );\n\nCOMMENT ON COLUMN public.form_endpoints.recipient_email IS 'Per-form override. NULL means \"use the site contact address\" (CMS -> Messages, falling back to the first admin), which is the default and the usual case.';\n\n-- Same reasoning for the site-wide rows: a placeholder there is worse than an empty\n-- value, because an empty one falls through to the first admin and actually arrives.\nUPDATE public.site_settings\n SET value = jsonb_set(value, '{contactEmail}', '\"\"'::jsonb)\n WHERE key IN ('forms_contact', 'store_contact')\n AND value ? 'contactEmail'\n AND lower(value->>'contactEmail') LIKE '%@example.%';\n"
|
|
146
166
|
}
|
|
147
167
|
];
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { hasExplicitSiteUrl, isPubliclyRoutableSiteUrl } from './site-url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A real send looked successful end to end — SMTP2GO accepted it, Microsoft returned
|
|
7
|
+
* "250 Queued mail for delivery" — and the message still never arrived. The cause was
|
|
8
|
+
* the link inside it: `resolveSiteUrl()` falls back to http://localhost:3000 when
|
|
9
|
+
* nothing is configured, so the reply carried a dead button under a "Re:" subject, which
|
|
10
|
+
* reads as phishing and gets quarantined without a bounce.
|
|
11
|
+
*
|
|
12
|
+
* Anything that mails a link has to ask this question first.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
describe('isPubliclyRoutableSiteUrl', () => {
|
|
16
|
+
it.each([
|
|
17
|
+
'http://localhost:3000',
|
|
18
|
+
'http://localhost:4200',
|
|
19
|
+
'https://localhost',
|
|
20
|
+
'http://127.0.0.1:3000',
|
|
21
|
+
'http://[::1]:3000',
|
|
22
|
+
'http://0.0.0.0:8080',
|
|
23
|
+
'http://mymachine.local',
|
|
24
|
+
'http://site.localhost:3000',
|
|
25
|
+
])('rejects the loopback address %s', (url) => {
|
|
26
|
+
expect(isPubliclyRoutableSiteUrl(url)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it.each([
|
|
30
|
+
'http://10.0.0.5:3000',
|
|
31
|
+
'http://192.168.1.20',
|
|
32
|
+
'http://172.16.0.9',
|
|
33
|
+
'http://172.31.255.254',
|
|
34
|
+
'http://169.254.10.1',
|
|
35
|
+
])('rejects the private-network address %s', (url) => {
|
|
36
|
+
expect(isPubliclyRoutableSiteUrl(url)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects a bare hostname that only resolves on the local network', () => {
|
|
40
|
+
expect(isPubliclyRoutableSiteUrl('http://intranet')).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects anything that is not a URL at all', () => {
|
|
44
|
+
expect(isPubliclyRoutableSiteUrl('')).toBe(false);
|
|
45
|
+
expect(isPubliclyRoutableSiteUrl('not a url')).toBe(false);
|
|
46
|
+
expect(isPubliclyRoutableSiteUrl('ftp://example.com')).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it.each([
|
|
50
|
+
'https://nextblock.dev',
|
|
51
|
+
'https://www.newrootsherbal.com',
|
|
52
|
+
'https://shop.example.co.uk',
|
|
53
|
+
'https://my-app.vercel.app',
|
|
54
|
+
// 172.32 is outside the RFC1918 block, so it is a public address.
|
|
55
|
+
'http://172.32.0.1',
|
|
56
|
+
])('accepts the publicly reachable address %s', (url) => {
|
|
57
|
+
expect(isPubliclyRoutableSiteUrl(url)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('hasExplicitSiteUrl', () => {
|
|
62
|
+
const original = process.env.NEXT_PUBLIC_URL;
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
if (original === undefined) delete process.env.NEXT_PUBLIC_URL;
|
|
65
|
+
else process.env.NEXT_PUBLIC_URL = original;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('is false when nothing is configured, which is the case that must be refused', () => {
|
|
69
|
+
delete process.env.NEXT_PUBLIC_URL;
|
|
70
|
+
expect(hasExplicitSiteUrl()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('is true for a deliberately local address — testing on your own machine is valid', () => {
|
|
74
|
+
process.env.NEXT_PUBLIC_URL = 'http://localhost:3000';
|
|
75
|
+
expect(hasExplicitSiteUrl()).toBe(true);
|
|
76
|
+
// Still not routable: the send is allowed, but the log says who can open it.
|
|
77
|
+
expect(isPubliclyRoutableSiteUrl('http://localhost:3000')).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('is true for a real public URL', () => {
|
|
81
|
+
process.env.NEXT_PUBLIC_URL = 'https://nextblock.dev';
|
|
82
|
+
expect(hasExplicitSiteUrl()).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('treats whitespace as unset rather than as a choice', () => {
|
|
86
|
+
process.env.NEXT_PUBLIC_URL = ' ';
|
|
87
|
+
expect(hasExplicitSiteUrl()).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -1,48 +1,102 @@
|
|
|
1
|
-
// Resolve the canonical public site URL across deploy channels WITHOUT requiring
|
|
2
|
-
// NEXT_PUBLIC_URL to be set. On Vercel it falls back to the auto-provisioned
|
|
3
|
-
// production URL, so a one-click deploy needs no URL input at all.
|
|
4
|
-
//
|
|
5
|
-
// Dependency-free and safe to import anywhere — server components, route handlers,
|
|
6
|
-
// or client components — like lib/setup/env-status.ts. It only reads `process.env`:
|
|
7
|
-
// NEXT_PUBLIC_* names are inlined into the browser bundle at build time, the others
|
|
8
|
-
// resolve server-side only (and are simply absent — harmless — in the browser).
|
|
9
|
-
|
|
10
|
-
const stripTrailingSlash = (value: string): string => value.replace(/\/+$/, '');
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Vercel always sets a production domain (even on preview deployments):
|
|
14
|
-
* `VERCEL_PROJECT_PRODUCTION_URL` server-side, and Vercel exposes the
|
|
15
|
-
* framework-prefixed `NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL` to the browser
|
|
16
|
-
* bundle at build time. Neither includes the protocol. We prefer the production URL
|
|
17
|
-
* over the per-deployment `VERCEL_URL` so absolute links (sitemap, canonical, OG)
|
|
18
|
-
* stay stable across deploys.
|
|
19
|
-
*/
|
|
20
|
-
function vercelProductionUrl(): string {
|
|
21
|
-
const host =
|
|
22
|
-
process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL?.trim() ||
|
|
23
|
-
process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
|
|
24
|
-
return host ? `https://${stripTrailingSlash(host)}` : '';
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Canonical absolute site origin (e.g. `https://example.com`), no trailing slash.
|
|
29
|
-
* Precedence: explicit `NEXT_PUBLIC_URL` → Vercel production URL → `fallback`.
|
|
30
|
-
*/
|
|
31
|
-
export function resolveSiteUrl(fallback = 'http://localhost:3000'): string {
|
|
32
|
-
const explicit = process.env.NEXT_PUBLIC_URL?.trim();
|
|
33
|
-
if (explicit) return stripTrailingSlash(explicit);
|
|
34
|
-
|
|
35
|
-
const vercel = vercelProductionUrl();
|
|
36
|
-
if (vercel) return vercel;
|
|
37
|
-
|
|
38
|
-
return stripTrailingSlash(fallback);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* True when a real, production-intended site URL is available (an explicit
|
|
43
|
-
* `NEXT_PUBLIC_URL` or the Vercel production URL) — i.e. {@link resolveSiteUrl}
|
|
44
|
-
* is NOT returning the local-dev fallback. Use this to gate "URL not set" warnings.
|
|
45
|
-
*/
|
|
46
|
-
export function hasResolvedSiteUrl(): boolean {
|
|
47
|
-
return Boolean(process.env.NEXT_PUBLIC_URL?.trim() || vercelProductionUrl());
|
|
48
|
-
}
|
|
1
|
+
// Resolve the canonical public site URL across deploy channels WITHOUT requiring
|
|
2
|
+
// NEXT_PUBLIC_URL to be set. On Vercel it falls back to the auto-provisioned
|
|
3
|
+
// production URL, so a one-click deploy needs no URL input at all.
|
|
4
|
+
//
|
|
5
|
+
// Dependency-free and safe to import anywhere — server components, route handlers,
|
|
6
|
+
// or client components — like lib/setup/env-status.ts. It only reads `process.env`:
|
|
7
|
+
// NEXT_PUBLIC_* names are inlined into the browser bundle at build time, the others
|
|
8
|
+
// resolve server-side only (and are simply absent — harmless — in the browser).
|
|
9
|
+
|
|
10
|
+
const stripTrailingSlash = (value: string): string => value.replace(/\/+$/, '');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Vercel always sets a production domain (even on preview deployments):
|
|
14
|
+
* `VERCEL_PROJECT_PRODUCTION_URL` server-side, and Vercel exposes the
|
|
15
|
+
* framework-prefixed `NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL` to the browser
|
|
16
|
+
* bundle at build time. Neither includes the protocol. We prefer the production URL
|
|
17
|
+
* over the per-deployment `VERCEL_URL` so absolute links (sitemap, canonical, OG)
|
|
18
|
+
* stay stable across deploys.
|
|
19
|
+
*/
|
|
20
|
+
function vercelProductionUrl(): string {
|
|
21
|
+
const host =
|
|
22
|
+
process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL?.trim() ||
|
|
23
|
+
process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
|
|
24
|
+
return host ? `https://${stripTrailingSlash(host)}` : '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Canonical absolute site origin (e.g. `https://example.com`), no trailing slash.
|
|
29
|
+
* Precedence: explicit `NEXT_PUBLIC_URL` → Vercel production URL → `fallback`.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveSiteUrl(fallback = 'http://localhost:3000'): string {
|
|
32
|
+
const explicit = process.env.NEXT_PUBLIC_URL?.trim();
|
|
33
|
+
if (explicit) return stripTrailingSlash(explicit);
|
|
34
|
+
|
|
35
|
+
const vercel = vercelProductionUrl();
|
|
36
|
+
if (vercel) return vercel;
|
|
37
|
+
|
|
38
|
+
return stripTrailingSlash(fallback);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True when a real, production-intended site URL is available (an explicit
|
|
43
|
+
* `NEXT_PUBLIC_URL` or the Vercel production URL) — i.e. {@link resolveSiteUrl}
|
|
44
|
+
* is NOT returning the local-dev fallback. Use this to gate "URL not set" warnings.
|
|
45
|
+
*/
|
|
46
|
+
export function hasResolvedSiteUrl(): boolean {
|
|
47
|
+
return Boolean(process.env.NEXT_PUBLIC_URL?.trim() || vercelProductionUrl());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the resolved site URL is one a stranger could actually open.
|
|
52
|
+
*
|
|
53
|
+
* `resolveSiteUrl()` falls back to http://localhost:3000 when nothing is configured,
|
|
54
|
+
* which is correct for rendering local links but catastrophic in an email: the
|
|
55
|
+
* recipient gets a dead button, and — because a non-routable host under a plain http
|
|
56
|
+
* scheme alongside a long opaque token reads exactly like phishing — the message is
|
|
57
|
+
* likely to be quarantined before they even see it. Anything that mails a link out
|
|
58
|
+
* must check this first.
|
|
59
|
+
*/
|
|
60
|
+
export function isPubliclyRoutableSiteUrl(url: string = resolveSiteUrl()): boolean {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = new URL(url);
|
|
63
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false;
|
|
64
|
+
|
|
65
|
+
const host = parsed.hostname.toLowerCase();
|
|
66
|
+
if (
|
|
67
|
+
host === 'localhost' ||
|
|
68
|
+
host === '127.0.0.1' ||
|
|
69
|
+
host === '::1' ||
|
|
70
|
+
host === '0.0.0.0' ||
|
|
71
|
+
host.endsWith('.local') ||
|
|
72
|
+
host.endsWith('.localhost') ||
|
|
73
|
+
// RFC1918 / link-local: reachable on someone's LAN, never from an inbox.
|
|
74
|
+
/^10\./.test(host) ||
|
|
75
|
+
/^192\.168\./.test(host) ||
|
|
76
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
|
77
|
+
/^169\.254\./.test(host)
|
|
78
|
+
) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A bare hostname with no dot cannot be resolved from outside this network.
|
|
83
|
+
return host.includes('.');
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether the site URL was actually chosen by the operator, rather than invented.
|
|
91
|
+
*
|
|
92
|
+
* This is the distinction that matters before mailing a link. A `localhost` URL is
|
|
93
|
+
* perfectly workable when the person reading the mail is on the machine running the
|
|
94
|
+
* server — that is an ordinary way to test. What is not workable is `resolveSiteUrl()`
|
|
95
|
+
* quietly inventing `http://localhost:3000` because nothing was configured, and a link
|
|
96
|
+
* to it going out to a real customer.
|
|
97
|
+
*
|
|
98
|
+
* So: honour an explicit choice, refuse an accidental default.
|
|
99
|
+
*/
|
|
100
|
+
export function hasExplicitSiteUrl(): boolean {
|
|
101
|
+
return Boolean(process.env.NEXT_PUBLIC_URL?.trim() || vercelProductionUrl());
|
|
102
|
+
}
|