@nextblock-cms/db 0.17.0 → 0.17.1

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 (46) hide show
  1. package/package.json +1 -1
  2. package/supabase/migrations/02000_catchup_gen1.sql +5880 -0
  3. package/supabase/migrations/{00000000000000_baseline_schema.sql → 02001_baseline_schema.sql} +305 -25
  4. package/supabase/migrations/{00000000000001_baseline_constraints_and_indexes.sql → 02002_baseline_constraints_and_indexes.sql} +194 -3
  5. package/supabase/migrations/{00000000000002_baseline_security_and_grants.sql → 02003_baseline_security_and_grants.sql} +403 -161
  6. package/supabase/migrations/02004_baseline_seed.sql +565 -0
  7. package/supabase/migrations/00000000000003_baseline_seed.sql +0 -468
  8. package/supabase/migrations/00000000000004_default_logo_email_safe.sql +0 -24
  9. package/supabase/migrations/00000000000005_add_custom_canonical.sql +0 -13
  10. package/supabase/migrations/00000000000006_home_live_demo_promo.sql +0 -67
  11. package/supabase/migrations/00000000000007_home_live_demo_promo_copy_fix.sql +0 -49
  12. package/supabase/migrations/00000000000008_setup_article_reorder.sql +0 -30
  13. package/supabase/migrations/00000000000009_home_live_demo_promo_contrast.sql +0 -52
  14. package/supabase/migrations/00000000000010_drop_github_username.sql +0 -79
  15. package/supabase/migrations/00000000000011_language_detection_admin_only.sql +0 -21
  16. package/supabase/migrations/00000000000012_cortex_ai_stock_photo_settings.sql +0 -25
  17. package/supabase/migrations/00000000000013_youtube_nocookie_embeds.sql +0 -136
  18. package/supabase/migrations/00000000000014_site_themes.sql +0 -254
  19. package/supabase/migrations/00000000000015_scheduled_publishing.sql +0 -43
  20. package/supabase/migrations/00000000000016_product_revisions_and_revision_baseline.sql +0 -310
  21. package/supabase/migrations/00000000000017_cortex_ai_mcp_server.sql +0 -91
  22. package/supabase/migrations/00000000000018_site_scripts.sql +0 -96
  23. package/supabase/migrations/00000000000019_site_script_revisions.sql +0 -85
  24. package/supabase/migrations/00000000000020_updating_article.sql +0 -162
  25. package/supabase/migrations/00000000000021_updating_article_git_merge.sql +0 -91
  26. package/supabase/migrations/00000000000022_updating_article_accuracy.sql +0 -94
  27. package/supabase/migrations/00000000000023_updating_article_layout_not_host.sql +0 -87
  28. package/supabase/migrations/00000000000024_updating_article_newline_fix.sql +0 -70
  29. package/supabase/migrations/00000000000025_rebrand_nextblock_dev.sql +0 -53
  30. package/supabase/migrations/00000000000026_product_inquiries.sql +0 -118
  31. package/supabase/migrations/00000000000027_message_threads.sql +0 -391
  32. package/supabase/migrations/00000000000028_interaction_replies.sql +0 -86
  33. package/supabase/migrations/00000000000029_form_endpoints_default_empty.sql +0 -52
  34. package/supabase/migrations/00000000000030_seo_redirects_and_robots.sql +0 -149
  35. package/supabase/migrations/00000000000031_seo_robots_settings_admin_only.sql +0 -35
  36. package/supabase/migrations/00000000000032_seed_seo_score_optimizations.sql +0 -1172
  37. package/supabase/migrations/00000000000033_seed_seo_score_optimizations_p2.sql +0 -353
  38. package/supabase/migrations/00000000000034_seed_seo_french_legal_readability.sql +0 -101
  39. package/supabase/migrations/00000000000035_digital_products_seo_optimizations.sql +0 -134
  40. package/supabase/migrations/00000000000036_seed_posts_missing_seo_metadata.sql +0 -37
  41. package/supabase/migrations/00000000000037_reposition_marketing_and_cortex_mcp.sql +0 -501
  42. package/supabase/migrations/00000000000038_mcp_guide_visual_refresh.sql +0 -64
  43. package/supabase/migrations/00000000000039_mcp_guide_diagram_dimensions.sql +0 -21
  44. package/supabase/migrations/00000000000040_home_comparison_chart_free_cms.sql +0 -81
  45. package/supabase/migrations/00000000000041_marketing_review_free_cms_trial.sql +0 -141
  46. package/supabase/migrations/00000000000042_how_nextblock_works_css_diagram.sql +0 -66
