create-bw-app 0.27.3 → 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.
@@ -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$;