adf9208d2d
Card novo pra clínica e terapeuta com 3 métricas + sparkline: - Tempo médio (e mediana) de 1ª resposta no período - Taxa de SLA cumprido — % de respostas dentro do threshold configurado - Contagem total de respostas no período - Sparkline da evolução com indicador de tendência (melhorando/piorando) - Ranking top 5 terapeutas (só no ClinicDashboard) Filtro de período: 7/30/90 dias (muda granularidade do bucket: 1/7/15 dias pra sparkline com ~5-6 pontos). Banco (migration 20260423000006): - Helper interno _first_response_runs: identifica "runs" de inbound (sequências do paciente sem outbound entre) e calcula delta até a próxima outbound. Evita contar múltiplas mensagens repetidas do paciente. responder_id vem de conversation_assignments. - first_response_stats: agregados (count, avg, median, min, max, sla_compliance_rate baseado em conversation_sla_rules). - first_response_by_therapist: ranking com avg e count por assigned_to. - first_response_evolution: série temporal com bucket alinhado a p_from (p_from + bucket_index * N days). Parâmetro p_bucket_days deixa o frontend escolher granularidade por período. Todas SECURITY DEFINER + GRANT authenticated/service_role. Filtro opcional por therapist_id nas funções que aplicam. Frontend: - useFirstResponseAnalytics composable wraps as 3 RPCs com cache via Promise.all paralelo. Helper formatSeconds (Ns/Xmin/Xh). - FirstResponseCard.vue renderiza sparkline SVG nativo (sem lib extra), cor da taxa SLA por threshold (verde ≥80%, âmbar ≥50%, vermelho). - Integrado em ClinicDashboard (visão global) e TherapistDashboard (filtrado por ownerId, sem ranking). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
256 lines
9.5 KiB
PL/PgSQL
256 lines
9.5 KiB
PL/PgSQL
-- ==========================================================================
|
|
-- Agencia PSI — Migracao: Analytics de tempo de 1ª resposta (Grupo 7.1)
|
|
-- ==========================================================================
|
|
-- Criado por: Leonardo Nohama
|
|
-- Data: 2026-04-23 · Sao Carlos/SP — Brasil
|
|
--
|
|
-- 3 funcoes RPC pra popular card do dashboard:
|
|
-- 1. first_response_stats(tenant, from, to, therapist?)
|
|
-- Agregados do periodo: avg, median, count, sla_compliance_rate
|
|
-- 2. first_response_by_therapist(tenant, from, to)
|
|
-- Ranking: terapeuta → volume + tempo medio
|
|
-- 3. first_response_evolution(tenant, from, to, bucket_days)
|
|
-- Serie temporal (sparkline): buckets de N dias com avg e count
|
|
--
|
|
-- Metodo:
|
|
-- Um "run" de inbound = sequencia de mensagens inbound consecutivas sem
|
|
-- nenhuma outbound entre elas. Isso evita contar multiplas mensagens
|
|
-- do paciente (o famoso "vi sua mensagem mas so respondi depois de 3
|
|
-- disparos dele"). Pra cada run, o tempo conta a partir do PRIMEIRO
|
|
-- inbound ate a PROXIMA outbound.
|
|
--
|
|
-- Thread key identifico como patient_id OU 'anon:'+from_number, consistente
|
|
-- com a view conversation_threads.
|
|
--
|
|
-- SLA compliance: usa threshold da config conversation_sla_rules.
|
|
-- Se nao tem regra ou enabled=false, retorna sla_total=0.
|
|
-- ==========================================================================
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- fn helper interna: monta CTE dos "runs" de inbound com tempo de resposta
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE OR REPLACE FUNCTION public._first_response_runs(
|
|
p_tenant_id UUID,
|
|
p_from TIMESTAMPTZ,
|
|
p_to TIMESTAMPTZ
|
|
)
|
|
RETURNS TABLE (
|
|
thread_key TEXT,
|
|
inbound_started_at TIMESTAMPTZ,
|
|
responded_at TIMESTAMPTZ,
|
|
response_seconds INT,
|
|
responder_id UUID
|
|
)
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
WITH msgs AS (
|
|
SELECT
|
|
m.id,
|
|
m.tenant_id,
|
|
m.direction,
|
|
m.created_at,
|
|
m.patient_id,
|
|
m.from_number,
|
|
m.to_number,
|
|
-- mesma logica da view conversation_threads
|
|
COALESCE(
|
|
m.patient_id::text,
|
|
'anon:' || COALESCE(
|
|
CASE WHEN m.direction = 'inbound' THEN m.from_number ELSE m.to_number END,
|
|
'unknown'
|
|
)
|
|
) AS tk
|
|
FROM public.conversation_messages m
|
|
WHERE m.tenant_id = p_tenant_id
|
|
AND m.direction IN ('inbound', 'outbound')
|
|
AND m.created_at >= p_from
|
|
AND m.created_at <= p_to
|
|
),
|
|
with_prev AS (
|
|
SELECT *,
|
|
LAG(direction) OVER (PARTITION BY tenant_id, tk ORDER BY created_at, id) AS prev_direction
|
|
FROM msgs
|
|
),
|
|
run_starts AS (
|
|
-- Primeira mensagem de cada "run inbound"
|
|
SELECT tk, tenant_id, created_at AS inbound_started_at
|
|
FROM with_prev
|
|
WHERE direction = 'inbound'
|
|
AND (prev_direction IS NULL OR prev_direction = 'outbound')
|
|
)
|
|
SELECT
|
|
r.tk AS thread_key,
|
|
r.inbound_started_at,
|
|
o.created_at AS responded_at,
|
|
EXTRACT(EPOCH FROM (o.created_at - r.inbound_started_at))::INT AS response_seconds,
|
|
-- Quem respondeu: pega o assigned_to atual da thread (snapshot aproximado)
|
|
a.assigned_to AS responder_id
|
|
FROM run_starts r
|
|
LEFT JOIN LATERAL (
|
|
SELECT created_at
|
|
FROM public.conversation_messages m2
|
|
WHERE m2.tenant_id = r.tenant_id
|
|
AND COALESCE(m2.patient_id::text, 'anon:' || COALESCE(m2.to_number, m2.from_number, 'unknown')) = r.tk
|
|
AND m2.direction = 'outbound'
|
|
AND m2.created_at > r.inbound_started_at
|
|
ORDER BY m2.created_at
|
|
LIMIT 1
|
|
) o ON true
|
|
LEFT JOIN public.conversation_assignments a
|
|
ON a.tenant_id = r.tenant_id AND a.thread_key = r.tk
|
|
WHERE o.created_at IS NOT NULL; -- so runs que foram respondidos
|
|
$$;
|
|
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- first_response_stats: agregados do periodo
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE OR REPLACE FUNCTION public.first_response_stats(
|
|
p_tenant_id UUID,
|
|
p_from TIMESTAMPTZ DEFAULT (now() - interval '30 days'),
|
|
p_to TIMESTAMPTZ DEFAULT now(),
|
|
p_therapist_id UUID DEFAULT NULL
|
|
)
|
|
RETURNS TABLE (
|
|
runs_count INT,
|
|
avg_seconds INT,
|
|
median_seconds INT,
|
|
min_seconds INT,
|
|
max_seconds INT,
|
|
sla_threshold_seconds INT,
|
|
sla_compliant_count INT,
|
|
sla_compliance_rate NUMERIC
|
|
)
|
|
LANGUAGE plpgsql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_threshold_seconds INT;
|
|
BEGIN
|
|
-- Pega threshold do SLA (se habilitado)
|
|
SELECT CASE WHEN enabled THEN threshold_minutes * 60 ELSE NULL END
|
|
INTO v_threshold_seconds
|
|
FROM public.conversation_sla_rules
|
|
WHERE tenant_id = p_tenant_id;
|
|
|
|
RETURN QUERY
|
|
WITH runs AS (
|
|
SELECT r.response_seconds, r.responder_id
|
|
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
|
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
|
)
|
|
SELECT
|
|
COUNT(*)::INT AS runs_count,
|
|
COALESCE(AVG(response_seconds)::INT, 0) AS avg_seconds,
|
|
COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_seconds)::INT, 0) AS median_seconds,
|
|
COALESCE(MIN(response_seconds), 0) AS min_seconds,
|
|
COALESCE(MAX(response_seconds), 0) AS max_seconds,
|
|
v_threshold_seconds AS sla_threshold_seconds,
|
|
COUNT(*) FILTER (WHERE v_threshold_seconds IS NOT NULL AND response_seconds <= v_threshold_seconds)::INT AS sla_compliant_count,
|
|
CASE
|
|
WHEN v_threshold_seconds IS NULL OR COUNT(*) = 0 THEN NULL
|
|
ELSE ROUND(100.0 * COUNT(*) FILTER (WHERE response_seconds <= v_threshold_seconds) / COUNT(*), 1)
|
|
END AS sla_compliance_rate
|
|
FROM runs;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.first_response_stats(UUID, TIMESTAMPTZ, TIMESTAMPTZ, UUID) FROM PUBLIC;
|
|
GRANT EXECUTE ON FUNCTION public.first_response_stats(UUID, TIMESTAMPTZ, TIMESTAMPTZ, UUID) TO authenticated, service_role;
|
|
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- first_response_by_therapist: ranking por terapeuta
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE OR REPLACE FUNCTION public.first_response_by_therapist(
|
|
p_tenant_id UUID,
|
|
p_from TIMESTAMPTZ DEFAULT (now() - interval '30 days'),
|
|
p_to TIMESTAMPTZ DEFAULT now()
|
|
)
|
|
RETURNS TABLE (
|
|
therapist_id UUID,
|
|
runs_count INT,
|
|
avg_seconds INT,
|
|
median_seconds INT
|
|
)
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
SELECT
|
|
r.responder_id AS therapist_id,
|
|
COUNT(*)::INT AS runs_count,
|
|
AVG(r.response_seconds)::INT AS avg_seconds,
|
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY r.response_seconds)::INT AS median_seconds
|
|
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
|
WHERE r.responder_id IS NOT NULL
|
|
GROUP BY r.responder_id
|
|
ORDER BY avg_seconds ASC;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.first_response_by_therapist(UUID, TIMESTAMPTZ, TIMESTAMPTZ) FROM PUBLIC;
|
|
GRANT EXECUTE ON FUNCTION public.first_response_by_therapist(UUID, TIMESTAMPTZ, TIMESTAMPTZ) TO authenticated, service_role;
|
|
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- first_response_evolution: serie temporal (buckets de N dias)
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE OR REPLACE FUNCTION public.first_response_evolution(
|
|
p_tenant_id UUID,
|
|
p_from TIMESTAMPTZ DEFAULT (now() - interval '30 days'),
|
|
p_to TIMESTAMPTZ DEFAULT now(),
|
|
p_bucket_days INT DEFAULT 7,
|
|
p_therapist_id UUID DEFAULT NULL
|
|
)
|
|
RETURNS TABLE (
|
|
bucket_start TIMESTAMPTZ,
|
|
runs_count INT,
|
|
avg_seconds INT
|
|
)
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
WITH runs AS (
|
|
SELECT r.inbound_started_at, r.response_seconds
|
|
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
|
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
|
),
|
|
bucketed AS (
|
|
SELECT
|
|
-- Janela alinhada a p_from: bucket_index * N dias + p_from
|
|
p_from + (
|
|
FLOOR(EXTRACT(EPOCH FROM (inbound_started_at - p_from)) / (p_bucket_days * 86400))::INT
|
|
* p_bucket_days * interval '1 day'
|
|
) AS bucket_start,
|
|
response_seconds
|
|
FROM runs
|
|
)
|
|
SELECT
|
|
bucket_start,
|
|
COUNT(*)::INT AS runs_count,
|
|
AVG(response_seconds)::INT AS avg_seconds
|
|
FROM bucketed
|
|
GROUP BY bucket_start
|
|
ORDER BY bucket_start;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.first_response_evolution(UUID, TIMESTAMPTZ, TIMESTAMPTZ, INT, UUID) FROM PUBLIC;
|
|
GRANT EXECUTE ON FUNCTION public.first_response_evolution(UUID, TIMESTAMPTZ, TIMESTAMPTZ, INT, UUID) TO authenticated, service_role;
|
|
|
|
COMMENT ON FUNCTION public.first_response_stats(UUID, TIMESTAMPTZ, TIMESTAMPTZ, UUID) IS
|
|
'Metricas agregadas de tempo de 1a resposta no periodo. Opcionalmente filtra por responder_id.';
|
|
|
|
COMMENT ON FUNCTION public.first_response_by_therapist(UUID, TIMESTAMPTZ, TIMESTAMPTZ) IS
|
|
'Ranking de tempo medio de 1a resposta por terapeuta atribuido.';
|
|
|
|
COMMENT ON FUNCTION public.first_response_evolution(UUID, TIMESTAMPTZ, TIMESTAMPTZ, INT, UUID) IS
|
|
'Serie temporal em buckets de N dias pra sparkline.';
|