Initial commit: Edu Helper (Docker, React, Express, Prisma)
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/generated/prisma
|
||||
@@ -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
|
||||
Generated
+1991
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default prisma;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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: "Не назначен ученик для наставника" });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: { id: number; role: "TUTOR" | "STUDENT"; username: string };
|
||||
studentId?: number;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user