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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/bw.mjs +1 -1
- package/src/constants.mjs +14 -9
- package/src/generator.mjs +3 -6
- package/src/migrations.mjs +43 -5
- package/src/upgrade.mjs +54 -2
- package/template/base/app/(shell)/dashboard/dashboard-page-mount.tsx +7 -0
- package/template/base/app/(shell)/dashboard/page.tsx +1 -1
- package/template/base/app/(shell)/shell-layout-client.tsx +28 -7
- package/template/modules/crm/app/(shell)/crm/organizations/[id]/page.tsx +1 -1
- package/template/modules/crm/app/(shell)/crm/organizations/page.tsx +1 -3
- package/template/modules/crm/app/(shell)/crm/page.tsx +1 -5
- package/template/modules/crm/app/(shell)/crm/report/page.tsx +1 -5
- package/template/modules/projects/app/(shell)/account/page.tsx +5 -1
- package/template/modules/projects/app/(shell)/account/perfil/page.tsx +1 -0
- package/template/modules/projects/app/(shell)/projetos/projetos-server-mounts.tsx +3 -2
- package/template/modules/projects/app/api/account/projects/[id]/route.ts +2 -0
- package/template/modules/projects/app/api/account/projects/route.ts +2 -0
- package/template/modules/projects/app/api/projects/[id]/client-access/route.ts +2 -0
- package/template/modules/projects/app/api/projects/[id]/organizations/route.ts +2 -0
- package/template/modules/projects/app/api/projects/_handlers.ts +24 -0
- package/template/modules/projects/app/api/projects/setup-options/route.ts +2 -0
- package/template/supabase/modules/orgs/migrations/20260812120000_staff_only_organization_access_management.sql +7 -0
- package/template/supabase/modules/projects/migrations/20260810120000_project_client_access.sql +328 -0
- package/template/supabase/modules/projects/migrations/20260811120000_project_client_access_expand.sql +1061 -0
- package/template/supabase/modules/projects/migrations/20260811120500_project_member_sync_hardening.sql +156 -0
- package/template/supabase/modules/projects/migrations/20260811121000_project_client_access_enforcement.sql +651 -0
- package/template/supabase/modules/projects/migrations/20260811121500_project_client_organization_memberships.sql +29 -0
- package/template/supabase/modules/projects/migrations/20260811121700_project_client_meta_preview.sql +193 -0
- package/template/supabase/modules/projects/migrations/20260811122000_project_client_access_identity_cleanup.sql +201 -0
- 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
|
+
$$;
|