-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathall_migrations.sql
More file actions
2551 lines (2213 loc) · 89 KB
/
Copy pathall_migrations.sql
File metadata and controls
2551 lines (2213 loc) · 89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- Profiles
CREATE TABLE public.profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE,
display_name TEXT,
tutor_persona TEXT NOT NULL DEFAULT 'adams',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "profiles_select_own" ON public.profiles FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "profiles_insert_own" ON public.profiles FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "profiles_update_own" ON public.profiles FOR UPDATE USING (auth.uid() = user_id);
-- Points
CREATE TABLE public.user_points (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
points INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL,
meta JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.user_points ENABLE ROW LEVEL SECURITY;
CREATE POLICY "points_select_own" ON public.user_points FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "points_insert_own" ON public.user_points FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Task attempts
CREATE TABLE public.task_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
topic_id TEXT NOT NULL,
score_pct NUMERIC NOT NULL,
passed BOOLEAN NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.task_attempts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "attempts_select_own" ON public.task_attempts FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "attempts_insert_own" ON public.task_attempts FOR INSERT WITH CHECK (auth.uid() = user_id);
-- News broadcasts
CREATE TABLE public.news_broadcasts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
body TEXT NOT NULL,
media_url TEXT,
media_type TEXT,
is_ad BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
published_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.news_broadcasts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "news_public_read" ON public.news_broadcasts FOR SELECT USING (is_active = true);
-- updated_at trigger
CREATE OR REPLACE FUNCTION public.touch_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql SET search_path = public AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END; $$;
CREATE TRIGGER profiles_touch BEFORE UPDATE ON public.profiles FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
-- Auto-create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
INSERT INTO public.profiles (user_id, display_name)
VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'display_name', split_part(NEW.email,'@',1)));
RETURN NEW;
END; $$;
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
REVOKE EXECUTE ON FUNCTION public.touch_updated_at() FROM anon, authenticated, public;
REVOKE EXECUTE ON FUNCTION public.handle_new_user() FROM anon, authenticated, public;
-- 1. Storage policies for the private 'news' bucket: deny all client access.
-- Service role bypasses RLS, so admin-side uploads still work; clients use signed URLs.
DROP POLICY IF EXISTS "news_no_client_select" ON storage.objects;
DROP POLICY IF EXISTS "news_no_client_insert" ON storage.objects;
DROP POLICY IF EXISTS "news_no_client_update" ON storage.objects;
DROP POLICY IF EXISTS "news_no_client_delete" ON storage.objects;
CREATE POLICY "news_no_client_select" ON storage.objects
FOR SELECT TO authenticated, anon
USING (bucket_id <> 'news');
CREATE POLICY "news_no_client_insert" ON storage.objects
FOR INSERT TO authenticated, anon
WITH CHECK (bucket_id <> 'news');
CREATE POLICY "news_no_client_update" ON storage.objects
FOR UPDATE TO authenticated, anon
USING (bucket_id <> 'news');
CREATE POLICY "news_no_client_delete" ON storage.objects
FOR DELETE TO authenticated, anon
USING (bucket_id <> 'news');
-- 2. Score range guard
ALTER TABLE public.task_attempts
DROP CONSTRAINT IF EXISTS task_attempts_score_range;
ALTER TABLE public.task_attempts
ADD CONSTRAINT task_attempts_score_range CHECK (score_pct BETWEEN 0 AND 100);
-- 3. Server-side submission function: validates input, derives passed + points
CREATE OR REPLACE FUNCTION public.submit_quiz_attempt(
_topic_id text,
_score_pct numeric
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
uid uuid := auth.uid();
passed boolean;
pts integer := 0;
BEGIN
IF uid IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
IF _topic_id IS NULL OR length(_topic_id) = 0 OR length(_topic_id) > 128 THEN
RAISE EXCEPTION 'Invalid topic_id';
END IF;
IF _score_pct IS NULL OR _score_pct < 0 OR _score_pct > 100 THEN
RAISE EXCEPTION 'Invalid score_pct';
END IF;
passed := _score_pct >= 70;
INSERT INTO public.task_attempts (user_id, topic_id, score_pct, passed)
VALUES (uid, _topic_id, _score_pct, passed);
IF passed THEN
pts := 10 + GREATEST(0, FLOOR((_score_pct - 70) / 5))::int;
INSERT INTO public.user_points (user_id, points, source, meta)
VALUES (uid, pts, 'quiz', jsonb_build_object('topic_id', _topic_id, 'score_pct', _score_pct));
END IF;
RETURN jsonb_build_object('passed', passed, 'points', pts);
END;
$$;
REVOKE ALL ON FUNCTION public.submit_quiz_attempt(text, numeric) FROM public;
GRANT EXECUTE ON FUNCTION public.submit_quiz_attempt(text, numeric) TO authenticated;
-- 4. Remove direct INSERT on user_points (only the SECURITY DEFINER function writes now)
DROP POLICY IF EXISTS points_insert_own ON public.user_points;
REVOKE EXECUTE ON FUNCTION public.submit_quiz_attempt(text, numeric) FROM anon, public;
GRANT EXECUTE ON FUNCTION public.submit_quiz_attempt(text, numeric) TO authenticated;
-- 1. New schema for private internal logic
CREATE SCHEMA IF NOT EXISTS private;
-- 2. Quiz Questions table
CREATE TABLE IF NOT EXISTS public.quiz_questions (
id TEXT PRIMARY KEY,
topic_id TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT[] NOT NULL,
correct_index INTEGER NOT NULL,
explanation TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Enable RLS on questions (readable by all)
ALTER TABLE public.quiz_questions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "questions_select_all" ON public.quiz_questions;
CREATE POLICY "questions_select_all" ON public.quiz_questions FOR SELECT USING (true);
-- 3. Update task_attempts to store answers
ALTER TABLE public.task_attempts ADD COLUMN IF NOT EXISTS answers JSONB;
-- 4. Secure Point Awarding Logic (Private Schema)
CREATE OR REPLACE FUNCTION private.process_quiz_submission()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
correct_count INTEGER := 0;
total_count INTEGER;
score_pct NUMERIC;
passed BOOLEAN;
pts INTEGER := 0;
ans_record RECORD;
q_correct_index INTEGER;
BEGIN
-- We expect NEW.answers to be a JSONB array of { question_id: string, selected_index: number }
-- Re-calculate score server-side
-- Get total questions for this topic
SELECT count(*) INTO total_count FROM public.quiz_questions WHERE topic_id = NEW.topic_id;
IF total_count = 0 THEN
-- Fallback for topics not yet in DB or legacy
RETURN NEW;
END IF;
-- Calculate correct answers
-- NEW.answers is expected to be like: [{"questionId": "q1", "selectedIndex": 0}, ...]
FOR ans_record IN SELECT * FROM jsonb_to_recordset(NEW.answers) AS x(questionId text, selectedIndex int)
LOOP
SELECT correct_index INTO q_correct_index FROM public.quiz_questions WHERE id = ans_record.questionId;
IF q_correct_index = ans_record.selectedIndex THEN
correct_count := correct_count + 1;
END IF;
END LOOP;
score_pct := (correct_count::numeric / total_count::numeric) * 100;
passed := score_pct >= 70;
-- Overwrite user-provided values to ensure integrity
NEW.score_pct := score_pct;
NEW.passed := passed;
-- Award points if passed
IF passed THEN
pts := 10 + GREATEST(0, FLOOR((score_pct - 70) / 5))::int;
INSERT INTO public.user_points (user_id, points, source, meta)
VALUES (NEW.user_id, pts, 'quiz', jsonb_build_object('topic_id', NEW.topic_id, 'score_pct', score_pct));
-- Mark daily task as completed if it was a retry for this topic
UPDATE public.daily_tasks
SET is_completed = true
WHERE user_id = NEW.user_id
AND assigned_date = CURRENT_DATE
AND task_type = 'retry_quiz'
AND reference_id = NEW.topic_id;
END IF;
RETURN NEW;
END;
$$;
-- Trigger to process submission
DROP TRIGGER IF EXISTS on_quiz_submission ON public.task_attempts;
CREATE TRIGGER on_quiz_submission
BEFORE INSERT ON public.task_attempts
FOR EACH ROW
EXECUTE FUNCTION private.process_quiz_submission();
-- 5. Lock user_points table
-- Ensure no one can insert points directly. Only our SECURITY DEFINER trigger can.
DROP POLICY IF EXISTS "points_insert_own" ON public.user_points;
ALTER TABLE public.user_points ENABLE ROW LEVEL SECURITY;
-- No INSERT policy means only service_role and SECURITY DEFINER functions can insert.
-- 6. Move sensitive functions out of public schema
-- handle_new_user should not be directly executable by users.
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE OR REPLACE FUNCTION private.handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.profiles (user_id, display_name)
VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'display_name', split_part(NEW.email,'@',1)));
RETURN NEW;
END; $$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION private.handle_new_user();
-- Drop the old one from public schema
DROP FUNCTION IF EXISTS public.handle_new_user();
-- 7. Secure the Quiz Submission RPC
CREATE OR REPLACE FUNCTION public.submit_quiz_attempt(
_topic_id text,
_answers jsonb
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER -- Use invoker's rights to ensure they can only insert their own records
SET search_path = public
AS $$
DECLARE
new_attempt_id uuid;
final_score numeric;
is_passed boolean;
BEGIN
-- This insert will trigger private.process_quiz_submission()
INSERT INTO public.task_attempts (user_id, topic_id, answers, score_pct, passed)
VALUES (auth.uid(), _topic_id, _answers, 0, false) -- Dummy score, trigger will overwrite
RETURNING id, score_pct, passed INTO new_attempt_id, final_score, is_passed;
RETURN jsonb_build_object(
'id', new_attempt_id,
'score_pct', final_score,
'passed', is_passed
);
END;
$$;
-- Ensure public cannot execute it directly without auth
REVOKE ALL ON FUNCTION public.submit_quiz_attempt(text, jsonb) FROM public;
GRANT EXECUTE ON FUNCTION public.submit_quiz_attempt(text, jsonb) TO authenticated;
-- 9. Daily Tasks and Personalization
CREATE TABLE IF NOT EXISTS public.daily_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
task_type TEXT NOT NULL, -- 'retry_quiz', 'study_topic', 'activity'
reference_id TEXT, -- topic_id or quiz_id
description TEXT NOT NULL,
is_completed BOOLEAN NOT NULL DEFAULT false,
assigned_date DATE NOT NULL DEFAULT CURRENT_DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, assigned_date)
);
ALTER TABLE public.daily_tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_select_own_tasks" ON public.daily_tasks FOR SELECT USING (auth.uid() = user_id);
-- Function to generate/get daily task
CREATE OR REPLACE FUNCTION public.get_or_create_daily_task()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
uid uuid := auth.uid();
today date := CURRENT_DATE;
existing_task record;
failed_attempt record;
new_task_desc text;
new_task_type text;
new_ref_id text;
BEGIN
IF uid IS NULL THEN RAISE EXCEPTION 'Not authenticated'; END IF;
-- Check if task already exists for today
SELECT * INTO existing_task FROM public.daily_tasks
WHERE user_id = uid AND assigned_date = today;
IF existing_task.id IS NOT NULL THEN
RETURN row_to_json(existing_task)::jsonb;
END IF;
-- Try to find a failed attempt first (prioritize the most recent failure)
SELECT * INTO failed_attempt FROM public.task_attempts
WHERE user_id = uid AND passed = false
ORDER BY created_at DESC
LIMIT 1;
IF failed_attempt.id IS NOT NULL THEN
new_task_type := 'retry_quiz';
new_ref_id := failed_attempt.topic_id;
new_task_desc := 'Master your previous challenge: Retry the ' || new_ref_id || ' quiz and aim for 70%+!';
ELSE
-- Default to a study task if no failures
new_task_type := 'study_topic';
new_ref_id := 'm1-1'; -- Default or random logic could go here
new_task_desc := 'Start your day with something fresh: Explore ' || new_ref_id || ' now.';
END IF;
INSERT INTO public.daily_tasks (user_id, task_type, reference_id, description, assigned_date)
VALUES (uid, new_task_type, new_ref_id, new_task_desc, today)
RETURNING * INTO existing_task;
RETURN row_to_json(existing_task)::jsonb;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_or_create_daily_task() TO authenticated;
-- Ensure a public 'users' table exists for easier access and metadata mapping
CREATE TABLE IF NOT EXISTS public.users (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT UNIQUE,
full_name TEXT,
avatar_url TEXT,
is_verified BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Enable RLS on public.users
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_read_own" ON public.users FOR SELECT USING (auth.uid() = id);
CREATE POLICY "users_update_own" ON public.users FOR UPDATE USING (auth.uid() = id);
-- Update handle_new_user to sync auth.users metadata to public.users and public.profiles
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
-- Sync to public.profiles (legacy support)
INSERT INTO public.profiles (user_id, display_name)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'display_name', split_part(NEW.email,'@',1))
)
ON CONFLICT (user_id) DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, public.profiles.display_name);
-- Sync to public.users (new standard)
INSERT INTO public.users (id, email, full_name, avatar_url)
VALUES (
NEW.id,
NEW.email,
NEW.raw_user_meta_data->>'full_name',
NEW.raw_user_meta_data->>'avatar_url'
)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
full_name = COALESCE(EXCLUDED.full_name, public.users.full_name),
avatar_url = COALESCE(EXCLUDED.avatar_url, public.users.avatar_url),
updated_at = now();
RETURN NEW;
END; $$;
-- Ensure the trigger is active
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created AFTER INSERT OR UPDATE ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- Backfill existing users
INSERT INTO public.users (id, email, full_name, avatar_url)
SELECT
id,
email,
raw_user_meta_data->>'full_name',
raw_user_meta_data->>'avatar_url'
FROM auth.users
ON CONFLICT (id) DO NOTHING;
-- Create app_config table
CREATE TABLE IF NOT EXISTS public.app_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
whatsapp_number TEXT,
support_email TEXT,
mobile_money_details TEXT,
merchant_id TEXT,
support_price TEXT,
about_app TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Enable RLS
ALTER TABLE public.app_config ENABLE ROW LEVEL SECURITY;
-- Allow public read access
CREATE POLICY "Allow public read access to app_config" ON public.app_config
FOR SELECT USING (true);
-- Insert default config
INSERT INTO public.app_config (
whatsapp_number,
support_email,
mobile_money_details,
merchant_id,
support_price,
about_app
) VALUES (
'+256768715065',
'latifisabirye123@gmail.com',
'Send 5,000 UGX to +256 768 715065 (MTN) - Latif Sabirye. After payment, send a screenshot to WhatsApp for instant activation.',
'7064464',
'5,000 UGX',
'Cymatic Hub is an advanced educational platform tailored for Uganda''s New Lower Secondary Curriculum, providing students with interactive tools, high-quality notes, and AI-powered learning assistance.'
) ON CONFLICT DO NOTHING;
-- 1. Create helper function for streak calculation
CREATE OR REPLACE FUNCTION public.get_user_streak(uid uuid)
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
streak integer := 0;
check_date date := CURRENT_DATE - 1;
BEGIN
-- Simple streak count (days active in last 90 days)
SELECT count(DISTINCT assigned_date)::integer INTO streak
FROM public.daily_tasks
WHERE user_id = uid
AND is_completed = true
AND assigned_date >= CURRENT_DATE - 90;
RETURN streak;
END;
$$;
-- 2. Update process_quiz_submission to include multiplier and task protection
CREATE OR REPLACE FUNCTION private.process_quiz_submission()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
correct_count INTEGER := 0;
total_count INTEGER;
score_pct NUMERIC;
passed BOOLEAN;
base_pts INTEGER := 0;
final_pts INTEGER := 0;
streak INTEGER := 0;
multiplier NUMERIC := 1.0;
task_record RECORD;
BEGIN
-- Get total questions
SELECT count(*) INTO total_count FROM public.quiz_questions WHERE topic_id = NEW.topic_id;
IF total_count = 0 THEN RETURN NEW; END IF;
-- Re-calculate score
FOR ans_record IN SELECT * FROM jsonb_to_recordset(NEW.answers) AS x(questionId text, selectedIndex int)
LOOP
IF (SELECT correct_index FROM public.quiz_questions WHERE id = ans_record.questionId) = ans_record.selectedIndex THEN
correct_count := correct_count + 1;
END IF;
END LOOP;
score_pct := (correct_count::numeric / total_count::numeric) * 100;
passed := score_pct >= 70;
NEW.score_pct := score_pct;
NEW.passed := passed;
IF passed THEN
base_pts := 10 + GREATEST(0, FLOOR((score_pct - 70) / 5))::int;
-- Check for existing daily task for this topic
SELECT * INTO task_record FROM public.daily_tasks
WHERE user_id = NEW.user_id AND assigned_date = CURRENT_DATE AND reference_id = NEW.topic_id;
-- Apply multiplier and task protection
streak := public.get_user_streak(NEW.user_id);
-- 90-day logic: multiplier up to 1.11x (11% bonus)
multiplier := 1.0 + (LEAST(streak, 90) / 900.0);
-- If it's a "retry_quiz" daily task, award full points. Otherwise, cap it.
IF task_record.id IS NOT NULL AND task_record.task_type = 'retry_quiz' THEN
final_pts := floor(base_pts * multiplier)::int;
UPDATE public.daily_tasks SET is_completed = true WHERE id = task_record.id;
ELSE
-- Protect pool: Subsequent attempts on same day get 50%
final_pts := floor((base_pts * multiplier) / 2)::int;
END IF;
INSERT INTO public.user_points (user_id, points, source, meta)
VALUES (NEW.user_id, final_pts, 'quiz', jsonb_build_object('topic_id', NEW.topic_id, 'score_pct', score_pct, 'streak', streak));
END IF;
RETURN NEW;
END;
$$;
-- 1. Extend profiles table
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS school_name TEXT,
ADD COLUMN IF NOT EXISTS phone TEXT,
ADD COLUMN IF NOT EXISTS current_mood TEXT;
-- 2. Daily Challenges table
CREATE TABLE IF NOT EXISTS public.daily_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
assigned_date DATE NOT NULL DEFAULT CURRENT_DATE,
target_points INTEGER NOT NULL DEFAULT 100,
earned_points INTEGER NOT NULL DEFAULT 0,
is_completed BOOLEAN NOT NULL DEFAULT false,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, assigned_date)
);
ALTER TABLE public.daily_challenges ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_select_own_challenges" ON public.daily_challenges FOR SELECT USING (auth.uid() = user_id);
-- 3. Roles System
CREATE TYPE public.app_role AS ENUM ('admin', 'student', 'teacher');
CREATE TABLE IF NOT EXISTS public.user_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role app_role NOT NULL DEFAULT 'student',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, role)
);
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "admins_read_all_roles" ON public.user_roles FOR SELECT USING (
EXISTS (SELECT 1 FROM public.user_roles ur WHERE ur.user_id = auth.uid() AND ur.role = 'admin')
);
CREATE POLICY "users_read_own_roles" ON public.user_roles FOR SELECT USING (auth.uid() = user_id);
-- Security definer function to check roles
CREATE OR REPLACE FUNCTION public.has_role(uid uuid, requested_role public.app_role)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.user_roles
WHERE user_id = uid AND role = requested_role
);
END;
$$;
-- 4. Update News Policies for Admins
ALTER TABLE public.news_broadcasts ADD COLUMN IF NOT EXISTS is_curriculum_update BOOLEAN DEFAULT false;
DROP POLICY IF EXISTS "news_admin_insert" ON public.news_broadcasts;
CREATE POLICY "news_admin_insert" ON public.news_broadcasts
FOR INSERT WITH CHECK (public.has_role(auth.uid(), 'admin'));
DROP POLICY IF EXISTS "news_admin_update" ON public.news_broadcasts;
CREATE POLICY "news_admin_update" ON public.news_broadcasts
FOR UPDATE USING (public.has_role(auth.uid(), 'admin'));
-- 5. Update handle_new_user to sync metadata
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
-- Sync to public.profiles
INSERT INTO public.profiles (user_id, display_name, school_name, phone)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'display_name', split_part(NEW.email,'@',1)),
NEW.raw_user_meta_data->>'school_name',
NEW.raw_user_meta_data->>'phone_number'
)
ON CONFLICT (user_id) DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, public.profiles.display_name),
school_name = COALESCE(EXCLUDED.school_name, public.profiles.school_name),
phone = COALESCE(EXCLUDED.phone, public.profiles.phone),
updated_at = now();
-- Sync to public.users
INSERT INTO public.users (id, email, full_name, avatar_url)
VALUES (
NEW.id,
NEW.email,
NEW.raw_user_meta_data->>'full_name',
NEW.raw_user_meta_data->>'avatar_url'
)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
full_name = COALESCE(EXCLUDED.full_name, public.users.full_name),
avatar_url = COALESCE(EXCLUDED.avatar_url, public.users.avatar_url),
updated_at = now();
-- Default role
INSERT INTO public.user_roles (user_id, role)
VALUES (NEW.id, 'student')
ON CONFLICT DO NOTHING;
RETURN NEW;
END; $$;
-- 1. Create referrals table
CREATE TABLE IF NOT EXISTS public.referrals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
referrer_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
referred_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
referred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_verified BOOLEAN NOT NULL DEFAULT false,
UNIQUE(referred_user_id)
);
ALTER TABLE public.referrals ENABLE ROW LEVEL SECURITY;
CREATE POLICY "referrals_select_own" ON public.referrals FOR SELECT USING (auth.uid() = referrer_id OR auth.uid() = referred_user_id);
-- 2. Add referral_code to profiles
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS referral_code TEXT UNIQUE DEFAULT substring(md5(random()::text) from 1 for 8);
-- 3. Function to record a new referral
CREATE OR REPLACE FUNCTION public.record_referral(referrer_code TEXT, new_user_id UUID)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
referrer_uuid UUID;
BEGIN
-- Get the referrer's user_id from their code
SELECT user_id INTO referrer_uuid FROM public.profiles WHERE referral_code = referrer_code;
IF referrer_uuid IS NOT NULL AND referrer_uuid != new_user_id THEN
INSERT INTO public.referrals (referrer_id, referred_user_id)
VALUES (referrer_uuid, new_user_id)
ON CONFLICT (referred_user_id) DO NOTHING;
END IF;
END;
$$;
-- =========================================================
-- PROFILES
-- =========================================================
CREATE TABLE public.profiles (
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
user_id uuid NOT NULL UNIQUE,
display_name text,
school_name text,
is_verified boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Profiles are viewable by owner"
ON public.profiles FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own profile"
ON public.profiles FOR INSERT
WITH CHECK (auth.uid() = user_id AND auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON public.profiles FOR UPDATE
USING (auth.uid() = user_id);
-- updated_at trigger function
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE TRIGGER profiles_set_updated_at
BEFORE UPDATE ON public.profiles
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- Auto-create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.profiles (id, user_id, display_name, school_name)
VALUES (
NEW.id,
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'name', split_part(NEW.email, '@', 1)),
NEW.raw_user_meta_data->>'school_name'
)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- =========================================================
-- USER POINTS
-- =========================================================
CREATE TABLE public.user_points (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
points integer NOT NULL DEFAULT 0,
reason text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_user_points_user_created ON public.user_points(user_id, created_at DESC);
ALTER TABLE public.user_points ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own points"
ON public.user_points FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own points"
ON public.user_points FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- =========================================================
-- NEWS BROADCASTS
-- =========================================================
CREATE TABLE public.news_broadcasts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL,
body text,
is_active boolean NOT NULL DEFAULT true,
is_ad boolean NOT NULL DEFAULT false,
published_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_news_active_pub ON public.news_broadcasts(is_active, published_at DESC);
ALTER TABLE public.news_broadcasts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Active broadcasts are public"
ON public.news_broadcasts FOR SELECT
USING (is_active = true);
ALTER PUBLICATION supabase_realtime ADD TABLE public.news_broadcasts;
-- =========================================================
-- APP CONFIG (single-row config)
-- =========================================================
CREATE TABLE public.app_config (
id integer PRIMARY KEY DEFAULT 1,
whatsapp_number text,
support_email text,
merchant_id text,
support_price text,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT app_config_singleton CHECK (id = 1)
);
INSERT INTO public.app_config (id) VALUES (1);
ALTER TABLE public.app_config ENABLE ROW LEVEL SECURITY;
CREATE POLICY "App config is publicly readable"
ON public.app_config FOR SELECT
USING (true);
CREATE TRIGGER app_config_set_updated_at
BEFORE UPDATE ON public.app_config
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- =========================================================
-- QUIZ ATTEMPTS + SUBMIT RPC
-- =========================================================
CREATE TABLE public.quiz_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
topic_id text NOT NULL,
answers jsonb NOT NULL DEFAULT '[]'::jsonb,
score integer NOT NULL DEFAULT 0,
total integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_quiz_attempts_user_topic ON public.quiz_attempts(user_id, topic_id, created_at DESC);
ALTER TABLE public.quiz_attempts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own attempts"
ON public.quiz_attempts FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own attempts"
ON public.quiz_attempts FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE OR REPLACE FUNCTION public.submit_quiz_attempt(_topic_id text, _answers jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user uuid := auth.uid();
v_total int;
v_score int;
v_points int;
v_attempt_id uuid;
BEGIN
IF v_user IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
v_total := COALESCE(jsonb_array_length(_answers), 0);
-- count answers flagged as correct (objects with "correct": true) or boolean true entries
SELECT COUNT(*) INTO v_score
FROM jsonb_array_elements(_answers) elem
WHERE (elem = 'true'::jsonb)
OR (jsonb_typeof(elem) = 'object' AND COALESCE((elem->>'correct')::boolean, false) = true);
INSERT INTO public.quiz_attempts (user_id, topic_id, answers, score, total)
VALUES (v_user, _topic_id, _answers, v_score, v_total)
RETURNING id INTO v_attempt_id;
v_points := v_score * 5;
IF v_points > 0 THEN
INSERT INTO public.user_points (user_id, points, reason)
VALUES (v_user, v_points, 'quiz:' || _topic_id);
END IF;
RETURN jsonb_build_object(
'attempt_id', v_attempt_id,
'score', v_score,
'total', v_total,
'points_awarded', v_points
);
END;
$$;
REVOKE ALL ON FUNCTION public.submit_quiz_attempt(text, jsonb) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.submit_quiz_attempt(text, jsonb) TO authenticated;
-- =========================================================
-- DAILY TASKS + get_or_create_daily_task RPC
-- =========================================================
CREATE TABLE public.daily_tasks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
task_date date NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')::date,
task_type text NOT NULL,
description text NOT NULL,
is_completed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, task_date)
);
ALTER TABLE public.daily_tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own daily tasks"
ON public.daily_tasks FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can update own daily tasks"
ON public.daily_tasks FOR UPDATE
USING (auth.uid() = user_id);
CREATE OR REPLACE FUNCTION public.get_or_create_daily_task()
RETURNS public.daily_tasks
RETURNS NULL ON NULL INPUT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user uuid := auth.uid();
v_today date := (now() AT TIME ZONE 'UTC')::date;
v_row public.daily_tasks;
v_options text[][] := ARRAY[
ARRAY['read_notes', 'Read one lesson from your Physics notes today.'],
ARRAY['retry_quiz', 'Retake a quiz and beat your previous score.'],
ARRAY['read_notes', 'Spend 10 focused minutes reviewing a new topic.'],
ARRAY['retry_quiz', 'Complete any quiz to earn 25+ points.']
];
v_pick text[];
BEGIN
IF v_user IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
SELECT * INTO v_row
FROM public.daily_tasks
WHERE user_id = v_user AND task_date = v_today;
IF FOUND THEN
RETURN v_row;
END IF;
v_pick := v_options[1 + floor(random() * array_length(v_options, 1))::int];
INSERT INTO public.daily_tasks (user_id, task_date, task_type, description)
VALUES (v_user, v_today, v_pick[1], v_pick[2])
RETURNING * INTO v_row;
RETURN v_row;
END;
$$;
REVOKE ALL ON FUNCTION public.get_or_create_daily_task() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.get_or_create_daily_task() TO authenticated;
ALTER TABLE public.news_broadcasts
ADD COLUMN media_url text,
ADD COLUMN media_type text;-- 1. Lock down record_referral: derive user from auth.uid(), not client input.
DROP FUNCTION IF EXISTS public.record_referral(text, uuid);
DROP FUNCTION IF EXISTS public.record_referral(text);
CREATE OR REPLACE FUNCTION public.record_referral(referrer_code TEXT)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
referrer_uuid UUID;
caller_uuid UUID := auth.uid();
BEGIN
SELECT user_id INTO referrer_uuid
FROM public.profiles
WHERE referral_code = referrer_code;
IF referrer_uuid IS NULL THEN
RAISE EXCEPTION 'Invalid referral code';
END IF;
-- Allow pre-signup referral validation for anonymous users.
IF caller_uuid IS NULL THEN
RETURN;
END IF;
IF referrer_uuid <> caller_uuid THEN
INSERT INTO public.referrals (referrer_id, referred_user_id)
VALUES (referrer_uuid, caller_uuid)
ON CONFLICT (referred_user_id) DO NOTHING;
END IF;