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,1061 @@
1
+ -- Expand the project model with explicit participation, client-safe content,
2
+ -- atomic creation, and narrow authenticated client read contracts.
3
+
4
+ ALTER TABLE public.projects
5
+ ADD COLUMN IF NOT EXISTS client_summary text,
6
+ ADD COLUMN IF NOT EXISTS client_scope text,
7
+ ADD COLUMN IF NOT EXISTS client_contact_profile_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
8
+ ADD COLUMN IF NOT EXISTS client_next_steps text;
9
+
10
+ CREATE INDEX IF NOT EXISTS idx_projects_client_contact_profile_id
11
+ ON public.projects (client_contact_profile_id)
12
+ WHERE client_contact_profile_id IS NOT NULL;
13
+
14
+ CREATE TABLE IF NOT EXISTS public.project_organizations (
15
+ project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE,
16
+ organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
17
+ is_primary boolean NOT NULL DEFAULT false,
18
+ added_by_profile_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
19
+ added_at timestamptz NOT NULL DEFAULT now(),
20
+ PRIMARY KEY (project_id, organization_id)
21
+ );
22
+
23
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_project_organizations_one_primary
24
+ ON public.project_organizations (project_id)
25
+ WHERE is_primary;
26
+
27
+ CREATE INDEX IF NOT EXISTS idx_project_organizations_org_project
28
+ ON public.project_organizations (organization_id, project_id);
29
+
30
+ ALTER TABLE public.project_organizations ENABLE ROW LEVEL SECURITY;
31
+
32
+ DROP POLICY IF EXISTS "Project staff view participating organizations"
33
+ ON public.project_organizations;
34
+ CREATE POLICY "Project staff view participating organizations"
35
+ ON public.project_organizations
36
+ FOR SELECT
37
+ TO authenticated
38
+ USING (
39
+ public.is_admin()
40
+ OR (
41
+ public.is_staff()
42
+ AND (
43
+ public.is_project_team_member(project_id)
44
+ OR public.is_project_org_admin(project_id)
45
+ )
46
+ )
47
+ );
48
+
49
+ DROP POLICY IF EXISTS "Project owners manage participating organizations"
50
+ ON public.project_organizations;
51
+ CREATE POLICY "Project owners manage participating organizations"
52
+ ON public.project_organizations
53
+ FOR ALL
54
+ TO authenticated
55
+ USING (
56
+ public.is_admin()
57
+ OR (public.is_staff() AND public.is_project_owner_member(project_id))
58
+ )
59
+ WITH CHECK (
60
+ public.is_admin()
61
+ OR (public.is_staff() AND public.is_project_owner_member(project_id))
62
+ );
63
+
64
+ INSERT INTO public.project_organizations (project_id, organization_id, is_primary)
65
+ SELECT project.id, project.organization_id, true
66
+ FROM public.projects project
67
+ ON CONFLICT (project_id, organization_id)
68
+ DO UPDATE SET is_primary = true;
69
+
70
+ -- Preserve any audience already configured before participation became
71
+ -- explicit. These rows are participants, but are never promoted to primary.
72
+ INSERT INTO public.project_organizations (project_id, organization_id, is_primary)
73
+ SELECT audience.project_id, audience.organization_id, false
74
+ FROM public.project_client_organizations audience
75
+ ON CONFLICT (project_id, organization_id) DO NOTHING;
76
+
77
+ ALTER TABLE public.project_client_organizations
78
+ DROP CONSTRAINT IF EXISTS project_client_organizations_participation_fkey;
79
+ ALTER TABLE public.project_client_organizations
80
+ ADD CONSTRAINT project_client_organizations_participation_fkey
81
+ FOREIGN KEY (project_id, organization_id)
82
+ REFERENCES public.project_organizations(project_id, organization_id)
83
+ ON DELETE CASCADE;
84
+
85
+ CREATE OR REPLACE FUNCTION public.sync_project_primary_organization()
86
+ RETURNS trigger
87
+ LANGUAGE plpgsql
88
+ SECURITY DEFINER
89
+ SET search_path = public
90
+ AS $$
91
+ BEGIN
92
+ UPDATE public.project_organizations
93
+ SET is_primary = false
94
+ WHERE project_id = NEW.id
95
+ AND is_primary;
96
+
97
+ INSERT INTO public.project_organizations (
98
+ project_id,
99
+ organization_id,
100
+ is_primary,
101
+ added_by_profile_id
102
+ )
103
+ VALUES (
104
+ NEW.id,
105
+ NEW.organization_id,
106
+ true,
107
+ public.current_profile_id()
108
+ )
109
+ ON CONFLICT (project_id, organization_id)
110
+ DO UPDATE SET is_primary = true;
111
+
112
+ RETURN NEW;
113
+ END;
114
+ $$;
115
+
116
+ DROP TRIGGER IF EXISTS trg_sync_project_primary_organization ON public.projects;
117
+ CREATE TRIGGER trg_sync_project_primary_organization
118
+ AFTER INSERT OR UPDATE OF organization_id ON public.projects
119
+ FOR EACH ROW
120
+ EXECUTE FUNCTION public.sync_project_primary_organization();
121
+
122
+ CREATE TABLE IF NOT EXISTS public.project_creation_requests (
123
+ request_id uuid PRIMARY KEY,
124
+ actor_profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE RESTRICT,
125
+ request_payload jsonb NOT NULL,
126
+ project_id uuid NOT NULL UNIQUE REFERENCES public.projects(id) ON DELETE CASCADE,
127
+ created_at timestamptz NOT NULL DEFAULT now()
128
+ );
129
+
130
+ ALTER TABLE public.project_creation_requests ENABLE ROW LEVEL SECURITY;
131
+ REVOKE ALL ON public.project_creation_requests FROM PUBLIC, anon, authenticated;
132
+
133
+ CREATE OR REPLACE FUNCTION public.set_project_organizations(
134
+ target_project_id uuid,
135
+ target_organization_ids uuid[]
136
+ )
137
+ RETURNS void
138
+ LANGUAGE plpgsql
139
+ SECURITY DEFINER
140
+ SET search_path = public
141
+ AS $$
142
+ DECLARE
143
+ v_actor_profile_id uuid := public.current_profile_id();
144
+ v_primary_organization_id uuid;
145
+ v_organization_ids uuid[] := COALESCE(target_organization_ids, ARRAY[]::uuid[]);
146
+ v_previous_organization_ids uuid[];
147
+ BEGIN
148
+ SELECT project.organization_id
149
+ INTO v_primary_organization_id
150
+ FROM public.projects project
151
+ WHERE project.id = target_project_id
152
+ FOR UPDATE;
153
+ IF NOT FOUND THEN
154
+ RAISE EXCEPTION 'Projeto não encontrado.' USING ERRCODE = 'P0002';
155
+ END IF;
156
+
157
+ IF NOT (
158
+ public.is_admin()
159
+ OR (public.is_staff() AND public.is_project_owner_member(target_project_id))
160
+ ) THEN
161
+ RAISE EXCEPTION 'Only project owners or administrators can manage participating organizations.'
162
+ USING ERRCODE = '42501';
163
+ END IF;
164
+
165
+ IF NOT (v_primary_organization_id = ANY(v_organization_ids)) THEN
166
+ RAISE EXCEPTION 'The primary organization must remain a project participant.'
167
+ USING ERRCODE = '22023';
168
+ END IF;
169
+
170
+ IF EXISTS (
171
+ SELECT 1
172
+ FROM unnest(v_organization_ids) AS selected(organization_id)
173
+ LEFT JOIN public.organizations organization ON organization.id = selected.organization_id
174
+ WHERE selected.organization_id IS NULL OR organization.id IS NULL
175
+ ) THEN
176
+ RAISE EXCEPTION 'One or more participating organizations do not exist.'
177
+ USING ERRCODE = '22023';
178
+ END IF;
179
+
180
+ SELECT COALESCE(array_agg(participant.organization_id ORDER BY participant.organization_id), ARRAY[]::uuid[])
181
+ INTO v_previous_organization_ids
182
+ FROM public.project_organizations participant
183
+ WHERE participant.project_id = target_project_id;
184
+
185
+ DELETE FROM public.project_organizations participant
186
+ WHERE participant.project_id = target_project_id
187
+ AND NOT (participant.organization_id = ANY(v_organization_ids));
188
+
189
+ -- Participation deletion cascades matching client audiences and grants. If
190
+ -- that leaves a visible mode with no coherent audience, fail closed in the
191
+ -- same project-locked transaction instead of retaining a misleading mode.
192
+ IF EXISTS (
193
+ SELECT 1
194
+ FROM public.projects project
195
+ WHERE project.id = target_project_id
196
+ AND project.client_access_mode <> 'hidden'
197
+ AND (
198
+ NOT EXISTS (
199
+ SELECT 1
200
+ FROM public.project_client_organizations audience
201
+ WHERE audience.project_id = target_project_id
202
+ )
203
+ OR (
204
+ project.client_access_mode = 'selected_clients'
205
+ AND EXISTS (
206
+ SELECT 1
207
+ FROM public.project_client_organizations audience
208
+ WHERE audience.project_id = target_project_id
209
+ AND NOT EXISTS (
210
+ SELECT 1
211
+ FROM public.project_client_profiles client_profile
212
+ WHERE client_profile.project_id = audience.project_id
213
+ AND client_profile.organization_id = audience.organization_id
214
+ )
215
+ )
216
+ )
217
+ )
218
+ ) THEN
219
+ DELETE FROM public.project_client_organizations audience
220
+ WHERE audience.project_id = target_project_id;
221
+
222
+ UPDATE public.projects
223
+ SET client_access_mode = 'hidden'
224
+ WHERE id = target_project_id;
225
+ END IF;
226
+
227
+ INSERT INTO public.project_organizations (
228
+ project_id,
229
+ organization_id,
230
+ is_primary,
231
+ added_by_profile_id
232
+ )
233
+ SELECT
234
+ target_project_id,
235
+ selected.organization_id,
236
+ selected.organization_id = v_primary_organization_id,
237
+ v_actor_profile_id
238
+ FROM (
239
+ SELECT DISTINCT organization_id
240
+ FROM unnest(v_organization_ids) AS requested(organization_id)
241
+ ) selected
242
+ ON CONFLICT ON CONSTRAINT project_organizations_pkey
243
+ DO UPDATE SET is_primary = EXCLUDED.is_primary;
244
+
245
+ INSERT INTO public.app_activity_events (
246
+ domain, event_type, entity_table, entity_id, actor_profile_id, summary, payload
247
+ )
248
+ VALUES (
249
+ 'projects',
250
+ 'project_organizations_changed',
251
+ 'projects',
252
+ target_project_id,
253
+ v_actor_profile_id,
254
+ 'Organizações do projeto atualizadas.',
255
+ jsonb_build_object(
256
+ 'project_id', target_project_id,
257
+ 'previous_organization_ids', to_jsonb(v_previous_organization_ids),
258
+ 'organization_ids', to_jsonb(v_organization_ids)
259
+ )
260
+ );
261
+ END;
262
+ $$;
263
+
264
+ REVOKE ALL ON FUNCTION public.set_project_organizations(uuid, uuid[])
265
+ FROM PUBLIC, anon;
266
+ GRANT EXECUTE ON FUNCTION public.set_project_organizations(uuid, uuid[])
267
+ TO authenticated;
268
+
269
+ CREATE OR REPLACE FUNCTION public.set_project_client_access(
270
+ target_project_id uuid,
271
+ target_mode text,
272
+ target_organization_ids uuid[],
273
+ target_profile_grants jsonb
274
+ )
275
+ RETURNS void
276
+ LANGUAGE plpgsql
277
+ SECURITY DEFINER
278
+ SET search_path = public
279
+ AS $$
280
+ DECLARE
281
+ v_actor_profile_id uuid := public.current_profile_id();
282
+ v_organization_ids uuid[] := COALESCE(target_organization_ids, ARRAY[]::uuid[]);
283
+ v_profile_grants jsonb := COALESCE(target_profile_grants, '[]'::jsonb);
284
+ v_previous_mode text;
285
+ v_previous_organization_ids uuid[];
286
+ v_previous_profile_grants jsonb;
287
+ BEGIN
288
+ IF target_mode IS NULL
289
+ OR target_mode NOT IN ('hidden', 'all_org_clients', 'selected_clients') THEN
290
+ RAISE EXCEPTION 'Invalid project client access mode.' USING ERRCODE = '22023';
291
+ END IF;
292
+ IF jsonb_typeof(v_profile_grants) <> 'array' THEN
293
+ RAISE EXCEPTION 'Profile grants must be a JSON array.' USING ERRCODE = '22023';
294
+ END IF;
295
+
296
+ SELECT project.client_access_mode
297
+ INTO v_previous_mode
298
+ FROM public.projects project
299
+ WHERE project.id = target_project_id
300
+ FOR UPDATE;
301
+ IF NOT FOUND THEN
302
+ RAISE EXCEPTION 'Projeto não encontrado.' USING ERRCODE = 'P0002';
303
+ END IF;
304
+
305
+ IF NOT (
306
+ public.is_admin()
307
+ OR (public.is_staff() AND public.is_project_owner_member(target_project_id))
308
+ ) THEN
309
+ RAISE EXCEPTION 'Only project owners or administrators can manage client project access.'
310
+ USING ERRCODE = '42501';
311
+ END IF;
312
+
313
+ IF target_mode <> 'hidden' AND cardinality(v_organization_ids) = 0 THEN
314
+ RAISE EXCEPTION 'Visible client access requires at least one organization.'
315
+ USING ERRCODE = '22023';
316
+ END IF;
317
+
318
+ IF EXISTS (
319
+ SELECT 1
320
+ FROM unnest(v_organization_ids) AS selected(organization_id)
321
+ LEFT JOIN public.project_organizations participant
322
+ ON participant.project_id = target_project_id
323
+ AND participant.organization_id = selected.organization_id
324
+ WHERE selected.organization_id IS NULL OR participant.project_id IS NULL
325
+ ) THEN
326
+ RAISE EXCEPTION 'Every client organization must participate in the project.'
327
+ USING ERRCODE = '22023';
328
+ END IF;
329
+
330
+ IF target_mode = 'selected_clients' AND jsonb_array_length(v_profile_grants) = 0 THEN
331
+ RAISE EXCEPTION 'Selected-clients mode requires at least one profile grant.'
332
+ USING ERRCODE = '22023';
333
+ END IF;
334
+
335
+ IF target_mode = 'selected_clients' AND EXISTS (
336
+ SELECT 1
337
+ FROM unnest(v_organization_ids) AS selected(organization_id)
338
+ WHERE NOT EXISTS (
339
+ SELECT 1
340
+ FROM jsonb_to_recordset(v_profile_grants)
341
+ AS access_grant(organization_id uuid, profile_id uuid)
342
+ WHERE access_grant.organization_id = selected.organization_id
343
+ )
344
+ ) THEN
345
+ RAISE EXCEPTION 'Every selected organization requires at least one selected client.'
346
+ USING ERRCODE = '22023';
347
+ END IF;
348
+
349
+ IF target_mode = 'selected_clients' AND EXISTS (
350
+ SELECT 1
351
+ FROM jsonb_to_recordset(v_profile_grants)
352
+ AS access_grant(organization_id uuid, profile_id uuid)
353
+ LEFT JOIN public.organization_members org_member
354
+ ON org_member.organization_id = access_grant.organization_id
355
+ AND org_member.profile_id = access_grant.profile_id
356
+ LEFT JOIN public.user_role_assignments role_assignment
357
+ ON role_assignment.profile_id = access_grant.profile_id
358
+ AND role_assignment.role_code = 'client'
359
+ LEFT JOIN public.profiles profile
360
+ ON profile.id = access_grant.profile_id
361
+ AND profile.user_id IS NOT NULL
362
+ WHERE access_grant.organization_id IS NULL
363
+ OR access_grant.profile_id IS NULL
364
+ OR NOT (access_grant.organization_id = ANY(v_organization_ids))
365
+ OR org_member.id IS NULL
366
+ OR role_assignment.id IS NULL
367
+ OR profile.id IS NULL
368
+ ) THEN
369
+ RAISE EXCEPTION 'Every selected profile must be an active client member with a login.'
370
+ USING ERRCODE = '22023';
371
+ END IF;
372
+
373
+ SELECT COALESCE(array_agg(audience.organization_id ORDER BY audience.organization_id), ARRAY[]::uuid[])
374
+ INTO v_previous_organization_ids
375
+ FROM public.project_client_organizations audience
376
+ WHERE audience.project_id = target_project_id;
377
+
378
+ SELECT COALESCE(
379
+ jsonb_agg(
380
+ jsonb_build_object(
381
+ 'organization_id', client_profile.organization_id,
382
+ 'profile_id', client_profile.profile_id
383
+ ) ORDER BY client_profile.organization_id, client_profile.profile_id
384
+ ),
385
+ '[]'::jsonb
386
+ )
387
+ INTO v_previous_profile_grants
388
+ FROM public.project_client_profiles client_profile
389
+ WHERE client_profile.project_id = target_project_id;
390
+
391
+ DELETE FROM public.project_client_organizations
392
+ WHERE project_id = target_project_id;
393
+
394
+ INSERT INTO public.project_client_organizations (
395
+ project_id, organization_id, added_by_profile_id
396
+ )
397
+ SELECT target_project_id, selected.organization_id, v_actor_profile_id
398
+ FROM (
399
+ SELECT DISTINCT organization_id
400
+ FROM unnest(v_organization_ids) AS requested(organization_id)
401
+ ) selected;
402
+
403
+ IF target_mode = 'selected_clients' THEN
404
+ INSERT INTO public.project_client_profiles (
405
+ project_id, organization_id, profile_id, granted_by_profile_id
406
+ )
407
+ SELECT DISTINCT
408
+ target_project_id,
409
+ access_grant.organization_id,
410
+ access_grant.profile_id,
411
+ v_actor_profile_id
412
+ FROM jsonb_to_recordset(v_profile_grants)
413
+ AS access_grant(organization_id uuid, profile_id uuid);
414
+ END IF;
415
+
416
+ UPDATE public.projects
417
+ SET client_access_mode = target_mode
418
+ WHERE id = target_project_id;
419
+
420
+ INSERT INTO public.app_activity_events (
421
+ domain, event_type, entity_table, entity_id, actor_profile_id, summary, payload
422
+ )
423
+ VALUES (
424
+ 'projects',
425
+ 'project_client_access_changed',
426
+ 'projects',
427
+ target_project_id,
428
+ v_actor_profile_id,
429
+ 'Acesso de clientes atualizado.',
430
+ jsonb_build_object(
431
+ 'project_id', target_project_id,
432
+ 'previous_mode', v_previous_mode,
433
+ 'mode', target_mode,
434
+ 'previous_organization_ids', to_jsonb(v_previous_organization_ids),
435
+ 'organization_ids', to_jsonb(v_organization_ids),
436
+ 'previous_profile_grants', v_previous_profile_grants,
437
+ 'profile_grants', CASE WHEN target_mode = 'selected_clients' THEN v_profile_grants ELSE '[]'::jsonb END
438
+ )
439
+ );
440
+ END;
441
+ $$;
442
+
443
+ REVOKE ALL ON FUNCTION public.set_project_client_access(uuid, text, uuid[], jsonb)
444
+ FROM PUBLIC, anon, service_role;
445
+ GRANT EXECUTE ON FUNCTION public.set_project_client_access(uuid, text, uuid[], jsonb)
446
+ TO authenticated;
447
+
448
+ CREATE OR REPLACE FUNCTION public.set_project_client_configuration(
449
+ target_project_id uuid,
450
+ target_configuration jsonb
451
+ )
452
+ RETURNS void
453
+ LANGUAGE plpgsql
454
+ SECURITY DEFINER
455
+ SET search_path = public
456
+ AS $$
457
+ DECLARE
458
+ v_contact_profile_id uuid := NULLIF(target_configuration->>'client_contact_profile_id', '')::uuid;
459
+ v_organization_ids uuid[] := ARRAY(
460
+ SELECT value::uuid
461
+ FROM jsonb_array_elements_text(
462
+ COALESCE(target_configuration->'organization_ids', '[]'::jsonb)
463
+ ) value
464
+ );
465
+ BEGIN
466
+ IF jsonb_typeof(COALESCE(target_configuration, '{}'::jsonb)) <> 'object' THEN
467
+ RAISE EXCEPTION 'Client project configuration must be a JSON object.'
468
+ USING ERRCODE = '22023';
469
+ END IF;
470
+ IF NOT (
471
+ public.is_admin()
472
+ OR (public.is_staff() AND public.is_project_owner_member(target_project_id))
473
+ ) THEN
474
+ RAISE EXCEPTION 'Only project owners or administrators can manage client project configuration.'
475
+ USING ERRCODE = '42501';
476
+ END IF;
477
+ IF v_contact_profile_id IS NOT NULL AND NOT EXISTS (
478
+ SELECT 1
479
+ FROM public.project_members member
480
+ JOIN public.user_role_assignments role_assignment
481
+ ON role_assignment.profile_id = member.profile_id
482
+ AND role_assignment.role_code IN ('staff', 'admin')
483
+ WHERE member.project_id = target_project_id
484
+ AND member.profile_id = v_contact_profile_id
485
+ ) THEN
486
+ RAISE EXCEPTION 'The client contact must be an internal project team member.'
487
+ USING ERRCODE = '22023';
488
+ END IF;
489
+
490
+ UPDATE public.projects
491
+ SET client_summary = NULLIF(btrim(target_configuration->>'client_summary'), ''),
492
+ client_scope = NULLIF(btrim(target_configuration->>'client_scope'), ''),
493
+ client_contact_profile_id = v_contact_profile_id,
494
+ client_next_steps = NULLIF(btrim(target_configuration->>'client_next_steps'), '')
495
+ WHERE id = target_project_id;
496
+ IF NOT FOUND THEN
497
+ RAISE EXCEPTION 'Projeto não encontrado.' USING ERRCODE = 'P0002';
498
+ END IF;
499
+
500
+ PERFORM public.set_project_client_access(
501
+ target_project_id,
502
+ COALESCE(NULLIF(target_configuration->>'mode', ''), 'hidden'),
503
+ v_organization_ids,
504
+ COALESCE(target_configuration->'profile_grants', '[]'::jsonb)
505
+ );
506
+ END;
507
+ $$;
508
+
509
+ REVOKE ALL ON FUNCTION public.set_project_client_configuration(uuid, jsonb)
510
+ FROM PUBLIC, anon, service_role;
511
+ GRANT EXECUTE ON FUNCTION public.set_project_client_configuration(uuid, jsonb)
512
+ TO authenticated;
513
+
514
+ CREATE OR REPLACE FUNCTION public.get_project_access_configuration(target_project_id uuid)
515
+ RETURNS jsonb
516
+ LANGUAGE plpgsql
517
+ STABLE
518
+ SECURITY DEFINER
519
+ SET search_path = public
520
+ AS $$
521
+ DECLARE
522
+ v_result jsonb;
523
+ BEGIN
524
+ IF NOT (
525
+ public.is_admin()
526
+ OR (public.is_staff() AND public.is_project_owner_member(target_project_id))
527
+ ) THEN
528
+ RAISE EXCEPTION 'Only project owners or administrators can view client access configuration.'
529
+ USING ERRCODE = '42501';
530
+ END IF;
531
+
532
+ SELECT jsonb_build_object(
533
+ 'mode', project.client_access_mode,
534
+ 'updated_at', project.updated_at,
535
+ 'client_summary', project.client_summary,
536
+ 'client_scope', project.client_scope,
537
+ 'client_contact_profile_id', project.client_contact_profile_id,
538
+ 'client_next_steps', project.client_next_steps,
539
+ 'organizations', COALESCE((
540
+ SELECT jsonb_agg(
541
+ jsonb_build_object(
542
+ 'organization_id', organization.id,
543
+ 'organization_name', organization.name,
544
+ 'is_primary', participant.is_primary,
545
+ 'selected_for_client_access', audience.project_id IS NOT NULL,
546
+ 'eligible_clients', COALESCE((
547
+ SELECT jsonb_agg(
548
+ jsonb_build_object(
549
+ 'profile_id', profile.id,
550
+ 'label', COALESCE(NULLIF(btrim(concat_ws(' ', profile.first_name, profile.last_name)), ''), profile.email),
551
+ 'email', profile.email
552
+ ) ORDER BY profile.first_name, profile.last_name, profile.email
553
+ )
554
+ FROM public.organization_members org_member
555
+ JOIN public.profiles profile ON profile.id = org_member.profile_id AND profile.user_id IS NOT NULL
556
+ JOIN public.user_role_assignments role_assignment
557
+ ON role_assignment.profile_id = profile.id AND role_assignment.role_code = 'client'
558
+ WHERE org_member.organization_id = organization.id
559
+ ), '[]'::jsonb),
560
+ 'selected_profile_ids', COALESCE((
561
+ SELECT jsonb_agg(client_profile.profile_id ORDER BY client_profile.profile_id)
562
+ FROM public.project_client_profiles client_profile
563
+ WHERE client_profile.project_id = target_project_id
564
+ AND client_profile.organization_id = organization.id
565
+ ), '[]'::jsonb)
566
+ ) ORDER BY participant.is_primary DESC, organization.name
567
+ )
568
+ FROM public.project_organizations participant
569
+ JOIN public.organizations organization ON organization.id = participant.organization_id
570
+ LEFT JOIN public.project_client_organizations audience
571
+ ON audience.project_id = participant.project_id
572
+ AND audience.organization_id = participant.organization_id
573
+ WHERE participant.project_id = target_project_id
574
+ ), '[]'::jsonb)
575
+ )
576
+ INTO v_result
577
+ FROM public.projects project
578
+ WHERE project.id = target_project_id;
579
+
580
+ IF v_result IS NULL THEN
581
+ RAISE EXCEPTION 'Projeto não encontrado.' USING ERRCODE = 'P0002';
582
+ END IF;
583
+ RETURN v_result;
584
+ END;
585
+ $$;
586
+
587
+ REVOKE ALL ON FUNCTION public.get_project_access_configuration(uuid)
588
+ FROM PUBLIC, anon, service_role;
589
+ GRANT EXECUTE ON FUNCTION public.get_project_access_configuration(uuid)
590
+ TO authenticated;
591
+
592
+ CREATE OR REPLACE FUNCTION public.get_project_client_access_summary(target_project_id uuid)
593
+ RETURNS jsonb
594
+ LANGUAGE plpgsql
595
+ STABLE
596
+ SECURITY DEFINER
597
+ SET search_path = public
598
+ AS $$
599
+ DECLARE
600
+ v_result jsonb;
601
+ BEGIN
602
+ IF NOT (
603
+ public.is_admin()
604
+ OR (
605
+ public.is_staff()
606
+ AND (
607
+ public.is_project_team_member(target_project_id)
608
+ OR public.is_project_org_admin(target_project_id)
609
+ )
610
+ )
611
+ ) THEN
612
+ RAISE EXCEPTION 'Only authorized internal project viewers can view the client access summary.'
613
+ USING ERRCODE = '42501';
614
+ END IF;
615
+
616
+ SELECT jsonb_build_object(
617
+ 'mode', project.client_access_mode,
618
+ 'organization_names', COALESCE((
619
+ SELECT jsonb_agg(organization.name ORDER BY organization.name)
620
+ FROM public.project_client_organizations audience
621
+ JOIN public.organizations organization
622
+ ON organization.id = audience.organization_id
623
+ WHERE audience.project_id = project.id
624
+ ), '[]'::jsonb),
625
+ 'organization_count', (
626
+ SELECT count(*)
627
+ FROM public.project_client_organizations audience
628
+ WHERE audience.project_id = project.id
629
+ ),
630
+ 'selected_client_count', (
631
+ SELECT count(*)
632
+ FROM public.project_client_profiles client_profile
633
+ WHERE client_profile.project_id = project.id
634
+ ),
635
+ 'content_readiness', jsonb_build_object(
636
+ 'has_summary', NULLIF(btrim(project.client_summary), '') IS NOT NULL,
637
+ 'has_scope', NULLIF(btrim(project.client_scope), '') IS NOT NULL,
638
+ 'has_contact', project.client_contact_profile_id IS NOT NULL,
639
+ 'has_next_steps', NULLIF(btrim(project.client_next_steps), '') IS NOT NULL,
640
+ 'client_visible_milestone_count', (
641
+ SELECT count(*)
642
+ FROM public.project_milestones milestone
643
+ WHERE milestone.project_id = project.id
644
+ AND milestone.visibility = 'client'
645
+ ),
646
+ 'shared_document_count', (
647
+ SELECT count(*)
648
+ FROM public.project_links link
649
+ WHERE link.project_id = project.id
650
+ AND link.visibility = 'client'
651
+ )
652
+ )
653
+ )
654
+ INTO v_result
655
+ FROM public.projects project
656
+ WHERE project.id = target_project_id;
657
+
658
+ IF v_result IS NULL THEN
659
+ RAISE EXCEPTION 'Projeto não encontrado.' USING ERRCODE = 'P0002';
660
+ END IF;
661
+ RETURN v_result;
662
+ END;
663
+ $$;
664
+
665
+ REVOKE ALL ON FUNCTION public.get_project_client_access_summary(uuid)
666
+ FROM PUBLIC, anon, service_role;
667
+ GRANT EXECUTE ON FUNCTION public.get_project_client_access_summary(uuid)
668
+ TO authenticated;
669
+
670
+ CREATE OR REPLACE FUNCTION public.create_project_with_client_access(
671
+ p_request_id uuid,
672
+ p_project jsonb,
673
+ p_organization_ids uuid[],
674
+ p_members jsonb,
675
+ p_client_access jsonb
676
+ )
677
+ RETURNS TABLE(project_id uuid, created boolean)
678
+ LANGUAGE plpgsql
679
+ SECURITY DEFINER
680
+ SET search_path = public
681
+ AS $$
682
+ DECLARE
683
+ v_actor_profile_id uuid := public.current_profile_id();
684
+ v_actor_role text := public.current_global_role();
685
+ v_payload jsonb := jsonb_build_object(
686
+ 'project', COALESCE(p_project, '{}'::jsonb),
687
+ 'organization_ids', to_jsonb(COALESCE(p_organization_ids, ARRAY[]::uuid[])),
688
+ 'members', COALESCE(p_members, '[]'::jsonb),
689
+ 'client_access', COALESCE(p_client_access, '{}'::jsonb)
690
+ );
691
+ v_existing public.project_creation_requests%ROWTYPE;
692
+ v_project_id uuid;
693
+ v_primary_organization_id uuid;
694
+ v_owner_profile_id uuid;
695
+ v_status text;
696
+ v_mode text;
697
+ v_access_organization_ids uuid[];
698
+ v_profile_grants jsonb;
699
+ v_organization_name text;
700
+ v_organization_token text;
701
+ v_project_token text;
702
+ v_code_base text;
703
+ v_code text;
704
+ v_code_suffix integer := 1;
705
+ BEGIN
706
+ IF p_request_id IS NULL OR v_actor_profile_id IS NULL OR v_actor_role NOT IN ('staff', 'admin') THEN
707
+ RAISE EXCEPTION 'Only authenticated staff can create projects.' USING ERRCODE = '42501';
708
+ END IF;
709
+ IF jsonb_typeof(COALESCE(p_project, '{}'::jsonb)) <> 'object'
710
+ OR jsonb_typeof(COALESCE(p_members, '[]'::jsonb)) <> 'array'
711
+ OR jsonb_typeof(COALESCE(p_client_access, '{}'::jsonb)) <> 'object' THEN
712
+ RAISE EXCEPTION 'Invalid project creation payload.' USING ERRCODE = '22023';
713
+ END IF;
714
+
715
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_request_id::text, 0));
716
+ SELECT request.* INTO v_existing
717
+ FROM public.project_creation_requests request
718
+ WHERE request.request_id = p_request_id;
719
+ IF FOUND THEN
720
+ IF v_existing.actor_profile_id <> v_actor_profile_id OR v_existing.request_payload <> v_payload THEN
721
+ RAISE EXCEPTION 'The idempotency key was already used with another request.' USING ERRCODE = '22023';
722
+ END IF;
723
+ RETURN QUERY SELECT v_existing.project_id, false;
724
+ RETURN;
725
+ END IF;
726
+
727
+ v_primary_organization_id := NULLIF(p_project->>'primary_organization_id', '')::uuid;
728
+ v_status := COALESCE(NULLIF(p_project->>'status', ''), 'planned');
729
+ IF v_primary_organization_id IS NULL
730
+ OR NULLIF(btrim(p_project->>'name'), '') IS NULL
731
+ OR v_status NOT IN ('planned', 'active', 'paused', 'blocked', 'completed', 'canceled') THEN
732
+ RAISE EXCEPTION 'Project name, primary organization, and status are invalid.' USING ERRCODE = '22023';
733
+ END IF;
734
+ IF NOT (v_primary_organization_id = ANY(COALESCE(p_organization_ids, ARRAY[]::uuid[]))) THEN
735
+ RAISE EXCEPTION 'The primary organization must participate in the project.' USING ERRCODE = '22023';
736
+ END IF;
737
+ IF EXISTS (
738
+ SELECT 1 FROM unnest(COALESCE(p_organization_ids, ARRAY[]::uuid[])) selected(organization_id)
739
+ LEFT JOIN public.organizations organization ON organization.id = selected.organization_id
740
+ WHERE selected.organization_id IS NULL OR organization.id IS NULL
741
+ ) THEN
742
+ RAISE EXCEPTION 'One or more participating organizations do not exist.' USING ERRCODE = '22023';
743
+ END IF;
744
+ SELECT organization.name
745
+ INTO v_organization_name
746
+ FROM public.organizations organization
747
+ WHERE organization.id = v_primary_organization_id;
748
+ IF NULLIF(p_project->>'start_date', '')::date > NULLIF(p_project->>'target_date', '')::date THEN
749
+ RAISE EXCEPTION 'A data alvo não pode ser anterior à data de início.' USING ERRCODE = '22023';
750
+ END IF;
751
+ IF v_status = 'canceled' AND NULLIF(btrim(p_project->>'cancellation_reason'), '') IS NULL THEN
752
+ RAISE EXCEPTION 'Indica o motivo do cancelamento.' USING ERRCODE = '22023';
753
+ END IF;
754
+
755
+ IF (SELECT count(*) FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text) WHERE role = 'owner') <> 1 THEN
756
+ RAISE EXCEPTION 'Exactly one internal project owner is required.' USING ERRCODE = '22023';
757
+ END IF;
758
+ IF (
759
+ SELECT count(*) <> count(DISTINCT member.profile_id)
760
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text)
761
+ ) THEN
762
+ RAISE EXCEPTION 'Each internal profile can appear only once in the project team.'
763
+ USING ERRCODE = '22023';
764
+ END IF;
765
+ SELECT member.profile_id INTO v_owner_profile_id
766
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text)
767
+ WHERE member.role = 'owner';
768
+
769
+ IF EXISTS (
770
+ SELECT 1
771
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text)
772
+ LEFT JOIN public.user_role_assignments role_assignment
773
+ ON role_assignment.profile_id = member.profile_id
774
+ AND role_assignment.role_code IN ('staff', 'admin')
775
+ WHERE member.profile_id IS NULL
776
+ OR member.role NOT IN ('owner', 'contributor', 'observer')
777
+ OR role_assignment.id IS NULL
778
+ ) THEN
779
+ RAISE EXCEPTION 'Project members must be internal staff.' USING ERRCODE = '22023';
780
+ END IF;
781
+ IF v_actor_role = 'staff' AND v_owner_profile_id <> v_actor_profile_id THEN
782
+ RAISE EXCEPTION 'Staff creators must own the projects they create.' USING ERRCODE = '42501';
783
+ END IF;
784
+
785
+ IF NULLIF(p_project->>'client_contact_profile_id', '') IS NOT NULL AND NOT EXISTS (
786
+ SELECT 1
787
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text)
788
+ JOIN public.user_role_assignments role_assignment
789
+ ON role_assignment.profile_id = member.profile_id
790
+ AND role_assignment.role_code IN ('staff', 'admin')
791
+ WHERE member.profile_id = NULLIF(p_project->>'client_contact_profile_id', '')::uuid
792
+ ) THEN
793
+ RAISE EXCEPTION 'The client contact must be an internal project team member.' USING ERRCODE = '22023';
794
+ END IF;
795
+
796
+ v_code := NULLIF(upper(btrim(p_project->>'code')), '');
797
+ IF v_code IS NULL THEN
798
+ v_organization_token := trim(both '-' FROM regexp_replace(
799
+ translate(
800
+ upper(COALESCE(v_organization_name, 'ORG')),
801
+ 'ÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ',
802
+ 'AAAAAEEEEIIIIOOOOOUUUUC'
803
+ ),
804
+ '[^A-Z0-9]+',
805
+ '-',
806
+ 'g'
807
+ ));
808
+ v_project_token := trim(both '-' FROM regexp_replace(
809
+ translate(
810
+ upper(COALESCE(p_project->>'name', 'PROJECT')),
811
+ 'ÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ',
812
+ 'AAAAAEEEEIIIIOOOOOUUUUC'
813
+ ),
814
+ '[^A-Z0-9]+',
815
+ '-',
816
+ 'g'
817
+ ));
818
+ v_organization_token := COALESCE(NULLIF(array_to_string(
819
+ (regexp_split_to_array(v_organization_token, '-'))[1:2], '-'
820
+ ), ''), 'ORG');
821
+ v_project_token := COALESCE(NULLIF(array_to_string(
822
+ (regexp_split_to_array(v_project_token, '-'))[1:3], '-'
823
+ ), ''), 'PROJECT');
824
+ v_code_base := left(
825
+ format('%s-%s-%s', v_organization_token, v_project_token, extract(year FROM current_date)::integer),
826
+ 72
827
+ );
828
+
829
+ -- Serialize only projects competing for the same generated reference.
830
+ PERFORM pg_advisory_xact_lock(hashtextextended('project-code:' || v_code_base, 0));
831
+ LOOP
832
+ v_code := CASE
833
+ WHEN v_code_suffix = 1 THEN v_code_base
834
+ ELSE format('%s-%s', v_code_base, v_code_suffix)
835
+ END;
836
+ EXIT WHEN NOT EXISTS (
837
+ SELECT 1
838
+ FROM public.projects existing_project
839
+ WHERE lower(existing_project.code) = lower(v_code)
840
+ );
841
+ v_code_suffix := v_code_suffix + 1;
842
+ END LOOP;
843
+ END IF;
844
+
845
+ INSERT INTO public.projects (
846
+ organization_id, name, code, status, health, owner_profile_id,
847
+ start_date, target_date, cancellation_reason, summary,
848
+ client_access_mode, client_summary, client_scope,
849
+ client_contact_profile_id, client_next_steps
850
+ )
851
+ VALUES (
852
+ v_primary_organization_id,
853
+ btrim(p_project->>'name'),
854
+ v_code,
855
+ v_status,
856
+ CASE WHEN v_status IN ('blocked', 'canceled') THEN 'off_track' ELSE 'on_track' END,
857
+ v_owner_profile_id,
858
+ NULLIF(p_project->>'start_date', '')::date,
859
+ NULLIF(p_project->>'target_date', '')::date,
860
+ CASE WHEN v_status = 'canceled' THEN NULLIF(btrim(p_project->>'cancellation_reason'), '') ELSE NULL END,
861
+ NULLIF(btrim(p_project->>'summary'), ''),
862
+ 'hidden',
863
+ NULLIF(btrim(p_project->>'client_summary'), ''),
864
+ NULLIF(btrim(p_project->>'client_scope'), ''),
865
+ NULLIF(p_project->>'client_contact_profile_id', '')::uuid,
866
+ NULLIF(btrim(p_project->>'client_next_steps'), '')
867
+ )
868
+ RETURNING id INTO v_project_id;
869
+
870
+ INSERT INTO public.project_organizations (
871
+ project_id, organization_id, is_primary, added_by_profile_id
872
+ )
873
+ SELECT
874
+ v_project_id,
875
+ selected.organization_id,
876
+ selected.organization_id = v_primary_organization_id,
877
+ v_actor_profile_id
878
+ FROM (
879
+ SELECT DISTINCT organization_id
880
+ FROM unnest(p_organization_ids) requested(organization_id)
881
+ ) selected
882
+ ON CONFLICT ON CONSTRAINT project_organizations_pkey
883
+ DO UPDATE SET is_primary = EXCLUDED.is_primary;
884
+
885
+ INSERT INTO public.project_members (project_id, profile_id, role)
886
+ SELECT DISTINCT v_project_id, member.profile_id, member.role
887
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text);
888
+
889
+ v_mode := COALESCE(NULLIF(p_client_access->>'mode', ''), 'hidden');
890
+ v_access_organization_ids := ARRAY(
891
+ SELECT value::uuid
892
+ FROM jsonb_array_elements_text(COALESCE(p_client_access->'organization_ids', '[]'::jsonb)) value
893
+ );
894
+ v_profile_grants := COALESCE(p_client_access->'profile_grants', '[]'::jsonb);
895
+ PERFORM public.set_project_client_access(
896
+ v_project_id,
897
+ v_mode,
898
+ v_access_organization_ids,
899
+ v_profile_grants
900
+ );
901
+
902
+ INSERT INTO public.project_creation_requests (
903
+ request_id, actor_profile_id, request_payload, project_id
904
+ )
905
+ VALUES (p_request_id, v_actor_profile_id, v_payload, v_project_id);
906
+
907
+ RETURN QUERY SELECT v_project_id, true;
908
+ END;
909
+ $$;
910
+
911
+ REVOKE ALL ON FUNCTION public.create_project_with_client_access(uuid, jsonb, uuid[], jsonb, jsonb)
912
+ FROM PUBLIC, anon, service_role;
913
+ GRANT EXECUTE ON FUNCTION public.create_project_with_client_access(uuid, jsonb, uuid[], jsonb, jsonb)
914
+ TO authenticated;
915
+
916
+ CREATE OR REPLACE FUNCTION public.list_current_client_projects()
917
+ RETURNS TABLE (
918
+ project_id uuid,
919
+ name text,
920
+ reference text,
921
+ status text,
922
+ start_date date,
923
+ target_date date,
924
+ completed_at timestamptz,
925
+ archived_at timestamptz,
926
+ client_summary text,
927
+ client_scope text,
928
+ client_contact jsonb,
929
+ client_next_steps text,
930
+ organizations jsonb,
931
+ progress_percent integer
932
+ )
933
+ LANGUAGE sql
934
+ STABLE
935
+ SECURITY DEFINER
936
+ SET search_path = public
937
+ AS $$
938
+ SELECT
939
+ project.id,
940
+ project.name,
941
+ project.code,
942
+ project.status,
943
+ project.start_date,
944
+ project.target_date,
945
+ project.completed_at,
946
+ project.archived_at,
947
+ project.client_summary,
948
+ project.client_scope,
949
+ CASE WHEN contact.id IS NULL THEN NULL ELSE jsonb_build_object(
950
+ 'profile_id', contact.id,
951
+ 'name', COALESCE(NULLIF(btrim(concat_ws(' ', contact.first_name, contact.last_name)), ''), contact.email),
952
+ 'email', contact.email
953
+ ) END,
954
+ project.client_next_steps,
955
+ COALESCE((
956
+ SELECT jsonb_agg(jsonb_build_object('id', organization.id, 'name', organization.name) ORDER BY organization.name)
957
+ FROM public.project_client_organizations audience
958
+ JOIN public.organization_members org_member
959
+ ON org_member.organization_id = audience.organization_id
960
+ AND org_member.profile_id = public.current_profile_id()
961
+ JOIN public.organizations organization ON organization.id = audience.organization_id
962
+ WHERE audience.project_id = project.id
963
+ AND (
964
+ project.client_access_mode = 'all_org_clients'
965
+ OR EXISTS (
966
+ SELECT 1 FROM public.project_client_profiles grant_row
967
+ WHERE grant_row.project_id = project.id
968
+ AND grant_row.organization_id = audience.organization_id
969
+ AND grant_row.profile_id = public.current_profile_id()
970
+ )
971
+ )
972
+ ), '[]'::jsonb),
973
+ CASE WHEN count(milestone.id) = 0 THEN 0
974
+ ELSE round(100.0 * count(milestone.id) FILTER (WHERE milestone.status = 'achieved') / count(milestone.id))::integer
975
+ END
976
+ FROM public.projects project
977
+ LEFT JOIN public.profiles contact ON contact.id = project.client_contact_profile_id
978
+ LEFT JOIN public.project_milestones milestone
979
+ ON milestone.project_id = project.id AND milestone.visibility = 'client'
980
+ WHERE public.can_current_client_view_project(project.id)
981
+ GROUP BY project.id, contact.id
982
+ ORDER BY project.updated_at DESC;
983
+ $$;
984
+
985
+ REVOKE ALL ON FUNCTION public.list_current_client_projects()
986
+ FROM PUBLIC, anon, service_role;
987
+ GRANT EXECUTE ON FUNCTION public.list_current_client_projects()
988
+ TO authenticated;
989
+
990
+ CREATE OR REPLACE FUNCTION public.get_current_client_project(target_project_id uuid)
991
+ RETURNS TABLE (
992
+ project_id uuid,
993
+ name text,
994
+ reference text,
995
+ status text,
996
+ start_date date,
997
+ target_date date,
998
+ completed_at timestamptz,
999
+ archived_at timestamptz,
1000
+ client_summary text,
1001
+ client_scope text,
1002
+ client_contact jsonb,
1003
+ client_next_steps text,
1004
+ organizations jsonb,
1005
+ progress_percent integer,
1006
+ metas jsonb,
1007
+ documents jsonb
1008
+ )
1009
+ LANGUAGE sql
1010
+ STABLE
1011
+ SECURITY DEFINER
1012
+ SET search_path = public
1013
+ AS $$
1014
+ SELECT
1015
+ list_row.project_id,
1016
+ list_row.name,
1017
+ list_row.reference,
1018
+ list_row.status,
1019
+ list_row.start_date,
1020
+ list_row.target_date,
1021
+ list_row.completed_at,
1022
+ list_row.archived_at,
1023
+ list_row.client_summary,
1024
+ list_row.client_scope,
1025
+ list_row.client_contact,
1026
+ list_row.client_next_steps,
1027
+ list_row.organizations,
1028
+ list_row.progress_percent,
1029
+ COALESCE((
1030
+ SELECT jsonb_agg(jsonb_build_object(
1031
+ 'id', milestone.id,
1032
+ 'title', milestone.title,
1033
+ 'status', milestone.status,
1034
+ 'target_date', milestone.target_date,
1035
+ 'completed_at', milestone.completed_at,
1036
+ 'position', milestone.position
1037
+ ) ORDER BY milestone.position, milestone.target_date, milestone.id)
1038
+ FROM public.project_milestones milestone
1039
+ WHERE milestone.project_id = target_project_id
1040
+ AND milestone.visibility = 'client'
1041
+ ), '[]'::jsonb),
1042
+ COALESCE((
1043
+ SELECT jsonb_agg(jsonb_build_object(
1044
+ 'id', link.id,
1045
+ 'label', link.label,
1046
+ 'url', link.url,
1047
+ 'kind', link.kind,
1048
+ 'created_at', link.created_at
1049
+ ) ORDER BY link.created_at DESC, link.id)
1050
+ FROM public.project_links link
1051
+ WHERE link.project_id = target_project_id
1052
+ AND link.visibility = 'client'
1053
+ ), '[]'::jsonb)
1054
+ FROM public.list_current_client_projects() list_row
1055
+ WHERE list_row.project_id = target_project_id;
1056
+ $$;
1057
+
1058
+ REVOKE ALL ON FUNCTION public.get_current_client_project(uuid)
1059
+ FROM PUBLIC, anon, service_role;
1060
+ GRANT EXECUTE ON FUNCTION public.get_current_client_project(uuid)
1061
+ TO authenticated;