Init monorepo: Frontend + Backend

This commit is contained in:
2025-11-20 06:59:26 +09:00
commit 3fa73b869b
35 changed files with 7898 additions and 0 deletions

17
survey-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# Зависимости
node_modules/
server/node_modules/
# Сборка
dist/
server/dist/
build/
# Среда и секреты (Никогда не отправляй их!)
.env
server/.env
# Логи и системные файлы
*.log
.DS_Store
.vscode/

View File

@@ -0,0 +1,15 @@
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: password123
POSTGRES_DB: surveydb
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:

2107
survey-backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "survey-backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "nodemon src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@prisma/client": "^6.19.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"prisma": "^6.19.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.1",
"nodemon": "^3.1.11",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,12 @@
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@@ -0,0 +1,100 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// --- МОДЕЛИ ---
model User {
id Int @id @default(autoincrement())
email String @unique
password String // Хеш пароля
name String?
inviteCode String @unique // Уникальный код типа "#USER-1234"
surveys Survey[] @relation("CreatedSurveys") // Опросы, которые я создал
submissions Submission[] // Опросы, которые я прошел
allowedIn AllowedAccess[] // Куда меня пригласили
}
model Survey {
id Int @id @default(autoincrement())
title String
description String?
isPublished Boolean @default(false)
accessType AccessType @default(PUBLIC) // PUBLIC, INVITE_ONLY
createdAt DateTime @default(now())
authorId Int
author User @relation("CreatedSurveys", fields: [authorId], references: [id])
questions Question[]
submissions Submission[]
allowedUsers AllowedAccess[]
}
enum AccessType {
PUBLIC
INVITE_ONLY
}
model Question {
id Int @id @default(autoincrement())
surveyId Int
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
text String
type QuestionType @default(SINGLE)
points Int @default(0)
order Int @default(0)
options Option[]
}
enum QuestionType {
SINGLE // Один выбор (Radio)
MULTI // Множественный (Checkbox)
// TEXT - можно добавить позже
}
model Option {
id Int @id @default(autoincrement())
questionId Int
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
text String
isCorrect Boolean @default(false) // Правильный ли это ответ?
}
// Белый список для приватных опросов
model AllowedAccess {
id Int @id @default(autoincrement())
surveyId Int
userId Int
// ДОБАВЛЕНО: onDelete: Cascade
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@unique([surveyId, userId])
}
// Результаты прохождения
model Submission {
id Int @id @default(autoincrement())
surveyId Int
userId Int?
score Int
maxScore Int
completedAt DateTime @default(now())
// ДОБАВЛЕНО: onDelete: Cascade
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id])
}

248
survey-backend/src/index.ts Normal file
View File

