Files
dragonsstash/prisma/schema.prisma
xCyanGrizzly f4aa9d9a2f
All checks were successful
continuous-integration/drone/push Build is passing
feat: complete remaining features — training, FTS, bot groups, repair, re-tag
Manual override training (GroupingRule):
- Learn patterns from manual group creation (common filename prefix or creator)
- Apply learned rules as first auto-grouping pass (highest confidence after albums)
- GroupingRule model stores pattern, channel, signal type, confidence

Hash verification after upload:
- Re-hash upload files on disk before indexing to catch disk corruption
- Creates HASH_MISMATCH notification on discrepancy

Grouping conflict detection:
- After all grouping passes, check if grouped packages match rules from different groups
- Creates GROUPING_CONFLICT notification for manual review

Per-channel grouping flags:
- Add autoGroupEnabled boolean to TelegramChannel (default true)
- Auto-grouping passes (all except album) gated behind this flag
- Album grouping always runs as it reflects Telegram's native behavior

Full-text search (tsvector):
- Add searchVector tsvector column with GIN index and auto-update trigger
- Backfill 1870 existing packages
- FTS with ts_rank for ranked results, ILIKE fallback for short/failed queries
- Applied to both web app and bot search

Bot group awareness:
- /group <query> — view group info or search groups by name
- /sendgroup <id> — send all packages in a group to linked Telegram account

Bulk repair:
- repairPackageAction clears dest info and resets watermark for re-processing
- Repair button in notification bell for MISSING_PART and HASH_MISMATCH alerts
- /api/notifications/repair endpoint

Retroactive category re-tagging:
- When channel category changes, auto-update tags on all existing packages
- Removes old category tag, adds new one

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:34:14 +02:00

868 lines
25 KiB
Plaintext

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?
}
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 <code> 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")
}