Saltar al contenido principal

🏆 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

  1. Contexto y Motivación
  2. Arquitectura de Base de Datos
  3. Flujos de Usuario
  4. Navegación y Sidebar
  5. Fases de Implementación
  6. APIs y Endpoints
  7. Componentes UI
  8. Notificaciones
  9. Testing
  10. 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:

  1. ✅ Gestiona premios monetarios (tracking offline)
  2. ✅ Facilita contacto sponsor-participantes
  3. ✅ Administra ofertas laborales individuales/grupales
  4. ✅ 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

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ún enum 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 con getUserByClerkId(userId) en src/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, obtener userId con auth() y luego Profile con getUserByClerkId(userId).
  • Pasar profile.role a AppSidebar.

🚀 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) y src/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 NotificationType con eventos del Módulo 5, por ejemplo:
    • AWARD_WON
    • JOB_OFFER_RECEIVED
    • DIRECT_CONTACT_RECEIVED
    • SPONSOR_FOLLOWUP_REQUIRED

Nota: hoy NotificationType solo incluye SUBMISSION_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.tsx
  • src/app/participant/job-offers/[id]/page.tsx
  • src/components/job-offers/job-offer-card.tsx
  • src/components/job-offers/job-offer-detail.tsx
  • src/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 memberResponses y actualiza counters)
    • Marcar “Not interested”
    • Botón “Reply” (MVP: mailto: al contactEmail del sponsor)

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:

  • country
  • githubUsername
  • linkedinUrl
  • portfolioUrl
  • phoneCountryCode, phoneNumber
  • allowPhoneContact, allowEmailContact

Validaciones mínimas:

  • phoneNumber: formato E.164 si se decide normalizar; si no, guardar “tal cual” pero exigir phoneCountryCode cuando 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 allowPhoneContact y hay phone)
    • Botón “Mark as Contacted”
    • Botón “Mark as Completed”
    • Sección “Create Job Offer” (si aplica)

Reglas:

  • Sponsor solo puede ver awards donde award.organizationId pertenezca 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 DirectContact donde organizationId + hackathonId
    • Si count >= 5 → rechazar con error claro.

En detalle:

  • Mostrar counters: respondedCount / interestedCount y lastActivityAt.
  • 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_FOLLOWUP y 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 = false
    • sponsorContactDeadline < now
    • organizerNotifiedAt 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)

📅 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 desde awardId
  • GET /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 hackathon
  • GET /api/participant/contacts (Participant)
  • POST /api/participant/contacts/[directContactId]/interest (Participant) — interested/decline
  • GET /api/sponsor/contacts (Sponsor)

RBAC (obligatorio)

  • Participant:
    • Solo sus submissions/teams
  • Sponsor:
    • Solo su organizationId
  • Organizer:
    • Solo hackathons que administra
  • Admin:
    • Global

📅 FASE 7: UI Components, Notificaciones, Testing y Despliegue (Semana 3-4)

🧩 COMPONENTES UI

Awards

  • AwardsList, AwardCard, AwardDetail
  • AwardStatusBadge

Job Offers

  • JobOfferCard, JobOfferDetail
  • ApplyJobOfferModal
  • JobApplicationsTable (Sponsor)

Direct Contacts

  • DirectContactsList
  • DirectContactDetail
  • DirectContactModal (Sponsor)

Organizer

  • AwardsOverviewCards
  • AwardsTable
  • FollowUpQueue

🔔 NOTIFICACIONES

Canales

  • In-app (tabla Notification existente)
  • 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=true y hay winner
  • 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

  1. Aplicar migraciones en entorno staging
  2. Desplegar backend con modelos + queries
  3. Desplegar UI (participant → sponsor → organizer)
  4. 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)
  • Riesgo: sponsor ve datos de contacto no autorizados
    • Mitigación: aplicar reglas de allowPhoneContact y RBAC estricto

✅ TAREAS CRÍTICAS (OBLIGATORIAS)

  1. Idempotencia de Awards Automáticos

    • Antes de crear, verificar si ya existen awards para hackathonId.
    • O usar una marca awardsAnnouncedAt en Hackathon y validar.
  2. RBAC en cada query/action

    • Nunca confiar en el cliente.
  3. Contacto por teléfono solo si el usuario lo permite

    • Mostrar teléfono únicamente si:
      • allowPhoneContact = true
      • phoneNumber no es null
  4. 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.