diff --git a/Api/wwwroot/css/project.css b/Api/wwwroot/css/project.css index 6cfa478..ad30137 100644 --- a/Api/wwwroot/css/project.css +++ b/Api/wwwroot/css/project.css @@ -4,12 +4,10 @@ --panel-line: #3a3a3a; --text: #f4f4f4; --muted: #838383; - --orange: #f4864d; --blue: #42609f; --green: #407d52; --peach: #ffe3d8; - --serif: "Yeseva One", Georgia, serif; --sans: Inter, Arial, sans-serif; --label: Raleway, Inter, Arial, sans-serif; @@ -49,11 +47,9 @@ select { box-shadow: 0 3px 8px rgba(0, 0, 0, 0.28); border-bottom-right-radius: 14px; border-bottom-left-radius: 14px; - display: flex; align-items: center; justify-content: center; - position: sticky; top: 0; z-index: 40; @@ -62,7 +58,6 @@ select { .brand { text-decoration: none; display: block; - font-family: var(--serif); font-size: clamp(72px, 7vw, 96px); line-height: 1; @@ -76,10 +71,8 @@ select { .home-link { position: absolute; top: 42px; - width: 38px; height: 38px; - border: 0; background: transparent; color: var(--orange); @@ -136,7 +129,6 @@ select { .project-head { margin-bottom: 38px; - display: flex; align-items: center; justify-content: space-between; @@ -145,7 +137,6 @@ select { .project-head h1 { margin: 0; - color: white; font-family: var(--label); font-size: 64px; @@ -158,16 +149,12 @@ select { border: 0; background: transparent; color: white; - display: inline-flex; align-items: center; gap: 9px; - cursor: pointer; - font-family: var(--label); font-weight: 500; - transition: color 0.2s ease; } @@ -192,17 +179,14 @@ select { .project-panel { min-height: 300px; - padding: 24px 26px; background: var(--panel); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25); border: 1px solid transparent; border-radius: 30px; - display: flex; flex-direction: column; gap: 18px; - cursor: pointer; transition: border-color 0.2s ease, @@ -221,45 +205,57 @@ select { } .project-panel-head { + position: relative; + min-height: 58px; display: grid; - grid-template-columns: 22px minmax(0, 1fr) auto; + grid-template-columns: 24px minmax(260px, 1fr) auto; align-items: center; - gap: 14px; + gap: 12px; } .project-color-dot { width: 21px; height: 21px; border-radius: 50%; + z-index: 3; } .project-title-block { min-width: 0; + z-index: 3; } .project-team-name { - margin: 0 0 8px; + margin: 0; + max-width: none; color: var(--orange); font-family: var(--label); - font-size: 16px; - font-weight: 700; - line-height: 1.1; - overflow: hidden; - text-overflow: ellipsis; + font-size: 21px; + font-weight: 800; + line-height: 1.15; + text-align: left; + overflow: visible; + text-overflow: unset; white-space: nowrap; } .project-card-name { - min-width: 0; + position: absolute; + left: 50%; + top: 50%; + max-width: 360px; margin: 0; - + transform: translate(-50%, -50%); color: white; font-family: var(--label); font-size: 38px; font-weight: 700; line-height: 1; - - overflow-wrap: anywhere; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + z-index: 1; } .project-board-count { @@ -267,25 +263,23 @@ select { padding: 0 14px; border: 1px solid rgba(255, 255, 255, 0.14); border-radius: 999px; - color: var(--muted); display: inline-flex; align-items: center; justify-content: center; - font-family: var(--label); font-size: 15px; white-space: nowrap; + justify-self: end; + z-index: 3; } .project-boards { flex: 1; min-height: 180px; - border: 1px solid white; border-radius: 20px; overflow: hidden; - display: flex; flex-direction: column; } @@ -298,18 +292,14 @@ select { .project-board-row { min-height: 66px; - padding: 14px 20px; - display: grid; grid-template-columns: minmax(0, 1fr) minmax(150px, 0.6fr) 32px; align-items: center; gap: 24px; - border-bottom: 1px solid rgba(255, 255, 255, 0.9); color: white; text-decoration: none; - transition: background 0.2s ease, color 0.2s ease; @@ -332,12 +322,10 @@ select { .project-board-name, .project-board-meta { min-width: 0; - font-family: var(--label); font-size: 24px; font-weight: 400; line-height: 1.25; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -358,10 +346,8 @@ select { .project-empty { flex: 1; - display: grid; place-items: center; - color: var(--orange); font-family: var(--serif); font-size: 24px; @@ -369,17 +355,13 @@ select { .project-state { min-height: 220px; - padding: 36px; border: 1px solid var(--panel-line); border-radius: 30px; - background: var(--panel); color: var(--muted); - display: grid; place-items: center; - text-align: center; font-family: var(--label); font-size: 24px; @@ -395,17 +377,13 @@ body.modal-locked { position: fixed; inset: 0; z-index: 999; - display: grid; place-items: center; - padding: 24px; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(3px); - opacity: 0; pointer-events: none; - transition: opacity 0.22s ease; } @@ -417,19 +395,15 @@ body.modal-locked { .project-modal { width: min(720px, calc(100vw - 36px)); max-height: calc(100vh - 48px); - padding: 28px; border: 0; border-radius: 22px; - background: var(--panel); color: white; box-shadow: 0 30px 90px rgba(0, 0, 0, 0.62); - display: flex; flex-direction: column; gap: 26px; - overflow-y: auto; transform: translateY(18px) scale(0.97); transition: transform 0.22s ease; @@ -452,7 +426,6 @@ body.modal-locked { .project-modal-header h2 { margin: 0; - color: white; font-family: var(--label); font-size: 30px; @@ -462,10 +435,8 @@ body.modal-locked { .project-modal-close { position: relative; - width: 42px; height: 42px; - border: 0; background: transparent; cursor: pointer; @@ -475,10 +446,8 @@ body.modal-locked { position: absolute; left: 6px; top: 19px; - width: 32px; height: 4px; - border-radius: 999px; background: white; } @@ -513,15 +482,12 @@ body.modal-locked { .project-select { width: 100%; min-height: 48px; - padding: 0 18px; border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 12px; outline: none; - background: rgba(255, 255, 255, 0.06); color: white; - font-size: 16px; } @@ -542,7 +508,6 @@ body.modal-locked { .project-modal-actions { margin-top: auto; - display: flex; justify-content: space-between; align-items: center; @@ -551,12 +516,9 @@ body.modal-locked { .project-modal-action { min-height: 42px; - border: 0; background: transparent; - cursor: pointer; - font-family: var(--label); font-size: 18px; font-weight: 500; @@ -595,24 +557,18 @@ body.modal-locked { left: 50%; bottom: 46px; z-index: 250; - transform: translateX(-50%) translateY(20px); opacity: 0; pointer-events: none; - min-height: 34px; padding: 0 14px; border-radius: 10px; - background: white; color: #111; - display: flex; align-items: center; gap: 8px; - font-size: 14px; - transition: opacity 0.25s ease, transform 0.25s ease; } @@ -629,6 +585,8 @@ body.modal-locked { color: #000; } +/* MOBILE */ + @media (max-width: 800px) { .project-topbar { height: 82px; @@ -664,18 +622,53 @@ body.modal-locked { } .project-panel-head { - grid-template-columns: 18px minmax(0, 1fr); + min-height: auto; + grid-template-columns: 20px minmax(0, 1fr); + gap: 10px; + align-items: center; } - .project-board-count { - width: max-content; + .project-color-dot { + width: 18px; + height: 18px; + grid-column: 1; + grid-row: 1; + } + + .project-title-block { grid-column: 2; + grid-row: 1; + min-width: 0; + } + + .project-team-name { + max-width: 100%; + margin: 0 0 8px; + font-size: 18px; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .project-card-name { + position: static; + max-width: 100%; + transform: none; + text-align: left; + white-space: normal; + overflow: visible; + text-overflow: unset; font-size: 30px; } + .project-board-count { + width: max-content; + grid-column: 2; + grid-row: 2; + justify-self: start; + } + .project-board-row { grid-template-columns: minmax(0, 1fr) 32px; gap: 8px 12px; @@ -717,4 +710,4 @@ body.modal-locked { .project-modal-action { width: 100%; } -} +} \ No newline at end of file diff --git a/Api/wwwroot/css/styles.css b/Api/wwwroot/css/styles.css index fbeb26f..89243bb 100644 --- a/Api/wwwroot/css/styles.css +++ b/Api/wwwroot/css/styles.css @@ -628,6 +628,10 @@ button.section-head:hover { margin 0.22s ease; } +.sidebar-section:not(.is-collapsed) .collapsible-content { + overflow: visible; +} + .sidebar-section.is-collapsed .collapsible-content { max-height: 0; opacity: 0; @@ -648,7 +652,7 @@ button.section-head:hover { background: var(--sidebar-hover-bg); border-color: var(--sidebar-hover-border); transform: translateX(var(--sidebar-hover-shift)); - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + box-shadow: none; } .project-item:hover .project-name { @@ -669,6 +673,7 @@ button.section-head:hover { .project-list { display: grid; gap: 16px; + padding-right: calc(var(--sidebar-hover-shift) + 10px); } .sidebar-empty { @@ -699,7 +704,7 @@ button.section-head:hover { .project-item.active { min-height: 72px; background: #333333; - box-shadow: 0 12px 20px rgba(0, 0, 0, 0.32); + box-shadow: none; } .project-color { @@ -740,6 +745,7 @@ button.section-head:hover { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 20px; + padding-right: calc(var(--sidebar-hover-shift) + 10px); } .team-card { @@ -771,7 +777,7 @@ button.section-head:hover { background: var(--sidebar-hover-bg); border-color: var(--sidebar-hover-border); transform: translateX(var(--sidebar-hover-shift)); - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + box-shadow: none; } @@ -806,6 +812,7 @@ button.section-head:hover { display: flex; flex-wrap: wrap; gap: 16px; + padding-right: calc(var(--sidebar-hover-shift) + 10px); } /* ========================= diff --git a/Api/wwwroot/css/tutorial.css b/Api/wwwroot/css/tutorial.css new file mode 100644 index 0000000..efa4a43 --- /dev/null +++ b/Api/wwwroot/css/tutorial.css @@ -0,0 +1,206 @@ +:root { + --tutorial-bg: #1f1f1f; + --tutorial-panel: #282828; + --tutorial-line: #3a3a3a; + --tutorial-text: #f4f4f4; + --tutorial-muted: #a7a7a7; + --tutorial-orange: #f4864d; + --tutorial-green: #407d52; + --tutorial-serif: "Yeseva One", Georgia, serif; + --tutorial-sans: Inter, Arial, sans-serif; + --tutorial-label: Raleway, Inter, Arial, sans-serif; +} + +body.tutorial-locked { + overflow: hidden; +} + +.tutorial-overlay { + position: fixed; + inset: 0; + z-index: 12000; + display: grid; + place-items: center; + padding: 24px; + background: rgba(0, 0, 0, 0.64); + backdrop-filter: blur(4px); + opacity: 0; + pointer-events: none; + transition: opacity 0.22s ease; +} + +.tutorial-overlay.is-open { + opacity: 1; + pointer-events: auto; +} + +.tutorial-card { + position: relative; + width: min(720px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + padding: 34px; + border: 1px solid rgba(244, 134, 77, 0.42); + border-radius: 24px; + background: linear-gradient(180deg, #2b2b2b 0%, var(--tutorial-panel) 100%); + color: var(--tutorial-text); + box-shadow: 0 30px 90px rgba(0, 0, 0, 0.62); + display: grid; + gap: 22px; + overflow-y: auto; + transform: translateY(18px) scale(0.97); + transition: transform 0.22s ease; + text-align: left; +} + +.tutorial-overlay.is-open .tutorial-card { + transform: translateY(0) scale(1); +} + +.tutorial-close { + position: absolute; + right: 22px; + top: 20px; + width: 38px; + height: 38px; + border: 0; + background: transparent; + cursor: pointer; +} + +.tutorial-close span { + position: absolute; + left: 6px; + top: 17px; + width: 28px; + height: 4px; + border-radius: 999px; + background: white; +} + +.tutorial-close span:first-child { + transform: rotate(45deg); +} + +.tutorial-close span:last-child { + transform: rotate(-45deg); +} + +.tutorial-close:hover span, +.tutorial-close:focus-visible span { + background: var(--tutorial-orange); +} + +.tutorial-eyebrow { + margin: 0; + color: var(--tutorial-orange); + font-family: var(--tutorial-label); + font-size: 16px; + font-weight: 700; + text-align: left; +} + +.tutorial-card h2 { + margin: 0; + padding-right: 44px; + color: white; + font-family: var(--tutorial-label); + font-size: 36px; + font-weight: 500; + line-height: 1.1; + text-align: left; +} + +.tutorial-steps { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 12px; +} + +.tutorial-steps li { + min-height: 58px; + padding: 14px 16px; + border: 1px solid var(--tutorial-line); + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + align-items: center; + gap: 12px; + color: var(--tutorial-text); + font-family: var(--tutorial-label); + font-size: 17px; + line-height: 1.35; + text-align: left; +} + +.tutorial-step-index { + width: 30px; + height: 30px; + border-radius: 50%; + background: rgba(244, 134, 77, 0.18); + color: var(--tutorial-orange); + display: grid; + place-items: center; + font-family: var(--tutorial-sans); + font-size: 14px; + font-weight: 800; +} + +.tutorial-done { + width: max-content; + min-height: 44px; + margin-left: auto; + border: 0; + background: transparent; + color: var(--tutorial-green); + cursor: pointer; + font-family: var(--tutorial-label); + font-size: 21px; + font-weight: 600; + text-align: left; +} + +.tutorial-done:hover, +.tutorial-done:focus-visible { + color: #5fb978; + outline: none; +} + +@media (max-width: 700px) { + .tutorial-overlay { + padding: 14px; + } + + .tutorial-card { + width: calc(100vw - 28px); + padding: 26px 20px; + gap: 18px; + text-align: left; + } + + .tutorial-card h2 { + font-size: 28px; + padding-right: 38px; + text-align: left; + } + + .tutorial-eyebrow { + text-align: left; + } + + .tutorial-steps li { + grid-template-columns: 30px minmax(0, 1fr); + padding: 12px; + font-size: 15px; + align-items: center; + text-align: left; + } + + .tutorial-done { + width: 100%; + margin-left: 0; + text-align: left; + } +} \ No newline at end of file diff --git a/Api/wwwroot/js/authGuard.js b/Api/wwwroot/js/authGuard.js index e2cae6a..83243b0 100644 --- a/Api/wwwroot/js/authGuard.js +++ b/Api/wwwroot/js/authGuard.js @@ -8,4 +8,12 @@ if (!hasToken) { authUrl.searchParams.set("next", current); window.location.replace(authUrl); +} else { + import("./tutorial.js") + .then(({ showFirstLoginTutorial }) => { + showFirstLoginTutorial(); + }) + .catch((error) => { + console.error(error); + }); } diff --git a/Api/wwwroot/js/project.js b/Api/wwwroot/js/project.js index 753c65c..c9de99d 100644 --- a/Api/wwwroot/js/project.js +++ b/Api/wwwroot/js/project.js @@ -81,13 +81,13 @@ function createProjectCard(project) { panel.innerHTML = `
- - -
+
+

