diff --git a/database-novo/manual/f6_2d_user_rpcs.supabase_admin.sql b/database-novo/manual/f6_2d_user_rpcs.supabase_admin.sql new file mode 100644 index 0000000..ec39845 --- /dev/null +++ b/database-novo/manual/f6_2d_user_rpcs.supabase_admin.sql @@ -0,0 +1,412 @@ +-- ============================================================================= +-- 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; diff --git a/src/components/agenda/AgendaEventoFinanceiroPanel.vue b/src/components/agenda/AgendaEventoFinanceiroPanel.vue index fc9cc59..514a9ad 100644 --- a/src/components/agenda/AgendaEventoFinanceiroPanel.vue +++ b/src/components/agenda/AgendaEventoFinanceiroPanel.vue @@ -36,6 +36,7 @@ import { useConfirm } from 'primevue/useconfirm'; import { supabase } from '@/lib/supabase/client'; import { tenantDb } from '@/lib/supabase/tenantClient'; +import { useTenantStore } from '@/stores/tenantStore'; import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro'; import { emitirReciboParaSessao } from '@/services/DocumentGenerate.service'; @@ -52,6 +53,7 @@ const emit = defineEmits(['cobranca-atualizada']); // ── external ────────────────────────────────────────────────────────────────── const toast = useToast(); const confirm = useConfirm(); +const tenantStore = useTenantStore(); const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaFinanceiro(); // ── estado local ────────────────────────────────────────────────────────────── @@ -186,6 +188,7 @@ async function confirmPayment() { payDlgLoading.value = true; try { const { data, error } = await supabase.rpc('mark_as_paid', { + p_tenant_id: tenantStore.activeTenantId, p_financial_record_id: record.value.id, p_payment_method: payDlgMethod.value }); diff --git a/src/components/search/GlobalSearch.vue b/src/components/search/GlobalSearch.vue index e3a5a24..ad0dd7a 100644 --- a/src/components/search/GlobalSearch.vue +++ b/src/components/search/GlobalSearch.vue @@ -123,7 +123,7 @@ watch(query, (v) => { const mySeq = ++searchSeq; debounceT = setTimeout(async () => { try { - const { data, error } = await supabase.rpc('search_global', { p_q: q, p_limit: 6 }); + const { data, error } = await supabase.rpc('search_global', { p_tenant_id: tenantStore.activeTenantId, p_q: q, p_limit: 6 }); if (mySeq !== searchSeq) return; // resposta antiga, descarta if (error) { // eslint-disable-next-line no-console diff --git a/src/composables/useFinancialRecords.js b/src/composables/useFinancialRecords.js index ea23227..51ec331 100644 --- a/src/composables/useFinancialRecords.js +++ b/src/composables/useFinancialRecords.js @@ -254,14 +254,19 @@ export function useFinancialRecords() { error.value = null; try { + const tenantStore = useTenantStore(); + const tenantId = tenantStore.activeTenantId; + assertTenantId(tenantId); + const { data, error: err } = await supabase.rpc('mark_as_paid', { + p_tenant_id: tenantId, p_financial_record_id: recordId, p_payment_method: paymentMethod }); if (err) throw err; - // RPC retorna SETOF (array) — patch local direto, sem depender do retorno + // RPC retorna jsonb (objeto único) — patch local direto, sem depender do retorno const idx = records.value.findIndex((r) => r.id === recordId); if (idx !== -1) { records.value[idx] = { diff --git a/src/composables/useLgpdExport.js b/src/composables/useLgpdExport.js index 17a1c46..f39635d 100644 --- a/src/composables/useLgpdExport.js +++ b/src/composables/useLgpdExport.js @@ -17,6 +17,7 @@ import { ref } from 'vue'; import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; import { downloadLgpdPDF } from '@/utils/lgpdExportFormats'; function slugify(s) { @@ -53,7 +54,8 @@ export function useLgpdExport() { throw new Error('patientId obrigatório'); } - const { data, error: rpcErr } = await supabase.rpc('export_patient_data', { p_patient_id: patientId }); + const tenantId = useTenantStore().activeTenantId; + const { data, error: rpcErr } = await supabase.rpc('export_patient_data', { p_tenant_id: tenantId, p_patient_id: patientId }); if (rpcErr) throw rpcErr; return data; } diff --git a/src/composables/usePatientLifecycle.js b/src/composables/usePatientLifecycle.js index eaa43e1..12bf103 100644 --- a/src/composables/usePatientLifecycle.js +++ b/src/composables/usePatientLifecycle.js @@ -17,6 +17,7 @@ import { supabase } from '@/lib/supabase/client'; import { tenantDb } from '@/lib/supabase/tenantClient'; +import { useTenantStore } from '@/stores/tenantStore'; export function usePatientLifecycle() { async function canDelete(patientId) { const { data, error } = await supabase.rpc('can_delete_patient', { p_patient_id: patientId }); @@ -25,7 +26,8 @@ export function usePatientLifecycle() { } async function deletePatient(patientId) { - const { data, error } = await supabase.rpc('safe_delete_patient', { p_patient_id: patientId }); + const tenantId = useTenantStore().activeTenantId; + const { data, error } = await supabase.rpc('safe_delete_patient', { p_tenant_id: tenantId, p_patient_id: patientId }); if (error) return { ok: false, error: 'rpc_error', message: error.message }; return data; // { ok, error?, message? } } diff --git a/src/features/agenda/composables/useRecurrence.js b/src/features/agenda/composables/useRecurrence.js index abe3a41..1ba900c 100644 --- a/src/features/agenda/composables/useRecurrence.js +++ b/src/features/agenda/composables/useRecurrence.js @@ -623,7 +623,9 @@ export function useRecurrence() { * Retorna o id da nova regra criada */ async function splitRuleAt(id, fromDateISO) { + const tenantId = currentTenantId(); const { data, error: err } = await supabase.rpc('split_recurrence_at', { + p_tenant_id: tenantId, p_recurrence_id: id, p_from_date: fromDateISO }); @@ -635,7 +637,9 @@ export function useRecurrence() { * Cancela a série a partir de uma data */ async function cancelRuleFrom(id, fromDateISO) { + const tenantId = currentTenantId(); const { error: err } = await supabase.rpc('cancel_recurrence_from', { + p_tenant_id: tenantId, p_recurrence_id: id, p_from_date: fromDateISO }); diff --git a/src/features/financeiro/services/financialRecordsRepository.js b/src/features/financeiro/services/financialRecordsRepository.js index fdcaf78..770832b 100644 --- a/src/features/financeiro/services/financialRecordsRepository.js +++ b/src/features/financeiro/services/financialRecordsRepository.js @@ -163,10 +163,13 @@ export async function createManual(payload) { /** * Marca record como pago via RPC (server-side timestamps + audit). */ -export async function markAsPaid(recordId, paymentMethod) { +export async function markAsPaid(recordId, paymentMethod, { tenantId } = {}) { if (!recordId) throw new Error('recordId obrigatório.'); + const tid = resolveTenantId(tenantId); + // RPC retorna jsonb (objeto único) — `data` é o financial_record, não array. const { data, error } = await supabase.rpc('mark_as_paid', { + p_tenant_id: tid, p_financial_record_id: recordId, p_payment_method: paymentMethod }); diff --git a/src/layout/melissa/MelissaBusca.vue b/src/layout/melissa/MelissaBusca.vue index f2b7423..7de6f08 100644 --- a/src/layout/melissa/MelissaBusca.vue +++ b/src/layout/melissa/MelissaBusca.vue @@ -16,6 +16,7 @@ */ import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'; import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; import { useRecentPatients } from '@/composables/useRecentPatients'; const props = defineProps({ @@ -35,6 +36,8 @@ const props = defineProps({ const emit = defineEmits(['acao', 'paciente', 'evento', 'documento', 'intake', 'goto-date']); +const tenantStore = useTenantStore(); + const rootEl = ref(null); const inputEl = ref(null); const query = ref(''); @@ -277,7 +280,7 @@ watch(query, (v) => { const mySeq = ++searchSeq; debounceT = setTimeout(async () => { try { - const { data, error } = await supabase.rpc('search_global', { p_q: q, p_limit: 6 }); + const { data, error } = await supabase.rpc('search_global', { p_tenant_id: tenantStore.activeTenantId, p_q: q, p_limit: 6 }); if (mySeq !== searchSeq) return; if (error) { console.error('[MelissaBusca search_global]', error);