create-bw-app 0.27.4 → 0.27.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ $$;