create-bw-app 0.24.8 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/src/bw.mjs +1 -1
  4. package/src/constants.mjs +14 -9
  5. package/src/generator.mjs +3 -6
  6. package/src/migrations.mjs +43 -5
  7. package/src/upgrade.mjs +54 -2
  8. package/template/base/app/(shell)/dashboard/dashboard-page-mount.tsx +7 -0
  9. package/template/base/app/(shell)/dashboard/page.tsx +1 -1
  10. package/template/base/app/(shell)/shell-layout-client.tsx +28 -7
  11. package/template/modules/crm/app/(shell)/crm/organizations/[id]/page.tsx +1 -1
  12. package/template/modules/crm/app/(shell)/crm/organizations/page.tsx +1 -3
  13. package/template/modules/crm/app/(shell)/crm/page.tsx +1 -5
  14. package/template/modules/crm/app/(shell)/crm/report/page.tsx +1 -5
  15. package/template/modules/projects/app/(shell)/account/page.tsx +5 -1
  16. package/template/modules/projects/app/(shell)/account/perfil/page.tsx +1 -0
  17. package/template/modules/projects/app/(shell)/projetos/projetos-server-mounts.tsx +3 -2
  18. package/template/modules/projects/app/api/account/projects/[id]/route.ts +2 -0
  19. package/template/modules/projects/app/api/account/projects/route.ts +2 -0
  20. package/template/modules/projects/app/api/projects/[id]/client-access/route.ts +2 -0
  21. package/template/modules/projects/app/api/projects/[id]/organizations/route.ts +2 -0
  22. package/template/modules/projects/app/api/projects/_handlers.ts +24 -0
  23. package/template/modules/projects/app/api/projects/setup-options/route.ts +2 -0
  24. package/template/supabase/modules/orgs/migrations/20260812120000_staff_only_organization_access_management.sql +7 -0
  25. package/template/supabase/modules/projects/migrations/20260810120000_project_client_access.sql +328 -0
  26. package/template/supabase/modules/projects/migrations/20260811120000_project_client_access_expand.sql +1061 -0
  27. package/template/supabase/modules/projects/migrations/20260811120500_project_member_sync_hardening.sql +156 -0
  28. package/template/supabase/modules/projects/migrations/20260811121000_project_client_access_enforcement.sql +651 -0
  29. package/template/supabase/modules/projects/migrations/20260811121500_project_client_organization_memberships.sql +29 -0
  30. package/template/supabase/modules/projects/migrations/20260811121700_project_client_meta_preview.sql +193 -0
  31. package/template/supabase/modules/projects/migrations/20260811122000_project_client_access_identity_cleanup.sql +201 -0
  32. package/template/supabase/modules/projects/migrations/20260811122500_remove_project_client_next_steps.sql +681 -0
