Este documento presenta la arquitectura funcional definitiva de una plataforma para gestionar agendas de emprendedores individuales y negocios con múltiples sucursales, incorporando todas las mejoras y correcciones propuestas en el análisis de errores. La plataforma permite a los emprendedores configurar agendas personales y a los negocios administrar agendas complejas con sucursales y trabajadores. Los usuarios finales reservan citas a través de una URL pública basada en el nombre de usuario (miagenda.com/<username>), con selección automática o manual de sucursales según corresponda. El diseño es robusto, escalable, y está optimizado para cumplir con las restricciones de usar tecnologías gratuitas (Node.js, Express, Prisma, PostgreSQL con Supabase, Nodemailer, node-cron) mientras se mantiene la simplicidad.
- Gestión de agendas:
- Emprendedores individuales: Configurar agendas personales con horarios y restricciones.
- Negocios: Gestionar agendas con múltiples sucursales y trabajadores.
- Reserva de citas:
- Usuarios finales reservan citas mediante
miagenda.com/<username>. - Para emprendedores: Seleccionar día y hora.
- Para negocios: Mostrar sucursales disponibles; seleccionar automáticamente si hay una sola.
- Usuarios finales reservan citas mediante
- Autenticación y verificación:
- Emprendedores/negocios se registran, verifican por correo, y se autentican.
- Usuarios finales proporcionan nombre, correo y teléfono.
- Restricciones:
- Tecnologías gratuitas: Node.js, Express, Prisma, PostgreSQL (Supabase), Nodemailer, node-cron.
- Endpoints autenticados retornan solo token JWT; datos vía
/auth/me. - Diseño simple y escalable.
La base de datos usa PostgreSQL en Supabase, modelada con Prisma. Se incorporan todas las mejoras propuestas.
-
User:
- Representa emprendedores o administradores.
- Campos:
id: Int, clave primaria, autoincremental.email: String, único, obligatorio, @db.VarChar(255).password: String, obligatorio, @db.VarChar(255).name: String, obligatorio paraisBusiness: false, opcional paraisBusiness: true, @db.VarChar(100).phone: String, opcional, @db.VarChar(20).username: String, único, obligatorio, @db.VarChar(50) (URL pública).isVerified: Boolean, por defectofalse.isBusiness: Boolean, por defectofalse.createdAt: DateTime, por defectonow().updatedAt: DateTime, actualizado automáticamente.
- Validaciones:
username: Solo alfanuméricos y guiones (^[a-zA-Z0-9-]+$), longitud 3-50, normalizado a minúsculas.email: Validar formato con expresión regular.name: Obligatorio para emprendedores para sincronizar conWorker.workerName.
- Índices:
@unique(fields: [email], map: "email_idx", caseSensitive: false).@unique(fields: [username], map: "username_idx", caseSensitive: false).
- Relaciones:
- Uno a uno con
Business(víauserId). - Uno a muchos con
VerificationToken,RefreshToken.
- Uno a uno con
-
Business:
- Representa negocio o agenda personal.
- Campos:
id: Int, clave primaria, autoincremental.userId: Int, único, obligatorio.name: String, obligatorio, @db.VarChar(100) (personalizable, por defecto "Agenda de [name]").logo: String, opcional, @db.VarChar(255).timezone: String, opcional, @db.VarChar(50) (por defecto "UTC").createdAt: DateTime, por defectonow().updatedAt: DateTime, actualizado automáticamente.
- Validaciones:
name: No depende deUser.usernamepara negocios; personalizable.
- Índices:
@unique(userId).
- Relaciones:
- Uno a uno con
User. - Uno a muchos con
Branch,Worker,Schedule,Appointment,Exception,AvailableSlots.
- Uno a uno con
- Eliminación:
onDelete: Restrictsi existenBranch,Worker,Schedule,Appointment, oException; opcionalonDelete: Cascadepara entornos de prueba con confirmación.
-
Branch:
- Representa una sucursal.
- Campos:
id: Int, clave primaria, autoincremental.businessId: Int, obligatorio.name: String, obligatorio, @db.VarChar(100).address: String, opcional, @db.VarChar(255).createdAt: DateTime, por defectonow().updatedAt: DateTime, actualizado automáticamente.
- Validaciones:
- Crear
Branchpredeterminada (name: "Sucursal Principal") paraisBusiness: true.
- Crear
- Índices:
@index(businessId).
- Relaciones:
- Muchos a uno con
Business. - Uno a muchos con
Worker,Schedule,Appointment.
- Muchos a uno con
-
Worker:
- Representa trabajador o emprendedor.
- Campos:
id: Int, clave primaria, autoincremental.businessId: Int, obligatorio.branchId: Int, opcional.workerName: String, obligatorio, @db.VarChar(100).isOwner: Boolean, por defectofalse.createdAt: DateTime, por defectonow().updatedAt: DateTime, actualizado automáticamente.
- Validaciones:
- Para
isBusiness: false, crearWorkerconworkerName = User.name,isOwner: true. - Sincronizar
workerNameconUser.nameparaisOwner: true. - Prohibir eliminación si
isOwner: true(error 403: "Cannot delete owner worker").
- Para
- Índices:
@index([businessId, branchId]).
- Relaciones:
- Muchos a uno con
Business,Branch. - Uno a muchos con
Schedule,Appointment.
- Muchos a uno con
-
Schedule:
- Representa horarios.
- Campos:
id: Int, clave primaria, autoincremental.businessId: Int, obligatorio.branchId: Int, opcional.workerId: Int, opcional.dayOfWeek: Int, obligatorio (0-6).startTime: Time, obligatorio.endTime: Time, obligatorio.slotDuration: Int, obligatorio (5-120, múltiplo de 5).createdAt: DateTime, por defectonow().updatedAt: DateTime, actualizado automáticamente.
- Validaciones:
startTime < endTime.slotDuration: Validar en backend (Joi/Zod).- Evitar superposiciones de horarios para el mismo
workerId,dayOfWeek:SELECT * FROM Schedule WHERE workerId = :workerId AND dayOfWeek = :dayOfWeek AND (startTime <= :newEndTime AND endTime >= :newStartTime);
- Índices:
@index([businessId, dayOfWeek]).
-
Appointment:
- Representa citas.
- Campos:
id: Int, clave primaria, autoincremental.businessId: Int, obligatorio.branchId: Int, opcional.workerId: Int, opcional.clientName: String, obligatorio, @db.VarChar(100).clientEmail: String, obligatorio, @db.VarChar(255).clientPhone: String, obligatorio, @db.VarChar(20).startTime: DateTime, obligatorio.endTime: DateTime, obligatorio.status: String, obligatorio, @db.VarChar(20) (pending,confirmed,cancelled).createdAt: DateTime, por defectonow().updatedAt: DateTime, actualizado automáticamente.
- Validaciones:
clientEmail: Validar formato.endTime - startTime: Igual aslotDuration(extensible a múltiplos en el futuro).- Máquina de estados:
pending→confirmed(por negocio).pending→cancelled(por cliente o negocio).confirmed→cancelled.
- Índices:
@index([businessId, startTime]).
-
VerificationToken:
- Almacena tokens de verificación.
- Campos:
id: Int, clave primaria, autoincremental.token: String, único, @db.VarChar(64).userId: Int, obligatorio.expiresAt: DateTime, obligatorio.createdAt: DateTime, por defectonow().
- Validaciones:
- Máximo 3 tokens por
userId; borrar anteriores al crear nuevo.
- Máximo 3 tokens por
- Índices:
@unique(token).
-
Exception:
- Representa fechas especiales.
- Campos:
id: Int, clave primaria, autoincremental.businessId: Int, obligatorio.branchId: Int, opcional.workerId: Int, opcional.date: Date, obligatorio.isClosed: Boolean, obligatorio.startTime: Time, opcional (para cierres parciales).endTime: Time, opcional.createdAt: DateTime, por defectonow().updatedAt: DateTime, actualizado automáticamente.
- Validaciones:
- Si
isClosed: true, ignorarstartTime,endTime. - Si
startTimepresente, validarstartTime < endTime.
- Si
- Índices:
@index([businessId, date]).
-
RefreshToken:
- Almacena tokens de renovación.
- Campos:
id: Int, clave primaria, autoincremental.token: String, único, @db.VarChar(64).userId: Int, obligatorio.expiresAt: DateTime, obligatorio (7 días).createdAt: DateTime, por defectonow().
- Relaciones:
- Muchos a uno con
User(onDelete: Cascade).
- Muchos a uno con
- Índices:
@unique(token).
-
TemporaryToken:
- Almacena tokens para reprogramar/cancelar citas.
- Campos:
id: Int, clave primaria, autoincremental.token: String, único, @db.VarChar(64).appointmentId: Int, obligatorio.clientEmail: String, obligatorio, @db.VarChar(255).expiresAt: DateTime, obligatorio (10 minutos).used: Boolean, por defectofalse.createdAt: DateTime, por defectonow().
- Relaciones:
- Muchos a uno con
Appointment(onDelete: Cascade).
- Muchos a uno con
- Índices:
@unique(token).
-
AvailableSlots:
- Almacena slots precalculados para optimizar disponibilidad.
- Campos:
id: Int, clave primaria, autoincremental.businessId: Int, obligatorio.branchId: Int, opcional.workerId: Int, opcional.date: Date, obligatorio.startTime: Time, obligatorio.endTime: Time, obligatorio.createdAt: DateTime, por defectonow().
- Índices:
@index([businessId, date, startTime]).
-
AuditLog:
- Registra acciones críticas.
- Campos:
id: Int, clave primaria, autoincremental.action: String, obligatorio, @db.VarChar(50) (create,update,delete).entity: String, obligatorio, @db.VarChar(50) (User,Appointment, etc.).entityId: Int, obligatorio.userId: Int, opcional (null para acciones públicas).createdAt: DateTime, por defectonow().
- Índices:
@index([entity, entityId]).
-
POST /auth/register
- Descripción: Registra usuario, negocio, trabajador (emprendedores), sucursal y horario predeterminados (negocios).
- Entrada:
email: String, obligatorio.password: String, obligatorio.name: String, obligatorio paraisBusiness: false.phone: String, opcional.username: String, obligatorio (3-50, alfanuméricos/guiones, minúsculas).businessName: String, opcional.logo: String, opcional.isBusiness: Boolean, opcional (falsepor defecto).
- Salida:
{ token: String }(JWT conuserId,isBusiness,username). - Lógica:
- Validar entradas (Joi/Zod).
- Normalizar
usernamea minúsculas. - Verificar unicidad de
email,username. - Hashear contraseña.
- Crear
Business(name: businessName || "Agenda de [name || username]"). - Crear
User. - Si
isBusiness: false, crearWorker(workerName: name,isOwner: true). - Si
isBusiness: true, crearBranch("Sucursal Principal"). - Crear
Schedulepredeterminado (lunes-viernes, 9:00-17:00,slotDuration: 30). - Borrar tokens anteriores, crear
VerificationToken(expira en 30 minutos). - Enviar correo (reintentar 3 veces: 1s, 5s, 10s).
- Generar token JWT.
- Errores:
- 400: Entradas inválidas,
email/usernameocupados. - 500: Error persistente al enviar correo.
- 400: Entradas inválidas,
-
GET /auth/verify
- Descripción: Verifica cuenta.
- Entrada (query):
token: String. - Salida:
{ message: String }. - Lógica:
- Buscar
VerificationToken. - Si inválido, error 400.
- Si expirado, permitir reenvío vía
/auth/resend-verification. - Actualizar
isVerified: true, eliminar token.
- Buscar
- Errores:
- 400: Token inválido/expirado.
-
POST /auth/resend-verification
- Descripción: Reenvía token de verificación.
- Entrada:
email: String. - Salida:
{ message: String }. - Lógica:
- Buscar
User(isVerified: false). - Limitar 3 reenvíos/día (rate-limiting).
- Borrar tokens anteriores, crear nuevo.
- Enviar correo.
- Buscar
- Errores:
- 400: Email no registrado o verificado.
- 429: Límite de reenvíos excedido.
-
POST /auth/login
- Descripción: Autentica usuario.
- Entrada:
email: String.password: String.
- Salida:
{ token: String, refreshToken: String }. - Lógica:
- Validar entradas.
- Verificar
email,password,isVerified. - Crear
RefreshToken(7 días). - Retornar JWT (1 hora) y refresh token.
- Errores:
- 400: Credenciales inválidas.
- 403: Cuenta no verificada.
-
POST /auth/refresh
- Descripción: Renueva token JWT.
- Entrada:
refreshToken: String. - Salida:
{ token: String, refreshToken: String }. - Lógica:
- Validar
RefreshToken. - Rotar token (crear nuevo, invalidar anterior).
- Retornar nuevo JWT y refresh token.
- Validar
- Errores:
- 401: Token inválido/expirado.
-
GET /auth/me
- Descripción: Obtiene datos del usuario.
- Salida:
{ id: Int, email: String, name: String | null, phone: String | null, username: String, isBusiness: Boolean, business: { id: Int, name: String, logo: String | null, timezone: String }, worker: { id: Int, workerName: String, isOwner: Boolean } | null } - Lógica:
- Retornar
User,Business,Worker(siisOwner: true).
- Retornar
- Errores:
- 401: No autenticado.
-
PUT /user/update
- Descripción: Actualiza usuario.
- Entrada:
name: String, opcional.phone: String, opcional.
- Salida:
{ token: String }. - Lógica:
- Validar entradas.
- Actualizar
User. - Sincronizar
workerNamesiisOwner: true. - Registrar en
AuditLog.
- Errores:
- 400: Entradas inválidas.
- 401: No autenticado.
-
PUT /business/update
- Descripción: Actualiza negocio.
- Entrada:
name: String, opcional.logo: String, opcional.timezone: String, opcional.
- Salida:
{ token: String }. - Lógica:
- Validar entradas.
- Actualizar
Business. - Registrar en
AuditLog.
- Errores:
- 400: Entradas inválidas.
- 401: No autenticado.
-
POST /branches, PUT /branches/:id, DELETE /branches/:id:
- Como descrito, con validación de
isBusiness: true. - Errores:
- 403: No es negocio.
- 404: Sucursal no encontrada.
- Como descrito, con validación de
-
POST /workers, PUT /workers/:id:
- Usar
workerName. - Errores:
- 403: Intento de modificar
isOwner: true.
- 403: Intento de modificar
- Usar
-
DELETE /workers/:id:
- Prohibir si
isOwner: true. - Errores:
- 403: Cannot delete owner worker.
- Prohibir si
-
POST /schedules, PUT /schedules/:id, DELETE /schedules/:id:
- Validar superposiciones y
slotDuration. - Errores:
- 400: Horario inválido o superpuesto.
- Validar superposiciones y
-
POST /exceptions, PUT /exceptions/:id, DELETE /exceptions/:id:
- Validar
startTime < endTimesi presentes. - Errores:
- 400: Fecha/hora inválida.
- Validar
-
PUT /appointments/:id
- Descripción: Modifica cita (negocio).
- Entrada:
startTime: DateTime, opcional.endTime: DateTime, opcional.status: String, opcional.
- Salida:
{ token: String }. - Lógica:
- Validar slot disponible.
- Actualizar
Appointment. - Notificar cliente por correo.
- Registrar en
AuditLog.
- Errores:
- 400: Slot ocupado.
- 401: No autenticado.
-
GET /audit-logs
- Descripción: Consulta logs de auditoría.
- Entrada (query):
entity,entityId,action,startDate,endDate. - Salida:
{ logs: [{ id: Int, action: String, entity: String, entityId: Int, userId: Int | null, createdAt: DateTime }] }. - Lógica:
- Filtrar logs por parámetros.
- Limitar a 100 registros.
- Errores:
- 401: No autenticado.
-
GET /public/business/:username
- Descripción: Obtiene negocio y sucursales.
- Salida:
{ business: { id: Int, name: String, logo: String | null, isBusiness: Boolean }, branches: [{ id: Int, name: String, address: String | null }] } - Lógica:
- Buscar
Userporusername(insensible a mayúsculas). - Retornar
BusinessyBranch(una sola siisBusiness: falseo única). - Error si
isBusiness: truey no hay sucursales. - Rate-limiting: 100/hora por IP.
- Buscar
- Errores:
- 400: No branches configured.
- 404: Negocio no encontrado.
-
GET /public/business/:username/availability
- Descripción: Obtiene slots disponibles.
- Entrada (query):
branchId,workerId,date(YYYY-MM-DD). - Salida:
{ availableSlots: [{ startTime: String, endTime: String, workerId: Int | null, workerName: String | null }] } - Lógica:
- Consultar
AvailableSlotso calcular desdeSchedule. - Excluir
Appointment(status != cancelled) yException. - Rate-limiting: 50/hora por IP.
- Consultar
- Errores:
- 400: Fecha inválida.
- 404: Negocio no encontrado.
-
POST /public/business/:username/appointments
- Descripción: Reserva cita.
- Entrada:
branchId: Int, opcional.workerId: Int, opcional.startTime: DateTime.endTime: DateTime.clientName: String.clientEmail: String.clientPhone: String.
- Salida:
{ message: String, appointmentId: Int }. - Lógica:
- Validar entradas.
- Verificar slot en transacción:
SELECT * FROM Appointment WHERE businessId = :businessId AND startTime = :startTime AND status != 'cancelled' FOR UPDATE;
- Crear
Appointment(status: pending). - Enviar correo de confirmación.
- Registrar en
AuditLog. - Rate-limiting: 5/hora por IP o
clientEmail.
- Errores:
- 400: Slot ocupado, entradas inválidas.
- 429: Límite excedido.
-
PUT /public/appointments/:id
- Descripción: Reprograma cita.
- Entrada:
token: String (enviado por correo).startTime: DateTime, opcional.endTime: DateTime, opcional.
- Salida:
{ message: String }. - Lógica:
- Validar
TemporaryToken(used: false, no expirado). - Verificar slot disponible.
- Actualizar
Appointment(status: pending). - Marcar token como usado.
- Rate-limiting: 5/hora por IP.
- Validar
- Errores:
- 400: Token inválido, slot ocupado.
- 429: Límite excedido.
-
DELETE /public/appointments/:id
- Descripción: Cancela cita.
- Entrada:
token: String. - Salida:
{ message: String }. - Lógica:
- Validar
TemporaryToken. - Actualizar
Appointment(status: cancelled). - Notificar negocio.
- Marcar token como usado.
- Registrar en
AuditLog. - Rate-limiting: 5/hora por IP.
- Validar
- Errores:
- 400: Token inválido.
- 429: Límite excedido.
- Rate-limiting (
express-rate-limit):- Públicos: 100/hora (
/business/:username), 50/hora (/availability), 5/hora (/appointments). - Autenticados: 200/hora.
- Reenvíos: 3/día por email.
- Públicos: 100/hora (
- Validación: Joi/Zod para sanitizar entradas.
- JWT: 1 hora, con
userId,isBusiness,username. - Refresh Tokens: 7 días, rotación al usarse.
- Temporary Tokens: 10 minutos, uso único.
- Cron Job: Cada hora, elimina usuarios no verificados (
expiresAtvencido,createdAt > 24 horas).DELETE FROM User WHERE isVerified = false AND id IN ( SELECT userId FROM VerificationToken WHERE expiresAt < NOW() ) AND createdAt < NOW() - INTERVAL '24 hours';
- Eliminar
AuditLog> 90 días (cron mensual).
- Correos para verificación, confirmación, recordatorios (24 horas antes), cancelaciones.
- Plantillas reutilizables con Nodemailer.
- Caché:
AvailableSlotsprecalculada por cron diario. - Índices: Optimizados para consultas frecuentes.
- Zonas horarias: UTC en DB, conversión en API según
Business.timezone. - Colas: Opcional, usar
bullpara picos de tráfico (si permitido).
- Jest, Supertest: Cubrir endpoints, validaciones, disponibilidad.
- Registro → Verificación → Login.
- Gestión de horarios, sucursales, trabajadores.
- Cliente accede a
miagenda.com/<username>, reserva cita. - Reprogramación/cancelación con token temporal.
- Estructura:
prisma/schema.prisma: Modelos.index.js: Servidor, cron.routes/auth.js,routes/business.js,routes/public.js.middleware/authenticate.js,middleware/rateLimit.js..env:DATABASE_URL,JWT_SECRET,EMAIL_USER,EMAIL_PASS,FRONTEND_URL.package.json: Dependencias (@prisma/client,prisma,express,bcrypt,jsonwebtoken,nodemailer,node-cron,express-rate-limit,joi,nodemon,jest,supertest).
Este diseño es completo, seguro, y escalable, listo para implementación.