-- ===================================================== -- Atomic Application Workflow -- Replaces multi-query approve/reject in api.ts with -- single transactional PG functions. -- ===================================================== -- approve_application: atomically creates a member from an application CREATE OR REPLACE FUNCTION public.approve_application( p_application_id uuid, p_user_id uuid ) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE v_app record; v_member_id uuid; v_member_number text; BEGIN -- 1. Fetch and lock the application SELECT * INTO v_app FROM public.membership_applications WHERE id = p_application_id FOR UPDATE; IF v_app IS NULL THEN RAISE EXCEPTION 'Application % not found', p_application_id USING ERRCODE = 'P0002'; END IF; -- Authorization: caller must have write permission on this account IF NOT public.has_permission(auth.uid(), v_app.account_id, 'members.write'::public.app_permissions) THEN RAISE EXCEPTION 'Access denied to account %', v_app.account_id USING ERRCODE = '42501'; END IF; IF v_app.status NOT IN ('submitted', 'review') THEN RAISE EXCEPTION 'Application is not in a reviewable state (current: %)', v_app.status USING ERRCODE = 'P0001'; END IF; -- 2. Generate next member number SELECT LPAD( (COALESCE( MAX(CASE WHEN member_number ~ '^\d+$' THEN member_number::integer ELSE 0 END), 0 ) + 1)::text, 4, '0' ) INTO v_member_number FROM public.members WHERE account_id = v_app.account_id; -- 3. Create the member INSERT INTO public.members ( account_id, member_number, first_name, last_name, email, phone, street, postal_code, city, date_of_birth, status, entry_date, created_by, updated_by ) VALUES ( v_app.account_id, v_member_number, v_app.first_name, v_app.last_name, v_app.email, v_app.phone, v_app.street, v_app.postal_code, v_app.city, v_app.date_of_birth, 'active'::public.membership_status, current_date, auth.uid(), auth.uid() ) RETURNING id INTO v_member_id; -- 4. Mark application as approved UPDATE public.membership_applications SET status = 'approved'::public.application_status, reviewed_by = auth.uid(), reviewed_at = now(), member_id = v_member_id, updated_at = now() WHERE id = p_application_id; RETURN v_member_id; END; $$; GRANT EXECUTE ON FUNCTION public.approve_application(uuid, uuid) TO authenticated; GRANT EXECUTE ON FUNCTION public.approve_application(uuid, uuid) TO service_role; -- reject_application: atomically rejects an application with notes CREATE OR REPLACE FUNCTION public.reject_application( p_application_id uuid, p_user_id uuid, p_review_notes text DEFAULT NULL ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE v_app record; BEGIN -- Fetch and lock the application SELECT * INTO v_app FROM public.membership_applications WHERE id = p_application_id FOR UPDATE; IF v_app IS NULL THEN RAISE EXCEPTION 'Application % not found', p_application_id USING ERRCODE = 'P0002'; END IF; -- Authorization: caller must have write permission on this account IF NOT public.has_permission(auth.uid(), v_app.account_id, 'members.write'::public.app_permissions) THEN RAISE EXCEPTION 'Access denied to account %', v_app.account_id USING ERRCODE = '42501'; END IF; IF v_app.status NOT IN ('submitted', 'review') THEN RAISE EXCEPTION 'Application is not in a reviewable state (current: %)', v_app.status USING ERRCODE = 'P0001'; END IF; UPDATE public.membership_applications SET status = 'rejected'::public.application_status, reviewed_by = auth.uid(), reviewed_at = now(), review_notes = p_review_notes, updated_at = now() WHERE id = p_application_id; END; $$; GRANT EXECUTE ON FUNCTION public.reject_application(uuid, uuid, text) TO authenticated; GRANT EXECUTE ON FUNCTION public.reject_application(uuid, uuid, text) TO service_role;