diff --git a/database-novo/migrations/001_twilio_whatsapp_subaccount.sql b/database-novo/migrations/001_twilio_whatsapp_subaccount.sql new file mode 100644 index 0000000..4dc44a3 --- /dev/null +++ b/database-novo/migrations/001_twilio_whatsapp_subaccount.sql @@ -0,0 +1,132 @@ +-- ============================================================================= +-- AgenciaPsi — Migration 001: Twilio WhatsApp Subaccounts +-- ============================================================================= +-- Adiciona suporte a subcontas Twilio com número WhatsApp dedicado por tenant. +-- Cada clínica/terapeuta recebe sua própria subconta Twilio com número exclusivo. +-- ============================================================================= + +-- ── 1. Campos de subconta Twilio em notification_channels ────────────────── + +ALTER TABLE public.notification_channels + ADD COLUMN IF NOT EXISTS twilio_subaccount_sid text, + ADD COLUMN IF NOT EXISTS twilio_phone_number text, + ADD COLUMN IF NOT EXISTS twilio_phone_sid text, + ADD COLUMN IF NOT EXISTS webhook_url text, + ADD COLUMN IF NOT EXISTS cost_per_message_usd numeric(8,6) DEFAULT 0, + ADD COLUMN IF NOT EXISTS price_per_message_brl numeric(8,4) DEFAULT 0, + ADD COLUMN IF NOT EXISTS provisioned_at timestamp with time zone; + +COMMENT ON COLUMN public.notification_channels.twilio_subaccount_sid IS 'SID da subconta Twilio criada para este tenant'; +COMMENT ON COLUMN public.notification_channels.twilio_phone_number IS 'Número WhatsApp provisionado (E.164, ex: +5511999990000)'; +COMMENT ON COLUMN public.notification_channels.twilio_phone_sid IS 'SID do número de telefone na subconta Twilio'; +COMMENT ON COLUMN public.notification_channels.webhook_url IS 'URL do webhook configurada na Twilio para receber callbacks de status'; +COMMENT ON COLUMN public.notification_channels.cost_per_message_usd IS 'Custo real Twilio por mensagem WhatsApp (USD)'; +COMMENT ON COLUMN public.notification_channels.price_per_message_brl IS 'Valor cobrado do tenant por mensagem (BRL, inclui margem SaaS)'; +COMMENT ON COLUMN public.notification_channels.provisioned_at IS 'Timestamp do provisionamento da subconta'; + +-- Índice para busca rápida por subconta +CREATE INDEX IF NOT EXISTS idx_notification_channels_twilio_subaccount_sid + ON public.notification_channels (twilio_subaccount_sid) + WHERE twilio_subaccount_sid IS NOT NULL; + +-- ── 2. Tabela de consumo por subconta ───────────────────────────────────── + +CREATE TABLE IF NOT EXISTS public.twilio_subaccount_usage ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid NOT NULL, + channel_id uuid NOT NULL, + twilio_subaccount_sid text NOT NULL, + period_start date NOT NULL, + period_end date NOT NULL, + messages_sent integer DEFAULT 0 NOT NULL, + messages_delivered integer DEFAULT 0 NOT NULL, + messages_failed integer DEFAULT 0 NOT NULL, + cost_usd numeric(12,6) DEFAULT 0 NOT NULL, + cost_brl numeric(12,4) DEFAULT 0 NOT NULL, + revenue_brl numeric(12,4) DEFAULT 0 NOT NULL, + margin_brl numeric(12,4) GENERATED ALWAYS AS (revenue_brl - cost_brl) STORED, + usd_brl_rate numeric(8,4) DEFAULT 0, + synced_at timestamp with time zone DEFAULT now(), + created_at timestamp with time zone DEFAULT now() NOT NULL, + + CONSTRAINT twilio_subaccount_usage_pkey PRIMARY KEY (id), + CONSTRAINT twilio_subaccount_usage_channel_fk + FOREIGN KEY (channel_id) REFERENCES public.notification_channels(id) ON DELETE CASCADE, + CONSTRAINT twilio_subaccount_usage_period_check + CHECK (period_end >= period_start) +); + +COMMENT ON TABLE public.twilio_subaccount_usage IS + 'Consumo mensal de mensagens WhatsApp por subconta Twilio. Sincronizado via Edge Function.'; + +CREATE INDEX IF NOT EXISTS idx_twilio_usage_tenant_period + ON public.twilio_subaccount_usage (tenant_id, period_start DESC); + +CREATE INDEX IF NOT EXISTS idx_twilio_usage_channel + ON public.twilio_subaccount_usage (channel_id, period_start DESC); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_twilio_usage_unique_period + ON public.twilio_subaccount_usage (channel_id, period_start, period_end); + +ALTER TABLE public.twilio_subaccount_usage OWNER TO supabase_admin; + +-- ── 3. RLS: twilio_subaccount_usage ─────────────────────────────────────── + +ALTER TABLE public.twilio_subaccount_usage ENABLE ROW LEVEL SECURITY; + +-- Tenant vê apenas seu próprio consumo +CREATE POLICY "tenant_select_own_usage" + ON public.twilio_subaccount_usage + FOR SELECT + USING ( + tenant_id IN ( + SELECT tenant_id FROM public.tenant_members + WHERE user_id = auth.uid() + ) + ); + +-- Apenas service_role pode inserir/atualizar (via Edge Function) +CREATE POLICY "service_role_manage_usage" + ON public.twilio_subaccount_usage + FOR ALL + USING (auth.role() = 'service_role'); + +-- ── 4. RLS: notification_channels — acesso ao twilio_subaccount_sid ─────── +-- As políticas existentes já cobrem SELECT/UPDATE. Nenhuma alteração necessária. + +-- ── 5. View: resumo de subcontas para o painel SaaS admin ───────────────── + +CREATE OR REPLACE VIEW public.v_twilio_whatsapp_overview AS +SELECT + nc.id AS channel_id, + nc.tenant_id, + nc.owner_id, + nc.is_active, + nc.connection_status, + nc.display_name, + nc.twilio_subaccount_sid, + nc.twilio_phone_number, + nc.twilio_phone_sid, + nc.cost_per_message_usd, + nc.price_per_message_brl, + nc.provisioned_at, + nc.created_at, + nc.updated_at, + -- Uso do mês atual + COALESCE(u.messages_sent, 0) AS current_month_sent, + COALESCE(u.messages_delivered, 0) AS current_month_delivered, + COALESCE(u.messages_failed, 0) AS current_month_failed, + COALESCE(u.cost_usd, 0) AS current_month_cost_usd, + COALESCE(u.cost_brl, 0) AS current_month_cost_brl, + COALESCE(u.revenue_brl, 0) AS current_month_revenue_brl, + COALESCE(u.margin_brl, 0) AS current_month_margin_brl +FROM public.notification_channels nc +LEFT JOIN public.twilio_subaccount_usage u + ON u.channel_id = nc.id + AND u.period_start = date_trunc('month', CURRENT_DATE)::date +WHERE nc.channel = 'whatsapp' + AND nc.provider = 'twilio' + AND nc.deleted_at IS NULL; + +COMMENT ON VIEW public.v_twilio_whatsapp_overview IS + 'Visão consolidada de subcontas Twilio WhatsApp com uso do mês corrente.'; diff --git a/src/assets/styles.scss b/src/assets/styles.scss index f866186..f28a4d5 100644 --- a/src/assets/styles.scss +++ b/src/assets/styles.scss @@ -139,49 +139,7 @@ opacity: 0; transform: translateY(10px); } -/* ── Subheader de seção ──────────────────────────────── */ -.cfg-subheader { - display: flex; - align-items: center; - gap: 0.65rem; - padding: 0.875rem 1rem; - border-radius: 6px; - border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent); - background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%); - overflow: hidden; - position: relative; -} -/* Brilho sutil no canto */ -.cfg-subheader::before { - content: ''; - position: absolute; - top: -20px; - right: -20px; - width: 80px; - height: 80px; - border-radius: 50%; - background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent); - filter: blur(20px); - pointer-events: none; -} -.cfg-subheader__icon { - display: grid; - place-items: center; - flex-shrink: 0; - background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent); - color: var(--primary-color, #6366f1); -} -.cfg-subheader__title { - font-size: 0.95rem; - font-weight: 700; - color: var(--primary-color, #6366f1); - letter-spacing: -0.01em; -} -.cfg-subheader__sub { - font-size: 0.75rem; - color: var(--text-color-secondary); - opacity: 0.85; -} + .cfg-card__icon-wrap { border: 1px solid var(--surface-border); background: var(--surface-ground); diff --git a/src/components/agenda/AgendaQuickAddDialog.vue b/src/components/agenda/AgendaQuickAddDialog.vue index 3bc1cf7..2dc7a94 100644 --- a/src/components/agenda/AgendaQuickAddDialog.vue +++ b/src/components/agenda/AgendaQuickAddDialog.vue @@ -204,7 +204,7 @@ function skipProcedures() {