Compare commits

...

109 Commits

Author SHA1 Message Date
admins 3b57b14d9b Add file attachments to questions (new question form + admin reply)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:26:17 +05:00
admins 367764b71e Expand /questions/new form: wider container, larger inputs, hint texts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:10:33 +05:00
admins acf7ee49aa Replace single-line reply input with resizable textarea in QuestionSplitView 2026-05-19 18:01:27 +05:00
admins 751c012f3d Add /admin/comments page with delete and pagination
- Add admin auth guard (redirect to /dashboard if not admin)
- Add delete-action.ts with deleteComment(commentId) soft-delete action
- Fix pagination label to show "Страница X из Y · Всего: N"
- Existing actions.ts, comments-table.tsx, and admin-nav entry preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:31:38 +05:00
admins 7084806aac Add search, filters, pagination to homework list
- Replace client HomeworkFilters component with server-side <form method="GET">
- Switch search param from `search` to `q`, add lesson title to search scope
- Change status filter from DB status field to feedback-count logic (pending=no feedbacks, reviewed=has feedbacks)
- Update pagination label to "Страница X из Y · Всего: N"
- Preserve all existing submission links and layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:29:47 +05:00
admins b2fa98051f Add quick enroll button to admin users table
Adds a "+ Доступ" button to each user row in the admin users table.
Clicking it opens a centered modal with a course dropdown, optional
expiry date, and a Server Action that upserts CourseEnrollment, logs
to AccessLog, and sends sendCourseAccessEmail on new enrollments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:27:57 +05:00
admins 4f5b5c535a Add search, filters, pagination to admin users table
- Add emailVerified filter (true/false/any) to UsersSearch component
- Wire emailVerified param through page searchParams, where clause, and pageUrl helper
- Preserve emailVerified in pagination links alongside existing search/role/balance params
- Update pagination label to "Страница X из Y · Всего: N"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:25:53 +05:00
admins e5ba94cb33 Fix security, transaction, and badge issues from final review
- Validate file URLs against S3 prefix in messages route (Fix 1)
- Guard attachment hrefs with https:// check in QuestionThread and QuestionSplitView (Fix 2)
- Wrap message create + updatedAt bump in prisma.$transaction (Fix 3)
- Add questionsBadge count query to curator layout for admin branch (Fix 4)
- Fire-and-forget email sends with void Promise.all (Fix 5)
- Wrap req.json() calls in try/catch returning 400 on parse failure (Fix 6)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:56:31 +05:00
admins 12e1785ff2 Send homework-updated email to staff on submission edit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:52:04 +05:00
admins bd1e77c2a3 Add questions nav links and admin unread badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:49:18 +05:00
admins d2362a3f1e Fix QuestionSplitView error handling, loading state, file key
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:47:35 +05:00
admins 3a2f64d47d Fix QuestionSplitView panel widths and message bubble styling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:45:57 +05:00
admins d32186c101 Add admin/curator split-view questions page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:42:39 +05:00
admins f7d428180b Fix student questions pages: CSS tokens, scroll, upload guard, S3 path
- Replace non-existent --surface/--surface-muted/--border-strong with actual
  design-system tokens (--color-surface, --background, --foreground, --muted)
- Remove tmp/ segment from S3 upload key in question-upload route
- Add auto-scroll to bottom on new message in QuestionThread
- Block Send while file upload is in progress (uploading guard)
- Replace <a> with Next.js <Link> in new question page back-link
- Replace hardcoded #c00 error color with var(--destructive) in both files
- Replace hardcoded #E8E8E0/#F5F5F0 hex backgrounds with CSS tokens
2026-05-19 13:40:05 +05:00
admins c5d2caa345 Fix message alignment in QuestionThread 2026-05-19 13:32:52 +05:00
admins 89d614fa00 Add student questions list, new question form, and thread pages
Tasks 8–10: student-facing questions UI — list page with unread badges,
new question form with client-side submission, and thread page with
QuestionThread component for real-time reply + file attachment flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:31:31 +05:00
admins a9e6272d2d Fix API routes: closed-question guard, file validation, files sanitization, follow-up email
- Add CLOSED status guard in messages POST (returns 409)
- Add extension allowlist check in upload route + text/x-markdown MIME type
- Sanitize files JSON array before DB write
- Add sendQuestionFollowUpEmail helper and use it for student follow-up replies
- Scope email field to staff only in questions list query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:28:08 +05:00
admins f2946db57a Add student questions API routes
Implements GET/POST /api/questions, GET /api/questions/[id] with read tracking, POST /api/questions/[id]/messages with email notifications, PATCH /api/questions/[id]/close for staff, and POST /api/student/question-upload for file attachments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:23:26 +05:00
admins 9cb56b9b04 Add question and homework-update email helpers 2026-05-19 13:20:06 +05:00
admins 6fa49d4113 Add indexes to StudentQuestion and StudentQuestionMessage 2026-05-19 13:18:54 +05:00
admins 90f155d334 Add StudentQuestion and StudentQuestionMessage models
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:11:51 +05:00
admins d47f79be1a Add student questions implementation plan 2026-05-19 12:59:25 +05:00
admins ec128f670a Add student questions feature design spec 2026-05-19 12:52:57 +05:00
admins a27089bc0c docs(lms): заменить устаревший DESIGN.md указателем на ДС-2
Прежний DESIGN.md ссылался на легаси-дизайн-систему v1 (кремовая
палитра + лаванда) и расходился с реальным globals.css. Новый —
указатель на канон ДС-2 «Second Brain LMS & Press» (терминальный
Aubade) и описание того, где токены живут в этом репозитории.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:14:16 +05:00
admins c94a8dafa9 style(lms): синхронизировать типографику со шкалой ДС-2 (+2px)
Переопределены токены шкалы Tailwind (--text-xs…--text-5xl) на +2px,
базовый размер body 18px, размеры компонентных классов (.btn-aubade,
.tag-aubade, .admin-sidebar-nav-link) и инлайновые fontSize приведены
к канону дизайн-системы ДС-2. Rem-база (html 16px) не тронута —
спейсинг и сетка не затронуты, растёт только текст.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:14:16 +05:00
admins 47840901c5 feat: collapsible mobile sidebar for student course view
Hamburger button (top-left, lg:hidden), dark overlay, slide-in animation.
Sidebar closes on lesson link click. Spacer added to prevent content overlap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 05:07:29 +00:00
admins e3e6c713d2 Add reset password button to admin user page 2026-05-11 19:34:33 +05:00
admins 77016a03c7 Add active users last 24h card to admin dashboard 2026-05-11 18:52:40 +05:00
admins c1ae048c14 Rewrite password change form to use Server Action
Replaces client-side fetch with a proper Server Action + useActionState.
Uncontrolled inputs fix agent-browser testing and improve reliability.
Server action verifies bcrypt hash directly via Prisma.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:41:42 +05:00
admins 799117d287 Fix change-password form to use direct fetch instead of authClient
authClient.changePassword does not exist in better-auth v1.6 client bundle.
Use direct POST to /api/auth/change-password endpoint instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:10:18 +05:00
admins c445bfacad Add student profile page with password change
- New page /profile: shows name/email and password change form
- Uses authClient.changePassword (current + new + confirm)
- Student name in header is now a link to /profile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:03:33 +05:00
admins 41871a1e8e Fix build: remove deleted package imports from globals.css
tw-animate-css, shadcn, @tailwindcss/typography were removed in F008
but their @import/@plugin lines remained in globals.css, breaking the build.
CSS variables are defined inline so none of these imports are needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:56:06 +05:00
admins 444b9c0faf Apply tech debt fixes: middleware rename, React.cache, file size limits, remove dead deps
- Rename proxy.ts → middleware.ts, export proxy() → middleware() so Next.js edge
  protection actually activates (F001)
- Add PUBLIC_ROUTES entries for /forgot-password and /reset-password
- Wrap getSettings() in React.cache() to eliminate duplicate DB call in root layout (F003)
- Remove 4 console.log calls from saveLesson Server Action, keep console.error (F005)
- Add 50 MB file size guard to all 6 upload routes before arrayBuffer() read (F004)
- Remove unused deps: @tailwindcss/typography, shadcn, tw-animate-css (F008)
- Update CLAUDE.md: Prisma version 6.x → 7.x
- Add TECH_DEBT_AUDIT.md with 14 findings across 9 dimensions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 19:35:05 +05:00
admins 5547b427bb Add nonzero balance filter to users page, link from dashboard card 2026-05-08 14:24:31 +05:00
admins 2dfc42821c Move balance card below Activity block in admin dashboard 2026-05-08 14:20:44 +05:00
admins 33dcf9bb4a Add total user balances stat card to admin dashboard 2026-05-08 14:09:59 +05:00
admins a5e7b20699 Add forgot-password and reset-password flow
Users can now request a password reset link via email. Better Auth
sendResetPassword callback sends a branded email via Resend. Login
page shows success notice after password is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 11:04:06 +05:00
admins 93e74951a7 Add balance transactions to user admin panel
Introduces BalanceTransaction model to track per-user balance history
(prepayments, refunds, partner credits). Admin can add/delete transactions;
current balance is computed as the running sum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 09:24:25 +05:00
admins 48721759d3 Add comment field to user profile in admin panel
- Prisma: User.comment String? column + migration
- UserContactEditor: comment shown in view mode, textarea in edit mode
- updateUserContact action: saves comment to DB
2026-05-06 14:06:53 +00:00
admins 4f3b389f05 Update load test to 100 VUs with login jitter
Stages: 10 → 50 → 100 VUs, hold 3 min, ramp down.
Added random 0-10s sleep before first login to spread auth requests
and reduce Better Auth rate-limit retries.
Results: p(95)=244ms, pages 100% OK, auth retries 6.28% (rate-limit artifact).
2026-05-06 12:33:49 +00:00
admins 628226151b Add k6 load test script for 50 concurrent users
Tests login → dashboard → course page → 3 random lessons flow.
VU-level session: each VU logs in once and reuses the cookie jar.
Thresholds: p(95) < 3s, error rate < 5%.
Results on 50 VU / 5 min: p(95)=329ms, errors=4.94% (login rate-limit retries).
2026-05-06 11:51:39 +00:00
admins 9a21c705b7 Fix KinescopePlayer SSR crash on direct page load
The @kinescope/react-kinescope-player library accesses browser APIs
(window/document) during server-side rendering. In Next.js App Router,
client components are SSR-rendered on full page loads (direct URL,
refresh) but not on RSC navigations. This caused a 500 error for all
lessons with a kinescopeId when accessed directly.

Fix: defer rendering KinescopeReactPlayer until after mount with
useEffect + useState(false), so it only runs in the browser.
2026-05-06 11:42:01 +00:00
admins 7888a7598b Add coverImage poster to player, fix TipTap v3 editor reset, quiz admin preview
- Add coverImage field to Lesson model (prisma)
- Pass coverImage as poster prop to KinescopePlayer
- Show quiz in read-only preview mode for admin on lesson page
- Fix TipTap v3 editor reset on save: pass [lesson.id] as deps to useEditor
  to prevent setOptions() from reinitializing content on every re-render
- Replace saveLesson Server Action call with fetch PATCH /api/admin/lessons/[id]
  to avoid Next.js 16 automatic RSC refresh after Server Actions
- Simplify revalidatePath: only revalidate module page, not lesson editor page
2026-05-01 13:26:30 +00:00
admins c25369b766 Add threaded comment replies for admin and curator
- Add parentId field to LessonComment (self-referential FK, SetNull on delete)
- Show replies indented under parent comment with left border visual
- Add reply button (visible to admin/curator only) with inline textarea
- Only root comments shown in main list; replies nested below their parent
- Update comment count to include replies
- Server-side validation: only admin/curator can reply, parent must belong to same lesson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 13:17:30 +05:00
admins 6b5bfc853e Add name/email editing and days-based course access in admin user card
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:01:01 +05:00
admins e691124058 Fix LessonFile duplication: upsert on upload, delete S3 on remove
POST /api/admin/lesson-files now checks for an existing record with the
same (lessonId, name) before uploading — replaces it (old S3 object
deleted) instead of always creating a new one. Previously every save
cycle accumulated an extra copy; 1183 duplicates occupying 6.5 GiB were
found and cleaned up.

DELETE now receives the file URL and extracts the S3 key from it, so
manual deletion actually removes the object from storage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 10:59:37 +05:00
admins fdb9f96382 Add phone and birthday fields to User model with admin editor
- Add phone/birthday columns via Prisma migration
- Admin user page shows phone and birthday with inline edit UI
- UserContactEditor client component for editing contact info
- updateUserContact server action with admin-only guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 16:43:35 +05:00
admins c64f393a7b Implement platform settings (Stage 9)
- Wire settings to actual platform behavior: maintenance mode, registration toggle,
  notification emails, curator feedback emails, email verification flag
- Add logo (logoUrl, showLogo) and social network links (YouTube, VK, Telegram) settings
- Show logo + school name dynamically in student layout header
- Add footer to student layout with org requisites and social links
- Register page: read settings server-side, validate terms checkbox with legal links
- Login page: show notice when redirected from closed registration
- Settings form: add Logo and Social Networks sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:31:10 +05:00
admins ba0a630fd9 Fix quiz attempts page: fetch users separately (no User relation on QuizAttempt)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 12:07:28 +05:00
admins 2468671d82 Fix QuizAttempt field name: createdAt -> completedAt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 12:06:13 +05:00
admins 7242a989ba Add admin quiz attempts viewer
- /admin/quizzes: list all quizzes with question and attempt counts
- /admin/quizzes/[quizId]: view all student attempts with answers per question
- Add "Тесты" link to admin sidebar navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 12:05:01 +05:00
admins d2150153df Add quiz feature: student UI, admin editor, lesson page integration
- QuizSection component: shows questions as text inputs, read-only after submission
- QuizEditor component: admin CRUD for quiz questions with type selector
- saveQuiz/deleteQuiz server actions for admin
- submitQuizAttempt server action: idempotent, auto-marks lesson complete
- Student lesson page: renders QuizSection, updates complete button logic
- Admin lesson page: renders QuizEditor below homework section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:43:16 +05:00
admins 3ed7bc147b Add lesson complete button with homework-aware logic
- Show "Отметить как пройденный" button only on lessons without homework
- Show static "Пройдено" badge on homework lessons completed via approval
- Auto-create LessonProgress when curator/admin approves homework submission
- Revalidate student lesson, course, and dashboard pages on approval

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 14:00:47 +05:00
admins 39d84a3db2 Add labeled file materials with format badge
- Store human-readable label in LessonFile.name via optional label field on upload
- Add PATCH endpoint to rename existing files inline
- Admin: label input before upload, click-to-edit inline rename
- Student: colored format badge (PDF/DOCX/XLSX/ZIP/etc) replaces paperclip emoji

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 11:55:07 +05:00
admins 15df731e37 Make lesson editor header and toolbar sticky on scroll
Both the controls bar (Save button, publish toggle) and the formatting
toolbar now stick to the top of the viewport while editing long lessons.
Header sticks at top: 0, toolbar sticks just below it at top: 62px.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:10:07 +05:00
admins bfa037885f Fix saveLesson: sanitize content JSON to prevent RSC proxy error
React treats TipTap's editor.getJSON() output as a non-plain object when
passed through Server Action serialization, causing isDecimal() inside
Prisma's query builder to receive a temporary client reference proxy and
throw ERROR 1213974697. JSON.parse(JSON.stringify()) strips the prototype
chain and any non-enumerable properties, ensuring Prisma receives a
clean plain object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:58:13 +05:00
admins 8757537344 Debug: add try/catch and JSON sanitize in saveLesson
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:54:10 +05:00
admins 65aa669522 Fix prisma generator provider to prisma-client
The generated TypeScript client in src/generated/prisma was created with
prisma-client provider (new TypeScript-first generator), but schema.prisma
had prisma-client-js. This caused Docker builds to generate files in
node_modules/@prisma/client instead of src/generated/prisma, breaking the
@/generated/prisma/client import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:47:26 +05:00
admins f4e74b38d4 Add explicit prisma output path to fix Docker build
Without output directive, Prisma 7 generates to node_modules/@prisma/client
in the Docker build context, causing the @/generated/prisma/client import
to fail with "Module not found". Explicit output ensures generated TypeScript
files are always placed at src/generated/prisma/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:38:19 +05:00
admins c050c005e4 Include src/generated in Docker build context for Prisma 7 TS client
Remove src/generated from .dockerignore so Turbopack can resolve
@/generated/prisma/client during build. The files are regenerated
by prisma generate inside the builder anyway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:29:47 +05:00
admins af1fb6f61e Fix RSC toStringTag error: import PrismaClient from generated TS client
Use @/generated/prisma/client instead of @prisma/client to avoid
Turbopack creating a broken external proxy for the missing
.prisma/client/default module at runtime. Add @prisma/adapter-pg
to serverExternalPackages, remove unused resolveAlias.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:08:09 +05:00
admins 09e5653191 Add Turbopack resolveAlias for Prisma client to fix RSC crash
Without resolveAlias, Turbopack fails to resolve .prisma/client/default
and creates a temporary client reference, causing the toStringTag error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:35:26 +05:00
admins 29f6533e63 Switch to prisma-client-js generator to fix Turbopack RSC crash
The new prisma-client generator outputs TypeScript files to src/generated/prisma/
which include import.meta.url at module level. Turbopack sees this and marks the
entire module as a client reference, causing 'Cannot access toStringTag on the
server' on every page that uses Prisma.

Switching to prisma-client-js puts the generated client in node_modules/@prisma/client
where serverExternalPackages can properly exclude it from the server bundle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:32:31 +05:00
admins 4821764a4f Fix Prisma 7 + Turbopack RSC compatibility by adding serverExternalPackages
Next.js 16 with Turbopack bundles @prisma/client into the RSC bundle,
causing it to be treated as a client module and creating 'temporary client
references'. This triggers the 'Cannot access toStringTag on the server' error
whenever Prisma result objects are used in Server Components.

Adding serverExternalPackages tells Turbopack to treat these as native
Node.js packages and keep them out of the RSC bundle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:23:02 +05:00
admins 5dfa79d357 Fix all Server Actions imported from dynamic route paths
All admin and student Client Components were importing Server Actions
from paths with dynamic segments ([courseId], [moduleId], [lessonId], [slug]).
This caused "Cannot access toStringTag on the server" RSC crash.