@@ -0,0 +1,248 @@
import express from 'express';
import type { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
// Логируем SQL запросы для отладки (опционально)
const prisma = new PrismaClient({
log: ['error', 'warn'],
});
const app = express();
const PORT = 5000;
const SECRET_KEY = process.env.JWT_SECRET || 'dev-secret';
app.use(cors());
app.use(express.json());
// --- MIDDLEWARE ---
interface AuthRequest extends Request { user?: { id: number; email: string }; }
const authMiddleware = (req: AuthRequest, res: Response, next: NextFunction) => {
const header = req.headers.authorization;
if (!header) { res.status(401).json({ error: "No token" }); return; }
try {
const token = header.split(' ')[1];
req.user = jwt.verify(token, SECRET_KEY) as any;
next();
} catch (e) { res.status(401).json({ error: "Invalid token" }); }
};
// --- AUTH ---
app.post('/api/auth/register', async (req, res) => {
try {
const { email, password, name } = req.body;
const hashedPassword = await bcrypt.hash(password, 10);
// FIX 1: Генерируем код ВСЕГДА В ВЕРХНЕМ РЕГИСТРЕ
const inviteCode = `#USER-${Math.floor(10000 + Math.random() * 90000)}`.toUpperCase();
const user = await prisma.user.create({ data: { email, password: hashedPassword, name, inviteCode } });
const token = jwt.sign({ id: user.id, email: user.email }, SECRET_KEY);
console.log(`[AUTH] Registered: ${email} with code ${inviteCode}`);
res.json({ token, user: { id: user.id, name: user.name, inviteCode: user.inviteCode } });
} catch (e) {
console.error(e);
res.status(400).json({ error: "User exists" });
}
});
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !await bcrypt.compare(password, user.password)) { res.status(401).json({ error: "Invalid credentials" }); return; }
const token = jwt.sign({ id: user.id, email: user.email }, SECRET_KEY);
res.json({ token, user: { id: user.id, name: user.name, inviteCode: user.inviteCode } });
});
app.get('/api/auth/me', authMiddleware, async (req: AuthRequest, res) => {
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
res.json(user);
});
// --- SURVEYS CRUD ---
// CREATE
app.post('/api/surveys', authMiddleware, async (req: AuthRequest, res) => {
// ... (код создания оставляем как был в прошлом шаге, он рабочий)
const { title, description, questions, accessType, allowedUserCodes } = req.body;
console.log(`[CREATE] Survey "${title}"`);
try {
let allowedIds: number[] = [];
if (accessType === 'INVITE_ONLY' && allowedUserCodes?.length) {
const users = await prisma.user.findMany({
where: { inviteCode: { in: allowedUserCodes, mode: 'insensitive' } }
});
allowedIds = users.map(u => u.id);
}
const survey = await prisma.survey.create({
data: {
title, description, accessType, authorId: req.user!.id, isPublished: true,
questions: {
create: questions.map((q: any) => ({
text: q.text,
type: q.type || 'SINGLE', // <--- Важно: сохраняем тип
points: q.points || 0,
options: { create: q.options.map((o: any) => ({ text: o.text, isCorrect: o.isCorrect || false })) }
}))
},
allowedUsers: { create: allowedIds.map(uid => ({ userId: uid })) }
}
});
res.json(survey);
} catch (e) { console.error(e); res.status(500).json({ error: "Create failed" }); }
});
// 2. UPDATE (NEW: Редактирование)
app.put('/api/surveys/:id', authMiddleware, async (req: AuthRequest, res) => {
const id = Number(req.params.id);
const { title, questions, accessType, allowedUserCodes } = req.body;
// Проверяем права
const existing = await prisma.survey.findUnique({ where: { id } });
if (!existing || existing.authorId !== req.user!.id) {
res.status(403).json({ error: "Access denied" });
return;
}
try {
// Транзакция: Удаляем старое -> Создаем новое (Самый надежный способ для MVP)
await prisma.$transaction([
// 1. Обновляем шапку
prisma.survey.update({
where: { id },
data: { title, accessType }
}),
// 2. Удаляем старые доступы и вопросы (каскадно удалятся варианты)
prisma.allowedAccess.deleteMany({ where: { surveyId: id } }),
prisma.question.deleteMany({ where: { surveyId: id } }),
]);
// 3. Создаем заново (как при CREATE)
let allowedIds: number[] = [];
if (accessType === 'INVITE_ONLY' && allowedUserCodes?.length) {
const users = await prisma.user.findMany({ where: { inviteCode: { in: allowedUserCodes, mode: 'insensitive' } } });
allowedIds = users.map(u => u.id);
}
// 4. Добавляем новые вопросы
// Мы не можем использовать nested update, поэтому делаем отдельный update
const updated = await prisma.survey.update({
where: { id },
data: {
questions: {
create: questions.map((q: any) => ({
text: q.text, type: q.type || 'SINGLE', points: q.points || 0,
options: { create: q.options.map((o: any) => ({ text: o.text, isCorrect: o.isCorrect || false })) }
}))
},
allowedUsers: { create: allowedIds.map(uid => ({ userId: uid })) }
}
});
res.json(updated);
} catch (e) { console.error(e); res.status(500).json({ error: "Update failed" }); }
});
// GET MY, DELETE, FEED, GET SINGLE -> Оставляем без изменений (код из прошлого сообщения)
app.get('/api/surveys/my', authMiddleware, async (req: AuthRequest, res) => {
const surveys = await prisma.survey.findMany({
where: { authorId: req.user!.id },
include: { _count: { select: { submissions: true } } },
orderBy: { createdAt: 'desc' }
});
res.json(surveys);
});
app.delete('/api/surveys/:id', authMiddleware, async (req: AuthRequest, res) => {
try {
await prisma.survey.delete({ where: { id: Number(req.params.id), authorId: req.user!.id } });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: "Delete failed" }); }
});
app.get('/api/surveys/feed', authMiddleware, async (req: AuthRequest, res) => {
// ... (Код из прошлого сообщения про Feed)
const userId = req.user!.id;
const publicSurveys = await prisma.survey.findMany({ where: { accessType: 'PUBLIC', isPublished: true, authorId: { not: userId } }, include: { author: { select: { name: true } }, _count: { select: { submissions: true } } } });
const invitedSurveys = await prisma.survey.findMany({ where: { accessType: 'INVITE_ONLY', isPublished: true, allowedUsers: { some: { userId: userId } } }, include: { author: { select: { name: true } }, _count: { select: { submissions: true } } } });
const allSurveys = [...publicSurveys, ...invitedSurveys].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
res.json(allSurveys);
});
app.get('/api/surveys/:id', authMiddleware, async (req: AuthRequest, res) => {
// ... (Код получения одного опроса из прошлого сообщения)
const id = Number(req.params.id);
const isCreator = req.query.mode === 'edit';
const survey = await prisma.survey.findUnique({ where: { id }, include: { allowedUsers: { include: { user: true } }, questions: { include: { options: true } } } });
if (!survey) { res.status(404).json({ error: "Not found" }); return; }
if (!isCreator) {
if (survey.accessType === 'INVITE_ONLY') {
const isAllowed = survey.allowedUsers.some(u => u.userId === req.user!.id);
if (!isAllowed && survey.authorId !== req.user!.id) { res.status(403).json({ error: "Access denied" }); return; }
}
survey.questions.forEach(q => q.options.forEach(o => (o as any).isCorrect = undefined));
} else { if (survey.authorId !== req.user!.id) { res.status(403).json({ error: "Not your survey" }); return; } }
res.json(survey);
});
// SUBMIT (NEW LOGIC: Поддержка MULTI)
app.post('/api/surveys/:id/submit', authMiddleware, async (req: AuthRequest, res) => {
const surveyId = Number(req.params.id);
const { answers } = req.body; // answers: { questionId: number, optionId: number }[]
const survey = await prisma.survey.findUnique({
where: { id: surveyId },
include: { questions: { include: { options: true } } }
});
if (!survey) return;
let totalScore = 0; // Сколько набрал юзер
let maxScore = 0; // Сколько можно было набрать (сумма всех правильных галочек)
survey.questions.forEach(q => {
// 1. Считаем, сколько всего правильных ответов в этом вопросе
// Если вопрос SINGLE, там будет 1 правильный. Если MULTI — может быть несколько.
const correctOptionIds = q.options.filter(o => o.isCorrect).map(o => o.id);
maxScore += correctOptionIds.length;
// 2. Смотрим, что выбрал юзер
const userSelectedOptionIds = answers
.filter((a: any) => a.questionId === q.id)
.map((a: any) => a.optionId);
// 3. Начисляем баллы: +1 за каждое совпадение
userSelectedOptionIds.forEach((selectedId: number) => {
if (correctOptionIds.includes(selectedId)) {
totalScore += 1;
} else {
// Опционально: Можно отнимать баллы за ошибки (totalScore -= 1),
// но пока оставим просто 0 (не угадал - не получил).
}
});
});
const sub = await prisma.submission.create({
data: { surveyId, userId: req.user!.id, score: totalScore, maxScore }
});
res.json({ score: totalScore, maxScore, id: sub.id });
});
app.get('/api/surveys/:id/results', authMiddleware, async (req: AuthRequest, res) => {
// ... (старый код)
const surveyId = Number(req.params.id);
const survey = await prisma.survey.findUnique({ where: { id: surveyId } });
if (survey?.authorId !== req.user!.id) { res.status(403).json({ error: "Access denied" }); return; }
const submissions = await prisma.submission.findMany({ where: { surveyId }, include: { user: { select: { name: true, email: true } } }, orderBy: { completedAt: 'desc' } });
res.json(submissions);
});
app.listen(PORT, () => console.log(`Server running on ${PORT}`));

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"noImplicitAny": false
}
}