feat: implementar trava automatica de limites dos planos (profissionais, servicos e agendamentos)
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m41s Details

This commit is contained in:
Sidney 2026-07-19 19:45:43 -03:00
parent 757029f80e
commit cf4a838d9c
8 changed files with 110 additions and 1 deletions

View File

@ -146,7 +146,8 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
if (res.ok) { if (res.ok) {
setBooked(true); setBooked(true);
} else { } else {
console.error('Falha ao agendar no banco.'); const errData = await res.json();
alert(errData.error || 'Erro ao realizar o agendamento.');
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db'; import { query } from '@/lib/db';
import { getTenantPlanLimits } from '@/lib/planLimits';
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); 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 }); if (tenantRes.rows.length === 0) return NextResponse.json({ error: 'Tenant not found' }, { status: 404 });
const tenantId = tenantRes.rows[0].id; 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`; const startTime = `${String(startHour).padStart(2, '0')}:${String(startMin).padStart(2, '0')}:00`;
// Calculate end time // Calculate end time

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db'; import { query } from '@/lib/db';
import { getTenantPlanLimits } from '@/lib/planLimits';
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); 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 }); if (tenantRes.rows.length === 0) return NextResponse.json({ error: 'Tenant not found' }, { status: 404 });
const tenantId = tenantRes.rows[0].id; 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 // Get or create category
let categoryId = null; let categoryId = null;
if (category) { if (category) {

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db'; import { query } from '@/lib/db';
import { getTenantPlanLimits } from '@/lib/planLimits';
import crypto from 'crypto'; import crypto from 'crypto';
export async function GET(req: NextRequest) { 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 }); if (tenantRes.rows.length === 0) return NextResponse.json({ error: 'Tenant not found' }, { status: 404 });
const tenantId = tenantRes.rows[0].id; 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 // Use a random hash for password since they are created by admin
const passwordHash = crypto.randomBytes(16).toString('hex'); const passwordHash = crypto.randomBytes(16).toString('hex');
const safeEmail = email || `${Date.now()}@agendapro.com`; const safeEmail = email || `${Date.now()}@agendapro.com`;

View File

@ -287,6 +287,9 @@ export default function Agenda() {
setNewPhone(''); setNewPhone('');
setNewEmail(''); setNewEmail('');
setNewNotes(''); setNewNotes('');
} else {
const errData = await res.json();
alert(errData.error || 'Erro ao realizar agendamento.');
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View File

@ -73,6 +73,9 @@ export default function Services() {
fetchServices(); fetchServices();
setShowModal(false); setShowModal(false);
setEditingService(null); setEditingService(null);
} else {
const errData = await res.json();
alert(errData.error || 'Erro ao salvar serviço.');
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View File

@ -69,6 +69,9 @@ export default function Team() {
fetchTeam(); fetchTeam();
setShowModal(false); setShowModal(false);
setEditingProf(null); setEditingProf(null);
} else {
const errData = await res.json();
alert(errData.error || 'Erro ao salvar profissional.');
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);

48
src/lib/planLimits.ts Normal file
View File

@ -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,
};
}