create-bw-app 0.27.4 → 0.27.5
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/package.json +2 -1
- package/src/constants.mjs +8 -4
- package/src/generator.mjs +13 -9
- package/src/migration-order.mjs +8 -0
- package/src/migrations.mjs +47 -8
- package/src/update.mjs +152 -5
- package/src/upgrade.mjs +91 -14
- package/template/modules/projects/app/api/account/projects/[id]/tasks/[itemId]/route.ts +2 -0
- package/template/modules/projects/app/api/account/projects/[id]/tasks/route.ts +2 -0
- package/template/modules/projects/app/api/projects/[id]/client-tasks/[itemId]/route.ts +2 -0
- package/template/modules/projects/app/api/projects/[id]/client-tasks/route.ts +2 -0
- package/template/supabase/modules/admin/migrations/20261014183138_auth_verified_admin_acceptance.sql +232 -0
- package/template/supabase/modules/core/migrations/20261014183138_protect_profile_identity.sql +272 -0
- package/template/supabase/modules/orgs/migrations/20261014183138_auth_verified_organization_membership.sql +194 -0
- package/template/supabase/modules/projects/migrations/20261015135430_enforce_task_milestone_project.sql +52 -0
- package/template/supabase/modules/projects/migrations/20261015143936_client_task_collaboration.sql +763 -0
package/template/supabase/modules/projects/migrations/20261015143936_client_task_collaboration.sql
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
-- Deliberately authored, project-bound client work. Existing tasks stay private.
|
|
2
|
+
-- One statement leaves existing permissions intact if any prerequisite/DDL fails.
|
|
3
|
+
DO $client_task_migration$
|
|
4
|
+
BEGIN
|
|
5
|
+
IF to_regprocedure('public.assert_profile_auth_identity_v1()') IS NULL THEN
|
|
6
|
+
RAISE EXCEPTION 'PROJECTS_MIGRATION_REQUIRED: apply the verified Core identity repair first.' USING ERRCODE='55000';
|
|
7
|
+
END IF;
|
|
8
|
+
PERFORM public.assert_profile_auth_identity_v1();
|
|
9
|
+
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_constraint WHERE conrelid='public.project_tasks'::regclass
|
|
10
|
+
AND conname='project_tasks_milestone_id_fkey' AND cardinality(conkey)=2 AND convalidated) THEN
|
|
11
|
+
RAISE EXCEPTION 'PROJECTS_MIGRATION_REQUIRED: apply the task/milestone project relationship repair first.' USING ERRCODE='55000';
|
|
12
|
+
END IF;
|
|
13
|
+
IF current_user IN ('anon','authenticated','service_role','authenticator') OR NOT pg_catalog.pg_has_role(current_user,
|
|
14
|
+
(SELECT relowner FROM pg_catalog.pg_class WHERE oid='public.project_tasks'::regclass),'USAGE') THEN
|
|
15
|
+
RAISE EXCEPTION 'Client task migration requires the trusted application schema owner.' USING ERRCODE='42501';
|
|
16
|
+
END IF;
|
|
17
|
+
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname='bw_projects_task_executor') THEN
|
|
18
|
+
CREATE ROLE bw_projects_task_executor NOLOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
|
|
19
|
+
END IF;
|
|
20
|
+
IF EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname='bw_projects_task_executor'
|
|
21
|
+
AND (rolcanlogin OR rolsuper OR rolcreatedb OR rolcreaterole OR rolbypassrls)) THEN
|
|
22
|
+
RAISE EXCEPTION 'Unsafe existing Projects executor role.' USING ERRCODE='55000';
|
|
23
|
+
END IF;
|
|
24
|
+
-- No API role may reach the executor, including indirect and SET-only grants.
|
|
25
|
+
IF EXISTS (SELECT 1 FROM pg_catalog.pg_roles api_role
|
|
26
|
+
WHERE api_role.rolname IN ('anon','authenticated','service_role','authenticator')
|
|
27
|
+
AND pg_catalog.pg_has_role(api_role.oid,'bw_projects_task_executor'::regrole,'MEMBER')) THEN
|
|
28
|
+
RAISE EXCEPTION 'Projects executor must not be granted to API roles.' USING ERRCODE='55000';
|
|
29
|
+
END IF;
|
|
30
|
+
-- PostgreSQL 17 gives CREATEROLE creators ADMIN membership without SET or
|
|
31
|
+
-- INHERIT by default. Only the trusted schema migrator may assume this role
|
|
32
|
+
-- to transfer function ownership; no API/authenticator role receives it.
|
|
33
|
+
EXECUTE format('GRANT bw_projects_task_executor TO %I WITH INHERIT TRUE, SET TRUE',current_user);
|
|
34
|
+
EXECUTE $client_task_ddl$
|
|
35
|
+
CREATE SCHEMA IF NOT EXISTS bw_projects_private;
|
|
36
|
+
REVOKE ALL ON SCHEMA bw_projects_private FROM PUBLIC, anon, authenticated, service_role;
|
|
37
|
+
GRANT USAGE ON SCHEMA public, auth, bw_projects_private TO bw_projects_task_executor;
|
|
38
|
+
|
|
39
|
+
ALTER TABLE public.project_tasks
|
|
40
|
+
ADD COLUMN task_kind text NOT NULL DEFAULT 'internal',
|
|
41
|
+
ADD COLUMN client_title text,
|
|
42
|
+
ADD COLUMN client_instructions text,
|
|
43
|
+
ADD COLUMN client_organization_id uuid REFERENCES public.organizations(id) ON DELETE RESTRICT,
|
|
44
|
+
ADD COLUMN client_audience_mode text NOT NULL DEFAULT 'all_eligible',
|
|
45
|
+
ADD COLUMN client_publication text NOT NULL DEFAULT 'draft',
|
|
46
|
+
ADD COLUMN client_workflow text NOT NULL DEFAULT 'open',
|
|
47
|
+
ADD COLUMN client_revision integer NOT NULL DEFAULT 0,
|
|
48
|
+
ADD COLUMN client_generation integer NOT NULL DEFAULT 0,
|
|
49
|
+
ADD COLUMN client_submission_sequence integer NOT NULL DEFAULT 0,
|
|
50
|
+
ADD COLUMN client_review_feedback text,
|
|
51
|
+
ADD COLUMN client_reviewed_sequence integer,
|
|
52
|
+
ADD CONSTRAINT project_tasks_kind_check CHECK (task_kind IN ('internal','client')),
|
|
53
|
+
ADD CONSTRAINT project_tasks_client_audience_check CHECK (client_audience_mode IN ('all_eligible','selected')),
|
|
54
|
+
ADD CONSTRAINT project_tasks_client_publication_check CHECK (client_publication IN ('draft','published','withdrawn')),
|
|
55
|
+
ADD CONSTRAINT project_tasks_client_workflow_check CHECK (client_workflow IN ('open','in_progress','submitted','returned','accepted')),
|
|
56
|
+
ADD CONSTRAINT project_tasks_client_revision_check CHECK (client_revision>=0 AND client_generation>=0 AND client_submission_sequence>=0),
|
|
57
|
+
ADD CONSTRAINT project_tasks_client_content_check CHECK (task_kind='internal' OR
|
|
58
|
+
(client_organization_id IS NOT NULL AND length(btrim(client_title)) BETWEEN 1 AND 240 AND length(coalesce(client_instructions,''))<=20000)),
|
|
59
|
+
ADD CONSTRAINT project_tasks_client_identity_key UNIQUE(id,project_id,client_organization_id);
|
|
60
|
+
CREATE INDEX idx_project_tasks_client_published ON public.project_tasks(project_id,client_organization_id,id)
|
|
61
|
+
WHERE task_kind='client' AND client_publication='published';
|
|
62
|
+
|
|
63
|
+
CREATE TABLE bw_projects_private.task_bindings (
|
|
64
|
+
task_id uuid NOT NULL,
|
|
65
|
+
project_id uuid NOT NULL,
|
|
66
|
+
organization_id uuid NOT NULL,
|
|
67
|
+
profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
|
68
|
+
membership_id uuid NOT NULL REFERENCES public.organization_members(id) ON DELETE CASCADE,
|
|
69
|
+
role_assignment_id uuid NOT NULL REFERENCES public.user_role_assignments(id) ON DELETE CASCADE,
|
|
70
|
+
generation integer NOT NULL,
|
|
71
|
+
is_audience boolean NOT NULL,
|
|
72
|
+
is_responsible boolean NOT NULL DEFAULT false,
|
|
73
|
+
PRIMARY KEY(task_id,profile_id),
|
|
74
|
+
FOREIGN KEY(task_id,project_id,organization_id) REFERENCES public.project_tasks(id,project_id,client_organization_id) ON DELETE CASCADE
|
|
75
|
+
);
|
|
76
|
+
CREATE UNIQUE INDEX task_bindings_one_responsible ON bw_projects_private.task_bindings(task_id) WHERE is_responsible;
|
|
77
|
+
CREATE INDEX task_bindings_profile ON bw_projects_private.task_bindings(profile_id,project_id,organization_id);
|
|
78
|
+
CREATE INDEX task_bindings_membership ON bw_projects_private.task_bindings(membership_id);
|
|
79
|
+
CREATE INDEX task_bindings_role ON bw_projects_private.task_bindings(role_assignment_id);
|
|
80
|
+
CREATE INDEX task_bindings_project_org ON bw_projects_private.task_bindings(project_id,organization_id,profile_id);
|
|
81
|
+
CREATE TABLE bw_projects_private.submissions (
|
|
82
|
+
task_id uuid NOT NULL REFERENCES public.project_tasks(id) ON DELETE CASCADE,
|
|
83
|
+
sequence integer NOT NULL,
|
|
84
|
+
task_revision integer NOT NULL,
|
|
85
|
+
publication_generation integer NOT NULL,
|
|
86
|
+
response text NOT NULL CHECK(length(btrim(response)) BETWEEN 1 AND 20000),
|
|
87
|
+
submitted_at timestamptz NOT NULL DEFAULT now(),
|
|
88
|
+
submitted_by_profile_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL,
|
|
89
|
+
review_feedback text,
|
|
90
|
+
PRIMARY KEY(task_id,sequence)
|
|
91
|
+
);
|
|
92
|
+
CREATE INDEX submissions_actor ON bw_projects_private.submissions(submitted_by_profile_id);
|
|
93
|
+
CREATE TABLE bw_projects_private.requests (
|
|
94
|
+
actor_profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
|
95
|
+
request_id uuid NOT NULL,
|
|
96
|
+
project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE,
|
|
97
|
+
task_id uuid NOT NULL REFERENCES public.project_tasks(id) ON DELETE CASCADE,
|
|
98
|
+
fingerprint jsonb NOT NULL,
|
|
99
|
+
result_revision integer NOT NULL,
|
|
100
|
+
PRIMARY KEY(actor_profile_id,request_id)
|
|
101
|
+
);
|
|
102
|
+
CREATE INDEX requests_task ON bw_projects_private.requests(task_id);
|
|
103
|
+
CREATE INDEX requests_project ON bw_projects_private.requests(project_id);
|
|
104
|
+
ALTER TABLE bw_projects_private.task_bindings ENABLE ROW LEVEL SECURITY;
|
|
105
|
+
ALTER TABLE bw_projects_private.submissions ENABLE ROW LEVEL SECURITY;
|
|
106
|
+
ALTER TABLE bw_projects_private.requests ENABLE ROW LEVEL SECURITY;
|
|
107
|
+
REVOKE ALL ON ALL TABLES IN SCHEMA bw_projects_private FROM PUBLIC,anon,authenticated,service_role;
|
|
108
|
+
GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA bw_projects_private TO bw_projects_task_executor;
|
|
109
|
+
CREATE POLICY executor_bindings ON bw_projects_private.task_bindings TO bw_projects_task_executor USING(true) WITH CHECK(true);
|
|
110
|
+
CREATE POLICY executor_submissions ON bw_projects_private.submissions TO bw_projects_task_executor USING(true) WITH CHECK(true);
|
|
111
|
+
CREATE POLICY executor_requests ON bw_projects_private.requests TO bw_projects_task_executor USING(true) WITH CHECK(true);
|
|
112
|
+
GRANT SELECT,INSERT,UPDATE,DELETE ON public.project_tasks TO bw_projects_task_executor;
|
|
113
|
+
CREATE POLICY task_executor ON public.project_tasks TO bw_projects_task_executor USING(true) WITH CHECK(true);
|
|
114
|
+
|
|
115
|
+
CREATE FUNCTION public.can_current_user_create_project_tasks(p_project_id uuid)
|
|
116
|
+
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path='' AS $$
|
|
117
|
+
SELECT EXISTS(SELECT 1 FROM public.profiles profile JOIN public.user_role_assignments role_assignment ON role_assignment.profile_id=profile.id
|
|
118
|
+
WHERE profile.user_id=auth.uid() AND (role_assignment.role_code='admin' OR
|
|
119
|
+
(role_assignment.role_code='staff' AND EXISTS(SELECT 1 FROM public.project_members member
|
|
120
|
+
WHERE member.project_id=p_project_id AND member.profile_id=profile.id AND member.role IN ('owner','contributor')))))
|
|
121
|
+
AND EXISTS(SELECT 1 FROM public.projects WHERE id=p_project_id)
|
|
122
|
+
$$;
|
|
123
|
+
REVOKE ALL ON FUNCTION public.can_current_user_create_project_tasks(uuid) FROM PUBLIC,anon,service_role;
|
|
124
|
+
GRANT EXECUTE ON FUNCTION public.can_current_user_create_project_tasks(uuid) TO authenticated,bw_projects_task_executor;
|
|
125
|
+
DROP POLICY "Project managers insert project tasks" ON public.project_tasks;
|
|
126
|
+
CREATE POLICY "Task-capable staff insert internal tasks" ON public.project_tasks FOR INSERT TO authenticated
|
|
127
|
+
WITH CHECK(task_kind='internal' AND public.can_current_user_create_project_tasks(project_id));
|
|
128
|
+
|
|
129
|
+
-- Project eligibility is evaluated for the task's organization, never via a
|
|
130
|
+
-- different organization through which the client can also see this project.
|
|
131
|
+
CREATE FUNCTION bw_projects_private.client_eligible(p_project uuid,p_org uuid,p_profile uuid)
|
|
132
|
+
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path='' AS $$
|
|
133
|
+
SELECT EXISTS(SELECT 1 FROM public.profiles profile
|
|
134
|
+
JOIN public.user_role_assignments role_assignment ON role_assignment.profile_id=profile.id AND role_assignment.role_code='client'
|
|
135
|
+
JOIN public.organization_members membership ON membership.profile_id=profile.id AND membership.organization_id=p_org
|
|
136
|
+
JOIN public.project_organizations participant ON participant.project_id=p_project AND participant.organization_id=p_org
|
|
137
|
+
JOIN public.project_client_organizations audience ON audience.project_id=p_project AND audience.organization_id=p_org
|
|
138
|
+
JOIN public.projects project ON project.id=p_project
|
|
139
|
+
WHERE profile.id=p_profile AND profile.user_id IS NOT NULL AND
|
|
140
|
+
(project.client_access_mode='all_org_clients' OR (project.client_access_mode='selected_clients' AND EXISTS(
|
|
141
|
+
SELECT 1 FROM public.project_client_profiles grant_row WHERE grant_row.project_id=p_project AND grant_row.organization_id=p_org AND grant_row.profile_id=p_profile))))
|
|
142
|
+
$$;
|
|
143
|
+
|
|
144
|
+
CREATE FUNCTION bw_projects_private.lock_actor(p_project uuid,p_staff boolean,p_org uuid DEFAULT NULL,p_write boolean DEFAULT true)
|
|
145
|
+
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path='' AS $$
|
|
146
|
+
DECLARE actor uuid; actor_role text; member_role text;
|
|
147
|
+
BEGIN
|
|
148
|
+
SELECT id INTO actor FROM public.profiles WHERE user_id=auth.uid() FOR SHARE;
|
|
149
|
+
IF actor IS NULL THEN RAISE EXCEPTION 'Task access denied.' USING ERRCODE='42501'; END IF;
|
|
150
|
+
SELECT role_code INTO actor_role FROM public.user_role_assignments WHERE profile_id=actor FOR SHARE;
|
|
151
|
+
IF (p_staff AND actor_role NOT IN ('staff','admin')) OR (NOT p_staff AND actor_role IS DISTINCT FROM 'client') OR actor_role IS NULL THEN
|
|
152
|
+
RAISE EXCEPTION 'Task access denied.' USING ERRCODE='42501'; END IF;
|
|
153
|
+
IF NOT p_staff THEN
|
|
154
|
+
PERFORM id FROM public.organization_members WHERE profile_id=actor AND organization_id=p_org FOR SHARE;
|
|
155
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Task access denied.' USING ERRCODE='42501'; END IF;
|
|
156
|
+
END IF;
|
|
157
|
+
-- Match the established project/member RPC lock order. Direct member changes
|
|
158
|
+
-- conflict with the explicit membership SHARE lock as well.
|
|
159
|
+
PERFORM id FROM public.projects WHERE id=p_project FOR UPDATE;
|
|
160
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Task access denied.' USING ERRCODE='42501'; END IF;
|
|
161
|
+
IF p_staff AND actor_role<>'admin' THEN
|
|
162
|
+
SELECT role INTO member_role FROM public.project_members WHERE project_id=p_project AND profile_id=actor FOR SHARE;
|
|
163
|
+
IF member_role IS NULL OR (p_write AND member_role NOT IN ('owner','contributor')) THEN
|
|
164
|
+
RAISE EXCEPTION 'Task access denied.' USING ERRCODE='42501'; END IF;
|
|
165
|
+
END IF;
|
|
166
|
+
RETURN actor;
|
|
167
|
+
END;
|
|
168
|
+
$$;
|
|
169
|
+
|
|
170
|
+
CREATE FUNCTION bw_projects_private.lock_client(p_project uuid,p_org uuid,p_profile uuid)
|
|
171
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path='' AS $$
|
|
172
|
+
BEGIN
|
|
173
|
+
PERFORM id FROM public.profiles WHERE id=p_profile AND user_id IS NOT NULL FOR SHARE;
|
|
174
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Client is not currently eligible.' USING ERRCODE='22023'; END IF;
|
|
175
|
+
PERFORM id FROM public.user_role_assignments WHERE profile_id=p_profile AND role_code='client' FOR SHARE;
|
|
176
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Client is not currently eligible.' USING ERRCODE='22023'; END IF;
|
|
177
|
+
PERFORM id FROM public.organization_members WHERE profile_id=p_profile AND organization_id=p_org FOR SHARE;
|
|
178
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Client is not currently eligible.' USING ERRCODE='22023'; END IF;
|
|
179
|
+
PERFORM 1 FROM public.project_organizations WHERE project_id=p_project AND organization_id=p_org FOR SHARE;
|
|
180
|
+
PERFORM 1 FROM public.project_client_organizations WHERE project_id=p_project AND organization_id=p_org FOR SHARE;
|
|
181
|
+
PERFORM 1 FROM public.project_client_profiles WHERE project_id=p_project AND organization_id=p_org AND profile_id=p_profile FOR SHARE;
|
|
182
|
+
IF NOT bw_projects_private.client_eligible(p_project,p_org,p_profile) THEN
|
|
183
|
+
RAISE EXCEPTION 'Client is not currently eligible.' USING ERRCODE='22023'; END IF;
|
|
184
|
+
END;
|
|
185
|
+
$$;
|
|
186
|
+
|
|
187
|
+
CREATE FUNCTION bw_projects_private.task_visible(p_task uuid,p_profile uuid)
|
|
188
|
+
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path='' AS $$
|
|
189
|
+
SELECT EXISTS(SELECT 1 FROM public.project_tasks task WHERE task.id=p_task AND task.task_kind='client' AND task.client_publication='published'
|
|
190
|
+
AND bw_projects_private.client_eligible(task.project_id,task.client_organization_id,p_profile)
|
|
191
|
+
AND (task.milestone_id IS NULL OR EXISTS(SELECT 1 FROM public.project_milestones milestone WHERE milestone.id=task.milestone_id
|
|
192
|
+
AND milestone.project_id=task.project_id AND milestone.visibility='client'))
|
|
193
|
+
AND (task.client_audience_mode='all_eligible' OR EXISTS(SELECT 1 FROM bw_projects_private.task_bindings binding
|
|
194
|
+
WHERE binding.task_id=task.id AND binding.profile_id=p_profile AND binding.is_audience AND binding.generation=task.client_generation)))
|
|
195
|
+
$$;
|
|
196
|
+
|
|
197
|
+
CREATE FUNCTION bw_projects_private.task_json(p_task uuid,p_staff boolean DEFAULT false)
|
|
198
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path='' AS $$
|
|
199
|
+
DECLARE task public.project_tasks%ROWTYPE; value jsonb;
|
|
200
|
+
BEGIN
|
|
201
|
+
SELECT * INTO task FROM public.project_tasks WHERE id=p_task AND task_kind='client';
|
|
202
|
+
IF NOT FOUND THEN RETURN NULL; END IF;
|
|
203
|
+
SELECT jsonb_build_object('id',task.id,'project_id',task.project_id,'client_title',task.client_title,'client_instructions',coalesce(task.client_instructions,''),
|
|
204
|
+
'publication_generation',task.client_generation,'workflow',task.client_workflow,'revision',task.client_revision,'due_date',task.due_date,
|
|
205
|
+
'milestone',(SELECT jsonb_build_object('id',m.id,'title',m.title) FROM public.project_milestones m
|
|
206
|
+
WHERE m.id=task.milestone_id AND m.project_id=task.project_id AND m.visibility='client'),
|
|
207
|
+
'responsible_label',(SELECT coalesce(nullif(btrim(concat_ws(' ',p.first_name,p.last_name)),''),p.email) FROM bw_projects_private.task_bindings b
|
|
208
|
+
JOIN public.profiles p ON p.id=b.profile_id WHERE b.task_id=task.id AND b.is_responsible
|
|
209
|
+
AND bw_projects_private.client_eligible(task.project_id,task.client_organization_id,b.profile_id)),
|
|
210
|
+
'response',(SELECT s.response FROM bw_projects_private.submissions s WHERE s.task_id=task.id AND s.sequence=task.client_submission_sequence AND s.publication_generation=task.client_generation),
|
|
211
|
+
'review_feedback',task.client_review_feedback,
|
|
212
|
+
'submission_sequence',(SELECT s.sequence FROM bw_projects_private.submissions s WHERE s.task_id=task.id AND s.sequence=task.client_submission_sequence AND s.publication_generation=task.client_generation)) INTO value;
|
|
213
|
+
IF p_staff THEN
|
|
214
|
+
value:=value||jsonb_build_object('publication',task.client_publication,'organization_id',task.client_organization_id,'audience_mode',task.client_audience_mode,
|
|
215
|
+
'selected_profile_ids',coalesce((SELECT jsonb_agg(b.profile_id ORDER BY b.profile_id) FROM bw_projects_private.task_bindings b WHERE b.task_id=task.id AND b.is_audience),'[]'::jsonb),
|
|
216
|
+
'responsible_profile_id',(SELECT b.profile_id FROM bw_projects_private.task_bindings b WHERE b.task_id=task.id AND b.is_responsible),
|
|
217
|
+
'submissions',coalesce((SELECT jsonb_agg(to_jsonb(s)-'task_id' ORDER BY s.sequence DESC) FROM bw_projects_private.submissions s WHERE s.task_id=task.id),'[]'::jsonb));
|
|
218
|
+
END IF;
|
|
219
|
+
RETURN value;
|
|
220
|
+
END;
|
|
221
|
+
$$;
|
|
222
|
+
|
|
223
|
+
CREATE FUNCTION public.list_current_client_project_tasks(p_project_id uuid)
|
|
224
|
+
RETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path='' AS $$
|
|
225
|
+
SELECT coalesce(jsonb_agg(bw_projects_private.task_json(task.id,false) ORDER BY task.created_at,task.id),'[]'::jsonb)
|
|
226
|
+
FROM public.project_tasks task WHERE task.project_id=p_project_id AND bw_projects_private.task_visible(task.id,public.current_profile_id())
|
|
227
|
+
$$;
|
|
228
|
+
REVOKE ALL ON FUNCTION public.list_current_client_project_tasks(uuid) FROM PUBLIC,anon,service_role;
|
|
229
|
+
GRANT EXECUTE ON FUNCTION public.list_current_client_project_tasks(uuid) TO authenticated;
|
|
230
|
+
|
|
231
|
+
CREATE FUNCTION public.get_project_client_task_workbench(p_project_id uuid)
|
|
232
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path='' AS $$
|
|
233
|
+
DECLARE actor uuid:=public.current_profile_id(); actor_role text;
|
|
234
|
+
BEGIN
|
|
235
|
+
SELECT role_code INTO actor_role FROM public.user_role_assignments WHERE profile_id=actor;
|
|
236
|
+
IF actor_role IS NULL OR actor_role NOT IN ('staff','admin') OR NOT EXISTS(SELECT 1 FROM public.projects WHERE id=p_project_id) OR
|
|
237
|
+
(actor_role<>'admin' AND NOT EXISTS(SELECT 1 FROM public.project_members WHERE project_id=p_project_id AND profile_id=actor)) THEN
|
|
238
|
+
RAISE EXCEPTION 'Task access denied.' USING ERRCODE='42501'; END IF;
|
|
239
|
+
RETURN jsonb_build_object('tasks',coalesce((SELECT jsonb_agg(bw_projects_private.task_json(task.id,true) ORDER BY task.created_at,task.id)
|
|
240
|
+
FROM public.project_tasks task WHERE task.project_id=p_project_id AND task.task_kind='client'),'[]'::jsonb),
|
|
241
|
+
'organizations',coalesce((SELECT jsonb_agg(jsonb_build_object('id',org.id,'name',org.name,'eligible_clients',coalesce((
|
|
242
|
+
SELECT jsonb_agg(jsonb_build_object('profile_id',profile.id,'label',coalesce(nullif(btrim(concat_ws(' ',profile.first_name,profile.last_name)),''),profile.email)) ORDER BY profile.id)
|
|
243
|
+
FROM public.organization_members member JOIN public.profiles profile ON profile.id=member.profile_id
|
|
244
|
+
WHERE member.organization_id=org.id AND bw_projects_private.client_eligible(p_project_id,org.id,profile.id)),'[]'::jsonb)) ORDER BY org.name,org.id)
|
|
245
|
+
FROM public.project_organizations participant JOIN public.organizations org ON org.id=participant.organization_id WHERE participant.project_id=p_project_id),'[]'::jsonb),
|
|
246
|
+
'capabilities',jsonb_build_object('can_create_tasks',public.can_current_user_create_project_tasks(p_project_id),
|
|
247
|
+
'can_share_client_tasks',public.can_current_user_create_project_tasks(p_project_id),'can_review_client_tasks',public.can_current_user_create_project_tasks(p_project_id)));
|
|
248
|
+
END;
|
|
249
|
+
$$;
|
|
250
|
+
REVOKE ALL ON FUNCTION public.get_project_client_task_workbench(uuid) FROM PUBLIC,anon,service_role;
|
|
251
|
+
GRANT EXECUTE ON FUNCTION public.get_project_client_task_workbench(uuid) TO authenticated;
|
|
252
|
+
|
|
253
|
+
CREATE FUNCTION bw_projects_private.lock_task_milestone(p_project uuid,p_task uuid)
|
|
254
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path='' AS $$
|
|
255
|
+
BEGIN
|
|
256
|
+
PERFORM milestone.id FROM public.project_milestones milestone JOIN public.project_tasks task ON task.milestone_id=milestone.id
|
|
257
|
+
WHERE task.id=p_task AND task.project_id=p_project FOR SHARE OF milestone;
|
|
258
|
+
END;
|
|
259
|
+
$$;
|
|
260
|
+
|
|
261
|
+
CREATE FUNCTION bw_projects_private.validate_configuration(p_project uuid,p_input jsonb,p_publish boolean DEFAULT false)
|
|
262
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path='' AS $$
|
|
263
|
+
DECLARE org uuid; milestone uuid; responsible uuid; selected uuid[]; person uuid;
|
|
264
|
+
BEGIN
|
|
265
|
+
IF jsonb_typeof(p_input) IS DISTINCT FROM 'object' OR NOT p_input ?& ARRAY['clientTitle','clientInstructions','organizationId','audienceMode','selectedProfileIds','responsibleProfileId','milestoneId','dueDate']
|
|
266
|
+
OR EXISTS(SELECT 1 FROM jsonb_object_keys(p_input) k WHERE k NOT IN ('clientTitle','clientInstructions','organizationId','audienceMode','selectedProfileIds','responsibleProfileId','milestoneId','dueDate'))
|
|
267
|
+
OR jsonb_typeof(p_input->'clientTitle') IS DISTINCT FROM 'string' OR length(btrim(p_input->>'clientTitle')) NOT BETWEEN 1 AND 240
|
|
268
|
+
OR jsonb_typeof(p_input->'clientInstructions') IS DISTINCT FROM 'string' OR length(p_input->>'clientInstructions')>20000
|
|
269
|
+
OR jsonb_typeof(p_input->'organizationId') IS DISTINCT FROM 'string'
|
|
270
|
+
OR jsonb_typeof(p_input->'audienceMode') IS DISTINCT FROM 'string' OR p_input->>'audienceMode' NOT IN ('all_eligible','selected')
|
|
271
|
+
OR jsonb_typeof(p_input->'selectedProfileIds') IS DISTINCT FROM 'array' THEN
|
|
272
|
+
RAISE EXCEPTION 'Invalid client task configuration.' USING ERRCODE='22023'; END IF;
|
|
273
|
+
IF jsonb_array_length(p_input->'selectedProfileIds')>500 OR EXISTS(SELECT 1 FROM jsonb_array_elements(p_input->'selectedProfileIds') e WHERE jsonb_typeof(e)<>'string') THEN
|
|
274
|
+
RAISE EXCEPTION 'Invalid selected client audience.' USING ERRCODE='22023'; END IF;
|
|
275
|
+
org:=(p_input->>'organizationId')::uuid;
|
|
276
|
+
milestone:=(p_input->>'milestoneId')::uuid;
|
|
277
|
+
responsible:=(p_input->>'responsibleProfileId')::uuid;
|
|
278
|
+
selected:=ARRAY(SELECT value::uuid FROM jsonb_array_elements_text(p_input->'selectedProfileIds'));
|
|
279
|
+
IF cardinality(selected)<>(SELECT count(DISTINCT x) FROM unnest(selected) x) OR
|
|
280
|
+
(p_input->>'audienceMode'='all_eligible' AND cardinality(selected)>0) OR
|
|
281
|
+
(p_input->>'audienceMode'='selected' AND responsible IS NOT NULL AND NOT responsible=ANY(selected)) THEN
|
|
282
|
+
RAISE EXCEPTION 'Responsibility must remain within the selected audience.' USING ERRCODE='22023'; END IF;
|
|
283
|
+
IF org IS NULL OR NOT EXISTS(SELECT 1 FROM public.project_organizations WHERE project_id=p_project AND organization_id=org) THEN
|
|
284
|
+
RAISE EXCEPTION 'Task organization must participate in the project.' USING ERRCODE='22023'; END IF;
|
|
285
|
+
IF (p_input->>'dueDate') IS NOT NULL THEN
|
|
286
|
+
IF (p_input->>'dueDate') !~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' OR (p_input->>'dueDate')::date::text<>p_input->>'dueDate' THEN
|
|
287
|
+
RAISE EXCEPTION 'Invalid task due date.' USING ERRCODE='22023'; END IF;
|
|
288
|
+
END IF;
|
|
289
|
+
IF milestone IS NOT NULL THEN
|
|
290
|
+
PERFORM id FROM public.project_milestones WHERE id=milestone AND project_id=p_project AND (NOT p_publish OR visibility='client') FOR SHARE;
|
|
291
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Publishing requires a client-visible milestone in this project.' USING ERRCODE='22023'; END IF;
|
|
292
|
+
END IF;
|
|
293
|
+
FOR person IN SELECT DISTINCT x FROM unnest(selected||CASE WHEN responsible IS NULL THEN ARRAY[]::uuid[] ELSE ARRAY[responsible] END) x ORDER BY x LOOP
|
|
294
|
+
PERFORM bw_projects_private.lock_client(p_project,org,person);
|
|
295
|
+
END LOOP;
|
|
296
|
+
IF p_publish THEN
|
|
297
|
+
IF length(btrim(p_input->>'clientInstructions'))=0 OR (p_input->>'audienceMode'='selected' AND cardinality(selected)=0)
|
|
298
|
+
OR NOT EXISTS(SELECT 1 FROM public.projects project JOIN public.project_client_organizations audience ON audience.project_id=project.id
|
|
299
|
+
WHERE project.id=p_project AND project.client_access_mode<>'hidden' AND audience.organization_id=org) THEN
|
|
300
|
+
RAISE EXCEPTION 'Publishing requires authored instructions and a currently eligible project audience.' USING ERRCODE='22023'; END IF;
|
|
301
|
+
END IF;
|
|
302
|
+
END;
|
|
303
|
+
$$;
|
|
304
|
+
|
|
305
|
+
CREATE FUNCTION bw_projects_private.write_bindings(p_task uuid,p_project uuid,p_org uuid,p_generation integer,p_selected uuid[],p_responsible uuid)
|
|
306
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path='' AS $$
|
|
307
|
+
BEGIN
|
|
308
|
+
DELETE FROM bw_projects_private.task_bindings WHERE task_id=p_task;
|
|
309
|
+
INSERT INTO bw_projects_private.task_bindings(task_id,project_id,organization_id,profile_id,membership_id,role_assignment_id,generation,is_audience,is_responsible)
|
|
310
|
+
SELECT p_task,p_project,p_org,person.profile_id,membership.id,role_assignment.id,p_generation,person.profile_id=ANY(p_selected),coalesce(person.profile_id=p_responsible,false)
|
|
311
|
+
FROM (SELECT DISTINCT x AS profile_id FROM unnest(p_selected||CASE WHEN p_responsible IS NULL THEN ARRAY[]::uuid[] ELSE ARRAY[p_responsible] END) x) person
|
|
312
|
+
JOIN public.organization_members membership ON membership.organization_id=p_org AND membership.profile_id=person.profile_id
|
|
313
|
+
JOIN public.user_role_assignments role_assignment ON role_assignment.profile_id=person.profile_id AND role_assignment.role_code='client';
|
|
314
|
+
END;
|
|
315
|
+
$$;
|
|
316
|
+
|
|
317
|
+
-- This invoker guard observes the effective caller. A SECURITY DEFINER guard
|
|
318
|
+
-- would see its own owner and let ordinary internal APIs bypass the workflow.
|
|
319
|
+
CREATE FUNCTION bw_projects_private.guard_task_write()
|
|
320
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path='' AS $$
|
|
321
|
+
BEGIN
|
|
322
|
+
IF current_user='bw_projects_task_executor' THEN RETURN CASE WHEN TG_OP='DELETE' THEN OLD ELSE NEW END; END IF;
|
|
323
|
+
IF TG_OP='DELETE' THEN
|
|
324
|
+
IF OLD.task_kind='client' THEN RAISE EXCEPTION 'Client tasks require their dedicated workflow.' USING ERRCODE='42501'; END IF;
|
|
325
|
+
RETURN OLD;
|
|
326
|
+
END IF;
|
|
327
|
+
IF NEW.task_kind='client' OR (TG_OP='UPDATE' AND OLD.task_kind='client') OR NEW.client_title IS NOT NULL OR NEW.client_instructions IS NOT NULL
|
|
328
|
+
OR NEW.client_organization_id IS NOT NULL OR NEW.client_audience_mode<>'all_eligible' OR NEW.client_publication<>'draft'
|
|
329
|
+
OR NEW.client_workflow<>'open' OR NEW.client_revision<>0 OR NEW.client_generation<>0 OR NEW.client_submission_sequence<>0
|
|
330
|
+
OR NEW.client_review_feedback IS NOT NULL OR NEW.client_reviewed_sequence IS NOT NULL THEN
|
|
331
|
+
RAISE EXCEPTION 'Client tasks require their dedicated workflow.' USING ERRCODE='42501'; END IF;
|
|
332
|
+
RETURN NEW;
|
|
333
|
+
END;
|
|
334
|
+
$$;
|
|
335
|
+
CREATE TRIGGER aa_project_client_task_write BEFORE INSERT OR UPDATE OR DELETE ON public.project_tasks
|
|
336
|
+
FOR EACH ROW EXECUTE FUNCTION bw_projects_private.guard_task_write();
|
|
337
|
+
|
|
338
|
+
CREATE FUNCTION bw_projects_private.valid_task_assignee(p_project uuid,p_profile uuid)
|
|
339
|
+
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path='' AS $$
|
|
340
|
+
SELECT p_profile IS NULL OR EXISTS(SELECT 1 FROM public.project_members member JOIN public.user_role_assignments role_assignment
|
|
341
|
+
ON role_assignment.profile_id=member.profile_id AND role_assignment.role_code IN ('staff','admin')
|
|
342
|
+
WHERE member.project_id=p_project AND member.profile_id=p_profile AND member.role IN ('owner','contributor'))
|
|
343
|
+
$$;
|
|
344
|
+
CREATE OR REPLACE FUNCTION public.enforce_project_task_assignee_and_contributor_update()
|
|
345
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path='' AS $$
|
|
346
|
+
DECLARE v_actor_profile_id uuid:=public.current_profile_id();
|
|
347
|
+
BEGIN
|
|
348
|
+
IF NEW.task_kind='client' AND current_user='bw_projects_task_executor' THEN
|
|
349
|
+
IF NOT bw_projects_private.valid_task_assignee(NEW.project_id,NEW.assignee_profile_id) THEN
|
|
350
|
+
RAISE EXCEPTION 'Tasks can only be assigned to current project managers or collaborators.' USING ERRCODE='23514'; END IF;
|
|
351
|
+
RETURN NEW;
|
|
352
|
+
END IF;
|
|
353
|
+
IF NEW.assignee_profile_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM public.project_members member WHERE member.project_id=NEW.project_id
|
|
354
|
+
AND member.profile_id=NEW.assignee_profile_id AND member.role IN ('owner','contributor')) THEN
|
|
355
|
+
RAISE EXCEPTION 'Tasks can only be assigned to project managers or collaborators.' USING ERRCODE='23514'; END IF;
|
|
356
|
+
IF TG_OP='UPDATE' AND public.is_staff() AND NOT public.is_admin() AND NOT public.is_project_owner_member(OLD.project_id) THEN
|
|
357
|
+
IF OLD.assignee_profile_id IS DISTINCT FROM v_actor_profile_id OR NEW.project_id IS DISTINCT FROM OLD.project_id
|
|
358
|
+
OR NEW.milestone_id IS DISTINCT FROM OLD.milestone_id OR NEW.title IS DISTINCT FROM OLD.title OR NEW.description IS DISTINCT FROM OLD.description
|
|
359
|
+
OR NEW.priority IS DISTINCT FROM OLD.priority OR NEW.assignee_profile_id IS DISTINCT FROM OLD.assignee_profile_id
|
|
360
|
+
OR NEW.reporter_profile_id IS DISTINCT FROM OLD.reporter_profile_id OR NEW.start_date IS DISTINCT FROM OLD.start_date OR NEW.due_date IS DISTINCT FROM OLD.due_date THEN
|
|
361
|
+
RAISE EXCEPTION 'Collaborators can only update the state of their assigned tasks.' USING ERRCODE='42501'; END IF;
|
|
362
|
+
END IF;
|
|
363
|
+
RETURN NEW;
|
|
364
|
+
END;
|
|
365
|
+
$$;
|
|
366
|
+
|
|
367
|
+
CREATE FUNCTION public.manage_project_client_task(p_project_id uuid,p_task_id uuid,p_request_id uuid,p_expected_revision integer,p_action text,p_input jsonb)
|
|
368
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path='' AS $$
|
|
369
|
+
#variable_conflict use_column
|
|
370
|
+
DECLARE actor uuid; task public.project_tasks%ROWTYPE; fingerprint jsonb; prior bw_projects_private.requests%ROWTYPE;
|
|
371
|
+
config jsonb; task_id uuid:=p_task_id; generation integer; selected uuid[]; responsible uuid; review_sequence integer;
|
|
372
|
+
BEGIN
|
|
373
|
+
actor:=bw_projects_private.lock_actor(p_project_id,true);
|
|
374
|
+
IF p_request_id IS NULL OR p_action IS NULL OR jsonb_typeof(p_input) IS DISTINCT FROM 'object' THEN RAISE EXCEPTION 'Invalid task action.' USING ERRCODE='22023'; END IF;
|
|
375
|
+
fingerprint:=jsonb_build_object('projectId',p_project_id,'taskId',p_task_id,'expectedRevision',p_expected_revision,'action',p_action,'input',p_input);
|
|
376
|
+
SELECT * INTO prior FROM bw_projects_private.requests WHERE actor_profile_id=actor AND request_id=p_request_id;
|
|
377
|
+
IF FOUND THEN
|
|
378
|
+
IF prior.fingerprint IS DISTINCT FROM fingerprint THEN RAISE EXCEPTION 'Task request key already used.' USING ERRCODE='BW409'; END IF;
|
|
379
|
+
RETURN bw_projects_private.task_json(prior.task_id,true);
|
|
380
|
+
END IF;
|
|
381
|
+
IF p_action='create' THEN
|
|
382
|
+
IF p_task_id IS NOT NULL OR p_expected_revision IS NOT NULL THEN RAISE EXCEPTION 'Invalid create action.' USING ERRCODE='22023'; END IF;
|
|
383
|
+
PERFORM bw_projects_private.validate_configuration(p_project_id,p_input,false);
|
|
384
|
+
task_id:=gen_random_uuid();
|
|
385
|
+
INSERT INTO public.project_tasks(id,project_id,milestone_id,title,task_kind,reporter_profile_id,client_title,client_instructions,client_organization_id,client_audience_mode,due_date,client_revision)
|
|
386
|
+
VALUES(task_id,p_project_id,(p_input->>'milestoneId')::uuid,p_input->>'clientTitle','client',actor,p_input->>'clientTitle',p_input->>'clientInstructions',
|
|
387
|
+
(p_input->>'organizationId')::uuid,p_input->>'audienceMode',(p_input->>'dueDate')::date,1);
|
|
388
|
+
selected:=ARRAY(SELECT value::uuid FROM jsonb_array_elements_text(p_input->'selectedProfileIds'));
|
|
389
|
+
PERFORM bw_projects_private.write_bindings(task_id,p_project_id,(p_input->>'organizationId')::uuid,0,selected,(p_input->>'responsibleProfileId')::uuid);
|
|
390
|
+
ELSE
|
|
391
|
+
IF p_task_id IS NULL OR p_expected_revision IS NULL OR p_expected_revision<0 THEN RAISE EXCEPTION 'Expected task revision required.' USING ERRCODE='22023'; END IF;
|
|
392
|
+
IF p_action='configure' THEN PERFORM bw_projects_private.validate_configuration(p_project_id,p_input,false); END IF;
|
|
393
|
+
IF p_action='publish' THEN
|
|
394
|
+
SELECT * INTO task FROM public.project_tasks WHERE id=task_id AND project_id=p_project_id AND task_kind='client';
|
|
395
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Task access denied.' USING ERRCODE='42501'; END IF;
|
|
396
|
+
SELECT coalesce(array_agg(profile_id ORDER BY profile_id) FILTER(WHERE is_audience),ARRAY[]::uuid[]),
|
|
397
|
+
(array_agg(profile_id) FILTER(WHERE is_responsible))[1] INTO selected,responsible FROM bw_projects_private.task_bindings WHERE task_id=task.id;
|
|
398
|
+
config:=jsonb_build_object('clientTitle',task.client_title,'clientInstructions',coalesce(task.client_instructions,''),'organizationId',task.client_organization_id,
|
|
399
|
+
'audienceMode',task.client_audience_mode,'selectedProfileIds',to_jsonb(selected),'responsibleProfileId',responsible,'milestoneId',task.milestone_id,'dueDate',task.due_date);
|
|
400
|
+
PERFORM bw_projects_private.validate_configuration(p_project_id,config,true);
|
|
401
|
+
END IF;
|
|
402
|
+
-- Milestone locks precede task locks, matching hide/delete cleanup.
|
|
403
|
+
PERFORM bw_projects_private.lock_task_milestone(p_project_id,task_id);
|
|
404
|
+
SELECT * INTO task FROM public.project_tasks WHERE id=task_id AND project_id=p_project_id AND task_kind='client' FOR UPDATE;
|
|
405
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Task access denied.' USING ERRCODE='42501'; END IF;
|
|
406
|
+
IF task.client_revision<>p_expected_revision THEN RAISE EXCEPTION 'Task changed. Refresh before acting.' USING ERRCODE='40001'; END IF;
|
|
407
|
+
IF p_action='configure' THEN
|
|
408
|
+
generation:=task.client_generation+CASE WHEN task.client_publication='published' THEN 1 ELSE 0 END;
|
|
409
|
+
DELETE FROM bw_projects_private.task_bindings WHERE task_id=task.id;
|
|
410
|
+
UPDATE public.project_tasks SET client_title=p_input->>'clientTitle',client_instructions=p_input->>'clientInstructions',
|
|
411
|
+
client_organization_id=(p_input->>'organizationId')::uuid,client_audience_mode=p_input->>'audienceMode',milestone_id=(p_input->>'milestoneId')::uuid,
|
|
412
|
+
due_date=(p_input->>'dueDate')::date,client_publication=CASE WHEN task.client_publication='draft' THEN 'draft' ELSE 'withdrawn' END,
|
|
413
|
+
client_generation=generation,client_workflow='open',status='todo',client_review_feedback=NULL,client_reviewed_sequence=NULL,client_revision=client_revision+1
|
|
414
|
+
WHERE id=task.id;
|
|
415
|
+
selected:=ARRAY(SELECT value::uuid FROM jsonb_array_elements_text(p_input->'selectedProfileIds'));
|
|
416
|
+
PERFORM bw_projects_private.write_bindings(task.id,p_project_id,(p_input->>'organizationId')::uuid,generation,selected,(p_input->>'responsibleProfileId')::uuid);
|
|
417
|
+
ELSIF p_action='publish' THEN
|
|
418
|
+
IF p_input<>'{}'::jsonb OR task.client_publication='published' THEN RAISE EXCEPTION 'Invalid publication action.' USING ERRCODE='40001'; END IF;
|
|
419
|
+
generation:=task.client_generation+1;
|
|
420
|
+
UPDATE public.project_tasks SET client_publication='published',client_generation=generation,client_workflow='open',status='todo',
|
|
421
|
+
client_revision=client_revision+1,client_review_feedback=NULL,client_reviewed_sequence=NULL WHERE id=task.id;
|
|
422
|
+
PERFORM bw_projects_private.write_bindings(task.id,p_project_id,task.client_organization_id,generation,selected,responsible);
|
|
423
|
+
ELSIF p_action='withdraw' THEN
|
|
424
|
+
IF p_input<>'{}'::jsonb OR task.client_publication<>'published' THEN RAISE EXCEPTION 'Invalid withdrawal action.' USING ERRCODE='40001'; END IF;
|
|
425
|
+
UPDATE public.project_tasks SET client_publication='withdrawn',client_generation=client_generation+1,client_workflow='open',status='todo',
|
|
426
|
+
client_revision=client_revision+1,client_review_feedback=NULL,client_reviewed_sequence=NULL WHERE id=task.id;
|
|
427
|
+
ELSIF p_action IN ('return','accept') THEN
|
|
428
|
+
IF NOT p_input ?& ARRAY['submissionSequence','feedback'] OR EXISTS(SELECT 1 FROM jsonb_object_keys(p_input) k WHERE k NOT IN ('submissionSequence','feedback'))
|
|
429
|
+
OR jsonb_typeof(p_input->'submissionSequence') IS DISTINCT FROM 'number' OR jsonb_typeof(p_input->'feedback') IS DISTINCT FROM 'string'
|
|
430
|
+
OR length(p_input->>'feedback')>20000 OR (p_action='return' AND length(btrim(p_input->>'feedback'))=0) THEN
|
|
431
|
+
RAISE EXCEPTION 'Invalid submission review.' USING ERRCODE='22023'; END IF;
|
|
432
|
+
review_sequence:=(p_input->>'submissionSequence')::integer;
|
|
433
|
+
IF task.client_publication<>'published' OR task.client_workflow<>'submitted' OR task.client_submission_sequence<>review_sequence
|
|
434
|
+
OR NOT EXISTS(SELECT 1 FROM bw_projects_private.submissions s WHERE s.task_id=task.id AND s.sequence=review_sequence AND s.publication_generation=task.client_generation) THEN
|
|
435
|
+
RAISE EXCEPTION 'Submitted version changed. Refresh before reviewing.' USING ERRCODE='40001'; END IF;
|
|
436
|
+
UPDATE public.project_tasks SET client_workflow=CASE WHEN p_action='accept' THEN 'accepted' ELSE 'returned' END,
|
|
437
|
+
status=CASE WHEN p_action='accept' THEN 'done' ELSE 'todo' END,client_revision=client_revision+1,
|
|
438
|
+
client_review_feedback=nullif(btrim(p_input->>'feedback'),''),client_reviewed_sequence=review_sequence WHERE id=task.id;
|
|
439
|
+
UPDATE bw_projects_private.submissions SET review_feedback=nullif(btrim(p_input->>'feedback'),'') WHERE task_id=task.id AND sequence=review_sequence;
|
|
440
|
+
ELSE RAISE EXCEPTION 'Invalid staff task action.' USING ERRCODE='22023'; END IF;
|
|
441
|
+
END IF;
|
|
442
|
+
INSERT INTO bw_projects_private.requests(actor_profile_id,request_id,project_id,task_id,fingerprint,result_revision)
|
|
443
|
+
SELECT actor,p_request_id,p_project_id,task_id,fingerprint,client_revision FROM public.project_tasks WHERE id=task_id;
|
|
444
|
+
RETURN bw_projects_private.task_json(task_id,true);
|
|
445
|
+
EXCEPTION WHEN unique_violation THEN RAISE EXCEPTION 'Task request key already used.' USING ERRCODE='BW409';
|
|
446
|
+
END;
|
|
447
|
+
$$;
|
|
448
|
+
|
|
449
|
+
CREATE FUNCTION public.act_on_current_client_project_task(p_project_id uuid,p_task_id uuid,p_request_id uuid,p_expected_revision integer,p_action text,p_input jsonb)
|
|
450
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path='' AS $$
|
|
451
|
+
DECLARE actor uuid; task public.project_tasks%ROWTYPE; org uuid; fingerprint jsonb; prior bw_projects_private.requests%ROWTYPE;
|
|
452
|
+
BEGIN
|
|
453
|
+
SELECT client_organization_id INTO org FROM public.project_tasks WHERE id=p_task_id AND project_id=p_project_id AND task_kind='client';
|
|
454
|
+
actor:=bw_projects_private.lock_actor(p_project_id,false,org);
|
|
455
|
+
IF p_request_id IS NULL OR p_expected_revision IS NULL OR p_expected_revision<0 OR jsonb_typeof(p_input) IS DISTINCT FROM 'object' THEN
|
|
456
|
+
RAISE EXCEPTION 'Invalid client action.' USING ERRCODE='22023'; END IF;
|
|
457
|
+
IF p_action IS NULL OR p_action NOT IN ('start','submit') OR (p_action='start' AND p_input<>'{}'::jsonb)
|
|
458
|
+
OR (p_action='submit' AND (NOT p_input ? 'response' OR EXISTS(SELECT 1 FROM jsonb_object_keys(p_input) k WHERE k<>'response')
|
|
459
|
+
OR jsonb_typeof(p_input->'response') IS DISTINCT FROM 'string' OR length(btrim(p_input->>'response')) NOT BETWEEN 1 AND 20000)) THEN
|
|
460
|
+
RAISE EXCEPTION 'Invalid client action.' USING ERRCODE='22023'; END IF;
|
|
461
|
+
BEGIN
|
|
462
|
+
PERFORM bw_projects_private.lock_client(p_project_id,org,actor);
|
|
463
|
+
EXCEPTION WHEN invalid_parameter_value THEN
|
|
464
|
+
RAISE EXCEPTION 'Task access denied.' USING ERRCODE='42501';
|
|
465
|
+
END;
|
|
466
|
+
PERFORM bw_projects_private.lock_task_milestone(p_project_id,p_task_id);
|
|
467
|
+
SELECT * INTO task FROM public.project_tasks WHERE id=p_task_id AND project_id=p_project_id AND task_kind='client' FOR UPDATE;
|
|
468
|
+
IF NOT FOUND OR task.client_organization_id IS DISTINCT FROM org OR NOT bw_projects_private.task_visible(p_task_id,actor) THEN
|
|
469
|
+
RAISE EXCEPTION 'Task access denied.' USING ERRCODE='42501'; END IF;
|
|
470
|
+
PERFORM 1 FROM bw_projects_private.task_bindings WHERE task_id=task.id AND profile_id=actor FOR SHARE;
|
|
471
|
+
fingerprint:=jsonb_build_object('projectId',p_project_id,'taskId',p_task_id,'expectedRevision',p_expected_revision,'action',p_action,'input',p_input);
|
|
472
|
+
SELECT * INTO prior FROM bw_projects_private.requests WHERE actor_profile_id=actor AND request_id=p_request_id;
|
|
473
|
+
IF FOUND THEN
|
|
474
|
+
IF prior.fingerprint IS DISTINCT FROM fingerprint THEN RAISE EXCEPTION 'Task request key already used.' USING ERRCODE='BW409'; END IF;
|
|
475
|
+
RETURN bw_projects_private.task_json(task.id,false);
|
|
476
|
+
END IF;
|
|
477
|
+
IF task.client_revision<>p_expected_revision OR task.client_workflow NOT IN ('open','in_progress','returned') OR (p_action='start' AND task.client_workflow='in_progress') THEN
|
|
478
|
+
RAISE EXCEPTION 'Task changed. Refresh before acting.' USING ERRCODE='40001'; END IF;
|
|
479
|
+
IF p_action='submit' THEN
|
|
480
|
+
INSERT INTO bw_projects_private.submissions(task_id,sequence,task_revision,publication_generation,response,submitted_by_profile_id)
|
|
481
|
+
VALUES(task.id,task.client_submission_sequence+1,task.client_revision+1,task.client_generation,p_input->>'response',actor);
|
|
482
|
+
END IF;
|
|
483
|
+
UPDATE public.project_tasks SET client_workflow=CASE WHEN p_action='submit' THEN 'submitted' ELSE 'in_progress' END,status='in_progress',
|
|
484
|
+
client_revision=client_revision+1,client_submission_sequence=client_submission_sequence+CASE WHEN p_action='submit' THEN 1 ELSE 0 END,
|
|
485
|
+
client_review_feedback=CASE WHEN p_action='submit' THEN NULL ELSE client_review_feedback END,
|
|
486
|
+
client_reviewed_sequence=CASE WHEN p_action='submit' THEN NULL ELSE client_reviewed_sequence END WHERE id=task.id;
|
|
487
|
+
INSERT INTO bw_projects_private.requests(actor_profile_id,request_id,project_id,task_id,fingerprint,result_revision)
|
|
488
|
+
SELECT actor,p_request_id,p_project_id,task.id,fingerprint,client_revision FROM public.project_tasks WHERE id=task.id;
|
|
489
|
+
RETURN bw_projects_private.task_json(task.id,false);
|
|
490
|
+
EXCEPTION WHEN unique_violation THEN RAISE EXCEPTION 'Task request key already used.' USING ERRCODE='BW409';
|
|
491
|
+
END;
|
|
492
|
+
$$;
|
|
493
|
+
|
|
494
|
+
-- Eligibility removal affects only private bindings, avoiding task/source lock
|
|
495
|
+
-- inversion. Responsibility is a flag on the binding, never a task column.
|
|
496
|
+
CREATE FUNCTION bw_projects_private.revoke_bindings()
|
|
497
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path='' AS $$
|
|
498
|
+
BEGIN
|
|
499
|
+
IF TG_TABLE_NAME='user_role_assignments' THEN
|
|
500
|
+
IF TG_OP='DELETE' OR NEW.role_code IS DISTINCT FROM OLD.role_code OR NEW.profile_id IS DISTINCT FROM OLD.profile_id THEN
|
|
501
|
+
DELETE FROM bw_projects_private.task_bindings WHERE role_assignment_id=OLD.id;
|
|
502
|
+
END IF;
|
|
503
|
+
ELSIF TG_TABLE_NAME='organization_members' THEN
|
|
504
|
+
IF TG_OP='DELETE' OR NEW.organization_id IS DISTINCT FROM OLD.organization_id OR NEW.profile_id IS DISTINCT FROM OLD.profile_id THEN
|
|
505
|
+
DELETE FROM bw_projects_private.task_bindings WHERE membership_id=OLD.id;
|
|
506
|
+
END IF;
|
|
507
|
+
ELSIF TG_TABLE_NAME='profiles' THEN
|
|
508
|
+
IF NEW.user_id IS DISTINCT FROM OLD.user_id THEN DELETE FROM bw_projects_private.task_bindings WHERE profile_id=OLD.id; END IF;
|
|
509
|
+
ELSIF TG_TABLE_NAME='projects' THEN
|
|
510
|
+
DELETE FROM bw_projects_private.task_bindings b WHERE b.project_id=NEW.id
|
|
511
|
+
AND NOT bw_projects_private.client_eligible(b.project_id,b.organization_id,b.profile_id);
|
|
512
|
+
ELSE
|
|
513
|
+
DELETE FROM bw_projects_private.task_bindings b WHERE b.project_id=OLD.project_id AND b.organization_id=OLD.organization_id
|
|
514
|
+
AND NOT bw_projects_private.client_eligible(b.project_id,b.organization_id,b.profile_id);
|
|
515
|
+
END IF;
|
|
516
|
+
RETURN CASE WHEN TG_OP='DELETE' THEN OLD ELSE NEW END;
|
|
517
|
+
END;
|
|
518
|
+
$$;
|
|
519
|
+
CREATE TRIGGER trg_client_task_role_revocation AFTER UPDATE OF role_code,profile_id OR DELETE ON public.user_role_assignments
|
|
520
|
+
FOR EACH ROW EXECUTE FUNCTION bw_projects_private.revoke_bindings();
|
|
521
|
+
CREATE TRIGGER trg_client_task_membership_revocation AFTER UPDATE OF organization_id,profile_id OR DELETE ON public.organization_members
|
|
522
|
+
FOR EACH ROW EXECUTE FUNCTION bw_projects_private.revoke_bindings();
|
|
523
|
+
CREATE TRIGGER trg_client_task_profile_revocation AFTER UPDATE OF user_id ON public.profiles
|
|
524
|
+
FOR EACH ROW EXECUTE FUNCTION bw_projects_private.revoke_bindings();
|
|
525
|
+
CREATE TRIGGER trg_client_task_project_mode_revocation AFTER UPDATE OF client_access_mode ON public.projects
|
|
526
|
+
FOR EACH ROW EXECUTE FUNCTION bw_projects_private.revoke_bindings();
|
|
527
|
+
CREATE TRIGGER trg_client_task_project_profile_revocation AFTER UPDATE OR DELETE ON public.project_client_profiles
|
|
528
|
+
FOR EACH ROW EXECUTE FUNCTION bw_projects_private.revoke_bindings();
|
|
529
|
+
CREATE TRIGGER trg_client_task_project_audience_revocation AFTER UPDATE OR DELETE ON public.project_client_organizations
|
|
530
|
+
FOR EACH ROW EXECUTE FUNCTION bw_projects_private.revoke_bindings();
|
|
531
|
+
CREATE TRIGGER trg_client_task_participation_revocation AFTER UPDATE OR DELETE ON public.project_organizations
|
|
532
|
+
FOR EACH ROW EXECUTE FUNCTION bw_projects_private.revoke_bindings();
|
|
533
|
+
|
|
534
|
+
CREATE FUNCTION bw_projects_private.withdraw_milestone_tasks()
|
|
535
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path='' AS $$
|
|
536
|
+
BEGIN
|
|
537
|
+
IF TG_OP='DELETE' THEN
|
|
538
|
+
UPDATE public.project_tasks SET milestone_id=NULL,client_publication='withdrawn',client_generation=client_generation+1,
|
|
539
|
+
client_revision=client_revision+1,client_workflow='open',status='todo',client_review_feedback=NULL,client_reviewed_sequence=NULL
|
|
540
|
+
WHERE milestone_id=OLD.id AND task_kind='client';
|
|
541
|
+
ELSIF OLD.visibility='client' AND NEW.visibility<>'client' THEN
|
|
542
|
+
UPDATE public.project_tasks SET client_publication='withdrawn',client_generation=client_generation+1,
|
|
543
|
+
client_revision=client_revision+1,client_workflow='open',status='todo',client_review_feedback=NULL,client_reviewed_sequence=NULL
|
|
544
|
+
WHERE milestone_id=OLD.id AND task_kind='client';
|
|
545
|
+
END IF;
|
|
546
|
+
RETURN CASE WHEN TG_OP='DELETE' THEN OLD ELSE NEW END;
|
|
547
|
+
END;
|
|
548
|
+
$$;
|
|
549
|
+
CREATE TRIGGER ab_withdraw_milestone_client_tasks BEFORE UPDATE OF visibility OR DELETE ON public.project_milestones
|
|
550
|
+
FOR EACH ROW EXECUTE FUNCTION bw_projects_private.withdraw_milestone_tasks();
|
|
551
|
+
|
|
552
|
+
CREATE FUNCTION bw_projects_private.delete_project_tasks()
|
|
553
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path='' AS $$
|
|
554
|
+
BEGIN
|
|
555
|
+
DELETE FROM public.project_tasks WHERE project_id=OLD.id AND task_kind='client';
|
|
556
|
+
RETURN OLD;
|
|
557
|
+
END;
|
|
558
|
+
$$;
|
|
559
|
+
CREATE TRIGGER ab_delete_project_client_tasks BEFORE DELETE ON public.projects
|
|
560
|
+
FOR EACH ROW EXECUTE FUNCTION bw_projects_private.delete_project_tasks();
|
|
561
|
+
CREATE FUNCTION bw_projects_private.unlink_profile_tasks()
|
|
562
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path='' AS $$
|
|
563
|
+
BEGIN
|
|
564
|
+
UPDATE public.project_tasks SET assignee_profile_id=CASE WHEN assignee_profile_id=OLD.id THEN NULL ELSE assignee_profile_id END,
|
|
565
|
+
reporter_profile_id=CASE WHEN reporter_profile_id=OLD.id THEN NULL ELSE reporter_profile_id END
|
|
566
|
+
WHERE task_kind='client' AND (assignee_profile_id=OLD.id OR reporter_profile_id=OLD.id);
|
|
567
|
+
RETURN OLD;
|
|
568
|
+
END;
|
|
569
|
+
$$;
|
|
570
|
+
CREATE TRIGGER ab_unlink_profile_client_tasks BEFORE DELETE ON public.profiles
|
|
571
|
+
FOR EACH ROW EXECUTE FUNCTION bw_projects_private.unlink_profile_tasks();
|
|
572
|
+
|
|
573
|
+
-- Only these tightly scoped mutation entry points/trigger handlers receive the
|
|
574
|
+
-- executor identity. API roles cannot SET ROLE to it or write private records.
|
|
575
|
+
GRANT CREATE ON SCHEMA public,bw_projects_private TO bw_projects_task_executor;
|
|
576
|
+
ALTER FUNCTION public.manage_project_client_task(uuid,uuid,uuid,integer,text,jsonb) OWNER TO bw_projects_task_executor;
|
|
577
|
+
ALTER FUNCTION public.act_on_current_client_project_task(uuid,uuid,uuid,integer,text,jsonb) OWNER TO bw_projects_task_executor;
|
|
578
|
+
ALTER FUNCTION bw_projects_private.withdraw_milestone_tasks() OWNER TO bw_projects_task_executor;
|
|
579
|
+
ALTER FUNCTION bw_projects_private.delete_project_tasks() OWNER TO bw_projects_task_executor;
|
|
580
|
+
ALTER FUNCTION bw_projects_private.unlink_profile_tasks() OWNER TO bw_projects_task_executor;
|
|
581
|
+
REVOKE CREATE ON SCHEMA public,bw_projects_private FROM bw_projects_task_executor;
|
|
582
|
+
REVOKE ALL ON FUNCTION public.manage_project_client_task(uuid,uuid,uuid,integer,text,jsonb) FROM PUBLIC,anon,service_role;
|
|
583
|
+
REVOKE ALL ON FUNCTION public.act_on_current_client_project_task(uuid,uuid,uuid,integer,text,jsonb) FROM PUBLIC,anon,service_role;
|
|
584
|
+
GRANT EXECUTE ON FUNCTION public.manage_project_client_task(uuid,uuid,uuid,integer,text,jsonb) TO authenticated;
|
|
585
|
+
GRANT EXECUTE ON FUNCTION public.act_on_current_client_project_task(uuid,uuid,uuid,integer,text,jsonb) TO authenticated;
|
|
586
|
+
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA bw_projects_private FROM PUBLIC,anon,authenticated,service_role;
|
|
587
|
+
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA bw_projects_private TO bw_projects_task_executor;
|
|
588
|
+
|
|
589
|
+
CREATE OR REPLACE FUNCTION public.set_project_client_access(
|
|
590
|
+
target_project_id uuid,
|
|
591
|
+
target_mode text,
|
|
592
|
+
target_organization_ids uuid[],
|
|
593
|
+
target_profile_grants jsonb
|
|
594
|
+
)
|
|
595
|
+
RETURNS void
|
|
596
|
+
LANGUAGE plpgsql
|
|
597
|
+
SECURITY DEFINER
|
|
598
|
+
SET search_path = public
|
|
599
|
+
AS $$
|
|
600
|
+
DECLARE
|
|
601
|
+
v_actor_profile_id uuid := public.current_profile_id();
|
|
602
|
+
v_organization_ids uuid[] := COALESCE(target_organization_ids, ARRAY[]::uuid[]);
|
|
603
|
+
v_profile_grants jsonb := COALESCE(target_profile_grants, '[]'::jsonb);
|
|
604
|
+
v_previous_mode text;
|
|
605
|
+
v_previous_organization_ids uuid[];
|
|
606
|
+
v_previous_profile_grants jsonb;
|
|
607
|
+
BEGIN
|
|
608
|
+
IF target_mode IS NULL
|
|
609
|
+
OR target_mode NOT IN ('hidden', 'all_org_clients', 'selected_clients') THEN
|
|
610
|
+
RAISE EXCEPTION 'Invalid project client access mode.' USING ERRCODE = '22023';
|
|
611
|
+
END IF;
|
|
612
|
+
IF jsonb_typeof(v_profile_grants) <> 'array' THEN
|
|
613
|
+
RAISE EXCEPTION 'Profile grants must be a JSON array.' USING ERRCODE = '22023';
|
|
614
|
+
END IF;
|
|
615
|
+
|
|
616
|
+
SELECT project.client_access_mode
|
|
617
|
+
INTO v_previous_mode
|
|
618
|
+
FROM public.projects project
|
|
619
|
+
WHERE project.id = target_project_id
|
|
620
|
+
FOR UPDATE;
|
|
621
|
+
IF NOT FOUND THEN
|
|
622
|
+
RAISE EXCEPTION 'Projeto não encontrado.' USING ERRCODE = 'P0002';
|
|
623
|
+
END IF;
|
|
624
|
+
|
|
625
|
+
IF NOT (
|
|
626
|
+
public.is_admin()
|
|
627
|
+
OR (public.is_staff() AND public.is_project_owner_member(target_project_id))
|
|
628
|
+
) THEN
|
|
629
|
+
RAISE EXCEPTION 'Only project owners or administrators can manage client project access.'
|
|
630
|
+
USING ERRCODE = '42501';
|
|
631
|
+
END IF;
|
|
632
|
+
|
|
633
|
+
IF target_mode <> 'hidden' AND cardinality(v_organization_ids) = 0 THEN
|
|
634
|
+
RAISE EXCEPTION 'Visible client access requires at least one organization.'
|
|
635
|
+
USING ERRCODE = '22023';
|
|
636
|
+
END IF;
|
|
637
|
+
|
|
638
|
+
IF EXISTS (
|
|
639
|
+
SELECT 1
|
|
640
|
+
FROM unnest(v_organization_ids) AS selected(organization_id)
|
|
641
|
+
LEFT JOIN public.project_organizations participant
|
|
642
|
+
ON participant.project_id = target_project_id
|
|
643
|
+
AND participant.organization_id = selected.organization_id
|
|
644
|
+
WHERE selected.organization_id IS NULL OR participant.project_id IS NULL
|
|
645
|
+
) THEN
|
|
646
|
+
RAISE EXCEPTION 'Every client organization must participate in the project.'
|
|
647
|
+
USING ERRCODE = '22023';
|
|
648
|
+
END IF;
|
|
649
|
+
|
|
650
|
+
IF target_mode = 'selected_clients' AND jsonb_array_length(v_profile_grants) = 0 THEN
|
|
651
|
+
RAISE EXCEPTION 'Selected-clients mode requires at least one profile grant.'
|
|
652
|
+
USING ERRCODE = '22023';
|
|
653
|
+
END IF;
|
|
654
|
+
|
|
655
|
+
IF target_mode = 'selected_clients' AND EXISTS (
|
|
656
|
+
SELECT 1
|
|
657
|
+
FROM unnest(v_organization_ids) AS selected(organization_id)
|
|
658
|
+
WHERE NOT EXISTS (
|
|
659
|
+
SELECT 1
|
|
660
|
+
FROM jsonb_to_recordset(v_profile_grants)
|
|
661
|
+
AS access_grant(organization_id uuid, profile_id uuid)
|
|
662
|
+
WHERE access_grant.organization_id = selected.organization_id
|
|
663
|
+
)
|
|
664
|
+
) THEN
|
|
665
|
+
RAISE EXCEPTION 'Every selected organization requires at least one selected client.'
|
|
666
|
+
USING ERRCODE = '22023';
|
|
667
|
+
END IF;
|
|
668
|
+
|
|
669
|
+
IF target_mode = 'selected_clients' AND EXISTS (
|
|
670
|
+
SELECT 1
|
|
671
|
+
FROM jsonb_to_recordset(v_profile_grants)
|
|
672
|
+
AS access_grant(organization_id uuid, profile_id uuid)
|
|
673
|
+
LEFT JOIN public.organization_members org_member
|
|
674
|
+
ON org_member.organization_id = access_grant.organization_id
|
|
675
|
+
AND org_member.profile_id = access_grant.profile_id
|
|
676
|
+
LEFT JOIN public.user_role_assignments role_assignment
|
|
677
|
+
ON role_assignment.profile_id = access_grant.profile_id
|
|
678
|
+
AND role_assignment.role_code = 'client'
|
|
679
|
+
LEFT JOIN public.profiles profile
|
|
680
|
+
ON profile.id = access_grant.profile_id
|
|
681
|
+
AND profile.user_id IS NOT NULL
|
|
682
|
+
WHERE access_grant.organization_id IS NULL
|
|
683
|
+
OR access_grant.profile_id IS NULL
|
|
684
|
+
OR NOT (access_grant.organization_id = ANY(v_organization_ids))
|
|
685
|
+
OR org_member.id IS NULL
|
|
686
|
+
OR role_assignment.id IS NULL
|
|
687
|
+
OR profile.id IS NULL
|
|
688
|
+
) THEN
|
|
689
|
+
RAISE EXCEPTION 'Every selected profile must be an active client member with a login.'
|
|
690
|
+
USING ERRCODE = '22023';
|
|
691
|
+
END IF;
|
|
692
|
+
|
|
693
|
+
SELECT COALESCE(array_agg(audience.organization_id ORDER BY audience.organization_id), ARRAY[]::uuid[])
|
|
694
|
+
INTO v_previous_organization_ids
|
|
695
|
+
FROM public.project_client_organizations audience
|
|
696
|
+
WHERE audience.project_id = target_project_id;
|
|
697
|
+
|
|
698
|
+
SELECT COALESCE(
|
|
699
|
+
jsonb_agg(
|
|
700
|
+
jsonb_build_object(
|
|
701
|
+
'organization_id', client_profile.organization_id,
|
|
702
|
+
'profile_id', client_profile.profile_id
|
|
703
|
+
) ORDER BY client_profile.organization_id, client_profile.profile_id
|
|
704
|
+
),
|
|
705
|
+
'[]'::jsonb
|
|
706
|
+
)
|
|
707
|
+
INTO v_previous_profile_grants
|
|
708
|
+
FROM public.project_client_profiles client_profile
|
|
709
|
+
WHERE client_profile.project_id = target_project_id;
|
|
710
|
+
|
|
711
|
+
-- Retain unchanged audience keys. Losing actual eligibility still removes
|
|
712
|
+
-- task bindings; a no-op save or selected-to-all transition must not do so.
|
|
713
|
+
INSERT INTO public.project_client_organizations(project_id,organization_id,added_by_profile_id)
|
|
714
|
+
SELECT target_project_id,selected.organization_id,v_actor_profile_id
|
|
715
|
+
FROM (SELECT DISTINCT organization_id FROM unnest(v_organization_ids) AS requested(organization_id)) selected
|
|
716
|
+
ON CONFLICT(project_id,organization_id) DO NOTHING;
|
|
717
|
+
|
|
718
|
+
IF target_mode='selected_clients' THEN
|
|
719
|
+
INSERT INTO public.project_client_profiles(project_id,organization_id,profile_id,granted_by_profile_id)
|
|
720
|
+
SELECT DISTINCT target_project_id,access_grant.organization_id,access_grant.profile_id,v_actor_profile_id
|
|
721
|
+
FROM jsonb_to_recordset(v_profile_grants) AS access_grant(organization_id uuid,profile_id uuid)
|
|
722
|
+
ON CONFLICT(project_id,organization_id,profile_id) DO NOTHING;
|
|
723
|
+
END IF;
|
|
724
|
+
UPDATE public.projects SET client_access_mode=target_mode WHERE id=target_project_id;
|
|
725
|
+
DELETE FROM public.project_client_profiles existing
|
|
726
|
+
WHERE existing.project_id=target_project_id AND (target_mode<>'selected_clients' OR NOT EXISTS(
|
|
727
|
+
SELECT 1 FROM jsonb_to_recordset(v_profile_grants) AS requested(organization_id uuid,profile_id uuid)
|
|
728
|
+
WHERE requested.organization_id=existing.organization_id AND requested.profile_id=existing.profile_id));
|
|
729
|
+
DELETE FROM public.project_client_organizations
|
|
730
|
+
WHERE project_id=target_project_id AND NOT organization_id=ANY(v_organization_ids);
|
|
731
|
+
|
|
732
|
+
INSERT INTO public.app_activity_events (
|
|
733
|
+
domain, event_type, entity_table, entity_id, actor_profile_id, summary, payload
|
|
734
|
+
)
|
|
735
|
+
VALUES (
|
|
736
|
+
'projects',
|
|
737
|
+
'project_client_access_changed',
|
|
738
|
+
'projects',
|
|
739
|
+
target_project_id,
|
|
740
|
+
v_actor_profile_id,
|
|
741
|
+
'Acesso de clientes atualizado.',
|
|
742
|
+
jsonb_build_object(
|
|
743
|
+
'project_id', target_project_id,
|
|
744
|
+
'previous_mode', v_previous_mode,
|
|
745
|
+
'mode', target_mode,
|
|
746
|
+
'previous_organization_ids', to_jsonb(v_previous_organization_ids),
|
|
747
|
+
'organization_ids', to_jsonb(v_organization_ids),
|
|
748
|
+
'previous_profile_grants', v_previous_profile_grants,
|
|
749
|
+
'profile_grants', CASE WHEN target_mode = 'selected_clients' THEN v_profile_grants ELSE '[]'::jsonb END
|
|
750
|
+
)
|
|
751
|
+
);
|
|
752
|
+
END;
|
|
753
|
+
$$;
|
|
754
|
+
|
|
755
|
+
REVOKE ALL ON FUNCTION public.set_project_client_access(uuid, text, uuid[], jsonb)
|
|
756
|
+
FROM PUBLIC, anon, service_role;
|
|
757
|
+
GRANT EXECUTE ON FUNCTION public.set_project_client_access(uuid, text, uuid[], jsonb)
|
|
758
|
+
TO authenticated;
|
|
759
|
+
|
|
760
|
+
$client_task_ddl$;
|
|
761
|
+
PERFORM pg_catalog.pg_notify('pgrst','reload schema');
|
|
762
|
+
END;
|
|
763
|
+
$client_task_migration$;
|