create-bw-app 0.27.1 → 0.27.3

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,50 @@
1
+ -- atomic_admin_invitation_acceptance
2
+ -- target: admin
3
+ -- created_at: 2026-09-06T10:16:16.616Z
4
+
5
+ CREATE OR REPLACE FUNCTION public.accept_admin_user_invitation(p_invitation_id uuid, p_profile_id uuid, p_user_email text)
6
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
7
+ DECLARE
8
+ v_invitation public.admin_user_invitations%ROWTYPE;
9
+ v_old_role text;
10
+ BEGIN
11
+ SELECT * INTO v_invitation FROM public.admin_user_invitations WHERE id = p_invitation_id FOR UPDATE;
12
+ IF NOT FOUND THEN RAISE EXCEPTION 'Convite não encontrado.'; END IF;
13
+ IF lower(btrim(p_user_email)) IS DISTINCT FROM lower(btrim(v_invitation.invited_email)) THEN
14
+ RAISE EXCEPTION 'Este convite pertence a outro email.';
15
+ END IF;
16
+ -- Only the service role can call this RPC; the server supplies the authenticated
17
+ -- user's email. Require the matching auth-linked profile without granting auth-table access.
18
+ PERFORM p.id FROM public.profiles p
19
+ WHERE p.id = p_profile_id AND p.user_id IS NOT NULL
20
+ AND lower(btrim(p.email)) = lower(btrim(v_invitation.invited_email))
21
+ FOR UPDATE OF p;
22
+ IF NOT FOUND THEN RAISE EXCEPTION 'Este convite pertence a outro email.'; END IF;
23
+ IF v_invitation.status = 'accepted' AND v_invitation.accepted_by_profile_id = p_profile_id THEN
24
+ RETURN jsonb_build_object('status', 'accepted', 'role', v_invitation.role_code);
25
+ END IF;
26
+ IF v_invitation.status <> 'pending' THEN RAISE EXCEPTION 'Este convite já não está disponível.'; END IF;
27
+ IF v_invitation.expires_at <= clock_timestamp() THEN
28
+ UPDATE public.admin_user_invitations SET status = 'expired' WHERE id = p_invitation_id;
29
+ RETURN jsonb_build_object('status', 'expired');
30
+ END IF;
31
+
32
+ SELECT role_code INTO v_old_role FROM public.user_role_assignments WHERE profile_id = p_profile_id FOR UPDATE;
33
+ INSERT INTO public.user_role_assignments(profile_id, role_code, assigned_by_profile_id, assigned_at, reason)
34
+ VALUES(p_profile_id, v_invitation.role_code, v_invitation.invited_by_profile_id, now(), 'Convite de utilizador do portal aceite.')
35
+ ON CONFLICT(profile_id) DO UPDATE SET role_code = EXCLUDED.role_code,
36
+ assigned_by_profile_id = EXCLUDED.assigned_by_profile_id, assigned_at = EXCLUDED.assigned_at, reason = EXCLUDED.reason;
37
+ IF v_invitation.invited_by_profile_id IS NOT NULL THEN
38
+ INSERT INTO public.role_change_audit(target_profile_id, changed_by_profile_id, old_role_code, new_role_code, reason)
39
+ VALUES(p_profile_id, v_invitation.invited_by_profile_id, v_old_role, v_invitation.role_code, 'Convite de utilizador do portal aceite.');
40
+ END IF;
41
+ UPDATE public.admin_user_invitations SET status = 'accepted', accepted_at = now(), accepted_by_profile_id = p_profile_id
42
+ WHERE id = p_invitation_id;
43
+ PERFORM public.log_app_activity_event('admin', 'admin_user_invitation_accepted', 'admin_user_invitations', p_invitation_id,
44
+ 'Convite de utilizador aceite.', jsonb_build_object('email', v_invitation.invited_email, 'role', v_invitation.role_code,
45
+ 'status', 'accepted', 'accepted_by_profile_id', p_profile_id), p_profile_id);
46
+ RETURN jsonb_build_object('status', 'accepted', 'role', v_invitation.role_code);
47
+ END;
48
+ $$;
49
+ REVOKE ALL ON FUNCTION public.accept_admin_user_invitation(uuid, uuid, text) FROM PUBLIC, anon, authenticated;
50
+ GRANT EXECUTE ON FUNCTION public.accept_admin_user_invitation(uuid, uuid, text) TO service_role;
@@ -0,0 +1,9 @@
1
+ -- The admin directory joins role assignments to profiles using the caller's
2
+ -- authenticated session. Grant the corresponding profile reads through RLS.
3
+ -- Staff and clients retain their existing profile visibility and write rules.
4
+ DROP POLICY IF EXISTS "Admins can view profiles" ON public.profiles;
5
+ CREATE POLICY "Admins can view profiles"
6
+ ON public.profiles
7
+ FOR SELECT
8
+ TO authenticated
9
+ USING (public.is_admin());
@@ -0,0 +1,4 @@
1
+ -- Profile synchronization is an internal auth bridge, not a caller-supplied identity API.
2
+ -- Auth triggers execute as their owner; server registration uses service_role.
3
+ REVOKE ALL ON FUNCTION public.sync_profile_from_auth_identity(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;
4
+ GRANT EXECUTE ON FUNCTION public.sync_profile_from_auth_identity(uuid, text, jsonb) TO service_role;
@@ -0,0 +1,31 @@
1
+ -- atomic_invitation_contact_link
2
+ -- target: crm
3
+ -- created_at: 2026-09-06T10:16:16.695Z
4
+
5
+ -- This integration is installed after orgs and is never required by an orgs-only app.
6
+ CREATE OR REPLACE FUNCTION public.link_organization_invitation_contact(p_profile_id uuid, p_organization_id uuid)
7
+ RETURNS uuid LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
8
+ DECLARE
9
+ v_profile public.profiles%ROWTYPE;
10
+ v_contact public.crm_contacts%ROWTYPE;
11
+ BEGIN
12
+ SELECT * INTO STRICT v_profile FROM public.profiles WHERE id = p_profile_id FOR UPDATE;
13
+ SELECT * INTO v_contact FROM public.crm_contacts
14
+ WHERE profile_id = p_profile_id OR lower(btrim(email)) = lower(btrim(v_profile.email))
15
+ ORDER BY (profile_id = p_profile_id) DESC NULLS LAST, id LIMIT 1 FOR UPDATE;
16
+ IF FOUND THEN
17
+ IF v_contact.profile_id IS NOT NULL AND v_contact.profile_id <> p_profile_id THEN
18
+ RAISE EXCEPTION 'O contacto CRM já está ligado a outro perfil.';
19
+ END IF;
20
+ UPDATE public.crm_contacts SET profile_id = p_profile_id WHERE id = v_contact.id;
21
+ ELSE
22
+ INSERT INTO public.crm_contacts(profile_id, first_name, last_name, email, status, source, organization_id)
23
+ VALUES(p_profile_id, v_profile.first_name, v_profile.last_name, lower(btrim(v_profile.email)), 'lead',
24
+ 'organization_invitation_accept', p_organization_id) RETURNING * INTO v_contact;
25
+ END IF;
26
+ PERFORM public.link_crm_contact_organization(v_contact.id, p_organization_id);
27
+ RETURN v_contact.id;
28
+ END;
29
+ $$;
30
+ REVOKE ALL ON FUNCTION public.link_organization_invitation_contact(uuid, uuid) FROM PUBLIC, anon, authenticated;
31
+ GRANT EXECUTE ON FUNCTION public.link_organization_invitation_contact(uuid, uuid) TO service_role;
@@ -0,0 +1,97 @@
1
+ -- Check position uniqueness at the end of the statement so retained node IDs
2
+ -- can exchange positions without breaking their step-history references.
3
+ ALTER TABLE public.marketing_workflow_nodes
4
+ DROP CONSTRAINT marketing_workflow_nodes_workflow_id_position_key;
5
+ ALTER TABLE public.marketing_workflow_nodes
6
+ ADD CONSTRAINT marketing_workflow_nodes_workflow_id_position_key
7
+ UNIQUE (workflow_id, position) DEFERRABLE INITIALLY IMMEDIATE;
8
+
9
+ CREATE OR REPLACE FUNCTION public.replace_marketing_workflow_nodes(p_workflow_id uuid, p_nodes jsonb)
10
+ RETURNS SETOF public.marketing_workflow_nodes
11
+ LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
12
+ DECLARE
13
+ v_status text;
14
+ BEGIN
15
+ SELECT status INTO v_status FROM public.marketing_workflows WHERE id = p_workflow_id FOR UPDATE;
16
+ IF NOT FOUND THEN RAISE EXCEPTION 'Workflow not found.'; END IF;
17
+ IF v_status = 'active' THEN RAISE EXCEPTION 'Pause the workflow before editing its nodes.'; END IF;
18
+ IF jsonb_typeof(p_nodes) IS DISTINCT FROM 'array' THEN RAISE EXCEPTION 'Workflow nodes must be an array.'; END IF;
19
+ IF EXISTS (
20
+ SELECT 1 FROM jsonb_array_elements(p_nodes) AS node
21
+ WHERE jsonb_typeof(node) IS DISTINCT FROM 'object'
22
+ OR node->>'node_type' IS NULL
23
+ OR node->>'node_type' NOT IN ('send_email', 'wait', 'add_tag')
24
+ OR (node ? 'config' AND jsonb_typeof(node->'config') IS DISTINCT FROM 'object')
25
+ ) THEN RAISE EXCEPTION 'Invalid workflow node.'; END IF;
26
+ IF EXISTS (
27
+ SELECT 1 FROM jsonb_array_elements(p_nodes) AS node
28
+ WHERE node->>'id' IS NOT NULL
29
+ GROUP BY (node->>'id')::uuid HAVING count(*) > 1
30
+ ) THEN RAISE EXCEPTION 'Duplicate workflow node ID.'; END IF;
31
+ PERFORM id FROM public.marketing_workflow_nodes WHERE workflow_id = p_workflow_id ORDER BY id FOR UPDATE;
32
+ IF EXISTS (
33
+ SELECT 1 FROM jsonb_array_elements(p_nodes) AS node
34
+ WHERE node->>'id' IS NOT NULL AND NOT EXISTS (
35
+ SELECT 1 FROM public.marketing_workflow_nodes existing
36
+ WHERE existing.id = (node->>'id')::uuid AND existing.workflow_id = p_workflow_id
37
+ )
38
+ ) THEN RAISE EXCEPTION 'Workflow node does not belong to this workflow.'; END IF;
39
+
40
+ DELETE FROM public.marketing_workflow_nodes existing
41
+ WHERE existing.workflow_id = p_workflow_id AND NOT EXISTS (
42
+ SELECT 1 FROM jsonb_array_elements(p_nodes) AS node WHERE (node->>'id')::uuid = existing.id
43
+ );
44
+ INSERT INTO public.marketing_workflow_nodes(id, workflow_id, position, node_type, config)
45
+ SELECT coalesce((node->>'id')::uuid, gen_random_uuid()), p_workflow_id,
46
+ (ordinality - 1)::integer, node->>'node_type', coalesce(node->'config', '{}'::jsonb)
47
+ FROM jsonb_array_elements(p_nodes) WITH ORDINALITY AS input(node, ordinality)
48
+ ON CONFLICT(id) DO UPDATE SET position = EXCLUDED.position, node_type = EXCLUDED.node_type, config = EXCLUDED.config;
49
+ RETURN QUERY SELECT * FROM public.marketing_workflow_nodes WHERE workflow_id = p_workflow_id ORDER BY position;
50
+ END;
51
+ $$;
52
+ REVOKE ALL ON FUNCTION public.replace_marketing_workflow_nodes(uuid, jsonb) FROM PUBLIC, anon, authenticated;
53
+ GRANT EXECUTE ON FUNCTION public.replace_marketing_workflow_nodes(uuid, jsonb) TO service_role;
54
+
55
+ -- Activation must share the node editor's parent lock; otherwise it can approve
56
+ -- an old non-empty snapshot while a concurrent edit clears the node set.
57
+ CREATE OR REPLACE FUNCTION public.set_marketing_workflow_status(p_workflow_id uuid, p_status text)
58
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
59
+ DECLARE
60
+ v_workflow public.marketing_workflows%ROWTYPE;
61
+ BEGIN
62
+ IF p_status IS NULL OR p_status NOT IN ('active', 'paused') THEN RAISE EXCEPTION 'Invalid workflow status.'; END IF;
63
+ SELECT * INTO v_workflow FROM public.marketing_workflows WHERE id = p_workflow_id FOR UPDATE;
64
+ IF NOT FOUND THEN RAISE EXCEPTION 'Workflow not found.'; END IF;
65
+ IF p_status = 'active' AND NOT EXISTS (SELECT 1 FROM public.marketing_workflow_nodes WHERE workflow_id = p_workflow_id) THEN
66
+ RAISE EXCEPTION 'A workflow must have at least one node before activation.';
67
+ END IF;
68
+ UPDATE public.marketing_workflows SET status = p_status WHERE id = p_workflow_id RETURNING * INTO v_workflow;
69
+ RETURN to_jsonb(v_workflow);
70
+ END;
71
+ $$;
72
+ REVOKE ALL ON FUNCTION public.set_marketing_workflow_status(uuid, text) FROM PUBLIC, anon, authenticated;
73
+ GRANT EXECUTE ON FUNCTION public.set_marketing_workflow_status(uuid, text) TO service_role;
74
+
75
+ CREATE OR REPLACE FUNCTION public.delete_marketing_workflow_node(p_workflow_id uuid, p_node_id uuid)
76
+ RETURNS SETOF public.marketing_workflow_nodes
77
+ LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
78
+ DECLARE
79
+ v_status text;
80
+ BEGIN
81
+ SELECT status INTO v_status FROM public.marketing_workflows WHERE id = p_workflow_id FOR UPDATE;
82
+ IF NOT FOUND THEN RAISE EXCEPTION 'Workflow not found.'; END IF;
83
+ IF v_status = 'active' THEN RAISE EXCEPTION 'Pause the workflow before editing its nodes.'; END IF;
84
+ -- Remove only the requested node, never a stale replacement snapshot supplied
85
+ -- by the server before another editor appended or changed a different node.
86
+ DELETE FROM public.marketing_workflow_nodes WHERE workflow_id = p_workflow_id AND id = p_node_id;
87
+ WITH positions AS (
88
+ SELECT id, (row_number() OVER (ORDER BY position, id) - 1)::integer AS position
89
+ FROM public.marketing_workflow_nodes WHERE workflow_id = p_workflow_id
90
+ )
91
+ UPDATE public.marketing_workflow_nodes node SET position = positions.position
92
+ FROM positions WHERE node.id = positions.id AND node.position <> positions.position;
93
+ RETURN QUERY SELECT * FROM public.marketing_workflow_nodes WHERE workflow_id = p_workflow_id ORDER BY position;
94
+ END;
95
+ $$;
96
+ REVOKE ALL ON FUNCTION public.delete_marketing_workflow_node(uuid, uuid) FROM PUBLIC, anon, authenticated;
97
+ GRANT EXECUTE ON FUNCTION public.delete_marketing_workflow_node(uuid, uuid) TO service_role;
@@ -0,0 +1,61 @@
1
+ -- atomic_organization_invitation_acceptance
2
+ -- target: orgs
3
+ -- created_at: 2026-09-06T10:16:16.656Z
4
+
5
+ CREATE OR REPLACE FUNCTION public.accept_organization_invitation(p_invitation_id uuid, p_profile_id uuid, p_user_email text)
6
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
7
+ DECLARE
8
+ v_invitation public.organization_invitations%ROWTYPE;
9
+ v_contact_id uuid;
10
+ BEGIN
11
+ SELECT * INTO v_invitation FROM public.organization_invitations WHERE id = p_invitation_id FOR UPDATE;
12
+ IF NOT FOUND THEN RAISE EXCEPTION 'Convite não encontrado.'; END IF;
13
+ IF lower(btrim(p_user_email)) IS DISTINCT FROM lower(btrim(v_invitation.invited_email)) THEN
14
+ RAISE EXCEPTION 'Este convite pertence a outro email.';
15
+ END IF;
16
+ -- Only the service role can call this RPC; the server supplies the authenticated
17
+ -- user's email. Require the matching auth-linked profile without granting auth-table access.
18
+ PERFORM p.id FROM public.profiles p
19
+ WHERE p.id = p_profile_id AND p.user_id IS NOT NULL
20
+ AND lower(btrim(p.email)) = lower(btrim(v_invitation.invited_email))
21
+ FOR UPDATE OF p;
22
+ IF NOT FOUND THEN RAISE EXCEPTION 'Este convite pertence a outro email.'; END IF;
23
+ IF v_invitation.status = 'accepted' AND v_invitation.accepted_by_profile_id = p_profile_id THEN
24
+ RETURN jsonb_build_object('status', 'accepted', 'organizationId', v_invitation.organization_id);
25
+ END IF;
26
+ IF v_invitation.status <> 'pending' THEN RAISE EXCEPTION 'Este convite já não está disponível.'; END IF;
27
+ IF v_invitation.expires_at <= clock_timestamp() THEN
28
+ UPDATE public.organization_invitations SET status = 'expired' WHERE id = p_invitation_id;
29
+ RETURN jsonb_build_object('status', 'expired');
30
+ END IF;
31
+
32
+ -- Serialize different invitations for the same organization's primary-contact choice.
33
+ PERFORM id FROM public.organizations WHERE id = v_invitation.organization_id FOR UPDATE;
34
+ -- Client-owned integration takes precedence; package updates never create or replace it.
35
+ IF to_regprocedure('public.client_organization_invitation_contact_hook(uuid,uuid)') IS NOT NULL THEN
36
+ EXECUTE 'SELECT public.client_organization_invitation_contact_hook($1, $2)' INTO v_contact_id
37
+ USING p_profile_id, v_invitation.organization_id;
38
+ ELSIF to_regclass('public.crm_contacts') IS NOT NULL THEN
39
+ IF to_regprocedure('public.link_organization_invitation_contact(uuid,uuid)') IS NULL THEN
40
+ RAISE EXCEPTION 'Aplique a migration atomic_invitation_contact_link antes de aceitar o convite.';
41
+ END IF;
42
+ EXECUTE 'SELECT public.link_organization_invitation_contact($1, $2)' INTO v_contact_id
43
+ USING p_profile_id, v_invitation.organization_id;
44
+ END IF;
45
+ INSERT INTO public.organization_members(organization_id, profile_id, role)
46
+ VALUES(v_invitation.organization_id, p_profile_id, v_invitation.role)
47
+ ON CONFLICT(organization_id, profile_id) DO UPDATE SET role = EXCLUDED.role;
48
+ UPDATE public.organizations SET primary_contact_id = (
49
+ SELECT profile_id FROM public.organization_members WHERE organization_id = v_invitation.organization_id AND role = 'admin'
50
+ ORDER BY joined_at, profile_id LIMIT 1
51
+ ) WHERE id = v_invitation.organization_id;
52
+ UPDATE public.organization_invitations SET status = 'accepted', accepted_at = now(), accepted_by_profile_id = p_profile_id,
53
+ accepted_contact_id = v_contact_id WHERE id = p_invitation_id;
54
+ PERFORM public.log_app_activity_event('crm', 'crm_organization_invitation_accepted', 'organizations', v_invitation.organization_id,
55
+ 'Convite de organização aceite.', jsonb_build_object('invitation_id', p_invitation_id,
56
+ 'organization_id', v_invitation.organization_id, 'email', v_invitation.invited_email), p_profile_id);
57
+ RETURN jsonb_build_object('status', 'accepted', 'organizationId', v_invitation.organization_id);
58
+ END;
59
+ $$;
60
+ REVOKE ALL ON FUNCTION public.accept_organization_invitation(uuid, uuid, text) FROM PUBLIC, anon, authenticated;
61
+ GRANT EXECUTE ON FUNCTION public.accept_organization_invitation(uuid, uuid, text) TO service_role;
@@ -0,0 +1,76 @@
1
+ -- Each direct assignment, including database integration and reconciliation, commits together.
2
+ CREATE OR REPLACE FUNCTION public.assign_organization_member_atomic(
3
+ p_organization_id uuid, p_profile_id uuid, p_email text, p_role text, p_actor_profile_id uuid
4
+ ) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
5
+ DECLARE
6
+ v_role text;
7
+ v_inserted boolean;
8
+ v_status text;
9
+ v_contact_id uuid;
10
+ BEGIN
11
+ IF p_role IS NULL OR p_role NOT IN ('admin', 'member') THEN
12
+ RAISE EXCEPTION 'Função de membro inválida.';
13
+ END IF;
14
+ IF p_email IS NULL OR btrim(p_email) = '' THEN
15
+ RAISE EXCEPTION 'Email de membro inválido.';
16
+ END IF;
17
+ PERFORM id FROM public.profiles WHERE id = p_actor_profile_id;
18
+ IF NOT FOUND THEN RAISE EXCEPTION 'Perfil do autor não encontrado.'; END IF;
19
+
20
+ -- Match acceptance's invitation -> profile -> organization lock order. Lock
21
+ -- terminal rows too: acceptance may still be checking an idempotent retry.
22
+ PERFORM id FROM public.organization_invitations
23
+ WHERE organization_id = p_organization_id AND lower(btrim(invited_email)) = lower(btrim(p_email))
24
+ ORDER BY id FOR UPDATE;
25
+ PERFORM id FROM public.profiles WHERE id = p_profile_id
26
+ AND lower(btrim(email)) = lower(btrim(p_email)) FOR UPDATE;
27
+ IF NOT FOUND THEN RAISE EXCEPTION 'O perfil não corresponde ao email do membro.'; END IF;
28
+ PERFORM id FROM public.organizations WHERE id = p_organization_id FOR UPDATE;
29
+ IF NOT FOUND THEN RAISE EXCEPTION 'Organização não encontrada.'; END IF;
30
+
31
+ -- A writer outside this RPC may have inserted since the caller's snapshot.
32
+ -- Wait for that row, then derive the outcome from its committed current role.
33
+ INSERT INTO public.organization_members(organization_id, profile_id, role)
34
+ VALUES(p_organization_id, p_profile_id, p_role)
35
+ ON CONFLICT(organization_id, profile_id) DO NOTHING;
36
+ v_inserted := FOUND;
37
+ SELECT role INTO STRICT v_role FROM public.organization_members
38
+ WHERE organization_id = p_organization_id AND profile_id = p_profile_id FOR UPDATE;
39
+ IF v_inserted THEN v_status := 'immediate_access';
40
+ ELSIF v_role = p_role THEN v_status := 'already_member';
41
+ ELSE
42
+ UPDATE public.organization_members SET role = p_role
43
+ WHERE organization_id = p_organization_id AND profile_id = p_profile_id;
44
+ v_status := 'membership_updated';
45
+ END IF;
46
+
47
+ BEGIN
48
+ -- Client-owned SQL is transactional and takes precedence over stock CRM.
49
+ IF to_regprocedure('public.client_organization_invitation_contact_hook(uuid,uuid)') IS NOT NULL THEN
50
+ EXECUTE 'SELECT public.client_organization_invitation_contact_hook($1, $2)' INTO v_contact_id
51
+ USING p_profile_id, p_organization_id;
52
+ ELSIF to_regclass('public.crm_contacts') IS NOT NULL THEN
53
+ IF to_regprocedure('public.link_organization_invitation_contact(uuid,uuid)') IS NULL THEN
54
+ RAISE EXCEPTION 'Aplique a migration atomic_invitation_contact_link.';
55
+ END IF;
56
+ EXECUTE 'SELECT public.link_organization_invitation_contact($1, $2)' INTO v_contact_id
57
+ USING p_profile_id, p_organization_id;
58
+ END IF;
59
+ EXCEPTION WHEN OTHERS THEN
60
+ RAISE EXCEPTION 'ORGANIZATION_CONTACT_LINK_FAILED: %', SQLERRM USING ERRCODE = 'BW001';
61
+ END;
62
+
63
+ UPDATE public.organizations SET primary_contact_id = (
64
+ SELECT profile_id FROM public.organization_members
65
+ WHERE organization_id = p_organization_id AND role = 'admin'
66
+ ORDER BY joined_at, profile_id LIMIT 1
67
+ ) WHERE id = p_organization_id;
68
+ UPDATE public.organization_invitations SET status = 'accepted', accepted_at = now(),
69
+ accepted_by_profile_id = p_profile_id, accepted_contact_id = v_contact_id
70
+ WHERE organization_id = p_organization_id AND lower(btrim(invited_email)) = lower(btrim(p_email))
71
+ AND status = 'pending';
72
+ RETURN jsonb_build_object('status', v_status, 'profileId', p_profile_id, 'organizationId', p_organization_id);
73
+ END;
74
+ $$;
75
+ REVOKE ALL ON FUNCTION public.assign_organization_member_atomic(uuid, uuid, text, text, uuid) FROM PUBLIC, anon, authenticated;
76
+ GRANT EXECUTE ON FUNCTION public.assign_organization_member_atomic(uuid, uuid, text, text, uuid) TO service_role;