create-bw-app 0.24.8 → 0.25.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 (32) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/src/bw.mjs +1 -1
  4. package/src/constants.mjs +14 -9
  5. package/src/generator.mjs +3 -6
  6. package/src/migrations.mjs +43 -5
  7. package/src/upgrade.mjs +54 -2
  8. package/template/base/app/(shell)/dashboard/dashboard-page-mount.tsx +7 -0
  9. package/template/base/app/(shell)/dashboard/page.tsx +1 -1
  10. package/template/base/app/(shell)/shell-layout-client.tsx +28 -7
  11. package/template/modules/crm/app/(shell)/crm/organizations/[id]/page.tsx +1 -1
  12. package/template/modules/crm/app/(shell)/crm/organizations/page.tsx +1 -3
  13. package/template/modules/crm/app/(shell)/crm/page.tsx +1 -5
  14. package/template/modules/crm/app/(shell)/crm/report/page.tsx +1 -5
  15. package/template/modules/projects/app/(shell)/account/page.tsx +5 -1
  16. package/template/modules/projects/app/(shell)/account/perfil/page.tsx +1 -0
  17. package/template/modules/projects/app/(shell)/projetos/projetos-server-mounts.tsx +3 -2
  18. package/template/modules/projects/app/api/account/projects/[id]/route.ts +2 -0
  19. package/template/modules/projects/app/api/account/projects/route.ts +2 -0
  20. package/template/modules/projects/app/api/projects/[id]/client-access/route.ts +2 -0
  21. package/template/modules/projects/app/api/projects/[id]/organizations/route.ts +2 -0
  22. package/template/modules/projects/app/api/projects/_handlers.ts +24 -0
  23. package/template/modules/projects/app/api/projects/setup-options/route.ts +2 -0
  24. package/template/supabase/modules/orgs/migrations/20260812120000_staff_only_organization_access_management.sql +7 -0
  25. package/template/supabase/modules/projects/migrations/20260810120000_project_client_access.sql +328 -0
  26. package/template/supabase/modules/projects/migrations/20260811120000_project_client_access_expand.sql +1061 -0
  27. package/template/supabase/modules/projects/migrations/20260811120500_project_member_sync_hardening.sql +156 -0
  28. package/template/supabase/modules/projects/migrations/20260811121000_project_client_access_enforcement.sql +651 -0
  29. package/template/supabase/modules/projects/migrations/20260811121500_project_client_organization_memberships.sql +29 -0
  30. package/template/supabase/modules/projects/migrations/20260811121700_project_client_meta_preview.sql +193 -0
  31. package/template/supabase/modules/projects/migrations/20260811122000_project_client_access_identity_cleanup.sql +201 -0
  32. package/template/supabase/modules/projects/migrations/20260811122500_remove_project_client_next_steps.sql +681 -0
