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:
@@ -0,0 +1,5 @@
|
|||||||
|
DATABASE_URL="postgresql://taller:taller123@localhost:5432/taller_turnos"
|
||||||
|
NEXTAUTH_SECRET="cambia-esto-por-un-secret-seguro"
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
RESEND_API_KEY="re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
MAGIC_LINK_BASE_URL="http://localhost:3000"
|
||||||
@@ -27,6 +27,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -34,3 +35,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: taller_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: taller
|
||||||
|
POSTGRES_PASSWORD: taller123
|
||||||
|
POSTGRES_DB: taller_turnos
|
||||||
|
volumes:
|
||||||
|
- taller_db_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: taller_app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://taller:taller123@db:5432/taller_turnos
|
||||||
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||||
|
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||||
|
MAGIC_LINK_BASE_URL: ${MAGIC_LINK_BASE_URL}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
taller_db_data:
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {};
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+1782
-17
File diff suppressed because it is too large
Load Diff
+23
-5
@@ -6,21 +6,39 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:seed": "npx tsx prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "npx tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"jose": "^6.2.3",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"next": "14.2.35",
|
||||||
|
"next-auth": "^5.0.0-beta.25",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"next": "14.2.35"
|
"resend": "^6.14.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.35",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"eslint": "^8",
|
"tsx": "^4.22.4",
|
||||||
"eslint-config-next": "14.2.35"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Cliente {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
nombre String
|
||||||
|
apellido String
|
||||||
|
telefono String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
autos Auto[]
|
||||||
|
turnos Turno[]
|
||||||
|
magicTokens MagicToken[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Auto {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
clienteId String
|
||||||
|
patente String @unique
|
||||||
|
marca String
|
||||||
|
modelo String
|
||||||
|
anio Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
cliente Cliente @relation(fields: [clienteId], references: [id])
|
||||||
|
turnos Turno[]
|
||||||
|
historial HistorialServicio[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model TipoServicio {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
duracionHoras Float
|
||||||
|
activo Boolean @default(true)
|
||||||
|
|
||||||
|
turnos Turno[]
|
||||||
|
historial HistorialServicio[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Turno {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
clienteId String
|
||||||
|
autoId String
|
||||||
|
tipoServicioId String
|
||||||
|
fechaInicio DateTime
|
||||||
|
fechaFin DateTime
|
||||||
|
estado EstadoTurno @default(PENDIENTE)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
cliente Cliente @relation(fields: [clienteId], references: [id])
|
||||||
|
auto Auto @relation(fields: [autoId], references: [id])
|
||||||
|
tipoServicio TipoServicio @relation(fields: [tipoServicioId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model HistorialServicio {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
autoId String
|
||||||
|
adminId String
|
||||||
|
tipoServicioId String?
|
||||||
|
descripcion String
|
||||||
|
observaciones String?
|
||||||
|
fechaRealizado DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
auto Auto @relation(fields: [autoId], references: [id])
|
||||||
|
admin Admin @relation(fields: [adminId], references: [id])
|
||||||
|
tipoServicio TipoServicio? @relation(fields: [tipoServicioId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Admin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
nombre String
|
||||||
|
passwordHash String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
historial HistorialServicio[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model MagicToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
clienteId String
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
usado Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
cliente Cliente @relation(fields: [clienteId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EstadoTurno {
|
||||||
|
PENDIENTE
|
||||||
|
CONFIRMADO
|
||||||
|
CANCELADO
|
||||||
|
COMPLETADO
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const hash = (pwd: string) => bcrypt.hash(pwd, 10);
|
||||||
|
|
||||||
|
await prisma.admin.upsert({
|
||||||
|
where: { email: "jefe1@taller.com" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: "jefe1@taller.com",
|
||||||
|
nombre: "Jefe 1",
|
||||||
|
passwordHash: await hash("jefe1password"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.admin.upsert({
|
||||||
|
where: { email: "jefe2@taller.com" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: "jefe2@taller.com",
|
||||||
|
nombre: "Jefe 2",
|
||||||
|
passwordHash: await hash("jefe2password"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tipos de servicio de ejemplo
|
||||||
|
const servicios = [
|
||||||
|
{ nombre: "Cambio de aceite y filtro", duracionHoras: 1 },
|
||||||
|
{ nombre: "Cambio de bujías", duracionHoras: 1 },
|
||||||
|
{ nombre: "Cambio de frenos", duracionHoras: 2 },
|
||||||
|
{ nombre: "Alineación y balanceo", duracionHoras: 1.5 },
|
||||||
|
{ nombre: "Cambio de correa de distribución", duracionHoras: 4 },
|
||||||
|
{ nombre: "Cambio de distribución con bomba de agua", duracionHoras: 8 },
|
||||||
|
{ nombre: "Revisión general", duracionHoras: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const s of servicios) {
|
||||||
|
await prisma.tipoServicio.upsert({
|
||||||
|
where: { id: s.nombre },
|
||||||
|
update: {},
|
||||||
|
create: s,
|
||||||
|
}).catch(() => prisma.tipoServicio.create({ data: s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Seed completado");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { verificarSesion } from "@/lib/auth-admin";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const sesion = await verificarSesion("admin_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = searchParams.get("q");
|
||||||
|
|
||||||
|
const clientes = await prisma.cliente.findMany({
|
||||||
|
where: q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ nombre: { contains: q, mode: "insensitive" } },
|
||||||
|
{ apellido: { contains: q, mode: "insensitive" } },
|
||||||
|
{ email: { contains: q, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
include: { autos: true },
|
||||||
|
orderBy: { apellido: "asc" },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(clientes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { verificarSesion } from "@/lib/auth-admin";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const sesion = await verificarSesion("admin_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const autoId = searchParams.get("autoId");
|
||||||
|
if (!autoId) return NextResponse.json({ error: "autoId requerido" }, { status: 400 });
|
||||||
|
|
||||||
|
const historial = await prisma.historialServicio.findMany({
|
||||||
|
where: { autoId },
|
||||||
|
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } },
|
||||||
|
orderBy: { fechaRealizado: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(historial);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const sesion = await verificarSesion("admin_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { autoId, tipoServicioId, descripcion, observaciones, fechaRealizado } = await req.json();
|
||||||
|
if (!autoId || !descripcion || !fechaRealizado)
|
||||||
|
return NextResponse.json({ error: "Datos incompletos" }, { status: 400 });
|
||||||
|
|
||||||
|
const registro = await prisma.historialServicio.create({
|
||||||
|
data: {
|
||||||
|
autoId,
|
||||||
|
adminId: sesion.sub,
|
||||||
|
tipoServicioId: tipoServicioId || null,
|
||||||
|
descripcion,
|
||||||
|
observaciones: observaciones || null,
|
||||||
|
fechaRealizado: new Date(fechaRealizado),
|
||||||
|
},
|
||||||
|
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(registro, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { verificarSesion } from "@/lib/auth-admin";
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const sesion = await verificarSesion("admin_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await req.json();
|
||||||
|
|
||||||
|
const servicio = await prisma.tipoServicio.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(data.nombre !== undefined && { nombre: data.nombre }),
|
||||||
|
...(data.duracionHoras !== undefined && { duracionHoras: Number(data.duracionHoras) }),
|
||||||
|
...(data.activo !== undefined && { activo: data.activo }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(servicio);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { verificarSesion } from "@/lib/auth-admin";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const sesion = await verificarSesion("admin_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const servicios = await prisma.tipoServicio.findMany({ orderBy: { nombre: "asc" } });
|
||||||
|
return NextResponse.json(servicios);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const sesion = await verificarSesion("admin_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { nombre, duracionHoras } = await req.json();
|
||||||
|
if (!nombre || !duracionHoras)
|
||||||
|
return NextResponse.json({ error: "nombre y duracionHoras requeridos" }, { status: 400 });
|
||||||
|
|
||||||
|
const servicio = await prisma.tipoServicio.create({
|
||||||
|
data: { nombre, duracionHoras: Number(duracionHoras) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(servicio, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { verificarSesion } from "@/lib/auth-admin";
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const sesion = await verificarSesion("admin_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const { estado } = await req.json();
|
||||||
|
|
||||||
|
const turno = await prisma.turno.update({
|
||||||
|
where: { id },
|
||||||
|
data: { estado },
|
||||||
|
include: { auto: true, tipoServicio: true, cliente: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(turno);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { verificarSesion } from "@/lib/auth-admin";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const sesion = await verificarSesion("admin_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const fecha = searchParams.get("fecha"); // YYYY-MM-DD
|
||||||
|
|
||||||
|
const where = fecha
|
||||||
|
? {
|
||||||
|
fechaInicio: {
|
||||||
|
gte: new Date(`${fecha}T00:00:00`),
|
||||||
|
lte: new Date(`${fecha}T23:59:59`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const turnos = await prisma.turno.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
cliente: { select: { nombre: true, apellido: true, telefono: true } },
|
||||||
|
auto: true,
|
||||||
|
tipoServicio: true,
|
||||||
|
},
|
||||||
|
orderBy: { fechaInicio: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(turnos);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { crearSesionAdmin } from "@/lib/auth-admin";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { email, password } = await req.json();
|
||||||
|
if (!email || !password)
|
||||||
|
return NextResponse.json({ error: "Credenciales requeridas" }, { status: 400 });
|
||||||
|
|
||||||
|
const admin = await prisma.admin.findUnique({ where: { email: email.toLowerCase() } });
|
||||||
|
if (!admin) return NextResponse.json({ error: "Credenciales incorrectas" }, { status: 401 });
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(password, admin.passwordHash);
|
||||||
|
if (!ok) return NextResponse.json({ error: "Credenciales incorrectas" }, { status: 401 });
|
||||||
|
|
||||||
|
const jwt = await crearSesionAdmin(admin.id);
|
||||||
|
|
||||||
|
const response = NextResponse.json({ ok: true, admin: { id: admin.id, nombre: admin.nombre } });
|
||||||
|
response.cookies.set("admin_session", jwt, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 8,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { enviarMagicLink } from "@/lib/magic-link";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { email } = await req.json();
|
||||||
|
if (!email) return NextResponse.json({ error: "Email requerido" }, { status: 400 });
|
||||||
|
|
||||||
|
const result = await enviarMagicLink(email.toLowerCase().trim());
|
||||||
|
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { validarMagicToken } from "@/lib/magic-link";
|
||||||
|
import { crearSesionCliente } from "@/lib/auth-admin";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { token } = await req.json();
|
||||||
|
if (!token) return NextResponse.json({ error: "Token requerido" }, { status: 400 });
|
||||||
|
|
||||||
|
const cliente = await validarMagicToken(token);
|
||||||
|
if (!cliente) return NextResponse.json({ error: "Token inválido o expirado" }, { status: 401 });
|
||||||
|
|
||||||
|
const jwt = await crearSesionCliente(cliente.id);
|
||||||
|
|
||||||
|
const response = NextResponse.json({ ok: true, cliente: { id: cliente.id, nombre: cliente.nombre } });
|
||||||
|
response.cookies.set("cliente_session", jwt, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { verificarSesion } from "@/lib/auth-admin";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const sesion = await verificarSesion("cliente_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const autos = await prisma.auto.findMany({
|
||||||
|
where: { clienteId: sesion.sub },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(autos);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const sesion = await verificarSesion("cliente_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { patente, marca, modelo, anio } = await req.json();
|
||||||
|
if (!patente || !marca || !modelo || !anio)
|
||||||
|
return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 });
|
||||||
|
|
||||||
|
const existe = await prisma.auto.findUnique({ where: { patente: patente.toUpperCase() } });
|
||||||
|
if (existe) return NextResponse.json({ error: "Patente ya registrada" }, { status: 409 });
|
||||||
|
|
||||||
|
const auto = await prisma.auto.create({
|
||||||
|
data: {
|
||||||
|
clienteId: sesion.sub,
|
||||||
|
patente: patente.toUpperCase().trim(),
|
||||||
|
marca,
|
||||||
|
modelo,
|
||||||
|
anio: Number(anio),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(auto, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { verificarSesion } from "@/lib/auth-admin";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const sesion = await verificarSesion("cliente_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const autoId = searchParams.get("autoId");
|
||||||
|
if (!autoId) return NextResponse.json({ error: "autoId requerido" }, { status: 400 });
|
||||||
|
|
||||||
|
// Verificar que el auto pertenece al cliente
|
||||||
|
const auto = await prisma.auto.findFirst({ where: { id: autoId, clienteId: sesion.sub } });
|
||||||
|
if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
|
||||||
|
|
||||||
|
const historial = await prisma.historialServicio.findMany({
|
||||||
|
where: { autoId },
|
||||||
|
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } },
|
||||||
|
orderBy: { fechaRealizado: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(historial);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { email, nombre, apellido, telefono } = await req.json();
|
||||||
|
|
||||||
|
if (!email || !nombre || !apellido || !telefono)
|
||||||
|
return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 });
|
||||||
|
|
||||||
|
const existe = await prisma.cliente.findUnique({ where: { email: email.toLowerCase() } });
|
||||||
|
if (existe) return NextResponse.json({ error: "El email ya está registrado" }, { status: 409 });
|
||||||
|
|
||||||
|
const cliente = await prisma.cliente.create({
|
||||||
|
data: { email: email.toLowerCase().trim(), nombre, apellido, telefono },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, id: cliente.id }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { verificarSesion } from "@/lib/auth-admin";
|
||||||
|
import { obtenerSlotsDisponibles } from "@/lib/scheduling";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const sesion = await verificarSesion("cliente_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const tipoServicioId = searchParams.get("tipoServicioId");
|
||||||
|
if (!tipoServicioId)
|
||||||
|
return NextResponse.json({ error: "tipoServicioId requerido" }, { status: 400 });
|
||||||
|
|
||||||
|
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
|
||||||
|
if (!servicio || !servicio.activo)
|
||||||
|
return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 });
|
||||||
|
|
||||||
|
const slots = await obtenerSlotsDisponibles(servicio.duracionHoras);
|
||||||
|
return NextResponse.json(slots);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { verificarSesion } from "@/lib/auth-admin";
|
||||||
|
import { slotEstaDisponible } from "@/lib/scheduling";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const sesion = await verificarSesion("cliente_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const turnos = await prisma.turno.findMany({
|
||||||
|
where: { clienteId: sesion.sub },
|
||||||
|
include: { auto: true, tipoServicio: true },
|
||||||
|
orderBy: { fechaInicio: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(turnos);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const sesion = await verificarSesion("cliente_session");
|
||||||
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { autoId, tipoServicioId, fechaInicio } = await req.json();
|
||||||
|
if (!autoId || !tipoServicioId || !fechaInicio)
|
||||||
|
return NextResponse.json({ error: "Datos incompletos" }, { status: 400 });
|
||||||
|
|
||||||
|
// Verificar que el auto pertenece al cliente
|
||||||
|
const auto = await prisma.auto.findFirst({
|
||||||
|
where: { id: autoId, clienteId: sesion.sub },
|
||||||
|
});
|
||||||
|
if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
|
||||||
|
|
||||||
|
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
|
||||||
|
if (!servicio || !servicio.activo)
|
||||||
|
return NextResponse.json({ error: "Servicio no disponible" }, { status: 404 });
|
||||||
|
|
||||||
|
const inicio = new Date(fechaInicio);
|
||||||
|
const fin = new Date(inicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const disponible = await slotEstaDisponible(inicio, fin);
|
||||||
|
if (!disponible)
|
||||||
|
return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 });
|
||||||
|
|
||||||
|
const turno = await prisma.turno.create({
|
||||||
|
data: { clienteId: sesion.sub, autoId, tipoServicioId, fechaInicio: inicio, fechaFin: fin },
|
||||||
|
include: { auto: true, tipoServicio: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(turno, { status: 201 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user