Init monorepo: Frontend + Backend
This commit is contained in:
17
survey-backend/.gitignore
vendored
Normal file
17
survey-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Зависимости
|
||||
node_modules/
|
||||
server/node_modules/
|
||||
|
||||
# Сборка
|
||||
dist/
|
||||
server/dist/
|
||||
build/
|
||||
|
||||
# Среда и секреты (Никогда не отправляй их!)
|
||||
.env
|
||||
server/.env
|
||||
|
||||
# Логи и системные файлы
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode/
|
||||
15
survey-backend/docker-compose.yml
Normal file
15
survey-backend/docker-compose.yml
Normal 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
2107
survey-backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
survey-backend/package.json
Normal file
35
survey-backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
12
survey-backend/prisma.config.ts
Normal file
12
survey-backend/prisma.config.ts
Normal 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"),
|
||||
},
|
||||
});
|
||||
100
survey-backend/prisma/schema.prisma
Normal file
100
survey-backend/prisma/schema.prisma
Normal 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
248
survey-backend/src/index.ts
Normal 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}`));
|
||||
12
survey-backend/tsconfig.json
Normal file
12
survey-backend/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noImplicitAny": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user