From cf4a838d9c3a536778b940c6e19968948c8b93c2 Mon Sep 17 00:00:00 2001 From: Sidney Date: Sun, 19 Jul 2026 19:45:43 -0300 Subject: [PATCH] feat: implementar trava automatica de limites dos planos (profissionais, servicos e agendamentos) --- src/app/[slug]/agendar/page.tsx | 3 +- src/app/api/tenant/agenda/route.ts | 19 +++++++++++ src/app/api/tenant/services/route.ts | 16 ++++++++++ src/app/api/tenant/team/route.ts | 16 ++++++++++ src/components/Agenda.tsx | 3 ++ src/components/Services.tsx | 3 ++ src/components/Team.tsx | 3 ++ src/lib/planLimits.ts | 48 ++++++++++++++++++++++++++++ 8 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/lib/planLimits.ts diff --git a/src/app/[slug]/agendar/page.tsx b/src/app/[slug]/agendar/page.tsx index a66744b..775ffa8 100644 --- a/src/app/[slug]/agendar/page.tsx +++ b/src/app/[slug]/agendar/page.tsx @@ -146,7 +146,8 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug: if (res.ok) { setBooked(true); } else { - console.error('Falha ao agendar no banco.'); + const errData = await res.json(); + alert(errData.error || 'Erro ao realizar o agendamento.'); } } catch (e) { console.error(e); diff --git a/src/app/api/tenant/agenda/route.ts b/src/app/api/tenant/agenda/route.ts index ee38583..6746237 100644 --- a/src/app/api/tenant/agenda/route.ts +++ b/src/app/api/tenant/agenda/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; +import { getTenantPlanLimits } from '@/lib/planLimits'; export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); @@ -67,6 +68,24 @@ export async function POST(req: NextRequest) { if (tenantRes.rows.length === 0) return NextResponse.json({ error: 'Tenant not found' }, { status: 404 }); const tenantId = tenantRes.rows[0].id; + // Valida limite de agendamentos mensais do plano + const limits = await getTenantPlanLimits(tenantId); + if (limits.max_appointments_month !== null) { + const countRes = await query( + `SELECT COUNT(*) FROM appointments + WHERE tenant_id = $1 + AND date >= DATE_TRUNC('month', CURRENT_DATE) + AND date < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'`, + [tenantId] + ); + const currentCount = parseInt(countRes.rows[0].count); + if (currentCount >= limits.max_appointments_month) { + return NextResponse.json({ + error: `Limite de agendamentos mensais do seu plano (${limits.plan_name}: máximo de ${limits.max_appointments_month}) foi atingido neste mês. Faça o upgrade do seu plano para agendar mais.` + }, { status: 403 }); + } + } + const startTime = `${String(startHour).padStart(2, '0')}:${String(startMin).padStart(2, '0')}:00`; // Calculate end time diff --git a/src/app/api/tenant/services/route.ts b/src/app/api/tenant/services/route.ts index e802191..c4af439 100644 --- a/src/app/api/tenant/services/route.ts +++ b/src/app/api/tenant/services/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; +import { getTenantPlanLimits } from '@/lib/planLimits'; export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); @@ -45,6 +46,21 @@ export async function POST(req: NextRequest) { if (tenantRes.rows.length === 0) return NextResponse.json({ error: 'Tenant not found' }, { status: 404 }); const tenantId = tenantRes.rows[0].id; + // Valida limite de serviços do plano + const limits = await getTenantPlanLimits(tenantId); + if (limits.max_services !== null) { + const countRes = await query( + `SELECT COUNT(*) FROM services WHERE tenant_id = $1 AND is_active = true`, + [tenantId] + ); + const currentCount = parseInt(countRes.rows[0].count); + if (currentCount >= limits.max_services) { + return NextResponse.json({ + error: `Limite de serviços do seu plano (${limits.plan_name}: máximo de ${limits.max_services}) foi atingido. Faça o upgrade do plano para cadastrar mais serviços.` + }, { status: 403 }); + } + } + // Get or create category let categoryId = null; if (category) { diff --git a/src/app/api/tenant/team/route.ts b/src/app/api/tenant/team/route.ts index a70e0a8..972156d 100644 --- a/src/app/api/tenant/team/route.ts +++ b/src/app/api/tenant/team/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; +import { getTenantPlanLimits } from '@/lib/planLimits'; import crypto from 'crypto'; export async function GET(req: NextRequest) { @@ -58,6 +59,21 @@ export async function POST(req: NextRequest) { if (tenantRes.rows.length === 0) return NextResponse.json({ error: 'Tenant not found' }, { status: 404 }); const tenantId = tenantRes.rows[0].id; + // Valida limite de profissionais do plano + const limits = await getTenantPlanLimits(tenantId); + if (limits.max_professionals !== null) { + const countRes = await query( + `SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND role = 'professional' AND is_active = true`, + [tenantId] + ); + const currentCount = parseInt(countRes.rows[0].count); + if (currentCount >= limits.max_professionals) { + return NextResponse.json({ + error: `Limite de profissionais do seu plano (${limits.plan_name}: máximo de ${limits.max_professionals}) foi atingido. Faça o upgrade do plano para adicionar mais profissionais.` + }, { status: 403 }); + } + } + // Use a random hash for password since they are created by admin const passwordHash = crypto.randomBytes(16).toString('hex'); const safeEmail = email || `${Date.now()}@agendapro.com`; diff --git a/src/components/Agenda.tsx b/src/components/Agenda.tsx index 2d241b5..44be192 100644 --- a/src/components/Agenda.tsx +++ b/src/components/Agenda.tsx @@ -287,6 +287,9 @@ export default function Agenda() { setNewPhone(''); setNewEmail(''); setNewNotes(''); + } else { + const errData = await res.json(); + alert(errData.error || 'Erro ao realizar agendamento.'); } } catch (e) { console.error(e); diff --git a/src/components/Services.tsx b/src/components/Services.tsx index bb289f6..fa1c378 100644 --- a/src/components/Services.tsx +++ b/src/components/Services.tsx @@ -73,6 +73,9 @@ export default function Services() { fetchServices(); setShowModal(false); setEditingService(null); + } else { + const errData = await res.json(); + alert(errData.error || 'Erro ao salvar serviço.'); } } catch (e) { console.error(e); diff --git a/src/components/Team.tsx b/src/components/Team.tsx index f046b4c..2ce60d0 100644 --- a/src/components/Team.tsx +++ b/src/components/Team.tsx @@ -69,6 +69,9 @@ export default function Team() { fetchTeam(); setShowModal(false); setEditingProf(null); + } else { + const errData = await res.json(); + alert(errData.error || 'Erro ao salvar profissional.'); } } catch (e) { console.error(e); diff --git a/src/lib/planLimits.ts b/src/lib/planLimits.ts new file mode 100644 index 0000000..e46f7a6 --- /dev/null +++ b/src/lib/planLimits.ts @@ -0,0 +1,48 @@ +import { query } from '@/lib/db'; + +export async function getTenantPlanLimits(tenantId: string) { + try { + // Busca plano da assinatura ativa + const subRes = await query(` + SELECT p.name as plan_name, p.max_professionals, p.max_services, p.max_appointments_month + FROM subscriptions s + JOIN plans p ON s.plan_id = p.id + WHERE s.tenant_id = $1 AND s.status = 'active' + ORDER BY s.created_at DESC LIMIT 1 + `, [tenantId]); + + if (subRes.rows.length > 0) { + return { + plan_name: subRes.rows[0].plan_name || 'Personalizado', + max_professionals: subRes.rows[0].max_professionals !== null ? Number(subRes.rows[0].max_professionals) : null, + max_services: subRes.rows[0].max_services !== null ? Number(subRes.rows[0].max_services) : null, + max_appointments_month: subRes.rows[0].max_appointments_month !== null ? Number(subRes.rows[0].max_appointments_month) : null, + }; + } + + // Caso não haja assinatura ativa registrada, busca pelo plano Starter padrão + const planRes = await query(` + SELECT name as plan_name, max_professionals, max_services, max_appointments_month + FROM plans WHERE slug = 'starter' LIMIT 1 + `); + + if (planRes.rows.length > 0) { + return { + plan_name: planRes.rows[0].plan_name || 'Starter', + max_professionals: planRes.rows[0].max_professionals !== null ? Number(planRes.rows[0].max_professionals) : 1, + max_services: planRes.rows[0].max_services !== null ? Number(planRes.rows[0].max_services) : 10, + max_appointments_month: planRes.rows[0].max_appointments_month !== null ? Number(planRes.rows[0].max_appointments_month) : 200, + }; + } + } catch (err) { + console.error('Erro ao buscar limites do plano:', err); + } + + // Fallback seguro de plano Starter + return { + plan_name: 'Starter', + max_professionals: 1, + max_services: 10, + max_appointments_month: 200, + }; +}