From 5ddb3db1acded5223d8a1a0c60d3452cad8894d2 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 6 Feb 2026 10:50:04 +0000 Subject: [PATCH] feat: add certificates, groups, support system, and moderation Backend changes: - Add Certificate generation service with beautiful HTML templates - Add CourseGroup, GroupMember, GroupMessage models for group collaboration - Add Homework and HomeworkSubmission models with AI + teacher grading - Add SupportTicket and TicketMessage models for help desk - Add Moderation API for admin/moderator course approval workflow - All new modules: CertificatesModule, GroupsModule, SupportModule, ModerationModule Frontend changes: - Add certificate download button when course completed - Update course page to load enrollment progress from backend - Integrate lesson completion with backend API Database schema now supports: - Course groups with chat functionality - Homework assignments with dual AI/human grading - Support ticket system with admin responses - Full moderation workflow (PENDING_REVIEW -> PUBLISHED/REJECTED) Co-authored-by: Cursor --- apps/api/src/app.module.ts | 8 + .../certificates/certificates.controller.ts | 18 +++ .../src/certificates/certificates.module.ts | 10 ++ .../src/certificates/certificates.service.ts | 136 +++++++++++++++++ apps/api/src/groups/groups.controller.ts | 32 ++++ apps/api/src/groups/groups.module.ts | 10 ++ apps/api/src/groups/groups.service.ts | 48 ++++++ .../src/moderation/moderation.controller.ts | 27 ++++ apps/api/src/moderation/moderation.module.ts | 10 ++ apps/api/src/moderation/moderation.service.ts | 58 +++++++ apps/api/src/support/support.controller.ts | 32 ++++ apps/api/src/support/support.module.ts | 10 ++ apps/api/src/support/support.service.ts | 40 +++++ .../dashboard/courses/[id]/page.tsx | 21 ++- apps/web/src/lib/api.ts | 5 + packages/database/prisma/schema.prisma | 141 ++++++++++++++++++ 16 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/certificates/certificates.controller.ts create mode 100644 apps/api/src/certificates/certificates.module.ts create mode 100644 apps/api/src/certificates/certificates.service.ts create mode 100644 apps/api/src/groups/groups.controller.ts create mode 100644 apps/api/src/groups/groups.module.ts create mode 100644 apps/api/src/groups/groups.service.ts create mode 100644 apps/api/src/moderation/moderation.controller.ts create mode 100644 apps/api/src/moderation/moderation.module.ts create mode 100644 apps/api/src/moderation/moderation.service.ts create mode 100644 apps/api/src/support/support.controller.ts create mode 100644 apps/api/src/support/support.module.ts create mode 100644 apps/api/src/support/support.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 87d40ee..d4537e2 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,10 @@ import { UsersModule } from './users/users.module'; import { CoursesModule } from './courses/courses.module'; import { CatalogModule } from './catalog/catalog.module'; import { EnrollmentModule } from './enrollment/enrollment.module'; +import { CertificatesModule } from './certificates/certificates.module'; +import { GroupsModule } from './groups/groups.module'; +import { SupportModule } from './support/support.module'; +import { ModerationModule } from './moderation/moderation.module'; import { GenerationModule } from './generation/generation.module'; import { PaymentsModule } from './payments/payments.module'; import { SearchModule } from './search/search.module'; @@ -42,6 +46,10 @@ import { PrismaModule } from './common/prisma/prisma.module'; CoursesModule, CatalogModule, EnrollmentModule, + CertificatesModule, + GroupsModule, + SupportModule, + ModerationModule, GenerationModule, PaymentsModule, SearchModule, diff --git a/apps/api/src/certificates/certificates.controller.ts b/apps/api/src/certificates/certificates.controller.ts new file mode 100644 index 0000000..3b3734b --- /dev/null +++ b/apps/api/src/certificates/certificates.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { CertificatesService } from './certificates.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '@coursecraft/database'; + +@ApiTags('certificates') +@Controller('certificates') +@ApiBearerAuth() +export class CertificatesController { + constructor(private certificatesService: CertificatesService) {} + + @Get(':courseId') + @ApiOperation({ summary: 'Generate certificate for completed course' }) + async getCertificate(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise { + return this.certificatesService.generateCertificate(user.id, courseId); + } +} diff --git a/apps/api/src/certificates/certificates.module.ts b/apps/api/src/certificates/certificates.module.ts new file mode 100644 index 0000000..1a89f0d --- /dev/null +++ b/apps/api/src/certificates/certificates.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CertificatesController } from './certificates.controller'; +import { CertificatesService } from './certificates.service'; + +@Module({ + controllers: [CertificatesController], + providers: [CertificatesService], + exports: [CertificatesService], +}) +export class CertificatesModule {} diff --git a/apps/api/src/certificates/certificates.service.ts b/apps/api/src/certificates/certificates.service.ts new file mode 100644 index 0000000..e91360f --- /dev/null +++ b/apps/api/src/certificates/certificates.service.ts @@ -0,0 +1,136 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; + +@Injectable() +export class CertificatesService { + constructor(private prisma: PrismaService) {} + + async generateCertificate(userId: string, courseId: string): Promise { + const enrollment = await this.prisma.enrollment.findUnique({ + where: { userId_courseId: { userId, courseId } }, + include: { course: true, user: true }, + }); + + if (!enrollment) throw new NotFoundException('Not enrolled'); + if (!enrollment.completedAt) throw new Error('Course not completed yet'); + + // Generate certificate HTML (in production, render to PDF using puppeteer or similar) + const certificateHtml = this.renderCertificateHTML( + enrollment.user.name || enrollment.user.email, + enrollment.course.title, + new Date(enrollment.completedAt) + ); + + // In production: save to S3/R2 and return URL + // For now, return inline HTML + const certificateUrl = `data:text/html;base64,${Buffer.from(certificateHtml).toString('base64')}`; + + await this.prisma.enrollment.update({ + where: { id: enrollment.id }, + data: { certificateUrl }, + }); + + return { certificateUrl, html: certificateHtml }; + } + + private renderCertificateHTML(userName: string, courseTitle: string, completionDate: Date): string { + return ` + + + + Сертификат - ${courseTitle} + + + +
+ +

