diff --git a/ROADMAP.md b/ROADMAP.md index d9fdd59..6836b7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,17 +39,15 @@ --- -## Этап 1.5 — Расширенное управление доступом (из emdesell) -**Цель:** способы выдачи доступа как в emdesell — ключи активации, срок доступа, категории. +## Этап 1.5 — Расширенное управление доступом +**Цель:** гибкое управление доступом: сроки, категории, пакеты, история. -- [ ] **Ключи активации:** генерировать N одноразовых кодов для курса → ученик вводит код и получает доступ - [ ] **Срок доступа:** поле `expiresAt` в `CourseEnrollment` + автоблокировка по дате - [ ] **Категории курсов:** таблица `Category`, поле `categoryId` в `Course`, фильтрация в списке -- [ ] **Расширенный энролл:** Admin может дать доступ к нескольким курсам сразу (пакеты) -- [ ] **История доступа:** лог выдачи/отзыва доступа (кто, когда, каким методом) -- [ ] **Страница активации** для ученика: `/activate` — ввод кода → редирект на курс +- [ ] **Расширенный энролл:** на странице ученика — дать доступ сразу к нескольким курсам +- [ ] **История доступа:** лог выдачи/отзыва (кто, когда, метод, примечание) -**Критерий готовности:** генерирую 10 ключей, отдаю ученику, ученик вводит — получает доступ с датой истечения через 3 месяца. +**Критерий готовности:** задаю ученику доступ к 3 курсам с разными сроками, в логе вижу все операции. --- diff --git a/prisma/migrations/20260407100000_add_categories_expiry_access_log/migration.sql b/prisma/migrations/20260407100000_add_categories_expiry_access_log/migration.sql new file mode 100644 index 0000000..ba15a41 --- /dev/null +++ b/prisma/migrations/20260407100000_add_categories_expiry_access_log/migration.sql @@ -0,0 +1,38 @@ +-- CreateTable: Category +CREATE TABLE "Category" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Category_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug"); + +-- Add categoryId to Course +ALTER TABLE "Course" ADD COLUMN "categoryId" TEXT; +ALTER TABLE "Course" ADD CONSTRAINT "Course_categoryId_fkey" + FOREIGN KEY ("categoryId") REFERENCES "Category"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +-- Add expiresAt to CourseEnrollment +ALTER TABLE "CourseEnrollment" ADD COLUMN "expiresAt" TIMESTAMP(3); + +-- CreateTable: AccessLog +CREATE TABLE "AccessLog" ( + "id" TEXT NOT NULL, + "courseId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "method" TEXT NOT NULL DEFAULT 'manual', + "grantedById" TEXT, + "note" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AccessLog_pkey" PRIMARY KEY ("id") +); +ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_courseId_fkey" + FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_grantedById_fkey" + FOREIGN KEY ("grantedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cee27df..39ec51b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,13 +24,15 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - sessions Session[] - accounts Account[] - enrollments CourseEnrollment[] - progress LessonProgress[] - submissions HomeworkSubmission[] - comments LessonComment[] - feedbacks HomeworkFeedback[] + sessions Session[] + accounts Account[] + enrollments CourseEnrollment[] + progress LessonProgress[] + submissions HomeworkSubmission[] + comments LessonComment[] + feedbacks HomeworkFeedback[] + accessLogs AccessLog[] @relation("AccessLogUser") + adminAccessLogs AccessLog[] @relation("AccessLogAdmin") } model Session { @@ -77,6 +79,16 @@ model Verification { // LMS core tables // ───────────────────────────────────────────── +model Category { + id String @id @default(cuid()) + title String + slug String @unique + order Int @default(0) + createdAt DateTime @default(now()) + + courses Course[] +} + model Course { id String @id @default(cuid()) slug String @unique @@ -85,11 +97,14 @@ model Course { coverImage String? published Boolean @default(false) order Int @default(0) + categoryId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) modules Module[] enrollments CourseEnrollment[] + accessLogs AccessLog[] } model Module { @@ -137,7 +152,8 @@ model LessonFile { model CourseEnrollment { userId String courseId String - enrolledAt DateTime @default(now()) + enrolledAt DateTime @default(now()) + expiresAt DateTime? user User @relation(fields: [userId], references: [id], onDelete: Cascade) course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) @@ -145,6 +161,21 @@ model CourseEnrollment { @@id([userId, courseId]) } +model AccessLog { + id String @id @default(cuid()) + courseId String + userId String + action String // "granted" | "revoked" + method String @default("manual") + grantedById String? + note String? + createdAt DateTime @default(now()) + + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + user User @relation("AccessLogUser", fields: [userId], references: [id], onDelete: Cascade) + grantedBy User? @relation("AccessLogAdmin", fields: [grantedById], references: [id], onDelete: SetNull) +} + model LessonProgress { userId String lessonId String diff --git a/src/app/admin/categories/actions.ts b/src/app/admin/categories/actions.ts new file mode 100644 index 0000000..f76aa32 --- /dev/null +++ b/src/app/admin/categories/actions.ts @@ -0,0 +1,48 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +async function requireAdmin() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || session.user.role !== "admin") throw new Error("Forbidden"); +} + +function slugify(str: string) { + const map: Record = { + а:"a",б:"b",в:"v",г:"g",д:"d",е:"e",ё:"yo",ж:"zh",з:"z",и:"i",й:"y", + к:"k",л:"l",м:"m",н:"n",о:"o",п:"p",р:"r",с:"s",т:"t",у:"u",ф:"f", + х:"kh",ц:"ts",ч:"ch",ш:"sh",щ:"shch",ъ:"",ы:"y",ь:"",э:"e",ю:"yu",я:"ya", + }; + return str.toLowerCase() + .replace(/[а-яё]/g, (c) => map[c] ?? c) + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +export async function createCategory(formData: FormData) { + await requireAdmin(); + const title = formData.get("title") as string; + const slug = (formData.get("slug") as string).trim() || slugify(title); + const count = await prisma.category.count(); + await prisma.category.create({ data: { title, slug, order: count } }); + revalidatePath("/admin/categories"); +} + +export async function updateCategory(id: string, formData: FormData) { + await requireAdmin(); + const title = formData.get("title") as string; + const slug = formData.get("slug") as string; + await prisma.category.update({ where: { id }, data: { title, slug } }); + revalidatePath("/admin/categories"); +} + +export async function deleteCategory(id: string) { + await requireAdmin(); + await prisma.category.delete({ where: { id } }); + revalidatePath("/admin/categories"); + revalidatePath("/admin/courses"); +} diff --git a/src/app/admin/categories/page.tsx b/src/app/admin/categories/page.tsx new file mode 100644 index 0000000..8ca4c6f --- /dev/null +++ b/src/app/admin/categories/page.tsx @@ -0,0 +1,60 @@ +import { prisma } from "@/lib/prisma"; +import { CategoryRow } from "@/components/admin/category-row"; +import { createCategory } from "./actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +export default async function CategoriesPage() { + const categories = await prisma.category.findMany({ + orderBy: { order: "asc" }, + include: { _count: { select: { courses: true } } }, + }); + + return ( +
+
+

