Agenda, Agendador, Configurações
This commit is contained in:
8
migrations/agenda_eventos_price.sql
Normal file
8
migrations/agenda_eventos_price.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- migrations/agenda_eventos_price.sql
|
||||
-- Adiciona coluna price à agenda_eventos para registrar o valor da sessão
|
||||
|
||||
ALTER TABLE agenda_eventos
|
||||
ADD COLUMN IF NOT EXISTS price numeric(10,2);
|
||||
|
||||
COMMENT ON COLUMN agenda_eventos.price IS
|
||||
'Valor da sessão em BRL. Preenchido automaticamente pela tabela professional_pricing do profissional.';
|
||||
36
migrations/agendador_check_email.sql
Normal file
36
migrations/agendador_check_email.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- agendador_check_email
|
||||
-- Verifica se um e-mail já possui solicitação anterior para este agendador
|
||||
-- SECURITY DEFINER → anon pode chamar sem burlar RLS diretamente
|
||||
-- Execute no Supabase SQL Editor
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.agendador_check_email(
|
||||
p_slug text,
|
||||
p_email text
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
BEGIN
|
||||
SELECT c.owner_id INTO v_owner_id
|
||||
FROM public.agendador_configuracoes c
|
||||
WHERE c.link_slug = p_slug AND c.ativo = true
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN RETURN false; END IF;
|
||||
|
||||
RETURN EXISTS (
|
||||
SELECT 1 FROM public.agendador_solicitacoes s
|
||||
WHERE s.owner_id = v_owner_id
|
||||
AND lower(s.paciente_email) = lower(trim(p_email))
|
||||
LIMIT 1
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.agendador_check_email(text, text) TO anon, authenticated;
|
||||
62
migrations/agendador_features.sql
Normal file
62
migrations/agendador_features.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Feature keys do Agendador Online
|
||||
-- Execute no Supabase SQL Editor
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── 1. Inserir as features ──────────────────────────────────────────────────
|
||||
INSERT INTO public.features (key, name, descricao)
|
||||
VALUES
|
||||
(
|
||||
'agendador.online',
|
||||
'Agendador Online',
|
||||
'Permite que pacientes solicitem agendamentos via link público. Inclui aprovação manual ou automática, controle de horários e notificações.'
|
||||
),
|
||||
(
|
||||
'agendador.link_personalizado',
|
||||
'Link Personalizado do Agendador',
|
||||
'Permite que o profissional escolha um slug de URL próprio para o agendador (ex: /agendar/dra-ana-silva) em vez de um link gerado automaticamente.'
|
||||
)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
descricao = EXCLUDED.descricao;
|
||||
|
||||
-- ── 2. Vincular aos planos ──────────────────────────────────────────────────
|
||||
-- ATENÇÃO: ajuste os filtros de plan key/name conforme seus planos reais.
|
||||
-- Exemplo: agendador.online disponível para planos PRO e acima.
|
||||
-- agendador.link_personalizado apenas para planos Elite/Superior.
|
||||
|
||||
-- agendador.online → todos os planos com target 'therapist' ou 'clinic'
|
||||
-- (Adapte o WHERE conforme necessário)
|
||||
INSERT INTO public.plan_features (plan_id, feature_id, enabled)
|
||||
SELECT
|
||||
p.id,
|
||||
f.id,
|
||||
true
|
||||
FROM public.plans p
|
||||
CROSS JOIN public.features f
|
||||
WHERE f.key = 'agendador.online'
|
||||
AND p.is_active = true
|
||||
-- Comente a linha abaixo para liberar para TODOS os planos:
|
||||
-- AND p.key IN ('pro', 'elite', 'clinic_pro', 'clinic_elite')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- agendador.link_personalizado → apenas planos superiores
|
||||
-- Deixe comentado e adicione manualmente quando definir os planos:
|
||||
-- INSERT INTO public.plan_features (plan_id, feature_id, enabled)
|
||||
-- SELECT p.id, f.id, true
|
||||
-- FROM public.plans p
|
||||
-- CROSS JOIN public.features f
|
||||
-- WHERE f.key = 'agendador.link_personalizado'
|
||||
-- AND p.key IN ('elite', 'clinic_elite', 'pro_plus')
|
||||
-- ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── 3. Verificação ─────────────────────────────────────────────────────────
|
||||
SELECT
|
||||
f.key,
|
||||
f.name,
|
||||
COUNT(pf.plan_id) AS planos_vinculados
|
||||
FROM public.features f
|
||||
LEFT JOIN public.plan_features pf ON pf.feature_id = f.id AND pf.enabled = true
|
||||
WHERE f.key IN ('agendador.online', 'agendador.link_personalizado')
|
||||
GROUP BY f.key, f.name
|
||||
ORDER BY f.key;
|
||||
221
migrations/agendador_fix_slots.sql
Normal file
221
migrations/agendador_fix_slots.sql
Normal file
@@ -0,0 +1,221 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- FIX: agendador_slots_disponiveis + agendador_dias_disponiveis
|
||||
-- Usa agenda_online_slots como fonte de slots
|
||||
-- Cruzamento com: agenda_eventos, recurrence_rules/exceptions, agendador_solicitacoes
|
||||
-- Execute no Supabase SQL Editor
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.agendador_slots_disponiveis(
|
||||
p_slug text,
|
||||
p_data date
|
||||
)
|
||||
RETURNS TABLE (hora time, disponivel boolean)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
v_duracao int;
|
||||
v_antecedencia int;
|
||||
v_agora timestamptz;
|
||||
v_db_dow int;
|
||||
v_slot time;
|
||||
v_slot_fim time;
|
||||
v_slot_ts timestamptz;
|
||||
v_ocupado boolean;
|
||||
-- loop de recorrências
|
||||
v_rule RECORD;
|
||||
v_rule_start_dow int;
|
||||
v_first_occ date;
|
||||
v_day_diff int;
|
||||
v_ex_type text;
|
||||
BEGIN
|
||||
SELECT c.owner_id, c.duracao_sessao_min, c.antecedencia_minima_horas
|
||||
INTO v_owner_id, v_duracao, v_antecedencia
|
||||
FROM public.agendador_configuracoes c
|
||||
WHERE c.link_slug = p_slug AND c.ativo = true
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||
|
||||
v_agora := now();
|
||||
v_db_dow := extract(dow from p_data::timestamp)::int;
|
||||
|
||||
FOR v_slot IN
|
||||
SELECT s.time
|
||||
FROM public.agenda_online_slots s
|
||||
WHERE s.owner_id = v_owner_id
|
||||
AND s.weekday = v_db_dow
|
||||
AND s.enabled = true
|
||||
ORDER BY s.time
|
||||
LOOP
|
||||
v_slot_fim := v_slot + (v_duracao || ' minutes')::interval;
|
||||
v_ocupado := false;
|
||||
|
||||
-- ── Antecedência mínima ──────────────────────────────────────────────────
|
||||
v_slot_ts := (p_data::text || ' ' || v_slot::text)::timestamp
|
||||
AT TIME ZONE 'America/Sao_Paulo';
|
||||
IF v_slot_ts < v_agora + (v_antecedencia || ' hours')::interval THEN
|
||||
v_ocupado := true;
|
||||
END IF;
|
||||
|
||||
-- ── Eventos avulsos internos (agenda_eventos) ────────────────────────────
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agenda_eventos e
|
||||
WHERE e.owner_id = v_owner_id
|
||||
AND e.status::text NOT IN ('cancelado', 'faltou')
|
||||
AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date = p_data
|
||||
AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::time < v_slot_fim
|
||||
AND (e.fim_em AT TIME ZONE 'America/Sao_Paulo')::time > v_slot
|
||||
) INTO v_ocupado;
|
||||
END IF;
|
||||
|
||||
-- ── Recorrências ativas (recurrence_rules) ───────────────────────────────
|
||||
-- Loop explícito para evitar erros de tipo no cálculo do ciclo semanal
|
||||
IF NOT v_ocupado THEN
|
||||
FOR v_rule IN
|
||||
SELECT
|
||||
r.id,
|
||||
r.start_date::date AS start_date,
|
||||
r.end_date::date AS end_date,
|
||||
r.start_time::time AS start_time,
|
||||
r.end_time::time AS end_time,
|
||||
COALESCE(r.interval, 1)::int AS interval
|
||||
FROM public.recurrence_rules r
|
||||
WHERE r.owner_id = v_owner_id
|
||||
AND r.status = 'ativo'
|
||||
AND p_data >= r.start_date::date
|
||||
AND (r.end_date IS NULL OR p_data <= r.end_date::date)
|
||||
AND v_db_dow = ANY(r.weekdays)
|
||||
AND r.start_time::time < v_slot_fim
|
||||
AND r.end_time::time > v_slot
|
||||
LOOP
|
||||
-- Calcula a primeira ocorrência do dia-da-semana a partir do start_date
|
||||
v_rule_start_dow := extract(dow from v_rule.start_date)::int;
|
||||
v_first_occ := v_rule.start_date
|
||||
+ (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
|
||||
v_day_diff := (p_data - v_first_occ)::int;
|
||||
|
||||
-- Ocorrência válida: diff >= 0 e divisível pelo ciclo semanal
|
||||
IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
|
||||
|
||||
-- Verifica se há exceção para esta data
|
||||
v_ex_type := NULL;
|
||||
SELECT ex.type INTO v_ex_type
|
||||
FROM public.recurrence_exceptions ex
|
||||
WHERE ex.recurrence_id = v_rule.id
|
||||
AND ex.original_date = p_data
|
||||
LIMIT 1;
|
||||
|
||||
-- Sem exceção, ou exceção que não cancela → bloqueia o slot
|
||||
IF v_ex_type IS NULL OR v_ex_type NOT IN (
|
||||
'cancel_session', 'patient_missed',
|
||||
'therapist_canceled', 'holiday_block',
|
||||
'reschedule_session'
|
||||
) THEN
|
||||
v_ocupado := true;
|
||||
EXIT; -- já basta uma regra que conflite
|
||||
END IF;
|
||||
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- ── Recorrências remarcadas para este dia (reschedule → new_date = p_data) ─
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.recurrence_exceptions ex
|
||||
JOIN public.recurrence_rules r ON r.id = ex.recurrence_id
|
||||
WHERE r.owner_id = v_owner_id
|
||||
AND r.status = 'ativo'
|
||||
AND ex.type = 'reschedule_session'
|
||||
AND ex.new_date = p_data
|
||||
AND COALESCE(ex.new_start_time, r.start_time)::time < v_slot_fim
|
||||
AND COALESCE(ex.new_end_time, r.end_time)::time > v_slot
|
||||
) INTO v_ocupado;
|
||||
END IF;
|
||||
|
||||
-- ── Solicitações públicas pendentes ──────────────────────────────────────
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agendador_solicitacoes sol
|
||||
WHERE sol.owner_id = v_owner_id
|
||||
AND sol.status = 'pendente'
|
||||
AND sol.data_solicitada = p_data
|
||||
AND sol.hora_solicitada = v_slot
|
||||
AND (sol.reservado_ate IS NULL OR sol.reservado_ate > v_agora)
|
||||
) INTO v_ocupado;
|
||||
END IF;
|
||||
|
||||
hora := v_slot;
|
||||
disponivel := NOT v_ocupado;
|
||||
RETURN NEXT;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.agendador_slots_disponiveis(text, date) TO anon, authenticated;
|
||||
|
||||
|
||||
-- ── agendador_dias_disponiveis ───────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.agendador_dias_disponiveis(
|
||||
p_slug text,
|
||||
p_ano int,
|
||||
p_mes int
|
||||
)
|
||||
RETURNS TABLE (data date, tem_slots boolean)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
v_antecedencia int;
|
||||
v_agora timestamptz;
|
||||
v_data date;
|
||||
v_data_inicio date;
|
||||
v_data_fim date;
|
||||
v_db_dow int;
|
||||
v_tem_slot boolean;
|
||||
BEGIN
|
||||
SELECT c.owner_id, c.antecedencia_minima_horas
|
||||
INTO v_owner_id, v_antecedencia
|
||||
FROM public.agendador_configuracoes c
|
||||
WHERE c.link_slug = p_slug AND c.ativo = true
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||
|
||||
v_agora := now();
|
||||
v_data_inicio := make_date(p_ano, p_mes, 1);
|
||||
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date;
|
||||
|
||||
v_data := v_data_inicio;
|
||||
WHILE v_data <= v_data_fim LOOP
|
||||
v_db_dow := extract(dow from v_data::timestamp)::int;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agenda_online_slots s
|
||||
WHERE s.owner_id = v_owner_id
|
||||
AND s.weekday = v_db_dow
|
||||
AND s.enabled = true
|
||||
AND (v_data::text || ' ' || s.time::text)::timestamp
|
||||
AT TIME ZONE 'America/Sao_Paulo'
|
||||
>= v_agora + (v_antecedencia || ' hours')::interval
|
||||
) INTO v_tem_slot;
|
||||
|
||||
IF v_tem_slot THEN
|
||||
data := v_data;
|
||||
tem_slots := true;
|
||||
RETURN NEXT;
|
||||
END IF;
|
||||
|
||||
v_data := v_data + 1;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.agendador_dias_disponiveis(text, int, int) TO anon, authenticated;
|
||||
170
migrations/agendador_online.sql
Normal file
170
migrations/agendador_online.sql
Normal file
@@ -0,0 +1,170 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Agendador Online — tabelas de configuração e solicitações
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── 1. agendador_configuracoes ──────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS "public"."agendador_configuracoes" (
|
||||
"owner_id" "uuid" NOT NULL,
|
||||
"tenant_id" "uuid",
|
||||
|
||||
-- PRO / Ativação
|
||||
"ativo" boolean DEFAULT false NOT NULL,
|
||||
"link_slug" "text",
|
||||
|
||||
-- Identidade Visual
|
||||
"imagem_fundo_url" "text",
|
||||
"imagem_header_url" "text",
|
||||
"logomarca_url" "text",
|
||||
"cor_primaria" "text" DEFAULT '#4b6bff',
|
||||
|
||||
-- Perfil Público
|
||||
"nome_exibicao" "text",
|
||||
"endereco" "text",
|
||||
"botao_como_chegar_ativo" boolean DEFAULT true NOT NULL,
|
||||
"maps_url" "text",
|
||||
|
||||
-- Fluxo de Agendamento
|
||||
"modo_aprovacao" "text" DEFAULT 'aprovacao' NOT NULL,
|
||||
"modalidade" "text" DEFAULT 'presencial' NOT NULL,
|
||||
"tipos_habilitados" "jsonb" DEFAULT '["primeira","retorno"]'::jsonb NOT NULL,
|
||||
"duracao_sessao_min" integer DEFAULT 50 NOT NULL,
|
||||
"antecedencia_minima_horas" integer DEFAULT 24 NOT NULL,
|
||||
"prazo_resposta_horas" integer DEFAULT 2 NOT NULL,
|
||||
"reserva_horas" integer DEFAULT 2 NOT NULL,
|
||||
|
||||
-- Pagamento
|
||||
"pagamento_obrigatorio" boolean DEFAULT false NOT NULL,
|
||||
"pix_chave" "text",
|
||||
"pix_countdown_minutos" integer DEFAULT 20 NOT NULL,
|
||||
|
||||
-- Triagem & Conformidade
|
||||
"triagem_motivo" boolean DEFAULT true NOT NULL,
|
||||
"triagem_como_conheceu" boolean DEFAULT false NOT NULL,
|
||||
"verificacao_email" boolean DEFAULT false NOT NULL,
|
||||
"exigir_aceite_lgpd" boolean DEFAULT true NOT NULL,
|
||||
|
||||
-- Textos
|
||||
"mensagem_boas_vindas" "text",
|
||||
"texto_como_se_preparar" "text",
|
||||
"texto_termos_lgpd" "text",
|
||||
|
||||
-- Timestamps
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
|
||||
CONSTRAINT "agendador_configuracoes_pkey" PRIMARY KEY ("owner_id"),
|
||||
CONSTRAINT "agendador_configuracoes_owner_fk"
|
||||
FOREIGN KEY ("owner_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "agendador_configuracoes_tenant_fk"
|
||||
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "agendador_configuracoes_modo_check"
|
||||
CHECK ("modo_aprovacao" = ANY (ARRAY['automatico','aprovacao'])),
|
||||
CONSTRAINT "agendador_configuracoes_modalidade_check"
|
||||
CHECK ("modalidade" = ANY (ARRAY['presencial','online','ambos'])),
|
||||
CONSTRAINT "agendador_configuracoes_duracao_check"
|
||||
CHECK ("duracao_sessao_min" >= 10 AND "duracao_sessao_min" <= 240),
|
||||
CONSTRAINT "agendador_configuracoes_antecedencia_check"
|
||||
CHECK ("antecedencia_minima_horas" >= 0 AND "antecedencia_minima_horas" <= 720),
|
||||
CONSTRAINT "agendador_configuracoes_reserva_check"
|
||||
CHECK ("reserva_horas" >= 1 AND "reserva_horas" <= 48),
|
||||
CONSTRAINT "agendador_configuracoes_pix_countdown_check"
|
||||
CHECK ("pix_countdown_minutos" >= 5 AND "pix_countdown_minutos" <= 120),
|
||||
CONSTRAINT "agendador_configuracoes_prazo_check"
|
||||
CHECK ("prazo_resposta_horas" >= 1 AND "prazo_resposta_horas" <= 72)
|
||||
);
|
||||
|
||||
ALTER TABLE "public"."agendador_configuracoes" ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "agendador_cfg_select" ON "public"."agendador_configuracoes";
|
||||
CREATE POLICY "agendador_cfg_select" ON "public"."agendador_configuracoes"
|
||||
FOR SELECT USING (auth.uid() = owner_id);
|
||||
|
||||
DROP POLICY IF EXISTS "agendador_cfg_write" ON "public"."agendador_configuracoes";
|
||||
CREATE POLICY "agendador_cfg_write" ON "public"."agendador_configuracoes"
|
||||
USING (auth.uid() = owner_id)
|
||||
WITH CHECK (auth.uid() = owner_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "agendador_cfg_tenant_idx"
|
||||
ON "public"."agendador_configuracoes" ("tenant_id");
|
||||
|
||||
-- ── 2. agendador_solicitacoes ───────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS "public"."agendador_solicitacoes" (
|
||||
"id" "uuid" DEFAULT gen_random_uuid() NOT NULL,
|
||||
"owner_id" "uuid" NOT NULL,
|
||||
"tenant_id" "uuid",
|
||||
|
||||
-- Dados do paciente
|
||||
"paciente_nome" "text" NOT NULL,
|
||||
"paciente_sobrenome" "text",
|
||||
"paciente_email" "text" NOT NULL,
|
||||
"paciente_celular" "text",
|
||||
"paciente_cpf" "text",
|
||||
|
||||
-- Agendamento solicitado
|
||||
"tipo" "text" NOT NULL,
|
||||
"modalidade" "text" NOT NULL,
|
||||
"data_solicitada" date NOT NULL,
|
||||
"hora_solicitada" time NOT NULL,
|
||||
|
||||
-- Reserva temporária
|
||||
"reservado_ate" timestamp with time zone,
|
||||
|
||||
-- Triagem
|
||||
"motivo" "text",
|
||||
"como_conheceu" "text",
|
||||
|
||||
-- Pagamento
|
||||
"pix_status" "text" DEFAULT 'pendente',
|
||||
"pix_pago_em" timestamp with time zone,
|
||||
|
||||
-- Status geral
|
||||
"status" "text" DEFAULT 'pendente' NOT NULL,
|
||||
"recusado_motivo" "text",
|
||||
|
||||
-- Autorização
|
||||
"autorizado_em" timestamp with time zone,
|
||||
"autorizado_por" "uuid",
|
||||
|
||||
-- Vínculos internos
|
||||
"user_id" "uuid",
|
||||
"patient_id" "uuid",
|
||||
"evento_id" "uuid",
|
||||
|
||||
-- Timestamps
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
|
||||
CONSTRAINT "agendador_solicitacoes_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "agendador_sol_owner_fk"
|
||||
FOREIGN KEY ("owner_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "agendador_sol_tenant_fk"
|
||||
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "agendador_sol_status_check"
|
||||
CHECK ("status" = ANY (ARRAY['pendente','autorizado','recusado','expirado'])),
|
||||
CONSTRAINT "agendador_sol_tipo_check"
|
||||
CHECK ("tipo" = ANY (ARRAY['primeira','retorno','reagendar'])),
|
||||
CONSTRAINT "agendador_sol_modalidade_check"
|
||||
CHECK ("modalidade" = ANY (ARRAY['presencial','online'])),
|
||||
CONSTRAINT "agendador_sol_pix_check"
|
||||
CHECK ("pix_status" IS NULL OR "pix_status" = ANY (ARRAY['pendente','pago','expirado']))
|
||||
);
|
||||
|
||||
ALTER TABLE "public"."agendador_solicitacoes" ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "agendador_sol_owner_select" ON "public"."agendador_solicitacoes";
|
||||
CREATE POLICY "agendador_sol_owner_select" ON "public"."agendador_solicitacoes"
|
||||
FOR SELECT USING (auth.uid() = owner_id);
|
||||
|
||||
DROP POLICY IF EXISTS "agendador_sol_owner_write" ON "public"."agendador_solicitacoes";
|
||||
CREATE POLICY "agendador_sol_owner_write" ON "public"."agendador_solicitacoes"
|
||||
USING (auth.uid() = owner_id)
|
||||
WITH CHECK (auth.uid() = owner_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "agendador_sol_owner_idx"
|
||||
ON "public"."agendador_solicitacoes" ("owner_id", "status");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "agendador_sol_tenant_idx"
|
||||
ON "public"."agendador_solicitacoes" ("tenant_id");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "agendador_sol_data_idx"
|
||||
ON "public"."agendador_solicitacoes" ("data_solicitada", "hora_solicitada");
|
||||
20
migrations/agendador_pagamento_modo.sql
Normal file
20
migrations/agendador_pagamento_modo.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- migrations/agendador_pagamento_modo.sql
|
||||
-- Adiciona suporte a modo de pagamento no agendador online
|
||||
-- Execute no Supabase SQL Editor
|
||||
|
||||
ALTER TABLE agendador_configuracoes
|
||||
ADD COLUMN IF NOT EXISTS pagamento_modo text NOT NULL DEFAULT 'sem_pagamento',
|
||||
ADD COLUMN IF NOT EXISTS pagamento_metodos_visiveis text[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Migração de dados existentes:
|
||||
-- quem tinha pagamento_obrigatorio = true → pix_antecipado
|
||||
UPDATE agendador_configuracoes
|
||||
SET pagamento_modo = 'pix_antecipado'
|
||||
WHERE pagamento_obrigatorio = true
|
||||
AND pagamento_modo = 'sem_pagamento';
|
||||
|
||||
COMMENT ON COLUMN agendador_configuracoes.pagamento_modo IS
|
||||
'sem_pagamento | pagar_na_hora | pix_antecipado';
|
||||
|
||||
COMMENT ON COLUMN agendador_configuracoes.pagamento_metodos_visiveis IS
|
||||
'Métodos exibidos ao paciente quando pagamento_modo = pagar_na_hora. Ex: {pix, deposito, dinheiro, cartao, convenio}';
|
||||
219
migrations/agendador_publico.sql
Normal file
219
migrations/agendador_publico.sql
Normal file
@@ -0,0 +1,219 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Agendador Online — acesso público (anon) + função de slots disponíveis
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── 1. Geração automática de slug ──────────────────────────────────────────
|
||||
-- Cria slug único de 8 chars quando o profissional ativa sem link_personalizado
|
||||
CREATE OR REPLACE FUNCTION public.agendador_gerar_slug()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_slug text;
|
||||
v_exists boolean;
|
||||
BEGIN
|
||||
-- só gera se ativou e não tem slug ainda
|
||||
IF NEW.ativo = true AND (NEW.link_slug IS NULL OR NEW.link_slug = '') THEN
|
||||
LOOP
|
||||
v_slug := lower(substring(replace(gen_random_uuid()::text, '-', ''), 1, 8));
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agendador_configuracoes
|
||||
WHERE link_slug = v_slug AND owner_id <> NEW.owner_id
|
||||
) INTO v_exists;
|
||||
EXIT WHEN NOT v_exists;
|
||||
END LOOP;
|
||||
NEW.link_slug := v_slug;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS agendador_slug_trigger ON public.agendador_configuracoes;
|
||||
CREATE TRIGGER agendador_slug_trigger
|
||||
BEFORE INSERT OR UPDATE ON public.agendador_configuracoes
|
||||
FOR EACH ROW EXECUTE FUNCTION public.agendador_gerar_slug();
|
||||
|
||||
-- ── 2. Políticas públicas (anon) ────────────────────────────────────────────
|
||||
|
||||
-- Leitura pública da config pelo slug (só ativo)
|
||||
DROP POLICY IF EXISTS "agendador_cfg_public_read" ON public.agendador_configuracoes;
|
||||
CREATE POLICY "agendador_cfg_public_read" ON public.agendador_configuracoes
|
||||
FOR SELECT TO anon
|
||||
USING (ativo = true AND link_slug IS NOT NULL);
|
||||
|
||||
-- Inserção pública de solicitações (qualquer pessoa pode solicitar)
|
||||
DROP POLICY IF EXISTS "agendador_sol_public_insert" ON public.agendador_solicitacoes;
|
||||
CREATE POLICY "agendador_sol_public_insert" ON public.agendador_solicitacoes
|
||||
FOR INSERT TO anon
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Leitura da própria solicitação (pelo paciente logado)
|
||||
DROP POLICY IF EXISTS "agendador_sol_patient_read" ON public.agendador_solicitacoes;
|
||||
CREATE POLICY "agendador_sol_patient_read" ON public.agendador_solicitacoes
|
||||
FOR SELECT TO authenticated
|
||||
USING (auth.uid() = user_id OR auth.uid() = owner_id);
|
||||
|
||||
-- ── 3. Função: retorna slots disponíveis para uma data ──────────────────────
|
||||
-- Roda como SECURITY DEFINER (acessa agenda_regras e agenda_eventos sem RLS)
|
||||
CREATE OR REPLACE FUNCTION public.agendador_slots_disponiveis(
|
||||
p_slug text,
|
||||
p_data date
|
||||
)
|
||||
RETURNS TABLE (hora time, disponivel boolean)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
v_duracao int;
|
||||
v_reserva int;
|
||||
v_antecedencia int;
|
||||
v_dia_semana int; -- 0=dom..6=sab (JS) → convertemos
|
||||
v_db_dow int; -- 0=dom..6=sab no Postgres (extract dow)
|
||||
v_inicio time;
|
||||
v_fim time;
|
||||
v_slot time;
|
||||
v_slot_fim time;
|
||||
v_agora timestamptz;
|
||||
BEGIN
|
||||
-- carrega config do agendador
|
||||
SELECT
|
||||
c.owner_id,
|
||||
c.duracao_sessao_min,
|
||||
c.reserva_horas,
|
||||
c.antecedencia_minima_horas
|
||||
INTO v_owner_id, v_duracao, v_reserva, v_antecedencia
|
||||
FROM public.agendador_configuracoes c
|
||||
WHERE c.link_slug = p_slug AND c.ativo = true
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
v_agora := now();
|
||||
v_db_dow := extract(dow from p_data::timestamp)::int; -- 0=dom..6=sab
|
||||
|
||||
-- regra semanal para o dia da semana
|
||||
SELECT hora_inicio, hora_fim
|
||||
INTO v_inicio, v_fim
|
||||
FROM public.agenda_regras_semanais
|
||||
WHERE owner_id = v_owner_id
|
||||
AND dia_semana = v_db_dow
|
||||
AND ativo = true
|
||||
LIMIT 1;
|
||||
|
||||
IF v_inicio IS NULL THEN
|
||||
RETURN; -- profissional não atende nesse dia
|
||||
END IF;
|
||||
|
||||
-- itera slots de v_duracao em v_duracao dentro da jornada
|
||||
v_slot := v_inicio;
|
||||
WHILE v_slot + (v_duracao || ' minutes')::interval <= v_fim LOOP
|
||||
v_slot_fim := v_slot + (v_duracao || ' minutes')::interval;
|
||||
|
||||
-- bloco temporário para verificar conflitos
|
||||
DECLARE
|
||||
v_ocupado boolean := false;
|
||||
v_slot_ts timestamptz;
|
||||
BEGIN
|
||||
-- antecedência mínima (compara em horário de Brasília)
|
||||
v_slot_ts := (p_data::text || ' ' || v_slot::text)::timestamp AT TIME ZONE 'America/Sao_Paulo';
|
||||
|
||||
IF v_slot_ts < v_agora + (v_antecedencia || ' hours')::interval THEN
|
||||
v_ocupado := true;
|
||||
END IF;
|
||||
|
||||
-- conflito com eventos existentes na agenda
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agenda_eventos
|
||||
WHERE owner_id = v_owner_id
|
||||
AND status::text NOT IN ('cancelado', 'faltou')
|
||||
AND inicio_em AT TIME ZONE 'America/Sao_Paulo' >= p_data::timestamp
|
||||
AND inicio_em AT TIME ZONE 'America/Sao_Paulo' < p_data::timestamp + interval '1 day'
|
||||
AND (inicio_em AT TIME ZONE 'America/Sao_Paulo')::time < v_slot_fim
|
||||
AND (fim_em AT TIME ZONE 'America/Sao_Paulo')::time > v_slot
|
||||
) INTO v_ocupado;
|
||||
END IF;
|
||||
|
||||
-- conflito com solicitações pendentes (reservadas)
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agendador_solicitacoes
|
||||
WHERE owner_id = v_owner_id
|
||||
AND status = 'pendente'
|
||||
AND data_solicitada = p_data
|
||||
AND hora_solicitada = v_slot
|
||||
AND (reservado_ate IS NULL OR reservado_ate > v_agora)
|
||||
) INTO v_ocupado;
|
||||
END IF;
|
||||
|
||||
hora := v_slot;
|
||||
disponivel := NOT v_ocupado;
|
||||
RETURN NEXT;
|
||||
END;
|
||||
|
||||
v_slot := v_slot + (v_duracao || ' minutes')::interval;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.agendador_slots_disponiveis(text, date) TO anon, authenticated;
|
||||
|
||||
-- ── 4. Função: retorna dias com disponibilidade no mês ─────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.agendador_dias_disponiveis(
|
||||
p_slug text,
|
||||
p_ano int,
|
||||
p_mes int -- 1-12
|
||||
)
|
||||
RETURNS TABLE (data date, tem_slots boolean)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
v_antecedencia int;
|
||||
v_data date;
|
||||
v_data_inicio date;
|
||||
v_data_fim date;
|
||||
v_agora timestamptz;
|
||||
v_db_dow int;
|
||||
v_tem_regra boolean;
|
||||
BEGIN
|
||||
SELECT c.owner_id, c.antecedencia_minima_horas
|
||||
INTO v_owner_id, v_antecedencia
|
||||
FROM public.agendador_configuracoes c
|
||||
WHERE c.link_slug = p_slug AND c.ativo = true
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||
|
||||
v_agora := now();
|
||||
v_data_inicio := make_date(p_ano, p_mes, 1);
|
||||
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date;
|
||||
|
||||
v_data := v_data_inicio;
|
||||
WHILE v_data <= v_data_fim LOOP
|
||||
-- não oferece dias no passado ou dentro da antecedência mínima
|
||||
IF v_data::timestamptz + '23:59:59'::interval > v_agora + (v_antecedencia || ' hours')::interval THEN
|
||||
v_db_dow := extract(dow from v_data::timestamp)::int;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agenda_regras_semanais
|
||||
WHERE owner_id = v_owner_id AND dia_semana = v_db_dow AND ativo = true
|
||||
) INTO v_tem_regra;
|
||||
|
||||
IF v_tem_regra THEN
|
||||
data := v_data;
|
||||
tem_slots := true;
|
||||
RETURN NEXT;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_data := v_data + 1;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.agendador_dias_disponiveis(text, int, int) TO anon, authenticated;
|
||||
19
migrations/agendador_status_convertido.sql
Normal file
19
migrations/agendador_status_convertido.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- FIX: adiciona status 'convertido' na constraint de agendador_solicitacoes
|
||||
-- e adiciona coluna motivo_recusa (alias amigável de recusado_motivo)
|
||||
-- Execute no Supabase SQL Editor
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- 1. Remove o CHECK existente e recria com os novos valores
|
||||
ALTER TABLE public.agendador_solicitacoes
|
||||
DROP CONSTRAINT IF EXISTS "agendador_sol_status_check";
|
||||
|
||||
ALTER TABLE public.agendador_solicitacoes
|
||||
ADD CONSTRAINT "agendador_sol_status_check"
|
||||
CHECK (status = ANY (ARRAY[
|
||||
'pendente',
|
||||
'autorizado',
|
||||
'recusado',
|
||||
'expirado',
|
||||
'convertido'
|
||||
]));
|
||||
56
migrations/agendador_storage.sql
Normal file
56
migrations/agendador_storage.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Storage bucket para imagens do Agendador Online
|
||||
-- Execute no Supabase SQL Editor
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── 1. Criar o bucket ──────────────────────────────────────────────────────
|
||||
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||
VALUES (
|
||||
'agendador',
|
||||
'agendador',
|
||||
true, -- público (URLs diretas sem assinar)
|
||||
5242880, -- 5 MB
|
||||
ARRAY['image/jpeg','image/png','image/webp','image/gif']
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET public = true,
|
||||
file_size_limit = 5242880,
|
||||
allowed_mime_types = ARRAY['image/jpeg','image/png','image/webp','image/gif'];
|
||||
|
||||
-- ── 2. Políticas ───────────────────────────────────────────────────────────
|
||||
|
||||
-- Leitura pública (anon e authenticated)
|
||||
DROP POLICY IF EXISTS "agendador_storage_public_read" ON storage.objects;
|
||||
CREATE POLICY "agendador_storage_public_read"
|
||||
ON storage.objects FOR SELECT
|
||||
USING (bucket_id = 'agendador');
|
||||
|
||||
-- Upload: apenas o dono da pasta (owner_id é o primeiro segmento do path)
|
||||
DROP POLICY IF EXISTS "agendador_storage_owner_insert" ON storage.objects;
|
||||
CREATE POLICY "agendador_storage_owner_insert"
|
||||
ON storage.objects FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
bucket_id = 'agendador'
|
||||
AND (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||
|
||||
-- Update/upsert pelo dono
|
||||
DROP POLICY IF EXISTS "agendador_storage_owner_update" ON storage.objects;
|
||||
CREATE POLICY "agendador_storage_owner_update"
|
||||
ON storage.objects FOR UPDATE
|
||||
TO authenticated
|
||||
USING (
|
||||
bucket_id = 'agendador'
|
||||
AND (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||
|
||||
-- Delete pelo dono
|
||||
DROP POLICY IF EXISTS "agendador_storage_owner_delete" ON storage.objects;
|
||||
CREATE POLICY "agendador_storage_owner_delete"
|
||||
ON storage.objects FOR DELETE
|
||||
TO authenticated
|
||||
USING (
|
||||
bucket_id = 'agendador'
|
||||
AND (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||
71
migrations/payment_settings.sql
Normal file
71
migrations/payment_settings.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- migrations/payment_settings.sql
|
||||
-- Tabela de configurações de formas de pagamento por terapeuta/owner
|
||||
-- Execute no Supabase SQL Editor
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payment_settings (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Pix
|
||||
pix_ativo boolean NOT NULL DEFAULT false,
|
||||
pix_tipo text NOT NULL DEFAULT 'cpf', -- cpf | cnpj | email | celular | aleatoria
|
||||
pix_chave text NOT NULL DEFAULT '',
|
||||
pix_nome_titular text NOT NULL DEFAULT '',
|
||||
|
||||
-- Depósito / TED
|
||||
deposito_ativo boolean NOT NULL DEFAULT false,
|
||||
deposito_banco text NOT NULL DEFAULT '',
|
||||
deposito_agencia text NOT NULL DEFAULT '',
|
||||
deposito_conta text NOT NULL DEFAULT '',
|
||||
deposito_tipo_conta text NOT NULL DEFAULT 'corrente', -- corrente | poupanca
|
||||
deposito_titular text NOT NULL DEFAULT '',
|
||||
deposito_cpf_cnpj text NOT NULL DEFAULT '',
|
||||
|
||||
-- Dinheiro (espécie)
|
||||
dinheiro_ativo boolean NOT NULL DEFAULT false,
|
||||
|
||||
-- Cartão (maquininha presencial)
|
||||
cartao_ativo boolean NOT NULL DEFAULT false,
|
||||
cartao_instrucao text NOT NULL DEFAULT '',
|
||||
|
||||
-- Plano de saúde / Convênio
|
||||
convenio_ativo boolean NOT NULL DEFAULT false,
|
||||
convenio_lista text NOT NULL DEFAULT '', -- texto livre com convênios aceitos
|
||||
|
||||
-- Observações gerais exibidas ao paciente
|
||||
observacoes_pagamento text NOT NULL DEFAULT '',
|
||||
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
|
||||
CONSTRAINT payment_settings_owner_id_key UNIQUE (owner_id)
|
||||
);
|
||||
|
||||
-- Índice por tenant
|
||||
CREATE INDEX IF NOT EXISTS payment_settings_tenant_id_idx ON payment_settings(tenant_id);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE payment_settings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Owner lê e escreve os próprios dados
|
||||
DROP POLICY IF EXISTS "payment_settings: owner full access" ON payment_settings;
|
||||
CREATE POLICY "payment_settings: owner full access"
|
||||
ON payment_settings
|
||||
FOR ALL
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- updated_at automático
|
||||
CREATE OR REPLACE FUNCTION update_payment_settings_updated_at()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_payment_settings_updated_at ON payment_settings;
|
||||
CREATE TRIGGER trg_payment_settings_updated_at
|
||||
BEFORE UPDATE ON payment_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION update_payment_settings_updated_at();
|
||||
61
migrations/professional_pricing.sql
Normal file
61
migrations/professional_pricing.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- migrations/professional_pricing.sql
|
||||
-- Fase 1: Precificação — tabela de preços padrão por profissional
|
||||
-- Execute no Supabase SQL Editor
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
-- 1. Campo price em agenda_eventos
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
ALTER TABLE agenda_eventos
|
||||
ADD COLUMN IF NOT EXISTS price numeric(10,2);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
-- 2. Tabela de preços por profissional
|
||||
-- Chave: owner_id + determined_commitment_id (NULL = padrão)
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS professional_pricing (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL,
|
||||
determined_commitment_id uuid REFERENCES determined_commitments(id) ON DELETE SET NULL,
|
||||
-- NULL = preço padrão (fallback quando não há match por tipo)
|
||||
price numeric(10,2) NOT NULL,
|
||||
notes text,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
|
||||
CONSTRAINT professional_pricing_owner_commitment_key
|
||||
UNIQUE (owner_id, determined_commitment_id)
|
||||
);
|
||||
|
||||
-- Índice por tenant (listagens do admin)
|
||||
CREATE INDEX IF NOT EXISTS professional_pricing_tenant_idx
|
||||
ON professional_pricing (tenant_id);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
-- 3. RLS
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
ALTER TABLE professional_pricing ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Terapeuta lê e escreve seus próprios preços
|
||||
DROP POLICY IF EXISTS "professional_pricing: owner full access" ON professional_pricing;
|
||||
CREATE POLICY "professional_pricing: owner full access"
|
||||
ON professional_pricing
|
||||
FOR ALL
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
-- 4. updated_at automático
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION update_professional_pricing_updated_at()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_professional_pricing_updated_at ON professional_pricing;
|
||||
CREATE TRIGGER trg_professional_pricing_updated_at
|
||||
BEFORE UPDATE ON professional_pricing
|
||||
FOR EACH ROW EXECUTE FUNCTION update_professional_pricing_updated_at();
|
||||
6
migrations/recurrence_rules_price.sql
Normal file
6
migrations/recurrence_rules_price.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- migrations/recurrence_rules_price.sql
|
||||
-- Adiciona campo price em recurrence_rules para herança nas ocorrências virtuais
|
||||
-- Execute no Supabase SQL Editor
|
||||
|
||||
ALTER TABLE recurrence_rules
|
||||
ADD COLUMN IF NOT EXISTS price numeric(10,2);
|
||||
6
migrations/remove_session_start_offset.sql
Normal file
6
migrations/remove_session_start_offset.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Migration: remove session_start_offset_min from agenda_configuracoes
|
||||
-- This field is replaced by hora_inicio in agenda_regras_semanais (work schedule per day)
|
||||
-- The first session slot is now derived directly from hora_inicio of the work rule.
|
||||
|
||||
ALTER TABLE public.agenda_configuracoes
|
||||
DROP COLUMN IF EXISTS session_start_offset_min;
|
||||
235
migrations/support_sessions.sql
Normal file
235
migrations/support_sessions.sql
Normal file
@@ -0,0 +1,235 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Support Sessions — Sessões de suporte técnico SaaS
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Permite que admins SaaS gerem tokens de acesso temporário para debug
|
||||
-- de agendas de terapeutas, sem expor debug para usuários comuns.
|
||||
--
|
||||
-- SEGURANÇA:
|
||||
-- - RLS: só saas_admin pode criar/listar sessões
|
||||
-- - Token é opaco (gen_random_uuid) — não adivinhável
|
||||
-- - expires_at com TTL máximo de 60 minutos
|
||||
-- - validate_support_session() retorna apenas true/false + tenant_id
|
||||
-- (não expõe dados do admin)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── Tabela ──────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "public"."support_sessions" (
|
||||
"id" uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tenant_id" uuid NOT NULL,
|
||||
"admin_id" uuid NOT NULL,
|
||||
"token" text NOT NULL DEFAULT (replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '')),
|
||||
"expires_at" timestamp with time zone NOT NULL
|
||||
DEFAULT (now() + interval '60 minutes'),
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT "support_sessions_pkey" PRIMARY KEY ("id"),
|
||||
|
||||
CONSTRAINT "support_sessions_tenant_fk"
|
||||
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT "support_sessions_admin_fk"
|
||||
FOREIGN KEY ("admin_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT "support_sessions_token_unique" UNIQUE ("token")
|
||||
);
|
||||
|
||||
-- ── Índices ──────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "support_sessions_token_idx"
|
||||
ON "public"."support_sessions" ("token");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "support_sessions_tenant_idx"
|
||||
ON "public"."support_sessions" ("tenant_id");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "support_sessions_expires_idx"
|
||||
ON "public"."support_sessions" ("expires_at");
|
||||
|
||||
-- ── RLS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE "public"."support_sessions" ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Somente saas_admin pode ver suas próprias sessões de suporte
|
||||
DROP POLICY IF EXISTS "support_sessions_saas_select" ON "public"."support_sessions";
|
||||
CREATE POLICY "support_sessions_saas_select"
|
||||
ON "public"."support_sessions"
|
||||
FOR SELECT
|
||||
USING (
|
||||
auth.uid() = admin_id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.profiles
|
||||
WHERE id = auth.uid()
|
||||
AND role = 'saas_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Somente saas_admin pode criar sessões de suporte
|
||||
DROP POLICY IF EXISTS "support_sessions_saas_insert" ON "public"."support_sessions";
|
||||
CREATE POLICY "support_sessions_saas_insert"
|
||||
ON "public"."support_sessions"
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.uid() = admin_id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.profiles
|
||||
WHERE id = auth.uid()
|
||||
AND role = 'saas_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Somente saas_admin pode deletar suas próprias sessões
|
||||
DROP POLICY IF EXISTS "support_sessions_saas_delete" ON "public"."support_sessions";
|
||||
CREATE POLICY "support_sessions_saas_delete"
|
||||
ON "public"."support_sessions"
|
||||
FOR DELETE
|
||||
USING (
|
||||
auth.uid() = admin_id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.profiles
|
||||
WHERE id = auth.uid()
|
||||
AND role = 'saas_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- ── RPC: create_support_session ───────────────────────────────────────────────
|
||||
-- Cria uma sessão de suporte para um tenant.
|
||||
-- Apenas saas_admin pode chamar. TTL: 60 minutos (configurável via p_ttl_minutes).
|
||||
-- Retorna: token, expires_at
|
||||
|
||||
DROP FUNCTION IF EXISTS public.create_support_session(uuid, integer);
|
||||
CREATE OR REPLACE FUNCTION public.create_support_session(
|
||||
p_tenant_id uuid,
|
||||
p_ttl_minutes integer DEFAULT 60
|
||||
)
|
||||
RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_admin_id uuid;
|
||||
v_role text;
|
||||
v_token text;
|
||||
v_expires timestamp with time zone;
|
||||
v_session support_sessions;
|
||||
BEGIN
|
||||
-- Verifica autenticação
|
||||
v_admin_id := auth.uid();
|
||||
IF v_admin_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado.' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- Verifica role saas_admin
|
||||
SELECT role INTO v_role
|
||||
FROM public.profiles
|
||||
WHERE id = v_admin_id;
|
||||
|
||||
IF v_role <> 'saas_admin' THEN
|
||||
RAISE EXCEPTION 'Acesso negado. Somente saas_admin pode criar sessões de suporte.'
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
-- Valida TTL (1 a 120 minutos)
|
||||
IF p_ttl_minutes < 1 OR p_ttl_minutes > 120 THEN
|
||||
RAISE EXCEPTION 'TTL inválido. Use entre 1 e 120 minutos.'
|
||||
USING ERRCODE = 'P0003';
|
||||
END IF;
|
||||
|
||||
-- Valida tenant
|
||||
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
|
||||
RAISE EXCEPTION 'Tenant não encontrado.'
|
||||
USING ERRCODE = 'P0004';
|
||||
END IF;
|
||||
|
||||
-- Gera token único (64 chars hex, sem pgcrypto)
|
||||
v_token := replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '');
|
||||
v_expires := now() + (p_ttl_minutes || ' minutes')::interval;
|
||||
|
||||
-- Insere sessão
|
||||
INSERT INTO public.support_sessions (tenant_id, admin_id, token, expires_at)
|
||||
VALUES (p_tenant_id, v_admin_id, v_token, v_expires)
|
||||
RETURNING * INTO v_session;
|
||||
|
||||
RETURN json_build_object(
|
||||
'token', v_session.token,
|
||||
'expires_at', v_session.expires_at,
|
||||
'session_id', v_session.id
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── RPC: validate_support_session ────────────────────────────────────────────
|
||||
-- Valida um token de suporte. Não requer autenticação (chamada pública).
|
||||
-- Retorna: { valid: bool, tenant_id: uuid|null }
|
||||
-- NUNCA retorna admin_id ou dados internos.
|
||||
|
||||
DROP FUNCTION IF EXISTS public.validate_support_session(text);
|
||||
CREATE OR REPLACE FUNCTION public.validate_support_session(
|
||||
p_token text
|
||||
)
|
||||
RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_session support_sessions;
|
||||
BEGIN
|
||||
IF p_token IS NULL OR length(trim(p_token)) < 32 THEN
|
||||
RETURN json_build_object('valid', false, 'tenant_id', null);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_session
|
||||
FROM public.support_sessions
|
||||
WHERE token = p_token
|
||||
AND expires_at > now()
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN json_build_object('valid', false, 'tenant_id', null);
|
||||
END IF;
|
||||
|
||||
RETURN json_build_object(
|
||||
'valid', true,
|
||||
'tenant_id', v_session.tenant_id
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── RPC: revoke_support_session ───────────────────────────────────────────────
|
||||
-- Revoga um token manualmente. Apenas o admin que criou pode revogar.
|
||||
|
||||
DROP FUNCTION IF EXISTS public.revoke_support_session(text);
|
||||
CREATE OR REPLACE FUNCTION public.revoke_support_session(
|
||||
p_token text
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_admin_id uuid;
|
||||
v_role text;
|
||||
BEGIN
|
||||
v_admin_id := auth.uid();
|
||||
IF v_admin_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado.' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
SELECT role INTO v_role FROM public.profiles WHERE id = v_admin_id;
|
||||
IF v_role <> 'saas_admin' THEN
|
||||
RAISE EXCEPTION 'Acesso negado.' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
DELETE FROM public.support_sessions
|
||||
WHERE token = p_token
|
||||
AND admin_id = v_admin_id;
|
||||
|
||||
RETURN FOUND;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Cleanup automático (opcional) ────────────────────────────────────────────
|
||||
-- Sessões expiradas podem ser limpas periodicamente via pg_cron ou edge function.
|
||||
-- DELETE FROM public.support_sessions WHERE expires_at < now();
|
||||
34
migrations/unify_patient_id.sql
Normal file
34
migrations/unify_patient_id.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Unifica paciente_id → patient_id em agenda_eventos
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Contexto:
|
||||
-- Campo legado `paciente_id` (texto, sem FK) coexiste com `patient_id`
|
||||
-- (uuid, com FK → patients.id). Eventos antigos têm `paciente_id` preenchido
|
||||
-- mas `patient_id = null`. Esta migration corrige isso e remove a coluna legada.
|
||||
--
|
||||
-- SEGURANÇA:
|
||||
-- Execute em transação. Verifique os counts antes do COMMIT.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Copia paciente_id → patient_id onde patient_id ainda é null
|
||||
-- paciente_id já é uuid no banco — sem necessidade de cast ou validação de regex
|
||||
UPDATE public.agenda_eventos
|
||||
SET patient_id = paciente_id
|
||||
WHERE patient_id IS NULL
|
||||
AND paciente_id IS NOT NULL;
|
||||
|
||||
-- 2. Verificação: deve retornar 0
|
||||
SELECT COUNT(*) AS "orfaos_restantes"
|
||||
FROM public.agenda_eventos
|
||||
WHERE patient_id IS NULL AND paciente_id IS NOT NULL;
|
||||
|
||||
-- 3. Remove a coluna legada
|
||||
ALTER TABLE public.agenda_eventos DROP COLUMN IF EXISTS paciente_id;
|
||||
|
||||
-- 4. Remove FK e coluna legada de terapeuta_id se existir equivalente
|
||||
-- (opcional — remova o comentário se quiser limpar terapeuta_id também)
|
||||
-- ALTER TABLE public.agenda_eventos DROP COLUMN IF EXISTS terapeuta_id;
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user