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.
- package/package.json +2 -1
- package/src/add.mjs +3 -2
- package/src/constants.mjs +10 -6
- package/src/generator.mjs +13 -9
- package/src/migration-order.mjs +8 -0
- package/src/migrations.mjs +73 -6
- package/src/update.mjs +152 -5
- package/src/upgrade.mjs +92 -14
- package/template/modules/projects/app/api/account/projects/[id]/tasks/[itemId]/route.ts +2 -0
- package/template/modules/projects/app/api/account/projects/[id]/tasks/route.ts +2 -0
- package/template/modules/projects/app/api/projects/[id]/client-tasks/[itemId]/route.ts +2 -0
- package/template/modules/projects/app/api/projects/[id]/client-tasks/route.ts +2 -0
- package/template/supabase/modules/admin/migrations/20261014183138_auth_verified_admin_acceptance.sql +232 -0
- package/template/supabase/modules/core/migrations/20261014183138_protect_profile_identity.sql +272 -0
- package/template/supabase/modules/orgs/migrations/20261014183138_auth_verified_organization_membership.sql +194 -0
- package/template/supabase/modules/projects/migrations/20261015135430_enforce_task_milestone_project.sql +52 -0
- package/template/supabase/modules/projects/migrations/20261015143936_client_task_collaboration.sql +763 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
-- auth_verified_organization_membership
|
|
2
|
+
-- target: orgs
|
|
3
|
+
-- created_at: 2026-10-14T18:31:38.382Z
|
|
4
|
+
|
|
5
|
+
-- One atomic statement protects old CLI/manual continue-on-error application:
|
|
6
|
+
-- failed prerequisites or later DDL leave every existing guard/function intact.
|
|
7
|
+
DO $identity_migration$
|
|
8
|
+
BEGIN
|
|
9
|
+
IF to_regprocedure('public.assert_profile_auth_identity_v1()') IS NULL THEN
|
|
10
|
+
RAISE EXCEPTION 'IDENTITY_MIGRATION_REQUIRED: apply Core 20261014183138_protect_profile_identity before Admin/Orgs identity migrations.' USING ERRCODE = '55000';
|
|
11
|
+
END IF;
|
|
12
|
+
PERFORM public.assert_profile_auth_identity_v1();
|
|
13
|
+
EXECUTE $identity_ddl$
|
|
14
|
+
-- Each direct assignment, including database integration and reconciliation, commits together.
|
|
15
|
+
CREATE OR REPLACE FUNCTION public.assign_organization_member_atomic(
|
|
16
|
+
p_organization_id uuid, p_profile_id uuid, p_email text, p_role text, p_actor_profile_id uuid
|
|
17
|
+
) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
|
|
18
|
+
DECLARE
|
|
19
|
+
v_user_id uuid;
|
|
20
|
+
v_role text;
|
|
21
|
+
v_inserted boolean;
|
|
22
|
+
v_status text;
|
|
23
|
+
v_contact_id uuid;
|
|
24
|
+
BEGIN
|
|
25
|
+
IF p_role IS NULL OR p_role NOT IN ('admin', 'member') THEN
|
|
26
|
+
RAISE EXCEPTION 'Função de membro inválida.';
|
|
27
|
+
END IF;
|
|
28
|
+
IF p_email IS NULL OR btrim(p_email) = '' THEN
|
|
29
|
+
RAISE EXCEPTION 'Email de membro inválido.';
|
|
30
|
+
END IF;
|
|
31
|
+
PERFORM id FROM public.profiles WHERE id = p_actor_profile_id;
|
|
32
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Perfil do autor não encontrado.'; END IF;
|
|
33
|
+
|
|
34
|
+
-- Auth -> ordered invitations -> profile -> organisation -> membership.
|
|
35
|
+
v_user_id := public.lock_confirmed_profile_auth_identity(p_profile_id, p_email);
|
|
36
|
+
PERFORM id FROM public.organization_invitations
|
|
37
|
+
WHERE organization_id = p_organization_id AND lower(btrim(invited_email)) = lower(btrim(p_email))
|
|
38
|
+
ORDER BY id FOR UPDATE;
|
|
39
|
+
PERFORM id FROM public.profiles WHERE id = p_profile_id AND user_id = v_user_id
|
|
40
|
+
AND lower(btrim(email)) = lower(btrim(p_email)) FOR UPDATE;
|
|
41
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'O perfil não corresponde ao email do membro.' USING ERRCODE = 'BW002'; END IF;
|
|
42
|
+
PERFORM id FROM public.organizations WHERE id = p_organization_id FOR UPDATE;
|
|
43
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Organização não encontrada.'; END IF;
|
|
44
|
+
|
|
45
|
+
-- A writer outside this RPC may have inserted since the caller's snapshot.
|
|
46
|
+
-- Wait for that row, then derive the outcome from its committed current role.
|
|
47
|
+
INSERT INTO public.organization_members(organization_id, profile_id, role)
|
|
48
|
+
VALUES(p_organization_id, p_profile_id, p_role)
|
|
49
|
+
ON CONFLICT(organization_id, profile_id) DO NOTHING;
|
|
50
|
+
v_inserted := FOUND;
|
|
51
|
+
SELECT role INTO STRICT v_role FROM public.organization_members
|
|
52
|
+
WHERE organization_id = p_organization_id AND profile_id = p_profile_id FOR UPDATE;
|
|
53
|
+
IF v_inserted THEN v_status := 'immediate_access';
|
|
54
|
+
ELSIF v_role = p_role THEN v_status := 'already_member';
|
|
55
|
+
ELSE
|
|
56
|
+
UPDATE public.organization_members SET role = p_role
|
|
57
|
+
WHERE organization_id = p_organization_id AND profile_id = p_profile_id;
|
|
58
|
+
v_status := 'membership_updated';
|
|
59
|
+
END IF;
|
|
60
|
+
|
|
61
|
+
BEGIN
|
|
62
|
+
-- Client-owned SQL is transactional and takes precedence over stock CRM.
|
|
63
|
+
IF to_regprocedure('public.client_organization_invitation_contact_hook(uuid,uuid)') IS NOT NULL THEN
|
|
64
|
+
EXECUTE 'SELECT public.client_organization_invitation_contact_hook($1, $2)' INTO v_contact_id
|
|
65
|
+
USING p_profile_id, p_organization_id;
|
|
66
|
+
ELSIF to_regclass('public.crm_contacts') IS NOT NULL THEN
|
|
67
|
+
IF to_regprocedure('public.link_organization_invitation_contact(uuid,uuid)') IS NULL THEN
|
|
68
|
+
RAISE EXCEPTION 'Aplique a migration atomic_invitation_contact_link.';
|
|
69
|
+
END IF;
|
|
70
|
+
EXECUTE 'SELECT public.link_organization_invitation_contact($1, $2)' INTO v_contact_id
|
|
71
|
+
USING p_profile_id, p_organization_id;
|
|
72
|
+
END IF;
|
|
73
|
+
EXCEPTION WHEN OTHERS THEN
|
|
74
|
+
RAISE EXCEPTION 'ORGANIZATION_CONTACT_LINK_FAILED: %', SQLERRM USING ERRCODE = 'BW001';
|
|
75
|
+
END;
|
|
76
|
+
|
|
77
|
+
UPDATE public.organizations SET primary_contact_id = (
|
|
78
|
+
SELECT profile_id FROM public.organization_members
|
|
79
|
+
WHERE organization_id = p_organization_id AND role = 'admin'
|
|
80
|
+
ORDER BY joined_at, profile_id LIMIT 1
|
|
81
|
+
) WHERE id = p_organization_id;
|
|
82
|
+
UPDATE public.organization_invitations SET status = 'accepted', accepted_at = now(),
|
|
83
|
+
accepted_by_profile_id = p_profile_id, accepted_contact_id = v_contact_id
|
|
84
|
+
WHERE organization_id = p_organization_id AND lower(btrim(invited_email)) = lower(btrim(p_email))
|
|
85
|
+
AND status = 'pending';
|
|
86
|
+
RETURN jsonb_build_object('status', v_status, 'profileId', p_profile_id, 'organizationId', p_organization_id);
|
|
87
|
+
END;
|
|
88
|
+
$$;
|
|
89
|
+
REVOKE ALL ON FUNCTION public.assign_organization_member_atomic(uuid, uuid, text, text, uuid) FROM PUBLIC, anon, authenticated;
|
|
90
|
+
GRANT EXECUTE ON FUNCTION public.assign_organization_member_atomic(uuid, uuid, text, text, uuid) TO service_role;
|
|
91
|
+
|
|
92
|
+
-- atomic_organization_invitation_acceptance
|
|
93
|
+
-- target: orgs
|
|
94
|
+
-- created_at: 2026-09-06T10:16:16.656Z
|
|
95
|
+
|
|
96
|
+
CREATE OR REPLACE FUNCTION public.accept_organization_invitation(p_invitation_id uuid, p_profile_id uuid, p_user_email text)
|
|
97
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
|
|
98
|
+
DECLARE
|
|
99
|
+
v_invitation public.organization_invitations%ROWTYPE;
|
|
100
|
+
v_user_id uuid;
|
|
101
|
+
v_contact_id uuid;
|
|
102
|
+
BEGIN
|
|
103
|
+
v_user_id := public.lock_confirmed_profile_auth_identity(p_profile_id, p_user_email);
|
|
104
|
+
-- Lock the same ordered set as direct assignment, including terminal retries.
|
|
105
|
+
SELECT * INTO v_invitation FROM public.organization_invitations WHERE id = p_invitation_id;
|
|
106
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Convite não encontrado.'; END IF;
|
|
107
|
+
PERFORM id FROM public.organization_invitations
|
|
108
|
+
WHERE organization_id = v_invitation.organization_id AND lower(btrim(invited_email)) = lower(btrim(v_invitation.invited_email))
|
|
109
|
+
ORDER BY id FOR UPDATE;
|
|
110
|
+
SELECT * INTO v_invitation FROM public.organization_invitations WHERE id = p_invitation_id FOR UPDATE;
|
|
111
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Convite não encontrado.'; END IF;
|
|
112
|
+
IF lower(btrim(p_user_email)) IS DISTINCT FROM lower(btrim(v_invitation.invited_email)) THEN
|
|
113
|
+
RAISE EXCEPTION 'Este convite pertence a outro email.';
|
|
114
|
+
END IF;
|
|
115
|
+
-- Recheck the profile link after Auth and invitations are locked.
|
|
116
|
+
PERFORM p.id FROM public.profiles p
|
|
117
|
+
WHERE p.id = p_profile_id AND p.user_id = v_user_id
|
|
118
|
+
AND lower(btrim(p.email)) = lower(btrim(v_invitation.invited_email))
|
|
119
|
+
FOR UPDATE OF p;
|
|
120
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'Este convite pertence a outro email.' USING ERRCODE = 'BW002'; END IF;
|
|
121
|
+
IF v_invitation.status = 'accepted' AND v_invitation.accepted_by_profile_id = p_profile_id THEN
|
|
122
|
+
RETURN jsonb_build_object('status', 'accepted', 'organizationId', v_invitation.organization_id);
|
|
123
|
+
END IF;
|
|
124
|
+
IF v_invitation.status <> 'pending' THEN RAISE EXCEPTION 'Este convite já não está disponível.'; END IF;
|
|
125
|
+
IF v_invitation.expires_at <= clock_timestamp() THEN
|
|
126
|
+
UPDATE public.organization_invitations SET status = 'expired' WHERE id = p_invitation_id;
|
|
127
|
+
RETURN jsonb_build_object('status', 'expired');
|
|
128
|
+
END IF;
|
|
129
|
+
|
|
130
|
+
-- Serialize different invitations for the same organization's primary-contact choice.
|
|
131
|
+
PERFORM id FROM public.organizations WHERE id = v_invitation.organization_id FOR UPDATE;
|
|
132
|
+
-- Client-owned integration takes precedence; package updates never create or replace it.
|
|
133
|
+
IF to_regprocedure('public.client_organization_invitation_contact_hook(uuid,uuid)') IS NOT NULL THEN
|
|
134
|
+
EXECUTE 'SELECT public.client_organization_invitation_contact_hook($1, $2)' INTO v_contact_id
|
|
135
|
+
USING p_profile_id, v_invitation.organization_id;
|
|
136
|
+
ELSIF to_regclass('public.crm_contacts') IS NOT NULL THEN
|
|
137
|
+
IF to_regprocedure('public.link_organization_invitation_contact(uuid,uuid)') IS NULL THEN
|
|
138
|
+
RAISE EXCEPTION 'Aplique a migration atomic_invitation_contact_link antes de aceitar o convite.';
|
|
139
|
+
END IF;
|
|
140
|
+
EXECUTE 'SELECT public.link_organization_invitation_contact($1, $2)' INTO v_contact_id
|
|
141
|
+
USING p_profile_id, v_invitation.organization_id;
|
|
142
|
+
END IF;
|
|
143
|
+
INSERT INTO public.organization_members(organization_id, profile_id, role)
|
|
144
|
+
VALUES(v_invitation.organization_id, p_profile_id, v_invitation.role)
|
|
145
|
+
ON CONFLICT(organization_id, profile_id) DO UPDATE SET role = EXCLUDED.role;
|
|
146
|
+
UPDATE public.organizations SET primary_contact_id = (
|
|
147
|
+
SELECT profile_id FROM public.organization_members WHERE organization_id = v_invitation.organization_id AND role = 'admin'
|
|
148
|
+
ORDER BY joined_at, profile_id LIMIT 1
|
|
149
|
+
) WHERE id = v_invitation.organization_id;
|
|
150
|
+
UPDATE public.organization_invitations SET status = 'accepted', accepted_at = now(), accepted_by_profile_id = p_profile_id,
|
|
151
|
+
accepted_contact_id = v_contact_id WHERE id = p_invitation_id;
|
|
152
|
+
PERFORM public.log_app_activity_event('crm', 'crm_organization_invitation_accepted', 'organizations', v_invitation.organization_id,
|
|
153
|
+
'Convite de organização aceite.', jsonb_build_object('invitation_id', p_invitation_id,
|
|
154
|
+
'organization_id', v_invitation.organization_id, 'email', v_invitation.invited_email), p_profile_id);
|
|
155
|
+
RETURN jsonb_build_object('status', 'accepted', 'organizationId', v_invitation.organization_id);
|
|
156
|
+
END;
|
|
157
|
+
$$;
|
|
158
|
+
REVOKE ALL ON FUNCTION public.accept_organization_invitation(uuid, uuid, text) FROM PUBLIC, anon, authenticated;
|
|
159
|
+
GRANT EXECUTE ON FUNCTION public.accept_organization_invitation(uuid, uuid, text) TO service_role;
|
|
160
|
+
|
|
161
|
+
-- A new package must not silently call pre-repair module RPCs.
|
|
162
|
+
CREATE OR REPLACE FUNCTION public.assert_orgs_auth_identity_v1()
|
|
163
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
164
|
+
DECLARE
|
|
165
|
+
expected record;
|
|
166
|
+
actual pg_catalog.pg_proc%ROWTYPE;
|
|
167
|
+
BEGIN
|
|
168
|
+
PERFORM public.assert_profile_auth_identity_v1();
|
|
169
|
+
FOR expected IN SELECT * FROM (VALUES
|
|
170
|
+
('public.assign_organization_member_atomic(uuid,uuid,text,text,uuid)','c53da15bbfc07ff1871980c730165ebb',false,'jsonb',true),
|
|
171
|
+
('public.accept_organization_invitation(uuid,uuid,text)','0eef95e11deadf31d0724283a35110cd',false,'jsonb',true)
|
|
172
|
+
) AS contracts(signature, body_hash, definer, result_type, service_callable)
|
|
173
|
+
LOOP
|
|
174
|
+
SELECT * INTO actual FROM pg_catalog.pg_proc WHERE oid = to_regprocedure(expected.signature);
|
|
175
|
+
IF NOT FOUND OR md5(actual.prosrc) IS DISTINCT FROM expected.body_hash
|
|
176
|
+
OR actual.prosecdef IS DISTINCT FROM expected.definer
|
|
177
|
+
OR actual.prorettype IS DISTINCT FROM to_regtype(expected.result_type)
|
|
178
|
+
OR actual.proconfig IS DISTINCT FROM ARRAY['search_path=""']::text[]
|
|
179
|
+
OR pg_catalog.pg_get_userbyid(actual.proowner) IN ('anon', 'authenticated')
|
|
180
|
+
OR has_function_privilege('anon', actual.oid, 'EXECUTE')
|
|
181
|
+
OR has_function_privilege('authenticated', actual.oid, 'EXECUTE')
|
|
182
|
+
OR (expected.service_callable AND NOT has_function_privilege('service_role', actual.oid, 'EXECUTE')) THEN
|
|
183
|
+
RAISE EXCEPTION 'IDENTITY_MIGRATION_REQUIRED: apply complete Core profile_auth_identity_v1 (%).', expected.signature USING ERRCODE = '55000';
|
|
184
|
+
END IF;
|
|
185
|
+
END LOOP;
|
|
186
|
+
END;
|
|
187
|
+
$$;
|
|
188
|
+
REVOKE ALL ON FUNCTION public.assert_orgs_auth_identity_v1() FROM PUBLIC, anon, authenticated;
|
|
189
|
+
GRANT EXECUTE ON FUNCTION public.assert_orgs_auth_identity_v1() TO service_role;
|
|
190
|
+
|
|
191
|
+
$identity_ddl$;
|
|
192
|
+
PERFORM public.assert_orgs_auth_identity_v1();
|
|
193
|
+
END;
|
|
194
|
+
$identity_migration$;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
-- A task's optional milestone must belong to its required project.
|
|
2
|
+
-- Read-only rollout preflight (also enforced below):
|
|
3
|
+
-- SELECT count(*) AS invalid_task_milestone_relationships
|
|
4
|
+
-- FROM public.project_tasks task
|
|
5
|
+
-- LEFT JOIN public.project_milestones milestone ON milestone.id = task.milestone_id
|
|
6
|
+
-- WHERE task.milestone_id IS NOT NULL
|
|
7
|
+
-- AND (milestone.id IS NULL OR task.project_id IS DISTINCT FROM milestone.project_id);
|
|
8
|
+
|
|
9
|
+
-- One statement keeps preflight, constraint replacement and index creation
|
|
10
|
+
-- atomic even for SQL clients that apply migrations statement by statement.
|
|
11
|
+
DO $$
|
|
12
|
+
DECLARE
|
|
13
|
+
v_invalid_count bigint;
|
|
14
|
+
BEGIN
|
|
15
|
+
-- Exclude writes before inspecting legacy rows. Acquire the eventual DDL
|
|
16
|
+
-- locks up front, avoiding lock upgrades between preflight and validation.
|
|
17
|
+
LOCK TABLE public.project_milestones, public.project_tasks IN ACCESS EXCLUSIVE MODE;
|
|
18
|
+
|
|
19
|
+
SELECT count(*) INTO v_invalid_count
|
|
20
|
+
FROM public.project_tasks task
|
|
21
|
+
LEFT JOIN public.project_milestones milestone ON milestone.id = task.milestone_id
|
|
22
|
+
WHERE task.milestone_id IS NOT NULL
|
|
23
|
+
AND (milestone.id IS NULL OR task.project_id IS DISTINCT FROM milestone.project_id);
|
|
24
|
+
|
|
25
|
+
IF v_invalid_count > 0 THEN
|
|
26
|
+
RAISE EXCEPTION 'Projects relationship migration refused: invalid task/milestone relationships exist.'
|
|
27
|
+
USING ERRCODE = '23514',
|
|
28
|
+
DETAIL = format('invalid_task_milestone_relationships=%s', v_invalid_count),
|
|
29
|
+
HINT = 'Run the read-only aggregate preflight at the top of this migration. Review and remediate affected data separately before retrying; this migration does not repair records.';
|
|
30
|
+
END IF;
|
|
31
|
+
|
|
32
|
+
ALTER TABLE public.project_milestones
|
|
33
|
+
ADD CONSTRAINT project_milestones_id_project_id_key UNIQUE (id, project_id);
|
|
34
|
+
|
|
35
|
+
-- Retain one relationship and its existing PostgREST hint name. MATCH SIMPLE
|
|
36
|
+
-- permits an absent milestone; only milestone_id is cleared on deletion.
|
|
37
|
+
ALTER TABLE public.project_tasks
|
|
38
|
+
DROP CONSTRAINT project_tasks_milestone_id_fkey,
|
|
39
|
+
ADD CONSTRAINT project_tasks_milestone_id_fkey
|
|
40
|
+
FOREIGN KEY (milestone_id, project_id)
|
|
41
|
+
REFERENCES public.project_milestones (id, project_id)
|
|
42
|
+
MATCH SIMPLE
|
|
43
|
+
ON UPDATE NO ACTION
|
|
44
|
+
ON DELETE SET NULL (milestone_id)
|
|
45
|
+
NOT DEFERRABLE;
|
|
46
|
+
|
|
47
|
+
CREATE INDEX idx_project_tasks_milestone_project
|
|
48
|
+
ON public.project_tasks (milestone_id, project_id);
|
|
49
|
+
|
|
50
|
+
PERFORM pg_catalog.pg_notify('pgrst', 'reload schema');
|
|
51
|
+
END;
|
|
52
|
+
$$;
|