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.
95 lines
3.8 KiB
TypeScript
95 lines
3.8 KiB
TypeScript
import express from "express";
|
|
import cors from "cors";
|
|
import path from "node:path";
|
|
import fs from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { db } from "./db.ts";
|
|
import { ensurePlatformAdmin, seedBusiness, getTemplate, DEFAULT_TEMPLATE_KEY } from "./scripts/seed.ts";
|
|
import { authRouter } from "./routes/auth.ts";
|
|
import { businessRouter } from "./routes/business.ts";
|
|
import { employeesRouter } from "./routes/employees.ts";
|
|
import { servicesRouter } from "./routes/services.ts";
|
|
import { clientsRouter } from "./routes/clients.ts";
|
|
import { appointmentsRouter } from "./routes/appointments.ts";
|
|
import { dashboardRouter } from "./routes/dashboard.ts";
|
|
import { adminRouter } from "./routes/admin.ts";
|
|
import { settingsRouter } from "./routes/settings.ts";
|
|
import { cashRouter } from "./routes/cash.ts";
|
|
import { notificationsRouter, scheduleReminders } from "./routes/notifications.ts";
|
|
import { bookingRouter } from "./routes/booking.ts";
|
|
import { authRequired } from "./lib/auth.ts";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(__dirname, "..");
|
|
|
|
// Auto-seed demo data if the database is empty (first run). Preserves existing data.
|
|
function ensureSeed() {
|
|
ensurePlatformAdmin();
|
|
const row = db.prepare(`SELECT COUNT(*) c FROM businesses`).get() as { c: number };
|
|
if (row.c === 0) {
|
|
console.log("[seed] Base de datos vacía — creando negocio demo y admin…");
|
|
const biz = db
|
|
.prepare(
|
|
`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active') RETURNING id`
|
|
)
|
|
.get("Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX") as { id: number };
|
|
seedBusiness({
|
|
businessId: biz.id,
|
|
template: getTemplate(DEFAULT_TEMPLATE_KEY)!,
|
|
ownerEmail: "[email protected]",
|
|
ownerName: "Daniela Reyes",
|
|
});
|
|
}
|
|
}
|
|
ensureSeed();
|
|
// On startup, refresh pending reminders for all active businesses (idempotent).
|
|
try {
|
|
const bids = db.prepare(`SELECT id FROM businesses WHERE status = 'active'`).all() as { id: number }[];
|
|
for (const b of bids) scheduleReminders(b.id);
|
|
} catch (e) {
|
|
console.warn("[reminders] no se pudieron generar al inicio:", (e as Error).message);
|
|
}
|
|
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(express.json({ limit: "1mb" }));
|
|
|
|
app.use("/api/auth", authRouter);
|
|
// Public booking (no auth)
|
|
app.use("/api/public", bookingRouter);
|
|
// Protected APIs
|
|
app.use("/api/business", authRequired, businessRouter);
|
|
app.use("/api/employees", authRequired, employeesRouter);
|
|
app.use("/api/services", authRequired, servicesRouter);
|
|
app.use("/api/clients", authRequired, clientsRouter);
|
|
app.use("/api/appointments", authRequired, appointmentsRouter);
|
|
app.use("/api/dashboard", authRequired, dashboardRouter);
|
|
app.use("/api/admin", authRequired, adminRouter);
|
|
app.use("/api/settings", authRequired, settingsRouter);
|
|
app.use("/api/cash", authRequired, cashRouter);
|
|
app.use("/api/notifications", authRequired, notificationsRouter);
|
|
|
|
app.get("/api/health", (_req, res) => res.json({ ok: true, ts: Date.now() }));
|
|
|
|
// Serve built frontend in production
|
|
const distDir = path.join(ROOT, "dist");
|
|
if (fs.existsSync(distDir)) {
|
|
app.use(express.static(distDir));
|
|
app.get("*", (req, res, next) => {
|
|
if (req.path.startsWith("/api/")) return next();
|
|
res.sendFile(path.join(distDir, "index.html"));
|
|
});
|
|
}
|
|
|
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
console.error("[server error]", err);
|
|
res.status(err.status || 500).json({ error: err.message || "Error interno" });
|
|
});
|
|
|
|
const port = Number(process.env.PORT) || 3000;
|
|
const host = process.env.HOST || "0.0.0.0";
|
|
app.listen(port, host, () => {
|
|
console.log(`AgendaPro API en http://${host}:${port}`);
|
|
});
|