-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMASTER.sql
More file actions
2873 lines (2848 loc) · 106 KB
/
MASTER.sql
File metadata and controls
2873 lines (2848 loc) · 106 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- ================================================================
-- DINA COSMETIC · MASTER DATABASE · v2.1
-- Single source of truth — run this once in Supabase SQL Editor.
-- Safe to re-run (fully idempotent — no data loss).
-- NOTE: Before first use on a fresh DB, run RESET_DATABASE.sql first.
--
-- SECTIONS
-- §0 Helper functions
DO $$ BEGIN -- Drop NOT NULL on legacy 'title' column on product_variants (v1 schema artifact)
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'product_variants'
AND column_name = 'title'
) THEN EXECUTE 'ALTER TABLE public.product_variants ALTER COLUMN title DROP NOT NULL';
END IF;
-- Drop NOT NULL on legacy 'title' column on variants (old table name)
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'variants'
AND column_name = 'title'
) THEN EXECUTE 'ALTER TABLE public.variants ALTER COLUMN title DROP NOT NULL';
END IF;
-- Drop NOT NULL on legacy 'name' column on products
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'products'
AND column_name = 'name'
) THEN EXECUTE 'ALTER TABLE public.products ALTER COLUMN name DROP NOT NULL';
END IF;
-- Drop NOT NULL on legacy 'price' column on product_variants (v1 schema artifact)
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'product_variants'
AND column_name = 'price'
) THEN EXECUTE 'ALTER TABLE public.product_variants ALTER COLUMN price DROP NOT NULL';
END IF;
-- Drop NOT NULL on legacy 'price' column on variants (old table name)
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'variants'
AND column_name = 'price'
) THEN EXECUTE 'ALTER TABLE public.variants ALTER COLUMN price DROP NOT NULL';
END IF;
END $$;
-- §1 Core tables (profiles, categories, products, variants,
-- orders, order_items, stripe_events)
-- §2 CMS tables (site_settings, frontend_content,
-- newsletter_subscribers, navigation_menus,
-- cms_pages, cms_sections, theme_settings)
-- §3 Enable RLS on every table
-- §4 NUCLEAR DROP all existing public policies (clean slate)
-- §5 RLS policies — exactly 1 per verb per table
-- §6 Triggers
-- §7 Storage bucket + policies
-- §8 Indexes
-- §9 Seed data (categories, products, site_settings,
-- frontend_content, navigation_menus,
-- cms_pages + sections, theme_settings)
-- §10 Admin stats view
-- §11 RPC: process_order_atomic
-- §12 Verification query
--
-- Linter fixes included:
-- ✓ auth_rls_initplan → auth.uid() wrapped in (SELECT auth.uid())
-- ✓ multiple_permissive_policies → exactly 1 policy / verb / table
-- ✓ rogue_policies → nuclear drop before recreate
-- ✓ newsletter permissive INSERT → email regex constraint
-- ================================================================
-- ───────────────────────────────────────────────────────────────
-- §0 HELPER FUNCTIONS
-- ───────────────────────────────────────────────────────────────
-- is_admin(): cached per query — no per-row re-evaluation
CREATE OR REPLACE FUNCTION public.is_admin() RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE
SET search_path = public AS $$ BEGIN RETURN EXISTS (
SELECT 1
FROM public.profiles
WHERE id = (
SELECT auth.uid()
)
AND role = 'admin'
);
END;
$$;
-- handle_updated_at(): auto-stamps updated_at on every update
CREATE OR REPLACE FUNCTION public.handle_updated_at() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public AS $$ BEGIN NEW.updated_at = now();
RETURN NEW;
END;
$$;
-- handle_new_user(): auto-creates profile row when auth.user is created
CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public AS $$ BEGIN
INSERT INTO public.profiles (id, email, full_name, role)
VALUES (
NEW.id,
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'full_name', ''),
'user'
) ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$;
-- ───────────────────────────────────────────────────────────────
-- §1 CORE TABLES
-- ───────────────────────────────────────────────────────────────
-- 1A. Profiles — extends Supabase auth.users
DROP TABLE IF EXISTS public.admins CASCADE;
DROP TABLE IF EXISTS public.customers CASCADE;
CREATE TABLE IF NOT EXISTS public.profiles (
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email text UNIQUE NOT NULL,
full_name text,
avatar_url text,
role text NOT NULL DEFAULT 'user',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS role text NOT NULL DEFAULT 'user';
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS full_name text;
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS avatar_url text;
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
-- 1B. categories
CREATE TABLE IF NOT EXISTS public.categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug text UNIQUE NOT NULL,
description text,
image_url text,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.categories
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'active';
ALTER TABLE public.categories
ADD COLUMN IF NOT EXISTS name text;
ALTER TABLE public.categories
ADD COLUMN IF NOT EXISTS slug text;
ALTER TABLE public.categories
ADD COLUMN IF NOT EXISTS description text;
ALTER TABLE public.categories
ADD COLUMN IF NOT EXISTS image_url text;
ALTER TABLE public.categories
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'active';
ALTER TABLE public.categories
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
-- Rename legacy is_active → status if present
DO $$ BEGIN IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'categories'
AND column_name = 'is_active'
)
AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'categories'
AND column_name = 'status'
) THEN
ALTER TABLE public.categories
RENAME COLUMN is_active TO status;
UPDATE public.categories
SET status = 'active'
WHERE status = 'true';
UPDATE public.categories
SET status = 'draft'
WHERE status = 'false';
END IF;
END $$;
-- 1C. products
CREATE TABLE IF NOT EXISTS public.products (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL,
slug text UNIQUE,
description text,
base_price numeric(10, 2) NOT NULL DEFAULT 0.00,
sale_price numeric(10, 2),
on_sale boolean NOT NULL DEFAULT false,
category_id uuid REFERENCES public.categories(id) ON DELETE
SET NULL,
stock integer NOT NULL DEFAULT 0,
images text [] NOT NULL DEFAULT '{}',
is_featured boolean NOT NULL DEFAULT false,
is_bestseller boolean NOT NULL DEFAULT false,
is_new boolean NOT NULL DEFAULT false,
status text NOT NULL DEFAULT 'active',
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Rename legacy column price → base_price if present
DO $$ BEGIN IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'products'
AND column_name = 'price'
)
AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'products'
AND column_name = 'base_price'
) THEN
ALTER TABLE public.products
RENAME COLUMN price TO base_price;
END IF;
END $$;
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS base_price numeric(10, 2) NOT NULL DEFAULT 0.00;
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS sale_price numeric(10, 2) CHECK (
sale_price IS NULL
OR sale_price >= 0
);
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS on_sale boolean NOT NULL DEFAULT false;
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS slug text UNIQUE;
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS description text;
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS category_id uuid REFERENCES public.categories(id);
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS stock integer NOT NULL DEFAULT 0;
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS images text [] NOT NULL DEFAULT '{}';
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS is_featured boolean NOT NULL DEFAULT false;
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS is_bestseller boolean NOT NULL DEFAULT false;
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS is_new boolean NOT NULL DEFAULT false;
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'active';
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}';
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
DO $$
BEGIN
-- 1. Addition with ADD COLUMN IF NOT EXISTS (Safety)
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS weight_oz numeric(10, 2);
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS length_in numeric(10, 2);
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS width_in numeric(10, 2);
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS height_in numeric(10, 2);
-- 2. Rename Legacy Columns ONLY if they exist and target doesn't exist yet
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='products' AND column_name='weight_grams') AND
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='products' AND column_name='weight_oz')
THEN
ALTER TABLE public.products RENAME COLUMN weight_grams TO weight_oz;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='products' AND column_name='length_cm') AND
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='products' AND column_name='length_in')
THEN
ALTER TABLE public.products RENAME COLUMN length_cm TO length_in;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='products' AND column_name='width_cm') AND
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='products' AND column_name='width_in')
THEN
ALTER TABLE public.products RENAME COLUMN width_cm TO width_in;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='products' AND column_name='height_cm') AND
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='products' AND column_name='height_in')
THEN
ALTER TABLE public.products RENAME COLUMN height_cm TO height_in;
END IF;
END $$;
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS sku text;
-- Product-level SKU (for simple products without variants)
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS country_of_origin text;
-- ISO 3166-1 alpha-2 country code, e.g. 'US', 'CN', 'FR' — required for customs on international shipments
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS customs_value_usd numeric(10, 2);
-- Declared value per unit in USD — used on customs forms (CN22/CP72) for international shipping
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS title text;
-- Rename legacy name → title if present
DO $$ BEGIN IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'products'
AND column_name = 'name'
)
AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'products'
AND column_name = 'title'
) THEN
ALTER TABLE public.products
RENAME COLUMN name TO title;
END IF;
END $$;
-- Rename legacy is_active → status if present
DO $$ BEGIN IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'products'
AND column_name = 'is_active'
)
AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'products'
AND column_name = 'status'
) THEN
ALTER TABLE public.products
RENAME COLUMN is_active TO status;
UPDATE public.products
SET status = 'active'
WHERE status = 'true'
OR status = '1';
UPDATE public.products
SET status = 'draft'
WHERE status = 'false'
OR status = '0';
END IF;
END $$;
-- Sync legacy inventory → stock
DO $$ BEGIN IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'products'
AND column_name = 'inventory'
) THEN EXECUTE 'UPDATE public.products SET stock = inventory WHERE stock = 0 AND inventory > 0';
END IF;
END $$;
-- 1D. product_variants
CREATE TABLE IF NOT EXISTS public.product_variants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
product_id uuid NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
name text NOT NULL,
variant_type text NOT NULL DEFAULT 'shade',
price_override numeric(10, 2) CHECK (
price_override IS NULL
OR price_override >= 0
),
stock integer NOT NULL DEFAULT 0 CHECK (stock >= 0),
sku text UNIQUE,
color_code text,
image_url text,
-- per-variant hero image (shown when this variant is selected)
weight numeric(8, 3), -- in oz, used by Shippo for shipping rate calculation
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.product_variants
ADD COLUMN IF NOT EXISTS name text;
ALTER TABLE public.product_variants
ADD COLUMN IF NOT EXISTS variant_type text NOT NULL DEFAULT 'shade';
ALTER TABLE public.product_variants
ADD COLUMN IF NOT EXISTS price_override numeric(10, 2);
ALTER TABLE public.product_variants
ADD COLUMN IF NOT EXISTS stock integer NOT NULL DEFAULT 0;
ALTER TABLE public.product_variants
ADD COLUMN IF NOT EXISTS sku text;
ALTER TABLE public.product_variants
ADD COLUMN IF NOT EXISTS color_code text;
ALTER TABLE public.product_variants
ADD COLUMN IF NOT EXISTS image_url text;
ALTER TABLE public.product_variants
ADD COLUMN IF NOT EXISTS weight numeric(8, 3);
ALTER TABLE public.product_variants
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'active';
ALTER TABLE public.product_variants
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
-- 1D.bis Data Normalization and SKU Generation
DO $$ BEGIN -- Normalize variant_type (only if NULL/empty)
-- Shade grouping
UPDATE public.product_variants
SET variant_type = 'shade'
WHERE (
variant_type IS NULL
OR variant_type = ''
OR variant_type = 'shade'
)
AND (
name ILIKE ANY (
ARRAY ['Burgundy', 'Rose Matte', 'Mauve', 'Red', 'Abrico', 'Brown', 'Gold', 'Nature']
)
OR color_code IS NOT NULL
);
-- Size grouping
UPDATE public.product_variants
SET variant_type = 'size'
WHERE (
variant_type IS NULL
OR variant_type = ''
OR variant_type = 'size'
)
AND (
name ILIKE ANY (
ARRAY ['Small', 'Medium', 'Large', '14 Pieces', '18 Pieces']
)
);
-- Default for others if still NULL
UPDATE public.product_variants
SET variant_type = 'shade'
WHERE variant_type IS NULL
OR variant_type = '';
-- Generate missing SKUs: PRODUCTCODE-VARIANTCODE
-- Using first 3 chars of product title and first 4 chars of variant name
UPDATE public.product_variants v
SET sku = UPPER(
REGEXP_REPLACE(
SUBSTRING(p.title, 1, 3),
'[^a-zA-Z0-0]',
'',
'g'
)
) || '-' || UPPER(
REGEXP_REPLACE(SUBSTRING(v.name, 1, 4), '[^a-zA-Z0-0]', '', 'g')
)
FROM public.products p
WHERE v.product_id = p.id
AND (
v.sku IS NULL
OR v.sku = ''
);
-- Final Validation: Ensure required fields are NOT NULL
-- We do this at the end of the block to ensure data is populated
ALTER TABLE public.product_variants
ALTER COLUMN product_id
SET NOT NULL;
ALTER TABLE public.product_variants
ALTER COLUMN variant_type
SET NOT NULL;
ALTER TABLE public.product_variants
ALTER COLUMN sku
SET NOT NULL;
ALTER TABLE public.product_variants
ALTER COLUMN stock
SET NOT NULL;
ALTER TABLE public.product_variants
ALTER COLUMN status
SET NOT NULL;
END $$;
-- ensure product_variants table name consistency
DO $$ BEGIN IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_name = 'variants'
AND table_schema = 'public'
)
AND NOT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_name = 'product_variants'
AND table_schema = 'public'
) THEN EXECUTE 'ALTER TABLE public.variants RENAME TO product_variants';
END IF;
END $$;
-- Fix legacy title/name columns for v2
DO $$ BEGIN -- If title exists but not name, rename it
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'product_variants'
AND column_name = 'title'
)
AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'product_variants'
AND column_name = 'name'
) THEN
ALTER TABLE public.product_variants
RENAME COLUMN title TO name;
END IF;
-- If title exists and is NOT NULL, make it nullable so inserts without it work
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'product_variants'
AND column_name = 'title'
) THEN
ALTER TABLE public.product_variants
ALTER COLUMN title DROP NOT NULL;
END IF;
END $$;
-- Rename legacy stock_quantity → stock if present
DO $$ BEGIN IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'variants'
AND column_name = 'stock_quantity'
)
AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'variants'
AND column_name = 'stock'
) THEN EXECUTE 'ALTER TABLE public.variants RENAME COLUMN stock_quantity TO stock';
END IF;
END $$;
-- Rename legacy is_active → status if present
DO $$ BEGIN IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'variants'
AND column_name = 'is_active'
)
AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'variants'
AND column_name = 'status'
) THEN EXECUTE 'ALTER TABLE public.variants RENAME COLUMN is_active TO status';
END IF;
END $$;
-- 1E. orders
CREATE TABLE IF NOT EXISTS public.orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES public.profiles(id) ON DELETE
SET NULL,
customer_email text,
amount_total numeric(10, 2),
currency text NOT NULL DEFAULT 'usd',
status text NOT NULL DEFAULT 'pending' CHECK (
status IN (
'pending',
'paid',
'shipped',
'out_for_delivery',
'delivered',
'cancelled',
'refunded'
)
),
fulfillment_status text NOT NULL DEFAULT 'unfulfilled',
shipping_address jsonb,
billing_address jsonb,
stripe_session_id text UNIQUE,
tracking_number text,
carrier text,
shippo_tracking_status text,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Ensure existing constraint is updated
ALTER TABLE public.orders DROP CONSTRAINT IF EXISTS orders_status_check;
ALTER TABLE public.orders ADD CONSTRAINT orders_status_check CHECK (status IN ('pending', 'paid', 'shipped', 'out_for_delivery', 'delivered', 'cancelled', 'refunded'));
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS user_id uuid;
ALTER TABLE public.orders DROP CONSTRAINT IF EXISTS orders_user_id_fkey;
ALTER TABLE public.orders ADD CONSTRAINT orders_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.profiles(id) ON DELETE SET NULL;
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS customer_email text;
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS amount_total numeric(10, 2);
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS currency text NOT NULL DEFAULT 'usd';
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'pending';
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS stripe_session_id text UNIQUE;
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS fulfillment_status text NOT NULL DEFAULT 'unfulfilled';
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS tracking_number text;
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS metadata jsonb;
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS shipping_label_url text;
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS customer_name text;
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS customer_phone text;
-- Drop orphaned admin_audit_logs table (has RLS but no policies — not in schema)
DROP TABLE IF EXISTS public.admin_audit_logs CASCADE;
-- Sync legacy columns
DO $$ BEGIN IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'orders'
AND column_name = 'email'
) THEN
UPDATE public.orders
SET customer_email = email
WHERE customer_email IS NULL
AND email IS NOT NULL;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'orders'
AND column_name = 'total_amount'
) THEN
UPDATE public.orders
SET amount_total = total_amount
WHERE amount_total IS NULL
AND total_amount IS NOT NULL;
END IF;
END $$;
-- Safely drop legacy NOT NULL constraints if columns still exist
DO $$ BEGIN IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'orders'
AND column_name = 'email'
) THEN
ALTER TABLE public.orders
ALTER COLUMN email DROP NOT NULL;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'orders'
AND column_name = 'total_amount'
) THEN
ALTER TABLE public.orders
ALTER COLUMN total_amount DROP NOT NULL;
END IF;
END $$;
-- 1F. order_items
CREATE TABLE IF NOT EXISTS public.order_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES public.orders(id) ON DELETE CASCADE,
product_id uuid REFERENCES public.products(id) ON DELETE
SET NULL,
variant_id uuid REFERENCES public.product_variants(id) ON DELETE
SET NULL,
quantity integer NOT NULL CHECK (quantity > 0),
price numeric(10, 2) NOT NULL,
fulfilled_quantity integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.order_items ADD COLUMN IF NOT EXISTS fulfilled_quantity integer NOT NULL DEFAULT 0;
ALTER TABLE public.order_items
ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
DO $$ BEGIN IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'order_items'
AND column_name = 'variant_id'
) THEN
ALTER TABLE public.order_items
ADD COLUMN variant_id uuid REFERENCES public.product_variants(id);
END IF;
END $$;
-- 1G. stripe_events — idempotency log, service_role only
CREATE TABLE IF NOT EXISTS public.stripe_events (
id text PRIMARY KEY,
type text,
data jsonb,
processed boolean NOT NULL DEFAULT false,
error text,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.stripe_events ADD COLUMN IF NOT EXISTS processed boolean NOT NULL DEFAULT false;
ALTER TABLE public.stripe_events ADD COLUMN IF NOT EXISTS error text;
-- 1H. email_logs — idempotency log for sent emails (service_role only)
CREATE TABLE IF NOT EXISTS public.email_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email_type text NOT NULL,
recipient text NOT NULL,
order_id uuid REFERENCES public.orders(id) ON DELETE
SET NULL,
sent_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
-- 1I. inventory_reservations — hold stock during checkout (service_role only)
CREATE TABLE IF NOT EXISTS public.inventory_reservations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
product_id uuid NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
variant_id uuid REFERENCES public.product_variants(id) ON DELETE CASCADE,
quantity integer NOT NULL CHECK (quantity > 0),
session_id text NOT NULL,
expires_at timestamptz NOT NULL DEFAULT (now() + interval '15 minutes'),
created_at timestamptz NOT NULL DEFAULT now()
);
-- 1J. product_images — optional separate image metadata table
CREATE TABLE IF NOT EXISTS public.product_images (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
product_id uuid NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
url text NOT NULL,
alt_text text,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 1K. user_profiles — extended user data (maps 1:1 with profiles)
-- Note: profiles is the primary user table; user_profiles holds extra UI preferences
CREATE TABLE IF NOT EXISTS public.user_profiles (
id uuid PRIMARY KEY REFERENCES public.profiles(id) ON DELETE CASCADE,
display_name text,
preferences jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- 1L. order_tracking_history — real-time carrier updates
CREATE TABLE IF NOT EXISTS public.order_tracking_history (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES public.orders(id) ON DELETE CASCADE,
status text NOT NULL,
details text,
location text,
shippo_event_id text UNIQUE,
object_created timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- ───────────────────────────────────────────────────────────────
-- §2 CMS TABLES
-- ───────────────────────────────────────────────────────────────
-- 2A. site_settings — key-value store for all store config
CREATE TABLE IF NOT EXISTS public.site_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
setting_key text UNIQUE NOT NULL,
setting_value jsonb NOT NULL DEFAULT '{}',
created_by uuid REFERENCES public.profiles(id) DEFAULT (SELECT auth.uid()),
updated_by uuid REFERENCES public.profiles(id),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.site_settings ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES public.profiles(id);
ALTER TABLE public.site_settings ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES public.profiles(id);
-- 2B. frontend_content — every editable storefront section
CREATE TABLE IF NOT EXISTS public.frontend_content (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
content_key text UNIQUE NOT NULL,
content_type text NOT NULL,
content_data jsonb NOT NULL DEFAULT '{}',
created_by uuid REFERENCES public.profiles(id) DEFAULT (SELECT auth.uid()),
updated_by uuid REFERENCES public.profiles(id),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.frontend_content ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES public.profiles(id);
ALTER TABLE public.frontend_content ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES public.profiles(id);
-- 2C. newsletter_subscribers
CREATE TABLE IF NOT EXISTS public.newsletter_subscribers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 2D. navigation_menus — admin edits header/footer nav from dashboard
CREATE TABLE IF NOT EXISTS public.navigation_menus (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
menu_key text UNIQUE NOT NULL,
label text NOT NULL,
menu_items jsonb NOT NULL DEFAULT '[]',
display_order integer NOT NULL DEFAULT 0,
is_active boolean NOT NULL DEFAULT true,
created_by uuid REFERENCES public.profiles(id) DEFAULT (SELECT auth.uid()),
updated_by uuid REFERENCES public.profiles(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.navigation_menus ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES public.profiles(id);
ALTER TABLE public.navigation_menus ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES public.profiles(id);
-- 2E. cms_pages — dynamic site pages (About/Contact/Privacy/Terms/Home)
CREATE TABLE IF NOT EXISTS public.cms_pages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text UNIQUE NOT NULL,
title text NOT NULL,
description text,
is_published boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- 2F. cms_sections — dynamic sections within cms_pages
CREATE TABLE IF NOT EXISTS public.cms_sections (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
page_id uuid NOT NULL REFERENCES public.cms_pages(id) ON DELETE CASCADE,
type text NOT NULL,
-- 'hero', 'productGrid', 'richText', 'imageBanner'
sort_order integer NOT NULL DEFAULT 0,
props jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
-- 2G. theme_settings — admin controls brand colours/fonts
CREATE TABLE IF NOT EXISTS public.theme_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
theme_key text UNIQUE NOT NULL,
label text NOT NULL,
settings jsonb NOT NULL DEFAULT '{}',
is_active boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ───────────────────────────────────────────────────────────────
-- §3 ENABLE RLS ON EVERY TABLE
-- ───────────────────────────────────────────────────────────────
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.categories ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.product_variants ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.order_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.stripe_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.email_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.inventory_reservations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.product_images ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.site_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.frontend_content ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.newsletter_subscribers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.navigation_menus ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.cms_pages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.cms_sections ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.theme_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.order_tracking_history ENABLE ROW LEVEL SECURITY;
-- ───────────────────────────────────────────────────────────────
-- §4 NUCLEAR DROP — every public RLS policy
-- Guarantees zero duplicates and removes all stale/rogue
-- policies (products_admin_all, sensitive_owner_access, etc.)
-- ───────────────────────────────────────────────────────────────
DO $$
DECLARE r RECORD;
BEGIN FOR r IN
SELECT schemaname,
tablename,
policyname
FROM pg_policies
WHERE schemaname = 'public' LOOP EXECUTE format(
'DROP POLICY IF EXISTS %I ON %I.%I',
r.policyname,
r.schemaname,
r.tablename
);
END LOOP;
END $$;
-- Remove stale test table if it exists
DROP TABLE IF EXISTS public.sensitive_table CASCADE;
-- ───────────────────────────────────────────────────────────────
-- §5 RLS POLICIES
-- Rules:
-- • Exactly 1 policy per SQL verb per table
-- • auth.uid() always wrapped in (SELECT auth.uid())
-- → eliminates auth_rls_initplan linter warning
-- • Admin access uses (SELECT public.is_admin())
-- → cached per query, not per row
-- ───────────────────────────────────────────────────────────────
-- 5.1 PROFILES
CREATE POLICY "profiles_select" ON public.profiles FOR
SELECT USING (
(
SELECT auth.uid()
) = id
OR (
SELECT public.is_admin()
)
);
CREATE POLICY "profiles_insert" ON public.profiles FOR
INSERT WITH CHECK (
(
SELECT auth.uid()
) = id
OR (
SELECT public.is_admin()
)
);
CREATE POLICY "profiles_update" ON public.profiles FOR
UPDATE USING (
(
SELECT auth.uid()
) = id
OR (
SELECT public.is_admin()
)
);
CREATE POLICY "profiles_delete" ON public.profiles FOR DELETE USING (
(
SELECT public.is_admin()
)
);
-- 5.2 CATEGORIES — public read, admin write
CREATE POLICY "categories_select" ON public.categories FOR
SELECT USING (true);
CREATE POLICY "categories_insert" ON public.categories FOR
INSERT TO authenticated WITH CHECK (
(
SELECT public.is_admin()
)
);
CREATE POLICY "categories_update" ON public.categories FOR
UPDATE USING (
(
SELECT public.is_admin()
)
);
CREATE POLICY "categories_delete" ON public.categories FOR DELETE USING (
(
SELECT public.is_admin()
)
);
-- 5.3 PRODUCTS — active products are public, admins see all
CREATE POLICY "products_select" ON public.products FOR
SELECT USING (
status = 'active'
OR (
SELECT public.is_admin()
)
);
CREATE POLICY "products_insert" ON public.products FOR
INSERT TO authenticated WITH CHECK (
(
SELECT public.is_admin()
)
);
CREATE POLICY "products_update" ON public.products FOR
UPDATE USING (
(
SELECT public.is_admin()
)
);
CREATE POLICY "products_delete" ON public.products FOR DELETE USING (
(
SELECT public.is_admin()
)
);
-- 5.4 PRODUCT_VARIANTS — public read, admin write
CREATE POLICY "product_variants_select" ON public.product_variants FOR
SELECT USING (true);
CREATE POLICY "product_variants_insert" ON public.product_variants FOR
INSERT TO authenticated WITH CHECK (
(
SELECT public.is_admin()
)
);
CREATE POLICY "product_variants_update" ON public.product_variants FOR
UPDATE USING (
(
SELECT public.is_admin()
)
);
CREATE POLICY "product_variants_delete" ON public.product_variants FOR DELETE USING (
(
SELECT public.is_admin()
)
);
-- 5.5 ORDERS — users see own orders, admins see all
CREATE POLICY "orders_select" ON public.orders FOR
SELECT USING (
(
SELECT auth.uid()
) = user_id
OR (
SELECT public.is_admin()
)
);
CREATE POLICY "orders_insert" ON public.orders FOR
INSERT WITH CHECK (
(
SELECT public.is_admin()
)
OR (
SELECT auth.uid()
) = user_id
);
CREATE POLICY "orders_update" ON public.orders FOR
UPDATE USING (
(