Ir al contenido

Schema de Base de Datos

El schema define 17 modelos y 4 enums, organizados en 5 capas: Autenticación, Perfil, Workspace, Kanban y Time tracking.

┌─────────────────────────────────────────────────────────────────┐
│ AUTENTICACIÓN (NextAuth) │
│ │
│ User ──1:N──► Account (OAuth providers) │
│ User ──1:N──► Session (sesiones activas) │
│ VerificationToken (tokens de verificación de email) │
└──────────────────────────┬──────────────────────────────────────┘
│ 1:1
┌──────┴──────┐
│ Profile │
└──────┬──────┘
┌──────────────────────────┴──────────────────────────────────────┐
│ WORKSPACE │
│ │
│ User ──M:N──► Workspace (via WorkspaceMember) │
│ User ──1:N──► Invitation (invitaciones enviadas) │
│ │
│ Workspace ──1:N──► Board │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ KANBAN │
│ │
│ Board ──1:N──► Column ──1:N──► Task │
│ │
│ Task ──M:N──► User (via TaskAssignee) │
│ Task ──M:N──► Label (via TaskLabel) │
│ Task ──1:N──► Comment │
│ Task ──1:N──► TaskActivity (historial de la tarea) │
│ Task ──1:N──► TimeEntry (horas imputadas) │
│ Workspace ──1:N──► Task (una tarea puede estar en backlog)│
└─────────────────────────────────────────────────────────────────┘
enum MemberRole {
OWNER
ADMIN
MEMBER
}

Controla los permisos dentro de un workspace. El OWNER creó el workspace, los ADMIN pueden gestionar miembros, y los MEMBER trabajan en tableros y tareas.

Decisión: Usar un enum en vez de un string libre. Esto garantiza que la base de datos solo acepte valores válidos, y evita errores de typo como "adimn".

enum TaskPriority {
LOW
MEDIUM
HIGH
URGENT
}

Prioridad visual de cada tarea. Se usa MEDIUM como valor por defecto.

enum TaskAction {
TASK_CREATED
TASK_UPDATED
TASK_MOVED
TASK_ASSIGNED
TASK_UNASSIGNED
TASK_DELETED
}

Describe el tipo de evento que registra cada entrada del historial de la tarea (TaskActivity). Alimenta el log de actividad del tablero.

enum InvitationStatus {
PENDING
ACCEPTED
EXPIRED
}

Ciclo de vida de una invitación. Permite filtrar invitaciones pendientes y detectar expiradas.

Estos 4 modelos son requeridos por NextAuth v5 con el PrismaAdapter. No se deben modificar salvo que se sepa lo que se hace.

model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String?
createdAt DateTime @default(now())
accounts Account[]
sessions Session[]
profile Profile?
memberships WorkspaceMember[]
createdTasks Task[] @relation("TaskCreator")
updatedTasks Task[] @relation("TaskUpdater")
assignments TaskAssignee[]
comments Comment[]
activities TaskActivity[]
sentInvitations Invitation[] @relation("InvitationSender")
timeEntries TimeEntry[]
}

Decisión — cuid() como generador de IDs:

Se eligió cuid() sobre uuid() o ulid() por tres razones:

  1. Cortos y legibles (~25 caracteres vs 36 de UUID) — se ven bien en URLs como /task/clq5k9h8m
  2. Ordenables cronológicamente — buen rendimiento para índices en PostgreSQL
  3. Consistencia — es el default de Prisma y se usa en todos los modelos
Generador Longitud Ordenable Uso recomendado
cuid() ~25 car. IDs generales (nuestra elección)
uuid() 36 car. No Interoperabilidad con sistemas externos
ulid() 26 car. Alternativa estándar a cuid

Decisión — password opcional:

El campo password es String? (opcional) porque NextAuth soporta múltiples providers. Un usuario con Google OAuth no tiene contraseña. Solo se usa cuando se implementa el credentials provider (login con email + password).

Modelos estándar de NextAuth. Account usa clave primaria compuesta (@@id([provider, providerAccountId])) para evitar que un usuario tenga dos cuentas del mismo proveedor.