@@ -0,0 +1,193 @@
1
+ -- Add bounded client-visible meta previews before destructive cleanup. Keep the
2
+ -- legacy next-steps output during this compatibility phase so upgraded readers
3
+ -- can deploy while older consumers still understand the previous RPC shape.
4
+
5
+ DROP FUNCTION public.get_current_client_project(uuid);
6
+ DROP FUNCTION public.list_current_client_projects();
7
+
8
+ CREATE FUNCTION public.list_current_client_projects()
9
+ RETURNS TABLE (
10
+ project_id uuid,
11
+ name text,
12
+ reference text,
13
+ status text,
14
+ start_date date,
15
+ target_date date,
16
+ completed_at timestamptz,
17
+ archived_at timestamptz,
18
+ client_summary text,
19
+ client_scope text,
20
+ client_contact jsonb,
21
+ client_next_steps text,
22
+ meta_preview jsonb,
23
+ organizations jsonb,
24
+ progress_percent integer
25
+ )
26
+ LANGUAGE sql
27
+ STABLE
28
+ SECURITY DEFINER
29
+ SET search_path = public
30
+ AS $$
31
+ SELECT
32
+ project.id,
33
+ project.name,
34
+ project.code,
35
+ project.status,
36
+ project.start_date,
37
+ project.target_date,
38
+ project.completed_at,
39
+ project.archived_at,
40
+ project.client_summary,
41
+ project.client_scope,
42
+ CASE WHEN contact.id IS NULL THEN NULL ELSE jsonb_build_object(
43
+ 'profile_id', contact.id,
44
+ 'name', COALESCE(NULLIF(btrim(concat_ws(' ', contact.first_name, contact.last_name)), ''), contact.email),
45
+ 'email', contact.email
46
+ ) END,
47
+ project.client_next_steps,
48
+ COALESCE((
49
+ SELECT jsonb_agg(jsonb_build_object(
50
+ 'id', current_meta.id,
51
+ 'title', current_meta.title,
52
+ 'status', current_meta.status,
53
+ 'target_date', current_meta.target_date,
54
+ 'completed_at', current_meta.completed_at,
55
+ 'position', current_meta.position
56
+ ) ORDER BY current_meta.position, current_meta.target_date, current_meta.id)
57
+ FROM (
58
+ SELECT milestone.*
59
+ FROM public.project_milestones milestone
60
+ WHERE milestone.project_id = project.id
61
+ AND milestone.visibility = 'client'
62
+ AND milestone.status IN ('in_progress', 'delayed')
63
+ ORDER BY milestone.position, milestone.target_date, milestone.id
64
+ LIMIT 3
65
+ ) current_meta
66
+ ), (
67
+ SELECT jsonb_agg(jsonb_build_object(
68
+ 'id', pending_meta.id,
69
+ 'title', pending_meta.title,
70
+ 'status', pending_meta.status,
71
+ 'target_date', pending_meta.target_date,
72
+ 'completed_at', pending_meta.completed_at,
73
+ 'position', pending_meta.position
74
+ ) ORDER BY pending_meta.position, pending_meta.target_date, pending_meta.id)
75
+ FROM (
76
+ SELECT milestone.*
77
+ FROM public.project_milestones milestone
78
+ WHERE milestone.project_id = project.id
79
+ AND milestone.visibility = 'client'
80
+ AND milestone.status = 'pending'
81
+ ORDER BY milestone.position, milestone.target_date, milestone.id
82
+ LIMIT 1
83
+ ) pending_meta
84
+ ), '[]'::jsonb),
85
+ COALESCE((
86
+ SELECT jsonb_agg(jsonb_build_object('id', organization.id, 'name', organization.name) ORDER BY organization.name)
87
+ FROM public.project_client_organizations audience
88
+ JOIN public.organization_members org_member
89
+ ON org_member.organization_id = audience.organization_id
90
+ AND org_member.profile_id = public.current_profile_id()
91
+ JOIN public.organizations organization ON organization.id = audience.organization_id
92
+ WHERE audience.project_id = project.id
93
+ AND (
94
+ project.client_access_mode = 'all_org_clients'
95
+ OR EXISTS (
96
+ SELECT 1 FROM public.project_client_profiles grant_row
97
+ WHERE grant_row.project_id = project.id
98
+ AND grant_row.organization_id = audience.organization_id
99
+ AND grant_row.profile_id = public.current_profile_id()
100
+ )
101
+ )
102
+ ), '[]'::jsonb),
103
+ CASE WHEN count(milestone.id) = 0 THEN 0
104
+ ELSE round(100.0 * count(milestone.id) FILTER (WHERE milestone.status = 'achieved') / count(milestone.id))::integer
105
+ END
106
+ FROM public.projects project
107
+ LEFT JOIN public.profiles contact ON contact.id = project.client_contact_profile_id
108
+ LEFT JOIN public.project_milestones milestone
109
+ ON milestone.project_id = project.id AND milestone.visibility = 'client'
110
+ WHERE public.can_current_client_view_project(project.id)
111
+ GROUP BY project.id, contact.id
112
+ ORDER BY project.updated_at DESC;
113
+ $$;
114
+
115
+ REVOKE ALL ON FUNCTION public.list_current_client_projects()
116
+ FROM PUBLIC, anon, service_role;
117
+ GRANT EXECUTE ON FUNCTION public.list_current_client_projects()
118
+ TO authenticated;
119
+
120
+ CREATE FUNCTION public.get_current_client_project(target_project_id uuid)
121
+ RETURNS TABLE (
122
+ project_id uuid,
123
+ name text,
124
+ reference text,
125
+ status text,
126
+ start_date date,
127
+ target_date date,
128
+ completed_at timestamptz,
129
+ archived_at timestamptz,
130
+ client_summary text,
131
+ client_scope text,
132
+ client_contact jsonb,
133
+ client_next_steps text,
134
+ meta_preview jsonb,
135
+ organizations jsonb,
136
+ progress_percent integer,
137
+ metas jsonb,
138
+ documents jsonb
139
+ )
140
+ LANGUAGE sql
141
+ STABLE
142
+ SECURITY DEFINER
143
+ SET search_path = public
144
+ AS $$
145
+ SELECT
146
+ list_row.project_id,
147
+ list_row.name,
148
+ list_row.reference,
149
+ list_row.status,
150
+ list_row.start_date,
151
+ list_row.target_date,
152
+ list_row.completed_at,
153
+ list_row.archived_at,
154
+ list_row.client_summary,
155
+ list_row.client_scope,
156
+ list_row.client_contact,
157
+ list_row.client_next_steps,
158
+ list_row.meta_preview,
159
+ list_row.organizations,
160
+ list_row.progress_percent,
161
+ COALESCE((
162
+ SELECT jsonb_agg(jsonb_build_object(
163
+ 'id', milestone.id,
164
+ 'title', milestone.title,
165
+ 'status', milestone.status,
166
+ 'target_date', milestone.target_date,
167
+ 'completed_at', milestone.completed_at,
168
+ 'position', milestone.position
169
+ ) ORDER BY milestone.position, milestone.target_date, milestone.id)
170
+ FROM public.project_milestones milestone
171
+ WHERE milestone.project_id = target_project_id
172
+ AND milestone.visibility = 'client'
173
+ ), '[]'::jsonb),
174
+ COALESCE((
175
+ SELECT jsonb_agg(jsonb_build_object(
176
+ 'id', link.id,
177
+ 'label', link.label,
178
+ 'url', link.url,
179
+ 'kind', link.kind,
180
+ 'created_at', link.created_at
181
+ ) ORDER BY link.created_at DESC, link.id)
182
+ FROM public.project_links link
183
+ WHERE link.project_id = target_project_id
184
+ AND link.visibility = 'client'
185
+ ), '[]'::jsonb)
186
+ FROM public.list_current_client_projects() list_row
187
+ WHERE list_row.project_id = target_project_id;
188
+ $$;
189
+
190
+ REVOKE ALL ON FUNCTION public.get_current_client_project(uuid)
191
+ FROM PUBLIC, anon, service_role;
192
+ GRANT EXECUTE ON FUNCTION public.get_current_client_project(uuid)
193
+ TO authenticated;
@@ -0,0 +1,201 @@
1
+ -- bw-migration-safety: destructive
2
+ -- Destructively normalize legacy project identities only after all readers and
3
+ -- writers use the explicit internal-team and client-audience model.
4
+
5
+ DELETE FROM public.project_members member
6
+ WHERE NOT EXISTS (
7
+ SELECT 1
8
+ FROM public.user_role_assignments role_assignment
9
+ WHERE role_assignment.profile_id = member.profile_id
10
+ AND role_assignment.role_code IN ('staff', 'admin')
11
+ );
12
+
13
+ -- The enforcement migration intentionally rejects unauthenticated identity
14
+ -- changes. Temporarily disable only that column guard for this reviewed,
15
+ -- destructive normalization; deferred consistency checks remain active.
16
+ ALTER TABLE public.projects DISABLE TRIGGER trg_protect_project_identity_fields;
17
+
18
+ UPDATE public.projects project
19
+ SET owner_profile_id = NULL
20
+ WHERE owner_profile_id IS NOT NULL
21
+ AND NOT EXISTS (
22
+ SELECT 1
23
+ FROM public.user_role_assignments role_assignment
24
+ WHERE role_assignment.profile_id = project.owner_profile_id
25
+ AND role_assignment.role_code IN ('staff', 'admin')
26
+ );
27
+
28
+ ALTER TABLE public.projects ENABLE TRIGGER trg_protect_project_identity_fields;
29
+
30
+ -- Existing owner_profile_id is canonical during cleanup. Keep exactly its
31
+ -- matching owner membership and demote stale owner rows.
32
+ UPDATE public.project_members member
33
+ SET role = 'contributor'
34
+ FROM public.projects project
35
+ WHERE project.id = member.project_id
36
+ AND member.role = 'owner'
37
+ AND member.profile_id IS DISTINCT FROM project.owner_profile_id;
38
+
39
+ DELETE FROM public.project_members member
40
+ USING public.projects project
41
+ WHERE project.id = member.project_id
42
+ AND project.owner_profile_id IS NULL
43
+ AND member.role = 'owner';
44
+
45
+ INSERT INTO public.project_members (project_id, profile_id, role)
46
+ SELECT project.id, project.owner_profile_id, 'owner'
47
+ FROM public.projects project
48
+ WHERE project.owner_profile_id IS NOT NULL
49
+ ON CONFLICT (project_id, profile_id)
50
+ DO UPDATE SET role = 'owner';
51
+
52
+ CREATE OR REPLACE FUNCTION public.enforce_internal_project_identity()
53
+ RETURNS trigger
54
+ LANGUAGE plpgsql
55
+ SECURITY DEFINER
56
+ SET search_path = public
57
+ AS $$
58
+ DECLARE
59
+ v_profile_id uuid;
60
+ BEGIN
61
+ -- Keep table-specific NEW fields in separate statements. A CASE expression
62
+ -- still asks PostgreSQL to resolve both record fields and fails on projects
63
+ -- because that row type has no profile_id column.
64
+ IF TG_TABLE_NAME = 'projects' THEN
65
+ v_profile_id := NEW.owner_profile_id;
66
+ ELSE
67
+ v_profile_id := NEW.profile_id;
68
+ END IF;
69
+
70
+ IF v_profile_id IS NOT NULL AND NOT EXISTS (
71
+ SELECT 1
72
+ FROM public.user_role_assignments role_assignment
73
+ WHERE role_assignment.profile_id = v_profile_id
74
+ AND role_assignment.role_code IN ('staff', 'admin')
75
+ ) THEN
76
+ RAISE EXCEPTION 'Project owners and members must be internal staff.'
77
+ USING ERRCODE = '23514';
78
+ END IF;
79
+ RETURN NEW;
80
+ END;
81
+ $$;
82
+
83
+ DROP TRIGGER IF EXISTS trg_enforce_internal_project_owner ON public.projects;
84
+ CREATE TRIGGER trg_enforce_internal_project_owner
85
+ BEFORE INSERT OR UPDATE OF owner_profile_id ON public.projects
86
+ FOR EACH ROW
87
+ EXECUTE FUNCTION public.enforce_internal_project_identity();
88
+
89
+ DROP TRIGGER IF EXISTS trg_enforce_internal_project_member ON public.project_members;
90
+ CREATE TRIGGER trg_enforce_internal_project_member
91
+ BEFORE INSERT OR UPDATE OF profile_id, role ON public.project_members
92
+ FOR EACH ROW
93
+ EXECUTE FUNCTION public.enforce_internal_project_identity();
94
+
95
+ CREATE OR REPLACE FUNCTION public.validate_project_owner_consistency()
96
+ RETURNS trigger
97
+ LANGUAGE plpgsql
98
+ SECURITY DEFINER
99
+ SET search_path = public
100
+ AS $$
101
+ DECLARE
102
+ v_project_id uuid;
103
+ v_owner_profile_id uuid;
104
+ v_owner_count integer;
105
+ v_matching_owner_count integer;
106
+ BEGIN
107
+ IF TG_TABLE_NAME = 'projects' THEN
108
+ v_project_id := COALESCE(NEW.id, OLD.id);
109
+ ELSE
110
+ v_project_id := COALESCE(NEW.project_id, OLD.project_id);
111
+ END IF;
112
+
113
+ SELECT project.owner_profile_id
114
+ INTO v_owner_profile_id
115
+ FROM public.projects project
116
+ WHERE project.id = v_project_id;
117
+ IF NOT FOUND THEN
118
+ RETURN NULL;
119
+ END IF;
120
+
121
+ SELECT
122
+ count(*) FILTER (WHERE member.role = 'owner'),
123
+ count(*) FILTER (
124
+ WHERE member.role = 'owner'
125
+ AND member.profile_id = v_owner_profile_id
126
+ )
127
+ INTO v_owner_count, v_matching_owner_count
128
+ FROM public.project_members member
129
+ WHERE member.project_id = v_project_id;
130
+
131
+ IF (v_owner_profile_id IS NULL AND v_owner_count <> 0)
132
+ OR (v_owner_profile_id IS NOT NULL AND (v_owner_count <> 1 OR v_matching_owner_count <> 1)) THEN
133
+ RAISE EXCEPTION 'Project owner and owner membership must match exactly.'
134
+ USING ERRCODE = '23514';
135
+ END IF;
136
+ RETURN NULL;
137
+ END;
138
+ $$;
139
+
140
+ DROP TRIGGER IF EXISTS trg_validate_project_owner_from_project ON public.projects;
141
+ CREATE CONSTRAINT TRIGGER trg_validate_project_owner_from_project
142
+ AFTER INSERT OR UPDATE OF owner_profile_id ON public.projects
143
+ DEFERRABLE INITIALLY DEFERRED
144
+ FOR EACH ROW
145
+ EXECUTE FUNCTION public.validate_project_owner_consistency();
146
+
147
+ DROP TRIGGER IF EXISTS trg_validate_project_owner_from_member ON public.project_members;
148
+ CREATE CONSTRAINT TRIGGER trg_validate_project_owner_from_member
149
+ AFTER INSERT OR UPDATE OR DELETE ON public.project_members
150
+ DEFERRABLE INITIALLY DEFERRED
151
+ FOR EACH ROW
152
+ EXECUTE FUNCTION public.validate_project_owner_consistency();
153
+
154
+ CREATE OR REPLACE FUNCTION public.validate_project_primary_participation()
155
+ RETURNS trigger
156
+ LANGUAGE plpgsql
157
+ SECURITY DEFINER
158
+ SET search_path = public
159
+ AS $$
160
+ DECLARE
161
+ v_project_id uuid := COALESCE(NEW.project_id, OLD.project_id);
162
+ v_primary_organization_id uuid;
163
+ v_matching_primary_count integer;
164
+ v_other_primary_count integer;
165
+ BEGIN
166
+ SELECT project.organization_id
167
+ INTO v_primary_organization_id
168
+ FROM public.projects project
169
+ WHERE project.id = v_project_id;
170
+ IF NOT FOUND THEN
171
+ RETURN NULL;
172
+ END IF;
173
+
174
+ SELECT
175
+ count(*) FILTER (
176
+ WHERE participant.organization_id = v_primary_organization_id
177
+ AND participant.is_primary
178
+ ),
179
+ count(*) FILTER (
180
+ WHERE participant.is_primary
181
+ AND participant.organization_id <> v_primary_organization_id
182
+ )
183
+ INTO v_matching_primary_count, v_other_primary_count
184
+ FROM public.project_organizations participant
185
+ WHERE participant.project_id = v_project_id;
186
+
187
+ IF v_matching_primary_count <> 1 OR v_other_primary_count <> 0 THEN
188
+ RAISE EXCEPTION 'The project primary organization must be its only primary participant.'
189
+ USING ERRCODE = '23514';
190
+ END IF;
191
+ RETURN NULL;
192
+ END;
193
+ $$;
194
+
195
+ DROP TRIGGER IF EXISTS trg_validate_project_primary_participation
196
+ ON public.project_organizations;
197
+ CREATE CONSTRAINT TRIGGER trg_validate_project_primary_participation
198
+ AFTER INSERT OR UPDATE OR DELETE ON public.project_organizations
199
+ DEFERRABLE INITIALLY DEFERRED
200
+ FOR EACH ROW
201
+ EXECUTE FUNCTION public.validate_project_primary_participation();