create-bw-app 0.9.5 → 0.9.7

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.
@@ -0,0 +1,317 @@
1
+ -- Brightweb admin v1 baseline.
2
+
3
+ CREATE TABLE IF NOT EXISTS public.roles (
4
+ code text PRIMARY KEY,
5
+ label text NOT NULL,
6
+ created_at timestamptz NOT NULL DEFAULT now()
7
+ );
8
+
9
+ ALTER TABLE public.roles ENABLE ROW LEVEL SECURITY;
10
+
11
+ DROP POLICY IF EXISTS "Authenticated users can view roles" ON public.roles;
12
+ CREATE POLICY "Authenticated users can view roles"
13
+ ON public.roles
14
+ FOR SELECT
15
+ TO authenticated
16
+ USING (true);
17
+
18
+ INSERT INTO public.roles (code, label)
19
+ VALUES
20
+ ('client', 'Client'),
21
+ ('staff', 'Staff'),
22
+ ('admin', 'Admin')
23
+ ON CONFLICT (code) DO UPDATE
24
+ SET label = EXCLUDED.label;
25
+
26
+ CREATE TABLE IF NOT EXISTS public.user_role_assignments (
27
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
28
+ profile_id uuid NOT NULL UNIQUE REFERENCES public.profiles(id) ON DELETE CASCADE,
29
+ role_code text NOT NULL REFERENCES public.roles(code),
30
+ assigned_by_profile_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
31
+ assigned_at timestamptz NOT NULL DEFAULT now(),
32
+ reason text
33
+ );
34
+
35
+ CREATE INDEX IF NOT EXISTS idx_user_role_assignments_role_code
36
+ ON public.user_role_assignments (role_code);
37
+
38
+ ALTER TABLE public.user_role_assignments ENABLE ROW LEVEL SECURITY;
39
+
40
+ DROP POLICY IF EXISTS "Users can view own role assignments" ON public.user_role_assignments;
41
+ CREATE POLICY "Users can view own role assignments"
42
+ ON public.user_role_assignments
43
+ FOR SELECT
44
+ TO authenticated
45
+ USING (profile_id = public.current_profile_id());
46
+
47
+ DROP POLICY IF EXISTS "Staff can view role assignments" ON public.user_role_assignments;
48
+ CREATE POLICY "Staff can view role assignments"
49
+ ON public.user_role_assignments
50
+ FOR SELECT
51
+ TO authenticated
52
+ USING (public.is_staff());
53
+
54
+ CREATE TABLE IF NOT EXISTS public.role_change_audit (
55
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
56
+ target_profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
57
+ changed_by_profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE RESTRICT,
58
+ old_role_code text,
59
+ new_role_code text NOT NULL REFERENCES public.roles(code),
60
+ reason text,
61
+ created_at timestamptz NOT NULL DEFAULT now()
62
+ );
63
+
64
+ CREATE INDEX IF NOT EXISTS idx_role_change_audit_target
65
+ ON public.role_change_audit (target_profile_id, created_at DESC);
66
+
67
+ ALTER TABLE public.role_change_audit ENABLE ROW LEVEL SECURITY;
68
+
69
+ DROP POLICY IF EXISTS "Admins can view role change audit" ON public.role_change_audit;
70
+ CREATE POLICY "Admins can view role change audit"
71
+ ON public.role_change_audit
72
+ FOR SELECT
73
+ TO authenticated
74
+ USING (public.is_admin());
75
+
76
+ CREATE OR REPLACE FUNCTION public.current_global_role()
77
+ RETURNS text
78
+ LANGUAGE sql
79
+ STABLE
80
+ SECURITY DEFINER
81
+ SET search_path = public
82
+ AS $$
83
+ SELECT ura.role_code
84
+ FROM public.user_role_assignments ura
85
+ WHERE ura.profile_id = public.current_profile_id()
86
+ LIMIT 1
87
+ $$;
88
+
89
+ CREATE OR REPLACE FUNCTION public.has_role(target_role text)
90
+ RETURNS boolean
91
+ LANGUAGE sql
92
+ STABLE
93
+ SECURITY DEFINER
94
+ SET search_path = public
95
+ AS $$
96
+ SELECT EXISTS (
97
+ SELECT 1
98
+ FROM public.user_role_assignments ura
99
+ WHERE ura.profile_id = public.current_profile_id()
100
+ AND ura.role_code = target_role
101
+ )
102
+ $$;
103
+
104
+ CREATE OR REPLACE FUNCTION public.is_staff()
105
+ RETURNS boolean
106
+ LANGUAGE sql
107
+ STABLE
108
+ SECURITY DEFINER
109
+ SET search_path = public
110
+ AS $$
111
+ SELECT COALESCE(public.current_global_role() IN ('staff', 'admin'), false)
112
+ $$;
113
+
114
+ CREATE OR REPLACE FUNCTION public.is_admin()
115
+ RETURNS boolean
116
+ LANGUAGE sql
117
+ STABLE
118
+ SECURITY DEFINER
119
+ SET search_path = public
120
+ AS $$
121
+ SELECT COALESCE(public.current_global_role() = 'admin', false)
122
+ $$;
123
+
124
+ CREATE OR REPLACE FUNCTION public.admin_set_user_role(
125
+ p_target_profile_id uuid,
126
+ p_new_role_code text,
127
+ p_reason text DEFAULT NULL
128
+ )
129
+ RETURNS TABLE (
130
+ changed boolean,
131
+ reason text,
132
+ old_role_code text,
133
+ new_role_code text
134
+ )
135
+ LANGUAGE plpgsql
136
+ SECURITY DEFINER
137
+ SET search_path = public
138
+ AS $$
139
+ DECLARE
140
+ v_actor_profile_id uuid;
141
+ v_target_exists boolean;
142
+ v_old_role text;
143
+ v_admin_count integer;
144
+ BEGIN
145
+ v_actor_profile_id := public.current_profile_id();
146
+
147
+ IF v_actor_profile_id IS NULL OR NOT public.is_admin() THEN
148
+ RAISE EXCEPTION 'Only admins can change roles'
149
+ USING ERRCODE = '42501';
150
+ END IF;
151
+
152
+ IF p_new_role_code IS NULL OR NOT EXISTS (
153
+ SELECT 1 FROM public.roles r WHERE r.code = p_new_role_code
154
+ ) THEN
155
+ RAISE EXCEPTION 'Invalid role code: %', p_new_role_code
156
+ USING ERRCODE = '22023';
157
+ END IF;
158
+
159
+ SELECT EXISTS (
160
+ SELECT 1 FROM public.profiles p WHERE p.id = p_target_profile_id
161
+ ) INTO v_target_exists;
162
+
163
+ IF NOT v_target_exists THEN
164
+ RETURN QUERY SELECT false, 'not_found', NULL::text, p_new_role_code;
165
+ RETURN;
166
+ END IF;
167
+
168
+ SELECT ura.role_code
169
+ INTO v_old_role
170
+ FROM public.user_role_assignments ura
171
+ WHERE ura.profile_id = p_target_profile_id
172
+ LIMIT 1;
173
+
174
+ IF v_old_role = p_new_role_code THEN
175
+ RETURN QUERY SELECT false, 'already_role', v_old_role, p_new_role_code;
176
+ RETURN;
177
+ END IF;
178
+
179
+ IF v_old_role = 'admin' AND p_new_role_code <> 'admin' THEN
180
+ SELECT count(*)
181
+ INTO v_admin_count
182
+ FROM public.user_role_assignments ura
183
+ WHERE ura.role_code = 'admin';
184
+
185
+ IF v_admin_count <= 1 THEN
186
+ RETURN QUERY SELECT false, 'last_admin_guard', v_old_role, p_new_role_code;
187
+ RETURN;
188
+ END IF;
189
+ END IF;
190
+
191
+ INSERT INTO public.user_role_assignments (
192
+ profile_id,
193
+ role_code,
194
+ assigned_by_profile_id,
195
+ assigned_at,
196
+ reason
197
+ )
198
+ VALUES (
199
+ p_target_profile_id,
200
+ p_new_role_code,
201
+ v_actor_profile_id,
202
+ now(),
203
+ NULLIF(trim(COALESCE(p_reason, '')), '')
204
+ )
205
+ ON CONFLICT (profile_id)
206
+ DO UPDATE
207
+ SET role_code = EXCLUDED.role_code,
208
+ assigned_by_profile_id = EXCLUDED.assigned_by_profile_id,
209
+ assigned_at = EXCLUDED.assigned_at,
210
+ reason = EXCLUDED.reason;
211
+
212
+ INSERT INTO public.role_change_audit (
213
+ target_profile_id,
214
+ changed_by_profile_id,
215
+ old_role_code,
216
+ new_role_code,
217
+ reason,
218
+ created_at
219
+ ) VALUES (
220
+ p_target_profile_id,
221
+ v_actor_profile_id,
222
+ v_old_role,
223
+ p_new_role_code,
224
+ NULLIF(trim(COALESCE(p_reason, '')), ''),
225
+ now()
226
+ );
227
+
228
+ RETURN QUERY SELECT true, 'changed', v_old_role, p_new_role_code;
229
+ END;
230
+ $$;
231
+
232
+ CREATE OR REPLACE FUNCTION public.guard_privileged_profile_fields()
233
+ RETURNS trigger
234
+ LANGUAGE plpgsql
235
+ SECURITY DEFINER
236
+ SET search_path = public
237
+ AS $$
238
+ BEGIN
239
+ IF auth.role() = 'service_role' THEN
240
+ RETURN NEW;
241
+ END IF;
242
+
243
+ IF public.is_admin() THEN
244
+ RETURN NEW;
245
+ END IF;
246
+
247
+ IF public.is_staff() THEN
248
+ IF NEW.user_id IS DISTINCT FROM OLD.user_id
249
+ OR NEW.email IS DISTINCT FROM OLD.email
250
+ OR NEW.created_at IS DISTINCT FROM OLD.created_at
251
+ THEN
252
+ RAISE EXCEPTION 'Staff cannot modify privileged profile fields'
253
+ USING ERRCODE = '42501';
254
+ END IF;
255
+ END IF;
256
+
257
+ RETURN NEW;
258
+ END;
259
+ $$;
260
+
261
+ DROP TRIGGER IF EXISTS guard_privileged_profile_fields ON public.profiles;
262
+ CREATE TRIGGER guard_privileged_profile_fields
263
+ BEFORE UPDATE ON public.profiles
264
+ FOR EACH ROW
265
+ EXECUTE FUNCTION public.guard_privileged_profile_fields();
266
+
267
+ CREATE OR REPLACE FUNCTION public.ensure_profile_role_assignment()
268
+ RETURNS trigger
269
+ LANGUAGE plpgsql
270
+ SECURITY DEFINER
271
+ SET search_path = public
272
+ AS $$
273
+ BEGIN
274
+ INSERT INTO public.user_role_assignments (
275
+ profile_id,
276
+ role_code,
277
+ assigned_at,
278
+ assigned_by_profile_id,
279
+ reason
280
+ )
281
+ VALUES (
282
+ NEW.id,
283
+ 'client',
284
+ now(),
285
+ NULL,
286
+ 'auto_profile_default_client_role'
287
+ )
288
+ ON CONFLICT (profile_id)
289
+ DO NOTHING;
290
+
291
+ RETURN NEW;
292
+ END;
293
+ $$;
294
+
295
+ DROP TRIGGER IF EXISTS trg_ensure_profile_role_assignment ON public.profiles;
296
+ CREATE TRIGGER trg_ensure_profile_role_assignment
297
+ AFTER INSERT ON public.profiles
298
+ FOR EACH ROW
299
+ EXECUTE FUNCTION public.ensure_profile_role_assignment();
300
+
301
+ INSERT INTO public.user_role_assignments (
302
+ profile_id,
303
+ role_code,
304
+ assigned_at,
305
+ assigned_by_profile_id,
306
+ reason
307
+ )
308
+ SELECT
309
+ p.id,
310
+ 'client',
311
+ now(),
312
+ NULL,
313
+ 'auto_backfill_missing_profile_role'
314
+ FROM public.profiles p
315
+ LEFT JOIN public.user_role_assignments ura
316
+ ON ura.profile_id = p.id
317
+ WHERE ura.profile_id IS NULL;
@@ -0,0 +1,24 @@
1
+ # Core Migrations
2
+
3
+ `core` owns shared platform foundations that every client instance needs.
4
+
5
+ ## Owns
6
+
7
+ - `profiles`
8
+ - auth/profile sync triggers
9
+ - shared helper functions like `current_profile_id()`
10
+ - shared app events / alerts foundations
11
+ - rate limiting primitives
12
+ - global notification/language preferences
13
+ - any always-on shared platform table or helper
14
+
15
+ ## Does not own
16
+
17
+ - module-specific CRM tables
18
+ - project collaboration tables
19
+ - admin-only role governance tables
20
+ - client-only tables
21
+
22
+ ## Apply order
23
+
24
+ Apply `core` before any module migrations.