diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..e69de29
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..4cb0c7a
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,16 @@
+## Описание
+
+
+## Тип изменений
+- [ ] Новая фича (frontend)
+- [ ] Новая фича (backend)
+- [ ] Исправление бага
+- [ ] Рефакторинг / документация
+
+## Чеклист
+- [ ] `npm run lint` проходит (если затронут frontend)
+- [ ] `npm run build` проходит (если затронут frontend)
+- [ ] Проверено локально
+
+## Связанные задачи
+
diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml
new file mode 100644
index 0000000..e49c4fd
--- /dev/null
+++ b/.github/workflows/ci-backend.yml
@@ -0,0 +1,27 @@
+# CI для CodeFlow (backend)
+name: CI Backend
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore
+ run: dotnet restore
+ working-directory: backend
+
+ - name: Build
+ run: dotnet build --no-restore -c Release
+ working-directory: backend
diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml
new file mode 100644
index 0000000..b095e68
--- /dev/null
+++ b/.github/workflows/ci-frontend.yml
@@ -0,0 +1,33 @@
+# CI для CodeFlow (frontend)
+name: CI Frontend
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install dependencies
+ run: npm install --legacy-peer-deps
+ working-directory: frontend
+
+ - name: Lint
+ run: npm run lint
+ working-directory: frontend
+
+ - name: Build
+ run: npm run build
+ working-directory: frontend
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d7a182a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,22 @@
+# Фронтенд
+node_modules/
+dist/
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Бэкенд (будет позже)
+bin/obj/
+*.csproj.user
+
+# Общее
+.DS_Store
+Thumbs.db
+.idea/
+.vscode/
+*.swp
+*.swo
diff --git a/README.md b/README.md
index 13a7325..f11e315 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,68 @@
-# codeflow
\ No newline at end of file
+## CodeFlow
+
+Онлайн-сервис для изучения программирования с элементами геймификации.
+
+## Структура репозитория
+
+- **frontend/** — SPA (React, TypeScript, Vite)
+- **backend/** — API (ASP.NET Core 8, C#)
+- **.github/workflows/** — CI (frontend и backend)
+- **docker-compose.yml** — PostgreSQL + API для локального запуска
+
+## Требования
+
+- Node.js 20+ (для frontend)
+- .NET 8 SDK (для backend)
+- Docker и Docker Compose (опционально, для БД и API)
+
+## Быстрый запуск
+
+- **1. БД и Backend**
+
+```bash
+docker-compose up -d
+```
+
+- **Или по отдельности:**
+- **2. База данных (PostgreSQL)**
+ - Через Docker:
+
+```bash
+docker-compose up -d postgres
+```
+
+
+
+- **3. Backend (API)**
+
+```bash
+cd backend
+dotnet restore
+dotnet run --project CodeFlow.Api
+```
+
+API: `http://localhost:5001`, Swagger: `http://localhost:5001/swagger`.
+
+- **3. Frontend**
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+Фронт: `http://localhost:5173`.
+
+## Что сейчас сделано
+
+- **Фронтенд**
+ - SPA на React + TypeScript + Vite.
+ - Экран курса и урока, редактор кода, локальное хранение прогресса.
+ - Дизайн и базовая геймификация (XP, уровни, достижения, фракции, магазин) — пока в основном на клиенте.
+
+- **Бэкенд**
+ - ASP.NET Core 8 API, PostgreSQL (EF Core), JWT‑аутентификация.
+ - Курсы, уроки, прогресс, рейтинг, достижения, фракции, магазин, уведомления.
+ - Роли `User/Teacher/Admin`, админ‑эндпоинты для курсов, уроков и пользователей.
+ - Проверка Python‑кода: синхронно и асинхронно через очередь и фоновый воркер.
+ - Экспорт отчётов по прогрессу и рейтингу в CSV и PDF.
diff --git a/backend/.gitkeep b/backend/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/backend/CodeFlow.Api/CodeFlow.Api.csproj b/backend/CodeFlow.Api/CodeFlow.Api.csproj
new file mode 100644
index 0000000..ef8f2ab
--- /dev/null
+++ b/backend/CodeFlow.Api/CodeFlow.Api.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+ CodeFlow.Api
+ CodeFlow.Api
+
+
+
+
+
+ all
+
+
+
+
+
+
+
+
diff --git a/backend/CodeFlow.Api/Controllers/AchievementsController.cs b/backend/CodeFlow.Api/Controllers/AchievementsController.cs
new file mode 100644
index 0000000..bf2d3af
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/AchievementsController.cs
@@ -0,0 +1,44 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using CodeFlow.Api.DTOs;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class AchievementsController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public AchievementsController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll(CancellationToken ct)
+ {
+ var list = await _db.AchievementDefinitions
+ .Select(a => new AchievementDefinitionDto(a.Id, a.Title, a.Description, a.Icon, a.Rarity))
+ .ToListAsync(ct);
+ return Ok(list);
+ }
+
+ [HttpGet("me")]
+ [Authorize]
+ public async Task>> GetMyAchievements(CancellationToken ct)
+ {
+ var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (userIdStr == null || !Guid.TryParse(userIdStr, out var userId))
+ return Unauthorized();
+ var list = await _db.UserAchievements
+ .Where(a => a.UserId == userId)
+ .OrderBy(a => a.UnlockedAtUtc)
+ .Select(a => new UserAchievementDto(a.AchievementId, a.UnlockedAtUtc))
+ .ToListAsync(ct);
+ return Ok(list);
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/Admin/AdminCoursesController.cs b/backend/CodeFlow.Api/Controllers/Admin/AdminCoursesController.cs
new file mode 100644
index 0000000..d68fbcd
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/Admin/AdminCoursesController.cs
@@ -0,0 +1,80 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using CodeFlow.Api.DTOs;
+using CodeFlow.Api.Models;
+
+namespace CodeFlow.Api.Controllers.Admin;
+
+[ApiController]
+[Route("api/admin/courses")]
+[Authorize(Policy = "AdminOrTeacher")]
+public class AdminCoursesController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public AdminCoursesController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll(CancellationToken ct)
+ {
+ var list = await _db.Courses
+ .Select(c => new CourseDto(c.Id, c.Title, c.Description, c.Level, c.Color, c.TotalLessons))
+ .ToListAsync(ct);
+ return Ok(list);
+ }
+
+ [HttpGet("{id:int}")]
+ public async Task> GetById(int id, CancellationToken ct)
+ {
+ var course = await _db.Courses.FindAsync(new object[] { id }, ct);
+ if (course == null) return NotFound();
+ return Ok(new CourseDto(course.Id, course.Title, course.Description, course.Level, course.Color, course.TotalLessons));
+ }
+
+ [HttpPost]
+ public async Task> Create([FromBody] CreateCourseRequest request, CancellationToken ct)
+ {
+ var id = await _db.Courses.AnyAsync(ct) ? await _db.Courses.MaxAsync(c => c.Id, ct) + 1 : 1;
+ var course = new Course
+ {
+ Id = id,
+ Title = request.Title,
+ Description = request.Description,
+ Level = request.Level ?? "",
+ Color = request.Color ?? "green",
+ TotalLessons = request.TotalLessons
+ };
+ _db.Courses.Add(course);
+ await _db.SaveChangesAsync(ct);
+ return CreatedAtAction(nameof(GetById), new { id = course.Id }, new CourseDto(course.Id, course.Title, course.Description, course.Level, course.Color, course.TotalLessons));
+ }
+
+ [HttpPut("{id:int}")]
+ public async Task> Update(int id, [FromBody] UpdateCourseRequest request, CancellationToken ct)
+ {
+ var course = await _db.Courses.FindAsync(new object[] { id }, ct);
+ if (course == null) return NotFound();
+ if (request.Title != null) course.Title = request.Title;
+ if (request.Description != null) course.Description = request.Description;
+ if (request.Level != null) course.Level = request.Level;
+ if (request.Color != null) course.Color = request.Color;
+ if (request.TotalLessons.HasValue) course.TotalLessons = request.TotalLessons.Value;
+ await _db.SaveChangesAsync(ct);
+ return Ok(new CourseDto(course.Id, course.Title, course.Description, course.Level, course.Color, course.TotalLessons));
+ }
+
+ [HttpDelete("{id:int}")]
+ public async Task Delete(int id, CancellationToken ct)
+ {
+ var course = await _db.Courses.FindAsync(new object[] { id }, ct);
+ if (course == null) return NotFound();
+ _db.Courses.Remove(course);
+ await _db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/Admin/AdminLessonsController.cs b/backend/CodeFlow.Api/Controllers/Admin/AdminLessonsController.cs
new file mode 100644
index 0000000..1701a2b
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/Admin/AdminLessonsController.cs
@@ -0,0 +1,100 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using CodeFlow.Api.DTOs;
+using CodeFlow.Api.Models;
+
+namespace CodeFlow.Api.Controllers.Admin;
+
+[ApiController]
+[Route("api/admin/lessons")]
+[Authorize(Policy = "AdminOrTeacher")]
+public class AdminLessonsController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public AdminLessonsController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll([FromQuery] int? courseId, CancellationToken ct)
+ {
+ var query = _db.Lessons.AsQueryable();
+ if (courseId.HasValue)
+ query = query.Where(l => l.CourseId == courseId.Value);
+ var list = await query
+ .OrderBy(l => l.CourseId).ThenBy(l => l.Id)
+ .Select(l => new LessonDto(l.Id, l.CourseId, l.Chapter, l.Title, l.Description, l.Task, l.InitialCode, l.ExpectedOutput, l.Xp, l.IsBoss, l.HasDebugger, l.Hint, l.Hint2))
+ .ToListAsync(ct);
+ return Ok(list);
+ }
+
+ [HttpGet("{id:int}")]
+ public async Task> GetById(int id, CancellationToken ct)
+ {
+ var lesson = await _db.Lessons.Include(l => l.Course).FirstOrDefaultAsync(l => l.Id == id, ct);
+ if (lesson == null) return NotFound();
+ return Ok(new LessonDto(lesson.Id, lesson.CourseId, lesson.Chapter, lesson.Title, lesson.Description, lesson.Task, lesson.InitialCode, lesson.ExpectedOutput, lesson.Xp, lesson.IsBoss, lesson.HasDebugger, lesson.Hint, lesson.Hint2));
+ }
+
+ [HttpPost]
+ public async Task> Create([FromBody] CreateLessonRequest request, CancellationToken ct)
+ {
+ var courseExists = await _db.Courses.AnyAsync(c => c.Id == request.CourseId, ct);
+ if (!courseExists) return BadRequest(new { message = "Course not found." });
+ var id = await _db.Lessons.AnyAsync(ct) ? await _db.Lessons.MaxAsync(l => l.Id, ct) + 1 : 1;
+ var lesson = new Lesson
+ {
+ Id = id,
+ CourseId = request.CourseId,
+ Chapter = request.Chapter,
+ Title = request.Title,
+ Description = request.Description,
+ Task = request.Task,
+ InitialCode = request.InitialCode,
+ ExpectedOutput = request.ExpectedOutput,
+ Xp = request.Xp,
+ IsBoss = request.IsBoss,
+ HasDebugger = request.HasDebugger,
+ Hint = request.Hint,
+ Hint2 = request.Hint2
+ };
+ _db.Lessons.Add(lesson);
+ await _db.SaveChangesAsync(ct);
+ return CreatedAtAction(nameof(GetById), new { id = lesson.Id }, new LessonDto(lesson.Id, lesson.CourseId, lesson.Chapter, lesson.Title, lesson.Description, lesson.Task, lesson.InitialCode, lesson.ExpectedOutput, lesson.Xp, lesson.IsBoss, lesson.HasDebugger, lesson.Hint, lesson.Hint2));
+ }
+
+ [HttpPut("{id:int}")]
+ public async Task> Update(int id, [FromBody] UpdateLessonRequest request, CancellationToken ct)
+ {
+ var lesson = await _db.Lessons.FindAsync(new object[] { id }, ct);
+ if (lesson == null) return NotFound();
+ if (request.CourseId.HasValue) lesson.CourseId = request.CourseId.Value;
+ if (request.Chapter != null) lesson.Chapter = request.Chapter;
+ if (request.Title != null) lesson.Title = request.Title;
+ if (request.Description != null) lesson.Description = request.Description;
+ if (request.Task != null) lesson.Task = request.Task;
+ if (request.InitialCode != null) lesson.InitialCode = request.InitialCode;
+ if (request.ExpectedOutput != null) lesson.ExpectedOutput = request.ExpectedOutput;
+ if (request.Xp.HasValue) lesson.Xp = request.Xp.Value;
+ if (request.IsBoss.HasValue) lesson.IsBoss = request.IsBoss.Value;
+ if (request.HasDebugger.HasValue) lesson.HasDebugger = request.HasDebugger.Value;
+ if (request.Hint != null) lesson.Hint = request.Hint;
+ if (request.Hint2 != null) lesson.Hint2 = request.Hint2;
+ await _db.SaveChangesAsync(ct);
+ return Ok(new LessonDto(lesson.Id, lesson.CourseId, lesson.Chapter, lesson.Title, lesson.Description, lesson.Task, lesson.InitialCode, lesson.ExpectedOutput, lesson.Xp, lesson.IsBoss, lesson.HasDebugger, lesson.Hint, lesson.Hint2));
+ }
+
+ [HttpDelete("{id:int}")]
+ public async Task Delete(int id, CancellationToken ct)
+ {
+ var lesson = await _db.Lessons.FindAsync(new object[] { id }, ct);
+ if (lesson == null) return NotFound();
+ _db.Lessons.Remove(lesson);
+ await _db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/Admin/AdminUsersController.cs b/backend/CodeFlow.Api/Controllers/Admin/AdminUsersController.cs
new file mode 100644
index 0000000..67a341a
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/Admin/AdminUsersController.cs
@@ -0,0 +1,55 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using CodeFlow.Api.DTOs;
+using CodeFlow.Api.Models;
+
+namespace CodeFlow.Api.Controllers.Admin;
+
+[ApiController]
+[Route("api/admin/users")]
+[Authorize(Policy = "Admin")]
+public class AdminUsersController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public AdminUsersController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll([FromQuery] int skip = 0, [FromQuery] int take = 50, CancellationToken ct = default)
+ {
+ if (take <= 0 || take > 100) take = 50;
+ var list = await _db.Users
+ .OrderBy(u => u.CreatedAtUtc)
+ .Skip(skip)
+ .Take(take)
+ .Select(u => new AdminUserDto(u.Id, u.Email, u.DisplayName, u.Role, u.TotalXp, u.CreatedAtUtc, u.EmailConfirmedAtUtc.HasValue))
+ .ToListAsync(ct);
+ return Ok(list);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id, CancellationToken ct)
+ {
+ var user = await _db.Users.FindAsync(new object[] { id }, ct);
+ if (user == null) return NotFound();
+ return Ok(new AdminUserDto(user.Id, user.Email, user.DisplayName, user.Role, user.TotalXp, user.CreatedAtUtc, user.EmailConfirmedAtUtc.HasValue));
+ }
+
+ [HttpPatch("{id:guid}/role")]
+ public async Task> SetRole(Guid id, [FromBody] SetUserRoleRequest request, CancellationToken ct)
+ {
+ var role = request.Role?.Trim();
+ if (string.IsNullOrEmpty(role) || !Role.All.Contains(role))
+ return BadRequest(new { message = "Role must be one of: User, Teacher, Admin." });
+ var user = await _db.Users.FindAsync(new object[] { id }, ct);
+ if (user == null) return NotFound();
+ user.Role = role;
+ await _db.SaveChangesAsync(ct);
+ return Ok(new AdminUserDto(user.Id, user.Email, user.DisplayName, user.Role, user.TotalXp, user.CreatedAtUtc, user.EmailConfirmedAtUtc.HasValue));
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/AuthController.cs b/backend/CodeFlow.Api/Controllers/AuthController.cs
new file mode 100644
index 0000000..3ea0e69
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/AuthController.cs
@@ -0,0 +1,62 @@
+using Microsoft.AspNetCore.Mvc;
+using CodeFlow.Api.DTOs;
+using CodeFlow.Api.Services;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class AuthController : ControllerBase
+{
+ private readonly IAuthService _auth;
+
+ public AuthController(IAuthService auth)
+ {
+ _auth = auth;
+ }
+
+ [HttpPost("register")]
+ public async Task> Register([FromBody] RegisterRequest request, CancellationToken ct)
+ {
+ var result = await _auth.RegisterAsync(request, ct);
+ if (result == null)
+ return BadRequest(new { message = "Email already registered or invalid input (password min 6 characters)." });
+ return Ok(result);
+ }
+
+ [HttpPost("login")]
+ public async Task> Login([FromBody] LoginRequest request, CancellationToken ct)
+ {
+ var result = await _auth.LoginAsync(request, ct);
+ if (result == null)
+ return Unauthorized(new { message = "Invalid email or password." });
+ return Ok(result);
+ }
+
+ [HttpPost("confirm-email")]
+ public async Task ConfirmEmail([FromQuery] string email, [FromQuery] string token, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(token))
+ return BadRequest(new { message = "Email and token are required." });
+ var ok = await _auth.ConfirmEmailAsync(email, token, ct);
+ if (!ok)
+ return BadRequest(new { message = "Invalid or expired confirmation link." });
+ return Ok(new { message = "Email confirmed." });
+ }
+
+ [HttpPost("forgot-password")]
+ public async Task ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken ct)
+ {
+ await _auth.RequestPasswordResetAsync(request.Email, ct);
+ return Ok(new { message = "If the email exists, a reset link has been sent." });
+ }
+
+ [HttpPost("reset-password")]
+ public async Task ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken ct)
+ {
+ var ok = await _auth.ResetPasswordAsync(request, ct);
+ if (!ok)
+ return BadRequest(new { message = "Invalid or expired reset link." });
+ return Ok(new { message = "Password has been reset." });
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/CoursesController.cs b/backend/CodeFlow.Api/Controllers/CoursesController.cs
new file mode 100644
index 0000000..58c0ff0
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/CoursesController.cs
@@ -0,0 +1,46 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using CodeFlow.Api.DTOs;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class CoursesController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public CoursesController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll(CancellationToken ct)
+ {
+ var list = await _db.Courses
+ .Select(c => new CourseDto(c.Id, c.Title, c.Description, c.Level, c.Color, c.TotalLessons))
+ .ToListAsync(ct);
+ return Ok(list);
+ }
+
+ [HttpGet("{id:int}")]
+ public async Task> GetById(int id, CancellationToken ct)
+ {
+ var course = await _db.Courses.FindAsync(new object[] { id }, ct);
+ if (course == null) return NotFound();
+ return Ok(new CourseDto(course.Id, course.Title, course.Description, course.Level, course.Color, course.TotalLessons));
+ }
+
+ [HttpGet("{id:int}/lessons")]
+ public async Task>> GetLessons(int id, CancellationToken ct)
+ {
+ var lessons = await _db.Lessons
+ .Where(l => l.CourseId == id)
+ .OrderBy(l => l.Id)
+ .Select(l => new LessonDto(l.Id, l.CourseId, l.Chapter, l.Title, l.Description, l.Task, l.InitialCode, l.ExpectedOutput, l.Xp, l.IsBoss, l.HasDebugger, l.Hint, l.Hint2))
+ .ToListAsync(ct);
+ return Ok(lessons);
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/FactionsController.cs b/backend/CodeFlow.Api/Controllers/FactionsController.cs
new file mode 100644
index 0000000..d10510e
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/FactionsController.cs
@@ -0,0 +1,43 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using CodeFlow.Api.DTOs;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class FactionsController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public FactionsController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll(CancellationToken ct)
+ {
+ var list = await _db.Factions
+ .Select(f => new FactionDto(f.Id, f.Name, f.Description, f.Icon, f.Color, f.Bonus, f.RequiredRep))
+ .ToListAsync(ct);
+ return Ok(list);
+ }
+
+ [HttpGet("me")]
+ [Authorize]
+ public async Task>> GetMyReputation(CancellationToken ct)
+ {
+ var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (userIdStr == null || !Guid.TryParse(userIdStr, out var userId))
+ return Unauthorized();
+ var list = await _db.UserReputations
+ .Where(r => r.UserId == userId)
+ .Select(r => new UserReputationDto(r.FactionId, r.Reputation))
+ .ToListAsync(ct);
+ return Ok(list);
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/HealthController.cs b/backend/CodeFlow.Api/Controllers/HealthController.cs
new file mode 100644
index 0000000..68de16c
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/HealthController.cs
@@ -0,0 +1,31 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class HealthController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public HealthController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ public async Task> Get(CancellationToken ct)
+ {
+ try
+ {
+ await _db.Database.CanConnectAsync(ct);
+ return Ok(new { status = "ok", database = "connected" });
+ }
+ catch
+ {
+ return StatusCode(503, new { status = "degraded", database = "disconnected" });
+ }
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/LeaderboardController.cs b/backend/CodeFlow.Api/Controllers/LeaderboardController.cs
new file mode 100644
index 0000000..9e71218
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/LeaderboardController.cs
@@ -0,0 +1,31 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using CodeFlow.Api.DTOs;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class LeaderboardController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public LeaderboardController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ public async Task>> Get([FromQuery] int limit = 50, CancellationToken ct = default)
+ {
+ if (limit <= 0 || limit > 100) limit = 50;
+ var users = await _db.Users
+ .OrderByDescending(u => u.TotalXp)
+ .Take(limit)
+ .Select(u => new { u.Id, u.DisplayName, u.TotalXp })
+ .ToListAsync(ct);
+ var list = users.Select((u, i) => new LeaderboardEntryDto(i + 1, u.Id, u.DisplayName, u.TotalXp)).ToList();
+ return Ok(list);
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/LessonsController.cs b/backend/CodeFlow.Api/Controllers/LessonsController.cs
new file mode 100644
index 0000000..f041bc0
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/LessonsController.cs
@@ -0,0 +1,96 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using CodeFlow.Api.DTOs;
+using CodeFlow.Api.Models;
+using CodeFlow.Api.Services;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class LessonsController : ControllerBase
+{
+ private readonly AppDbContext _db;
+ private readonly IPythonSandboxService _sandbox;
+ private readonly ISubmissionQueue _queue;
+
+ public LessonsController(AppDbContext db, IPythonSandboxService sandbox, ISubmissionQueue queue)
+ {
+ _db = db;
+ _sandbox = sandbox;
+ _queue = queue;
+ }
+
+ private Guid? UserId => Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
+
+ [HttpGet("{id:int}")]
+ public async Task> GetById(int id, CancellationToken ct)
+ {
+ var lesson = await _db.Lessons
+ .Include(l => l.Course)
+ .FirstOrDefaultAsync(l => l.Id == id, ct);
+ if (lesson == null) return NotFound();
+ return Ok(new LessonDto(
+ lesson.Id, lesson.CourseId, lesson.Chapter, lesson.Title, lesson.Description,
+ lesson.Task, lesson.InitialCode, lesson.ExpectedOutput, lesson.Xp,
+ lesson.IsBoss, lesson.HasDebugger, lesson.Hint, lesson.Hint2
+ ));
+ }
+
+ [HttpPost("{id:int}/submit")]
+ [Authorize]
+ public async Task> Submit(int id, [FromBody] SubmitCodeRequest request, CancellationToken ct)
+ {
+ var lesson = await _db.Lessons.FindAsync(new object[] { id }, ct);
+ if (lesson == null) return NotFound();
+
+ var result = await _sandbox.RunAsync(request.Code, TimeSpan.FromSeconds(10), ct);
+ var output = NormalizeOutput(result.Output);
+ var expected = NormalizeOutput(lesson.ExpectedOutput);
+ var passed = result.Success && output == expected;
+
+ return Ok(new SubmitResultDto(
+ passed,
+ result.Output,
+ lesson.ExpectedOutput,
+ result.Error,
+ result.FailureReason
+ ));
+ }
+
+ /// Асинхронная отправка кода: создаётся задача, результат проверяется по GET /api/submissions/{jobId}.
+ [HttpPost("{id:int}/submit-async")]
+ [Authorize]
+ public async Task> SubmitAsync(int id, [FromBody] SubmitCodeRequest request, CancellationToken ct)
+ {
+ var userId = UserId;
+ if (userId == null) return Unauthorized();
+
+ var lesson = await _db.Lessons.FindAsync(new object[] { id }, ct);
+ if (lesson == null) return NotFound();
+
+ var job = new SubmissionJob
+ {
+ Id = Guid.NewGuid(),
+ UserId = userId.Value,
+ LessonId = id,
+ Code = request.Code,
+ Status = "Pending",
+ CreatedAtUtc = DateTime.UtcNow
+ };
+ _db.SubmissionJobs.Add(job);
+ await _db.SaveChangesAsync(ct);
+ await _queue.EnqueueAsync(job.Id, ct);
+
+ return Accepted(new SubmitAsyncResponse(job.Id));
+ }
+
+ private static string NormalizeOutput(string s)
+ {
+ if (string.IsNullOrEmpty(s)) return "";
+ return s.TrimEnd().Replace("\r\n", "\n").Replace("\r", "\n");
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/NotificationsController.cs b/backend/CodeFlow.Api/Controllers/NotificationsController.cs
new file mode 100644
index 0000000..76058d0
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/NotificationsController.cs
@@ -0,0 +1,63 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using CodeFlow.Api.Models;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+[Authorize]
+public class NotificationsController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public NotificationsController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ private Guid? UserId => Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
+
+ [HttpGet]
+ public async Task>> GetMy([FromQuery] bool unreadOnly = false, [FromQuery] int limit = 50, CancellationToken ct = default)
+ {
+ var userId = UserId;
+ if (userId == null) return Unauthorized();
+ if (limit <= 0 || limit > 100) limit = 50;
+
+ var query = _db.UserNotifications.Where(n => n.UserId == userId.Value);
+ if (unreadOnly) query = query.Where(n => !n.IsRead);
+ var list = await query
+ .OrderByDescending(n => n.CreatedAtUtc)
+ .Take(limit)
+ .Select(n => new NotificationDto(n.Id, n.Type, n.Title, n.Body, n.CreatedAtUtc, n.IsRead))
+ .ToListAsync(ct);
+ return Ok(list);
+ }
+
+ [HttpPatch("{id:guid}/read")]
+ public async Task MarkRead(Guid id, CancellationToken ct)
+ {
+ var userId = UserId;
+ if (userId == null) return Unauthorized();
+ var n = await _db.UserNotifications.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId.Value, ct);
+ if (n == null) return NotFound();
+ n.IsRead = true;
+ await _db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+
+ [HttpPost("read-all")]
+ public async Task MarkAllRead(CancellationToken ct)
+ {
+ var userId = UserId;
+ if (userId == null) return Unauthorized();
+ await _db.UserNotifications.Where(n => n.UserId == userId.Value && !n.IsRead).ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct);
+ return NoContent();
+ }
+}
+
+public record NotificationDto(Guid Id, string Type, string Title, string? Body, DateTime CreatedAtUtc, bool IsRead);
diff --git a/backend/CodeFlow.Api/Controllers/ProgressController.cs b/backend/CodeFlow.Api/Controllers/ProgressController.cs
new file mode 100644
index 0000000..123dfbc
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/ProgressController.cs
@@ -0,0 +1,42 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using CodeFlow.Api.DTOs;
+using CodeFlow.Api.Services;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+[Authorize]
+public class ProgressController : ControllerBase
+{
+ private readonly IProgressService _progress;
+
+ public ProgressController(IProgressService progress)
+ {
+ _progress = progress;
+ }
+
+ private Guid? UserId => Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
+
+ [HttpGet]
+ public async Task> GetMyProgress(CancellationToken ct)
+ {
+ var userId = UserId;
+ if (userId == null) return Unauthorized();
+ var summary = await _progress.GetProgressAsync(userId.Value, ct);
+ if (summary == null) return NotFound();
+ return Ok(summary);
+ }
+
+ [HttpPost("complete")]
+ public async Task> CompleteLesson([FromBody] CompleteLessonRequest request, CancellationToken ct)
+ {
+ var userId = UserId;
+ if (userId == null) return Unauthorized();
+ var result = await _progress.CompleteLessonAsync(userId.Value, request, ct);
+ if (result == null) return BadRequest(new { message = "Lesson not found or already completed." });
+ return Ok(result);
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/ReportsController.cs b/backend/CodeFlow.Api/Controllers/ReportsController.cs
new file mode 100644
index 0000000..4740126
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/ReportsController.cs
@@ -0,0 +1,175 @@
+using System.Security.Claims;
+using System.Text;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+[Authorize]
+public class ReportsController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public ReportsController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ private Guid? UserId => Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
+
+ [HttpGet("progress")]
+ public async Task ExportProgress([FromQuery] string format = "csv", CancellationToken ct = default)
+ {
+ var userId = UserId;
+ if (userId == null) return Unauthorized();
+
+ var user = await _db.Users.FindAsync(new object[] { userId.Value }, ct);
+ if (user == null) return NotFound();
+
+ var progress = await _db.UserProgress
+ .Where(p => p.UserId == userId.Value)
+ .Include(p => p.Lesson)
+ .OrderBy(p => p.CompletedAtUtc)
+ .Select(p => new { p.LessonId, LessonTitle = p.Lesson.Title, p.CompletedAtUtc, p.XpEarned, p.WasCleanRun })
+ .ToListAsync(ct);
+
+ if (format.Equals("csv", StringComparison.OrdinalIgnoreCase))
+ {
+ var csv = new StringBuilder();
+ csv.AppendLine("LessonId;LessonTitle;CompletedAtUtc;XpEarned;WasCleanRun");
+ foreach (var p in progress)
+ csv.AppendLine($"{p.LessonId};{EscapeCsv(p.LessonTitle)};{p.CompletedAtUtc:O};{p.XpEarned};{p.WasCleanRun}");
+ csv.AppendLine();
+ csv.AppendLine($"TotalXp;{user.TotalXp}");
+ return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv; charset=utf-8", "progress.csv");
+ }
+
+ if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase))
+ {
+ var bytes = Document.Create(container =>
+ {
+ container.Page(page =>
+ {
+ page.Size(PageSizes.A4);
+ page.Margin(2, Unit.Centimetre);
+ page.Header().Text("Отчёт по прогрессу — CodeFlow").Bold().FontSize(18).FontColor(Colors.Blue.Medium);
+ page.Content().PaddingTop(1, Unit.Centimetre).Table(table =>
+ {
+ table.ColumnsDefinition(c =>
+ {
+ c.ConstantColumn(40);
+ c.RelativeColumn();
+ c.ConstantColumn(80);
+ c.ConstantColumn(60);
+ c.ConstantColumn(70);
+ });
+ table.Header(h =>
+ {
+ h.Cell().BorderBottom(1).Padding(4).Text("#").Bold();
+ h.Cell().BorderBottom(1).Padding(4).Text("Урок").Bold();
+ h.Cell().BorderBottom(1).Padding(4).Text("Дата").Bold();
+ h.Cell().BorderBottom(1).Padding(4).Text("XP").Bold();
+ h.Cell().BorderBottom(1).Padding(4).Text("Чистый").Bold();
+ });
+ var i = 1;
+ foreach (var p in progress)
+ {
+ table.Cell().Padding(4).Text(i.ToString());
+ table.Cell().Padding(4).Text(p.LessonTitle);
+ table.Cell().Padding(4).Text(p.CompletedAtUtc.ToString("yyyy-MM-dd HH:mm"));
+ table.Cell().Padding(4).Text(p.XpEarned.ToString());
+ table.Cell().Padding(4).Text(p.WasCleanRun ? "Да" : "Нет");
+ i++;
+ }
+ });
+ page.Footer().AlignCenter().Text(x => { x.Span("Всего XP: "); x.Span(user.TotalXp.ToString()); });
+ });
+ }).GeneratePdf();
+ return File(bytes, "application/pdf", "progress.pdf");
+ }
+
+ return BadRequest(new { message = "Supported format: csv, pdf" });
+ }
+
+ [HttpGet("leaderboard")]
+ public async Task ExportLeaderboard([FromQuery] string format = "csv", [FromQuery] int limit = 100, CancellationToken ct = default)
+ {
+ if (limit <= 0 || limit > 500) limit = 100;
+
+ var list = await _db.Users
+ .OrderByDescending(u => u.TotalXp)
+ .Take(limit)
+ .Select(u => new { u.Id, u.DisplayName, u.TotalXp, u.CreatedAtUtc })
+ .ToListAsync(ct);
+
+ if (format.Equals("csv", StringComparison.OrdinalIgnoreCase))
+ {
+ var csv = new StringBuilder();
+ csv.AppendLine("Rank;UserId;DisplayName;TotalXp;CreatedAtUtc");
+ var rank = 1;
+ foreach (var u in list)
+ {
+ csv.AppendLine($"{rank};{u.Id};{EscapeCsv(u.DisplayName)};{u.TotalXp};{u.CreatedAtUtc:O}");
+ rank++;
+ }
+ return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv; charset=utf-8", "leaderboard.csv");
+ }
+
+ if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase))
+ {
+ var bytes = Document.Create(container =>
+ {
+ container.Page(page =>
+ {
+ page.Size(PageSizes.A4);
+ page.Margin(2, Unit.Centimetre);
+ page.Header().Text("Рейтинг — CodeFlow").Bold().FontSize(18).FontColor(Colors.Blue.Medium);
+ page.Content().PaddingTop(1, Unit.Centimetre).Table(table =>
+ {
+ table.ColumnsDefinition(c =>
+ {
+ c.ConstantColumn(50);
+ c.RelativeColumn();
+ c.ConstantColumn(80);
+ c.ConstantColumn(90);
+ });
+ table.Header(h =>
+ {
+ h.Cell().BorderBottom(1).Padding(4).Text("Место").Bold();
+ h.Cell().BorderBottom(1).Padding(4).Text("Имя").Bold();
+ h.Cell().BorderBottom(1).Padding(4).Text("XP").Bold();
+ h.Cell().BorderBottom(1).Padding(4).Text("Регистрация").Bold();
+ });
+ var rank = 1;
+ foreach (var u in list)
+ {
+ table.Cell().Padding(4).Text(rank.ToString());
+ table.Cell().Padding(4).Text(u.DisplayName);
+ table.Cell().Padding(4).Text(u.TotalXp.ToString());
+ table.Cell().Padding(4).Text(u.CreatedAtUtc.ToString("yyyy-MM-dd"));
+ rank++;
+ }
+ });
+ });
+ }).GeneratePdf();
+ return File(bytes, "application/pdf", "leaderboard.pdf");
+ }
+
+ return BadRequest(new { message = "Supported format: csv, pdf" });
+ }
+
+ private static string EscapeCsv(string s)
+ {
+ if (string.IsNullOrEmpty(s)) return "";
+ if (s.Contains(';') || s.Contains('"') || s.Contains('\n'))
+ return "\"" + s.Replace("\"", "\"\"") + "\"";
+ return s;
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/SearchController.cs b/backend/CodeFlow.Api/Controllers/SearchController.cs
new file mode 100644
index 0000000..cec773d
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/SearchController.cs
@@ -0,0 +1,44 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using CodeFlow.Api.DTOs;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class SearchController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public SearchController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ public async Task> Search([FromQuery] string? q, [FromQuery] int limit = 20, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
+ return Ok(new SearchResultDto(Array.Empty(), Array.Empty()));
+
+ if (limit <= 0 || limit > 50) limit = 20;
+ var term = $"%{q.Trim()}%";
+
+ var courses = await _db.Courses
+ .Where(c => EF.Functions.ILike(c.Title, term) || EF.Functions.ILike(c.Description, term) || EF.Functions.ILike(c.Level, term))
+ .Take(limit)
+ .Select(c => new CourseDto(c.Id, c.Title, c.Description, c.Level, c.Color, c.TotalLessons))
+ .ToListAsync(ct);
+
+ var lessons = await _db.Lessons
+ .Where(l => EF.Functions.ILike(l.Title, term) || EF.Functions.ILike(l.Description, term) || EF.Functions.ILike(l.Chapter, term) || EF.Functions.ILike(l.Task, term))
+ .Take(limit)
+ .Select(l => new LessonDto(l.Id, l.CourseId, l.Chapter, l.Title, l.Description, l.Task, l.InitialCode, l.ExpectedOutput, l.Xp, l.IsBoss, l.HasDebugger, l.Hint, l.Hint2))
+ .ToListAsync(ct);
+
+ return Ok(new SearchResultDto(courses, lessons));
+ }
+}
+
+public record SearchResultDto(IReadOnlyList Courses, IReadOnlyList Lessons);
diff --git a/backend/CodeFlow.Api/Controllers/ShopController.cs b/backend/CodeFlow.Api/Controllers/ShopController.cs
new file mode 100644
index 0000000..a937eb2
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/ShopController.cs
@@ -0,0 +1,75 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using CodeFlow.Api.DTOs;
+using CodeFlow.Api.Models;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class ShopController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public ShopController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet("items")]
+ public async Task>> GetItems(CancellationToken ct)
+ {
+ var list = await _db.ShopItems
+ .Select(s => new ShopItemDto(s.Id, s.Name, s.Color, s.Bg, s.Price))
+ .ToListAsync(ct);
+ return Ok(list);
+ }
+
+ [HttpPost("purchase")]
+ [Authorize]
+ public async Task> Purchase([FromBody] PurchaseRequest request, CancellationToken ct)
+ {
+ var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (userIdStr == null || !Guid.TryParse(userIdStr, out var userId))
+ return Unauthorized();
+
+ var user = await _db.Users
+ .Include(u => u.OwnedShopItems)
+ .FirstOrDefaultAsync(u => u.Id == userId, ct);
+ var item = await _db.ShopItems.FindAsync(new object[] { request.ShopItemId }, ct);
+ if (user == null || item == null) return NotFound();
+ if (user.OwnedShopItems.Any(o => o.ShopItemId == request.ShopItemId))
+ return BadRequest(new { message = "Already owned." });
+ if (user.TotalXp < item.Price)
+ return BadRequest(new { message = "Not enough XP." });
+
+ user.TotalXp -= item.Price;
+ user.OwnedShopItems.Add(new UserShopItem
+ {
+ UserId = userId,
+ ShopItemId = item.Id,
+ PurchasedAtUtc = DateTime.UtcNow
+ });
+ await _db.SaveChangesAsync(ct);
+
+ return Ok(new ShopItemDto(item.Id, item.Name, item.Color, item.Bg, item.Price));
+ }
+
+ [HttpGet("me")]
+ [Authorize]
+ public async Task>> GetMyItems(CancellationToken ct)
+ {
+ var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (userIdStr == null || !Guid.TryParse(userIdStr, out var userId))
+ return Unauthorized();
+ var list = await _db.UserShopItems
+ .Where(o => o.UserId == userId)
+ .Include(o => o.ShopItem)
+ .Select(o => new ShopItemDto(o.ShopItem.Id, o.ShopItem.Name, o.ShopItem.Color, o.ShopItem.Bg, o.ShopItem.Price))
+ .ToListAsync(ct);
+ return Ok(list);
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/SubmissionsController.cs b/backend/CodeFlow.Api/Controllers/SubmissionsController.cs
new file mode 100644
index 0000000..af537ac
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/SubmissionsController.cs
@@ -0,0 +1,45 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Data;
+using CodeFlow.Api.DTOs;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+[Authorize]
+public class SubmissionsController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public SubmissionsController(AppDbContext db)
+ {
+ _db = db;
+ }
+
+ private Guid? UserId => Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
+
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id, CancellationToken ct)
+ {
+ var userId = UserId;
+ if (userId == null) return Unauthorized();
+
+ var job = await _db.SubmissionJobs
+ .AsNoTracking()
+ .FirstOrDefaultAsync(j => j.Id == id && j.UserId == userId.Value, ct);
+ if (job == null) return NotFound();
+
+ return Ok(new SubmissionStatusDto(
+ job.Id,
+ job.Status,
+ job.Output,
+ job.Error,
+ job.Passed,
+ job.CreatedAtUtc,
+ job.CompletedAtUtc
+ ));
+ }
+}
diff --git a/backend/CodeFlow.Api/Controllers/UsersController.cs b/backend/CodeFlow.Api/Controllers/UsersController.cs
new file mode 100644
index 0000000..67cd71b
--- /dev/null
+++ b/backend/CodeFlow.Api/Controllers/UsersController.cs
@@ -0,0 +1,44 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using CodeFlow.Api.DTOs;
+using CodeFlow.Api.Services;
+
+namespace CodeFlow.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+[Authorize]
+public class UsersController : ControllerBase
+{
+ private readonly IUserService _userService;
+
+ public UsersController(IUserService userService)
+ {
+ _userService = userService;
+ }
+
+ private Guid? UserId => Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
+
+ [HttpGet("me")]
+ public async Task> GetMe(CancellationToken ct)
+ {
+ var userId = UserId;
+ if (userId == null) return Unauthorized();
+ var user = await _userService.GetByIdAsync(userId.Value, ct);
+ if (user == null) return NotFound();
+ return Ok(user);
+ }
+
+ [HttpPatch("me")]
+ public async Task> UpdateMe([FromBody] UpdateProfileRequest? request, CancellationToken ct)
+ {
+ var userId = UserId;
+ if (userId == null) return Unauthorized();
+ var user = await _userService.UpdateProfileAsync(userId.Value, request?.DisplayName, ct);
+ if (user == null) return NotFound();
+ return Ok(user);
+ }
+}
+
+public record UpdateProfileRequest(string? DisplayName);
diff --git a/backend/CodeFlow.Api/DTOs/AchievementDtos.cs b/backend/CodeFlow.Api/DTOs/AchievementDtos.cs
new file mode 100644
index 0000000..ce3f969
--- /dev/null
+++ b/backend/CodeFlow.Api/DTOs/AchievementDtos.cs
@@ -0,0 +1,4 @@
+namespace CodeFlow.Api.DTOs;
+
+public record AchievementDefinitionDto(string Id, string Title, string Description, string Icon, string Rarity);
+public record UserAchievementDto(string AchievementId, DateTime UnlockedAtUtc);
diff --git a/backend/CodeFlow.Api/DTOs/AdminDtos.cs b/backend/CodeFlow.Api/DTOs/AdminDtos.cs
new file mode 100644
index 0000000..5cf81b5
--- /dev/null
+++ b/backend/CodeFlow.Api/DTOs/AdminDtos.cs
@@ -0,0 +1,61 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CodeFlow.Api.DTOs;
+
+public record CreateCourseRequest(
+ [Required, MinLength(1), MaxLength(200)] string Title,
+ [Required, MinLength(1)] string Description,
+ [MaxLength(100)] string Level,
+ [MaxLength(50)] string Color,
+ int TotalLessons
+);
+
+public record UpdateCourseRequest(
+ [MinLength(1), MaxLength(200)] string? Title,
+ [MinLength(1)] string? Description,
+ [MaxLength(100)] string? Level,
+ [MaxLength(50)] string? Color,
+ int? TotalLessons
+);
+
+public record CreateLessonRequest(
+ [Required] int CourseId,
+ [Required, MinLength(1)] string Chapter,
+ [Required, MinLength(1)] string Title,
+ [Required] string Description,
+ [Required] string Task,
+ [Required] string InitialCode,
+ [Required] string ExpectedOutput,
+ int Xp,
+ bool IsBoss,
+ bool HasDebugger,
+ [Required] string Hint,
+ [Required] string Hint2
+);
+
+public record UpdateLessonRequest(
+ int? CourseId,
+ string? Chapter,
+ string? Title,
+ string? Description,
+ string? Task,
+ string? InitialCode,
+ string? ExpectedOutput,
+ int? Xp,
+ bool? IsBoss,
+ bool? HasDebugger,
+ string? Hint,
+ string? Hint2
+);
+
+public record SetUserRoleRequest([Required] string Role);
+
+public record AdminUserDto(
+ Guid Id,
+ string Email,
+ string DisplayName,
+ string Role,
+ int TotalXp,
+ DateTime CreatedAtUtc,
+ bool EmailConfirmed
+);
diff --git a/backend/CodeFlow.Api/DTOs/AuthDtos.cs b/backend/CodeFlow.Api/DTOs/AuthDtos.cs
new file mode 100644
index 0000000..d832137
--- /dev/null
+++ b/backend/CodeFlow.Api/DTOs/AuthDtos.cs
@@ -0,0 +1,34 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CodeFlow.Api.DTOs;
+
+public record RegisterRequest(
+ [Required, MinLength(1), EmailAddress] string Email,
+ [Required, MinLength(6)] string Password,
+ [MaxLength(100)] string? DisplayName
+);
+
+public record LoginRequest(
+ [Required, MinLength(1)] string Email,
+ [Required, MinLength(1)] string Password
+);
+
+public record ForgotPasswordRequest([Required, EmailAddress] string Email);
+
+public record ResetPasswordRequest(
+ [Required, MinLength(1)] string Token,
+ [Required, EmailAddress] string Email,
+ [Required, MinLength(6)] string NewPassword
+);
+
+public record AuthResponse(string AccessToken, string TokenType, int ExpiresInSeconds, UserDto User);
+
+public record UserDto(
+ Guid Id,
+ string Email,
+ string DisplayName,
+ int TotalXp,
+ DateTime CreatedAtUtc,
+ bool EmailConfirmed,
+ string Role
+);
diff --git a/backend/CodeFlow.Api/DTOs/CourseDtos.cs b/backend/CodeFlow.Api/DTOs/CourseDtos.cs
new file mode 100644
index 0000000..94e1d53
--- /dev/null
+++ b/backend/CodeFlow.Api/DTOs/CourseDtos.cs
@@ -0,0 +1,18 @@
+namespace CodeFlow.Api.DTOs;
+
+public record CourseDto(int Id, string Title, string Description, string Level, string Color, int TotalLessons);
+public record LessonDto(
+ int Id,
+ int CourseId,
+ string Chapter,
+ string Title,
+ string Description,
+ string Task,
+ string InitialCode,
+ string ExpectedOutput,
+ int Xp,
+ bool IsBoss,
+ bool HasDebugger,
+ string Hint,
+ string Hint2
+);
diff --git a/backend/CodeFlow.Api/DTOs/FactionDtos.cs b/backend/CodeFlow.Api/DTOs/FactionDtos.cs
new file mode 100644
index 0000000..03412a0
--- /dev/null
+++ b/backend/CodeFlow.Api/DTOs/FactionDtos.cs
@@ -0,0 +1,4 @@
+namespace CodeFlow.Api.DTOs;
+
+public record FactionDto(string Id, string Name, string Description, string Icon, string Color, string Bonus, int RequiredRep);
+public record UserReputationDto(string FactionId, int Reputation);
diff --git a/backend/CodeFlow.Api/DTOs/LeaderboardDtos.cs b/backend/CodeFlow.Api/DTOs/LeaderboardDtos.cs
new file mode 100644
index 0000000..1e4937d
--- /dev/null
+++ b/backend/CodeFlow.Api/DTOs/LeaderboardDtos.cs
@@ -0,0 +1,3 @@
+namespace CodeFlow.Api.DTOs;
+
+public record LeaderboardEntryDto(int Rank, Guid UserId, string DisplayName, int TotalXp);
diff --git a/backend/CodeFlow.Api/DTOs/ProgressDtos.cs b/backend/CodeFlow.Api/DTOs/ProgressDtos.cs
new file mode 100644
index 0000000..c5b8dc7
--- /dev/null
+++ b/backend/CodeFlow.Api/DTOs/ProgressDtos.cs
@@ -0,0 +1,11 @@
+namespace CodeFlow.Api.DTOs;
+
+public record CompleteLessonRequest(int LessonId, bool WasCleanRun);
+public record ProgressDto(int LessonId, DateTime CompletedAtUtc, int XpEarned, bool WasCleanRun);
+public record UserProgressSummaryDto(
+ int TotalXp,
+ int CompletedLessonsCount,
+ IReadOnlyList CompletedLessonIds,
+ int CleanStreak,
+ bool FastBossKill
+);
diff --git a/backend/CodeFlow.Api/DTOs/ShopDtos.cs b/backend/CodeFlow.Api/DTOs/ShopDtos.cs
new file mode 100644
index 0000000..0e6cee1
--- /dev/null
+++ b/backend/CodeFlow.Api/DTOs/ShopDtos.cs
@@ -0,0 +1,7 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CodeFlow.Api.DTOs;
+
+public record ShopItemDto(string Id, string Name, string Color, string Bg, int Price);
+
+public record PurchaseRequest([Required, MinLength(1)] string ShopItemId);
diff --git a/backend/CodeFlow.Api/DTOs/SubmitDtos.cs b/backend/CodeFlow.Api/DTOs/SubmitDtos.cs
new file mode 100644
index 0000000..6eb39bb
--- /dev/null
+++ b/backend/CodeFlow.Api/DTOs/SubmitDtos.cs
@@ -0,0 +1,27 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CodeFlow.Api.DTOs;
+
+public record SubmitCodeRequest([Required, MinLength(1)] string Code);
+
+public record SubmitResultDto(
+ bool Passed,
+ string Output,
+ string Expected,
+ string? Error,
+ string? FailureReason
+);
+
+/// Ответ при асинхронной отправке кода: id задачи для опроса статуса.
+public record SubmitAsyncResponse(Guid JobId);
+
+/// Статус задачи проверки кода.
+public record SubmissionStatusDto(
+ Guid Id,
+ string Status,
+ string? Output,
+ string? Error,
+ bool? Passed,
+ DateTime CreatedAtUtc,
+ DateTime? CompletedAtUtc
+);
diff --git a/backend/CodeFlow.Api/Data/AppDbContext.cs b/backend/CodeFlow.Api/Data/AppDbContext.cs
new file mode 100644
index 0000000..bd11754
--- /dev/null
+++ b/backend/CodeFlow.Api/Data/AppDbContext.cs
@@ -0,0 +1,85 @@
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Models;
+
+namespace CodeFlow.Api.Data;
+
+public class AppDbContext : DbContext
+{
+ public AppDbContext(DbContextOptions options) : base(options) { }
+
+ public DbSet Users => Set();
+ public DbSet Courses => Set();
+ public DbSet Lessons => Set();
+ public DbSet UserProgress => Set();
+ public DbSet AchievementDefinitions => Set();
+ public DbSet UserAchievements => Set();
+ public DbSet Factions => Set();
+ public DbSet UserReputations => Set();
+ public DbSet ShopItems => Set();
+ public DbSet UserShopItems => Set();
+ public DbSet UserNotifications => Set();
+ public DbSet SubmissionJobs => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+ e.HasIndex(x => x.Email).IsUnique();
+ e.HasIndex(x => x.TotalXp).IsDescending();
+ });
+
+ modelBuilder.Entity(e => e.HasKey(x => x.Id));
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+ e.HasOne(x => x.Course).WithMany(c => c.Lessons).HasForeignKey(x => x.CourseId);
+ });
+
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(x => new { x.UserId, x.LessonId });
+ e.HasOne(x => x.User).WithMany(u => u.Progress).HasForeignKey(x => x.UserId);
+ e.HasOne(x => x.Lesson).WithMany().HasForeignKey(x => x.LessonId);
+ });
+
+ modelBuilder.Entity(e => e.HasKey(x => x.Id));
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(x => new { x.UserId, x.AchievementId });
+ e.HasOne(x => x.User).WithMany(u => u.Achievements).HasForeignKey(x => x.UserId);
+ e.HasOne(x => x.Achievement).WithMany().HasForeignKey(x => x.AchievementId);
+ });
+
+ modelBuilder.Entity(e => e.HasKey(x => x.Id));
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(x => new { x.UserId, x.FactionId });
+ e.HasOne(x => x.User).WithMany(u => u.Reputation).HasForeignKey(x => x.UserId);
+ e.HasOne(x => x.Faction).WithMany(f => f.UserReputations).HasForeignKey(x => x.FactionId);
+ });
+
+ modelBuilder.Entity(e => e.HasKey(x => x.Id));
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(x => new { x.UserId, x.ShopItemId });
+ e.HasOne(x => x.User).WithMany(u => u.OwnedShopItems).HasForeignKey(x => x.UserId);
+ e.HasOne(x => x.ShopItem).WithMany().HasForeignKey(x => x.ShopItemId);
+ });
+
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+ e.HasIndex(x => x.UserId);
+ e.HasOne(x => x.User).WithMany(u => u.Notifications).HasForeignKey(x => x.UserId);
+ });
+
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+ e.HasIndex(x => new { x.UserId, x.CreatedAtUtc });
+ e.HasOne(x => x.User).WithMany(u => u.SubmissionJobs).HasForeignKey(x => x.UserId);
+ e.HasOne(x => x.Lesson).WithMany().HasForeignKey(x => x.LessonId);
+ });
+ }
+}
diff --git a/backend/CodeFlow.Api/Data/SeedData.cs b/backend/CodeFlow.Api/Data/SeedData.cs
new file mode 100644
index 0000000..a140ede
--- /dev/null
+++ b/backend/CodeFlow.Api/Data/SeedData.cs
@@ -0,0 +1,101 @@
+using Microsoft.EntityFrameworkCore;
+using CodeFlow.Api.Models;
+
+namespace CodeFlow.Api.Data;
+
+public static class SeedData
+{
+ public static async Task EnsureSeedAsync(AppDbContext db)
+ {
+ if (await db.Courses.AnyAsync())
+ return;
+
+ // Courses
+ var course = new Course
+ {
+ Id = 1,
+ Title = "Операция 'Тихий Шторм'",
+ Description = "Проникни в ядро OmniCorp и уничтожь Левиафана. Полный курс Python с интерактивными туториалами.",
+ Level = "Сюжетная кампания",
+ Color = "green",
+ TotalLessons = 15
+ };
+ db.Courses.Add(course);
+ db.Courses.Add(new Course
+ {
+ Id = 2,
+ Title = "Сетевые протоколы (DLC)",
+ Description = "Дополнительные задачи на работу со словарями и кортежами. [COMING SOON]",
+ Level = "Сложный",
+ Color = "blue",
+ TotalLessons = 0
+ });
+
+ // Lessons (from frontend lessons.ts)
+ var lessons = new List
+ {
+ new() { Id = 1, CourseId = 1, Chapter = "Глава 1: Проникновение", Title = "Миссия 1: Точка входа", Description = "Мы подключились к внешнему узлу OmniCorp. Чтобы подтвердить стабильность канала связи, необходимо отправить идентификационный пакет `CONNECTION_STABLE`.", Task = "Используй print(), чтобы вывести: CONNECTION_STABLE", InitialCode = "# Введи команду вывода ниже:\n", ExpectedOutput = "CONNECTION_STABLE", Xp = 50, HasDebugger = true, Hint = "Тебе нужна функция для вывода текста в консоль.", Hint2 = "Используй: print('ТВОЙ_ТЕКСТ')" },
+ new() { Id = 2, CourseId = 1, Chapter = "Глава 1: Проникновение", Title = "Миссия 2: Энергосеть", Description = "Для активации дешифратора нужно сложить мощности двух подстанций: 1024 и 2048.", Task = "Выведи результат сложения 1024 + 2048.", InitialCode = "# Сложи числа внутри функции вывода\n", ExpectedOutput = "3072", Xp = 100, HasDebugger = true, Hint = "Python может считать прямо внутри print().", Hint2 = "Пример: print(5 + 5)" },
+ new() { Id = 3, CourseId = 1, Chapter = "Глава 1: Проникновение", Title = "Миссия 3: Переменные доступа", Description = "Система запрашивает ключ. Глитч нашёл код: 777. Сохрани его в переменную `key`.", Task = "Создай переменную key = 777 и выведи её на экран.", InitialCode = "# Создай переменную и выведи её\n", ExpectedOutput = "777", Xp = 150, HasDebugger = true, Hint = "Сначала присвой значение переменной, а потом передай её имя в print().", Hint2 = "x = 10\nprint(x)" },
+ new() { Id = 4, CourseId = 1, IsBoss = true, Chapter = "Глава 1: Проникновение", Title = "⚠️ БОСС: Обход биометрии", Description = "ВНИМАНИЕ! Сработал сканер. Нужно отправить два параметра: `admin` и `123`.", Task = "Создай user = 'admin', pass_code = 123. Выведи сначала user, затем pass_code.", InitialCode = "# Взломай биометрию за 60 секунд!\n", ExpectedOutput = "admin\n123", Xp = 500, Hint = "Тебе нужно создать две переменные и дважды вызвать функцию вывода.", Hint2 = "Для текста используй кавычки, для чисел — нет." },
+ new() { Id = 5, CourseId = 1, Chapter = "Глава 2: Файрвол", Title = "Миссия 5: Логический фильтр", Description = "Файрвол пропускает пакеты только если `x` больше 100.", Task = "Задай x = 150. Если x > 100, выведи 'OPEN'.", InitialCode = "x = 150\n# Напиши условие ниже:\n", ExpectedOutput = "OPEN", Xp = 200, HasDebugger = true, Hint = "Используй оператор сравнения '>' внутри блока if.", Hint2 = "if x > 50:\n print('Да')" },
+ new() { Id = 6, CourseId = 1, Chapter = "Глава 2: Файрвол", Title = "Миссия 6: Двойная проверка", Description = "Если статус 'active' — выведи 'READY', иначе — 'ERROR'.", Task = "Задай status = 'active'. Используй if-else.", InitialCode = "status = 'active'\n", ExpectedOutput = "READY", Xp = 250, HasDebugger = true, Hint = "Тебе понадобится блок else для обработки случая, когда условие неверно.", Hint2 = "if status == '...':\n ...\nelse:\n ..." },
+ new() { Id = 7, CourseId = 1, IsBoss = true, Chapter = "Глава 2: Файрвол", Title = "⚠️ БОСС: ИИ 'Цербер'", Description = "Цербер требует уровень 3. Выведи 'HIGH'.", Task = "Задай level = 3. Используй if-elif-else, чтобы вывести 'HIGH' для уровня 3.", InitialCode = "level = 3\n", ExpectedOutput = "HIGH", Xp = 600, Hint = "Используй elif для проверки нескольких условий подряд.", Hint2 = "if l == 1: ...\nelif l == 3: ...\nelse: ..." },
+ new() { Id = 8, CourseId = 1, Chapter = "Глава 3: Брутфорс", Title = "Миссия 8: Цикличный взлом", Description = "Нужно 5 раз отправить сигнал 'HACK'.", Task = "Используй цикл for и range(5), чтобы 5 раз вывести слово 'HACK'.", InitialCode = "# Повтори вывод 5 раз\n", ExpectedOutput = "HACK\nHACK\nHACK\nHACK\nHACK", Xp = 300, HasDebugger = true, Hint = "Цикл for i in range(N) выполнит код N раз.", Hint2 = "for i in range(5):\n print('...')" },
+ new() { Id = 9, CourseId = 1, Chapter = "Глава 3: Брутфорс", Title = "Миссия 9: Обратный отсчёт", Description = "Запусти обратный отсчёт: 3, 2, 1.", Task = "Используй цикл, чтобы вывести числа 3, 2, 1.", InitialCode = "# Используй range с тремя параметрами\n", ExpectedOutput = "3\n2\n1", Xp = 350, HasDebugger = true, Hint = "range(start, stop, step) позволяет считать в обратном порядке.", Hint2 = "range(3, 0, -1) считает от 3 до 1." },
+ new() { Id = 10, CourseId = 1, IsBoss = true, Chapter = "Глава 3: Брутфорс", Title = "⚠️ БОСС: Подбор пароля", Description = "Выведи попытки 'Try: 0' до 'Try: 3'.", Task = "Используй цикл, чтобы вывести:\nTry: 0\nTry: 1\nTry: 2\nTry: 3", InitialCode = "", ExpectedOutput = "Try: 0\nTry: 1\nTry: 2\nTry: 3", Xp = 700, Hint = "Используй f-строки или запятую в print для объединения текста и числа.", Hint2 = "print(f'Try: {i}')" },
+ new() { Id = 11, CourseId = 1, Chapter = "Глава 4: База данных", Title = "Миссия 11: Список сотрудников", Description = "Извлеки первое имя из списка ['Alice', 'Bob', 'Charlie'].", Task = "Создай список names и выведи элемент с индексом 0.", InitialCode = "names = ['Alice', 'Bob', 'Charlie']\n", ExpectedOutput = "Alice", Xp = 400, HasDebugger = true, Hint = "Доступ к элементу списка осуществляется через квадратные скобки [].", Hint2 = "print(my_list[0])" },
+ new() { Id = 12, CourseId = 1, Chapter = "Глава 4: База данных", Title = "Миссия 12: Длина архива", Description = "Посчитай количество файлов в списке [1, 2, 3, 4, 5].", Task = "Выведи длину списка files с помощью функции len().", InitialCode = "files = [1, 2, 3, 4, 5]\n", ExpectedOutput = "5", Xp = 450, HasDebugger = true, Hint = "Функция len() возвращает размер (длину) объекта.", Hint2 = "print(len(my_list))" },
+ new() { Id = 13, CourseId = 1, IsBoss = true, Chapter = "Глава 4: База данных", Title = "⚠️ БОСС: Извлечение данных", Description = "Выведи все ID из списка ['ID1', 'ID2'] по одному.", Task = "Используй цикл for, чтобы вывести каждый элемент списка на новой строке.", InitialCode = "ids = ['ID1', 'ID2']\n", ExpectedOutput = "ID1\nID2", Xp = 800, Hint = "Цикл for может проходить прямо по элементам списка.", Hint2 = "for item in ids:\n print(item)" },
+ new() { Id = 14, CourseId = 1, Chapter = "Глава 5: Финальный удар", Title = "Миссия 14: Вирусная функция", Description = "Создай функцию `attack`, которая выводит 'STRIKE'.", Task = "Определи функцию и вызови её.", InitialCode = "# Объяви функцию через def\n", ExpectedOutput = "STRIKE", Xp = 500, HasDebugger = true, Hint = "Сначала напиши определение функции, а затем вызови её по имени со скобками.", Hint2 = "def func():\n ...\nfunc()" },
+ new() { Id = 15, CourseId = 1, IsBoss = true, Chapter = "Глава 5: Финальный удар", Title = "🔥 ФИНАЛ: Отключение Левиафана", Description = "Передай функции `shutdown` аргумент 'confirm'.", Task = "Напиши функцию shutdown(msg), которая выводит msg. Вызови её с текстом 'confirm'.", InitialCode = "def shutdown(msg):\n # Твой код тут\n", ExpectedOutput = "confirm", Xp = 2000, Hint = "Функция должна принимать один параметр и печатать его.", Hint2 = "shutdown('confirm')" }
+ };
+ db.Lessons.AddRange(lessons);
+
+ // Achievement definitions
+ var achievements = new[]
+ {
+ new AchievementDefinition { Id = "first_hack", Title = "Первая кровь", Description = "Выполнили свою первую миссию", Icon = "🔌", Rarity = "common" },
+ new AchievementDefinition { Id = "five_missions", Title = "На взводе", Description = "Выполнили 5 миссий", Icon = "⚡", Rarity = "common" },
+ new AchievementDefinition { Id = "ten_missions", Title = "Ветеран", Description = "Выполнили 10 миссий", Icon = "🎖️", Rarity = "rare" },
+ new AchievementDefinition { Id = "all_missions", Title = "Легенда сопротивления", Description = "Пройдите все 15 миссий", Icon = "👑", Rarity = "legendary" },
+ new AchievementDefinition { Id = "boss_slayer", Title = "Убийца Цербера", Description = "Взломали систему защиты Главы 1", Icon = "💀", Rarity = "rare" },
+ new AchievementDefinition { Id = "boss_slayer_2", Title = "Пожиратель файрволов", Description = "Победили ИИ Цербера (Босс Главы 2)", Icon = "🔥", Rarity = "rare" },
+ new AchievementDefinition { Id = "boss_slayer_3", Title = "Мастер брутфорса", Description = "Подобрали пароль на время (Босс Главы 3)", Icon = "🔓", Rarity = "rare" },
+ new AchievementDefinition { Id = "boss_slayer_4", Title = "Архивариус", Description = "Извлекли данные из базы (Босс Главы 4)", Icon = "📁", Rarity = "rare" },
+ new AchievementDefinition { Id = "leviathan_slayer", Title = "Убийца Левиафана", Description = "Отключили финального босса — систему Левиафан", Icon = "🐉", Rarity = "legendary" },
+ new AchievementDefinition { Id = "xp_500", Title = "Накопитель", Description = "Собрали 500 XP", Icon = "💰", Rarity = "common" },
+ new AchievementDefinition { Id = "xp_1000", Title = "Богач", Description = "Собрали более 1000 XP", Icon = "💎", Rarity = "rare" },
+ new AchievementDefinition { Id = "xp_3000", Title = "Магнат", Description = "Собрали более 3000 XP", Icon = "🏆", Rarity = "epic" },
+ new AchievementDefinition { Id = "xp_5000", Title = "Элита", Description = "Собрали более 5000 XP", Icon = "⭐", Rarity = "legendary" },
+ new AchievementDefinition { Id = "speed_demon", Title = "Скоростной демон", Description = "Завершили босс-миссию за 30 секунд", Icon = "⏱️", Rarity = "epic" },
+ new AchievementDefinition { Id = "clean_code", Title = "Чистый код", Description = "Завершили 5 миссий подряд без ошибок", Icon = "✨", Rarity = "epic" },
+ new AchievementDefinition { Id = "night_owl", Title = "Ночная сова", Description = "Кодили после полуночи", Icon = "🦉", Rarity = "rare" },
+ new AchievementDefinition { Id = "collector", Title = "Коллекционер", Description = "Купили все темы в магазине", Icon = "🎨", Rarity = "epic" },
+ new AchievementDefinition { Id = "faction_friend", Title = "Друг андеграунда", Description = "Получили 100+ репутации с любой фракцией", Icon = "🤝", Rarity = "rare" }
+ };
+ db.AchievementDefinitions.AddRange(achievements);
+
+ // Factions
+ var factions = new[]
+ {
+ new Faction { Id = "data_brokers", Name = "Торговцы Данными", Description = "Группа хакеров, специализирующихся на извлечении и продаже информации", Icon = "💾", Color = "blue", Bonus = "+20% XP за задачи со списками и строками", RequiredRep = 0 },
+ new Faction { Id = "crypto_rebels", Name = "Крипто-Повстанцы", Description = "Анархисты, взламывающие финансовые системы", Icon = "🔐", Color = "violet", Bonus = "Доступ к шифрованным миссиям", RequiredRep = 500 },
+ new Faction { Id = "ai_ethicists", Name = "AI-Этики", Description = "Борются за честный и чистый код", Icon = "🤖", Color = "cyan", Bonus = "+15% XP за код без ошибок", RequiredRep = 1000 },
+ new Faction { Id = "ghost_protocol", Name = "Протокол Призрак", Description = "Элитная группа невидимых операторов", Icon = "👻", Color = "dark", Bonus = "Скрытые миссии и эксклюзивный доступ", RequiredRep = 2000 }
+ };
+ db.Factions.AddRange(factions);
+
+ // Shop items (themes)
+ var shopItems = new[]
+ {
+ new ShopItem { Id = "classic", Name = "Classic Green", Color = "#00FF41", Bg = "#050505", Price = 0 },
+ new ShopItem { Id = "cyberia", Name = "Cyberia Blue", Color = "#00FFF9", Bg = "#020b12", Price = 500 },
+ new ShopItem { Id = "blood", Name = "Blood Code", Color = "#FF4136", Bg = "#0f0202", Price = 1000 },
+ new ShopItem { Id = "gold", Name = "Elite Gold", Color = "#FFD700", Bg = "#0a0900", Price = 2500 }
+ };
+ db.ShopItems.AddRange(shopItems);
+
+ await db.SaveChangesAsync();
+ }
+}
diff --git a/backend/CodeFlow.Api/Dockerfile b/backend/CodeFlow.Api/Dockerfile
new file mode 100644
index 0000000..7a6d3c1
--- /dev/null
+++ b/backend/CodeFlow.Api/Dockerfile
@@ -0,0 +1,20 @@
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
+WORKDIR /app
+EXPOSE 8080
+
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+WORKDIR /src
+COPY ["CodeFlow.Api/CodeFlow.Api.csproj", "CodeFlow.Api/"]
+COPY ["CodeFlow.sln", "."]
+RUN dotnet restore "CodeFlow.Api/CodeFlow.Api.csproj"
+COPY . .
+WORKDIR "/src/CodeFlow.Api"
+RUN dotnet build "CodeFlow.Api.csproj" -c Release -o /app/build
+
+FROM build AS publish
+RUN dotnet publish "CodeFlow.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "CodeFlow.Api.dll"]
diff --git a/backend/CodeFlow.Api/Middleware/ExceptionMiddleware.cs b/backend/CodeFlow.Api/Middleware/ExceptionMiddleware.cs
new file mode 100644
index 0000000..1645ce6
--- /dev/null
+++ b/backend/CodeFlow.Api/Middleware/ExceptionMiddleware.cs
@@ -0,0 +1,38 @@
+using System.Net;
+using System.Text.Json;
+
+namespace CodeFlow.Api.Middleware;
+
+public class ExceptionMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+ private readonly IHostEnvironment _env;
+
+ public ExceptionMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment env)
+ {
+ _next = next;
+ _logger = logger;
+ _env = env;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await _next(context);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unhandled exception: {Message}", ex.Message);
+ context.Response.ContentType = "application/json";
+ context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
+ var response = new
+ {
+ message = _env.IsDevelopment() ? ex.Message : "An error occurred.",
+ detail = _env.IsDevelopment() ? ex.StackTrace : (string?)null
+ };
+ await context.Response.WriteAsync(JsonSerializer.Serialize(response));
+ }
+ }
+}
diff --git a/backend/CodeFlow.Api/Migrations/20260201221722_InitialCreate.Designer.cs b/backend/CodeFlow.Api/Migrations/20260201221722_InitialCreate.Designer.cs
new file mode 100644
index 0000000..b6859d9
--- /dev/null
+++ b/backend/CodeFlow.Api/Migrations/20260201221722_InitialCreate.Designer.cs
@@ -0,0 +1,435 @@
+//
+using System;
+using CodeFlow.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace CodeFlow.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260201221722_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("CodeFlow.Api.Models.AchievementDefinition", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Icon")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Rarity")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("AchievementDefinitions");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.Course", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Color")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Level")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TotalLessons")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Courses");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.Faction", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("Bonus")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Icon")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RequiredRep")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Factions");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.Lesson", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Chapter")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CourseId")
+ .HasColumnType("integer");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ExpectedOutput")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("HasDebugger")
+ .HasColumnType("boolean");
+
+ b.Property("Hint")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Hint2")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("InitialCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsBoss")
+ .HasColumnType("boolean");
+
+ b.Property("Task")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Xp")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CourseId");
+
+ b.ToTable("Lessons");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.ShopItem", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("Bg")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Price")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("ShopItems");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("EmailConfirmationToken")
+ .HasColumnType("text");
+
+ b.Property("EmailConfirmedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PasswordResetToken")
+ .HasColumnType("text");
+
+ b.Property("PasswordResetTokenExpiresAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TotalXp")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.UserAchievement", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("AchievementId")
+ .HasColumnType("text");
+
+ b.Property("UnlockedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("UserId", "AchievementId");
+
+ b.HasIndex("AchievementId");
+
+ b.ToTable("UserAchievements");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.UserProgress", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("LessonId")
+ .HasColumnType("integer");
+
+ b.Property("CompletedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WasCleanRun")
+ .HasColumnType("boolean");
+
+ b.Property("XpEarned")
+ .HasColumnType("integer");
+
+ b.HasKey("UserId", "LessonId");
+
+ b.HasIndex("LessonId");
+
+ b.ToTable("UserProgress");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.UserReputation", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("FactionId")
+ .HasColumnType("text");
+
+ b.Property("Reputation")
+ .HasColumnType("integer");
+
+ b.HasKey("UserId", "FactionId");
+
+ b.HasIndex("FactionId");
+
+ b.ToTable("UserReputations");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.UserShopItem", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("ShopItemId")
+ .HasColumnType("text");
+
+ b.Property("PurchasedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("UserId", "ShopItemId");
+
+ b.HasIndex("ShopItemId");
+
+ b.ToTable("UserShopItems");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.Lesson", b =>
+ {
+ b.HasOne("CodeFlow.Api.Models.Course", "Course")
+ .WithMany("Lessons")
+ .HasForeignKey("CourseId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Course");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.UserAchievement", b =>
+ {
+ b.HasOne("CodeFlow.Api.Models.AchievementDefinition", "Achievement")
+ .WithMany()
+ .HasForeignKey("AchievementId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("CodeFlow.Api.Models.User", "User")
+ .WithMany("Achievements")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Achievement");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.UserProgress", b =>
+ {
+ b.HasOne("CodeFlow.Api.Models.Lesson", "Lesson")
+ .WithMany()
+ .HasForeignKey("LessonId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("CodeFlow.Api.Models.User", "User")
+ .WithMany("Progress")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Lesson");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.UserReputation", b =>
+ {
+ b.HasOne("CodeFlow.Api.Models.Faction", "Faction")
+ .WithMany("UserReputations")
+ .HasForeignKey("FactionId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("CodeFlow.Api.Models.User", "User")
+ .WithMany("Reputation")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Faction");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.UserShopItem", b =>
+ {
+ b.HasOne("CodeFlow.Api.Models.ShopItem", "ShopItem")
+ .WithMany()
+ .HasForeignKey("ShopItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("CodeFlow.Api.Models.User", "User")
+ .WithMany("OwnedShopItems")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ShopItem");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.Course", b =>
+ {
+ b.Navigation("Lessons");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.Faction", b =>
+ {
+ b.Navigation("UserReputations");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.User", b =>
+ {
+ b.Navigation("Achievements");
+
+ b.Navigation("OwnedShopItems");
+
+ b.Navigation("Progress");
+
+ b.Navigation("Reputation");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/CodeFlow.Api/Migrations/20260201221722_InitialCreate.cs b/backend/CodeFlow.Api/Migrations/20260201221722_InitialCreate.cs
new file mode 100644
index 0000000..01cd1b7
--- /dev/null
+++ b/backend/CodeFlow.Api/Migrations/20260201221722_InitialCreate.cs
@@ -0,0 +1,297 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace CodeFlow.Api.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "AchievementDefinitions",
+ columns: table => new
+ {
+ Id = table.Column(type: "text", nullable: false),
+ Title = table.Column(type: "text", nullable: false),
+ Description = table.Column(type: "text", nullable: false),
+ Icon = table.Column(type: "text", nullable: false),
+ Rarity = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_AchievementDefinitions", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Courses",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ Title = table.Column(type: "text", nullable: false),
+ Description = table.Column(type: "text", nullable: false),
+ Level = table.Column(type: "text", nullable: false),
+ Color = table.Column(type: "text", nullable: false),
+ TotalLessons = table.Column(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Courses", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Factions",
+ columns: table => new
+ {
+ Id = table.Column(type: "text", nullable: false),
+ Name = table.Column(type: "text", nullable: false),
+ Description = table.Column(type: "text", nullable: false),
+ Icon = table.Column(type: "text", nullable: false),
+ Color = table.Column(type: "text", nullable: false),
+ Bonus = table.Column(type: "text", nullable: false),
+ RequiredRep = table.Column(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Factions", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "ShopItems",
+ columns: table => new
+ {
+ Id = table.Column(type: "text", nullable: false),
+ Name = table.Column(type: "text", nullable: false),
+ Color = table.Column(type: "text", nullable: false),
+ Bg = table.Column(type: "text", nullable: false),
+ Price = table.Column(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ShopItems", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Users",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Email = table.Column(type: "text", nullable: false),
+ PasswordHash = table.Column(type: "text", nullable: false),
+ DisplayName = table.Column(type: "text", nullable: false),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ EmailConfirmedAtUtc = table.Column(type: "timestamp with time zone", nullable: true),
+ EmailConfirmationToken = table.Column(type: "text", nullable: true),
+ PasswordResetToken = table.Column(type: "text", nullable: true),
+ PasswordResetTokenExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: true),
+ TotalXp = table.Column(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Users", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Lessons",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ CourseId = table.Column(type: "integer", nullable: false),
+ Chapter = table.Column(type: "text", nullable: false),
+ Title = table.Column(type: "text", nullable: false),
+ Description = table.Column(type: "text", nullable: false),
+ Task = table.Column(type: "text", nullable: false),
+ InitialCode = table.Column(type: "text", nullable: false),
+ ExpectedOutput = table.Column(type: "text", nullable: false),
+ Xp = table.Column(type: "integer", nullable: false),
+ IsBoss = table.Column(type: "boolean", nullable: false),
+ HasDebugger = table.Column(type: "boolean", nullable: false),
+ Hint = table.Column(type: "text", nullable: false),
+ Hint2 = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Lessons", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Lessons_Courses_CourseId",
+ column: x => x.CourseId,
+ principalTable: "Courses",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "UserAchievements",
+ columns: table => new
+ {
+ UserId = table.Column(type: "uuid", nullable: false),
+ AchievementId = table.Column(type: "text", nullable: false),
+ UnlockedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_UserAchievements", x => new { x.UserId, x.AchievementId });
+ table.ForeignKey(
+ name: "FK_UserAchievements_AchievementDefinitions_AchievementId",
+ column: x => x.AchievementId,
+ principalTable: "AchievementDefinitions",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_UserAchievements_Users_UserId",
+ column: x => x.UserId,
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "UserReputations",
+ columns: table => new
+ {
+ UserId = table.Column(type: "uuid", nullable: false),
+ FactionId = table.Column(type: "text", nullable: false),
+ Reputation = table.Column(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_UserReputations", x => new { x.UserId, x.FactionId });
+ table.ForeignKey(
+ name: "FK_UserReputations_Factions_FactionId",
+ column: x => x.FactionId,
+ principalTable: "Factions",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_UserReputations_Users_UserId",
+ column: x => x.UserId,
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "UserShopItems",
+ columns: table => new
+ {
+ UserId = table.Column(type: "uuid", nullable: false),
+ ShopItemId = table.Column(type: "text", nullable: false),
+ PurchasedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_UserShopItems", x => new { x.UserId, x.ShopItemId });
+ table.ForeignKey(
+ name: "FK_UserShopItems_ShopItems_ShopItemId",
+ column: x => x.ShopItemId,
+ principalTable: "ShopItems",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_UserShopItems_Users_UserId",
+ column: x => x.UserId,
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "UserProgress",
+ columns: table => new
+ {
+ UserId = table.Column(type: "uuid", nullable: false),
+ LessonId = table.Column(type: "integer", nullable: false),
+ CompletedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ XpEarned = table.Column(type: "integer", nullable: false),
+ WasCleanRun = table.Column(type: "boolean", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_UserProgress", x => new { x.UserId, x.LessonId });
+ table.ForeignKey(
+ name: "FK_UserProgress_Lessons_LessonId",
+ column: x => x.LessonId,
+ principalTable: "Lessons",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_UserProgress_Users_UserId",
+ column: x => x.UserId,
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Lessons_CourseId",
+ table: "Lessons",
+ column: "CourseId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_UserAchievements_AchievementId",
+ table: "UserAchievements",
+ column: "AchievementId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_UserProgress_LessonId",
+ table: "UserProgress",
+ column: "LessonId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_UserReputations_FactionId",
+ table: "UserReputations",
+ column: "FactionId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Users_Email",
+ table: "Users",
+ column: "Email",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_UserShopItems_ShopItemId",
+ table: "UserShopItems",
+ column: "ShopItemId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "UserAchievements");
+
+ migrationBuilder.DropTable(
+ name: "UserProgress");
+
+ migrationBuilder.DropTable(
+ name: "UserReputations");
+
+ migrationBuilder.DropTable(
+ name: "UserShopItems");
+
+ migrationBuilder.DropTable(
+ name: "AchievementDefinitions");
+
+ migrationBuilder.DropTable(
+ name: "Lessons");
+
+ migrationBuilder.DropTable(
+ name: "Factions");
+
+ migrationBuilder.DropTable(
+ name: "ShopItems");
+
+ migrationBuilder.DropTable(
+ name: "Users");
+
+ migrationBuilder.DropTable(
+ name: "Courses");
+ }
+ }
+}
diff --git a/backend/CodeFlow.Api/Migrations/20260201225436_AddUserTotalXpIndex.Designer.cs b/backend/CodeFlow.Api/Migrations/20260201225436_AddUserTotalXpIndex.Designer.cs
new file mode 100644
index 0000000..46f9045
--- /dev/null
+++ b/backend/CodeFlow.Api/Migrations/20260201225436_AddUserTotalXpIndex.Designer.cs
@@ -0,0 +1,438 @@
+//
+using System;
+using CodeFlow.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace CodeFlow.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260201225436_AddUserTotalXpIndex")]
+ partial class AddUserTotalXpIndex
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("CodeFlow.Api.Models.AchievementDefinition", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Icon")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Rarity")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("AchievementDefinitions");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.Course", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Color")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Level")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TotalLessons")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Courses");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.Faction", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("Bonus")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Icon")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RequiredRep")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Factions");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.Lesson", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Chapter")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CourseId")
+ .HasColumnType("integer");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ExpectedOutput")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("HasDebugger")
+ .HasColumnType("boolean");
+
+ b.Property("Hint")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Hint2")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("InitialCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsBoss")
+ .HasColumnType("boolean");
+
+ b.Property("Task")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Xp")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CourseId");
+
+ b.ToTable("Lessons");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.ShopItem", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("Bg")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Price")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("ShopItems");
+ });
+
+ modelBuilder.Entity("CodeFlow.Api.Models.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("EmailConfirmationToken")
+ .HasColumnType("text");
+
+ b.Property