-- ============================================================================= -- F6.2 Lote D — RPCs user-facing roteadas pro schema do tenant -- -- ⚠️ APLICAR COMO supabase_admin (mix de funções owned postgres/supabase_admin). -- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \ -- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \ -- < database-novo/manual/f6_2d_user_rpcs.supabase_admin.sql -- -- Padrão: valida is_tenant_member(p_tenant_id) + set_config search_path pro -- schema do tenant; remove `WHERE tenant_id=` e tenant_id de inserts; unqualify -- tabelas tenant; %ROWTYPE→RECORD; RETURNS →jsonb. -- Tabelas que FICAM em public (audit_logs global; patient_intake_requests, -- document_share_links F1b) seguem com `public.` + filtro tenant_id. -- -- list_my_signatures é CROSS-TENANT (assinante em vários tenants) → Lote F. -- ============================================================================= BEGIN; -- helper: valida acesso e RETORNA o schema do tenant. NÃO seta search_path -- (set_config feito dentro de helper com SET search_path próprio seria revertido -- na saída do helper). Cada RPC faz: PERFORM set_config('search_path', -- public._tenant_route(p_tenant_id) || ',public,pg_temp', true); CREATE OR REPLACE FUNCTION public._tenant_route(p_tenant_id uuid) RETURNS text LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_schema text; BEGIN IF p_tenant_id IS NULL THEN RAISE EXCEPTION 'p_tenant_id obrigatório'; END IF; IF NOT public.is_tenant_member(p_tenant_id) AND NOT public.is_saas_admin() THEN RAISE EXCEPTION 'Sem permissão no tenant %', p_tenant_id USING ERRCODE='42501'; END IF; v_schema := public.tenant_schema_for(p_tenant_id); IF v_schema IS NULL THEN RAISE EXCEPTION 'schema não encontrado p/ tenant %', p_tenant_id; END IF; RETURN v_schema; END $$; -- ─────────────────────────────────────────────────────────────────────────── -- GRUPO 1 — já têm p_tenant_id, RETURNS jsonb/void (CREATE OR REPLACE) -- ─────────────────────────────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_is_native boolean; v_fields int:=0; v_logs int:=0; v_parent int:=0; BEGIN PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); SELECT dc.is_native INTO v_is_native FROM determined_commitments dc WHERE dc.id = p_commitment_id; IF v_is_native IS NULL THEN RAISE EXCEPTION 'Commitment not found'; END IF; IF v_is_native = true THEN RAISE EXCEPTION 'Cannot delete native commitment'; END IF; DELETE FROM determined_commitment_fields WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_fields = ROW_COUNT; DELETE FROM commitment_time_logs WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_logs = ROW_COUNT; DELETE FROM determined_commitments WHERE id = p_commitment_id; GET DIAGNOSTICS v_parent = ROW_COUNT; IF v_parent <> 1 THEN RAISE EXCEPTION 'Parent not deleted'; END IF; RETURN jsonb_build_object('ok',true,'deleted',jsonb_build_object('fields',v_fields,'logs',v_logs,'commitment',v_parent)); END $$; CREATE OR REPLACE FUNCTION public.delete_determined_commitment(p_tenant_id uuid, p_commitment_id uuid) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_is_native boolean; v_fields int:=0; v_logs int:=0; v_parent int:=0; BEGIN PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); SELECT dc.is_native INTO v_is_native FROM determined_commitments dc WHERE dc.id = p_commitment_id; IF v_is_native IS NULL THEN RAISE EXCEPTION 'Commitment not found for tenant'; END IF; IF v_is_native = true THEN RAISE EXCEPTION 'Cannot delete native commitment'; END IF; DELETE FROM determined_commitment_fields WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_fields = ROW_COUNT; DELETE FROM commitment_time_logs WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_logs = ROW_COUNT; DELETE FROM determined_commitments WHERE id = p_commitment_id; GET DIAGNOSTICS v_parent = ROW_COUNT; IF v_parent <> 1 THEN RAISE EXCEPTION 'Delete did not remove the commitment'; END IF; RETURN jsonb_build_object('ok',true,'tenant_id',p_tenant_id,'commitment_id',p_commitment_id, 'deleted',jsonb_build_object('fields',v_fields,'logs',v_logs,'commitment',v_parent)); END $$; CREATE OR REPLACE FUNCTION public.seed_default_patient_groups(p_tenant_id uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_owner_id uuid; v_schema text; BEGIN v_schema := public.tenant_schema_for(p_tenant_id); IF v_schema IS NULL THEN RETURN; END IF; -- schema ainda não existe (chamado antes do clone): no-op SELECT user_id INTO v_owner_id FROM public.tenant_members WHERE tenant_id = p_tenant_id AND role='tenant_admin' AND status='active' LIMIT 1; IF v_owner_id IS NULL THEN RETURN; END IF; PERFORM set_config('search_path', v_schema || ',public,pg_temp', true); INSERT INTO patient_groups (owner_id, nome, cor, is_system) VALUES (v_owner_id,'Crianças','#60a5fa',true), (v_owner_id,'Adolescentes','#a78bfa',true), (v_owner_id,'Idosos','#34d399',true) ON CONFLICT (owner_id, nome) DO NOTHING; END $$; -- seed_determined_commitments: idêntico em estrutura, sem tenant_id nos inserts. -- Recriado integralmente. CREATE OR REPLACE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_id uuid; v_schema text; BEGIN v_schema := public.tenant_schema_for(p_tenant_id); IF v_schema IS NULL THEN RETURN; END IF; PERFORM set_config('search_path', v_schema || ',public,pg_temp', true); IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='session') THEN INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description) VALUES (true,'session',true,true,'Sessão','Sessão com paciente'); END IF; IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='reading') THEN INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description) VALUES (true,'reading',false,true,'Leitura','Praticar leitura'); END IF; IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='supervision') THEN INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description) VALUES (true,'supervision',false,true,'Supervisão','Supervisão'); END IF; IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='class') THEN INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description) VALUES (true,'class',false,false,'Aula','Dar aula'); END IF; IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='analysis') THEN INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description) VALUES (true,'analysis',false,true,'Análise Pessoal','Minha análise pessoal'); END IF; SELECT id INTO v_id FROM determined_commitments WHERE is_native=true AND native_key='session' LIMIT 1; IF v_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='notes') THEN INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'notes','Observação','textarea',false,30); END IF; SELECT id INTO v_id FROM determined_commitments WHERE is_native=true AND native_key='reading' LIMIT 1; IF v_id IS NOT NULL THEN IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='book') THEN INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'book','Livro','text',false,10); END IF; IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='author') THEN INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'author','Autor','text',false,20); END IF; IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='notes') THEN INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'notes','Observação','textarea',false,30); END IF; END IF; END $$; -- ─────────────────────────────────────────────────────────────────────────── -- GRUPO 2 — novo p_tenant_id (1º param), RETURNS scalar/jsonb (DROP+CREATE) -- ─────────────────────────────────────────────────────────────────────────── DROP FUNCTION IF EXISTS public.cancel_recurrence_from(uuid, date); CREATE FUNCTION public.cancel_recurrence_from(p_tenant_id uuid, p_recurrence_id uuid, p_from_date date) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ BEGIN PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); UPDATE recurrence_rules SET end_date = p_from_date - INTERVAL '1 day', open_ended = false, status = CASE WHEN p_from_date <= start_date THEN 'cancelado' ELSE status END, updated_at = now() WHERE id = p_recurrence_id; END $$; DROP FUNCTION IF EXISTS public.cancelar_eventos_serie(uuid, timestamptz); CREATE FUNCTION public.cancelar_eventos_serie(p_tenant_id uuid, p_serie_id uuid, p_a_partir_de timestamptz DEFAULT now()) RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_count integer; BEGIN PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); UPDATE agenda_eventos SET status='cancelado', updated_at=now() WHERE serie_id = p_serie_id AND inicio_em >= p_a_partir_de AND status NOT IN ('realizado','cancelado'); GET DIAGNOSTICS v_count = ROW_COUNT; RETURN v_count; END $$; DROP FUNCTION IF EXISTS public.split_recurrence_at(uuid, date); CREATE FUNCTION public.split_recurrence_at(p_tenant_id uuid, p_recurrence_id uuid, p_from_date date) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_old RECORD; v_new_id uuid; BEGIN PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); SELECT * INTO v_old FROM recurrence_rules WHERE id = p_recurrence_id; IF NOT FOUND THEN RAISE EXCEPTION 'recurrence_rule % não encontrada', p_recurrence_id; END IF; UPDATE recurrence_rules SET end_date = p_from_date - INTERVAL '1 day', open_ended=false, updated_at=now() WHERE id = p_recurrence_id; INSERT INTO recurrence_rules (owner_id, therapist_id, patient_id, determined_commitment_id, type, interval, weekdays, start_time, end_time, timezone, duration_min, start_date, end_date, max_occurrences, open_ended, modalidade, titulo_custom, observacoes, extra_fields, status) SELECT owner_id, therapist_id, patient_id, determined_commitment_id, type, interval, weekdays, start_time, end_time, timezone, duration_min, p_from_date, v_old.end_date, v_old.max_occurrences, v_old.open_ended, modalidade, titulo_custom, observacoes, extra_fields, status FROM recurrence_rules WHERE id = p_recurrence_id RETURNING id INTO v_new_id; RETURN v_new_id; END $$; -- can_delete_patient: SQL sem SET search_path → herda o do chamador (schema). -- Unqualified pra resolver no schema do tenant ativo. CREATE OR REPLACE FUNCTION public.can_delete_patient(p_patient_id uuid) RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $$ SELECT NOT EXISTS ( SELECT 1 FROM agenda_eventos WHERE patient_id = p_patient_id UNION ALL SELECT 1 FROM recurrence_rules WHERE patient_id = p_patient_id UNION ALL SELECT 1 FROM billing_contracts WHERE patient_id = p_patient_id ); $$; DROP FUNCTION IF EXISTS public.safe_delete_patient(uuid); CREATE FUNCTION public.safe_delete_patient(p_tenant_id uuid, p_patient_id uuid) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ BEGIN PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); IF NOT public.can_delete_patient(p_patient_id) THEN RETURN jsonb_build_object('ok',false,'error','has_history', 'message','Este paciente possui histórico clínico ou financeiro e não pode ser removido. Você pode desativar ou arquivar o paciente.'); END IF; -- ownership: owner_id direto ou responsible_member do caller (tenant_members é GLOBAL) IF NOT EXISTS (SELECT 1 FROM patients WHERE id = p_patient_id AND (owner_id = auth.uid() OR responsible_member_id IN (SELECT id FROM public.tenant_members WHERE user_id = auth.uid()))) THEN RETURN jsonb_build_object('ok',false,'error','forbidden','message','Sem permissão para excluir este paciente.'); END IF; DELETE FROM patients WHERE id = p_patient_id; RETURN jsonb_build_object('ok',true); END $$; DROP FUNCTION IF EXISTS public.export_patient_data(uuid); CREATE FUNCTION public.export_patient_data(p_tenant_id uuid, p_patient_id uuid) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_patient RECORD; v_caller uuid; v_result jsonb; BEGIN v_caller := auth.uid(); IF v_caller IS NULL THEN RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE='28000'; END IF; PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); SELECT * INTO v_patient FROM patients WHERE id = p_patient_id; IF NOT FOUND THEN RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE='P0002'; END IF; v_result := jsonb_build_object( 'export_metadata', jsonb_build_object('generated_at', now(), 'generated_by', v_caller, 'tenant_id', p_tenant_id, 'patient_id', p_patient_id, 'lgpd_basis','Art. 18, II - portabilidade dos dados do titular', 'controller','AgenciaPSI - Clinica responsavel','format_version','1.0'), 'paciente', to_jsonb(v_patient), 'contatos', COALESCE((SELECT jsonb_agg(to_jsonb(pc) ORDER BY pc.created_at) FROM patient_contacts pc WHERE pc.patient_id = p_patient_id),'[]'::jsonb), 'contatos_apoio', COALESCE((SELECT jsonb_agg(to_jsonb(psc) ORDER BY psc.created_at) FROM patient_support_contacts psc WHERE psc.patient_id = p_patient_id),'[]'::jsonb), 'historico_status', COALESCE((SELECT jsonb_agg(to_jsonb(psh) ORDER BY psh.alterado_em) FROM patient_status_history psh WHERE psh.patient_id = p_patient_id),'[]'::jsonb), 'timeline', COALESCE((SELECT jsonb_agg(to_jsonb(pt) ORDER BY pt.ocorrido_em) FROM patient_timeline pt WHERE pt.patient_id = p_patient_id),'[]'::jsonb), 'descontos', COALESCE((SELECT jsonb_agg(to_jsonb(pd) ORDER BY pd.created_at) FROM patient_discounts pd WHERE pd.patient_id = p_patient_id),'[]'::jsonb), 'eventos_agenda', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',ae.id,'tipo',ae.tipo,'status',ae.status,'inicio_em',ae.inicio_em,'fim_em',ae.fim_em) ORDER BY ae.inicio_em) FROM agenda_eventos ae WHERE ae.patient_id = p_patient_id),'[]'::jsonb), 'documentos', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',d.id,'nome',d.nome_original,'tipo',d.tipo_documento,'criado_em',d.created_at) ORDER BY d.created_at) FROM documents d WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL),'[]'::jsonb), 'financeiro', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',fr.id,'tipo',fr.type,'valor',fr.final_amount,'status',fr.status,'vencimento',fr.due_date) ORDER BY fr.created_at) FROM financial_records fr WHERE fr.patient_id = p_patient_id AND fr.deleted_at IS NULL),'[]'::jsonb) ); RETURN v_result; END $$; DROP FUNCTION IF EXISTS public.search_global(text, text[], integer); CREATE FUNCTION public.search_global(p_tenant_id uuid, p_q text, p_scope text[] DEFAULT NULL, p_limit integer DEFAULT 8) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_q text; v_pattern text; v_limit int; v_patients jsonb:='[]'::jsonb; v_appointments jsonb:='[]'::jsonb; v_documents jsonb:='[]'::jsonb; v_services jsonb:='[]'::jsonb; v_intakes jsonb:='[]'::jsonb; BEGIN PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); v_q := nullif(btrim(coalesce(p_q,'')),''); IF v_q IS NULL OR length(v_q) < 2 THEN RETURN jsonb_build_object('patients','[]'::jsonb,'appointments','[]'::jsonb,'documents','[]'::jsonb,'services','[]'::jsonb,'intakes','[]'::jsonb); END IF; v_q := left(v_q,80); v_pattern := '%'||v_q||'%'; v_limit := GREATEST(1, LEAST(coalesce(p_limit,8),20)); IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',nome_completo, 'sublabel',coalesce(nullif(email_principal,''),nullif(telefone,''),''),'avatar_url',avatar_url, 'deeplink','/therapist/patients/cadastro/'||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_patients FROM (SELECT p.id,p.nome_completo,p.email_principal,p.telefone,p.avatar_url, GREATEST(similarity(coalesce(p.nome_completo,''),v_q),similarity(coalesce(p.email_principal,''),v_q)*0.7, similarity(coalesce(p.telefone,''),v_q)*0.5,similarity(coalesce(p.cpf,''),v_q)*0.6) AS score FROM patients p WHERE p.nome_completo ILIKE v_pattern OR p.email_principal ILIKE v_pattern OR p.telefone ILIKE v_pattern OR p.cpf ILIKE v_pattern ORDER BY score DESC, p.nome_completo ASC LIMIT v_limit) ranked; END IF; IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',label, 'sublabel',trim(both ' · ' from coalesce(patient_name,')')||' · '||to_char(inicio_em,'DD/MM/YYYY HH24:MI')), 'deeplink','/therapist/agenda?event='||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_appointments FROM (SELECT e.id, coalesce(nullif(e.titulo_custom,''),nullif(e.titulo,''),'Sessão') AS label, e.inicio_em, pat.nome_completo AS patient_name, GREATEST(similarity(coalesce(e.titulo,''),v_q),similarity(coalesce(e.titulo_custom,''),v_q),similarity(coalesce(pat.nome_completo,''),v_q)*0.9) AS score FROM agenda_eventos e LEFT JOIN patients pat ON pat.id = e.patient_id WHERE e.titulo ILIKE v_pattern OR e.titulo_custom ILIKE v_pattern OR pat.nome_completo ILIKE v_pattern ORDER BY score DESC, e.inicio_em DESC LIMIT v_limit) ranked; END IF; IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',nome_original, 'sublabel',trim(both ' · ' from coalesce(patient_name,'')||' · '||coalesce(tipo_documento,'')), 'deeplink','/therapist/patients/'||patient_id::text||'/documents','score',round(score::numeric,3))),'[]'::jsonb) INTO v_documents FROM (SELECT d.id,d.patient_id,d.nome_original,d.tipo_documento,pat.nome_completo AS patient_name, GREATEST(similarity(coalesce(d.nome_original,''),v_q),similarity(coalesce(d.descricao,''),v_q)*0.7) AS score FROM documents d LEFT JOIN patients pat ON pat.id = d.patient_id WHERE d.nome_original ILIKE v_pattern OR d.descricao ILIKE v_pattern ORDER BY score DESC, d.nome_original ASC LIMIT v_limit) ranked; END IF; IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',name, 'sublabel',trim(both ' · ' from 'R$ '||to_char(price,'FM999G999G990D00')||' · '||coalesce(duration_min::text||' min','')), 'deeplink','/configuracoes/precificacao','score',round(score::numeric,3))),'[]'::jsonb) INTO v_services FROM (SELECT s.id,s.name,s.price,s.duration_min, GREATEST(similarity(coalesce(s.name,''),v_q),similarity(coalesce(s.description,''),v_q)*0.7) AS score FROM services s WHERE s.active IS TRUE AND (s.name ILIKE v_pattern OR s.description ILIKE v_pattern) ORDER BY score DESC, s.name ASC LIMIT v_limit) ranked; END IF; -- intakes: patient_intake_requests FICA em public (F1b) → qualifica + filtra tenant_id IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN SELECT coalesce(jsonb_agg(jsonb_build_object('id',id, 'label',coalesce(nullif(trim(nome_completo),''),'(sem nome)'), 'sublabel',trim(both ' · ' from coalesce(nullif(email_principal,''),nullif(telefone,''),'')||' · '||'recebido '||to_char(created_at,'DD/MM/YYYY')), 'deeplink','/therapist/patients/cadastro/recebidos?id='||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_intakes FROM (SELECT r.id,r.nome_completo,r.email_principal,r.telefone,r.created_at, GREATEST(similarity(coalesce(r.nome_completo,''),v_q),similarity(coalesce(r.email_principal,''),v_q)*0.7,similarity(coalesce(r.telefone,''),v_q)*0.5) AS score FROM public.patient_intake_requests r WHERE r.tenant_id = p_tenant_id AND r.status='new' AND (r.nome_completo ILIKE v_pattern OR r.email_principal ILIKE v_pattern OR r.telefone ILIKE v_pattern) ORDER BY score DESC, r.created_at DESC LIMIT v_limit) ranked; END IF; RETURN jsonb_build_object('patients',v_patients,'appointments',v_appointments,'documents',v_documents,'services',v_services,'intakes',v_intakes); END $$; -- ─────────────────────────────────────────────────────────────────────────── -- GRUPO 3 — RETURNS → jsonb (ripple no FE) -- ─────────────────────────────────────────────────────────────────────────── DROP FUNCTION IF EXISTS public.mark_as_paid(uuid, text); CREATE FUNCTION public.mark_as_paid(p_tenant_id uuid, p_financial_record_id uuid, p_payment_method text) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_record RECORD; BEGIN PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); SELECT * INTO v_record FROM financial_records WHERE id = p_financial_record_id AND owner_id = auth.uid() AND deleted_at IS NULL; IF NOT FOUND THEN RAISE EXCEPTION 'Registro financeiro não encontrado ou sem permissão.'; END IF; IF v_record.status NOT IN ('pending','overdue') THEN RAISE EXCEPTION 'Apenas cobranças pendentes ou vencidas podem ser marcadas como pagas.'; END IF; UPDATE financial_records SET status='paid', paid_at=now(), payment_method=p_payment_method, updated_at=now() WHERE id = p_financial_record_id RETURNING * INTO v_record; RETURN to_jsonb(v_record); END $$; DROP FUNCTION IF EXISTS public.create_financial_record_for_session(uuid, uuid, uuid, uuid, numeric, date); CREATE FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_existing RECORD; v_new RECORD; BEGIN PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); SELECT * INTO v_existing FROM financial_records WHERE agenda_evento_id = p_agenda_evento_id AND deleted_at IS NULL AND status != 'cancelled' LIMIT 1; IF FOUND THEN RETURN to_jsonb(v_existing); END IF; INSERT INTO financial_records (owner_id, patient_id, agenda_evento_id, amount, discount_amount, final_amount, status, due_date) VALUES (p_owner_id, p_patient_id, p_agenda_evento_id, p_amount, 0, p_amount, 'pending', p_due_date) RETURNING * INTO v_new; UPDATE agenda_eventos SET billed = TRUE WHERE id = p_agenda_evento_id; RETURN to_jsonb(v_new); END $$; DROP FUNCTION IF EXISTS public.mark_payout_as_paid(uuid); CREATE FUNCTION public.mark_payout_as_paid(p_tenant_id uuid, p_payout_id uuid) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_payout RECORD; BEGIN PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); SELECT * INTO v_payout FROM therapist_payouts WHERE id = p_payout_id; IF NOT FOUND THEN RAISE EXCEPTION 'Repasse não encontrado: %', p_payout_id; END IF; IF NOT public.is_tenant_admin(p_tenant_id) THEN RAISE EXCEPTION 'Apenas o administrador da clínica pode marcar repasses como pagos.'; END IF; IF v_payout.status <> 'pending' THEN RAISE EXCEPTION 'Repasse já está com status ''%''. Apenas repasses pendentes podem ser pagos.', v_payout.status; END IF; UPDATE therapist_payouts SET status='paid', paid_at=now(), updated_at=now() WHERE id = p_payout_id RETURNING * INTO v_payout; RETURN to_jsonb(v_payout); END $$; DROP FUNCTION IF EXISTS public.create_therapist_payout(uuid, uuid, date, date); CREATE FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_payout RECORD; v_total int; v_gross numeric(10,2); v_clinic_fee numeric(10,2); v_net numeric(10,2); BEGIN IF auth.uid() <> p_therapist_id AND NOT public.is_tenant_admin(p_tenant_id) THEN RAISE EXCEPTION 'Sem permissão para criar repasse para este terapeuta.'; END IF; PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); IF EXISTS (SELECT 1 FROM therapist_payouts WHERE owner_id=p_therapist_id AND period_start=p_period_start AND period_end=p_period_end AND status<>'cancelled') THEN RAISE EXCEPTION 'Já existe um repasse ativo para o período % a % deste terapeuta.', p_period_start, p_period_end; END IF; SELECT COUNT(*), COALESCE(SUM(amount),0), COALESCE(SUM(clinic_fee_amount),0), COALESCE(SUM(net_amount),0) INTO v_total, v_gross, v_clinic_fee, v_net FROM financial_records fr WHERE fr.owner_id=p_therapist_id AND fr.type='receita' AND fr.status='paid' AND fr.deleted_at IS NULL AND fr.paid_at::date BETWEEN p_period_start AND p_period_end AND NOT EXISTS (SELECT 1 FROM therapist_payout_records tpr WHERE tpr.financial_record_id = fr.id); IF v_total = 0 THEN RAISE EXCEPTION 'Nenhum registro financeiro elegível encontrado para o período % a %.', p_period_start, p_period_end; END IF; INSERT INTO therapist_payouts (owner_id, period_start, period_end, total_sessions, gross_amount, clinic_fee_total, net_amount, status) VALUES (p_therapist_id, p_period_start, p_period_end, v_total, v_gross, v_clinic_fee, v_net, 'pending') RETURNING * INTO v_payout; INSERT INTO therapist_payout_records (payout_id, financial_record_id) SELECT v_payout.id, fr.id FROM financial_records fr WHERE fr.owner_id=p_therapist_id AND fr.type='receita' AND fr.status='paid' AND fr.deleted_at IS NULL AND fr.paid_at::date BETWEEN p_period_start AND p_period_end AND NOT EXISTS (SELECT 1 FROM therapist_payout_records tpr WHERE tpr.financial_record_id = fr.id); RETURN to_jsonb(v_payout); END $$; COMMIT;