Files
Tautvydas Derzinskas 88949d9d8c feat: let users choose what their API token can read over the REST API
The API token read every GET endpoint. It now reads only the scopes its user allows
(search, models, dashboard, activity, collections, tags, categories, authors, images,
model files, imports, everything else), set beside the MCP tools in a sectioned Token
permissions dialog. Existing and new tokens get every scope, so integrations such as
Homarr and holm keep working; a refused request names the scope it needs.
2026-10-21 17:57:28 +02:00

758 lines
31 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
MEMBER
}
enum CategorySource {
MANUAL
RULE
AI
LEGACY
}
/// What an AI connection sends as reasoning_effort, spelled the way providers expect it.
enum ReasoningEffort {
none
low
medium
high
}
model User {
id String @id @default(cuid())
email String @unique
// Null for someone who has only ever signed in through an OIDC provider.
passwordHash String?
displayName String
role Role @default(MEMBER)
// The first admin: may demote and delete other admins, and can be neither demoted nor deleted.
isOwner Boolean @default(false)
// Defaults true so existing accounts aren't locked out; registration sets false when SMTP is on.
emailVerified Boolean @default(true)
emailVerificationToken String? @unique
emailVerificationExpires DateTime?
// Awaiting confirmation of an email change.
pendingEmail String?
// SHA-256 of the emailed token, so a database leak can't be used to take over accounts.
passwordResetTokenHash String? @unique
passwordResetExpires DateTime?
// A read-only token other apps use to call the API as this user. Only its SHA-256
// is stored; the token itself is shown once, when generated. One per user: generating replaces it.
apiTokenHash String? @unique
// The token's last four characters, so the profile can show which one is in use.
apiTokenHint String?
apiTokenCreatedAt DateTime?
apiTokenLastUsedAt DateTime?
// The MCP tools the token may call at /api/mcp (see mcp/tools.ts). Chosen per token, so a tool
// that writes is only ever reachable if the user ticked it.
apiTokenMcpTools String[] @default([])
// What the token may read over the REST API, by scope (see services/apiTokenScopes.ts).
apiTokenRestScopes String[] @default([])
// Per-user, unlike the instance-wide Thingiverse token.
makerworldCookie String?
// When MakerWorld last turned the cookie down. Scheduled MakerWorld syncs wait until it changes.
makerworldRejectedAt DateTime?
// Who the user is on providers without a per-user login, so their collections can be listed for
// syncing. Printables keeps its numeric id (the API's key) with the handle to show.
printablesUserId String?
printablesHandle String?
// Set when the user forgot their Printables account in the app: Thingport Grab's pushes of their
// private collections and likes are refused until they give one again.
printablesPushOff Boolean @default(false)
thingiverseUsername String?
myminifactoryUsername String?
// Per-user, beside the instance-wide API key: the MyMiniFactory session (PHPSESSID) that downloads
// paid and login-only models from the user's own library.
myminifactoryCookie String?
// When MyMiniFactory last turned the session down.
myminifactoryRejectedAt DateTime?
// The user's Snapmaker access token (a JWT, valid a day). Snapmaker's refresh tokens work once, so Thingport never
// refreshes it itself: Thingport Grab keeps the browser's login fresh and sends each new one here.
snapmakerToken String?
snapmakerTokenExpiresAt DateTime?
// Their Snapmaker user id, from the token: their likes and bookmarks are synced under it.
snapmakerUserId String?
// When Snapmaker last turned the token down.
snapmakerRejectedAt DateTime?
// The user's MakerOnline access token (a JWT, valid about 90 days), from its mo_access_token cookie. It reads
// models, likes and collections; files only download through Thingport Grab (see GrabImport).
makeronlineToken String?
makeronlineTokenExpires DateTime?
// Their MakerOnline user id, from the token: their likes are synced under it.
makeronlineUserId String?
// When MakerOnline last turned the token down.
makeronlineRejectedAt DateTime?
// The user's Creality Cloud token, from its model_token cookie. The site sets it once at sign-in for 7 days and
// never renews it; Thingport Grab sends each new one here.
crealitycloudToken String?
crealitycloudExpiresAt DateTime?
// Their Creality Cloud user id (model_user_id): their likes are synced under it.
crealitycloudUserId String?
// When Creality Cloud last turned the token down.
crealitycloudRejectedAt DateTime?
// The user's NexPrint token, from its auth_token cookie, sent as a Bearer token. The site keeps the cookie for a
// week and renews it as it refreshes the token; Thingport Grab sends each new one here.
nexprintToken String?
nexprintExpiresAt DateTime?
// When NexPrint last turned the token down.
nexprintRejectedAt DateTime?
// The user's Thangs sign-in: the accessToken cookie (a 7-day JWT, sent as a Bearer token) and the refreshToken
// cookie that renews it, which lasts about a year and isn't replaced on use. Thingport renews the access token
// itself; Thingport Grab sends both whenever the browser's change.
thangsToken String?
thangsRefreshToken String?
// The access token's own expiry, from its JWT.
thangsExpiresAt DateTime?
thangsUserId String?
// When Thangs last turned the sign-in down, refresh token included.
thangsRejectedAt DateTime?
// The user's Cults3D API credentials (their nickname and an API key they made on the site), which read models,
// likes, printlists and orders without Cloudflare in the way.
cults3dNick String?
cults3dApiKey String?
// When Cults3D last turned the API key down.
cults3dApiRejectedAt DateTime?
// The browser's Cults3D sign-in, sent by Thingport Grab: the _session_id cookie (20 days, renewed on use), and the
// cf_clearance cookie with the User-Agent it was issued to, which the site's Cloudflare wants for the order and
// download pages. The clearance only holds from the browser's own IP address.
cults3dSession String?
cults3dClearance String?
cults3dUserAgent String?
// When Cults3D last answered the session with its sign-in page.
cults3dSessionRejectedAt DateTime?
slicer String?
// "light" | "dark" | "system"; null means never set.
themePreference String?
// One of ACCENT_COLORS; null means Thingport green.
accentColor String?
// What generated renders are tinted, one of RENDER_COLORS; null follows the accent.
renderColor String?
// Which tree the models page shows: "categories" | "folders"; null means categories.
categoriesView String?
// The dashboard widgets the user has switched on or off, as { [widgetId]: boolean }. A widget
// missing from it shows or hides by its own default, so new widgets need no migration.
dashboardWidgets Json?
// How the dashboard arranges its boxes, one of DASHBOARD_LAYOUTS; null means "left".
dashboardLayout String?
// What model cards lead with, one of CARD_PREVIEWS; null means "images".
cardPreview String?
// Where the dashboard's weather is for: { lat, lon, name, source }. Null until the user sets one.
weatherLocation Json?
authorPreviewEnabled Boolean @default(true)
// Model cards cycle through a model's pictures while hovered.
hoverSlideshowEnabled Boolean @default(true)
// Filled from the first linked Author with this data; later links never overwrite it.
bio String?
backgroundUrl String?
categories Category[]
prints Print[]
collections Collection[]
collectionSyncs CollectionSync[]
providerListings ProviderListing[]
importJobs ImportJob[]
grabImports GrabImport[]
notifications Notification[]
systemReceipts SystemReceipt[]
logs Log[]
authorLinks AuthorLink[]
bookmarks Bookmark[]
activities Activity[]
invitationsSent Invitation[]
identities UserIdentity[]
grabConnections GrabConnection[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Lets one email register while registrations are closed. Re-inviting replaces the token.
model Invitation {
id String @id @default(cuid())
email String @unique
token String @unique
invitedById String?
invitedBy User? @relation(fields: [invitedById], references: [id], onDelete: SetNull)
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Category {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
tags String[] @default([])
// "category" (the starter categories imports sort into) or "folder" (the user's own tree, e.g.
// from uploading folders). Always the same as the top-level ancestor's.
kind String @default("folder")
parentId String?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryTree")
// Order among siblings. No unique index: Postgres treats NULL parentIds as distinct.
position Int @default(0)
metaTitle String?
metaDescription String?
// Imports whose site category id matches any of these land in this category. Each site has its
// own id namespace.
makerworldCatIds Int[] @default([])
thingiverseCatIds Int[] @default([])
printablesCatIds Int[] @default([])
myminifactoryCatIds Int[] @default([])
snapmakerCatIds Int[] @default([])
makeronlineCatIds Int[] @default([])
crealitycloudCatIds Int[] @default([])
// Text: NexPrint's ids are 16 digits, past Int, and a BigInt would break JSON serialization of the row.
nexprintCatIds String[] @default([])
// Thangs has no category ids, only paths: "Tools & Organizers", or "Tools & Organizers/Multiboard".
thangsCatIds String[] @default([])
cults3dCatIds 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)
@@index([myminifactoryCatIds], type: Gin)
@@index([snapmakerCatIds], type: Gin)
@@index([makeronlineCatIds], type: Gin)
@@index([crealitycloudCatIds], type: Gin)
@@index([nexprintCatIds], type: Gin)
@@index([thangsCatIds], type: Gin)
@@index([cults3dCatIds], 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?
tags String[] @default([])
viewCount Int @default(0)
printCount Int @default(0)
// Back the Favourites and Browsing History pseudo-collections.
favoritedAt DateTime?
lastViewedAt DateTime?
// Dedupes re-imports of the same design. Null for uploads and unidentifiable sources.
sourceProvider String?
sourceExternalId String?
// Gaps (see services/sourceGaps.ts) the source had nothing for at the last fill.
unfillableGaps String[] @default([])
categoryId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
categorySource CategorySource?
// A provider result awaiting the owner's review: { categoryId, confidence, reason, model, createdAt }.
aiSuggestion Json?
// A rejected category is never offered to the provider for this model again.
aiRejectedCategoryIds String[] @default([])
// Improved descriptions: a suggested rewrite awaiting review, { notes, model, createdAt }.
aiDescriptionSuggestion Json?
// The description before an AI rewrite was applied, so it can be reverted; null once edited by hand.
aiDescriptionOriginal String?
aiDescriptionAppliedAt DateTime?
// Of the description and the options the AI last looked at, so a run skips models with nothing new.
aiDescriptionCheckedHash String?
authorId String?
author Author? @relation(fields: [authorId], references: [id], onDelete: SetNull)
sourcePath String?
// The folder beside its files that holds the model's pictures (see services/modelAssets.ts), relative to
// storage. Null for a model from before, whose pictures are still in the shared folders keyed by id.
assetsPath String? @unique
plates Plate[]
previewImages PreviewImage[]
descriptionImages DescriptionImage[]
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
// Generated column (name A, tags B, notes C, creator D) created with its GIN index in a
// hand-written migration, since Prisma can't express a generated tsvector.
searchVector Unsupported("tsvector")?
@@unique([userId, categoryId, nameNormalized])
@@unique([userId, sourceProvider, sourceExternalId])
@@index([userId, categoryId, name])
@@index([tags], type: Gin)
@@index([authorId])
@@index([userId, favoritedAt])
@@index([userId, lastViewedAt])
}
/// A user-curated group of models. Collection imports reuse a row by nameNormalized.
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[]
bookmarks Bookmark[]
sync CollectionSync?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Generated column (name A, tags B, description C); see Print.searchVector.
searchVector Unsupported("tsvector")?
@@unique([userId, nameNormalized])
@@index([tags], type: Gin)
}
/// Keeps a collection in step with one on a provider: models added there later are imported into
/// it by the scheduled sync (services/collectionSyncRunner.ts). Only Thingport Grab creates these.
/// Tracked by the provider's own id, so renaming either side keeps the link.
model CollectionSync {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
collectionId String @unique
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
provider String
externalId String
sourceUrl String
// MakerWorld only: which print profiles a new model brings ("url" is the model's default one).
// New MakerWorld syncs start on "designer" (see createSync).
profileScope String @default("url")
// How often the provider's collection is checked: 1, 6 or 24.
intervalHours Int @default(24)
// Every model id the provider's collection has listed, so a sync only imports what's new since.
knownIds String[] @default([])
// Checks in a row that found the provider's collection gone. One bad answer doesn't unsync it.
missingChecks Int @default(0)
lastCheckedAt DateTime?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
importJobs ImportJob[]
@@unique([userId, provider, externalId])
}
/// Lists Thingport Grab reads from a provider with the user's own login, for what the instance
/// can't read itself: Printables shows private collections and likes to their owner only. Pushed
/// on the user's visits to the site, so they're as fresh as the last one.
model ProviderListing {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider String
/// "collections": the user's collections, as the Browse tab shows them. "likes": their liked
/// models. Anything else: a collection's id, for its model ids.
key String
data Json
updatedAt DateTime @updatedAt
@@unique([userId, provider, key])
}
/// An import waiting for Thingport Grab. MakerOnline signs its download links only for a page its own anti-bot script
/// runs on, so a MakerOnline import started in the app, the queue or a sync waits here until Grab, on the next
/// makeronline.com page it sees, signs the files and imports it.
model GrabImport {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// The model's page, with `#profileId-<id>` when it names a print profile.
url String
/// "url" | "designer" | "all", as for MakerWorld.
scope String @default("url")
collectionId String?
categoryId String?
tags String[] @default([])
notes String?
attempts Int @default(0)
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, url])
}
enum BookmarkType {
TAG
COLLECTION
}
/// A tag or collection pinned to the sidebar. Both kinds share one `order` sequence.
///
/// Exactly one of `tag` / `collectionId` is set, matching `type`. Postgres treats NULLs as
/// distinct, so each @@unique only dedupes its own type's rows.
model Bookmark {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type BookmarkType
tag String?
collectionId String?
collection Collection? @relation(fields: [collectionId], references: [id], onDelete: Cascade)
order Int
createdAt DateTime @default(now())
@@unique([userId, type, tag])
@@unique([userId, collectionId])
@@index([userId, order])
}
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
PROFILES
LINKS
}
enum ImportJobStatus {
RUNNING
DONE
ERROR
// A LINKS job waiting for someone to start it: the extension's "Add to the queue" collects links
// here, and pausing a running job parks its remaining links here too.
PAUSED
}
/// A background batch import. At most one RUNNING job per user (enforced in routes/imports.ts).
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?
// Set when the job created exactly one Print, so the frontend can open it directly.
resultPrintId String?
// The LINKS job's shared request body (never the MakerWorld cookie), so a retry can rerun it.
payload Json?
// Set on the LINKS jobs a collection sync queues. They pause on a rate limit instead of failing
// the rest, and are kept out of the extension's "Add to the queue" job.
collectionSyncId String?
collectionSync CollectionSync? @relation(fields: [collectionSyncId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items ImportJobItem[]
@@index([userId, status])
@@index([collectionSyncId])
}
enum ImportJobItemStatus {
PENDING
RUNNING
DONE
FAILED
}
/// One queued URL of a LINKS job, kept so failed links can be retried after a LINKS run.
model ImportJobItem {
id String @id @default(cuid())
jobId String
job ImportJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
url String
status ImportJobItemStatus @default(PENDING)
attempts Int @default(0)
errorMessage String?
// Per-link options a queued link carries on its own (collection_id, scope, title), over the
// job's shared payload.
payload Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([jobId, status])
}
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// What it's about, for its icon: import, sync, queue, consume, ai, system or general.
type String @default("general")
/// Set on notifications later ones may fold into, e.g. { key: "single-import", items: [...] }.
group Json?
title String
body String?
externalUrl String?
internalPath String?
readAt DateTime?
createdAt DateTime @default(now())
@@index([userId, readAt])
@@index([userId, createdAt])
// For the daily cleanup of old notifications.
@@index([createdAt])
}
/// Which system notifications (from the repo's system-notifications.json) a user was already sent,
/// so deleting or pruning the notification never brings it back.
model SystemReceipt {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
systemId String
createdAt DateTime @default(now())
@@id([userId, systemId])
@@index([createdAt])
}
/// A creator on a source platform. `id` is provider-prefixed ("makerworld:3582000380") so
/// re-imports upsert the same row.
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[]
link AuthorLink?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, externalId])
}
/// Claims an Author as "this is me". An Author maps to at most one user, and a user has at most
/// one linked identity per provider.
model AuthorLink {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
authorId String @unique
author Author @relation(fields: [authorId], references: [id], onDelete: Cascade)
// Denormalized so the [userId, provider] unique index needs no join.
provider String
createdAt DateTime @default(now())
@@unique([userId, provider])
}
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?
// The MakerWorld profile ("instance") this file came from. Each profile's 3MF has its own print
// settings, so each gets its own Plate.
sourceInstanceId String?
// Computed lazily; plate files are never rewritten in place.
contentSha256 String?
// The thumb is our own 3D render, which an imported image replaces.
thumbGenerated Boolean @default(false)
// The printability check's result (services/meshQuality.ts QualityReport), or { version, status: "skipped" }
// for a file it couldn't check. Null until the model is first opened with checks on.
qualityCheck Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([printId, position])
@@unique([printId, sourceInstanceId])
@@index([printId])
}
/// A photo of the finished model, stored as a normalized JPEG keyed by id. Position 0 is the
/// main image.
model PreviewImage {
id String @id @default(cuid())
printId String
print Print @relation(fields: [printId], references: [id], onDelete: Cascade)
position Int
// Our own 3D render, which an imported image replaces.
generated Boolean @default(false)
createdAt DateTime @default(now())
@@unique([printId, position])
@@index([printId])
}
enum PrintFileRole {
SUPPORTING
PREPARED
}
// Images embedded in a print's Markdown notes, stored locally so the description survives the
// source changing or going away. Referenced from the notes as /description-image/<id>.
model DescriptionImage {
id String @id @default(cuid())
printId String
print Print @relation(fields: [printId], references: [id], onDelete: Cascade)
// Where an imported image came from, so a refresh reuses it instead of downloading again.
sourceUrl String?
mime String
size Int
createdAt DateTime @default(now())
@@index([printId])
}
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
}
/// An OpenAI-compatible AI service. Features use the default one unless set to another.
model AiConnection {
id String @id @default(cuid())
baseUrl String
/// Write-only: the API only ever says whether one is set.
apiKey String?
model String
timeoutMs Int @default(60000)
/// How many requests may wait on this provider at once.
concurrency Int @default(1)
/// Sent as reasoning_effort. Null sends nothing, leaving it to the provider.
reasoningEffort ReasoningEffort?
isDefault Boolean @default(false)
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// An OpenID Connect identity provider (Authentik, PocketID, ...) people can sign in with.
model OidcProvider {
id String @id @default(cuid())
/// In the redirect URI, so it can't change once the IdP has been told about it.
slug String @unique
name String
issuerUrl String
clientId String
/// Write-only: the API only ever says whether one is set. Null for a public client.
clientSecret String?
scopes String @default("openid email profile")
enabled Boolean @default(true)
/// Someone the provider vouches for but Thingport doesn't know gets a new account.
createAccounts Boolean @default(false)
/// A first sign-in attaches to the account with the same (provider-verified) email.
linkByEmail Boolean @default(false)
position Int @default(0)
identities UserIdentity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// Who a user is at an OIDC provider: its `sub`, which stays put when their email changes.
model UserIdentity {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
providerId String
provider OidcProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
subject String
/// The email the provider last gave, to show which of their accounts it is.
email String?
lastUsedAt DateTime?
createdAt DateTime @default(now())
@@unique([providerId, subject])
@@index([userId])
}
/// A browser's Thingport Grab, connected by the user approving it in Thingport rather than by saving their
/// password in the extension. It trades its token for sessions, until the user removes it.
model GrabConnection {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// SHA-256 of the token; the token itself only ever reaches the extension.
tokenHash String @unique
/// Which browser it is, e.g. "Chrome on macOS", so the user can tell them apart.
name String
lastUsedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
/// Admin audit trail. Written fire-and-forget so logging never breaks the action.
model Log {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
action String
targetId String?
details Json @default("{}")
createdAt DateTime @default(now())
@@index([userId, createdAt])
@@index([createdAt])
}
// One per thing the user did to a model, for their activity heatmap: "import", "upload", "delete",
// "download" or "slicer". Separate from Log, which an import job writes once for many models and
// which can't tell a download from a slicer open.
model Activity {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kind String
// No relation: a deleted model keeps its history, shown by the name it had.
printId String?
name String?
createdAt DateTime @default(now())
@@index([userId, createdAt])
}