📐 Arquitectura PuntoHack MVP - Diseño Planeado
📋 Índice
- Visión General
- Stack Tecnológico
- Arquitectura del Sistema
- Modelo de Datos
- Sistema RBAC
- Patrón Modular
- Flujos de Datos
- Roadmap de Fases
🎯 Visión General
PuntoHack MVP es una plataforma de gestión de hackathons que permitirá:
- 👥 Organizadores: Crear y gestionar hackathons
- 🎯 Participantes: Registrarse, formar equipos y enviar proyectos
- ⚖️ Jueces: Evaluar proyectos con criterios ponderados
- 🏆 Sponsors: Ofrecer challenges y patrocinios
- 👨💼 Admins: Gestión completa del sistema
⚡ Enfoque del Proyecto
IMPORTANTE: Este proyecto se enfoca en el CORE funcional, no en el frontend elaborado.
- 🎯 Prioridad #1: Lógica de negocio robusta (RBAC, validaciones, Server Actions)
- 🎯 Prioridad #2: Base de datos bien diseñada (Prisma + Supabase)
- 🎯 Prioridad #3: Testing completo del core (80%+ coverage)
- 📱 Frontend: Profesional con Next.js estándar, sin librerías UI innecesarias
Aclaración Frontend:
- ✅ Usar Next.js 16 correctamente (App Router, Server Components, Server Actions)
- ✅ Usar Tailwind CSS profesionalmente
- ✅ Seguir mejores prácticas de Next.js
- ❌ NO usar librerías UI complejas innecesarias (Radix UI, shadcn/ui, etc.)
MVP Estricto:
- ✅ Implementar SOLO lo documentado
- ❌ NO agregar cache, optimizaciones, o features extra
- ❌ NO sobreingeniería
- Ver MVP-ESTRICTO.md para detalles
Principios de Diseño
- ✅ Arquitectura modular y escalable (Core + Modules)
- ✅ Sistema de roles robusto (RBAC) - Autorización en cada acción
- ✅ Type-safety completo (TypeScript + Zod)
- ✅ Validaciones exhaustivas - Nunca confiar en el cliente
- ✅ Testing desde el inicio - 80%+ coverage
- ✅ Next.js estándar - App Router, Server Components, Server Actions
- ⚠️ MVP Estricto - Solo lo documentado, sin extras
🛠️ Stack Tecnológico
⚡ Core (PRIORIDAD)
| Tecnología | Versión | Propósito |
|---|---|---|
| Prisma | 7.x | ORM para PostgreSQL |
| Supabase | Última | Base de datos PostgreSQL + Realtime (futuro) |
| Zod | 3.x | Validación de esquemas (CRÍTICO) |
| TypeScript | 5.x | Seguridad de tipos |
| Vitest | Última | Framework de testing |
🔐 Authentication & Monitoring
| Tecnología | Versión | Propósito |
|---|---|---|
| Clerk | Última | Autenticación |
| Sentry | Última | Monitoreo de errores |
📱 Frontend (Profesional con Next.js)
| Tecnología | Versión | Propósito |
|---|---|---|
| Next.js | 16.x | Framework (App Router, Server Components) |
| Turbopack | Incluido | Bundler de Next.js (desarrollo rápido) |
| React | 19.x | UI |
| Tailwind CSS | 4.x | Styling profesional |
Nota sobre Supabase:
- ✅ Usaremos Supabase como base de datos PostgreSQL
- ✅ Preparado para Realtime Events en futuras iteraciones
- ✅ Prisma se conecta directamente a Supabase PostgreSQL
Nota sobre Frontend:
- ✅ Usaremos Next.js siguiendo sus mejores prácticas (App Router, Server Components, Server Actions)
- ✅ Tailwind CSS para styling profesional
- ✅ Formularios HTML nativos (funcionan perfecto con Server Actions)
- ❌ NO usaremos librerías UI innecesarias: Radix UI, shadcn/ui, Framer Motion, React Hook Form
- ❌ Razón: Añaden complejidad sin valor para este MVP
🏗️ Arquitectura del Sistema
Diagrama de Capas
┌─────────────────────────────────────────┐
│ CAPA DE PRESENTACIÓN │
│ (Next.js App Router) │
│ - Server Components (data fetching) │
│ - Client Components (interactivity) │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ CAPA DE LÓGICA DE NEGOCIO │
│ Core: │
│ - rbac.ts (authorization) │
│ - errors.ts (error handling) │
│ - db.ts (database client) │
│ │
│ Modules (domain logic): │
│ - users/ │
│ - hackathons/ │
│ - teams/ │
│ - submissions/ │
│ - evaluation/ │
│ - sponsors/ │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ CAPA DE DATOS │
│ - Prisma ORM │
│ - Supabase (PostgreSQL) │
│ - Realtime Events (futuro) │
└─────────────────────────────────────────┘
Estructura de Directorios
proyecto/
├── src/
│ ├── core/ # ⚡ CORE - PRIORIDAD #1
│ │ ├── rbac.ts # Sistema de roles y permisos
│ │ ├── errors.ts # Manejo de errores + Sentry
│ │ ├── db.ts # Singleton de Prisma Client
│ │ ├── auth.ts # Helpers de autenticación (Clerk)
│ │ └── validations.ts # Helpers de validación de fechas
│ │
│ ├── modules/ # ⚡ LÓGICA DE NEGOCIO - PRIORIDAD #2
│ │ └── [domain]/ # Ejemplo: users, hackathons, teams
│ │ ├── queries.ts # Consultas a la BD (lectura)
│ │ ├── actions.ts # Server Actions (escritura)
│ │ ├── types.ts # TypeScript interfaces
│ │ └── validations.ts # Zod schemas (CRÍTICO)
│ │
│ ├── app/ # 📱 FRONTEND MINIMALISTA
│ │ ├── layout.tsx # Root layout básico
│ │ ├── page.tsx # Landing simple
│ │ ├── onboarding/ # Formulario onboarding
│ │ ├── dashboard/ # Panel básico
│ │ ├── admin/ # Panel de admin (simple)
│ │ │ └── users/ # Tabla de usuarios
│ │ ├── hackathons/ # CRUD hackathons
│ │ │ ├── page.tsx # Lista simple
│ │ │ ├── [slug]/ # Detalle
│ │ │ └── create/ # Formulario
│ │ ├── judge/ # Panel de juez
│ │ ├── sponsor/ # Panel de sponsor
│ │ ├── sign-in/ # Clerk auth
│ │ ├── sign-up/
│ │ └── api/
│ │ └── cron/
│ │ └── update-hackathon-states/
│ │ └── route.ts # Cron job para cambios de estado
│ │
│ ├── components/ # UI componentes SIMPLES
│ │ └── [feature]/ # Solo lo necesario
│ │
│ └── lib/ # Utilities
│ └── utils.ts
│
├── prisma/ # ⚡ DATABASE - PRIORIDAD #2
│ └── schema.prisma # 17 modelos bien diseñados
│
└── tests/ # ⚡ TESTING - PRIORIDAD #3
└── modules/
└── [domain]/ # Tests por módulo
├── queries.test.ts
├── actions.test.ts
└── validations.test.ts
Nota: El 80% del tiempo debe ir a core/, modules/ y tests/. El frontend es solo una capa delgada para exponer la funcionalidad.
🗄️ Modelo de Datos
Modelos Principales (17 total)
1. Core Domain
Profile - Usuarios del sistema
model Profile {
id String @id @default(cuid())
userId String @unique // Clerk ID
name String
email String @unique
avatarUrl String?
bio String?
techStack String[]
role Role @default(PARTICIPANT)
// Relaciones
participations HackathonParticipation[]
teamMemberships TeamMember[]
scoresGiven Score[]
hackathonsAsJudge HackathonJudge[]
organizationMemberships OrganizationMember[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum Role {
PARTICIPANT
JUDGE
ORGANIZER
ADMIN
SPONSOR
}
Hackathon - Eventos principales
model Hackathon {
id String @id @default(cuid())
name String
slug String @unique
description String
status HackathonStatus @default(DRAFT)
// Fechas
startsAt DateTime
endsAt DateTime
registrationOpensAt DateTime
registrationClosesAt DateTime
submissionDeadline DateTime // Deadline para enviar/editar submissions
judgingStartsAt DateTime
judgingEndsAt DateTime
// Config
maxTeamSize Int @default(5)
minTeamSize Int @default(1)
// Relaciones
participations HackathonParticipation[]
teams Team[]
submissions Submission[]
criteria Criterion[]
judges HackathonJudge[]
sponsorships Sponsorship[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum HackathonStatus {
DRAFT
REGISTRATION
RUNNING
JUDGING
FINISHED
}
2. Teams & Submissions
Team - Equipos de participantes
model Team {
id String @id @default(cuid())
hackathonId String
name String
code String @unique // Código de invitación
description String?
hackathon Hackathon @relation(fields: [hackathonId], references: [id])
members TeamMember[]
submissions Submission[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model TeamMember {
id String @id @default(cuid())
teamId String
profileId String
team Team @relation(fields: [teamId], references: [id])
profile Profile @relation(fields: [profileId], references: [id])
createdAt DateTime @default(now())
@@unique([teamId, profileId])
}
Submission - Proyectos enviados
model Submission {
id String @id @default(cuid())
hackathonId String
teamId String
challengeId String? // Opcional
title String
description String
repoUrl String?
demoUrl String?
extraLinks Json?
hackathon Hackathon @relation(fields: [hackathonId], references: [id])
team Team @relation(fields: [teamId], references: [id])
challenge Challenge? @relation(fields: [challengeId], references: [id])
scores Score[]
shortlistItems ShortlistItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([teamId])
}
3. Evaluation System
Criterion - Criterios de evaluación
model Criterion {
id String @id @default(cuid())
hackathonId String
name String
description String?
weight Int @default(1) // 1-10
maxScore Int @default(10)
hackathon Hackathon @relation(fields: [hackathonId], references: [id])
scores Score[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Score - Puntajes de jueces
model Score {
id String @id @default(cuid())
submissionId String
judgeId String
criterionId String
hackathonId String
value Int // 0-maxScore
comment String?
submission Submission @relation(fields: [submissionId], references: [id])
judge Profile @relation(fields: [judgeId], references: [id])
criterion Criterion @relation(fields: [criterionId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([submissionId, judgeId, criterionId])
// Validación de consistencia (servidor/tests):
// score.hackathonId debe ser igual a submission.hackathonId y criterion.hackathonId
}
4. Sponsors Domain
Organization - Empresas patrocinadoras
model Organization {
id String @id @default(cuid())
name String
description String?
logoUrl String?
website String?
members OrganizationMember[]
sponsorships Sponsorship[]
shortlistItems ShortlistItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Sponsorship - Patrocinios
model Sponsorship {
id String @id @default(cuid())
organizationId String
hackathonId String
tier SponsorshipTier
benefits Json?
organization Organization @relation(fields: [organizationId], references: [id])
hackathon Hackathon @relation(fields: [hackathonId], references: [id])
challenges Challenge[]
@@unique([organizationId, hackathonId])
}
enum SponsorshipTier {
DIAMOND
PLATINUM
GOLD
SILVER
BRONZE
PARTNER
}
Challenge - Desafíos de sponsors
model Challenge {
id String @id @default(cuid())
hackathonId String
sponsorshipId String
title String
description String
tags String[]
prizeDetails String?
hackathon Hackathon @relation(fields: [hackathonId], references: [id])
sponsorship Sponsorship @relation(fields: [sponsorshipId], references: [id])
submissions Submission[]
shortlistItems ShortlistItem[]
}
ShortlistItem - Proyectos favoritos de sponsors
model ShortlistItem {
id String @id @default(cuid())
submissionId String
organizationId String
challengeId String
notes String? // Notas privadas del sponsor
submission Submission @relation(fields: [submissionId], references: [id])
organization Organization @relation(fields: [organizationId], references: [id])
challenge Challenge @relation(fields: [challengeId], references: [id])
createdAt DateTime @default(now())
@@unique([submissionId, organizationId])
}
🔐 Sistema RBAC
Jerarquía de Roles
ADMIN (Full Access)
│
├─ Puede asignar cualquier rol
├─ Acceso a todos los recursos
└─ Gestión completa del sistema
ORGANIZER
│
├─ Crear/gestionar hackathons
├─ Asignar jueces
├─ Gestionar usuarios (excepto ADMIN)
└─ Ver estadísticas
JUDGE
│
├─ Ver submissions del hackathon asignado
├─ Evaluar proyectos
└─ Dar feedback
SPONSOR
│
├─ Crear challenges
├─ Ver submissions
└─ Shortlist proyectos favoritos
PARTICIPANT
│
├─ Registrarse en hackathons
├─ Formar/unirse a equipos
└─ Enviar proyectos
Implementación
// src/core/rbac.ts
export type Role = 'PARTICIPANT' | 'JUDGE' | 'ORGANIZER' | 'ADMIN' | 'SPONSOR';
export function hasRole(user: User, roles: Role[]): boolean {
return roles.includes(user.profile.role);
}
export function requireRole(user: User, roles: Role[]): void {
if (!hasRole(user, roles)) {
throw new Error('Unauthorized');
}
}
Matriz de Permisos
| Acción | ADMIN | ORGANIZER | JUDGE | SPONSOR | PARTICIPANT |
|---|---|---|---|---|---|
| Cambiar roles | ✅ | ❌ | ❌ | ❌ | ❌ |
| Gestionar usuarios* | ✅ | ✅ | ❌ | ❌ | ❌ |
| Crear hackathon | ✅ | ✅ | ❌ | ❌ | ❌ |
| Asignar jueces | ✅ | ✅ | ❌ | ❌ | ❌ |
| Evaluar proyectos** | ✅ | ❌ | ✅ | ❌ | ❌ |
| Ver scores de otros | ✅ | ✅ | ❌ | ❌ | ❌ |
| Crear challenges | ✅ | ❌ | ❌ | ✅ | ❌ |
| Shortlist proyectos | ✅ | ❌ | ❌ | ✅ | ❌ |
| Registrarse en hackathon | ❌ | ❌ | ❌*** | ❌ | ✅ |
| Formar equipos | ❌ | ❌ | ❌*** | ❌ | ✅ |
Notas importantes:
- * Gestionar usuarios (ORGANIZER): Eliminar usuarios, agrupar en equipos, expulsar de equipos. NO cambiar roles.
- ** Evaluar proyectos (JUDGE): Solo del hackathon asignado. Por ahora, 1 hackathon a la vez.
- *** JUDGE: NO puede formar equipos ni registrarse en el MISMO hackathon que juzga (conflicto de interés). Puede ver scores de otros jueces solo cuando el hackathon está FINISHED.
🎯 Reglas de Negocio Críticas
Flujo por Actor
PARTICIPANT
- Registrarse → Crear perfil
- Navegar hackathons (estado REGISTRATION)
- Registrarse en hackathon (solo role
PARTICIPANT) → CrearHackathonParticipation - Crear equipo O unirse con código de invitación (crear/unirse permitido solo hasta
submissionDeadline) - Trabajar en proyecto (fuera de la app)
- Enviar submission (repoUrl, demoUrl, elegir challenge opcional)
- Editar submission hasta
submissionDeadline(luego solo lectura; segundo intento redirige/avisa que ya existe submission del equipo) - Ver leaderboard cuando hackathon esté FINISHED
- Salir del equipo permitido hasta
submissionDeadline; si el equipo no tiene submission y queda vacío → eliminar equipo; si tiene submission, no se permite que el último miembro salga
JUDGE
- Es asignado a UN hackathon por ORGANIZER → Crear
HackathonJudge - Espera a que hackathon pase a estado JUDGING (antes de esto no ve lista de submissions)
- Ve lista de submissions del hackathon asignado solo en
JUDGING - Por cada submission:
- Ve criterios con sus pesos (ej: Innovation 30%, Technical 40%, Design 30%)
- Asigna puntaje (0-maxScore) por cada criterio
- Opcional: deja comentario
- Guarda → Crear
Score
- Restricción: NO puede ver scores de otros jueces durante JUDGING; puede verlos en FINISHED
- Restricción: NO puede formar equipo en el mismo hackathon que juzga
- Asignación bloqueada: Si ya participa en el hackathon (participation/team), no se puede crear
HackathonJudge - Si el juez es removido, sus
Scorequedan en DB pero no cuentan para el cálculo (se consideran solo jueces conHackathonJudgevigente)
ORGANIZER
- Crear hackathon (estado DRAFT)
- Configurar:
- Fechas: registrationOpensAt, registrationClosesAt, startsAt, endsAt, judgingStartsAt, judgingEndsAt
- maxTeamSize, minTeamSize
- Crear criterios: name, description, weight (1-10), maxScore (default 10)
- Asignar jueces: elegir profiles con role JUDGE → Crear
HackathonJudge - Publicar manual: cambiar estado DRAFT → REGISTRATION solo si
now >= registrationOpensAty orden de fechas válido - Monitorear:
- Ver participaciones
- Ver equipos formados
- Expulsar usuarios de equipos si necesario
- Eliminar usuarios problemáticos
- Automático: Sistema cambia estados según fechas (cron) solo para hackathons ya publicados (
REGISTRATIONen adelante):
registrationClosesAt→ RUNNINGjudgingStartsAt→ JUDGINGjudgingEndsAt→ FINISHEDsubmissionDeadlineno cambia estado: solo bloquea edición de submissions
- Extensión: Puede alargar solo fase SIGUIENTE, máximo +7 días, nunca acortar, sin romper orden de fechas
- Ver leaderboard final con scores ponderados
SPONSOR
- Crear/pertenecer a Organization →
OrganizationMember - Crear Sponsorship para un hackathon:
- Elegir tier (DIAMOND, PLATINUM, GOLD, SILVER, BRONZE, PARTNER)
- Definir benefits (JSON con logo placement, booth, etc.)
- Crear Challenge:
- title, description, tags
- prizeDetails (ej: "$5000 para mejor proyecto usando nuestra API")
- Esperar a que equipos envíen submissions eligiendo su challenge
- Ver submissions que eligieron su challenge (
submission.challengeId) - Shortlist proyectos favoritos:
- Marcar submissions de interés → Crear
ShortlistItem - Agregar notas privadas
- Marcar submissions de interés → Crear
- Restricción: NO puede evaluar (no asigna scores)
- Post-hackathon:
- Ver leaderboard final y ganador de su challenge (mejor score final de los que eligieron su challenge; empates permitidos)
- Ver perfiles completos de shortlist (bio, techStack, historial)
- Contactar vía botón “Contact” (email vía Clerk, sin exponer correo)
ADMIN
- Acceso completo a todo el CRUD
- Puede cambiar roles (incluido asignar ADMIN)
- Puede crear/editar/eliminar cualquier recurso
- Por ahora: "Dios mode" sin restricciones
- Por ahora: “modo Dios” sin restricciones
- Futuro: Agregar audit logs y reglas específicas si es necesario
Cambios de Estado Automáticos
Sistema debe verificar fechas y cambiar estados automáticamente:
// Pseudo-código del cron job o middleware (solo hackathons ya publicados)
setInterval(async () => {
const now = new Date();
// REGISTRATION → RUNNING
await db.hackathon.updateMany({
where: {
status: 'REGISTRATION',
registrationClosesAt: { lte: now }
},
data: { status: 'RUNNING' }
});
// RUNNING → JUDGING
await db.hackathon.updateMany({
where: {
status: 'RUNNING',
judgingStartsAt: { lte: now }
},
data: { status: 'JUDGING' }
});
// JUDGING → FINISHED
await db.hackathon.updateMany({
where: {
status: 'JUDGING',
judgingEndsAt: { lte: now }
},
data: { status: 'FINISHED' }
});
}, 60000); // Cada minuto
Validaciones importantes:
registrationOpensAt < registrationClosesAt < startsAt < submissionDeadline < judgingStartsAt < judgingEndsAt- Participants pueden editar submissions hasta
submissionDeadline - ORGANIZER puede alargar fechas de fase SIGUIENTE (no la actual) hasta +1 semana máximo
- Publicación es manual: solo se puede pasar de DRAFT a REGISTRATION si
now >= registrationOpensAt; el cron no publica drafts - Cron no cambia RUNNING por
submissionDeadline; solo bloquea edición (validaciones de Server Actions) - Equipos: create/join/leave solo hasta
submissionDeadline; si equipo no tiene submission y queda vacío se elimina; si tiene submission, el último miembro no puede salir - Jueces removidos: sus scores permanecen pero no cuentan (solo jueces con
HackathonJudgevigente)
Gestión de Fechas por ORGANIZER
Regla: ORGANIZER puede alargar solo la fase SIGUIENTE, no la actual.
Ejemplo:
- Si hackathon está en
REGISTRATION:- ❌ No puede alargar
registrationClosesAt - ✅ Puede alargar
startsAt,submissionDeadline,judgingStartsAt, etc. - Límite: Máximo +7 días
- ❌ No puede alargar
Razón: Evitar confusión en participantes. No afecta la etapa actual.
⏰ Sistema de Cron Job para Cambios de Estado
Implementación
Archivo: src/app/api/cron/update-hackathon-states/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/core/db';
export async function GET(request: NextRequest) {
// Validar cron secret (protección contra llamadas no autorizadas)
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const now = new Date();
let updates = {
regToRunning: 0,
runningToJudging: 0,
judgingToFinished: 0,
};
try {
// REGISTRATION → RUNNING
const regToRunning = await db.hackathon.updateMany({
where: {
status: 'REGISTRATION',
registrationClosesAt: { lte: now },
},
data: { status: 'RUNNING' },
});
updates.regToRunning = regToRunning.count;
// RUNNING → JUDGING
const runningToJudging = await db.hackathon.updateMany({
where: {
status: 'RUNNING',
judgingStartsAt: { lte: now },
},
data: { status: 'JUDGING' },
});
updates.runningToJudging = runningToJudging.count;
// JUDGING → FINISHED
const judgingToFinished = await db.hackathon.updateMany({
where: {
status: 'JUDGING',
judgingEndsAt: { lte: now },
},
data: { status: 'FINISHED' },
});
updates.judgingToFinished = judgingToFinished.count;
return NextResponse.json({
success: true,
timestamp: now.toISOString(),
updates,
});
} catch (error) {
console.error('[Cron Error]', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Configuración en Vercel
Archivo: vercel.json
{
"crons": [
{
"path": "/api/cron/update-hackathon-states",
"schedule": "* * * * *"
}
]
}
Nota: El cron se ejecuta cada minuto. Solo procesa hackathons ya publicados (status != DRAFT).
Variables de Entorno
# .env.local
CRON_SECRET=tu-secret-super-seguro-aqui
Reglas del Cron
- ✅ Solo cambia estados de hackathons ya publicados (
REGISTRATIONen adelante) - ❌ NO publica hackathons en
DRAFT(publicación es manual) - ✅ Ejecuta transiciones automáticas basadas en fechas
- ✅ No afecta
submissionDeadline(solo bloquea edición, no cambia estado)
📅 Helper de Validación de Fechas
Implementación Centralizada
Archivo: src/core/validations.ts
import { z } from 'zod';
export interface HackathonDates {
registrationOpensAt: Date;
registrationClosesAt: Date;
startsAt: Date;
endsAt: Date;
submissionDeadline: Date;
judgingStartsAt: Date;
judgingEndsAt: Date;
}
/**
* Valida que las fechas de un hackathon sigan el orden correcto
*
* Orden requerido:
* registrationOpensAt < registrationClosesAt < startsAt <
* submissionDeadline < judgingStartsAt < judgingEndsAt
*
* @throws Error si el orden es inválido
*/
export function validateHackathonDates(dates: HackathonDates): void {
const {
registrationOpensAt,
registrationClosesAt,
startsAt,
endsAt,
submissionDeadline,
judgingStartsAt,
judgingEndsAt,
} = dates;
// Validar orden estricto
if (registrationOpensAt >= registrationClosesAt) {
throw new Error(
'registrationOpensAt debe ser anterior a registrationClosesAt'
);
}
if (registrationClosesAt >= startsAt) {
throw new Error(
'registrationClosesAt debe ser anterior a startsAt'
);
}
if (startsAt >= endsAt) {
throw new Error('startsAt debe ser anterior a endsAt');
}
if (startsAt >= submissionDeadline) {
throw new Error('startsAt debe ser anterior a submissionDeadline');
}
if (submissionDeadline >= judgingStartsAt) {
throw new Error('submissionDeadline debe ser anterior a judgingStartsAt');
}
if (judgingStartsAt >= judgingEndsAt) {
throw new Error('judgingStartsAt debe ser anterior a judgingEndsAt');
}
}
/**
* Valida extensión de fechas por ORGANIZER
*
* Reglas:
* - Solo puede alargar la fase SIGUIENTE (no la actual)
* - Máximo +7 días de extensión
* - Debe mantener el orden de fechas
*
* @param currentStatus Estado actual del hackathon
* @param dates Fechas actuales
* @param newDates Nuevas fechas propuestas
* @throws Error si la extensión es inválida
*/
export function validateDateExtension(
currentStatus: 'REGISTRATION' | 'RUNNING' | 'JUDGING',
dates: HackathonDates,
newDates: Partial<HackathonDates>
): void {
const now = new Date();
// Determinar qué fechas se pueden modificar según el estado
const allowedFields: (keyof HackathonDates)[] = [];
if (currentStatus === 'REGISTRATION') {
// Puede alargar: startsAt, submissionDeadline, judgingStartsAt, judgingEndsAt
allowedFields.push('startsAt', 'submissionDeadline', 'judgingStartsAt', 'judgingEndsAt');
} else if (currentStatus === 'RUNNING') {
// Puede alargar: submissionDeadline, judgingStartsAt, judgingEndsAt
allowedFields.push('submissionDeadline', 'judgingStartsAt', 'judgingEndsAt');
} else if (currentStatus === 'JUDGING') {
// Puede alargar: solo judgingEndsAt
allowedFields.push('judgingEndsAt');
}
// Verificar que solo se modifiquen campos permitidos
for (const field of Object.keys(newDates) as (keyof HackathonDates)[]) {
if (!allowedFields.includes(field)) {
throw new Error(
`No se puede modificar ${field} cuando el hackathon está en estado ${currentStatus}`
);
}
}
// Verificar extensión máxima (+7 días)
const MAX_EXTENSION_DAYS = 7;
const MAX_EXTENSION_MS = MAX_EXTENSION_DAYS * 24 * 60 * 60 * 1000;
for (const field of allowedFields) {
if (newDates[field]) {
const oldDate = dates[field];
const newDate = newDates[field]!;
const diff = newDate.getTime() - oldDate.getTime();
// Solo permitir alargar (no acortar)
if (diff < 0) {
throw new Error(`No se puede acortar ${field}`);
}
// Verificar límite de extensión
if (diff > MAX_EXTENSION_MS) {
throw new Error(
`La extensión de ${field} no puede exceder ${MAX_EXTENSION_DAYS} días`
);
}
}
}
// Validar orden de fechas con los nuevos valores
const finalDates: HackathonDates = {
registrationOpensAt: newDates.registrationOpensAt ?? dates.registrationOpensAt,
registrationClosesAt: newDates.registrationClosesAt ?? dates.registrationClosesAt,
startsAt: newDates.startsAt ?? dates.startsAt,
endsAt: newDates.endsAt ?? dates.endsAt,
submissionDeadline: newDates.submissionDeadline ?? dates.submissionDeadline,
judgingStartsAt: newDates.judgingStartsAt ?? dates.judgingStartsAt,
judgingEndsAt: newDates.judgingEndsAt ?? dates.judgingEndsAt,
};
validateHackathonDates(finalDates);
}
/**
* Schema Zod para validación de fechas en formularios
*/
export const hackathonDatesSchema = z.object({
registrationOpensAt: z.coerce.date(),
registrationClosesAt: z.coerce.date(),
startsAt: z.coerce.date(),
endsAt: z.coerce.date(),
submissionDeadline: z.coerce.date(),
judgingStartsAt: z.coerce.date(),
judgingEndsAt: z.coerce.date(),
}).refine(
(data) => {
try {
validateHackathonDates(data);
return true;
} catch {
return false;
}
},
{
message: 'Las fechas deben seguir el orden correcto',
}
);
Uso en Server Actions
// modules/hackathons/actions.ts
import { validateHackathonDates, validateDateExtension } from '@/core/validations';
export async function createHackathon(formData: FormData) {
// ... validación RBAC ...
const dates = {
registrationOpensAt: new Date(formData.get('registrationOpensAt')),
// ... otras fechas
};
// Validar orden de fechas
validateHackathonDates(dates);
// ... crear hackathon ...
}
export async function extendHackathonDates(
hackathonId: string,
newDates: Partial<HackathonDates>
) {
// ... validación RBAC ...
const hackathon = await db.hackathon.findUnique({
where: { id: hackathonId },
});
if (!hackathon) throw new Error('Hackathon no encontrado');
// Validar extensión
validateDateExtension(hackathon.status, hackathon, newDates);
// ... actualizar fechas ...
}
📦 Patrón Modular
Estructura de un Módulo
Cada dominio sigue el mismo patrón:
modules/[domain]/
├── queries.ts # Read operations (DB queries)
├── actions.ts # Write operations (Server Actions)
├── types.ts # TypeScript types
└── validations.ts # Zod schemas
Ejemplo Completo: Users Module
queries.ts
import { db } from '@/core/db';
import { Role } from '@prisma/client';
export async function getUserByClerkId(userId: string) {
return await db.profile.findUnique({
where: { userId }
});
}
export async function getProfileById(id: string) {
return await db.profile.findUnique({
where: { id }
});
}
export async function listProfiles(filters?: {
role?: Role;
limit?: number;
}) {
return await db.profile.findMany({
where: filters?.role ? { role: filters.role } : undefined,
take: filters?.limit || 50,
orderBy: { createdAt: 'desc' }
});
}
actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { createProfileSchema } from './validations';
import { db } from '@/core/db';
import { captureError } from '@/core/errors';
import { requireRole } from '@/core/rbac';
export async function createProfile(formData: FormData) {
try {
const rawData = {
userId: formData.get('userId'),
name: formData.get('name'),
email: formData.get('email'),
role: formData.get('role'),
};
const validated = createProfileSchema.parse(rawData);
const profile = await db.profile.create({
data: validated
});
revalidatePath('/dashboard');
return { success: true, profile };
} catch (error) {
captureError(error, { context: 'createProfile' });
return { success: false, error: 'Failed to create profile' };
}
}
export async function updateUserRole(profileId: string, newRole: Role) {
try {
const currentUser = await getCurrentUser();
requireRole(currentUser, ['ADMIN', 'ORGANIZER']);
if (newRole === 'ADMIN' && currentUser.profile.role !== 'ADMIN') {
throw new Error('Only admins can assign ADMIN role');
}
await db.profile.update({
where: { id: profileId },
data: { role: newRole }
});
revalidatePath('/admin/users');
return { success: true };
} catch (error) {
captureError(error, { context: 'updateUserRole' });
return { success: false, error: error.message };
}
}
validations.ts
import { z } from 'zod';
import { Role } from '@prisma/client';
export const createProfileSchema = z.object({
userId: z.string().min(1),
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email'),
bio: z.string().max(500).optional(),
techStack: z.array(z.string()).max(10).optional(),
role: z.nativeEnum(Role)
});
export const updateProfileSchema = createProfileSchema.partial();
types.ts
import { Profile, HackathonParticipation } from '@prisma/client';
export type ProfileWithParticipations = Profile & {
participations: HackathonParticipation[];
};
export type CreateProfileInput = {
userId: string;
name: string;
email: string;
bio?: string;
techStack?: string[];
role: Role;
};
🔄 Flujos de Datos
1. Autenticación y Onboarding
1. Usuario → Clerk (registro/inicio de sesión)
2. Redirigir a /onboarding
3. Usuario completa perfil + selecciona rol
4. Server Action: createProfile()
- Validar con Zod
- Insertar en DB
- Revalidar cache
5. Redirigir a /dashboard (según rol)
2. Crear Hackathon
1. ORGANIZER → /hackathons/create
2. Llenar formulario (info, fechas, criterios)
3. Server Action: createHackathon()
- requireRole(['ORGANIZER', 'ADMIN'])
- Validar datos
- Transaction:
* INSERT hackathon
* INSERT criteria[]
- revalidatePath('/hackathons')
4. Redirigir a /hackathons/[slug]
3. Evaluación de Proyectos
1. ORGANIZER asigna jueces (manual)
2. JUDGE ve submissions en el panel
3. JUDGE abre formulario de scoring
4. Por cada criterio, asigna puntaje + comentario
5. Server Action: submitScores()
- requireRole(['JUDGE'])
- Validar assignment
- UPSERT scores
- Recalcular leaderboard
6. Leaderboard actualizado
🚀 Roadmap por Fases
Fase 0: Infraestructura (1-2 semanas)
Configuración inicial:
- Crear proyecto Next.js 16
- Configurar TypeScript estricto
- Configurar Tailwind CSS 4
- Configurar autenticación con Clerk
- Configurar Prisma + Supabase
- Configurar Sentry
Core Layer:
-
src/core/db.ts- Prisma client -
src/core/rbac.ts- Sistema de roles -
src/core/errors.ts- Error handling -
src/core/auth.ts- Auth helpers
Módulo Users:
- Schema Prisma: Profile
- queries.ts (getUserByClerkId, etc.)
- actions.ts (createProfile, updateUserRole)
- validations.ts (Zod schemas)
- types.ts (TypeScript interfaces)
UI Básica:
- Layout principal
- Página de onboarding
- Panel (redirección por rol)
- Panel de admin (básico)
Testing:
- Configurar Vitest
- Tests para validations
- Tests para queries
- Tests para actions
Fase 1: Hackathons (1 semana)
Módulo Hackathons:
- Schema: Hackathon, Criterion, HackathonParticipation
- CRUD completo
- Sistema de estados (DRAFT → FINISHED)
- Gestión de criterios
UI:
- Lista pública de hackathons
- Detalle de hackathon
- Formulario crear/editar
- Panel de organizador
Testing:
- Tests para módulo completo
Fase 2: Equipos y Evaluación (2 semanas)
Módulos:
- Teams: Códigos de invitación, gestión de miembros
- Submissions: CRUD de proyectos
- Evaluation: Asignación de jueces, scoring
Sistema de Scoring:
- Scoring ponderado por criterio
- Cálculo de promedios
- Leaderboard en tiempo real
UI:
- Crear/unirse a equipo
- Formulario de submission
- Panel de juez
- Interfaz de scoring
- Leaderboard público
Fase 3: Sponsors (1 semana)
Módulos:
- Organizations
- Sponsorships (con tiers)
- Challenges
UI:
- Panel de sponsor
- Crear challenge
- Shortlist de proyectos
🔧 Consideraciones Técnicas
Performance
- Server Components por defecto: Solo usar Client Components cuando sea necesario
- Database indexing: Índices en campos de búsqueda frecuente
- Caching: Aprovechar cache automático de Next.js 16
Security
- RBAC en todas las Server Actions
- Validación con Zod en todos los inputs
- Verificar ownership antes de editar recursos
Error Handling
// src/core/errors.ts
import * as Sentry from '@sentry/nextjs';
export function captureError(error: unknown, context?: Record<string, any>) {
console.error('[Error]', error, context);
Sentry.captureException(error, { extra: context });
}
Testing Strategy
- Unit tests: Validaciones, utilities
- Integration tests: Queries, actions con DB mock
- E2E tests: Flujos críticos (opcional con Playwright)
📚 Convenciones
Naming
// Components: PascalCase
UserButton.tsx
// Files: kebab-case
user-profile.tsx
// Functions: camelCase
getUserProfile()
// Constants: SCREAMING_SNAKE_CASE
MAX_TEAM_SIZE
Server Actions Pattern
'use server';
export async function myAction(formData: FormData) {
try {
// 1. Auth check
const user = await getCurrentUser();
requireRole(user, ['ADMIN']);
// 2. Validate input
const validated = schema.parse(data);
// 3. Business logic
const result = await db.model.create({ data: validated });
// 4. Revalidate cache
revalidatePath('/path');
// 5. Return result
return { success: true, data: result };
} catch (error) {
captureError(error, { context: 'myAction' });
return { success: false, error: error.message };
}
}
Client Components Pattern (con Actualizaciones Optimistas)
IMPORTANTE: Siempre usar actualizaciones optimistas en lugar de window.location.reload().
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { myServerAction } from '@/modules/my-module/actions';
export default function MyComponent({ initialData }: { initialData: Data[] }) {
const router = useRouter();
const [data, setData] = useState<Data[]>(initialData);
// Sincronizar con props cuando cambien (después de router.refresh())
useEffect(() => {
setData(initialData);
}, [initialData]);
async function handleUpdate(id: string, newValue: string) {
// Guardar valor anterior para rollback
const previousItem = data.find((item) => item.id === id);
const previousValue = previousItem?.value;
// ACTUALIZACIÓN OPTIMISTA: Actualizar estado local inmediatamente
setData((prev) =>
prev.map((item) => (item.id === id ? { ...item, value: newValue } : item))
);
try {
const result = await myServerAction(id, newValue);
if (!result.success) {
// ROLLBACK: Revertir si falla
if (previousValue) {
setData((prev) =>
prev.map((item) => (item.id === id ? { ...item, value: previousValue } : item))
);
}
setError(result.error);
} else {
// SINCRONIZAR: Actualizar con datos del servidor sin recargar
router.refresh();
}
} catch (error) {
// ROLLBACK en caso de error
if (previousValue) {
setData((prev) =>
prev.map((item) => (item.id === id ? { ...item, value: previousValue } : item))
);
}
setError(error.message);
}
}
return <div>{/* Renderizar con data */}</div>;
}
Ver FRONTEND-MINIMALISTA.md para detalles completos.
🎨 Frontend: Minimalista y Funcional
⚡ IMPORTANTE: Enfoque del Frontend
Este proyecto se paga por la funcionalidad del CORE, no por el frontend elaborado.
Reglas del Frontend:
- ✅ HTML nativo + Tailwind básico
- ✅ Formularios HTML con Server Actions
- ✅ Sin librerías UI complejas (Radix, shadcn, etc.)
- ✅ Server Components por defecto
- ✅ Client Components solo cuando es estrictamente necesario
- ✅ Actualizaciones optimistas (patrón estándar - ver FRONTEND-MINIMALISTA.md)
Ejemplo: Formulario Minimalista
// app/hackathons/create/page.tsx - CORRECTO
export default function CreateHackathonPage() {
return (
<form action={createHackathon} className="max-w-2xl mx-auto p-6 space-y-4">
<h1 className="text-2xl font-bold">Crear Hackathon</h1>
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Nombre
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full px-3 py-2 border rounded"
/>
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded"
>
Crear
</button>
</form>
);
}
Por qué es correcto:
- HTML nativo (no componentes custom)
- Server Action directo
- Tailwind básico
- Sin estado del cliente
- Sin validaciones duplicadas
Lo Que NO Hacer
// ❌ INCORRECTO - Demasiado complejo
'use client';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { motion } from 'framer-motion';
// NO hacer esto - es innecesario
Distribución de Tiempo
80% - Core (RBAC, validaciones, DB, tests)
15% - Formularios funcionales
5% - Estilos básicos
Ver FRONTEND-MINIMALISTA.md para guías completas.
Última Actualización: 30 de diciembre, 2025
Versión: 3.1 - Core-Focused
Estado: 📋 Planificación completa - Enfoque en CORE funcional