@@ -1,6 +1,7 @@
1
- -- AUTO-GENERATED baseline (re-baseline of migrations 000..044). Idempotent; safe to replay.
2
- -- 00 · schema: enums, functions, tables, sequences, defaults
3
- -- Regenerate via tools/scripts/rebaseline-transform.mjs. Do not hand-edit.
1
+ -- AUTO-GENERATED baseline: squash generation 2 (of migrations 00000000000000..00000000000042).
2
+ -- 001 · schema: enums, functions, tables, sequences, defaults
3
+ -- Idempotent; safe to replay. Regenerate via tools/scripts/rebaseline-transform.mjs do not hand-edit.
4
+ -- Naming: GGNNN_name.sql (GG = squash generation, NNN = sequence). See docs/04-DATABASE-AND-AUTH.md.
4
5
 
5
6
  SET check_function_bodies = false;
6
7
 
@@ -473,6 +474,20 @@ BEGIN
473
474
  END;
474
475
  $$;
475
476
 
477
+ CREATE OR REPLACE FUNCTION public.handle_default_theme_change() RETURNS trigger
478
+ LANGUAGE plpgsql
479
+ SET search_path TO ''
480
+ AS $$
481
+ BEGIN
482
+ IF NEW.is_default THEN
483
+ UPDATE public.site_themes
484
+ SET is_default = false
485
+ WHERE id <> NEW.id AND is_default;
486
+ END IF;
487
+ RETURN NEW;
488
+ END;
489
+ $$;
490
+
476
491
  CREATE OR REPLACE FUNCTION public.handle_inventory_item_change() RETURNS trigger
477
492
  LANGUAGE plpgsql
478
493
  SET search_path TO 'public'
@@ -534,8 +549,6 @@ DECLARE
534
549
  user_role public.user_role;
535
550
  v_full_name text;
536
551
  v_avatar_url text;
537
- v_github_username text;
538
- v_provider text;
539
552
  BEGIN
540
553
  INSERT INTO public.site_settings (key, value)
541
554
  VALUES ('is_admin_created', 'false'::jsonb)
@@ -559,35 +572,22 @@ BEGIN
559
572
 
560
573
  v_full_name := NEW.raw_user_meta_data->>'full_name';
561
574
  v_avatar_url := NEW.raw_user_meta_data->>'avatar_url';
562
- v_provider := NEW.raw_app_meta_data->>'provider';
563
-
564
- IF v_provider = 'github' OR (NEW.raw_user_meta_data->>'iss') LIKE '%github%' THEN
565
- v_github_username := COALESCE(
566
- NEW.raw_user_meta_data->>'user_name',
567
- NEW.raw_user_meta_data->>'preferred_username'
568
- );
569
- ELSE
570
- v_github_username := NULL;
571
- END IF;
572
575
 
573
576
  INSERT INTO public.profiles (
574
577
  id,
575
578
  role,
576
579
  full_name,
577
- avatar_url,
578
- github_username
580
+ avatar_url
579
581
  )
580
582
  VALUES (
581
583
  NEW.id,
582
584
  user_role,
583
585
  v_full_name,
584
- v_avatar_url,
585
- v_github_username
586
+ v_avatar_url
586
587
  )
587
588
  ON CONFLICT (id) DO UPDATE SET
588
589
  full_name = EXCLUDED.full_name,
589
- avatar_url = EXCLUDED.avatar_url,
590
- github_username = EXCLUDED.github_username;
590
+ avatar_url = EXCLUDED.avatar_url;
591
591
 
592
592
  RETURN NEW;
593
593
  END;
@@ -752,6 +752,49 @@ CREATE OR REPLACE FUNCTION public.normalize_currency_amount_map(amounts jsonb) R
752
752
  END;
753
753
  $_$;
754
754
 