${escapeHtml(project.team?.name || "Без команды")}

-

${escapeHtml(project.name || "Проект")}

+

${escapeHtml(project.name || "Проект")}

+ ${escapeHtml(formatBoardCount(project.kanbans?.length ?? 0))}
diff --git a/Api/wwwroot/js/tutorial.js b/Api/wwwroot/js/tutorial.js new file mode 100644 index 0000000..3eabd4c --- /dev/null +++ b/Api/wwwroot/js/tutorial.js @@ -0,0 +1,125 @@ +const TUTORIAL_STORAGE_KEY = "boardify.tutorialSeen"; +const TUTORIAL_CSS_ID = "boardifyTutorialStyles"; +const TUTORIAL_CSS_HREF = "./css/tutorial.css"; + +export function showFirstLoginTutorial() { + if (wasTutorialSeen() || document.querySelector("[data-tutorial-overlay]")) { + return; + } + + ensureTutorialStyles(); + + const overlay = document.createElement("div"); + overlay.className = "tutorial-overlay"; + overlay.dataset.tutorialOverlay = "true"; + overlay.innerHTML = createTutorialMarkup(); + + document.body.appendChild(overlay); + document.body.classList.add("tutorial-locked"); + + const close = () => closeTutorial(overlay); + + overlay.querySelector("[data-tutorial-close]")?.addEventListener("click", close); + overlay.querySelector("[data-tutorial-done]")?.addEventListener("click", close); + overlay.addEventListener("click", (event) => { + if (event.target === overlay) { + close(); + } + }); + + const onKeyDown = (event) => { + if (event.key === "Escape") { + close(); + } + }; + + document.addEventListener("keydown", onKeyDown); + overlay.cleanupTutorial = () => { + document.removeEventListener("keydown", onKeyDown); + }; + + requestAnimationFrame(() => { + overlay.classList.add("is-open"); + overlay.querySelector("[data-tutorial-done]")?.focus(); + }); +} + +function createTutorialMarkup() { + return ` + + `; +} + +function closeTutorial(overlay) { + markTutorialSeen(); + overlay.classList.remove("is-open"); + document.body.classList.remove("tutorial-locked"); + overlay.cleanupTutorial?.(); + + setTimeout(() => { + overlay.remove(); + }, 220); +} + +function ensureTutorialStyles() { + if (document.getElementById(TUTORIAL_CSS_ID)) { + return; + } + + const link = document.createElement("link"); + link.id = TUTORIAL_CSS_ID; + link.rel = "stylesheet"; + link.href = TUTORIAL_CSS_HREF; + document.head.appendChild(link); +} + +function wasTutorialSeen() { + try { + return localStorage.getItem(TUTORIAL_STORAGE_KEY) === "true"; + } catch { + return false; + } +} + +function markTutorialSeen() { + try { + localStorage.setItem(TUTORIAL_STORAGE_KEY, "true"); + } catch { + // Ignore storage errors: closing the dialog should still work. + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e125e9a --- /dev/null +++ b/README.md @@ -0,0 +1,315 @@ +# UnitHack + +![Platform](https://img.shields.io/badge/platform-Web-blue) +![Language](https://img.shields.io/badge/language-C%23-purple) +![Backend](https://img.shields.io/badge/backend-ASP.NET%20Core-green) +![Frontend](https://img.shields.io/badge/frontend-HTML%20%7C%20CSS%20%7C%20JavaScript-orange) +![Database](https://img.shields.io/badge/database-PostgreSQL-blue) +![Realtime](https://img.shields.io/badge/realtime-SignalR-red) +![Queue](https://img.shields.io/badge/queue-RabbitMQ-ff6600) +![Status](https://img.shields.io/badge/status-hackathon%20project-lightgrey) + +UnitHack — система для управления задачами с автоматизацией и синхронизацией между пользователями и событиями. + +Проект создан для кейса компании Victory Group в рамках хакатона UnitHack 2026. Платформа помогает командам вести проекты, управлять задачами на kanban-досках, назначать исполнителей, использовать теги и получать уведомления о важных изменениях. + +# Contents + +* [About](#about) +* [Возможности](#возможности) +* [Шаги для установки](#шаги-для-установки) +* [Tech Stack](#tech-stack) +* [API](#api) +* [Realtime и автоматизация](#realtime-и-автоматизация) +* [Архитектура](#архитектура) +* [Authors](#authors) + +# About + +UnitHack позволяет: + +* регистрироваться и авторизовываться в системе +* создавать команды и управлять участниками +* менять роли участников внутри команды +* создавать проекты внутри команд +* создавать kanban-доски для проектов +* добавлять, переименовывать и удалять колонки +* создавать, обновлять, перемещать и удалять задачи +* назначать задачи пользователям +* добавлять дедлайны, приоритеты и описания задач +* создавать теги для kanban-доски +* прикреплять теги к задачам +* получать уведомления о создании, изменении и перемещении задач +* работать с интерфейсом через статический frontend + +# Возможности + +UnitHack построен вокруг командной работы с задачами: + +* **Teams** — команды, участники и роли +* **Projects** — проекты, привязанные к командам +* **Kanban** — доски внутри проектов +* **Columns** — колонки kanban-досок +* **Tasks** — задачи с исполнителем, приоритетом, дедлайном и порядком +* **Tags** — теги для группировки задач +* **Notifications** — уведомления о событиях задач +* **Realtime** — доставка уведомлений через SignalR +* **Automation events** — публикация событий через outbox и RabbitMQ + +# Шаги для установки + +1. Склонируйте репозиторий. + +```bash +git clone https://github.com/prlzrakk/UnitHack.git +cd UnitHack +``` + +2. Создайте `.env` на основе `.env.example`. + +Пример переменных окружения: + +```env +# ASP.NET +ASPNETCORE_URLS=http://+:8080 +ASPNETCORE_ENVIRONMENT=Development + +# JWT +JWT_SECRET=your_super_secret_key_32_chars_minimum + +# Database +DB_HOST=db +DB_PORT=5432 +DB_NAME=unithack +DB_USER=postgres +DB_PASSWORD=postgres + +# RabbitMQ +RabbitMq__Host=rabbitmq +RabbitMq__Port=5672 +RabbitMq__Username=guest +RabbitMq__Password=guest +RabbitMq__Exchange=kanban.events +``` + +3. Запустите проект через Docker Compose. + +```bash +docker compose up -d --build +``` + +4. После запуска будут доступны: + +* frontend/backend: `http://localhost:8080` +* nginx: `http://localhost` +* PostgreSQL: `localhost:5332` +* RabbitMQ AMQP: `localhost:5672` +* RabbitMQ Management UI: `http://localhost:15672` +* Swagger в Development-режиме: `http://localhost:8080/swagger` + +Для запуска тестов: + +```bash +dotnet test +``` + +# Tech Stack + +## Frontend + +* HTML +* CSS +* JavaScript +* SignalR client + +Статический frontend находится в `Api/wwwroot`. + +Основные страницы: + +* `auth.html` — авторизация +* `index.html` — главная страница +* `teams.html` — команды +* `project.html` — проекты +* `kanban.html` — kanban-доска +* `user.html` — профиль пользователя + +## Backend + +* C# +* .NET +* ASP.NET Core Web API +* MediatR +* FluentValidation +* JWT authentication +* Swagger / OpenAPI +* SignalR + +Backend реализован в проекте `Api`. + +## Database + +* PostgreSQL +* Entity Framework Core +* Npgsql + +В базе данных есть сущности для: + +* пользователей +* команд +* участников команд +* проектов +* kanban-досок +* колонок +* задач +* тегов +* уведомлений +* событий задач +* правил автоматизации +* outbox-событий + +## Messaging and realtime + +* RabbitMQ +* Outbox pattern +* Background workers +* SignalR + +RabbitMQ используется для публикации и обработки событий задач. SignalR используется для realtime-уведомлений пользователей. + +## DevOps + +* Docker +* Docker Compose +* Nginx +* GitHub Actions + +## Tests + +* xUnit + +# API + +## Auth and users + +* `POST /api/users` — регистрация пользователя +* `GET /api/users/me` — текущий пользователь +* `GET /api/users?query=&limit=` — поиск пользователей +* `POST /api/auth/sessions` — создание сессии +* `POST /api/auth/tokens/refresh` — обновление access/refresh token + +## Teams + +* `GET /api/teams` — список команд пользователя +* `GET /api/teams/{teamId}` — команда по id +* `POST /api/teams` — создание команды +* `POST /api/teams/{teamId}/members` — добавление участника +* `DELETE /api/teams/{teamId}/members/{userId}` — удаление участника +* `PATCH /api/teams/{teamId}/members/{userId}/role` — изменение роли участника + +## Projects + +* `POST /api/teams/{teamId}/projects` — создание проекта +* `GET /api/projects/{projectId}` — проект по id +* `GET /api/teams/{teamId}/projects` — проекты команды +* `DELETE /api/projects/{projectId}` — удаление проекта + +## Kanban + +* `POST /api/projects/{projectId}/kanbans` — создание kanban-доски +* `GET /api/projects/{projectId}/kanbans` — все kanban-доски проекта +* `GET /api/kanbans/{kanbanId}` — kanban-доска по id +* `PUT /api/kanbans/{kanbanId}` — переименование kanban-доски +* `DELETE /api/kanbans/{kanbanId}` — удаление kanban-доски + +## Columns + +* `POST /api/kanbans/{kanbanId}/columns` — создание колонки +* `PUT /api/columns/{columnId}` — переименование колонки +* `DELETE /api/columns/{columnId}` — удаление колонки + +## Tasks + +* `POST /api/kanbans/{kanbanId}/tasks` — создание задачи +* `PUT /api/tasks/{taskId}` — обновление задачи +* `PATCH /api/tasks/{taskId}` — перемещение задачи +* `DELETE /api/tasks/{taskId}` — удаление задачи + +## Tags + +* `GET /api/kanbans/{kanbanId}/tags` — теги kanban-доски +* `POST /api/kanbans/{kanbanId}/tags` — создание тега +* `PUT /api/tags/{tagId}` — переименование тега +* `DELETE /api/tags/{tagId}` — удаление тега +* `GET /api/tasks/{taskId}/tags` — теги задачи +* `POST /api/tasks/{taskId}/tags/{tagId}` — прикрепить тег к задаче +* `DELETE /api/tasks/{taskId}/tags/{tagId}` — открепить тег от задачи + +## Notifications + +* `GET /api/notifications` — все уведомления пользователя +* `GET /api/notifications/unread` — непрочитанные уведомления +* `GET /api/notifications/{notificationId}` — уведомление по id +* `PATCH /api/notifications/{notificationId}/read` — отметить уведомление прочитанным +* `PUT /api/notifications/read-all` — отметить все уведомления прочитанными + +# Realtime и автоматизация + +В проекте предусмотрена синхронизация событий задач и realtime-уведомления. + +Как это работает: + +1. Пользователь создает, обновляет или перемещает задачу. +2. Backend создает событие задачи. +3. Событие сохраняется в outbox. +4. `OutboxWorker` публикует pending-события в RabbitMQ. +5. `NotificationWorker` читает события из очереди. +6. Для пользователя создается уведомление. +7. Уведомление отправляется через SignalR. + +SignalR hub: + +```text +/hubs/notifications +``` + +RabbitMQ queue: + +```text +notifications:events +``` + +# Архитектура + +Основные части решения: + +* `Api` — ASP.NET Core приложение, API-контроллеры, middleware, SignalR hub и статический frontend +* `Client.Models` — DTO, enum-ы и конфигурационные модели, общие для API и клиента +* `Core` — слой для доменной и прикладной логики +* `Infrastructure` — база данных, Entity Framework Core, репозитории, JWT/security, RabbitMQ, workers +* `Tests` — тестовый проект + +Проект следует feature-based структуре: каждая крупная возможность вынесена в отдельную папку внутри `Api/Application/Features`. + +Пример: + +```text +Api/Application/Features/ +├── Auth +├── Columns +├── Kanban +├── Notifications +├── Projects +├── Tags +├── TaskTags +├── Tasks +├── Teams +└── Users +``` + +# Authors + +* [prlzrakk](https://github.com/prlzrakk) +* [Jlychee](https://github.com/Jlychee) +* [Kitiketov](https://github.com/Kitiketov) +* [FayTim](https://github.com/FayTim) +* [reqied](https://github.com/reqied)