feat: bootstrap taller-turnos project

Estructura inicial con Next.js 14, PostgreSQL/Prisma 7, autenticación por
magic link (clientes) y JWT (admins), lógica de scheduling con 4 slots
paralelos y API routes completas para cliente y panel admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Beldor
2026-06-18 11:51:19 -03:00
parent 4fde77e3ed
commit 93ad1cb8c1
28 changed files with 2626 additions and 23 deletions
+35
View File
@@ -0,0 +1,35 @@
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";
const SECRET = new TextEncoder().encode(process.env.NEXTAUTH_SECRET || "dev-secret");
const COOKIE = "admin_session";
export async function crearSesionAdmin(adminId: string): Promise<string> {
const token = await new SignJWT({ sub: adminId, role: "admin" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("8h")
.sign(SECRET);
return token;
}
export async function crearSesionCliente(clienteId: string): Promise<string> {
const token = await new SignJWT({ sub: clienteId, role: "cliente" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.sign(SECRET);
return token;
}
export async function verificarSesion(
cookieName: string
): Promise<{ sub: string; role: string } | null> {
const cookieStore = await cookies();
const token = cookieStore.get(cookieName)?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, SECRET);
return payload as { sub: string; role: string };
} catch {
return null;
}
}
+59
View File
@@ -0,0 +1,59 @@
import { prisma } from "./prisma";
import { Resend } from "resend";
import crypto from "crypto";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function enviarMagicLink(email: string): Promise<{ ok: boolean; error?: string }> {
const cliente = await prisma.cliente.findUnique({ where: { email } });
if (!cliente) return { ok: false, error: "Email no registrado" };
// Invalidar tokens anteriores no usados
await prisma.magicToken.updateMany({
where: { clienteId: cliente.id, usado: false },
data: { usado: true },
});
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutos
await prisma.magicToken.create({
data: { clienteId: cliente.id, token, expiresAt },
});
const link = `${process.env.MAGIC_LINK_BASE_URL}/cliente/auth?token=${token}`;
await resend.emails.send({
from: "Taller <noreply@taller.com>",
to: email,
subject: "Tu link de acceso al taller",
html: `
<h2>Hola ${cliente.nombre}!</h2>
<p>Hacé click en el siguiente botón para acceder a tu cuenta:</p>
<a href="${link}" style="background:#2563eb;color:white;padding:12px 24px;border-radius:6px;text-decoration:none;display:inline-block">
Acceder al taller
</a>
<p style="color:#6b7280;font-size:14px">Este link expira en 15 minutos. Si no lo pediste, ignorá este email.</p>
`,
});
return { ok: true };
}
export async function validarMagicToken(token: string) {
const registro = await prisma.magicToken.findUnique({
where: { token },
include: { cliente: true },
});
if (!registro || registro.usado || registro.expiresAt < new Date()) {
return null;
}
await prisma.magicToken.update({
where: { id: registro.id },
data: { usado: true },
});
return registro.cliente;
}
+12
View File
@@ -0,0 +1,12 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
datasourceUrl: process.env.DATABASE_URL,
log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+92
View File
@@ -0,0 +1,92 @@
import { prisma } from "./prisma";
const SLOTS_PARALELOS = 4; // 2 jefes × 2 autos c/u
const HORA_INICIO = 8;
const HORA_FIN = 18;
function esDiaHabil(fecha: Date): boolean {
const dia = fecha.getDay(); // 0=Dom, 6=Sab
return dia >= 1 && dia <= 5;
}
function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
const h1 = inicio.getHours() + inicio.getMinutes() / 60;
const h2 = fin.getHours() + fin.getMinutes() / 60;
return h1 >= HORA_INICIO && h2 <= HORA_FIN;
}
// Devuelve cuántos turnos activos se superponen con el rango dado
async function turnosSolapados(inicio: Date, fin: Date): Promise<number> {
const count = await prisma.turno.count({
where: {
estado: { in: ["PENDIENTE", "CONFIRMADO"] },
AND: [
{ fechaInicio: { lt: fin } },
{ fechaFin: { gt: inicio } },
],
},
});
return count;
}
export interface SlotDisponible {
fechaInicio: Date;
fechaFin: Date;
}
// Busca los próximos N slots disponibles para una duración dada (en horas)
export async function obtenerSlotsDisponibles(
duracionHoras: number,
diasAdelante = 14
): Promise<SlotDisponible[]> {
const slots: SlotDisponible[] = [];
const ahora = new Date();
const duracionMs = duracionHoras * 60 * 60 * 1000;
const fechaLimite = new Date(ahora);
fechaLimite.setDate(fechaLimite.getDate() + diasAdelante);
let cursor = new Date(ahora);
// Empezar desde el siguiente slot redondo (media hora)
cursor.setMinutes(cursor.getMinutes() >= 30 ? 60 : 30, 0, 0);
while (cursor < fechaLimite && slots.length < 20) {
if (!esDiaHabil(cursor)) {
cursor.setDate(cursor.getDate() + 1);
cursor.setHours(HORA_INICIO, 0, 0, 0);
continue;
}
const hInicio = cursor.getHours() + cursor.getMinutes() / 60;
if (hInicio < HORA_INICIO) {
cursor.setHours(HORA_INICIO, 0, 0, 0);
continue;
}
const fin = new Date(cursor.getTime() + duracionMs);
const hFin = fin.getHours() + fin.getMinutes() / 60;
if (hFin > HORA_FIN) {
// Pasar al día siguiente
cursor.setDate(cursor.getDate() + 1);
cursor.setHours(HORA_INICIO, 0, 0, 0);
continue;
}
const ocupados = await turnosSolapados(cursor, fin);
if (ocupados < SLOTS_PARALELOS) {
slots.push({ fechaInicio: new Date(cursor), fechaFin: new Date(fin) });
}
// Avanzar 30 minutos
cursor = new Date(cursor.getTime() + 30 * 60 * 1000);
}
return slots;
}
export async function slotEstaDisponible(inicio: Date, fin: Date): Promise<boolean> {
if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false;
const ocupados = await turnosSolapados(inicio, fin);
return ocupados < SLOTS_PARALELOS;
}