@@ -0,0 +1,651 @@
1
+ -- Install fail-closed project boundaries before any destructive identity
2
+ -- cleanup. This migration is safe to deploy while older project rows still
3
+ -- contain external observer memberships.
4
+
5
+ CREATE OR REPLACE FUNCTION public.enforce_project_client_content_manager()
6
+ RETURNS trigger
7
+ LANGUAGE plpgsql
8
+ SECURITY DEFINER
9
+ SET search_path = public
10
+ AS $$
11
+ BEGIN
12
+ IF NEW.client_contact_profile_id IS DISTINCT FROM OLD.client_contact_profile_id
13
+ AND NEW.client_contact_profile_id IS NOT NULL
14
+ AND NOT EXISTS (
15
+ SELECT 1
16
+ FROM public.project_members member
17
+ JOIN public.user_role_assignments role_assignment
18
+ ON role_assignment.profile_id = member.profile_id
19
+ AND role_assignment.role_code IN ('staff', 'admin')
20
+ WHERE member.project_id = NEW.id
21
+ AND member.profile_id = NEW.client_contact_profile_id
22
+ ) THEN
23
+ RAISE EXCEPTION 'The client contact must be an internal project team member.'
24
+ USING ERRCODE = '23514';
25
+ END IF;
26
+
27
+ IF (
28
+ NEW.client_access_mode IS DISTINCT FROM OLD.client_access_mode
29
+ OR NEW.client_summary IS DISTINCT FROM OLD.client_summary
30
+ OR NEW.client_scope IS DISTINCT FROM OLD.client_scope
31
+ OR NEW.client_contact_profile_id IS DISTINCT FROM OLD.client_contact_profile_id
32
+ OR NEW.client_next_steps IS DISTINCT FROM OLD.client_next_steps
33
+ ) AND NOT (
34
+ public.is_admin()
35
+ OR (public.is_staff() AND public.is_project_owner_member(OLD.id))
36
+ ) THEN
37
+ RAISE EXCEPTION 'Only project owners or administrators can publish client content.'
38
+ USING ERRCODE = '42501';
39
+ END IF;
40
+ RETURN NEW;
41
+ END;
42
+ $$;
43
+
44
+ DROP TRIGGER IF EXISTS trg_enforce_project_client_content_manager ON public.projects;
45
+ CREATE TRIGGER trg_enforce_project_client_content_manager
46
+ BEFORE UPDATE OF client_access_mode, client_summary, client_scope,
47
+ client_contact_profile_id, client_next_steps ON public.projects
48
+ FOR EACH ROW
49
+ EXECUTE FUNCTION public.enforce_project_client_content_manager();
50
+
51
+ CREATE OR REPLACE FUNCTION public.enforce_client_visible_project_item_manager()
52
+ RETURNS trigger
53
+ LANGUAGE plpgsql
54
+ SECURITY DEFINER
55
+ SET search_path = public
56
+ AS $$
57
+ DECLARE
58
+ v_project_id uuid := COALESCE(NEW.project_id, OLD.project_id);
59
+ v_previous_visibility text := CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.visibility END;
60
+ v_next_visibility text := CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE NEW.visibility END;
61
+ BEGIN
62
+ IF (v_previous_visibility = 'client' OR v_next_visibility = 'client')
63
+ AND NOT (
64
+ public.is_admin()
65
+ OR (public.is_staff() AND public.is_project_owner_member(v_project_id))
66
+ ) THEN
67
+ RAISE EXCEPTION 'Only project owners or administrators can publish client-visible items.'
68
+ USING ERRCODE = '42501';
69
+ END IF;
70
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
71
+ END;
72
+ $$;
73
+
74
+ DROP TRIGGER IF EXISTS trg_enforce_client_milestone_manager ON public.project_milestones;
75
+ CREATE TRIGGER trg_enforce_client_milestone_manager
76
+ BEFORE INSERT OR UPDATE OR DELETE ON public.project_milestones
77
+ FOR EACH ROW
78
+ EXECUTE FUNCTION public.enforce_client_visible_project_item_manager();
79
+
80
+ DROP TRIGGER IF EXISTS trg_enforce_client_link_manager ON public.project_links;
81
+ CREATE TRIGGER trg_enforce_client_link_manager
82
+ BEFORE INSERT OR UPDATE OR DELETE ON public.project_links
83
+ FOR EACH ROW
84
+ EXECUTE FUNCTION public.enforce_client_visible_project_item_manager();
85
+
86
+ -- Primary contacts remain organization admins only. Project membership is
87
+ -- always an explicit internal staff decision.
88
+ CREATE OR REPLACE FUNCTION public.sync_org_primary_contact_admin_membership()
89
+ RETURNS trigger
90
+ LANGUAGE plpgsql
91
+ SECURITY DEFINER
92
+ SET search_path = public
93
+ AS $$
94
+ BEGIN
95
+ IF TG_OP = 'UPDATE' AND OLD.primary_contact_id IS DISTINCT FROM NEW.primary_contact_id THEN
96
+ IF OLD.primary_contact_id IS NOT NULL THEN
97
+ DELETE FROM public.organization_members
98
+ WHERE organization_id = NEW.id
99
+ AND profile_id = OLD.primary_contact_id
100
+ AND role = 'admin';
101
+ END IF;
102
+ END IF;
103
+
104
+ IF NEW.primary_contact_id IS NOT NULL THEN
105
+ INSERT INTO public.organization_members (organization_id, profile_id, role)
106
+ VALUES (NEW.id, NEW.primary_contact_id, 'admin')
107
+ ON CONFLICT (organization_id, profile_id)
108
+ DO UPDATE SET role = 'admin';
109
+ END IF;
110
+ RETURN NEW;
111
+ END;
112
+ $$;
113
+
114
+ REVOKE ALL ON FUNCTION public.create_project_with_client_access(uuid, jsonb, uuid[], jsonb, jsonb)
115
+ FROM PUBLIC, anon, service_role;
116
+ REVOKE ALL ON FUNCTION public.set_project_client_configuration(uuid, jsonb)
117
+ FROM PUBLIC, anon, service_role;
118
+ REVOKE ALL ON FUNCTION public.get_project_access_configuration(uuid)
119
+ FROM PUBLIC, anon, service_role;
120
+ REVOKE ALL ON FUNCTION public.get_project_client_access_summary(uuid)
121
+ FROM PUBLIC, anon, service_role;
122
+ REVOKE ALL ON FUNCTION public.list_current_client_projects()
123
+ FROM PUBLIC, anon, service_role;
124
+ REVOKE ALL ON FUNCTION public.get_current_client_project(uuid)
125
+ FROM PUBLIC, anon, service_role;
126
+
127
+ -- RLS can decide which project rows a contributor may edit, but it cannot
128
+ -- safely express column-level identity rules. Protect ownership and the
129
+ -- primary organization before enabling the broader staff update policy.
130
+ CREATE OR REPLACE FUNCTION public.protect_project_identity_fields()
131
+ RETURNS trigger
132
+ LANGUAGE plpgsql
133
+ SECURITY DEFINER
134
+ SET search_path = public
135
+ AS $$
136
+ BEGIN
137
+ IF (
138
+ NEW.owner_profile_id IS DISTINCT FROM OLD.owner_profile_id
139
+ OR NEW.organization_id IS DISTINCT FROM OLD.organization_id
140
+ ) AND NOT (
141
+ public.is_admin()
142
+ OR (
143
+ public.is_staff()
144
+ AND (
145
+ OLD.owner_profile_id = public.current_profile_id()
146
+ OR EXISTS (
147
+ SELECT 1
148
+ FROM public.project_members member
149
+ WHERE member.project_id = OLD.id
150
+ AND member.profile_id = public.current_profile_id()
151
+ AND member.role = 'owner'
152
+ )
153
+ )
154
+ )
155
+ ) THEN
156
+ RAISE EXCEPTION 'Only project owners or administrators can change project ownership or the primary organization.'
157
+ USING ERRCODE = '42501';
158
+ END IF;
159
+
160
+ IF NEW.owner_profile_id IS NOT NULL AND NOT EXISTS (
161
+ SELECT 1
162
+ FROM public.user_role_assignments role_assignment
163
+ WHERE role_assignment.profile_id = NEW.owner_profile_id
164
+ AND role_assignment.role_code IN ('staff', 'admin')
165
+ ) THEN
166
+ RAISE EXCEPTION 'Project owners must be internal staff.' USING ERRCODE = '23514';
167
+ END IF;
168
+
169
+ RETURN NEW;
170
+ END;
171
+ $$;
172
+
173
+ DROP TRIGGER IF EXISTS trg_protect_project_identity_fields ON public.projects;
174
+ CREATE TRIGGER trg_protect_project_identity_fields
175
+ BEFORE UPDATE OF owner_profile_id, organization_id ON public.projects
176
+ FOR EACH ROW
177
+ EXECUTE FUNCTION public.protect_project_identity_fields();
178
+
179
+ -- Keep owner_profile_id and the internal owner membership atomic before the
180
+ -- later legacy cleanup runs. This blocks direct PostgREST owner promotion while
181
+ -- still allowing sync_project_members_exact() to update both in one transaction.
182
+ CREATE OR REPLACE FUNCTION public.validate_project_owner_consistency()
183
+ RETURNS trigger
184
+ LANGUAGE plpgsql
185
+ SECURITY DEFINER
186
+ SET search_path = public
187
+ AS $$
188
+ DECLARE
189
+ v_project_id uuid;
190
+ v_owner_profile_id uuid;
191
+ v_owner_count integer;
192
+ v_matching_owner_count integer;
193
+ BEGIN
194
+ v_project_id := CASE
195
+ WHEN TG_TABLE_NAME = 'projects' THEN COALESCE(NEW.id, OLD.id)
196
+ ELSE COALESCE(NEW.project_id, OLD.project_id)
197
+ END;
198
+
199
+ SELECT project.owner_profile_id
200
+ INTO v_owner_profile_id
201
+ FROM public.projects project
202
+ WHERE project.id = v_project_id;
203
+ IF NOT FOUND THEN
204
+ RETURN NULL;
205
+ END IF;
206
+
207
+ SELECT
208
+ count(*) FILTER (WHERE member.role = 'owner'),
209
+ count(*) FILTER (
210
+ WHERE member.role = 'owner'
211
+ AND member.profile_id = v_owner_profile_id
212
+ )
213
+ INTO v_owner_count, v_matching_owner_count
214
+ FROM public.project_members member
215
+ WHERE member.project_id = v_project_id;
216
+
217
+ IF (v_owner_profile_id IS NULL AND v_owner_count <> 0)
218
+ OR (v_owner_profile_id IS NOT NULL AND (v_owner_count <> 1 OR v_matching_owner_count <> 1)) THEN
219
+ RAISE EXCEPTION 'Project owner and owner membership must match exactly.'
220
+ USING ERRCODE = '23514';
221
+ END IF;
222
+ RETURN NULL;
223
+ END;
224
+ $$;
225
+
226
+ DROP TRIGGER IF EXISTS trg_validate_project_owner_from_project ON public.projects;
227
+ CREATE CONSTRAINT TRIGGER trg_validate_project_owner_from_project
228
+ AFTER INSERT OR UPDATE OF owner_profile_id ON public.projects
229
+ DEFERRABLE INITIALLY DEFERRED
230
+ FOR EACH ROW
231
+ EXECUTE FUNCTION public.validate_project_owner_consistency();
232
+
233
+ DROP TRIGGER IF EXISTS trg_validate_project_owner_from_member ON public.project_members;
234
+ CREATE CONSTRAINT TRIGGER trg_validate_project_owner_from_member
235
+ AFTER INSERT OR UPDATE OR DELETE ON public.project_members
236
+ DEFERRABLE INITIALLY DEFERRED
237
+ FOR EACH ROW
238
+ EXECUTE FUNCTION public.validate_project_owner_consistency();
239
+
240
+ -- Explicit selected-client grants must not silently reactivate if a profile is
241
+ -- later changed back to the client role. Leaving the client role revokes the
242
+ -- approval and a project owner must select the profile again.
243
+ CREATE OR REPLACE FUNCTION public.clear_stale_project_client_profile_grants()
244
+ RETURNS trigger
245
+ LANGUAGE plpgsql
246
+ SECURITY DEFINER
247
+ SET search_path = public
248
+ AS $$
249
+ BEGIN
250
+ IF OLD.role_code = 'client'
251
+ AND (TG_OP = 'DELETE' OR NEW.role_code <> 'client') THEN
252
+ DELETE FROM public.project_client_profiles grant_row
253
+ WHERE grant_row.profile_id = OLD.profile_id;
254
+ END IF;
255
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
256
+ END;
257
+ $$;
258
+
259
+ DROP TRIGGER IF EXISTS trg_clear_stale_project_client_profile_grants
260
+ ON public.user_role_assignments;
261
+ CREATE TRIGGER trg_clear_stale_project_client_profile_grants
262
+ AFTER UPDATE OF role_code OR DELETE ON public.user_role_assignments
263
+ FOR EACH ROW
264
+ EXECUTE FUNCTION public.clear_stale_project_client_profile_grants();
265
+
266
+ -- Project membership is internal. Client access is represented only by the
267
+ -- explicit organization/profile audience tables above.
268
+ DROP POLICY IF EXISTS "Project team view projects" ON public.projects;
269
+ DROP POLICY IF EXISTS "Project staff view projects" ON public.projects;
270
+ CREATE POLICY "Project staff view projects"
271
+ ON public.projects
272
+ FOR SELECT
273
+ TO authenticated
274
+ USING (
275
+ public.is_admin()
276
+ OR (
277
+ public.is_staff()
278
+ AND (public.is_project_team_member(id) OR public.is_project_org_admin(id))
279
+ )
280
+ );
281
+
282
+ DROP POLICY IF EXISTS "Project team create projects" ON public.projects;
283
+ CREATE POLICY "Project staff create projects"
284
+ ON public.projects
285
+ FOR INSERT
286
+ TO authenticated
287
+ WITH CHECK (
288
+ public.is_admin()
289
+ OR (
290
+ public.is_staff()
291
+ AND (
292
+ owner_profile_id = public.current_profile_id()
293
+ OR public.can_create_projects_for_org(organization_id)
294
+ )
295
+ )
296
+ );
297
+
298
+ DROP POLICY IF EXISTS "Project team update projects" ON public.projects;
299
+ CREATE POLICY "Project staff update projects"
300
+ ON public.projects
301
+ FOR UPDATE
302
+ TO authenticated
303
+ USING (
304
+ public.is_admin()
305
+ OR (public.is_staff() AND public.is_project_content_editor(id))
306
+ )
307
+ WITH CHECK (
308
+ public.is_admin()
309
+ OR (public.is_staff() AND public.is_project_content_editor(id))
310
+ );
311
+
312
+ DROP POLICY IF EXISTS "Project owners delete projects" ON public.projects;
313
+ CREATE POLICY "Project staff delete projects"
314
+ ON public.projects
315
+ FOR DELETE
316
+ TO authenticated
317
+ USING (
318
+ public.is_admin()
319
+ OR (public.is_staff() AND public.is_project_owner_member(id))
320
+ );
321
+
322
+ DROP POLICY IF EXISTS "Project team view project members" ON public.project_members;
323
+ DROP POLICY IF EXISTS "Project staff view project members" ON public.project_members;
324
+ CREATE POLICY "Project staff view project members"
325
+ ON public.project_members
326
+ FOR SELECT
327
+ TO authenticated
328
+ USING (
329
+ public.is_admin()
330
+ OR (public.is_staff() AND public.is_project_team_member(project_id))
331
+ );
332
+
333
+ DROP POLICY IF EXISTS "Project owners insert project members" ON public.project_members;
334
+ CREATE POLICY "Project staff insert project members"
335
+ ON public.project_members
336
+ FOR INSERT
337
+ TO authenticated
338
+ WITH CHECK (
339
+ public.is_admin()
340
+ OR (public.is_staff() AND public.is_project_owner_member(project_id))
341
+ );
342
+
343
+ DROP POLICY IF EXISTS "Project owners update project members" ON public.project_members;
344
+ CREATE POLICY "Project staff update project members"
345
+ ON public.project_members
346
+ FOR UPDATE
347
+ TO authenticated
348
+ USING (
349
+ public.is_admin()
350
+ OR (public.is_staff() AND public.is_project_owner_member(project_id))
351
+ )
352
+ WITH CHECK (
353
+ public.is_admin()
354
+ OR (public.is_staff() AND public.is_project_owner_member(project_id))
355
+ );
356
+
357
+ DROP POLICY IF EXISTS "Project owners delete project members" ON public.project_members;
358
+ CREATE POLICY "Project staff delete project members"
359
+ ON public.project_members
360
+ FOR DELETE
361
+ TO authenticated
362
+ USING (
363
+ public.is_admin()
364
+ OR (public.is_staff() AND public.is_project_owner_member(project_id))
365
+ );
366
+
367
+ DROP POLICY IF EXISTS "Project team view project milestones" ON public.project_milestones;
368
+ DROP POLICY IF EXISTS "Authorized users view project milestones" ON public.project_milestones;
369
+ CREATE POLICY "Authorized users view project milestones"
370
+ ON public.project_milestones
371
+ FOR SELECT
372
+ TO authenticated
373
+ USING (
374
+ public.is_admin()
375
+ OR (public.is_staff() AND public.is_project_team_member(project_id))
376
+ OR (
377
+ visibility = 'client'
378
+ AND public.can_current_client_view_project(project_id)
379
+ )
380
+ );
381
+
382
+ DROP POLICY IF EXISTS "Project content editors insert project milestones" ON public.project_milestones;
383
+ CREATE POLICY "Project staff insert project milestones"
384
+ ON public.project_milestones
385
+ FOR INSERT
386
+ TO authenticated
387
+ WITH CHECK (
388
+ public.is_admin()
389
+ OR (public.is_staff() AND public.is_project_content_editor(project_id))
390
+ );
391
+
392
+ DROP POLICY IF EXISTS "Project content editors update project milestones" ON public.project_milestones;
393
+ CREATE POLICY "Project staff update project milestones"
394
+ ON public.project_milestones
395
+ FOR UPDATE
396
+ TO authenticated
397
+ USING (
398
+ public.is_admin()
399
+ OR (public.is_staff() AND public.is_project_content_editor(project_id))
400
+ )
401
+ WITH CHECK (
402
+ public.is_admin()
403
+ OR (public.is_staff() AND public.is_project_content_editor(project_id))
404
+ );
405
+
406
+ DROP POLICY IF EXISTS "Project content editors delete project milestones" ON public.project_milestones;
407
+ CREATE POLICY "Project staff delete project milestones"
408
+ ON public.project_milestones
409
+ FOR DELETE
410
+ TO authenticated
411
+ USING (
412
+ public.is_admin()
413
+ OR (public.is_staff() AND public.is_project_content_editor(project_id))
414
+ );
415
+
416
+ DROP POLICY IF EXISTS "Project team view project tasks" ON public.project_tasks;
417
+ DROP POLICY IF EXISTS "Project staff view project tasks" ON public.project_tasks;
418
+ CREATE POLICY "Project staff view project tasks"
419
+ ON public.project_tasks
420
+ FOR SELECT
421
+ TO authenticated
422
+ USING (
423
+ public.is_admin()
424
+ OR (public.is_staff() AND public.is_project_team_member(project_id))
425
+ );
426
+
427
+ DROP POLICY IF EXISTS "Project content editors insert project tasks" ON public.project_tasks;
428
+ CREATE POLICY "Project staff insert project tasks"
429
+ ON public.project_tasks
430
+ FOR INSERT
431
+ TO authenticated
432
+ WITH CHECK (
433
+ public.is_admin()
434
+ OR (public.is_staff() AND public.is_project_content_editor(project_id))
435
+ );
436
+
437
+ DROP POLICY IF EXISTS "Project content editors update project tasks" ON public.project_tasks;
438
+ CREATE POLICY "Project staff update project tasks"
439
+ ON public.project_tasks
440
+ FOR UPDATE
441
+ TO authenticated
442
+ USING (
443
+ public.is_admin()
444
+ OR (public.is_staff() AND public.is_project_content_editor(project_id))
445
+ )
446
+ WITH CHECK (
447
+ public.is_admin()
448
+ OR (public.is_staff() AND public.is_project_content_editor(project_id))
449
+ );
450
+
451
+ DROP POLICY IF EXISTS "Project content editors delete project tasks" ON public.project_tasks;
452
+ CREATE POLICY "Project staff delete project tasks"
453
+ ON public.project_tasks
454
+ FOR DELETE
455
+ TO authenticated
456
+ USING (
457
+ public.is_admin()
458
+ OR (public.is_staff() AND public.is_project_content_editor(project_id))
459
+ );
460
+
461
+ DROP POLICY IF EXISTS "Project team view project links" ON public.project_links;
462
+ DROP POLICY IF EXISTS "Authorized users view project links" ON public.project_links;
463
+ CREATE POLICY "Authorized users view project links"
464
+ ON public.project_links
465
+ FOR SELECT
466
+ TO authenticated
467
+ USING (
468
+ public.is_admin()
469
+ OR (public.is_staff() AND public.is_project_team_member(project_id))
470
+ OR (
471
+ visibility = 'client'
472
+ AND public.can_current_client_view_project(project_id)
473
+ )
474
+ );
475
+
476
+ DROP POLICY IF EXISTS "Project team create project links" ON public.project_links;
477
+ CREATE POLICY "Project staff create project links"
478
+ ON public.project_links
479
+ FOR INSERT
480
+ TO authenticated
481
+ WITH CHECK (
482
+ public.is_admin()
483
+ OR (public.is_staff() AND public.is_project_team_member(project_id))
484
+ );
485
+
486
+ DROP POLICY IF EXISTS "Project content editors update project links" ON public.project_links;
487
+ CREATE POLICY "Project staff update project links"
488
+ ON public.project_links
489
+ FOR UPDATE
490
+ TO authenticated
491
+ USING (
492
+ public.is_admin()
493
+ OR (public.is_staff() AND public.is_project_content_editor(project_id))
494
+ )
495
+ WITH CHECK (
496
+ public.is_admin()
497
+ OR (public.is_staff() AND public.is_project_content_editor(project_id))
498
+ );
499
+
500
+ DROP POLICY IF EXISTS "Project content editors delete project links" ON public.project_links;
501
+ CREATE POLICY "Project staff delete project links"
502
+ ON public.project_links
503
+ FOR DELETE
504
+ TO authenticated
505
+ USING (
506
+ public.is_admin()
507
+ OR (public.is_staff() AND public.is_project_content_editor(project_id))
508
+ );
509
+
510
+ DROP POLICY IF EXISTS "Project team view project status log" ON public.project_status_log;
511
+ DROP POLICY IF EXISTS "Project staff view project status log" ON public.project_status_log;
512
+ CREATE POLICY "Project staff view project status log"
513
+ ON public.project_status_log
514
+ FOR SELECT
515
+ TO authenticated
516
+ USING (
517
+ public.is_admin()
518
+ OR (public.is_staff() AND public.is_project_team_member(project_id))
519
+ );
520
+
521
+
522
+ -- Notification and realtime activity audiences remain internal even when a
523
+ -- project is client-visible. Client-facing updates need a separate safe feed.
524
+ CREATE OR REPLACE FUNCTION public.can_profile_view_app_activity_event(
525
+ p_profile_id uuid,
526
+ p_domain text,
527
+ p_entity_table text,
528
+ p_entity_id uuid,
529
+ p_payload jsonb DEFAULT '{}'::jsonb
530
+ )
531
+ RETURNS boolean
532
+ LANGUAGE plpgsql
533
+ STABLE
534
+ SECURITY DEFINER
535
+ SET search_path = public
536
+ AS $$
537
+ DECLARE
538
+ v_project_id uuid;
539
+ v_role text;
540
+ BEGIN
541
+ IF p_profile_id IS NULL THEN
542
+ RETURN false;
543
+ END IF;
544
+
545
+ SELECT assignment.role_code
546
+ INTO v_role
547
+ FROM public.user_role_assignments assignment
548
+ WHERE assignment.profile_id = p_profile_id
549
+ LIMIT 1;
550
+
551
+ IF p_domain = 'admin' THEN
552
+ RETURN v_role = 'admin';
553
+ END IF;
554
+
555
+ IF p_domain <> 'projects' THEN
556
+ RETURN v_role = ANY (ARRAY['staff', 'admin']);
557
+ END IF;
558
+
559
+ IF v_role = 'admin' THEN
560
+ RETURN true;
561
+ END IF;
562
+ IF v_role <> 'staff' THEN
563
+ RETURN false;
564
+ END IF;
565
+
566
+ BEGIN
567
+ v_project_id := NULLIF(COALESCE(p_payload, '{}'::jsonb)->>'project_id', '')::uuid;
568
+ EXCEPTION WHEN invalid_text_representation THEN
569
+ v_project_id := NULL;
570
+ END;
571
+
572
+ IF v_project_id IS NULL AND p_entity_table = 'projects' THEN
573
+ v_project_id := p_entity_id;
574
+ END IF;
575
+ IF v_project_id IS NULL THEN
576
+ RETURN false;
577
+ END IF;
578
+
579
+ RETURN EXISTS (
580
+ SELECT 1
581
+ FROM public.projects project
582
+ WHERE project.id = v_project_id
583
+ AND (
584
+ project.owner_profile_id = p_profile_id
585
+ OR EXISTS (
586
+ SELECT 1
587
+ FROM public.project_members member
588
+ WHERE member.project_id = project.id
589
+ AND member.profile_id = p_profile_id
590
+ )
591
+ OR EXISTS (
592
+ SELECT 1
593
+ FROM public.organization_members member
594
+ WHERE member.organization_id = project.organization_id
595
+ AND member.profile_id = p_profile_id
596
+ AND member.role = 'admin'
597
+ )
598
+ )
599
+ );
600
+ END;
601
+ $$;
602
+
603
+ REVOKE ALL ON FUNCTION public.can_profile_view_app_activity_event(uuid, text, text, uuid, jsonb)
604
+ FROM PUBLIC, anon, authenticated;
605
+ GRANT EXECUTE ON FUNCTION public.can_profile_view_app_activity_event(uuid, text, text, uuid, jsonb)
606
+ TO service_role;
607
+
608
+ CREATE OR REPLACE FUNCTION public.can_view_app_activity_event(
609
+ p_domain text,
610
+ p_entity_table text,
611
+ p_entity_id uuid,
612
+ p_payload jsonb DEFAULT '{}'::jsonb
613
+ )
614
+ RETURNS boolean
615
+ LANGUAGE sql
616
+ STABLE
617
+ SECURITY DEFINER
618
+ SET search_path = public
619
+ AS $$
620
+ SELECT public.can_profile_view_app_activity_event(
621
+ public.current_profile_id(),
622
+ p_domain,
623
+ p_entity_table,
624
+ p_entity_id,
625
+ p_payload
626
+ )
627
+ $$;
628
+
629
+ REVOKE ALL ON FUNCTION public.can_view_app_activity_event(text, text, uuid, jsonb)
630
+ FROM PUBLIC, anon;
631
+ GRANT EXECUTE ON FUNCTION public.can_view_app_activity_event(text, text, uuid, jsonb)
632
+ TO authenticated;
633
+
634
+ DROP POLICY IF EXISTS "Project members can view project activity events"
635
+ ON public.app_activity_events;
636
+ DROP POLICY IF EXISTS "Project staff can view project activity events"
637
+ ON public.app_activity_events;
638
+ DROP POLICY IF EXISTS "Staff can view app activity events"
639
+ ON public.app_activity_events;
640
+ CREATE POLICY "Staff can view app activity events"
641
+ ON public.app_activity_events
642
+ FOR SELECT
643
+ TO authenticated
644
+ USING (
645
+ public.is_staff()
646
+ AND public.can_view_app_activity_event(domain, entity_table, entity_id, payload)
647
+ );
648
+
649
+ DROP TRIGGER IF EXISTS capture_removed_project_member_realtime_audience
650
+ ON public.app_activity_events;
651
+ DROP FUNCTION IF EXISTS public.capture_removed_project_member_realtime_audience();
@@ -0,0 +1,29 @@
1
+ -- Narrow client-owned organization memberships for account surfaces.
2
+
3
+ CREATE OR REPLACE FUNCTION public.list_current_client_organizations()
4
+ RETURNS TABLE (
5
+ organization_id uuid,
6
+ organization_name text,
7
+ membership_role text
8
+ )
9
+ LANGUAGE sql
10
+ STABLE
11
+ SECURITY DEFINER
12
+ SET search_path = public
13
+ AS $$
14
+ SELECT
15
+ organization.id,
16
+ organization.name,
17
+ membership.role
18
+ FROM public.organization_members membership
19
+ JOIN public.organizations organization
20
+ ON organization.id = membership.organization_id
21
+ WHERE public.current_global_role() = 'client'
22
+ AND membership.profile_id = public.current_profile_id()
23
+ ORDER BY organization.name, organization.id
24
+ $$;
25
+
26
+ REVOKE ALL ON FUNCTION public.list_current_client_organizations()
27
+ FROM PUBLIC, anon, service_role;
28
+ GRANT EXECUTE ON FUNCTION public.list_current_client_organizations()
29
+ TO authenticated;