@bygghammar/cms-platform 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/migrations/20260728113741_f9744b19-078b-46e5-b00f-31c84aaf71ab.sql +111 -0
  2. package/migrations/20260728113757_4156a886-21fb-4a1f-9c12-acb7bb145102.sql +5 -0
  3. package/migrations/20260728113823_6e0237e1-99e3-4e1a-8a68-804b7bcdbbb8.sql +1 -0
  4. package/migrations/20260728115305_506d92b4-129f-4d2b-961f-cf76e592677e.sql +92 -0
  5. package/migrations/20260728115320_717d4447-f433-4a1d-89d2-fc8f028d4ac6.sql +1 -0
  6. package/migrations/20260728115347_c77f5a56-19db-4117-bb60-9ad692b817ab.sql +11 -0
  7. package/migrations/20260728132052_b692dcfe-a944-4a19-afd0-187f7600feb8.sql +63 -0
  8. package/migrations/20260728183318_05b05a47-ba05-4a66-ab8f-69f2688fea84.sql +209 -0
  9. package/migrations/20260728193035_84b68d27-c3f3-4516-a3a9-15fc6f476163.sql +17 -0
  10. package/migrations/20260728193707_8438ee2d-d589-4048-86e2-464fd209af80.sql +37 -0
  11. package/migrations/20260729055312_08862d7e-0279-4d4f-8709-a59b9f068c55.sql +5 -0
  12. package/migrations/20260729055348_418b7e36-4877-4304-831f-290ca73e56f3.sql +133 -0
  13. package/migrations/20260729055358_20b467e2-4efd-42c1-bd34-773ed6b77fc5.sql +3 -0
  14. package/migrations/20260729061039_306859eb-a38c-44ef-af1a-6c02a8fd84db.sql +43 -0
  15. package/migrations/20260730065600_54c25861-3355-4600-a72f-3d67b457ecb1.sql +54 -0
  16. package/migrations/20260730071745_d4904314-15ff-4c58-ac66-85e7371e04b5.sql +113 -0
  17. package/migrations/20260730071914_48163aa3-830e-4dcc-bdd4-c35be282ab87.sql +22 -0
  18. package/migrations/20260730085949_bcfc3270-ea80-4617-86a3-2555ed34a280.sql +24 -0
  19. package/migrations/20260730095256_c977c30e-b285-4041-8804-c0bd72b6d9b3.sql +13 -0
  20. package/migrations/20260730100057_e341b887-eb0e-4a74-850e-0ee5ffb70d7a.sql +38 -0
  21. package/migrations/20260730103703_d5c4e1b7-1e2c-4dd0-be4e-185c159ade69.sql +62 -0
  22. package/migrations/20260730105635_3557b7da-919e-48db-8c92-8221d68d3cf9.sql +29 -0
  23. package/migrations/20260730120308_12ff8cfb-43f9-4070-abde-61531ad3b75f.sql +73 -0
  24. package/migrations/20260730125024_1baf8926-12a2-48f4-8edc-7309941309e9.sql +18 -0
  25. package/migrations/20260730173510_feb974d1-510e-4f52-9e20-5fd0582d8952.sql +107 -0
  26. package/migrations/20260730175409_9f60743f-f5e1-418c-a700-c91fb3704db8.sql +9 -0
  27. package/migrations/20260730181224_1c52ff16-c7a2-4ef1-a0a8-e493c2c9106f.sql +7 -0
  28. package/migrations/20260730183129_933adb75-f985-4d82-ad5d-213b69fd42e5.sql +32 -0
  29. package/migrations/20260730183918_7bc7a827-2100-49f1-bc6e-24509249350b.sql +68 -0
  30. package/migrations/20260730202628_7ceade5e-644c-4bc2-91b8-594133329f31.sql +1 -0
  31. package/migrations/20260730211056_57f42049-00b4-473b-a2cd-65a13570b8d2.sql +16 -0
  32. package/migrations/20260730214340_98af2e55-9031-4c11-9659-c47a37a8d6b7.sql +1 -0
  33. package/migrations/20260731091630_8a6f28ce-5833-4901-ab17-27648cef32ec.sql +149 -0
  34. package/migrations/20260731094716_4c68a1df-83ed-4ba8-8019-fa2fe2b9681e.sql +65 -0
  35. package/migrations/20260731191655_fef97c3c-1397-47b4-8b56-afb020298e97.sql +165 -0
  36. package/migrations/20260731192212_95772a2f-19a1-4013-b463-2eb161e96737.sql +135 -0
  37. package/migrations/20260731192815_c490653e-1232-4ad9-a150-3c83d2b90f5e.sql +13 -0
  38. package/migrations/20260731193135_7966e740-5e86-4b14-8e1d-5119069666ba.sql +2 -0
  39. package/migrations/20260731193245_599790d0-ca5e-4b5d-a3e7-d69b2a79e1a3.sql +13 -0
  40. package/migrations/20260731212959_a748cb0d-4dc7-45dc-be90-f6bacbfab26c.sql +9 -0
  41. package/migrations/20260801003057_1195f4c1-fb54-42dc-9718-638a25e6a82e.sql +3 -0
  42. package/migrations/20260801003300_fc591960-cb41-41cf-837d-2eef23b29ca9.sql +40 -0
  43. package/migrations/20260802151445_a56b0023-eaf7-41e2-a7fb-d4beddfc587c.sql +3 -0
  44. package/migrations/20260802202621_d661af8a-8513-4b54-b375-f963ae824c8f.sql +225 -0
  45. package/migrations/20260802202702_c30cd837-744c-4641-85f1-edd3a7ebbcb6.sql +16 -0
  46. package/migrations/20260802202720_4418d18d-c851-42dc-9345-78fc330128ae.sql +2 -0
  47. package/migrations/20260803132514_78ba1f53-c85b-4457-96b1-7d49c5c8b494.sql +34 -0
  48. package/migrations/20260803181201_28c8aa09-baa8-431c-8338-90b1af9c8172.sql +22 -0
  49. package/migrations/20260803201518_6e8a6028-b70f-4a7d-8bc9-95c86304e7ae.sql +12 -0
  50. package/migrations/20260803201535_32a9a213-cb92-4035-b8cd-8211ca34af68.sql +12 -0
  51. package/migrations/20260811103505_29df753d-14fa-4a05-8fea-f4207785fba3.sql +35 -0
  52. package/migrations/20260811112455_217c4028-f587-4c98-9e4f-9e6c452ab761.sql +20 -0
  53. package/migrations/20260811114438_6687d67d-bad3-4803-9638-8b7670d2e5d4.sql +86 -0
  54. package/migrations/20260811130318_7cb95e95-9baa-40af-8f3f-de1d4e09cec4.sql +1 -0
  55. package/migrations/20260811220548_80d99ec2-bd4b-475d-a627-9408bb60ce56.sql +1 -0
  56. package/migrations/20260811222519_6b040f56-4de3-49e0-92e4-d9deec5ead6a.sql +3 -0
  57. package/migrations/20260812000000_seed_voyara_content.sql +390 -0
  58. package/migrations/20260818040722_1bdf1b8f-7fb0-4ae8-8ecf-73f1fadff3c2.sql +2 -0
  59. package/migrations/20260818040808_69a5e832-57e8-4aa7-aff8-7fd7777faa7c.sql +15 -0
  60. package/migrations/20260818040839_e6523163-bbf5-4a39-9233-91759059569a.sql +26 -0
  61. package/package.json +9 -4
  62. package/src/backoffice/media/ReviewImageUpload.tsx +104 -0
  63. package/src/backoffice/seo/PageSeoPanelView.tsx +406 -0
  64. package/src/platform/contract.ts +92 -0
