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.
Diagrama de relaciones
Sección titulada «Diagrama de relaciones»┌─────────────────────────────────────────────────────────────────┐│ 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)│└─────────────────────────────────────────────────────────────────┘MemberRole
Sección titulada «MemberRole»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".
TaskPriority
Sección titulada «TaskPriority»enum TaskPriority { LOW MEDIUM HIGH URGENT}Prioridad visual de cada tarea. Se usa MEDIUM como valor por defecto.
TaskAction
Sección titulada «TaskAction»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.
InvitationStatus
Sección titulada «InvitationStatus»enum InvitationStatus { PENDING ACCEPTED EXPIRED}Ciclo de vida de una invitación. Permite filtrar invitaciones pendientes y detectar expiradas.
Modelos de Autenticación
Sección titulada «Modelos de Autenticación»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:
- Cortos y legibles (~25 caracteres vs 36 de UUID) — se ven bien en URLs como
/task/clq5k9h8m - Ordenables cronológicamente — buen rendimiento para índices en PostgreSQL
- Consistencia — es el default de Prisma y se usa en todos los modelos
| Generador | Longitud | Ordenable | Uso recomendado |
|---|---|---|---|
cuid() |
~25 car. | Sí | IDs generales (nuestra elección) |
uuid() |
36 car. | No | Interoperabilidad con sistemas externos |
ulid() |
26 car. | Sí | 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).
Account, Session, VerificationToken
Sección titulada «Account, Session, VerificationToken»Modelos estándar de NextAuth. Account usa clave primaria compuesta (@@id([provider, providerAccountId])) para evitar que un usuario tenga dos cuentas del mismo proveedor.
Perfil de Usuario
Sección titulada «Perfil de Usuario»Profile
Sección titulada «Profile»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.
Workspace
Sección titulada «Workspace»Workspace
Sección titulada «Workspace»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.
WorkspaceMember (tabla intermedia)
Sección titulada «WorkspaceMember (tabla intermedia)»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.
Invitation
Sección titulada «Invitation»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.0Insertar entre 1 y 2: 1.0 1.5 2.0 3.0Esto 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.
TaskActivity
Sección titulada «TaskActivity»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.
TaskAssignee (tabla intermedia)
Sección titulada «TaskAssignee (tabla intermedia)»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.
TaskLabel (tabla intermedia)
Sección titulada «TaskLabel (tabla intermedia)»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.
Comment
Sección titulada «Comment»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)}Time tracking
Sección titulada «Time tracking»TimeEntry
Sección titulada «TimeEntry»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).
Cascade Delete
Sección titulada «Cascade Delete»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.
Convenciones del schema
Sección titulada «Convenciones del schema»| 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 |