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,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;
|