generator client { provider = "prisma-client" output = "../src/generated/prisma" } datasource db { provider = "postgresql" } model User { id String @id name String email String emailVerified Boolean @default(false) image String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt sessions Session[] accounts Account[] assigneeIssues Issue[] @relation(name: "assigneeIssues") reporterIssues Issue[] @relation(name: "reporterIssues") projects Project[] projectMemberships ProjectMember[] notificationDeviceTokens NotificationDeviceToken[] notificationPreferences NotificationPreference[] notificationMessages NotificationMessage[] comments Comment[] uploadedFiles File[] issueChanges IssueChange[] @@unique([email]) @@map("user") } model Session { id String @id expiresAt DateTime token String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt ipAddress String? userAgent String? userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([token]) @@index([userId]) @@map("session") } model Account { id String @id accountId String providerId String userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) accessToken String? refreshToken String? idToken String? accessTokenExpiresAt DateTime? refreshTokenExpiresAt DateTime? scope String? password String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([providerId, accountId]) @@index([userId]) @@map("account") } model Verification { id String @id identifier String value String expiresAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([identifier]) @@map("verification") } model Issue { id Int @id @default(autoincrement()) key String title String content String? status String priority Int @default(0) type String @default("TASK") assigneeId String? assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: SetNull) reporterId String? reporter User? @relation(name: "reporterIssues", fields: [reporterId], references: [id], onDelete: SetNull) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) dueDate DateTime? @db.Timestamptz(3) comments Comment[] attachments IssueAttachment[] changes IssueChange[] createdAt DateTime @default(now()) @db.Timestamptz(3) updatedAt DateTime @updatedAt @db.Timestamptz(3) @@index([status]) @@index([assigneeId]) @@index([reporterId]) @@index([projectId]) @@index([createdAt]) @@index([projectId, createdAt]) @@index([dueDate]) @@index([projectId, dueDate]) @@index([projectId, type]) @@map("issue") } model Project { id String @id @default(uuid(7)) name String key String createdAt DateTime @default(now()) @db.Timestamptz(3) updatedAt DateTime @updatedAt @db.Timestamptz(3) issues Issue[] ownerId String owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict) members ProjectMember[] @@unique([key]) @@index([createdAt]) @@index([ownerId]) @@map("project") } model ProjectMember { id String @id @default(uuid(7)) projectId String userId String createdAt DateTime @default(now()) @db.Timestamptz(3) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([projectId, userId]) @@index([userId]) @@map("project_member") } /// Push-токены устройств пользователя. Регистрируются напрямую клиентом, /// без участия остального приложения. model NotificationDeviceToken { id String @id @default(uuid(7)) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) token String platform String // ios | android | web createdAt DateTime @default(now()) @db.Timestamptz(3) updatedAt DateTime @updatedAt @db.Timestamptz(3) @@unique([userId, token]) @@index([userId]) @@map("notification_device_token") } /// Включён ли у пользователя конкретный канал доставки уведомлений. /// Категорий нет — настройка одна на канал. /// Допустимые значения channel валидируются в коде (NotificationChannel VO), /// а не типом БД — расширение списка каналов не требует миграции. model NotificationPreference { id String @id @default(uuid(7)) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) channel String enabled Boolean @default(true) updatedAt DateTime @updatedAt @db.Timestamptz(3) @@unique([userId, channel]) @@map("notification_preference") } /// Лог/история отправленных уведомлений — одна запись на попытку доставки /// по одному каналу. channel/status — строки, валидируются в коде /// (NotificationChannel/NotificationStatus VO), см. комментарий выше. model NotificationMessage { id String @id @default(uuid(7)) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) channel String status String @default("PENDING") subject String? content String error String? sentAt DateTime? @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3) @@index([userId]) @@index([status]) @@index([createdAt]) @@map("notification_message") } /// Комментарий к задаче. При удалении автора комментарий остаётся (authorId -> NULL). /// deletedAt — мягкое удаление, editedAt — время последней правки текста. model Comment { id String @id @default(uuid(7)) content String issueId Int issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) authorId String? author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) editedAt DateTime? @db.Timestamptz(3) deletedAt DateTime? @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3) updatedAt DateTime @updatedAt @db.Timestamptz(3) attachments CommentAttachment[] @@index([issueId, createdAt]) @@index([authorId]) @@map("comment") } /// Загруженный файл. Ничего не знает о владельце: к задачам, комментариям и т.п. /// файлы привязываются через таблицы связей в модулях-владельцах. /// Файл, на который никто не ссылается, считается сиротой и удаляется GC. /// storageKey — ключ объекта в хранилище, имя от пользователя в него не попадает. model File { id String @id @default(uuid(7)) storageKey String fileName String mimeType String size Int uploaderId String? uploader User? @relation(fields: [uploaderId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) @db.Timestamptz(3) issueAttachments IssueAttachment[] commentAttachments CommentAttachment[] @@unique([storageKey]) @@index([uploaderId]) @@index([createdAt]) @@map("file") } /// Вложение задачи — связь задачи с файлом. Удаление задачи или файла /// удаляет связь каскадом; сам файл, оставшийся без связей, удалит GC. model IssueAttachment { issueId Int issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) fileId String file File @relation(fields: [fileId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @db.Timestamptz(3) @@id([issueId, fileId]) @@index([fileId]) @@map("issue_attachment") } /// Вложение комментария — связь комментария с файлом. Управляется вместе /// с комментарием (fileIds в create/update); при мягком удалении комментария /// связи снимаются, чтобы файлы забрал GC. model CommentAttachment { commentId String comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade) fileId String file File @relation(fields: [fileId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @db.Timestamptz(3) @@id([commentId, fileId]) @@index([fileId]) @@map("comment_attachment") } /// История изменений задачи — одна строка на изменённое поле. /// Строки одного сохранения задачи объединены общим changeId. /// field: created | title | content | status | priority | assignee | dueDate /// (валидируется в коде, см. ISSUE_CHANGE_FIELDS). Значения — строки: /// priority — число, dueDate — ISO-дата, assignee — id пользователя. /// При создании задачи пишутся две строки: created и status null -> OPEN, /// поэтому статус задачи на любой момент восстанавливается по строкам status. /// У задач, созданных до появления истории, строк нет — в статистику они не попадают. model IssueChange { id Int @id @default(autoincrement()) issueId Int issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) changeId String field String oldValue String? newValue String? actorId String? actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull) changedAt DateTime @db.Timestamptz(3) @@index([issueId, changedAt]) @@index([field, changedAt]) @@index([actorId]) @@map("issue_change") }