🏆 MÓDULO 5: AWARDS & POST-HACKATHON SYSTEM
Fecha: 7 de enero de 2026
Estado: Planificación
Complejidad: Alta 🔴
Prioridad: Crítica
Duración Estimada: 3-4 semanas
📋 ÍNDICE
- Contexto y Motivación
- Arquitectura de Base de Datos
- Flujos de Usuario
- Navegación y Sidebar
- Fases de Implementación
- APIs y Endpoints
- Componentes UI
- Notificaciones
- Testing
- Despliegue
🎯 CONTEXTO Y MOTIVACIÓN
Problema Actual
Al finalizar un hackathon, existe un vacío crítico entre:
- Publicación del leaderboard
- Entrega de premios
- Contacto sponsor-ganadores
- Ofertas laborales
Actualmente no hay sistema para gestionar esta fase post-hackathon.
Objetivo del Módulo
Crear un sistema híbrido que:
- ✅ Gestiona premios monetarios (tracking offline)
- ✅ Facilita contacto sponsor-participantes
- ✅ Administra ofertas laborales individuales/grupales
- ✅ Provee trazabilidad sin responsabilidad legal de transacciones
Valor Agregado
- Para Participantes: Visibilidad de premios, tracking de ofertas
- Para Sponsors: Canal directo con talento, ROI medible
- Para Organizers: Oversight de entregas, métricas de éxito
- Para la Plataforma: Diferenciador competitivo enorme
🗄️ ARQUITECTURA DE BASE DE DATOS
FASE 0: ACTUALIZACIÓN DE MODELOS EXISTENTES
A) Modelo Hackathon - Agregar Premios
model Hackathon {
// ... campos existentes ...
// NUEVOS CAMPOS: Sistema de Premios
hasPrizes Boolean @default(false) // Indica si hay premios
firstPlacePrize Decimal? // Premio 1er lugar
secondPlacePrize Decimal? // Premio 2do lugar
thirdPlacePrize Decimal? // Premio 3er lugar
prizeCurrency String? @default("USD")
prizeDescription String? @db.Text // Descripción adicional de premios
// NUEVOS CAMPOS: Configuración Post-Hackathon
awardsAnnouncedAt DateTime? // Cuándo se anunciaron los premios
awardsDeadline DateTime? // Deadline para contactar ganadores
// Nuevas relaciones
awards Award[]
// ... resto de campos existentes ...
}
Migración requerida:
prisma migrate dev --name add-prizes-to-hackathon
B) Modelo Challenge - Agregar Premio
model Challenge {
// ... campos existentes ...
// Campo EXISTENTE (ya está en el schema actual):
// prizeDetails String? @db.Text
// NUEVOS CAMPOS: Sistema de Premios
hasPrize Boolean @default(false) // Indica si hay premio
prizeAmount Decimal? // Monto del premio
prizeCurrency String? @default("USD")
prizeDescription String? @db.Text // Descripción del premio
// Nuevas relaciones
awards Award[]
// ... resto de campos existentes ...
}
Migración requerida:
prisma migrate dev --name add-prize-to-challenge
C) Modelo Profile - Agregar Info de Contacto
model Profile {
// ... campos existentes ...
// NUEVOS CAMPOS: Información de Contacto
country String? // País de residencia (ISO code ej: "MX")
phoneNumber String? // Formato: +52 1234567890
phoneCountryCode String? // Código del país (ej: "+52")
githubUsername String? // Username de GitHub
linkedinUrl String? // URL completa de LinkedIn
portfolioUrl String? // Portafolio personal
// NUEVOS CAMPOS: Preferencias de Contacto
allowPhoneContact Boolean @default(false) // Acepta contacto por teléfono
allowEmailContact Boolean @default(true) // Acepta contacto por email
// Nuevas relaciones
jobApplications JobApplication[]
// Nota: `DirectContact` se modela a nivel Submission/Team (no por Profile) en MVP.
// ... resto de campos existentes ...
@@index([country])
@@index([githubUsername])
}
Migración requerida:
prisma migrate dev --name add-contact-info-to-profile
NUEVOS MODELOS
1. Award (Premios)
enum AwardCategory {
GENERAL_PODIUM // Top 1, 2, 3 del hackathon
CHALLENGE_WINNER // Ganador de challenge específico
SPECIAL_MENTION // Mención honorífica (futuro)
}
enum AwardType {
MONETARY // Dinero en efectivo
JOB_OFFER // Oferta laboral
RECOGNITION // Solo reconocimiento
}
enum AwardStatus {
ANNOUNCED // Anunciado públicamente
SPONSOR_NOTIFIED // Sponsor notificado (debe contactar)
TEAM_NOTIFIED // Equipo notificado
SPONSOR_CONTACTED // Sponsor contactó al equipo
IN_PROGRESS // En proceso de entrega
COMPLETED // Completado exitosamente
ORGANIZER_FOLLOWUP // Organizer debe intervenir (sponsor sin contactar)
}
model Award {
id String @id @default(cuid())
hackathonId String
submissionId String // Equipo ganador
// Categoría y tipo
category AwardCategory
type AwardType
// Quién otorga el premio
organizationId String? // Si es de sponsor
fromOrganizer Boolean @default(false) // Si es del organizer
// Challenge específico (si aplica)
challengeId String?
// Posición en podium (si aplica)
position Int? // 1, 2, 3
// Detalles del premio
title String // "1st Place" o "Best AI Solution"
description String @db.Text
// Valor monetario (si aplica)
monetaryValue Decimal?
currency String? @default("USD")
// Estado y tracking
status AwardStatus @default(ANNOUNCED)
announcedAt DateTime @default(now())
sponsorContactedAt DateTime? // Cuándo el sponsor contactó
completedAt DateTime? // Cuándo se completó
// Control de timeouts
sponsorContactDeadline DateTime? // Deadline para que sponsor contacte
sponsorDidContact Boolean @default(false) // Flag de contacto
organizerNotifiedAt DateTime? // Si organizer fue notificado
// Notas privadas
sponsorNotes String? @db.Text // Notas del sponsor
teamNotes String? @db.Text // Notas del equipo
organizerNotes String? @db.Text // Notas del organizer
// Relaciones
hackathon Hackathon @relation(fields: [hackathonId], references: [id], onDelete: Cascade)
submission Submission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
challenge Challenge? @relation(fields: [challengeId], references: [id], onDelete: SetNull)
// Si es oferta laboral
jobOpportunity JobOpportunity?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([hackathonId])
@@index([submissionId])
@@index([category])
@@index([status])
@@index([organizationId])
@@index([challengeId])
@@index([hackathonId, status]) // Para queries de awards por hackathon
@@index([submissionId, status]) // Para awards de un equipo
}
2. JobOpportunity (Ofertas Laborales)
enum JobLocationType {
REMOTE // 100% remoto
ONSITE // 100% presencial
HYBRID // Híbrido
}
enum JobOfferScope {
INDIVIDUAL // Oferta para miembros individuales
TEAM // Oferta para TODO el equipo
}
model JobOpportunity {
id String @id @default(cuid())
awardId String @unique
organizationId String
// Alcance de la oferta
scope JobOfferScope @default(INDIVIDUAL)
// Detalles del puesto
title String
description String @db.Text
responsibilities String[]
requirements String[]
niceToHave String[]
// Modalidad y ubicación
locationType JobLocationType
locations String[] // ["San Francisco, USA", "Remote LATAM"]
timezone String? // "UTC-8 to UTC-5" (si aplica)
// Compensación
salaryMin Decimal?
salaryMax Decimal?
currency String @default("USD")
equity String? // "0.1% - 0.5%"
benefits String[] // ["Health Insurance", "Remote", "Visa"]
// Requisitos de ubicación
requiresRelocation Boolean @default(false)
relocationSupport Boolean @default(false)
visaSponsor Boolean @default(false)
// Restricciones geográficas
allowedCountries String[] // ["*"] = global, o ["US", "MX", "AR"]
excludedCountries String[] // [] = ninguno
// Aplicación
applicationUrl String? // URL externa (opcional)
contactEmail String
deadline DateTime? // No visible para participantes
internalDeadline DateTime? // Deadline interna del sponsor
// Estado
isActive Boolean @default(true)
filledAt DateTime?
positionsFilled Int @default(0) // Cuántas posiciones se llenaron
totalPositions Int @default(1) // Total de posiciones disponibles
// Aplicantes
applications JobApplication[]
// Relaciones
award Award @relation(fields: [awardId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([isActive])
@@index([scope])
@@index([awardId])
}
3. JobApplication (Aplicaciones Individuales)
enum JobApplicationStatus {
INTERESTED // Mostró interés inicial
APPLIED // Aplicó formalmente
UNDER_REVIEW // En revisión por sponsor
INTERVIEWING // En proceso de entrevistas
OFFER_EXTENDED // Oferta extendida
ACCEPTED // Oferta aceptada
DECLINED_BY_APPLICANT // Rechazada por aplicante
DECLINED_BY_SPONSOR // Rechazada por sponsor
}
model JobApplication {
id String @id @default(cuid())
jobOpportunityId String
profileId String // Miembro del equipo que aplica
submissionId String // Para contexto
// Información del aplicante
currentLocation String? // "México City, Mexico"
willingToRelocate Boolean @default(false)
availableFrom DateTime? // Disponibilidad
// Preferencias de contacto (override del perfil)
preferredContactMethod String? // "email" | "phone" | "both"
// Status
status JobApplicationStatus @default(INTERESTED)
// Documentos/Links
resumeUrl String?
coverLetter String? @db.Text
portfolioUrl String?
// Notas
applicantNotes String? @db.Text // Notas del aplicante
sponsorNotes String? @db.Text // Notas del sponsor
// Tracking de comunicación
appliedAt DateTime @default(now())
lastContactedAt DateTime?
sponsorViewedAt DateTime? // Cuándo el sponsor vio la app
interviewScheduledAt DateTime?
// Relaciones
jobOpportunity JobOpportunity @relation(fields: [jobOpportunityId], references: [id], onDelete: Cascade)
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
submission Submission @relation(fields: [submissionId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([jobOpportunityId, profileId]) // Un miembro solo puede aplicar una vez
@@index([jobOpportunityId])
@@index([profileId])
@@index([status])
@@index([submissionId])
}
4. DirectContact (Contacto Directo Sponsor → Equipo)
enum DirectContactReason {
INTERESTED_IN_PROJECT // Le gustó el proyecto
MENTORSHIP_OFFER // Ofrecer mentoría
INVESTMENT_INQUIRY // Posible inversión
COLLABORATION // Propuesta de colaboración
JOB_OFFER // Oferta laboral (no formal)
OTHER
}
enum DirectContactStatus {
INITIATED // Sponsor inició contacto
TEAM_NOTIFIED // Equipo notificado (MVP: in-app)
MEMBERS_RESPONDING // Miembros respondiendo individualmente
IN_CONVERSATION // Conversación activa
CLOSED_SUCCESS // Cerrado exitosamente
CLOSED_NO_RESPONSE // Cerrado por falta de respuesta
DECLINED_BY_TEAM // Equipo declinó
}
model DirectContact {
id String @id @default(cuid())
organizationId String
submissionId String
hackathonId String // Para contexto
// Razón del contacto
reason DirectContactReason
subject String // Asunto del mensaje
message String @db.Text
// Estado
status DirectContactStatus @default(INITIATED)
// Respuestas individuales de miembros
memberResponses Json? // { profileId: { responded: bool, interestedAt: DateTime } }
respondedCount Int @default(0) // Cuántos respondieron
interestedCount Int @default(0) // Cuántos mostraron interés
// Tracking de tiempos
initiatedAt DateTime @default(now())
teamNotifiedAt DateTime? // Cuándo se notificó al equipo
firstResponseAt DateTime? // Primera respuesta de algún miembro
lastActivityAt DateTime? // Última actividad
closedAt DateTime?
// Timeout automático
expiresAt DateTime? // Después de 1 mes sin respuesta
// Relaciones
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
submission Submission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
hackathon Hackathon @relation(fields: [hackathonId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([submissionId])
@@index([hackathonId])
@@index([status])
@@index([expiresAt]) // Para limpiar contactos expirados
}
5. Actualizar Submission
model Submission {
// ... campos existentes ...
// Nuevas relaciones
awards Award[]
jobApplications JobApplication[]
directContacts DirectContact[]
// ... resto de campos existentes ...
}
6. Actualizar Organization
model Organization {
// ... campos existentes ...
// Nuevas relaciones
awards Award[]
jobOpportunities JobOpportunity[]
directContacts DirectContact[]
// ... resto de campos existentes ...
}
🔄 FLUJOS DE USUARIO
FLUJO 1: Creación Automática de Awards (Sistema)
Trigger: Cuando Hackathon.status cambia a FINISHED
Lógica:
async function createAwardsAutomatically(hackathonId: string) {
// Nota: este snippet es pseudocódigo. En el codebase existen:
// - getHackathonById(hackathonId) en src/modules/hackathons/queries.ts
// - calculateLeaderboardOptimized(hackathonId) en src/modules/evaluation/queries-optimized.ts
const hackathon = await getHackathonById(hackathonId);
const leaderboard = await calculateLeaderboardOptimized(hackathonId);
const awards: Award[] = [];
// Premios generales (podium)
if (hackathon.hasPrizes) {
if (hackathon.firstPlacePrize && leaderboard[0]) {
awards.push({
category: 'GENERAL_PODIUM',
type: 'MONETARY',
position: 1,
submissionId: leaderboard[0].submissionId,
monetaryValue: hackathon.firstPlacePrize,
title: '1st Place',
fromOrganizer: true,
});
}
// Repeat for 2nd and 3rd...
}
// Premios de challenges:
// Seleccionar el winner por Challenge usando ChallengeEvaluation.sponsorApprovalStatus = APPROVED
// (ver sección de implementación/queries más adelante en este documento).
// Crear todos los awards
await db.award.createMany({ data: awards });
// Notificar a todos (MVP: in-app usando src/modules/notifications/actions.ts)
}
FLUJO 2: Sponsor Crea Job Offer
1. Sponsor ve sus awards pendientes
/sponsor/awards-to-deliver
2. Click en Award específico
/sponsor/awards/[awardId]
3. Tab "Create Job Offer"
4. Form:
┌─────────────────────────────────┐
│ Job Opportunity │
│ │
│ Scope: │
│ (*) Individual Members │
│ ( ) Entire Team │
│ │
│ Title: [ ] │
│ Type: [Remote ▼] │
│ Locations: [+ Add] │
│ Salary: $[ ] - $[ ] │
│ │
│ Requirements: │
│ [+ Add Requirement] │
│ │
│ [Cancel] [Create & Publish] │
└─────────────────────────────────┘
5. Sistema:
- Crea JobOpportunity
- Asocia con Award
- Notifica a TODOS los miembros del equipo
- Award.type = 'JOB_OFFER'
FLUJO 3: Participante Ve y Aplica a Job Offer
1. Participante recibe notificación
"🎉 New Job Opportunity from Google!"
2. Va a /participant/job-offers
3. Ve lista de ofertas disponibles
[Card per job offer]
4. Click en oferta interesante
/participant/job-offers/[jobId]
5. Vista detallada:
├─ Description
├─ Requirements
├─ Benefits
├─ Location & Type
└─ [Apply Now] button
6. Si es TEAM scope:
Alert: "This offer is for the entire team"
7. Click "Apply Now" → Modal:
┌──────────────────────────────────┐
│ Apply to: Senior Developer │
│ │
│ Current Location: │
│ [Mexico City, Mexico ] │
│ │
│ Willing to relocate? │
│ [✓] Yes [ ] No │
│ │
│ Available from: │
│ [Feb 15, 2026 ] │
│ │
│ Resume (optional): │
│ [Upload PDF] │
│ │
│ Cover Letter (optional): │
│ [Text area...] │
│ │
│ Contact via: │
│ [✓] Email │
│ [ ] Phone (if provided) │
│ │
│ [Cancel] [Submit Application] │
└──────────────────────────────────┘
8. Sistema:
- Crea JobApplication
- Notifica a Sponsor
- Notificación in-app al participante
FLUJO 4: Direct Contact (Sponsor → Equipo sin premio)
1. Sponsor explora submissions
/sponsor/challenges/[id]/submissions
2. Ve submission interesante (no ganó nada)
3. Click "Contact Team" button
4. Modal:
┌─────────────────────────────────┐
│ Contact Team: Dev Ninjas │
│ 5 members │
│ │
│ Reason: │
│ (*) Interested in project │
│ ( ) Mentorship offer │
│ ( ) Investment inquiry │
│ ( ) Job opportunity │
│ ( ) Other │
│ │
│ Subject: │
│ [ ] │
│ │
│ Message: │
│ [Text area - no limit] │
│ │
│ Note: All team members will │
│ receive this message and can │
│ respond individually. │
│ │
│ [Cancel] [Send Contact] │
└─────────────────────────────────┘
5. Sistema:
- Crea DirectContact
- Crea notificaciones in-app a TODOS los miembros
- Cada miembro puede:
* Marcar como "Interested"
* Decline (individual)
* Responder por fuera (ej: mailto)
6. Sponsor ve tracking:
/sponsor/contacts/[directContactId]
- 3/5 members responded
- 2/5 interested
- Last activity: 2 days ago
FLUJO 5: Organizer Oversight
1. Organizer ve hackathon finalizado
/organizer/hackathons/[slug]
2. Tab "Awards & Prizes"
/organizer/hackathons/[slug]/awards
3. Dashboard muestra:
┌─────────────────────────────────┐
│ Awards Overview │
│ │
│ Total Awards: 8 │
│ ✓ Completed: 3 │
│ ⏳ In Progress: 4 │
│ ⚠️ Need Follow-up: 1 │
│ │
│ [Export Report] │
└─────────────────────────────────┘
4. Tabla de awards:
| Award | Team | Sponsor | Status | Actions |
|-------|------|---------|--------|---------|
| 1st Place | TeamA | - | ✓ Completed | View |
| Best AI | TeamB | Google | ⚠️ No Contact | Contact |
5. Si "Need Follow-up":
- Organizer puede enviar recordatorio (MVP: in-app) o contactar por fuera
- Puede marcar award como "handled"
- Notas internas
🧭 NAVEGACIÓN Y SIDEBAR
Estructura del Sidebar (Dinámico por Rol)
Estado actual del codebase (real):
- Ya existen layouts por rol:
src/app/participant/layout.tsx,src/app/sponsor/layout.tsx,src/app/organizer/layout.tsx,src/app/judge/layout.tsx. - Hoy esos layouts son wrappers simples (sin sidebar).
- El rol real vive en DB (
Profile.role) segúnenum Role { PARTICIPANT, JUDGE, ORGANIZER, ADMIN, SPONSOR }.
Objetivo (nuevo):
- Implementar un sidebar compartido y dinámico sin depender de Clerk publicMetadata.
- La fuente de verdad del rol debe ser el
Profile(obtenible congetUserByClerkId(userId)ensrc/modules/users/queries.ts).
// Configuración del sidebar por rol
const sidebarConfig = {
ADMIN: [
{ icon: HomeIcon, label: 'Dashboard', href: '/admin' },
{ icon: TrophyIcon, label: 'Hackathons', href: '/admin/hackathons' },
{ icon: UsersIcon, label: 'Users', href: '/admin/users' },
{ icon: ChartBarIcon, label: 'Comparisons', href: '/admin/comparisons' },
{ icon: BellIcon, label: 'Notifications', href: '/notifications' },
],
ORGANIZER: [
{ icon: HomeIcon, label: 'Dashboard', href: '/organizer' },
{ icon: TrophyIcon, label: 'My Hackathons', href: '/organizer/hackathons' },
{ icon: PlusIcon, label: 'Create Hackathon', href: '/organizer/hackathons/create' },
{ icon: GiftIcon, label: 'Awards Management', href: '/organizer/awards' }, // NUEVO (ruta a crear)
{ icon: BellIcon, label: 'Notifications', href: '/notifications' },
],
PARTICIPANT: [
{ icon: HomeIcon, label: 'Dashboard', href: '/participant' },
{ icon: TrophyIcon, label: 'Hackathons', href: '/participant/hackathons' }, // EXISTE
{ icon: GiftIcon, label: 'My Awards', href: '/participant/awards', badge: awardsCount }, // NUEVO (ruta a crear)
{ icon: BriefcaseIcon, label: 'Job Offers', href: '/participant/job-offers', badge: offersCount }, // NUEVO (ruta a crear)
{ icon: ChatIcon, label: 'Contacts', href: '/participant/contacts', badge: contactsCount }, // NUEVO (ruta a crear)
{ icon: BellIcon, label: 'Notifications', href: '/notifications' },
],
SPONSOR: [
{ icon: HomeIcon, label: 'Dashboard', href: '/sponsor' },
{ icon: BuildingIcon, label: 'Organizations', href: '/sponsor/organizations' },
{ icon: HandIcon, label: 'Sponsorships', href: '/sponsor/sponsorships' },
{ icon: FlagIcon, label: 'Challenges', href: '/sponsor/challenges' },
{ icon: ClipboardCheckIcon, label: 'Evaluations', href: '/sponsor/evaluations' }, // EXISTE
{ icon: GiftIcon, label: 'Awards to Deliver', href: '/sponsor/awards', badge: pendingCount }, // NUEVO (ruta a crear)
{ icon: BriefcaseIcon, label: 'Job Offers', href: '/sponsor/job-offers' }, // NUEVO (ruta a crear)
{ icon: UserGroupIcon, label: 'Direct Contacts', href: '/sponsor/contacts', badge: activeContacts }, // NUEVO (ruta a crear)
{ icon: ListIcon, label: 'Shortlist', href: '/sponsor/shortlist' },
{ icon: BellIcon, label: 'Notifications', href: '/notifications' },
],
JUDGE: [
{ icon: HomeIcon, label: 'Dashboard', href: '/judge' },
{ icon: TrophyIcon, label: 'My Hackathons', href: '/judge/hackathons' }, // EXISTE
{ icon: BellIcon, label: 'Notifications', href: '/notifications' },
],
};
Implementación del Componente:
// src/components/layout/app-sidebar.tsx
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { Role } from '@prisma/client';
/**
* Nota (alineado al codebase actual): el rol NO vive en Clerk publicMetadata.
* Se obtiene desde DB (`Profile.role`) en un Server Component/layout y se pasa
* como prop al sidebar.
*/
export function AppSidebar({ role }: { role: Role }) {
const pathname = usePathname();
const config = sidebarConfig[role] || [];
return (
<aside className="w-64 bg-white border-r border-gray-200 h-screen sticky top-0">
<div className="p-4">
<h1 className="text-xl font-bold">PuntoHack</h1>
<p className="text-sm text-gray-600">{role}</p>
</div>
<nav className="p-2">
{config.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`
flex items-center gap-3 px-3 py-2 rounded-lg
transition-colors duration-200
${isActive
? 'bg-blue-50 text-blue-600'
: 'text-gray-700 hover:bg-gray-100'
}
`}
>
<item.icon className="w-5 h-5" />
<span>{item.label}</span>
{item.badge && item.badge > 0 && (
<span className="ml-auto bg-red-500 text-white text-xs rounded-full px-2 py-0.5">
{item.badge}
</span>
)}
</Link>
);
})}
</nav>
</aside>
);
}
Dónde se conecta el rol (real):
- En el layout del rol (
src/app/*/layout.tsx) o un layout compartido, obteneruserIdconauth()y luegoProfilecongetUserByClerkId(userId). - Pasar
profile.roleaAppSidebar.
🚀 FASES DE IMPLEMENTACIÓN
📅 FASE 1: Database & Backend Foundation (Semana 1)
Objetivos:
- ✅ Actualizar modelos existentes (Hackathon, Challenge, Profile)
- ✅ Crear nuevos modelos (Award, JobOpportunity, JobApplication, DirectContact)
- ✅ Migraciones de base de datos
- ✅ Queries básicas
Tareas:
1.1 Actualizar Prisma Schema
- Agregar campos de premios a
Hackathon - Agregar campos de premio a
Challenge - Agregar info de contacto a
Profile - Crear enum types
- Crear modelo
Award - Crear modelo
JobOpportunity - Crear modelo
JobApplication - Crear modelo
DirectContact - Actualizar relaciones en modelos existentes
Archivo: prisma/schema.prisma
1.2 Ejecutar Migraciones
prisma migrate dev --name add-prizes-to-hackathon-and-challenge
prisma migrate dev --name add-contact-info-to-profile
prisma migrate dev --name create-awards-system
prisma generate
1.3 Crear Módulo Awards
Estructura de archivos:
src/modules/awards/
├── types.ts # TypeScript types
├── queries.ts # Database queries
├── actions.ts # Server actions
├── utils.ts # Helper functions
└── notifications.ts # Helpers para notificaciones (MVP: in-app)
Archivo: src/modules/awards/types.ts
import { Award, AwardCategory, AwardStatus, AwardType } from '@prisma/client';
import { Submission, Team, Profile, Organization, Challenge } from '@prisma/client';
export interface AwardWithRelations extends Award {
submission: Submission & {
team: Team & {
members: Array<{ profile: Profile }>;
};
};
organization?: Organization | null;
challenge?: Challenge | null;
jobOpportunity?: JobOpportunity | null;
}
export interface CreateAwardInput {
hackathonId: string;
submissionId: string;
category: AwardCategory;
type: AwardType;
position?: number;
challengeId?: string;
organizationId?: string;
fromOrganizer: boolean;
title: string;
description: string;
monetaryValue?: number;
currency?: string;
}
// ... más types
Archivo: src/modules/awards/queries.ts
import { db } from '@/core/db';
import type { AwardWithRelations } from './types';
/**
* Obtiene awards de un hackathon
*/
export async function getAwardsByHackathon(
hackathonId: string
): Promise<AwardWithRelations[]> {
return db.award.findMany({
where: { hackathonId },
include: {
submission: {
include: {
team: {
include: {
members: {
include: { profile: true },
},
},
},
},
},
organization: true,
challenge: true,
jobOpportunity: true,
},
orderBy: [
{ category: 'asc' },
{ position: 'asc' },
],
});
}
/**
* Obtiene awards de una submission (equipo)
*/
export async function getAwardsBySubmission(
submissionId: string
): Promise<AwardWithRelations[]> {
return db.award.findMany({
where: { submissionId },
include: {
organization: true,
challenge: true,
jobOpportunity: {
include: {
applications: {
where: { submissionId },
},
},
},
},
orderBy: { createdAt: 'desc' },
});
}
/**
* Obtiene awards pendientes para un sponsor
*/
export async function getPendingAwardsBySponsor(
organizationId: string
): Promise<AwardWithRelations[]> {
return db.award.findMany({
where: {
organizationId,
status: {
in: ['SPONSOR_NOTIFIED', 'TEAM_NOTIFIED'],
},
},
include: {
submission: {
include: {
team: {
include: {
members: {
include: { profile: true },
},
},
},
},
},
challenge: true,
},
orderBy: { announcedAt: 'desc' },
});
}
// ... más queries
Archivo: src/modules/awards/actions.ts
'use server';
import { db } from '@/core/db';
import { revalidatePath } from 'next/cache';
import { ChallengeApprovalStatus } from '@prisma/client';
import { calculateLeaderboardOptimized } from '@/modules/evaluation/queries-optimized';
import type { CreateAwardInput } from './types';
import { notifyAwardWon } from './notifications';
/**
* Crea awards automáticamente cuando un hackathon termina
*/
export async function createAwardsAutomatically(hackathonId: string) {
const hackathon = await db.hackathon.findUnique({
where: { id: hackathonId },
include: {
challenges: {
include: {
sponsorship: {
include: { organization: true },
},
},
},
},
});
if (!hackathon) throw new Error('Hackathon not found');
// Calcular leaderboard final (usar versión optimizada existente)
const leaderboard = await calculateLeaderboardOptimized(hackathonId);
const awards: CreateAwardInput[] = [];
// Premios del podium (si están definidos)
if (hackathon.hasPrizes) {
const prizes = [
{ position: 1, amount: hackathon.firstPlacePrize },
{ position: 2, amount: hackathon.secondPlacePrize },
{ position: 3, amount: hackathon.thirdPlacePrize },
];
prizes.forEach(({ position, amount }) => {
if (amount && leaderboard[position - 1]) {
awards.push({
hackathonId,
submissionId: leaderboard[position - 1].submissionId,
category: 'GENERAL_PODIUM',
type: 'MONETARY',
position,
fromOrganizer: true,
title: `${position}${position === 1 ? 'st' : position === 2 ? 'nd' : 'rd'} Place`,
description: `${position}${position === 1 ? 'st' : position === 2 ? 'nd' : 'rd'} place in ${hackathon.name}`,
monetaryValue: amount,
currency: hackathon.prizeCurrency || 'USD',
});
}
});
}
// Premios de challenges
// Nota: en el schema actual `Challenge` solo tiene `prizeDetails` (texto).
// Para este módulo se agregan campos numéricos (`hasPrize`, `prizeAmount`, `prizeCurrency`, `prizeDescription`).
for (const challenge of hackathon.challenges) {
if (challenge.hasPrize && challenge.prizeAmount) {
// Obtener submission ganadora del challenge
const winnerSubmissionId = await getChallengeWinnerSubmissionId(challenge.id);
if (winnerSubmissionId) {
awards.push({
hackathonId,
submissionId: winnerSubmissionId,
category: 'CHALLENGE_WINNER',
type: 'MONETARY',
challengeId: challenge.id,
organizationId: challenge.sponsorship.organizationId,
fromOrganizer: false,
title: `Winner: ${challenge.title}`,
description: challenge.prizeDescription || challenge.prizeDetails || `Winner of challenge: ${challenge.title}`,
monetaryValue: challenge.prizeAmount,
currency: challenge.prizeCurrency || 'USD',
});
}
}
}
// Crear todos los awards
// Importante: Prisma `createMany` NO retorna los IDs creados.
// Como necesitamos `awardId` para notificar, creamos uno por uno en una transacción.
const createdAwards = await db.$transaction(
awards.map((award) =>
db.award.create({
data: {
...award,
status: 'ANNOUNCED',
announcedAt: new Date(),
sponsorContactDeadline: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 días
},
select: { id: true, submissionId: true, title: true },
})
)
);
// Notificar (in-app) a los equipos ganadores
// Nota: email es integración futura; el MVP no depende de `sendEmail`.
for (const award of createdAwards) {
const submission = await db.submission.findUnique({
where: { id: award.submissionId },
select: { teamId: true },
});
if (submission?.teamId) {
await notifyAwardWon({
teamId: submission.teamId,
hackathonId,
submissionId: award.submissionId,
awardId: award.id,
title: award.title,
});
}
}
// Revalidate paths
revalidatePath(`/hackathons/${hackathon.slug}`);
revalidatePath(`/organizer/hackathons/${hackathon.slug}`);
return createdAwards;
}
// Helper: Obtener submission ganadora de challenge
async function getChallengeWinnerSubmissionId(challengeId: string): Promise<string | null> {
// Estrategia (alineada a schema real):
// - Considerar solo evaluaciones con `sponsorApprovalStatus = APPROVED`
// - Calcular score por evaluación: 0.4*fulfillment + 0.3*technical + 0.2*adoption + 0.1*documentation
// - Promediar por submissionId
// - Ganador = submission con mayor promedio
const evaluations = await db.challengeEvaluation.findMany({
where: {
challengeId,
sponsorApprovalStatus: ChallengeApprovalStatus.APPROVED,
},
select: {
submissionId: true,
fulfillmentScore: true,
technicalScore: true,
adoptionScore: true,
documentationScore: true,
},
});
if (evaluations.length === 0) return null;
const totals = new Map<string, { sum: number; count: number }>();
for (const e of evaluations) {
const score = e.fulfillmentScore * 0.4 + e.technicalScore * 0.3 + e.adoptionScore * 0.2 + e.documentationScore * 0.1;
const current = totals.get(e.submissionId) ?? { sum: 0, count: 0 };
totals.set(e.submissionId, { sum: current.sum + score, count: current.count + 1 });
}
let bestSubmissionId: string | null = null;
let bestAvg = -Infinity;
for (const [submissionId, { sum, count }] of totals.entries()) {
const avg = sum / count;
if (avg > bestAvg) {
bestAvg = avg;
bestSubmissionId = submissionId;
}
}
return bestSubmissionId;
}
// ... más actions
1.4 Testing de Migraciones
# Test en desarrollo
npm run prisma:migrate
# Verificar schema
npm run prisma:studio
# Seed de datos de prueba
npm run prisma:seed
📅 FASE 2: Awards Creation & Notifications (Semana 1-2)
Objetivos:
- ✅ Sistema de creación automática de awards
- ✅ Notificaciones in-app (existente) + email (integración nueva)
- ✅ Testing de flujo completo
Tareas:
2.1 Notificaciones y Email (alineado al codebase actual)
Estado actual (real):
- Existe sistema de notificaciones in-app en
prisma/schema.prisma(Notification,NotificationType,NotificationStatus). - Existe módulo listo para usar:
src/modules/notifications/actions.ts(createNotification,notifyTeamMembers) ysrc/modules/notifications/queries.ts. - No existe un cliente de email (
sendEmail) en el repo actual.
Decisión MVP:
- El sistema debe funcionar completo con in-app notifications.
- El canal email es una integración nueva (se puede implementar más adelante con Resend/SES/etc.).
Cambio requerido en Prisma (nuevo):
- Extender
enum NotificationTypecon eventos del Módulo 5, por ejemplo:AWARD_WONJOB_OFFER_RECEIVEDDIRECT_CONTACT_RECEIVEDSPONSOR_FOLLOWUP_REQUIRED
Nota: hoy
NotificationTypesolo incluyeSUBMISSION_EVALUATED,TEAM_INVITATION_EMAIL,TEAM_JOIN_REQUEST,HACKATHON_STATUS_CHANGED.
Archivo (nuevo): src/modules/awards/notifications.ts
import { NotificationType } from '@prisma/client';
import { notifyTeamMembers, createNotification } from '@/modules/notifications/actions';
/**
* Notifica (in-app) a los miembros del equipo ganador.
* Email se implementa como integración posterior.
*/
export async function notifyAwardWon(params: {
teamId: string;
hackathonId: string;
submissionId: string;
awardId: string;
title: string;
}) {
await notifyTeamMembers(params.teamId, {
type: NotificationType.AWARD_WON,
title: `🏆 Premio ganado: ${params.title}`,
message: `Tu equipo ganó: ${params.title}. Revisa el detalle para próximos pasos.`,
link: `/participant/awards/${params.awardId}`,
hackathonId: params.hackathonId,
submissionId: params.submissionId,
teamId: params.teamId,
});
}
export async function notifyOrganizerFollowUp(params: {
organizerProfileId: string;
hackathonId: string;
awardId: string;
}) {
await createNotification({
profileId: params.organizerProfileId,
type: NotificationType.SPONSOR_FOLLOWUP_REQUIRED,
title: '⚠️ Sponsor sin contacto a ganador',
message: 'Un sponsor no ha contactado a un equipo ganador dentro del plazo. Requiere seguimiento.',
link: `/organizer/awards/${params.awardId}`,
hackathonId: params.hackathonId,
});
}
2.2 Email (integración futura, sin bloquear MVP)
Cuando se decida el proveedor, crear una abstracción nueva (ej: src/lib/email.ts) con una interfaz tipo:
export type SendEmailInput = { to: string; subject: string; html: string };
export async function sendEmail(_input: SendEmailInput) {
throw new Error('Email provider not configured');
}
Y luego conectar templates. Mientras tanto, el tracking funciona con notificaciones in-app y estados del modelo Award.
Archivo: src/modules/awards/email-templates.ts
import type { Award, Profile, Team, Organization } from '@prisma/client';
/**
* Template: Award anunciado a ganadores
*/
export function generateWinnerAwardEmail(
award: Award & {
submission: { team: Team & { members: Array<{ profile: Profile }> } };
organization?: Organization | null;
},
appUrl: string
) {
const { submission, organization } = award;
const teamName = submission.team.name;
const sponsorName = organization?.name || 'the organizer';
return {
subject: `🎉 Congratulations! You won: ${award.title}`,
html: `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
.content { background: white; padding: 30px; border: 1px solid #e5e7eb; border-top: none; }
.award-box { background: #f9fafb; padding: 20px; border-radius: 8px; margin: 20px 0; }
.cta-button { display: inline-block; background: #6366f1; color: white;
padding: 12px 24px; text-decoration: none; border-radius: 6px;
margin: 20px 0; }
.footer { text-align: center; color: #6b7280; font-size: 12px; padding: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🏆 Congratulations, ${teamName}!</h1>
</div>
<div class="content">
<p>Great news! Your team has won an award:</p>
<div class="award-box">
<h2>${award.title}</h2>
<p>${award.description}</p>
${award.monetaryValue ? `
<p style="font-size: 24px; font-weight: bold; color: #6366f1;">
${award.currency} $${award.monetaryValue.toLocaleString()}
</p>
` : ''}
</div>
<p>
<strong>${sponsorName}</strong> will be contacting you soon to arrange
the prize delivery. Please ensure your contact information is up to date.
</p>
<a href="${appUrl}/participant/awards" class="cta-button">
View My Awards
</a>
<p style="margin-top: 30px; font-size: 14px; color: #6b7280;">
<strong>What's next?</strong><br>
- Check your awards dashboard<br>
- Wait for ${sponsorName} to contact you<br>
- Keep your profile updated<br>
</p>
</div>
<div class="footer">
<p>PuntoHack - Empowering Innovation</p>
</div>
</div>
</body>
</html>
`,
};
}
/**
* Template: Notificar a sponsor que debe contactar
*/
export function generateSponsorContactReminderEmail(
award: Award & {
submission: { team: Team & { members: Array<{ profile: Profile }> } };
},
organization: Organization,
appUrl: string
) {
const { submission } = award;
const teamName = submission.team.name;
const members = submission.team.members;
return {
subject: `Action Required: Contact ${teamName} for Award Delivery`,
html: `
<!DOCTYPE html>
<html>
<body style="font-family: Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2>Award Delivery Action Required</h2>
<p>Hello ${organization.name},</p>
<p>
The winning team <strong>${teamName}</strong> is waiting to hear from you
regarding their award: <strong>${award.title}</strong>
</p>
<div style="background: #fef3c7; padding: 15px; border-radius: 8px; margin: 20px 0;">
<strong>⚠️ Important:</strong> Please contact the team within the next 7 days.
</div>
<h3>Team Contact Information:</h3>
<ul>
${members.map(m => `
<li>
<strong>${m.profile.name}</strong><br>
Email: ${m.profile.email}${m.profile.allowPhoneContact && m.profile.phoneNumber ? `<br>Phone: ${m.profile.phoneNumber}` : ''}
</li>
`).join('')}
</ul>
<a href="${appUrl}/sponsor/awards/${award.id}"
style="display: inline-block; background: #6366f1; color: white;
padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0;">
View Award Details
</a>
</div>
</body>
</html>
`,
};
}
// ... más templates
2.3 Trigger Automático
Archivo: src/modules/hackathons/actions.ts (actualizar updateHackathon)
// En `updateHackathon`, después de `const updated = await db.hackathon.update(...)`
// Si el status cambió a FINISHED, disparar creación automática de awards (idempotente)
2.4 Testing
- Crear hackathon de prueba con premios definidos
- Crear challenges con premios
- Finalizar hackathon
- Verificar creación de awards
- Verificar emails enviados
- Verificar notificaciones in-app
📅 FASE 3: UI - Participant Views (Semana 2)
Objetivos:
- ✅ Vista de awards ganados
- ✅ Vista de job offers
- ✅ Vista de direct contacts
- ✅ Profile settings (contact info)
Tareas:
3.1 My Awards Page
Archivo: src/app/participant/awards/page.tsx
import { auth } from '@clerk/nextjs';
import { redirect } from 'next/navigation';
import { getUserByClerkId } from '@/modules/users/queries';
import { getAwardsByProfileId } from '@/modules/awards/queries';
import { AwardsList } from '@/components/awards/awards-list';
export default async function ParticipantAwardsPage() {
const { userId } = await auth();
if (!userId) redirect('/sign-in');
const profile = await getUserByClerkId(userId);
if (!profile) redirect('/onboarding');
const awards = await getAwardsByProfileId(profile.id);
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold">My Awards</h1>
<p className="text-gray-600 mt-2">
Track your prizes, job offers, and recognitions
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<StatCard
title="Total Awards"
value={awards.length}
icon="🏆"
/>
<StatCard
title="Monetary Prizes"
value={`$${awards
.filter(a => a.type === 'MONETARY')
.reduce((sum, a) => sum + Number(a.monetaryValue || 0), 0)
.toLocaleString()}`}
icon="💰"
/>
<StatCard
title="Job Offers"
value={awards.filter(a => a.type === 'JOB_OFFER').length}
icon="💼"
/>
</div>
<AwardsList awards={awards} />
</div>
);
}
Archivo: src/components/awards/awards-list.tsx
'use client';
import { Award } from '@prisma/client';
import { AwardCard } from './award-card';
interface AwardsListProps {
awards: AwardWithRelations[];
}
export function AwardsList({ awards }: AwardsListProps) {
if (awards.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500">No awards yet. Keep participating!</p>
</div>
);
}
return (
<div className="space-y-4">
{awards.map((award) => (
<AwardCard key={award.id} award={award} />
))}
</div>
);
}
Archivo: src/components/awards/award-card.tsx
'use client';
import Link from 'next/link';
import { AwardWithRelations } from '@/modules/awards/types';
export function AwardCard({ award }: { award: AwardWithRelations }) {
const statusColors = {
ANNOUNCED: 'bg-blue-100 text-blue-800',
SPONSOR_NOTIFIED: 'bg-yellow-100 text-yellow-800',
IN_PROGRESS: 'bg-purple-100 text-purple-800',
COMPLETED: 'bg-green-100 text-green-800',
};
return (
<div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="text-3xl">
{award.category === 'GENERAL_PODIUM' ? '🏆' : '🎯'}
</span>
<div>
<h3 className="text-xl font-bold">{award.title}</h3>
<p className="text-sm text-gray-600">
{award.hackathon.name}
</p>
</div>
</div>
<p className="text-gray-700 mb-4">{award.description}</p>
{award.monetaryValue && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
<p className="text-2xl font-bold text-green-700">
{award.currency} ${Number(award.monetaryValue).toLocaleString()}
</p>
</div>
)}
<div className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-sm ${statusColors[award.status]}`}>
{award.status.replace('_', ' ')}
</span>
{award.organization && (
<span className="text-sm text-gray-600">
by {award.organization.name}
</span>
)}
</div>
</div>
<Link
href={`/participant/awards/${award.id}`}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
View Details
</Link>
</div>
</div>
);
}
Nota UI: el código anterior es ilustrativo. En la implementación real se deben reutilizar los componentes del design system existente (Button/Card/Badge/Modal/Input) y tokens Tailwind ya usados en el proyecto.
3.2 Job Offers (Participante)
Objetivo: mostrar ofertas (asociadas a Award tipo JOB_OFFER) y permitir aplicar de forma individual, sin exponer otros aplicantes.
Rutas:
- Lista:
/participant/job-offers - Detalle:
/participant/job-offers/[jobOpportunityId]
Requerimientos funcionales:
- Mostrar solo ofertas relacionadas a submissions/teams del participante.
- Si la oferta es
scope = TEAM: todos los miembros ven la oferta, pero cada uno decide aplicar o no (a menos que el sponsor explícitamente pida “equipo completo”; aun así el tracking será individual). - Deadline no visible al participante (solo sponsor).
- Aplicación por usuario:
@@unique([jobOpportunityId, profileId]).
Archivos sugeridos:
src/app/participant/job-offers/page.tsxsrc/app/participant/job-offers/[id]/page.tsxsrc/components/job-offers/job-offer-card.tsxsrc/components/job-offers/job-offer-detail.tsxsrc/components/job-offers/apply-job-offer-modal.tsx
Criterios de aceptación:
- Un participante ve ofertas solo de sus teams/submissions.
- No hay forma de ver otros aplicantes.
- Aplicar dos veces falla con mensaje claro.
3.3 Direct Contacts (Participante)
Objetivo: el participante ve mensajes de sponsors (DirectContact) y puede marcar interés / declinar / responder (MVP: in-app; sin chat).
Rutas:
- Lista:
/participant/contacts - Detalle:
/participant/contacts/[directContactId]
MVP (sin chat):
- La plataforma crea el DirectContact y notifica in-app a todos los miembros.
- En UI, cada miembro puede:
- Marcar “Interested” (registra en
memberResponsesy actualiza counters) - Marcar “Not interested”
- Botón “Reply” (MVP:
mailto:alcontactEmaildel sponsor)
- Marcar “Interested” (registra en
Criterios de aceptación:
- Un participante ve solo contactos de sus submissions.
- Puede marcar interés sin compartir teléfono si no lo habilitó.
3.4 Profile Settings (Contact Info)
Objetivo: permitir al usuario controlar qué información se comparte cuando es ganador o recibe contacto.
Ruta:
- Propuesta MVP:
/participant/profile(o dentro de la página de perfil existente)
Campos:
countrygithubUsernamelinkedinUrlportfolioUrlphoneCountryCode,phoneNumberallowPhoneContact,allowEmailContact
Validaciones mínimas:
phoneNumber: formato E.164 si se decide normalizar; si no, guardar “tal cual” pero exigirphoneCountryCodecuando haya teléfono.linkedinUrl/portfolioUrl: URL válida.
📅 FASE 4: UI - Sponsor Views (Semana 2-3)
Objetivos
- ✅ Dashboard “Awards to Deliver”
- ✅ Crear Job Opportunities desde Award
- ✅ Sistema Direct Contact (con límite)
- ✅ Tracking de contacto (sin chat)
4.1 Awards to Deliver (Sponsor)
Rutas:
- Lista:
/sponsor/awards - Detalle:
/sponsor/awards/[awardId]
Funcionalidad:
- Filtrar por status (MVP: tabs simples: Pending / In progress / Completed).
- En detalle del award:
- Contact info de miembros (email siempre; teléfono solo si
allowPhoneContacty hay phone) - Botón “Mark as Contacted”
- Botón “Mark as Completed”
- Sección “Create Job Offer” (si aplica)
- Contact info de miembros (email siempre; teléfono solo si
Reglas:
- Sponsor solo puede ver awards donde
award.organizationIdpertenezca a su organization. - Cuando marque “Contacted”: set
sponsorDidContact = true,sponsorContactedAt = now, status →SPONSOR_CONTACTED. - Cuando marque “Completed”: status →
COMPLETED,completedAt = now.
4.2 Job Offers (Sponsor)
Rutas:
- Lista:
/sponsor/job-offers - Detalle:
/sponsor/job-offers/[jobOpportunityId] - Crear (vía Award): dentro de
/sponsor/awards/[awardId]
Funcionalidad:
- Crear oferta y notificar a miembros del equipo ganador.
- Ver aplicaciones (solo las recibidas).
- Cambiar status de aplicación (UNDER_REVIEW, INTERVIEWING, etc.).
Privacidad:
- Sponsor ve datos solo del aplicante + submission relacionada.
- Participantes no ven el pipeline del sponsor; solo su propio status.
4.3 Direct Contact (Sponsor)
Rutas:
- Crear contacto desde una submission (en la vista de submissions)
- Lista:
/sponsor/contacts - Detalle:
/sponsor/contacts/[directContactId]
Límite MVP (según requerimiento):
- Máximo 5 equipos contactados por hackathon por sponsor.
Implementación sugerida:
- Validación server-side en acción/endpoint
createDirectContact:- Contar
DirectContactdondeorganizationId+hackathonId - Si
count >= 5→ rechazar con error claro.
- Contar
En detalle:
- Mostrar counters:
respondedCount/interestedCountylastActivityAt. - Mostrar info “Team responded via email” (sin mensajes internos).
📅 FASE 5: Organizer Oversight + Timeouts (Semana 3)
Objetivos
- ✅ Vista de awards por hackathon
- ✅ Alertas de sponsors inactivos
- ✅ Reporte/export (texto/CSV si existe infraestructura; si no, tabla)
5.1 Awards Dashboard (Organizer)
Rutas:
- Global:
/organizer/awards - Por hackathon:
/organizer/hackathons/[slug]/awards
Funcionalidad:
- Summary cards: Total / Completed / In progress / Need follow-up.
- Tabla con:
- Award title
- Team
- Sponsor (si aplica)
- Status
- Deadline de contacto
- Última actividad
- Notas internas
5.2 Timeouts & Escalation
Regla:
- Awards no expiran.
- Pero si un sponsor no contacta al equipo en $X$ días (MVP: 7 días) → status
ORGANIZER_FOLLOWUPy notificar organizer.
Implementación sugerida:
- Job de cron diario (ya existe infraestructura de cron en repo: ver
scripts/test-cron.ts). - Query:
status in (SPONSOR_NOTIFIED, TEAM_NOTIFIED)sponsorDidContact = falsesponsorContactDeadline < noworganizerNotifiedAt is null
- Acción:
- update status →
ORGANIZER_FOLLOWUP - set
organizerNotifiedAt = now - crear notificación in-app al organizer (email opcional si se integra a futuro)
- update status →
📅 FASE 6: APIs y Endpoints (Semana 3)
🔌 APIs Y ENDPOINTS
Preferencia: usar Server Actions para mutaciones internas y Route Handlers (
src/app/api/.../route.ts) para integraciones o acciones que requieran llamadas desde cliente con fetch.
Awards
GET /api/participant/awards(Participant)GET /api/sponsor/awards(Sponsor)GET /api/organizer/awards?hackathonId=...(Organizer/Admin)POST /api/sponsor/awards/[awardId]/mark-contacted(Sponsor)POST /api/sponsor/awards/[awardId]/mark-completed(Sponsor)
Job Opportunities
POST /api/sponsor/job-offers(Sponsor) — crear desdeawardIdGET /api/participant/job-offers(Participant)GET /api/sponsor/job-offers(Sponsor)GET /api/participant/job-offers/[jobOpportunityId](Participant)GET /api/sponsor/job-offers/[jobOpportunityId](Sponsor)
Job Applications
POST /api/participant/job-applications(Participant)GET /api/participant/job-applications(Participant)GET /api/sponsor/job-applications?jobOpportunityId=...(Sponsor)POST /api/sponsor/job-applications/[jobApplicationId]/status(Sponsor)
Direct Contacts
POST /api/sponsor/contacts(Sponsor) — aplicar límite 5 por hackathonGET /api/participant/contacts(Participant)POST /api/participant/contacts/[directContactId]/interest(Participant) — interested/declineGET /api/sponsor/contacts(Sponsor)
RBAC (obligatorio)
- Participant:
- Solo sus submissions/teams
- Sponsor:
- Solo su
organizationId
- Solo su
- Organizer:
- Solo hackathons que administra
- Admin:
- Global
📅 FASE 7: UI Components, Notificaciones, Testing y Despliegue (Semana 3-4)
🧩 COMPONENTES UI
Awards
AwardsList,AwardCard,AwardDetailAwardStatusBadge
Job Offers
JobOfferCard,JobOfferDetailApplyJobOfferModalJobApplicationsTable(Sponsor)
Direct Contacts
DirectContactsListDirectContactDetailDirectContactModal(Sponsor)
Organizer
AwardsOverviewCardsAwardsTableFollowUpQueue
🔔 NOTIFICACIONES
Canales
- In-app (tabla
Notificationexistente) - Email (opcional, integración futura)
Eventos
- Hackathon FINISHED → awards creados
- Notificación in-app a ganadores
- Notificación in-app a sponsors (si challenge awards)
- Notificación in-app a organizer (resumen)
- Sponsor crea JobOpportunity
- In-app a miembros del equipo (email opcional a futuro)
- Participante aplica a JobOpportunity
- In-app a sponsor
- In-app confirmación a participante
- Sponsor inicia DirectContact
- In-app a todos los miembros (email opcional a futuro)
- Timeout sponsor sin contactar
- In-app a organizer (email opcional a futuro)
Plantillas
- Si se integra email a futuro, mantener templates y no incluir datos sensibles más allá del contacto permitido.
🧪 TESTING
Unit tests (Vitest)
createAwardsAutomatically:- Crea podium awards cuando
hasPrizes=true - Crea challenge awards cuando
hasPrize=truey hay winner
- Crea podium awards cuando
- Validación límite DirectContact:
- Permite 0..4
- Rechaza a partir de 5
applyJobOpportunity:- Crea JobApplication
- Evita duplicados
Integration / E2E (si aplica)
- Flujo: terminar hackathon → ver awards en participante → sponsor marca contacted.
Checks mínimos
- Migraciones corren sin drift
- Seeds crean hackathon con premios + challenge prize
- No hay rutas accesibles sin rol
🚢 DESPLIEGUE
Orden recomendado
- Aplicar migraciones en entorno staging
- Desplegar backend con modelos + queries
- Desplegar UI (participant → sponsor → organizer)
- Activar cron de follow-up
Riesgos y mitigación
- Riesgo: awards duplicados si el hackathon se marca FINISHED múltiples veces
- Mitigación: idempotencia en
createAwardsAutomatically(ver sección “Tareas críticas” abajo)
- Mitigación: idempotencia en
- Riesgo: sponsor ve datos de contacto no autorizados
- Mitigación: aplicar reglas de
allowPhoneContacty RBAC estricto
- Mitigación: aplicar reglas de
✅ TAREAS CRÍTICAS (OBLIGATORIAS)
-
Idempotencia de Awards Automáticos
- Antes de crear, verificar si ya existen awards para
hackathonId. - O usar una marca
awardsAnnouncedAten Hackathon y validar.
- Antes de crear, verificar si ya existen awards para
-
RBAC en cada query/action
- Nunca confiar en el cliente.
-
Contacto por teléfono solo si el usuario lo permite
- Mostrar teléfono únicamente si:
allowPhoneContact = truephoneNumberno es null
- Mostrar teléfono únicamente si:
-
Sin chat en MVP
- Toda comunicación interna debe ser: email + tracking de estado.
📌 CHECKLIST FINAL DEL MÓDULO
- Prisma schema actualizado y migrado
- Awards automáticos al finalizar hackathon
- Participant: My Awards / Job Offers / Contacts
- Sponsor: Awards to Deliver / Job Offers / Direct Contacts
- Organizer: Awards oversight + follow-up queue
- Emails funcionando
- Límite 5 direct contacts por hackathon por sponsor
- Cron de escalación activo
✅ FIN
Este documento es el “source of truth” para implementar el Módulo 5 por fases, con enfoque MVP, privacidad y trazabilidad post-hackathon.