Files
Thingport/backend/prisma/schema.prisma
T

291 lines
11 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
MEMBER
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
displayName String
role Role @default(MEMBER)
folders Folder[]
prints Print[]
collections Collection[]
importJobs ImportJob[]
notifications Notification[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Folder {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
tags String[] @default([])
parentId String?
parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: SetNull)
children Folder[] @relation("FolderTree")
// Manual sort order within a sibling group (same userId + parentId), set by the "move up/down"
// controls in the category manager. Not DB-uniqueness-constrained (parentId is nullable, and
// Postgres treats NULLs as distinct, so a unique index wouldn't actually cover the root-level
// sibling group) -- ordering is only ever applied via the reorder endpoint, which always
// rewrites every sibling's position together.
position Int @default(0)
metaTitle String?
metaDescription String?
// When set, an import from that site whose own category id matches ANY of these auto-lands
// in this folder (see importService.ts's resolveFolderIdByCategory -- a Prisma `hasSome`
// overlap check), unless the caller already picked a folder explicitly. Each site's category
// ids are independent integer namespaces. A folder can list several ids (e.g. a parent
// category and a couple of its subcategories) since one folder may reasonably want to catch
// more than one upstream category -- entered/edited as a "800;71;1001"-style semicolon-
// separated string (see routes/folders.ts's parseCatIdsInput), stored as a real int array.
makerworldCatIds Int[] @default([])
thingiverseCatIds Int[] @default([])
printablesCatIds Int[] @default([])
prints Print[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, parentId, position])
@@index([tags], type: Gin)
@@index([makerworldCatIds], type: Gin)
@@index([thingiverseCatIds], type: Gin)
@@index([printablesCatIds], type: Gin)
}
model Print {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
nameNormalized String
title String?
notes String?
creator String?
collection String?
tags String[] @default([])
viewCount Int @default(0)
printCount Int @default(0)
// Backs the two built-in "Favourites"/"Browsing History" pseudo-collections (see
// routes/collections.ts's SYSTEM_COLLECTIONS) -- not a real Collection/CollectionItem row.
// lastViewedAt is stamped on every GET /print/:id alongside viewCount, so "Browsing History"
// falls out of `orderBy: lastViewedAt desc` with no separate event log.
favoritedAt DateTime?
lastViewedAt DateTime?
// Identifies the source design this print was imported from (MakerWorld today), so a
// re-import of the same design -- whether via a plain link paste or as part of a different
// collection's batch import -- reuses this row instead of re-downloading a duplicate. Null
// for uploads, zip/folder-scan imports, and anything not resolvable to a stable provider id.
sourceProvider String?
sourceExternalId String?
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
authorId String?
author Author? @relation(fields: [authorId], references: [id], onDelete: SetNull)
sourcePath String?
plates Plate[]
previewImages PreviewImage[]
files PrintFile[] @relation("PrintFiles")
preparedFileId String? @unique
preparedFile PrintFile? @relation("PreparedFile", fields: [preparedFileId], references: [id], onDelete: SetNull)
preparedMetadata Json?
collectionItems CollectionItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, folderId, nameNormalized])
@@unique([userId, sourceProvider, sourceExternalId])
@@index([userId, folderId, name])
@@index([tags], type: Gin)
@@index([authorId])
@@index([userId, favoritedAt])
@@index([userId, lastViewedAt])
}
/// A user-curated, named group of models -- acts like a "playlist" for prints. Populated
/// manually (via the create/edit modal) or automatically when importing a MakerWorld
/// collection by URL (see collectionService.findOrCreateCollectionByName), which matches
/// on nameNormalized so re-importing the same MakerWorld collection reuses the same row
/// instead of creating a duplicate.
model Collection {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
nameNormalized String
description String?
tags String[] @default([])
items CollectionItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, nameNormalized])
@@index([tags], type: Gin)
}
/// Join row between Collection and Print (many-to-many), with a position for cover/display
/// ordering.
model CollectionItem {
id String @id @default(cuid())
collectionId String
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
printId String
print Print @relation(fields: [printId], references: [id], onDelete: Cascade)
position Int @default(0)
createdAt DateTime @default(now())
@@unique([collectionId, printId])
@@index([collectionId, position])
@@index([printId])
}
enum ImportJobType {
COLLECTION
ZIP
}
enum ImportJobStatus {
RUNNING
DONE
ERROR
}
/// Tracks one background batch import (a MakerWorld collection, or a remote zip's selected
/// entries) so the triggering request can return immediately instead of blocking on however
/// long the whole batch takes -- see services/importJobRunner.ts. At most one RUNNING job per
/// user is ever allowed (enforced in routes/imports.ts); that single row doubles as the "an
/// import is already in progress" lock, and survives a page refresh since the frontend can just
/// ask for the user's active job again.
model ImportJob {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type ImportJobType
status ImportJobStatus @default(RUNNING)
sourceUrl String
sourceLabel String?
provider String?
total Int @default(0)
processed Int @default(0)
imported Int @default(0)
alreadyInLibrary Int @default(0)
failedCount Int @default(0)
errorMessage String?
resultCollectionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, status])
}
/// A generic per-user notification -- today only ever created by importJobRunner.ts when a
/// batch import finishes, but the shape (title/body + an optional in-app and/or external link)
/// is deliberately not import-specific so a future notification source can reuse it.
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
body String?
externalUrl String?
internalPath String?
readAt DateTime?
createdAt DateTime @default(now())
@@index([userId, readAt])
@@index([userId, createdAt])
}
/// A creator/designer on some source platform (MakerWorld today; Printables/Thingiverse etc.
/// later). `id` is provider-prefixed ("makerworld:3582000380") rather than a generated cuid so
/// re-importing a model from the same author upserts the same row instead of creating a
/// duplicate. Print.creator stays as the plain display string for backward compatibility and
/// non-provider sources; this table is the richer record clickable author pages will use.
model Author {
id String @id
provider String
externalId String
name String?
handle String?
bio String?
bioTranslated String?
links String[] @default([])
avatarUrl String?
backgroundUrl String?
prints Print[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, externalId])
}
model Plate {
id String @id @default(cuid())
printId String
print Print @relation(fields: [printId], references: [id], onDelete: Cascade)
position Int
filename String
mime String
size Int @default(0)
storagePath String @unique
sourcePath String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([printId, position])
@@index([printId])
}
/// A photo of the finished model, shown as the model detail page's main image gallery -- distinct
/// from Plate (a printable 3D file) and PrintFile (a supporting document/prepared print). Always
/// stored as a normalized JPEG under config.PREVIEWS, keyed by id, so there's no filename/mime/
/// size to track the way Plate and PrintFile need. Position 0 is the default/main image.
model PreviewImage {
id String @id @default(cuid())
printId String
print Print @relation(fields: [printId], references: [id], onDelete: Cascade)
position Int
createdAt DateTime @default(now())
@@unique([printId, position])
@@index([printId])
}
enum PrintFileRole {
SUPPORTING
PREPARED
}
model PrintFile {
id String @id @default(cuid())
printId String
print Print @relation("PrintFiles", fields: [printId], references: [id], onDelete: Cascade)
role PrintFileRole
filename String
mime String
size Int @default(0)
storagePath String @unique
metadata Json?
createdAt DateTime @default(now())
preparedFor Print? @relation("PreparedFile")
@@index([printId, role])
}
model Setting {
key String @id
value Json
}