Files
teknav-bak/app/backend/prisma/schema.prisma
T
arsam 393c6b034a Import source: app/backend/prisma/schema.prisma
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:18:59 +03:30

822 lines
25 KiB
Plaintext

// Teknav DB schema — Postgres 16 + Prisma 5
// Mirrors the shapes used by the legacy localStorage TeknavStore.
// Persian-FTS extras (tsvector + GIN index + diacritic-stripping fn) live in
// prisma/migrations/00000000000001_persian_fts/migration.sql
// and are applied as a follow-up migration after the initial schema.
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum UserRole {
admin
editor
writer
reviewer
reader
}
enum UserStatus {
active
suspended
pending
}
enum NotificationType {
comment
comment_reply
new_article
review_approved
review_revision
review_rejected
review_submitted
system
}
enum ReviewStatus {
pending
revision_requested
approved
rejected
}
model User {
id String @id @default(cuid())
email String @unique
phone String? @unique
username String? @unique // URL-safe slug, e.g. "coaxys"
passwordHash String?
name String
bio String? @db.VarChar(280)
avatarUrl String?
emailVerifiedAt DateTime?
phoneVerifiedAt DateTime?
twoFactorEnabled Boolean @default(false)
twoFactorMethod String? @default("sms")
membershipTier String @default("free")
membershipExpiresAt DateTime?
role UserRole @default(reader)
status UserStatus @default(active)
// Growth & Community
streakCount Int @default(0)
lastReadAt DateTime?
// OAuth — populated when the user signs in with Google/GitHub.
oauthProvider String? // 'google' | 'github'
oauthSubject String? // provider-side user id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
passwordChangedAt DateTime?
author Author?
corrections ArticleCorrection[] @relation("ArticleCorrectionEditor")
authoredArticles Article[] @relation("ArticleCreatedBy")
uploadedMedia MediaAsset[] @relation("MediaUploadedBy")
reactions Reaction[]
saved SavedArticle[]
activity ActivityLog[]
comments Comment[]
commentFlags CommentFlag[]
readHistory ReadHistory[]
notifications Notification[]
following WriterFollow[] @relation("Follower")
followers WriterFollow[] @relation("Writer")
topicFollows TopicFollow[]
newsletterCampaigns NewsletterCampaign[]
commentLikes CommentLike[]
commentUpvotes CommentUpvote[]
reviewAssignments ArticleReview[] @relation("ArticleReviewReviewer")
qualityReviews Article[] @relation("ArticleQualityReviewer")
readingLists ReadingList[]
analyticsEvents AnalyticsEvent[]
questions Question[]
answers Answer[]
clientErrors ClientError[]
membershipPayments MembershipPayment[]
pushSubscriptions PushSubscription[]
notificationPreferences NotificationPreference[]
badges UserBadge[]
revisionsAuthored ArticleRevision[] @relation("ArticleRevisionSavedBy")
wikiProposals WikiProposal[]
glossaryProposals GlossaryProposal[]
quizAttempts QuizAttempt[]
certifications UserCertification[]
companyClaims CompanyClaim[]
ownedCompany CompanyProfile? @relation("CompanyOwner")
glossaryReviewed GlossaryProposal[] @relation("GlossaryProposalReviewer")
wikiReviewed WikiProposal[] @relation("WikiProposalReviewer")
companyClaimsReviewed CompanyClaim[] @relation("CompanyClaimReviewer")
@@unique([oauthProvider, oauthSubject])
@@index([role, status])
}
model Author {
id String @id @default(cuid())
slug String @unique
name String
specialty String
bio String
initials String
color String
verifiedExpert Boolean @default(false)
verificationNote String?
social Json @default("{}")
articleCount Int @default(0)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId String? @unique
articles Article[]
}
model Category {
id String @id @default(cuid())
slug String @unique
name String
description String
color String
diagram String?
articleCount Int @default(0)
articles Article[]
}
model Tag {
id String @id @default(cuid())
name String @unique
count Int @default(0)
articles ArticleTag[]
}
// type/status kept as String (not enum) to preserve the Persian values used by the
// legacy frontend without round-tripping through enum maps. Validation is enforced
// at the API layer via Zod.
model Article {
id String @id @default(cuid())
slug String @unique
title String
subtitle String?
summary String
content String
type String
status String @default("پیش‌نویس")
readTime Int @default(5)
views Int @default(0)
reactions Int @default(0)
featured Boolean @default(false)
sponsored Boolean @default(false)
premiumOnly Boolean @default(false)
diagram String?
metaDescription String? @db.VarChar(300)
keywords String[] @default([])
ogTitle String? @db.VarChar(200)
ogDescription String? @db.VarChar(300)
ogImage String?
canonicalPath String?
dateModified DateTime?
factCheckedAt DateTime?
reviewedBy User? @relation("ArticleQualityReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
reviewedById String?
sourceNotes String?
contentFreshnessStatus String @default("current")
correctionNotice String?
category Category @relation(fields: [categoryId], references: [id])
categoryId String
author Author @relation(fields: [authorId], references: [id])
authorId String
createdBy User? @relation("ArticleCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
createdById String?
tags ArticleTag[]
reactionLog Reaction[]
savedBy SavedArticle[]
comments Comment[]
readHistory ReadHistory[]
viewLogs ArticleViewLog[]
seriesLinks SeriesArticle[]
review ArticleReview?
readingListItems ReadingListItem[]
analyticsEvents AnalyticsEvent[]
corrections ArticleCorrection[]
questions Question[]
revisions ArticleRevision[]
wikiProposals WikiProposal[]
articleModules ArticleModule[]
embeddings ArticleEmbedding?
search_tsv Unsupported("tsvector")?
publishedAt DateTime?
scheduledAt DateTime?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, publishedAt(sort: Desc)])
@@index([status, scheduledAt])
@@index([categoryId, status])
@@index([authorId])
@@index([reviewedById])
@@index([featured, publishedAt(sort: Desc)])
@@index([sponsored, publishedAt(sort: Desc)])
@@index([premiumOnly, publishedAt(sort: Desc)])
@@index([publishedAt(sort: Desc)])
@@index([deletedAt])
}
model JobListing {
id String @id @default(cuid())
company String
title String
location String
remote Boolean @default(true)
url String?
contactEmail String?
description String @db.VarChar(3000)
status String @default("pending")
featuredUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt(sort: Desc)])
}
model Course {
id String @id @default(cuid())
slug String @unique
title String
summary String
level String
price Int @default(0)
status String @default("published")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt(sort: Desc)])
}
model ArticleCorrection {
id String @id @default(cuid())
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String
note String @db.VarChar(1000)
editor User? @relation("ArticleCorrectionEditor", fields: [editorId], references: [id], onDelete: SetNull)
editorId String?
createdAt DateTime @default(now())
@@index([articleId, createdAt(sort: Desc)])
@@index([createdAt(sort: Desc)])
}
model MediaAsset {
id String @id @default(cuid())
url String @unique
path String @unique
filename String
mimeType String
sizeBytes Int
width Int?
height Int?
uploadedBy User? @relation("MediaUploadedBy", fields: [uploadedById], references: [id], onDelete: SetNull)
uploadedById String?
createdAt DateTime @default(now())
@@index([createdAt(sort: Desc)])
@@index([mimeType])
@@index([uploadedById, createdAt(sort: Desc)])
}
model ArticleTag {
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
tagId String
@@id([articleId, tagId])
@@index([tagId])
}
model Reaction {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String
type String // insightful | curious | applause
createdAt DateTime @default(now())
@@id([userId, articleId, type])
@@index([articleId])
}
model SavedArticle {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String
savedAt DateTime @default(now())
@@id([userId, articleId])
@@index([userId, savedAt(sort: Desc)])
}
model Comment {
id String @id @default(cuid())
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
replyTo Comment? @relation("CommentReplies", fields: [replyToId], references: [id], onDelete: Cascade)
replyToId String?
replies Comment[] @relation("CommentReplies")
body String @db.VarChar(1000)
flagCount Int @default(0)
likeCount Int @default(0)
upvoteCount Int @default(0)
flagged Boolean @default(false)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
flags CommentFlag[]
likes CommentLike[]
upvotes CommentUpvote[]
@@index([articleId, upvoteCount(sort: Desc), createdAt(sort: Desc)])
@@index([authorId, createdAt(sort: Desc)])
@@index([flagged, createdAt(sort: Desc)])
@@index([deletedAt])
}
model CommentFlag {
id String @id @default(cuid())
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
commentId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
createdAt DateTime @default(now())
@@unique([commentId, userId])
@@index([userId, createdAt(sort: Desc)])
}
model CommentLike {
id String @id @default(cuid())
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
commentId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
createdAt DateTime @default(now())
@@unique([commentId, userId])
@@index([userId, createdAt(sort: Desc)])
}
model CommentUpvote {
id String @id @default(cuid())
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
commentId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
createdAt DateTime @default(now())
@@unique([commentId, userId])
}
model ArticleViewLog {
id String @id @default(cuid())
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String
date DateTime @db.Date
views Int @default(0)
@@unique([articleId, date])
@@index([date])
}
model ReadHistory {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String
progress Float @default(0) // 0 to 1
readAt DateTime @default(now())
@@unique([userId, articleId])
@@index([userId, readAt(sort: Desc)])
}
model Question {
id String @id @default(cuid())
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
body String @db.VarChar(1000)
createdAt DateTime @default(now())
answers Answer[]
@@index([articleId, createdAt(sort: Desc)])
}
model Answer {
id String @id @default(cuid())
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
questionId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
body String @db.VarChar(1000)
createdAt DateTime @default(now())
@@index([questionId, createdAt(sort: Desc)])
}
model Notification {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
type NotificationType
payload Json
read Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId, read, createdAt(sort: Desc)])
@@index([createdAt(sort: Desc)])
}
model WriterFollow {
id String @id @default(cuid())
follower User @relation("Follower", fields: [followerId], references: [id], onDelete: Cascade)
followerId String
writer User @relation("Writer", fields: [writerId], references: [id], onDelete: Cascade)
writerId String
createdAt DateTime @default(now())
@@unique([followerId, writerId])
@@index([writerId, createdAt(sort: Desc)])
}
model TopicFollow {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
topic String
createdAt DateTime @default(now())
@@unique([userId, topic])
@@index([topic, createdAt(sort: Desc)])
}
model NewsletterSubscriber {
id String @id @default(cuid())
email String @unique
unsubscribeToken String @unique @default(cuid())
source String?
doubleOptInToken String? @unique
confirmedAt DateTime?
createdAt DateTime @default(now())
}
model AnalyticsEvent {
id String @id @default(cuid())
type String
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId String?
article Article? @relation(fields: [articleId], references: [id], onDelete: SetNull)
articleId String?
topic String?
metadata Json @default("{}")
createdAt DateTime @default(now())
@@index([type, createdAt(sort: Desc)])
@@index([userId, createdAt(sort: Desc)])
@@index([articleId, createdAt(sort: Desc)])
@@index([topic, createdAt(sort: Desc)])
}
model NewsletterCampaign {
id String @id @default(cuid())
subject String @db.VarChar(200)
slug String? @unique
bodyHtml String
sentAt DateTime?
recipientCount Int @default(0)
createdBy User @relation(fields: [createdById], references: [id])
createdById String
createdAt DateTime @default(now())
@@index([sentAt, createdAt])
}
model ArticleSeries {
id String @id @default(cuid())
title String @db.VarChar(200)
slug String @unique
description String? @db.VarChar(500)
coverImage String?
createdAt DateTime @default(now())
articles SeriesArticle[]
}
model SeriesArticle {
id String @id @default(cuid())
series ArticleSeries @relation(fields: [seriesId], references: [id], onDelete: Cascade)
seriesId String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String
position Int
@@unique([seriesId, articleId])
@@unique([seriesId, position])
@@index([articleId])
}
model ArticleReview {
id String @id @default(cuid())
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String @unique
reviewer User? @relation("ArticleReviewReviewer", fields: [reviewerId], references: [id])
reviewerId String?
status ReviewStatus @default(pending)
note String? @db.VarChar(1000)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ReadingList {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
name String @db.VarChar(80)
isDefault Boolean @default(false)
createdAt DateTime @default(now())
items ReadingListItem[]
@@unique([userId, name])
@@index([userId, createdAt])
}
model ReadingListItem {
id String @id @default(cuid())
list ReadingList @relation(fields: [listId], references: [id], onDelete: Cascade)
listId String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String
position Int
addedAt DateTime @default(now())
@@unique([listId, articleId])
@@index([articleId])
}
model ActivityLog {
id String @id @default(cuid())
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId String?
action String
target String
type String
createdAt DateTime @default(now())
@@index([createdAt(sort: Desc)])
@@index([userId, createdAt(sort: Desc)])
}
model ClientError {
id String @id @default(cuid())
message String @db.VarChar(1000)
stack String? @db.VarChar(5000)
url String? @db.VarChar(500)
userAgent String? @db.VarChar(500)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId String?
createdAt DateTime @default(now())
@@index([createdAt(sort: Desc)])
@@index([message, createdAt(sort: Desc)])
}
model MembershipPayment {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
authority String @unique
amount Int
currency String @default("IRR")
status String @default("pending")
refId String?
tier String @default("premium")
durationDays Int @default(365)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, createdAt(sort: Desc)])
@@index([authority])
@@index([status, createdAt(sort: Desc)])
}
model PushSubscription {
id String @id @default(cuid())
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String?
endpoint String @unique
auth String
p256dh String
userAgent String?
topics String[] @default([])
createdAt DateTime @default(now())
@@index([userId])
}
model NotificationPreference {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
eventType String
channel String
enabled Boolean @default(true)
@@unique([userId, eventType, channel])
@@index([userId])
}
model UserBadge {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
badgeType String
earnedAt DateTime @default(now())
metadata Json @default("{}")
@@unique([userId, badgeType])
@@index([userId])
}
model ArticleRevision {
id String @id @default(cuid())
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String
savedBy User? @relation("ArticleRevisionSavedBy", fields: [savedById], references: [id], onDelete: SetNull)
savedById String?
title String
content String
summary String
status String
createdAt DateTime @default(now())
@@index([articleId, createdAt(sort: Desc)])
}
// Phase 14 Schema Extensions
// 1. Wiki / Glossary Proposals
model WikiProposal {
id String @id @default(cuid())
articleId String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
title String
content String
summary String
status ReviewStatus @default(pending)
rejectionReason String?
suggestedById String
suggestedBy User @relation(fields: [suggestedById], references: [id], onDelete: Cascade)
reviewedById String?
reviewedBy User? @relation("WikiProposalReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([articleId])
}
model GlossaryProposal {
id String @id @default(cuid())
term String
definition String
sameAs String[]
status ReviewStatus @default(pending)
rejectionReason String?
suggestedById String
suggestedBy User @relation(fields: [suggestedById], references: [id], onDelete: Cascade)
reviewedById String?
reviewedBy User? @relation("GlossaryProposalReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 2. Learning Paths & LMS
model LearningPath {
id String @id @default(cuid())
slug String @unique
title String @db.VarChar(200)
description String
color String
modules LearningModule[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model LearningModule {
id String @id @default(cuid())
pathId String
path LearningPath @relation(fields: [pathId], references: [id], onDelete: Cascade)
title String
order Int
articles ArticleModule[]
quizzes Quiz[]
}
model ArticleModule {
id String @id @default(cuid())
moduleId String
module LearningModule @relation(fields: [moduleId], references: [id], onDelete: Cascade)
articleId String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
order Int
}
model Quiz {
id String @id @default(cuid())
moduleId String
module LearningModule @relation(fields: [moduleId], references: [id], onDelete: Cascade)
questions QuizQuestion[]
attempts QuizAttempt[]
}
model QuizQuestion {
id String @id @default(cuid())
quizId String
quiz Quiz @relation(fields: [quizId], references: [id], onDelete: Cascade)
questionText String
options String[]
correctOption Int
}
model QuizAttempt {
id String @id @default(cuid())
quizId String
quiz Quiz @relation(fields: [quizId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
score Int
passed Boolean
createdAt DateTime @default(now())
}
model UserCertification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pathId String
certificateUrl String?
createdAt DateTime @default(now())
}
// 3. Farsi Tech & Startup Directory
model CompanyProfile {
id String @id @default(cuid())
slug String @unique
name String
description String
logoUrl String?
websiteUrl String?
techStack String[]
verified Boolean @default(false)
claimedById String? @unique
claimedBy User? @relation("CompanyOwner", fields: [claimedById], references: [id], onDelete: SetNull)
claims CompanyClaim[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CompanyClaim {
id String @id @default(cuid())
companyId String
company CompanyProfile @relation(fields: [companyId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
documentsUrl String?
status ReviewStatus @default(pending)
reviewedById String?
reviewedBy User? @relation("CompanyClaimReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 4. Vector Embeddings
model ArticleEmbedding {
id String @id @default(cuid())
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId String @unique
embedding Unsupported("vector(1536)")
}