Consolidated all Server Actions into static files under src/lib/actions/:
- course-actions.ts  (modules + enrollment)
- module-actions.ts  (lessons + reorder + move)
- user-actions.ts    (bulk grant / revoke)
- student-actions.ts (progress + homework + comments)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:05:26 +05:00
admins 9eb21e3ab4 Move Server Actions to static paths to fix RSC temporary client reference error
Server Actions imported from dynamic route paths ([courseId]/[moduleId]/[lessonId])
caused "Cannot access toStringTag on the server" crash after saving a lesson.
Moved saveLesson, saveHomework, deleteHomework to src/lib/actions/*.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:58:27 +05:00
admins af8644ebce Serialize all Prisma proxy data in admin lesson and module pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:48:43 +05:00
admins 0bde11b86e Serialize all Prisma data before passing to Client Components
Prisma 7 proxy objects (DateTime, _count, relations) cannot be
serialized by React RSC. Convert all course page data to plain
JSON objects with JSON.parse/stringify before passing as props.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:42:05 +05:00
admins d8be6d6d95 Fix Prisma 7 JSON proxy serialization in RSC props
Prisma 7 wraps Json fields in proxy objects that RSC cannot serialize.
Fix: select specific columns (exclude content) in module page,
and JSON.parse/stringify lesson content before passing to client.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:36:04 +05:00
admins 9731fcab48 Add impersonatedBy field to Session model for admin plugin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:25:39 +05:00
admins 0e4f6c4b01 Fix impersonation: use direct fetch to /api/auth/admin/impersonate-user
authClient.admin.impersonateUser is not registered in pathMethods
in better-auth v1.6 client plugin — call the endpoint directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:18:49 +05:00
admins dd198349fb Fix impersonation: hard navigation + stop impersonating banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:12:47 +05:00
admins 808bcadfca Fix nested list spacing in TipTap lesson content
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:05:33 +05:00
admins ab37af59f2 Fix server component passing event handlers to client components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:59:00 +05:00
admins ce305eab58 Add admin impersonation button to users table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:52:13 +05:00
admins e590f541b3 Add automated backup scripts for PostgreSQL and S3 files to Backblaze B2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:38:48 +05:00
admins 48a9398905 Add optional audio response for students in homework submissions
- Course: add allowAudio toggle (per-course setting, off by default)
- HomeworkSubmission: add audioUrl field
- Student: AudioRecorder in homework form when allowAudio is enabled
- Student: show audio player in submission view and curator feedback view
- Curator: show student audio on submission detail page
- AudioRecorder: accept uploadUrl prop (reused for student/curator)
- API: /api/student/audio-upload route for S3 upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:17:46 +05:00
admins 3855bbd4be Add homework review workflow: statuses, audio, file attachments, tabs
- HomeworkSubmission: add status (PENDING/REVIEWING/APPROVED/REJECTED) + statusAt
- HomeworkFeedback: add files (Json) + audioUrl fields
- Curator detail page: meta table, content tabs, feedback history with audio/files
- FeedbackForm: file upload, audio recorder (Web Audio API + S3), action buttons
- AudioRecorder component: record → preview → upload to S3
- ContentTabs: toggle between homework description and lesson content (TipTap read-only)
- Homework list: 4-color status badges with proper filtering
- API routes: /api/curator/upload and /api/curator/audio-upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:01:55 +05:00
admins 768a38b9d3 Add course tree, lesson actions, and module description schema
- CourseTree: expandable module/lesson overview with Eye/Video icons
- SortableLessons: Kinescope ID in create form, published toggle, move-to-module dropdown
- Actions: toggleLessonPublished, moveLessonToModule, updateModule with description
- Schema: add description field to Module model + migration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:32:30 +05:00
admins f0024c4243 Add course management improvements: tree view, module descriptions, lesson toggles
- SortableModules: add description textarea in edit form, show description in row
- CourseDetailPage: fetch lessons per module, add CourseTree overview section
- ModulePage: fetch sibling modules, pass as otherModules to SortableLessons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:31:30 +05:00
admins d0ba4bf909 Polish: homework filters, users search/popup, admin comments
Homework (/curator/homework):
- Search by student name/email
- Filter by status (pending/reviewed) and course
- Server-side pagination (20 per page) with URL params

Users (/admin/users):
- Search by name/email, filter by role
- Hover popup on each row: enrolled courses + expiry dates + email
- Pagination (20 per page) with URL params

Comments (/admin/comments):
- New admin page with all active comments
- Search by author or text content
- One-click delete (soft-delete) from the table
- Pagination (30 per page)
- Added "Комментарии" link to admin nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:00:57 +05:00
admins dd46a10c20 Add CSV import/export for students (Stage 11)
Import wizard (4 steps):
- Upload CSV with UTF-8 or Windows-1251 (iconv-lite) decoding
- Auto-detect columns: Email, Имя, Фамилия, Телефон
- Preview table with per-row status: new / update / error
- Options: auto-verify email, assign course + access days, send welcome email
- Apply: creates users with bcrypt password + Account record, grants enrollments

Export:
- GET /api/admin/export-users with course filter + encoding selection
- UTF-8 with BOM (works in all apps) or Windows-1251 (legacy Excel)
- Fields: Email, Имя, Телефон, Дата регистрации, Курсы, Прогресс

Navigation: added "Импорт / Экспорт" link to admin sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:51:43 +05:00
admins 99c143d670 Add manual user creation in admin panel
- Server action createUser() with bcrypt password hash + Account record
- Form with name, email, password (show/hide + generate), role, emailVerified toggle
- Optional welcome email toggle (bypasses auto-hook for admin-created users)
- /admin/users/new page with breadcrumb navigation
- After creation, redirects to the new user's profile page
- "Добавить пользователя" button on the users list page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:36:52 +05:00
admins 58a61d6f04 Fix settings: catch DB errors at build time, return defaults
getSettings() and getSetting() now fall back to defaults when the
database is unavailable (e.g., during Docker image build).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:23:26 +05:00
admins e77588deb8 Add platform settings (Stage 9)
- Settings key-value table in Prisma with migration
- getSettings() / getSetting() helpers in lib/settings.ts
- Admin UI at /admin/settings with 6 sections: General, Notifications,
  Student profile, Legal docs, Curator permissions, Code injection
- saveSettings() server action with admin-only guard
- Maintenance mode: non-admin users redirected to /maintenance page
- schoolName propagated to page metadata and all email templates
- headCode / bodyCode injected into root layout <head> and <body>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:18:37 +05:00
admins 093e403f5f Enhance lesson editor: prev/next nav + richer toolbar
- page.tsx: fetch sibling lessons, pass prevLesson/nextLesson props
- LessonEditor: ChevronLeft/Right nav buttons with lesson title tooltip
- Toolbar: added Underline, Strikethrough, inline Code, H1, Horizontal rule,
  Link dialog (prompt), labeled buttons for better discoverability
- Install @tiptap/extension-underline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 10:29:39 +05:00
admins 66b311f17e Polish email template: white outer bg, beige card, Arial font
- Outer background: #FFFFFF
- Card background: #F5F5F0 (beige)
- All text/buttons: Arial/Helvetica (renders consistently on mobile)
- Shadow via table wrapper technique (works in Gmail Android)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:05:47 +05:00
admins 32b0fa9d6f Rewrite email template with inline styles for mobile compatibility
- All styles moved to inline attributes (no <style> block)
- Table-based layout for Gmail/Outlook/mobile client compatibility
- Aubade card effect via border-right/border-bottom:4px
- Monospace font stack with web-safe fallbacks
- btn() and quote() helper functions rewritten as <table> elements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:35:30 +05:00
admins c647b29712 Add Markdown import from Obsidian (Stage 8)
- md-to-tiptap.ts: remark-based converter (headings, lists, blockquotes,
  code blocks, bold/italic/strike, links, images, hr)
- Obsidian ![[wikilink]] stripped, [[link|alias]] → plain text
- POST /api/admin/import-md: parses frontmatter (gray-matter) + converts content
- LessonEditor: "Импорт .md" button populates editor without auto-save
- ROADMAP: marked Stages 2, 3, 5, 6, 7, 8 as complete, fixed numbering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:44:42 +05:00
admins 6d93a7b406 Add lesson comments (Stage 6)
- LessonComment CRUD: addComment / deleteComment server actions
- LessonComments client component with form, avatar, delete
- Comments section at bottom of lesson page (enrolled users only)
- Soft-delete support, moderation for curator/admin
- ROADMAP: moved Квизы to end (Stage 11), marked Stage 7 done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:33:47 +05:00
admins 97f4c1ec24 Fix admin sidebar missing on /curator/* routes
- Extract AdminShell component (sidebar + wrapper)
- admin/layout.tsx uses AdminShell
- curator/layout.tsx uses AdminShell for admin role (was rendering children only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:59:51 +05:00
admins ec51dd34bb Replace admin dashboard stub with real stats
- 4 stat cards: students (+monthly), courses (published), active enrollments (expiring alert), homework pending
- Recent enrollments list (last 8)
- Top courses by enrollment count
- Activity counters: total lessons completed, total homework submitted
- All cards link to relevant admin pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:58:46 +05:00
admins b40d518b74 Fix Resend lazy init to avoid build-time API key error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:48:14 +05:00
admins 6975a9f97e Add email notifications via Resend
- src/lib/email.ts: HTML templates for 4 email types (Second Brain design)
- Welcome email on user registration (Better Auth databaseHooks)
- Course access email when admin grants enrollment
- Homework submitted email to all admins/curators (first submission only)
- Feedback received email to student with feedback text and lesson link
- Update TECHNICAL.md: Resend domain, from-address, email vars, Stage 3 summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:46:46 +05:00
admins 9bc18247df Add homework review link to admin sidebar
- Admin sidebar now has "ДЗ на проверку" link → /curator/homework
- Curator layout renders children-only for admin (no double sidebar)
- Active state highlights correctly when on /curator/* routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:18:56 +05:00
admins 543d5b2d5e Add homework system (admin, student, curator)
Admin:
- HomeworkEditor in lesson page: create/update/delete assignment description

Student:
- HomeworkSection in lesson page: view assignment, submit text + files
- Resubmission allowed until curator gives feedback
- Shows feedback from curator with date and name

Curator:
- New layout with Second Brain dark sidebar (replaces green theme)
- /curator/dashboard: stats cards (pending, total, reviewed this week)
- /curator/homework: list of all submissions, pending highlighted
- /curator/homework/[id]: review submission, write feedback, redirect after send

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:13:24 +05:00
admins d0c8c6dd53 Add lesson progress tracking
- Toggle lesson completion via server action (LessonProgress table)
- "Отметить как пройденный" button on lesson page, turns accent when done
- Course sidebar: progress bar, checkmarks on completed lessons, X/Y counter per module
- Dashboard: progress bar on each course card with completion percentage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:16:28 +05:00
admins c88b5d2004 Allow admin to preview unpublished lessons without enrollment
- Course layout skips enrollment and published checks for admin role
- Lesson page skips published filter for admin role
- Enables admin preview button to work for any lesson/course state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:57:18 +05:00
admins 4183a912e4 Add save and preview icons to lesson editor
- Save button now shows floppy disk icon (lucide Save)
- New Preview button with eye icon opens lesson in student view (new tab)
- Pass courseSlug through to LessonEditor for preview URL construction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:45:30 +05:00
admins 07b9a6d261 Polish UX: auto-redirect on create, fix design consistency
- createModule now redirects to module page after creation
- createLesson now redirects to lesson editor after creation
- Regenerate Prisma client to fix missing types (category, accessLog, expiresAt)
- Rewrite sortable-modules/lessons with Second Brain design tokens (remove amber/slate)
- Rewrite lesson-editor toolbar and toggle with design tokens
- Fix register page/form: replace amber theme with card-aubade design

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:38:46 +05:00
admins 05dd4d1df2 Stage 2: student lesson viewer, Kinescope player, PDF files, prev/next nav, My Courses dashboard 2026-04-07 12:13:12 +05:00
admins 03e3972388 Add TECHNICAL.md: infrastructure, design tokens, media specs, DB schema, done stages 2026-04-07 12:08:44 +05:00
admins 8fdc67b4a5 Mark Stage 1.5 complete in ROADMAP 2026-04-07 12:01:19 +05:00
admins e9eff5bae5 Stage 1.5: categories, enrollment expiry, access log, bulk grant, user page 2026-04-07 11:59:13 +05:00
admins 992763aeb9 Apply Second Brain design: Fira Mono, Aubade cards, brand palette 2026-04-07 11:51:20 +05:00
admins 09325187f9 Fix createLesson return type: void instead of string 2026-04-07 11:39:51 +05:00
admins 01a9ef482c Fix DialogTrigger: remove asChild (base-ui doesn't support it) 2026-04-07 11:38:33 +05:00
admins d356dddc96 Stage 1: Course/Module/Lesson CRUD admin UI with TipTap editor 2026-04-07 11:36:27 +05:00
admins 9d82b73e58 feat: initial commit 2026-04-07 11:30:38 +05:00
168 changed files with 21899 additions and 357 deletions
-1
View File
@@ -5,7 +5,6 @@
.env.production
node_modules
.next
src/generated
*.md
docker-compose.yml
docker-compose.prod.yml
+3
View File
@@ -45,3 +45,6 @@ yarn-error.log*
next-env.d.ts
/src/generated/prisma
# Claude Code local plugins (external git repos, не коммитим)
.claude/plugins/
+307 -4
View File
@@ -1,5 +1,308 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
# AGENTS.md — LMS Second Brain
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
Собственная LMS-платформа для образовательных курсов по PKM и Obsidian.
Заменяет emdesell.ru. Масштаб: ~1000 аккаунтов, ~200 активных, до 10 курсов.
Production: **https://school.second-brain.ru**
> Подробная техническая документация — в `TECHNICAL.md`.
> Роадмап и текущий статус — в `ROADMAP.md`.
> Полные правила для Claude Code — в `CLAUDE.md`.
---
## Стек
| Слой | Технология | Версия |
|------|-----------|--------|
| Фреймворк | Next.js (App Router) | **16.2.2** |
| Язык | TypeScript (strict) | 5.x |
| UI | React | 19 |
| Стили | Tailwind CSS (CSS-based, **без** tailwind.config.ts) | 4.x |
| Компоненты | shadcn/ui (Base UI, **не Radix**) | v4 |
| ORM | Prisma | 7.x |
| Auth | Better Auth (**не NextAuth**) | 1.6.0 |
| Редактор | TipTap WYSIWYG | 2.x |
| Drag-and-drop | @dnd-kit | latest |
| БД | PostgreSQL | 16 |
| Email | Resend | latest |
| Хранилище | Hetzner Object Storage (S3-совместимый) | — |
| Видео | Kinescope (iframe embed) | — |
| Валидация | Zod | 3.x |
---
## Критические отличия от стандартных версий
Эти технологии отличаются от того, что содержится в обучающих данных большинства моделей. **Читай документацию перед написанием кода.**
### Next.js 16.2.2
- Используется `proxy.ts` вместо `middleware.ts`
- Экспортируемая функция называется `proxy`, не `middleware`
- Перед написанием кода смотри `node_modules/next/dist/docs/`
### Tailwind CSS v4
- **Нет файла `tailwind.config.ts`** — вся кастомизация через CSS
- Конфиг: `@import "tailwindcss"` и `@theme` в `globals.css`
### shadcn/ui v4
- Базируется на `@base-ui/react`, **не Radix**
- Нет пропа `asChild` — триггеры обычные элементы
- Установка: `npx shadcn@latest add <component>`
### Prisma 7.x
- Импорт: `from "@/generated/prisma/client"` (не `from "@/generated/prisma"`)
- Требует адаптер: `new PrismaPg({ connectionString })`
- Не генерирует `index.ts`
### Better Auth 1.6.0
- **Не путать с NextAuth** — другая библиотека, другое API
- В этом проекте используется **bcrypt** (не scrypt по умолчанию)
- Настройки `password.hash` / `password.verify` в `src/lib/auth.ts`
- `auth-client.ts` не использует `baseURL` — берёт `window.location.origin`
- Seed-пользователи вставлены через SQL с `emailVerified = true`
---
## Команды
```bash
# Разработка
npm run dev # localhost:3000
docker compose up -d # Поднять PostgreSQL локально
# Проверка качества
npm run lint # ESLint
npm run type-check # tsc --noEmit
# Сборка
npm run build
npm run start
# База данных
npx prisma migrate dev --name <snake_case_name> # Новая миграция
npx prisma migrate deploy # Применить в production
npx prisma generate # Пересоздать клиент
npx prisma db seed # Заполнить тестовыми данными
npx prisma studio # GUI для БД
# Production-деплой (на сервере в /root/digital-household/lms-sb/)
git pull
docker compose -f docker-compose.prod.yml up -d --build
```
При старте production-контейнер автоматически запускает `prisma migrate deploy`, затем `node server.js`.
---
## Структура проекта
```
lms-system/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (auth)/ # login, register, verify-email
│ │ ├── (student)/ # dashboard, courses/[slug], lessons/[lessonId]
│ │ ├── curator/ # homework review, dashboard
│ │ ├── admin/ # courses, users, settings, categories
│ │ └── api/ # REST endpoints + Better Auth handler
│ ├── components/
│ │ ├── ui/ # shadcn/ui (автогенерация, не трогать)
│ │ ├── editor/ # TipTap WYSIWYG
│ │ ├── player/ # Kinescope Player wrapper
│ │ ├── course/ # Компоненты курса
│ │ └── layout/ # Header, Sidebar, Footer
│ ├── lib/
│ │ ├── auth.ts # Better Auth config (сервер)
│ │ ├── auth-client.ts # Better Auth client (браузер)
│ │ ├── prisma.ts # Prisma singleton
│ │ ├── s3.ts # Hetzner S3 клиент
│ │ ├── email.ts # Resend email helpers
│ │ └── utils.ts # cn() и утилиты
│ ├── types/ # TypeScript-типы
│ ├── proxy.ts # Auth middleware (защита маршрутов)
│ └── middleware.ts # Обёртка над proxy
├── prisma/
│ ├── schema.prisma # Схема БД (~314 строк)
│ ├── seed.ts # Тестовые данные
│ └── migrations/ # НЕ РЕДАКТИРОВАТЬ ВРУЧНУЮ
├── docker-compose.yml # Локальная разработка
├── docker-compose.prod.yml # Production
├── Dockerfile # Multi-stage build
├── .env.example # Шаблон переменных (без секретов)
└── .env.local # Локальные секреты (в .gitignore)
```
---
## Роли и маршруты
| Роль | Маршруты | Описание |
|------|---------|----------|
| `admin` | `/admin/*`, `/curator/*`, всё | Полный доступ |
| `curator` | `/curator/*`, `/dashboard` | Проверка ДЗ, комментарии |
| `student` | `/dashboard`, `/courses/*` | Просмотр курсов, прогресс |
Защита маршрутов — в `src/proxy.ts` + проверка сессии в layout/page.
---
## Модель данных (ключевые сущности)
```
User → Session, Account, Verification # Better Auth
Category → Course → Module → Lesson # Структура контента
Lesson → LessonFile # Файлы к уроку
CourseEnrollment (userId + courseId) # Доступ с expiresAt
AccessLog # Аудит доступов
LessonProgress (userId + lessonId) # Прогресс ученика
Lesson → Homework → HomeworkSubmission → HomeworkFeedback # ДЗ
Lesson → LessonComment # Обсуждения (soft-delete)
Lesson → Quiz → QuizQuestion → QuizOption # Тесты
Quiz → QuizAttempt # Результаты тестов
Settings (key-value) # Настройки платформы
```
---
## Дизайн-система «Second Brain Aubade»
Типографский, монохромный, газетный стиль.
| Токен | Значение |
|-------|---------|
| Шрифт | Fira Mono (400/500/700, Latin + Cyrillic) |
| Фон | `#F5F5F0` (тёплый off-white) |
| Текст | `#323232` |
| Поверхность | `#E8E8E0` |
| Акцент | `#E8F0D8` (зелёный) |
| Border | `#AAAAAA` |
| Сайдбар | `#2A2A28` (тёмный) |
**Aubade-эффект** (карточки и кнопки):
- Border: `2px solid #AAAAAA`
- Shadow: `4px 4px 0 0 #AAAAAA`
- Hover: `translate(-2px, -2px)` + shadow `6px 6px`
- Active: `translate(2px, 2px)`, shadow убирается
- CSS-классы: `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aubade`
---
## Инфраструктура
| Компонент | Значение |
|-----------|---------|
| Сервер | Hetzner VPS — 8 vCPU / 16 GB RAM / 320 GB NVMe |
| Reverse proxy | Caddy (auto HTTPS, Let's Encrypt) |
| Порт | 3010 (внутри контейнера 3000) |
| БД | PostgreSQL 16 (контейнер `lms-sb-db-1`) |
| Object Storage | Hetzner S3, endpoint `nbg1.your-objectstorage.com`, бакет `second-brain-lms` |
| Git | Gitea — `https://git.second-brain.ru/admins/lms-sb` |
| Email | Resend, домен `mailsend.second-brain.ru` |
| Бэкапы | PostgreSQL → Backblaze B2 (ежедневно, 03:00, ротация 7 дней) |
---
## Переменные окружения
```env
DATABASE_URL="postgresql://lms_user:password@localhost:5432/lms_db"
BETTER_AUTH_SECRET="generate-with-openssl-rand-base64-32"
BETTER_AUTH_URL="http://localhost:3000"
RESEND_API_KEY=""
EMAIL_FROM="noreply@mailsend.second-brain.ru"
S3_ENDPOINT="https://nbg1.your-objectstorage.com"
S3_BUCKET="second-brain-lms"
S3_ACCESS_KEY=""
S3_SECRET_KEY=""
S3_REGION="eu-central"
```
Секреты — **только в `.env.local`**. При добавлении новых переменных обновлять `.env.example`.
---
## Правила написания кода
### Языки
- **UI-строки** (заголовки, кнопки, сообщения): русский
- **Переменные, функции, файлы, комментарии**: английский
- **Коммиты**: английский, imperative mood (`Add lesson progress`, `Fix auth redirect`)
### Стиль
- Server Actions для форм и мутаций
- Не добавлять абстракции «на будущее» — только текущий этап
- Нет `console.log` в production (только `console.error` для реальных ошибок)
- Нет захардкоженных секретов, URL, ID
### Миграции БД
- **Никогда** не редактировать `prisma/migrations/` вручную
- **Всегда** спрашивать перед миграцией, которая меняет или удаляет существующие поля
- Имена миграций: английский, snake_case (`add_lesson_progress`)
- Перед `prisma migrate deploy` на production — бэкап БД
### Файлы и загрузки
- Все файлы (ДЗ, PDF, изображения) — через Hetzner Object Storage, **не на диск VPS**
- Обложки курсов: 16:9, max 5 MB, JPG/PNG/WebP
- Изображения в уроках: max 10 MB
- Файлы к уроку: max 100 MB, PDF/ZIP/DOCX
### Коммиты
- Один коммит = одна логически завершённая единица
- Перед коммитом: `npm run lint && npm run type-check`
- После завершения каждого этапа ROADMAP — `git push` в Gitea
---
## Чек-лист перед коммитом
- [ ] `npm run lint` — без ошибок
- [ ] `npm run type-check` — без ошибок
- [ ] Новые `.env` переменные добавлены в `.env.example`
- [ ] Миграция БД согласована (если есть)
- [ ] Нет `console.log`, нет секретов в коде
---
## Тестовые аккаунты
| Email | Пароль | Роль |
|-------|--------|------|
| admin@second-brain.ru | Password123! | admin |
| curator@second-brain.ru | Password123! | curator |
| student@second-brain.ru | Password123! | student |
---
## Текущий статус проекта
**Завершено (9 из 13 этапов):**
- Этап 0: Каркас, auth, роли, деплой
- Этап 1: Курсы → Модули → Уроки (CRUD, drag-and-drop, TipTap, S3)
- Этап 1.5: Расширенный доступ (сроки, категории, AccessLog)
- Этап 2: Kinescope-интеграция, рендер уроков для ученика
- Этап 3: Прогресс (кнопка завершения, прогресс-бар)
- Этап 5: Домашние задания + обратная связь куратора
- Этап 6: Обсуждения под уроками
- Этап 7: Email-уведомления (Resend)
- Этап 8: Импорт уроков из Markdown (Obsidian)
**В работе:**
- Этап 9: Настройки платформы (Admin Settings)
**Впереди:**
- Этап 11: Импорт/экспорт учеников (CSV, миграция с emdesell)
- Этап 12: Telegram-бот + аналитика (Yandex.Metrika)
- Этап 13: Тесты и квизы с автопроверкой
**Бэклог:** сертификаты, геймификация, платежи, медиатека, цифровой сад, CI/CD
Полный роадмап с деталями и критериями готовности — в `ROADMAP.md`.
---
## Известные ограничения
- Seed-пользователи вставлены через SQL с `emailVerified = true` (обход Better Auth)
- Загрузка файлов: нет лимита на уровне Next.js (только S3)
- Drag-and-drop: возможны race conditions при быстрых перетаскиваниях (некритично)
- `expiresAt` проверяется в UI, но не блокирует доступ на уровне middleware
+1 -1
View File
@@ -11,7 +11,7 @@
| TypeScript | 5.x | Язык |
| React | 19 | UI |
| PostgreSQL | 16 | База данных |
| Prisma | 6.x | ORM + миграции |
| Prisma | 7.x | ORM + миграции |
| Better Auth | latest | Аутентификация и сессии |
| Tailwind CSS | 4.x (CSS-based config) | Стили (нет tailwind.config.ts) |
| shadcn/ui | latest | UI-компоненты |
+34
View File
@@ -0,0 +1,34 @@
# Дизайн-система — LMS
Этот проект использует дизайн-систему **ДС-2 «Second Brain LMS & Press»** — терминальный язык «Aubade».
## Канон
Полная спецификация (9 секций, формат `DESIGN.md`):
- **Source of truth:** `SecondBrainTech/02-Стандарты/Дизайн-LMS/DESIGN.md`
- Копия для Open Design: `~/Documents/Claude/open-design/design-systems/second-brain-lms/`
- Превью со всеми примерами: `preview.html` в тех же каталогах
> Канон правится только там. Этот файл — практический указатель для разработки внутри репозитория.
## Язык в двух словах
Терминальный, моноширинный, «реестровый». Серо-зелёная палитра, острые углы 2px, выраженные рамки 2px, жёсткие тени-подложки с физикой hover/active. Тёмный админ-сайдбар. Без кремовых тонов и серифа — это язык ДС-1 (сайт и PDF), отдельной парной системы.
## Где токены в этом репозитории
Реализация — `src/app/globals.css`:
- **Палитра** — CSS-переменные в `:root`: `--background #F5F5F0`, `--foreground #323232`, `--accent #E8F0D8`, `--border #AAAAAA`, тёмный сайдбар `--sidebar-*`.
- **Типографическая шкала** — переопределённые токены Tailwind `--text-*` в блоке `@theme` (канон ДС-2, +2px к дефолту Tailwind).
- **Шрифт** — Fira Mono, подключение в `src/app/layout.tsx` через `next/font/google`.
- **Компонентные классы** — `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aubade`, `.admin-sidebar*`.
## Письма Press
Рассылка Second Brain Press — поверхность Email той же ДС-2. Шаблон — Listmonk template id=1 (табличная вёрстка, Arial, карта 620px с рамкой 2px `#AAAAAA`). Подробности — секция 5 канонического `DESIGN.md`.
## История
Предыдущая версия этого файла ссылалась на легаси-дизайн-систему v1 (кремовая палитра + лаванда, `~/Documents/Claude/design-system/`). Она заменена: v1 — легаси, актуальна ДС-2.
+289 -142
View File
@@ -6,13 +6,12 @@
---
## Этап 0 — Каркас, auth, роли ✅ ЗАВЕРШЁН (07.04.2026)
**Задеплоено на:** https://school.second-brain.ru
- [x] Next.js 16.2.2 (App Router, TypeScript, Tailwind v4)
- [x] Docker Compose: PostgreSQL 16 (прод на Hetzner)
- [x] Prisma 7: схема User, Session, Account + полная LMS-модель
- [x] Better Auth: вход по email/password, роли student/curator/admin
- [x] proxy.ts: защита маршрутов по сессии
- [x] Middleware: защита маршрутов по сессии
- [x] Дашборды для трёх ролей
- [x] Страница входа, регистрации, подтверждения email
- [x] Seed: admin/curator/student (пароль: Password123!)
@@ -21,62 +20,249 @@
---
## Этап 1 — Курсы → Модули → Уроки (CRUD в админке)
**Цель:** могу создать полную структуру курса из браузера.
## Этап 1 — Курсы → Модули → Уроки (CRUD в админке) ✅ ЗАВЕРШЁН (07.04.2026)
- [ ] Prisma-схема: Course, Module, Lesson (с порядком, статусом published)
- [ ] Admin: список курсов, создать / редактировать / удалить курс
- [ ] Admin: список модулей внутри курса, drag-and-drop сортировка
- [ ] Admin: список уроков внутри модуля, drag-and-drop сортировка
- [ ] Admin: редактор урока с TipTap (заголовки, списки, цитаты, код, картинки, ссылки)
- [ ] Загрузка картинок в уроке → Hetzner Object Storage
- [ ] Поле для Kinescope ID в уроке (просто текстовое, без интеграции — это Этап 2)
- [ ] Публикация/скрытие курса и урока (черновик / опубликован)
- [ ] Управление доступом: выдать / забрать доступ к курсу для пользователя
**Критерий готовности:** создаю курс «Obsidian PKM», добавляю 2 модуля, в каждом по 3 урока с текстом и картинкой, публикую.
- [x] Prisma-схема: Course, Module, Lesson (с порядком, статусом published)
- [x] Admin: список курсов, создать / редактировать / удалить курс
- [x] Admin: список модулей внутри курса, drag-and-drop сортировка
- [x] Admin: список уроков внутри модуля, drag-and-drop сортировка
- [x] Admin: редактор урока с TipTap (заголовки, списки, цитаты, код, картинки, ссылки)
- [x] Загрузка картинок в уроке → Hetzner Object Storage
- [x] Поле для Kinescope ID в уроке
- [x] Публикация/скрытие курса и урока (черновик / опубликован)
- [x] Управление доступом: выдать / забрать доступ к курсу для пользователя
- [x] Дизайн: Fira Mono, #F5F5F0, Aubade-карточки
- [x] Admin: таблица пользователей (/admin/users)
---
## Этап 2 — Интеграция Kinescope, рендер уроков для ученика
**Цель:** ученик видит урок с видео Kinescope и текстом.
- [ ] Компонент `KinescopePlayer` — обёртка над `@kinescope/react-kinescope-player`
- [ ] Рендер урока для ученика: видео (если есть Kinescope ID) + текст + файлы
- [ ] Загрузка PDF/файлов к уроку (Object Storage), список для скачивания
- [ ] Страница курса для ученика: список модулей и уроков, статус прохождения
- [ ] Навигация по урокам: предыдущий / следующий
- [ ] Блокировка доступа к курсу без enrollment (middleware или server component)
- [ ] Страница «Мои курсы» в личном кабинете ученика
**Кинескоп-нюанс:** пока без DRM (бесплатный план), просто передаём `videoId` в компонент. Когда появится платный план — добавим signed URLs в отдельной задаче.
**Критерий готовности:** ученик открывает урок, смотрит видео из Kinescope, читает текст, скачивает PDF.
**Доработки таблицы пользователей (добавить в рамках Этапа 9):**
- [ ] Фильтры: по роли (Ученик / Куратор / Администратор), по статусу email (подтверждён/нет)
- [ ] Поиск по имени / email
- [ ] Пагинация + выбор кол-ва записей на странице (20/50/100)
- [ ] Ховер-попап на строке: список курсов пользователя со сроками доступа + email + телефон
- [ ] Быстрые действия в строке: кнопка «Выдать доступ» без перехода на страницу пользователя
---
## Этап 3 — Прогресс и линейное открытие уроков
**Цель:** ученик проходит курс последовательно, прогресс сохраняется.
## Этап 1.5 — Расширенное управление доступом ✅ ЗАВЕРШЁН (07.04.2026)
- [ ] Prisma: таблица `LessonProgress` (userId, lessonId, completedAt)
- [ ] Кнопка «Урок завершён» — создаёт запись в LessonProgress
- [ ] Логика блокировки: следующий урок открыт только если предыдущий завершён
- [ ] Прогресс-бар по курсу (% завершённых уроков)
- [ ] Прогресс-бар по модулю
- [ ] API: `POST /api/progress/complete` — принимает lessonId, создаёт прогресс
- [ ] Иконки статуса уроков в боковой панели: ✓ пройден / 🔒 заблокирован / → текущий
**Примечание:** если в уроке есть тест (Этап 4) или ДЗ (Этап 5) — кнопка «Завершить» появляется только после их прохождения/отправки. Эту логику дорабатываем на соответствующих этапах.
**Критерий готовности:** прохожу урок 1, нажимаю «Завершён» — открывается урок 2. Урок 3 заблокирован. Прогресс-бар показывает 33%.
- [x] Срок доступа: `expiresAt` в `CourseEnrollment`, просроченный подсвечивается красным
- [x] Категории курсов: таблица `Category`, `/admin/categories`, привязка к курсу
- [x] Расширенный энролл: `/admin/users/[userId]` — выбор нескольких курсов + срок одной операцией
- [x] История доступа: `AccessLog` — каждая операция логируется (кто, когда, метод, примечание)
---
## Этап 4Тесты и квизы
## Этап 2Интеграция Kinescope, рендер уроков для ученика ✅ ЗАВЕРШЁН (07.04.2026)
- [x] Компонент `KinescopePlayer` — обёртка над `@kinescope/react-kinescope-player`
- [x] Рендер урока для ученика: видео (если есть Kinescope ID) + текст + файлы
- [x] Загрузка PDF/файлов к уроку (Object Storage), список для скачивания
- [x] Страница курса для ученика: список модулей и уроков, статус прохождения
- [x] Навигация по урокам: предыдущий / следующий
- [x] Блокировка доступа к курсу без enrollment (layout server component)
- [x] Страница «Мои курсы» в личном кабинете ученика (dashboard)
- [x] Кнопки Сохранить / Просмотр в редакторе урока
- [x] Иконка-статус уроков в боковой панели курса (✓ пройден)
---
## Этап 3 — Прогресс ✅ ЗАВЕРШЁН (07.04.2026)
- [x] Prisma: таблица `LessonProgress` (userId, lessonId, completedAt)
- [x] Кнопка «Урок завершён» — создаёт/удаляет запись в LessonProgress
- [x] Прогресс-бар по курсу в боковой панели (% завершённых уроков)
- [x] Прогресс-бар по курсу на дашборде студента
- [x] Admin bypass: администратор видит все уроки без отметок о прогрессе
---
## Этап 5 — Домашние задания и обратная связь куратора ✅ ЗАВЕРШЁН (07.04.2026)
- [x] Prisma: Homework, HomeworkSubmission, HomeworkFeedback
- [x] Admin: добавить / редактировать / удалить блок ДЗ к уроку (HomeworkEditor)
- [x] Ученик: форма отправки ДЗ (текст + файлы → Object Storage)
- [x] Ученик: видит статус ДЗ и фидбек куратора в карточке урока
- [x] Куратор / Admin: список ДЗ на проверку (`/curator/homework`)
- [x] Куратор / Admin: просмотр работы, текстовый комментарий с обратной связью
- [x] Admin: AdminShell на `/curator/*` маршрутах (сайдбар не пропадает)
- [x] Admin: реальная статистика на дашборде (студенты, курсы, ДЗ, прогресс)
**Доработки (добавить в рамках Этапа 9):**
- [ ] Фильтры в списке ДЗ: по имени/email ученика, по уроку, по курсу, по статусу
- [ ] Поиск по имени/email ученика
- [ ] Пагинация + выбор кол-ва записей на странице (20/50/100)
- [ ] Расширенные статусы: «Новое» / «Просмотрено» / «Прокомментировано»
- [ ] Шаблоны ответов куратора — готовые тексты фидбека, выбираемые из списка
---
## Этап 6 — Обсуждения под уроками ✅ ЗАВЕРШЁН (07.04.2026)
- [x] Prisma: LessonComment (soft-delete через поле `deleted`)
- [x] Рендер списка комментариев под уроком (server-fetched, client-rendered)
- [x] Форма отправки комментария (только для enrolled учеников и admin)
- [x] Модерация: автор, куратор или admin может удалить комментарий
- [x] Счётчик активных комментариев в заголовке секции
**Не реализовано (добавить в Этап 9 или отдельно):**
- [ ] `/admin/comments` — сводная таблица всех комментариев по всем урокам
- Колонки: №, Имя ученика, Урок (ссылка), Время, кол-во ответов, превью текста
- Удалить комментарий прямо из списка
- Пагинация
- Ссылка в сайдбаре AdminNav
---
## Этап 7 — Email-уведомления ✅ ЗАВЕРШЁН (07.04.2026)
- [x] Базовый HTML email-шаблон (фирменный стиль Second Brain)
- [x] Приветственное письмо при регистрации (`databaseHooks.user.create.after`)
- [x] Письмо ученику об открытии доступа к курсу
- [x] Куратор / Admin: уведомление о новом ДЗ на проверку
- [x] Ученик: уведомление о полученном фидбеке
- [x] Resend domain: mailsend.second-brain.ru (verified)
---
## Этап 8 — Импорт уроков из Markdown (Obsidian) ✅ ЗАВЕРШЁН (07.04.2026)
- [x] API: `POST /api/admin/import-md` — принимает .md-файл
- [x] Парсинг frontmatter (title, kinescopeId, order, published) через `gray-matter`
- [x] Конвертация Markdown → TipTap JSON через `unified` + `remark-parse`
- [x] Поддержка: заголовки, параграфы, жирный/курсив/зачёркнутый, инлайн-код, блоки кода, цитаты, списки, ссылки, изображения (HTTP), горизонтальные разделители
- [x] Очистка Obsidian-синтаксиса: `![[image]]` удаляется, `[[link|alias]]` → текст
- [x] UI: кнопка «Импорт .md» в редакторе урока — заполняет форму без автосохранения
---
## Этап 9 — Настройки платформы (Admin Settings) ✅ ЗАВЕРШЁН (08.04.2026)
**Цель:** администратор управляет ключевыми параметрами платформы без правки кода.
### Основное
- [ ] Название школы (используется в заголовке сайта, подписи писем)
- [ ] Описание школы (мета-тег description)
- [ ] Ключевые слова (мета-тег keywords)
- [ ] Режим тех. работ: вкл/выкл (показывает заглушку всем кроме admin)
- [ ] Регистрация учеников: вкл/выкл
### Оформление
- [ ] Логотип школы (загрузка → Object Storage, отображается в шапке)
- [ ] Фавикон (загрузка → Object Storage)
- [ ] Показывать логотип: да/нет
### Уведомления
- [ ] Email(ы) для системных уведомлений (кому слать письма о ДЗ, вопросах, регистрациях)
- [ ] Уведомление куратору/админу о новом ДЗ: вкл/выкл
- [ ] Уведомление куратору/админу о новом вопросе ученика: вкл/выкл
- [ ] Уведомление админу о новой регистрации: вкл/выкл
- [ ] Уведомление ученику при ответе на ДЗ/вопрос: вкл/выкл
### Данные ученика
- [ ] Требовать подтверждение email перед доступом к курсам: да/нет
- [ ] Фамилия при регистрации: обязательная / необязательная / выключена
- [ ] Телефон при регистрации: обязательный / необязательный / выключен
### Защита
- [ ] Одна активная сессия на аккаунт: вкл/выкл
- [ ] CAPTCHA на форме регистрации: вкл/выкл (reCAPTCHA v3)
### Права куратора
- [ ] Куратор видит ДЗ: по всем курсам / только по назначенным курсам
- [ ] Куратор может отвечать на вопросы учеников: да/нет
- [ ] Куратор видит список всех студентов: да/нет
### Вставка кода
- [ ] Произвольный код в `<head>` (Yandex.Metrika, Google Analytics, пиксели)
- [ ] Произвольный код в `<body>` (виджеты, чаты поддержки)
### Юридические документы
- [ ] URL Политики конфиденциальности (ссылка на внешний документ)
- [ ] URL Согласия на обработку персональных данных
- [ ] URL Договора-оферты
- [ ] Показывать чекбокс «Я принимаю условия» при регистрации: да/нет
- [ ] Реквизиты организации (текстовое поле, отображается в подвале)
### Соц. сети
- [ ] YouTube: одна ссылка
- [ ] VK: несколько ссылок (название + URL), например «Основная группа» и «Канал»
- [ ] Telegram: несколько ссылок (название + URL), например «Основной канал» и «Канал курса»
(отображаются в подвале личного кабинета ученика; хранятся как JSON-массив в Settings)
### Вопросы учеников
- [ ] Система вопросов глобально: вкл/выкл
- [ ] Куратор/админ может написать ученику первым: да/нет
- [ ] Вопросы только по курсам ученика: да/нет
- [ ] Включать вопросы для новых курсов автоматически: да/нет
**Хранение:** таблица `Settings` (key-value), доступна через `getSettings()` в server components.
**Критерий готовности:** меняю название школы → оно появляется в заголовке. Включаю тех. работы → ученики видят заглушку. Куратор привязан к курсу — видит только его ДЗ.
---
## Этап 11 — Импорт/Экспорт учеников и миграция с emdesell
**Цель:** все пользователи и контент перенесены в новую LMS. Раздел `/admin/import-export`.
### Импорт учеников (CSV)
- [ ] Скачать файл-шаблон CSV (Email, Имя, Фамилия, Телефон)
- [ ] Загрузка CSV, поддержка кодировок Windows-1251 и UTF-8
- [ ] Опция: подтверждать email автоматически (да/нет)
- [ ] Опция: обновлять уже существующие аккаунты (да/нет)
- [ ] Присвоение доступов к курсам при импорте (выбор курса + срок в днях, 0 = бессрочно)
- [ ] Опция: отправить письмо-уведомление ученику (со ссылкой для установки пароля)
- [ ] Предпросмотр перед применением (таблица: кто создаётся, кто обновляется, кому даётся доступ)
- [ ] Применить импорт — создать пользователей, выдать доступы, отправить письма
### Экспорт учеников (CSV)
- [ ] Все ученики или фильтр по конкретному курсу/доступу
- [ ] Фильтр по просмотрам уроков (экспортировать только тех кто смотрел)
- [ ] Выбор кодировки: Windows-1251 (для Excel) / UTF-8
- [ ] Поля: Email, Имя, Фамилия, Телефон, Дата регистрации, Курсы, Прогресс
### Миграция контента
- [ ] Чек-лист ручного переноса контента (уроки, PDF, структура курсов)
- [ ] Скрипт проверки целостности: все enrolled пользователи имеют доступ к нужным курсам
- [ ] QA: проверить 10 случайных аккаунтов после импорта
**Критерий готовности:** загружаю CSV из emdesell → предпросмотр показывает корректные данные → применяю → ученики получают письма → могут войти и продолжить обучение.
---
## Этап 12 — Telegram-бот и аналитика
**Цель:** уведомления в Telegram для всех участников, базовая аналитика.
**Настройки (в разделе Настройки → Telegram):**
- Токен бота (вводится в админке, хранится в Settings)
- Интеграция вкл/выкл глобально
- Показывать кнопку «Подключить Telegram» в кабинете ученика: да/нет
**Уведомления куратору/админу:**
- [ ] Новое ДЗ на проверку
- [ ] Новый вопрос от ученика
- [ ] Новая регистрация студента
- [ ] Ошибки платформы (500-е, failed email и т.д.)
**Уведомления ученику:**
- [ ] Получен фидбек по ДЗ
- [ ] Ответ куратора на вопрос
- [ ] Открыт доступ к новому курсу
**Реализация:**
- [ ] Ученик привязывает Telegram через `/start` в боте (сохраняется `telegramChatId` в User)
- [ ] Кнопка «Подключить Telegram» в личном кабинете ученика
- [ ] Админ/куратор вводит свой `chatId` в профиле или через `/start`
- [ ] Настройки бота в разделе Настройки → Telegram
- [ ] Yandex.Metrika: базовое подключение (pageviews)
- [ ] Admin: простая страница аналитики (активные ученики, прогресс по курсам)
---
## Этап 13 — Тесты и квизы
**Цель:** можно добавить тест к уроку, ученик проходит и получает результат.
- [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt
- [ ] Admin: создание теста к уроку (добавить вопрос → варианты ответов → отметить правильный)
- [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt (схема уже есть)
- [ ] Admin: создание теста к уроку (вопрос → варианты → отметить правильный)
- [ ] Типы вопросов: одиночный выбор, множественный выбор, короткий текст
- [ ] Рендер теста в уроке для ученика
- [ ] Авто-проверка (single/multiple choice), результат сразу
@@ -87,105 +273,34 @@
---
## Этап 5 — Домашние задания и обратная связь куратора
**Цель:** ученик сдаёт ДЗ, куратор оставляет комментарий.
- [ ] Prisma: Homework, HomeworkSubmission, HomeworkFeedback
- [ ] Admin: добавить блок ДЗ к уроку (текст задания)
- [ ] Ученик: форма отправки ДЗ (текст + файлы → Object Storage)
- [ ] Ученик: ДЗ засчитывается **автоматически при отправке** (урок открывается сразу)
- [ ] Куратор: список ДЗ на проверку (все или по курсу), статус «новое / просмотрено»
- [ ] Куратор: просмотр ДЗ, оставить текстовый комментарий
- [ ] История обмена по ДЗ (ученик может ответить на комментарий куратора)
- [ ] Уведомление ученику когда куратор оставил комментарий (заглушка — реальные email на Этапе 7)
**Критерий готовности:** отправляю ДЗ — урок открывается. Куратор заходит в панель, видит ДЗ, оставляет комментарий. Ученик видит комментарий в карточке урока.
---
## Этап 6 — Обсуждения под уроками
**Цель:** ученики могут общаться под каждым уроком.
- [ ] Prisma: LessonComment (с поддержкой вложенных ответов — опционально)
- [ ] Рендер треда комментариев под уроком
- [ ] Форма отправки комментария (только для enrolled учеников)
- [ ] Модерация: куратор/админ может удалить комментарий
- [ ] Пагинация или infinite scroll для длинных тредов
**Критерий готовности:** ученик оставляет комментарий, другой ученик его видит, куратор может удалить.
---
## Этап 7 — Email-уведомления
**Цель:** все участники получают нужные письма через Resend.
- [ ] Базовый email-шаблон (HTML, фирменный стиль)
- [ ] Ученик: подтверждение регистрации (уже частично с Этапа 0, финализировать)
- [ ] Ученик: письмо когда куратор оставил комментарий к ДЗ
- [ ] Ученик: письмо когда ответили на его комментарий в уроке
- [ ] Куратор / Админ: новое ДЗ на проверку
- [ ] Куратор / Админ: новый комментарий в обсуждении
- [ ] Админ: зарегистрирован новый ученик
- [ ] Очередь отправки (edge case: Resend временно недоступен → retry)
**Критерий готовности:** отправляю ДЗ — куратор получает email. Куратор отвечает — ученик получает email.
---
## Этап 8 — Импорт уроков из Markdown
**Цель:** могу импортировать урок из .md-файла Obsidian одним действием.
- [ ] API: `POST /api/admin/lessons/import-md` — принимает .md-файл
- [ ] Парсинг frontmatter (title, order, kinescopeId и кастомные поля) → метаданные урока
- [ ] Конвертация Markdown-тела в TipTap JSON (через remark / rehype)
- [ ] UI в админке: кнопка «Импортировать из .md» на странице урока
- [ ] Обработка картинок в Markdown (локальные пути → Object Storage)
**Критерий готовности:** беру .md-файл из Obsidian с frontmatter и текстом → импортирую → урок создан с правильными метаданными и контентом.
---
## Этап 9 — Миграция с emdesell
**Цель:** все пользователи и контент перенесены в новую LMS.
- [ ] Скрипт импорта пользователей из CSV-экспорта emdesell (email, имя, курсы)
- [ ] Создание пользователей без пароля + письмо «установите пароль»
- [ ] Назначение доступов к курсам по данным из CSV
- [ ] Чек-лист ручного переноса контента (уроки, PDF, структура курсов)
- [ ] Скрипт проверки целостности: все enrolled пользователи имеют доступ к нужным курсам
- [ ] QA: проверить 10 случайных аккаунтов после импорта
**Критерий готовности:** все ученики из emdesell могут войти в новую LMS и продолжить обучение.
---
## Этап 10 — Telegram-бот и аналитика
**Цель:** получаю уведомления в Telegram, вижу базовую аналитику.
- [ ] Telegram-бот: уведомление куратору/админу о новом ДЗ
- [ ] Telegram-бот: уведомление об ошибках (500-е, failed email и т.д.)
- [ ] Yandex.Metrika: базовое подключение (pageviews)
- [ ] Admin: простая страница аналитики (активные ученики, прогресс по курсам)
---
## Этап 11 — Деплой на Hetzner
**Цель:** LMS работает на production-сервере по своему домену с SSL.
- [ ] `docker-compose.prod.yml`: app + PostgreSQL + Redis + Nginx
- [ ] Nginx: SSL через Let's Encrypt (certbot), reverse proxy на Next.js
- [ ] GitHub Actions: CI/CD pipeline (lint → build → push Docker image → deploy)
- [ ] Резервное копирование PostgreSQL (cron → Object Storage)
- [ ] Мониторинг uptime (UptimeRobot или аналог)
- [ ] `.env` на сервере через Hetzner Secrets Manager или vault-файл вне репозитория
- [ ] Smoke-тест: регистрация → урок → ДЗ → куратор → email
**Критерий MVP готов:** создаю курс из админки, добавляю уроки с Kinescope, импортирую ученика из emdesell, даю доступ — ученик регистрируется, проходит урок, сдаёт тест, отправляет ДЗ, получает автодоступ к следующему уроку, позже — комментарий куратора на email.
---
## Бэклог (после MVP)
- **Миграция email-шаблонов на React Email 6 + Resend CLI 2.0** (Resend Launch Week 6, 24.04.2026):
- React Email 6: новые шаблоны для auth и ecommerce flows (welcome, password reset, purchase confirmation, course progress) — можно взять за основу вместо своих
- Resend CLI 2.0: локальный preview и тестирование шаблонов (`resend send --local ...`), 50+ команд
- Embeddable open-source editor (в одну строку) — отложить, пока не требуется
- Сейчас Этап 7 (Email-уведомления) завершён на базовой связке, задача — рефакторинг на React Email
- **Самостоятельная регистрация + автоматический онбординг** — два сценария входа и воронка после регистрации:
**Сценарии регистрации:**
- С лендинга через покупку — пользователь оплачивает курс, аккаунт создаётся автоматически, письмо с доступом приходит сразу
- Прямой вход на платформу — пользователь приходит по реферальной ссылке, из соцсетей, от партнёров — регистрируется сам без покупки
**Автоматический онбординг после регистрации:**
- Автоназначение вводных / вотер-модулей курсов (бесплатные превью, чтобы зацепить)
- Доступ к базовой библиотеке материалов по умолчанию (статьи, шаблоны, гайды — определяется в настройках)
- Приветственная воронка: серия писем / уведомлений, которая ведёт к первой покупке
- Уведомление администратора о новой регистрации (email + Telegram)
**Что нужно проработать:**
- Публичная страница регистрации (+ CAPTCHA, опционально)
- Настройка в Этапе 9: «Регистрация открыта: да/нет» + выбор вводных курсов/модулей, которые назначаются автоматически
- Интеграция с платёжной системой: оплата на лендинге → автосоздание аккаунта → автовыдача доступа к купленному курсу
- Разграничение: что видит гость / зарегистрированный без покупки / купивший курс
- Резервное копирование PostgreSQL (cron → Object Storage)
- GitHub Actions: CI/CD pipeline (lint → build → push Docker image → deploy)
- Сертификаты по окончании курса
- Геймификация (баллы, бейджи, рейтинги)
- Промокоды и интеграция с платёжными системами
@@ -193,3 +308,35 @@
- Kinescope DRM (signed URLs) — при переходе на платный план
- Водяные знаки на PDF и картинках
- Мобильное приложение
- **Вопросы учеников** — система тикетов `/admin/questions` и `/questions` для ученика:
- Таблица в админке: №, Имя, Курс, Тема, Статус (Ожидает / Отвечено), Дата
- Статусы отсортированы: сначала «Ожидает ответа»
- Куратор/Admin может создать обращение первым (написать ученику)
- Внутри тикета: история переписки, смена статуса
- **База знаний** — FAQ, который ученик видит до отправки вопроса
- **Шаблоны ответов** — куратор выбирает готовый ответ из списка
- Email + Telegram уведомления обеим сторонам
- **Главная страница ученика** — кастомизируемый экран после входа:
- Приветственный баннер с описанием школы (редактируется в настройках)
- Список курсов ученика с прогрессом
- Блок бесплатных/открытых материалов (статьи, PDF, видео)
- Анонсы ближайших событий и новых курсов
- **Медиатека (Файлы)** — централизованное файловое хранилище `/admin/files`:
- Prisma: `MediaFolder` (id, name, courseId?, createdAt) + `MediaFile` (id, folderId?, name, url, size, mimeType, uploadedById, createdAt)
- Папки автоматически создаются по курсам + «Common» для общих файлов
- Вид: грид (карточки с иконкой типа) или список — переключатель
- Breadcrumb-навигация: Все файлы / Название папки
- Загрузка файлов (PDF, изображения, любые) → Object Storage
- Создание папки вручную
- Клик на файл → диалог: имя (редактируемое), дата загрузки, размер, автор
- Действия в диалоге: скопировать ссылку, скачать, удалить
- Вставка файлов из медиатеки в урок (вместо повторной загрузки)
- **Цифровой сад** — публичный раздел платформы для сообщества:
- Методические материалы и статьи (PKM, Obsidian, Second Brain)
- Рекомендованная литература с аннотациями
- Записи открытых встреч и вебинаров
- Календарь: предстоящие открытые уроки, запуски курсов, события
- Возможно: публичный Obsidian-like граф знаний
+267
View File
@@ -0,0 +1,267 @@
# TECHNICAL — LMS Second Brain
Живая документация проекта. Обновляется по мере разработки.
Роадмап и планирование — в `ROADMAP.md`. Здесь — факты о том, как всё устроено.
---
## Инфраструктура
| Компонент | Значение |
|---|---|
| **Сервер** | Hetzner VPS — 8 vCPU / 16 GB RAM / 320 GB NVMe |
| **IP** | 178.104.27.196 |
| **Домен LMS** | https://school.second-brain.ru |
| **Reverse proxy** | Caddy (auto HTTPS через Let's Encrypt) |
| **Порт приложения** | 3010 (внутри контейнера — 3000) |
| **БД** | PostgreSQL 16 (контейнер `lms-sb-db-1`) |
| **Object Storage** | Hetzner Object Storage, регион Nuremberg |
| **Бакет** | `second-brain-lms` (публичный, read-only) |
| **Endpoint S3** | https://nbg1.your-objectstorage.com |
| **Git-репозиторий** | https://git.second-brain.ru/admins/lms-sb |
| **Email-сервис** | Resend, домен `mailsend.second-brain.ru` (verified) |
| **From-адрес** | noreply@mailsend.second-brain.ru |
### Деплой
```bash
# На сервере: /root/digital-household/lms-sb/
git pull ...
docker compose -f docker-compose.prod.yml up -d --build
```
При старте контейнер автоматически запускает `prisma migrate deploy`, затем `node server.js`.
### .env на сервере
Файл `/root/digital-household/lms-sb/.env`:
```
DB_PASSWORD=lms_cd5041e961a3050db359aa15
BETTER_AUTH_SECRET=<secret>
RESEND_API_KEY=re_VX7TCnjs_N2geRqvveHjVfbsSyr4VbmzL
EMAIL_FROM=noreply@mailsend.second-brain.ru
S3_ENDPOINT=https://nbg1.your-objectstorage.com
S3_BUCKET=second-brain-lms
S3_ACCESS_KEY=<ключ>
S3_SECRET_KEY=<секрет>
S3_REGION=eu-central
```
---
## Стек
| Слой | Технология | Версия |
|---|---|---|
| Фреймворк | Next.js (App Router) | 16.2.2 |
| Язык | TypeScript | 5.x |
| UI | React | 19 |
| Стили | Tailwind CSS (CSS-based config) | 4.x |
| UI-компоненты | shadcn/ui (базируется на Base UI, **не Radix**) | latest |
| ORM | Prisma | 7.x |
| Auth | Better Auth | 1.6.0 |
| WYSIWYG | TipTap | 2.x |
| Drag-and-drop | @dnd-kit | latest |
| Шрифт | Fira Mono (400/500/700, Latin + Cyrillic) | Google Fonts |
| Email | Resend | latest |
| S3 | @aws-sdk/client-s3 | 3.x |
| БД | PostgreSQL | 16 |
### Важные нюансы стека
- **shadcn/ui v4** использует `@base-ui/react`, а не Radix. Нет `asChild`. Триггеры — обычные элементы.
- **Prisma 7** не генерирует `index.ts`. Импорт: `from "@/generated/prisma/client"`, не `from "@/generated/prisma"`.
- **Prisma 7** требует адаптер: `new PrismaPg({ connectionString })` — иначе `PrismaClient()` бросает ошибку.
- **Better Auth** использует `scrypt` по умолчанию. В этом проекте **переключён на bcrypt**`auth.ts` настроены `password.hash` / `password.verify`).
- **`NEXT_PUBLIC_*`** переменные запекаются при сборке. `auth-client.ts` не использует `baseURL` — клиент сам берёт `window.location.origin`.
- **Next.js 16** использует `proxy.ts` вместо `middleware.ts` (и экспортируемая функция называется `proxy`, не `middleware`).
- **Tailwind v4**: конфиг только в CSS через `@import "tailwindcss"` и `@theme`. Нет `tailwind.config.ts`.
---
## Дизайн-система
Стиль: **Second Brain Aubade** — типографский, монохромный, с газетным характером.
| Токен | Значение |
|---|---|
| Шрифт | Fira Mono (весь UI) |
| Фон страницы | `#F5F5F0` (тёплый off-white) |
| Текст основной | `#323232` (тёмный уголь) |
| Текст вторичный | `#666666` |
| Поверхность / surface | `#E8E8E0` |
| Акцент / highlight | `#E8F0D8` (зелёный) |
| Divider / border | `#AAAAAA` |
| Hover | `#D8D8D0` |
| Фон сайдбара (тёмный) | `#2A2A28` |
| Активный пункт сайдбара | `#E8F0D8` (зелёный) |
**Aubade-эффект** — фирменный стиль карточек и кнопок:
- Border: `2px solid #AAAAAA`
- Box-shadow: `4px 4px 0 0 #AAAAAA` (смещение без размытия)
- Hover: `transform: translate(-2px, -2px)` + shadow `6px 6px`
- Active (кнопка): `transform: translate(2px, 2px)` + shadow убирается
CSS-классы: `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aubade`
---
## Требования к медиафайлам
### Обложка курса (`Course.coverImage`)
| Параметр | Требование |
|---|---|
| **Соотношение сторон** | **16 : 9** (горизонтальный прямоугольник) |
| **Рекомендуемое разрешение** | 1280 × 720 px (HD) или 1920 × 1080 px (Full HD) |
| **Минимальное разрешение** | 800 × 450 px |
| **Максимальный размер файла** | 5 MB |
| **Форматы** | JPG, PNG, WebP |
| **Цветовое пространство** | sRGB |
| **Где хранится** | Hetzner Object Storage, бакет `second-brain-lms`, путь `uploads/<uuid>.ext` |
| **Доступ** | Публичный URL (прямая ссылка на файл) |
> Пример URL: `https://nbg1.your-objectstorage.com/second-brain-lms/uploads/abc123.jpg`
### Изображения в уроках (TipTap)
| Параметр | Требование |
|---|---|
| **Соотношение сторон** | Любое — TipTap встраивает как `<img>` с `max-width: 100%` |
| **Рекомендуемая ширина** | 1200 px (контент-зона урока) |
| **Максимальный размер файла** | 10 MB |
| **Форматы** | JPG, PNG, GIF, WebP |
| **Где хранится** | Hetzner Object Storage, путь `uploads/<uuid>.ext` |
### PDF и файлы к уроку (Этап 2+)
| Параметр | Требование |
|---|---|
| **Форматы** | PDF, ZIP, DOCX, XLSX, PPTX |
| **Максимальный размер** | 100 MB |
| **Где хранится** | Hetzner Object Storage, путь `lessons/<lessonId>/files/<uuid>.ext` |
### Аватары пользователей (если добавим)
| Параметр | Требование |
|---|---|
| **Соотношение сторон** | 1 : 1 (квадрат) |
| **Рекомендуемый размер** | 256 × 256 px |
| **Максимальный размер файла** | 2 MB |
| **Форматы** | JPG, PNG, WebP |
---
## Роли и доступ
| Роль | Маршруты | Описание |
|---|---|---|
| `admin` | `/admin/*`, `/curator/*`, `/dashboard` | Полный доступ |
| `curator` | `/curator/*`, `/dashboard` | Проверка ДЗ, комментарии |
| `student` | `/dashboard`, `/courses/*` | Просмотр курсов, прогресс |
Защита маршрутов — в `src/proxy.ts` + проверка сессии в каждом layout/page.
---
## API-маршруты
| Метод | Путь | Описание | Кто может |
|---|---|---|---|
| `POST` | `/api/auth/[...all]` | Better Auth handler | Все |
| `POST` | `/api/admin/upload` | Загрузка файла в S3, возвращает `{ url, key }` | admin |
---
## Структура БД (ключевые таблицы)
```
User — id, email, name, role, emailVerified
Session — Better Auth sessions
Account — Better Auth credentials (bcrypt password)
Verification — Better Auth email verification tokens
Category — id, title, slug, order
Course — id, slug, title, description, coverImage, published, order, categoryId
Module — id, courseId, title, order
Lesson — id, moduleId, title, content (JSON), kinescopeId, published, order
LessonFile — id, lessonId, name, url, size
CourseEnrollment — userId + courseId (PK), enrolledAt, expiresAt
AccessLog — id, courseId, userId, action, method, grantedById, note, createdAt
LessonProgress — userId + lessonId (PK), completedAt
Quiz — id, lessonId, showAnswers
QuizQuestion — id, quizId, text, type (SINGLE/MULTIPLE/TEXT), order
QuizOption — id, questionId, text, isCorrect, order
QuizAttempt — id, userId, quizId, score, answers (JSON), completedAt
Homework — id, lessonId, description
HomeworkSubmission — id, homeworkId, userId, text, files (JSON), submittedAt
HomeworkFeedback — id, submissionId, curatorId, text, createdAt
LessonComment — id, lessonId, userId, text, deleted, createdAt
```
Миграции: `prisma/migrations/`**никогда не редактировать вручную**.
---
## Тестовые аккаунты (seed)
| Email | Пароль | Роль |
|---|---|---|
| admin@second-brain.ru | Password123! | admin |
| curator@second-brain.ru | Password123! | curator |
| student@second-brain.ru | Password123! | student |
---
## Что сделано (по этапам)
### Этап 0 — Каркас + Auth ✅
- Next.js 16.2.2 + TypeScript + Tailwind v4
- PostgreSQL 16 + Prisma 7 + полная LMS-схема
- Better Auth: email/password, роли, сессии
- proxy.ts: защита маршрутов
- Дашборды для 3 ролей (admin / curator / student)
- Dockerfile multi-stage + docker-compose.prod.yml
- Caddy: school.second-brain.ru → порт 3010
### Этап 1 — CRUD курсов в админке ✅
- Список курсов: `/admin/courses`
- Создание курса (диалог), редактирование, удаление
- Обложка курса: загрузка в S3, требования — см. раздел «Медиафайлы»
- Модули: drag-and-drop сортировка, CRUD
- Уроки: drag-and-drop сортировка, CRUD
- Редактор урока: TipTap (Bold, Italic, H2/H3, списки, цитата, код, ссылки, изображения)
- Загрузка изображений в урок → S3
- Поле Kinescope ID (текстовое)
- Публикация / скрытие курса и урока
- Управление доступом к курсу (выдать / отозвать)
- Страница пользователей: `/admin/users`
- Дизайн Second Brain Aubade (Fira Mono, #F5F5F0, карточки с тенью)
### Этап 1.5 — Расширенное управление доступом ✅
- Категории курсов: `/admin/categories`, CRUD, привязка к курсу
- Срок доступа: поле `expiresAt` при энролле, просроченный подсвечивается красным
- Страница ученика `/admin/users/[userId]`: мультиэнролл (несколько курсов + срок)
- История доступа: таблица `AccessLog`, отображается на странице курса и ученика
- Hetzner Object Storage подключён: бакет `second-brain-lms`, Nuremberg
### Этап 3 — Прогресс, ДЗ, Email ✅
- Прогресс студента: кнопка «Отметить как пройденный», галочки и прогресс-бар в сайдбаре, прогресс на дашборде
- Домашние задания: редактор ДЗ в уроке (admin), сдача текстом + файлами (student), проверка и фидбек (curator/admin)
- Куратор-панель: `/curator/dashboard` со статистикой, `/curator/homework` список, страница проверки
- Админ имеет доступ к куратор-маршрутам через пункт «ДЗ на проверку» в сайдбаре
- Email уведомления через Resend: доступ к курсу, новая работа, фидбек получен, приветствие
---
## Известные ограничения / технический долг
- `requireEmailVerification: true` в Better Auth — seed-пользователи вставлены напрямую через SQL с `emailVerified = true`
- Загрузка файлов через `/api/admin/upload` — нет ограничения по размеру на уровне Next.js (только S3). При необходимости добавить middleware с проверкой `Content-Length`
- Drag-and-drop обновляет порядок через Server Actions — при быстрых последовательных перетаскиваниях возможны race conditions (некритично для MVP)
- `expiresAt` проверяется только в UI (красная подсветка). Блокировка доступа по сроку на уровне middleware — в рамках Этапа 2
+153
View File
@@ -0,0 +1,153 @@
# Tech Debt Audit — lms-sb
Generated: 2026-05-09
---
## Executive Summary
- **1 Critical**: middleware не работает — файл называется `proxy.ts` вместо `middleware.ts`, защита маршрутов на уровне Next.js отсутствует
- **3 High**: двойная загрузка полной структуры курса на каждый урок; `getSettings()` вызывается дважды в root layout; нет ограничения размера загружаемых файлов
- **0 тестов** — ни одного test-файла во всём проекте
- **4 отладочных `console.log`** в production-коде Server Action
- Zod установлен как зависимость, но нигде не используется — Server Actions принимают `FormData` без валидации схемы
- 3 уязвимости npm **high** severity (next, fast-uri, fast-xml-builder)
- `settings-form.tsx` — 506 строк, единственный god-файл, но внутренняя структура оправданна
- Самые горячие файлы совпадают с самыми крупными: student lesson page (12 правок, 270 строк) и lesson-editor (8 правок, 408 строк) — концентрация долга
---
## Architectural Mental Model
LMS построена на Next.js 16 App Router с тремя зонами доступа: `(auth)`, `(student)`, `admin`/`curator`. Мутации идут через Server Actions, данные читаются в RSC. Better Auth отвечает за сессии и роли. Prisma 7 с PostgreSQL через собственный PrismaPg адаптер (обходит ограничения Turbopack).
Главная аномалия: middleware объявлен в `src/proxy.ts` с функцией `proxy()`, а не в `src/middleware.ts` с функцией `middleware()`. Next.js его не подхватывает. Защита работает только за счёт явных проверок сессии в каждой странице и action — что само по себе достаточно, но заявленный в CLAUDE.md "Auth middleware (защита маршрутов)" фактически не существует.
Второй структурный факт: при открытии страницы урока студентом происходит двойная загрузка полной структуры курса — один раз в `layout.tsx` (для sidebar), второй в `page.tsx` (для prev/next навигации). Это N+1 на уровне layout/page.
---
## Findings
| ID | Category | File:Line | Severity | Effort | Description | Recommendation |
|----|----------|-----------|----------|--------|-------------|----------------|
| F001 | Security | src/proxy.ts:1 | Critical | S | Файл `proxy.ts` не является Next.js middleware. Next.js ищет `src/middleware.ts` с экспортом `middleware`. Маршруты не защищены на уровне edge. | Переименовать файл в `src/middleware.ts`, переименовать экспорт `proxy``middleware` |
| F002 | Performance | src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx:37 | High | M | `page.tsx` загружает `lesson.module.course.modules` с вложенными уроками для prev/next nav — та же структура уже загружена в `layout.tsx:20`. Двойной DB-запрос на каждый pageview урока. | Вынести prev/next навигацию в layout или передавать через `searchParams`/context вместо повторной загрузки |
| F003 | Performance | src/app/layout.tsx:14,27 | High | S | `getSettings()` вызывается дважды в одном компоненте — в `generateMetadata()` и в `RootLayout()`. Два одинаковых DB-запроса на каждый запрос. | Объединить в один вызов или обернуть `getSettings` в `React.cache()` |
| F004 | Security | src/app/api/admin/upload/route.ts:1, src/app/api/student/homework-upload/route.ts:1, src/app/api/curator/audio-upload/route.ts:1 | High | S | Ни один upload endpoint не проверяет размер файла перед `file.arrayBuffer()`. Загрузка 1 ГБ файла ляжет в память Node.js. | Добавить проверку `file.size` до 50 МБ (или другого лимита) сразу после `form.get("file")` |
| F005 | Observability | src/lib/actions/lesson-actions.ts:24,27,42,48 | Medium | S | 4 `console.log` в production Server Action. Логируют `lessonId` и статус каждого сохранения в prod-консоль. | Убрать все 4. Оставить только `console.error` в catch-блоках. |
| F006 | Type & Contract | src/app/admin/courses/actions.ts:28-47 | Medium | M | Server Actions принимают `FormData` и читают поля через `as string` без валидации. Zod установлен, но не используется нигде в проекте. | Добавить Zod-схему на входе `createCourse` и `updateCourse`; повторить паттерн в остальных actions |
| F007 | Dependencies | package.json | Medium | S | `npm audit` показывает 3 high severity уязвимости: `next` (сам фреймворк), `fast-uri`, `fast-xml-builder`. | `npm update next` до последнего патча; проверить влияние на остальные зависимости |
| F008 | Dependencies | package.json | Low | S | `depcheck` находит 4 неиспользуемые зависимости: `@tailwindcss/typography`, `shadcn`, `tw-animate-css`, `zod`. Если Zod будет использован (F006), убрать оставшиеся три. | `npm remove @tailwindcss/typography shadcn tw-animate-css` |
| F009 | Architecture | src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx:102-106 | Low | S | Функция `formatSize` определена внутри Server Component — при каждом рендере пересоздаётся. Не ошибка, но засоряет файл. | Вынести в `src/lib/utils.ts` |
| F010 | Consistency | src/app/admin/courses/[courseId]/actions.ts:12, src/app/admin/categories/actions.ts:11, src/app/admin/settings/actions.ts:11 | Low | S | Разные строки ошибки авторизации: `"Forbidden"` (EN), `"Нет доступа"` (RU), `"Unauthorized"` (EN). Нет единого паттерна. | Выбрать один формат и применить везде; ошибки авторизации не должны уходить клиенту как читаемый текст |
| F011 | Security | src/app/layout.tsx:32,37 | Low | — | `dangerouslySetInnerHTML` с `headCode`/`bodyCode` из БД — admin может вставить произвольный JS. | Намеренная функция (code injection для аналитики). Задокументировать явно, что это admin-only привилегия. Добавить проверку роли на странице настроек — уже есть. |
| F012 | Testing | — | High | L | Ни одного теста во всём проекте. Нет `*.test.*`, `*.spec.*`, нет `__tests__/`. Горячие файлы (lesson page, lesson-editor) не прикрыты ничем. | Начать с unit-тестов `src/lib/md-to-tiptap.ts` (чистая функция, высокий риск регрессии) и `src/lib/settings.ts`. Для UI — Playwright E2E на login + lesson complete flow. |
| F013 | Consistency | src/app/(student)/courses/[slug]/layout.tsx:54, src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx:53 | Low | S | Оба файла самостоятельно проверяют `isAdmin` для conditional DB queries с одинаковой логикой. Паттерн не вынесен. | Не критично при текущем размере, но при росте числа маршрутов станет проблемой |
| F014 | Documentation | src/proxy.ts:1, CLAUDE.md | Medium | S | CLAUDE.md: `src/middleware.ts — Auth middleware (защита маршрутов)`. Файла не существует, существует `src/proxy.ts`. Документация врёт. | После исправления F001 — обновить CLAUDE.md |
---
## Top 5 "fix these first"
### 1. F001 — Переименовать proxy.ts в middleware.ts
```bash
git mv src/proxy.ts src/middleware.ts
```
В `src/middleware.ts`:
```ts
// было:
export function proxy(request: NextRequest) { ... }
// стало:
export function middleware(request: NextRequest) { ... }
```
`config` экспорт уже правильный — оставить как есть. Это однострочный фикс с нулевым риском регрессии.
---
### 2. F003 — Двойной `getSettings()` в root layout
```ts
// src/app/layout.tsx — было: два вызова
const settings = await getSettings(); // в generateMetadata
const settings = await getSettings(); // в RootLayout
// стало: обернуть в React.cache
// src/lib/settings.ts
import { cache } from "react";
export const getSettings = cache(async (): Promise<Settings> => { ... });
```
Один `cache()` снимает оба дублированных запроса в рамках одного render pass.
---
### 3. F004 — Лимит размера файлов в upload endpoints
В каждом из 5 upload routes добавить сразу после получения файла:
```ts
const MAX_BYTES = 50 * 1024 * 1024; // 50 МБ
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
}
```
---
### 4. F005 — Убрать console.log из lesson-actions.ts
```ts
// src/lib/actions/lesson-actions.ts — удалить строки 24, 27, 42, 48
console.log("[saveLesson] start", lessonId); // удалить
console.log("[saveLesson] auth ok"); // удалить
console.log("[saveLesson] db update ok"); // удалить
console.log("[saveLesson] done"); // удалить
```
---
### 5. F002 — Устранить двойную загрузку структуры курса
`layout.tsx` уже загружает все модули/уроки курса для sidebar. `page.tsx` загружает ту же структуру ещё раз для prev/next навигации. Самое чистое решение — передавать `allLessons` через `searchParams` или вычислять в layout и передавать через `slot`:
Альтернатива проще: убрать из `page.tsx` `include: { modules: { include: { lessons } } }` и принять `prevLessonId`/`nextLessonId` как query params, которые layout прописывает в ссылки sidebar-а.
---
## Quick Wins
- [ ] **F001**`git mv src/proxy.ts src/middleware.ts` + переименовать экспорт (5 минут)
- [ ] **F003**`import { cache } from "react"` в settings.ts, обернуть `getSettings` (10 минут)
- [ ] **F005** — Удалить 4 строки `console.log` в lesson-actions.ts (2 минуты)
- [ ] **F007**`npm update next` — закрыть CVE в самом фреймворке
- [ ] **F008**`npm remove @tailwindcss/typography shadcn tw-animate-css` — убрать мёртвый вес
- [ ] **F004** — Добавить `file.size` проверку в 5 upload routes (15 минут)
---
## Things that look bad but are actually fine
**`src/generated/prisma/`** — 20+ сгенерированных файлов в `src/`. Выглядит как мусор, но это намеренно: Prisma 7 с Turbopack требует TypeScript-клиента в src для корректной работы RSC. Объяснено в коммите `af8644e` и в memory-файле `project_lms_prisma_config.md`. Не трогать.
**`src/app/(student)/courses/[slug]/layout.tsx:54`** — `prisma.lessonProgress.findMany({ where: { lessonId: { in: allLessonIds } } })` загружает прогресс по всем урокам курса. Выглядит избыточно, но это единственный способ отрисовать sidebar с чекбоксами без N+1 запроса на каждый урок. Правильный паттерн.
**`src/lib/auth.ts:37`** — Хардкод `"https://school.second-brain.ru"` в `trustedOrigins`. Выглядит как нарушение правила "нет захардкоженных URL". На самом деле это security-критичный список и он должен быть явным, не конфигурируемым через переменные (иначе можно было бы переопределить в .env). Оставить.
**`catch (() => {})` в трёх местах** (email в import, S3 delete) — выглядит как проглатывание ошибок. В контексте это правильно: ошибка отправки welcome-email или удаления старого файла из S3 не должна ронять основную операцию (импорт/апгрейд файла).
**506 строк в `settings-form.tsx`** — формально god-файл, но это один большой конфиг-экран с однородной структурой (Section + Field + Toggle). Файл читается линейно, нет запутанной логики. Декомпозиция не добавит ясности.
---
## Open Questions
1. **`forgot-password` и `reset-password` маршруты** не входят в `PUBLIC_ROUTES` в `proxy.ts:4`. Это намеренно — эти страницы требуют cookie для валидации токена? Или просто забыто?
2. **`src/app/api/admin/import-md/route.ts`** — существует, но нет UI для вызова. Мёртвый endpoint или WIP?
3. **QuizOption** — схема Prisma содержит `QuizOption` с `isCorrect`, но в `page.tsx` урока quiz загружается без `options` (`include: { questions: { orderBy: { order: "asc" } } }`). Тест работает только с open-ended вопросами или options загружаются где-то ещё?
4. **`load-test.js`** в корне репо — `k6` отмечен как missing dependency в depcheck. Это намеренно отдельный инструмент или планируется CI-интеграция?
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
@@ -0,0 +1,159 @@
# Student Questions — Design Spec
_Created: 20260519_
## Overview
A support-chat feature inside the LMS. Students ask questions, school staff (admin/curator) answers. Each question is a threaded conversation with open/closed status. Includes file attachments and email notifications for all parties.
Also in scope: email notifications for homework submissions (new + student updates).
---
## Data Model
### StudentQuestion
```
id String @id @default(cuid())
userId String -- student who created it
courseId String? -- optional course context
title String
status QuestionStatus @default(OPEN) -- OPEN | CLOSED
closedAt DateTime?
closedById String? -- admin/curator who closed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User
course Course?
messages StudentQuestionMessage[]
```
### StudentQuestionMessage
```
id String @id @default(cuid())
questionId String
authorId String
text String
files String[] -- S3 paths: questions/{questionId}/{messageId}/{filename}
isRead Boolean @default(false)
createdAt DateTime @default(now())
question StudentQuestion
author User
```
### QuestionStatus (enum)
```
OPEN
CLOSED
```
---
## Routes
### Student
| Route | Description |
|---|---|
| `(student)/questions` | List of own questions with unread indicators |
| `(student)/questions/new` | Form to create a new question |
| `(student)/questions/[id]` | Thread view — read messages + reply |
### Admin / Curator
| Route | Description |
|---|---|
| `admin/questions` | Split-view: question list left, thread right |
| `curator/questions` | Same split-view (curators have same access as admins here) |
---
## UI Behaviour
### Student — Questions List (`/questions`)
- Header: "Мои вопросы" + "+ Задать вопрос" button (→ `/questions/new`)
- Each row: title, message count, last activity, status badge (ОТКРЫТ / ЗАКРЫТ)
- Unread indicator: black dot + "Новый ответ от школы" when school replied since last student visit
- Closed questions: dimmed (opacity 0.7), grey badge
- Active (unread) questions: bold border, bold title
### Student — Thread (`/questions/[id]`)
- Header: question title, created date, status, "← Все вопросы" link
- Message bubbles: student messages left (#E8E8E0), school messages right (#F5F5F0 + green border #E8F0D8)
- New school message: bold label "🔵 новое" in timestamp
- File attachments shown inline under message text (📎 filename · size)
- Reply form at bottom: textarea + attach button + send button
- Attachment types: jpg, png, pdf, md · max 10 MB
- Student CAN reply to closed questions (creates new message, does NOT reopen question)
### Admin/Curator — Split View (`/admin/questions`, `/curator/questions`)
- Left panel (45%): tab filter "Открытые / Закрытые", question list sorted by last activity
- Unread: red dot, green-tinted background, bold student name
- Right panel (55%): selected thread with full message history + reply form + "✓ Закрыть вопрос" button
- Only admin/curator can close a question
- Closing a question does NOT prevent further messages
---
## Notifications
### → School (admin + all curators)
| Trigger | Channel |
|---|---|
| New question created | Email + admin sidebar badge (count of unread questions) |
| Student adds message to existing question | Email |
### → Student
| Trigger | Channel |
|---|---|
| Admin/curator replies to question | Email |
### Admin Badge
- Sidebar badge shows count of questions with unread messages (school hasn't seen yet)
- Separate from homework badge
### Email for Homework (added to scope)
| Trigger | Recipient |
|---|---|
| New HomeworkSubmission created | Admin + all curators |
| Student updates existing submission (adds text/file) | Admin + all curators |
---
## File Storage
- Path pattern: `questions/{questionId}/{messageId}/{filename}`
- Reuse existing S3 upload infrastructure (`src/lib/s3.ts`)
- Allowed: jpg, png, pdf, md
- Max size: 10 MB per file
- No limit on number of files per message (reasonable: 5)
---
## Read Tracking
- `isRead` flag per message, set to `true` when the OTHER party opens the thread
- Student opens `/questions/[id]` → all school messages in that thread marked `isRead = true`
- Admin/curator opens a question in split-view → all student messages marked `isRead = true`
- Admin badge recalculates on each page load (count questions where latest student message is unread)
---
## API Routes
```
POST /api/questions -- create question
GET /api/questions -- list own questions (student) or all (admin/curator)
GET /api/questions/[id] -- get question + messages
POST /api/questions/[id]/messages -- add message + files
PATCH /api/questions/[id]/close -- close question (admin/curator only)
POST /api/upload/question-file -- upload attachment, returns S3 path
```
---
## Out of Scope (this iteration)
- Question categories / tags
- Assigning question to a specific curator
- Email threading (reply-to email to answer)
- Push/browser notifications
- Question templates
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
import http from "k6/http";
import { check, sleep } from "k6";
const BASE = "https://school.second-brain.ru";
// Реальные lesson IDs курса obsidian (опубликованные уроки)
const LESSONS = [
"c729fjgtrl0tfowh49jh55uak",
"ctxca16mjamn5bh2exa3dxltg",
"c1f130hwjgks3zm4ohrcneueh",
"cn3bahic20cdxj9ih4cxr8tjl",
"c2usfe6rwoqcombd9veaalvgj",
"clil8czg79uqmqtexw8e5cede",
"c0ej1a3wrueg60d1oew2j8ky6",
"cypv15bq07deuyi2tjb556n52",
"c7v4qdnowy7i6y7pp361dwne3",
"c3l9ox9xvd5qyv5mt2pd7if2x",
];
const TEST_USER = {
email: "loadtest@second-brain.ru",
password: "LoadTest2025!",
};
const BROWSER_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9",
};
export const options = {
stages: [
{ duration: "30s", target: 10 }, // разгон до 10 пользователей
{ duration: "1m", target: 50 }, // разгон до 50
{ duration: "1m", target: 100 }, // разгон до 100
{ duration: "3m", target: 100 }, // держим 100 три минуты
{ duration: "30s", target: 0 }, // спад
],
thresholds: {
http_req_duration: ["p(95)<3000"], // 95% запросов быстрее 3 секунд
http_req_failed: ["rate<0.05"], // ошибок меньше 5%
},
};
// Переменная уровня VU — логин один раз на весь жизненный цикл VU.
let isLoggedIn = false;
export default function () {
// http.cookieJar() без аргументов — jar уровня VU, сохраняется между итерациями.
const jar = http.cookieJar();
// 1. Логин — только при первой итерации VU
if (!isLoggedIn) {
// Случайная задержка 0-10s: распределяем 100 логинов во времени,
// иначе все VU стартуют одновременно и бьют rate-limit Better Auth.
sleep(Math.random() * 10);
const loginRes = http.post(
`${BASE}/api/auth/sign-in/email`,
JSON.stringify({ email: TEST_USER.email, password: TEST_USER.password }),
{ headers: { "Content-Type": "application/json" }, jar }
);
check(loginRes, { "login 200": (r) => r.status === 200 });
if (loginRes.status !== 200) {
sleep(5); // пауза при неудаче, не штурмуем auth endpoint
return;
}
isLoggedIn = true;
sleep(1);
}
// 2. Дашборд студента
const dashRes = http.get(`${BASE}/dashboard`, { jar, headers: BROWSER_HEADERS });
check(dashRes, { "dashboard 200": (r) => r.status === 200 });
sleep(1);
// 3. Страница курса
const courseRes = http.get(`${BASE}/courses/obsidian`, { jar, headers: BROWSER_HEADERS });
check(courseRes, { "course page 200": (r) => r.status === 200 });
sleep(2);
// 4. Открыть 3 случайных урока (имитация чтения)
for (let i = 0; i < 3; i++) {
const lessonId = LESSONS[Math.floor(Math.random() * LESSONS.length)];
const lessonRes = http.get(
`${BASE}/courses/obsidian/lessons/${lessonId}`,
{ jar, headers: BROWSER_HEADERS }
);
check(lessonRes, { "lesson page 200": (r) => r.status === 200 });
sleep(Math.random() * 3 + 2); // студент "читает" 2-5 секунд
}
}
+2
View File
@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
transpilePackages: ["unified", "remark-parse"],
serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg"],
};
export default nextConfig;
+3482 -9
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -13,17 +13,39 @@
"seed": "ts-node --project tsconfig.json prisma/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1025.0",
"@aws-sdk/s3-request-presigner": "^3.1025.0",
"@base-ui/react": "^1.3.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@kinescope/react-kinescope-player": "^0.5.4",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"@tiptap/extension-image": "^3.22.2",
"@tiptap/extension-link": "^3.22.2",
"@tiptap/extension-placeholder": "^3.22.2",
"@tiptap/extension-underline": "^3.22.2",
"@tiptap/pm": "^3.22.2",
"@tiptap/react": "^3.22.2",
"@tiptap/starter-kit": "^3.22.2",
"bcryptjs": "^3.0.3",
"better-auth": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"gray-matter": "^4.0.3",
"iconv-lite": "^0.7.2",
"lucide-react": "^1.7.0",
"next": "16.2.2",
"next-themes": "^0.4.6",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"remark-parse": "^11.0.0",
"resend": "^6.10.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"unified": "^11.0.5",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -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;
@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "Settings" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Settings_pkey" PRIMARY KEY ("key")
);
@@ -0,0 +1 @@
ALTER TABLE "Module" ADD COLUMN "description" TEXT;
@@ -0,0 +1,2 @@
ALTER TABLE "HomeworkSubmission" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'PENDING';
ALTER TABLE "HomeworkSubmission" ADD COLUMN "statusAt" TIMESTAMP(3);
@@ -0,0 +1,2 @@
ALTER TABLE "HomeworkFeedback" ADD COLUMN "files" JSONB;
ALTER TABLE "HomeworkFeedback" ADD COLUMN "audioUrl" TEXT;
@@ -0,0 +1,2 @@
ALTER TABLE "Course" ADD COLUMN "allowAudio" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "HomeworkSubmission" ADD COLUMN "audioUrl" TEXT;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "phone" TEXT;
ALTER TABLE "User" ADD COLUMN "birthday" TIMESTAMP(3);
@@ -0,0 +1,4 @@
ALTER TABLE "LessonComment" ADD COLUMN "parentId" TEXT;
ALTER TABLE "LessonComment" ADD CONSTRAINT "LessonComment_parentId_fkey"
FOREIGN KEY ("parentId") REFERENCES "LessonComment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "comment" TEXT;
@@ -0,0 +1,11 @@
CREATE TABLE "BalanceTransaction" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"description" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BalanceTransaction_pkey" PRIMARY KEY ("id"),
CONSTRAINT "BalanceTransaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "BalanceTransaction_userId_idx" ON "BalanceTransaction"("userId");
@@ -0,0 +1,45 @@
-- CreateEnum
CREATE TYPE "QuestionStatus" AS ENUM ('OPEN', 'CLOSED');
-- CreateTable
CREATE TABLE "StudentQuestion" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"courseId" TEXT,
"title" TEXT NOT NULL,
"status" "QuestionStatus" NOT NULL DEFAULT 'OPEN',
"closedAt" TIMESTAMP(3),
"closedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StudentQuestion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StudentQuestionMessage" (
"id" TEXT NOT NULL,
"questionId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"text" TEXT NOT NULL,
"files" JSONB,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StudentQuestionMessage_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "StudentQuestion" ADD CONSTRAINT "StudentQuestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StudentQuestion" ADD CONSTRAINT "StudentQuestion_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StudentQuestion" ADD CONSTRAINT "StudentQuestion_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StudentQuestionMessage" ADD CONSTRAINT "StudentQuestionMessage_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "StudentQuestion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StudentQuestionMessage" ADD CONSTRAINT "StudentQuestionMessage_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,8 @@
-- CreateIndex
CREATE INDEX "StudentQuestion_userId_idx" ON "StudentQuestion"("userId");
-- CreateIndex
CREATE INDEX "StudentQuestion_status_idx" ON "StudentQuestion"("status");
-- CreateIndex
CREATE INDEX "StudentQuestionMessage_questionId_idx" ON "StudentQuestionMessage"("questionId");
+149 -30
View File
@@ -18,30 +18,40 @@ model User {
emailVerified Boolean @default(false)
image String?
role String @default("student") // student | curator | admin
phone String?
birthday DateTime?
banned Boolean? @default(false)
banReason String?
banExpires DateTime?
comment String?
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")
balanceTransactions BalanceTransaction[]
questions StudentQuestion[]
closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
questionMessages StudentQuestionMessage[]
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
impersonatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
@@ -77,6 +87,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
@@ -84,21 +104,27 @@ model Course {
description String?
coverImage String?
published Boolean @default(false)
allowAudio 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[]
questions StudentQuestion[]
}
model Module {
id String @id @default(cuid())
courseId String
title String
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
courseId String
title String
description String?
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
lessons Lesson[]
@@ -110,6 +136,7 @@ model Lesson {
title String
content Json?
kinescopeId String?
coverImage String?
order Int @default(0)
published Boolean @default(false)
createdAt DateTime @default(now())
@@ -137,7 +164,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 +173,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
@@ -226,12 +269,15 @@ model Homework {
}
model HomeworkSubmission {
id String @id @default(cuid())
homeworkId String
userId String
text String?
files Json?
submittedAt DateTime @default(now())
id String @id @default(cuid())
homeworkId String
userId String
text String?
files Json?
status String @default("PENDING") // PENDING | REVIEWING | APPROVED | REJECTED
statusAt DateTime?
audioUrl String?
submittedAt DateTime @default(now())
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -243,6 +289,8 @@ model HomeworkFeedback {
submissionId String
curatorId String
text String
files Json? // [{name, url, size}]
audioUrl String?
createdAt DateTime @default(now())
submission HomeworkSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
@@ -260,7 +308,78 @@ model LessonComment {
text String
deleted Boolean @default(false)
createdAt DateTime @default(now())
parentId String?
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
parent LessonComment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull)
replies LessonComment[] @relation("CommentReplies")
}
// ─────────────────────────────────────────────
// Student Questions
// ─────────────────────────────────────────────
enum QuestionStatus {
OPEN
CLOSED
}
model StudentQuestion {
id String @id @default(cuid())
userId String
courseId String?
title String
status QuestionStatus @default(OPEN)
closedAt DateTime?
closedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull)
closedBy User? @relation("QuestionClosedBy", fields: [closedById], references: [id], onDelete: SetNull)
messages StudentQuestionMessage[]
@@index([userId])
@@index([status])
}
model StudentQuestionMessage {
id String @id @default(cuid())
questionId String
authorId String
text String
files Json? // [{name, url, size}]
isRead Boolean @default(false)
createdAt DateTime @default(now())
question StudentQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
@@index([questionId])
}
// ─────────────────────────────────────────────
// Balance
// ─────────────────────────────────────────────
model BalanceTransaction {
id String @id @default(cuid())
userId String
amount Decimal @db.Decimal(10, 2)
description String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// ─────────────────────────────────────────────
// Platform Settings (key-value store)
// ─────────────────────────────────────────────
model Settings {
key String @id
value String @db.Text
updatedAt DateTime @updatedAt
}
+129
View File
@@ -0,0 +1,129 @@
# Настройка бекапов на сервере
## Что бекапится
- **PostgreSQL** → дамп каждую ночь → Backblaze B2
- **S3-файлы** (Hetzner Object Storage) → синхронизация → Backblaze B2
- Хранение: последние 7 дневных дампов БД + все файлы (sync зеркало)
---
## Шаг 1 — Backblaze B2: создать bucket и ключи
1. Зарегистрироваться на https://www.backblaze.com/b2/
2. **Buckets → Create a Bucket**:
- Name: `lms-backups-second-brain`
- Files in Bucket are: `Private`
3. **App Keys → Add a New Application Key**:
- Name: `lms-server`
- Access: `Read and Write`
- Bucket: `lms-backups-second-brain`
- Сохранить `keyID` и `applicationKey` — показываются один раз
---
## Шаг 2 — Установить rclone на сервере
```bash
curl https://rclone.org/install.sh | sudo bash
```
---
## Шаг 3 — Настроить rclone: Backblaze B2
```bash
rclone config
```
Ответы:
```
n (новый remote)
name: b2lms
type: b2
account: <keyID из шага 1>
key: <applicationKey из шага 1>
<Enter для остальных — defaults>
q (quit)
```
---
## Шаг 4 — Настроить rclone: Hetzner S3
Значения берём из `.env` на сервере.
```bash
rclone config
```
Ответы:
```
n
name: hetzner
type: s3
provider: Other
env_auth: false
access_key_id: <S3_ACCESS_KEY>
secret_access_key: <S3_SECRET_KEY>
region: <пусто — Enter>
endpoint: <S3_ENDPOINT, например: fsn1.your-objectstorage.com>
<Enter для остальных>
q
```
Проверить:
```bash
rclone ls hetzner:lms-uploads
```
---
## Шаг 5 — Установить скрипт
```bash
sudo mkdir -p /opt/lms-backup
sudo cp scripts/backup.sh /opt/lms-backup/backup.sh
sudo chmod +x /opt/lms-backup/backup.sh
```
---
## Шаг 6 — Настроить cron
```bash
sudo crontab -e
```
Добавить строку (запуск каждую ночь в 3:00):
```
0 3 * * * /opt/lms-backup/backup.sh >> /var/log/lms-backup.log 2>&1
```
---
## Шаг 7 — Проверить вручную
```bash
sudo /opt/lms-backup/backup.sh
tail -50 /var/log/lms-backup.log
```
---
## Восстановление из бекапа
### База данных
```bash
# Скачать нужный дамп с B2
rclone copy b2lms:lms-backups-second-brain/db/db_20260408_0300.sql.gz /tmp/
# Восстановить в контейнер
gunzip -c /tmp/db_20260408_0300.sql.gz \
| docker exec -i lms-system-db-1 psql -U lms_user lms_db
```
### Файлы
```bash
# Синхронизировать файлы обратно на Hetzner S3
rclone sync b2lms:lms-backups-second-brain/files hetzner:lms-uploads
```
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# LMS Second Brain — Backup Script
# Backs up PostgreSQL (from Docker) + S3 files to Backblaze B2
# Place at: /opt/lms-backup/backup.sh on the server
# Cron: 0 3 * * * /opt/lms-backup/backup.sh >> /var/log/lms-backup.log 2>&1
set -euo pipefail
# ── Config ───────────────────────────────────────────────────────────────────
DB_CONTAINER="lms-system-db-1"
DB_USER="lms_user"
DB_NAME="lms_db"
BACKUP_DIR="/tmp/lms-backups"
DATE=$(date +%Y%m%d_%H%M)
DUMP_FILE="${BACKUP_DIR}/db_${DATE}.sql.gz"
# B2 rclone remote name (configured via: rclone config)
B2_REMOTE="b2lms"
B2_BUCKET="lms-backups-second-brain"
B2_DB_PATH="${B2_REMOTE}:${B2_BUCKET}/db"
B2_FILES_PATH="${B2_REMOTE}:${B2_BUCKET}/files"
# Hetzner S3 rclone remote name
S3_REMOTE="hetzner"
S3_BUCKET="lms-uploads"
# Retention: keep last N daily backups
KEEP_DAYS=7
# ── Functions ─────────────────────────────────────────────────────────────────
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
# ── Main ──────────────────────────────────────────────────────────────────────
log "=== LMS Backup started ==="
mkdir -p "$BACKUP_DIR"
# 1. PostgreSQL dump
log "Dumping PostgreSQL from container ${DB_CONTAINER}..."
docker exec "$DB_CONTAINER" \
pg_dump -U "$DB_USER" "$DB_NAME" \
| gzip > "$DUMP_FILE"
log "Dump created: ${DUMP_FILE} ($(du -sh "$DUMP_FILE" | cut -f1))"
# 2. Upload DB dump to B2
log "Uploading DB dump to Backblaze B2..."
rclone copy "$DUMP_FILE" "$B2_DB_PATH"
log "DB dump uploaded: ${B2_DB_PATH}/$(basename "$DUMP_FILE")"
# 3. Sync S3 files to B2
log "Syncing S3 files to Backblaze B2..."
rclone sync \
"${S3_REMOTE}:${S3_BUCKET}" \
"$B2_FILES_PATH" \
--progress \
--transfers=8
log "S3 files synced to ${B2_FILES_PATH}"
# 4. Cleanup local temp files
rm -f "$DUMP_FILE"
log "Local temp files cleaned"
# 5. Prune old DB backups on B2 (keep last KEEP_DAYS)
log "Pruning DB backups older than ${KEEP_DAYS} days..."
rclone delete "$B2_DB_PATH" \
--min-age "${KEEP_DAYS}d" \
--include "db_*.sql.gz"
log "Pruning done"
log "=== LMS Backup finished successfully ==="
@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { authClient } from "@/lib/auth-client";
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
color: "var(--foreground)",
fontFamily: "var(--font-sans)",
outline: "none",
};
export function ForgotPasswordForm() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
await authClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
setLoading(false);
setSent(true);
}
if (sent) {
return (
<div className="space-y-4 text-center">
<p className="text-sm" style={{ color: "var(--foreground)" }}>
Письмо со ссылкой для сброса пароля отправлено на{" "}
<strong>{email}</strong>.
</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Проверьте папку «Спам», если письмо не пришло в течение пары минут.
</p>
<Link href="/login" className="block text-xs underline mt-4" style={{ color: "var(--muted-foreground)" }}>
Вернуться к входу
</Link>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5">
<p className="text-sm mb-4" style={{ color: "var(--muted-foreground)" }}>
Введите email мы пришлём ссылку для задания нового пароля.
</p>
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="you@example.com"
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>
)}
<button
type="submit"
disabled={loading}
className="btn-aubade w-full justify-center"
style={loading ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
>
{loading ? "Отправка..." : "Сбросить пароль"}
</button>
<p className="text-center text-xs" style={{ color: "var(--muted-foreground)" }}>
<Link href="/login" className="underline" style={{ color: "var(--foreground)" }}>
Вернуться к входу
</Link>
</p>
</form>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { getSetting } from "@/lib/settings";
import { ForgotPasswordForm } from "./forgot-password-form";
export default async function ForgotPasswordPage() {
const schoolName = await getSetting("schoolName");
return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
{schoolName}
</h1>
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "13px" }}>
Образовательная платформа
</p>
</div>
<div className="card-aubade p-8">
<ForgotPasswordForm />
</div>
</div>
</div>
);
}
+36 -13
View File
@@ -39,9 +39,9 @@ export function LoginForm() {
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Email
</label>
<input
@@ -49,12 +49,20 @@ export function LoginForm() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
className="w-full px-3 py-2 text-sm bg-transparent"
style={{
border: "2px solid var(--border)",
color: "var(--foreground)",
fontFamily: "var(--font-sans)",
outline: "none",
}}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Пароль
</label>
<input
@@ -62,24 +70,39 @@ export function LoginForm() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
className="w-full px-3 py-2 text-sm bg-transparent"
style={{
border: "2px solid var(--border)",
color: "var(--foreground)",
fontFamily: "var(--font-sans)",
outline: "none",
}}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="••••••••"
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
{error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50"
className="btn-aubade w-full justify-center"
style={loading ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
>
{loading ? "Вход..." : "Войти"}
</button>
<p className="text-center text-sm text-gray-500">
Нет аккаунта?{" "}
<Link href="/register" className="text-amber-600 hover:underline">
<div className="flex items-center justify-between text-xs" style={{ color: "var(--muted-foreground)" }}>
<Link href="/forgot-password" className="underline" style={{ color: "var(--muted-foreground)" }}>
Забыли пароль?
</Link>
<Link href="/register" className="underline" style={{ color: "var(--foreground)" }}>
Зарегистрироваться
</Link>
</p>
</div>
</form>
);
}
+36 -6
View File
@@ -1,14 +1,44 @@
import { getSetting } from "@/lib/settings";
import { LoginForm } from "./login-form";
export default function LoginPage() {
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ notice?: string }>;
}) {
const [schoolName, { notice }] = await Promise.all([
getSetting("schoolName"),
searchParams,
]);
return (
<div className="min-h-screen flex items-center justify-center bg-amber-50">
<div className="w-full max-w-md">
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-amber-900">Second Brain</h1>
<p className="text-amber-700 mt-2">Войдите в свой аккаунт</p>
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
{schoolName}
</h1>
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "13px" }}>
Образовательная платформа
</p>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-amber-100 p-8">
{notice === "password_reset" && (
<div
className="mb-4 px-4 py-3 text-sm text-center"
style={{ border: "2px solid var(--border)", color: "var(--foreground)" }}
>
Пароль успешно задан. Войдите с новым паролем.
</div>
)}
{notice === "registration_closed" && (
<div
className="mb-4 px-4 py-3 text-sm text-center"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
Регистрация временно закрыта. Обратитесь к администратору.
</div>
)}
<div className="card-aubade p-8">
<LoginForm />
</div>
</div>
+24 -7
View File
@@ -1,15 +1,32 @@
import { redirect } from "next/navigation";
import { getSettings } from "@/lib/settings";
import { RegisterForm } from "./register-form";
export default function RegisterPage() {
export default async function RegisterPage() {
const settings = await getSettings();
if (settings.registrationEnabled !== "true") {
redirect("/login?notice=registration_closed");
}
return (
<div className="min-h-screen flex items-center justify-center bg-amber-50">
<div className="w-full max-w-md">
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-amber-900">Second Brain</h1>
<p className="text-amber-700 mt-2">Создайте аккаунт</p>
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
{settings.schoolName}
</h1>
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "13px" }}>
Образовательная платформа
</p>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-amber-100 p-8">
<RegisterForm />
<div className="card-aubade p-8">
<RegisterForm
showTermsCheckbox={settings.showTermsCheckbox === "true"}
privacyPolicyUrl={settings.privacyPolicyUrl}
termsUrl={settings.termsUrl}
offerUrl={settings.offerUrl}
/>
</div>
</div>
</div>
+89 -23
View File
@@ -1,21 +1,47 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { signUp } from "@/lib/auth-client";
export function RegisterForm() {
const router = useRouter();
interface Props {
showTermsCheckbox: boolean;
privacyPolicyUrl: string;
termsUrl: string;
offerUrl: string;
}
export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl }: Props) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [termsAccepted, setTermsAccepted] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "16px",
fontFamily: "inherit",
} as React.CSSProperties;
const legalLinks = [
{ url: privacyPolicyUrl, label: "Политику конфиденциальности" },
{ url: termsUrl, label: "Согласие на обработку данных" },
{ url: offerUrl, label: "Договор-оферту" },
].filter((l) => l.url);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (showTermsCheckbox && !termsAccepted) {
setError("Необходимо принять условия для продолжения");
return;
}
setError("");
setLoading(true);
@@ -35,14 +61,11 @@ export function RegisterForm() {
return (
<div className="text-center space-y-4">
<div className="text-4xl"></div>
<h2 className="text-xl font-semibold text-gray-800">
Проверьте почту
</h2>
<p className="text-gray-500">
Мы отправили письмо на <strong>{email}</strong> для подтверждения
аккаунта.
<p className="font-bold">Проверьте почту</p>
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Мы отправили письмо на <strong>{email}</strong> для подтверждения аккаунта.
</p>
<Link href="/login" className="text-amber-600 hover:underline text-sm">
<Link href="/login" className="text-sm underline" style={{ color: "var(--foreground)" }}>
Вернуться к входу
</Link>
</div>
@@ -51,8 +74,8 @@ export function RegisterForm() {
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Имя
</label>
<input
@@ -60,12 +83,14 @@ export function RegisterForm() {
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
placeholder="Иван Иванов"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Email
</label>
<input
@@ -73,12 +98,14 @@ export function RegisterForm() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Пароль
</label>
<input
@@ -87,21 +114,60 @@ export function RegisterForm() {
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
placeholder="Минимум 8 символов"
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
{showTermsCheckbox && (
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="mt-0.5 flex-shrink-0"
style={{ width: "16px", height: "16px", accentColor: "var(--foreground)" }}
/>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Я принимаю{" "}
{legalLinks.length > 0
? legalLinks.map((l, i) => (
<span key={l.url}>
<a
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="underline"
style={{ color: "var(--foreground)" }}
>
{l.label}
</a>
{i < legalLinks.length - 1 ? ", " : ""}
</span>
))
: "условия использования платформы"}
</span>
</label>
)}
{error && (
<p className="text-xs py-2 px-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50"
className="btn-aubade btn-aubade-accent w-full py-2 text-sm"
style={{ opacity: loading ? 0.6 : 1 }}
>
{loading ? "Регистрация..." : "Зарегистрироваться"}
</button>
<p className="text-center text-sm text-gray-500">
<p className="text-center text-xs" style={{ color: "var(--muted-foreground)" }}>
Уже есть аккаунт?{" "}
<Link href="/login" className="text-amber-600 hover:underline">
<Link href="/login" className="underline" style={{ color: "var(--foreground)" }}>
Войти
</Link>
</p>
+26
View File
@@ -0,0 +1,26 @@
import { Suspense } from "react";
import { getSetting } from "@/lib/settings";
import { ResetPasswordForm } from "./reset-password-form";
export default async function ResetPasswordPage() {
const schoolName = await getSetting("schoolName");
return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
{schoolName}
</h1>
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "13px" }}>
Образовательная платформа
</p>
</div>
<div className="card-aubade p-8">
<Suspense fallback={null}>
<ResetPasswordForm />
</Suspense>
</div>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { authClient } from "@/lib/auth-client";
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
color: "var(--foreground)",
fontFamily: "var(--font-sans)",
outline: "none",
};
export function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
if (!token) {
return (
<div className="text-center space-y-4">
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Ссылка недействительна или устарела.
</p>
<Link href="/forgot-password" className="text-xs underline" style={{ color: "var(--foreground)" }}>
Запросить новую ссылку
</Link>
</div>
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (password !== confirm) {
setError("Пароли не совпадают");
return;
}
if (password.length < 8) {
setError("Пароль должен быть не короче 8 символов");
return;
}
setLoading(true);
const result = await authClient.resetPassword({ newPassword: password, token });
setLoading(false);
if (result.error) {
setError("Ссылка устарела или уже использована. Запросите новую.");
return;
}
router.push("/login?notice=password_reset");
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Задайте новый пароль для вашего аккаунта.
</p>
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Новый пароль
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="Минимум 8 символов"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Повторите пароль
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="••••••••"
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>
)}
<button
type="submit"
disabled={loading}
className="btn-aubade w-full justify-center"
style={loading ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
>
{loading ? "Сохранение..." : "Сохранить пароль"}
</button>
</form>
);
}
@@ -0,0 +1,70 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { CourseSidebar } from "@/components/student/course-sidebar";
interface Props {
children: React.ReactNode;
params: Promise<{ slug: string }>;
}
export default async function CourseLayout({ children, params }: Props) {
const { slug } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const isAdmin = session.user.role === "admin";
const course = await prisma.course.findUnique({
where: { slug, ...(isAdmin ? {} : { published: true }) },
include: {
modules: {
orderBy: { order: "asc" },
include: {
lessons: {
where: { published: true },
orderBy: { order: "asc" },
select: { id: true, title: true },
},
},
},
},
});
if (!course) notFound();
if (!isAdmin) {
const enrollment = await prisma.courseEnrollment.findUnique({
where: { userId_courseId: { userId: session.user.id, courseId: course.id } },
});
if (!enrollment) redirect("/dashboard");
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) {
redirect("/dashboard?expired=1");
}
}
// Fetch completed lesson IDs for this user
const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id));
const progressRecords = isAdmin
? []
: await prisma.lessonProgress.findMany({
where: { userId: session.user.id, lessonId: { in: allLessonIds } },
select: { lessonId: true },
});
const completedLessonIds = new Set(progressRecords.map((p) => p.lessonId));
return (
<div className="flex flex-1">
<CourseSidebar course={course} completedLessonIds={completedLessonIds} />
<main className="flex-1 min-w-0 overflow-y-auto">
<div className="h-12 lg:hidden" />
{children}
</main>
</div>
);
}
@@ -0,0 +1,29 @@
"use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
export async function toggleLessonProgress(lessonId: string, slug: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const existing = await prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
});
if (existing) {
await prisma.lessonProgress.delete({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
});
} else {
await prisma.lessonProgress.create({
data: { userId: session.user.id, lessonId },
});
}
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
revalidatePath(`/courses/${slug}`);
revalidatePath("/dashboard");
}
@@ -0,0 +1,69 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function addComment(lessonId: string, slug: string, text: string, parentId?: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const trimmed = text.trim();
if (!trimmed || trimmed.length > 2000) throw new Error("Invalid text");
// Verify user has access to this lesson's course
const lesson = await prisma.lesson.findUnique({
where: { id: lessonId },
select: { module: { select: { course: { select: { id: true } } } } },
});
if (!lesson) throw new Error("Lesson not found");
const isAdmin = session.user.role === "admin";
const isCurator = session.user.role === "curator";
if (!isAdmin && !isCurator) {
const enrollment = await prisma.courseEnrollment.findUnique({
where: {
userId_courseId: {
userId: session.user.id,
courseId: lesson.module.course.id,
},
},
});
if (!enrollment) throw new Error("Not enrolled");
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired");
}
if (parentId) {
if (!isAdmin && !isCurator) throw new Error("Forbidden");
const parent = await prisma.lessonComment.findUnique({ where: { id: parentId } });
if (!parent || parent.lessonId !== lessonId) throw new Error("Invalid parent");
}
await prisma.lessonComment.create({
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
});
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
}
export async function deleteComment(commentId: string, lessonId: string, slug: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const comment = await prisma.lessonComment.findUnique({ where: { id: commentId } });
if (!comment) throw new Error("Not found");
const canDelete =
comment.userId === session.user.id ||
session.user.role === "curator" ||
session.user.role === "admin";
if (!canDelete) throw new Error("Forbidden");
await prisma.lessonComment.update({
where: { id: commentId },
data: { deleted: true },
});
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
}
@@ -0,0 +1,91 @@
"use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { sendHomeworkSubmittedEmail, sendHomeworkUpdatedEmail } from "@/lib/email";
interface HomeworkFile {
name: string;
url: string;
size: number;
}
export async function submitHomework(
homeworkId: string,
slug: string,
lessonId: string,
text: string,
files: HomeworkFile[],
audioUrl?: string | null
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const existing = await prisma.homeworkSubmission.findFirst({
where: { homeworkId, userId: session.user.id },
include: { feedbacks: true },
});
// Don't allow resubmission if feedback already given
if (existing?.feedbacks && existing.feedbacks.length > 0) {
throw new Error("Работа уже проверена");
}
let submissionId: string;
if (existing) {
const updated = await prisma.homeworkSubmission.update({
where: { id: existing.id },
data: { text, files: files as object[], audioUrl: audioUrl ?? null, submittedAt: new Date() },
});
submissionId = updated.id;
// Notify admins/curators when student edits an existing submission
const [lessonRecord, staff] = await Promise.all([
prisma.homework.findUnique({
where: { id: homeworkId },
include: { lesson: { select: { title: true } } },
}),
prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
}),
]);
if (lessonRecord) {
await Promise.all(
staff.map((s) =>
sendHomeworkUpdatedEmail(s.email, s.name, session.user.name, lessonRecord.lesson.title, submissionId).catch(
(e) => console.error("[email] homework-updated:", e)
)
)
);
}
} else {
const created = await prisma.homeworkSubmission.create({
data: { homeworkId, userId: session.user.id, text, files: files as object[], audioUrl: audioUrl ?? null },
});
submissionId = created.id;
// Notify admins/curators on first submission only
const [lesson, admins] = await Promise.all([
prisma.homework.findUnique({
where: { id: homeworkId },
include: { lesson: { select: { title: true } } },
}),
prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
}),
]);
if (lesson) {
await Promise.all(
admins.map((a) =>
sendHomeworkSubmittedEmail(a.email, a.name, session.user.name, lesson.lesson.title, submissionId)
)
);
}
}
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
}
@@ -0,0 +1,270 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { KinescopePlayer } from "@/components/player/kinescope-player";
import { LessonContent } from "@/components/student/lesson-content";
import { LessonCompleteButton } from "@/components/student/lesson-complete-button";
import { HomeworkSection } from "@/components/student/homework-section";
import { QuizSection } from "@/components/student/quiz-section";
import { LessonComments } from "@/components/student/lesson-comments";
import { FileFormatBadge } from "@/components/shared/file-format-badge";
interface Props {
params: Promise<{ slug: string; lessonId: string }>;
}
export default async function LessonPage({ params }: Props) {
const { slug, lessonId } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const isAdmin = session?.user.role === "admin";
const [lesson, progress, comments] = await Promise.all([
prisma.lesson.findUnique({
where: { id: lessonId, ...(isAdmin ? {} : { published: true }) },
include: {
files: { orderBy: { createdAt: "asc" } },
homework: true,
quiz: {
include: { questions: { orderBy: { order: "asc" } } },
},
module: {
include: {
course: {
include: {
modules: {
orderBy: { order: "asc" },
include: {
lessons: {
where: { published: true },
orderBy: { order: "asc" },
select: { id: true, title: true },
},
},
},
},
},
},
},
},
}),
session && !isAdmin
? prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
})
: null,
prisma.lessonComment.findMany({
where: { lessonId, parentId: null },
orderBy: { createdAt: "asc" },
include: {
user: { select: { id: true, name: true } },
replies: {
orderBy: { createdAt: "asc" },
include: { user: { select: { id: true, name: true } } },
},
},
}),
]);
// Fetch homework submission for this student
const homeworkSubmission = lesson?.homework && session && !isAdmin
? await prisma.homeworkSubmission.findFirst({
where: { homeworkId: lesson.homework.id, userId: session.user.id },
include: {
feedbacks: {
include: { curator: { select: { name: true } } },
orderBy: { createdAt: "asc" },
},
},
})
: null;
const quizAttempt = lesson?.quiz && session && !isAdmin
? await prisma.quizAttempt.findFirst({
where: { quizId: lesson.quiz.id, userId: session.user.id },
})
: null;
if (!lesson || lesson.module.course.slug !== slug) notFound();
const isCompleted = !!progress;
// Build ordered flat list of all lessons for prev/next
const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons);
const idx = allLessons.findIndex((l) => l.id === lessonId);
const prevLesson = idx > 0 ? allLessons[idx - 1] : null;
const nextLesson = idx < allLessons.length - 1 ? allLessons[idx + 1] : null;
const hasContent = lesson.content && Object.keys(lesson.content as object).length > 0;
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
return (
<article className="max-w-3xl mx-auto px-6 py-8">
{/* Title */}
<h1 className="text-2xl font-bold mb-6 leading-snug">{lesson.title}</h1>
{/* Video */}
{lesson.kinescopeId && (
<div className="mb-8">
<KinescopePlayer videoId={lesson.kinescopeId} poster={lesson.coverImage ?? undefined} />
</div>
)}
{/* Text content */}
{hasContent && (
<div className="mb-8">
<LessonContent content={lesson.content as object} />
</div>
)}
{/* Files */}
{lesson.files.length > 0 && (
<div className="mb-8">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Материалы урока
</p>
<div className="space-y-2">
{lesson.files.map((file) => (
<a
key={file.id}
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]"
style={{ border: "2px solid var(--border)" }}
>
<FileFormatBadge url={file.url} />
<span className="flex-1 font-medium">{file.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{formatSize(file.size)}
</span>
<span className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
Скачать
</span>
</a>
))}
</div>
</div>
)}
{/* Homework */}
{lesson.homework && !isAdmin && (
<div className="mb-8">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Домашнее задание
</p>
<HomeworkSection
homework={lesson.homework}
submission={homeworkSubmission ? {
...homeworkSubmission,
files: (homeworkSubmission.files as { name: string; url: string; size: number }[]) ?? [],
audioUrl: homeworkSubmission.audioUrl ?? null,
feedbacks: homeworkSubmission.feedbacks.map((fb) => ({
...fb,
files: (fb.files as { name: string; url: string; size: number }[]) ?? [],
audioUrl: fb.audioUrl ?? null,
})),
} : null}
slug={slug}
lessonId={lessonId}
allowAudio={lesson.module.course.allowAudio}
/>
</div>
)}
{/* Quiz */}
{lesson.quiz && (
<div className="mb-8">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Тест{isAdmin && <span className="ml-2 opacity-50">(предпросмотр)</span>}
</p>
{isAdmin ? (
<div className="space-y-4 opacity-70">
{lesson.quiz.questions.map((q, idx) => (
<div key={q.id} className="space-y-1">
<p className="text-sm font-medium">{idx + 1}. {q.text}</p>
<div className="px-4 py-3 text-sm" style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}>
Поле для ответа студента
</div>
</div>
))}
</div>
) : (
<QuizSection
quiz={lesson.quiz}
attempt={quizAttempt ? { answers: quizAttempt.answers as Record<string, string> } : null}
slug={slug}
lessonId={lessonId}
/>
)}
</div>
)}
{/* Complete button + Prev/Next navigation */}
<div
className="flex items-center justify-between pt-6 mt-6"
style={{ borderTop: "2px solid var(--border)" }}
>
{prevLesson ? (
<Link
href={`/courses/${slug}/lessons/${prevLesson.id}`}
className="btn-aubade text-sm max-w-[40%]"
>
{prevLesson.title}
</Link>
) : (
<div />
)}
{!isAdmin && !lesson.homework && !lesson.quiz && (
<LessonCompleteButton lessonId={lessonId} slug={slug} isCompleted={isCompleted} />
)}
{!isAdmin && (lesson.homework || lesson.quiz) && isCompleted && (
<LessonCompleteButton lessonId={lessonId} slug={slug} isCompleted={true} readOnly={true} />
)}
{nextLesson ? (
<Link
href={`/courses/${slug}/lessons/${nextLesson.id}`}
className="btn-aubade btn-aubade-accent text-sm max-w-[40%] text-right"
>
{nextLesson.title}
</Link>
) : (
<div className="text-sm" style={{ color: "var(--muted-foreground)" }}>
{isAdmin ? "Последний урок курса" : ""}
</div>
)}
</div>
{/* Comments */}
{session && (
<div
className="mt-10 pt-8"
style={{ borderTop: "2px solid var(--border)" }}
>
<p className="text-xs font-bold uppercase tracking-widest mb-6" style={{ color: "var(--muted-foreground)" }}>
Обсуждение ({
comments.filter(c => !c.deleted).length +
comments.flatMap(c => c.replies).filter(r => !r.deleted).length
})
</p>
<LessonComments
lessonId={lessonId}
slug={slug}
comments={comments}
currentUserId={session.user.id}
currentUserRole={session.user.role ?? "student"}
/>
</div>
)}
</article>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{ slug: string }>;
}
export default async function CoursePage({ params }: Props) {
const { slug } = await params;
const course = await prisma.course.findUnique({
where: { slug, published: true },
include: {
modules: {
orderBy: { order: "asc" },
include: {
lessons: {
where: { published: true },
orderBy: { order: "asc" },
select: { id: true },
take: 1,
},
},
},
},
});
if (!course) notFound();
// Redirect to the first published lesson
for (const mod of course.modules) {
if (mod.lessons.length > 0) {
redirect(`/courses/${slug}/lessons/${mod.lessons[0].id}`);
}
}
// No lessons yet — show placeholder
return (
<div className="p-10 text-center">
<p className="text-4xl mb-4">📭</p>
<p className="font-bold text-lg">Уроков пока нет</p>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
Курс в разработке. Загляните позже.
</p>
</div>
);
}
+140 -20
View File
@@ -1,33 +1,153 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/layout/logout-button";
import { prisma } from "@/lib/prisma";
import Link from "next/link";
export default async function StudentDashboard() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const enrollments = await prisma.courseEnrollment.findMany({
where: { userId: session.user.id },
include: {
course: {
include: {
modules: {
include: {
lessons: {
where: { published: true },
select: { id: true },
},
},
},
_count: { select: { modules: true } },
},
},
},
orderBy: { enrolledAt: "desc" },
});
const now = new Date();
const active = enrollments.filter((e) => !e.expiresAt || e.expiresAt > now);
const expired = enrollments.filter((e) => e.expiresAt && e.expiresAt <= now);
// Fetch progress for all lessons in active courses
const allLessonIds = active.flatMap((e) =>
e.course.modules.flatMap((m) => m.lessons.map((l) => l.id))
);
const progressRecords = allLessonIds.length > 0
? await prisma.lessonProgress.findMany({
where: { userId: session.user.id, lessonId: { in: allLessonIds } },
select: { lessonId: true },
})
: [];
const completedSet = new Set(progressRecords.map((p) => p.lessonId));
return (
<div className="min-h-screen bg-amber-50">
<header className="bg-white border-b border-amber-100 px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-amber-900">Second Brain</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{session.user.name}</span>
<LogoutButton />
</div>
</header>
<main className="max-w-4xl mx-auto px-6 py-10">
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
Добро пожаловать, {session.user.name}!
</h2>
<p className="text-gray-500 mb-8">Ваши курсы появятся здесь.</p>
<div className="bg-white rounded-2xl border border-amber-100 p-8 text-center text-gray-400">
<main className="max-w-4xl mx-auto px-6 py-10 w-full">
<h1 className="text-2xl font-bold mb-1">Мои курсы</h1>
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
{active.length} активных курсов
</p>
{active.length === 0 ? (
<div className="card-aubade p-12 text-center">
<p className="text-4xl mb-3">📚</p>
<p>Доступных курсов пока нет.</p>
<p className="text-sm mt-1">Обратитесь к администратору за доступом.</p>
<p className="font-medium">Доступных курсов пока нет</p>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
Обратитесь к администратору за доступом
</p>
</div>
</main>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{active.map(({ course, expiresAt }) => {
const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0);
const completedLessons = course.modules
.flatMap((m) => m.lessons)
.filter((l) => completedSet.has(l.id)).length;
const progressPct = totalLessons > 0
? Math.round((completedLessons / totalLessons) * 100)
: 0;
return (
<Link
key={course.id}
href={`/courses/${course.slug}`}
className="card-aubade p-0 overflow-hidden flex flex-col"
>
{course.coverImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={course.coverImage} alt={course.title} className="w-full aspect-video object-cover" />
) : (
<div className="w-full aspect-video flex items-center justify-center text-4xl" style={{ backgroundColor: "var(--color-surface)" }}>
📚
</div>
)}
<div className="p-4 flex-1 flex flex-col gap-2">
<h2 className="font-bold text-base leading-tight">{course.title}</h2>
{course.description && (
<p className="text-xs line-clamp-2" style={{ color: "var(--muted-foreground)" }}>
{course.description}
</p>
)}
{/* Progress bar */}
{totalLessons > 0 && (
<div className="mt-auto">
<div className="flex items-center justify-between mb-1">
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{completedLessons} из {totalLessons} уроков
</span>
<span
className="text-xs font-bold"
style={{ color: progressPct === 100 ? "var(--foreground)" : "var(--muted-foreground)" }}
>
{progressPct === 100 ? "✓ Завершён" : `${progressPct}%`}
</span>
</div>
<div className="h-1.5 w-full" style={{ background: "var(--border)" }}>
<div
className="h-full transition-all"
style={{
width: `${progressPct}%`,
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
border: progressPct > 0 ? "1px solid var(--foreground)" : "none",
}}
/>
</div>
</div>
)}
{expiresAt && (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Доступ до {new Date(expiresAt).toLocaleDateString("ru-RU")}
</p>
)}
</div>
</Link>
);
})}
</div>
)}
{expired.length > 0 && (
<div className="mt-10">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Доступ истёк
</p>
<div className="space-y-2">
{expired.map(({ course, expiresAt }) => (
<div key={course.id} className="flex items-center justify-between px-4 py-3 opacity-50" style={{ border: "2px solid var(--border)" }}>
<span className="text-sm font-medium">{course.title}</span>
<span className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Истёк {new Date(expiresAt!).toLocaleDateString("ru-RU")}
</span>
</div>
))}
</div>
</div>
)}
</main>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
import { LogoutButton } from "@/components/layout/logout-button";
import { getSetting } from "@/lib/settings";
import { StopImpersonateBanner } from "@/components/admin/stop-impersonate-banner";
export default async function StudentLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
// Maintenance mode: non-admin users see the maintenance page
if (session.user.role !== "admin") {
const maintenance = await getSetting("maintenanceMode");
if (maintenance === "true") redirect("/maintenance");
}
const [schoolName, logoUrl, showLogo, socialYoutube, socialVk, socialTelegram, orgRequisites] =
await Promise.all([
getSetting("schoolName"),
getSetting("logoUrl"),
getSetting("showLogo"),
getSetting("socialYoutube"),
getSetting("socialVk"),
getSetting("socialTelegram"),
getSetting("orgRequisites"),
]);
const isImpersonating = !!(session.session as { impersonatedBy?: string }).impersonatedBy;
return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--background)" }}>
{isImpersonating && <StopImpersonateBanner userName={session.user.name} />}
<header
className="sticky top-0 z-10 flex items-center justify-between px-6 py-3"
style={{ borderBottom: "2px solid var(--border)", backgroundColor: "var(--background)" }}
>
<Link href="/dashboard" className="flex items-center gap-2 font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
{logoUrl && showLogo === "true" && (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoUrl} alt={schoolName} className="h-6 w-auto object-contain" />
)}
{schoolName}
</Link>
<div className="flex items-center gap-4">
<Link href="/questions" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
Вопросы
</Link>
<Link href="/profile" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
{session.user.name}
</Link>
<LogoutButton />
</div>
</header>
<div className="flex-1 flex flex-col">{children}</div>
{(socialYoutube || socialVk || socialTelegram || orgRequisites) && (
<footer
className="px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs"
style={{ borderTop: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
{orgRequisites && (
<p className="whitespace-pre-line text-center sm:text-left">{orgRequisites}</p>
)}
{(socialYoutube || socialVk || socialTelegram) && (
<div className="flex items-center gap-4 flex-shrink-0">
{socialYoutube && (
<a href={socialYoutube} target="_blank" rel="noopener noreferrer" className="hover:underline">
YouTube
</a>
)}
{socialVk && (
<a href={socialVk} target="_blank" rel="noopener noreferrer" className="hover:underline">
VK
</a>
)}
{socialTelegram && (
<a href={socialTelegram} target="_blank" rel="noopener noreferrer" className="hover:underline">
Telegram
</a>
)}
</div>
)}
</footer>
)}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function changePasswordAction(_prevState: unknown, formData: FormData) {
const current = (formData.get("currentPassword") as string) ?? "";
const next = (formData.get("newPassword") as string) ?? "";
const confirm = (formData.get("confirmPassword") as string) ?? "";
if (!current || !next || !confirm) return { error: "Заполните все поля" };
if (next !== confirm) return { error: "Пароли не совпадают" };
if (next.length < 8) return { error: "Новый пароль должен быть не короче 8 символов" };
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return { error: "Сессия истекла, войдите заново" };
const account = await prisma.account.findFirst({
where: { userId: session.user.id, providerId: "credential" },
});
if (!account?.password) return { error: "Аккаунт не найден" };
const valid = await bcrypt.compare(current, account.password);
if (!valid) return { error: "Неверный текущий пароль" };
const hash = await bcrypt.hash(next, 10);
await prisma.account.update({
where: { id: account.id },
data: { password: hash },
});
return { success: true };
}
@@ -0,0 +1,80 @@
"use client";
import { useActionState } from "react";
import { changePasswordAction } from "./actions";
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
color: "var(--foreground)",
fontFamily: "var(--font-sans)",
outline: "none",
};
export function ChangePasswordForm() {
const [state, formAction, isPending] = useActionState(changePasswordAction, null);
return (
<form action={formAction} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Текущий пароль
</label>
<input
type="password"
name="currentPassword"
required
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="••••••••"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Новый пароль
</label>
<input
type="password"
name="newPassword"
required
minLength={8}
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="Минимум 8 символов"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Повторите новый пароль
</label>
<input
type="password"
name="confirmPassword"
required
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="••••••••"
/>
</div>
{state?.error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>{state.error}</p>
)}
{state?.success && (
<p className="text-sm" style={{ color: "var(--foreground)" }}>Пароль успешно изменён.</p>
)}
<button
type="submit"
disabled={isPending}
className="btn-aubade justify-center"
style={isPending ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
>
{isPending ? "Сохранение..." : "Сменить пароль"}
</button>
</form>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { ChangePasswordForm } from "./change-password-form";
export const metadata = { title: "Профиль" };
export default async function ProfilePage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
return (
<main className="max-w-lg mx-auto px-4 py-10 w-full">
<h1 className="text-xl font-bold tracking-wide mb-8" style={{ color: "var(--foreground)" }}>
Профиль
</h1>
<div className="card-aubade p-6 mb-6">
<h2 className="text-xs uppercase tracking-widest font-bold mb-4" style={{ color: "var(--muted-foreground)" }}>
Аккаунт
</h2>
<div className="space-y-3 text-sm" style={{ color: "var(--foreground)" }}>
<div className="flex justify-between">
<span style={{ color: "var(--muted-foreground)" }}>Имя</span>
<span>{session.user.name}</span>
</div>
<div className="flex justify-between">
<span style={{ color: "var(--muted-foreground)" }}>Email</span>
<span>{session.user.email}</span>
</div>
</div>
</div>
<div className="card-aubade p-6">
<h2 className="text-xs uppercase tracking-widest font-bold mb-4" style={{ color: "var(--muted-foreground)" }}>
Смена пароля
</h2>
<ChangePasswordForm />
</div>
</main>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect, notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { QuestionThread } from "@/components/questions/QuestionThread";
export default async function QuestionThreadPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const { id } = await params;
const question = await prisma.studentQuestion.findUnique({
where: { id },
include: {
user: { select: { id: true, name: true } },
messages: {
include: { author: { select: { id: true, name: true, role: true } } },
orderBy: { createdAt: "asc" },
},
},
});
if (!question || question.userId !== session.user.id) notFound();
// Mark staff messages as read
await prisma.studentQuestionMessage.updateMany({
where: {
questionId: id,
isRead: false,
NOT: { authorId: session.user.id },
},
data: { isRead: true },
});
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-lg font-bold mb-1" style={{ color: "var(--foreground)" }}>
{question.title}
</h1>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Создан{" "}
{new Date(question.createdAt).toLocaleDateString("ru", {
day: "numeric",
month: "long",
})}{" "}
·{" "}
<span
style={{
color: question.status === "OPEN" ? "var(--foreground)" : "var(--muted-foreground)",
fontWeight: 700,
}}
>
{question.status === "OPEN" ? "● Открыт" : "✓ Закрыт"}
</span>
</p>
</div>
<Link
href="/questions"
className="text-xs"
style={{ color: "var(--muted-foreground)", textDecoration: "underline" }}
>
Все вопросы
</Link>
</div>
<QuestionThread
questionId={id}
questionStatus={question.status as "OPEN" | "CLOSED"}
currentUserId={session.user.id}
initialMessages={question.messages.map((m) => ({
...m,
files: m.files as Array<{ name: string; url: string; size: number }> | null,
createdAt: m.createdAt.toISOString(),
}))}
/>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
"use client";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
interface FileAttachment {
name: string;
url: string;
size: number;
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
export default function NewQuestionPage() {
const router = useRouter();
const [title, setTitle] = useState("");
const [text, setText] = useState("");
const [files, setFiles] = useState<FileAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const selected = Array.from(e.target.files ?? []);
if (!selected.length) return;
setUploading(true);
setError("");
try {
const uploaded: FileAttachment[] = [];
for (const f of selected) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/student/question-upload", { method: "POST", body: form });
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? "Ошибка загрузки файла");
}
uploaded.push(await res.json());
}
setFiles((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim() || !text.trim()) return;
setLoading(true);
setError("");
try {
const res = await fetch("/api/questions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, text, files }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Ошибка при создании вопроса");
}
const q = await res.json();
router.push(`/questions/${q.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
} finally {
setLoading(false);
}
}
return (
<div className="max-w-2xl mx-auto px-6 py-10">
<div className="mb-8">
<Link
href="/questions"
className="text-sm"
style={{ color: "var(--muted-foreground)", textDecoration: "underline" }}
>
Все вопросы
</Link>
</div>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--foreground)" }}>
Новый вопрос
</h1>
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
Опишите свой вопрос подробно куратор ответит в ближайшее время.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div>
<label
className="block text-sm font-bold mb-2"
style={{ color: "var(--foreground)" }}
>
Тема вопроса
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Кратко опишите суть вопроса"
required
className="w-full text-sm px-4 py-3 outline-none"
style={{
border: "2px solid var(--border)",
background: "var(--color-surface)",
color: "var(--foreground)",
}}
/>
<p className="text-xs mt-1.5" style={{ color: "var(--muted-foreground)" }}>
Например: «Не получается настроить плагин» или «Вопрос по уроку 3»
</p>
</div>
<div>
<label
className="block text-sm font-bold mb-2"
style={{ color: "var(--foreground)" }}
>
Описание
</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Подробно опишите вопрос или проблему. Что именно не получается? Что уже пробовали? Прикрепите скриншот или ссылку, если нужно."
required
rows={10}
className="w-full text-sm px-4 py-3 outline-none resize-y"
style={{
border: "2px solid var(--border)",
background: "var(--color-surface)",
color: "var(--foreground)",
minHeight: "200px",
}}
/>
</div>
{/* File attachments */}
<div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.pdf,.md,.txt"
className="hidden"
onChange={handleFileSelect}
/>
{files.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{files.map((f, i) => (
<div
key={f.url}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5"
style={{ background: "var(--color-surface)", border: "1px solid var(--border)" }}
>
📎 <span>{f.name}</span>
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
<button
type="button"
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
className="ml-1"
style={{ color: "var(--muted-foreground)" }}
>
×
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-xs px-3 py-2"
style={{ background: "var(--color-surface)", border: "1px solid var(--border)" }}
>
{uploading ? "Загрузка..." : "📎 Прикрепить файл"}
</button>
<span className="text-xs" style={{ color: "#999" }}>
jpg, png, pdf, md · до 10 МБ
</span>
</div>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>
{error}
</p>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={loading || uploading || !title.trim() || !text.trim()}
className="text-sm font-bold px-8 py-3"
style={{
background: "var(--foreground)",
color: "var(--background)",
border: "none",
opacity: loading || uploading || !title.trim() || !text.trim() ? 0.5 : 1,
}}
>
{loading ? "Отправка..." : "Отправить →"}
</button>
</div>
</form>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import Link from "next/link";
export default async function QuestionsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const questions = await prisma.studentQuestion.findMany({
where: { userId: session.user.id },
include: {
_count: {
select: {
messages: {
where: { isRead: false, NOT: { authorId: session.user.id } },
},
},
},
messages: {
orderBy: { createdAt: "desc" },
take: 1,
include: { author: { select: { name: true } } },
},
},
orderBy: { updatedAt: "desc" },
});
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold" style={{ color: "var(--foreground)" }}>
Мои вопросы
</h1>
<Link
href="/questions/new"
className="text-sm font-bold px-4 py-2"
style={{
background: "var(--color-surface)",
border: "2px solid var(--foreground)",
color: "var(--foreground)",
}}
>
+ Задать вопрос
</Link>
</div>
{questions.length === 0 && (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
У вас ещё нет вопросов.
</p>
)}
<div className="flex flex-col gap-2">
{questions.map((q) => {
const unread = q._count.messages > 0;
const lastMsg = q.messages[0];
return (
<Link
key={q.id}
href={`/questions/${q.id}`}
className="block p-3 rounded-sm transition-colors"
style={{
border: unread ? "2px solid var(--foreground)" : "1px solid var(--border)",
background: q.status === "CLOSED" ? "var(--background)" : "var(--color-surface)",
opacity: q.status === "CLOSED" ? 0.7 : 1,
}}
>
<div className="flex items-start justify-between gap-2 mb-1">
<span
className="text-sm"
style={{
fontWeight: unread ? 700 : 400,
color: q.status === "CLOSED" ? "var(--muted-foreground)" : "var(--foreground)",
}}
>
{q.title}
</span>
<span
className="text-xs shrink-0 px-1.5 py-0.5 rounded-sm"
style={
q.status === "OPEN"
? { background: "#E8F0D8", border: "1px solid var(--border)", color: "var(--foreground)" }
: { background: "var(--background)", color: "var(--muted-foreground)" }
}
>
{q.status === "OPEN" ? "● ОТКРЫТ" : "✓ ЗАКРЫТ"}
</span>
</div>
{unread && (
<div className="flex items-center gap-1.5 mb-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ background: "var(--foreground)" }}
/>
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
Новый ответ от школы
</span>
</div>
)}
{lastMsg && !unread && (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
последнее от {lastMsg.author.name}
</p>
)}
</Link>
);
})}
</div>
</div>
);
}
+48
View File
@@ -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<string, string> = {
а:"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");
}
+60
View File
@@ -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 (
<div className="p-8 max-w-2xl">
<div className="mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--foreground)" }}>Категории</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--muted-foreground)" }}>
{categories.length} категорий
</p>
</div>
<div className="space-y-2 mb-8">
{categories.length === 0 ? (
<p className="text-sm py-4" style={{ color: "var(--muted-foreground)" }}>
Категорий пока нет. Создайте первую.
</p>
) : (
categories.map((cat) => (
<CategoryRow key={cat.id} category={cat} courseCount={cat._count.courses} />
))
)}
</div>
<div className="card-aubade p-5">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Новая категория
</p>
<form action={createCategory} className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs uppercase tracking-widest font-bold block mb-1.5" style={{ color: "var(--muted-foreground)" }}>
Название
</label>
<Input name="title" placeholder="Obsidian PKM" required />
</div>
<div>
<label className="text-xs uppercase tracking-widest font-bold block mb-1.5" style={{ color: "var(--muted-foreground)" }}>
Slug
</label>
<Input name="slug" placeholder="obsidian-pkm (авто)" />
</div>
</div>
<div className="flex justify-end">
<Button type="submit">+ Создать</Button>
</div>
</form>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function adminDeleteComment(commentId: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
await prisma.lessonComment.update({
where: { id: commentId },
data: { deleted: true },
});
revalidatePath("/admin/comments");
}
+19
View File
@@ -0,0 +1,19 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function deleteComment(commentId: string): Promise<{ ok: boolean }> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
await prisma.lessonComment.update({
where: { id: commentId },
data: { deleted: true },
});
revalidatePath("/admin/comments");
return { ok: true };
}
+120
View File
@@ -0,0 +1,120 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { Suspense } from "react";
import { CommentsTable } from "@/components/admin/comments-table";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export const metadata = { title: "Комментарии" };
const PAGE_SIZE = 30;
interface Props {
searchParams: Promise<{ page?: string; search?: string }>;
}
export default async function AdminCommentsPage({ searchParams }: Props) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") redirect("/dashboard");
const { page = "1", search = "" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE;
const where = {
deleted: false,
...(search
? {
OR: [
{ user: { name: { contains: search, mode: "insensitive" as const } } },
{ user: { email: { contains: search, mode: "insensitive" as const } } },
{ text: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
};
const [comments, total] = await Promise.all([
prisma.lessonComment.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: PAGE_SIZE,
include: {
user: { select: { id: true, name: true, email: true } },
lesson: {
select: {
id: true,
title: true,
module: {
select: {
course: { select: { slug: true, title: true } },
},
},
},
},
},
}),
prisma.lessonComment.count({ where }),
]);
const totalPages = Math.ceil(total / PAGE_SIZE);
function pageUrl(p: number) {
const params = new URLSearchParams();
if (search) params.set("search", search);
params.set("page", String(p));
return `/admin/comments?${params.toString()}`;
}
return (
<div className="p-8">
<div className="mb-6">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Комментарии
</h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{total} активных комментариев
</p>
</div>
<Suspense>
<CommentsTable comments={comments} search={search} />
</Suspense>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center gap-1 mt-4">
{currentPage > 1 && (
<Link href={pageUrl(currentPage - 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
.reduce<(number | "…")[]>((acc, p, i, arr) => {
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === "…" ? (
<span key={`e${i}`} className="px-2 text-xs" style={{ color: "var(--muted-foreground)" }}></span>
) : (
<Link key={p} href={pageUrl(p as number)} className="px-3 py-1 text-xs"
style={{ border: "2px solid var(--border)", background: p === currentPage ? "var(--foreground)" : "transparent", color: p === currentPage ? "var(--background)" : "var(--foreground)" }}>
{p}
</Link>
)
)}
{currentPage < totalPages && (
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)}
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>Страница {currentPage} из {totalPages} · Всего: {total}</span>
</div>
)}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"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";
import { sendCourseAccessEmail } from "@/lib/email";
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 ──────────────────────────────────────────────────────────────────
export async function createModule(courseId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const count = await prisma.module.count({ where: { courseId } });
const mod = await prisma.module.create({ data: { courseId, title, order: count } });
revalidatePath(`/admin/courses/${courseId}`);
redirect(`/admin/courses/${courseId}/modules/${mod.id}`);
}
export async function updateModule(moduleId: string, courseId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const description = (formData.get("description") as string | null)?.trim() || null;
await prisma.module.update({ where: { id: moduleId }, data: { title, description } });
revalidatePath(`/admin/courses/${courseId}`);
}
export async function deleteModule(moduleId: string, courseId: string) {
await requireAdmin();
await prisma.module.delete({ where: { id: moduleId } });
revalidatePath(`/admin/courses/${courseId}`);
}
export async function reorderModules(courseId: string, orderedIds: string[]) {
await requireAdmin();
await Promise.all(
orderedIds.map((id, index) =>
prisma.module.update({ where: { id }, data: { order: index } })
)
);
revalidatePath(`/admin/courses/${courseId}`);
}
// ── Enrollment ───────────────────────────────────────────────────────────────
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: { 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,
},
});
// Send email notification
const [user, course] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
prisma.course.findUnique({ where: { id: courseId }, select: { title: true } }),
]);
if (user && course) {
await sendCourseAccessEmail(user.email, user.name, course.title);
}
revalidatePath(`/admin/courses/${courseId}`);
}
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}`);
}
@@ -0,0 +1,90 @@
"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");
}
export async function createLesson(moduleId: string, courseId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const kinescopeId = (formData.get("kinescopeId") as string | null)?.trim() || null;
const count = await prisma.lesson.count({ where: { moduleId } });
const lesson = await prisma.lesson.create({
data: { moduleId, title, kinescopeId, order: count },
});
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
redirect(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}`);
}
export async function updateLesson(lessonId: string, courseId: string, moduleId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
await prisma.lesson.update({ where: { id: lessonId }, data: { title } });
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
}
export async function deleteLesson(lessonId: string, courseId: string, moduleId: string) {
await requireAdmin();
await prisma.lesson.delete({ where: { id: lessonId } });
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
}
export async function reorderLessons(moduleId: string, courseId: string, orderedIds: string[]) {
await requireAdmin();
await Promise.all(
orderedIds.map((id, index) =>
prisma.lesson.update({ where: { id }, data: { order: index } })
)
);
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
}
export async function toggleLessonPublished(
lessonId: string,
courseId: string,
moduleId: string,
currentValue: boolean
) {
await requireAdmin();
await prisma.lesson.update({
where: { id: lessonId },
data: { published: !currentValue },
});
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
revalidatePath(`/admin/courses/${courseId}`);
}
export async function moveLessonToModule(
lessonId: string,
targetModuleId: string,
courseId: string,
sourceModuleId: string
) {
await requireAdmin();
// verify target module belongs to same course
const target = await prisma.module.findFirst({
where: { id: targetModuleId, courseId },
});
if (!target) throw new Error("Module not found");
const maxOrder = await prisma.lesson.aggregate({
where: { moduleId: targetModuleId },
_max: { order: true },
});
await prisma.lesson.update({
where: { id: lessonId },
data: { moduleId: targetModuleId, order: (maxOrder._max.order ?? -1) + 1 },
});
revalidatePath(`/admin/courses/${courseId}/modules/${sourceModuleId}`);
revalidatePath(`/admin/courses/${courseId}/modules/${targetModuleId}`);
revalidatePath(`/admin/courses/${courseId}`);
}
@@ -0,0 +1,36 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
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");
}
export async function saveLesson(
lessonId: string,
courseId: string,
moduleId: string,
data: {
title: string;
kinescopeId: string;
content: object;
published: boolean;
}
) {
await requireAdmin();
await prisma.lesson.update({
where: { id: lessonId },
data: {
title: data.title,
kinescopeId: data.kinescopeId || null,
content: data.content,
published: data.published,
},
});
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`);
}
@@ -0,0 +1,27 @@
"use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
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");
}
export async function saveHomework(lessonId: string, description: string) {
await requireAdmin();
await prisma.homework.upsert({
where: { lessonId },
update: { description },
create: { lessonId, description },
});
revalidatePath(`/admin/courses`);
}
export async function deleteHomework(lessonId: string) {
await requireAdmin();
await prisma.homework.delete({ where: { lessonId } });
revalidatePath(`/admin/courses`);
}
@@ -0,0 +1,111 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { LessonEditor } from "@/components/admin/lesson-editor";
import { LessonFilesManager } from "@/components/admin/lesson-files-manager";
import { HomeworkEditor } from "@/components/admin/homework-editor";
import { QuizEditor } from "@/components/admin/quiz-editor";
interface Props {
params: Promise<{ courseId: string; moduleId: string; lessonId: string }>;
}
export default async function LessonEditorPage({ params }: Props) {
const { courseId, moduleId, lessonId } = await params;
const [lesson, siblings] = await Promise.all([
prisma.lesson.findUnique({
where: { id: lessonId },
include: {
files: { orderBy: { createdAt: "asc" } },
homework: true,
quiz: {
include: { questions: { orderBy: { order: "asc" } } },
},
module: {
include: { course: { select: { title: true, slug: true } } },
},
},
}),
prisma.lesson.findMany({
where: { moduleId },
orderBy: { order: "asc" },
select: { id: true, title: true },
}),
]);
if (!lesson || lesson.moduleId !== moduleId) notFound();
const idx = siblings.findIndex((l) => l.id === lessonId);
const prevLesson = idx > 0 ? siblings[idx - 1] : null;
const nextLesson = idx < siblings.length - 1 ? siblings[idx + 1] : null;
// Serialize all Prisma proxy objects (DateTime, relations) before passing to Client Components
const plain = JSON.parse(JSON.stringify({
files: lesson.files,
homework: lesson.homework,
quiz: lesson.quiz,
siblings,
})) as {
files: typeof lesson.files;
homework: typeof lesson.homework;
quiz: typeof lesson.quiz;
siblings: typeof siblings;
};
return (
<div className="p-8 max-w-4xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
<span className="mx-2">/</span>
<Link href={`/admin/courses/${courseId}`} className="hover:underline">{lesson.module.course.title}</Link>
<span className="mx-2">/</span>
<Link href={`/admin/courses/${courseId}/modules/${moduleId}`} className="hover:underline">{lesson.module.title}</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{lesson.title}</span>
</nav>
{/* Lesson editor */}
<div className="card-aubade p-6 mb-6">
<LessonEditor
lesson={{
id: lesson.id,
title: lesson.title,
kinescopeId: lesson.kinescopeId ?? "",
content: JSON.parse(JSON.stringify(lesson.content ?? {})),
published: lesson.published,
}}
courseId={courseId}
moduleId={moduleId}
courseSlug={lesson.module.course.slug}
prevLesson={plain.siblings[idx - 1] ?? null}
nextLesson={plain.siblings[idx + 1] ?? null}
/>
</div>
{/* Files section */}
<div className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Файлы и материалы
</p>
<LessonFilesManager lessonId={lessonId} initialFiles={plain.files} />
</div>
{/* Homework section */}
<div className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Домашнее задание
</p>
<HomeworkEditor lessonId={lessonId} initial={plain.homework} />
</div>
{/* Quiz section */}
<div className="card-aubade p-6">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Тест
</p>
<QuizEditor lessonId={lessonId} initial={plain.quiz} />
</div>
</div>
);
}
@@ -0,0 +1,68 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { SortableLessons } from "@/components/admin/sortable-lessons";
interface Props {
params: Promise<{ courseId: string; moduleId: string }>;
}
export default async function ModulePage({ params }: Props) {
const { courseId, moduleId } = await params;
const [module, allModules] = await Promise.all([
prisma.module.findUnique({
where: { id: moduleId },
include: {
course: { select: { title: true } },
lessons: {
orderBy: { order: "asc" },
select: { id: true, title: true, order: true, published: true, kinescopeId: true },
},
},
}),
prisma.module.findMany({
where: { courseId, NOT: { id: moduleId } },
select: { id: true, title: true },
orderBy: { order: "asc" },
}),
]);
if (!module || module.courseId !== courseId) notFound();
const plain = JSON.parse(JSON.stringify({ lessons: module.lessons, allModules })) as {
lessons: typeof module.lessons;
allModules: typeof allModules;
};
return (
<div className="p-8 max-w-3xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
<span className="mx-2">/</span>
<Link href={`/admin/courses/${courseId}`} className="hover:underline">{module.course.title}</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{module.title}</span>
</nav>
<div className="mb-6">
<h1 className="text-2xl font-bold">{module.title}</h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{module.lessons.length} {module.lessons.length === 1 ? "урок" : module.lessons.length < 5 ? "урока" : "уроков"}
</p>
</div>
<section className="card-aubade p-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Уроки модуля
</p>
<SortableLessons
courseId={courseId}
moduleId={moduleId}
lessons={plain.lessons}
otherModules={plain.allModules}
/>
</section>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { CourseEditForm } from "@/components/admin/course-edit-form";
import { SortableModules } from "@/components/admin/sortable-modules";
import { EnrollmentManager } from "@/components/admin/enrollment-manager";
import { CourseTree } from "@/components/admin/course-tree";
interface Props {
params: Promise<{ courseId: string }>;
}
export default async function CourseDetailPage({ params }: Props) {
const { courseId } = await params;
const [course, allStudents, categories] = await Promise.all([
prisma.course.findUnique({
where: { id: courseId },
include: {
modules: {
orderBy: { order: "asc" },
include: {
_count: { select: { lessons: true } },
lessons: {
orderBy: { order: "asc" },
select: { id: true, title: true, published: true, kinescopeId: 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({
where: { role: "student" },
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
}),
prisma.category.findMany({ orderBy: { order: "asc" } }),
]);
if (!course) notFound();
// Prisma 7 returns proxy objects for relations/aggregates that RSC cannot serialize.
// Convert to plain JS before passing to Client Components.
const plain = JSON.parse(JSON.stringify({ course, allStudents, categories })) as {
course: typeof course;
allStudents: typeof allStudents;
categories: typeof categories;
};
return (
<div className="p-8 max-w-4xl">
{/* Breadcrumb */}
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{plain.course.title}</span>
</nav>
{/* Course metadata */}
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Основная информация
</p>
<CourseEditForm course={plain.course} categories={plain.categories} />
</section>
{/* Modules */}
<section className="card-aubade p-6 mb-6">
<div className="flex items-center justify-between mb-5">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Модули
</p>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{plain.course.modules.length} модулей
</span>
</div>
<SortableModules courseId={courseId} modules={plain.course.modules} />
</section>
{/* Course tree overview */}
{plain.course.modules.length > 0 && (
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Структура курса
</p>
<CourseTree courseId={courseId} modules={plain.course.modules} />
</section>
)}
{/* Access management */}
<section className="card-aubade p-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Управление доступом
</p>
<EnrollmentManager
courseId={courseId}
allStudents={plain.allStudents}
enrollments={plain.course.enrollments}
accessLogs={plain.course.accessLogs}
/>
</section>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
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");
}
function slugify(str: string): string {
const map: Record<string, string> = {
а:"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 createCourse(formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const slug = (formData.get("slug") as string).trim() || slugify(title);
const description = (formData.get("description") as string) || null;
const course = await prisma.course.create({
data: { title, slug, description },
});
revalidatePath("/admin/courses");
redirect(`/admin/courses/${course.id}`);
}
export async function updateCourse(courseId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const slug = formData.get("slug") as string;
const description = (formData.get("description") as string) || null;
const published = formData.get("published") === "true";
const allowAudio = formData.get("allowAudio") === "true";
const coverImage = (formData.get("coverImage") as string) || null;
const categoryId = (formData.get("categoryId") as string) || null;
await prisma.course.update({
where: { id: courseId },
data: { title, slug, description, published, allowAudio, coverImage, categoryId },
});
revalidatePath("/admin/courses");
revalidatePath(`/admin/courses/${courseId}`);
}
export async function deleteCourse(courseId: string) {
await requireAdmin();
await prisma.course.delete({ where: { id: courseId } });
revalidatePath("/admin/courses");
redirect("/admin/courses");
}
+56
View File
@@ -0,0 +1,56 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CreateCourseDialog } from "@/components/admin/create-course-dialog";
export default async function CoursesPage() {
const courses = await prisma.course.findMany({
orderBy: { order: "asc" },
include: { _count: { select: { modules: true, enrollments: true } } },
});
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-slate-800">Курсы</h1>
<p className="text-slate-500 text-sm mt-0.5">{courses.length} курсов</p>
</div>
<CreateCourseDialog />
</div>
{courses.length === 0 ? (
<div className="bg-white border border-slate-200 rounded-2xl p-12 text-center">
<p className="text-4xl mb-3">📚</p>
<p className="text-slate-600 font-medium">Курсов пока нет</p>
<p className="text-slate-400 text-sm mt-1">Создайте первый курс</p>
</div>
) : (
<div className="space-y-2">
{courses.map((course) => (
<Link
key={course.id}
href={`/admin/courses/${course.id}`}
className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-5 py-4 hover:border-amber-300 transition-colors group"
>
<div className="flex items-center gap-3">
<div>
<p className="font-medium text-slate-800 group-hover:text-amber-700">{course.title}</p>
<p className="text-xs text-slate-400 mt-0.5">/{course.slug}</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-400">{course._count.modules} модулей</span>
<span className="text-sm text-slate-400">{course._count.enrollments} учеников</span>
<Badge variant={course.published ? "default" : "secondary"}>
{course.published ? "Опубликован" : "Черновик"}
</Badge>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+218 -33
View File
@@ -1,46 +1,231 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/layout/logout-button";
import { prisma } from "@/lib/prisma";
import Link from "next/link";
export default async function AdminDashboard() {
const session = await auth.api.getSession({ headers: await headers() });
const now = new Date();
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
if (!session) redirect("/login");
if (session.user.role !== "admin") redirect("/dashboard");
const [
totalStudents,
newStudentsMonth,
totalCourses,
publishedCourses,
activeEnrollments,
expiringWeek,
homeworkPending,
homeworkTotal,
progressTotal,
balanceAggregate,
activeLast24h,
] = await Promise.all([
prisma.user.count({ where: { role: "student" } }),
prisma.user.count({ where: { role: "student", createdAt: { gte: monthAgo } } }),
prisma.course.count(),
prisma.course.count({ where: { published: true } }),
prisma.courseEnrollment.count({
where: { OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
}),
prisma.courseEnrollment.count({
where: { expiresAt: { gt: now, lte: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) } },
}),
prisma.homeworkSubmission.count({ where: { feedbacks: { none: {} } } }),
prisma.homeworkSubmission.count(),
prisma.lessonProgress.count(),
prisma.balanceTransaction.aggregate({ _sum: { amount: true } }),
prisma.session.findMany({
where: { createdAt: { gte: dayAgo } },
select: { userId: true },
distinct: ["userId"],
}).then((rows) => rows.length),
]);
const totalBalance = Number(balanceAggregate._sum.amount ?? 0);
// Recent enrollments
const recentEnrollments = await prisma.courseEnrollment.findMany({
orderBy: { enrolledAt: "desc" },
take: 8,
include: {
user: { select: { name: true, email: true } },
course: { select: { title: true } },
},
});
// Most active courses (by enrollment count)
const topCourses = await prisma.course.findMany({
where: { published: true },
include: { _count: { select: { enrollments: true, modules: true } } },
orderBy: { enrollments: { _count: "desc" } },
take: 5,
});
return (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">Second Brain Админ</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{session.user.name}</span>
<LogoutButton />
<div className="p-8 max-w-5xl">
<h1 className="text-2xl font-bold mb-1">Обзор</h1>
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
Платформа Second Brain · {now.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" })}
</p>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatCard
label="Студентов"
value={totalStudents}
sub={`+${newStudentsMonth} за месяц`}
href="/admin/users"
/>
<StatCard
label="Курсов"
value={totalCourses}
sub={`${publishedCourses} опубликовано`}
href="/admin/courses"
/>
<StatCard
label="Активных доступов"
value={activeEnrollments}
sub={expiringWeek > 0 ? `${expiringWeek} истекает на неделе` : "нет истекающих"}
subAccent={expiringWeek > 0}
href="/admin/courses"
/>
<StatCard
label="ДЗ на проверку"
value={homeworkPending}
sub={`${homeworkTotal} всего сдано`}
subAccent={homeworkPending > 0}
href="/curator/homework"
/>
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* Recent enrollments */}
<div className="card-aubade p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Последние зачисления
</p>
<Link href="/admin/users" className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
Все
</Link>
</div>
{recentEnrollments.length === 0 ? (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Нет зачислений</p>
) : (
<div className="space-y-2">
{recentEnrollments.map((e) => (
<div key={`${e.userId}-${e.courseId}`} className="flex items-center gap-3 text-sm">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{e.user.name}</p>
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>{e.course.title}</p>
</div>
<span className="text-xs shrink-0" style={{ color: "var(--muted-foreground)" }}>
{new Date(e.enrolledAt).toLocaleDateString("ru-RU")}
</span>
</div>
))}
</div>
)}
</div>
</header>
<main className="max-w-5xl mx-auto px-6 py-10">
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
Панель администратора
</h2>
<p className="text-gray-500 mb-8">Управление платформой Second Brain.</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-2xl border border-slate-200 p-6">
<p className="text-3xl mb-2">📚</p>
<p className="font-medium text-gray-800">Курсы</p>
<p className="text-sm text-gray-400 mt-1">CRUD Этап 1</p>
{/* Top courses + progress stat */}
<div className="space-y-6">
<div className="card-aubade p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Популярные курсы
</p>
<Link href="/admin/courses" className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
Все
</Link>
</div>
{topCourses.length === 0 ? (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Нет курсов</p>
) : (
<div className="space-y-3">
{topCourses.map((c) => (
<div key={c.id} className="flex items-center gap-3 text-sm">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{c.title}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{c._count.modules} модулей
</p>
</div>
<div className="text-right shrink-0">
<p className="font-bold">{c._count.enrollments}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>студентов</p>
</div>
</div>
))}
</div>
)}
</div>
<div className="bg-white rounded-2xl border border-slate-200 p-6">
<p className="text-3xl mb-2">👥</p>
<p className="font-medium text-gray-800">Пользователи</p>
<p className="text-sm text-gray-400 mt-1">Управление Этап 1</p>
<div className="card-aubade p-5">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Активность
</p>
<div className="flex items-end gap-6">
<div>
<p className="text-3xl font-bold">{progressTotal}</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>уроков пройдено</p>
</div>
<div>
<p className="text-3xl font-bold">{homeworkTotal}</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>работ сдано</p>
</div>
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-200 p-6">
<p className="text-3xl mb-2">📊</p>
<p className="font-medium text-gray-800">Аналитика</p>
<p className="text-sm text-gray-400 mt-1">Этап 10</p>
<Link href="/admin/users?balance=nonzero" className="card-aubade p-5 block">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
На балансах
</p>
<p className="text-3xl font-bold">
{totalBalance.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>сумма по всем пользователям</p>
</Link>
<div className="card-aubade p-5">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Авторизации за 24 часа
</p>
<p className="text-3xl font-bold">{activeLast24h}</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>уникальных пользователей</p>
</div>
</div>
</main>
</div>
</div>
);
}
function StatCard({
label,
value,
sub,
subAccent,
href,
}: {
label: string;
value: number | string;
sub?: string;
subAccent?: boolean;
href?: string;
}) {
const content = (
<div className="card-aubade p-4">
<p className="text-3xl font-bold">{value}</p>
<p className="text-xs font-bold uppercase tracking-widest mt-1" style={{ color: "var(--muted-foreground)" }}>
{label}
</p>
{sub && (
<p className="text-xs mt-1.5" style={{ color: subAccent ? "oklch(0.577 0.245 27.325)" : "var(--muted-foreground)" }}>
{sub}
</p>
)}
</div>
);
return href ? <Link href={href}>{content}</Link> : content;
}
@@ -0,0 +1,260 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import iconv from "iconv-lite";
import { sendWelcomeEmail } from "@/lib/email";
// ── Types ─────────────────────────────────────────────────────────────────────
export type ParsedRow = {
index: number;
email: string;
name: string;
lastName: string;
phone: string;
// resolved during preview
status: "new" | "update" | "error";
errorMsg?: string;
existingId?: string;
};
export type PreviewResult = {
rows: ParsedRow[];
countNew: number;
countUpdate: number;
countError: number;
};
export type ImportOptions = {
updateExisting: boolean;
autoVerifyEmail: boolean;
courseId?: string;
accessDays: number; // 0 = unlimited
sendWelcome: boolean;
encoding: "utf8" | "win1251";
};
export type ApplyResult = {
created: number;
updated: number;
errors: string[];
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if ((ch === "," || ch === ";") && !inQuotes) {
result.push(current.trim());
current = "";
} else {
current += ch;
}
}
result.push(current.trim());
return result;
}
function normalizeHeaders(headers: string[]): Record<string, number> {
const map: Record<string, number> = {};
const aliases: Record<string, string[]> = {
email: ["email", "e-mail", "почта", "login", "логин"],
name: ["имя", "name", "firstname", "first_name", "имя пользователя"],
lastName: ["фамилия", "lastname", "last_name", "surname"],
phone: ["телефон", "phone", "tel", "мобильный"],
};
headers.forEach((h, i) => {
const lower = h.toLowerCase().replace(/[^a-zа-яё0-9]/gi, "");
for (const [field, aliasList] of Object.entries(aliases)) {
if (aliasList.some((a) => lower.includes(a.toLowerCase().replace(/[^a-zа-яё0-9]/gi, "")))) {
if (!(field in map)) map[field] = i;
}
}
});
return map;
}
async function assertAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
return session;
}
// ── Parse action ──────────────────────────────────────────────────────────────
export async function parseCSV(
base64: string,
encoding: "utf8" | "win1251",
updateExisting: boolean
): Promise<PreviewResult> {
await assertAdmin();
// Decode bytes
const buffer = Buffer.from(base64, "base64");
const text = encoding === "win1251"
? iconv.decode(buffer, "win1251")
: buffer.toString("utf8");
// Split lines (handle \r\n and \n)
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const nonEmpty = lines.filter((l) => l.trim().length > 0);
if (nonEmpty.length < 2) {
return { rows: [], countNew: 0, countUpdate: 0, countError: 0 };
}
const headerLine = parseCSVLine(nonEmpty[0]);
const colMap = normalizeHeaders(headerLine);
if (colMap.email === undefined) {
throw new Error("Не найдена колонка Email. Проверьте заголовки CSV-файла.");
}
// Load existing emails for fast lookup
const existingUsers = await prisma.user.findMany({
select: { id: true, email: true },
});
const existingByEmail = new Map(existingUsers.map((u) => [u.email.toLowerCase(), u.id]));
const rows: ParsedRow[] = [];
let countNew = 0, countUpdate = 0, countError = 0;
for (let i = 1; i < nonEmpty.length; i++) {
const cols = parseCSVLine(nonEmpty[i]);
const email = (cols[colMap.email] ?? "").trim().toLowerCase();
const name = (cols[colMap.name ?? -1] ?? "").trim();
const lastName = (cols[colMap.lastName ?? -1] ?? "").trim();
const phone = (cols[colMap.phone ?? -1] ?? "").trim();
const row: ParsedRow = {
index: i,
email,
name: [name, lastName].filter(Boolean).join(" ") || email.split("@")[0],
lastName,
phone,
status: "new",
};
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
row.status = "error";
row.errorMsg = "Некорректный email";
countError++;
} else if (existingByEmail.has(email)) {
row.existingId = existingByEmail.get(email);
if (updateExisting) {
row.status = "update";
countUpdate++;
} else {
row.status = "error";
row.errorMsg = "Уже существует (обновление отключено)";
countError++;
}
} else {
row.status = "new";
countNew++;
}
rows.push(row);
}
return { rows, countNew, countUpdate, countError };
}
// ── Apply action ──────────────────────────────────────────────────────────────
export async function applyImport(
rows: ParsedRow[],
options: ImportOptions
): Promise<ApplyResult> {
await assertAdmin();
let created = 0, updated = 0;
const errors: string[] = [];
const validRows = rows.filter((r) => r.status !== "error");
for (const row of validRows) {
try {
if (row.status === "new") {
// Generate a random password
const rawPassword = Math.random().toString(36).slice(-10) + "A1!";
const hashedPassword = await bcrypt.hash(rawPassword, 10);
const user = await prisma.user.create({
data: {
name: row.name,
email: row.email,
emailVerified: options.autoVerifyEmail,
role: "student",
},
});
await prisma.account.create({
data: {
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
},
});
if (options.courseId) {
const expiresAt = options.accessDays > 0
? new Date(Date.now() + options.accessDays * 86_400_000)
: null;
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId: user.id, courseId: options.courseId } },
update: { expiresAt },
create: { userId: user.id, courseId: options.courseId, expiresAt },
});
}
if (options.sendWelcome) {
await sendWelcomeEmail(user.email, user.name).catch(() => {});
}
created++;
} else if (row.status === "update" && row.existingId) {
await prisma.user.update({
where: { id: row.existingId },
data: {
name: row.name || undefined,
},
});
if (options.courseId) {
const expiresAt = options.accessDays > 0
? new Date(Date.now() + options.accessDays * 86_400_000)
: null;
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId: row.existingId, courseId: options.courseId } },
update: { expiresAt },
create: { userId: row.existingId, courseId: options.courseId, expiresAt },
});
}
updated++;
}
} catch (e) {
errors.push(`${row.email}: ${e instanceof Error ? e.message : "Ошибка"}`);
}
}
return { created, updated, errors };
}
+49
View File
@@ -0,0 +1,49 @@
import { prisma } from "@/lib/prisma";
import { CsvImporter } from "@/components/admin/csv-importer";
import { CsvExporter } from "@/components/admin/csv-exporter";
export const metadata = { title: "Импорт и экспорт" };
export default async function ImportExportPage() {
const courses = await prisma.course.findMany({
orderBy: { title: "asc" },
select: { id: true, title: true },
});
return (
<div className="p-8 max-w-3xl">
<div className="mb-8">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Импорт и экспорт
</h1>
</div>
<div className="space-y-6">
{/* Import */}
<div className="card-aubade p-6">
<p
className="text-xs font-bold uppercase tracking-widest mb-5"
style={{ color: "var(--muted-foreground)" }}
>
Импорт учеников из CSV
</p>
<CsvImporter courses={courses} />
</div>
{/* Export */}
<div className="card-aubade p-6">
<p
className="text-xs font-bold uppercase tracking-widest mb-5"
style={{ color: "var(--muted-foreground)" }}
>
Экспорт учеников в CSV
</p>
<CsvExporter courses={courses} />
</div>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { AdminShell } from "@/components/admin/admin-shell";
import { prisma } from "@/lib/prisma";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "admin") redirect("/dashboard");
const questionsBadge = await prisma.studentQuestion.count({
where: {
messages: {
some: {
isRead: false,
author: { role: "student" },
},
},
},
});
return <AdminShell userName={session.user.name} questionsBadge={questionsBadge}>{children}</AdminShell>;
}
+11
View File
@@ -0,0 +1,11 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { QuestionSplitView } from "@/components/questions/QuestionSplitView";
export default async function AdminQuestionsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") redirect("/login");
return <QuestionSplitView currentUserId={session.user.id} />;
}
+116
View File
@@ -0,0 +1,116 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
interface Props {
params: Promise<{ quizId: string }>;
}
export default async function AdminQuizAttemptsPage({ params }: Props) {
const { quizId } = await params;
const quiz = await prisma.quiz.findUnique({
where: { id: quizId },
include: {
questions: { orderBy: { order: "asc" } },
attempts: { orderBy: { completedAt: "desc" } },
lesson: {
select: {
title: true,
module: {
select: {
course: { select: { title: true } },
},
},
},
},
},
});
if (!quiz) notFound();
const userIds = [...new Set(quiz.attempts.map((a) => a.userId))];
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true },
});
const userMap = Object.fromEntries(users.map((u) => [u.id, u]));
return (
<div className="p-8 max-w-4xl">
<nav
className="text-xs mb-6 uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
<Link href="/admin/quizzes" className="hover:underline">
Тесты
</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{quiz.lesson.title}</span>
</nav>
<div className="mb-6">
<h1 className="text-lg font-bold">{quiz.lesson.title}</h1>
<p className="text-xs mt-1 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
{quiz.lesson.module.course.title} · {quiz.questions.length} вопросов · {quiz.attempts.length} ответов
</p>
</div>
{quiz.attempts.length === 0 ? (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Ответов пока нет
</p>
) : (
<div className="space-y-4">
{quiz.attempts.map((attempt) => {
const answers = attempt.answers as Record<string, string>;
const user = userMap[attempt.userId];
const date = new Date(attempt.completedAt).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<div
key={attempt.id}
className="px-4 py-4 space-y-3"
style={{ border: "2px solid var(--border)" }}
>
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-medium">{user?.name ?? "—"}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{user?.email ?? attempt.userId}
</p>
</div>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{date}
</span>
</div>
<div className="space-y-2 pt-2" style={{ borderTop: "1px solid var(--border)" }}>
{quiz.questions.map((q, idx) => (
<div key={q.id}>
<p
className="text-xs font-medium mb-0.5"
style={{ color: "var(--muted-foreground)" }}
>
{idx + 1}. {q.text}
</p>
<p className="text-sm whitespace-pre-wrap">
{answers[q.id]?.trim() || <span style={{ color: "var(--muted-foreground)" }}></span>}
</p>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
export const metadata = { title: "Тесты" };
export default async function AdminQuizzesPage() {
const quizzes = await prisma.quiz.findMany({
orderBy: { createdAt: "desc" },
include: {
_count: { select: { questions: true, attempts: true } },
lesson: {
select: {
title: true,
module: {
select: {
course: { select: { title: true, slug: true } },
},
},
},
},
},
});
return (
<div className="p-8">
<div className="mb-6">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Тесты
</h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{quizzes.length} тестов
</p>
</div>
{quizzes.length === 0 ? (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Тестов нет
</p>
) : (
<div className="space-y-0" style={{ border: "2px solid var(--border)" }}>
{quizzes.map((quiz) => (
<Link
key={quiz.id}
href={`/admin/quizzes/${quiz.id}`}
className="flex items-center gap-4 px-4 py-3 hover:[background:var(--muted)] transition-colors"
style={{ borderBottom: "1px solid var(--border)" }}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{quiz.lesson.title}
</p>
<p
className="text-xs mt-0.5 truncate"
style={{ color: "var(--muted-foreground)" }}
>
{quiz.lesson.module.course.title}
</p>
</div>
<div className="flex items-center gap-4 shrink-0 text-xs" style={{ color: "var(--muted-foreground)" }}>
<span>{quiz._count.questions} вопр.</span>
<span
className="font-bold"
style={{ color: quiz._count.attempts > 0 ? "var(--foreground)" : undefined }}
>
{quiz._count.attempts} ответов
</span>
<span style={{ color: "var(--muted-foreground)" }}></span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { SETTINGS_DEFAULTS, type SettingsKey } from "@/lib/settings";
export async function saveSettings(data: Record<string, string>) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
const validKeys = Object.keys(SETTINGS_DEFAULTS) as SettingsKey[];
const ops = validKeys
.filter((key) => key in data)
.map((key) =>
prisma.settings.upsert({
where: { key },
update: { value: data[key] },
create: { key, value: data[key] },
})
);
await Promise.all(ops);
revalidatePath("/admin/settings");
revalidatePath("/", "layout");
}
+22
View File
@@ -0,0 +1,22 @@
import { getSettings } from "@/lib/settings";
import { SettingsForm } from "@/components/admin/settings-form";
export const metadata = { title: "Настройки платформы" };
export default async function SettingsPage() {
const settings = await getSettings();
return (
<div className="p-8 max-w-2xl">
<div className="mb-6">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Настройки платформы
</h1>
</div>
<SettingsForm initial={settings} />
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import bcrypt from "bcryptjs";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
return session;
}
export async function bulkGrantAccess(
userId: string,
courseIds: string[],
expiresAt?: string | null
) {
const session = await requireAdmin();
const expiry = expiresAt ? new Date(expiresAt) : null;
await Promise.all(
courseIds.map(async (courseId) => {
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId, courseId } },
update: { expiresAt: expiry },
create: { userId, courseId, expiresAt: expiry },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "granted",
method: "bulk",
grantedById: session.user.id,
},
});
})
);
revalidatePath(`/admin/users/${userId}`);
}
export async function updateUserContact(
userId: string,
data: { name: string; email: string; phone: string; birthday: string; comment: string }
) {
await requireAdmin();
await prisma.user.update({
where: { id: userId },
data: {
name: data.name.trim() || undefined,
email: data.email.trim() || undefined,
phone: data.phone.trim() || null,
birthday: data.birthday ? new Date(data.birthday) : null,
comment: data.comment.trim() || null,
},
});
revalidatePath(`/admin/users/${userId}`);
}
export async function addBalanceTransaction(
userId: string,
data: { amount: string; description: string }
) {
await requireAdmin();
const amount = parseFloat(data.amount.replace(",", "."));
if (isNaN(amount) || amount === 0) throw new Error("Некорректная сумма");
await prisma.balanceTransaction.create({
data: { userId, amount, description: data.description.trim() },
});
revalidatePath(`/admin/users/${userId}`);
}
export async function deleteBalanceTransaction(userId: string, txId: string) {
await requireAdmin();
await prisma.balanceTransaction.delete({ where: { id: txId } });
revalidatePath(`/admin/users/${userId}`);
}
export async function resetUserPassword(userId: string): Promise<{ tempPassword: string }> {
await requireAdmin();
const account = await prisma.account.findFirst({
where: { userId, providerId: "credential" },
});
if (!account) throw new Error("Аккаунт с паролем не найден");
const chars = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
const tempPassword = Array.from({ length: 10 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
const hash = await bcrypt.hash(tempPassword, 10);
await prisma.account.update({
where: { id: account.id },
data: { password: hash },
});
return { tempPassword };
}
export async function revokeUserAccess(userId: string, courseId: 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,
},
});
revalidatePath(`/admin/users/${userId}`);
}
+145
View File
@@ -0,0 +1,145 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager";
import { UserContactEditor } from "@/components/admin/user-contact-editor";
import { UserBalanceBlock } from "@/components/admin/user-balance-block";
import { ResetPasswordButton } from "@/components/admin/reset-password-button";
interface Props {
params: Promise<{ userId: string }>;
}
export default async function UserPage({ params }: Props) {
const { userId } = await params;
const [user, allCourses] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
include: {
enrollments: {
include: { course: { select: { id: true, title: true, published: true } } },
orderBy: { enrolledAt: "desc" },
},
accessLogs: {
orderBy: { createdAt: "desc" },
take: 30,
include: {
course: { select: { title: true } },
grantedBy: { select: { name: true } },
},
},
balanceTransactions: {
orderBy: { createdAt: "desc" },
},
},
}),
prisma.course.findMany({
orderBy: { title: "asc" },
select: { id: true, title: true, published: true },
}),
]);
if (!user) notFound();
const roleLabel: Record<string, string> = { admin: "Администратор", curator: "Куратор", student: "Ученик" };
return (
<div className="p-8 max-w-3xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/users" className="hover:underline">Пользователи</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{user.name}</span>
</nav>
{/* User info */}
<section className="card-aubade p-6 mb-6 space-y-4">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold">{user.name}</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--muted-foreground)" }}>{user.email}</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className="tag-aubade">{roleLabel[user.role] ?? user.role}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
с {new Date(user.createdAt).toLocaleDateString("ru-RU")}
</span>
</div>
</div>
<div style={{ borderTop: "2px solid var(--border)", paddingTop: "1rem" }}>
<UserContactEditor
userId={userId}
name={user.name ?? ""}
email={user.email}
phone={user.phone ?? null}
birthday={user.birthday ?? null}
comment={user.comment ?? null}
/>
</div>
</section>
{/* Reset password */}
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Пароль
</p>
<ResetPasswordButton userId={userId} />
</section>
{/* Balance */}
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Баланс
</p>
<UserBalanceBlock
userId={userId}
transactions={user.balanceTransactions.map((tx) => ({
id: tx.id,
amount: Number(tx.amount),
description: tx.description,
createdAt: tx.createdAt,
}))}
/>
</section>
{/* Enrollments + bulk grant */}
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Доступ к курсам
</p>
<UserEnrollmentManager
userId={userId}
allCourses={allCourses}
enrollments={user.enrollments.map((e) => ({
courseId: e.courseId,
expiresAt: e.expiresAt,
courseTitle: e.course.title,
}))}
/>
</section>
{/* Access log */}
{user.accessLogs.length > 0 && (
<section className="card-aubade p-6">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
История доступа
</p>
<div className="space-y-1.5 max-h-72 overflow-y-auto">
{user.accessLogs.map((log) => (
<div key={log.id} className="flex items-center gap-3 px-3 py-2 text-xs" style={{ border: "2px solid var(--border)" }}>
<span style={{ color: log.action === "granted" ? "#3A6A3A" : "oklch(0.577 0.245 27.325)", fontWeight: 700, minWidth: 70 }}>
{log.action === "granted" ? "▲ Выдан" : "▼ Отозван"}
</span>
<span className="flex-1">{log.course.title}</span>
<span style={{ color: "var(--muted-foreground)" }}>{log.grantedBy?.name ?? "—"}</span>
<span style={{ color: "var(--muted-foreground)" }}>
{new Date(log.createdAt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
</span>
</div>
))}
</div>
</section>
)}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { sendWelcomeEmail } from "@/lib/email";
export async function createUser(data: {
name: string;
email: string;
password: string;
role: string;
emailVerified: boolean;
sendWelcome: boolean;
}): Promise<{ success: true; userId: string } | { success: false; error: string }> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return { success: false, error: "Нет доступа" };
}
const { name, email, password, role, emailVerified, sendWelcome } = data;
if (!name.trim() || !email.trim() || !password.trim()) {
return { success: false, error: "Заполните все обязательные поля" };
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return { success: false, error: "Пользователь с таким email уже существует" };
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: { name: name.trim(), email: email.trim().toLowerCase(), role, emailVerified },
});
// Create credential account (Better Auth's internal structure)
await prisma.account.create({
data: {
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
},
});
if (sendWelcome) {
await sendWelcomeEmail(user.email, user.name).catch(() => {});
}
return { success: true, userId: user.id };
}
+75
View File
@@ -0,0 +1,75 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { sendCourseAccessEmail } from "@/lib/email";
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;
}
export async function grantCourseAccess(
userId: string,
courseId: string,
expiresAt: Date | null
): Promise<{ ok: true } | { error: string }> {
try {
const session = await requireAdmin();
const [user, course] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
prisma.course.findUnique({ where: { id: courseId }, select: { title: true } }),
]);
if (!user) return { error: "Пользователь не найден" };
if (!course) return { error: "Курс не найден" };
const existing = await prisma.courseEnrollment.findUnique({
where: { userId_courseId: { userId, courseId } },
});
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId, courseId } },
update: { expiresAt },
create: { userId, courseId, expiresAt },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "granted",
method: "quick",
grantedById: session.user.id,
},
});
// Send email only on new enrollment (not on update)
if (!existing) {
await sendCourseAccessEmail(user.email, user.name ?? user.email, course.title).catch(
(e) => console.error("[enroll-action] sendCourseAccessEmail:", e)
);
}
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`);
return { ok: true };
} catch (e) {
console.error("[enroll-action] grantCourseAccess:", e);
return { error: "Произошла ошибка. Попробуйте ещё раз." };
}
}
export async function getPublishedCourses(): Promise<{ id: string; title: string }[]> {
await requireAdmin();
return prisma.course.findMany({
where: { published: true },
select: { id: true, title: true },
orderBy: { title: "asc" },
});
}
+29
View File
@@ -0,0 +1,29 @@
import Link from "next/link";
import { CreateUserForm } from "@/components/admin/create-user-form";
export const metadata = { title: "Новый пользователь" };
export default function NewUserPage() {
return (
<div className="p-8">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/users" className="hover:underline">Пользователи</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>Новый пользователь</span>
</nav>
<div className="mb-6">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Создание пользователя
</h1>
</div>
<div className="card-aubade p-6">
<CreateUserForm />
</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { UserPlus } from "lucide-react";
import { UsersTable } from "@/components/admin/users-table";
import { Suspense } from "react";
import { UsersSearch } from "@/components/admin/users-search";
const PAGE_SIZE = 20;
interface Props {
searchParams: Promise<{ search?: string; role?: string; page?: string; balance?: string; emailVerified?: string }>;
}
export default async function UsersPage({ searchParams }: Props) {
const { search = "", role = "", page = "1", balance = "", emailVerified = "" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE;
// Collect userIds with non-zero balance if filter is active
let balanceUserIds: string[] | null = null;
if (balance === "nonzero") {
const groups = await prisma.balanceTransaction.groupBy({
by: ["userId"],
_sum: { amount: true },
having: { amount: { _sum: { not: { equals: 0 } } } },
});
balanceUserIds = groups.map((g) => g.userId);
}
const where = {
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ email: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
...(role ? { role } : {}),
...(emailVerified === "true" ? { emailVerified: true } : {}),
...(emailVerified === "false" ? { emailVerified: false } : {}),
...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}),
};
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: PAGE_SIZE,
include: {
_count: { select: { enrollments: true } },
enrollments: {
include: { course: { select: { title: true } } },
orderBy: { enrolledAt: "desc" },
},
},
}),
prisma.user.count({ where }),
]);
const totalPages = Math.ceil(total / PAGE_SIZE);
const tableUsers = users.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role,
emailVerified: u.emailVerified,
createdAt: u.createdAt,
enrollmentCount: u._count.enrollments,
enrollments: u.enrollments.map((e) => ({
courseId: e.courseId,
courseTitle: e.course.title,
expiresAt: e.expiresAt,
})),
}));
function pageUrl(p: number) {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (role) params.set("role", role);
if (emailVerified) params.set("emailVerified", emailVerified);
if (balance) params.set("balance", balance);
params.set("page", String(p));
return `/admin/users?${params.toString()}`;
}
return (
<div className="p-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-slate-800">Пользователи</h1>
<p className="text-slate-500 text-sm mt-0.5">{total} пользователей</p>
</div>
<Link
href="/admin/users/new"
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-4 py-2 text-sm"
>
<UserPlus size={14} />
Добавить пользователя
</Link>
</div>
{/* Filters */}
<Suspense>
<UsersSearch initialSearch={search} initialRole={role} initialEmailVerified={emailVerified} initialBalance={balance} />
</Suspense>
<UsersTable users={tableUsers} />
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center gap-1 mt-4">
{currentPage > 1 && (
<Link href={pageUrl(currentPage - 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
.reduce<(number | "…")[]>((acc, p, i, arr) => {
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === "…" ? (
<span key={`e${i}`} className="px-2 text-xs" style={{ color: "var(--muted-foreground)" }}></span>
) : (
<Link key={p} href={pageUrl(p as number)} className="px-3 py-1 text-xs"
style={{ border: "2px solid var(--border)", background: p === currentPage ? "var(--foreground)" : "transparent", color: p === currentPage ? "var(--background)" : "var(--foreground)" }}>
{p}
</Link>
)
)}
{currentPage < totalPages && (
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)}
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>Страница {currentPage} из {totalPages} · Всего: {total}</span>
</div>
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import iconv from "iconv-lite";
export async function GET(request: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Нет доступа" }, { status: 403 });
}
const { searchParams } = request.nextUrl;
const courseId = searchParams.get("courseId") || undefined;
const encoding = (searchParams.get("encoding") as "utf8" | "win1251") ?? "utf8";
// Fetch users
const users = await prisma.user.findMany({
where: courseId
? { enrollments: { some: { courseId } } }
: { role: "student" },
orderBy: { createdAt: "desc" },
include: {
enrollments: {
include: { course: { select: { title: true } } },
},
progress: { select: { lessonId: true } },
},
});
// Build CSV rows
const csvHeaders = ["Email", "Имя", "Телефон", "Дата регистрации", "Курсы", "Прогресс (уроков)"];
const rows = users.map((u) => {
const courses = u.enrollments.map((e) => e.course.title).join(" | ");
const progress = u.progress.length;
const registeredAt = new Date(u.createdAt).toLocaleDateString("ru-RU");
return [u.email, u.name, "", registeredAt, courses, String(progress)];
});
const allRows = [csvHeaders, ...rows];
const csvText = allRows
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(";"))
.join("\r\n");
// Encode
let body: Buffer;
let charset: string;
if (encoding === "win1251") {
body = iconv.encode(csvText, "win1251");
charset = "windows-1251";
} else {
body = Buffer.from("\uFEFF" + csvText, "utf8"); // BOM for Excel
charset = "utf-8";
}
const filename = `students_${new Date().toISOString().slice(0, 10)}.csv`;
return new NextResponse(body as unknown as BodyInit, {
headers: {
"Content-Type": `text/csv; charset=${charset}`,
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import matter from "gray-matter";
import { mdToTiptap } from "@/lib/md-to-tiptap";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!file.name.endsWith(".md")) {
return NextResponse.json({ error: "Only .md files are supported" }, { status: 400 });
}
const raw = await file.text();
const { data: fm, content } = matter(raw);
// Extract known frontmatter fields (Obsidian-compatible naming)
const title =
typeof fm.title === "string" ? fm.title.trim() : null;
const kinescopeId =
(fm.kinescopeId ?? fm.kinescope_id ?? fm.videoId ?? fm.video_id ?? "") as string;
const order =
typeof fm.order === "number" ? fm.order : null;
const published =
typeof fm.published === "boolean" ? fm.published : null;
const tiptapContent = mdToTiptap(content);
return NextResponse.json({ title, kinescopeId, order, published, content: tiptapContent });
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { uploadFile, deleteFile } from "@/lib/s3";
import { randomUUID } from "crypto";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") return null;
return session;
}
export async function POST(req: NextRequest) {
if (!await requireAdmin()) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const form = await req.formData();
const file = form.get("file") as File | null;
const lessonId = form.get("lessonId") as string | null;
const label = (form.get("label") as string | null)?.trim() || null;
if (!file || !lessonId) return NextResponse.json({ error: "Missing fields" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const name = label ?? file.name;
const existing = await prisma.lessonFile.findFirst({ where: { lessonId, name } });
const ext = file.name.split(".").pop() ?? "bin";
const key = `lessons/${lessonId}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type);
if (existing) {
const oldKey = existing.url.split(`/${process.env.S3_BUCKET}/`)[1];
if (oldKey) await deleteFile(oldKey).catch(() => {});
const lessonFile = await prisma.lessonFile.update({
where: { id: existing.id },
data: { url, size: file.size },
});
return NextResponse.json(lessonFile);
}
const lessonFile = await prisma.lessonFile.create({
data: { lessonId, name, url, size: file.size },
});
return NextResponse.json(lessonFile);
}
export async function PATCH(req: NextRequest) {
if (!await requireAdmin()) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { fileId, label } = await req.json();
if (!fileId || typeof label !== "string") {
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
}
const updated = await prisma.lessonFile.update({
where: { id: fileId },
data: { name: label.trim() || undefined },
});
return NextResponse.json(updated);
}
export async function DELETE(req: NextRequest) {
if (!await requireAdmin()) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { fileId, url } = await req.json();
if (url) {
const key = (url as string).split(`/${process.env.S3_BUCKET}/`)[1];
if (key) await deleteFile(key).catch(() => {});
}
await prisma.lessonFile.delete({ where: { id: fileId } });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ lessonId: string }> }
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { lessonId } = await params;
const body = await req.json() as {
title: string;
kinescopeId: string;
content: object;
published: boolean;
};
await prisma.lesson.update({
where: { id: lessonId },
data: {
title: body.title,
kinescopeId: body.kinescopeId || null,
content: body.content,
published: body.published,
},
});
return NextResponse.json({ ok: true });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.name.split(".").pop() ?? "bin";
const key = `uploads/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type);
return NextResponse.json({ url, key });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm";
const key = `feedback-audio/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type || "audio/webm");
return NextResponse.json({ url });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.name.split(".").pop() ?? "bin";
const key = `feedback/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type);
return NextResponse.json({ name: file.name, url, size: file.size });
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (session.user.role !== "admin" && session.user.role !== "curator") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const question = await prisma.studentQuestion.findUnique({ where: { id } });
if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (question.status === "CLOSED") {
return NextResponse.json({ error: "Already closed" }, { status: 400 });
}
const updated = await prisma.studentQuestion.update({
where: { id },
data: { status: "CLOSED", closedAt: new Date(), closedById: session.user.id },
});
return NextResponse.json(updated);
}
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email";
interface FileAttachment {
name: string;
url: string;
size: number;
}
function buildS3Prefix(): string {
const endpoint = process.env.S3_ENDPOINT ?? "";
const bucket = process.env.S3_BUCKET ?? "";
// e.g. https://fsn1.your-objectstorage.com/lms-uploads/
return `${endpoint}/${bucket}/`;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const isStaff = session.user.role === "admin" || session.user.role === "curator";
const question = await prisma.studentQuestion.findUnique({
where: { id },
include: { user: { select: { id: true, name: true, email: true } } },
});
if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!isStaff && question.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (question.status === "CLOSED") {
return NextResponse.json({ error: "Question is closed" }, { status: 409 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { text, files } = body as { text: string; files?: FileAttachment[] };
if (!text?.trim()) {
return NextResponse.json({ error: "text is required" }, { status: 400 });
}
const s3Prefix = buildS3Prefix();
const safeFiles = files
?.filter(
(f) =>
typeof f.name === "string" &&
typeof f.url === "string" &&
f.url.startsWith("https://") &&
f.url.startsWith(s3Prefix) &&
typeof f.size === "number"
)
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
const [msg] = await prisma.$transaction([
prisma.studentQuestionMessage.create({
data: {
questionId: id,
authorId: session.user.id,
text: text.trim(),
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
},
include: { author: { select: { id: true, name: true, role: true } } },
}),
prisma.studentQuestion.update({
where: { id },
data: { updatedAt: new Date() },
}),
]);
// Send notifications (fire-and-forget, outside transaction)
if (isStaff) {
void sendQuestionReplyEmail(
question.user.email,
question.user.name,
question.title,
id,
);
} else {
const staff = await prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
});
void Promise.all(
staff.map((s) =>
sendQuestionFollowUpEmail(s.email, s.name, session.user.name, question.title)
)
);
}
return NextResponse.json(msg, { status: 201 });
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const isStaff = session.user.role === "admin" || session.user.role === "curator";
const question = await prisma.studentQuestion.findUnique({
where: { id },
include: {
user: { select: { id: true, name: true } },
course: { select: { id: true, title: true } },
messages: {
include: { author: { select: { id: true, name: true, role: true } } },
orderBy: { createdAt: "asc" },
},
},
});
if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!isStaff && question.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Mark unread messages as read
const unreadWhere = isStaff
? { questionId: id, isRead: false, author: { role: "student" } }
: { questionId: id, isRead: false, NOT: { authorId: session.user.id } };
await prisma.studentQuestionMessage.updateMany({
where: unreadWhere,
data: { isRead: true },
});
return NextResponse.json(question);
}
+123
View File
@@ -0,0 +1,123 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sendQuestionCreatedEmail } from "@/lib/email";
interface FileAttachment {
name: string;
url: string;
size: number;
}
function buildS3Prefix(): string {
const endpoint = process.env.S3_ENDPOINT ?? "";
const bucket = process.env.S3_BUCKET ?? "";
return `${endpoint}/${bucket}/`;
}
export async function GET() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const isStaff = session.user.role === "admin" || session.user.role === "curator";
const userSelect = isStaff
? { id: true as const, name: true as const, email: true as const }
: { id: true as const, name: true as const };
const questions = await prisma.studentQuestion.findMany({
where: isStaff ? undefined : { userId: session.user.id },
include: {
user: { select: userSelect },
course: { select: { id: true, title: true } },
_count: {
select: {
messages: {
where: isStaff
? { isRead: false, author: { role: "student" } }
: { isRead: false, NOT: { authorId: session.user.id } },
},
},
},
},
orderBy: { updatedAt: "desc" },
});
return NextResponse.json(
questions.map((q) => ({
id: q.id,
title: q.title,
status: q.status,
createdAt: q.createdAt,
updatedAt: q.updatedAt,
user: q.user,
course: q.course,
unreadCount: q._count.messages,
}))
);
}
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (session.user.role !== "student") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { title, text, courseId, files } = body as {
title: string;
text: string;
courseId?: string;
files?: FileAttachment[];
};
if (!title?.trim() || !text?.trim()) {
return NextResponse.json({ error: "title and text are required" }, { status: 400 });
}
const s3Prefix = buildS3Prefix();
const safeFiles = files
?.filter(
(f) =>
typeof f.name === "string" &&
typeof f.url === "string" &&
f.url.startsWith("https://") &&
f.url.startsWith(s3Prefix) &&
typeof f.size === "number"
)
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
const question = await prisma.studentQuestion.create({
data: {
userId: session.user.id,
courseId: courseId ?? null,
title: title.trim(),
messages: {
create: {
authorId: session.user.id,
text: text.trim(),
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
},
},
},
});
const staff = await prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
});
void Promise.all(
staff.map((s) =>
sendQuestionCreatedEmail(s.email, s.name, session.user.name, title.trim())
)
);
return NextResponse.json(question, { status: 201 });
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm";
const key = `homework-audio/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type || "audio/webm");
return NextResponse.json({ url });
}
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.name.split(".").pop() ?? "bin";
const key = `homework/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type);
return NextResponse.json({ name: file.name, url, size: file.size });
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
const ALLOWED_TYPES = new Set([
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf", "text/markdown", "text/x-markdown", "text/plain",
]);
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "Файл слишком большой (макс. 10 МБ)" }, { status: 413 });
}
if (!ALLOWED_TYPES.has(file.type)) {
return NextResponse.json(
{ error: "Разрешены только jpg, png, pdf, md" },
{ status: 415 }
);
}
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
const ALLOWED_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "pdf", "md", "txt"]);
if (!ALLOWED_EXTS.has(ext)) {
return NextResponse.json({ error: "Недопустимое расширение файла" }, { status: 415 });
}
const key = `questions/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type);
return NextResponse.json({ name: file.name, url, size: file.size });
}
+44 -30
View File
@@ -1,43 +1,57 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/layout/logout-button";
import { prisma } from "@/lib/prisma";
import Link from "next/link";
export default async function CuratorDashboard() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "curator" && session.user.role !== "admin") {
redirect("/dashboard");
}
const [pending, total, recentFeedbacks] = await Promise.all([
prisma.homeworkSubmission.count({ where: { feedbacks: { none: {} } } }),
prisma.homeworkSubmission.count(),
prisma.homeworkFeedback.count({
where: {
createdAt: { gte: new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) },
curatorId: session.user.id,
},
}),
]);
return (
<div className="min-h-screen bg-green-50">
<header className="bg-white border-b border-green-100 px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-green-900">Second Brain Куратор</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{session.user.name}</span>
<LogoutButton />
<div className="p-8 max-w-3xl">
<h1 className="text-2xl font-bold mb-1">Обзор</h1>
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>Панель куратора</p>
<div className="grid grid-cols-3 gap-4 mb-8">
<StatCard label="Ожидают проверки" value={pending} accent={pending > 0} />
<StatCard label="Всего сдано" value={total} />
<StatCard label="Проверено за 7 дней" value={recentFeedbacks} />
</div>
{pending > 0 ? (
<Link href="/curator/homework" className="btn-aubade btn-aubade-accent inline-flex items-center gap-2 px-5 py-2.5 text-sm">
Перейти к проверке ({pending})
</Link>
) : (
<div className="card-aubade p-8 text-center">
<p className="text-3xl mb-2"></p>
<p className="font-bold">Все работы проверены</p>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>Новых заданий нет</p>
</div>
</header>
<main className="max-w-4xl mx-auto px-6 py-10">
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
Панель куратора
</h2>
<p className="text-gray-500 mb-8">Здесь будут домашние задания на проверку.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white rounded-2xl border border-green-100 p-6">
<p className="text-3xl mb-2">📝</p>
<p className="font-medium text-gray-800">Домашние задания</p>
<p className="text-sm text-gray-400 mt-1">Новых заданий нет</p>
</div>
<div className="bg-white rounded-2xl border border-green-100 p-6">
<p className="text-3xl mb-2">👥</p>
<p className="font-medium text-gray-800">Мои ученики</p>
<p className="text-sm text-gray-400 mt-1">Откроется в Этапе 3</p>
</div>
</div>
</main>
)}
</div>
);
}
function StatCard({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
return (
<div className="card-aubade p-4">
<p className="text-3xl font-bold" style={{ color: accent ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}>
{value}
</p>
<p className="text-xs mt-1 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>{label}</p>
</div>
);
}
@@ -0,0 +1,115 @@
"use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { sendFeedbackReceivedEmail } from "@/lib/email";
import { getSetting, asBool } from "@/lib/settings";
async function requireCurator() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
throw new Error("Forbidden");
}
return session;
}
export async function submitFeedback(
submissionId: string,
data: {
text: string;
files?: { name: string; url: string; size: number }[];
audioUrl?: string | null;
action: "approve" | "reject";
}
) {
const session = await requireCurator();
const status = data.action === "approve" ? "APPROVED" : "REJECTED";
const submission = await prisma.homeworkSubmission.findUnique({
where: { id: submissionId },
include: {
user: { select: { id: true, email: true, name: true } },
homework: {
include: {
lesson: {
select: {
title: true,
id: true,
module: { select: { course: { select: { slug: true } } } },
},
},
},
},
},
});
if (!submission) throw new Error("Submission not found");
await prisma.$transaction(async (tx) => {
await tx.homeworkFeedback.create({
data: {
submissionId,
curatorId: session.user.id,
text: data.text,
files: data.files ?? [],
audioUrl: data.audioUrl ?? null,
},
});
await tx.homeworkSubmission.update({
where: { id: submissionId },
data: { status, statusAt: new Date() },
});
if (status === "APPROVED") {
await tx.lessonProgress.upsert({
where: {
userId_lessonId: {
userId: submission.user.id,
lessonId: submission.homework.lesson.id,
},
},
create: {
userId: submission.user.id,
lessonId: submission.homework.lesson.id,
},
update: {},
});
}
});
const { lesson } = submission.homework;
const notifySetting = await getSetting("notifyStudentOnFeedback");
if (asBool(notifySetting)) {
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${lesson.id}`;
await sendFeedbackReceivedEmail(
submission.user.email,
submission.user.name,
lesson.title,
data.text,
lessonUrl
);
}
revalidatePath("/curator/homework");
revalidatePath(`/curator/homework/${submissionId}`);
revalidatePath(`/courses/${lesson.module.course.slug}/lessons/${lesson.id}`);
revalidatePath(`/courses/${lesson.module.course.slug}`);
revalidatePath("/dashboard");
}
export async function setReviewing(submissionId: string) {
await requireCurator();
await prisma.homeworkSubmission.update({
where: { id: submissionId },
data: { status: "REVIEWING", statusAt: new Date() },
});
revalidatePath("/curator/homework");
revalidatePath(`/curator/homework/${submissionId}`);
}
export async function deleteSubmission(submissionId: string) {
await requireCurator();
await prisma.homeworkSubmission.delete({ where: { id: submissionId } });
revalidatePath("/curator/homework");
}
@@ -0,0 +1,53 @@
"use client";
import { useState } from "react";
import { ContentViewer } from "@/components/curator/content-viewer";
interface Props {
homeworkDescription: string;
lessonContent: unknown;
}
export function ContentTabs({ homeworkDescription, lessonContent }: Props) {
const [tab, setTab] = useState<"homework" | "lesson">("homework");
return (
<div>
{/* Tab bar */}
<div className="flex items-center gap-0" style={{ borderBottom: "2px solid var(--border)" }}>
{(["homework", "lesson"] as const).map((t) => {
const label = t === "homework" ? "Содержимое ДЗ" : "Содержимое урока";
const active = tab === t;
return (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className="px-4 py-2 text-xs font-medium"
style={{
borderBottom: active ? "2px solid var(--foreground)" : "2px solid transparent",
marginBottom: -2,
color: active ? "var(--foreground)" : "var(--muted-foreground)",
background: "transparent",
}}
>
{label}
</button>
);
})}
</div>
{/* Content */}
<div
className="px-4 py-4 text-sm"
style={{ border: "2px solid var(--border)", borderTop: "none" }}
>
{tab === "homework" ? (
<div className="whitespace-pre-wrap">{homeworkDescription}</div>
) : (
<ContentViewer content={lessonContent} />
)}
</div>
</div>
);
}
@@ -0,0 +1,40 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteSubmission } from "./actions";
export function DeleteSubmissionButton({
submissionId,
userName,
}: {
submissionId: string;
userName: string;
}) {
const [pending, startTransition] = useTransition();
const router = useRouter();
function handleDelete() {
if (!confirm(`Удалить работу студента ${userName}? Это действие нельзя отменить.`)) return;
startTransition(async () => {
await deleteSubmission(submissionId);
router.push("/curator/homework");
});
}
return (
<button
type="button"
onClick={handleDelete}
disabled={pending}
className="text-xs px-3 py-1.5"
style={{
border: "1px solid var(--border)",
color: "oklch(0.577 0.245 27.325)",
opacity: pending ? 0.5 : 1,
}}
>
🗑 Удалить работу
</button>
);
}
@@ -0,0 +1,209 @@
"use client";
import { useState, useTransition, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { submitFeedback, setReviewing } from "./actions";
import { AudioRecorder } from "@/components/curator/audio-recorder";
interface FileItem {
name: string;
url: string;
size: number;
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
export function FeedbackForm({
submissionId,
currentStatus,
}: {
submissionId: string;
currentStatus: string;
}) {
const [text, setText] = useState("");
const [files, setFiles] = useState<FileItem[]>([]);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [pending, startTransition] = useTransition();
const fileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const picked = Array.from(e.target.files ?? []);
if (!picked.length) return;
setUploading(true);
const uploaded: FileItem[] = [];
for (const f of picked) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/curator/upload", { method: "POST", body: form });
const data = await res.json();
if (data.url) uploaded.push({ name: data.name, url: data.url, size: data.size });
}
setFiles((prev) => [...prev, ...uploaded]);
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
function handleAction(action: "approve" | "reject") {
if (!text.trim()) return;
startTransition(async () => {
await submitFeedback(submissionId, {
text: text.trim(),
files,
audioUrl,
action,
});
router.push("/curator/homework");
});
}
function handleReviewing() {
startTransition(async () => {
await setReviewing(submissionId);
});
}
const isWorking = pending || uploading;
return (
<div className="space-y-4">
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Ваш ответ
</p>
{/* Text */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Напишите обратную связь студенту..."
disabled={isWorking}
style={{
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "16px",
fontFamily: "inherit",
resize: "vertical",
minHeight: "120px",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
{/* File upload */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<label
className="btn-aubade text-xs px-3 py-1.5 cursor-pointer"
style={{ opacity: isWorking ? 0.5 : 1 }}
>
📎 Прикрепить файл
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
disabled={isWorking}
/>
</label>
{uploading && (
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Загрузка...
</span>
)}
</div>
{files.length > 0 && (
<div className="space-y-1">
{files.map((f, i) => (
<div
key={f.url}
className="flex items-center gap-2 px-3 py-1.5 text-xs"
style={{ border: "1px solid var(--border)" }}
>
<span>📎</span>
<span className="flex-1 truncate">{f.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
<button
type="button"
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
×
</button>
</div>
))}
</div>
)}
</div>
{/* Audio recorder */}
<AudioRecorder value={audioUrl} onChange={setAudioUrl} />
{/* Action buttons */}
<div className="flex items-center gap-2 flex-wrap pt-1">
<button
type="button"
onClick={() => handleAction("approve")}
disabled={isWorking || !text.trim()}
className="btn-aubade-accent px-4 py-2 text-sm"
style={{ opacity: isWorking || !text.trim() ? 0.5 : 1 }}
>
{pending ? "Отправка..." : "Отправить ответ"}
</button>
{currentStatus !== "REVIEWING" && (
<button
type="button"
onClick={handleReviewing}
disabled={isWorking}
className="px-4 py-2 text-sm"
style={{
border: "2px solid var(--border)",
background: "oklch(0.9 0.08 80)",
color: "oklch(0.4 0.1 80)",
opacity: isWorking ? 0.5 : 1,
}}
>
На рассмотрение
</button>
)}
<button
type="button"
onClick={() => handleAction("reject")}
disabled={isWorking || !text.trim()}
className="px-4 py-2 text-sm"
style={{
border: "2px solid var(--border)",
background: "oklch(0.9 0.06 27)",
color: "oklch(0.45 0.2 27)",
opacity: isWorking || !text.trim() ? 0.5 : 1,
}}
>
Отклонить и отправить
</button>
<Link
href="/curator/homework"
className="px-4 py-2 text-sm"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
К списку ДЗ
</Link>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More