Agenda, Agendador, Configurações

This commit is contained in:
Leonardo
2026-03-12 08:58:36 -03:00
parent f733db8436
commit f4b185ae17
197 changed files with 33405 additions and 6507 deletions

View 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;

View 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;

View 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;

View 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");

View 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;

View 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'
]));

View 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
);

View 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;