Initial commit: Edu Helper (Docker, React, Express, Prisma)

Made-with: Cursor
This commit is contained in:
Константин Лебединский
2026-04-01 21:40:51 +05:00
commit 3dec3ea720
75 changed files with 13806 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma
+15
View File
@@ -0,0 +1,15 @@
# Локальная разработка с PostgreSQL (например docker compose up db -d)
DATABASE_URL="postgresql://edu:edu@localhost:5432/edu_helper"
JWT_SECRET="dev-secret-min-32-characters-long-string!"
# Первый запуск при пустой БД (npm run db:seed)
SEED_TUTOR_USERNAME=alexey
SEED_STUDENT_USERNAME=konstantin
SEED_TUTOR_PASSWORD=your-tutor-password
SEED_STUDENT_PASSWORD=your-student-password
# Опционально
# DEEPSEEK_API_KEY=
# PORT=3001
# COOKIE_SECURE=true
+1991
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:seed": "tsx src/seed.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"prisma": {
"seed": "tsx src/seed.ts"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"@types/multer": "^2.1.0",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"openai": "^6.33.0",
"prisma": "^5.22.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^25.5.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
}
}
@@ -0,0 +1,128 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('TUTOR', 'STUDENT');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "Role" NOT NULL,
"displayName" TEXT,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TutorAssignment" (
"tutorId" INTEGER NOT NULL,
"studentId" INTEGER NOT NULL,
CONSTRAINT "TutorAssignment_pkey" PRIMARY KEY ("tutorId","studentId")
);
-- CreateTable
CREATE TABLE "Setting" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "Setting_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "ChatMessage" (
"id" SERIAL NOT NULL,
"studentId" INTEGER NOT NULL,
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Question" (
"id" SERIAL NOT NULL,
"studentId" INTEGER NOT NULL,
"text" TEXT NOT NULL,
"answer" TEXT,
"date" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Question_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Textbook" (
"id" SERIAL NOT NULL,
"studentId" INTEGER NOT NULL,
"topic" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Textbook_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Test" (
"id" SERIAL NOT NULL,
"studentId" INTEGER NOT NULL,
"topic" TEXT NOT NULL,
"questions" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Test_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TestResult" (
"id" SERIAL NOT NULL,
"testId" INTEGER NOT NULL,
"answers" TEXT NOT NULL,
"score" INTEGER NOT NULL,
"total" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TestResult_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Report" (
"id" SERIAL NOT NULL,
"studentId" INTEGER NOT NULL,
"date" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "TutorAssignment_studentId_key" ON "TutorAssignment"("studentId");
-- AddForeignKey
ALTER TABLE "TutorAssignment" ADD CONSTRAINT "TutorAssignment_tutorId_fkey" FOREIGN KEY ("tutorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TutorAssignment" ADD CONSTRAINT "TutorAssignment_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Question" ADD CONSTRAINT "Question_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Textbook" ADD CONSTRAINT "Textbook_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Test" ADD CONSTRAINT "Test_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TestResult" ADD CONSTRAINT "TestResult_testId_fkey" FOREIGN KEY ("testId") REFERENCES "Test"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "HallPhoto" (
"id" SERIAL NOT NULL,
"studentId" INTEGER NOT NULL,
"date" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "HallPhoto_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "HallPhoto" ADD CONSTRAINT "HallPhoto_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "HallPhoto" ADD COLUMN "originalName" TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+114
View File
@@ -0,0 +1,114 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
TUTOR
STUDENT
}
model User {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
role Role
displayName String?
tutoringAssignments TutorAssignment[] @relation("TutorInAssignment")
studentAssignment TutorAssignment? @relation("StudentInAssignment")
questions Question[]
chatMessages ChatMessage[]
textbooks Textbook[]
tests Test[]
reports Report[]
hallPhotos HallPhoto[]
}
model TutorAssignment {
tutorId Int
studentId Int @unique
tutor User @relation("TutorInAssignment", fields: [tutorId], references: [id], onDelete: Cascade)
student User @relation("StudentInAssignment", fields: [studentId], references: [id], onDelete: Cascade)
@@id([tutorId, studentId])
}
model Setting {
key String @id
value String
}
model ChatMessage {
id Int @id @default(autoincrement())
studentId Int
student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
role String
content String
createdAt DateTime @default(now())
}
model Question {
id Int @id @default(autoincrement())
studentId Int
student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
text String
answer String?
date String
createdAt DateTime @default(now())
}
model Textbook {
id Int @id @default(autoincrement())
studentId Int
student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
topic String
content String
createdAt DateTime @default(now())
}
model Test {
id Int @id @default(autoincrement())
studentId Int
student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
topic String
questions String
createdAt DateTime @default(now())
results TestResult[]
}
model TestResult {
id Int @id @default(autoincrement())
testId Int
answers String
score Int
total Int
createdAt DateTime @default(now())
test Test @relation(fields: [testId], references: [id], onDelete: Cascade)
}
model Report {
id Int @id @default(autoincrement())
studentId Int
student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
date String
content String
createdAt DateTime @default(now())
}
model HallPhoto {
id Int @id @default(autoincrement())
studentId Int
student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
date String
fileName String
originalName String @default("")
mimeType String
createdAt DateTime @default(now())
}
+67
View File
@@ -0,0 +1,67 @@
/// <reference path="./types/express.d.ts" />
import fs from "fs";
import path from "path";
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import dotenv from "dotenv";
import { errorHandler } from "./middleware/errorHandler";
import { requireAuth } from "./middleware/auth";
import { attachStudentId } from "./middleware/studentContext";
import authRouter from "./routes/auth";
import settingsRouter from "./routes/settings";
import chatRouter from "./routes/chat";
import questionsRouter from "./routes/questions";
import textbooksRouter from "./routes/textbooks";
import testsRouter from "./routes/tests";
import reportsRouter from "./routes/reports";
dotenv.config();
const app = express();
const PORT = Number(process.env.PORT) || 3001;
const isProd = process.env.NODE_ENV === "production";
app.use(
cors({
origin: true,
credentials: true,
})
);
app.use(cookieParser());
app.use(express.json({ limit: "10mb" }));
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
app.use("/api/auth", authRouter);
app.use("/api/settings", requireAuth, settingsRouter);
app.use("/api/chat", requireAuth, attachStudentId, chatRouter);
app.use("/api/questions", requireAuth, attachStudentId, questionsRouter);
app.use("/api/textbooks", requireAuth, attachStudentId, textbooksRouter);
app.use("/api/tests", requireAuth, attachStudentId, testsRouter);
app.use("/api/reports", requireAuth, attachStudentId, reportsRouter);
const publicDir = path.join(__dirname, "../public");
if (isProd && fs.existsSync(publicDir)) {
app.use(express.static(publicDir));
app.use((req, res, next) => {
if (req.method !== "GET" && req.method !== "HEAD") {
next();
return;
}
if (req.path.startsWith("/api")) {
res.status(404).json({ error: "Not found" });
return;
}
res.sendFile(path.join(publicDir, "index.html"));
});
}
app.use(errorHandler);
app.listen(PORT, "0.0.0.0", () => {
console.log(`Server listening on http://0.0.0.0:${PORT}`);
});
+22
View File
@@ -0,0 +1,22 @@
import jwt from "jsonwebtoken";
const SECRET = process.env.JWT_SECRET || "dev-only-change-me-use-JWT_SECRET-in-production-32chars";
export interface JwtPayload {
sub: number;
role: "TUTOR" | "STUDENT";
username: string;
}
export function signToken(p: JwtPayload): string {
return jwt.sign(
{ sub: p.sub, role: p.role, username: p.username },
SECRET,
{ expiresIn: "7d" }
);
}
export function verifyToken(token: string): JwtPayload {
const decoded = jwt.verify(token, SECRET);
return decoded as unknown as JwtPayload;
}
+31
View File
@@ -0,0 +1,31 @@
import OpenAI from "openai";
import prisma from "./prisma";
let clientInstance: OpenAI | null = null;
let cachedKey: string | null = null;
export async function getDeepSeekClient(): Promise<OpenAI> {
const setting = await prisma.setting.findUnique({ where: { key: "deepseek_api_key" } });
const apiKey = setting?.value;
if (!apiKey) {
throw new Error("DeepSeek API key not configured. Go to Settings to add it.");
}
if (clientInstance && cachedKey === apiKey) {
return clientInstance;
}
clientInstance = new OpenAI({
baseURL: "https://api.deepseek.com",
apiKey,
});
cachedKey = apiKey;
return clientInstance;
}
export async function getPrompt(key: string, fallback: string): Promise<string> {
const setting = await prisma.setting.findUnique({ where: { key } });
return setting?.value || fallback;
}
+5
View File
@@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export default prisma;
+12
View File
@@ -0,0 +1,12 @@
import prisma from "./prisma";
import type { AuthRequest } from "../middleware/auth";
export async function getStudentIdForRequest(req: AuthRequest): Promise<number> {
if (!req.user) throw new Error("No user");
if (req.user.role === "STUDENT") return req.user.id;
const a = await prisma.tutorAssignment.findFirst({
where: { tutorId: req.user.id },
});
if (!a) throw new Error("Tutor has no assigned student");
return a.studentId;
}
+25
View File
@@ -0,0 +1,25 @@
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/authTokens";
export interface AuthUser {
id: number;
role: "TUTOR" | "STUDENT";
username: string;
}
export type AuthRequest = Request & { user?: AuthUser };
export function requireAuth(req: AuthRequest, res: Response, next: NextFunction): void {
const token = req.cookies?.token;
if (!token) {
res.status(401).json({ error: "Unauthorized" });
return;
}
try {
const p = verifyToken(token);
req.user = { id: p.sub, role: p.role, username: p.username };
next();
} catch {
res.status(401).json({ error: "Unauthorized" });
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Request, Response, NextFunction } from "express";
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction) {
console.error("[Error]", err.message);
res.status(500).json({ error: err.message });
}
+16
View File
@@ -0,0 +1,16 @@
import { Response, NextFunction } from "express";
import type { AuthRequest } from "./auth";
import { getStudentIdForRequest } from "../lib/studentContext";
export async function attachStudentId(
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> {
try {
req.studentId = await getStudentIdForRequest(req);
next();
} catch {
res.status(403).json({ error: "Не назначен ученик для наставника" });
}
}
+69
View File
@@ -0,0 +1,69 @@
import { Router, Request, Response } from "express";
import bcrypt from "bcryptjs";
import type { CookieOptions } from "express";
import prisma from "../lib/prisma";
import { signToken } from "../lib/authTokens";
import { requireAuth, type AuthRequest } from "../middleware/auth";
const router = Router();
// В Docker/проде часто открывают по HTTP — при secure:true браузер не сохраняет cookie.
// Включайте COOKIE_SECURE=true только за HTTPS (reverse proxy + TLS).
const cookieSecure = process.env.COOKIE_SECURE === "true";
const cookieOpts: CookieOptions = {
httpOnly: true,
secure: cookieSecure,
sameSite: "lax",
maxAge: 7 * 24 * 3600 * 1000,
path: "/",
};
router.post("/login", async (req: Request, res: Response) => {
const { username, password } = req.body as { username?: string; password?: string };
if (!username || !password) {
res.status(400).json({ error: "Нужны имя пользователя и пароль" });
return;
}
const user = await prisma.user.findUnique({ where: { username: username.trim().toLowerCase() } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
res.status(401).json({ error: "Неверный логин или пароль" });
return;
}
const token = signToken({
sub: user.id,
role: user.role,
username: user.username,
});
res.cookie("token", token, cookieOpts);
res.json({
user: {
id: user.id,
username: user.username,
role: user.role,
displayName: user.displayName,
},
});
});
router.post("/logout", (_req, res: Response) => {
res.clearCookie("token", { ...cookieOpts, maxAge: undefined });
res.json({ success: true });
});
router.get("/me", requireAuth, async (req: AuthRequest, res: Response) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { id: true, username: true, role: true, displayName: true },
});
if (!user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
res.json({ user });
});
export default router;
+83
View File
@@ -0,0 +1,83 @@
import { Router, Response } from "express";
import prisma from "../lib/prisma";
import { getDeepSeekClient } from "../lib/deepseek";
import type { AuthRequest } from "../middleware/auth";
const router = Router();
router.get("/history", async (req: AuthRequest, res: Response) => {
const studentId = req.studentId!;
const messages = await prisma.chatMessage.findMany({
where: { studentId },
orderBy: { createdAt: "asc" },
take: 100,
});
res.json(messages);
});
router.post("/", async (req: AuthRequest, res: Response) => {
const { message } = req.body;
const studentId = req.studentId!;
if (!message || typeof message !== "string") {
res.status(400).json({ error: "Message is required" });
return;
}
await prisma.chatMessage.create({
data: { role: "user", content: message, studentId },
});
const history = await prisma.chatMessage.findMany({
where: { studentId },
orderBy: { createdAt: "asc" },
take: 50,
});
const client = await getDeepSeekClient();
const messages = history.map((m: { role: string; content: string }) => ({
role: m.role as "user" | "assistant",
content: m.content,
}));
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
try {
const stream = await client.chat.completions.create({
model: "deepseek-chat",
messages,
stream: true,
});
let fullResponse = "";
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
fullResponse += content;
res.write(`data: ${JSON.stringify({ content })}\n\n`);
}
}
await prisma.chatMessage.create({
data: { role: "assistant", content: fullResponse, studentId },
});
res.write(`data: [DONE]\n\n`);
res.end();
} catch (err: any) {
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
res.end();
}
});
router.delete("/history", async (req: AuthRequest, res: Response) => {
const studentId = req.studentId!;
await prisma.chatMessage.deleteMany({ where: { studentId } });
res.json({ success: true });
});
export default router;
+132
View File
@@ -0,0 +1,132 @@
import { Router, Response } from "express";
import prisma from "../lib/prisma";
import { getDeepSeekClient, getPrompt } from "../lib/deepseek";
import type { AuthRequest } from "../middleware/auth";
const router = Router();
const DEFAULT_ANSWER_PROMPT = `Ты — терпеливый и дружелюбный репетитор. Твоя задача — ответить на вопрос ученика.
Правила:
- Объясняй максимально простым языком, как будто объясняешь человеку, который вообще не разбирается в теме
- Используй аналогии из повседневной жизни
- Приводи конкретные примеры
- Если вопрос сложный — разбей ответ на шаги
- Форматируй ответ с использованием Markdown (заголовки, списки, выделение)
- Не используй сложные термины без объяснения`;
router.get("/", async (req: AuthRequest, res: Response) => {
const date = req.query.date as string | undefined;
const search = req.query.search as string | undefined;
const studentId = req.studentId!;
const where: { studentId: number; date?: string; text?: { contains: string } } = { studentId };
if (date) where.date = date;
if (search) where.text = { contains: search };
const questions = await prisma.question.findMany({
where,
orderBy: { createdAt: "desc" },
});
res.json(questions);
});
router.get("/dates", async (req: AuthRequest, res: Response) => {
const studentId = req.studentId!;
const questions = await prisma.question.findMany({
where: { studentId },
select: { date: true },
distinct: ["date"],
orderBy: { createdAt: "desc" },
});
res.json(questions.map((q: { date: string }) => q.date));
});
router.post("/", async (req: AuthRequest, res: Response) => {
const { questions } = req.body as { questions: string[] };
const studentId = req.studentId!;
if (!Array.isArray(questions) || questions.length === 0) {
res.status(400).json({ error: "At least 1 question is required" });
return;
}
const date = new Date().toISOString().split("T")[0];
const created = await Promise.all(
questions.map((text) =>
prisma.question.create({ data: { text, date, studentId } })
)
);
res.json(created);
});
router.delete("/:id", async (req: AuthRequest, res: Response) => {
if (req.user!.role !== "STUDENT") {
res.status(403).json({ error: "Удалять вопросы может только ученик — только свои" });
return;
}
const id = parseInt(req.params.id as string, 10);
const studentId = req.user!.id;
const existing = await prisma.question.findFirst({
where: { id, studentId },
});
if (!existing) {
res.status(404).json({ error: "Вопрос не найден" });
return;
}
await prisma.question.delete({ where: { id } });
res.json({ success: true });
});
router.post("/:id/answer", async (req: AuthRequest, res: Response) => {
const id = parseInt(req.params.id as string, 10);
const studentId = req.studentId!;
const question = await prisma.question.findFirst({ where: { id, studentId } });
if (!question) {
res.status(404).json({ error: "Question not found" });
return;
}
const client = await getDeepSeekClient();
const systemPrompt = await getPrompt("prompt_answer", DEFAULT_ANSWER_PROMPT);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
try {
const stream = await client.chat.completions.create({
model: "deepseek-chat",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: question.text },
],
stream: true,
});
let fullAnswer = "";
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
fullAnswer += content;
res.write(`data: ${JSON.stringify({ content })}\n\n`);
}
}
await prisma.question.update({ where: { id }, data: { answer: fullAnswer } });
res.write(`data: [DONE]\n\n`);
res.end();
} catch (err: any) {
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
res.end();
}
});
export default router;
+192
View File
@@ -0,0 +1,192 @@
import fs from "fs";
import path from "path";
import { Router, Response } from "express";
import multer from "multer";
import prisma from "../lib/prisma";
import type { AuthRequest } from "../middleware/auth";
const router = Router();
const uploadsDir = process.env.UPLOADS_DIR || path.join(process.cwd(), "uploads");
fs.mkdirSync(uploadsDir, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname || "").toLowerCase();
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype.startsWith("image/")) cb(null, true);
else cb(new Error("Можно загружать только изображения"));
},
});
/** UTF-8 имя, ошибочно прочитанное как Latin-1 (типично для multipart). */
function normalizeUtf8Filename(s: string): string {
if (!s) return s;
if (/[\u0400-\u04FF]/.test(s)) return s;
try {
const recovered = Buffer.from(s, "latin1").toString("utf8");
if (/[\u0400-\u04FF]/.test(recovered)) return recovered;
} catch {
/* ignore */
}
return s;
}
function safeOriginalName(name: string | undefined): string {
if (!name) return "";
const base = path.basename(name.replace(/\0/g, ""));
const normalized = normalizeUtf8Filename(base);
return normalized.length > 255 ? normalized.slice(0, 255) : normalized;
}
/** Загрузка и удаление фото в «Зале» — только ученик konstantin. */
function canManageHallPhotos(req: AuthRequest): boolean {
return req.user!.username.toLowerCase() === "konstantin";
}
router.get("/", async (req: AuthRequest, res: Response) => {
const studentId = req.studentId!;
const date = req.query.date ? String(req.query.date) : undefined;
const items = await prisma.hallPhoto.findMany({
where: date ? { studentId, date } : { studentId },
orderBy: { createdAt: "desc" },
});
res.json(
items.map((p) => {
const raw = p.originalName.trim() || p.fileName;
const displayName = normalizeUtf8Filename(raw);
return {
id: p.id,
date: p.date,
createdAt: p.createdAt,
imageUrl: `/api/reports/${p.id}/image`,
fileName: p.fileName,
originalName: p.originalName,
displayName,
};
})
);
});
router.post(
"/upload",
(req: AuthRequest, res, next) => {
if (!canManageHallPhotos(req)) {
res.status(403).json({ error: "Загрузка фото доступна только ученику" });
return;
}
next();
},
(req, res, next) => {
upload.single("photo")(req, res, (err) => {
if (err) {
res.status(400).json({ error: err.message || "Ошибка загрузки файла" });
return;
}
next();
});
},
async (req: AuthRequest, res: Response) => {
const studentId = req.studentId!;
if (!req.file) {
res.status(400).json({ error: "Файл не передан" });
return;
}
const date =
typeof req.body.date === "string" && req.body.date
? req.body.date
: new Date().toISOString().split("T")[0];
const originalName = safeOriginalName(req.file.originalname);
const created = await prisma.hallPhoto.create({
data: {
studentId,
date,
fileName: req.file.filename,
originalName,
mimeType: req.file.mimetype || "application/octet-stream",
},
});
const displayName = normalizeUtf8Filename(
created.originalName.trim() || created.fileName
);
res.json({
id: created.id,
date: created.date,
createdAt: created.createdAt,
imageUrl: `/api/reports/${created.id}/image`,
fileName: created.fileName,
originalName: created.originalName,
displayName,
});
}
);
router.delete("/:id", async (req: AuthRequest, res: Response) => {
if (!canManageHallPhotos(req)) {
res.status(403).json({ error: "Удаление фото доступно только ученику" });
return;
}
const studentId = req.studentId!;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: "Некорректный id" });
return;
}
const photo = await prisma.hallPhoto.findFirst({ where: { id, studentId } });
if (!photo) {
res.status(404).json({ error: "Фото не найдено" });
return;
}
const fullPath = path.join(uploadsDir, path.basename(photo.fileName));
await prisma.hallPhoto.delete({ where: { id } });
if (fs.existsSync(fullPath)) {
try {
fs.unlinkSync(fullPath);
} catch {
/* ignore */
}
}
res.json({ success: true });
});
router.get("/:id/image", async (req: AuthRequest, res: Response) => {
const studentId = req.studentId!;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: "Некорректный id" });
return;
}
const photo = await prisma.hallPhoto.findFirst({ where: { id, studentId } });
if (!photo) {
res.status(404).json({ error: "Фото не найдено" });
return;
}
const fullPath = path.join(uploadsDir, path.basename(photo.fileName));
if (!fs.existsSync(fullPath)) {
res.status(404).json({ error: "Файл не найден на диске" });
return;
}
res.setHeader("Content-Type", photo.mimeType);
res.sendFile(fullPath);
});
export default router;
+48
View File
@@ -0,0 +1,48 @@
import { Router, Response } from "express";
import prisma from "../lib/prisma";
import type { AuthRequest } from "../middleware/auth";
const router = Router();
router.get("/", async (_req: AuthRequest, res: Response) => {
const settings = await prisma.setting.findMany();
const result: Record<string, string> = {};
for (const s of settings) {
result[s.key] = s.key === "deepseek_api_key" ? "••••••" + s.value.slice(-4) : s.value;
}
res.json(result);
});
router.get("/raw", async (req: AuthRequest, res: Response) => {
if (req.user!.role !== "TUTOR") {
res.status(403).json({ error: "Только наставник может просматривать полные настройки" });
return;
}
const settings = await prisma.setting.findMany();
const result: Record<string, string> = {};
for (const s of settings) {
result[s.key] = s.value;
}
res.json(result);
});
router.put("/", async (req: AuthRequest, res: Response) => {
if (req.user!.role !== "TUTOR") {
res.status(403).json({ error: "Только наставник может менять настройки" });
return;
}
const entries: Record<string, string> = req.body;
for (const [key, value] of Object.entries(entries)) {
await prisma.setting.upsert({
where: { key },
update: { value },
create: { key, value },
});
}
res.json({ success: true });
});
export default router;
+143
View File
@@ -0,0 +1,143 @@
import { Router, Response } from "express";
import prisma from "../lib/prisma";
import { getDeepSeekClient, getPrompt } from "../lib/deepseek";
import type { AuthRequest } from "../middleware/auth";
const router = Router();
const DEFAULT_TEST_PROMPT = `Ты — составитель тестов. Сгенерируй тест из РОВНО 10 вопросов.
Требования к вопросам:
- Вопросы должны проверять ПОНИМАНИЕ темы, а не запоминание фактов
- Сложность: 3 лёгких, 4 средних, 3 сложных
- Каждый вопрос имеет 4 варианта ответа (a, b, c, d) и ровно один правильный
- Неправильные варианты должны быть правдоподобными (не абсурдными)
- Вопросы на русском языке
ВАЖНО: Верни ТОЛЬКО JSON-массив, без markdown-обёрток (\`\`\`json), без пояснений, ЧИСТЫЙ JSON:
[{"question":"текст вопроса","options":{"a":"вариант А","b":"вариант Б","c":"вариант В","d":"вариант Г"},"correct":"a"}]`;
/** Тесты привязаны к текущему пользователю (ученик — свои; наставник — свои черновые, не к ученику). */
function testOwnerId(req: AuthRequest): number {
return req.user!.id;
}
router.get("/", async (req: AuthRequest, res: Response) => {
const ownerId = testOwnerId(req);
const tests = await prisma.test.findMany({
where: { studentId: ownerId },
orderBy: { createdAt: "desc" },
include: { results: true },
});
res.json(tests);
});
router.get("/:id", async (req: AuthRequest, res: Response) => {
const id = parseInt(req.params.id as string, 10);
const ownerId = testOwnerId(req);
const test = await prisma.test.findFirst({
where: { id, studentId: ownerId },
include: { results: true },
});
if (!test) {
res.status(404).json({ error: "Test not found" });
return;
}
res.json(test);
});
router.post("/generate", async (req: AuthRequest, res: Response) => {
const { topic, fromQuestions } = req.body;
const ownerId = testOwnerId(req);
/** Вопросы «по моим вопросам» берутся из базы назначенного ученика (для наставника — его ученик). */
const questionBankStudentId = req.studentId!;
const client = await getDeepSeekClient();
const systemPrompt = await getPrompt("prompt_test", DEFAULT_TEST_PROMPT);
let userMessage: string;
if (fromQuestions) {
const questions = await prisma.question.findMany({
where: { studentId: questionBankStudentId },
orderBy: { createdAt: "desc" },
take: 20,
});
const questionTexts = questions.map((q: { text: string }) => q.text).join("\n");
userMessage = `Составь тест на основе этих вопросов, которые задавал пользователь ранее:\n${questionTexts}`;
} else {
if (!topic) {
res.status(400).json({ error: "Topic is required" });
return;
}
userMessage = `Тема: ${topic}`;
}
try {
const response = await client.chat.completions.create({
model: "deepseek-chat",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
});
const raw = response.choices[0]?.message?.content || "[]";
const jsonMatch = raw.match(/\[[\s\S]*\]/);
const questionsJson = jsonMatch ? jsonMatch[0] : "[]";
const test = await prisma.test.create({
data: {
topic: topic || "По прошлым вопросам",
questions: questionsJson,
studentId: ownerId,
},
});
res.json(test);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post("/:id/submit", async (req: AuthRequest, res: Response) => {
const id = parseInt(req.params.id as string, 10);
const ownerId = testOwnerId(req);
const { answers } = req.body as { answers: Record<string, string> };
const test = await prisma.test.findFirst({ where: { id, studentId: ownerId } });
if (!test) {
res.status(404).json({ error: "Test not found" });
return;
}
const questions = JSON.parse(test.questions);
let score = 0;
const total = questions.length;
for (const q of questions as { question: string; correct: string }[]) {
if (answers[q.question] === q.correct) {
score++;
}
}
if (req.user!.role === "TUTOR") {
res.json({ score, total, persisted: false as const });
return;
}
const result = await prisma.testResult.create({
data: {
testId: id,
answers: JSON.stringify(answers),
score,
total,
},
});
res.json({ ...result, persisted: true as const });
});
export default router;
+99
View File
@@ -0,0 +1,99 @@
import { Router, Response } from "express";
import prisma from "../lib/prisma";
import { getDeepSeekClient, getPrompt } from "../lib/deepseek";
import type { AuthRequest } from "../middleware/auth";
const router = Router();
const DEFAULT_TEXTBOOK_PROMPT = `Ты — автор учебника для абсолютных новичков. Твоя аудитория — люди, которые ВООБЩЕ ничего не знают по теме (и не стесняются этого).
Структура учебника:
1. **Введение** — зачем это нужно, где применяется в реальной жизни
2. **Основные понятия** — каждое на отдельном подзаголовке, с определением простыми словами
3. **Подробное объяснение** — для каждого понятия: аналогия из жизни + конкретный пример
4. **Как это работает на практике** — пошаговый разбор
5. **Частые ошибки и заблуждения** — что обычно путают новички
6. **Итог** — краткое резюме в 5-7 пунктах
Правила:
- Пиши так, будто объясняешь другу за чашкой кофе
- Никаких сложных терминов без немедленного объяснения
- Используй аналогии из повседневной жизни (кухня, транспорт, магазин и т.д.)
- Формат — Markdown с заголовками, списками, выделением важного
- Учебник должен быть ДЛИННЫМ и ПОДРОБНЫМ — не менее 2000 слов`;
router.get("/", async (req: AuthRequest, res: Response) => {
const search = req.query.search as string | undefined;
const studentId = req.studentId!;
const where: { studentId: number; topic?: { contains: string } } = { studentId };
if (search) where.topic = { contains: search };
const textbooks = await prisma.textbook.findMany({
where,
orderBy: { createdAt: "desc" },
});
res.json(textbooks);
});
router.get("/:id", async (req: AuthRequest, res: Response) => {
const id = parseInt(req.params.id as string, 10);
const studentId = req.studentId!;
const textbook = await prisma.textbook.findFirst({ where: { id, studentId } });
if (!textbook) {
res.status(404).json({ error: "Textbook not found" });
return;
}
res.json(textbook);
});
router.post("/generate", async (req: AuthRequest, res: Response) => {
const { topic } = req.body;
const studentId = req.studentId!;
if (!topic || typeof topic !== "string") {
res.status(400).json({ error: "Topic is required" });
return;
}
const client = await getDeepSeekClient();
const systemPrompt = await getPrompt("prompt_textbook", DEFAULT_TEXTBOOK_PROMPT);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
try {
const stream = await client.chat.completions.create({
model: "deepseek-chat",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: `Тема: ${topic}` },
],
stream: true,
});
let fullContent = "";
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
fullContent += content;
res.write(`data: ${JSON.stringify({ content })}\n\n`);
}
}
const textbook = await prisma.textbook.create({
data: { topic, content: fullContent, studentId },
});
res.write(`data: ${JSON.stringify({ id: textbook.id })}\n\n`);
res.write(`data: [DONE]\n\n`);
res.end();
} catch (err: any) {
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
res.end();
}
});
export default router;
+65
View File
@@ -0,0 +1,65 @@
import "dotenv/config";
import bcrypt from "bcryptjs";
import prisma from "./lib/prisma";
async function main() {
const n = await prisma.user.count();
if (n > 0) {
console.log("Users already exist, skipping seed.");
return;
}
const tutorUser = process.env.SEED_TUTOR_USERNAME?.trim().toLowerCase() || "alexey";
const studentUser = process.env.SEED_STUDENT_USERNAME?.trim().toLowerCase() || "konstantin";
const tutorPass = process.env.SEED_TUTOR_PASSWORD;
const studentPass = process.env.SEED_STUDENT_PASSWORD;
if (!tutorPass || !studentPass) {
throw new Error(
"Первый запуск: задайте SEED_TUTOR_PASSWORD и SEED_STUDENT_PASSWORD в окружении."
);
}
const tutor = await prisma.user.create({
data: {
username: tutorUser,
passwordHash: await bcrypt.hash(tutorPass, 10),
role: "TUTOR",
displayName: "Алексей",
},
});
const student = await prisma.user.create({
data: {
username: studentUser,
passwordHash: await bcrypt.hash(studentPass, 10),
role: "STUDENT",
displayName: "Константин",
},
});
await prisma.tutorAssignment.create({
data: { tutorId: tutor.id, studentId: student.id },
});
const apiKey = process.env.DEEPSEEK_API_KEY?.trim();
if (apiKey) {
await prisma.setting.upsert({
where: { key: "deepseek_api_key" },
update: { value: apiKey },
create: { key: "deepseek_api_key", value: apiKey },
});
console.log("Seeded deepseek_api_key from DEEPSEEK_API_KEY");
}
console.log(`Seeded tutor=${tutorUser}, student=${studentUser}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+10
View File
@@ -0,0 +1,10 @@
declare global {
namespace Express {
interface Request {
user?: { id: number; role: "TUTOR" | "STUDENT"; username: string };
studentId?: number;
}
}
}
export {};
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}