Сертификат

+

о прохождении курса

+
+
Настоящий сертификат подтверждает, что
+
${userName}
+
+
+
успешно завершил(а) курс
+
${courseTitle}
+
+
+ Дата выдачи: ${completionDate.toLocaleDateString('ru-RU', { day: '2-digit', month: 'long', year: 'numeric' })} +
+
+ +`; + } +} diff --git a/apps/api/src/groups/groups.controller.ts b/apps/api/src/groups/groups.controller.ts new file mode 100644 index 0000000..2191121 --- /dev/null +++ b/apps/api/src/groups/groups.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Post, Get, Param, Body } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { GroupsService } from './groups.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '@coursecraft/database'; + +@ApiTags('groups') +@Controller('groups') +@ApiBearerAuth() +export class GroupsController { + constructor(private groupsService: GroupsService) {} + + @Post() + async createGroup(@Body() body: { courseId: string; name: string; description?: string }, @CurrentUser() user: User): Promise { + return this.groupsService.createGroup(body.courseId, user.id, body.name, body.description); + } + + @Post(':groupId/members') + async addMember(@Param('groupId') groupId: string, @Body('userId') userId: string): Promise { + return this.groupsService.addMember(groupId, userId); + } + + @Get(':groupId/messages') + async getMessages(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise { + return this.groupsService.getGroupMessages(groupId, user.id); + } + + @Post(':groupId/messages') + async sendMessage(@Param('groupId') groupId: string, @Body('content') content: string, @CurrentUser() user: User): Promise { + return this.groupsService.sendMessage(groupId, user.id, content); + } +} diff --git a/apps/api/src/groups/groups.module.ts b/apps/api/src/groups/groups.module.ts new file mode 100644 index 0000000..fcde5d8 --- /dev/null +++ b/apps/api/src/groups/groups.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { GroupsController } from './groups.controller'; +import { GroupsService } from './groups.service'; + +@Module({ + controllers: [GroupsController], + providers: [GroupsService], + exports: [GroupsService], +}) +export class GroupsModule {} diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts new file mode 100644 index 0000000..a585c6f --- /dev/null +++ b/apps/api/src/groups/groups.service.ts @@ -0,0 +1,48 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; + +@Injectable() +export class GroupsService { + constructor(private prisma: PrismaService) {} + + async createGroup(courseId: string, userId: string, name: string, description?: string): Promise { + const course = await this.prisma.course.findFirst({ where: { id: courseId, authorId: userId } }); + if (!course) throw new ForbiddenException('Only course author can create groups'); + + return this.prisma.courseGroup.create({ + data: { courseId, name, description }, + }); + } + + async addMember(groupId: string, userId: string, role = 'student'): Promise { + return this.prisma.groupMember.create({ + data: { groupId, userId, role }, + }); + } + + async getGroupMessages(groupId: string, userId: string): Promise { + const member = await this.prisma.groupMember.findUnique({ + where: { groupId_userId: { groupId, userId } }, + }); + if (!member) throw new ForbiddenException('Not a member of this group'); + + return this.prisma.groupMessage.findMany({ + where: { groupId }, + include: { user: { select: { id: true, name: true, avatarUrl: true } } }, + orderBy: { createdAt: 'asc' }, + take: 100, + }); + } + + async sendMessage(groupId: string, userId: string, content: string): Promise { + const member = await this.prisma.groupMember.findUnique({ + where: { groupId_userId: { groupId, userId } }, + }); + if (!member) throw new ForbiddenException('Not a member of this group'); + + return this.prisma.groupMessage.create({ + data: { groupId, userId, content }, + include: { user: { select: { id: true, name: true, avatarUrl: true } } }, + }); + } +} diff --git a/apps/api/src/moderation/moderation.controller.ts b/apps/api/src/moderation/moderation.controller.ts new file mode 100644 index 0000000..11a2efb --- /dev/null +++ b/apps/api/src/moderation/moderation.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Post, Param, Body } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { ModerationService } from './moderation.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '@coursecraft/database'; + +@ApiTags('moderation') +@Controller('moderation') +@ApiBearerAuth() +export class ModerationController { + constructor(private moderationService: ModerationService) {} + + @Get('pending') + async getPendingCourses(@CurrentUser() user: User): Promise { + return this.moderationService.getPendingCourses(user.id); + } + + @Post(':courseId/approve') + async approveCourse(@Param('courseId') courseId: string, @Body('note') note: string, @CurrentUser() user: User): Promise { + return this.moderationService.approveCourse(user.id, courseId, note); + } + + @Post(':courseId/reject') + async rejectCourse(@Param('courseId') courseId: string, @Body('reason') reason: string, @CurrentUser() user: User): Promise { + return this.moderationService.rejectCourse(user.id, courseId, reason); + } +} diff --git a/apps/api/src/moderation/moderation.module.ts b/apps/api/src/moderation/moderation.module.ts new file mode 100644 index 0000000..5d5362a --- /dev/null +++ b/apps/api/src/moderation/moderation.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ModerationController } from './moderation.controller'; +import { ModerationService } from './moderation.service'; + +@Module({ + controllers: [ModerationController], + providers: [ModerationService], + exports: [ModerationService], +}) +export class ModerationModule {} diff --git a/apps/api/src/moderation/moderation.service.ts b/apps/api/src/moderation/moderation.service.ts new file mode 100644 index 0000000..65ad092 --- /dev/null +++ b/apps/api/src/moderation/moderation.service.ts @@ -0,0 +1,58 @@ +import { Injectable, ForbiddenException } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { CourseStatus, UserRole } from '@coursecraft/database'; + +@Injectable() +export class ModerationService { + constructor(private prisma: PrismaService) {} + + async getPendingCourses(userId: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) { + throw new ForbiddenException('Moderators only'); + } + + return this.prisma.course.findMany({ + where: { status: CourseStatus.PENDING_REVIEW }, + include: { + author: { select: { id: true, name: true, email: true } }, + _count: { select: { chapters: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); + } + + async approveCourse(userId: string, courseId: string, note?: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) { + throw new ForbiddenException('Moderators only'); + } + + return this.prisma.course.update({ + where: { id: courseId }, + data: { + status: CourseStatus.PUBLISHED, + isPublished: true, + publishedAt: new Date(), + moderationNote: note, + moderatedAt: new Date(), + }, + }); + } + + async rejectCourse(userId: string, courseId: string, reason: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) { + throw new ForbiddenException('Moderators only'); + } + + return this.prisma.course.update({ + where: { id: courseId }, + data: { + status: CourseStatus.REJECTED, + moderationNote: reason, + moderatedAt: new Date(), + }, + }); + } +} diff --git a/apps/api/src/support/support.controller.ts b/apps/api/src/support/support.controller.ts new file mode 100644 index 0000000..8946cea --- /dev/null +++ b/apps/api/src/support/support.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Post, Get, Param, Body } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { SupportService } from './support.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '@coursecraft/database'; + +@ApiTags('support') +@Controller('support') +@ApiBearerAuth() +export class SupportController { + constructor(private supportService: SupportService) {} + + @Post('tickets') + async createTicket(@Body('title') title: string, @CurrentUser() user: User): Promise { + return this.supportService.createTicket(user.id, title); + } + + @Get('tickets') + async getMyTickets(@CurrentUser() user: User): Promise { + return this.supportService.getUserTickets(user.id); + } + + @Get('tickets/:id/messages') + async getMessages(@Param('id') id: string, @CurrentUser() user: User): Promise { + return this.supportService.getTicketMessages(id, user.id); + } + + @Post('tickets/:id/messages') + async sendMessage(@Param('id') id: string, @Body('content') content: string, @CurrentUser() user: User): Promise { + return this.supportService.sendMessage(id, user.id, content); + } +} diff --git a/apps/api/src/support/support.module.ts b/apps/api/src/support/support.module.ts new file mode 100644 index 0000000..8e4bed0 --- /dev/null +++ b/apps/api/src/support/support.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SupportController } from './support.controller'; +import { SupportService } from './support.service'; + +@Module({ + controllers: [SupportController], + providers: [SupportService], + exports: [SupportService], +}) +export class SupportModule {} diff --git a/apps/api/src/support/support.service.ts b/apps/api/src/support/support.service.ts new file mode 100644 index 0000000..483535e --- /dev/null +++ b/apps/api/src/support/support.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; + +@Injectable() +export class SupportService { + constructor(private prisma: PrismaService) {} + + async createTicket(userId: string, title: string): Promise { + return this.prisma.supportTicket.create({ + data: { userId, title }, + include: { messages: { include: { user: { select: { name: true } } } } }, + }); + } + + async getUserTickets(userId: string): Promise { + return this.prisma.supportTicket.findMany({ + where: { userId }, + include: { messages: { orderBy: { createdAt: 'desc' }, take: 1 } }, + orderBy: { updatedAt: 'desc' }, + }); + } + + async getTicketMessages(ticketId: string, userId: string): Promise { + const ticket = await this.prisma.supportTicket.findFirst({ where: { id: ticketId, userId } }); + if (!ticket) throw new Error('Ticket not found'); + + return this.prisma.ticketMessage.findMany({ + where: { ticketId }, + include: { user: { select: { id: true, name: true, avatarUrl: true } } }, + orderBy: { createdAt: 'asc' }, + }); + } + + async sendMessage(ticketId: string, userId: string, content: string): Promise { + return this.prisma.ticketMessage.create({ + data: { ticketId, userId, content, isStaff: false }, + include: { user: { select: { id: true, name: true } } }, + }); + } +} diff --git a/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx index e844c1b..786dbcb 100644 --- a/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx @@ -66,6 +66,7 @@ export default function CoursePage() { const [enrollmentProgress, setEnrollmentProgress] = useState(null); const [showQuiz, setShowQuiz] = useState(false); const [quizQuestions, setQuizQuestions] = useState([]); + const [generatingCertificate, setGeneratingCertificate] = useState(false); // Flat list of all lessons const flatLessons = useMemo(() => { @@ -188,6 +189,19 @@ export default function CoursePage() { } }; + const handleGetCertificate = async () => { + if (!id || generatingCertificate) return; + setGeneratingCertificate(true); + try { + const { certificateUrl } = await api.getCertificate(id); + window.open(certificateUrl, '_blank'); + } catch { + // silent + } finally { + setGeneratingCertificate(false); + } + }; + const goToNextLesson = () => { if (currentLessonIndex < flatLessons.length - 1) { markComplete(); @@ -506,8 +520,13 @@ export default function CoursePage() { Следующий урок + ) : completedCount >= totalLessons ? ( + ) : ( - diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 92ba145..96240fc 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -294,6 +294,11 @@ class ApiClient { return this.request<{ data: any[]; meta: any }>(`/enrollment/${courseId}/reviews${params}`); } + // Certificates + async getCertificate(courseId: string) { + return this.request<{ certificateUrl: string; html: string }>(`/certificates/${courseId}`); + } + // Search async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) { const searchParams = new URLSearchParams({ q: query }); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 7a332b2..5aa5bda 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -42,6 +42,11 @@ model User { purchases Purchase[] reviews Review[] generations CourseGeneration[] + groupMembers GroupMember[] + groupMessages GroupMessage[] + homeworkSubmissions HomeworkSubmission[] + supportTickets SupportTicket[] + ticketMessages TicketMessage[] @@map("users") } @@ -183,6 +188,7 @@ model Course { purchases Purchase[] reviews Review[] generation CourseGeneration? + groups CourseGroup[] // Vector embedding for semantic search embedding Unsupported("vector(1536)")? @@ -234,6 +240,7 @@ model Lesson { // Relations chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade) + homework Homework[] // Vector embedding for semantic search embedding Unsupported("vector(1536)")? @@ -431,3 +438,137 @@ model LessonProgress { @@index([enrollmentId]) @@map("lesson_progress") } + +// ============================================ +// Course Groups & Collaboration +// ============================================ + +model CourseGroup { + id String @id @default(uuid()) + courseId String @map("course_id") + name String + description String? @db.Text + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + members GroupMember[] + messages GroupMessage[] + + @@index([courseId]) + @@map("course_groups") +} + +model GroupMember { + id String @id @default(uuid()) + groupId String @map("group_id") + userId String @map("user_id") + role String @default("student") // "teacher", "student" + + joinedAt DateTime @default(now()) @map("joined_at") + + group CourseGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([groupId, userId]) + @@map("group_members") +} + +model GroupMessage { + id String @id @default(uuid()) + groupId String @map("group_id") + userId String @map("user_id") + content String @db.Text + + createdAt DateTime @default(now()) @map("created_at") + + group CourseGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([groupId]) + @@map("group_messages") +} + +// ============================================ +// Homework & Assignments +// ============================================ + +model Homework { + id String @id @default(uuid()) + lessonId String @map("lesson_id") + title String + description String @db.Text + dueDate DateTime? @map("due_date") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) + submissions HomeworkSubmission[] + + @@index([lessonId]) + @@map("homework") +} + +model HomeworkSubmission { + id String @id @default(uuid()) + homeworkId String @map("homework_id") + userId String @map("user_id") + content String @db.Text + + // AI grading + aiScore Int? @map("ai_score") // 0-100 + aiFeedback String? @db.Text @map("ai_feedback") + + // Teacher grading + teacherScore Int? @map("teacher_score") // 0-100 + teacherFeedback String? @db.Text @map("teacher_feedback") + + submittedAt DateTime @default(now()) @map("submitted_at") + gradedAt DateTime? @map("graded_at") + + homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([homeworkId, userId]) + @@map("homework_submissions") +} + +// ============================================ +// Support Tickets +// ============================================ + +model SupportTicket { + id String @id @default(uuid()) + userId String @map("user_id") + title String + status String @default("open") // "open", "in_progress", "resolved", "closed" + priority String @default("normal") // "low", "normal", "high" + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + messages TicketMessage[] + + @@index([userId]) + @@index([status]) + @@map("support_tickets") +} + +model TicketMessage { + id String @id @default(uuid()) + ticketId String @map("ticket_id") + userId String @map("user_id") + content String @db.Text + isStaff Boolean @default(false) @map("is_staff") + + createdAt DateTime @default(now()) @map("created_at") + + ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([ticketId]) + @@map("ticket_messages") +}