Files
AgendaPro/server/routes/admin.ts
T
AgendaPro Dev e8d2435cc2 AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking
Multi-tenant scheduling SaaS (AgendaPro-equivalent):
- Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee),
  versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank).
- Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop,
  Recharts dashboard, mobile-first (day/list views on mobile).
- Features: calendar+services+employees+clients+tickets, dashboard with
  best employee/service, top tickets, top/frequent clients, commissions,
  cancellation policy + no-show tracking, public online booking (/b/:slug),
  cash register (cierre de caja), reminders/notifications center.
- Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors),
  typecheck clean, vite build OK.
2026-07-25 13:45:53 -06:00

211 lines
8.2 KiB
TypeScript

import { Router } from "express";
import { db } from "../db.ts";
import { adminOnly, err, type AuthedRequest } from "../lib/auth.ts";
import { TEMPLATES, getTemplate, type TemplateDef } from "../lib/templates.ts";
import { seedBusiness, clearBusinessData } from "../scripts/seed.ts";
export const adminRouter = Router();
adminRouter.use(adminOnly);
function businessStats(businessId: number) {
const scalar = (sql: string) => (db.prepare(sql).get(businessId) as any)?.v ?? 0;
return {
users: scalar(`SELECT COUNT(*) v FROM users WHERE business_id = ?`),
employees: scalar(`SELECT COUNT(*) v FROM employees WHERE business_id = ?`),
services: scalar(`SELECT COUNT(*) v FROM services WHERE business_id = ?`),
clients: scalar(`SELECT COUNT(*) v FROM clients WHERE business_id = ?`),
appointments: scalar(`SELECT COUNT(*) v FROM appointments WHERE business_id = ?`),
appointments_upcoming: scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status='scheduled' AND start_at >= datetime('now')`
),
revenue_30d: scalar(
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`
),
tickets: scalar(`SELECT COUNT(*) v FROM tickets WHERE business_id = ?`),
};
}
function businessRow(id: number) {
return db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, plan, status, template, trial_ends_at, created_at
FROM businesses WHERE id = ?`
)
.get(id) as any;
}
// Platform overview
adminRouter.get("/overview", (_req: AuthedRequest, res) => {
const scalar = (sql: string) => (db.prepare(sql).get() as any)?.v ?? 0;
res.json({
businesses: scalar(`SELECT COUNT(*) v FROM businesses`),
active_businesses: scalar(`SELECT COUNT(*) v FROM businesses WHERE status='active'`),
trial_businesses: scalar(`SELECT COUNT(*) v FROM businesses WHERE plan='trial'`),
total_users: scalar(`SELECT COUNT(*) v FROM users WHERE role IN ('owner','employee')`),
total_appointments: scalar(`SELECT COUNT(*) v FROM appointments`),
revenue_30d: scalar(`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE created_at >= datetime('now','-30 days')`),
upcoming_appointments: scalar(
`SELECT COUNT(*) v FROM appointments WHERE status='scheduled' AND start_at >= datetime('now')`
),
templates: TEMPLATES.length,
});
});
// List templates
adminRouter.get("/templates", (_req: AuthedRequest, res) => {
const out = TEMPLATES.map((t) => ({
key: t.key,
name: t.name,
industry: t.industry,
description: t.description,
currency: t.currency,
currency_symbol: t.currency_symbol,
employees: t.employees.length,
services: t.services.length,
clients: t.clients.length,
}));
res.json({ templates: out });
});
// List all businesses with stats
adminRouter.get("/businesses", (_req: AuthedRequest, res) => {
const rows = db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, plan, status, template, trial_ends_at, created_at
FROM businesses ORDER BY created_at DESC`
)
.all() as any[];
const out = rows.map((b) => ({ business: b, stats: businessStats(b.id) }));
res.json({ businesses: out });
});
// Business detail
adminRouter.get("/businesses/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const b = businessRow(id);
if (!b) return err(res, 404, "Negocio no encontrado");
res.json({ business: b, stats: businessStats(id) });
});
// Create business + owner + template seed
adminRouter.post("/businesses", (req: AuthedRequest, res) => {
const { name, industry, template, ownerName, ownerEmail, ownerPassword, plan, currency, currency_symbol } = req.body ?? {};
if (!name) return err(res, 400, "El nombre del negocio es obligatorio");
if (!ownerEmail) return err(res, 400, "El correo del dueño es obligatorio");
const tpl: TemplateDef | undefined = template ? getTemplate(template) : getTemplate("blank");
if (!tpl) return err(res, 400, "Plantilla no válida");
const email = String(ownerEmail).toLowerCase().trim();
const existing = db.prepare(`SELECT id FROM users WHERE email = ?`).get(email);
if (existing) return err(res, 409, "Ya existe un usuario con ese correo");
const biz = db
.prepare(
`INSERT INTO businesses (name, industry, currency, currency_symbol, plan, status, template)
VALUES (?, ?, ?, ?, ?, 'active', ?) RETURNING id`
)
.get(
name,
industry || tpl.industry,
currency || tpl.currency || "MXN",
currency_symbol || tpl.currency_symbol || "$",
plan || "trial",
tpl.key
) as { id: number };
const result = seedBusiness({
businessId: biz.id,
template: tpl,
ownerEmail: email,
ownerName: ownerName || "Dueño",
ownerPassword: ownerPassword || "demo1234",
withHistory: tpl.key !== "blank",
withUpcoming: tpl.key !== "blank",
});
res.status(201).json({
business: businessRow(biz.id),
stats: businessStats(biz.id),
seeded: result,
message: `Negocio "${name}" creado con plantilla "${tpl.name}"`,
});
});
// Update business
adminRouter.patch("/businesses/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = businessRow(id);
if (!existing) return err(res, 404, "Negocio no encontrado");
const { name, industry, phone, address, slug, plan, status, trial_ends_at } = req.body ?? {};
db.prepare(
`UPDATE businesses SET name=?, industry=?, phone=?, address=?, slug=?, plan=?, status=?, trial_ends_at=? WHERE id=?`
).run(
name ?? existing.name,
industry ?? existing.industry,
phone ?? existing.phone,
address ?? existing.address,
slug ?? existing.slug,
plan ?? existing.plan,
status ?? existing.status,
trial_ends_at ?? existing.trial_ends_at,
id
);
res.json({ business: businessRow(id), stats: businessStats(id) });
});
// Delete business + all its data
adminRouter.delete("/businesses/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = businessRow(id);
if (!existing) return err(res, 404, "Negocio no encontrado");
clearBusinessData(id);
// clearBusinessData preserves user accounts; a full delete removes them too
db.prepare(`DELETE FROM users WHERE business_id = ?`).run(id);
db.prepare(`DELETE FROM businesses WHERE id = ?`).run(id);
res.json({ ok: true });
});
// Load template data into an existing business (clearFirst optional)
adminRouter.post("/businesses/:id/seed-template", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const b = businessRow(id);
if (!b) return err(res, 404, "Negocio no encontrado");
const { template, clearFirst = true, ownerEmail, ownerName, ownerPassword, withHistory = true, withUpcoming = true } = req.body ?? {};
const tpl: TemplateDef | undefined = template ? getTemplate(template) : b.template ? getTemplate(b.template) : getTemplate("blank");
if (!tpl) return err(res, 400, "Plantilla no válida");
const result = seedBusiness({
businessId: id,
template: tpl,
ownerEmail: ownerEmail ?? (db.prepare(`SELECT email FROM users WHERE business_id=? AND role='owner'`).get(id) as any)?.email,
ownerName: ownerName ?? "Dueño",
ownerPassword: ownerPassword ?? "demo1234",
clearFirst: clearFirst !== false,
withHistory,
withUpcoming,
});
res.json({ business: businessRow(id), stats: businessStats(id), seeded: result });
});
// Reset demo data (clear + reseed with current template)
adminRouter.post("/businesses/:id/reset-demo", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const b = businessRow(id);
if (!b) return err(res, 404, "Negocio no encontrado");
const tplKey = req.body?.template || b.template || "blank";
const tpl = getTemplate(tplKey);
if (!tpl) return err(res, 400, "Plantilla no válida");
const owner = db.prepare(`SELECT email, name FROM users WHERE business_id=? AND role='owner'`).get(id) as any;
const result = seedBusiness({
businessId: id,
template: tpl,
ownerEmail: owner?.email,
ownerName: owner?.name ?? "Dueño",
clearFirst: true,
withHistory: tpl.key !== "blank",
withUpcoming: tpl.key !== "blank",
});
res.json({ business: businessRow(id), stats: businessStats(id), seeded: result });
});