model Profile {
id String @id @default(cuid())
userId String @unique
bio String?
phone String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

Decisión — Relación 1:1 con la FK en Profile:

La clave foránea (userId) vive en Profile, no en User. La regla en Prisma para relaciones 1:1:

Solo un lado lleva la FK. El otro lado es “virtual” (sin @relation(fields: ...)).

Profile depende de User (si borras el usuario, se borra el perfil), por eso la FK está aquí. En User, el campo profile Profile? no tiene @relation — Prisma infiere la conexión automáticamente.

User (lado virtual) Profile (lado con FK)
┌──────────────┐ ┌──────────────┐
│ id │◄────────────│ userId │ ← FK aquí
│ profile │────────────►│ user │
└──────────────┘ 1:1 └──────────────┘

Decisión — @unique en userId:

Sin @unique, Prisma interpretaría la relación como 1:N (un usuario tendría muchos perfiles). Con @unique, garantizamos que cada usuario tiene exactamente un perfil.

model Workspace {
id String @id @default(cuid())
name String
slug String @unique
description String?
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members WorkspaceMember[]
boards Board[]
labels Label[]
invitations Invitation[] @relation("WorkspaceInvitations")
tasks Task[]
}

Decisión — slug con @unique:

El slug genera URLs amigables (/workspace/mi-equipo). Se usa @unique para que no existan dos workspaces con el mismo slug. La app debe validar y generar el slug automáticamente a partir del nombre.

Decisión — @updatedAt automático:

Prisma actualiza automáticamente el campo updatedAt cada vez que se modifica el registro. No hay que gestionarlo manualmente.

model WorkspaceMember {
id String @id @default(cuid())
role MemberRole @default(MEMBER)
joinedAt DateTime @default(now())
userId String
workspaceId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([userId, workspaceId])
}

Decisión — Tabla intermedia explícita en vez de M:N implícito:

Prisma soporta relaciones M:N implícitas (poner User[] en Workspace y Workspace[] en User), pero no permite campos extra en la tabla intermedia. Necesitamos role y joinedAt, así que usamos una tabla explícita.

Decisión — @@unique([userId, workspaceId]):

Restricción compuesta que impide que el mismo usuario aparezca dos veces en el mismo workspace. Sin esto, podrías añadir a Alice al workspace “Equipo A” dos veces.

model Invitation {
id String @id @default(cuid())
email String
role MemberRole @default(MEMBER)
status InvitationStatus @default(PENDING)
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
workspaceId String
senderId String
workspace Workspace @relation("WorkspaceInvitations", fields: [workspaceId], references: [id], onDelete: Cascade)
sender User @relation("InvitationSender", fields: [senderId], references: [id], onDelete: Cascade)
@@unique([email, workspaceId])
}

Decisión — token con @unique:

Cada invitación tiene un token único que se envía por email. El receptor acepta la invitación visitando /invite/{token}. Sin @unique, dos invitaciones podrían compartir token y causar conflictos de seguridad.

Decisión — Nombres de relación explícitos:

Workspace tiene dos relaciones hacia Invitation (una para las invitaciones del workspace, otra sería si hubiera otra). Prisma requiere nombres explícitos cuando un modelo tiene múltiples relaciones hacia el mismo modelo. Por eso: "WorkspaceInvitations" e "InvitationSender".

model Board {
id String @id @default(cuid())
name String
description String?
position Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
columns Column[]
}
model Column {
id String @id @default(cuid())
name String
color String?
position Float @default(0)
boardId String
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
tasks Task[]
}

Decisión — Float para position en vez de Int:

Para drag & drop, necesitas reordenar elementos. Con enteros, mover un elemento requiere actualizar la posición de muchos registros (1, 2, 3 → insertar en posición 2 → actualizar todo a 1, 2, 3, 4). Con Float, puedes insertar entre dos valores sin tocar los demás:

Antes: 1.0 2.0 3.0
Insertar entre 1 y 2: 1.0 1.5 2.0 3.0

Esto se aplica a Board.position, Column.position y Task.position.

Decisión — Sin @@unique([boardId, position]):

Aunque parece lógico, una restricción única en posición rompe el drag & drop. Durante el reordenamiento, dos elementos pueden compartir posición temporalmente mientras se recalcula. La app debe garantizar unicidad en su lógica, no la base de datos.

model Task {
id String @id @default(cuid())
title String
description String?
priority TaskPriority @default(MEDIUM)
position Float @default(0)
dueDate DateTime?
startDate DateTime?
estimatedHours Float? // Estimación en horas (time tracking)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
columnId String?
workspaceId String
creatorId String
updatedByUserId String?
column Column? @relation(fields: [columnId], references: [id], onDelete: SetNull)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
creator User @relation("TaskCreator", fields: [creatorId], references: [id], onDelete: Cascade)
updatedBy User? @relation("TaskUpdater", fields: [updatedByUserId], references: [id])
assignees TaskAssignee[]
labels TaskLabel[]
comments Comment[]
activities TaskActivity[]
timeEntries TimeEntry[]
}

Decisión — creatorId como FK en vez de string libre:

Con una FK, la base de datos valida que el usuario existe. Si intentas crear una tarea con un creatorId que no existe, PostgreSQL lanza error. Esto protege la integridad de los datos. Un string libre no ofrece esa garantía.

Decisión — columnId nullable con onDelete: SetNull:

Desde la versión 0.5.0 una tarea puede no pertenecer a ninguna columna: es una tarea de backlog. Al eliminar una columna, sus tareas no se borran (a diferencia del resto de relaciones con cascade) sino que vuelven al backlog con columnId: null. Por eso la columna es Column? y la relación usa onDelete: SetNull.

Decisión — workspaceId en Task:

Cada tarea pertenece directamente a un workspace (además de a una columna opcional). Esto permite consultar todas las tareas de un workspace (tablero + backlog) sin recorrer la jerarquía Column → Board.

model TaskActivity {
id String @id @default(cuid())
action TaskAction
fieldName String? // Campo modificado (ej: "priority", "title")
oldValue String?
newValue String?
createdAt DateTime @default(now())
taskId String
userId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

Historial de eventos de una tarea. Cada vez que se crea, mueve, asigna o edita una tarea, se registra una entrada. Alimenta el log de actividad del tablero Kanban (TaskActivityDialog). Se guardan valores serializados (String?) para poder mostrar “prioridad: ALTA → URGENTE” sin acoplarse a los tipos originales.

model TaskAssignee {
id String @id @default(cuid())
assignedAt DateTime @default(now())
taskId String
userId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([taskId, userId])
}

Permite asignar múltiples usuarios a una tarea. La restricción @@unique([taskId, userId]) evita duplicados.

model Label {
id String @id @default(cuid())
name String
color String
workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
tasks TaskLabel[]
}

Decisión — Labels a nivel de Workspace, no de Board:

Las etiquetas se comparten entre todos los tableros de un workspace. Esto evita que cada tablero tenga sus propias etiquetas “bug” o “feature” duplicadas.

model TaskLabel {
id String @id @default(cuid())
taskId String
labelId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
label Label @relation(fields: [labelId], references: [id], onDelete: Cascade)
@@unique([taskId, labelId])
}

Relación M:N entre tareas y etiquetas. Una tarea puede tener muchas etiquetas (["bug", "urgente"]), y una etiqueta puede estar en muchas tareas.

model Comment {
id String @id @default(cuid())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
taskId String
userId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model TimeEntry {
id String @id @default(cuid())
hours Float // Horas invertidas imputadas
date DateTime @default(now()) // Día al que se imputan las horas
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
taskId String
userId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, date])
@@index([taskId])
}

Cada fila es una imputación manual de horas de un usuario sobre una tarea en un día concreto. La estimación vive en Task.estimatedHours; la diferencia entre estimación y horas imputadas alimenta las métricas de desviación del Calendario.

Decisión — índices:

@@index([userId, date]) acelera la vista “Mi tiempo” (horas del usuario por día). @@index([taskId]) acelera el cálculo de horas imputadas por tarea (métricas).

La mayoría de relaciones usan onDelete: Cascade. Esto significa:

Si borras… Se borra automáticamente…
Un Workspace Sus boards, columnas, tareas, comentarios, historial, imputaciones de horas, labels, invitaciones y miembros
Un User Su perfil, membresías, tareas creadas, asignaciones, comentarios, historial, imputaciones e invitaciones enviadas
Un Board Sus columnas y todas las tareas de esas columnas
Una Column ⚠️ Sus tareas no se borran: vuelven al backlog (columnId = null, onDelete: SetNull)
Una Task Sus asignaciones, etiquetas, comentarios, historial e imputaciones de horas

Excepción — Column → Task con SetNull:

Es la única relación que no usa cascade. Al eliminar una columna, sus tareas se conservan como backlog en el workspace. Es una decisión deliberada para no perder trabajo al reestructurar un tablero.

Por qué cascade: Evita registros huérfanos. Sin cascade, borrar un workspace dejaría tableros y tareas sin padre, causando errores en la app. Con cascade, la base de datos limpia todo automáticamente.

Convención Ejemplo Motivo
IDs con cuid() clq5k9h8m000008la5x2b3f4g Cortos, ordenables, seguros en cliente
Timestamps createdAt + updatedAt En todos los modelos de negocio Auditoría y ordenación
FK con onDelete: Cascade En todas las relaciones Evitar huérfanos
@@unique en tablas intermedias [taskId, userId] Evitar duplicados en M:N
@unique en campos de negocio email, slug, token Integridad de datos
Enums para valores fijos MemberRole, TaskPriority, TaskAction, InvitationStatus Validación a nivel de DB
Float para posiciones Board.position, Task.position Drag & drop eficiente