From 393c6b034a9577be8ef5a553989852b170030f2c Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:18:59 +0330 Subject: [PATCH] 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. --- app/backend/prisma/schema.prisma | 821 +++++++++++++++++++++++++++++++ 1 file changed, 821 insertions(+) create mode 100644 app/backend/prisma/schema.prisma diff --git a/app/backend/prisma/schema.prisma b/app/backend/prisma/schema.prisma new file mode 100644 index 0000000..044e5f8 --- /dev/null +++ b/app/backend/prisma/schema.prisma @@ -0,0 +1,821 @@ +// 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)") +}