Saltar al contenido principal

📐 Arquitectura PuntoHack MVP - Diseño Planeado

📋 Índice

  1. Visión General
  2. Stack Tecnológico
  3. Arquitectura del Sistema
  4. Modelo de Datos
  5. Sistema RBAC
  6. Patrón Modular
  7. Flujos de Datos
  8. 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íaVersiónPropósito
Prisma7.xORM para PostgreSQL
SupabaseÚltimaBase de datos PostgreSQL + Realtime (futuro)
Zod3.xValidación de esquemas (CRÍTICO)
TypeScript5.xSeguridad de tipos
VitestÚltimaFramework de testing

🔐 Authentication & Monitoring

TecnologíaVersiónPropósito
ClerkÚltimaAutenticación
SentryÚltimaMonitoreo de errores

📱 Frontend (Profesional con Next.js)

TecnologíaVersiónPropósito
Next.js16.xFramework (App Router, Server Components)
TurbopackIncluidoBundler de Next.js (desarrollo rápido)
React19.xUI
Tailwind CSS4.xStyling 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ónADMINORGANIZERJUDGESPONSORPARTICIPANT
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

  1. Registrarse → Crear perfil
  2. Navegar hackathons (estado REGISTRATION)
  3. Registrarse en hackathon (solo role PARTICIPANT) → Crear HackathonParticipation
  4. Crear equipo O unirse con código de invitación (crear/unirse permitido solo hasta submissionDeadline)
  5. Trabajar en proyecto (fuera de la app)
  6. Enviar submission (repoUrl, demoUrl, elegir challenge opcional)
  7. Editar submission hasta submissionDeadline (luego solo lectura; segundo intento redirige/avisa que ya existe submission del equipo)
  8. Ver leaderboard cuando hackathon esté FINISHED
  9. 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

  1. Es asignado a UN hackathon por ORGANIZER → Crear HackathonJudge
  2. Espera a que hackathon pase a estado JUDGING (antes de esto no ve lista de submissions)
  3. Ve lista de submissions del hackathon asignado solo en JUDGING
  4. 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
  5. Restricción: NO puede ver scores de otros jueces durante JUDGING; puede verlos en FINISHED
  6. Restricción: NO puede formar equipo en el mismo hackathon que juzga
  7. Asignación bloqueada: Si ya participa en el hackathon (participation/team), no se puede crear HackathonJudge
  8. Si el juez es removido, sus Score quedan en DB pero no cuentan para el cálculo (se consideran solo jueces con HackathonJudge vigente)

ORGANIZER

  1. Crear hackathon (estado DRAFT)
  2. Configurar:
    • Fechas: registrationOpensAt, registrationClosesAt, startsAt, endsAt, judgingStartsAt, judgingEndsAt
    • maxTeamSize, minTeamSize
    • Crear criterios: name, description, weight (1-10), maxScore (default 10)
  3. Asignar jueces: elegir profiles con role JUDGE → Crear HackathonJudge
  4. Publicar manual: cambiar estado DRAFT → REGISTRATION solo si now >= registrationOpensAt y orden de fechas válido
  5. Monitorear:
    • Ver participaciones
    • Ver equipos formados
    • Expulsar usuarios de equipos si necesario
    • Eliminar usuarios problemáticos
  6. Automático: Sistema cambia estados según fechas (cron) solo para hackathons ya publicados (REGISTRATION en adelante):
  • registrationClosesAt → RUNNING
  • judgingStartsAt → JUDGING
  • judgingEndsAt → FINISHED
  • submissionDeadline no cambia estado: solo bloquea edición de submissions
  1. Extensión: Puede alargar solo fase SIGUIENTE, máximo +7 días, nunca acortar, sin romper orden de fechas
  2. Ver leaderboard final con scores ponderados
  1. Crear/pertenecer a Organization → OrganizationMember
  2. Crear Sponsorship para un hackathon:
    • Elegir tier (DIAMOND, PLATINUM, GOLD, SILVER, BRONZE, PARTNER)
    • Definir benefits (JSON con logo placement, booth, etc.)
  3. Crear Challenge:
    • title, description, tags
    • prizeDetails (ej: "$5000 para mejor proyecto usando nuestra API")
  4. Esperar a que equipos envíen submissions eligiendo su challenge
  5. Ver submissions que eligieron su challenge (submission.challengeId)
  6. Shortlist proyectos favoritos:
    • Marcar submissions de interés → Crear ShortlistItem
    • Agregar notas privadas
  7. Restricción: NO puede evaluar (no asigna scores)
  8. 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 HackathonJudge vigente)

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

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 (REGISTRATION en 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

  1. Server Components por defecto: Solo usar Client Components cuando sea necesario
  2. Database indexing: Índices en campos de búsqueda frecuente
  3. Caching: Aprovechar cache automático de Next.js 16

Security

  1. RBAC en todas las Server Actions
  2. Validación con Zod en todos los inputs
  3. 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:

  1. ✅ HTML nativo + Tailwind básico
  2. ✅ Formularios HTML con Server Actions
  3. ✅ Sin librerías UI complejas (Radix, shadcn, etc.)
  4. ✅ Server Components por defecto
  5. ✅ Client Components solo cuando es estrictamente necesario
  6. 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