create-bw-app 0.25.0 → 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 +1 -1
- package/src/constants.mjs +6 -6
- package/src/generator.mjs +4 -4
- package/template/base/app/(shell)/shell-layout-client.tsx +1 -1
- package/template/base/config/module-toolbar-controls.tsx +4 -4
- package/template/supabase/modules/admin/migrations/20260814100000_admin_client_invitations.sql +8 -0
- package/template/supabase/modules/projects/migrations/20260815120000_project_client_access_member_roles.sql +77 -0
- package/template/supabase/modules/projects/migrations/20260815133000_project_admin_creation_and_task_permissions.sql +376 -0
package/package.json
CHANGED
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.
|
|
185
|
+
"@brightweblabs/core-auth": "^0.12.0",
|
|
186
186
|
"@brightweblabs/infra": "^0.7.0",
|
|
187
|
-
"@brightweblabs/module-admin": "^0.9.
|
|
188
|
-
"@brightweblabs/module-crm": "^0.17.
|
|
189
|
-
"@brightweblabs/module-marketing": "^0.4.
|
|
190
|
-
"@brightweblabs/module-orgs": "^0.6.
|
|
191
|
-
"@brightweblabs/module-projects": "^0.18.
|
|
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
|
}
|
package/template/supabase/modules/admin/migrations/20260814100000_admin_client_invitations.sql
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Allow portal clients to be invited before they are associated with an organization.
|
|
2
|
+
|
|
3
|
+
ALTER TABLE public.admin_user_invitations
|
|
4
|
+
DROP CONSTRAINT IF EXISTS admin_user_invitations_role_code_check;
|
|
5
|
+
|
|
6
|
+
ALTER TABLE public.admin_user_invitations
|
|
7
|
+
ADD CONSTRAINT admin_user_invitations_role_code_check
|
|
8
|
+
CHECK (role_code IN ('client', 'staff', 'admin'));
|
|
@@ -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)));
|