@nextblock-cms/db 0.12.9 → 0.12.11

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.
@@ -1,2454 +1,2454 @@
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.
4
-
5
- SET check_function_bodies = false;
6
-
7
- CREATE SCHEMA IF NOT EXISTS public;
8
-
9
- COMMENT ON SCHEMA public IS 'standard public schema';
10
-
11
- DO $rb$ BEGIN
12
- CREATE TYPE public.approval_status AS ENUM (
13
- 'pending',
14
- 'approved',
15
- 'denied'
16
- );
17
- EXCEPTION WHEN duplicate_object THEN null; END $rb$;
18
-
19
- DO $rb$ BEGIN
20
- CREATE TYPE public.interaction_type AS ENUM (
21
- 'review',
22
- 'comment'
23
- );
24
- EXCEPTION WHEN duplicate_object THEN null; END $rb$;
25
-
26
- DO $rb$ BEGIN
27
- CREATE TYPE public.menu_location AS ENUM (
28
- 'HEADER',
29
- 'FOOTER',
30
- 'SIDEBAR'
31
- );
32
- EXCEPTION WHEN duplicate_object THEN null; END $rb$;
33
-
34
- DO $rb$ BEGIN
35
- CREATE TYPE public.page_status AS ENUM (
36
- 'draft',
37
- 'published',
38
- 'archived'
39
- );
40
- EXCEPTION WHEN duplicate_object THEN null; END $rb$;
41
-
42
- DO $rb$ BEGIN
43
- CREATE TYPE public.revision_type AS ENUM (
44
- 'snapshot',
45
- 'diff'
46
- );
47
- EXCEPTION WHEN duplicate_object THEN null; END $rb$;
48
-
49
- DO $rb$ BEGIN
50
- CREATE TYPE public.user_role AS ENUM (
51
- 'ADMIN',
52
- 'WRITER',
53
- 'USER'
54
- );
55
- EXCEPTION WHEN duplicate_object THEN null; END $rb$;
56
-
57
- CREATE OR REPLACE FUNCTION public.apply_order_inventory_deduction(p_order_id uuid) RETURNS void
58
- LANGUAGE plpgsql
59
- SET search_path TO 'public'
60
- AS $$
61
- DECLARE
62
- v_track_quantities boolean := public.get_ecommerce_track_quantities();
63
- v_item record;
64
- v_inventory_deducted_at timestamptz;
65
- v_sku text;
66
- v_current_quantity integer;
67
- BEGIN
68
- SELECT inventory_deducted_at
69
- INTO v_inventory_deducted_at
70
- FROM public.orders
71
- WHERE id = p_order_id
72
- FOR UPDATE;
73
-
74
- IF NOT FOUND OR v_inventory_deducted_at IS NOT NULL THEN
75
- RETURN;
76
- END IF;
77
-
78
- IF NOT v_track_quantities THEN
79
- UPDATE public.orders
80
- SET inventory_deducted_at = now()
81
- WHERE id = p_order_id;
82
-
83
- RETURN;
84
- END IF;
85
-
86
- FOR v_item IN
87
- SELECT
88
- product_id,
89
- variant_id,
90
- SUM(quantity)::integer AS quantity
91
- FROM public.order_items
92
- WHERE order_id = p_order_id
93
- GROUP BY product_id, variant_id
94
- LOOP
95
- v_sku := NULL;
96
- v_current_quantity := 0;
97
-
98
- IF v_item.variant_id IS NOT NULL THEN
99
- SELECT
100
- sku,
101
- GREATEST(COALESCE(stock_quantity, 0), 0)
102
- INTO v_sku,
103
- v_current_quantity
104
- FROM public.product_variants
105
- WHERE id = v_item.variant_id
106
- LIMIT 1;
107
- ELSIF v_item.product_id IS NOT NULL THEN
108
- SELECT
109
- sku,
110
- GREATEST(COALESCE(stock, 0), 0)
111
- INTO v_sku,
112
- v_current_quantity
113
- FROM public.products
114
- WHERE id = v_item.product_id
115
- LIMIT 1;
116
- END IF;
117
-
118
- IF NULLIF(trim(v_sku), '') IS NULL THEN
119
- CONTINUE;
120
- END IF;
121
-
122
- INSERT INTO public.inventory_items (sku, quantity)
123
- VALUES (v_sku, v_current_quantity)
124
- ON CONFLICT (sku) DO NOTHING;
125
-
126
- UPDATE public.inventory_items
127
- SET
128
- quantity = GREATEST(COALESCE(quantity, 0) - v_item.quantity, 0),
129
- updated_at = now()
130
- WHERE sku = v_sku;
131
- END LOOP;
132
-
133
- UPDATE public.orders
134
- SET inventory_deducted_at = now()
135
- WHERE id = p_order_id;
136
- END;
137
- $$;
138
-
139
- CREATE OR REPLACE FUNCTION public.assign_order_invoice_metadata(p_order_id uuid, p_paid_at timestamp with time zone DEFAULT now()) RETURNS TABLE(invoice_number text, paid_at timestamp with time zone)
140
- LANGUAGE plpgsql
141
- SET search_path TO 'public'
142
- AS $$
143
- DECLARE
144
- v_order public.orders%ROWTYPE;
145
- v_effective_paid_at timestamptz;
146
- BEGIN
147
- SELECT *
148
- INTO v_order
149
- FROM public.orders
150
- WHERE id = p_order_id
151
- FOR UPDATE;
152
-
153
- IF NOT FOUND THEN
154
- RAISE EXCEPTION 'Order % not found', p_order_id;
155
- END IF;
156
-
157
- v_effective_paid_at := COALESCE(v_order.paid_at, p_paid_at, now(), v_order.created_at);
158
-
159
- UPDATE public.orders
160
- SET
161
- invoice_number = COALESCE(v_order.invoice_number, public.generate_order_invoice_number()),
162
- paid_at = v_effective_paid_at
163
- WHERE id = p_order_id
164
- RETURNING orders.invoice_number, orders.paid_at
165
- INTO invoice_number, paid_at;
166
-
167
- RETURN NEXT;
168
- END;
169
- $$;
170
-
171
- CREATE OR REPLACE FUNCTION public.clear_currency_price_overrides(target_currency text) RETURNS void
172
- LANGUAGE plpgsql
173
- SET search_path TO ''
174
- AS $$
175
- DECLARE
176
- v_target_currency text := upper(trim(target_currency));
177
- BEGIN
178
- IF v_target_currency = '' THEN
179
- RETURN;
180
- END IF;
181
-
182
- UPDATE public.products
183
- SET
184
- prices = COALESCE(prices, '{}'::jsonb) - v_target_currency,
185
- sale_prices = CASE
186
- WHEN sale_prices IS NULL THEN NULL
187
- WHEN sale_prices - v_target_currency = '{}'::jsonb THEN NULL
188
- ELSE sale_prices - v_target_currency
189
- END,
190
- updated_at = now()
191
- WHERE COALESCE(prices, '{}'::jsonb) ? v_target_currency
192
- OR COALESCE(sale_prices, '{}'::jsonb) ? v_target_currency;
193
-
194
- UPDATE public.product_variants
195
- SET
196
- prices = COALESCE(prices, '{}'::jsonb) - v_target_currency,
197
- sale_prices = CASE
198
- WHEN sale_prices IS NULL THEN NULL
199
- WHEN sale_prices - v_target_currency = '{}'::jsonb THEN NULL
200
- ELSE sale_prices - v_target_currency
201
- END,
202
- updated_at = now()
203
- WHERE COALESCE(prices, '{}'::jsonb) ? v_target_currency
204
- OR COALESCE(sale_prices, '{}'::jsonb) ? v_target_currency;
205
- END;
206
- $$;
207
-
208
- CREATE OR REPLACE FUNCTION public.is_valid_custom_block_fields(candidate jsonb) RETURNS boolean
209
- LANGUAGE sql IMMUTABLE
210
- SET search_path TO ''
211
- AS $_$
212
- SELECT CASE
213
- WHEN jsonb_typeof(candidate) <> 'array' THEN false
214
- ELSE
215
- NOT EXISTS (
216
- SELECT 1
217
- FROM jsonb_array_elements(candidate) AS field(value)
218
- WHERE jsonb_typeof(field.value) <> 'object'
219
- OR jsonb_typeof(field.value -> 'key') IS DISTINCT FROM 'string'
220
- OR jsonb_typeof(field.value -> 'label') IS DISTINCT FROM 'string'
221
- OR jsonb_typeof(field.value -> 'type') IS DISTINCT FROM 'string'
222
- OR field.value ->> 'key' !~ '^[a-z][a-z0-9_]*$'
223
- OR field.value ->> 'type' NOT IN ('text', 'rich-text', 'image_r2', 'db_relation')
224
- )
225
- AND (
226
- SELECT COUNT(*) = COUNT(DISTINCT field.value ->> 'key')
227
- FROM jsonb_array_elements(candidate) AS field(value)
228
- )
229
- END;
230
- $_$;
231
-
232
- CREATE OR REPLACE FUNCTION public.is_valid_custom_block_layout_schema(candidate jsonb) RETURNS boolean
233
- LANGUAGE sql IMMUTABLE
234
- SET search_path TO ''
235
- AS $$
236
- SELECT CASE
237
- WHEN jsonb_typeof(candidate) <> 'object' THEN false
238
- ELSE candidate ->> 'type' IN ('container', 'field_render')
239
- END;
240
- $$;
241
-
242
- CREATE TABLE IF NOT EXISTS public.custom_block_definitions (
243
- id uuid DEFAULT gen_random_uuid() NOT NULL,
244
- slug text NOT NULL,
245
- name text NOT NULL,
246
- description text DEFAULT ''::text NOT NULL,
247
- fields jsonb DEFAULT '[]'::jsonb NOT NULL,
248
- layout_schema jsonb NOT NULL,
249
- is_original boolean DEFAULT true NOT NULL,
250
- CONSTRAINT custom_block_definitions_fields_check CHECK (public.is_valid_custom_block_fields(fields)),
251
- CONSTRAINT custom_block_definitions_layout_schema_check CHECK (public.is_valid_custom_block_layout_schema(layout_schema)),
252
- CONSTRAINT custom_block_definitions_name_check CHECK ((length(TRIM(BOTH FROM name)) > 0)),
253
- CONSTRAINT custom_block_definitions_slug_check CHECK ((slug ~ '^[a-z][a-z0-9-]*$'::text))
254
- );
255
-
256
- COMMENT ON TABLE public.custom_block_definitions IS 'Registry for user-created block definitions rendered from database JSONB without runtime code compilation.';
257
-
258
- COMMENT ON COLUMN public.custom_block_definitions.fields IS 'Strict JSONB field declarations for data-rendered custom blocks.';
259
-
260
- COMMENT ON COLUMN public.custom_block_definitions.layout_schema IS 'Open-ended recursive layout schema consumed by the dynamic layout renderer.';
261
-
262
- COMMENT ON COLUMN public.custom_block_definitions.is_original IS 'False when a definition was created by duplicating an existing registry row.';
263
-
264
- CREATE OR REPLACE FUNCTION public.duplicate_block_definition(target_id uuid) RETURNS public.custom_block_definitions
265
- LANGUAGE plpgsql
266
- SET search_path TO 'public'
267
- AS $_$
268
- DECLARE
269
- source_definition public.custom_block_definitions%ROWTYPE;
270
- copied_definition public.custom_block_definitions%ROWTYPE;
271
- base_slug text;
272
- copy_slug text;
273
- copy_index integer := 1;
274
- BEGIN
275
- IF auth.role() <> 'service_role'
276
- AND COALESCE((SELECT public.get_current_user_role())::text, '') NOT IN ('ADMIN', 'WRITER') THEN
277
- RAISE EXCEPTION 'Not authorized to duplicate custom block definitions.'
278
- USING ERRCODE = '42501';
279
- END IF;
280
-
281
- SELECT *
282
- INTO source_definition
283
- FROM public.custom_block_definitions
284
- WHERE id = target_id;
285
-
286
- IF NOT FOUND THEN
287
- RAISE EXCEPTION 'Custom block definition % not found.', target_id
288
- USING ERRCODE = 'P0002';
289
- END IF;
290
-
291
- base_slug := regexp_replace(source_definition.slug, '-copy(-[0-9]+)?$', '');
292
- copy_slug := base_slug || '-copy';
293
-
294
- WHILE EXISTS (
295
- SELECT 1
296
- FROM public.custom_block_definitions
297
- WHERE slug = copy_slug
298
- ) LOOP
299
- copy_index := copy_index + 1;
300
- copy_slug := base_slug || '-copy-' || copy_index;
301
- END LOOP;
302
-
303
- INSERT INTO public.custom_block_definitions (
304
- id,
305
- slug,
306
- name,
307
- description,
308
- fields,
309
- layout_schema,
310
- is_original
311
- )
312
- VALUES (
313
- gen_random_uuid(),
314
- copy_slug,
315
- source_definition.name || ' Copy',
316
- source_definition.description,
317
- source_definition.fields,
318
- source_definition.layout_schema,
319
- false
320
- )
321
- RETURNING *
322
- INTO copied_definition;
323
-
324
- RETURN copied_definition;
325
- END;
326
- $_$;
327
-
328
- CREATE OR REPLACE FUNCTION public.format_order_invoice_number(p_value bigint) RETURNS text
329
- LANGUAGE sql IMMUTABLE
330
- SET search_path TO ''
331
- AS $$
332
- SELECT 'INV-' || lpad(p_value::text, 6, '0');
333
- $$;
334
-
335
- CREATE OR REPLACE FUNCTION public.generate_order_invoice_number() RETURNS text
336
- LANGUAGE sql
337
- SET search_path TO ''
338
- AS $$
339
- SELECT public.format_order_invoice_number(nextval('public.order_invoice_number_seq'));
340
- $$;
341
-
342
- CREATE OR REPLACE FUNCTION public.get_current_user_role() RETURNS public.user_role
343
- LANGUAGE sql STABLE
344
- SET search_path TO 'public'
345
- AS $$
346
- SELECT role FROM public.profiles WHERE id = auth.uid();
347
- $$;
348
-
349
- CREATE OR REPLACE FUNCTION public.get_default_currency_code() RETURNS text
350
- LANGUAGE sql STABLE
351
- SET search_path TO ''
352
- AS $$
353
- SELECT COALESCE(
354
- (
355
- SELECT upper(code)
356
- FROM public.currencies
357
- WHERE is_default = true
358
- ORDER BY updated_at DESC, created_at DESC, code ASC
359
- LIMIT 1
360
- ),
361
- 'USD'
362
- );
363
- $$;
364
-
365
- CREATE OR REPLACE FUNCTION public.get_ecommerce_track_quantities() RETURNS boolean
366
- LANGUAGE plpgsql STABLE
367
- SET search_path TO 'public'
368
- AS $$
369
- DECLARE
370
- v_value jsonb;
371
- v_raw text;
372
- BEGIN
373
- SELECT value
374
- INTO v_value
375
- FROM public.site_settings
376
- WHERE key = 'ecommerce_inventory_settings';
377
-
378
- IF v_value IS NULL THEN
379
- RETURN true;
380
- END IF;
381
-
382
- IF jsonb_typeof(v_value) = 'object' THEN
383
- v_raw := NULLIF(v_value->>'track_quantities', '');
384
- ELSE
385
- v_raw := NULLIF(trim(BOTH '"' FROM v_value::text), '');
386
- END IF;
387
-
388
- IF v_raw IS NULL THEN
389
- RETURN true;
390
- END IF;
391
-
392
- IF lower(v_raw) IN ('false', 'f', '0', 'no', 'off') THEN
393
- RETURN false;
394
- END IF;
395
-
396
- RETURN true;
397
- END;
398
- $$;
399
-
400
- CREATE OR REPLACE FUNCTION public.get_my_claim(claim text) RETURNS jsonb
401
- LANGUAGE sql STABLE
402
- SET search_path TO ''
403
- AS $$
404
- SELECT COALESCE(current_setting('request.jwt.claims', true)::jsonb ->> claim, NULL)::jsonb
405
- $$;
406
-
407
- CREATE OR REPLACE FUNCTION public.handle_blocks_update() RETURNS trigger
408
- LANGUAGE plpgsql
409
- SET search_path TO 'public'
410
- AS $$
411
- BEGIN
412
- NEW.updated_at = now();
413
- RETURN NEW;
414
- END;
415
- $$;
416
-
417
- CREATE OR REPLACE FUNCTION public.handle_coupon_freemius_mappings_write() RETURNS trigger
418
- LANGUAGE plpgsql
419
- SET search_path TO ''
420
- AS $$
421
- BEGIN
422
- NEW.freemius_product_id := btrim(NEW.freemius_product_id);
423
- NEW.freemius_coupon_code := upper(regexp_replace(btrim(NEW.freemius_coupon_code), '\s+', '', 'g'));
424
- NEW.sync_status := lower(btrim(COALESCE(NEW.sync_status, 'pending')));
425
- NEW.updated_at := now();
426
-
427
- IF NEW.created_at IS NULL THEN
428
- NEW.created_at := now();
429
- END IF;
430
-
431
- RETURN NEW;
432
- END;
433
- $$;
434
-
435
- CREATE OR REPLACE FUNCTION public.handle_coupons_write() RETURNS trigger
436
- LANGUAGE plpgsql
437
- SET search_path TO ''
438
- AS $$
439
- BEGIN
440
- NEW.code := upper(regexp_replace(btrim(NEW.code), '\s+', '', 'g'));
441
- NEW.name := btrim(NEW.name);
442
- NEW.provider_scope := lower(btrim(NEW.provider_scope));
443
- NEW.discount_type := lower(btrim(NEW.discount_type));
444
- NEW.freemius_sync_status := lower(btrim(COALESCE(NEW.freemius_sync_status, 'not_synced')));
445
- NEW.updated_at := now();
446
-
447
- IF NEW.created_at IS NULL THEN
448
- NEW.created_at := now();
449
- END IF;
450
-
451
- RETURN NEW;
452
- END;
453
- $$;
454
-
455
- CREATE OR REPLACE FUNCTION public.handle_default_currency_change() RETURNS trigger
456
- LANGUAGE plpgsql
457
- SET search_path TO ''
458
- AS $$
459
- BEGIN
460
- IF TG_OP = 'INSERT' THEN
461
- IF NEW.is_default THEN
462
- PERFORM public.sync_legacy_price_columns_for_currency(NEW.code);
463
- END IF;
464
- ELSIF NEW.is_default
465
- AND (
466
- OLD.is_default IS DISTINCT FROM NEW.is_default
467
- OR OLD.code IS DISTINCT FROM NEW.code
468
- ) THEN
469
- PERFORM public.sync_legacy_price_columns_for_currency(NEW.code);
470
- END IF;
471
-
472
- RETURN NEW;
473
- END;
474
- $$;
475
-
476
- CREATE OR REPLACE FUNCTION public.handle_inventory_item_change() RETURNS trigger
477
- LANGUAGE plpgsql
478
- SET search_path TO 'public'
479
- AS $$
480
- DECLARE
481
- v_sku text := COALESCE(NEW.sku, OLD.sku);
482
- BEGIN
483
- PERFORM public.sync_inventory_cache_for_sku(v_sku);
484
- RETURN COALESCE(NEW, OLD);
485
- END;
486
- $$;
487
-
488
- CREATE OR REPLACE FUNCTION public.handle_inventory_items_update() RETURNS trigger
489
- LANGUAGE plpgsql
490
- SET search_path TO 'public'
491
- AS $$
492
- BEGIN
493
- NEW.updated_at = now();
494
- RETURN NEW;
495
- END;
496
- $$;
497
-
498
- CREATE OR REPLACE FUNCTION public.handle_languages_update() RETURNS trigger
499
- LANGUAGE plpgsql
500
- SET search_path TO ''
501
- AS $$
502
- BEGIN
503
- NEW.updated_at = now();
504
- RETURN NEW;
505
- END;
506
- $$;
507
-
508
- CREATE OR REPLACE FUNCTION public.handle_media_update() RETURNS trigger
509
- LANGUAGE plpgsql
510
- SET search_path TO 'public'
511
- AS $$
512
- BEGIN
513
- NEW.updated_at = now();
514
- RETURN NEW;
515
- END;
516
- $$;
517
-
518
- CREATE OR REPLACE FUNCTION public.handle_navigation_items_update() RETURNS trigger
519
- LANGUAGE plpgsql
520
- SET search_path TO 'public'
521
- AS $$
522
- BEGIN
523
- NEW.updated_at = now();
524
- RETURN NEW;
525
- END;
526
- $$;
527
-
528
- CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger
529
- LANGUAGE plpgsql SECURITY DEFINER
530
- SET search_path TO 'public'
531
- AS $$
532
- DECLARE
533
- admin_flag_set boolean := false;
534
- user_role public.user_role;
535
- v_full_name text;
536
- v_avatar_url text;
537
- v_github_username text;
538
- v_provider text;
539
- BEGIN
540
- INSERT INTO public.site_settings (key, value)
541
- VALUES ('is_admin_created', 'false'::jsonb)
542
- ON CONFLICT (key) DO NOTHING;
543
-
544
- SELECT COALESCE(value::jsonb::boolean, false)
545
- INTO admin_flag_set
546
- FROM public.site_settings
547
- WHERE key = 'is_admin_created'
548
- FOR UPDATE;
549
-
550
- IF admin_flag_set = false THEN
551
- user_role := 'ADMIN'::public.user_role;
552
-
553
- UPDATE public.site_settings
554
- SET value = 'true'::jsonb
555
- WHERE key = 'is_admin_created';
556
- ELSE
557
- user_role := 'USER'::public.user_role;
558
- END IF;
559
-
560
- v_full_name := NEW.raw_user_meta_data->>'full_name';
561
- 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
-
573
- INSERT INTO public.profiles (
574
- id,
575
- role,
576
- full_name,
577
- avatar_url,
578
- github_username
579
- )
580
- VALUES (
581
- NEW.id,
582
- user_role,
583
- v_full_name,
584
- v_avatar_url,
585
- v_github_username
586
- )
587
- ON CONFLICT (id) DO UPDATE SET
588
- full_name = EXCLUDED.full_name,
589
- avatar_url = EXCLUDED.avatar_url,
590
- github_username = EXCLUDED.github_username;
591
-
592
- RETURN NEW;
593
- END;
594
- $$;
595
-
596
- CREATE OR REPLACE FUNCTION public.handle_pages_update() RETURNS trigger
597
- LANGUAGE plpgsql
598
- SET search_path TO 'public'
599
- AS $$
600
- BEGIN
601
- NEW.updated_at = now();
602
- RETURN NEW;
603
- END;
604
- $$;
605
-
606
- CREATE OR REPLACE FUNCTION public.handle_posts_update() RETURNS trigger
607
- LANGUAGE plpgsql
608
- SET search_path TO 'public'
609
- AS $$
610
- BEGIN
611
- NEW.updated_at = now();
612
- RETURN NEW;
613
- END;
614
- $$;
615
-
616
- CREATE OR REPLACE FUNCTION public.handle_product_freemius_sale_coupons_write() RETURNS trigger
617
- LANGUAGE plpgsql
618
- SET search_path TO ''
619
- AS $$
620
- BEGIN
621
- NEW.freemius_product_id := btrim(NEW.freemius_product_id);
622
- NEW.freemius_coupon_code := upper(regexp_replace(btrim(NEW.freemius_coupon_code), '\s+', '', 'g'));
623
- NEW.sync_status := lower(btrim(COALESCE(NEW.sync_status, 'pending')));
624
- NEW.updated_at := now();
625
-
626
- IF NEW.created_at IS NULL THEN
627
- NEW.created_at := now();
628
- END IF;
629
-
630
- RETURN NEW;
631
- END;
632
- $$;
633
-
634
- CREATE OR REPLACE FUNCTION public.handle_shipping_zone_locations_write() RETURNS trigger
635
- LANGUAGE plpgsql
636
- SET search_path TO ''
637
- AS $$
638
- BEGIN
639
- NEW.country_code = upper(btrim(NEW.country_code));
640
- NEW.state_code = CASE
641
- WHEN NEW.state_code IS NULL OR btrim(NEW.state_code) = '' THEN NULL
642
- ELSE upper(btrim(NEW.state_code))
643
- END;
644
- NEW.postal_code = CASE
645
- WHEN NEW.postal_code IS NULL OR btrim(NEW.postal_code) = '' THEN NULL
646
- ELSE upper(btrim(NEW.postal_code))
647
- END;
648
- RETURN NEW;
649
- END;
650
- $$;
651
-
652
- CREATE OR REPLACE FUNCTION public.handle_tax_rates_write() RETURNS trigger
653
- LANGUAGE plpgsql
654
- SET search_path TO ''
655
- AS $$
656
- BEGIN
657
- NEW.country_code = upper(btrim(NEW.country_code));
658
- NEW.state_code = CASE
659
- WHEN NEW.state_code IS NULL OR btrim(NEW.state_code) = '' THEN NULL
660
- ELSE upper(btrim(NEW.state_code))
661
- END;
662
- NEW.tax_name = btrim(NEW.tax_name);
663
- NEW.updated_at = now();
664
-
665
- IF NEW.created_at IS NULL THEN
666
- NEW.created_at = now();
667
- END IF;
668
-
669
- RETURN NEW;
670
- END;
671
- $$;
672
-
673
- CREATE OR REPLACE FUNCTION public.handle_ucp_cart_sessions_update() RETURNS trigger
674
- LANGUAGE plpgsql
675
- SET search_path TO ''
676
- AS $$
677
- BEGIN
678
- NEW.updated_at = now();
679
- RETURN NEW;
680
- END;
681
- $$;
682
-
683
- CREATE OR REPLACE FUNCTION public.is_admin() RETURNS boolean
684
- LANGUAGE sql STABLE
685
- SET search_path TO 'public'
686
- AS $$
687
- SELECT role = 'ADMIN' FROM public.profiles WHERE id = auth.uid();
688
- $$;
689
-
690
- CREATE OR REPLACE FUNCTION public.is_valid_currency_amount_map(amounts jsonb) RETURNS boolean
691
- LANGUAGE sql IMMUTABLE
692
- SET search_path TO ''
693
- AS $_$
694
- SELECT CASE
695
- WHEN amounts IS NULL THEN false
696
- WHEN jsonb_typeof(amounts) <> 'object' THEN false
697
- WHEN amounts = '{}'::jsonb THEN false
698
- ELSE NOT EXISTS (
699
- SELECT 1
700
- FROM jsonb_each(amounts) AS entry
701
- WHERE entry.key !~ '^[A-Z]{3}$'
702
- OR jsonb_typeof(entry.value) <> 'number'
703
- OR entry.value::text !~ '^[0-9]+$'
704
- )
705
- END;
706
- $_$;
707
-
708
- CREATE OR REPLACE FUNCTION public.is_valid_sale_price_map(prices jsonb, sale_prices jsonb) RETURNS boolean
709
- LANGUAGE sql IMMUTABLE
710
- SET search_path TO ''
711
- AS $_$
712
- SELECT CASE
713
- WHEN sale_prices IS NULL THEN true
714
- WHEN jsonb_typeof(sale_prices) <> 'object' THEN false
715
- WHEN sale_prices = '{}'::jsonb THEN true
716
- WHEN prices IS NULL OR jsonb_typeof(prices) <> 'object' THEN false
717
- ELSE NOT EXISTS (
718
- SELECT 1
719
- FROM jsonb_each(sale_prices) AS entry
720
- WHERE entry.key !~ '^[A-Z]{3}$'
721
- OR NOT (prices ? entry.key)
722
- OR jsonb_typeof(entry.value) <> 'number'
723
- OR entry.value::text !~ '^[0-9]+$'
724
- OR entry.value::text::numeric > (prices ->> entry.key)::numeric
725
- )
726
- END;
727
- $_$;
728
-
729
- CREATE OR REPLACE FUNCTION public.normalize_currency_amount_map(amounts jsonb) RETURNS jsonb
730
- LANGUAGE sql IMMUTABLE
731
- SET search_path TO ''
732
- AS $_$
733
- SELECT CASE
734
- WHEN amounts IS NULL THEN '{}'::jsonb
735
- WHEN jsonb_typeof(amounts) <> 'object' THEN amounts
736
- ELSE COALESCE(
737
- (
738
- SELECT jsonb_object_agg(
739
- upper(trim(entry.key)),
740
- CASE
741
- WHEN jsonb_typeof(entry.value) = 'number'
742
- AND entry.value::text ~ '^[0-9]+$' THEN
743
- to_jsonb((entry.value::text)::bigint)
744
- ELSE
745
- entry.value
746
- END
747
- )
748
- FROM jsonb_each(amounts) AS entry
749
- ),
750
- '{}'::jsonb
751
- )
752
- END;
753
- $_$;
754
-
755
- CREATE OR REPLACE FUNCTION public.set_currency_defaults() RETURNS trigger
756
- LANGUAGE plpgsql
757
- SET search_path TO ''
758
- AS $$
759
- BEGIN
760
- NEW.code := upper(trim(NEW.code));
761
- NEW.updated_at := now();
762
-
763
- IF NEW.is_default THEN
764
- NEW.is_active := true;
765
- NEW.exchange_rate := 1;
766
- NEW.auto_update_exchange_rate := false;
767
- NEW.auto_sync_product_prices := false;
768
- NEW.exchange_rate_source := COALESCE(NULLIF(NEW.exchange_rate_source, ''), 'store-default');
769
- NEW.exchange_rate_updated_at := COALESCE(NEW.exchange_rate_updated_at, now());
770
-
771
- UPDATE public.currencies
772
- SET is_default = false,
773
- updated_at = now()
774
- WHERE id IS DISTINCT FROM NEW.id
775
- AND is_default = true;
776
- ELSIF NULLIF(NEW.exchange_rate_source, '') IS NULL THEN
777
- NEW.exchange_rate_source := NULL;
778
- END IF;
779
-
780
- RETURN NEW;
781
- END;
782
- $$;
783
-
784
- CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() RETURNS trigger
785
- LANGUAGE plpgsql
786
- SET search_path TO ''
787
- AS $$
788
- DECLARE
789
- _new record;
790
- BEGIN
791
- _new := NEW;
792
- _new.updated_at = now();
793
- RETURN _new;
794
- END;
795
- $$;
796
-
797
- CREATE OR REPLACE FUNCTION public.sync_currency_price_maps() RETURNS trigger
798
- LANGUAGE plpgsql
799
- SET search_path TO ''
800
- AS $$
801
- DECLARE
802
- v_default_currency text := public.get_default_currency_code();
803
- v_price_map_changed boolean := false;
804
- v_legacy_changed boolean := false;
805
- BEGIN
806
- NEW.prices := public.normalize_currency_amount_map(COALESCE(NEW.prices, '{}'::jsonb));
807
- NEW.sale_prices := public.normalize_currency_amount_map(COALESCE(NEW.sale_prices, '{}'::jsonb));
808
-
809
- IF NEW.sale_prices = '{}'::jsonb THEN
810
- NEW.sale_prices := NULL;
811
- END IF;
812
-
813
- IF TG_OP = 'UPDATE' THEN
814
- v_price_map_changed :=
815
- NEW.prices IS DISTINCT FROM OLD.prices
816
- OR NEW.sale_prices IS DISTINCT FROM OLD.sale_prices;
817
- v_legacy_changed :=
818
- NEW.price IS DISTINCT FROM OLD.price
819
- OR NEW.sale_price IS DISTINCT FROM OLD.sale_price;
820
- END IF;
821
-
822
- IF TG_OP = 'INSERT' THEN
823
- IF NEW.prices ? v_default_currency THEN
824
- NEW.price := (NEW.prices ->> v_default_currency)::integer;
825
- ELSE
826
- NEW.prices := NEW.prices || jsonb_build_object(
827
- v_default_currency,
828
- GREATEST(COALESCE(NEW.price, 0), 0)
829
- );
830
- END IF;
831
-
832
- IF NEW.sale_prices IS NOT NULL AND NEW.sale_prices ? v_default_currency THEN
833
- NEW.sale_price := (NEW.sale_prices ->> v_default_currency)::integer;
834
- ELSIF NEW.sale_price IS NOT NULL THEN
835
- NEW.sale_prices := COALESCE(NEW.sale_prices, '{}'::jsonb)
836
- || jsonb_build_object(v_default_currency, GREATEST(NEW.sale_price, 0));
837
- END IF;
838
-
839
- RETURN NEW;
840
- END IF;
841
-
842
- IF v_price_map_changed AND NOT v_legacy_changed THEN
843
- IF NOT (NEW.prices ? v_default_currency) THEN
844
- NEW.prices := NEW.prices || jsonb_build_object(
845
- v_default_currency,
846
- GREATEST(COALESCE(OLD.price, NEW.price, 0), 0)
847
- );
848
- END IF;
849
-
850
- NEW.price := (NEW.prices ->> v_default_currency)::integer;
851
- NEW.sale_price := CASE
852
- WHEN NEW.sale_prices IS NOT NULL AND NEW.sale_prices ? v_default_currency THEN
853
- (NEW.sale_prices ->> v_default_currency)::integer
854
- ELSE
855
- NULL
856
- END;
857
-
858
- RETURN NEW;
859
- END IF;
860
-
861
- NEW.prices := NEW.prices || jsonb_build_object(
862
- v_default_currency,
863
- GREATEST(COALESCE(NEW.price, 0), 0)
864
- );
865
-
866
- IF NEW.sale_price IS NULL THEN
867
- IF NEW.sale_prices IS NOT NULL THEN
868
- NEW.sale_prices := NEW.sale_prices - v_default_currency;
869
-
870
- IF NEW.sale_prices = '{}'::jsonb THEN
871
- NEW.sale_prices := NULL;
872
- END IF;
873
- END IF;
874
- ELSE
875
- NEW.sale_prices := COALESCE(NEW.sale_prices, '{}'::jsonb)
876
- || jsonb_build_object(v_default_currency, GREATEST(NEW.sale_price, 0));
877
- END IF;
878
-
879
- RETURN NEW;
880
- END;
881
- $$;
882
-
883
- CREATE OR REPLACE FUNCTION public.sync_inventory_cache_for_sku(p_sku text) RETURNS void
884
- LANGUAGE plpgsql
885
- SET search_path TO 'public'
886
- AS $$
887
- DECLARE
888
- v_quantity integer := 0;
889
- BEGIN
890
- IF NULLIF(trim(p_sku), '') IS NULL THEN
891
- RETURN;
892
- END IF;
893
-
894
- SELECT quantity
895
- INTO v_quantity
896
- FROM public.inventory_items
897
- WHERE sku = p_sku
898
- LIMIT 1;
899
-
900
- v_quantity := COALESCE(v_quantity, 0);
901
-
902
- UPDATE public.product_variants
903
- SET
904
- stock_quantity = v_quantity,
905
- updated_at = now()
906
- WHERE sku = p_sku;
907
-
908
- UPDATE public.products AS products
909
- SET
910
- stock = v_quantity,
911
- updated_at = now()
912
- WHERE products.sku = p_sku
913
- AND NOT EXISTS (
914
- SELECT 1
915
- FROM public.product_variants
916
- WHERE product_id = products.id
917
- );
918
-
919
- UPDATE public.products AS products
920
- SET
921
- stock = COALESCE((
922
- SELECT SUM(COALESCE(inventory.quantity, 0))
923
- FROM public.product_variants AS variants
924
- LEFT JOIN public.inventory_items AS inventory
925
- ON inventory.sku = variants.sku
926
- WHERE variants.product_id = products.id
927
- ), 0),
928
- updated_at = now()
929
- WHERE EXISTS (
930
- SELECT 1
931
- FROM public.product_variants AS variants
932
- WHERE variants.product_id = products.id
933
- AND variants.sku = p_sku
934
- );
935
- END;
936
- $$;
937
-
938
- CREATE OR REPLACE FUNCTION public.sync_legacy_price_columns_for_currency(target_currency text) RETURNS void
939
- LANGUAGE plpgsql
940
- SET search_path TO ''
941
- AS $$
942
- DECLARE
943
- v_target_currency text := upper(trim(target_currency));
944
- BEGIN
945
- UPDATE public.products
946
- SET
947
- prices = CASE
948
- WHEN COALESCE(prices, '{}'::jsonb) ? v_target_currency THEN prices
949
- ELSE COALESCE(prices, '{}'::jsonb) || jsonb_build_object(v_target_currency, price)
950
- END,
951
- sale_prices = CASE
952
- WHEN sale_price IS NULL THEN sale_prices
953
- WHEN sale_prices IS NOT NULL AND sale_prices ? v_target_currency THEN sale_prices
954
- ELSE COALESCE(sale_prices, '{}'::jsonb) || jsonb_build_object(v_target_currency, sale_price)
955
- END,
956
- price = CASE
957
- WHEN COALESCE(prices, '{}'::jsonb) ? v_target_currency THEN
958
- (COALESCE(prices, '{}'::jsonb) ->> v_target_currency)::integer
959
- ELSE
960
- price
961
- END,
962
- sale_price = CASE
963
- WHEN sale_prices IS NOT NULL AND sale_prices ? v_target_currency THEN
964
- (sale_prices ->> v_target_currency)::integer
965
- ELSE
966
- sale_price
967
- END,
968
- updated_at = now()
969
- WHERE
970
- NOT (COALESCE(prices, '{}'::jsonb) ? v_target_currency)
971
- OR (
972
- sale_price IS NOT NULL
973
- AND (sale_prices IS NULL OR NOT (sale_prices ? v_target_currency))
974
- )
975
- OR (
976
- COALESCE(prices, '{}'::jsonb) ? v_target_currency
977
- AND price IS DISTINCT FROM (COALESCE(prices, '{}'::jsonb) ->> v_target_currency)::integer
978
- )
979
- OR (
980
- sale_prices IS NOT NULL
981
- AND sale_prices ? v_target_currency
982
- AND sale_price IS DISTINCT FROM (sale_prices ->> v_target_currency)::integer
983
- );
984
-
985
- UPDATE public.product_variants
986
- SET
987
- prices = CASE
988
- WHEN COALESCE(prices, '{}'::jsonb) ? v_target_currency THEN prices
989
- ELSE COALESCE(prices, '{}'::jsonb) || jsonb_build_object(v_target_currency, price)
990
- END,
991
- sale_prices = CASE
992
- WHEN sale_price IS NULL THEN sale_prices
993
- WHEN sale_prices IS NOT NULL AND sale_prices ? v_target_currency THEN sale_prices
994
- ELSE COALESCE(sale_prices, '{}'::jsonb) || jsonb_build_object(v_target_currency, sale_price)
995
- END,
996
- price = CASE
997
- WHEN COALESCE(prices, '{}'::jsonb) ? v_target_currency THEN
998
- (COALESCE(prices, '{}'::jsonb) ->> v_target_currency)::integer
999
- ELSE
1000
- price
1001
- END,
1002
- sale_price = CASE
1003
- WHEN sale_prices IS NOT NULL AND sale_prices ? v_target_currency THEN
1004
- (sale_prices ->> v_target_currency)::integer
1005
- ELSE
1006
- sale_price
1007
- END,
1008
- updated_at = now()
1009
- WHERE
1010
- NOT (COALESCE(prices, '{}'::jsonb) ? v_target_currency)
1011
- OR (
1012
- sale_price IS NOT NULL
1013
- AND (sale_prices IS NULL OR NOT (sale_prices ? v_target_currency))
1014
- )
1015
- OR (
1016
- COALESCE(prices, '{}'::jsonb) ? v_target_currency
1017
- AND price IS DISTINCT FROM (COALESCE(prices, '{}'::jsonb) ->> v_target_currency)::integer
1018
- )
1019
- OR (
1020
- sale_prices IS NOT NULL
1021
- AND sale_prices ? v_target_currency
1022
- AND sale_price IS DISTINCT FROM (sale_prices ->> v_target_currency)::integer
1023
- );
1024
- END;
1025
- $$;
1026
-
1027
- CREATE OR REPLACE FUNCTION public.sync_shipping_method_currency_maps() RETURNS trigger
1028
- LANGUAGE plpgsql
1029
- SET search_path TO ''
1030
- AS $$
1031
- DECLARE
1032
- v_source_currency text;
1033
- BEGIN
1034
- v_source_currency := upper(trim(COALESCE(NULLIF(NEW.cost_currency, ''), public.get_default_currency_code())));
1035
-
1036
- NEW.cost_currency := v_source_currency;
1037
- NEW.currency_pricing_mode := COALESCE(NULLIF(lower(trim(NEW.currency_pricing_mode)), ''), 'auto');
1038
- NEW.cost_amounts := public.normalize_currency_amount_map(COALESCE(NEW.cost_amounts, '{}'::jsonb));
1039
- NEW.min_order_amounts := public.normalize_currency_amount_map(COALESCE(NEW.min_order_amounts, '{}'::jsonb));
1040
-
1041
- IF NEW.currency_pricing_mode NOT IN ('auto', 'manual') THEN
1042
- RAISE EXCEPTION 'Unsupported shipping currency pricing mode: %', NEW.currency_pricing_mode;
1043
- END IF;
1044
-
1045
- IF NEW.cost_amounts = '{}'::jsonb THEN
1046
- NEW.cost_amounts := jsonb_build_object(v_source_currency, GREATEST(COALESCE(NEW.cost_amount, 0), 0));
1047
- ELSIF NOT (NEW.cost_amounts ? v_source_currency) THEN
1048
- NEW.cost_amounts := NEW.cost_amounts || jsonb_build_object(
1049
- v_source_currency,
1050
- GREATEST(COALESCE(NEW.cost_amount, 0), 0)
1051
- );
1052
- END IF;
1053
-
1054
- IF NEW.min_order_amounts = '{}'::jsonb THEN
1055
- NEW.min_order_amounts := jsonb_build_object(
1056
- v_source_currency,
1057
- GREATEST(COALESCE(NEW.min_order_amount, 0), 0)
1058
- );
1059
- ELSIF NOT (NEW.min_order_amounts ? v_source_currency) THEN
1060
- NEW.min_order_amounts := NEW.min_order_amounts || jsonb_build_object(
1061
- v_source_currency,
1062
- GREATEST(COALESCE(NEW.min_order_amount, 0), 0)
1063
- );
1064
- END IF;
1065
-
1066
- IF NEW.currency_pricing_mode = 'auto' THEN
1067
- NEW.cost_amounts := jsonb_build_object(
1068
- v_source_currency,
1069
- GREATEST((NEW.cost_amounts ->> v_source_currency)::integer, 0)
1070
- );
1071
- NEW.min_order_amounts := jsonb_build_object(
1072
- v_source_currency,
1073
- GREATEST((NEW.min_order_amounts ->> v_source_currency)::integer, 0)
1074
- );
1075
- END IF;
1076
-
1077
- NEW.cost_amount := GREATEST((NEW.cost_amounts ->> v_source_currency)::integer, 0);
1078
- NEW.min_order_amount := GREATEST((NEW.min_order_amounts ->> v_source_currency)::integer, 0);
1079
- NEW.updated_at := now();
1080
-
1081
- RETURN NEW;
1082
- END;
1083
- $$;
1084
-
1085
- CREATE OR REPLACE FUNCTION public.update_product_ratings() RETURNS trigger
1086
- LANGUAGE plpgsql SECURITY DEFINER
1087
- SET search_path TO 'public'
1088
- AS $$
1089
- DECLARE
1090
- v_product_id uuid;
1091
- v_avg numeric(3,2);
1092
- v_count integer;
1093
- BEGIN
1094
- -- Determine which product_id we need to update
1095
- IF TG_OP = 'DELETE' THEN
1096
- v_product_id := OLD.product_id;
1097
- ELSE
1098
- v_product_id := NEW.product_id;
1099
- END IF;
1100
-
1101
- IF v_product_id IS NOT NULL THEN
1102
- -- Calculate average and count of approved reviews for this product
1103
- SELECT COALESCE(avg(rating), 0.00), count(*)
1104
- INTO v_avg, v_count
1105
- FROM public.cms_interactions
1106
- WHERE product_id = v_product_id
1107
- AND type = 'review'
1108
- AND status = 'approved';
1109
-
1110
- -- Update products table
1111
- UPDATE public.products
1112
- SET average_rating = ROUND(COALESCE(v_avg, 0.00), 2),
1113
- total_reviews = v_count
1114
- WHERE id = v_product_id;
1115
- END IF;
1116
-
1117
- -- Handle old product_id if it changed on UPDATE (e.g. transfer of reviews)
1118
- IF TG_OP = 'UPDATE' AND OLD.product_id IS NOT NULL AND OLD.product_id <> NEW.product_id THEN
1119
- SELECT COALESCE(avg(rating), 0.00), count(*)
1120
- INTO v_avg, v_count
1121
- FROM public.cms_interactions
1122
- WHERE product_id = OLD.product_id
1123
- AND type = 'review'
1124
- AND status = 'approved';
1125
-
1126
- UPDATE public.products
1127
- SET average_rating = ROUND(COALESCE(v_avg, 0.00), 2),
1128
- total_reviews = v_count
1129
- WHERE id = OLD.product_id;
1130
- END IF;
1131
-
1132
- RETURN NULL;
1133
- END;
1134
- $$;
1135
-
1136
- CREATE OR REPLACE FUNCTION public.upsert_product_with_variants(product_payload jsonb) RETURNS uuid
1137
- LANGUAGE plpgsql
1138
- SET search_path TO 'public'
1139
- AS $$
1140
- DECLARE
1141
- v_product_id uuid := NULLIF(product_payload->>'id', '')::uuid;
1142
- v_translation_group_id uuid := NULLIF(product_payload->>'translation_group_id', '')::uuid;
1143
- v_product_type text := CASE
1144
- WHEN product_payload->>'product_type' IN ('physical', 'digital') THEN
1145
- product_payload->>'product_type'
1146
- WHEN NULLIF(product_payload->>'freemius_product_id', '') IS NOT NULL
1147
- OR NULLIF(product_payload->>'freemius_plan_id', '') IS NOT NULL THEN
1148
- 'digital'
1149
- ELSE
1150
- 'physical'
1151
- END;
1152
- v_payment_provider text := CASE
1153
- WHEN v_product_type = 'digital' THEN 'freemius'
1154
- ELSE 'stripe'
1155
- END;
1156
- v_variants jsonb := COALESCE(product_payload->'variants', '[]'::jsonb);
1157
- v_variant jsonb;
1158
- v_variant_id uuid;
1159
- v_term_id text;
1160
- v_has_variants boolean := jsonb_typeof(v_variants) = 'array' AND jsonb_array_length(v_variants) > 0;
1161
- v_total_variant_stock integer := 0;
1162
- BEGIN
1163
- IF NOT public.is_admin() THEN
1164
- RAISE EXCEPTION 'Admin access required';
1165
- END IF;
1166
-
1167
- IF v_has_variants THEN
1168
- SELECT COALESCE(SUM(COALESCE((value->>'stock_quantity')::integer, 0)), 0)
1169
- INTO v_total_variant_stock
1170
- FROM jsonb_array_elements(v_variants);
1171
- END IF;
1172
-
1173
- IF v_product_id IS NULL THEN
1174
- INSERT INTO public.products (
1175
- title,
1176
- slug,
1177
- sku,
1178
- product_type,
1179
- payment_provider,
1180
- upc,
1181
- stock,
1182
- status,
1183
- short_description,
1184
- description_json,
1185
- metadata,
1186
- price,
1187
- prices,
1188
- sale_price,
1189
- sale_prices,
1190
- sale_start_at,
1191
- sale_end_at,
1192
- freemius_plan_id,
1193
- freemius_product_id,
1194
- trial_period_days,
1195
- trial_requires_payment_method,
1196
- language_id,
1197
- translation_group_id
1198
- )
1199
- VALUES (
1200
- product_payload->>'title',
1201
- product_payload->>'slug',
1202
- product_payload->>'sku',
1203
- v_product_type,
1204
- v_payment_provider,
1205
- NULLIF(product_payload->>'upc', ''),
1206
- CASE
1207
- WHEN v_has_variants THEN v_total_variant_stock
1208
- ELSE COALESCE((product_payload->>'stock')::integer, 0)
1209
- END,
1210
- COALESCE(product_payload->>'status', 'draft'),
1211
- NULLIF(product_payload->>'short_description', ''),
1212
- product_payload->'description_json',
1213
- COALESCE(product_payload->'metadata', '{}'::jsonb),
1214
- COALESCE((product_payload->>'price')::integer, 0),
1215
- COALESCE(product_payload->'prices', '{}'::jsonb),
1216
- CASE
1217
- WHEN product_payload ? 'sale_price' AND product_payload->>'sale_price' <> '' THEN
1218
- (product_payload->>'sale_price')::integer
1219
- ELSE
1220
- NULL
1221
- END,
1222
- CASE
1223
- WHEN product_payload ? 'sale_prices' THEN COALESCE(product_payload->'sale_prices', '{}'::jsonb)
1224
- ELSE NULL
1225
- END,
1226
- CASE
1227
- WHEN product_payload ? 'sale_start_at' AND product_payload->>'sale_start_at' <> '' THEN
1228
- (product_payload->>'sale_start_at')::timestamptz
1229
- ELSE
1230
- NULL
1231
- END,
1232
- CASE
1233
- WHEN product_payload ? 'sale_end_at' AND product_payload->>'sale_end_at' <> '' THEN
1234
- (product_payload->>'sale_end_at')::timestamptz
1235
- ELSE
1236
- NULL
1237
- END,
1238
- NULLIF(product_payload->>'freemius_plan_id', ''),
1239
- NULLIF(product_payload->>'freemius_product_id', ''),
1240
- COALESCE((product_payload->>'trial_period_days')::integer, 0),
1241
- COALESCE((product_payload->>'trial_requires_payment_method')::boolean, false),
1242
- (product_payload->>'language_id')::bigint,
1243
- COALESCE(v_translation_group_id, gen_random_uuid())
1244
- )
1245
- RETURNING id INTO v_product_id;
1246
- ELSE
1247
- UPDATE public.products
1248
- SET
1249
- title = product_payload->>'title',
1250
- slug = product_payload->>'slug',
1251
- sku = product_payload->>'sku',
1252
- product_type = v_product_type,
1253
- payment_provider = v_payment_provider,
1254
- upc = NULLIF(product_payload->>'upc', ''),
1255
- stock = CASE
1256
- WHEN v_has_variants THEN v_total_variant_stock
1257
- ELSE COALESCE((product_payload->>'stock')::integer, 0)
1258
- END,
1259
- status = COALESCE(product_payload->>'status', status),
1260
- short_description = NULLIF(product_payload->>'short_description', ''),
1261
- description_json = product_payload->'description_json',
1262
- metadata = COALESCE(product_payload->'metadata', '{}'::jsonb),
1263
- price = COALESCE((product_payload->>'price')::integer, 0),
1264
- prices = COALESCE(product_payload->'prices', '{}'::jsonb),
1265
- sale_price = CASE
1266
- WHEN product_payload ? 'sale_price' AND product_payload->>'sale_price' <> '' THEN
1267
- (product_payload->>'sale_price')::integer
1268
- ELSE
1269
- NULL
1270
- END,
1271
- sale_prices = CASE
1272
- WHEN product_payload ? 'sale_prices' THEN COALESCE(product_payload->'sale_prices', '{}'::jsonb)
1273
- ELSE NULL
1274
- END,
1275
- sale_start_at = CASE
1276
- WHEN product_payload ? 'sale_start_at' AND product_payload->>'sale_start_at' <> '' THEN
1277
- (product_payload->>'sale_start_at')::timestamptz
1278
- ELSE
1279
- NULL
1280
- END,
1281
- sale_end_at = CASE
1282
- WHEN product_payload ? 'sale_end_at' AND product_payload->>'sale_end_at' <> '' THEN
1283
- (product_payload->>'sale_end_at')::timestamptz
1284
- ELSE
1285
- NULL
1286
- END,
1287
- freemius_plan_id = NULLIF(product_payload->>'freemius_plan_id', ''),
1288
- freemius_product_id = NULLIF(product_payload->>'freemius_product_id', ''),
1289
- trial_period_days = COALESCE((product_payload->>'trial_period_days')::integer, 0),
1290
- trial_requires_payment_method = COALESCE((product_payload->>'trial_requires_payment_method')::boolean, false),
1291
- language_id = COALESCE((product_payload->>'language_id')::bigint, language_id),
1292
- translation_group_id = COALESCE(v_translation_group_id, translation_group_id),
1293
- updated_at = now()
1294
- WHERE id = v_product_id;
1295
-
1296
- IF NOT FOUND THEN
1297
- RAISE EXCEPTION 'Product not found';
1298
- END IF;
1299
- END IF;
1300
-
1301
- DELETE FROM public.variant_attribute_mapping
1302
- WHERE variant_id IN (
1303
- SELECT id
1304
- FROM public.product_variants
1305
- WHERE product_id = v_product_id
1306
- );
1307
-
1308
- DELETE FROM public.product_variants
1309
- WHERE product_id = v_product_id;
1310
-
1311
- IF v_has_variants THEN
1312
- FOR v_variant IN
1313
- SELECT value FROM jsonb_array_elements(v_variants)
1314
- LOOP
1315
- INSERT INTO public.product_variants (
1316
- product_id,
1317
- sku,
1318
- upc,
1319
- price,
1320
- prices,
1321
- sale_price,
1322
- sale_prices,
1323
- sale_start_at,
1324
- sale_end_at,
1325
- stock_quantity,
1326
- main_media_id
1327
- )
1328
- VALUES (
1329
- v_product_id,
1330
- v_variant->>'sku',
1331
- NULLIF(v_variant->>'upc', ''),
1332
- COALESCE((v_variant->>'price')::integer, 0),
1333
- COALESCE(v_variant->'prices', '{}'::jsonb),
1334
- CASE
1335
- WHEN v_variant ? 'sale_price' AND v_variant->>'sale_price' <> '' THEN
1336
- (v_variant->>'sale_price')::integer
1337
- ELSE
1338
- NULL
1339
- END,
1340
- CASE
1341
- WHEN v_variant ? 'sale_prices' THEN COALESCE(v_variant->'sale_prices', '{}'::jsonb)
1342
- ELSE NULL
1343
- END,
1344
- CASE
1345
- WHEN v_variant ? 'sale_start_at' AND v_variant->>'sale_start_at' <> '' THEN
1346
- (v_variant->>'sale_start_at')::timestamptz
1347
- ELSE
1348
- NULL
1349
- END,
1350
- CASE
1351
- WHEN v_variant ? 'sale_end_at' AND v_variant->>'sale_end_at' <> '' THEN
1352
- (v_variant->>'sale_end_at')::timestamptz
1353
- ELSE
1354
- NULL
1355
- END,
1356
- COALESCE((v_variant->>'stock_quantity')::integer, 0),
1357
- NULLIF(v_variant->>'main_media_id', '')::uuid
1358
- )
1359
- RETURNING id INTO v_variant_id;
1360
-
1361
- FOR v_term_id IN
1362
- SELECT jsonb_array_elements_text(COALESCE(v_variant->'attribute_term_ids', '[]'::jsonb))
1363
- LOOP
1364
- INSERT INTO public.variant_attribute_mapping (variant_id, attribute_term_id)
1365
- VALUES (v_variant_id, v_term_id::uuid);
1366
- END LOOP;
1367
- END LOOP;
1368
- END IF;
1369
-
1370
- RETURN v_product_id;
1371
- END;
1372
- $$;
1373
-
1374
- CREATE TABLE IF NOT EXISTS public.blocks (
1375
- id bigint NOT NULL,
1376
- page_id bigint,
1377
- post_id bigint,
1378
- language_id bigint NOT NULL,
1379
- block_type text NOT NULL,
1380
- content jsonb,
1381
- "order" integer DEFAULT 0 NOT NULL,
1382
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1383
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
1384
- product_id uuid,
1385
- CONSTRAINT check_exactly_one_parent CHECK ((((page_id IS NOT NULL) AND (post_id IS NULL) AND (product_id IS NULL)) OR ((post_id IS NOT NULL) AND (page_id IS NULL) AND (product_id IS NULL)) OR ((product_id IS NOT NULL) AND (page_id IS NULL) AND (post_id IS NULL))))
1386
- );
1387
-
1388
- COMMENT ON TABLE public.blocks IS 'Stores content blocks for pages and posts.';
1389
-
1390
- COMMENT ON COLUMN public.blocks.block_type IS 'Type of the block, e.g., "text", "image".';
1391
-
1392
- COMMENT ON COLUMN public.blocks.content IS 'JSONB content specific to the block_type.';
1393
-
1394
- COMMENT ON COLUMN public.blocks."order" IS 'Sort order of the block.';
1395
-
1396
- DO $rb$ BEGIN
1397
- IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.blocks'::regclass AND attname = 'id' AND attidentity <> '') THEN
1398
- ALTER TABLE public.blocks ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1399
- SEQUENCE NAME public.blocks_id_seq
1400
- START WITH 1
1401
- INCREMENT BY 1
1402
- NO MINVALUE
1403
- NO MAXVALUE
1404
- CACHE 1
1405
- );
1406
- END IF;
1407
- END $rb$;
1408
-
1409
- CREATE TABLE IF NOT EXISTS public.categories (
1410
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1411
- name text NOT NULL,
1412
- slug text NOT NULL,
1413
- description text,
1414
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1415
- name_translations jsonb DEFAULT '{}'::jsonb NOT NULL,
1416
- description_translations jsonb DEFAULT '{}'::jsonb NOT NULL
1417
- );
1418
-
1419
- COMMENT ON TABLE public.categories IS 'Product categories for organizing catalog items.';
1420
-
1421
- COMMENT ON COLUMN public.categories.name_translations IS 'Translated category names (e.g. {"fr": "Numérique"}).';
1422
-
1423
- COMMENT ON COLUMN public.categories.description_translations IS 'Translated category descriptions.';
1424
-
1425
- CREATE TABLE IF NOT EXISTS public.cms_interactions (
1426
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1427
- type public.interaction_type NOT NULL,
1428
- status public.approval_status DEFAULT 'pending'::public.approval_status NOT NULL,
1429
- content text NOT NULL,
1430
- rating integer,
1431
- user_id uuid NOT NULL,
1432
- product_id uuid,
1433
- post_id bigint,
1434
- reactions jsonb DEFAULT '{}'::jsonb NOT NULL,
1435
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1436
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
1437
- 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))))
1439
- );
1440
-
1441
- COMMENT ON TABLE public.cms_interactions IS 'Stores user-submitted product reviews and blog post comments.';
1442
-
1443
- COMMENT ON COLUMN public.cms_interactions.rating IS 'Star rating 1-5, only populated for product reviews.';
1444
-
1445
- COMMENT ON COLUMN public.cms_interactions.user_id IS 'References public.profiles.id';
1446
-
1447
- COMMENT ON COLUMN public.cms_interactions.reactions IS 'JSONB structure tracking counts of reactions (likes, etc.).';
1448
-
1449
- CREATE TABLE IF NOT EXISTS public.content_drafts (
1450
- id bigint NOT NULL,
1451
- parent_type text NOT NULL,
1452
- parent_id bigint NOT NULL,
1453
- author_id uuid,
1454
- base_version integer DEFAULT 1 NOT NULL,
1455
- meta jsonb DEFAULT '{}'::jsonb NOT NULL,
1456
- blocks jsonb DEFAULT '[]'::jsonb NOT NULL,
1457
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1458
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
1459
- CONSTRAINT content_drafts_blocks_array CHECK ((jsonb_typeof(blocks) = 'array'::text)),
1460
- CONSTRAINT content_drafts_meta_object CHECK ((jsonb_typeof(meta) = 'object'::text)),
1461
- CONSTRAINT content_drafts_parent_type_check CHECK ((parent_type = ANY (ARRAY['page'::text, 'post'::text])))
1462
- );
1463
-
1464
- COMMENT ON TABLE public.content_drafts IS 'Draft snapshots used by Draft Mode and front-end visual editing before publishing to live page/post rows.';
1465
-
1466
- COMMENT ON COLUMN public.content_drafts.parent_type IS 'The content table this draft belongs to: page or post.';
1467
-
1468
- COMMENT ON COLUMN public.content_drafts.parent_id IS 'ID of the page or post being drafted.';
1469
-
1470
- COMMENT ON COLUMN public.content_drafts.base_version IS 'Published page/post version the draft was created from.';
1471
-
1472
- COMMENT ON COLUMN public.content_drafts.meta IS 'Draft page/post metadata snapshot.';
1473
-
1474
- COMMENT ON COLUMN public.content_drafts.blocks IS 'Ordered draft block snapshot, including block ids, block types, content, language ids, and order values.';
1475
-
1476
- DO $rb$ BEGIN
1477
- IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.content_drafts'::regclass AND attname = 'id' AND attidentity <> '') THEN
1478
- ALTER TABLE public.content_drafts ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1479
- SEQUENCE NAME public.content_drafts_id_seq
1480
- START WITH 1
1481
- INCREMENT BY 1
1482
- NO MINVALUE
1483
- NO MAXVALUE
1484
- CACHE 1
1485
- );
1486
- END IF;
1487
- END $rb$;
1488
-
1489
- CREATE TABLE IF NOT EXISTS public.cortex_ai_db_mutation_audit (
1490
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1491
- actor_user_id uuid,
1492
- tool_name text NOT NULL,
1493
- action_name text NOT NULL,
1494
- target_tables text[] DEFAULT '{}'::text[] NOT NULL,
1495
- operation_summary text NOT NULL,
1496
- payload_hash text NOT NULL,
1497
- payload jsonb DEFAULT '{}'::jsonb NOT NULL,
1498
- preview jsonb DEFAULT '{}'::jsonb NOT NULL,
1499
- status text NOT NULL,
1500
- error_message text,
1501
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1502
- CONSTRAINT cortex_ai_db_mutation_audit_status_check CHECK ((status = ANY (ARRAY['success'::text, 'failure'::text])))
1503
- );
1504
-
1505
- COMMENT ON TABLE public.cortex_ai_db_mutation_audit IS 'Audit trail for confirmed Cortex AI database mutation attempts.';
1506
-
1507
- COMMENT ON COLUMN public.cortex_ai_db_mutation_audit.payload IS 'Redacted tool input payload for the confirmed mutation attempt.';
1508
-
1509
- COMMENT ON COLUMN public.cortex_ai_db_mutation_audit.preview IS 'Redacted confirmation preview shown before the mutation was confirmed.';
1510
-
1511
- CREATE TABLE IF NOT EXISTS public.coupon_freemius_mappings (
1512
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1513
- coupon_id uuid NOT NULL,
1514
- product_id uuid,
1515
- freemius_product_id text NOT NULL,
1516
- freemius_coupon_id text,
1517
- freemius_coupon_code text NOT NULL,
1518
- sync_status text DEFAULT 'pending'::text NOT NULL,
1519
- sync_error text,
1520
- remote_payload jsonb,
1521
- last_synced_at timestamp with time zone,
1522
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1523
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
1524
- CONSTRAINT coupon_freemius_mappings_code_not_blank CHECK ((char_length(btrim(freemius_coupon_code)) > 0)),
1525
- CONSTRAINT coupon_freemius_mappings_product_not_blank CHECK ((char_length(btrim(freemius_product_id)) > 0)),
1526
- CONSTRAINT coupon_freemius_mappings_sync_status_valid CHECK ((sync_status = ANY (ARRAY['pending'::text, 'synced'::text, 'failed'::text, 'deleted'::text])))
1527
- );
1528
-
1529
- CREATE TABLE IF NOT EXISTS public.coupon_products (
1530
- coupon_id uuid NOT NULL,
1531
- product_id uuid NOT NULL,
1532
- created_at timestamp with time zone DEFAULT now() NOT NULL
1533
- );
1534
-
1535
- COMMENT ON TABLE public.coupon_products IS 'Optional product allow-list for coupons. No rows means all products in the provider scope are eligible.';
1536
-
1537
- CREATE TABLE IF NOT EXISTS public.coupon_redemptions (
1538
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1539
- coupon_id uuid,
1540
- order_id uuid,
1541
- coupon_code text NOT NULL,
1542
- provider text NOT NULL,
1543
- discount_total integer DEFAULT 0 NOT NULL,
1544
- user_id uuid,
1545
- customer_email text,
1546
- metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
1547
- redeemed_at timestamp with time zone DEFAULT now() NOT NULL,
1548
- CONSTRAINT coupon_redemptions_code_not_blank CHECK ((char_length(btrim(coupon_code)) > 0)),
1549
- CONSTRAINT coupon_redemptions_discount_total_check CHECK ((discount_total >= 0)),
1550
- CONSTRAINT coupon_redemptions_provider_check CHECK ((provider = ANY (ARRAY['stripe'::text, 'freemius'::text])))
1551
- );
1552
-
1553
- CREATE TABLE IF NOT EXISTS public.coupons (
1554
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1555
- code text NOT NULL,
1556
- name text NOT NULL,
1557
- internal_note text,
1558
- provider_scope text DEFAULT 'all'::text NOT NULL,
1559
- discount_type text NOT NULL,
1560
- discount_amount integer NOT NULL,
1561
- is_active boolean DEFAULT true NOT NULL,
1562
- starts_at timestamp with time zone,
1563
- ends_at timestamp with time zone,
1564
- redemption_limit integer,
1565
- redemptions_count integer DEFAULT 0 NOT NULL,
1566
- freemius_sync_status text DEFAULT 'not_synced'::text NOT NULL,
1567
- freemius_sync_error text,
1568
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1569
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
1570
- CONSTRAINT coupons_code_not_blank CHECK ((char_length(btrim(code)) > 0)),
1571
- CONSTRAINT coupons_date_window_valid CHECK (((starts_at IS NULL) OR (ends_at IS NULL) OR (starts_at < ends_at))),
1572
- CONSTRAINT coupons_discount_amount_positive CHECK ((discount_amount > 0)),
1573
- CONSTRAINT coupons_discount_type_valid CHECK ((discount_type = ANY (ARRAY['percent'::text, 'fixed'::text]))),
1574
- CONSTRAINT coupons_freemius_sync_status_valid CHECK ((freemius_sync_status = ANY (ARRAY['not_synced'::text, 'pending'::text, 'synced'::text, 'failed'::text, 'not_required'::text]))),
1575
- CONSTRAINT coupons_name_not_blank CHECK ((char_length(btrim(name)) > 0)),
1576
- CONSTRAINT coupons_percent_amount_valid CHECK (((discount_type <> 'percent'::text) OR (discount_amount <= 100))),
1577
- CONSTRAINT coupons_provider_scope_valid CHECK ((provider_scope = ANY (ARRAY['all'::text, 'stripe'::text, 'freemius'::text]))),
1578
- CONSTRAINT coupons_redemption_limit_positive CHECK (((redemption_limit IS NULL) OR (redemption_limit > 0))),
1579
- CONSTRAINT coupons_redemptions_count_nonnegative CHECK ((redemptions_count >= 0))
1580
- );
1581
-
1582
- COMMENT ON TABLE public.coupons IS 'Unified commerce coupons managed by NextBlock CMS and applied through provider-aware checkout.';
1583
-
1584
- COMMENT ON COLUMN public.coupons.provider_scope IS 'Provider eligibility: all, stripe, or freemius.';
1585
-
1586
- COMMENT ON COLUMN public.coupons.discount_amount IS 'Percent value for percent coupons, or fixed minor-unit amount for fixed coupons.';
1587
-
1588
- COMMENT ON COLUMN public.coupons.freemius_sync_status IS 'Aggregate status for syncing this coupon to Freemius product coupon records.';
1589
-
1590
- CREATE TABLE IF NOT EXISTS public.currencies (
1591
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1592
- code text NOT NULL,
1593
- symbol text NOT NULL,
1594
- exchange_rate numeric(20,10) NOT NULL,
1595
- is_default boolean DEFAULT false NOT NULL,
1596
- is_active boolean DEFAULT true NOT NULL,
1597
- rounding_mode text DEFAULT 'none'::text NOT NULL,
1598
- rounding_increment integer DEFAULT 1 NOT NULL,
1599
- rounding_charm_amount integer,
1600
- auto_update_exchange_rate boolean DEFAULT true NOT NULL,
1601
- exchange_rate_updated_at timestamp with time zone,
1602
- exchange_rate_source text,
1603
- auto_sync_product_prices boolean DEFAULT false NOT NULL,
1604
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1605
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
1606
- CONSTRAINT currencies_charm_requires_amount CHECK (((rounding_mode <> 'charm'::text) OR (rounding_charm_amount IS NOT NULL))),
1607
- CONSTRAINT currencies_code_check CHECK ((code ~ '^[A-Z]{3}$'::text)),
1608
- CONSTRAINT currencies_default_auto_update_disabled CHECK (((NOT is_default) OR (auto_update_exchange_rate = false))),
1609
- CONSTRAINT currencies_default_exchange_rate_is_one CHECK (((NOT is_default) OR (exchange_rate = (1)::numeric))),
1610
- CONSTRAINT currencies_default_must_be_active CHECK (((NOT is_default) OR is_active)),
1611
- CONSTRAINT currencies_default_product_price_sync_disabled CHECK (((NOT is_default) OR (auto_sync_product_prices = false))),
1612
- CONSTRAINT currencies_exchange_rate_check CHECK ((exchange_rate > (0)::numeric)),
1613
- CONSTRAINT currencies_rounding_charm_nonnegative CHECK (((rounding_charm_amount IS NULL) OR (rounding_charm_amount >= 0))),
1614
- CONSTRAINT currencies_rounding_increment_positive CHECK ((rounding_increment > 0)),
1615
- CONSTRAINT currencies_rounding_mode_valid CHECK ((rounding_mode = ANY (ARRAY['none'::text, 'nearest'::text, 'up'::text, 'down'::text, 'charm'::text])))
1616
- );
1617
-
1618
- COMMENT ON TABLE public.currencies IS 'Store currencies available for storefront display and conversion.';
1619
-
1620
- COMMENT ON COLUMN public.currencies.exchange_rate IS 'Relative to the current store default currency. The default currency should have exchange_rate = 1.';
1621
-
1622
- COMMENT ON COLUMN public.currencies.rounding_mode IS 'Rounding strategy applied when prices are auto-converted into this currency.';
1623
-
1624
- COMMENT ON COLUMN public.currencies.rounding_increment IS 'Rounding step in the currency smallest unit. Example: 5 means 0.05 for USD/CAD.';
1625
-
1626
- COMMENT ON COLUMN public.currencies.rounding_charm_amount IS 'Charm ending in the currency smallest unit. Example: 90 means prices like 29.90.';
1627
-
1628
- COMMENT ON COLUMN public.currencies.auto_update_exchange_rate IS 'Whether scheduled FX sync jobs should refresh this currency.';
1629
-
1630
- COMMENT ON COLUMN public.currencies.exchange_rate_updated_at IS 'When this currency exchange rate was last refreshed or manually set.';
1631
-
1632
- COMMENT ON COLUMN public.currencies.exchange_rate_source IS 'Human-readable source for the current exchange rate, such as a provider host or manual override.';
1633
-
1634
- COMMENT ON COLUMN public.currencies.auto_sync_product_prices IS 'Whether storefront product and variant prices in this currency are derived automatically from the store default currency using FX and rounding rules.';
1635
-
1636
- CREATE TABLE IF NOT EXISTS public.email_2fa_challenges (
1637
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1638
- user_id uuid NOT NULL,
1639
- token_hash text NOT NULL,
1640
- expires_at timestamp with time zone NOT NULL,
1641
- consumed_at timestamp with time zone,
1642
- created_at timestamp with time zone DEFAULT now() NOT NULL
1643
- );
1644
-
1645
- 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
-
1647
- CREATE TABLE IF NOT EXISTS public.freemius_plans (
1648
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1649
- product_id uuid NOT NULL,
1650
- name text NOT NULL,
1651
- title text,
1652
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1653
- updated_at timestamp with time zone DEFAULT now() NOT NULL
1654
- );
1655
-
1656
- CREATE TABLE IF NOT EXISTS public.freemius_pricing (
1657
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1658
- plan_id uuid NOT NULL,
1659
- api_monthly_price numeric,
1660
- api_annual_price numeric,
1661
- api_lifetime_price numeric,
1662
- override_monthly_price numeric,
1663
- override_annual_price numeric,
1664
- override_lifetime_price numeric,
1665
- license_quota integer,
1666
- is_active boolean DEFAULT true NOT NULL,
1667
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1668
- updated_at timestamp with time zone DEFAULT now() NOT NULL
1669
- );
1670
-
1671
- CREATE TABLE IF NOT EXISTS public.inventory_items (
1672
- sku text NOT NULL,
1673
- quantity integer DEFAULT 0 NOT NULL,
1674
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1675
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
1676
- CONSTRAINT inventory_items_quantity_check CHECK ((quantity >= 0))
1677
- );
1678
-
1679
- COMMENT ON TABLE public.inventory_items IS 'Source-of-truth inventory records keyed by sellable SKU.';
1680
-
1681
- COMMENT ON COLUMN public.inventory_items.sku IS 'Global sellable SKU. Matching products or variants share inventory.';
1682
-
1683
- COMMENT ON COLUMN public.inventory_items.quantity IS 'Available quantity for this SKU.';
1684
-
1685
- CREATE TABLE IF NOT EXISTS public.languages (
1686
- id bigint NOT NULL,
1687
- code text NOT NULL,
1688
- name text NOT NULL,
1689
- is_default boolean DEFAULT false NOT NULL,
1690
- is_active boolean DEFAULT true,
1691
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1692
- updated_at timestamp with time zone DEFAULT now() NOT NULL
1693
- );
1694
-
1695
- COMMENT ON TABLE public.languages IS 'Stores supported languages for the CMS.';
1696
-
1697
- COMMENT ON COLUMN public.languages.code IS 'BCP 47 language code.';
1698
-
1699
- DO $rb$ BEGIN
1700
- IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.languages'::regclass AND attname = 'id' AND attidentity <> '') THEN
1701
- ALTER TABLE public.languages ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1702
- SEQUENCE NAME public.languages_id_seq
1703
- START WITH 1
1704
- INCREMENT BY 1
1705
- NO MINVALUE
1706
- NO MAXVALUE
1707
- CACHE 1
1708
- );
1709
- END IF;
1710
- END $rb$;
1711
-
1712
- CREATE TABLE IF NOT EXISTS public.logos (
1713
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1714
- name text NOT NULL,
1715
- media_id uuid,
1716
- created_at timestamp with time zone DEFAULT now() NOT NULL
1717
- );
1718
-
1719
- COMMENT ON TABLE public.logos IS 'Stores company and brand logos.';
1720
-
1721
- COMMENT ON COLUMN public.logos.name IS 'The name of the brand or company for the logo.';
1722
-
1723
- COMMENT ON COLUMN public.logos.media_id IS 'Foreign key to the media table for the logo image.';
1724
-
1725
- CREATE TABLE IF NOT EXISTS public.media (
1726
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1727
- uploader_id uuid,
1728
- file_name text NOT NULL,
1729
- object_key text NOT NULL,
1730
- file_type text,
1731
- size_bytes bigint,
1732
- description text,
1733
- width integer,
1734
- height integer,
1735
- blur_data_url text,
1736
- variants jsonb,
1737
- file_path text,
1738
- folder text,
1739
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1740
- updated_at timestamp with time zone DEFAULT now() NOT NULL
1741
- );
1742
-
1743
- COMMENT ON TABLE public.media IS 'Stores information about uploaded media assets.';
1744
-
1745
- COMMENT ON COLUMN public.media.object_key IS 'Unique key (path) in Cloudflare R2.';
1746
-
1747
- COMMENT ON COLUMN public.media.width IS 'Width of the image in pixels.';
1748
-
1749
- COMMENT ON COLUMN public.media.height IS 'Height of the image in pixels.';
1750
-
1751
- COMMENT ON COLUMN public.media.blur_data_url IS 'Base64 encoded string for image blur placeholders.';
1752
-
1753
- COMMENT ON COLUMN public.media.variants IS 'Array of image variant objects.';
1754
-
1755
- COMMENT ON COLUMN public.media.file_path IS 'Full path to the file in the storage bucket.';
1756
-
1757
- COMMENT ON COLUMN public.media.folder IS 'Folder path prefix for the R2 object.';
1758
-
1759
- CREATE TABLE IF NOT EXISTS public.navigation_items (
1760
- id bigint NOT NULL,
1761
- language_id bigint NOT NULL,
1762
- menu_key public.menu_location NOT NULL,
1763
- label text NOT NULL,
1764
- url text NOT NULL,
1765
- parent_id bigint,
1766
- "order" integer DEFAULT 0 NOT NULL,
1767
- page_id bigint,
1768
- translation_group_id uuid DEFAULT gen_random_uuid() NOT NULL,
1769
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1770
- updated_at timestamp with time zone DEFAULT now() NOT NULL
1771
- );
1772
-
1773
- COMMENT ON TABLE public.navigation_items IS 'Stores navigation menu items.';
1774
-
1775
- COMMENT ON COLUMN public.navigation_items.menu_key IS 'Identifies the menu this item belongs to.';
1776
-
1777
- DO $rb$ BEGIN
1778
- IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.navigation_items'::regclass AND attname = 'id' AND attidentity <> '') THEN
1779
- ALTER TABLE public.navigation_items ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1780
- SEQUENCE NAME public.navigation_items_id_seq
1781
- START WITH 1
1782
- INCREMENT BY 1
1783
- NO MINVALUE
1784
- NO MAXVALUE
1785
- CACHE 1
1786
- );
1787
- END IF;
1788
- END $rb$;
1789
-
1790
- CREATE SEQUENCE IF NOT EXISTS public.order_invoice_number_seq
1791
- START WITH 1
1792
- INCREMENT BY 1
1793
- NO MINVALUE
1794
- NO MAXVALUE
1795
- CACHE 1;
1796
-
1797
- CREATE TABLE IF NOT EXISTS public.order_items (
1798
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1799
- order_id uuid NOT NULL,
1800
- product_id uuid,
1801
- variant_id uuid,
1802
- quantity integer NOT NULL,
1803
- price_at_purchase integer NOT NULL
1804
- );
1805
-
1806
- CREATE TABLE IF NOT EXISTS public.orders (
1807
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1808
- user_id uuid,
1809
- status text DEFAULT 'pending'::text NOT NULL,
1810
- total integer NOT NULL,
1811
- stripe_session_id text,
1812
- payment_intent_id text,
1813
- customer_details jsonb,
1814
- provider text DEFAULT 'stripe'::text,
1815
- freemius_product_id text,
1816
- freemius_plan_id text,
1817
- freemius_license_id text,
1818
- freemius_subscription_id text,
1819
- freemius_trial_id text,
1820
- freemius_user_id text,
1821
- freemius_trial_ends_at timestamp with time zone,
1822
- freemius_last_event_type text,
1823
- freemius_last_synced_at timestamp with time zone,
1824
- currency text DEFAULT 'USD'::text NOT NULL,
1825
- subtotal integer,
1826
- shipping_total integer,
1827
- tax_total integer DEFAULT 0 NOT NULL,
1828
- tax_details jsonb,
1829
- exchange_rate_at_purchase numeric(20,10) DEFAULT 1 NOT NULL,
1830
- inventory_deducted_at timestamp with time zone,
1831
- invoice_number text,
1832
- paid_at timestamp with time zone,
1833
- created_at timestamp with time zone DEFAULT now(),
1834
- coupon_id uuid,
1835
- coupon_code text,
1836
- discount_total integer DEFAULT 0 NOT NULL,
1837
- discount_details jsonb,
1838
- CONSTRAINT orders_discount_total_check CHECK ((discount_total >= 0)),
1839
- CONSTRAINT orders_exchange_rate_at_purchase_positive CHECK ((exchange_rate_at_purchase > (0)::numeric)),
1840
- CONSTRAINT orders_provider_check CHECK ((provider = ANY (ARRAY['stripe'::text, 'freemius'::text]))),
1841
- CONSTRAINT orders_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'trial'::text, 'paid'::text, 'shipped'::text, 'cancelled'::text, 'refunded'::text])))
1842
- );
1843
-
1844
- COMMENT ON COLUMN public.orders.freemius_license_id IS 'Freemius license ID used to reconcile checkout callbacks and webhooks.';
1845
-
1846
- COMMENT ON COLUMN public.orders.freemius_subscription_id IS 'Freemius subscription ID when the order is associated with recurring billing.';
1847
-
1848
- COMMENT ON COLUMN public.orders.freemius_trial_id IS 'Freemius trial ID when checkout starts in trial mode.';
1849
-
1850
- COMMENT ON COLUMN public.orders.freemius_trial_ends_at IS 'Freemius trial expiration timestamp when supplied by checkout or webhook data.';
1851
-
1852
- COMMENT ON COLUMN public.orders.freemius_last_event_type IS 'Last Freemius checkout callback or webhook event applied to the order.';
1853
-
1854
- COMMENT ON COLUMN public.orders.freemius_last_synced_at IS 'Timestamp when Freemius metadata was last reconciled locally.';
1855
-
1856
- COMMENT ON COLUMN public.orders.currency IS 'ISO currency code used for the order totals.';
1857
-
1858
- COMMENT ON COLUMN public.orders.subtotal IS 'Subtotal before shipping and tax, in the smallest currency unit.';
1859
-
1860
- COMMENT ON COLUMN public.orders.shipping_total IS 'Shipping amount before tax, in the smallest currency unit.';
1861
-
1862
- COMMENT ON COLUMN public.orders.tax_total IS 'Total tax amount collected for the order, in the smallest currency unit.';
1863
-
1864
- COMMENT ON COLUMN public.orders.tax_details IS 'Normalized tax breakdown payload sourced from manual rates or finalized Stripe tax data.';
1865
-
1866
- COMMENT ON COLUMN public.orders.exchange_rate_at_purchase IS 'Exchange rate locked at purchase time relative to the store default currency.';
1867
-
1868
- COMMENT ON COLUMN public.orders.invoice_number IS 'Stable printable invoice number assigned once when the order first becomes paid.';
1869
-
1870
- COMMENT ON COLUMN public.orders.paid_at IS 'Timestamp when the order was first marked as paid.';
1871
-
1872
- COMMENT ON COLUMN public.orders.coupon_code IS 'Coupon code applied to the order at checkout time.';
1873
-
1874
- COMMENT ON COLUMN public.orders.discount_total IS 'Total discount applied to this order in the smallest currency unit.';
1875
-
1876
- COMMENT ON COLUMN public.orders.discount_details IS 'Provider-aware coupon quote details captured at checkout.';
1877
-
1878
- CREATE TABLE IF NOT EXISTS public.package_activations (
1879
- id uuid DEFAULT gen_random_uuid() NOT NULL,
1880
- license_key text NOT NULL,
1881
- instance_name text NOT NULL,
1882
- package_id text NOT NULL,
1883
- status text DEFAULT 'active'::text NOT NULL,
1884
- meta jsonb DEFAULT '{}'::jsonb,
1885
- last_validated_at timestamp with time zone DEFAULT now(),
1886
- created_at timestamp with time zone DEFAULT now()
1887
- );
1888
-
1889
- CREATE TABLE IF NOT EXISTS public.page_revisions (
1890
- id bigint NOT NULL,
1891
- page_id bigint NOT NULL,
1892
- author_id uuid,
1893
- version integer NOT NULL,
1894
- revision_type public.revision_type NOT NULL,
1895
- content jsonb NOT NULL,
1896
- created_at timestamp with time zone DEFAULT now() NOT NULL
1897
- );
1898
-
1899
- COMMENT ON TABLE public.page_revisions IS 'Hybrid (snapshot/diff) revisions for pages.';
1900
-
1901
- COMMENT ON COLUMN public.page_revisions.content IS 'If snapshot: full content; if diff: JSON Patch array.';
1902
-
1903
- DO $rb$ BEGIN
1904
- IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.page_revisions'::regclass AND attname = 'id' AND attidentity <> '') THEN
1905
- ALTER TABLE public.page_revisions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1906
- SEQUENCE NAME public.page_revisions_id_seq
1907
- START WITH 1
1908
- INCREMENT BY 1
1909
- NO MINVALUE
1910
- NO MAXVALUE
1911
- CACHE 1
1912
- );
1913
- END IF;
1914
- END $rb$;
1915
-
1916
- CREATE TABLE IF NOT EXISTS public.pages (
1917
- id bigint NOT NULL,
1918
- language_id bigint NOT NULL,
1919
- author_id uuid,
1920
- title text NOT NULL,
1921
- slug text NOT NULL,
1922
- status public.page_status DEFAULT 'draft'::public.page_status NOT NULL,
1923
- meta_title text,
1924
- meta_description text,
1925
- version integer DEFAULT 1 NOT NULL,
1926
- translation_group_id uuid DEFAULT gen_random_uuid() NOT NULL,
1927
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1928
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
1929
- feature_image_id uuid
1930
- );
1931
-
1932
- COMMENT ON TABLE public.pages IS 'Stores static pages for the website.';
1933
-
1934
- COMMENT ON COLUMN public.pages.slug IS 'URL-friendly identifier, unique per language.';
1935
-
1936
- COMMENT ON COLUMN public.pages.version IS 'Monotonic version number for hybrid revisions.';
1937
-
1938
- COMMENT ON COLUMN public.pages.translation_group_id IS 'Groups different language versions of the same conceptual page.';
1939
-
1940
- COMMENT ON COLUMN public.pages.feature_image_id IS 'ID of the media item to be used as the page feature image.';
1941
-
1942
- DO $rb$ BEGIN
1943
- IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.pages'::regclass AND attname = 'id' AND attidentity <> '') THEN
1944
- ALTER TABLE public.pages ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1945
- SEQUENCE NAME public.pages_id_seq
1946
- START WITH 1
1947
- INCREMENT BY 1
1948
- NO MINVALUE
1949
- NO MAXVALUE
1950
- CACHE 1
1951
- );
1952
- END IF;
1953
- END $rb$;
1954
-
1955
- CREATE TABLE IF NOT EXISTS public.post_revisions (
1956
- id bigint NOT NULL,
1957
- post_id bigint NOT NULL,
1958
- author_id uuid,
1959
- version integer NOT NULL,
1960
- revision_type public.revision_type NOT NULL,
1961
- content jsonb NOT NULL,
1962
- created_at timestamp with time zone DEFAULT now() NOT NULL
1963
- );
1964
-
1965
- COMMENT ON TABLE public.post_revisions IS 'Hybrid (snapshot/diff) revisions for posts.';
1966
-
1967
- COMMENT ON COLUMN public.post_revisions.content IS 'If snapshot: full content; if diff: JSON Patch array.';
1968
-
1969
- DO $rb$ BEGIN
1970
- IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.post_revisions'::regclass AND attname = 'id' AND attidentity <> '') THEN
1971
- ALTER TABLE public.post_revisions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1972
- SEQUENCE NAME public.post_revisions_id_seq
1973
- START WITH 1
1974
- INCREMENT BY 1
1975
- NO MINVALUE
1976
- NO MAXVALUE
1977
- CACHE 1
1978
- );
1979
- END IF;
1980
- END $rb$;
1981
-
1982
- CREATE TABLE IF NOT EXISTS public.posts (
1983
- id bigint NOT NULL,
1984
- language_id bigint NOT NULL,
1985
- author_id uuid,
1986
- title text NOT NULL,
1987
- slug text NOT NULL,
1988
- label text,
1989
- excerpt text,
1990
- subtitle text,
1991
- status public.page_status DEFAULT 'draft'::public.page_status NOT NULL,
1992
- published_at timestamp with time zone,
1993
- meta_title text,
1994
- meta_description text,
1995
- feature_image_id uuid,
1996
- version integer DEFAULT 1 NOT NULL,
1997
- translation_group_id uuid DEFAULT gen_random_uuid() NOT NULL,
1998
- created_at timestamp with time zone DEFAULT now() NOT NULL,
1999
- updated_at timestamp with time zone DEFAULT now() NOT NULL
2000
- );
2001
-
2002
- COMMENT ON TABLE public.posts IS 'Stores blog posts or news articles.';
2003
-
2004
- COMMENT ON COLUMN public.posts.slug IS 'URL-friendly identifier, unique per language.';
2005
-
2006
- COMMENT ON COLUMN public.posts.label IS 'Short editorial label rendered as a pill on post hero and article cards.';
2007
-
2008
- COMMENT ON COLUMN public.posts.excerpt IS 'Short editorial summary used in post metadata rows and post cards.';
2009
-
2010
- COMMENT ON COLUMN public.posts.subtitle IS 'Longer deck shown under the post title.';
2011
-
2012
- COMMENT ON COLUMN public.posts.feature_image_id IS 'ID of the media item to be used as the feature image.';
2013
-
2014
- COMMENT ON COLUMN public.posts.version IS 'Monotonic version number for hybrid revisions.';
2015
-
2016
- COMMENT ON COLUMN public.posts.translation_group_id IS 'Groups different language versions of the same conceptual post.';
2017
-
2018
- DO $rb$ BEGIN
2019
- IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.posts'::regclass AND attname = 'id' AND attidentity <> '') THEN
2020
- ALTER TABLE public.posts ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
2021
- SEQUENCE NAME public.posts_id_seq
2022
- START WITH 1
2023
- INCREMENT BY 1
2024
- NO MINVALUE
2025
- NO MAXVALUE
2026
- CACHE 1
2027
- );
2028
- END IF;
2029
- END $rb$;
2030
-
2031
- CREATE TABLE IF NOT EXISTS public.privacy_consent_logs (
2032
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2033
- consent_token text NOT NULL,
2034
- categories jsonb DEFAULT '{"analytics": false, "marketing": false, "necessary": true}'::jsonb NOT NULL,
2035
- ip_masked text,
2036
- user_agent text,
2037
- created_at timestamp with time zone DEFAULT now() NOT NULL,
2038
- CONSTRAINT privacy_consent_logs_categories_check CHECK ((jsonb_typeof(categories) = 'object'::text))
2039
- );
2040
-
2041
- COMMENT ON TABLE public.privacy_consent_logs IS 'Immutable audit log of visitor consent decisions for Quebec Law 25 / PIPEDA accountability.';
2042
-
2043
- COMMENT ON COLUMN public.privacy_consent_logs.consent_token IS 'Opaque token also stored in the nb_consent_preference cookie to correlate a decision with its record.';
2044
-
2045
- COMMENT ON COLUMN public.privacy_consent_logs.ip_masked IS 'Partially masked IP (e.g. 203.0.113.x) - never store a full address for an analytics/marketing consent log.';
2046
-
2047
- CREATE TABLE IF NOT EXISTS public.product_attribute_terms (
2048
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2049
- attribute_id uuid NOT NULL,
2050
- value text NOT NULL,
2051
- slug text NOT NULL,
2052
- sort_order integer DEFAULT 0 NOT NULL,
2053
- value_translations jsonb DEFAULT '{}'::jsonb NOT NULL,
2054
- created_at timestamp with time zone DEFAULT now(),
2055
- updated_at timestamp with time zone DEFAULT now()
2056
- );
2057
-
2058
- CREATE TABLE IF NOT EXISTS public.product_attributes (
2059
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2060
- name text NOT NULL,
2061
- slug text NOT NULL,
2062
- name_translations jsonb DEFAULT '{}'::jsonb NOT NULL,
2063
- created_at timestamp with time zone DEFAULT now(),
2064
- updated_at timestamp with time zone DEFAULT now()
2065
- );
2066
-
2067
- CREATE TABLE IF NOT EXISTS public.product_categories (
2068
- product_id uuid NOT NULL,
2069
- category_id uuid NOT NULL
2070
- );
2071
-
2072
- COMMENT ON TABLE public.product_categories IS 'Junction table mapping products to multiple categories.';
2073
-
2074
- CREATE TABLE IF NOT EXISTS public.product_drafts (
2075
- id bigint NOT NULL,
2076
- product_id uuid NOT NULL,
2077
- author_id uuid,
2078
- meta jsonb DEFAULT '{}'::jsonb NOT NULL,
2079
- created_at timestamp with time zone DEFAULT now() NOT NULL,
2080
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
2081
- blocks jsonb DEFAULT '[]'::jsonb NOT NULL,
2082
- CONSTRAINT product_drafts_blocks_array CHECK ((jsonb_typeof(blocks) = 'array'::text)),
2083
- CONSTRAINT product_drafts_meta_object CHECK ((jsonb_typeof(meta) = 'object'::text))
2084
- );
2085
-
2086
- COMMENT ON TABLE public.product_drafts IS 'Draft product metadata snapshots used by Draft Mode and front-end visual editing before publishing to live product rows.';
2087
-
2088
- COMMENT ON COLUMN public.product_drafts.product_id IS 'ID of the product being drafted.';
2089
-
2090
- COMMENT ON COLUMN public.product_drafts.meta IS 'Draft product metadata snapshot. Front-end visual editing currently mutates visible title, short_description, and description_json fields.';
2091
-
2092
- DO $rb$ BEGIN
2093
- IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.product_drafts'::regclass AND attname = 'id' AND attidentity <> '') THEN
2094
- ALTER TABLE public.product_drafts ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
2095
- SEQUENCE NAME public.product_drafts_id_seq
2096
- START WITH 1
2097
- INCREMENT BY 1
2098
- NO MINVALUE
2099
- NO MAXVALUE
2100
- CACHE 1
2101
- );
2102
- END IF;
2103
- END $rb$;
2104
-
2105
- CREATE TABLE IF NOT EXISTS public.product_freemius_sale_coupons (
2106
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2107
- product_id uuid NOT NULL,
2108
- freemius_product_id text NOT NULL,
2109
- freemius_plan_id text,
2110
- freemius_coupon_id text,
2111
- freemius_coupon_code text NOT NULL,
2112
- discount_percent integer,
2113
- starts_at timestamp with time zone,
2114
- ends_at timestamp with time zone,
2115
- is_active boolean DEFAULT false NOT NULL,
2116
- sync_status text DEFAULT 'pending'::text NOT NULL,
2117
- sync_error text,
2118
- remote_payload jsonb,
2119
- last_synced_at timestamp with time zone,
2120
- created_at timestamp with time zone DEFAULT now() NOT NULL,
2121
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
2122
- CONSTRAINT product_freemius_sale_coupons_code_not_blank CHECK ((char_length(btrim(freemius_coupon_code)) > 0)),
2123
- CONSTRAINT product_freemius_sale_coupons_discount_valid CHECK (((discount_percent IS NULL) OR ((discount_percent > 0) AND (discount_percent <= 100)))),
2124
- CONSTRAINT product_freemius_sale_coupons_fm_product_not_blank CHECK ((char_length(btrim(freemius_product_id)) > 0)),
2125
- CONSTRAINT product_freemius_sale_coupons_sync_status_valid CHECK ((sync_status = ANY (ARRAY['pending'::text, 'synced'::text, 'failed'::text, 'deleted'::text]))),
2126
- CONSTRAINT product_freemius_sale_coupons_window_valid CHECK (((starts_at IS NULL) OR (ends_at IS NULL) OR (starts_at < ends_at)))
2127
- );
2128
-
2129
- 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
-
2131
- CREATE TABLE IF NOT EXISTS public.product_media (
2132
- product_id uuid NOT NULL,
2133
- media_id uuid NOT NULL,
2134
- sort_order integer DEFAULT 0
2135
- );
2136
-
2137
- CREATE TABLE IF NOT EXISTS public.product_variants (
2138
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2139
- product_id uuid NOT NULL,
2140
- sku text NOT NULL,
2141
- price_adjustment integer DEFAULT 0 NOT NULL,
2142
- price integer DEFAULT 0 NOT NULL,
2143
- prices jsonb DEFAULT '{}'::jsonb NOT NULL,
2144
- sale_price integer,
2145
- sale_prices jsonb,
2146
- stock_quantity integer DEFAULT 0 NOT NULL,
2147
- upc text,
2148
- main_media_id uuid,
2149
- created_at timestamp with time zone DEFAULT now(),
2150
- updated_at timestamp with time zone DEFAULT now(),
2151
- sale_start_at timestamp with time zone,
2152
- sale_end_at timestamp with time zone,
2153
- scheduled_price integer,
2154
- scheduled_prices jsonb,
2155
- scheduled_price_at timestamp with time zone,
2156
- CONSTRAINT product_variants_prices_is_valid CHECK (public.is_valid_currency_amount_map(prices)),
2157
- CONSTRAINT product_variants_sale_prices_are_valid CHECK (public.is_valid_sale_price_map(prices, sale_prices)),
2158
- CONSTRAINT product_variants_sale_window_valid CHECK (((sale_start_at IS NULL) OR (sale_end_at IS NULL) OR (sale_start_at < sale_end_at)))
2159
- );
2160
-
2161
- COMMENT ON COLUMN public.product_variants.prices IS 'Variant regular prices by ISO 4217 code in the smallest currency unit.';
2162
-
2163
- COMMENT ON COLUMN public.product_variants.sale_prices IS 'Variant sale prices by ISO 4217 code in the smallest currency unit.';
2164
-
2165
- COMMENT ON COLUMN public.product_variants.sale_start_at IS 'Inclusive start of the scheduled sale window (UTC) for this variant. NULL means no lower bound.';
2166
-
2167
- COMMENT ON COLUMN public.product_variants.sale_end_at IS 'Exclusive end of the scheduled sale window (UTC) for this variant. NULL means no upper bound.';
2168
-
2169
- COMMENT ON COLUMN public.product_variants.scheduled_price IS 'Pending regular price (smallest currency unit) applied once scheduled_price_at has passed.';
2170
-
2171
- COMMENT ON COLUMN public.product_variants.scheduled_prices IS 'Pending multi-currency regular prices by ISO 4217 code, applied once scheduled_price_at has passed.';
2172
-
2173
- COMMENT ON COLUMN public.product_variants.scheduled_price_at IS 'Effective timestamp (UTC) for the pending regular-price change.';
2174
-
2175
- CREATE TABLE IF NOT EXISTS public.products (
2176
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2177
- language_id bigint NOT NULL,
2178
- translation_group_id uuid DEFAULT gen_random_uuid() NOT NULL,
2179
- sku text NOT NULL,
2180
- title text NOT NULL,
2181
- slug text NOT NULL,
2182
- product_type text NOT NULL,
2183
- payment_provider text NOT NULL,
2184
- price integer NOT NULL,
2185
- prices jsonb DEFAULT '{}'::jsonb NOT NULL,
2186
- sale_price integer,
2187
- sale_prices jsonb,
2188
- stock integer DEFAULT 0,
2189
- status text DEFAULT 'draft'::text NOT NULL,
2190
- meta_title text,
2191
- meta_description text,
2192
- short_description text,
2193
- description_json jsonb,
2194
- metadata jsonb,
2195
- freemius_plan_id text,
2196
- freemius_product_id text,
2197
- trial_period_days integer DEFAULT 0 NOT NULL,
2198
- trial_requires_payment_method boolean DEFAULT false NOT NULL,
2199
- upc text,
2200
- is_taxable boolean DEFAULT true NOT NULL,
2201
- created_at timestamp with time zone DEFAULT now(),
2202
- updated_at timestamp with time zone DEFAULT now(),
2203
- sale_start_at timestamp with time zone,
2204
- sale_end_at timestamp with time zone,
2205
- scheduled_price integer,
2206
- scheduled_prices jsonb,
2207
- scheduled_price_at timestamp with time zone,
2208
- average_rating numeric(3,2) DEFAULT 0.00 NOT NULL,
2209
- total_reviews integer DEFAULT 0 NOT NULL,
2210
- CONSTRAINT products_payment_provider_check CHECK ((payment_provider = ANY (ARRAY['stripe'::text, 'freemius'::text]))),
2211
- CONSTRAINT products_prices_is_valid CHECK (public.is_valid_currency_amount_map(prices)),
2212
- CONSTRAINT products_product_type_check CHECK ((product_type = ANY (ARRAY['physical'::text, 'digital'::text]))),
2213
- CONSTRAINT products_sale_prices_are_valid CHECK (public.is_valid_sale_price_map(prices, sale_prices)),
2214
- CONSTRAINT products_sale_window_valid CHECK (((sale_start_at IS NULL) OR (sale_end_at IS NULL) OR (sale_start_at < sale_end_at))),
2215
- CONSTRAINT products_status_check CHECK ((status = ANY (ARRAY['draft'::text, 'active'::text, 'archived'::text]))),
2216
- CONSTRAINT products_trial_period_days_check CHECK ((trial_period_days >= 0)),
2217
- CONSTRAINT products_type_provider_consistency_check CHECK ((((product_type = 'physical'::text) AND (payment_provider = 'stripe'::text)) OR ((product_type = 'digital'::text) AND (payment_provider = 'freemius'::text))))
2218
- );
2219
-
2220
- COMMENT ON COLUMN public.products.prices IS 'Regular prices by ISO 4217 code in the smallest currency unit.';
2221
-
2222
- COMMENT ON COLUMN public.products.sale_prices IS 'Sale prices by ISO 4217 code in the smallest currency unit.';
2223
-
2224
- COMMENT ON COLUMN public.products.is_taxable IS 'When true, this product participates in Stripe tax calculation.';
2225
-
2226
- COMMENT ON COLUMN public.products.sale_start_at IS 'Inclusive start of the scheduled sale window (UTC). NULL means no lower bound.';
2227
-
2228
- COMMENT ON COLUMN public.products.sale_end_at IS 'Exclusive end of the scheduled sale window (UTC). NULL means no upper bound. Both NULL = always-on sale.';
2229
-
2230
- COMMENT ON COLUMN public.products.scheduled_price IS 'Pending regular price (smallest currency unit) applied once scheduled_price_at has passed.';
2231
-
2232
- COMMENT ON COLUMN public.products.scheduled_prices IS 'Pending multi-currency regular prices by ISO 4217 code, applied once scheduled_price_at has passed.';
2233
-
2234
- COMMENT ON COLUMN public.products.scheduled_price_at IS 'Effective timestamp (UTC) for the pending regular-price change.';
2235
-
2236
- COMMENT ON COLUMN public.products.average_rating IS 'Aggregated average 1-5 rating of approved reviews.';
2237
-
2238
- COMMENT ON COLUMN public.products.total_reviews IS 'Total count of approved reviews for this product.';
2239
-
2240
- CREATE TABLE IF NOT EXISTS public.profiles (
2241
- id uuid NOT NULL,
2242
- updated_at timestamp with time zone,
2243
- full_name text,
2244
- avatar_url text,
2245
- website text,
2246
- github_username text,
2247
- phone text,
2248
- role public.user_role DEFAULT 'USER'::public.user_role NOT NULL
2249
- );
2250
-
2251
- COMMENT ON TABLE public.profiles IS 'Profile information for each user, extending auth.users.';
2252
-
2253
- COMMENT ON COLUMN public.profiles.id IS 'References auth.users.id';
2254
-
2255
- COMMENT ON COLUMN public.profiles.role IS 'User role for RBAC.';
2256
-
2257
- CREATE TABLE IF NOT EXISTS public.shipping_zone_locations (
2258
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2259
- zone_id uuid NOT NULL,
2260
- country_code text NOT NULL,
2261
- state_code text,
2262
- postal_code text,
2263
- created_at timestamp with time zone DEFAULT now()
2264
- );
2265
-
2266
- COMMENT ON COLUMN public.shipping_zone_locations.country_code IS 'ISO 3166-1 alpha-2 country code.';
2267
-
2268
- COMMENT ON COLUMN public.shipping_zone_locations.state_code IS 'Optional state/province code within the selected country (for example CA, NY, ON, QC). NULL means the whole country.';
2269
-
2270
- COMMENT ON COLUMN public.shipping_zone_locations.postal_code IS 'Optional exact postal code or wildcard pattern. NULL means all postal codes in the matched country/state.';
2271
-
2272
- CREATE TABLE IF NOT EXISTS public.shipping_zone_methods (
2273
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2274
- zone_id uuid NOT NULL,
2275
- method_type text NOT NULL,
2276
- cost_amount integer DEFAULT 0 NOT NULL,
2277
- cost_currency text DEFAULT 'USD'::text NOT NULL,
2278
- min_order_amount integer DEFAULT 0 NOT NULL,
2279
- name text NOT NULL,
2280
- name_translations jsonb DEFAULT '{}'::jsonb NOT NULL,
2281
- currency_pricing_mode text DEFAULT 'auto'::text NOT NULL,
2282
- cost_amounts jsonb DEFAULT '{}'::jsonb NOT NULL,
2283
- min_order_amounts jsonb DEFAULT '{}'::jsonb NOT NULL,
2284
- created_at timestamp with time zone DEFAULT now(),
2285
- updated_at timestamp with time zone DEFAULT now(),
2286
- CONSTRAINT shipping_zone_methods_cost_amounts_include_source CHECK ((cost_amounts ? upper(cost_currency))),
2287
- CONSTRAINT shipping_zone_methods_cost_amounts_valid CHECK (public.is_valid_currency_amount_map(cost_amounts)),
2288
- CONSTRAINT shipping_zone_methods_cost_currency_format CHECK ((cost_currency ~ '^[A-Z]{3}$'::text)),
2289
- CONSTRAINT shipping_zone_methods_currency_pricing_mode_valid CHECK ((currency_pricing_mode = ANY (ARRAY['auto'::text, 'manual'::text]))),
2290
- CONSTRAINT shipping_zone_methods_method_type_check CHECK ((method_type = ANY (ARRAY['flat_rate'::text, 'free_shipping'::text]))),
2291
- CONSTRAINT shipping_zone_methods_min_order_amounts_include_source CHECK ((min_order_amounts ? upper(cost_currency))),
2292
- CONSTRAINT shipping_zone_methods_min_order_amounts_valid CHECK (public.is_valid_currency_amount_map(min_order_amounts))
2293
- );
2294
-
2295
- COMMENT ON COLUMN public.shipping_zone_methods.name_translations IS 'Localized shipping method labels keyed by language code. Example: {"fr": "Livraison standard"}.';
2296
-
2297
- COMMENT ON COLUMN public.shipping_zone_methods.currency_pricing_mode IS 'Whether this rate uses auto FX conversion from a single source currency or exact manual amounts per currency.';
2298
-
2299
- COMMENT ON COLUMN public.shipping_zone_methods.cost_amounts IS 'Shipping costs by ISO 4217 code in the smallest currency unit.';
2300
-
2301
- COMMENT ON COLUMN public.shipping_zone_methods.min_order_amounts IS 'Minimum order thresholds by ISO 4217 code in the smallest currency unit.';
2302
-
2303
- CREATE TABLE IF NOT EXISTS public.shipping_zones (
2304
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2305
- name text NOT NULL,
2306
- priority_order integer DEFAULT 0 NOT NULL,
2307
- created_at timestamp with time zone DEFAULT now(),
2308
- updated_at timestamp with time zone DEFAULT now()
2309
- );
2310
-
2311
- CREATE TABLE IF NOT EXISTS public.site_settings (
2312
- key text NOT NULL,
2313
- value jsonb
2314
- );
2315
-
2316
- 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
-
2318
- CREATE TABLE IF NOT EXISTS public.system_alerts (
2319
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2320
- alert_type text NOT NULL,
2321
- title text NOT NULL,
2322
- message text NOT NULL,
2323
- metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
2324
- is_resolved boolean DEFAULT false NOT NULL,
2325
- resolved_at timestamp with time zone,
2326
- created_at timestamp with time zone DEFAULT now() NOT NULL,
2327
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
2328
- CONSTRAINT system_alerts_alert_type_check CHECK ((alert_type = ANY (ARRAY['merge_conflict'::text, 'runtime_update_available'::text])))
2329
- );
2330
-
2331
- COMMENT ON TABLE public.system_alerts IS 'System notifications for the automated upstream-update architecture (merge conflicts, runtime updates available). Written by service-role; read by ADMINs.';
2332
-
2333
- COMMENT ON COLUMN public.system_alerts.alert_type IS 'One of: merge_conflict, runtime_update_available.';
2334
-
2335
- COMMENT ON COLUMN public.system_alerts.metadata IS 'Structured deep-link context for the dashboard banner CTA (repo/branch/action_url or latest_version/download_url).';
2336
-
2337
- COMMENT ON COLUMN public.system_alerts.is_resolved IS 'When true the alert is hidden from the dashboard banner.';
2338
-
2339
- CREATE TABLE IF NOT EXISTS public.system_configuration (
2340
- id integer DEFAULT 1 NOT NULL,
2341
- auto_accept_signups boolean DEFAULT false NOT NULL,
2342
- settings jsonb DEFAULT '{}'::jsonb NOT NULL,
2343
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
2344
- CONSTRAINT system_configuration_singleton CHECK ((id = 1))
2345
- );
2346
-
2347
- COMMENT ON TABLE public.system_configuration IS 'Singleton (id = 1) of global setup-wizard configuration. ADMIN-only via RLS; never store secrets in settings.';
2348
-
2349
- CREATE TABLE IF NOT EXISTS public.tax_rates (
2350
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2351
- country_code text NOT NULL,
2352
- state_code text,
2353
- tax_name text NOT NULL,
2354
- tax_rate numeric(7,4) NOT NULL,
2355
- created_at timestamp with time zone DEFAULT now() NOT NULL,
2356
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
2357
- CONSTRAINT tax_rates_tax_name_check CHECK ((char_length(btrim(tax_name)) > 0)),
2358
- CONSTRAINT tax_rates_tax_rate_check CHECK (((tax_rate >= (0)::numeric) AND (tax_rate <= (100)::numeric)))
2359
- );
2360
-
2361
- COMMENT ON TABLE public.tax_rates IS 'Manual tax rates used for Stripe storefront orders. Multiple rows can exist per jurisdiction to support combined taxes such as GST + PST.';
2362
-
2363
- COMMENT ON COLUMN public.tax_rates.country_code IS 'ISO 3166-1 alpha-2 country code.';
2364
-
2365
- COMMENT ON COLUMN public.tax_rates.state_code IS 'Optional state/province code within country_code. NULL represents a country-wide or federal tax.';
2366
-
2367
- COMMENT ON COLUMN public.tax_rates.tax_name IS 'Display name for the tax component, for example GST, PST, HST, or State Sales Tax.';
2368
-
2369
- COMMENT ON COLUMN public.tax_rates.tax_rate IS 'Percent value, not decimal fraction. Example: 5.0000 means 5%.';
2370
-
2371
- CREATE TABLE IF NOT EXISTS public.translations (
2372
- key text NOT NULL,
2373
- translations jsonb NOT NULL,
2374
- created_at timestamp with time zone DEFAULT now() NOT NULL,
2375
- updated_at timestamp with time zone DEFAULT now() NOT NULL
2376
- );
2377
-
2378
- COMMENT ON COLUMN public.translations.key IS 'A unique, slugified identifier (e.g., "sign_in_button_text").';
2379
-
2380
- COMMENT ON COLUMN public.translations.translations IS 'Stores translations as key-value pairs (e.g., {"en": "Sign In", "fr": "S''inscrire"}).';
2381
-
2382
- CREATE TABLE IF NOT EXISTS public.ucp_cart_sessions (
2383
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2384
- status text DEFAULT 'active'::text NOT NULL,
2385
- currency text DEFAULT 'USD'::text NOT NULL,
2386
- locale text,
2387
- buyer_identity jsonb DEFAULT '{}'::jsonb NOT NULL,
2388
- context jsonb DEFAULT '{}'::jsonb NOT NULL,
2389
- signals jsonb DEFAULT '{}'::jsonb NOT NULL,
2390
- attribution jsonb DEFAULT '{}'::jsonb NOT NULL,
2391
- line_items jsonb DEFAULT '[]'::jsonb NOT NULL,
2392
- totals jsonb DEFAULT '[]'::jsonb NOT NULL,
2393
- checkout_url text,
2394
- metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
2395
- created_at timestamp with time zone DEFAULT now() NOT NULL,
2396
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
2397
- expires_at timestamp with time zone DEFAULT (now() + '7 days'::interval) NOT NULL,
2398
- CONSTRAINT ucp_cart_sessions_attribution_object CHECK ((jsonb_typeof(attribution) = 'object'::text)),
2399
- CONSTRAINT ucp_cart_sessions_buyer_identity_object CHECK ((jsonb_typeof(buyer_identity) = 'object'::text)),
2400
- CONSTRAINT ucp_cart_sessions_context_object CHECK ((jsonb_typeof(context) = 'object'::text)),
2401
- CONSTRAINT ucp_cart_sessions_line_items_array CHECK ((jsonb_typeof(line_items) = 'array'::text)),
2402
- CONSTRAINT ucp_cart_sessions_metadata_object CHECK ((jsonb_typeof(metadata) = 'object'::text)),
2403
- CONSTRAINT ucp_cart_sessions_signals_object CHECK ((jsonb_typeof(signals) = 'object'::text)),
2404
- CONSTRAINT ucp_cart_sessions_status_check CHECK ((status = ANY (ARRAY['active'::text, 'cancelled'::text, 'completed'::text]))),
2405
- CONSTRAINT ucp_cart_sessions_totals_array CHECK ((jsonb_typeof(totals) = 'array'::text))
2406
- );
2407
-
2408
- CREATE TABLE IF NOT EXISTS public.user_addresses (
2409
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2410
- user_id uuid NOT NULL,
2411
- address_type text NOT NULL,
2412
- is_default boolean DEFAULT false NOT NULL,
2413
- recipient_name text,
2414
- company_name text,
2415
- line1 text,
2416
- line2 text,
2417
- city text,
2418
- state text,
2419
- postal_code text,
2420
- country_code text,
2421
- created_at timestamp with time zone DEFAULT now() NOT NULL,
2422
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
2423
- CONSTRAINT user_addresses_address_type_check CHECK ((address_type = ANY (ARRAY['billing'::text, 'shipping'::text])))
2424
- );
2425
-
2426
- COMMENT ON COLUMN public.user_addresses.company_name IS 'Optional company or organization name for the address.';
2427
-
2428
- CREATE TABLE IF NOT EXISTS public.user_security_settings (
2429
- user_id uuid NOT NULL,
2430
- mfa_enabled boolean DEFAULT false NOT NULL,
2431
- mfa_type text,
2432
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
2433
- CONSTRAINT user_security_settings_mfa_type_check CHECK ((mfa_type = ANY (ARRAY['totp'::text, 'email'::text])))
2434
- );
2435
-
2436
- COMMENT ON TABLE public.user_security_settings IS 'Per-user multi-factor configuration. mfa_type is NULL until a factor is enrolled.';
2437
-
2438
- CREATE TABLE IF NOT EXISTS public.user_trusted_devices (
2439
- id uuid DEFAULT gen_random_uuid() NOT NULL,
2440
- user_id uuid NOT NULL,
2441
- device_hash text NOT NULL,
2442
- browser_metadata text,
2443
- expires_at timestamp with time zone NOT NULL,
2444
- created_at timestamp with time zone DEFAULT now() NOT NULL
2445
- );
2446
-
2447
- COMMENT ON TABLE public.user_trusted_devices IS 'SHA-256 hashes of trusted-device tokens. A 2FA bypass is only honoured when a non-expired row matches the cookie token, so deleting a row instantly revokes trust.';
2448
-
2449
- COMMENT ON COLUMN public.user_trusted_devices.device_hash IS 'SHA-256 of the raw token held in the nb_trusted_device cookie. The raw token is never stored.';
2450
-
2451
- CREATE TABLE IF NOT EXISTS public.variant_attribute_mapping (
2452
- variant_id uuid NOT NULL,
2453
- attribute_term_id uuid NOT NULL
2454
- );
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.
4
+
5
+ SET check_function_bodies = false;
6
+
7
+ CREATE SCHEMA IF NOT EXISTS public;
8
+
9
+ COMMENT ON SCHEMA public IS 'standard public schema';
10
+
11
+ DO $rb$ BEGIN
12
+ CREATE TYPE public.approval_status AS ENUM (
13
+ 'pending',
14
+ 'approved',
15
+ 'denied'
16
+ );
17
+ EXCEPTION WHEN duplicate_object THEN null; END $rb$;
18
+
19
+ DO $rb$ BEGIN
20
+ CREATE TYPE public.interaction_type AS ENUM (
21
+ 'review',
22
+ 'comment'
23
+ );
24
+ EXCEPTION WHEN duplicate_object THEN null; END $rb$;
25
+
26
+ DO $rb$ BEGIN
27
+ CREATE TYPE public.menu_location AS ENUM (
28
+ 'HEADER',
29
+ 'FOOTER',
30
+ 'SIDEBAR'
31
+ );
32
+ EXCEPTION WHEN duplicate_object THEN null; END $rb$;
33
+
34
+ DO $rb$ BEGIN
35
+ CREATE TYPE public.page_status AS ENUM (
36
+ 'draft',
37
+ 'published',
38
+ 'archived'
39
+ );
40
+ EXCEPTION WHEN duplicate_object THEN null; END $rb$;
41
+
42
+ DO $rb$ BEGIN
43
+ CREATE TYPE public.revision_type AS ENUM (
44
+ 'snapshot',
45
+ 'diff'
46
+ );
47
+ EXCEPTION WHEN duplicate_object THEN null; END $rb$;
48
+
49
+ DO $rb$ BEGIN
50
+ CREATE TYPE public.user_role AS ENUM (
51
+ 'ADMIN',
52
+ 'WRITER',
53
+ 'USER'
54
+ );
55
+ EXCEPTION WHEN duplicate_object THEN null; END $rb$;
56
+
57
+ CREATE OR REPLACE FUNCTION public.apply_order_inventory_deduction(p_order_id uuid) RETURNS void
58
+ LANGUAGE plpgsql
59
+ SET search_path TO 'public'
60
+ AS $$
61
+ DECLARE
62
+ v_track_quantities boolean := public.get_ecommerce_track_quantities();
63
+ v_item record;
64
+ v_inventory_deducted_at timestamptz;
65
+ v_sku text;
66
+ v_current_quantity integer;
67
+ BEGIN
68
+ SELECT inventory_deducted_at
69
+ INTO v_inventory_deducted_at
70
+ FROM public.orders
71
+ WHERE id = p_order_id
72
+ FOR UPDATE;
73
+
74
+ IF NOT FOUND OR v_inventory_deducted_at IS NOT NULL THEN
75
+ RETURN;
76
+ END IF;
77
+
78
+ IF NOT v_track_quantities THEN
79
+ UPDATE public.orders
80
+ SET inventory_deducted_at = now()
81
+ WHERE id = p_order_id;
82
+
83
+ RETURN;
84
+ END IF;
85
+
86
+ FOR v_item IN
87
+ SELECT
88
+ product_id,
89
+ variant_id,
90
+ SUM(quantity)::integer AS quantity
91
+ FROM public.order_items
92
+ WHERE order_id = p_order_id
93
+ GROUP BY product_id, variant_id
94
+ LOOP
95
+ v_sku := NULL;
96
+ v_current_quantity := 0;
97
+
98
+ IF v_item.variant_id IS NOT NULL THEN
99
+ SELECT
100
+ sku,
101
+ GREATEST(COALESCE(stock_quantity, 0), 0)
102
+ INTO v_sku,
103
+ v_current_quantity
104
+ FROM public.product_variants
105
+ WHERE id = v_item.variant_id
106
+ LIMIT 1;
107
+ ELSIF v_item.product_id IS NOT NULL THEN
108
+ SELECT
109
+ sku,
110
+ GREATEST(COALESCE(stock, 0), 0)
111
+ INTO v_sku,
112
+ v_current_quantity
113
+ FROM public.products
114
+ WHERE id = v_item.product_id
115
+ LIMIT 1;
116
+ END IF;
117
+
118
+ IF NULLIF(trim(v_sku), '') IS NULL THEN
119
+ CONTINUE;
120
+ END IF;
121
+
122
+ INSERT INTO public.inventory_items (sku, quantity)
123
+ VALUES (v_sku, v_current_quantity)
124
+ ON CONFLICT (sku) DO NOTHING;
125
+
126
+ UPDATE public.inventory_items
127
+ SET
128
+ quantity = GREATEST(COALESCE(quantity, 0) - v_item.quantity, 0),
129
+ updated_at = now()
130
+ WHERE sku = v_sku;
131
+ END LOOP;
132
+
133
+ UPDATE public.orders
134
+ SET inventory_deducted_at = now()
135
+ WHERE id = p_order_id;
136
+ END;
137
+ $$;
138
+
139
+ CREATE OR REPLACE FUNCTION public.assign_order_invoice_metadata(p_order_id uuid, p_paid_at timestamp with time zone DEFAULT now()) RETURNS TABLE(invoice_number text, paid_at timestamp with time zone)
140
+ LANGUAGE plpgsql
141
+ SET search_path TO 'public'
142
+ AS $$
143
+ DECLARE
144
+ v_order public.orders%ROWTYPE;
145
+ v_effective_paid_at timestamptz;
146
+ BEGIN
147
+ SELECT *
148
+ INTO v_order
149
+ FROM public.orders
150
+ WHERE id = p_order_id
151
+ FOR UPDATE;
152
+
153
+ IF NOT FOUND THEN
154
+ RAISE EXCEPTION 'Order % not found', p_order_id;
155
+ END IF;
156
+
157
+ v_effective_paid_at := COALESCE(v_order.paid_at, p_paid_at, now(), v_order.created_at);
158
+
159
+ UPDATE public.orders
160
+ SET
161
+ invoice_number = COALESCE(v_order.invoice_number, public.generate_order_invoice_number()),
162
+ paid_at = v_effective_paid_at
163
+ WHERE id = p_order_id
164
+ RETURNING orders.invoice_number, orders.paid_at
165
+ INTO invoice_number, paid_at;
166
+
167
+ RETURN NEXT;
168
+ END;
169
+ $$;
170
+
171
+ CREATE OR REPLACE FUNCTION public.clear_currency_price_overrides(target_currency text) RETURNS void
172
+ LANGUAGE plpgsql
173
+ SET search_path TO ''
174
+ AS $$
175
+ DECLARE
176
+ v_target_currency text := upper(trim(target_currency));
177
+ BEGIN
178
+ IF v_target_currency = '' THEN
179
+ RETURN;
180
+ END IF;
181
+
182
+ UPDATE public.products
183
+ SET
184
+ prices = COALESCE(prices, '{}'::jsonb) - v_target_currency,
185
+ sale_prices = CASE
186
+ WHEN sale_prices IS NULL THEN NULL
187
+ WHEN sale_prices - v_target_currency = '{}'::jsonb THEN NULL
188
+ ELSE sale_prices - v_target_currency
189
+ END,
190
+ updated_at = now()
191
+ WHERE COALESCE(prices, '{}'::jsonb) ? v_target_currency
192
+ OR COALESCE(sale_prices, '{}'::jsonb) ? v_target_currency;
193
+
194
+ UPDATE public.product_variants
195
+ SET
196
+ prices = COALESCE(prices, '{}'::jsonb) - v_target_currency,
197
+ sale_prices = CASE
198
+ WHEN sale_prices IS NULL THEN NULL
199
+ WHEN sale_prices - v_target_currency = '{}'::jsonb THEN NULL
200
+ ELSE sale_prices - v_target_currency
201
+ END,
202
+ updated_at = now()
203
+ WHERE COALESCE(prices, '{}'::jsonb) ? v_target_currency
204
+ OR COALESCE(sale_prices, '{}'::jsonb) ? v_target_currency;
205
+ END;
206
+ $$;
207
+
208
+ CREATE OR REPLACE FUNCTION public.is_valid_custom_block_fields(candidate jsonb) RETURNS boolean
209
+ LANGUAGE sql IMMUTABLE
210
+ SET search_path TO ''
211
+ AS $_$
212
+ SELECT CASE
213
+ WHEN jsonb_typeof(candidate) <> 'array' THEN false
214
+ ELSE
215
+ NOT EXISTS (
216
+ SELECT 1
217
+ FROM jsonb_array_elements(candidate) AS field(value)
218
+ WHERE jsonb_typeof(field.value) <> 'object'
219
+ OR jsonb_typeof(field.value -> 'key') IS DISTINCT FROM 'string'
220
+ OR jsonb_typeof(field.value -> 'label') IS DISTINCT FROM 'string'
221
+ OR jsonb_typeof(field.value -> 'type') IS DISTINCT FROM 'string'
222
+ OR field.value ->> 'key' !~ '^[a-z][a-z0-9_]*$'
223
+ OR field.value ->> 'type' NOT IN ('text', 'rich-text', 'image_r2', 'db_relation')
224
+ )
225
+ AND (
226
+ SELECT COUNT(*) = COUNT(DISTINCT field.value ->> 'key')
227
+ FROM jsonb_array_elements(candidate) AS field(value)
228
+ )
229
+ END;
230
+ $_$;
231
+
232
+ CREATE OR REPLACE FUNCTION public.is_valid_custom_block_layout_schema(candidate jsonb) RETURNS boolean
233
+ LANGUAGE sql IMMUTABLE
234
+ SET search_path TO ''
235
+ AS $$
236
+ SELECT CASE
237
+ WHEN jsonb_typeof(candidate) <> 'object' THEN false
238
+ ELSE candidate ->> 'type' IN ('container', 'field_render')
239
+ END;
240
+ $$;
241
+
242
+ CREATE TABLE IF NOT EXISTS public.custom_block_definitions (
243
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
244
+ slug text NOT NULL,
245
+ name text NOT NULL,
246
+ description text DEFAULT ''::text NOT NULL,
247
+ fields jsonb DEFAULT '[]'::jsonb NOT NULL,
248
+ layout_schema jsonb NOT NULL,
249
+ is_original boolean DEFAULT true NOT NULL,
250
+ CONSTRAINT custom_block_definitions_fields_check CHECK (public.is_valid_custom_block_fields(fields)),
251
+ CONSTRAINT custom_block_definitions_layout_schema_check CHECK (public.is_valid_custom_block_layout_schema(layout_schema)),
252
+ CONSTRAINT custom_block_definitions_name_check CHECK ((length(TRIM(BOTH FROM name)) > 0)),
253
+ CONSTRAINT custom_block_definitions_slug_check CHECK ((slug ~ '^[a-z][a-z0-9-]*$'::text))
254
+ );
255
+
256
+ COMMENT ON TABLE public.custom_block_definitions IS 'Registry for user-created block definitions rendered from database JSONB without runtime code compilation.';
257
+
258
+ COMMENT ON COLUMN public.custom_block_definitions.fields IS 'Strict JSONB field declarations for data-rendered custom blocks.';
259
+
260
+ COMMENT ON COLUMN public.custom_block_definitions.layout_schema IS 'Open-ended recursive layout schema consumed by the dynamic layout renderer.';
261
+
262
+ COMMENT ON COLUMN public.custom_block_definitions.is_original IS 'False when a definition was created by duplicating an existing registry row.';
263
+
264
+ CREATE OR REPLACE FUNCTION public.duplicate_block_definition(target_id uuid) RETURNS public.custom_block_definitions
265
+ LANGUAGE plpgsql
266
+ SET search_path TO 'public'
267
+ AS $_$
268
+ DECLARE
269
+ source_definition public.custom_block_definitions%ROWTYPE;
270
+ copied_definition public.custom_block_definitions%ROWTYPE;
271
+ base_slug text;
272
+ copy_slug text;
273
+ copy_index integer := 1;
274
+ BEGIN
275
+ IF auth.role() <> 'service_role'
276
+ AND COALESCE((SELECT public.get_current_user_role())::text, '') NOT IN ('ADMIN', 'WRITER') THEN
277
+ RAISE EXCEPTION 'Not authorized to duplicate custom block definitions.'
278
+ USING ERRCODE = '42501';
279
+ END IF;
280
+
281
+ SELECT *
282
+ INTO source_definition
283
+ FROM public.custom_block_definitions
284
+ WHERE id = target_id;
285
+
286
+ IF NOT FOUND THEN
287
+ RAISE EXCEPTION 'Custom block definition % not found.', target_id
288
+ USING ERRCODE = 'P0002';
289
+ END IF;
290
+
291
+ base_slug := regexp_replace(source_definition.slug, '-copy(-[0-9]+)?$', '');
292
+ copy_slug := base_slug || '-copy';
293
+
294
+ WHILE EXISTS (
295
+ SELECT 1
296
+ FROM public.custom_block_definitions
297
+ WHERE slug = copy_slug
298
+ ) LOOP
299
+ copy_index := copy_index + 1;
300
+ copy_slug := base_slug || '-copy-' || copy_index;
301
+ END LOOP;
302
+
303
+ INSERT INTO public.custom_block_definitions (
304
+ id,
305
+ slug,
306
+ name,
307
+ description,
308
+ fields,
309
+ layout_schema,
310
+ is_original
311
+ )
312
+ VALUES (
313
+ gen_random_uuid(),
314
+ copy_slug,
315
+ source_definition.name || ' Copy',
316
+ source_definition.description,
317
+ source_definition.fields,
318
+ source_definition.layout_schema,
319
+ false
320
+ )
321
+ RETURNING *
322
+ INTO copied_definition;
323
+
324
+ RETURN copied_definition;
325
+ END;
326
+ $_$;
327
+
328
+ CREATE OR REPLACE FUNCTION public.format_order_invoice_number(p_value bigint) RETURNS text
329
+ LANGUAGE sql IMMUTABLE
330
+ SET search_path TO ''
331
+ AS $$
332
+ SELECT 'INV-' || lpad(p_value::text, 6, '0');
333
+ $$;
334
+
335
+ CREATE OR REPLACE FUNCTION public.generate_order_invoice_number() RETURNS text
336
+ LANGUAGE sql
337
+ SET search_path TO ''
338
+ AS $$
339
+ SELECT public.format_order_invoice_number(nextval('public.order_invoice_number_seq'));
340
+ $$;
341
+
342
+ CREATE OR REPLACE FUNCTION public.get_current_user_role() RETURNS public.user_role
343
+ LANGUAGE sql STABLE
344
+ SET search_path TO 'public'
345
+ AS $$
346
+ SELECT role FROM public.profiles WHERE id = auth.uid();
347
+ $$;
348
+
349
+ CREATE OR REPLACE FUNCTION public.get_default_currency_code() RETURNS text
350
+ LANGUAGE sql STABLE
351
+ SET search_path TO ''
352
+ AS $$
353
+ SELECT COALESCE(
354
+ (
355
+ SELECT upper(code)
356
+ FROM public.currencies
357
+ WHERE is_default = true
358
+ ORDER BY updated_at DESC, created_at DESC, code ASC
359
+ LIMIT 1
360
+ ),
361
+ 'USD'
362
+ );
363
+ $$;
364
+
365
+ CREATE OR REPLACE FUNCTION public.get_ecommerce_track_quantities() RETURNS boolean
366
+ LANGUAGE plpgsql STABLE
367
+ SET search_path TO 'public'
368
+ AS $$
369
+ DECLARE
370
+ v_value jsonb;
371
+ v_raw text;
372
+ BEGIN
373
+ SELECT value
374
+ INTO v_value
375
+ FROM public.site_settings
376
+ WHERE key = 'ecommerce_inventory_settings';
377
+
378
+ IF v_value IS NULL THEN
379
+ RETURN true;
380
+ END IF;
381
+
382
+ IF jsonb_typeof(v_value) = 'object' THEN
383
+ v_raw := NULLIF(v_value->>'track_quantities', '');
384
+ ELSE
385
+ v_raw := NULLIF(trim(BOTH '"' FROM v_value::text), '');
386
+ END IF;
387
+
388
+ IF v_raw IS NULL THEN
389
+ RETURN true;
390
+ END IF;
391
+
392
+ IF lower(v_raw) IN ('false', 'f', '0', 'no', 'off') THEN
393
+ RETURN false;
394
+ END IF;
395
+
396
+ RETURN true;
397
+ END;
398
+ $$;
399
+
400
+ CREATE OR REPLACE FUNCTION public.get_my_claim(claim text) RETURNS jsonb
401
+ LANGUAGE sql STABLE
402
+ SET search_path TO ''
403
+ AS $$
404
+ SELECT COALESCE(current_setting('request.jwt.claims', true)::jsonb ->> claim, NULL)::jsonb
405
+ $$;
406
+
407
+ CREATE OR REPLACE FUNCTION public.handle_blocks_update() RETURNS trigger
408
+ LANGUAGE plpgsql
409
+ SET search_path TO 'public'
410
+ AS $$
411
+ BEGIN
412
+ NEW.updated_at = now();
413
+ RETURN NEW;
414
+ END;
415
+ $$;
416
+
417
+ CREATE OR REPLACE FUNCTION public.handle_coupon_freemius_mappings_write() RETURNS trigger
418
+ LANGUAGE plpgsql
419
+ SET search_path TO ''
420
+ AS $$
421
+ BEGIN
422
+ NEW.freemius_product_id := btrim(NEW.freemius_product_id);
423
+ NEW.freemius_coupon_code := upper(regexp_replace(btrim(NEW.freemius_coupon_code), '\s+', '', 'g'));
424
+ NEW.sync_status := lower(btrim(COALESCE(NEW.sync_status, 'pending')));
425
+ NEW.updated_at := now();
426
+
427
+ IF NEW.created_at IS NULL THEN
428
+ NEW.created_at := now();
429
+ END IF;
430
+
431
+ RETURN NEW;
432
+ END;
433
+ $$;
434
+
435
+ CREATE OR REPLACE FUNCTION public.handle_coupons_write() RETURNS trigger
436
+ LANGUAGE plpgsql
437
+ SET search_path TO ''
438
+ AS $$
439
+ BEGIN
440
+ NEW.code := upper(regexp_replace(btrim(NEW.code), '\s+', '', 'g'));
441
+ NEW.name := btrim(NEW.name);
442
+ NEW.provider_scope := lower(btrim(NEW.provider_scope));
443
+ NEW.discount_type := lower(btrim(NEW.discount_type));
444
+ NEW.freemius_sync_status := lower(btrim(COALESCE(NEW.freemius_sync_status, 'not_synced')));
445
+ NEW.updated_at := now();
446
+
447
+ IF NEW.created_at IS NULL THEN
448
+ NEW.created_at := now();
449
+ END IF;
450
+
451
+ RETURN NEW;
452
+ END;
453
+ $$;
454
+
455
+ CREATE OR REPLACE FUNCTION public.handle_default_currency_change() RETURNS trigger
456
+ LANGUAGE plpgsql
457
+ SET search_path TO ''
458
+ AS $$
459
+ BEGIN
460
+ IF TG_OP = 'INSERT' THEN
461
+ IF NEW.is_default THEN
462
+ PERFORM public.sync_legacy_price_columns_for_currency(NEW.code);
463
+ END IF;
464
+ ELSIF NEW.is_default
465
+ AND (
466
+ OLD.is_default IS DISTINCT FROM NEW.is_default
467
+ OR OLD.code IS DISTINCT FROM NEW.code
468
+ ) THEN
469
+ PERFORM public.sync_legacy_price_columns_for_currency(NEW.code);
470
+ END IF;
471
+
472
+ RETURN NEW;
473
+ END;
474
+ $$;
475
+
476
+ CREATE OR REPLACE FUNCTION public.handle_inventory_item_change() RETURNS trigger
477
+ LANGUAGE plpgsql
478
+ SET search_path TO 'public'
479
+ AS $$
480
+ DECLARE
481
+ v_sku text := COALESCE(NEW.sku, OLD.sku);
482
+ BEGIN
483
+ PERFORM public.sync_inventory_cache_for_sku(v_sku);
484
+ RETURN COALESCE(NEW, OLD);
485
+ END;
486
+ $$;
487
+
488
+ CREATE OR REPLACE FUNCTION public.handle_inventory_items_update() RETURNS trigger
489
+ LANGUAGE plpgsql
490
+ SET search_path TO 'public'
491
+ AS $$
492
+ BEGIN
493
+ NEW.updated_at = now();
494
+ RETURN NEW;
495
+ END;
496
+ $$;
497
+
498
+ CREATE OR REPLACE FUNCTION public.handle_languages_update() RETURNS trigger
499
+ LANGUAGE plpgsql
500
+ SET search_path TO ''
501
+ AS $$
502
+ BEGIN
503
+ NEW.updated_at = now();
504
+ RETURN NEW;
505
+ END;
506
+ $$;
507
+
508
+ CREATE OR REPLACE FUNCTION public.handle_media_update() RETURNS trigger
509
+ LANGUAGE plpgsql
510
+ SET search_path TO 'public'
511
+ AS $$
512
+ BEGIN
513
+ NEW.updated_at = now();
514
+ RETURN NEW;
515
+ END;
516
+ $$;
517
+
518
+ CREATE OR REPLACE FUNCTION public.handle_navigation_items_update() RETURNS trigger
519
+ LANGUAGE plpgsql
520
+ SET search_path TO 'public'
521
+ AS $$
522
+ BEGIN
523
+ NEW.updated_at = now();
524
+ RETURN NEW;
525
+ END;
526
+ $$;
527
+
528
+ CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger
529
+ LANGUAGE plpgsql SECURITY DEFINER
530
+ SET search_path TO 'public'
531
+ AS $$
532
+ DECLARE
533
+ admin_flag_set boolean := false;
534
+ user_role public.user_role;
535
+ v_full_name text;
536
+ v_avatar_url text;
537
+ v_github_username text;
538
+ v_provider text;
539
+ BEGIN
540
+ INSERT INTO public.site_settings (key, value)
541
+ VALUES ('is_admin_created', 'false'::jsonb)
542
+ ON CONFLICT (key) DO NOTHING;
543
+
544
+ SELECT COALESCE(value::jsonb::boolean, false)
545
+ INTO admin_flag_set
546
+ FROM public.site_settings
547
+ WHERE key = 'is_admin_created'
548
+ FOR UPDATE;
549
+
550
+ IF admin_flag_set = false THEN
551
+ user_role := 'ADMIN'::public.user_role;
552
+
553
+ UPDATE public.site_settings
554
+ SET value = 'true'::jsonb
555
+ WHERE key = 'is_admin_created';
556
+ ELSE
557
+ user_role := 'USER'::public.user_role;
558
+ END IF;
559
+
560
+ v_full_name := NEW.raw_user_meta_data->>'full_name';
561
+ 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
+
573
+ INSERT INTO public.profiles (
574
+ id,
575
+ role,
576
+ full_name,
577
+ avatar_url,
578
+ github_username
579
+ )
580
+ VALUES (
581
+ NEW.id,
582
+ user_role,
583
+ v_full_name,
584
+ v_avatar_url,
585
+ v_github_username
586
+ )
587
+ ON CONFLICT (id) DO UPDATE SET
588
+ full_name = EXCLUDED.full_name,
589
+ avatar_url = EXCLUDED.avatar_url,
590
+ github_username = EXCLUDED.github_username;
591
+
592
+ RETURN NEW;
593
+ END;
594
+ $$;
595
+
596
+ CREATE OR REPLACE FUNCTION public.handle_pages_update() RETURNS trigger
597
+ LANGUAGE plpgsql
598
+ SET search_path TO 'public'
599
+ AS $$
600
+ BEGIN
601
+ NEW.updated_at = now();
602
+ RETURN NEW;
603
+ END;
604
+ $$;
605
+
606
+ CREATE OR REPLACE FUNCTION public.handle_posts_update() RETURNS trigger
607
+ LANGUAGE plpgsql
608
+ SET search_path TO 'public'
609
+ AS $$
610
+ BEGIN
611
+ NEW.updated_at = now();
612
+ RETURN NEW;
613
+ END;
614
+ $$;
615
+
616
+ CREATE OR REPLACE FUNCTION public.handle_product_freemius_sale_coupons_write() RETURNS trigger
617
+ LANGUAGE plpgsql
618
+ SET search_path TO ''
619
+ AS $$
620
+ BEGIN
621
+ NEW.freemius_product_id := btrim(NEW.freemius_product_id);
622
+ NEW.freemius_coupon_code := upper(regexp_replace(btrim(NEW.freemius_coupon_code), '\s+', '', 'g'));
623
+ NEW.sync_status := lower(btrim(COALESCE(NEW.sync_status, 'pending')));
624
+ NEW.updated_at := now();
625
+
626
+ IF NEW.created_at IS NULL THEN
627
+ NEW.created_at := now();
628
+ END IF;
629
+
630
+ RETURN NEW;
631
+ END;
632
+ $$;
633
+
634
+ CREATE OR REPLACE FUNCTION public.handle_shipping_zone_locations_write() RETURNS trigger
635
+ LANGUAGE plpgsql
636
+ SET search_path TO ''
637
+ AS $$
638
+ BEGIN
639
+ NEW.country_code = upper(btrim(NEW.country_code));
640
+ NEW.state_code = CASE
641
+ WHEN NEW.state_code IS NULL OR btrim(NEW.state_code) = '' THEN NULL
642
+ ELSE upper(btrim(NEW.state_code))
643
+ END;
644
+ NEW.postal_code = CASE
645
+ WHEN NEW.postal_code IS NULL OR btrim(NEW.postal_code) = '' THEN NULL
646
+ ELSE upper(btrim(NEW.postal_code))
647
+ END;
648
+ RETURN NEW;
649
+ END;
650
+ $$;
651
+
652
+ CREATE OR REPLACE FUNCTION public.handle_tax_rates_write() RETURNS trigger
653
+ LANGUAGE plpgsql
654
+ SET search_path TO ''
655
+ AS $$
656
+ BEGIN
657
+ NEW.country_code = upper(btrim(NEW.country_code));
658
+ NEW.state_code = CASE
659
+ WHEN NEW.state_code IS NULL OR btrim(NEW.state_code) = '' THEN NULL
660
+ ELSE upper(btrim(NEW.state_code))
661
+ END;
662
+ NEW.tax_name = btrim(NEW.tax_name);
663
+ NEW.updated_at = now();
664
+
665
+ IF NEW.created_at IS NULL THEN
666
+ NEW.created_at = now();
667
+ END IF;
668
+
669
+ RETURN NEW;
670
+ END;
671
+ $$;
672
+
673
+ CREATE OR REPLACE FUNCTION public.handle_ucp_cart_sessions_update() RETURNS trigger
674
+ LANGUAGE plpgsql
675
+ SET search_path TO ''
676
+ AS $$
677
+ BEGIN
678
+ NEW.updated_at = now();
679
+ RETURN NEW;
680
+ END;
681
+ $$;
682
+
683
+ CREATE OR REPLACE FUNCTION public.is_admin() RETURNS boolean
684
+ LANGUAGE sql STABLE
685
+ SET search_path TO 'public'
686
+ AS $$
687
+ SELECT role = 'ADMIN' FROM public.profiles WHERE id = auth.uid();
688
+ $$;
689
+
690
+ CREATE OR REPLACE FUNCTION public.is_valid_currency_amount_map(amounts jsonb) RETURNS boolean
691
+ LANGUAGE sql IMMUTABLE
692
+ SET search_path TO ''
693
+ AS $_$
694
+ SELECT CASE
695
+ WHEN amounts IS NULL THEN false
696
+ WHEN jsonb_typeof(amounts) <> 'object' THEN false
697
+ WHEN amounts = '{}'::jsonb THEN false
698
+ ELSE NOT EXISTS (
699
+ SELECT 1
700
+ FROM jsonb_each(amounts) AS entry
701
+ WHERE entry.key !~ '^[A-Z]{3}$'
702
+ OR jsonb_typeof(entry.value) <> 'number'
703
+ OR entry.value::text !~ '^[0-9]+$'
704
+ )
705
+ END;
706
+ $_$;
707
+
708
+ CREATE OR REPLACE FUNCTION public.is_valid_sale_price_map(prices jsonb, sale_prices jsonb) RETURNS boolean
709
+ LANGUAGE sql IMMUTABLE
710
+ SET search_path TO ''
711
+ AS $_$
712
+ SELECT CASE
713
+ WHEN sale_prices IS NULL THEN true
714
+ WHEN jsonb_typeof(sale_prices) <> 'object' THEN false
715
+ WHEN sale_prices = '{}'::jsonb THEN true
716
+ WHEN prices IS NULL OR jsonb_typeof(prices) <> 'object' THEN false
717
+ ELSE NOT EXISTS (
718
+ SELECT 1
719
+ FROM jsonb_each(sale_prices) AS entry
720
+ WHERE entry.key !~ '^[A-Z]{3}$'
721
+ OR NOT (prices ? entry.key)
722
+ OR jsonb_typeof(entry.value) <> 'number'
723
+ OR entry.value::text !~ '^[0-9]+$'
724
+ OR entry.value::text::numeric > (prices ->> entry.key)::numeric
725
+ )
726
+ END;
727
+ $_$;
728
+
729
+ CREATE OR REPLACE FUNCTION public.normalize_currency_amount_map(amounts jsonb) RETURNS jsonb
730
+ LANGUAGE sql IMMUTABLE
731
+ SET search_path TO ''
732
+ AS $_$
733
+ SELECT CASE
734
+ WHEN amounts IS NULL THEN '{}'::jsonb
735
+ WHEN jsonb_typeof(amounts) <> 'object' THEN amounts
736
+ ELSE COALESCE(
737
+ (
738
+ SELECT jsonb_object_agg(
739
+ upper(trim(entry.key)),
740
+ CASE
741
+ WHEN jsonb_typeof(entry.value) = 'number'
742
+ AND entry.value::text ~ '^[0-9]+$' THEN
743
+ to_jsonb((entry.value::text)::bigint)
744
+ ELSE
745
+ entry.value
746
+ END
747
+ )
748
+ FROM jsonb_each(amounts) AS entry
749
+ ),
750
+ '{}'::jsonb
751
+ )
752
+ END;
753
+ $_$;
754
+
755
+ CREATE OR REPLACE FUNCTION public.set_currency_defaults() RETURNS trigger
756
+ LANGUAGE plpgsql
757
+ SET search_path TO ''
758
+ AS $$
759
+ BEGIN
760
+ NEW.code := upper(trim(NEW.code));
761
+ NEW.updated_at := now();
762
+
763
+ IF NEW.is_default THEN
764
+ NEW.is_active := true;
765
+ NEW.exchange_rate := 1;
766
+ NEW.auto_update_exchange_rate := false;
767
+ NEW.auto_sync_product_prices := false;
768
+ NEW.exchange_rate_source := COALESCE(NULLIF(NEW.exchange_rate_source, ''), 'store-default');
769
+ NEW.exchange_rate_updated_at := COALESCE(NEW.exchange_rate_updated_at, now());
770
+
771
+ UPDATE public.currencies
772
+ SET is_default = false,
773
+ updated_at = now()
774
+ WHERE id IS DISTINCT FROM NEW.id
775
+ AND is_default = true;
776
+ ELSIF NULLIF(NEW.exchange_rate_source, '') IS NULL THEN
777
+ NEW.exchange_rate_source := NULL;
778
+ END IF;
779
+
780
+ RETURN NEW;
781
+ END;
782
+ $$;
783
+
784
+ CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() RETURNS trigger
785
+ LANGUAGE plpgsql
786
+ SET search_path TO ''
787
+ AS $$
788
+ DECLARE
789
+ _new record;
790
+ BEGIN
791
+ _new := NEW;
792
+ _new.updated_at = now();
793
+ RETURN _new;
794
+ END;
795
+ $$;
796
+
797
+ CREATE OR REPLACE FUNCTION public.sync_currency_price_maps() RETURNS trigger
798
+ LANGUAGE plpgsql
799
+ SET search_path TO ''
800
+ AS $$
801
+ DECLARE
802
+ v_default_currency text := public.get_default_currency_code();
803
+ v_price_map_changed boolean := false;
804
+ v_legacy_changed boolean := false;
805
+ BEGIN
806
+ NEW.prices := public.normalize_currency_amount_map(COALESCE(NEW.prices, '{}'::jsonb));
807
+ NEW.sale_prices := public.normalize_currency_amount_map(COALESCE(NEW.sale_prices, '{}'::jsonb));
808
+
809
+ IF NEW.sale_prices = '{}'::jsonb THEN
810
+ NEW.sale_prices := NULL;
811
+ END IF;
812
+
813
+ IF TG_OP = 'UPDATE' THEN
814
+ v_price_map_changed :=
815
+ NEW.prices IS DISTINCT FROM OLD.prices
816
+ OR NEW.sale_prices IS DISTINCT FROM OLD.sale_prices;
817
+ v_legacy_changed :=
818
+ NEW.price IS DISTINCT FROM OLD.price
819
+ OR NEW.sale_price IS DISTINCT FROM OLD.sale_price;
820
+ END IF;
821
+
822
+ IF TG_OP = 'INSERT' THEN
823
+ IF NEW.prices ? v_default_currency THEN
824
+ NEW.price := (NEW.prices ->> v_default_currency)::integer;
825
+ ELSE
826
+ NEW.prices := NEW.prices || jsonb_build_object(
827
+ v_default_currency,
828
+ GREATEST(COALESCE(NEW.price, 0), 0)
829
+ );
830
+ END IF;
831
+
832
+ IF NEW.sale_prices IS NOT NULL AND NEW.sale_prices ? v_default_currency THEN
833
+ NEW.sale_price := (NEW.sale_prices ->> v_default_currency)::integer;
834
+ ELSIF NEW.sale_price IS NOT NULL THEN
835
+ NEW.sale_prices := COALESCE(NEW.sale_prices, '{}'::jsonb)
836
+ || jsonb_build_object(v_default_currency, GREATEST(NEW.sale_price, 0));
837
+ END IF;
838
+
839
+ RETURN NEW;
840
+ END IF;
841
+
842
+ IF v_price_map_changed AND NOT v_legacy_changed THEN
843
+ IF NOT (NEW.prices ? v_default_currency) THEN
844
+ NEW.prices := NEW.prices || jsonb_build_object(
845
+ v_default_currency,
846
+ GREATEST(COALESCE(OLD.price, NEW.price, 0), 0)
847
+ );
848
+ END IF;
849
+
850
+ NEW.price := (NEW.prices ->> v_default_currency)::integer;
851
+ NEW.sale_price := CASE
852
+ WHEN NEW.sale_prices IS NOT NULL AND NEW.sale_prices ? v_default_currency THEN
853
+ (NEW.sale_prices ->> v_default_currency)::integer
854
+ ELSE
855
+ NULL
856
+ END;
857
+
858
+ RETURN NEW;
859
+ END IF;
860
+
861
+ NEW.prices := NEW.prices || jsonb_build_object(
862
+ v_default_currency,
863
+ GREATEST(COALESCE(NEW.price, 0), 0)
864
+ );
865
+
866
+ IF NEW.sale_price IS NULL THEN
867
+ IF NEW.sale_prices IS NOT NULL THEN
868
+ NEW.sale_prices := NEW.sale_prices - v_default_currency;
869
+
870
+ IF NEW.sale_prices = '{}'::jsonb THEN
871
+ NEW.sale_prices := NULL;
872
+ END IF;
873
+ END IF;
874
+ ELSE
875
+ NEW.sale_prices := COALESCE(NEW.sale_prices, '{}'::jsonb)
876
+ || jsonb_build_object(v_default_currency, GREATEST(NEW.sale_price, 0));
877
+ END IF;
878
+
879
+ RETURN NEW;
880
+ END;
881
+ $$;
882
+
883
+ CREATE OR REPLACE FUNCTION public.sync_inventory_cache_for_sku(p_sku text) RETURNS void
884
+ LANGUAGE plpgsql
885
+ SET search_path TO 'public'
886
+ AS $$
887
+ DECLARE
888
+ v_quantity integer := 0;
889
+ BEGIN
890
+ IF NULLIF(trim(p_sku), '') IS NULL THEN
891
+ RETURN;
892
+ END IF;
893
+
894
+ SELECT quantity
895
+ INTO v_quantity
896
+ FROM public.inventory_items
897
+ WHERE sku = p_sku
898
+ LIMIT 1;
899
+
900
+ v_quantity := COALESCE(v_quantity, 0);
901
+
902
+ UPDATE public.product_variants
903
+ SET
904
+ stock_quantity = v_quantity,
905
+ updated_at = now()
906
+ WHERE sku = p_sku;
907
+
908
+ UPDATE public.products AS products
909
+ SET
910
+ stock = v_quantity,
911
+ updated_at = now()
912
+ WHERE products.sku = p_sku
913
+ AND NOT EXISTS (
914
+ SELECT 1
915
+ FROM public.product_variants
916
+ WHERE product_id = products.id
917
+ );
918
+
919
+ UPDATE public.products AS products
920
+ SET
921
+ stock = COALESCE((
922
+ SELECT SUM(COALESCE(inventory.quantity, 0))
923
+ FROM public.product_variants AS variants
924
+ LEFT JOIN public.inventory_items AS inventory
925
+ ON inventory.sku = variants.sku
926
+ WHERE variants.product_id = products.id
927
+ ), 0),
928
+ updated_at = now()
929
+ WHERE EXISTS (
930
+ SELECT 1
931
+ FROM public.product_variants AS variants
932
+ WHERE variants.product_id = products.id
933
+ AND variants.sku = p_sku
934
+ );
935
+ END;
936
+ $$;
937
+
938
+ CREATE OR REPLACE FUNCTION public.sync_legacy_price_columns_for_currency(target_currency text) RETURNS void
939
+ LANGUAGE plpgsql
940
+ SET search_path TO ''
941
+ AS $$
942
+ DECLARE
943
+ v_target_currency text := upper(trim(target_currency));
944
+ BEGIN
945
+ UPDATE public.products
946
+ SET
947
+ prices = CASE
948
+ WHEN COALESCE(prices, '{}'::jsonb) ? v_target_currency THEN prices
949
+ ELSE COALESCE(prices, '{}'::jsonb) || jsonb_build_object(v_target_currency, price)
950
+ END,
951
+ sale_prices = CASE
952
+ WHEN sale_price IS NULL THEN sale_prices
953
+ WHEN sale_prices IS NOT NULL AND sale_prices ? v_target_currency THEN sale_prices
954
+ ELSE COALESCE(sale_prices, '{}'::jsonb) || jsonb_build_object(v_target_currency, sale_price)
955
+ END,
956
+ price = CASE
957
+ WHEN COALESCE(prices, '{}'::jsonb) ? v_target_currency THEN
958
+ (COALESCE(prices, '{}'::jsonb) ->> v_target_currency)::integer
959
+ ELSE
960
+ price
961
+ END,
962
+ sale_price = CASE
963
+ WHEN sale_prices IS NOT NULL AND sale_prices ? v_target_currency THEN
964
+ (sale_prices ->> v_target_currency)::integer
965
+ ELSE
966
+ sale_price
967
+ END,
968
+ updated_at = now()
969
+ WHERE
970
+ NOT (COALESCE(prices, '{}'::jsonb) ? v_target_currency)
971
+ OR (
972
+ sale_price IS NOT NULL
973
+ AND (sale_prices IS NULL OR NOT (sale_prices ? v_target_currency))
974
+ )
975
+ OR (
976
+ COALESCE(prices, '{}'::jsonb) ? v_target_currency
977
+ AND price IS DISTINCT FROM (COALESCE(prices, '{}'::jsonb) ->> v_target_currency)::integer
978
+ )
979
+ OR (
980
+ sale_prices IS NOT NULL
981
+ AND sale_prices ? v_target_currency
982
+ AND sale_price IS DISTINCT FROM (sale_prices ->> v_target_currency)::integer
983
+ );
984
+
985
+ UPDATE public.product_variants
986
+ SET
987
+ prices = CASE
988
+ WHEN COALESCE(prices, '{}'::jsonb) ? v_target_currency THEN prices
989
+ ELSE COALESCE(prices, '{}'::jsonb) || jsonb_build_object(v_target_currency, price)
990
+ END,
991
+ sale_prices = CASE
992
+ WHEN sale_price IS NULL THEN sale_prices
993
+ WHEN sale_prices IS NOT NULL AND sale_prices ? v_target_currency THEN sale_prices
994
+ ELSE COALESCE(sale_prices, '{}'::jsonb) || jsonb_build_object(v_target_currency, sale_price)
995
+ END,
996
+ price = CASE
997
+ WHEN COALESCE(prices, '{}'::jsonb) ? v_target_currency THEN
998
+ (COALESCE(prices, '{}'::jsonb) ->> v_target_currency)::integer
999
+ ELSE
1000
+ price
1001
+ END,
1002
+ sale_price = CASE
1003
+ WHEN sale_prices IS NOT NULL AND sale_prices ? v_target_currency THEN
1004
+ (sale_prices ->> v_target_currency)::integer
1005
+ ELSE
1006
+ sale_price
1007
+ END,
1008
+ updated_at = now()
1009
+ WHERE
1010
+ NOT (COALESCE(prices, '{}'::jsonb) ? v_target_currency)
1011
+ OR (
1012
+ sale_price IS NOT NULL
1013
+ AND (sale_prices IS NULL OR NOT (sale_prices ? v_target_currency))
1014
+ )
1015
+ OR (
1016
+ COALESCE(prices, '{}'::jsonb) ? v_target_currency
1017
+ AND price IS DISTINCT FROM (COALESCE(prices, '{}'::jsonb) ->> v_target_currency)::integer
1018
+ )
1019
+ OR (
1020
+ sale_prices IS NOT NULL
1021
+ AND sale_prices ? v_target_currency
1022
+ AND sale_price IS DISTINCT FROM (sale_prices ->> v_target_currency)::integer
1023
+ );
1024
+ END;
1025
+ $$;
1026
+
1027
+ CREATE OR REPLACE FUNCTION public.sync_shipping_method_currency_maps() RETURNS trigger
1028
+ LANGUAGE plpgsql
1029
+ SET search_path TO ''
1030
+ AS $$
1031
+ DECLARE
1032
+ v_source_currency text;
1033
+ BEGIN
1034
+ v_source_currency := upper(trim(COALESCE(NULLIF(NEW.cost_currency, ''), public.get_default_currency_code())));
1035
+
1036
+ NEW.cost_currency := v_source_currency;
1037
+ NEW.currency_pricing_mode := COALESCE(NULLIF(lower(trim(NEW.currency_pricing_mode)), ''), 'auto');
1038
+ NEW.cost_amounts := public.normalize_currency_amount_map(COALESCE(NEW.cost_amounts, '{}'::jsonb));
1039
+ NEW.min_order_amounts := public.normalize_currency_amount_map(COALESCE(NEW.min_order_amounts, '{}'::jsonb));
1040
+
1041
+ IF NEW.currency_pricing_mode NOT IN ('auto', 'manual') THEN
1042
+ RAISE EXCEPTION 'Unsupported shipping currency pricing mode: %', NEW.currency_pricing_mode;
1043
+ END IF;
1044
+
1045
+ IF NEW.cost_amounts = '{}'::jsonb THEN
1046
+ NEW.cost_amounts := jsonb_build_object(v_source_currency, GREATEST(COALESCE(NEW.cost_amount, 0), 0));
1047
+ ELSIF NOT (NEW.cost_amounts ? v_source_currency) THEN
1048
+ NEW.cost_amounts := NEW.cost_amounts || jsonb_build_object(
1049
+ v_source_currency,
1050
+ GREATEST(COALESCE(NEW.cost_amount, 0), 0)
1051
+ );
1052
+ END IF;
1053
+
1054
+ IF NEW.min_order_amounts = '{}'::jsonb THEN
1055
+ NEW.min_order_amounts := jsonb_build_object(
1056
+ v_source_currency,
1057
+ GREATEST(COALESCE(NEW.min_order_amount, 0), 0)
1058
+ );
1059
+ ELSIF NOT (NEW.min_order_amounts ? v_source_currency) THEN
1060
+ NEW.min_order_amounts := NEW.min_order_amounts || jsonb_build_object(
1061
+ v_source_currency,
1062
+ GREATEST(COALESCE(NEW.min_order_amount, 0), 0)
1063
+ );
1064
+ END IF;
1065
+
1066
+ IF NEW.currency_pricing_mode = 'auto' THEN
1067
+ NEW.cost_amounts := jsonb_build_object(
1068
+ v_source_currency,
1069
+ GREATEST((NEW.cost_amounts ->> v_source_currency)::integer, 0)
1070
+ );
1071
+ NEW.min_order_amounts := jsonb_build_object(
1072
+ v_source_currency,
1073
+ GREATEST((NEW.min_order_amounts ->> v_source_currency)::integer, 0)
1074
+ );
1075
+ END IF;
1076
+
1077
+ NEW.cost_amount := GREATEST((NEW.cost_amounts ->> v_source_currency)::integer, 0);
1078
+ NEW.min_order_amount := GREATEST((NEW.min_order_amounts ->> v_source_currency)::integer, 0);
1079
+ NEW.updated_at := now();
1080
+
1081
+ RETURN NEW;
1082
+ END;
1083
+ $$;
1084
+
1085
+ CREATE OR REPLACE FUNCTION public.update_product_ratings() RETURNS trigger
1086
+ LANGUAGE plpgsql SECURITY DEFINER
1087
+ SET search_path TO 'public'
1088
+ AS $$
1089
+ DECLARE
1090
+ v_product_id uuid;
1091
+ v_avg numeric(3,2);
1092
+ v_count integer;
1093
+ BEGIN
1094
+ -- Determine which product_id we need to update
1095
+ IF TG_OP = 'DELETE' THEN
1096
+ v_product_id := OLD.product_id;
1097
+ ELSE
1098
+ v_product_id := NEW.product_id;
1099
+ END IF;
1100
+
1101
+ IF v_product_id IS NOT NULL THEN
1102
+ -- Calculate average and count of approved reviews for this product
1103
+ SELECT COALESCE(avg(rating), 0.00), count(*)
1104
+ INTO v_avg, v_count
1105
+ FROM public.cms_interactions
1106
+ WHERE product_id = v_product_id
1107
+ AND type = 'review'
1108
+ AND status = 'approved';
1109
+
1110
+ -- Update products table
1111
+ UPDATE public.products
1112
+ SET average_rating = ROUND(COALESCE(v_avg, 0.00), 2),
1113
+ total_reviews = v_count
1114
+ WHERE id = v_product_id;
1115
+ END IF;
1116
+
1117
+ -- Handle old product_id if it changed on UPDATE (e.g. transfer of reviews)
1118
+ IF TG_OP = 'UPDATE' AND OLD.product_id IS NOT NULL AND OLD.product_id <> NEW.product_id THEN
1119
+ SELECT COALESCE(avg(rating), 0.00), count(*)
1120
+ INTO v_avg, v_count
1121
+ FROM public.cms_interactions
1122
+ WHERE product_id = OLD.product_id
1123
+ AND type = 'review'
1124
+ AND status = 'approved';
1125
+
1126
+ UPDATE public.products
1127
+ SET average_rating = ROUND(COALESCE(v_avg, 0.00), 2),
1128
+ total_reviews = v_count
1129
+ WHERE id = OLD.product_id;
1130
+ END IF;
1131
+
1132
+ RETURN NULL;
1133
+ END;
1134
+ $$;
1135
+
1136
+ CREATE OR REPLACE FUNCTION public.upsert_product_with_variants(product_payload jsonb) RETURNS uuid
1137
+ LANGUAGE plpgsql
1138
+ SET search_path TO 'public'
1139
+ AS $$
1140
+ DECLARE
1141
+ v_product_id uuid := NULLIF(product_payload->>'id', '')::uuid;
1142
+ v_translation_group_id uuid := NULLIF(product_payload->>'translation_group_id', '')::uuid;
1143
+ v_product_type text := CASE
1144
+ WHEN product_payload->>'product_type' IN ('physical', 'digital') THEN
1145
+ product_payload->>'product_type'
1146
+ WHEN NULLIF(product_payload->>'freemius_product_id', '') IS NOT NULL
1147
+ OR NULLIF(product_payload->>'freemius_plan_id', '') IS NOT NULL THEN
1148
+ 'digital'
1149
+ ELSE
1150
+ 'physical'
1151
+ END;
1152
+ v_payment_provider text := CASE
1153
+ WHEN v_product_type = 'digital' THEN 'freemius'
1154
+ ELSE 'stripe'
1155
+ END;
1156
+ v_variants jsonb := COALESCE(product_payload->'variants', '[]'::jsonb);
1157
+ v_variant jsonb;
1158
+ v_variant_id uuid;
1159
+ v_term_id text;
1160
+ v_has_variants boolean := jsonb_typeof(v_variants) = 'array' AND jsonb_array_length(v_variants) > 0;
1161
+ v_total_variant_stock integer := 0;
1162
+ BEGIN
1163
+ IF NOT public.is_admin() THEN
1164
+ RAISE EXCEPTION 'Admin access required';
1165
+ END IF;
1166
+
1167
+ IF v_has_variants THEN
1168
+ SELECT COALESCE(SUM(COALESCE((value->>'stock_quantity')::integer, 0)), 0)
1169
+ INTO v_total_variant_stock
1170
+ FROM jsonb_array_elements(v_variants);
1171
+ END IF;
1172
+
1173
+ IF v_product_id IS NULL THEN
1174
+ INSERT INTO public.products (
1175
+ title,
1176
+ slug,
1177
+ sku,
1178
+ product_type,
1179
+ payment_provider,
1180
+ upc,
1181
+ stock,
1182
+ status,
1183
+ short_description,
1184
+ description_json,
1185
+ metadata,
1186
+ price,
1187
+ prices,
1188
+ sale_price,
1189
+ sale_prices,
1190
+ sale_start_at,
1191
+ sale_end_at,
1192
+ freemius_plan_id,
1193
+ freemius_product_id,
1194
+ trial_period_days,
1195
+ trial_requires_payment_method,
1196
+ language_id,
1197
+ translation_group_id
1198
+ )
1199
+ VALUES (
1200
+ product_payload->>'title',
1201
+ product_payload->>'slug',
1202
+ product_payload->>'sku',
1203
+ v_product_type,
1204
+ v_payment_provider,
1205
+ NULLIF(product_payload->>'upc', ''),
1206
+ CASE
1207
+ WHEN v_has_variants THEN v_total_variant_stock
1208
+ ELSE COALESCE((product_payload->>'stock')::integer, 0)
1209
+ END,
1210
+ COALESCE(product_payload->>'status', 'draft'),
1211
+ NULLIF(product_payload->>'short_description', ''),
1212
+ product_payload->'description_json',
1213
+ COALESCE(product_payload->'metadata', '{}'::jsonb),
1214
+ COALESCE((product_payload->>'price')::integer, 0),
1215
+ COALESCE(product_payload->'prices', '{}'::jsonb),
1216
+ CASE
1217
+ WHEN product_payload ? 'sale_price' AND product_payload->>'sale_price' <> '' THEN
1218
+ (product_payload->>'sale_price')::integer
1219
+ ELSE
1220
+ NULL
1221
+ END,
1222
+ CASE
1223
+ WHEN product_payload ? 'sale_prices' THEN COALESCE(product_payload->'sale_prices', '{}'::jsonb)
1224
+ ELSE NULL
1225
+ END,
1226
+ CASE
1227
+ WHEN product_payload ? 'sale_start_at' AND product_payload->>'sale_start_at' <> '' THEN
1228
+ (product_payload->>'sale_start_at')::timestamptz
1229
+ ELSE
1230
+ NULL
1231
+ END,
1232
+ CASE
1233
+ WHEN product_payload ? 'sale_end_at' AND product_payload->>'sale_end_at' <> '' THEN
1234
+ (product_payload->>'sale_end_at')::timestamptz
1235
+ ELSE
1236
+ NULL
1237
+ END,
1238
+ NULLIF(product_payload->>'freemius_plan_id', ''),
1239
+ NULLIF(product_payload->>'freemius_product_id', ''),
1240
+ COALESCE((product_payload->>'trial_period_days')::integer, 0),
1241
+ COALESCE((product_payload->>'trial_requires_payment_method')::boolean, false),
1242
+ (product_payload->>'language_id')::bigint,
1243
+ COALESCE(v_translation_group_id, gen_random_uuid())
1244
+ )
1245
+ RETURNING id INTO v_product_id;
1246
+ ELSE
1247
+ UPDATE public.products
1248
+ SET
1249
+ title = product_payload->>'title',
1250
+ slug = product_payload->>'slug',
1251
+ sku = product_payload->>'sku',
1252
+ product_type = v_product_type,
1253
+ payment_provider = v_payment_provider,
1254
+ upc = NULLIF(product_payload->>'upc', ''),
1255
+ stock = CASE
1256
+ WHEN v_has_variants THEN v_total_variant_stock
1257
+ ELSE COALESCE((product_payload->>'stock')::integer, 0)
1258
+ END,
1259
+ status = COALESCE(product_payload->>'status', status),
1260
+ short_description = NULLIF(product_payload->>'short_description', ''),
1261
+ description_json = product_payload->'description_json',
1262
+ metadata = COALESCE(product_payload->'metadata', '{}'::jsonb),
1263
+ price = COALESCE((product_payload->>'price')::integer, 0),
1264
+ prices = COALESCE(product_payload->'prices', '{}'::jsonb),
1265
+ sale_price = CASE
1266
+ WHEN product_payload ? 'sale_price' AND product_payload->>'sale_price' <> '' THEN
1267
+ (product_payload->>'sale_price')::integer
1268
+ ELSE
1269
+ NULL
1270
+ END,
1271
+ sale_prices = CASE
1272
+ WHEN product_payload ? 'sale_prices' THEN COALESCE(product_payload->'sale_prices', '{}'::jsonb)
1273
+ ELSE NULL
1274
+ END,
1275
+ sale_start_at = CASE
1276
+ WHEN product_payload ? 'sale_start_at' AND product_payload->>'sale_start_at' <> '' THEN
1277
+ (product_payload->>'sale_start_at')::timestamptz
1278
+ ELSE
1279
+ NULL
1280
+ END,
1281
+ sale_end_at = CASE
1282
+ WHEN product_payload ? 'sale_end_at' AND product_payload->>'sale_end_at' <> '' THEN
1283
+ (product_payload->>'sale_end_at')::timestamptz
1284
+ ELSE
1285
+ NULL
1286
+ END,
1287
+ freemius_plan_id = NULLIF(product_payload->>'freemius_plan_id', ''),
1288
+ freemius_product_id = NULLIF(product_payload->>'freemius_product_id', ''),
1289
+ trial_period_days = COALESCE((product_payload->>'trial_period_days')::integer, 0),
1290
+ trial_requires_payment_method = COALESCE((product_payload->>'trial_requires_payment_method')::boolean, false),
1291
+ language_id = COALESCE((product_payload->>'language_id')::bigint, language_id),
1292
+ translation_group_id = COALESCE(v_translation_group_id, translation_group_id),
1293
+ updated_at = now()
1294
+ WHERE id = v_product_id;
1295
+
1296
+ IF NOT FOUND THEN
1297
+ RAISE EXCEPTION 'Product not found';
1298
+ END IF;
1299
+ END IF;
1300
+
1301
+ DELETE FROM public.variant_attribute_mapping
1302
+ WHERE variant_id IN (
1303
+ SELECT id
1304
+ FROM public.product_variants
1305
+ WHERE product_id = v_product_id
1306
+ );
1307
+
1308
+ DELETE FROM public.product_variants
1309
+ WHERE product_id = v_product_id;
1310
+
1311
+ IF v_has_variants THEN
1312
+ FOR v_variant IN
1313
+ SELECT value FROM jsonb_array_elements(v_variants)
1314
+ LOOP
1315
+ INSERT INTO public.product_variants (
1316
+ product_id,
1317
+ sku,
1318
+ upc,
1319
+ price,
1320
+ prices,
1321
+ sale_price,
1322
+ sale_prices,
1323
+ sale_start_at,
1324
+ sale_end_at,
1325
+ stock_quantity,
1326
+ main_media_id
1327
+ )
1328
+ VALUES (
1329
+ v_product_id,
1330
+ v_variant->>'sku',
1331
+ NULLIF(v_variant->>'upc', ''),
1332
+ COALESCE((v_variant->>'price')::integer, 0),
1333
+ COALESCE(v_variant->'prices', '{}'::jsonb),
1334
+ CASE
1335
+ WHEN v_variant ? 'sale_price' AND v_variant->>'sale_price' <> '' THEN
1336
+ (v_variant->>'sale_price')::integer
1337
+ ELSE
1338
+ NULL
1339
+ END,
1340
+ CASE
1341
+ WHEN v_variant ? 'sale_prices' THEN COALESCE(v_variant->'sale_prices', '{}'::jsonb)
1342
+ ELSE NULL
1343
+ END,
1344
+ CASE
1345
+ WHEN v_variant ? 'sale_start_at' AND v_variant->>'sale_start_at' <> '' THEN
1346
+ (v_variant->>'sale_start_at')::timestamptz
1347
+ ELSE
1348
+ NULL
1349
+ END,
1350
+ CASE
1351
+ WHEN v_variant ? 'sale_end_at' AND v_variant->>'sale_end_at' <> '' THEN
1352
+ (v_variant->>'sale_end_at')::timestamptz
1353
+ ELSE
1354
+ NULL
1355
+ END,
1356
+ COALESCE((v_variant->>'stock_quantity')::integer, 0),
1357
+ NULLIF(v_variant->>'main_media_id', '')::uuid
1358
+ )
1359
+ RETURNING id INTO v_variant_id;
1360
+
1361
+ FOR v_term_id IN
1362
+ SELECT jsonb_array_elements_text(COALESCE(v_variant->'attribute_term_ids', '[]'::jsonb))
1363
+ LOOP
1364
+ INSERT INTO public.variant_attribute_mapping (variant_id, attribute_term_id)
1365
+ VALUES (v_variant_id, v_term_id::uuid);
1366
+ END LOOP;
1367
+ END LOOP;
1368
+ END IF;
1369
+
1370
+ RETURN v_product_id;
1371
+ END;
1372
+ $$;
1373
+
1374
+ CREATE TABLE IF NOT EXISTS public.blocks (
1375
+ id bigint NOT NULL,
1376
+ page_id bigint,
1377
+ post_id bigint,
1378
+ language_id bigint NOT NULL,
1379
+ block_type text NOT NULL,
1380
+ content jsonb,
1381
+ "order" integer DEFAULT 0 NOT NULL,
1382
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1383
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
1384
+ product_id uuid,
1385
+ CONSTRAINT check_exactly_one_parent CHECK ((((page_id IS NOT NULL) AND (post_id IS NULL) AND (product_id IS NULL)) OR ((post_id IS NOT NULL) AND (page_id IS NULL) AND (product_id IS NULL)) OR ((product_id IS NOT NULL) AND (page_id IS NULL) AND (post_id IS NULL))))
1386
+ );
1387
+
1388
+ COMMENT ON TABLE public.blocks IS 'Stores content blocks for pages and posts.';
1389
+
1390
+ COMMENT ON COLUMN public.blocks.block_type IS 'Type of the block, e.g., "text", "image".';
1391
+
1392
+ COMMENT ON COLUMN public.blocks.content IS 'JSONB content specific to the block_type.';
1393
+
1394
+ COMMENT ON COLUMN public.blocks."order" IS 'Sort order of the block.';
1395
+
1396
+ DO $rb$ BEGIN
1397
+ IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.blocks'::regclass AND attname = 'id' AND attidentity <> '') THEN
1398
+ ALTER TABLE public.blocks ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1399
+ SEQUENCE NAME public.blocks_id_seq
1400
+ START WITH 1
1401
+ INCREMENT BY 1
1402
+ NO MINVALUE
1403
+ NO MAXVALUE
1404
+ CACHE 1
1405
+ );
1406
+ END IF;
1407
+ END $rb$;
1408
+
1409
+ CREATE TABLE IF NOT EXISTS public.categories (
1410
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1411
+ name text NOT NULL,
1412
+ slug text NOT NULL,
1413
+ description text,
1414
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1415
+ name_translations jsonb DEFAULT '{}'::jsonb NOT NULL,
1416
+ description_translations jsonb DEFAULT '{}'::jsonb NOT NULL
1417
+ );
1418
+
1419
+ COMMENT ON TABLE public.categories IS 'Product categories for organizing catalog items.';
1420
+
1421
+ COMMENT ON COLUMN public.categories.name_translations IS 'Translated category names (e.g. {"fr": "Numérique"}).';
1422
+
1423
+ COMMENT ON COLUMN public.categories.description_translations IS 'Translated category descriptions.';
1424
+
1425
+ CREATE TABLE IF NOT EXISTS public.cms_interactions (
1426
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1427
+ type public.interaction_type NOT NULL,
1428
+ status public.approval_status DEFAULT 'pending'::public.approval_status NOT NULL,
1429
+ content text NOT NULL,
1430
+ rating integer,
1431
+ user_id uuid NOT NULL,
1432
+ product_id uuid,
1433
+ post_id bigint,
1434
+ reactions jsonb DEFAULT '{}'::jsonb NOT NULL,
1435
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1436
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
1437
+ 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))))
1439
+ );
1440
+
1441
+ COMMENT ON TABLE public.cms_interactions IS 'Stores user-submitted product reviews and blog post comments.';
1442
+
1443
+ COMMENT ON COLUMN public.cms_interactions.rating IS 'Star rating 1-5, only populated for product reviews.';
1444
+
1445
+ COMMENT ON COLUMN public.cms_interactions.user_id IS 'References public.profiles.id';
1446
+
1447
+ COMMENT ON COLUMN public.cms_interactions.reactions IS 'JSONB structure tracking counts of reactions (likes, etc.).';
1448
+
1449
+ CREATE TABLE IF NOT EXISTS public.content_drafts (
1450
+ id bigint NOT NULL,
1451
+ parent_type text NOT NULL,
1452
+ parent_id bigint NOT NULL,
1453
+ author_id uuid,
1454
+ base_version integer DEFAULT 1 NOT NULL,
1455
+ meta jsonb DEFAULT '{}'::jsonb NOT NULL,
1456
+ blocks jsonb DEFAULT '[]'::jsonb NOT NULL,
1457
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1458
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
1459
+ CONSTRAINT content_drafts_blocks_array CHECK ((jsonb_typeof(blocks) = 'array'::text)),
1460
+ CONSTRAINT content_drafts_meta_object CHECK ((jsonb_typeof(meta) = 'object'::text)),
1461
+ CONSTRAINT content_drafts_parent_type_check CHECK ((parent_type = ANY (ARRAY['page'::text, 'post'::text])))
1462
+ );
1463
+
1464
+ COMMENT ON TABLE public.content_drafts IS 'Draft snapshots used by Draft Mode and front-end visual editing before publishing to live page/post rows.';
1465
+
1466
+ COMMENT ON COLUMN public.content_drafts.parent_type IS 'The content table this draft belongs to: page or post.';
1467
+
1468
+ COMMENT ON COLUMN public.content_drafts.parent_id IS 'ID of the page or post being drafted.';
1469
+
1470
+ COMMENT ON COLUMN public.content_drafts.base_version IS 'Published page/post version the draft was created from.';
1471
+
1472
+ COMMENT ON COLUMN public.content_drafts.meta IS 'Draft page/post metadata snapshot.';
1473
+
1474
+ COMMENT ON COLUMN public.content_drafts.blocks IS 'Ordered draft block snapshot, including block ids, block types, content, language ids, and order values.';
1475
+
1476
+ DO $rb$ BEGIN
1477
+ IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.content_drafts'::regclass AND attname = 'id' AND attidentity <> '') THEN
1478
+ ALTER TABLE public.content_drafts ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1479
+ SEQUENCE NAME public.content_drafts_id_seq
1480
+ START WITH 1
1481
+ INCREMENT BY 1
1482
+ NO MINVALUE
1483
+ NO MAXVALUE
1484
+ CACHE 1
1485
+ );
1486
+ END IF;
1487
+ END $rb$;
1488
+
1489
+ CREATE TABLE IF NOT EXISTS public.cortex_ai_db_mutation_audit (
1490
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1491
+ actor_user_id uuid,
1492
+ tool_name text NOT NULL,
1493
+ action_name text NOT NULL,
1494
+ target_tables text[] DEFAULT '{}'::text[] NOT NULL,
1495
+ operation_summary text NOT NULL,
1496
+ payload_hash text NOT NULL,
1497
+ payload jsonb DEFAULT '{}'::jsonb NOT NULL,
1498
+ preview jsonb DEFAULT '{}'::jsonb NOT NULL,
1499
+ status text NOT NULL,
1500
+ error_message text,
1501
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1502
+ CONSTRAINT cortex_ai_db_mutation_audit_status_check CHECK ((status = ANY (ARRAY['success'::text, 'failure'::text])))
1503
+ );
1504
+
1505
+ COMMENT ON TABLE public.cortex_ai_db_mutation_audit IS 'Audit trail for confirmed Cortex AI database mutation attempts.';
1506
+
1507
+ COMMENT ON COLUMN public.cortex_ai_db_mutation_audit.payload IS 'Redacted tool input payload for the confirmed mutation attempt.';
1508
+
1509
+ COMMENT ON COLUMN public.cortex_ai_db_mutation_audit.preview IS 'Redacted confirmation preview shown before the mutation was confirmed.';
1510
+
1511
+ CREATE TABLE IF NOT EXISTS public.coupon_freemius_mappings (
1512
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1513
+ coupon_id uuid NOT NULL,
1514
+ product_id uuid,
1515
+ freemius_product_id text NOT NULL,
1516
+ freemius_coupon_id text,
1517
+ freemius_coupon_code text NOT NULL,
1518
+ sync_status text DEFAULT 'pending'::text NOT NULL,
1519
+ sync_error text,
1520
+ remote_payload jsonb,
1521
+ last_synced_at timestamp with time zone,
1522
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1523
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
1524
+ CONSTRAINT coupon_freemius_mappings_code_not_blank CHECK ((char_length(btrim(freemius_coupon_code)) > 0)),
1525
+ CONSTRAINT coupon_freemius_mappings_product_not_blank CHECK ((char_length(btrim(freemius_product_id)) > 0)),
1526
+ CONSTRAINT coupon_freemius_mappings_sync_status_valid CHECK ((sync_status = ANY (ARRAY['pending'::text, 'synced'::text, 'failed'::text, 'deleted'::text])))
1527
+ );
1528
+
1529
+ CREATE TABLE IF NOT EXISTS public.coupon_products (
1530
+ coupon_id uuid NOT NULL,
1531
+ product_id uuid NOT NULL,
1532
+ created_at timestamp with time zone DEFAULT now() NOT NULL
1533
+ );
1534
+
1535
+ COMMENT ON TABLE public.coupon_products IS 'Optional product allow-list for coupons. No rows means all products in the provider scope are eligible.';
1536
+
1537
+ CREATE TABLE IF NOT EXISTS public.coupon_redemptions (
1538
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1539
+ coupon_id uuid,
1540
+ order_id uuid,
1541
+ coupon_code text NOT NULL,
1542
+ provider text NOT NULL,
1543
+ discount_total integer DEFAULT 0 NOT NULL,
1544
+ user_id uuid,
1545
+ customer_email text,
1546
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
1547
+ redeemed_at timestamp with time zone DEFAULT now() NOT NULL,
1548
+ CONSTRAINT coupon_redemptions_code_not_blank CHECK ((char_length(btrim(coupon_code)) > 0)),
1549
+ CONSTRAINT coupon_redemptions_discount_total_check CHECK ((discount_total >= 0)),
1550
+ CONSTRAINT coupon_redemptions_provider_check CHECK ((provider = ANY (ARRAY['stripe'::text, 'freemius'::text])))
1551
+ );
1552
+
1553
+ CREATE TABLE IF NOT EXISTS public.coupons (
1554
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1555
+ code text NOT NULL,
1556
+ name text NOT NULL,
1557
+ internal_note text,
1558
+ provider_scope text DEFAULT 'all'::text NOT NULL,
1559
+ discount_type text NOT NULL,
1560
+ discount_amount integer NOT NULL,
1561
+ is_active boolean DEFAULT true NOT NULL,
1562
+ starts_at timestamp with time zone,
1563
+ ends_at timestamp with time zone,
1564
+ redemption_limit integer,
1565
+ redemptions_count integer DEFAULT 0 NOT NULL,
1566
+ freemius_sync_status text DEFAULT 'not_synced'::text NOT NULL,
1567
+ freemius_sync_error text,
1568
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1569
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
1570
+ CONSTRAINT coupons_code_not_blank CHECK ((char_length(btrim(code)) > 0)),
1571
+ CONSTRAINT coupons_date_window_valid CHECK (((starts_at IS NULL) OR (ends_at IS NULL) OR (starts_at < ends_at))),
1572
+ CONSTRAINT coupons_discount_amount_positive CHECK ((discount_amount > 0)),
1573
+ CONSTRAINT coupons_discount_type_valid CHECK ((discount_type = ANY (ARRAY['percent'::text, 'fixed'::text]))),
1574
+ CONSTRAINT coupons_freemius_sync_status_valid CHECK ((freemius_sync_status = ANY (ARRAY['not_synced'::text, 'pending'::text, 'synced'::text, 'failed'::text, 'not_required'::text]))),
1575
+ CONSTRAINT coupons_name_not_blank CHECK ((char_length(btrim(name)) > 0)),
1576
+ CONSTRAINT coupons_percent_amount_valid CHECK (((discount_type <> 'percent'::text) OR (discount_amount <= 100))),
1577
+ CONSTRAINT coupons_provider_scope_valid CHECK ((provider_scope = ANY (ARRAY['all'::text, 'stripe'::text, 'freemius'::text]))),
1578
+ CONSTRAINT coupons_redemption_limit_positive CHECK (((redemption_limit IS NULL) OR (redemption_limit > 0))),
1579
+ CONSTRAINT coupons_redemptions_count_nonnegative CHECK ((redemptions_count >= 0))
1580
+ );
1581
+
1582
+ COMMENT ON TABLE public.coupons IS 'Unified commerce coupons managed by NextBlock CMS and applied through provider-aware checkout.';
1583
+
1584
+ COMMENT ON COLUMN public.coupons.provider_scope IS 'Provider eligibility: all, stripe, or freemius.';
1585
+
1586
+ COMMENT ON COLUMN public.coupons.discount_amount IS 'Percent value for percent coupons, or fixed minor-unit amount for fixed coupons.';
1587
+
1588
+ COMMENT ON COLUMN public.coupons.freemius_sync_status IS 'Aggregate status for syncing this coupon to Freemius product coupon records.';
1589
+
1590
+ CREATE TABLE IF NOT EXISTS public.currencies (
1591
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1592
+ code text NOT NULL,
1593
+ symbol text NOT NULL,
1594
+ exchange_rate numeric(20,10) NOT NULL,
1595
+ is_default boolean DEFAULT false NOT NULL,
1596
+ is_active boolean DEFAULT true NOT NULL,
1597
+ rounding_mode text DEFAULT 'none'::text NOT NULL,
1598
+ rounding_increment integer DEFAULT 1 NOT NULL,
1599
+ rounding_charm_amount integer,
1600
+ auto_update_exchange_rate boolean DEFAULT true NOT NULL,
1601
+ exchange_rate_updated_at timestamp with time zone,
1602
+ exchange_rate_source text,
1603
+ auto_sync_product_prices boolean DEFAULT false NOT NULL,
1604
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1605
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
1606
+ CONSTRAINT currencies_charm_requires_amount CHECK (((rounding_mode <> 'charm'::text) OR (rounding_charm_amount IS NOT NULL))),
1607
+ CONSTRAINT currencies_code_check CHECK ((code ~ '^[A-Z]{3}$'::text)),
1608
+ CONSTRAINT currencies_default_auto_update_disabled CHECK (((NOT is_default) OR (auto_update_exchange_rate = false))),
1609
+ CONSTRAINT currencies_default_exchange_rate_is_one CHECK (((NOT is_default) OR (exchange_rate = (1)::numeric))),
1610
+ CONSTRAINT currencies_default_must_be_active CHECK (((NOT is_default) OR is_active)),
1611
+ CONSTRAINT currencies_default_product_price_sync_disabled CHECK (((NOT is_default) OR (auto_sync_product_prices = false))),
1612
+ CONSTRAINT currencies_exchange_rate_check CHECK ((exchange_rate > (0)::numeric)),
1613
+ CONSTRAINT currencies_rounding_charm_nonnegative CHECK (((rounding_charm_amount IS NULL) OR (rounding_charm_amount >= 0))),
1614
+ CONSTRAINT currencies_rounding_increment_positive CHECK ((rounding_increment > 0)),
1615
+ CONSTRAINT currencies_rounding_mode_valid CHECK ((rounding_mode = ANY (ARRAY['none'::text, 'nearest'::text, 'up'::text, 'down'::text, 'charm'::text])))
1616
+ );
1617
+
1618
+ COMMENT ON TABLE public.currencies IS 'Store currencies available for storefront display and conversion.';
1619
+
1620
+ COMMENT ON COLUMN public.currencies.exchange_rate IS 'Relative to the current store default currency. The default currency should have exchange_rate = 1.';
1621
+
1622
+ COMMENT ON COLUMN public.currencies.rounding_mode IS 'Rounding strategy applied when prices are auto-converted into this currency.';
1623
+
1624
+ COMMENT ON COLUMN public.currencies.rounding_increment IS 'Rounding step in the currency smallest unit. Example: 5 means 0.05 for USD/CAD.';
1625
+
1626
+ COMMENT ON COLUMN public.currencies.rounding_charm_amount IS 'Charm ending in the currency smallest unit. Example: 90 means prices like 29.90.';
1627
+
1628
+ COMMENT ON COLUMN public.currencies.auto_update_exchange_rate IS 'Whether scheduled FX sync jobs should refresh this currency.';
1629
+
1630
+ COMMENT ON COLUMN public.currencies.exchange_rate_updated_at IS 'When this currency exchange rate was last refreshed or manually set.';
1631
+
1632
+ COMMENT ON COLUMN public.currencies.exchange_rate_source IS 'Human-readable source for the current exchange rate, such as a provider host or manual override.';
1633
+
1634
+ COMMENT ON COLUMN public.currencies.auto_sync_product_prices IS 'Whether storefront product and variant prices in this currency are derived automatically from the store default currency using FX and rounding rules.';
1635
+
1636
+ CREATE TABLE IF NOT EXISTS public.email_2fa_challenges (
1637
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1638
+ user_id uuid NOT NULL,
1639
+ token_hash text NOT NULL,
1640
+ expires_at timestamp with time zone NOT NULL,
1641
+ consumed_at timestamp with time zone,
1642
+ created_at timestamp with time zone DEFAULT now() NOT NULL
1643
+ );
1644
+
1645
+ 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
+
1647
+ CREATE TABLE IF NOT EXISTS public.freemius_plans (
1648
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1649
+ product_id uuid NOT NULL,
1650
+ name text NOT NULL,
1651
+ title text,
1652
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1653
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
1654
+ );
1655
+
1656
+ CREATE TABLE IF NOT EXISTS public.freemius_pricing (
1657
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1658
+ plan_id uuid NOT NULL,
1659
+ api_monthly_price numeric,
1660
+ api_annual_price numeric,
1661
+ api_lifetime_price numeric,
1662
+ override_monthly_price numeric,
1663
+ override_annual_price numeric,
1664
+ override_lifetime_price numeric,
1665
+ license_quota integer,
1666
+ is_active boolean DEFAULT true NOT NULL,
1667
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1668
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
1669
+ );
1670
+
1671
+ CREATE TABLE IF NOT EXISTS public.inventory_items (
1672
+ sku text NOT NULL,
1673
+ quantity integer DEFAULT 0 NOT NULL,
1674
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1675
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
1676
+ CONSTRAINT inventory_items_quantity_check CHECK ((quantity >= 0))
1677
+ );
1678
+
1679
+ COMMENT ON TABLE public.inventory_items IS 'Source-of-truth inventory records keyed by sellable SKU.';
1680
+
1681
+ COMMENT ON COLUMN public.inventory_items.sku IS 'Global sellable SKU. Matching products or variants share inventory.';
1682
+
1683
+ COMMENT ON COLUMN public.inventory_items.quantity IS 'Available quantity for this SKU.';
1684
+
1685
+ CREATE TABLE IF NOT EXISTS public.languages (
1686
+ id bigint NOT NULL,
1687
+ code text NOT NULL,
1688
+ name text NOT NULL,
1689
+ is_default boolean DEFAULT false NOT NULL,
1690
+ is_active boolean DEFAULT true,
1691
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1692
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
1693
+ );
1694
+
1695
+ COMMENT ON TABLE public.languages IS 'Stores supported languages for the CMS.';
1696
+
1697
+ COMMENT ON COLUMN public.languages.code IS 'BCP 47 language code.';
1698
+
1699
+ DO $rb$ BEGIN
1700
+ IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.languages'::regclass AND attname = 'id' AND attidentity <> '') THEN
1701
+ ALTER TABLE public.languages ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1702
+ SEQUENCE NAME public.languages_id_seq
1703
+ START WITH 1
1704
+ INCREMENT BY 1
1705
+ NO MINVALUE
1706
+ NO MAXVALUE
1707
+ CACHE 1
1708
+ );
1709
+ END IF;
1710
+ END $rb$;
1711
+
1712
+ CREATE TABLE IF NOT EXISTS public.logos (
1713
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1714
+ name text NOT NULL,
1715
+ media_id uuid,
1716
+ created_at timestamp with time zone DEFAULT now() NOT NULL
1717
+ );
1718
+
1719
+ COMMENT ON TABLE public.logos IS 'Stores company and brand logos.';
1720
+
1721
+ COMMENT ON COLUMN public.logos.name IS 'The name of the brand or company for the logo.';
1722
+
1723
+ COMMENT ON COLUMN public.logos.media_id IS 'Foreign key to the media table for the logo image.';
1724
+
1725
+ CREATE TABLE IF NOT EXISTS public.media (
1726
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1727
+ uploader_id uuid,
1728
+ file_name text NOT NULL,
1729
+ object_key text NOT NULL,
1730
+ file_type text,
1731
+ size_bytes bigint,
1732
+ description text,
1733
+ width integer,
1734
+ height integer,
1735
+ blur_data_url text,
1736
+ variants jsonb,
1737
+ file_path text,
1738
+ folder text,
1739
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1740
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
1741
+ );
1742
+
1743
+ COMMENT ON TABLE public.media IS 'Stores information about uploaded media assets.';
1744
+
1745
+ COMMENT ON COLUMN public.media.object_key IS 'Unique key (path) in Cloudflare R2.';
1746
+
1747
+ COMMENT ON COLUMN public.media.width IS 'Width of the image in pixels.';
1748
+
1749
+ COMMENT ON COLUMN public.media.height IS 'Height of the image in pixels.';
1750
+
1751
+ COMMENT ON COLUMN public.media.blur_data_url IS 'Base64 encoded string for image blur placeholders.';
1752
+
1753
+ COMMENT ON COLUMN public.media.variants IS 'Array of image variant objects.';
1754
+
1755
+ COMMENT ON COLUMN public.media.file_path IS 'Full path to the file in the storage bucket.';
1756
+
1757
+ COMMENT ON COLUMN public.media.folder IS 'Folder path prefix for the R2 object.';
1758
+
1759
+ CREATE TABLE IF NOT EXISTS public.navigation_items (
1760
+ id bigint NOT NULL,
1761
+ language_id bigint NOT NULL,
1762
+ menu_key public.menu_location NOT NULL,
1763
+ label text NOT NULL,
1764
+ url text NOT NULL,
1765
+ parent_id bigint,
1766
+ "order" integer DEFAULT 0 NOT NULL,
1767
+ page_id bigint,
1768
+ translation_group_id uuid DEFAULT gen_random_uuid() NOT NULL,
1769
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1770
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
1771
+ );
1772
+
1773
+ COMMENT ON TABLE public.navigation_items IS 'Stores navigation menu items.';
1774
+
1775
+ COMMENT ON COLUMN public.navigation_items.menu_key IS 'Identifies the menu this item belongs to.';
1776
+
1777
+ DO $rb$ BEGIN
1778
+ IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.navigation_items'::regclass AND attname = 'id' AND attidentity <> '') THEN
1779
+ ALTER TABLE public.navigation_items ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1780
+ SEQUENCE NAME public.navigation_items_id_seq
1781
+ START WITH 1
1782
+ INCREMENT BY 1
1783
+ NO MINVALUE
1784
+ NO MAXVALUE
1785
+ CACHE 1
1786
+ );
1787
+ END IF;
1788
+ END $rb$;
1789
+
1790
+ CREATE SEQUENCE IF NOT EXISTS public.order_invoice_number_seq
1791
+ START WITH 1
1792
+ INCREMENT BY 1
1793
+ NO MINVALUE
1794
+ NO MAXVALUE
1795
+ CACHE 1;
1796
+
1797
+ CREATE TABLE IF NOT EXISTS public.order_items (
1798
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1799
+ order_id uuid NOT NULL,
1800
+ product_id uuid,
1801
+ variant_id uuid,
1802
+ quantity integer NOT NULL,
1803
+ price_at_purchase integer NOT NULL
1804
+ );
1805
+
1806
+ CREATE TABLE IF NOT EXISTS public.orders (
1807
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1808
+ user_id uuid,
1809
+ status text DEFAULT 'pending'::text NOT NULL,
1810
+ total integer NOT NULL,
1811
+ stripe_session_id text,
1812
+ payment_intent_id text,
1813
+ customer_details jsonb,
1814
+ provider text DEFAULT 'stripe'::text,
1815
+ freemius_product_id text,
1816
+ freemius_plan_id text,
1817
+ freemius_license_id text,
1818
+ freemius_subscription_id text,
1819
+ freemius_trial_id text,
1820
+ freemius_user_id text,
1821
+ freemius_trial_ends_at timestamp with time zone,
1822
+ freemius_last_event_type text,
1823
+ freemius_last_synced_at timestamp with time zone,
1824
+ currency text DEFAULT 'USD'::text NOT NULL,
1825
+ subtotal integer,
1826
+ shipping_total integer,
1827
+ tax_total integer DEFAULT 0 NOT NULL,
1828
+ tax_details jsonb,
1829
+ exchange_rate_at_purchase numeric(20,10) DEFAULT 1 NOT NULL,
1830
+ inventory_deducted_at timestamp with time zone,
1831
+ invoice_number text,
1832
+ paid_at timestamp with time zone,
1833
+ created_at timestamp with time zone DEFAULT now(),
1834
+ coupon_id uuid,
1835
+ coupon_code text,
1836
+ discount_total integer DEFAULT 0 NOT NULL,
1837
+ discount_details jsonb,
1838
+ CONSTRAINT orders_discount_total_check CHECK ((discount_total >= 0)),
1839
+ CONSTRAINT orders_exchange_rate_at_purchase_positive CHECK ((exchange_rate_at_purchase > (0)::numeric)),
1840
+ CONSTRAINT orders_provider_check CHECK ((provider = ANY (ARRAY['stripe'::text, 'freemius'::text]))),
1841
+ CONSTRAINT orders_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'trial'::text, 'paid'::text, 'shipped'::text, 'cancelled'::text, 'refunded'::text])))
1842
+ );
1843
+
1844
+ COMMENT ON COLUMN public.orders.freemius_license_id IS 'Freemius license ID used to reconcile checkout callbacks and webhooks.';
1845
+
1846
+ COMMENT ON COLUMN public.orders.freemius_subscription_id IS 'Freemius subscription ID when the order is associated with recurring billing.';
1847
+
1848
+ COMMENT ON COLUMN public.orders.freemius_trial_id IS 'Freemius trial ID when checkout starts in trial mode.';
1849
+
1850
+ COMMENT ON COLUMN public.orders.freemius_trial_ends_at IS 'Freemius trial expiration timestamp when supplied by checkout or webhook data.';
1851
+
1852
+ COMMENT ON COLUMN public.orders.freemius_last_event_type IS 'Last Freemius checkout callback or webhook event applied to the order.';
1853
+
1854
+ COMMENT ON COLUMN public.orders.freemius_last_synced_at IS 'Timestamp when Freemius metadata was last reconciled locally.';
1855
+
1856
+ COMMENT ON COLUMN public.orders.currency IS 'ISO currency code used for the order totals.';
1857
+
1858
+ COMMENT ON COLUMN public.orders.subtotal IS 'Subtotal before shipping and tax, in the smallest currency unit.';
1859
+
1860
+ COMMENT ON COLUMN public.orders.shipping_total IS 'Shipping amount before tax, in the smallest currency unit.';
1861
+
1862
+ COMMENT ON COLUMN public.orders.tax_total IS 'Total tax amount collected for the order, in the smallest currency unit.';
1863
+
1864
+ COMMENT ON COLUMN public.orders.tax_details IS 'Normalized tax breakdown payload sourced from manual rates or finalized Stripe tax data.';
1865
+
1866
+ COMMENT ON COLUMN public.orders.exchange_rate_at_purchase IS 'Exchange rate locked at purchase time relative to the store default currency.';
1867
+
1868
+ COMMENT ON COLUMN public.orders.invoice_number IS 'Stable printable invoice number assigned once when the order first becomes paid.';
1869
+
1870
+ COMMENT ON COLUMN public.orders.paid_at IS 'Timestamp when the order was first marked as paid.';
1871
+
1872
+ COMMENT ON COLUMN public.orders.coupon_code IS 'Coupon code applied to the order at checkout time.';
1873
+
1874
+ COMMENT ON COLUMN public.orders.discount_total IS 'Total discount applied to this order in the smallest currency unit.';
1875
+
1876
+ COMMENT ON COLUMN public.orders.discount_details IS 'Provider-aware coupon quote details captured at checkout.';
1877
+
1878
+ CREATE TABLE IF NOT EXISTS public.package_activations (
1879
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
1880
+ license_key text NOT NULL,
1881
+ instance_name text NOT NULL,
1882
+ package_id text NOT NULL,
1883
+ status text DEFAULT 'active'::text NOT NULL,
1884
+ meta jsonb DEFAULT '{}'::jsonb,
1885
+ last_validated_at timestamp with time zone DEFAULT now(),
1886
+ created_at timestamp with time zone DEFAULT now()
1887
+ );
1888
+
1889
+ CREATE TABLE IF NOT EXISTS public.page_revisions (
1890
+ id bigint NOT NULL,
1891
+ page_id bigint NOT NULL,
1892
+ author_id uuid,
1893
+ version integer NOT NULL,
1894
+ revision_type public.revision_type NOT NULL,
1895
+ content jsonb NOT NULL,
1896
+ created_at timestamp with time zone DEFAULT now() NOT NULL
1897
+ );
1898
+
1899
+ COMMENT ON TABLE public.page_revisions IS 'Hybrid (snapshot/diff) revisions for pages.';
1900
+
1901
+ COMMENT ON COLUMN public.page_revisions.content IS 'If snapshot: full content; if diff: JSON Patch array.';
1902
+
1903
+ DO $rb$ BEGIN
1904
+ IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.page_revisions'::regclass AND attname = 'id' AND attidentity <> '') THEN
1905
+ ALTER TABLE public.page_revisions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1906
+ SEQUENCE NAME public.page_revisions_id_seq
1907
+ START WITH 1
1908
+ INCREMENT BY 1
1909
+ NO MINVALUE
1910
+ NO MAXVALUE
1911
+ CACHE 1
1912
+ );
1913
+ END IF;
1914
+ END $rb$;
1915
+
1916
+ CREATE TABLE IF NOT EXISTS public.pages (
1917
+ id bigint NOT NULL,
1918
+ language_id bigint NOT NULL,
1919
+ author_id uuid,
1920
+ title text NOT NULL,
1921
+ slug text NOT NULL,
1922
+ status public.page_status DEFAULT 'draft'::public.page_status NOT NULL,
1923
+ meta_title text,
1924
+ meta_description text,
1925
+ version integer DEFAULT 1 NOT NULL,
1926
+ translation_group_id uuid DEFAULT gen_random_uuid() NOT NULL,
1927
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1928
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
1929
+ feature_image_id uuid
1930
+ );
1931
+
1932
+ COMMENT ON TABLE public.pages IS 'Stores static pages for the website.';
1933
+
1934
+ COMMENT ON COLUMN public.pages.slug IS 'URL-friendly identifier, unique per language.';
1935
+
1936
+ COMMENT ON COLUMN public.pages.version IS 'Monotonic version number for hybrid revisions.';
1937
+
1938
+ COMMENT ON COLUMN public.pages.translation_group_id IS 'Groups different language versions of the same conceptual page.';
1939
+
1940
+ COMMENT ON COLUMN public.pages.feature_image_id IS 'ID of the media item to be used as the page feature image.';
1941
+
1942
+ DO $rb$ BEGIN
1943
+ IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.pages'::regclass AND attname = 'id' AND attidentity <> '') THEN
1944
+ ALTER TABLE public.pages ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1945
+ SEQUENCE NAME public.pages_id_seq
1946
+ START WITH 1
1947
+ INCREMENT BY 1
1948
+ NO MINVALUE
1949
+ NO MAXVALUE
1950
+ CACHE 1
1951
+ );
1952
+ END IF;
1953
+ END $rb$;
1954
+
1955
+ CREATE TABLE IF NOT EXISTS public.post_revisions (
1956
+ id bigint NOT NULL,
1957
+ post_id bigint NOT NULL,
1958
+ author_id uuid,
1959
+ version integer NOT NULL,
1960
+ revision_type public.revision_type NOT NULL,
1961
+ content jsonb NOT NULL,
1962
+ created_at timestamp with time zone DEFAULT now() NOT NULL
1963
+ );
1964
+
1965
+ COMMENT ON TABLE public.post_revisions IS 'Hybrid (snapshot/diff) revisions for posts.';
1966
+
1967
+ COMMENT ON COLUMN public.post_revisions.content IS 'If snapshot: full content; if diff: JSON Patch array.';
1968
+
1969
+ DO $rb$ BEGIN
1970
+ IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.post_revisions'::regclass AND attname = 'id' AND attidentity <> '') THEN
1971
+ ALTER TABLE public.post_revisions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
1972
+ SEQUENCE NAME public.post_revisions_id_seq
1973
+ START WITH 1
1974
+ INCREMENT BY 1
1975
+ NO MINVALUE
1976
+ NO MAXVALUE
1977
+ CACHE 1
1978
+ );
1979
+ END IF;
1980
+ END $rb$;
1981
+
1982
+ CREATE TABLE IF NOT EXISTS public.posts (
1983
+ id bigint NOT NULL,
1984
+ language_id bigint NOT NULL,
1985
+ author_id uuid,
1986
+ title text NOT NULL,
1987
+ slug text NOT NULL,
1988
+ label text,
1989
+ excerpt text,
1990
+ subtitle text,
1991
+ status public.page_status DEFAULT 'draft'::public.page_status NOT NULL,
1992
+ published_at timestamp with time zone,
1993
+ meta_title text,
1994
+ meta_description text,
1995
+ feature_image_id uuid,
1996
+ version integer DEFAULT 1 NOT NULL,
1997
+ translation_group_id uuid DEFAULT gen_random_uuid() NOT NULL,
1998
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
1999
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
2000
+ );
2001
+
2002
+ COMMENT ON TABLE public.posts IS 'Stores blog posts or news articles.';
2003
+
2004
+ COMMENT ON COLUMN public.posts.slug IS 'URL-friendly identifier, unique per language.';
2005
+
2006
+ COMMENT ON COLUMN public.posts.label IS 'Short editorial label rendered as a pill on post hero and article cards.';
2007
+
2008
+ COMMENT ON COLUMN public.posts.excerpt IS 'Short editorial summary used in post metadata rows and post cards.';
2009
+
2010
+ COMMENT ON COLUMN public.posts.subtitle IS 'Longer deck shown under the post title.';
2011
+
2012
+ COMMENT ON COLUMN public.posts.feature_image_id IS 'ID of the media item to be used as the feature image.';
2013
+
2014
+ COMMENT ON COLUMN public.posts.version IS 'Monotonic version number for hybrid revisions.';
2015
+
2016
+ COMMENT ON COLUMN public.posts.translation_group_id IS 'Groups different language versions of the same conceptual post.';
2017
+
2018
+ DO $rb$ BEGIN
2019
+ IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.posts'::regclass AND attname = 'id' AND attidentity <> '') THEN
2020
+ ALTER TABLE public.posts ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
2021
+ SEQUENCE NAME public.posts_id_seq
2022
+ START WITH 1
2023
+ INCREMENT BY 1
2024
+ NO MINVALUE
2025
+ NO MAXVALUE
2026
+ CACHE 1
2027
+ );
2028
+ END IF;
2029
+ END $rb$;
2030
+
2031
+ CREATE TABLE IF NOT EXISTS public.privacy_consent_logs (
2032
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2033
+ consent_token text NOT NULL,
2034
+ categories jsonb DEFAULT '{"analytics": false, "marketing": false, "necessary": true}'::jsonb NOT NULL,
2035
+ ip_masked text,
2036
+ user_agent text,
2037
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2038
+ CONSTRAINT privacy_consent_logs_categories_check CHECK ((jsonb_typeof(categories) = 'object'::text))
2039
+ );
2040
+
2041
+ COMMENT ON TABLE public.privacy_consent_logs IS 'Immutable audit log of visitor consent decisions for Quebec Law 25 / PIPEDA accountability.';
2042
+
2043
+ COMMENT ON COLUMN public.privacy_consent_logs.consent_token IS 'Opaque token also stored in the nb_consent_preference cookie to correlate a decision with its record.';
2044
+
2045
+ COMMENT ON COLUMN public.privacy_consent_logs.ip_masked IS 'Partially masked IP (e.g. 203.0.113.x) - never store a full address for an analytics/marketing consent log.';
2046
+
2047
+ CREATE TABLE IF NOT EXISTS public.product_attribute_terms (
2048
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2049
+ attribute_id uuid NOT NULL,
2050
+ value text NOT NULL,
2051
+ slug text NOT NULL,
2052
+ sort_order integer DEFAULT 0 NOT NULL,
2053
+ value_translations jsonb DEFAULT '{}'::jsonb NOT NULL,
2054
+ created_at timestamp with time zone DEFAULT now(),
2055
+ updated_at timestamp with time zone DEFAULT now()
2056
+ );
2057
+
2058
+ CREATE TABLE IF NOT EXISTS public.product_attributes (
2059
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2060
+ name text NOT NULL,
2061
+ slug text NOT NULL,
2062
+ name_translations jsonb DEFAULT '{}'::jsonb NOT NULL,
2063
+ created_at timestamp with time zone DEFAULT now(),
2064
+ updated_at timestamp with time zone DEFAULT now()
2065
+ );
2066
+
2067
+ CREATE TABLE IF NOT EXISTS public.product_categories (
2068
+ product_id uuid NOT NULL,
2069
+ category_id uuid NOT NULL
2070
+ );
2071
+
2072
+ COMMENT ON TABLE public.product_categories IS 'Junction table mapping products to multiple categories.';
2073
+
2074
+ CREATE TABLE IF NOT EXISTS public.product_drafts (
2075
+ id bigint NOT NULL,
2076
+ product_id uuid NOT NULL,
2077
+ author_id uuid,
2078
+ meta jsonb DEFAULT '{}'::jsonb NOT NULL,
2079
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2080
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2081
+ blocks jsonb DEFAULT '[]'::jsonb NOT NULL,
2082
+ CONSTRAINT product_drafts_blocks_array CHECK ((jsonb_typeof(blocks) = 'array'::text)),
2083
+ CONSTRAINT product_drafts_meta_object CHECK ((jsonb_typeof(meta) = 'object'::text))
2084
+ );
2085
+
2086
+ COMMENT ON TABLE public.product_drafts IS 'Draft product metadata snapshots used by Draft Mode and front-end visual editing before publishing to live product rows.';
2087
+
2088
+ COMMENT ON COLUMN public.product_drafts.product_id IS 'ID of the product being drafted.';
2089
+
2090
+ COMMENT ON COLUMN public.product_drafts.meta IS 'Draft product metadata snapshot. Front-end visual editing currently mutates visible title, short_description, and description_json fields.';
2091
+
2092
+ DO $rb$ BEGIN
2093
+ IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.product_drafts'::regclass AND attname = 'id' AND attidentity <> '') THEN
2094
+ ALTER TABLE public.product_drafts ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
2095
+ SEQUENCE NAME public.product_drafts_id_seq
2096
+ START WITH 1
2097
+ INCREMENT BY 1
2098
+ NO MINVALUE
2099
+ NO MAXVALUE
2100
+ CACHE 1
2101
+ );
2102
+ END IF;
2103
+ END $rb$;
2104
+
2105
+ CREATE TABLE IF NOT EXISTS public.product_freemius_sale_coupons (
2106
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2107
+ product_id uuid NOT NULL,
2108
+ freemius_product_id text NOT NULL,
2109
+ freemius_plan_id text,
2110
+ freemius_coupon_id text,
2111
+ freemius_coupon_code text NOT NULL,
2112
+ discount_percent integer,
2113
+ starts_at timestamp with time zone,
2114
+ ends_at timestamp with time zone,
2115
+ is_active boolean DEFAULT false NOT NULL,
2116
+ sync_status text DEFAULT 'pending'::text NOT NULL,
2117
+ sync_error text,
2118
+ remote_payload jsonb,
2119
+ last_synced_at timestamp with time zone,
2120
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2121
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2122
+ CONSTRAINT product_freemius_sale_coupons_code_not_blank CHECK ((char_length(btrim(freemius_coupon_code)) > 0)),
2123
+ CONSTRAINT product_freemius_sale_coupons_discount_valid CHECK (((discount_percent IS NULL) OR ((discount_percent > 0) AND (discount_percent <= 100)))),
2124
+ CONSTRAINT product_freemius_sale_coupons_fm_product_not_blank CHECK ((char_length(btrim(freemius_product_id)) > 0)),
2125
+ CONSTRAINT product_freemius_sale_coupons_sync_status_valid CHECK ((sync_status = ANY (ARRAY['pending'::text, 'synced'::text, 'failed'::text, 'deleted'::text]))),
2126
+ CONSTRAINT product_freemius_sale_coupons_window_valid CHECK (((starts_at IS NULL) OR (ends_at IS NULL) OR (starts_at < ends_at)))
2127
+ );
2128
+
2129
+ 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
+
2131
+ CREATE TABLE IF NOT EXISTS public.product_media (
2132
+ product_id uuid NOT NULL,
2133
+ media_id uuid NOT NULL,
2134
+ sort_order integer DEFAULT 0
2135
+ );
2136
+
2137
+ CREATE TABLE IF NOT EXISTS public.product_variants (
2138
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2139
+ product_id uuid NOT NULL,
2140
+ sku text NOT NULL,
2141
+ price_adjustment integer DEFAULT 0 NOT NULL,
2142
+ price integer DEFAULT 0 NOT NULL,
2143
+ prices jsonb DEFAULT '{}'::jsonb NOT NULL,
2144
+ sale_price integer,
2145
+ sale_prices jsonb,
2146
+ stock_quantity integer DEFAULT 0 NOT NULL,
2147
+ upc text,
2148
+ main_media_id uuid,
2149
+ created_at timestamp with time zone DEFAULT now(),
2150
+ updated_at timestamp with time zone DEFAULT now(),
2151
+ sale_start_at timestamp with time zone,
2152
+ sale_end_at timestamp with time zone,
2153
+ scheduled_price integer,
2154
+ scheduled_prices jsonb,
2155
+ scheduled_price_at timestamp with time zone,
2156
+ CONSTRAINT product_variants_prices_is_valid CHECK (public.is_valid_currency_amount_map(prices)),
2157
+ CONSTRAINT product_variants_sale_prices_are_valid CHECK (public.is_valid_sale_price_map(prices, sale_prices)),
2158
+ CONSTRAINT product_variants_sale_window_valid CHECK (((sale_start_at IS NULL) OR (sale_end_at IS NULL) OR (sale_start_at < sale_end_at)))
2159
+ );
2160
+
2161
+ COMMENT ON COLUMN public.product_variants.prices IS 'Variant regular prices by ISO 4217 code in the smallest currency unit.';
2162
+
2163
+ COMMENT ON COLUMN public.product_variants.sale_prices IS 'Variant sale prices by ISO 4217 code in the smallest currency unit.';
2164
+
2165
+ COMMENT ON COLUMN public.product_variants.sale_start_at IS 'Inclusive start of the scheduled sale window (UTC) for this variant. NULL means no lower bound.';
2166
+
2167
+ COMMENT ON COLUMN public.product_variants.sale_end_at IS 'Exclusive end of the scheduled sale window (UTC) for this variant. NULL means no upper bound.';
2168
+
2169
+ COMMENT ON COLUMN public.product_variants.scheduled_price IS 'Pending regular price (smallest currency unit) applied once scheduled_price_at has passed.';
2170
+
2171
+ COMMENT ON COLUMN public.product_variants.scheduled_prices IS 'Pending multi-currency regular prices by ISO 4217 code, applied once scheduled_price_at has passed.';
2172
+
2173
+ COMMENT ON COLUMN public.product_variants.scheduled_price_at IS 'Effective timestamp (UTC) for the pending regular-price change.';
2174
+
2175
+ CREATE TABLE IF NOT EXISTS public.products (
2176
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2177
+ language_id bigint NOT NULL,
2178
+ translation_group_id uuid DEFAULT gen_random_uuid() NOT NULL,
2179
+ sku text NOT NULL,
2180
+ title text NOT NULL,
2181
+ slug text NOT NULL,
2182
+ product_type text NOT NULL,
2183
+ payment_provider text NOT NULL,
2184
+ price integer NOT NULL,
2185
+ prices jsonb DEFAULT '{}'::jsonb NOT NULL,
2186
+ sale_price integer,
2187
+ sale_prices jsonb,
2188
+ stock integer DEFAULT 0,
2189
+ status text DEFAULT 'draft'::text NOT NULL,
2190
+ meta_title text,
2191
+ meta_description text,
2192
+ short_description text,
2193
+ description_json jsonb,
2194
+ metadata jsonb,
2195
+ freemius_plan_id text,
2196
+ freemius_product_id text,
2197
+ trial_period_days integer DEFAULT 0 NOT NULL,
2198
+ trial_requires_payment_method boolean DEFAULT false NOT NULL,
2199
+ upc text,
2200
+ is_taxable boolean DEFAULT true NOT NULL,
2201
+ created_at timestamp with time zone DEFAULT now(),
2202
+ updated_at timestamp with time zone DEFAULT now(),
2203
+ sale_start_at timestamp with time zone,
2204
+ sale_end_at timestamp with time zone,
2205
+ scheduled_price integer,
2206
+ scheduled_prices jsonb,
2207
+ scheduled_price_at timestamp with time zone,
2208
+ average_rating numeric(3,2) DEFAULT 0.00 NOT NULL,
2209
+ total_reviews integer DEFAULT 0 NOT NULL,
2210
+ CONSTRAINT products_payment_provider_check CHECK ((payment_provider = ANY (ARRAY['stripe'::text, 'freemius'::text]))),
2211
+ CONSTRAINT products_prices_is_valid CHECK (public.is_valid_currency_amount_map(prices)),
2212
+ CONSTRAINT products_product_type_check CHECK ((product_type = ANY (ARRAY['physical'::text, 'digital'::text]))),
2213
+ CONSTRAINT products_sale_prices_are_valid CHECK (public.is_valid_sale_price_map(prices, sale_prices)),
2214
+ CONSTRAINT products_sale_window_valid CHECK (((sale_start_at IS NULL) OR (sale_end_at IS NULL) OR (sale_start_at < sale_end_at))),
2215
+ CONSTRAINT products_status_check CHECK ((status = ANY (ARRAY['draft'::text, 'active'::text, 'archived'::text]))),
2216
+ CONSTRAINT products_trial_period_days_check CHECK ((trial_period_days >= 0)),
2217
+ CONSTRAINT products_type_provider_consistency_check CHECK ((((product_type = 'physical'::text) AND (payment_provider = 'stripe'::text)) OR ((product_type = 'digital'::text) AND (payment_provider = 'freemius'::text))))
2218
+ );
2219
+
2220
+ COMMENT ON COLUMN public.products.prices IS 'Regular prices by ISO 4217 code in the smallest currency unit.';
2221
+
2222
+ COMMENT ON COLUMN public.products.sale_prices IS 'Sale prices by ISO 4217 code in the smallest currency unit.';
2223
+
2224
+ COMMENT ON COLUMN public.products.is_taxable IS 'When true, this product participates in Stripe tax calculation.';
2225
+
2226
+ COMMENT ON COLUMN public.products.sale_start_at IS 'Inclusive start of the scheduled sale window (UTC). NULL means no lower bound.';
2227
+
2228
+ COMMENT ON COLUMN public.products.sale_end_at IS 'Exclusive end of the scheduled sale window (UTC). NULL means no upper bound. Both NULL = always-on sale.';
2229
+
2230
+ COMMENT ON COLUMN public.products.scheduled_price IS 'Pending regular price (smallest currency unit) applied once scheduled_price_at has passed.';
2231
+
2232
+ COMMENT ON COLUMN public.products.scheduled_prices IS 'Pending multi-currency regular prices by ISO 4217 code, applied once scheduled_price_at has passed.';
2233
+
2234
+ COMMENT ON COLUMN public.products.scheduled_price_at IS 'Effective timestamp (UTC) for the pending regular-price change.';
2235
+
2236
+ COMMENT ON COLUMN public.products.average_rating IS 'Aggregated average 1-5 rating of approved reviews.';
2237
+
2238
+ COMMENT ON COLUMN public.products.total_reviews IS 'Total count of approved reviews for this product.';
2239
+
2240
+ CREATE TABLE IF NOT EXISTS public.profiles (
2241
+ id uuid NOT NULL,
2242
+ updated_at timestamp with time zone,
2243
+ full_name text,
2244
+ avatar_url text,
2245
+ website text,
2246
+ github_username text,
2247
+ phone text,
2248
+ role public.user_role DEFAULT 'USER'::public.user_role NOT NULL
2249
+ );
2250
+
2251
+ COMMENT ON TABLE public.profiles IS 'Profile information for each user, extending auth.users.';
2252
+
2253
+ COMMENT ON COLUMN public.profiles.id IS 'References auth.users.id';
2254
+
2255
+ COMMENT ON COLUMN public.profiles.role IS 'User role for RBAC.';
2256
+
2257
+ CREATE TABLE IF NOT EXISTS public.shipping_zone_locations (
2258
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2259
+ zone_id uuid NOT NULL,
2260
+ country_code text NOT NULL,
2261
+ state_code text,
2262
+ postal_code text,
2263
+ created_at timestamp with time zone DEFAULT now()
2264
+ );
2265
+
2266
+ COMMENT ON COLUMN public.shipping_zone_locations.country_code IS 'ISO 3166-1 alpha-2 country code.';
2267
+
2268
+ COMMENT ON COLUMN public.shipping_zone_locations.state_code IS 'Optional state/province code within the selected country (for example CA, NY, ON, QC). NULL means the whole country.';
2269
+
2270
+ COMMENT ON COLUMN public.shipping_zone_locations.postal_code IS 'Optional exact postal code or wildcard pattern. NULL means all postal codes in the matched country/state.';
2271
+
2272
+ CREATE TABLE IF NOT EXISTS public.shipping_zone_methods (
2273
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2274
+ zone_id uuid NOT NULL,
2275
+ method_type text NOT NULL,
2276
+ cost_amount integer DEFAULT 0 NOT NULL,
2277
+ cost_currency text DEFAULT 'USD'::text NOT NULL,
2278
+ min_order_amount integer DEFAULT 0 NOT NULL,
2279
+ name text NOT NULL,
2280
+ name_translations jsonb DEFAULT '{}'::jsonb NOT NULL,
2281
+ currency_pricing_mode text DEFAULT 'auto'::text NOT NULL,
2282
+ cost_amounts jsonb DEFAULT '{}'::jsonb NOT NULL,
2283
+ min_order_amounts jsonb DEFAULT '{}'::jsonb NOT NULL,
2284
+ created_at timestamp with time zone DEFAULT now(),
2285
+ updated_at timestamp with time zone DEFAULT now(),
2286
+ CONSTRAINT shipping_zone_methods_cost_amounts_include_source CHECK ((cost_amounts ? upper(cost_currency))),
2287
+ CONSTRAINT shipping_zone_methods_cost_amounts_valid CHECK (public.is_valid_currency_amount_map(cost_amounts)),
2288
+ CONSTRAINT shipping_zone_methods_cost_currency_format CHECK ((cost_currency ~ '^[A-Z]{3}$'::text)),
2289
+ CONSTRAINT shipping_zone_methods_currency_pricing_mode_valid CHECK ((currency_pricing_mode = ANY (ARRAY['auto'::text, 'manual'::text]))),
2290
+ CONSTRAINT shipping_zone_methods_method_type_check CHECK ((method_type = ANY (ARRAY['flat_rate'::text, 'free_shipping'::text]))),
2291
+ CONSTRAINT shipping_zone_methods_min_order_amounts_include_source CHECK ((min_order_amounts ? upper(cost_currency))),
2292
+ CONSTRAINT shipping_zone_methods_min_order_amounts_valid CHECK (public.is_valid_currency_amount_map(min_order_amounts))
2293
+ );
2294
+
2295
+ COMMENT ON COLUMN public.shipping_zone_methods.name_translations IS 'Localized shipping method labels keyed by language code. Example: {"fr": "Livraison standard"}.';
2296
+
2297
+ COMMENT ON COLUMN public.shipping_zone_methods.currency_pricing_mode IS 'Whether this rate uses auto FX conversion from a single source currency or exact manual amounts per currency.';
2298
+
2299
+ COMMENT ON COLUMN public.shipping_zone_methods.cost_amounts IS 'Shipping costs by ISO 4217 code in the smallest currency unit.';
2300
+
2301
+ COMMENT ON COLUMN public.shipping_zone_methods.min_order_amounts IS 'Minimum order thresholds by ISO 4217 code in the smallest currency unit.';
2302
+
2303
+ CREATE TABLE IF NOT EXISTS public.shipping_zones (
2304
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2305
+ name text NOT NULL,
2306
+ priority_order integer DEFAULT 0 NOT NULL,
2307
+ created_at timestamp with time zone DEFAULT now(),
2308
+ updated_at timestamp with time zone DEFAULT now()
2309
+ );
2310
+
2311
+ CREATE TABLE IF NOT EXISTS public.site_settings (
2312
+ key text NOT NULL,
2313
+ value jsonb
2314
+ );
2315
+
2316
+ 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
+
2318
+ CREATE TABLE IF NOT EXISTS public.system_alerts (
2319
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2320
+ alert_type text NOT NULL,
2321
+ title text NOT NULL,
2322
+ message text NOT NULL,
2323
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
2324
+ is_resolved boolean DEFAULT false NOT NULL,
2325
+ resolved_at timestamp with time zone,
2326
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2327
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2328
+ CONSTRAINT system_alerts_alert_type_check CHECK ((alert_type = ANY (ARRAY['merge_conflict'::text, 'runtime_update_available'::text])))
2329
+ );
2330
+
2331
+ COMMENT ON TABLE public.system_alerts IS 'System notifications for the automated upstream-update architecture (merge conflicts, runtime updates available). Written by service-role; read by ADMINs.';
2332
+
2333
+ COMMENT ON COLUMN public.system_alerts.alert_type IS 'One of: merge_conflict, runtime_update_available.';
2334
+
2335
+ COMMENT ON COLUMN public.system_alerts.metadata IS 'Structured deep-link context for the dashboard banner CTA (repo/branch/action_url or latest_version/download_url).';
2336
+
2337
+ COMMENT ON COLUMN public.system_alerts.is_resolved IS 'When true the alert is hidden from the dashboard banner.';
2338
+
2339
+ CREATE TABLE IF NOT EXISTS public.system_configuration (
2340
+ id integer DEFAULT 1 NOT NULL,
2341
+ auto_accept_signups boolean DEFAULT false NOT NULL,
2342
+ settings jsonb DEFAULT '{}'::jsonb NOT NULL,
2343
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2344
+ CONSTRAINT system_configuration_singleton CHECK ((id = 1))
2345
+ );
2346
+
2347
+ COMMENT ON TABLE public.system_configuration IS 'Singleton (id = 1) of global setup-wizard configuration. ADMIN-only via RLS; never store secrets in settings.';
2348
+
2349
+ CREATE TABLE IF NOT EXISTS public.tax_rates (
2350
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2351
+ country_code text NOT NULL,
2352
+ state_code text,
2353
+ tax_name text NOT NULL,
2354
+ tax_rate numeric(7,4) NOT NULL,
2355
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2356
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2357
+ CONSTRAINT tax_rates_tax_name_check CHECK ((char_length(btrim(tax_name)) > 0)),
2358
+ CONSTRAINT tax_rates_tax_rate_check CHECK (((tax_rate >= (0)::numeric) AND (tax_rate <= (100)::numeric)))
2359
+ );
2360
+
2361
+ COMMENT ON TABLE public.tax_rates IS 'Manual tax rates used for Stripe storefront orders. Multiple rows can exist per jurisdiction to support combined taxes such as GST + PST.';
2362
+
2363
+ COMMENT ON COLUMN public.tax_rates.country_code IS 'ISO 3166-1 alpha-2 country code.';
2364
+
2365
+ COMMENT ON COLUMN public.tax_rates.state_code IS 'Optional state/province code within country_code. NULL represents a country-wide or federal tax.';
2366
+
2367
+ COMMENT ON COLUMN public.tax_rates.tax_name IS 'Display name for the tax component, for example GST, PST, HST, or State Sales Tax.';
2368
+
2369
+ COMMENT ON COLUMN public.tax_rates.tax_rate IS 'Percent value, not decimal fraction. Example: 5.0000 means 5%.';
2370
+
2371
+ CREATE TABLE IF NOT EXISTS public.translations (
2372
+ key text NOT NULL,
2373
+ translations jsonb NOT NULL,
2374
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2375
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
2376
+ );
2377
+
2378
+ COMMENT ON COLUMN public.translations.key IS 'A unique, slugified identifier (e.g., "sign_in_button_text").';
2379
+
2380
+ COMMENT ON COLUMN public.translations.translations IS 'Stores translations as key-value pairs (e.g., {"en": "Sign In", "fr": "S''inscrire"}).';
2381
+
2382
+ CREATE TABLE IF NOT EXISTS public.ucp_cart_sessions (
2383
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2384
+ status text DEFAULT 'active'::text NOT NULL,
2385
+ currency text DEFAULT 'USD'::text NOT NULL,
2386
+ locale text,
2387
+ buyer_identity jsonb DEFAULT '{}'::jsonb NOT NULL,
2388
+ context jsonb DEFAULT '{}'::jsonb NOT NULL,
2389
+ signals jsonb DEFAULT '{}'::jsonb NOT NULL,
2390
+ attribution jsonb DEFAULT '{}'::jsonb NOT NULL,
2391
+ line_items jsonb DEFAULT '[]'::jsonb NOT NULL,
2392
+ totals jsonb DEFAULT '[]'::jsonb NOT NULL,
2393
+ checkout_url text,
2394
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
2395
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2396
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2397
+ expires_at timestamp with time zone DEFAULT (now() + '7 days'::interval) NOT NULL,
2398
+ CONSTRAINT ucp_cart_sessions_attribution_object CHECK ((jsonb_typeof(attribution) = 'object'::text)),
2399
+ CONSTRAINT ucp_cart_sessions_buyer_identity_object CHECK ((jsonb_typeof(buyer_identity) = 'object'::text)),
2400
+ CONSTRAINT ucp_cart_sessions_context_object CHECK ((jsonb_typeof(context) = 'object'::text)),
2401
+ CONSTRAINT ucp_cart_sessions_line_items_array CHECK ((jsonb_typeof(line_items) = 'array'::text)),
2402
+ CONSTRAINT ucp_cart_sessions_metadata_object CHECK ((jsonb_typeof(metadata) = 'object'::text)),
2403
+ CONSTRAINT ucp_cart_sessions_signals_object CHECK ((jsonb_typeof(signals) = 'object'::text)),
2404
+ CONSTRAINT ucp_cart_sessions_status_check CHECK ((status = ANY (ARRAY['active'::text, 'cancelled'::text, 'completed'::text]))),
2405
+ CONSTRAINT ucp_cart_sessions_totals_array CHECK ((jsonb_typeof(totals) = 'array'::text))
2406
+ );
2407
+
2408
+ CREATE TABLE IF NOT EXISTS public.user_addresses (
2409
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2410
+ user_id uuid NOT NULL,
2411
+ address_type text NOT NULL,
2412
+ is_default boolean DEFAULT false NOT NULL,
2413
+ recipient_name text,
2414
+ company_name text,
2415
+ line1 text,
2416
+ line2 text,
2417
+ city text,
2418
+ state text,
2419
+ postal_code text,
2420
+ country_code text,
2421
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2422
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2423
+ CONSTRAINT user_addresses_address_type_check CHECK ((address_type = ANY (ARRAY['billing'::text, 'shipping'::text])))
2424
+ );
2425
+
2426
+ COMMENT ON COLUMN public.user_addresses.company_name IS 'Optional company or organization name for the address.';
2427
+
2428
+ CREATE TABLE IF NOT EXISTS public.user_security_settings (
2429
+ user_id uuid NOT NULL,
2430
+ mfa_enabled boolean DEFAULT false NOT NULL,
2431
+ mfa_type text,
2432
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2433
+ CONSTRAINT user_security_settings_mfa_type_check CHECK ((mfa_type = ANY (ARRAY['totp'::text, 'email'::text])))
2434
+ );
2435
+
2436
+ COMMENT ON TABLE public.user_security_settings IS 'Per-user multi-factor configuration. mfa_type is NULL until a factor is enrolled.';
2437
+
2438
+ CREATE TABLE IF NOT EXISTS public.user_trusted_devices (
2439
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2440
+ user_id uuid NOT NULL,
2441
+ device_hash text NOT NULL,
2442
+ browser_metadata text,
2443
+ expires_at timestamp with time zone NOT NULL,
2444
+ created_at timestamp with time zone DEFAULT now() NOT NULL
2445
+ );
2446
+
2447
+ COMMENT ON TABLE public.user_trusted_devices IS 'SHA-256 hashes of trusted-device tokens. A 2FA bypass is only honoured when a non-expired row matches the cookie token, so deleting a row instantly revokes trust.';
2448
+
2449
+ COMMENT ON COLUMN public.user_trusted_devices.device_hash IS 'SHA-256 of the raw token held in the nb_trusted_device cookie. The raw token is never stored.';
2450
+
2451
+ CREATE TABLE IF NOT EXISTS public.variant_attribute_mapping (
2452
+ variant_id uuid NOT NULL,
2453
+ attribute_term_id uuid NOT NULL
2454
+ );