@@ -0,0 +1,133 @@
1
+
2
+ -- Update initial admin trigger to also grant superadmin
3
+ CREATE OR REPLACE FUNCTION public.grant_initial_admin()
4
+ RETURNS trigger
5
+ LANGUAGE plpgsql
6
+ SECURITY DEFINER
7
+ SET search_path TO 'public'
8
+ AS $$
9
+ BEGIN
10
+ IF NEW.email_confirmed_at IS NOT NULL
11
+ AND lower(NEW.email) = 'byggis@gmail.com' THEN
12
+ INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, 'admin')
13
+ ON CONFLICT (user_id, role) DO NOTHING;
14
+ INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, 'superadmin')
15
+ ON CONFLICT (user_id, role) DO NOTHING;
16
+ END IF;
17
+ RETURN NEW;
18
+ END;
19
+ $$;
20
+
21
+ -- Backfill superadmin for existing byggis account
22
+ INSERT INTO public.user_roles (user_id, role)
23
+ SELECT id, 'superadmin'::public.app_role FROM auth.users WHERE lower(email) = 'byggis@gmail.com'
24
+ ON CONFLICT (user_id, role) DO NOTHING;
25
+
26
+ -- Helper: is user staff (admin or superadmin)
27
+ CREATE OR REPLACE FUNCTION public.is_staff(_user_id uuid)
28
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
29
+ SELECT EXISTS (
30
+ SELECT 1 FROM public.user_roles
31
+ WHERE user_id = _user_id AND role IN ('admin','superadmin')
32
+ )
33
+ $$;
34
+
35
+ -- Allow admins/superadmins to manage user_roles (superadmin), and view all roles
36
+ CREATE POLICY "Superadmins manage user_roles" ON public.user_roles
37
+ FOR ALL TO authenticated
38
+ USING (public.has_role(auth.uid(), 'superadmin'))
39
+ WITH CHECK (public.has_role(auth.uid(), 'superadmin'));
40
+
41
+ CREATE POLICY "Staff view all user_roles" ON public.user_roles
42
+ FOR SELECT TO authenticated
43
+ USING (public.is_staff(auth.uid()));
44
+
45
+ -- ============ audit_logs ============
46
+ CREATE TABLE public.audit_logs (
47
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
48
+ actor_id uuid,
49
+ actor_email text,
50
+ action text NOT NULL,
51
+ entity text,
52
+ entity_id text,
53
+ meta jsonb NOT NULL DEFAULT '{}'::jsonb,
54
+ ip text,
55
+ user_agent text,
56
+ created_at timestamptz NOT NULL DEFAULT now()
57
+ );
58
+ CREATE INDEX audit_logs_created_at_idx ON public.audit_logs (created_at DESC);
59
+ CREATE INDEX audit_logs_actor_idx ON public.audit_logs (actor_id);
60
+
61
+ GRANT SELECT, INSERT ON public.audit_logs TO authenticated;
62
+ GRANT ALL ON public.audit_logs TO service_role;
63
+ ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
64
+ CREATE POLICY "Staff view audit_logs" ON public.audit_logs
65
+ FOR SELECT TO authenticated USING (public.is_staff(auth.uid()));
66
+ CREATE POLICY "Staff insert audit_logs" ON public.audit_logs
67
+ FOR INSERT TO authenticated WITH CHECK (public.is_staff(auth.uid()));
68
+
69
+ -- ============ email_logs ============
70
+ CREATE TABLE public.email_logs (
71
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
72
+ to_address text NOT NULL,
73
+ from_address text,
74
+ reply_to text,
75
+ subject text,
76
+ body_preview text,
77
+ provider text,
78
+ status text NOT NULL DEFAULT 'queued',
79
+ error text,
80
+ meta jsonb NOT NULL DEFAULT '{}'::jsonb,
81
+ created_at timestamptz NOT NULL DEFAULT now()
82
+ );
83
+ CREATE INDEX email_logs_created_at_idx ON public.email_logs (created_at DESC);
84
+
85
+ GRANT SELECT, INSERT ON public.email_logs TO authenticated;
86
+ GRANT ALL ON public.email_logs TO service_role;
87
+ ALTER TABLE public.email_logs ENABLE ROW LEVEL SECURITY;
88
+ CREATE POLICY "Staff view email_logs" ON public.email_logs
89
+ FOR SELECT TO authenticated USING (public.is_staff(auth.uid()));
90
+ CREATE POLICY "Staff insert email_logs" ON public.email_logs
91
+ FOR INSERT TO authenticated WITH CHECK (public.is_staff(auth.uid()));
92
+
93
+ -- ============ email_settings (singleton) ============
94
+ CREATE TABLE public.email_settings (
95
+ id boolean PRIMARY KEY DEFAULT true CHECK (id = true),
96
+ primary_from_name text,
97
+ primary_from_email text,
98
+ backup_from_email text,
99
+ reply_to text,
100
+ admin_notify_to text,
101
+ provider text NOT NULL DEFAULT 'smtp',
102
+ smtp_host text,
103
+ smtp_port integer,
104
+ smtp_secure boolean NOT NULL DEFAULT true,
105
+ smtp_username text,
106
+ smtp_password_secret_name text,
107
+ ses_region text,
108
+ ses_access_key_id text,
109
+ ses_secret_access_key_secret_name text,
110
+ updated_at timestamptz NOT NULL DEFAULT now(),
111
+ updated_by uuid
112
+ );
113
+
114
+ GRANT SELECT, INSERT, UPDATE ON public.email_settings TO authenticated;
115
+ GRANT ALL ON public.email_settings TO service_role;
116
+ ALTER TABLE public.email_settings ENABLE ROW LEVEL SECURITY;
117
+ CREATE POLICY "Staff view email_settings" ON public.email_settings
118
+ FOR SELECT TO authenticated USING (public.is_staff(auth.uid()));
119
+ CREATE POLICY "Superadmins update email_settings" ON public.email_settings
120
+ FOR UPDATE TO authenticated
121
+ USING (public.has_role(auth.uid(), 'superadmin'))
122
+ WITH CHECK (public.has_role(auth.uid(), 'superadmin'));
123
+ CREATE POLICY "Superadmins insert email_settings" ON public.email_settings
124
+ FOR INSERT TO authenticated
125
+ WITH CHECK (public.has_role(auth.uid(), 'superadmin'));
126
+
127
+ CREATE OR REPLACE FUNCTION public.touch_email_settings()
128
+ RETURNS trigger LANGUAGE plpgsql SET search_path = public AS $$
129
+ BEGIN NEW.updated_at = now(); NEW.updated_by = auth.uid(); RETURN NEW; END; $$;
130
+ CREATE TRIGGER trg_touch_email_settings BEFORE UPDATE ON public.email_settings
131
+ FOR EACH ROW EXECUTE FUNCTION public.touch_email_settings();
132
+
133
+ INSERT INTO public.email_settings (id) VALUES (true) ON CONFLICT DO NOTHING;
@@ -0,0 +1,3 @@
1
+
2
+ REVOKE ALL ON FUNCTION public.is_staff(uuid) FROM PUBLIC, anon;
3
+ GRANT EXECUTE ON FUNCTION public.is_staff(uuid) TO authenticated, service_role;
@@ -0,0 +1,43 @@
1
+
2
+ CREATE TABLE public.email_providers (
3
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4
+ name text NOT NULL,
5
+ kind text NOT NULL CHECK (kind IN ('smtp','ses','lovable','mailgun','http')),
6
+ is_active boolean NOT NULL DEFAULT true,
7
+ sort_order integer NOT NULL DEFAULT 100,
8
+ from_name text,
9
+ from_email text,
10
+ reply_to text,
11
+ config jsonb NOT NULL DEFAULT '{}'::jsonb,
12
+ secret_ref_name text,
13
+ notes text,
14
+ created_at timestamptz NOT NULL DEFAULT now(),
15
+ updated_at timestamptz NOT NULL DEFAULT now(),
16
+ updated_by uuid
17
+ );
18
+
19
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.email_providers TO authenticated;
20
+ GRANT ALL ON public.email_providers TO service_role;
21
+
22
+ ALTER TABLE public.email_providers ENABLE ROW LEVEL SECURITY;
23
+
24
+ CREATE POLICY "Staff view email_providers" ON public.email_providers
25
+ FOR SELECT TO authenticated USING (public.is_staff(auth.uid()));
26
+ CREATE POLICY "Superadmins insert email_providers" ON public.email_providers
27
+ FOR INSERT TO authenticated WITH CHECK (public.has_role(auth.uid(), 'superadmin'));
28
+ CREATE POLICY "Superadmins update email_providers" ON public.email_providers
29
+ FOR UPDATE TO authenticated USING (public.has_role(auth.uid(), 'superadmin'))
30
+ WITH CHECK (public.has_role(auth.uid(), 'superadmin'));
31
+ CREATE POLICY "Superadmins delete email_providers" ON public.email_providers
32
+ FOR DELETE TO authenticated USING (public.has_role(auth.uid(), 'superadmin'));
33
+
34
+ CREATE OR REPLACE FUNCTION public.touch_email_providers()
35
+ RETURNS trigger LANGUAGE plpgsql SET search_path = public AS $$
36
+ BEGIN NEW.updated_at = now(); NEW.updated_by = auth.uid(); RETURN NEW; END; $$;
37
+
38
+ CREATE TRIGGER trg_touch_email_providers BEFORE UPDATE ON public.email_providers
39
+ FOR EACH ROW EXECUTE FUNCTION public.touch_email_providers();
40
+
41
+ ALTER TABLE public.email_settings
42
+ ADD COLUMN IF NOT EXISTS default_provider_id uuid REFERENCES public.email_providers(id) ON DELETE SET NULL,
43
+ ADD COLUMN IF NOT EXISTS fallback_provider_id uuid REFERENCES public.email_providers(id) ON DELETE SET NULL;
@@ -0,0 +1,54 @@
1
+ -- 1. block_templates: staff only
2
+ DROP POLICY IF EXISTS "Anyone can read block_templates" ON public.block_templates;
3
+ CREATE POLICY "Staff read block_templates" ON public.block_templates
4
+ FOR SELECT TO authenticated USING (public.is_staff(auth.uid()));
5
+ REVOKE ALL ON public.block_templates FROM anon;
6
+
7
+ -- 2. pages / page_versions / page_publications: no anonymous reads
8
+ DROP POLICY IF EXISTS "Anyone can read pages" ON public.pages;
9
+ CREATE POLICY "Staff read pages" ON public.pages
10
+ FOR SELECT TO authenticated USING (public.is_staff(auth.uid()));
11
+ REVOKE ALL ON public.pages FROM anon;
12
+
13
+ DROP POLICY IF EXISTS "Anyone can read page_versions" ON public.page_versions;
14
+ CREATE POLICY "Staff read page_versions" ON public.page_versions
15
+ FOR SELECT TO authenticated USING (public.is_staff(auth.uid()));
16
+ REVOKE ALL ON public.page_versions FROM anon;
17
+
18
+ DROP POLICY IF EXISTS "Anyone can read page_publications" ON public.page_publications;
19
+ CREATE POLICY "Staff read page_publications" ON public.page_publications
20
+ FOR SELECT TO authenticated USING (public.is_staff(auth.uid()));
21
+ REVOKE ALL ON public.page_publications FROM anon;
22
+
23
+ -- 3. global sections + version history: no anonymous reads
24
+ DROP POLICY IF EXISTS "Anyone can read global_sections" ON public.global_sections;
25
+ CREATE POLICY "Staff read global_sections" ON public.global_sections
26
+ FOR SELECT TO authenticated USING (public.is_staff(auth.uid()));
27
+ REVOKE ALL ON public.global_sections FROM anon;
28
+
29
+ DROP POLICY IF EXISTS "Anyone can read global_section_versions" ON public.global_section_versions;
30
+ CREATE POLICY "Staff read global_section_versions" ON public.global_section_versions
31
+ FOR SELECT TO authenticated USING (public.is_staff(auth.uid()));
32
+ REVOKE ALL ON public.global_section_versions FROM anon;
33
+
34
+ -- 4. locale_domains: hide admin-domain routing from the public
35
+ DROP POLICY IF EXISTS "Anyone can read locale domains" ON public.locale_domains;
36
+ CREATE POLICY "Staff read locale domains" ON public.locale_domains
37
+ FOR SELECT TO authenticated USING (public.is_staff(auth.uid()));
38
+ REVOKE ALL ON public.locale_domains FROM anon;
39
+
40
+ -- 5. SECURITY DEFINER functions: not callable by anon/authenticated,
41
+ -- except the role helpers required by RLS policies.
42
+ REVOKE ALL ON FUNCTION public.grant_initial_admin() FROM PUBLIC, anon, authenticated;
43
+ REVOKE ALL ON FUNCTION public.touch_content_sections() FROM PUBLIC, anon, authenticated;
44
+ REVOKE ALL ON FUNCTION public.touch_page_seo() FROM PUBLIC, anon, authenticated;
45
+ REVOKE ALL ON FUNCTION public.touch_global_sections() FROM PUBLIC, anon, authenticated;
46
+ REVOKE ALL ON FUNCTION public.touch_locale_domains() FROM PUBLIC, anon, authenticated;
47
+ REVOKE ALL ON FUNCTION public.touch_pages() FROM PUBLIC, anon, authenticated;
48
+ REVOKE ALL ON FUNCTION public.touch_email_providers() FROM PUBLIC, anon, authenticated;
49
+ REVOKE ALL ON FUNCTION public.touch_email_settings() FROM PUBLIC, anon, authenticated;
50
+
51
+ REVOKE ALL ON FUNCTION public.has_role(uuid, public.app_role) FROM PUBLIC, anon;
52
+ REVOKE ALL ON FUNCTION public.is_staff(uuid) FROM PUBLIC, anon;
53
+ GRANT EXECUTE ON FUNCTION public.has_role(uuid, public.app_role) TO authenticated, service_role;
54
+ GRANT EXECUTE ON FUNCTION public.is_staff(uuid) TO authenticated, service_role;
@@ -0,0 +1,113 @@
1
+ DO $mig$
2
+ DECLARE
3
+ v_page uuid;
4
+ v_id uuid;
5
+ v_no int;
6
+ v_blocks jsonb;
7
+ BEGIN
8
+ SELECT id INTO v_page FROM public.pages WHERE key = 'utbildningen';
9
+ IF v_page IS NULL THEN RAISE NOTICE 'no page'; RETURN; END IF;
10
+ SELECT COALESCE(MAX(version_no),0)+1 INTO v_no FROM public.page_versions WHERE page_id = v_page;
11
+
12
+ v_blocks := $json$[
13
+ { "id": "intro", "type": "intro", "props": {} },
14
+ { "id": "included", "type": "checklist", "props": {
15
+ "tone": "paper",
16
+ "eyebrow": { "sv": "Bartenderkurs i Ayia Napa, Cypern", "en": "Bartender course in Ayia Napa, Cyprus" },
17
+ "title": { "sv": "Vad ingår?", "en": "What's included?" },
18
+ "columns": [
19
+ { "id": "c_inc", "title": { "sv": "Detta ingår", "en": "Included" }, "entries": [
20
+ { "id": "i1", "text": { "sv": "4 veckors teoretisk & praktisk kvalitetsutbildning", "en": "4 weeks of theoretical & practical quality training" } },
21
+ { "id": "i2", "text": { "sv": "Studiematerial", "en": "Study material" } },
22
+ { "id": "i3", "text": { "sv": "Boende 4 veckor i Ayia Napa", "en": "Accommodation for 4 weeks in Ayia Napa" } },
23
+ { "id": "i4", "text": { "sv": "Flairingkurs", "en": "Flairing course" } },
24
+ { "id": "i5", "text": { "sv": "Betyg, diplom och referenser", "en": "Grades, diploma and references" } },
25
+ { "id": "i6", "text": { "sv": "Rabatter på mat och dryck under kursens gång", "en": "Discounts on food and drinks during the course" } },
26
+ { "id": "i7", "text": { "sv": "Möjlighet till jobb efter avklarad kurs", "en": "Job opportunities after completing the course" } }
27
+ ] },
28
+ { "id": "c_perks", "title": { "sv": "Övriga förmåner", "en": "Other benefits" }, "entries": [
29
+ { "id": "p1", "text": { "sv": "VIP-kort (värde 2000 SEK)", "en": "VIP card (value 2000 SEK)" } },
30
+ { "id": "p2", "text": { "sv": "Strandaktiviteter", "en": "Beach activities" } },
31
+ { "id": "p3", "text": { "sv": "Gemensamma utgångar", "en": "Group nights out" } },
32
+ { "id": "p4", "text": { "sv": "Avslutningsmiddag och fest", "en": "Graduation dinner and party" } }
33
+ ] }
34
+ ]
35
+ } },
36
+ { "id": "about", "type": "prose", "props": {
37
+ "tone": "white",
38
+ "layout": "two-col",
39
+ "eyebrow": { "sv": "Utbildningen", "en": "The education" },
40
+ "title": { "sv": "En internationell bartenderutbildning", "en": "An international bartender education" },
41
+ "lead": { "sv": "Scandinavian Bar Academy utbildar dig till en internationell bartender. Bartenderutbildningen sker på svenska men vi tar emot elever från hela Norden. En fyraveckorsutbildning med fokus på kunskap, kvalité & service. Inga förkunskaper krävs!", "en": "Scandinavian Bar Academy trains you to become an international bartender. The course is taught in Swedish, but we welcome students from all of the Nordics. Four weeks focused on knowledge, quality and service. No previous experience required!" },
42
+ "items": [
43
+ { "id": "a1", "heading": { "sv": "Teori och praktik", "en": "Theory and practice" }, "body": { "sv": "Under utbildningen går du igenom alla grundläggande moment för att bli en bartender av högsta klass. Förutom teoretisk lära om diverse spritsorter och kundpsykologi praktiserar du fysiskt både på barskolan och på en bar eller nattklubb. Under praktiken sätts dina färdigheter på prov, vilket ger dig en uppfattning om hur det är att arbeta som bartender.", "en": "During the course you cover every fundamental skill needed to become a top-class bartender. Beyond theory on spirits and customer psychology, you practise both at the bar school and in a real bar or nightclub — putting your skills to the test." } },
44
+ { "id": "a2", "heading": { "sv": "Legitimerad bartender", "en": "Certified bartender" }, "body": { "sv": "SBA gör dig till legitimerad bartender och med certifikatet du erhåller efter avklarad utbildning har du möjlighet att jobba världen över. Efter avklarad utbildning och praktik erbjuder vi dig jobb på Cypern om intresset finns, så att du snabbt kan påbörja din karriär.", "en": "SBA makes you a certified bartender, and the certificate you receive lets you work all over the world. After finishing the course and internship we can offer you a job in Cyprus so you can start your career right away." } },
45
+ { "id": "a3", "heading": { "sv": "Garanterat jobb", "en": "Guaranteed job" }, "body": { "sv": "Tack vare Scandinavian Bar Academys kontakter med barer, klubbar och restauranger samt dess högt ansedda rykte kan barskolan garantera jobb efter avklarad kurs. Jobb garanteras på Cypern, Ayia Napa, samtidigt som certifikatet värderas högt i andra länder. SBA är ensamma om att kunna erbjuda denna fördelaktiga jobbgaranti.", "en": "Thanks to SBA's network of bars, clubs and restaurants — and our reputation — we guarantee a job after completing the course. Jobs are guaranteed in Ayia Napa, Cyprus, while the certificate is highly valued elsewhere too." } },
46
+ { "id": "a4", "heading": { "sv": "Praktik", "en": "Internship" }, "body": { "sv": "För att optimera bartenderutbildningen får alla elever praktik. Detta för att kombinera de teoretiska lärdomarna med den praktiska hetluften. Praktiken bidrar till en större förståelse för hur livet som bartender är, samtidigt som du får en högre beredskap till jobb efter utförd kurs. SBA är unika genom att erbjuda exklusiva praktikplatser på flertalet skandinaviska barer och nattklubbar.", "en": "Every student gets an internship, combining theory with the real thing. It gives you a deeper understanding of life as a bartender and makes you job-ready. SBA is unique in offering exclusive placements at several Scandinavian bars and nightclubs." } },
47
+ { "id": "a5", "heading": { "sv": "Bartenderutbildning på Cypern", "en": "Bartender training in Cyprus" }, "body": { "sv": "Genom att utbilda dig i Ayia Napa på Cypern får du ta del av en tropisk miljö med sol och bad. Här studerar du under en palm med utsikt över kristallblått vatten och tränar flairing på några av Europas finaste stränder. Mjuk finkornig sand, omtyckta restauranger och fantastisk service kännetecknar Cypern. I Ayia Napa erbjuder SBA allt från event och temakvällar till dagsutflykter.", "en": "Studying in Ayia Napa means sun, sea and a tropical setting. Study under a palm tree overlooking crystal-blue water and practise flairing on some of Europe's finest beaches. SBA arranges everything from events and theme nights to day trips." } },
48
+ { "id": "a6", "heading": { "sv": "Boende", "en": "Accommodation" }, "body": { "sv": "Boendet som ingår i utbildningen är Hotell Efi i Ayia Napa — ett populärt hotell bland skandinaviska ungdomar med gångavstånd till allt du behöver. På hotellområdet finns en stor pool med solterrass, loungebar och parasoller samt en snackbar öppen dygnet runt. Rummen har enklare kök, rymlig toalett med dusch/badkar samt balkong eller terrass.", "en": "Accommodation is at Hotel Efi in Ayia Napa — popular with Scandinavian students and within walking distance of everything. The hotel has a large pool with sun terrace, lounge bar and a 24-hour snack bar. Rooms include a kitchenette, spacious bathroom and balcony or terrace." } }
49
+ ]
50
+ } },
51
+ { "id": "features", "type": "features", "props": {} },
52
+ { "id": "curriculum", "type": "prose", "props": {
53
+ "tone": "paper",
54
+ "layout": "two-col",
55
+ "eyebrow": { "sv": "Kursinnehåll", "en": "Curriculum" },
56
+ "title": { "sv": "Vad lär man sig på SBA?", "en": "What do you learn at SBA?" },
57
+ "items": [
58
+ { "id": "k1", "heading": { "sv": "Utrustningen", "en": "The equipment" }, "body": { "sv": "Du lär dig att hantera en bartenders alla verktyg.", "en": "You learn to handle every tool a bartender uses." } },
59
+ { "id": "k2", "heading": { "sv": "Baruppställning", "en": "Bar setup" }, "body": { "sv": "Du får lära dig att ställa upp baren inför ett arbetspass.", "en": "You learn how to set up the bar before a shift." } },
60
+ { "id": "k3", "heading": { "sv": "Alkohollära", "en": "Spirits knowledge" }, "body": { "sv": "Här får du lära dig om de olika spritsorterna en bartender arbetar med. Vi går igenom historien bakom produktionen och smaksättningen av olika spritsorter.", "en": "Learn about the spirits a bartender works with, including the history of their production and flavouring." } },
61
+ { "id": "k4", "heading": { "sv": "Hygien", "en": "Hygiene" }, "body": { "sv": "Personlig hygien och renlighet är en självklar förutsättning för att arbeta som bartender. Vi går igenom hur du på bästa sätt förbereder dig inför ditt arbetspass.", "en": "Personal hygiene and cleanliness are essential. We go through how to best prepare for your shift." } },
62
+ { "id": "k5", "heading": { "sv": "Etikett", "en": "Etiquette" }, "body": { "sv": "Att ha respekt för sin arbetsplats är en viktig del av att vara bartender — här lär du dig om etikett bakom baren.", "en": "Respect for your workplace matters. Here you learn etiquette behind the bar." } },
63
+ { "id": "k6", "heading": { "sv": "Dekoration", "en": "Garnishing" }, "body": { "sv": "Du får lära dig hur du dekorerar olika cocktails och drinkar på olika sätt.", "en": "Learn how to garnish cocktails and drinks in different ways." } },
64
+ { "id": "k7", "heading": { "sv": "Mixologi", "en": "Mixology" }, "body": { "sv": "Här lär du dig att blanda drinkar. Denna del av utbildningen lär dig principerna för mixologi och hur du bygger en drink i lager och nivåer. Du kommer att lära dig över 100 av de populäraste och vanligaste cocktailsen.", "en": "Learn to mix drinks — the principles of mixology and how to build a drink in layers. You will learn over 100 of the most popular cocktails." } },
65
+ { "id": "k8", "heading": { "sv": "Speed Mixing/Pouring", "en": "Speed mixing/pouring" }, "body": { "sv": "I den här delen lär du dig att förbereda ett flertal drinkar snabbt och exakt utan att behöva använda mätmått.", "en": "Learn to prepare multiple drinks quickly and accurately without using a jigger." } },
66
+ { "id": "k9", "heading": { "sv": "Kundservice", "en": "Customer service" }, "body": { "sv": "Du lär dig att ge effektiv, tillmötesgående och framförallt trevlig service. En nöjd kund kommer tillbaka, ger dricks och sprider ett gott ord om din bar. Vi går även igenom hur man hanterar berusade gäster.", "en": "Learn to give efficient, welcoming and genuinely pleasant service. We also cover how to handle intoxicated guests." } },
67
+ { "id": "k10", "heading": { "sv": "Flairing", "en": "Flairing" }, "body": { "sv": "Vi lär dig hur working flair går till — att showa i baren samtidigt som du blandar drinkar. Det finns möjlighet att gå extra flairlektioner.", "en": "Learn working flair — performing while you mix. Extra flair lessons are available." } },
68
+ { "id": "k11", "heading": { "sv": "Praktik", "en": "Internship" }, "body": { "sv": "Du praktiserar på någon av partymetropolen Ayia Napas många barer och nattklubbar. Vi samarbetar enbart med de bästa och största ställena. När du börjar din praktik får du träffa alla som jobbar där, gå igenom regler och sortiment, och du tilldelas en handledare som du arbetar sida vid sida med.", "en": "You intern at one of Ayia Napa's many bars and nightclubs. We only work with the best venues. You meet the team, go through the rules and range, and are assigned a supervisor to work alongside." } },
69
+ { "id": "k12", "heading": { "sv": "Serveringstillstånd", "en": "Licensing law" }, "body": { "sv": "Vi går igenom de olika regler och lagar som gäller i Sverige vid alkoholservering. Du får lära dig att servera alkohol med medvetenhet.", "en": "We cover the rules and laws for serving alcohol in Sweden, so you serve responsibly." } },
70
+ { "id": "k13", "heading": { "sv": "Prov", "en": "Exams" }, "body": { "sv": "Under kursen har vi flera delprov som spelar in på ditt slutbetyg — allt för att se till att du är i fas och har vad som krävs för att bli bartender.", "en": "Several tests during the course count towards your final grade, making sure you are on track to become a bartender." } }
71
+ ]
72
+ } },
73
+ { "id": "memories", "type": "prose", "props": {
74
+ "tone": "white",
75
+ "layout": "single",
76
+ "eyebrow": { "sv": "Mer än en utbildning", "en": "More than a course" },
77
+ "title": { "sv": "Minnen för livet", "en": "Memories for life" },
78
+ "lead": { "sv": "SBA är mycket mer än bara en utbildning. Det är ett äventyr som berikar dig med minnen för livet.", "en": "SBA is much more than a course. It is an adventure that leaves you with memories for life." },
79
+ "items": [
80
+ { "id": "m1", "heading": { "sv": "Vänner för livet", "en": "Friends for life" }, "body": { "sv": "Under utbildningens gång träffar du mängder av olika personer. Du lär känna turister under praktiken men också personalen som jobbar på barerna och nattklubbarna på orten. Du knyter vänskapsband för livet — en bidragande anledning till att majoriteten av våra elever väljer att stanna kvar i Ayia Napa efter utbildningen.", "en": "You meet a huge range of people during the course — guests during your internship and the crews working the bars and clubs in town. You build friendships for life, which is why most of our students choose to stay in Ayia Napa afterwards." } },
81
+ { "id": "m2", "heading": { "sv": "Upplevelser i Ayia Napa", "en": "Experiences in Ayia Napa" }, "body": { "sv": "Under din vistelse får du möjlighet att ta del av många upplevelser: havsgrottor, stränder i världsklass, event som barrundor och partybåt och mycket mer.", "en": "During your stay you get to experience sea caves, world-class beaches, events such as bar crawls and party boats, and much more." } }
82
+ ]
83
+ } },
84
+ { "id": "vip", "type": "checklist", "props": {
85
+ "tone": "paper",
86
+ "eyebrow": { "sv": "Ingår kostnadsfritt", "en": "Included free of charge" },
87
+ "title": { "sv": "VIP-kort", "en": "VIP card" },
88
+ "note": { "sv": "Under din bartenderutbildning får du ett exklusivt och kostnadsfritt VIP-kort som ger dig rabatter och förmåner runt om i Ayia Napa — allt för att minimera dina kostnader under utbildningen.", "en": "During your training you receive an exclusive, free VIP card giving you discounts and perks around Ayia Napa — keeping your costs down while you study." },
89
+ "columns": [
90
+ { "id": "vipc", "title": { "sv": "Exempel på rabatter", "en": "Examples of discounts" }, "entries": [
91
+ { "id": "v1", "text": { "sv": "Gratis inträde hos vår samarbetspartner Blue Moon Bar (värde 100 SEK per kväll)", "en": "Free entry at our partner Blue Moon Bar (value 100 SEK per night)" } },
92
+ { "id": "v2", "text": { "sv": "Gratis inträde hos vår samarbetspartner Aqua Club (värde 70 SEK per kväll)", "en": "Free entry at our partner Aqua Club (value 70 SEK per night)" } },
93
+ { "id": "v3", "text": { "sv": "Ca 70 % rabatt på dryck", "en": "Approx. 70% off drinks" } },
94
+ { "id": "v4", "text": { "sv": "Ca 20 SEK per drink", "en": "Approx. 20 SEK per drink" } },
95
+ { "id": "v5", "text": { "sv": "Ca 10 SEK per shot", "en": "Approx. 10 SEK per shot" } },
96
+ { "id": "v6", "text": { "sv": "10–30 % rabatt på utvalda restauranger", "en": "10–30% off selected restaurants" } },
97
+ { "id": "v7", "text": { "sv": "50–70 % rabatt på event", "en": "50–70% off events" } }
98
+ ] }
99
+ ]
100
+ } },
101
+ { "id": "cta", "type": "contactCta", "props": {} }
102
+ ]$json$::jsonb;
103
+
104
+ INSERT INTO public.page_versions (page_id, version_no, blocks, note)
105
+ VALUES (v_page, v_no, v_blocks, 'Imported full content from barskola.nu/utbildningen')
106
+ RETURNING id INTO v_id;
107
+
108
+ UPDATE public.pages SET current_version_id = v_id, status = 'published', updated_at = now() WHERE id = v_page;
109
+ UPDATE public.page_publications SET is_active = false WHERE page_id = v_page AND is_active = true;
110
+ INSERT INTO public.page_publications (page_id, version_id, is_active, note)
111
+ VALUES (v_page, v_id, true, 'Full education page content');
112
+ END
113
+ $mig$;
@@ -0,0 +1,22 @@
1
+ CREATE OR REPLACE FUNCTION public.has_role(_user_id uuid, _role app_role)
2
+ RETURNS boolean
3
+ LANGUAGE sql
4
+ STABLE SECURITY DEFINER
5
+ SET search_path TO 'public'
6
+ AS $function$
7
+ SELECT EXISTS (
8
+ SELECT 1 FROM public.user_roles
9
+ WHERE user_id = _user_id
10
+ AND (
11
+ role = _role
12
+ -- superadmin implies every non-superadmin role
13
+ OR (role = 'superadmin'::app_role AND _role <> 'superadmin'::app_role)
14
+ )
15
+ )
16
+ $function$;
17
+
18
+ REVOKE ALL ON FUNCTION public.has_role(uuid, app_role) FROM PUBLIC, anon;
19
+ GRANT EXECUTE ON FUNCTION public.has_role(uuid, app_role) TO authenticated, service_role;
20
+
21
+ REVOKE ALL ON FUNCTION public.is_staff(uuid) FROM PUBLIC, anon;
22
+ GRANT EXECUTE ON FUNCTION public.is_staff(uuid) TO authenticated, service_role;
@@ -0,0 +1,24 @@
1
+ WITH m(cid, url) AS (VALUES
2
+ ('c2026-05','https://booking.barskola.nu/bokning-steg-2?ps=BARSKOLA2026-08-10&adults=1'),
3
+ ('c2026-06','https://booking.barskola.nu/bokning-steg-2?ps=BARSKOLA2026-09-07&adults=1'),
4
+ ('c2026-07','https://booking.barskola.nu/bokning-steg-2?ps=BARSKOLA-PMI2026-10-05&adults=1'),
5
+ ('c2027-01','https://booking.barskola.nu/bokning-steg-2?ps=BARSKOLA2027-04-12&adults=1'),
6
+ ('c2027-02','https://booking.barskola.nu/bokning-steg-2?ps=BARSKOLA2027-05-10&adults=1'),
7
+ ('c2027-03','https://booking.barskola.nu/bokning-steg-2?ps=BARSKOLA2027-06-21&adults=1'),
8
+ ('c2027-04','https://booking.barskola.nu/bokning-steg-2?ps=BARSKOLA2027-07-19&adults=1'),
9
+ ('c2027-05','https://booking.barskola.nu/bokning-steg-2?ps=BARSKOLA2027-08-16&adults=1'),
10
+ ('c2027-06','https://booking.barskola.nu/bokning-steg-2?ps=BARSKOLA2027-09-13&adults=1')
11
+ ),
12
+ fixed AS (
13
+ SELECT jsonb_agg(
14
+ CASE WHEN m.url IS NOT NULL THEN jsonb_set(item, '{bookUrl}', to_jsonb(m.url)) ELSE item END
15
+ ORDER BY ord
16
+ ) AS items
17
+ FROM content_sections cs,
18
+ LATERAL jsonb_array_elements(cs.data->'items') WITH ORDINALITY AS t(item, ord)
19
+ LEFT JOIN m ON m.cid = t.item->>'id'
20
+ WHERE cs.key = 'packages'
21
+ )
22
+ UPDATE content_sections cs
23
+ SET data = jsonb_set(cs.data, '{items}', (SELECT items FROM fixed))
24
+ WHERE cs.key = 'packages';
@@ -0,0 +1,13 @@
1
+ UPDATE public.page_versions v
2
+ SET blocks = (
3
+ SELECT jsonb_agg(
4
+ CASE
5
+ WHEN b->>'type' = 'courseList' AND (b->'props'->'note') IS NULL
6
+ THEN jsonb_set(b, '{props,note}', '{"sv":"Alla kurser inkluderar boende, utbildningsmaterial, examen och SBA-diplom. Flyg tillkommer.","en":"All courses include accommodation, course materials, the exam and the SBA diploma. Flights are not included."}'::jsonb)
7
+ ELSE b
8
+ END ORDER BY ord
9
+ )
10
+ FROM jsonb_array_elements(v.blocks) WITH ORDINALITY AS t(b, ord)
11
+ )
12
+ FROM public.pages p
13
+ WHERE p.key = 'boka' AND v.page_id = p.id;
@@ -0,0 +1,38 @@
1
+ CREATE TABLE public.media_focus (
2
+ path text PRIMARY KEY,
3
+ focal_x smallint NOT NULL DEFAULT 50 CHECK (focal_x >= 0 AND focal_x <= 100),
4
+ focal_y smallint NOT NULL DEFAULT 50 CHECK (focal_y >= 0 AND focal_y <= 100),
5
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
6
+ updated_at timestamp with time zone NOT NULL DEFAULT now(),
7
+ updated_by uuid
8
+ );
9
+
10
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.media_focus TO authenticated;
11
+ GRANT ALL ON public.media_focus TO service_role;
12
+
13
+ ALTER TABLE public.media_focus ENABLE ROW LEVEL SECURITY;
14
+
15
+ CREATE POLICY "Staff read media_focus" ON public.media_focus
16
+ FOR SELECT TO authenticated USING (public.is_staff(auth.uid()));
17
+ CREATE POLICY "Staff insert media_focus" ON public.media_focus
18
+ FOR INSERT TO authenticated WITH CHECK (public.is_staff(auth.uid()));
19
+ CREATE POLICY "Staff update media_focus" ON public.media_focus
20
+ FOR UPDATE TO authenticated USING (public.is_staff(auth.uid())) WITH CHECK (public.is_staff(auth.uid()));
21
+ CREATE POLICY "Staff delete media_focus" ON public.media_focus
22
+ FOR DELETE TO authenticated USING (public.is_staff(auth.uid()));
23
+
24
+ CREATE OR REPLACE FUNCTION public.touch_media_focus()
25
+ RETURNS trigger
26
+ LANGUAGE plpgsql
27
+ SET search_path = public
28
+ AS $$
29
+ BEGIN
30
+ NEW.updated_at = now();
31
+ NEW.updated_by = auth.uid();
32
+ RETURN NEW;
33
+ END;
34
+ $$;
35
+
36
+ CREATE TRIGGER touch_media_focus_before_update
37
+ BEFORE UPDATE ON public.media_focus
38
+ FOR EACH ROW EXECUTE FUNCTION public.touch_media_focus();
@@ -0,0 +1,62 @@
1
+ CREATE TABLE public.site_injections (
2
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3
+ name text NOT NULL DEFAULT 'Untitled',
4
+ scope_type text NOT NULL DEFAULT 'global',
5
+ scope_value text NOT NULL DEFAULT '',
6
+ enabled boolean NOT NULL DEFAULT false,
7
+ head_html text NOT NULL DEFAULT '',
8
+ body_start_html text NOT NULL DEFAULT '',
9
+ body_end_html text NOT NULL DEFAULT '',
10
+ gtm_id text NOT NULL DEFAULT '',
11
+ ga4_id text NOT NULL DEFAULT '',
12
+ meta_pixel_id text NOT NULL DEFAULT '',
13
+ tiktok_pixel_id text NOT NULL DEFAULT '',
14
+ extra_meta jsonb NOT NULL DEFAULT '[]'::jsonb,
15
+ sort_order integer NOT NULL DEFAULT 100,
16
+ created_at timestamptz NOT NULL DEFAULT now(),
17
+ updated_at timestamptz NOT NULL DEFAULT now(),
18
+ updated_by uuid,
19
+ CONSTRAINT site_injections_scope_type_check
20
+ CHECK (scope_type IN ('global', 'locale', 'domain'))
21
+ );
22
+
23
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.site_injections TO authenticated;
24
+ GRANT ALL ON public.site_injections TO service_role;
25
+
26
+ ALTER TABLE public.site_injections ENABLE ROW LEVEL SECURITY;
27
+
28
+ CREATE POLICY "Staff read site_injections"
29
+ ON public.site_injections FOR SELECT TO authenticated
30
+ USING (is_staff(auth.uid()));
31
+
32
+ CREATE POLICY "Admins insert site_injections"
33
+ ON public.site_injections FOR INSERT TO authenticated
34
+ WITH CHECK (has_role(auth.uid(), 'admin'::app_role));
35
+
36
+ CREATE POLICY "Admins update site_injections"
37
+ ON public.site_injections FOR UPDATE TO authenticated
38
+ USING (has_role(auth.uid(), 'admin'::app_role))
39
+ WITH CHECK (has_role(auth.uid(), 'admin'::app_role));
40
+
41
+ CREATE POLICY "Admins delete site_injections"
42
+ ON public.site_injections FOR DELETE TO authenticated
43
+ USING (has_role(auth.uid(), 'admin'::app_role));
44
+
45
+ CREATE OR REPLACE FUNCTION public.touch_site_injections()
46
+ RETURNS trigger
47
+ LANGUAGE plpgsql
48
+ SET search_path TO 'public'
49
+ AS $$
50
+ BEGIN
51
+ NEW.updated_at = now();
52
+ NEW.updated_by = auth.uid();
53
+ RETURN NEW;
54
+ END;
55
+ $$;
56
+
57
+ CREATE TRIGGER touch_site_injections
58
+ BEFORE UPDATE ON public.site_injections
59
+ FOR EACH ROW EXECUTE FUNCTION public.touch_site_injections();
60
+
61
+ INSERT INTO public.site_injections (name, scope_type, scope_value, enabled, sort_order)
62
+ VALUES ('All sites', 'global', '', false, 10);
@@ -0,0 +1,29 @@
1
+ CREATE TABLE public.media_trash (
2
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3
+ original_path text NOT NULL,
4
+ paths jsonb NOT NULL DEFAULT '[]'::jsonb,
5
+ trash_prefix text NOT NULL,
6
+ size_bytes bigint NOT NULL DEFAULT 0,
7
+ content_type text,
8
+ deleted_by uuid,
9
+ deleted_by_email text,
10
+ usage_snapshot jsonb NOT NULL DEFAULT '[]'::jsonb,
11
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
12
+ deleted_at timestamp with time zone NOT NULL DEFAULT now()
13
+ );
14
+
15
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.media_trash TO authenticated;
16
+ GRANT ALL ON public.media_trash TO service_role;
17
+
18
+ ALTER TABLE public.media_trash ENABLE ROW LEVEL SECURITY;
19
+
20
+ CREATE POLICY "Staff read media_trash" ON public.media_trash
21
+ FOR SELECT TO authenticated USING (is_staff(auth.uid()));
22
+
23
+ CREATE POLICY "Staff insert media_trash" ON public.media_trash
24
+ FOR INSERT TO authenticated WITH CHECK (is_staff(auth.uid()));
25
+
26
+ CREATE POLICY "Superadmins delete media_trash" ON public.media_trash
27
+ FOR DELETE TO authenticated USING (has_role(auth.uid(), 'superadmin'::app_role));
28
+
29
+ CREATE INDEX media_trash_deleted_at_idx ON public.media_trash (deleted_at DESC);
@@ -0,0 +1,73 @@
1
+
2
+ DO $$
3
+ DECLARE
4
+ v_page_id uuid := '35fe1548-05fc-4e62-8323-c9f608574eb7';
5
+ v_cur uuid;
6
+ v_blocks jsonb;
7
+ b1 jsonb; b2 jsonb;
8
+ gs1 uuid; gs2 uuid; gv1 uuid; gv2 uuid;
9
+ new_blocks jsonb := '[]'::jsonb;
10
+ el jsonb;
11
+ next_no int;
12
+ new_ver uuid;
13
+ BEGIN
14
+ SELECT current_version_id INTO v_cur FROM public.pages WHERE id = v_page_id;
15
+ SELECT blocks INTO v_blocks FROM public.page_versions WHERE id = v_cur;
16
+
17
+ SELECT b INTO b1 FROM jsonb_array_elements(v_blocks) t(b) WHERE b->>'id' = 'mediaSplit_8lyj9ip';
18
+ SELECT b INTO b2 FROM jsonb_array_elements(v_blocks) t(b) WHERE b->>'id' = 'mediaSplit_c66vhm7';
19
+ -- One-off content back-fill against rows that only exist in the live
20
+ -- database. On a fresh self-hosted install there is nothing to convert, so
21
+ -- it must be a no-op instead of aborting the whole initialisation.
22
+ IF b1 IS NULL OR b2 IS NULL THEN
23
+ RAISE NOTICE 'skipping global-section extraction: source blocks not present';
24
+ RETURN;
25
+ END IF;
26
+
27
+ INSERT INTO public.global_sections (key, name)
28
+ VALUES ('bartender-utbildning-utomlands', 'Bartender utbildning utomlands')
29
+ ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name
30
+ RETURNING id INTO gs1;
31
+
32
+ INSERT INTO public.global_sections (key, name)
33
+ VALUES ('utbildningen-mer-an-ett-diplom', 'Utbildningen som ger dig mer än ett diplom')
34
+ ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name
35
+ RETURNING id INTO gs2;
36
+
37
+ INSERT INTO public.global_section_versions (section_id, version_no, blocks, note)
38
+ VALUES (gs1, COALESCE((SELECT max(version_no) FROM public.global_section_versions WHERE section_id = gs1), 0) + 1,
39
+ jsonb_build_array(b1), 'Copied from homepage')
40
+ RETURNING id INTO gv1;
41
+
42
+ INSERT INTO public.global_section_versions (section_id, version_no, blocks, note)
43
+ VALUES (gs2, COALESCE((SELECT max(version_no) FROM public.global_section_versions WHERE section_id = gs2), 0) + 1,
44
+ jsonb_build_array(b2), 'Copied from homepage')
45
+ RETURNING id INTO gv2;
46
+
47
+ UPDATE public.global_sections SET current_version_id = gv1 WHERE id = gs1;
48
+ UPDATE public.global_sections SET current_version_id = gv2 WHERE id = gs2;
49
+
50
+ FOR el IN SELECT b FROM jsonb_array_elements(v_blocks) t(b) LOOP
51
+ IF el->>'id' = 'mediaSplit_8lyj9ip' THEN
52
+ new_blocks := new_blocks || jsonb_build_array(jsonb_build_object(
53
+ 'id', 'globalRef_bartender_utomlands', 'type', 'globalRef',
54
+ 'props', jsonb_build_object('sectionKey', 'bartender-utbildning-utomlands')));
55
+ ELSIF el->>'id' = 'mediaSplit_c66vhm7' THEN
56
+ new_blocks := new_blocks || jsonb_build_array(jsonb_build_object(
57
+ 'id', 'globalRef_mer_an_ett_diplom', 'type', 'globalRef',
58
+ 'props', jsonb_build_object('sectionKey', 'utbildningen-mer-an-ett-diplom')));
59
+ ELSE
60
+ new_blocks := new_blocks || jsonb_build_array(el);
61
+ END IF;
62
+ END LOOP;
63
+
64
+ SELECT COALESCE(max(version_no), 0) + 1 INTO next_no FROM public.page_versions WHERE page_id = v_page_id;
65
+ INSERT INTO public.page_versions (page_id, version_no, blocks, note)
66
+ VALUES (v_page_id, next_no, new_blocks, 'Extracted two media split blocks into global sections')
67
+ RETURNING id INTO new_ver;
68
+
69
+ UPDATE public.pages SET current_version_id = new_ver WHERE id = v_page_id;
70
+ UPDATE public.page_publications SET is_active = false WHERE page_id = v_page_id AND is_active;
71
+ INSERT INTO public.page_publications (page_id, version_id, is_active, note)
72
+ VALUES (v_page_id, new_ver, true, 'Global section extraction');
73
+ END $$;