From 93e74951a7ca73d4342a30b35f87fe4a3e786f81 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Thu, 7 May 2026 09:24:25 +0500 Subject: [PATCH] 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 --- .gitignore | 3 + AGENTS.md | 311 +++++++++++++++++- ROADMAP.md | 24 ++ .../migration.sql | 11 + prisma/schema.prisma | 33 +- src/app/admin/users/[userId]/actions.ts | 19 ++ src/app/admin/users/[userId]/page.tsx | 20 ++ src/components/admin/user-balance-block.tsx | 185 +++++++++++ 8 files changed, 593 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20260507120000_add_balance_transactions/migration.sql create mode 100644 src/components/admin/user-balance-block.tsx diff --git a/.gitignore b/.gitignore index 1cf2cab..5affc80 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ yarn-error.log* next-env.d.ts /src/generated/prisma + +# Claude Code local plugins (external git repos, не коммитим) +.claude/plugins/ diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..4f62968 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,308 @@ - -# 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. - +Собственная 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 ` + +### 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 # Новая миграция +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 diff --git a/ROADMAP.md b/ROADMAP.md index 5316ab4..58b5358 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -275,6 +275,30 @@ ## Бэклог (после 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) - Сертификаты по окончании курса diff --git a/prisma/migrations/20260507120000_add_balance_transactions/migration.sql b/prisma/migrations/20260507120000_add_balance_transactions/migration.sql new file mode 100644 index 0000000..6b7660d --- /dev/null +++ b/prisma/migrations/20260507120000_add_balance_transactions/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 55f35cd..0e4996d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,15 +27,16 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - sessions Session[] - accounts Account[] - enrollments CourseEnrollment[] - progress LessonProgress[] - submissions HomeworkSubmission[] - comments LessonComment[] - feedbacks HomeworkFeedback[] - accessLogs AccessLog[] @relation("AccessLogUser") - adminAccessLogs AccessLog[] @relation("AccessLogAdmin") + sessions Session[] + accounts Account[] + enrollments CourseEnrollment[] + progress LessonProgress[] + submissions HomeworkSubmission[] + comments LessonComment[] + feedbacks HomeworkFeedback[] + accessLogs AccessLog[] @relation("AccessLogUser") + adminAccessLogs AccessLog[] @relation("AccessLogAdmin") + balanceTransactions BalanceTransaction[] } model Session { @@ -311,6 +312,20 @@ model LessonComment { replies LessonComment[] @relation("CommentReplies") } +// ───────────────────────────────────────────── +// 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) // ───────────────────────────────────────────── diff --git a/src/app/admin/users/[userId]/actions.ts b/src/app/admin/users/[userId]/actions.ts index 994a182..b462179 100644 --- a/src/app/admin/users/[userId]/actions.ts +++ b/src/app/admin/users/[userId]/actions.ts @@ -59,6 +59,25 @@ export async function updateUserContact( 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 revokeUserAccess(userId: string, courseId: string) { const session = await requireAdmin(); await prisma.courseEnrollment.delete({ diff --git a/src/app/admin/users/[userId]/page.tsx b/src/app/admin/users/[userId]/page.tsx index 3005d9a..f4ed166 100644 --- a/src/app/admin/users/[userId]/page.tsx +++ b/src/app/admin/users/[userId]/page.tsx @@ -3,6 +3,7 @@ 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"; interface Props { params: Promise<{ userId: string }>; @@ -27,6 +28,9 @@ export default async function UserPage({ params }: Props) { grantedBy: { select: { name: true } }, }, }, + balanceTransactions: { + orderBy: { createdAt: "desc" }, + }, }, }), prisma.course.findMany({ @@ -73,6 +77,22 @@ export default async function UserPage({ params }: Props) { + {/* Balance */} +
+

+ Баланс +

+ ({ + id: tx.id, + amount: Number(tx.amount), + description: tx.description, + createdAt: tx.createdAt, + }))} + /> +
+ {/* Enrollments + bulk grant */}

diff --git a/src/components/admin/user-balance-block.tsx b/src/components/admin/user-balance-block.tsx new file mode 100644 index 0000000..b70b615 --- /dev/null +++ b/src/components/admin/user-balance-block.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { addBalanceTransaction, deleteBalanceTransaction } from "@/app/admin/users/[userId]/actions"; + +interface Transaction { + id: string; + amount: number; + description: string; + createdAt: Date; +} + +interface Props { + userId: string; + transactions: Transaction[]; +} + +const inputStyle = { + border: "2px solid var(--border)", + background: "var(--background)", + outline: "none", + padding: "0.4rem 0.6rem", + fontSize: "0.875rem", + fontFamily: "inherit", +} as React.CSSProperties; + +const focusHandlers = { + onFocus: (e: React.FocusEvent) => + (e.currentTarget.style.borderColor = "var(--foreground)"), + onBlur: (e: React.FocusEvent) => + (e.currentTarget.style.borderColor = "var(--border)"), +}; + +function formatAmount(amount: number): string { + const sign = amount > 0 ? "+" : ""; + return `${sign}${amount.toLocaleString("ru-RU", { minimumFractionDigits: 0, maximumFractionDigits: 2 })} ₽`; +} + +export function UserBalanceBlock({ userId, transactions }: Props) { + const [showForm, setShowForm] = useState(false); + const [amountVal, setAmountVal] = useState(""); + const [descVal, setDescVal] = useState(""); + const [error, setError] = useState(""); + const [pending, startTransition] = useTransition(); + const [deletingId, setDeletingId] = useState(null); + + const balance = transactions.reduce((sum, t) => sum + t.amount, 0); + + function handleAdd() { + setError(""); + const num = parseFloat(amountVal.replace(",", ".")); + if (isNaN(num) || num === 0) { setError("Введите ненулевую сумму"); return; } + if (!descVal.trim()) { setError("Добавьте описание"); return; } + startTransition(async () => { + await addBalanceTransaction(userId, { amount: amountVal, description: descVal }); + setAmountVal(""); + setDescVal(""); + setShowForm(false); + }); + } + + function handleDelete(txId: string) { + setDeletingId(txId); + startTransition(async () => { + await deleteBalanceTransaction(userId, txId); + setDeletingId(null); + }); + } + + return ( +

+ {/* Balance summary */} +
+
+ 0 ? "#3A6A3A" : balance < 0 ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }} + > + {formatAmount(balance)} + + + на балансе + +
+ +
+ + {/* Add form */} + {showForm && ( +
+
+
+ + setAmountVal(e.target.value)} + placeholder="-3490 или 1000" + style={{ ...inputStyle, width: "100%" }} + {...focusHandlers} + /> +
+
+ + setDescVal(e.target.value)} + placeholder="Предоплата курса, возврат, партнёрка..." + style={{ ...inputStyle, width: "100%" }} + {...focusHandlers} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + /> +
+
+ {error &&

{error}

} + +
+ )} + + {/* Transaction list */} + {transactions.length > 0 ? ( +
+ {transactions.map((tx) => ( +
+ 0 ? "#3A6A3A" : "oklch(0.577 0.245 27.325)", + }} + > + {formatAmount(tx.amount)} + + {tx.description} + + {new Date(tx.createdAt).toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} + + +
+ ))} +
+ ) : ( +

Операций ещё нет

+ )} +
+ ); +}