-- ============================================================================= -- Migration: 20260420000005_conversation_messages -- Sessao 11 - Fase 5a (CRM de WhatsApp / inbox). -- -- Cria infraestrutura para receber mensagens inbound de WhatsApp (Twilio e -- Evolution API) e exibir num Kanban de conversas. -- -- - conversation_messages — todas as mensagens (in/out) com link opcional -- ao paciente via telefone matching -- - function match_patient_by_phone(tenant_id, phone) — encontra paciente -- - view conversation_threads — agregado por paciente/numero pra UI Kanban -- -- RLS: tenant members leem; service_role (edge function) escreve via SECURITY -- DEFINER match_and_insert. App nao escreve direto. -- ============================================================================= -- --------------------------------------------------------------------------- -- Tabela de mensagens -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.conversation_messages ( id BIGSERIAL PRIMARY KEY, tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE, patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL, channel TEXT NOT NULL CHECK (channel IN ('whatsapp', 'sms', 'email')), direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')), from_number TEXT, to_number TEXT, body TEXT, media_url TEXT, media_mime TEXT, provider TEXT NOT NULL CHECK (provider IN ('twilio', 'evolution', 'manual')), provider_message_id TEXT, provider_raw JSONB, -- estado Kanban kanban_status TEXT NOT NULL DEFAULT 'awaiting_us' CHECK (kanban_status IN ('urgent', 'awaiting_us', 'awaiting_patient', 'resolved')), priority INT NOT NULL DEFAULT 0, read_at TIMESTAMPTZ, responded_at TIMESTAMPTZ, resolved_at TIMESTAMPTZ, received_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_conv_msg_tenant_created ON public.conversation_messages (tenant_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_conv_msg_patient ON public.conversation_messages (patient_id, created_at DESC) WHERE patient_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_conv_msg_from_number ON public.conversation_messages (tenant_id, from_number); CREATE INDEX IF NOT EXISTS idx_conv_msg_kanban ON public.conversation_messages (tenant_id, kanban_status, priority DESC, created_at DESC); CREATE INDEX IF NOT EXISTS idx_conv_msg_provider_msg_id ON public.conversation_messages (provider_message_id) WHERE provider_message_id IS NOT NULL; -- Trigger de updated_at (usa funcao existente set_updated_at) DROP TRIGGER IF EXISTS trg_conv_messages_updated_at ON public.conversation_messages; CREATE TRIGGER trg_conv_messages_updated_at BEFORE UPDATE ON public.conversation_messages FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); COMMENT ON TABLE public.conversation_messages IS 'Mensagens in/out de WhatsApp/SMS/email. Timeline de conversas do tenant com pacientes.'; -- --------------------------------------------------------------------------- -- Funcao: normaliza telefone BR (remove tudo que nao seja digito, tira DDI 55) -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.normalize_phone_br(p_phone TEXT) RETURNS TEXT LANGUAGE plpgsql IMMUTABLE AS $$ DECLARE v_digits TEXT; BEGIN IF p_phone IS NULL THEN RETURN NULL; END IF; -- remove tudo que nao seja digito v_digits := regexp_replace(p_phone, '\D', '', 'g'); -- remove DDI 55 se tem 12+ digitos (+55 + DDD + numero) IF length(v_digits) >= 12 AND left(v_digits, 2) = '55' THEN v_digits := substr(v_digits, 3); END IF; -- pega os ultimos 11 digitos (DDD + 9digito + 8numero) ou 10 (DDD + 8numero) IF length(v_digits) > 11 THEN v_digits := right(v_digits, 11); END IF; RETURN v_digits; END; $$; COMMENT ON FUNCTION public.normalize_phone_br(TEXT) IS 'Normaliza telefone BR para os ultimos 11 digitos (DDD+numero), removendo DDI +55 e formatacao.'; -- --------------------------------------------------------------------------- -- Funcao: match paciente por telefone dentro de um tenant -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.match_patient_by_phone(p_tenant_id UUID, p_phone TEXT) RETURNS UUID LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ DECLARE v_normalized TEXT; v_patient_id UUID; BEGIN v_normalized := public.normalize_phone_br(p_phone); IF v_normalized IS NULL OR length(v_normalized) < 10 THEN RETURN NULL; END IF; -- prioridade: telefone principal, depois alternativo, depois responsavel SELECT id INTO v_patient_id FROM public.patients WHERE tenant_id = p_tenant_id AND public.normalize_phone_br(telefone) = v_normalized LIMIT 1; IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF; SELECT id INTO v_patient_id FROM public.patients WHERE tenant_id = p_tenant_id AND public.normalize_phone_br(telefone_alternativo) = v_normalized LIMIT 1; IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF; SELECT id INTO v_patient_id FROM public.patients WHERE tenant_id = p_tenant_id AND public.normalize_phone_br(telefone_responsavel) = v_normalized LIMIT 1; RETURN v_patient_id; END; $$; COMMENT ON FUNCTION public.match_patient_by_phone(UUID, TEXT) IS 'Encontra patient_id do tenant cujo telefone (principal/alternativo/responsavel) bate com o numero.'; -- --------------------------------------------------------------------------- -- View: threads agrupadas por paciente ou numero anonimo -- --------------------------------------------------------------------------- DROP VIEW IF EXISTS public.conversation_threads CASCADE; CREATE VIEW public.conversation_threads WITH (security_invoker = true) AS WITH base AS ( SELECT cm.id, cm.tenant_id, cm.patient_id, cm.channel, cm.body, cm.direction, cm.kanban_status, cm.read_at, cm.created_at, CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END AS contact_number, COALESCE(cm.patient_id::text, 'anon:' || COALESCE( CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END, 'unknown' )) AS thread_key FROM public.conversation_messages cm ), latest AS ( SELECT DISTINCT ON (tenant_id, thread_key) tenant_id, thread_key, patient_id, channel, contact_number, body AS last_message_body, direction AS last_message_direction, kanban_status, created_at AS last_message_at FROM base ORDER BY tenant_id, thread_key, created_at DESC ), counts AS ( SELECT tenant_id, thread_key, COUNT(*) AS message_count, COUNT(*) FILTER (WHERE direction = 'inbound' AND read_at IS NULL) AS unread_count FROM base GROUP BY tenant_id, thread_key ) SELECT l.tenant_id, l.thread_key, l.patient_id, p.nome_completo AS patient_name, l.contact_number, l.channel, c.message_count, c.unread_count, l.last_message_at, l.last_message_body, l.last_message_direction, l.kanban_status FROM latest l JOIN counts c ON c.tenant_id = l.tenant_id AND c.thread_key = l.thread_key LEFT JOIN public.patients p ON p.id = l.patient_id; COMMENT ON VIEW public.conversation_threads IS 'Agregado de conversas por paciente ou por numero anonimo. Base do Kanban.'; GRANT SELECT ON public.conversation_threads TO authenticated; -- --------------------------------------------------------------------------- -- RLS: tenant member le; ninguem escreve direto (so via edge function service_role) -- --------------------------------------------------------------------------- ALTER TABLE public.conversation_messages ENABLE ROW LEVEL SECURITY; ALTER TABLE public.conversation_messages FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS "conv_msg: select tenant" ON public.conversation_messages; DROP POLICY IF EXISTS "conv_msg: update kanban" ON public.conversation_messages; DROP POLICY IF EXISTS "conv_msg: no direct insert" ON public.conversation_messages; DROP POLICY IF EXISTS "conv_msg: no direct delete" ON public.conversation_messages; CREATE POLICY "conv_msg: select tenant" ON public.conversation_messages FOR SELECT TO authenticated USING ( public.is_saas_admin() OR tenant_id IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ); -- tenant member pode atualizar apenas kanban_status/read_at/responded_at/resolved_at -- (nao pode mexer em body, provider, etc) CREATE POLICY "conv_msg: update kanban" ON public.conversation_messages FOR UPDATE TO authenticated USING ( tenant_id IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ) WITH CHECK ( tenant_id IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ); CREATE POLICY "conv_msg: no direct insert" ON public.conversation_messages FOR INSERT TO authenticated WITH CHECK (false); CREATE POLICY "conv_msg: no direct delete" ON public.conversation_messages FOR DELETE TO authenticated USING (false);