755
+ CREATE OR REPLACE FUNCTION public.prevent_site_script_revision_rewrite() RETURNS trigger
756
+ LANGUAGE plpgsql
757
+ SET search_path TO ''
758
+ AS $$
759
+ BEGIN
760
+ RAISE EXCEPTION 'site_script_revisions is append-only; % is not permitted', TG_OP
761
+ USING ERRCODE = 'restrict_violation';
762
+ END;
763
+ $$;
764
+
765
+ CREATE OR REPLACE FUNCTION public.prevent_system_theme_delete() RETURNS trigger
766
+ LANGUAGE plpgsql
767
+ SET search_path TO ''
768
+ AS $$
769
+ BEGIN
770
+ IF OLD.is_system THEN
771
+ RAISE EXCEPTION 'Theme "%" is a system theme and cannot be deleted', OLD.slug
772
+ USING ERRCODE = 'restrict_violation';
773
+ END IF;
774
+ RETURN OLD;
775
+ END;
776
+ $$;
777
+
778
+ CREATE OR REPLACE FUNCTION public.prevent_thread_message_rewrite() RETURNS trigger
779
+ LANGUAGE plpgsql
780
+ SET search_path TO ''
781
+ AS $$
782
+ BEGIN
783
+ IF TG_OP = 'DELETE' THEN
784
+ RAISE EXCEPTION 'thread_messages is append-only; DELETE is not permitted'
785
+ USING ERRCODE = 'restrict_violation';
786
+ END IF;
787
+ IF NEW.body IS DISTINCT FROM OLD.body
788
+ OR NEW.direction IS DISTINCT FROM OLD.direction
789
+ OR NEW.thread_id IS DISTINCT FROM OLD.thread_id
790
+ OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
791
+ RAISE EXCEPTION 'thread_messages content is append-only; only delivery flags may change'
792
+ USING ERRCODE = 'restrict_violation';
793
+ END IF;
794
+ RETURN NEW;
795
+ END;
796
+ $$;
797
+
755
798
  CREATE OR REPLACE FUNCTION public.set_currency_defaults() RETURNS trigger
756
799
  LANGUAGE plpgsql
757
800
  SET search_path TO ''
@@ -1434,8 +1477,10 @@ CREATE TABLE IF NOT EXISTS public.cms_interactions (
1434
1477
  reactions jsonb DEFAULT '{}'::jsonb NOT NULL,
1435
1478
  created_at timestamp with time zone DEFAULT now() NOT NULL,
1436
1479
  updated_at timestamp with time zone DEFAULT now() NOT NULL,
1480
+ parent_id uuid,
1437
1481
  CONSTRAINT check_product_or_post CHECK ((((product_id IS NOT NULL) AND (post_id IS NULL)) OR ((post_id IS NOT NULL) AND (product_id IS NULL)))),
1438
- CONSTRAINT check_rating_only_for_review CHECK ((((type = 'review'::public.interaction_type) AND (rating IS NOT NULL) AND (rating >= 1) AND (rating <= 5)) OR ((type = 'comment'::public.interaction_type) AND (rating IS NULL))))
1482
+ CONSTRAINT check_rating_only_for_review CHECK ((((type = 'review'::public.interaction_type) AND (rating IS NOT NULL) AND (rating >= 1) AND (rating <= 5)) OR ((type = 'comment'::public.interaction_type) AND (rating IS NULL)))),
1483
+ CONSTRAINT cms_interactions_reply_check CHECK (((parent_id IS NULL) OR ((parent_id <> id) AND (rating IS NULL) AND (type = 'comment'::public.interaction_type))))
1439
1484
  );
1440
1485
 
1441
1486
  COMMENT ON TABLE public.cms_interactions IS 'Stores user-submitted product reviews and blog post comments.';
@@ -1446,6 +1491,32 @@ COMMENT ON COLUMN public.cms_interactions.user_id IS 'References public.profiles
1446
1491
 
1447
1492
  COMMENT ON COLUMN public.cms_interactions.reactions IS 'JSONB structure tracking counts of reactions (likes, etc.).';
1448
1493
 
