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,681 @@
1
+ -- bw-migration-safety: destructive
2
+ -- Client-visible milestones are the single source of truth for current and
3
+ -- upcoming work. Remove the free-text project field that duplicated them.
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
+ ) AND NOT (
33
+ public.is_admin()
34
+ OR (public.is_staff() AND public.is_project_owner_member(OLD.id))
35
+ ) THEN
36
+ RAISE EXCEPTION 'Only project owners or administrators can publish client content.'
37
+ USING ERRCODE = '42501';
38
+ END IF;
39
+ RETURN NEW;
40
+ END;
41
+ $$;
42
+
43
+ DROP TRIGGER IF EXISTS trg_enforce_project_client_content_manager ON public.projects;
44
+ CREATE TRIGGER trg_enforce_project_client_content_manager
45
+ BEFORE UPDATE OF client_access_mode, client_summary, client_scope,
46
+ client_contact_profile_id ON public.projects
47
+ FOR EACH ROW
48
+ EXECUTE FUNCTION public.enforce_project_client_content_manager();
49
+
50
+ -- These table-returning functions change their public row shape, so PostgreSQL
51
+ -- requires them to be dropped and recreated rather than replaced in place.
52
+ DROP FUNCTION public.get_current_client_project(uuid);
53
+ DROP FUNCTION public.list_current_client_projects();
54
+
55
+ ALTER TABLE public.projects DROP COLUMN client_next_steps;
56
+
57
+ CREATE FUNCTION public.list_current_client_projects()
58
+ RETURNS TABLE (
59
+ project_id uuid,
60
+ name text,
61
+ reference text,
62
+ status text,
63
+ start_date date,
64
+ target_date date,
65
+ completed_at timestamptz,
66
+ archived_at timestamptz,
67
+ client_summary text,
68
+ client_scope text,
69
+ client_contact jsonb,
70
+ meta_preview jsonb,
71
+ organizations jsonb,
72
+ progress_percent integer
73
+ )
74
+ LANGUAGE sql
75
+ STABLE
76
+ SECURITY DEFINER
77
+ SET search_path = public
78
+ AS $$
79
+ SELECT
80
+ project.id,
81
+ project.name,
82
+ project.code,
83
+ project.status,
84
+ project.start_date,
85
+ project.target_date,
86
+ project.completed_at,
87
+ project.archived_at,
88
+ project.client_summary,
89
+ project.client_scope,
90
+ CASE WHEN contact.id IS NULL THEN NULL ELSE jsonb_build_object(
91
+ 'profile_id', contact.id,
92
+ 'name', COALESCE(NULLIF(btrim(concat_ws(' ', contact.first_name, contact.last_name)), ''), contact.email),
93
+ 'email', contact.email
94
+ ) END,
95
+ COALESCE((
96
+ SELECT jsonb_agg(jsonb_build_object(
97
+ 'id', current_meta.id,
98
+ 'title', current_meta.title,
99
+ 'status', current_meta.status,
100
+ 'target_date', current_meta.target_date,
101
+ 'completed_at', current_meta.completed_at,
102
+ 'position', current_meta.position
103
+ ) ORDER BY current_meta.position, current_meta.target_date, current_meta.id)
104
+ FROM (
105
+ SELECT milestone.*
106
+ FROM public.project_milestones milestone
107
+ WHERE milestone.project_id = project.id
108
+ AND milestone.visibility = 'client'
109
+ AND milestone.status IN ('in_progress', 'delayed')
110
+ ORDER BY milestone.position, milestone.target_date, milestone.id
111
+ LIMIT 3
112
+ ) current_meta
113
+ ), (
114
+ SELECT jsonb_agg(jsonb_build_object(
115
+ 'id', pending_meta.id,
116
+ 'title', pending_meta.title,
117
+ 'status', pending_meta.status,
118
+ 'target_date', pending_meta.target_date,
119
+ 'completed_at', pending_meta.completed_at,
120
+ 'position', pending_meta.position
121
+ ) ORDER BY pending_meta.position, pending_meta.target_date, pending_meta.id)
122
+ FROM (
123
+ SELECT milestone.*
124
+ FROM public.project_milestones milestone
125
+ WHERE milestone.project_id = project.id
126
+ AND milestone.visibility = 'client'
127
+ AND milestone.status = 'pending'
128
+ ORDER BY milestone.position, milestone.target_date, milestone.id
129
+ LIMIT 1
130
+ ) pending_meta
131
+ ), '[]'::jsonb),
132
+ COALESCE((
133
+ SELECT jsonb_agg(jsonb_build_object('id', organization.id, 'name', organization.name) ORDER BY organization.name)
134
+ FROM public.project_client_organizations audience
135
+ JOIN public.organization_members org_member
136
+ ON org_member.organization_id = audience.organization_id
137
+ AND org_member.profile_id = public.current_profile_id()
138
+ JOIN public.organizations organization ON organization.id = audience.organization_id
139
+ WHERE audience.project_id = project.id
140
+ AND (
141
+ project.client_access_mode = 'all_org_clients'
142
+ OR EXISTS (
143
+ SELECT 1 FROM public.project_client_profiles grant_row
144
+ WHERE grant_row.project_id = project.id
145
+ AND grant_row.organization_id = audience.organization_id
146
+ AND grant_row.profile_id = public.current_profile_id()
147
+ )
148
+ )
149
+ ), '[]'::jsonb),
150
+ CASE WHEN count(milestone.id) = 0 THEN 0
151
+ ELSE round(100.0 * count(milestone.id) FILTER (WHERE milestone.status = 'achieved') / count(milestone.id))::integer
152
+ END
153
+ FROM public.projects project
154
+ LEFT JOIN public.profiles contact ON contact.id = project.client_contact_profile_id
155
+ LEFT JOIN public.project_milestones milestone
156
+ ON milestone.project_id = project.id AND milestone.visibility = 'client'
157
+ WHERE public.can_current_client_view_project(project.id)
158
+ GROUP BY project.id, contact.id
159
+ ORDER BY project.updated_at DESC;
160
+ $$;
161
+
162
+ REVOKE ALL ON FUNCTION public.list_current_client_projects()
163
+ FROM PUBLIC, anon, service_role;
164
+ GRANT EXECUTE ON FUNCTION public.list_current_client_projects()
165
+ TO authenticated;
166
+
167
+ CREATE FUNCTION public.get_current_client_project(target_project_id uuid)
168
+ RETURNS TABLE (
169
+ project_id uuid,
170
+ name text,
171
+ reference text,
172
+ status text,
173
+ start_date date,
174
+ target_date date,
175
+ completed_at timestamptz,
176
+ archived_at timestamptz,
177
+ client_summary text,
178
+ client_scope text,
179
+ client_contact jsonb,
180
+ meta_preview jsonb,
181
+ organizations jsonb,
182
+ progress_percent integer,
183
+ metas jsonb,
184
+ documents jsonb
185
+ )
186
+ LANGUAGE sql
187
+ STABLE
188
+ SECURITY DEFINER
189
+ SET search_path = public
190
+ AS $$
191
+ SELECT
192
+ list_row.project_id,
193
+ list_row.name,
194
+ list_row.reference,
195
+ list_row.status,
196
+ list_row.start_date,
197
+ list_row.target_date,
198
+ list_row.completed_at,
199
+ list_row.archived_at,
200
+ list_row.client_summary,
201
+ list_row.client_scope,
202
+ list_row.client_contact,
203
+ list_row.meta_preview,
204
+ list_row.organizations,
205
+ list_row.progress_percent,
206
+ COALESCE((
207
+ SELECT jsonb_agg(jsonb_build_object(
208
+ 'id', milestone.id,
209
+ 'title', milestone.title,
210
+ 'status', milestone.status,
211
+ 'target_date', milestone.target_date,
212
+ 'completed_at', milestone.completed_at,
213
+ 'position', milestone.position
214
+ ) ORDER BY milestone.position, milestone.target_date, milestone.id)
215
+ FROM public.project_milestones milestone
216
+ WHERE milestone.project_id = target_project_id
217
+ AND milestone.visibility = 'client'
218
+ ), '[]'::jsonb),
219
+ COALESCE((
220
+ SELECT jsonb_agg(jsonb_build_object(
221
+ 'id', link.id,
222
+ 'label', link.label,
223
+ 'url', link.url,
224
+ 'kind', link.kind,
225
+ 'created_at', link.created_at
226
+ ) ORDER BY link.created_at DESC, link.id)
227
+ FROM public.project_links link
228
+ WHERE link.project_id = target_project_id
229
+ AND link.visibility = 'client'
230
+ ), '[]'::jsonb)
231
+ FROM public.list_current_client_projects() list_row
232
+ WHERE list_row.project_id = target_project_id;
233
+ $$;
234
+
235
+ REVOKE ALL ON FUNCTION public.get_current_client_project(uuid)
236
+ FROM PUBLIC, anon, service_role;
237
+ GRANT EXECUTE ON FUNCTION public.get_current_client_project(uuid)
238
+ TO authenticated;
239
+
240
+ CREATE OR REPLACE FUNCTION public.create_project_with_client_access(
241
+ p_request_id uuid,
242
+ p_project jsonb,
243
+ p_organization_ids uuid[],
244
+ p_members jsonb,
245
+ p_client_access jsonb
246
+ )
247
+ RETURNS TABLE(project_id uuid, created boolean)
248
+ LANGUAGE plpgsql
249
+ SECURITY DEFINER
250
+ SET search_path = public
251
+ AS $$
252
+ DECLARE
253
+ v_actor_profile_id uuid := public.current_profile_id();
254
+ v_actor_role text := public.current_global_role();
255
+ v_payload jsonb := jsonb_build_object(
256
+ 'project', COALESCE(p_project, '{}'::jsonb),
257
+ 'organization_ids', to_jsonb(COALESCE(p_organization_ids, ARRAY[]::uuid[])),
258
+ 'members', COALESCE(p_members, '[]'::jsonb),
259
+ 'client_access', COALESCE(p_client_access, '{}'::jsonb)
260
+ );
261
+ v_existing public.project_creation_requests%ROWTYPE;
262
+ v_project_id uuid;
263
+ v_primary_organization_id uuid;
264
+ v_owner_profile_id uuid;
265
+ v_status text;
266
+ v_mode text;
267
+ v_access_organization_ids uuid[];
268
+ v_profile_grants jsonb;
269
+ v_organization_name text;
270
+ v_organization_token text;
271
+ v_project_token text;
272
+ v_code_base text;
273
+ v_code text;
274
+ v_code_suffix integer := 1;
275
+ BEGIN
276
+ IF p_request_id IS NULL OR v_actor_profile_id IS NULL OR v_actor_role NOT IN ('staff', 'admin') THEN
277
+ RAISE EXCEPTION 'Only authenticated staff can create projects.' USING ERRCODE = '42501';
278
+ END IF;
279
+ IF jsonb_typeof(COALESCE(p_project, '{}'::jsonb)) <> 'object'
280
+ OR jsonb_typeof(COALESCE(p_members, '[]'::jsonb)) <> 'array'
281
+ OR jsonb_typeof(COALESCE(p_client_access, '{}'::jsonb)) <> 'object' THEN
282
+ RAISE EXCEPTION 'Invalid project creation payload.' USING ERRCODE = '22023';
283
+ END IF;
284
+
285
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_request_id::text, 0));
286
+ SELECT request.* INTO v_existing
287
+ FROM public.project_creation_requests request
288
+ WHERE request.request_id = p_request_id;
289
+ IF FOUND THEN
290
+ IF v_existing.actor_profile_id <> v_actor_profile_id OR v_existing.request_payload <> v_payload THEN
291
+ RAISE EXCEPTION 'The idempotency key was already used with another request.' USING ERRCODE = '22023';
292
+ END IF;
293
+ RETURN QUERY SELECT v_existing.project_id, false;
294
+ RETURN;
295
+ END IF;
296
+
297
+ v_primary_organization_id := NULLIF(p_project->>'primary_organization_id', '')::uuid;
298
+ v_status := COALESCE(NULLIF(p_project->>'status', ''), 'planned');
299
+ IF v_primary_organization_id IS NULL
300
+ OR NULLIF(btrim(p_project->>'name'), '') IS NULL
301
+ OR v_status NOT IN ('planned', 'active', 'paused', 'blocked', 'completed', 'canceled') THEN
302
+ RAISE EXCEPTION 'Project name, primary organization, and status are invalid.' USING ERRCODE = '22023';
303
+ END IF;
304
+ IF NOT (v_primary_organization_id = ANY(COALESCE(p_organization_ids, ARRAY[]::uuid[]))) THEN
305
+ RAISE EXCEPTION 'The primary organization must participate in the project.' USING ERRCODE = '22023';
306
+ END IF;
307
+ IF EXISTS (
308
+ SELECT 1 FROM unnest(COALESCE(p_organization_ids, ARRAY[]::uuid[])) selected(organization_id)
309
+ LEFT JOIN public.organizations organization ON organization.id = selected.organization_id
310
+ WHERE selected.organization_id IS NULL OR organization.id IS NULL
311
+ ) THEN
312
+ RAISE EXCEPTION 'One or more participating organizations do not exist.' USING ERRCODE = '22023';
313
+ END IF;
314
+ SELECT organization.name
315
+ INTO v_organization_name
316
+ FROM public.organizations organization
317
+ WHERE organization.id = v_primary_organization_id;
318
+ IF NULLIF(p_project->>'start_date', '')::date > NULLIF(p_project->>'target_date', '')::date THEN
319
+ RAISE EXCEPTION 'A data alvo não pode ser anterior à data de início.' USING ERRCODE = '22023';
320
+ END IF;
321
+ IF v_status = 'canceled' AND NULLIF(btrim(p_project->>'cancellation_reason'), '') IS NULL THEN
322
+ RAISE EXCEPTION 'Indica o motivo do cancelamento.' USING ERRCODE = '22023';
323
+ END IF;
324
+
325
+ IF (SELECT count(*) FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text) WHERE role = 'owner') <> 1 THEN
326
+ RAISE EXCEPTION 'Exactly one internal project owner is required.' USING ERRCODE = '22023';
327
+ END IF;
328
+ IF (
329
+ SELECT count(*) <> count(DISTINCT member.profile_id)
330
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text)
331
+ ) THEN
332
+ RAISE EXCEPTION 'Each internal profile can appear only once in the project team.'
333
+ USING ERRCODE = '22023';
334
+ END IF;
335
+ SELECT member.profile_id INTO v_owner_profile_id
336
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text)
337
+ WHERE member.role = 'owner';
338
+
339
+ IF EXISTS (
340
+ SELECT 1
341
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text)
342
+ LEFT JOIN public.user_role_assignments role_assignment
343
+ ON role_assignment.profile_id = member.profile_id
344
+ AND role_assignment.role_code IN ('staff', 'admin')
345
+ WHERE member.profile_id IS NULL
346
+ OR member.role NOT IN ('owner', 'contributor', 'observer')
347
+ OR role_assignment.id IS NULL
348
+ ) THEN
349
+ RAISE EXCEPTION 'Project members must be internal staff.' USING ERRCODE = '22023';
350
+ END IF;
351
+ IF v_actor_role = 'staff' AND v_owner_profile_id <> v_actor_profile_id THEN
352
+ RAISE EXCEPTION 'Staff creators must own the projects they create.' USING ERRCODE = '42501';
353
+ END IF;
354
+
355
+ IF NULLIF(p_project->>'client_contact_profile_id', '') IS NOT NULL AND NOT EXISTS (
356
+ SELECT 1
357
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text)
358
+ JOIN public.user_role_assignments role_assignment
359
+ ON role_assignment.profile_id = member.profile_id
360
+ AND role_assignment.role_code IN ('staff', 'admin')
361
+ WHERE member.profile_id = NULLIF(p_project->>'client_contact_profile_id', '')::uuid
362
+ ) THEN
363
+ RAISE EXCEPTION 'The client contact must be an internal project team member.' USING ERRCODE = '22023';
364
+ END IF;
365
+
366
+ v_code := NULLIF(upper(btrim(p_project->>'code')), '');
367
+ IF v_code IS NULL THEN
368
+ v_organization_token := trim(both '-' FROM regexp_replace(
369
+ translate(
370
+ upper(COALESCE(v_organization_name, 'ORG')),
371
+ 'ÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ',
372
+ 'AAAAAEEEEIIIIOOOOOUUUUC'
373
+ ),
374
+ '[^A-Z0-9]+',
375
+ '-',
376
+ 'g'
377
+ ));
378
+ v_project_token := trim(both '-' FROM regexp_replace(
379
+ translate(
380
+ upper(COALESCE(p_project->>'name', 'PROJECT')),
381
+ 'ÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ',
382
+ 'AAAAAEEEEIIIIOOOOOUUUUC'
383
+ ),
384
+ '[^A-Z0-9]+',
385
+ '-',
386
+ 'g'
387
+ ));
388
+ v_organization_token := COALESCE(NULLIF(array_to_string(
389
+ (regexp_split_to_array(v_organization_token, '-'))[1:2], '-'
390
+ ), ''), 'ORG');
391
+ v_project_token := COALESCE(NULLIF(array_to_string(
392
+ (regexp_split_to_array(v_project_token, '-'))[1:3], '-'
393
+ ), ''), 'PROJECT');
394
+ v_code_base := left(
395
+ format('%s-%s-%s', v_organization_token, v_project_token, extract(year FROM current_date)::integer),
396
+ 72
397
+ );
398
+
399
+ PERFORM pg_advisory_xact_lock(hashtextextended('project-code:' || v_code_base, 0));
400
+ LOOP
401
+ v_code := CASE
402
+ WHEN v_code_suffix = 1 THEN v_code_base
403
+ ELSE format('%s-%s', v_code_base, v_code_suffix)
404
+ END;
405
+ EXIT WHEN NOT EXISTS (
406
+ SELECT 1
407
+ FROM public.projects existing_project
408
+ WHERE lower(existing_project.code) = lower(v_code)
409
+ );
410
+ v_code_suffix := v_code_suffix + 1;
411
+ END LOOP;
412
+ END IF;
413
+
414
+ INSERT INTO public.projects (
415
+ organization_id, name, code, status, health, owner_profile_id,
416
+ start_date, target_date, cancellation_reason, summary,
417
+ client_access_mode, client_summary, client_scope,
418
+ client_contact_profile_id
419
+ )
420
+ VALUES (
421
+ v_primary_organization_id,
422
+ btrim(p_project->>'name'),
423
+ v_code,
424
+ v_status,
425
+ CASE WHEN v_status IN ('blocked', 'canceled') THEN 'off_track' ELSE 'on_track' END,
426
+ v_owner_profile_id,
427
+ NULLIF(p_project->>'start_date', '')::date,
428
+ NULLIF(p_project->>'target_date', '')::date,
429
+ CASE WHEN v_status = 'canceled' THEN NULLIF(btrim(p_project->>'cancellation_reason'), '') ELSE NULL END,
430
+ NULLIF(btrim(p_project->>'summary'), ''),
431
+ 'hidden',
432
+ NULLIF(btrim(p_project->>'client_summary'), ''),
433
+ NULLIF(btrim(p_project->>'client_scope'), ''),
434
+ NULLIF(p_project->>'client_contact_profile_id', '')::uuid
435
+ )
436
+ RETURNING id INTO v_project_id;
437
+
438
+ INSERT INTO public.project_organizations (
439
+ project_id, organization_id, is_primary, added_by_profile_id
440
+ )
441
+ SELECT
442
+ v_project_id,
443
+ selected.organization_id,
444
+ selected.organization_id = v_primary_organization_id,
445
+ v_actor_profile_id
446
+ FROM (
447
+ SELECT DISTINCT organization_id
448
+ FROM unnest(p_organization_ids) requested(organization_id)
449
+ ) selected
450
+ ON CONFLICT ON CONSTRAINT project_organizations_pkey
451
+ DO UPDATE SET is_primary = EXCLUDED.is_primary;
452
+
453
+ INSERT INTO public.project_members (project_id, profile_id, role)
454
+ SELECT DISTINCT v_project_id, member.profile_id, member.role
455
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text);
456
+
457
+ v_mode := COALESCE(NULLIF(p_client_access->>'mode', ''), 'hidden');
458
+ v_access_organization_ids := ARRAY(
459
+ SELECT value::uuid
460
+ FROM jsonb_array_elements_text(COALESCE(p_client_access->'organization_ids', '[]'::jsonb)) value
461
+ );
462
+ v_profile_grants := COALESCE(p_client_access->'profile_grants', '[]'::jsonb);
463
+ PERFORM public.set_project_client_access(
464
+ v_project_id,
465
+ v_mode,
466
+ v_access_organization_ids,
467
+ v_profile_grants
468
+ );
469
+
470
+ INSERT INTO public.project_creation_requests (
471
+ request_id, actor_profile_id, request_payload, project_id
472
+ )
473
+ VALUES (p_request_id, v_actor_profile_id, v_payload, v_project_id);
474
+
475
+ RETURN QUERY SELECT v_project_id, true;
476
+ END;
477
+ $$;
478
+
479
+ CREATE OR REPLACE FUNCTION public.set_project_client_configuration(
480
+ target_project_id uuid,
481
+ target_configuration jsonb
482
+ )
483
+ RETURNS void
484
+ LANGUAGE plpgsql
485
+ SECURITY DEFINER
486
+ SET search_path = public
487
+ AS $$
488
+ DECLARE
489
+ v_contact_profile_id uuid := NULLIF(target_configuration->>'client_contact_profile_id', '')::uuid;
490
+ v_organization_ids uuid[] := ARRAY(
491
+ SELECT value::uuid
492
+ FROM jsonb_array_elements_text(
493
+ COALESCE(target_configuration->'organization_ids', '[]'::jsonb)
494
+ ) value
495
+ );
496
+ BEGIN
497
+ IF jsonb_typeof(COALESCE(target_configuration, '{}'::jsonb)) <> 'object' THEN
498
+ RAISE EXCEPTION 'Client project configuration must be a JSON object.'
499
+ USING ERRCODE = '22023';
500
+ END IF;
501
+ IF NOT (
502
+ public.is_admin()
503
+ OR (public.is_staff() AND public.is_project_owner_member(target_project_id))
504
+ ) THEN
505
+ RAISE EXCEPTION 'Only project owners or administrators can manage client project configuration.'
506
+ USING ERRCODE = '42501';
507
+ END IF;
508
+ IF v_contact_profile_id IS NOT NULL AND NOT EXISTS (
509
+ SELECT 1
510
+ FROM public.project_members member
511
+ JOIN public.user_role_assignments role_assignment
512
+ ON role_assignment.profile_id = member.profile_id
513
+ AND role_assignment.role_code IN ('staff', 'admin')
514
+ WHERE member.project_id = target_project_id
515
+ AND member.profile_id = v_contact_profile_id
516
+ ) THEN
517
+ RAISE EXCEPTION 'The client contact must be an internal project team member.'
518
+ USING ERRCODE = '22023';
519
+ END IF;
520
+
521
+ UPDATE public.projects
522
+ SET client_summary = NULLIF(btrim(target_configuration->>'client_summary'), ''),
523
+ client_scope = NULLIF(btrim(target_configuration->>'client_scope'), ''),
524
+ client_contact_profile_id = v_contact_profile_id
525
+ WHERE id = target_project_id;
526
+ IF NOT FOUND THEN
527
+ RAISE EXCEPTION 'Projeto não encontrado.' USING ERRCODE = 'P0002';
528
+ END IF;
529
+
530
+ PERFORM public.set_project_client_access(
531
+ target_project_id,
532
+ COALESCE(NULLIF(target_configuration->>'mode', ''), 'hidden'),
533
+ v_organization_ids,
534
+ COALESCE(target_configuration->'profile_grants', '[]'::jsonb)
535
+ );
536
+ END;
537
+ $$;
538
+
539
+ CREATE OR REPLACE FUNCTION public.get_project_access_configuration(target_project_id uuid)
540
+ RETURNS jsonb
541
+ LANGUAGE plpgsql
542
+ STABLE
543
+ SECURITY DEFINER
544
+ SET search_path = public
545
+ AS $$
546
+ DECLARE
547
+ v_result jsonb;
548
+ BEGIN
549
+ IF NOT (
550
+ public.is_admin()
551
+ OR (public.is_staff() AND public.is_project_owner_member(target_project_id))
552
+ ) THEN
553
+ RAISE EXCEPTION 'Only project owners or administrators can view client access configuration.'
554
+ USING ERRCODE = '42501';
555
+ END IF;
556
+
557
+ SELECT jsonb_build_object(
558
+ 'mode', project.client_access_mode,
559
+ 'updated_at', project.updated_at,
560
+ 'client_summary', project.client_summary,
561
+ 'client_scope', project.client_scope,
562
+ 'client_contact_profile_id', project.client_contact_profile_id,
563
+ 'organizations', COALESCE((
564
+ SELECT jsonb_agg(
565
+ jsonb_build_object(
566
+ 'organization_id', organization.id,
567
+ 'organization_name', organization.name,
568
+ 'is_primary', participant.is_primary,
569
+ 'selected_for_client_access', audience.project_id IS NOT NULL,
570
+ 'eligible_clients', COALESCE((
571
+ SELECT jsonb_agg(
572
+ jsonb_build_object(
573
+ 'profile_id', profile.id,
574
+ 'label', COALESCE(NULLIF(btrim(concat_ws(' ', profile.first_name, profile.last_name)), ''), profile.email),
575
+ 'email', profile.email
576
+ ) ORDER BY profile.first_name, profile.last_name, profile.email
577
+ )
578
+ FROM public.organization_members org_member
579
+ JOIN public.profiles profile ON profile.id = org_member.profile_id AND profile.user_id IS NOT NULL
580
+ JOIN public.user_role_assignments role_assignment
581
+ ON role_assignment.profile_id = profile.id AND role_assignment.role_code = 'client'
582
+ WHERE org_member.organization_id = organization.id
583
+ ), '[]'::jsonb),
584
+ 'selected_profile_ids', COALESCE((
585
+ SELECT jsonb_agg(client_profile.profile_id ORDER BY client_profile.profile_id)
586
+ FROM public.project_client_profiles client_profile
587
+ WHERE client_profile.project_id = target_project_id
588
+ AND client_profile.organization_id = organization.id
589
+ ), '[]'::jsonb)
590
+ ) ORDER BY participant.is_primary DESC, organization.name
591
+ )
592
+ FROM public.project_organizations participant
593
+ JOIN public.organizations organization ON organization.id = participant.organization_id
594
+ LEFT JOIN public.project_client_organizations audience
595
+ ON audience.project_id = participant.project_id
596
+ AND audience.organization_id = participant.organization_id
597
+ WHERE participant.project_id = target_project_id
598
+ ), '[]'::jsonb)
599
+ )
600
+ INTO v_result
601
+ FROM public.projects project
602
+ WHERE project.id = target_project_id;
603
+
604
+ IF v_result IS NULL THEN
605
+ RAISE EXCEPTION 'Projeto não encontrado.' USING ERRCODE = 'P0002';
606
+ END IF;
607
+ RETURN v_result;
608
+ END;
609
+ $$;
610
+
611
+ CREATE OR REPLACE FUNCTION public.get_project_client_access_summary(target_project_id uuid)
612
+ RETURNS jsonb
613
+ LANGUAGE plpgsql
614
+ STABLE
615
+ SECURITY DEFINER
616
+ SET search_path = public
617
+ AS $$
618
+ DECLARE
619
+ v_result jsonb;
620
+ BEGIN
621
+ IF NOT (
622
+ public.is_admin()
623
+ OR (
624
+ public.is_staff()
625
+ AND (
626
+ public.is_project_team_member(target_project_id)
627
+ OR public.is_project_org_admin(target_project_id)
628
+ )
629
+ )
630
+ ) THEN
631
+ RAISE EXCEPTION 'Only authorized internal project viewers can view the client access summary.'
632
+ USING ERRCODE = '42501';
633
+ END IF;
634
+
635
+ SELECT jsonb_build_object(
636
+ 'mode', project.client_access_mode,
637
+ 'organization_names', COALESCE((
638
+ SELECT jsonb_agg(organization.name ORDER BY organization.name)
639
+ FROM public.project_client_organizations audience
640
+ JOIN public.organizations organization
641
+ ON organization.id = audience.organization_id
642
+ WHERE audience.project_id = project.id
643
+ ), '[]'::jsonb),
644
+ 'organization_count', (
645
+ SELECT count(*)
646
+ FROM public.project_client_organizations audience
647
+ WHERE audience.project_id = project.id
648
+ ),
649
+ 'selected_client_count', (
650
+ SELECT count(*)
651
+ FROM public.project_client_profiles client_profile
652
+ WHERE client_profile.project_id = project.id
653
+ ),
654
+ 'content_readiness', jsonb_build_object(
655
+ 'has_summary', NULLIF(btrim(project.client_summary), '') IS NOT NULL,
656
+ 'has_scope', NULLIF(btrim(project.client_scope), '') IS NOT NULL,
657
+ 'has_contact', project.client_contact_profile_id IS NOT NULL,
658
+ 'client_visible_milestone_count', (
659
+ SELECT count(*)
660
+ FROM public.project_milestones milestone
661
+ WHERE milestone.project_id = project.id
662
+ AND milestone.visibility = 'client'
663
+ ),
664
+ 'shared_document_count', (
665
+ SELECT count(*)
666
+ FROM public.project_links link
667
+ WHERE link.project_id = project.id
668
+ AND link.visibility = 'client'
669
+ )
670
+ )
671
+ )
672
+ INTO v_result
673
+ FROM public.projects project
674
+ WHERE project.id = target_project_id;
675
+
676
+ IF v_result IS NULL THEN
677
+ RAISE EXCEPTION 'Projeto não encontrado.' USING ERRCODE = 'P0002';
678
+ END IF;
679
+ RETURN v_result;
680
+ END;
681
+ $$;