Категории

+

+ {categories.length} категорий +

+
+ +
+ {categories.length === 0 ? ( +

+ Категорий пока нет. Создайте первую. +

+ ) : ( + categories.map((cat) => ( + + )) + )} +
+ +
+

+ Новая категория +

+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ ); +} diff --git a/src/app/admin/courses/[courseId]/actions.ts b/src/app/admin/courses/[courseId]/actions.ts index ddf3882..2bae5a8 100644 --- a/src/app/admin/courses/[courseId]/actions.ts +++ b/src/app/admin/courses/[courseId]/actions.ts @@ -8,6 +8,7 @@ import { revalidatePath } from "next/cache"; async function requireAdmin() { const session = await auth.api.getSession({ headers: await headers() }); if (!session || session.user.role !== "admin") throw new Error("Forbidden"); + return session; } // ── Modules ────────────────────────────────────────────────────────────────── @@ -45,20 +46,45 @@ export async function reorderModules(courseId: string, orderedIds: string[]) { // ── Enrollment ─────────────────────────────────────────────────────────────── -export async function grantAccess(courseId: string, userId: string) { - await requireAdmin(); +export async function grantAccess( + courseId: string, + userId: string, + expiresAt?: string | null, + note?: string +) { + const session = await requireAdmin(); await prisma.courseEnrollment.upsert({ where: { userId_courseId: { userId, courseId } }, - update: {}, - create: { userId, courseId }, + update: { expiresAt: expiresAt ? new Date(expiresAt) : null }, + create: { userId, courseId, expiresAt: expiresAt ? new Date(expiresAt) : null }, + }); + await prisma.accessLog.create({ + data: { + courseId, + userId, + action: "granted", + method: "manual", + grantedById: session.user.id, + note: note || null, + }, }); revalidatePath(`/admin/courses/${courseId}`); } -export async function revokeAccess(courseId: string, userId: string) { - await requireAdmin(); +export async function revokeAccess(courseId: string, userId: string, note?: string) { + const session = await requireAdmin(); await prisma.courseEnrollment.delete({ where: { userId_courseId: { userId, courseId } }, }); + await prisma.accessLog.create({ + data: { + courseId, + userId, + action: "revoked", + method: "manual", + grantedById: session.user.id, + note: note || null, + }, + }); revalidatePath(`/admin/courses/${courseId}`); } diff --git a/src/app/admin/courses/[courseId]/page.tsx b/src/app/admin/courses/[courseId]/page.tsx index ab1ef8b..917ecdd 100644 --- a/src/app/admin/courses/[courseId]/page.tsx +++ b/src/app/admin/courses/[courseId]/page.tsx @@ -12,7 +12,7 @@ interface Props { export default async function CourseDetailPage({ params }: Props) { const { courseId } = await params; - const [course, allStudents] = await Promise.all([ + const [course, allStudents, categories] = await Promise.all([ prisma.course.findUnique({ where: { id: courseId }, include: { @@ -20,7 +20,17 @@ export default async function CourseDetailPage({ params }: Props) { orderBy: { order: "asc" }, include: { _count: { select: { lessons: true } } }, }, - enrollments: { include: { user: { select: { id: true, name: true, email: true } } } }, + enrollments: { + select: { userId: true, expiresAt: true }, + }, + accessLogs: { + orderBy: { createdAt: "desc" }, + take: 50, + include: { + user: { select: { name: true } }, + grantedBy: { select: { name: true } }, + }, + }, }, }), prisma.user.findMany({ @@ -28,43 +38,51 @@ export default async function CourseDetailPage({ params }: Props) { select: { id: true, name: true, email: true }, orderBy: { name: "asc" }, }), + prisma.category.findMany({ orderBy: { order: "asc" } }), ]); if (!course) notFound(); - const enrolledIds = new Set(course.enrollments.map((e) => e.userId)); - return (
{/* Breadcrumb */} -