diff --git a/apps/api/src/moderation/moderation.controller.ts b/apps/api/src/moderation/moderation.controller.ts index 0373c07..ded8dde 100644 --- a/apps/api/src/moderation/moderation.controller.ts +++ b/apps/api/src/moderation/moderation.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Param, Body } from '@nestjs/common'; +import { Controller, Get, Post, Param, Body, Query, Delete, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ModerationService } from './moderation.service'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; @@ -15,6 +15,15 @@ export class ModerationController { return this.moderationService.getPendingCourses(user.id); } + @Get('courses') + async getCourses( + @CurrentUser() user: User, + @Query('status') status?: string, + @Query('search') search?: string, + ): Promise { + return this.moderationService.getCourses(user.id, { status, search }); + } + @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); @@ -34,4 +43,10 @@ export class ModerationController { async unhideReview(@Param('reviewId') reviewId: string, @CurrentUser() user: User): Promise { return this.moderationService.unhideReview(user.id, reviewId); } + + @Delete(':courseId') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteCourse(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise { + await this.moderationService.deleteCourse(user.id, courseId); + } } diff --git a/apps/api/src/moderation/moderation.service.ts b/apps/api/src/moderation/moderation.service.ts index cd69eca..022dd03 100644 --- a/apps/api/src/moderation/moderation.service.ts +++ b/apps/api/src/moderation/moderation.service.ts @@ -1,4 +1,4 @@ -import { Injectable, ForbiddenException } from '@nestjs/common'; +import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../common/prisma/prisma.service'; import { CourseStatus, UserRole } from '@coursecraft/database'; @@ -22,6 +22,48 @@ export class ModerationService { }); } + async getCourses( + userId: string, + options?: { + status?: string; + search?: string; + } + ): Promise { + await this.assertStaff(userId); + + const allowedStatuses = Object.values(CourseStatus); + const where: any = {}; + + if (options?.status && allowedStatuses.includes(options.status as CourseStatus)) { + where.status = options.status as CourseStatus; + } + + if (options?.search?.trim()) { + where.OR = [ + { title: { contains: options.search.trim(), mode: 'insensitive' } }, + { description: { contains: options.search.trim(), mode: 'insensitive' } }, + { author: { name: { contains: options.search.trim(), mode: 'insensitive' } } }, + { author: { email: { contains: options.search.trim(), mode: 'insensitive' } } }, + ]; + } + + return this.prisma.course.findMany({ + where, + include: { + author: { select: { id: true, name: true, email: true } }, + _count: { + select: { + chapters: true, + enrollments: true, + reviews: true, + }, + }, + }, + orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }], + take: 200, + }); + } + 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)) { @@ -98,6 +140,19 @@ export class ModerationService { return review; } + async deleteCourse(userId: string, courseId: string): Promise { + await this.assertStaff(userId); + const existing = await this.prisma.course.findUnique({ + where: { id: courseId }, + select: { id: true }, + }); + if (!existing) { + throw new NotFoundException('Course not found'); + } + + await this.prisma.course.delete({ where: { id: courseId } }); + } + private async assertStaff(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) { diff --git a/apps/web/src/app/(dashboard)/dashboard/admin/page.tsx b/apps/web/src/app/(dashboard)/dashboard/admin/page.tsx new file mode 100644 index 0000000..90d4ad0 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function DashboardAdminRedirectPage() { + redirect('/admin'); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/admin/support/page.tsx b/apps/web/src/app/(dashboard)/dashboard/admin/support/page.tsx index bb9e592..79e4159 100644 --- a/apps/web/src/app/(dashboard)/dashboard/admin/support/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/admin/support/page.tsx @@ -1,147 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { io, Socket } from 'socket.io-client'; -import { Send } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { api } from '@/lib/api'; -import { getWsBaseUrl } from '@/lib/ws'; - -export default function AdminSupportPage() { - const [tickets, setTickets] = useState([]); - const [selectedTicketId, setSelectedTicketId] = useState(null); - const [messages, setMessages] = useState([]); - const [status, setStatus] = useState('in_progress'); - const [message, setMessage] = useState(''); - const socketRef = useRef(null); - - const selected = useMemo( - () => tickets.find((ticket) => ticket.id === selectedTicketId) || null, - [tickets, selectedTicketId] - ); - - const loadTickets = async () => { - const data = await api.getAdminSupportTickets().catch(() => []); - setTickets(data); - if (!selectedTicketId && data.length > 0) { - setSelectedTicketId(data[0].id); - } - }; - - useEffect(() => { - loadTickets(); - }, []); - - useEffect(() => { - if (!selectedTicketId) return; - api - .getAdminSupportTicketMessages(selectedTicketId) - .then((data) => setMessages(data)) - .catch(() => setMessages([])); - - const token = - typeof window !== 'undefined' ? sessionStorage.getItem('coursecraft_api_token') || undefined : undefined; - const socket = io(`${getWsBaseUrl()}/ws/support`, { - transports: ['websocket'], - auth: { token }, - }); - socketRef.current = socket; - socket.emit('support:join', { ticketId: selectedTicketId }); - socket.on('support:new-message', (msg: any) => { - setMessages((prev) => [...prev, msg]); - }); - return () => { - socket.disconnect(); - socketRef.current = null; - }; - }, [selectedTicketId]); - - const sendReply = async () => { - if (!selectedTicketId || !message.trim()) return; - await api.sendAdminSupportMessage(selectedTicketId, message.trim()); - setMessage(''); - const list = await api.getAdminSupportTicketMessages(selectedTicketId).catch(() => []); - setMessages(list); - await loadTickets(); - }; - - const updateStatus = async () => { - if (!selectedTicketId) return; - await api.updateAdminSupportTicketStatus(selectedTicketId, status); - await loadTickets(); - }; - - return ( -
- - - Админ: тикеты поддержки - - - {tickets.map((ticket) => ( - - ))} - - - - - - {selected ? `Тикет: ${selected.title}` : 'Выберите тикет'} - - -
- - -
- -
- {messages.map((msg) => ( -
-

- {msg.user?.name || 'Пользователь'} {msg.isStaff ? '(Поддержка)' : ''} -

-

{msg.content}

-
- ))} -
- -
- setMessage(e.target.value)} - placeholder="Ответ поддержки" - className="flex-1 rounded-md border bg-background px-3 py-2 text-sm" - /> - -
-
-
-
- ); +export default function DashboardAdminSupportRedirectPage() { + redirect('/admin/support'); } diff --git a/apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx b/apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx index 7571d9c..5c8c099 100644 --- a/apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx @@ -1,375 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import { - ArrowLeft, - BookOpen, - Clock, - Users, - Star, - Shield, - Check, - Loader2, - ChevronDown, -} from 'lucide-react'; -import Link from 'next/link'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { Progress } from '@/components/ui/progress'; -import { api } from '@/lib/api'; -import { useAuth } from '@/contexts/auth-context'; -import { useToast } from '@/components/ui/use-toast'; -import { cn } from '@/lib/utils'; - -export default function PublicCoursePage() { - const params = useParams(); - const router = useRouter(); - const { toast } = useToast(); - const { user } = useAuth(); - const id = params?.id as string; - - const [course, setCourse] = useState(null); - const [reviews, setReviews] = useState([]); - const [loading, setLoading] = useState(true); - const [enrolling, setEnrolling] = useState(false); - const [enrolled, setEnrolled] = useState(false); - const [expandedChapters, setExpandedChapters] = useState([]); - const [submittingReview, setSubmittingReview] = useState(false); - const [reviewRating, setReviewRating] = useState(5); - const [reviewTitle, setReviewTitle] = useState(''); - const [reviewContent, setReviewContent] = useState(''); - - useEffect(() => { - if (!id) return; - (async () => { - setLoading(true); - try { - const [courseData, reviewsData] = await Promise.all([ - api.getPublicCourse(id), - api.getCourseReviews(id).catch(() => ({ data: [] })), - ]); - setCourse(courseData); - setReviews(reviewsData.data || []); - - // Check if already enrolled - if (user) { - const enrollments = await api.getMyEnrollments().catch(() => []); - setEnrolled(enrollments.some((e: any) => e.course.id === id)); - } - } catch { - setCourse(null); - } finally { - setLoading(false); - } - })(); - }, [id, user]); - - const handleEnroll = async () => { - if (!id || enrolling) return; - setEnrolling(true); - try { - if (course?.price) { - const session = await api.checkoutCourse(id); - if (session?.url) { - window.location.href = session.url; - return; - } - } else { - await api.enrollInCourse(id); - toast({ title: 'Успех', description: 'Вы записались на курс' }); - router.push(`/dashboard/courses/${id}`); - } - } catch (e: any) { - toast({ title: 'Ошибка', description: e.message, variant: 'destructive' }); - } finally { - setEnrolling(false); - } - }; - - const handleSubmitReview = async () => { - if (!id || !enrolled || submittingReview) return; - setSubmittingReview(true); - try { - await api.createReview(id, { - rating: reviewRating, - title: reviewTitle || undefined, - content: reviewContent || undefined, - }); - const reviewsData = await api.getCourseReviews(id).catch(() => ({ data: [] })); - setReviews(reviewsData.data || []); - setReviewTitle(''); - setReviewContent(''); - toast({ title: 'Спасибо!', description: 'Ваш отзыв опубликован' }); - } catch (e: any) { - toast({ title: 'Ошибка', description: e.message, variant: 'destructive' }); - } finally { - setSubmittingReview(false); - } - }; - - if (loading) { - return ( -
- -
- ); - } - - if (!course) { - return ( -
-

Курс не найден

- - Вернуться к каталогу - -
- ); - } - - const totalLessons = course.chapters.reduce((acc: number, ch: any) => acc + ch.lessons.length, 0); - - return ( -
- {/* Header */} - - - {/* Course header */} -
-
- {/* Cover */} -
- {course.coverImage ? ( - {course.title} - ) : ( -
- -
- )} -
- - {/* Title & meta */} -
-
- {course.isVerified && ( -
- - Проверен автором -
- )} - {course.difficulty && ( - - {course.difficulty === 'beginner' ? 'Начинающий' : course.difficulty === 'intermediate' ? 'Средний' : 'Продвинутый'} - - )} -
-

{course.title}

-

{course.description}

-
- - {/* Stats */} -
- {course.averageRating && ( -
- - {course.averageRating.toFixed(1)} ({course._count.reviews} отзывов) -
- )} - - - {course._count.enrollments} студентов - - {totalLessons} уроков - {course.estimatedHours && ( - - - {course.estimatedHours}ч - - )} -
- -
- - {/* Author */} -
-
- {course.author.name?.[0] || 'A'} -
-
-

{course.author.name || 'Автор'}

-

Преподаватель

-
-
-
- - {/* Sidebar: Enroll / Price */} -
- - -
-

- {course.price ? `${course.price} ${course.currency}` : 'Бесплатно'} -

-
- {enrolled ? ( - - ) : ( - - )} -
-
- - {course.chapters.length} глав -
-
- - {totalLessons} видеоуроков -
-
- - Сертификат по окончании -
-
- - Пожизненный доступ -
-
-
-
-
-
- - {/* Course content (chapters & lessons) */} - - -

Содержание курса

-
- {course.chapters.map((chapter: any) => { - const expanded = expandedChapters.includes(chapter.id); - const toggleExpanded = () => { - setExpandedChapters(prev => - prev.includes(chapter.id) - ? prev.filter(id => id !== chapter.id) - : [...prev, chapter.id] - ); - }; - return ( -
- - {expanded && ( -
- {chapter.lessons.map((lesson: any) => ( -
- - {lesson.title} - {lesson.durationMinutes && ( - {lesson.durationMinutes} мин - )} -
- ))} -
- )} -
- ); - })} -
-
-
- - {/* Reviews */} - {reviews.length > 0 && ( - - -

Отзывы студентов

-
- {reviews.map((review: any) => ( -
-
-
- {review.user.name?.[0] || 'U'} -
-
-

{review.user.name || 'Пользователь'}

-
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
-
-
- {review.title &&

{review.title}

} - {review.content &&

{review.content}

} -
- ))} -
-
-
- )} - - {enrolled && ( - - -

Оцените курс

-
-
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- setReviewTitle(e.target.value)} - placeholder="Заголовок отзыва" - className="w-full rounded-md border bg-background px-3 py-2 text-sm" - /> -