generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" } // ─────────────────────────────────────── // Auth.js required models // ─────────────────────────────────────── enum Role { ADMIN USER } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? hashedPassword String? role Role @default(ADMIN) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt accounts Account[] sessions Session[] filaments Filament[] resins Resin[] paints Paint[] supplies Supply[] vendors Vendor[] locations Location[] usageLogs UsageLog[] tags Tag[] settings UserSettings? telegramLink TelegramLink? kickstarters Kickstarter[] inviteCodes InviteCode[] @relation("InviteCreator") usedInvite InviteCode? @relation("InviteUser", fields: [usedInviteId], references: [id], onDelete: SetNull) usedInviteId String? manualUploads ManualUpload[] } model Account { id String @id @default(cuid()) userId String type String provider String providerAccountId String refresh_token String? @db.Text access_token String? @db.Text expires_at Int? token_type String? scope String? id_token String? @db.Text session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) } model Session { id String @id @default(cuid()) sessionToken String @unique userId String expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model VerificationToken { identifier String token String expires DateTime @@unique([identifier, token]) } // ─────────────────────────────────────── // Domain models // ─────────────────────────────────────── model Vendor { id String @id @default(cuid()) name String @db.VarChar(64) website String? @db.VarChar(256) notes String? @db.Text archived Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) filaments Filament[] resins Resin[] paints Paint[] supplies Supply[] @@index([userId]) @@index([archived]) } model Location { id String @id @default(cuid()) name String @db.VarChar(64) description String? @db.VarChar(256) archived Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) filaments Filament[] resins Resin[] paints Paint[] supplies Supply[] @@index([userId]) @@index([archived]) } model Filament { id String @id @default(cuid()) name String @db.VarChar(128) brand String @db.VarChar(64) material String @db.VarChar(32) color String @db.VarChar(64) colorHex String @db.VarChar(7) diameter Float @default(1.75) spoolWeight Float usedWeight Float @default(0) emptySpoolWeight Float @default(0) purchaseDate DateTime? cost Float? notes String? @db.Text archived Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String vendorId String? locationId String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: SetNull) location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) tags TagOnFilament[] usageLogs UsageLog[] @@index([userId]) @@index([vendorId]) @@index([locationId]) @@index([material]) @@index([archived]) @@index([brand]) } model Resin { id String @id @default(cuid()) name String @db.VarChar(128) brand String @db.VarChar(64) resinType String @db.VarChar(32) color String @db.VarChar(64) colorHex String @db.VarChar(7) bottleSize Float usedML Float @default(0) purchaseDate DateTime? cost Float? notes String? @db.Text archived Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String vendorId String? locationId String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: SetNull) location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) tags TagOnResin[] usageLogs UsageLog[] @@index([userId]) @@index([vendorId]) @@index([locationId]) @@index([resinType]) @@index([archived]) @@index([brand]) } model Paint { id String @id @default(cuid()) name String @db.VarChar(128) brand String @db.VarChar(64) line String? @db.VarChar(64) color String @db.VarChar(64) colorHex String @db.VarChar(7) finish String @db.VarChar(32) volumeML Float usedML Float @default(0) purchaseDate DateTime? cost Float? notes String? @db.Text archived Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String vendorId String? locationId String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: SetNull) location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) tags TagOnPaint[] usageLogs UsageLog[] @@index([userId]) @@index([vendorId]) @@index([locationId]) @@index([finish]) @@index([archived]) @@index([brand]) } model Tag { id String @id @default(cuid()) name String @db.VarChar(64) createdAt DateTime @default(now()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) filaments TagOnFilament[] resins TagOnResin[] paints TagOnPaint[] supplies TagOnSupply[] @@unique([name, userId]) @@index([userId]) } model TagOnFilament { filamentId String tagId String filament Filament @relation(fields: [filamentId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) @@id([filamentId, tagId]) } model TagOnResin { resinId String tagId String resin Resin @relation(fields: [resinId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) @@id([resinId, tagId]) } model TagOnPaint { paintId String tagId String paint Paint @relation(fields: [paintId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) @@id([paintId, tagId]) } model Supply { id String @id @default(cuid()) name String @db.VarChar(128) brand String @db.VarChar(64) category String @db.VarChar(32) color String? @db.VarChar(64) colorHex String? @db.VarChar(7) totalAmount Float usedAmount Float @default(0) unit String @db.VarChar(16) purchaseDate DateTime? cost Float? notes String? @db.Text archived Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String vendorId String? locationId String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: SetNull) location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) tags TagOnSupply[] usageLogs UsageLog[] @@index([userId]) @@index([vendorId]) @@index([locationId]) @@index([category]) @@index([archived]) @@index([brand]) } model TagOnSupply { supplyId String tagId String supply Supply @relation(fields: [supplyId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) @@id([supplyId, tagId]) } model UsageLog { id String @id @default(cuid()) itemType String @db.VarChar(16) itemId String amount Float unit String @db.VarChar(16) notes String? @db.Text createdAt DateTime @default(now()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) filament Filament? @relation(fields: [filamentId], references: [id], onDelete: Cascade) filamentId String? resin Resin? @relation(fields: [resinId], references: [id], onDelete: Cascade) resinId String? paint Paint? @relation(fields: [paintId], references: [id], onDelete: Cascade) paintId String? supply Supply? @relation(fields: [supplyId], references: [id], onDelete: Cascade) supplyId String? @@index([userId]) @@index([itemType, itemId]) @@index([createdAt]) } model UserSettings { id String @id @default(cuid()) userId String @unique lowStockThreshold Float @default(10) currency String @default("USD") @db.VarChar(3) theme String @default("dark") @db.VarChar(8) units String @default("metric") @db.VarChar(8) user User @relation(fields: [userId], references: [id], onDelete: Cascade) } // ─────────────────────────────────────── // Telegram ingestion models // ─────────────────────────────────────── enum AuthState { PENDING AWAITING_CODE AWAITING_PASSWORD AUTHENTICATED EXPIRED } enum ChannelType { SOURCE DESTINATION } enum ChannelRole { READER WRITER } enum ArchiveType { ZIP RAR SEVEN_Z DOCUMENT } enum IngestionStatus { RUNNING COMPLETED FAILED CANCELLED } enum FetchStatus { PENDING IN_PROGRESS COMPLETED FAILED } model TelegramAccount { id String @id @default(cuid()) phone String @unique displayName String? isActive Boolean @default(true) authState AuthState @default(PENDING) authCode String? lastSeenAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt channelMaps AccountChannelMap[] ingestionRuns IngestionRun[] fetchRequests ChannelFetchRequest[] skippedPackages SkippedPackage[] @@index([isActive]) @@map("telegram_accounts") } model TelegramChannel { id String @id @default(cuid()) telegramId BigInt @unique title String type ChannelType isForum Boolean @default(false) isActive Boolean @default(false) category String? @db.VarChar(64) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt autoGroupEnabled Boolean @default(true) accountMaps AccountChannelMap[] packages Package[] skippedPackages SkippedPackage[] packageGroups PackageGroup[] groupingRules GroupingRule[] @@index([type, isActive]) @@index([category]) @@map("telegram_channels") } model AccountChannelMap { id String @id @default(cuid()) accountId String channelId String role ChannelRole @default(READER) lastProcessedMessageId BigInt? createdAt DateTime @default(now()) account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) channel TelegramChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) topicProgress TopicProgress[] @@unique([accountId, channelId]) @@index([accountId]) @@index([channelId]) @@map("account_channel_map") } model Package { id String @id @default(cuid()) contentHash String @unique fileName String fileSize BigInt archiveType ArchiveType creator String? sourceChannelId String sourceMessageId BigInt sourceTopicId BigInt? destChannelId String? destMessageId BigInt? destMessageIds BigInt[] @default([]) isMultipart Boolean @default(false) partCount Int @default(1) fileCount Int @default(0) tags String[] @default([]) sourceCaption String? // Caption text from source Telegram message replyToMessageId BigInt? // reply_to_message_id from source message (for reply chain grouping) previewData Bytes? // JPEG thumbnail from nearby Telegram photo (stored as raw bytes) previewMsgId BigInt? // Telegram message ID of the matched photo packageGroupId String? indexedAt DateTime @default(now()) createdAt DateTime @default(now()) sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id]) packageGroup PackageGroup? @relation(fields: [packageGroupId], references: [id], onDelete: SetNull) files PackageFile[] ingestionRun IngestionRun? @relation(fields: [ingestionRunId], references: [id]) ingestionRunId String? sendRequests BotSendRequest[] extractRequests ArchiveExtractRequest[] kickstarterLinks KickstarterPackage[] @@index([sourceChannelId]) @@index([destChannelId]) @@index([fileName]) @@index([indexedAt]) @@index([archiveType]) @@index([creator]) @@index([packageGroupId]) @@map("packages") } model PackageFile { id String @id @default(cuid()) packageId String path String fileName String extension String? compressedSize BigInt @default(0) uncompressedSize BigInt @default(0) crc32 String? package Package @relation(fields: [packageId], references: [id], onDelete: Cascade) @@index([packageId]) @@index([extension]) @@index([fileName]) @@map("package_files") } model PackageGroup { id String @id @default(cuid()) name String mediaAlbumId String? sourceChannelId String groupingSource GroupingSource @default(MANUAL) previewData Bytes? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt packages Package[] sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id], onDelete: Cascade) @@unique([mediaAlbumId, sourceChannelId]) @@index([sourceChannelId]) @@map("package_groups") } model IngestionRun { id String @id @default(cuid()) accountId String status IngestionStatus @default(RUNNING) startedAt DateTime @default(now()) finishedAt DateTime? messagesScanned Int @default(0) zipsFound Int @default(0) zipsDuplicate Int @default(0) zipsIngested Int @default(0) errorMessage String? // Live activity tracking — written by worker in real-time currentActivity String? // Human-readable: "Downloading pack.zip (part 2/5)" currentStep String? // Machine-readable step key currentChannel String? // Channel title being processed currentFile String? // File currently being processed currentFileNum Int? // Which archive set (1-indexed) totalFiles Int? // Total archive sets found downloadedBytes BigInt? // Current download progress in bytes totalBytes BigInt? // Total size of current download downloadPercent Int? // 0-100 lastActivityAt DateTime? // When activity was last updated account TelegramAccount @relation(fields: [accountId], references: [id]) packages Package[] @@index([accountId]) @@index([status]) @@index([startedAt]) @@map("ingestion_runs") } model TopicProgress { id String @id @default(cuid()) accountChannelMapId String topicId BigInt topicName String? lastProcessedMessageId BigInt? accountChannelMap AccountChannelMap @relation(fields: [accountChannelMapId], references: [id], onDelete: Cascade) @@unique([accountChannelMapId, topicId]) @@index([accountChannelMapId]) @@map("topic_progress") } model GlobalSetting { key String @id @db.VarChar(64) value String @db.Text updatedAt DateTime @updatedAt @@map("global_settings") } model InviteCode { id String @id @default(cuid()) code String @unique @db.VarChar(32) maxUses Int @default(1) uses Int @default(0) expiresAt DateTime? createdBy String createdAt DateTime @default(now()) creator User @relation("InviteCreator", fields: [createdBy], references: [id], onDelete: Cascade) usedBy User[] @relation("InviteUser") @@index([code]) @@map("invite_codes") } model ChannelFetchRequest { id String @id @default(cuid()) accountId String status FetchStatus @default(PENDING) resultJson String? @db.Text error String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) @@index([accountId, status]) @@map("channel_fetch_requests") } // ─────────────────────────────────────── // Telegram Bot models // ─────────────────────────────────────── enum BotSendStatus { PENDING SENDING SENT FAILED } /// Links a NextAuth user to a Telegram user ID. /// Created when a user sends /link to the bot. model TelegramLink { id String @id @default(cuid()) userId String @unique telegramUserId BigInt @unique telegramName String? @db.VarChar(128) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) sendRequests BotSendRequest[] @@map("telegram_links") } /// A queued request to send a package to a Telegram user via the bot. model BotSendRequest { id String @id @default(cuid()) packageId String telegramLinkId String requestedByUserId String status BotSendStatus @default(PENDING) error String? createdAt DateTime @default(now()) completedAt DateTime? package Package @relation(fields: [packageId], references: [id]) telegramLink TelegramLink @relation(fields: [telegramLinkId], references: [id], onDelete: Cascade) @@index([status]) @@index([telegramLinkId]) @@index([createdAt]) @@map("bot_send_requests") } /// Subscriptions for new-package notifications via the bot. model BotSubscription { id String @id @default(cuid()) telegramUserId BigInt pattern String @db.VarChar(256) createdAt DateTime @default(now()) @@unique([telegramUserId, pattern]) @@index([telegramUserId]) @@map("bot_subscriptions") } // ─────────────────────────────────────── // Archive image extraction (worker-mediated) // ─────────────────────────────────────── enum ExtractStatus { PENDING IN_PROGRESS COMPLETED FAILED } /// A request for the worker to extract an image from an archive. /// The web app creates this, sends a pg_notify, and the worker /// downloads the archive, extracts the file, and writes the result. model ArchiveExtractRequest { id String @id @default(cuid()) packageId String filePath String @db.VarChar(1024) // path within archive to extract status ExtractStatus @default(PENDING) imageData Bytes? // extracted image bytes (JPEG/PNG/WebP) contentType String? @db.VarChar(64) // MIME type of extracted image error String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt package Package @relation(fields: [packageId], references: [id], onDelete: Cascade) @@index([packageId, filePath]) @@index([status]) @@map("archive_extract_requests") } // ─────────────────────────────────────── // Skipped/Failed Archives // ─────────────────────────────────────── enum SkipReason { SIZE_LIMIT DOWNLOAD_FAILED EXTRACT_FAILED UPLOAD_FAILED } model SkippedPackage { id String @id @default(cuid()) fileName String fileSize BigInt reason SkipReason errorMessage String? sourceChannelId String sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id], onDelete: Cascade) sourceMessageId BigInt sourceTopicId BigInt? isMultipart Boolean @default(false) partCount Int @default(1) accountId String account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@unique([sourceChannelId, sourceMessageId]) @@index([reason]) @@index([accountId]) @@map("skipped_packages") } // ─────────────────────────────────────── // Purchased Kickstarters // ─────────────────────────────────────── enum DeliveryStatus { NOT_DELIVERED PARTIAL DELIVERED } enum PaymentStatus { PAID UNPAID } model KickstarterHost { id String @id @default(cuid()) name String @unique createdAt DateTime @default(now()) kickstarters Kickstarter[] @@map("kickstarter_hosts") } model Kickstarter { id String @id @default(cuid()) name String link String? filesUrl String? deliveryStatus DeliveryStatus @default(NOT_DELIVERED) paymentStatus PaymentStatus @default(UNPAID) notes String? hostId String? userId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt host KickstarterHost? @relation(fields: [hostId], references: [id], onDelete: SetNull) user User @relation(fields: [userId], references: [id], onDelete: Cascade) packages KickstarterPackage[] @@index([hostId]) @@index([userId]) @@index([deliveryStatus]) @@index([paymentStatus]) @@map("kickstarters") } model KickstarterPackage { kickstarterId String packageId String kickstarter Kickstarter @relation(fields: [kickstarterId], references: [id], onDelete: Cascade) package Package @relation(fields: [packageId], references: [id], onDelete: Cascade) @@id([kickstarterId, packageId]) @@map("kickstarter_packages") } // ── Grouping & Notifications ── enum GroupingSource { ALBUM MANUAL AUTO_TIME AUTO_PATTERN AUTO_REPLY AUTO_ZIP AUTO_CAPTION } enum NotificationType { HASH_MISMATCH MISSING_PART UPLOAD_FAILED DOWNLOAD_FAILED GROUPING_CONFLICT INTEGRITY_AUDIT } enum NotificationSeverity { INFO WARNING ERROR } model SystemNotification { id String @id @default(cuid()) type NotificationType severity NotificationSeverity @default(INFO) title String message String context Json? isRead Boolean @default(false) createdAt DateTime @default(now()) @@index([isRead, createdAt]) @@index([type]) @@map("system_notifications") } model GroupingRule { id String @id @default(cuid()) sourceChannelId String pattern String // Regex or keyword pattern learned from manual grouping signalType GroupingSource // Which grouping signal this rule applies to confidence Float @default(1.0) createdAt DateTime @default(now()) createdByGroupId String? // The manual group that spawned this rule sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id], onDelete: Cascade) @@index([sourceChannelId]) @@map("grouping_rules") } enum ManualUploadStatus { PENDING PROCESSING COMPLETED FAILED } model ManualUpload { id String @id @default(cuid()) status ManualUploadStatus @default(PENDING) groupName String? // Group name if multiple files userId String errorMessage String? createdAt DateTime @default(now()) completedAt DateTime? files ManualUploadFile[] user User @relation(fields: [userId], references: [id]) @@index([status]) @@map("manual_uploads") } model ManualUploadFile { id String @id @default(cuid()) uploadId String fileName String filePath String // Path on shared volume fileSize BigInt packageId String? // Set after processing upload ManualUpload @relation(fields: [uploadId], references: [id], onDelete: Cascade) @@index([uploadId]) @@map("manual_upload_files") }