create-bw-app 0.25.1 → 0.25.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-bw-app",
3
3
  "private": false,
4
- "version": "0.25.1",
4
+ "version": "0.25.2",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-bw-app": "bin/create-bw-app.mjs",
package/src/constants.mjs CHANGED
@@ -182,13 +182,13 @@ export const PLATFORM_STARTER_FILES = [
182
182
 
183
183
  export const APP_DEPENDENCY_DEFAULTS = {
184
184
  "@brightweblabs/app-shell": "^0.16.0",
185
- "@brightweblabs/core-auth": "^0.11.0",
185
+ "@brightweblabs/core-auth": "^0.12.0",
186
186
  "@brightweblabs/infra": "^0.7.0",
187
- "@brightweblabs/module-admin": "^0.9.3",
188
- "@brightweblabs/module-crm": "^0.17.3",
189
- "@brightweblabs/module-marketing": "^0.4.23",
190
- "@brightweblabs/module-orgs": "^0.6.2",
191
- "@brightweblabs/module-projects": "^0.18.0",
187
+ "@brightweblabs/module-admin": "^0.9.4",
188
+ "@brightweblabs/module-crm": "^0.17.4",
189
+ "@brightweblabs/module-marketing": "^0.4.24",
190
+ "@brightweblabs/module-orgs": "^0.6.3",
191
+ "@brightweblabs/module-projects": "^0.18.5",
192
192
  "@brightweblabs/theme": "^0.8.3",
193
193
  "@brightweblabs/ui": "^1.5.4",
194
194
  "geist": "1.7.2",
package/src/generator.mjs CHANGED
@@ -1044,7 +1044,7 @@ export function createModuleToolbarControlsConfig(selectedModules) {
1044
1044
  }
1045
1045
  if (selectedModules.includes("projects")) {
1046
1046
  imports.push('import { ProjectBoardToolbarControls, ProjectsToolbarControls } from "@brightweblabs/module-projects/ui";');
1047
- entries.push(' projects: () => <ProjectsToolbarControls />,');
1047
+ entries.push(' projects: (canCreateProjects) => <ProjectsToolbarControls canCreateProjects={canCreateProjects} />,');
1048
1048
  entries.push(' "project-board": () => <ProjectBoardToolbarControls />,');
1049
1049
  }
1050
1050
  if (selectedModules.includes("marketing")) {
@@ -1060,13 +1060,13 @@ export function createModuleToolbarControlsConfig(selectedModules) {
1060
1060
  "// MANAGED BY BRIGHTWEB — regenerated when modules are added, removed, or updated.",
1061
1061
  ...imports,
1062
1062
  "",
1063
- "const toolbarControlBySurface: Partial<Record<ShellToolbarSurface, () => ReactNode>> = {",
1063
+ "const toolbarControlBySurface: Partial<Record<ShellToolbarSurface, (canCreateProjects: boolean) => ReactNode>> = {",
1064
1064
  ...entries,
1065
1065
  "};",
1066
1066
  "",
1067
- "export function getModuleToolbarControls(pathname: string, toolbarRoutes: ShellToolbarRouteConfig[]) {",
1067
+ "export function getModuleToolbarControls(pathname: string, toolbarRoutes: ShellToolbarRouteConfig[], canCreateProjects = false) {",
1068
1068
  " const surface = resolveShellToolbarSurface(pathname, toolbarRoutes);",
1069
- " return surface ? toolbarControlBySurface[surface]?.() ?? null : null;",
1069
+ " return surface ? toolbarControlBySurface[surface]?.(canCreateProjects) ?? null : null;",
1070
1070
  "}",
1071
1071
  "",
1072
1072
  ].join("\n");
@@ -84,7 +84,7 @@ function ShellLayoutInner({
84
84
  viewer.email ||
85
85
  "Conta";
86
86
  const projectsBaseHref = pathname.startsWith("/projects") ? "/projects" : "/projetos";
87
- const toolbarControls = getModuleToolbarControls(pathname, toolbarRoutes);
87
+ const toolbarControls = getModuleToolbarControls(pathname, toolbarRoutes, viewer.isAdmin);
88
88
 
89
89
  const dispatchShellAction = useShellActionDispatch();
90
90
  useShellAction("projects-back-to-portfolio", () => {
@@ -8,16 +8,16 @@ import { ProjectBoardToolbarControls, ProjectsToolbarControls } from "@brightweb
8
8
  import { MarketingToolbarControls } from "@brightweblabs/module-marketing/ui";
9
9
 
10
10
  // MANAGED BY BRIGHTWEB — regenerated when modules are added, removed, or updated.
11
- const toolbarControlBySurface: Partial<Record<ShellToolbarSurface, () => ReactNode>> = {
11
+ const toolbarControlBySurface: Partial<Record<ShellToolbarSurface, (canCreateProjects: boolean) => ReactNode>> = {
12
12
  "admin-users": () => <AdminToolbarControls />,
13
13
  crm: () => <CrmToolbarControls />,
14
14
  "crm-organizations": () => <CrmOrganizationsToolbarControls />,
15
- projects: () => <ProjectsToolbarControls />,
15
+ projects: (canCreateProjects) => <ProjectsToolbarControls canCreateProjects={canCreateProjects} />,
16
16
  "project-board": () => <ProjectBoardToolbarControls />,
17
17
  marketing: () => <MarketingToolbarControls />,
18
18
  };
19
19
 
20
- export function getModuleToolbarControls(pathname: string, toolbarRoutes: ShellToolbarRouteConfig[]) {
20
+ export function getModuleToolbarControls(pathname: string, toolbarRoutes: ShellToolbarRouteConfig[], canCreateProjects = false) {
21
21
  const surface = resolveShellToolbarSurface(pathname, toolbarRoutes);
22
- return surface ? toolbarControlBySurface[surface]?.() ?? null : null;
22
+ return surface ? toolbarControlBySurface[surface]?.(canCreateProjects) ?? null : null;
23
23
  }
@@ -0,0 +1,77 @@
1
+ CREATE OR REPLACE FUNCTION public.get_project_access_configuration(target_project_id uuid)
2
+ RETURNS jsonb
3
+ LANGUAGE plpgsql
4
+ STABLE
5
+ SECURITY DEFINER
6
+ SET search_path = public
7
+ AS $$
8
+ DECLARE
9
+ v_result jsonb;
10
+ BEGIN
11
+ IF NOT (
12
+ public.is_admin()
13
+ OR (public.is_staff() AND public.is_project_owner_member(target_project_id))
14
+ ) THEN
15
+ RAISE EXCEPTION 'Only project owners or administrators can view client access configuration.'
16
+ USING ERRCODE = '42501';
17
+ END IF;
18
+
19
+ SELECT jsonb_build_object(
20
+ 'mode', project.client_access_mode,
21
+ 'updated_at', project.updated_at,
22
+ 'client_summary', project.client_summary,
23
+ 'client_scope', project.client_scope,
24
+ 'client_contact_profile_id', project.client_contact_profile_id,
25
+ 'organizations', COALESCE((
26
+ SELECT jsonb_agg(
27
+ jsonb_build_object(
28
+ 'organization_id', organization.id,
29
+ 'organization_name', organization.name,
30
+ 'is_primary', participant.is_primary,
31
+ 'selected_for_client_access', audience.project_id IS NOT NULL,
32
+ 'eligible_clients', COALESCE((
33
+ SELECT jsonb_agg(
34
+ jsonb_build_object(
35
+ 'profile_id', profile.id,
36
+ 'label', COALESCE(NULLIF(btrim(concat_ws(' ', profile.first_name, profile.last_name)), ''), profile.email),
37
+ 'email', profile.email,
38
+ 'organization_role', org_member.role
39
+ ) ORDER BY profile.first_name, profile.last_name, profile.email
40
+ )
41
+ FROM public.organization_members org_member
42
+ JOIN public.profiles profile ON profile.id = org_member.profile_id AND profile.user_id IS NOT NULL
43
+ JOIN public.user_role_assignments role_assignment
44
+ ON role_assignment.profile_id = profile.id AND role_assignment.role_code = 'client'
45
+ WHERE org_member.organization_id = organization.id
46
+ ), '[]'::jsonb),
47
+ 'selected_profile_ids', COALESCE((
48
+ SELECT jsonb_agg(client_profile.profile_id ORDER BY client_profile.profile_id)
49
+ FROM public.project_client_profiles client_profile
50
+ WHERE client_profile.project_id = target_project_id
51
+ AND client_profile.organization_id = organization.id
52
+ ), '[]'::jsonb)
53
+ ) ORDER BY participant.is_primary DESC, organization.name
54
+ )
55
+ FROM public.project_organizations participant
56
+ JOIN public.organizations organization ON organization.id = participant.organization_id
57
+ LEFT JOIN public.project_client_organizations audience
58
+ ON audience.project_id = participant.project_id
59
+ AND audience.organization_id = participant.organization_id
60
+ WHERE participant.project_id = target_project_id
61
+ ), '[]'::jsonb)
62
+ )
63
+ INTO v_result
64
+ FROM public.projects project
65
+ WHERE project.id = target_project_id;
66
+
67
+ IF v_result IS NULL THEN
68
+ RAISE EXCEPTION 'Projeto não encontrado.' USING ERRCODE = 'P0002';
69
+ END IF;
70
+ RETURN v_result;
71
+ END;
72
+ $$;
73
+
74
+ REVOKE ALL ON FUNCTION public.get_project_access_configuration(uuid)
75
+ FROM PUBLIC, anon, service_role;
76
+ GRANT EXECUTE ON FUNCTION public.get_project_access_configuration(uuid)
77
+ TO authenticated;
@@ -0,0 +1,376 @@
1
+ -- Projects are created by platform administrators. Internal ownership may be
2
+ -- assigned later; contributors may only advance their own assigned tasks.
3
+
4
+ CREATE OR REPLACE FUNCTION public.create_project_with_client_access(
5
+ p_request_id uuid,
6
+ p_project jsonb,
7
+ p_organization_ids uuid[],
8
+ p_members jsonb,
9
+ p_client_access jsonb
10
+ )
11
+ RETURNS TABLE(project_id uuid, created boolean)
12
+ LANGUAGE plpgsql
13
+ SECURITY DEFINER
14
+ SET search_path = public
15
+ AS $$
16
+ DECLARE
17
+ v_actor_profile_id uuid := public.current_profile_id();
18
+ v_actor_role text := public.current_global_role();
19
+ v_payload jsonb := jsonb_build_object(
20
+ 'project', COALESCE(p_project, '{}'::jsonb),
21
+ 'organization_ids', to_jsonb(COALESCE(p_organization_ids, ARRAY[]::uuid[])),
22
+ 'members', COALESCE(p_members, '[]'::jsonb),
23
+ 'client_access', COALESCE(p_client_access, '{}'::jsonb)
24
+ );
25
+ v_existing public.project_creation_requests%ROWTYPE;
26
+ v_project_id uuid;
27
+ v_primary_organization_id uuid;
28
+ v_owner_profile_id uuid;
29
+ v_status text;
30
+ v_mode text;
31
+ v_access_organization_ids uuid[];
32
+ v_profile_grants jsonb;
33
+ v_organization_name text;
34
+ v_organization_token text;
35
+ v_project_token text;
36
+ v_code_base text;
37
+ v_code text;
38
+ v_code_suffix integer := 1;
39
+ BEGIN
40
+ IF p_request_id IS NULL OR v_actor_profile_id IS NULL OR v_actor_role <> 'admin' THEN
41
+ RAISE EXCEPTION 'Only administrators can create projects.' USING ERRCODE = '42501';
42
+ END IF;
43
+ IF jsonb_typeof(COALESCE(p_project, '{}'::jsonb)) <> 'object'
44
+ OR jsonb_typeof(COALESCE(p_members, '[]'::jsonb)) <> 'array'
45
+ OR jsonb_typeof(COALESCE(p_client_access, '{}'::jsonb)) <> 'object' THEN
46
+ RAISE EXCEPTION 'Invalid project creation payload.' USING ERRCODE = '22023';
47
+ END IF;
48
+
49
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_request_id::text, 0));
50
+ SELECT request.* INTO v_existing
51
+ FROM public.project_creation_requests request
52
+ WHERE request.request_id = p_request_id;
53
+ IF FOUND THEN
54
+ IF v_existing.actor_profile_id <> v_actor_profile_id OR v_existing.request_payload <> v_payload THEN
55
+ RAISE EXCEPTION 'The idempotency key was already used with another request.' USING ERRCODE = '22023';
56
+ END IF;
57
+ RETURN QUERY SELECT v_existing.project_id, false;
58
+ RETURN;
59
+ END IF;
60
+
61
+ v_primary_organization_id := NULLIF(p_project->>'primary_organization_id', '')::uuid;
62
+ v_status := COALESCE(NULLIF(p_project->>'status', ''), 'planned');
63
+ IF v_primary_organization_id IS NULL
64
+ OR NULLIF(btrim(p_project->>'name'), '') IS NULL
65
+ OR v_status NOT IN ('planned', 'active', 'paused', 'blocked', 'completed', 'canceled') THEN
66
+ RAISE EXCEPTION 'Project name, primary organization, and status are invalid.' USING ERRCODE = '22023';
67
+ END IF;
68
+ IF NOT (v_primary_organization_id = ANY(COALESCE(p_organization_ids, ARRAY[]::uuid[]))) THEN
69
+ RAISE EXCEPTION 'The primary organization must participate in the project.' USING ERRCODE = '22023';
70
+ END IF;
71
+ IF EXISTS (
72
+ SELECT 1 FROM unnest(COALESCE(p_organization_ids, ARRAY[]::uuid[])) selected(organization_id)
73
+ LEFT JOIN public.organizations organization ON organization.id = selected.organization_id
74
+ WHERE selected.organization_id IS NULL OR organization.id IS NULL
75
+ ) THEN
76
+ RAISE EXCEPTION 'One or more participating organizations do not exist.' USING ERRCODE = '22023';
77
+ END IF;
78
+ SELECT organization.name INTO v_organization_name
79
+ FROM public.organizations organization
80
+ WHERE organization.id = v_primary_organization_id;
81
+ IF NULLIF(p_project->>'start_date', '')::date > NULLIF(p_project->>'target_date', '')::date THEN
82
+ RAISE EXCEPTION 'A data alvo não pode ser anterior à data de início.' USING ERRCODE = '22023';
83
+ END IF;
84
+ IF v_status = 'canceled' AND NULLIF(btrim(p_project->>'cancellation_reason'), '') IS NULL THEN
85
+ RAISE EXCEPTION 'Indica o motivo do cancelamento.' USING ERRCODE = '22023';
86
+ END IF;
87
+
88
+ IF (SELECT count(*) FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text) WHERE role = 'owner') > 1 THEN
89
+ RAISE EXCEPTION 'A project can have at most one internal project manager.' USING ERRCODE = '22023';
90
+ END IF;
91
+ IF (
92
+ SELECT count(*) <> count(DISTINCT member.profile_id)
93
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text)
94
+ ) THEN
95
+ RAISE EXCEPTION 'Each internal profile can appear only once in the project team.' USING ERRCODE = '22023';
96
+ END IF;
97
+ SELECT member.profile_id INTO v_owner_profile_id
98
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text)
99
+ WHERE member.role = 'owner';
100
+
101
+ IF EXISTS (
102
+ SELECT 1
103
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text)
104
+ LEFT JOIN public.user_role_assignments role_assignment
105
+ ON role_assignment.profile_id = member.profile_id
106
+ AND role_assignment.role_code IN ('staff', 'admin')
107
+ WHERE member.profile_id IS NULL
108
+ OR member.role NOT IN ('owner', 'contributor', 'observer')
109
+ OR role_assignment.id IS NULL
110
+ ) THEN
111
+ RAISE EXCEPTION 'Project members must be internal staff.' USING ERRCODE = '22023';
112
+ END IF;
113
+ IF NULLIF(p_project->>'client_contact_profile_id', '') IS NOT NULL AND NOT EXISTS (
114
+ SELECT 1
115
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text)
116
+ WHERE member.profile_id = NULLIF(p_project->>'client_contact_profile_id', '')::uuid
117
+ AND member.role IN ('owner', 'contributor')
118
+ ) THEN
119
+ RAISE EXCEPTION 'The client contact must be an active internal project member.' USING ERRCODE = '22023';
120
+ END IF;
121
+
122
+ v_code := NULLIF(upper(btrim(p_project->>'code')), '');
123
+ IF v_code IS NULL THEN
124
+ v_organization_token := trim(both '-' FROM regexp_replace(
125
+ translate(upper(COALESCE(v_organization_name, 'ORG')), 'ÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ', 'AAAAAEEEEIIIIOOOOOUUUUC'),
126
+ '[^A-Z0-9]+', '-', 'g'
127
+ ));
128
+ v_project_token := trim(both '-' FROM regexp_replace(
129
+ translate(upper(COALESCE(p_project->>'name', 'PROJECT')), 'ÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ', 'AAAAAEEEEIIIIOOOOOUUUUC'),
130
+ '[^A-Z0-9]+', '-', 'g'
131
+ ));
132
+ v_organization_token := COALESCE(NULLIF(array_to_string((regexp_split_to_array(v_organization_token, '-'))[1:2], '-'), ''), 'ORG');
133
+ v_project_token := COALESCE(NULLIF(array_to_string((regexp_split_to_array(v_project_token, '-'))[1:3], '-'), ''), 'PROJECT');
134
+ v_code_base := left(format('%s-%s-%s', v_organization_token, v_project_token, extract(year FROM current_date)::integer), 72);
135
+ PERFORM pg_advisory_xact_lock(hashtextextended('project-code:' || v_code_base, 0));
136
+ LOOP
137
+ v_code := CASE WHEN v_code_suffix = 1 THEN v_code_base ELSE format('%s-%s', v_code_base, v_code_suffix) END;
138
+ EXIT WHEN NOT EXISTS (SELECT 1 FROM public.projects existing_project WHERE lower(existing_project.code) = lower(v_code));
139
+ v_code_suffix := v_code_suffix + 1;
140
+ END LOOP;
141
+ END IF;
142
+
143
+ INSERT INTO public.projects (
144
+ organization_id, name, code, status, health, owner_profile_id,
145
+ start_date, target_date, cancellation_reason, summary,
146
+ client_access_mode, client_summary, client_scope,
147
+ client_contact_profile_id
148
+ ) VALUES (
149
+ v_primary_organization_id, btrim(p_project->>'name'), v_code, v_status,
150
+ CASE WHEN v_status IN ('blocked', 'canceled') THEN 'off_track' ELSE 'on_track' END,
151
+ v_owner_profile_id,
152
+ NULLIF(p_project->>'start_date', '')::date,
153
+ NULLIF(p_project->>'target_date', '')::date,
154
+ CASE WHEN v_status = 'canceled' THEN NULLIF(btrim(p_project->>'cancellation_reason'), '') ELSE NULL END,
155
+ NULLIF(btrim(p_project->>'summary'), ''), 'hidden',
156
+ NULLIF(btrim(p_project->>'client_summary'), ''),
157
+ NULLIF(btrim(p_project->>'client_scope'), ''),
158
+ NULLIF(p_project->>'client_contact_profile_id', '')::uuid
159
+ ) RETURNING id INTO v_project_id;
160
+
161
+ INSERT INTO public.project_organizations (project_id, organization_id, is_primary, added_by_profile_id)
162
+ SELECT v_project_id, selected.organization_id,
163
+ selected.organization_id = v_primary_organization_id, v_actor_profile_id
164
+ FROM (SELECT DISTINCT organization_id FROM unnest(p_organization_ids) requested(organization_id)) selected
165
+ ON CONFLICT ON CONSTRAINT project_organizations_pkey
166
+ DO UPDATE SET is_primary = EXCLUDED.is_primary;
167
+
168
+ INSERT INTO public.project_members (project_id, profile_id, role)
169
+ SELECT DISTINCT v_project_id, member.profile_id, member.role
170
+ FROM jsonb_to_recordset(p_members) AS member(profile_id uuid, role text);
171
+
172
+ v_mode := COALESCE(NULLIF(p_client_access->>'mode', ''), 'hidden');
173
+ v_access_organization_ids := ARRAY(
174
+ SELECT value::uuid FROM jsonb_array_elements_text(COALESCE(p_client_access->'organization_ids', '[]'::jsonb)) value
175
+ );
176
+ v_profile_grants := COALESCE(p_client_access->'profile_grants', '[]'::jsonb);
177
+ PERFORM public.set_project_client_access(v_project_id, v_mode, v_access_organization_ids, v_profile_grants);
178
+
179
+ INSERT INTO public.project_creation_requests (request_id, actor_profile_id, request_payload, project_id)
180
+ VALUES (p_request_id, v_actor_profile_id, v_payload, v_project_id);
181
+ RETURN QUERY SELECT v_project_id, true;
182
+ END;
183
+ $$;
184
+
185
+ REVOKE ALL ON FUNCTION public.create_project_with_client_access(uuid, jsonb, uuid[], jsonb, jsonb)
186
+ FROM PUBLIC, anon, service_role;
187
+ GRANT EXECUTE ON FUNCTION public.create_project_with_client_access(uuid, jsonb, uuid[], jsonb, jsonb)
188
+ TO authenticated;
189
+
190
+ CREATE OR REPLACE FUNCTION public.sync_project_members_exact(
191
+ p_project_id uuid,
192
+ p_members jsonb,
193
+ p_owner_profile_id uuid DEFAULT NULL
194
+ )
195
+ RETURNS void
196
+ LANGUAGE plpgsql
197
+ SECURITY DEFINER
198
+ SET search_path = public
199
+ AS $$
200
+ DECLARE
201
+ v_actor_profile_id uuid := public.current_profile_id();
202
+ v_current_owner_profile_id uuid;
203
+ v_client_contact_profile_id uuid;
204
+ v_members jsonb := COALESCE(p_members, '[]'::jsonb);
205
+ BEGIN
206
+ IF jsonb_typeof(v_members) <> 'array' THEN
207
+ RAISE EXCEPTION 'Project members must be a JSON array.' USING ERRCODE = '22023';
208
+ END IF;
209
+ SELECT project.owner_profile_id, project.client_contact_profile_id
210
+ INTO v_current_owner_profile_id, v_client_contact_profile_id
211
+ FROM public.projects project WHERE project.id = p_project_id FOR UPDATE;
212
+ IF NOT FOUND THEN RAISE EXCEPTION 'Projeto não encontrado.' USING ERRCODE = 'P0002'; END IF;
213
+
214
+ IF NOT (
215
+ public.is_admin()
216
+ OR (public.is_staff() AND v_current_owner_profile_id = v_actor_profile_id)
217
+ ) THEN
218
+ RAISE EXCEPTION 'Only project managers or administrators can manage the internal project team.' USING ERRCODE = '42501';
219
+ END IF;
220
+ IF EXISTS (
221
+ SELECT 1 FROM jsonb_to_recordset(v_members) AS member(profile_id uuid, role text)
222
+ WHERE member.profile_id IS NULL OR member.role NOT IN ('owner', 'contributor', 'observer')
223
+ ) THEN
224
+ RAISE EXCEPTION 'Every project member requires a valid profile and role.' USING ERRCODE = '22023';
225
+ END IF;
226
+ IF (SELECT count(*) <> count(DISTINCT member.profile_id) FROM jsonb_to_recordset(v_members) AS member(profile_id uuid, role text)) THEN
227
+ RAISE EXCEPTION 'Each internal profile can appear only once in the project team.' USING ERRCODE = '22023';
228
+ END IF;
229
+ IF (SELECT count(*) FROM jsonb_to_recordset(v_members) AS member(profile_id uuid, role text) WHERE member.role = 'owner') > 1
230
+ OR (p_owner_profile_id IS NULL AND EXISTS (SELECT 1 FROM jsonb_to_recordset(v_members) AS member(profile_id uuid, role text) WHERE member.role = 'owner'))
231
+ OR (p_owner_profile_id IS NOT NULL AND NOT EXISTS (
232
+ SELECT 1 FROM jsonb_to_recordset(v_members) AS member(profile_id uuid, role text)
233
+ WHERE member.profile_id = p_owner_profile_id AND member.role = 'owner'
234
+ )) THEN
235
+ RAISE EXCEPTION 'The optional project manager must match the owner membership exactly.' USING ERRCODE = '22023';
236
+ END IF;
237
+ IF EXISTS (
238
+ SELECT 1 FROM jsonb_to_recordset(v_members) AS member(profile_id uuid, role text)
239
+ LEFT JOIN public.user_role_assignments role_assignment
240
+ ON role_assignment.profile_id = member.profile_id AND role_assignment.role_code IN ('staff', 'admin')
241
+ WHERE role_assignment.id IS NULL
242
+ ) THEN
243
+ RAISE EXCEPTION 'Project members must be internal staff.' USING ERRCODE = '22023';
244
+ END IF;
245
+ IF v_client_contact_profile_id IS NOT NULL AND NOT EXISTS (
246
+ SELECT 1 FROM jsonb_to_recordset(v_members) AS member(profile_id uuid, role text)
247
+ WHERE member.profile_id = v_client_contact_profile_id AND member.role IN ('owner', 'contributor')
248
+ ) THEN
249
+ RAISE EXCEPTION 'The responsible client contact must remain an active internal project member.' USING ERRCODE = '22023';
250
+ END IF;
251
+ IF EXISTS (
252
+ SELECT 1
253
+ FROM public.project_tasks task
254
+ WHERE task.project_id = p_project_id
255
+ AND task.status <> 'done'
256
+ AND task.assignee_profile_id IS NOT NULL
257
+ AND NOT EXISTS (
258
+ SELECT 1 FROM jsonb_to_recordset(v_members) AS member(profile_id uuid, role text)
259
+ WHERE member.profile_id = task.assignee_profile_id AND member.role IN ('owner', 'contributor')
260
+ )
261
+ ) THEN
262
+ RAISE EXCEPTION 'Reassign or unassign open tasks before removing this person from the active project team.' USING ERRCODE = '23514';
263
+ END IF;
264
+
265
+ INSERT INTO public.project_members(project_id, profile_id, role)
266
+ SELECT p_project_id, member.profile_id, member.role
267
+ FROM jsonb_to_recordset(v_members) AS member(profile_id uuid, role text)
268
+ ON CONFLICT (project_id, profile_id) DO UPDATE SET role = EXCLUDED.role;
269
+ DELETE FROM public.project_members existing
270
+ WHERE existing.project_id = p_project_id
271
+ AND NOT EXISTS (SELECT 1 FROM jsonb_to_recordset(v_members) AS member(profile_id uuid, role text) WHERE member.profile_id = existing.profile_id);
272
+ UPDATE public.projects SET owner_profile_id = p_owner_profile_id WHERE id = p_project_id;
273
+ END;
274
+ $$;
275
+
276
+ REVOKE ALL ON FUNCTION public.sync_project_members_exact(uuid, jsonb, uuid)
277
+ FROM PUBLIC, anon, service_role;
278
+ GRANT EXECUTE ON FUNCTION public.sync_project_members_exact(uuid, jsonb, uuid)
279
+ TO authenticated;
280
+
281
+ CREATE OR REPLACE FUNCTION public.enforce_project_task_assignee_and_contributor_update()
282
+ RETURNS trigger
283
+ LANGUAGE plpgsql
284
+ SECURITY DEFINER
285
+ SET search_path = public
286
+ AS $$
287
+ DECLARE
288
+ v_actor_profile_id uuid := public.current_profile_id();
289
+ BEGIN
290
+ IF NEW.assignee_profile_id IS NOT NULL AND NOT EXISTS (
291
+ SELECT 1 FROM public.project_members member
292
+ WHERE member.project_id = NEW.project_id
293
+ AND member.profile_id = NEW.assignee_profile_id
294
+ AND member.role IN ('owner', 'contributor')
295
+ ) THEN
296
+ RAISE EXCEPTION 'Tasks can only be assigned to project managers or collaborators.' USING ERRCODE = '23514';
297
+ END IF;
298
+ IF TG_OP = 'UPDATE'
299
+ AND public.is_staff()
300
+ AND NOT public.is_admin()
301
+ AND NOT public.is_project_owner_member(OLD.project_id) THEN
302
+ IF OLD.assignee_profile_id IS DISTINCT FROM v_actor_profile_id
303
+ OR NEW.project_id IS DISTINCT FROM OLD.project_id
304
+ OR NEW.milestone_id IS DISTINCT FROM OLD.milestone_id
305
+ OR NEW.title IS DISTINCT FROM OLD.title
306
+ OR NEW.description IS DISTINCT FROM OLD.description
307
+ OR NEW.priority IS DISTINCT FROM OLD.priority
308
+ OR NEW.assignee_profile_id IS DISTINCT FROM OLD.assignee_profile_id
309
+ OR NEW.reporter_profile_id IS DISTINCT FROM OLD.reporter_profile_id
310
+ OR NEW.start_date IS DISTINCT FROM OLD.start_date
311
+ OR NEW.due_date IS DISTINCT FROM OLD.due_date THEN
312
+ RAISE EXCEPTION 'Collaborators can only update the state of their assigned tasks.' USING ERRCODE = '42501';
313
+ END IF;
314
+ END IF;
315
+ RETURN NEW;
316
+ END;
317
+ $$;
318
+
319
+ DROP TRIGGER IF EXISTS trg_enforce_project_task_assignee_and_contributor_update ON public.project_tasks;
320
+ CREATE TRIGGER trg_enforce_project_task_assignee_and_contributor_update
321
+ BEFORE INSERT OR UPDATE ON public.project_tasks
322
+ FOR EACH ROW EXECUTE FUNCTION public.enforce_project_task_assignee_and_contributor_update();
323
+
324
+ DROP POLICY IF EXISTS "Project staff create projects" ON public.projects;
325
+ CREATE POLICY "Administrators create projects" ON public.projects FOR INSERT TO authenticated
326
+ WITH CHECK (public.is_admin());
327
+
328
+ DROP POLICY IF EXISTS "Project staff update projects" ON public.projects;
329
+ CREATE POLICY "Project managers update projects" ON public.projects FOR UPDATE TO authenticated
330
+ USING (public.is_admin() OR (public.is_staff() AND public.is_project_owner_member(id)))
331
+ WITH CHECK (public.is_admin() OR (public.is_staff() AND public.is_project_owner_member(id)));
332
+
333
+ DROP POLICY IF EXISTS "Project staff delete projects" ON public.projects;
334
+ CREATE POLICY "Administrators delete projects" ON public.projects FOR DELETE TO authenticated
335
+ USING (public.is_admin());
336
+
337
+ DROP POLICY IF EXISTS "Project staff insert project milestones" ON public.project_milestones;
338
+ CREATE POLICY "Project managers insert project milestones" ON public.project_milestones FOR INSERT TO authenticated
339
+ WITH CHECK (public.is_admin() OR (public.is_staff() AND public.is_project_owner_member(project_id)));
340
+ DROP POLICY IF EXISTS "Project staff update project milestones" ON public.project_milestones;
341
+ CREATE POLICY "Project managers update project milestones" ON public.project_milestones FOR UPDATE TO authenticated
342
+ USING (public.is_admin() OR (public.is_staff() AND public.is_project_owner_member(project_id)))
343
+ WITH CHECK (public.is_admin() OR (public.is_staff() AND public.is_project_owner_member(project_id)));
344
+ DROP POLICY IF EXISTS "Project staff delete project milestones" ON public.project_milestones;
345
+ CREATE POLICY "Project managers delete project milestones" ON public.project_milestones FOR DELETE TO authenticated
346
+ USING (public.is_admin() OR (public.is_staff() AND public.is_project_owner_member(project_id)));
347
+
348
+ DROP POLICY IF EXISTS "Project staff insert project tasks" ON public.project_tasks;
349
+ CREATE POLICY "Project managers insert project tasks" ON public.project_tasks FOR INSERT TO authenticated
350
+ WITH CHECK (public.is_admin() OR (public.is_staff() AND public.is_project_owner_member(project_id)));
351
+ DROP POLICY IF EXISTS "Project staff update project tasks" ON public.project_tasks;
352
+ CREATE POLICY "Project team update permitted tasks" ON public.project_tasks FOR UPDATE TO authenticated
353
+ USING (
354
+ public.is_admin()
355
+ OR (public.is_staff() AND public.is_project_owner_member(project_id))
356
+ OR (public.is_staff() AND assignee_profile_id = public.current_profile_id() AND public.is_project_content_editor(project_id))
357
+ )
358
+ WITH CHECK (
359
+ public.is_admin()
360
+ OR (public.is_staff() AND public.is_project_owner_member(project_id))
361
+ OR (public.is_staff() AND assignee_profile_id = public.current_profile_id() AND public.is_project_content_editor(project_id))
362
+ );
363
+ DROP POLICY IF EXISTS "Project staff delete project tasks" ON public.project_tasks;
364
+ CREATE POLICY "Project managers delete project tasks" ON public.project_tasks FOR DELETE TO authenticated
365
+ USING (public.is_admin() OR (public.is_staff() AND public.is_project_owner_member(project_id)));
366
+
367
+ DROP POLICY IF EXISTS "Project staff create project links" ON public.project_links;
368
+ CREATE POLICY "Project managers create project links" ON public.project_links FOR INSERT TO authenticated
369
+ WITH CHECK (public.is_admin() OR (public.is_staff() AND public.is_project_owner_member(project_id)));
370
+ DROP POLICY IF EXISTS "Project staff update project links" ON public.project_links;
371
+ CREATE POLICY "Project managers update project links" ON public.project_links FOR UPDATE TO authenticated
372
+ USING (public.is_admin() OR (public.is_staff() AND public.is_project_owner_member(project_id)))
373
+ WITH CHECK (public.is_admin() OR (public.is_staff() AND public.is_project_owner_member(project_id)));
374
+ DROP POLICY IF EXISTS "Project staff delete project links" ON public.project_links;
375
+ CREATE POLICY "Project managers delete project links" ON public.project_links FOR DELETE TO authenticated
376
+ USING (public.is_admin() OR (public.is_staff() AND public.is_project_owner_member(project_id)));