1494
+ COMMENT 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.';
1495
+
1496
+ CREATE TABLE IF NOT EXISTS public.cms_redirects (
1497
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1498
+ source_path text NOT NULL,
1499
+ destination_path text NOT NULL,
1500
+ status_code integer DEFAULT 301 NOT NULL,
1501
+ is_active boolean DEFAULT true NOT NULL,
1502
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1503
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
1504
+ CONSTRAINT cms_redirects_destination_path_check CHECK (((destination_path ~ '^/'::text) OR (destination_path ~ '^https://'::text))),
1505
+ CONSTRAINT cms_redirects_no_self_redirect_check CHECK ((source_path <> destination_path)),
1506
+ CONSTRAINT cms_redirects_source_path_check CHECK ((source_path ~ '^/'::text)),
1507
+ CONSTRAINT cms_redirects_status_code_check CHECK ((status_code = ANY (ARRAY[301, 302])))
1508
+ );
1509
+
1510
+ COMMENT ON TABLE public.cms_redirects IS 'Operator-managed 301/302 redirects resolved by apps/nextblock/proxy.ts before rendering. Only is_active rows are publicly readable; only ADMIN may write, because a redirect rule is an open-redirect surface.';
1511
+
1512
+ COMMENT ON COLUMN public.cms_redirects.source_path IS 'Exact site-relative path to match, normalized to a leading slash and no trailing slash (except root). No wildcards, by design.';
1513
+
1514
+ COMMENT ON COLUMN public.cms_redirects.destination_path IS 'Site-relative path or absolute https URL to send the visitor to.';
1515
+
1516
+ COMMENT ON COLUMN public.cms_redirects.status_code IS '301 permanent or 302 temporary. 307/308 are not offered by the admin UI.';
1517
+
1518
+ COMMENT ON COLUMN public.cms_redirects.is_active IS 'Only active rows are readable by anon, and only active rows are matched by the proxy.';
1519
+
1449
1520
  CREATE TABLE IF NOT EXISTS public.content_drafts (
1450
1521
  id bigint NOT NULL,
1451
1522
  parent_type text NOT NULL,
@@ -1644,6 +1715,25 @@ CREATE TABLE IF NOT EXISTS public.email_2fa_challenges (
1644
1715
 
1645
1716
  COMMENT ON TABLE public.email_2fa_challenges IS 'Short-lived SHA-256 hashes of 6-digit email verification codes. Readable/writable only by the service role.';
1646
1717
 
1718
+ CREATE TABLE IF NOT EXISTS public.form_endpoints (
1719
+ form_key uuid NOT NULL,
1720
+ label text DEFAULT 'Contact form'::text NOT NULL,
1721
+ recipient_email text,
1722
+ fields jsonb DEFAULT '[]'::jsonb NOT NULL,
1723
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1724
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
1725
+ CONSTRAINT form_endpoints_fields_array CHECK ((jsonb_typeof(fields) = 'array'::text)),
1726
+ CONSTRAINT form_endpoints_label_not_blank CHECK ((char_length(btrim(label)) > 0))
1727
+ );
1728
+
1729
+ COMMENT 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.';
1730
+
1731
+ COMMENT 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.';
1732
+
1733
+ COMMENT 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.';
1734
+
1735
+ COMMENT 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.';
1736
+
1647
1737
  CREATE TABLE IF NOT EXISTS public.freemius_plans (
1648
1738
  id uuid DEFAULT gen_random_uuid() NOT NULL,
1649
1739
  product_id uuid NOT NULL,
@@ -1722,6 +1812,21 @@ COMMENT ON COLUMN public.logos.name IS 'The name of the brand or company for the
1722
1812
 
1723
1813
  COMMENT ON COLUMN public.logos.media_id IS 'Foreign key to the media table for the logo image.';
1724
1814
 
1815
+ CREATE TABLE IF NOT EXISTS public.mcp_access_tokens (
1816
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1817
+ name text NOT NULL,
1818
+ token_hash text NOT NULL,
1819
+ token_prefix text NOT NULL,
1820
+ scopes text[] DEFAULT ARRAY['read'::text, 'write'::text] NOT NULL,
1821
+ created_by uuid,
1822
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1823
+ last_used_at timestamp with time zone,
1824
+ expires_at timestamp with time zone,
1825
+ revoked_at timestamp with time zone
1826
+ );
1827
+
1828
+ COMMENT ON TABLE public.mcp_access_tokens IS 'Bearer tokens for the Cortex AI MCP server at /api/mcp. Stores SHA-256 hashes only; plaintext is displayed once at mint time.';
1829
+
1725
1830
  CREATE TABLE IF NOT EXISTS public.media (
1726
1831
  id uuid DEFAULT gen_random_uuid() NOT NULL,
1727
1832
  uploader_id uuid,
@@ -1756,6 +1861,40 @@ COMMENT ON COLUMN public.media.file_path IS 'Full path to the file in the storag
1756
1861
 
1757
1862
  COMMENT ON COLUMN public.media.folder IS 'Folder path prefix for the R2 object.';
1758
1863
 
1864
+ CREATE TABLE IF NOT EXISTS public.message_threads (
1865
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1866
+ source text NOT NULL,
1867
+ subject_id uuid,
1868
+ form_key uuid,
1869
+ subject_label text DEFAULT 'Message'::text NOT NULL,
1870
+ sender_name text,
1871
+ sender_email text,
1872
+ locale text,
1873
+ fields jsonb DEFAULT '{}'::jsonb NOT NULL,
1874
+ status text DEFAULT 'open'::text NOT NULL,
1875
+ unread_for_admin boolean DEFAULT true NOT NULL,
1876
+ unread_for_visitor boolean DEFAULT false NOT NULL,
1877
+ token_hash text,
1878
+ token_expires_at timestamp with time zone,
1879
+ token_revoked_at timestamp with time zone,
1880
+ token_last_used_at timestamp with time zone,
1881
+ last_message_at timestamp with time zone DEFAULT now() NOT NULL,
1882
+ ip_masked text,
1883
+ user_agent text,
1884
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1885
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
1886
+ CONSTRAINT message_threads_fields_object CHECK ((jsonb_typeof(fields) = 'object'::text)),
1887
+ CONSTRAINT message_threads_source_check CHECK ((source = ANY (ARRAY['product_inquiry'::text, 'contact_form'::text]))),
1888
+ CONSTRAINT message_threads_status_check CHECK ((status = ANY (ARRAY['open'::text, 'closed'::text]))),
1889
+ CONSTRAINT message_threads_subject_check CHECK ((((source = 'product_inquiry'::text) AND (subject_id IS NOT NULL)) OR ((source = 'contact_form'::text) AND (form_key IS NOT NULL))))
1890
+ );
1891
+
1892
+ COMMENT 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.';
1893
+
1894
+ COMMENT ON COLUMN public.message_threads.subject_id IS 'product_inquiries.id. Plain uuid, no FK: a conversation outlives the enquiry record it grew from.';
1895
+
1896
+ COMMENT 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.';
1897
+
1759
1898
  CREATE TABLE IF NOT EXISTS public.navigation_items (
1760
1899
  id bigint NOT NULL,
1761
1900
  language_id bigint NOT NULL,
@@ -1926,7 +2065,9 @@ CREATE TABLE IF NOT EXISTS public.pages (
1926
2065
  translation_group_id uuid DEFAULT gen_random_uuid() NOT NULL,
1927
2066
  created_at timestamp with time zone DEFAULT now() NOT NULL,
1928
2067
  updated_at timestamp with time zone DEFAULT now() NOT NULL,
1929
- feature_image_id uuid
2068
+ feature_image_id uuid,
2069
+ custom_canonical text,
2070
+ published_at timestamp with time zone
1930
2071
  );
1931
2072
 
1932
2073
  COMMENT ON TABLE public.pages IS 'Stores static pages for the website.';
@@ -1939,6 +2080,8 @@ COMMENT ON COLUMN public.pages.translation_group_id IS 'Groups different languag
1939
2080
 
1940
2081
  COMMENT ON COLUMN public.pages.feature_image_id IS 'ID of the media item to be used as the page feature image.';
1941
2082
 
2083
+ COMMENT ON COLUMN public.pages.published_at IS 'Optional go-live moment. NULL = live as soon as status is published. A future value withholds the page from public reads until it passes (status stays "published"; the CMS renders that pair as "Scheduled").';
2084
+
1942
2085
  DO $rb$ BEGIN
1943
2086
  IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.pages'::regclass AND attname = 'id' AND attidentity <> '') THEN
1944
2087
  ALTER TABLE public.pages ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
@@ -1996,7 +2139,8 @@ CREATE TABLE IF NOT EXISTS public.posts (
1996
2139
  version integer DEFAULT 1 NOT NULL,
1997
2140
  translation_group_id uuid DEFAULT gen_random_uuid() NOT NULL,
1998
2141
  created_at timestamp with time zone DEFAULT now() NOT NULL,
1999
- updated_at timestamp with time zone DEFAULT now() NOT NULL
2142
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2143
+ custom_canonical text
2000
2144
  );
2001
2145
 
2002
2146
  COMMENT ON TABLE public.posts IS 'Stores blog posts or news articles.';
@@ -2128,12 +2272,66 @@ CREATE TABLE IF NOT EXISTS public.product_freemius_sale_coupons (
2128
2272
 
2129
2273
  COMMENT ON TABLE public.product_freemius_sale_coupons IS 'Auto-generated, time-bounded Freemius coupons that enforce a scheduled sale on a Freemius product at Freemius-hosted checkout.';
2130
2274
 
2275
+ CREATE TABLE IF NOT EXISTS public.product_inquiries (
2276
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2277
+ product_id uuid,
2278
+ product_slug text,
2279
+ product_title text,
2280
+ sender_name text NOT NULL,
2281
+ sender_email text NOT NULL,
2282
+ message text NOT NULL,
2283
+ locale text,
2284
+ ip_masked text,
2285
+ user_agent text,
2286
+ email_delivered boolean DEFAULT false NOT NULL,
2287
+ is_resolved boolean DEFAULT false NOT NULL,
2288
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2289
+ CONSTRAINT product_inquiries_message_not_blank CHECK ((char_length(btrim(message)) > 0)),
2290
+ CONSTRAINT product_inquiries_sender_email_not_blank CHECK ((char_length(btrim(sender_email)) > 0)),
2291
+ CONSTRAINT product_inquiries_sender_name_not_blank CHECK ((char_length(btrim(sender_name)) > 0))
2292
+ );
2293
+
2294
+ COMMENT 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.';
2295
+
2296
+ COMMENT ON COLUMN public.product_inquiries.product_id IS 'Plain uuid, no FK: an enquiry outlives the product it was about.';
2297
+
2298
+ COMMENT 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.';
2299
+
2300
+ COMMENT 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.';
2301
+
2131
2302
  CREATE TABLE IF NOT EXISTS public.product_media (
2132
2303
  product_id uuid NOT NULL,
2133
2304
  media_id uuid NOT NULL,
2134
2305
  sort_order integer DEFAULT 0
2135
2306
  );
2136
2307
 
2308
+ CREATE TABLE IF NOT EXISTS public.product_revisions (
2309
+ id bigint NOT NULL,
2310
+ product_id uuid NOT NULL,
2311
+ author_id uuid,
2312
+ version integer NOT NULL,
2313
+ revision_type public.revision_type NOT NULL,
2314
+ content jsonb NOT NULL,
2315
+ created_at timestamp with time zone DEFAULT now() NOT NULL
2316
+ );
2317
+
2318
+ COMMENT ON TABLE public.product_revisions IS 'Hybrid (snapshot/diff) revisions for products.';
2319
+
2320
+ COMMENT ON COLUMN public.product_revisions.content IS 'If snapshot: full content; if diff: JSON Patch array.';
2321
+
2322
+ DO $rb$ BEGIN
2323
+ IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.product_revisions'::regclass AND attname = 'id' AND attidentity <> '') THEN
2324
+ ALTER TABLE public.product_revisions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
2325
+ SEQUENCE NAME public.product_revisions_id_seq
2326
+ START WITH 1
2327
+ INCREMENT BY 1
2328
+ NO MINVALUE
2329
+ NO MAXVALUE
2330
+ CACHE 1
2331
+ );
2332
+ END IF;
2333
+ END $rb$;
2334
+
2137
2335
  CREATE TABLE IF NOT EXISTS public.product_variants (
2138
2336
  id uuid DEFAULT gen_random_uuid() NOT NULL,
2139
2337
  product_id uuid NOT NULL,
@@ -2207,6 +2405,9 @@ CREATE TABLE IF NOT EXISTS public.products (
2207
2405
  scheduled_price_at timestamp with time zone,
2208
2406
  average_rating numeric(3,2) DEFAULT 0.00 NOT NULL,
2209
2407
  total_reviews integer DEFAULT 0 NOT NULL,
2408
+ custom_canonical text,
2409
+ published_at timestamp with time zone,
2410
+ version integer DEFAULT 1 NOT NULL,
2210
2411
  CONSTRAINT products_payment_provider_check CHECK ((payment_provider = ANY (ARRAY['stripe'::text, 'freemius'::text]))),
2211
2412
  CONSTRAINT products_prices_is_valid CHECK (public.is_valid_currency_amount_map(prices)),
2212
2413
  CONSTRAINT products_product_type_check CHECK ((product_type = ANY (ARRAY['physical'::text, 'digital'::text]))),
@@ -2237,13 +2438,16 @@ COMMENT ON COLUMN public.products.average_rating IS 'Aggregated average 1-5 rati
2237
2438
 
2238
2439
  COMMENT ON COLUMN public.products.total_reviews IS 'Total count of approved reviews for this product.';
2239
2440
 
2441
+ COMMENT ON COLUMN public.products.published_at IS 'Optional go-live moment. NULL = live as soon as status is active. A future value withholds the product from public reads until it passes (status stays "active"; the CMS renders that pair as "Scheduled").';
2442
+
2443
+ COMMENT ON COLUMN public.products.version IS 'Monotonic version number for hybrid revisions.';
2444
+
2240
2445
  CREATE TABLE IF NOT EXISTS public.profiles (
2241
2446
  id uuid NOT NULL,
2242
2447
  updated_at timestamp with time zone,
2243
2448
  full_name text,
2244
2449
  avatar_url text,
2245
2450
  website text,
2246
- github_username text,
2247
2451
  phone text,
2248
2452
  role public.user_role DEFAULT 'USER'::public.user_role NOT NULL
2249
2453
  );
@@ -2308,6 +2512,43 @@ CREATE TABLE IF NOT EXISTS public.shipping_zones (
2308
2512
  updated_at timestamp with time zone DEFAULT now()
2309
2513
  );
2310
2514
 
2515
+ CREATE TABLE IF NOT EXISTS public.site_script_revisions (
2516
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2517
+ script_id uuid,
2518
+ script_name text NOT NULL,
2519
+ revision_type text NOT NULL,
2520
+ actor_user_id uuid,
2521
+ source text DEFAULT 'cms'::text NOT NULL,
2522
+ summary text,
2523
+ snapshot jsonb NOT NULL,
2524
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2525
+ CONSTRAINT site_script_revisions_snapshot_is_object_check CHECK ((jsonb_typeof(snapshot) = 'object'::text)),
2526
+ CONSTRAINT site_script_revisions_source_check CHECK ((source = ANY (ARRAY['cms'::text, 'mcp'::text]))),
2527
+ CONSTRAINT site_script_revisions_type_check CHECK ((revision_type = ANY (ARRAY['create'::text, 'update'::text, 'delete'::text, 'revert'::text])))
2528
+ );
2529
+
2530
+ COMMENT ON TABLE public.site_script_revisions IS 'Append-only audit log and undo history for site_scripts. Each row is a restorable snapshot. UPDATE and DELETE are blocked by trigger, including for the service role.';
2531
+
2532
+ CREATE TABLE IF NOT EXISTS public.site_scripts (
2533
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2534
+ name text NOT NULL,
2535
+ description text,
2536
+ code text DEFAULT ''::text NOT NULL,
2537
+ src text,
2538
+ placement text DEFAULT 'body_end'::text NOT NULL,
2539
+ load_strategy text DEFAULT 'default'::text NOT NULL,
2540
+ is_active boolean DEFAULT false NOT NULL,
2541
+ sort_order integer DEFAULT 0 NOT NULL,
2542
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2543
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2544
+ CONSTRAINT site_scripts_has_payload_check CHECK (((src IS NOT NULL) OR (length(btrim(code)) > 0))),
2545
+ CONSTRAINT site_scripts_load_strategy_check CHECK ((load_strategy = ANY (ARRAY['default'::text, 'defer'::text, 'async'::text]))),
2546
+ CONSTRAINT site_scripts_placement_check CHECK ((placement = ANY (ARRAY['head'::text, 'body_start'::text, 'body_end'::text]))),
2547
+ CONSTRAINT site_scripts_src_scheme_check CHECK (((src IS NULL) OR (src ~ '^https://'::text)))
2548
+ );
2549
+
2550
+ COMMENT ON TABLE public.site_scripts IS 'Admin-authored JavaScript injected into the public site by the root layout, with the request CSP nonce applied. Only is_active rows are publicly readable; only ADMIN may write. Distinct from privacy_settings.custom_scripts, which is consent-gated marketing tags.';
2551
+
2311
2552
  CREATE TABLE IF NOT EXISTS public.site_settings (
2312
2553
  key text NOT NULL,
2313
2554
  value jsonb
@@ -2315,6 +2556,28 @@ CREATE TABLE IF NOT EXISTS public.site_settings (
2315
2556
 
2316
2557
  COMMENT ON TABLE public.site_settings IS 'Key-value store for global site settings. Sensitive keys (Cortex AI BYOK, Bot Protection Secret, Email secret, Payment secret) hold encrypted envelopes and are restricted to ADMIN via row-level policies.';
2317
2558
 
2559
+ CREATE TABLE IF NOT EXISTS public.site_themes (
2560
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2561
+ slug text NOT NULL,
2562
+ name text NOT NULL,
2563
+ description text,
2564
+ icon text DEFAULT 'Palette'::text NOT NULL,
2565
+ color_scheme text DEFAULT 'light'::text NOT NULL,
2566
+ tokens jsonb DEFAULT '{}'::jsonb NOT NULL,
2567
+ extra_css text,
2568
+ is_system boolean DEFAULT false NOT NULL,
2569
+ is_default boolean DEFAULT false NOT NULL,
2570
+ is_active boolean DEFAULT true NOT NULL,
2571
+ sort_order integer DEFAULT 0 NOT NULL,
2572
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2573
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2574
+ CONSTRAINT site_themes_color_scheme_check CHECK ((color_scheme = ANY (ARRAY['light'::text, 'dark'::text]))),
2575
+ CONSTRAINT site_themes_slug_format_check CHECK ((slug ~ '^[a-z][a-z0-9-]{0,38}[a-z0-9]$'::text)),
2576
+ CONSTRAINT site_themes_tokens_is_object_check CHECK ((jsonb_typeof(tokens) = 'object'::text))
2577
+ );
2578
+
2579
+ COMMENT ON TABLE public.site_themes IS 'Editable colour themes. Each row renders to a `:root.<slug>` CSS rule injected by the root layout. Publicly readable (anonymous visitors need the palette); only ADMIN may write.';
2580
+
2318
2581
  CREATE TABLE IF NOT EXISTS public.system_alerts (
2319
2582
  id uuid DEFAULT gen_random_uuid() NOT NULL,
2320
2583
  alert_type text NOT NULL,
@@ -2368,6 +2631,23 @@ COMMENT ON COLUMN public.tax_rates.tax_name IS 'Display name for the tax compone
2368
2631
 
2369
2632
  COMMENT ON COLUMN public.tax_rates.tax_rate IS 'Percent value, not decimal fraction. Example: 5.0000 means 5%.';
2370
2633
 
2634
+ CREATE TABLE IF NOT EXISTS public.thread_messages (
2635
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2636
+ thread_id uuid NOT NULL,
2637
+ direction text NOT NULL,
2638
+ body text NOT NULL,
2639
+ author_id uuid,
2640
+ author_name text,
2641
+ email_delivered boolean DEFAULT false NOT NULL,
2642
+ email_error text,
2643
+ ip_masked text,
2644
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2645
+ CONSTRAINT thread_messages_body_not_blank CHECK ((char_length(btrim(body)) > 0)),
2646
+ CONSTRAINT thread_messages_direction_check CHECK ((direction = ANY (ARRAY['inbound'::text, 'outbound'::text])))
2647
+ );
2648
+
2649
+ COMMENT ON TABLE public.thread_messages IS 'Turns of a private conversation. Content is append-only; only the delivery flags may change after insert.';
2650
+
2371
2651
  CREATE TABLE IF NOT EXISTS public.translations (
2372
2652
  key text NOT NULL,
2373
2653
  translations jsonb NOT NULL,