Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 273d255bce | |||
| 57006dd481 | |||
| 136bcce7db |
@@ -1,165 +1,295 @@
|
||||
# UniVerse
|
||||
|
||||
UniVerse — backend (ASP.NET Core) для университетской платформы расписания, лекций, отзывов и геймификации.
|
||||
UniVerse - веб-платформа для открытых межнаправленческих лекций университета. Система помогает студентам находить занятия других направлений, записываться на них, получать напоминания, оставлять отзывы, а преподавателям и администраторам - видеть аналитику посещаемости и качества обратной связи.
|
||||
|
||||
[Документация API](backend/UniVerse.Api/openapi.json)
|
||||
[Документация бекнда](docs/backend.md)
|
||||
Проект состоит из Vue 3 frontend, ASP.NET Core backend и PostgreSQL-хранилища. Backend предоставляет REST API, интегрируется с Microsoft Entra ID, Modeus API и OpenAI-compatible LLM, а frontend реализует отдельные сценарии для ролей `Student`, `Teacher` и `Admin`.
|
||||
|
||||
## Что внутри
|
||||
- [OpenAPI snapshot](backend/UniVerse.Api/openapi.json)
|
||||
- [Backend notes](docs/backend.md)
|
||||
- [Frontend E2E tests](docs/playwright-tests.md)
|
||||
- [Load testing](docs/load-testing-k6.md)
|
||||
|
||||
- Расписание/события и сущности: курсы, лекции, аудитории (locations)
|
||||
- Отзывы студентов с фоновым LLM-анализом (качество/тональность/теги)
|
||||
- Геймификация: XP/уровни, монеты, достижения
|
||||
- JWT-аутентификация и роли (`Admin`, `Teacher`, `Student`)
|
||||
- Swagger/OpenAPI в Development
|
||||
## Возможности
|
||||
|
||||
## Технологии
|
||||
### Для студента
|
||||
|
||||
- .NET 10 / ASP.NET Core
|
||||
- PostgreSQL + EF Core (Npgsql)
|
||||
- Serilog
|
||||
- Swagger (Swashbuckle)
|
||||
- Каталог открытых лекций с поиском, фильтрацией, карточками и деталями занятия.
|
||||
- Запись на лекцию и отмена записи с учетом лимитов мест и персонального лимита активных записей.
|
||||
- Личный дашборд: ближайшие лекции, прогресс уровня, XP, монеты, достижения и статистика.
|
||||
- Мои лекции: список записей, скачивание `.ics` для одной лекции или всего расписания, ссылка календарной подписки.
|
||||
- Отзывы о лекциях с оценкой `Like`, `Neutral`, `Dislike`.
|
||||
- Уведомления и профиль пользователя.
|
||||
|
||||
## Структура репозитория
|
||||
### Для преподавателя
|
||||
|
||||
Код backend лежит в папке `backend/` и собран в solution `backend/UniVerse.sln`:
|
||||
- Дашборд преподавателя по своим занятиям.
|
||||
- Просмотр списка лекций и записей.
|
||||
- Аналитика отзывов: тональность, информативность, теги LLM и агрегированные показатели.
|
||||
- Работа с отзывами без раскрытия лишних персональных данных студентам.
|
||||
|
||||
- `backend/UniVerse.Api` — Web API (контроллеры, middleware, background services)
|
||||
- `backend/UniVerse.Application` — DTO, интерфейсы сервисов, маппинги
|
||||
- `backend/UniVerse.Domain` — доменные сущности/enum/исключения
|
||||
- `backend/UniVerse.Infrastructure` — EF Core, миграции, реализации сервисов, внешние клиенты
|
||||
### Для администратора
|
||||
|
||||
- Административная панель со статистикой пользователей, лекций, записей и ожидающих LLM-анализа отзывов.
|
||||
- Управление пользователями: роли `Student`, `Teacher`, `Admin`, блокировка и разблокировка аккаунтов.
|
||||
- Управление лекциями и создание новых занятий.
|
||||
- Синхронизация расписания, аудиторий и преподавателей из Modeus.
|
||||
- Модерация отзывов, повторный запуск LLM-анализа и настройка промпта.
|
||||
- Управление курсами, тегами, локациями и достижениями через API.
|
||||
|
||||
### Платформенные функции
|
||||
|
||||
- Microsoft Entra ID login и dev-login для локальной разработки.
|
||||
- JWT access token и refresh flow через cookie.
|
||||
- Ролевая защита маршрутов frontend и endpoint-level авторизация backend.
|
||||
- Фоновая очередь анализа отзывов через LLM.
|
||||
- Геймификация: XP, уровни, монеты, достижения и транзакции.
|
||||
- Email-уведомления и планировщик Quartz.
|
||||
- Rate limiting, Serilog, Prometheus metrics и Swagger UI в Development.
|
||||
- Aspire AppHost для совместного локального запуска API и Vite frontend.
|
||||
- Docker Compose для production-окружения с backend, frontend и PostgreSQL.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Student[Student UI] --> Frontend[Vue 3 frontend]
|
||||
Teacher[Teacher UI] --> Frontend
|
||||
Admin[Admin UI] --> Frontend
|
||||
|
||||
Frontend -->|/api/v1 JSON| Api[ASP.NET Core Web API]
|
||||
Api --> Auth[Auth and RBAC]
|
||||
Api --> App[Application services]
|
||||
App --> Domain[Domain model]
|
||||
App --> Infra[Infrastructure]
|
||||
Infra --> Db[(PostgreSQL)]
|
||||
Infra --> Llm[OpenAI-compatible LLM]
|
||||
Infra --> Modeus[Modeus schedule API]
|
||||
Infra --> Mail[SMTP email]
|
||||
|
||||
Api --> Quartz[Quartz jobs]
|
||||
Quartz --> Mail
|
||||
Api --> Metrics[Prometheus /metrics]
|
||||
```
|
||||
|
||||
Backend следует Clean Architecture:
|
||||
|
||||
- `UniVerse.Api` - controllers, middleware, Swagger, DI, background services.
|
||||
- `UniVerse.Application` - DTO, interfaces, service contracts, mappings, prompts.
|
||||
- `UniVerse.Domain` - entities, enums, domain exceptions, domain services.
|
||||
- `UniVerse.Infrastructure` - EF Core, migrations, external clients, notification and business service implementations.
|
||||
- `UniVerse.AppHost` - Aspire host для локального запуска API и frontend.
|
||||
- `UniVerse.Api.Tests` - unit и integration tests.
|
||||
|
||||
Frontend построен на Vue 3, TypeScript, Vite, Pinia и Vue Router:
|
||||
|
||||
- `src/views/student` - кабинет студента, каталог, карточка лекции, мои лекции, отзывы, профиль, уведомления.
|
||||
- `src/views/teacher` - кабинет преподавателя, лекции, аналитика.
|
||||
- `src/views/admin` - дашборд, пользователи, лекции, отзывы.
|
||||
- `src/api` - typed API client, DTO-типы и мапперы.
|
||||
- `src/components/ui` и `src/components/layout` - переиспользуемые UI и layout-компоненты.
|
||||
|
||||
## Сценарий записи и анализа отзыва
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor S as Student
|
||||
participant UI as Vue frontend
|
||||
participant API as UniVerse API
|
||||
participant DB as PostgreSQL
|
||||
participant Q as ReviewAnalysisQueue
|
||||
participant LLM as LLM API
|
||||
participant G as GamificationService
|
||||
|
||||
S->>UI: Выбирает открытую лекцию
|
||||
UI->>API: POST /api/v1/lectures/{id}/enroll
|
||||
API->>DB: Проверяет лимиты и сохраняет запись
|
||||
API-->>UI: 204 No Content
|
||||
S->>UI: Оставляет отзыв
|
||||
UI->>API: POST /api/v1/reviews
|
||||
API->>DB: Сохраняет отзыв со статусом Pending
|
||||
API->>Q: Ставит отзыв в очередь анализа
|
||||
Q->>LLM: Анализ качества, тональности и тегов
|
||||
LLM-->>Q: Результат анализа
|
||||
Q->>DB: Обновляет отзыв
|
||||
Q->>G: Начисляет XP, монеты и достижения
|
||||
G->>DB: Сохраняет транзакции и награды
|
||||
```
|
||||
|
||||
Основные группы таблиц:
|
||||
|
||||
- Пользователи и доступ: `users`, `user_roles`, `student_profiles`, `teacher_profiles`, `refresh_tokens`.
|
||||
- Расписание: `courses`, `lectures`, `locations`, `tags`, `course_tags`.
|
||||
- Участие и обратная связь: `lecture_enrollments`, `reviews`, `review_prompt_settings`.
|
||||
- Геймификация: `achievements`, `user_achievements`, `coin_transactions`, `level_thresholds`.
|
||||
- Уведомления: `user_notifications`.
|
||||
|
||||
## API
|
||||
|
||||
Базовый префикс: `/api/v1`.
|
||||
|
||||
- `/auth` - Microsoft login, dev-login, refresh, logout, текущий пользователь.
|
||||
- `/users` - профиль, статистика, роли, активность, записи, достижения, транзакции, `.ics`.
|
||||
- `/lectures` - каталог лекций, CRUD, запись, посещаемость, отзывы по лекции.
|
||||
- `/reviews` - создание, список, модерация, повторный анализ, настройка LLM-промпта.
|
||||
- `/courses` - курсы и привязка тегов.
|
||||
- `/tags` - теги и дерево тегов.
|
||||
- `/locations` - аудитории и локации.
|
||||
- `/achievements` - каталог достижений.
|
||||
- `/notifications` - уведомления, отметка прочтения, отправка и планирование.
|
||||
- `/sync` - синхронизация расписания, аудиторий и преподавателей из Modeus.
|
||||
|
||||
В Development Swagger UI доступен по адресу `http://localhost:5019/api/docs`.
|
||||
|
||||
## Стек
|
||||
|
||||
### Frontend
|
||||
|
||||
- Vue 3, TypeScript, Vite.
|
||||
- Pinia, Vue Router.
|
||||
- Playwright E2E tests.
|
||||
- Nginx для production-раздачи и проксирования.
|
||||
|
||||
### Backend
|
||||
|
||||
- .NET 10, ASP.NET Core Web API.
|
||||
- EF Core 10, Npgsql, PostgreSQL.
|
||||
- Swashbuckle/OpenAPI.
|
||||
- Serilog, Prometheus, ASP.NET Core Rate Limiting.
|
||||
- Quartz для отложенных уведомлений.
|
||||
- Aspire AppHost.
|
||||
- xUnit, NSubstitute, WebApplicationFactory.
|
||||
|
||||
### Интеграции
|
||||
|
||||
- Microsoft Entra ID.
|
||||
- Modeus schedule API.
|
||||
- OpenAI-compatible LLM API.
|
||||
- SMTP email.
|
||||
|
||||
## Требования
|
||||
|
||||
- .NET SDK 10 (`dotnet --version` должен показать `10.x`)
|
||||
- PostgreSQL 14+ (или Docker для поднятия Postgres)
|
||||
- .NET SDK 10.x.
|
||||
- Node.js `^20.19.0 || >=22.12.0`.
|
||||
- pnpm.
|
||||
- PostgreSQL 17+ или Docker.
|
||||
- Для production - Docker Engine и Docker Compose.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Основные настройки лежат в `backend/UniVerse.Api/appsettings.json`:
|
||||
Backend читает настройки из `backend/UniVerse.Api/appsettings.json`, `appsettings.Development.json` и переменных окружения в формате `Section__Key`.
|
||||
|
||||
- `ConnectionStrings:DefaultConnection` — строка подключения к Postgres
|
||||
- `Jwt:*` — секрет/issuer/audience и сроки жизни токенов
|
||||
- `Cors:Origins` — origin’ы фронтенда
|
||||
- `Llm:*` — настройки LLM (OpenAI-compatible)
|
||||
- `ModeusApi:*` — настройки интеграции с Modeus
|
||||
Основные секции:
|
||||
|
||||
Можно переопределять через переменные окружения в формате `Section__Key`, например:
|
||||
- `ConnectionStrings:DefaultConnection` - подключение к PostgreSQL.
|
||||
- `Jwt:*` - issuer, audience, secret и сроки жизни токенов.
|
||||
- `AzureAd:*` - Microsoft Entra ID.
|
||||
- `Cors:Origins` - разрешенные origin frontend.
|
||||
- `RateLimiting:*` - глобальный fixed-window limiter.
|
||||
- `Llm:*` - base URL, API key, model и параметры анализа отзывов.
|
||||
- `ModeusApi:*` - base URL, API key и timeout.
|
||||
- `Email:Smtp:*` - SMTP-настройки для уведомлений.
|
||||
|
||||
- `ConnectionStrings__DefaultConnection`
|
||||
- `Jwt__Secret`
|
||||
- `Llm__ApiKey`
|
||||
- `ModeusApi__ApiKey`
|
||||
Frontend использует переменные:
|
||||
|
||||
## Быстрый старт (локально)
|
||||
- `VITE_API_BASE_URL` - базовый адрес API, по умолчанию `/api`.
|
||||
- `VITE_API_PROXY_TARGET` - target для Vite proxy при запуске через Aspire.
|
||||
- `VITE_AUTH_RETURN_URL` - frontend callback URL, по умолчанию `/auth/callback`.
|
||||
|
||||
1) Поднять Postgres (пример через Docker):
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Установить зависимости frontend
|
||||
|
||||
```bash
|
||||
pnpm -C frontend install
|
||||
```
|
||||
|
||||
### 2. Поднять PostgreSQL
|
||||
|
||||
```bash
|
||||
docker run --rm --name universe-postgres \
|
||||
-e POSTGRES_DB=universe \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-p 5432:5432 \
|
||||
postgres:18
|
||||
-e POSTGRES_DB=universe \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-p 5432:5432 \
|
||||
postgres:18
|
||||
```
|
||||
|
||||
2) Применить миграции (первый раз потребуется `dotnet-ef`):
|
||||
### 3. Применить миграции
|
||||
|
||||
```bash
|
||||
dotnet tool install --global dotnet-ef
|
||||
|
||||
cd backend
|
||||
dotnet ef database update \
|
||||
--project UniVerse.Infrastructure \
|
||||
--startup-project UniVerse.Api
|
||||
--project UniVerse.Infrastructure \
|
||||
--startup-project UniVerse.Api
|
||||
```
|
||||
|
||||
3) Запустить API:
|
||||
### 4. Запустить backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
dotnet run --project UniVerse.Api --launch-profile http
|
||||
dotnet run --project backend/UniVerse.Api --launch-profile http
|
||||
```
|
||||
|
||||
По умолчанию (профиль `http`) API поднимется на `http://localhost:5019`.
|
||||
Swagger UI доступен в Development по адресу: `http://localhost:5019/swagger`.
|
||||
API по умолчанию слушает `http://localhost:5019`.
|
||||
|
||||
## Запуск в Docker
|
||||
|
||||
В `backend/UniVerse.Api/Dockerfile` настроена сборка контейнера API.
|
||||
### 5. Запустить frontend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
docker build -f UniVerse.Api/Dockerfile -t universe-api .
|
||||
|
||||
docker run --rm -p 8080:8080 \
|
||||
-e ASPNETCORE_URLS=http://+:8080 \
|
||||
-e ConnectionStrings__DefaultConnection="Host=host.docker.internal;Port=5432;Database=universe;Username=postgres;Password=postgres" \
|
||||
universe-api
|
||||
pnpm -C frontend dev
|
||||
```
|
||||
|
||||
Примечание: на Linux `host.docker.internal` может быть недоступен — проще запускать Postgres тоже в Docker и соединять контейнеры в одной сети.
|
||||
Vite frontend по умолчанию слушает `http://localhost:5173` и проксирует `/api` на `http://localhost:5019`.
|
||||
|
||||
## Аутентификация
|
||||
## Запуск через Aspire
|
||||
|
||||
- `POST /api/v1/auth/login/dev` — дев-логин (только в `Development`). Удобно для локальной разработки.
|
||||
- `GET /api/v1/auth/login/microsoft` — старт входа через Microsoft Entra ID (бэкенд сам делает редирект на Microsoft).
|
||||
- `GET /api/v1/auth/callback/microsoft` — callback, куда Microsoft возвращает `code`.
|
||||
- `POST /api/v1/auth/login/microsoft` — обмен `authorizationCode` на токены (полезно для интеграций/ручных тестов). Тело: `{ "authorizationCode": "...", "redirectUri"?: "..." }`.
|
||||
- `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/me`
|
||||
Aspire AppHost запускает API и Vite frontend вместе:
|
||||
|
||||
Для Microsoft Entra ID нужны настройки (через env или appsettings): `AzureAd:TenantId`, `AzureAd:ClientId`, `AzureAd:ClientSecret` (и при необходимости `AzureAd:Instance`, `AzureAd:RedirectUri`, `AzureAd:PostLoginRedirectUri`).
|
||||
```bash
|
||||
pnpm -C frontend install
|
||||
dotnet run --project backend/UniVerse.AppHost/UniVerse.AppHost.csproj
|
||||
```
|
||||
|
||||
Большинство методов API защищены `[Authorize]`.
|
||||
Frontend обычно доступен на `http://localhost:5173`. Target API передается во frontend через `VITE_API_PROXY_TARGET`.
|
||||
|
||||
## Фоновый LLM-анализ отзывов
|
||||
## Docker Compose
|
||||
|
||||
Сервис `LlmProcessingBackgroundService` раз в ~2 минуты берёт отзывы со статусом `Pending` и прогоняет через LLM-клиент.
|
||||
LLM-ключ задаётся через `Llm:ApiKey`.
|
||||
Production compose описан в `docker-compose-prod.yml`:
|
||||
|
||||
Если ключ не задан или внешний сервис недоступен — анализ будет ретраиться, а ошибки логироваться.
|
||||
- `app` - ASP.NET Core backend.
|
||||
- `frontend` - собранный Vue frontend и Nginx.
|
||||
- `db` - PostgreSQL.
|
||||
|
||||
## Интеграция с Modeus
|
||||
Перед запуском задайте переменные окружения для PostgreSQL, JWT, Microsoft auth, CORS и внешних интеграций:
|
||||
|
||||
Эндпоинты синхронизации доступны только администратору:
|
||||
```bash
|
||||
docker compose -f docker-compose-prod.yml up -d
|
||||
```
|
||||
|
||||
- `POST /api/v1/sync/schedule`
|
||||
- `POST /api/v1/sync/rooms`
|
||||
- `POST /api/v1/sync/employees`
|
||||
- `GET /api/v1/sync/status`
|
||||
|
||||
Ключ (если нужен) задаётся через `ModeusApi:ApiKey`.
|
||||
|
||||
## Карта API (high-level)
|
||||
|
||||
Базовый префикс: `/api/v1`.
|
||||
|
||||
- `/auth` — логин/refresh/logout/me
|
||||
- `/users` — профиль/статистика/достижения/транзакции (часть методов — только `Admin`)
|
||||
- `/courses` — курсы и теги (CRUD в основном для `Admin`)
|
||||
- `/lectures` — лекции, записи, посещаемость, отзывы
|
||||
- `/reviews` — отзывы (создание студентом; модерация/реанализ для `Admin`)
|
||||
- `/tags` — теги + дерево тегов
|
||||
- `/locations` — аудитории/локации
|
||||
- `/achievements` — достижения
|
||||
- `/sync` — синхронизация с внешним расписанием (только `Admin`)
|
||||
|
||||
Точные схемы запросов/ответов удобнее смотреть в Swagger.
|
||||
Тестовый compose находится в `docker-compose-test.yml`.
|
||||
|
||||
## Тестирование
|
||||
|
||||
В проекте настроено модульное и интеграционное тестирование (папка `backend/UniVerse.Api.Tests`):
|
||||
|
||||
- **xUnit** в качестве основного фреймворка для тестирования.
|
||||
- **NSubstitute** для создания заглушек (моков) зависимостей сервисов.
|
||||
- Используется `WebApplicationFactory` (`ApiWebApplicationFactory.cs`) для поднятия интеграционного тестового сервера с подменой БД на `InMemory` и отключенными фоновыми сервисами (например, LLM-интеграциями) для изоляции.
|
||||
- Реализованы полные тесты ролевой модели и авторизации (`EndpointAuthorizationTests.cs`), надежно проверяющие все API-конечные точки на политики доступа от имени различных ролей (`Admin`, `Teacher`, `Student`, `Anonymous`).
|
||||
|
||||
Запуск тестов:
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
dotnet test
|
||||
dotnet test backend/UniVerse.sln
|
||||
```
|
||||
|
||||
Frontend type-check и production build:
|
||||
|
||||
```bash
|
||||
pnpm -C frontend build
|
||||
```
|
||||
|
||||
Frontend E2E с mock API:
|
||||
|
||||
```bash
|
||||
pnpm -C frontend test:e2e
|
||||
```
|
||||
|
||||
Load testing helper:
|
||||
|
||||
```bash
|
||||
node frontend/scripts/loadtest-endpoints.js
|
||||
```
|
||||
@@ -47,6 +47,9 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
|
||||
body: """{"displayName":"Test","avatarUrl":null}""");
|
||||
yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student");
|
||||
yield return E("users/me/enrollments [AnyAuth]", "GET", "api/v1/users/me/enrollments", "Student");
|
||||
yield return E("users/me/enrollments/calendar-subscription [AnyAuth]", "GET", "api/v1/users/me/enrollments/calendar-subscription", "Student");
|
||||
yield return E("users/me/enrollments.ics [AnyAuth]", "GET", "api/v1/users/me/enrollments.ics", "Student");
|
||||
yield return E("users/me/enrollments/{id}.ics [AnyAuth]", "GET", "api/v1/users/me/enrollments/1.ics", "Student");
|
||||
yield return E("users/me/reviews [AnyAuth]", "GET", "api/v1/users/me/reviews", "Student");
|
||||
yield return E("users/me/achievements [AnyAuth]", "GET", "api/v1/users/me/achievements", "Student");
|
||||
yield return E("users/me/transactions [AnyAuth]", "GET", "api/v1/users/me/transactions", "Student");
|
||||
@@ -192,6 +195,7 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
|
||||
// dev login доступен в окружении Development
|
||||
yield return new object[] { "auth/login/dev POST", "POST", "api/v1/auth/login/dev",
|
||||
"""{"email":"test@test.com","displayName":"Test","role":"Student"}""" };
|
||||
yield return new object[] { "users/calendar/enrollments/{token}.ics GET", "GET", "api/v1/users/calendar/enrollments/bad-token.ics" };
|
||||
// refresh читает из cookie — возвращает 401, если нет cookie, но это не 401 от промежуточного ПО авторизации
|
||||
// (он возвращает 401 явно в теле действия, что отличается от Auth Challenge)
|
||||
// Мы тестируем это отдельно, чтобы убедиться, что заголовок JWT не требуется
|
||||
|
||||
@@ -20,6 +20,7 @@ using UniVerse.Application.DTOs.Tags;
|
||||
using UniVerse.Application.DTOs.Users;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Enums;
|
||||
using UniVerse.Domain.Exceptions;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
|
||||
namespace UniVerse.Api.Tests.Helpers;
|
||||
@@ -177,6 +178,13 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
||||
3,
|
||||
[new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)]));
|
||||
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures);
|
||||
stub.GetMyEnrollmentsIcsAsync(Arg.Any<int>()).Returns("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n");
|
||||
stub.GetEnrollmentIcsAsync(Arg.Any<int>(), Arg.Any<int>()).Returns("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n");
|
||||
stub.GetCalendarSubscriptionTokenAsync(Arg.Any<int>()).Returns("test-token");
|
||||
stub.GetEnrollmentsIcsBySubscriptionTokenAsync("bad-token")
|
||||
.Returns(Task.FromException<string>(new ForbiddenException("Invalid calendar subscription token.")));
|
||||
stub.GetEnrollmentsIcsBySubscriptionTokenAsync(Arg.Is<string>(token => token != "bad-token"))
|
||||
.Returns(Task.FromResult("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"));
|
||||
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
|
||||
stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask);
|
||||
stub.SetActiveAsync(Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
@@ -193,6 +194,56 @@ public class UserServiceTests
|
||||
Assert.Equal(2, Assert.Single(result.Items).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalendarSubscriptionToken_Roundtrip_ReturnsUserEnrollmentsIcs()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var startsAt = new DateTime(2026, 1, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.Add(Lecture(1, startsAt));
|
||||
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = 1, UserId = 1 });
|
||||
await db.SaveChangesAsync();
|
||||
var service = CreateService(db);
|
||||
|
||||
var token = await service.GetCalendarSubscriptionTokenAsync(1);
|
||||
var ics = await service.GetEnrollmentsIcsBySubscriptionTokenAsync(token);
|
||||
|
||||
Assert.Contains("BEGIN:VCALENDAR", ics);
|
||||
Assert.Contains("Lecture 1", ics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalendarSubscriptionToken_RejectsTamperedToken()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||
await db.SaveChangesAsync();
|
||||
var service = CreateService(db);
|
||||
var token = await service.GetCalendarSubscriptionTokenAsync(1);
|
||||
var tampered = token[..^1] + (token[^1] == 'A' ? 'B' : 'A');
|
||||
|
||||
await Assert.ThrowsAsync<ForbiddenException>(() =>
|
||||
service.GetEnrollmentsIcsBySubscriptionTokenAsync(tampered));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEnrollmentIcsAsync_ReturnsLectureIcsWithoutEnrollment()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var startsAt = new DateTime(2026, 2, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.Add(Lecture(1853, startsAt));
|
||||
await db.SaveChangesAsync();
|
||||
var service = CreateService(db);
|
||||
|
||||
var ics = await service.GetEnrollmentIcsAsync(1, 1853);
|
||||
|
||||
Assert.Contains("BEGIN:VCALENDAR", ics);
|
||||
Assert.Contains("Lecture 1853", ics);
|
||||
}
|
||||
|
||||
private static AppDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
@@ -215,7 +266,13 @@ public class UserServiceTests
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var gamification = new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
|
||||
return new UserService(db, gamification);
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:Secret"] = "test-calendar-subscription-secret-32chars"
|
||||
})
|
||||
.Build();
|
||||
return new UserService(db, gamification, config);
|
||||
}
|
||||
|
||||
private static void SeedLevelThresholds(AppDbContext db)
|
||||
|
||||
@@ -5,6 +5,7 @@ using UniVerse.Application.DTOs.Users;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Enums;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
@@ -83,6 +84,52 @@ public class UsersController : ControllerBase
|
||||
public async Task<ActionResult> MyEnrollments([FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination));
|
||||
|
||||
[HttpGet("me/enrollments/calendar-subscription")]
|
||||
[ProducesResponseType(typeof(CalendarSubscriptionDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<CalendarSubscriptionDto>> CalendarSubscription()
|
||||
{
|
||||
var token = await _users.GetCalendarSubscriptionTokenAsync(CurrentUserId);
|
||||
var feedUrl = Url.Action(
|
||||
nameof(CalendarEnrollmentsIcs),
|
||||
null,
|
||||
new { token },
|
||||
Request.Scheme)
|
||||
?? $"{Request.Scheme}://{Request.Host}/api/v1/users/calendar/enrollments/{token}.ics";
|
||||
|
||||
return Ok(new CalendarSubscriptionDto(feedUrl));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("calendar/enrollments/{token}.ics")]
|
||||
[Produces("text/calendar")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<FileContentResult> CalendarEnrollmentsIcs(string token)
|
||||
{
|
||||
var ics = await _users.GetEnrollmentsIcsBySubscriptionTokenAsync(token);
|
||||
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "my-lectures.ics");
|
||||
}
|
||||
|
||||
[HttpGet("me/enrollments.ics")]
|
||||
[Produces("text/calendar")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<FileContentResult> MyEnrollmentsIcs()
|
||||
{
|
||||
var ics = await _users.GetMyEnrollmentsIcsAsync(CurrentUserId);
|
||||
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "my-lectures.ics");
|
||||
}
|
||||
|
||||
[HttpGet("me/enrollments/{lectureId:int}.ics")]
|
||||
[Produces("text/calendar")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<FileContentResult> EnrollmentIcs(int lectureId)
|
||||
{
|
||||
var ics = await _users.GetEnrollmentIcsAsync(CurrentUserId, lectureId);
|
||||
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", $"lecture-{lectureId}.ics");
|
||||
}
|
||||
|
||||
/// <summary>Получить отзывы текущего пользователя.</summary>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список отзывов (пагинированный).</response>
|
||||
|
||||
@@ -3789,6 +3789,146 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/me/enrollments/calendar-subscription": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"description": "**Required:** any authenticated user",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CalendarSubscriptionDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/calendar/enrollments/{token}.ics": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"text/calendar": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/users/me/enrollments.ics": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"description": "**Required:** any authenticated user",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized — JWT token missing or invalid"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/me/enrollments/{lectureId}.ics": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"description": "**Required:** any authenticated user",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "lectureId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/calendar": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized — JWT token missing or invalid"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/me/reviews": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -4843,6 +4983,16 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CalendarSubscriptionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"feedUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CoinTransactionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.4.0">
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.4.4">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
|
||||
@@ -51,6 +51,8 @@ public record AdminDashboardStatsDto(
|
||||
|
||||
public record EnrollmentSlotRuleDto(int Level, int Slots);
|
||||
|
||||
public record CalendarSubscriptionDto(string FeedUrl);
|
||||
|
||||
public record UpdateUserRequest(
|
||||
string? DisplayName,
|
||||
string? AvatarUrl
|
||||
|
||||
@@ -12,6 +12,10 @@ public interface IUserService
|
||||
Task<UserStatsDto> GetStatsAsync(int id);
|
||||
Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync();
|
||||
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
|
||||
Task<string> GetMyEnrollmentsIcsAsync(int userId);
|
||||
Task<string> GetEnrollmentIcsAsync(int userId, int lectureId);
|
||||
Task<string> GetCalendarSubscriptionTokenAsync(int userId);
|
||||
Task<string> GetEnrollmentsIcsBySubscriptionTokenAsync(string token);
|
||||
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
|
||||
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
|
||||
Task SetActiveAsync(int id, bool isActive);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Ical.Net;
|
||||
using Ical.Net.CalendarComponents;
|
||||
using Ical.Net.DataTypes;
|
||||
using Ical.Net.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using UniVerse.Application.DTOs.Common;
|
||||
using UniVerse.Application.DTOs.Lectures;
|
||||
using UniVerse.Application.DTOs.Users;
|
||||
@@ -13,13 +21,20 @@ namespace UniVerse.Infrastructure.Services;
|
||||
|
||||
public class UserService : IUserService
|
||||
{
|
||||
private const byte CalendarTokenVersion = 1;
|
||||
private const int CalendarTokenPayloadLength = 5;
|
||||
private const int CalendarTokenSignatureLength = 32;
|
||||
private const string CalendarTokenKeyContext = "universe-calendar-subscription-v1";
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IGamificationService _gamification;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public UserService(AppDbContext db, IGamificationService gamification)
|
||||
public UserService(AppDbContext db, IGamificationService gamification, IConfiguration config)
|
||||
{
|
||||
_db = db;
|
||||
_gamification = gamification;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task<UserDto> GetByIdAsync(int id)
|
||||
@@ -115,6 +130,154 @@ public class UserService : IUserService
|
||||
pagination.PageSize);
|
||||
}
|
||||
|
||||
|
||||
public async Task<string> GetMyEnrollmentsIcsAsync(int userId)
|
||||
{
|
||||
if (!await _db.Users.AnyAsync(u => u.Id == userId))
|
||||
throw new NotFoundException("User", userId);
|
||||
|
||||
var lectures = await _db.LectureEnrollments
|
||||
.Where(e => e.UserId == userId)
|
||||
.Include(e => e.Lecture)
|
||||
.ThenInclude(l => l.Teacher)
|
||||
.Include(e => e.Lecture)
|
||||
.ThenInclude(l => l.Location)
|
||||
.OrderBy(e => e.Lecture.StartsAt)
|
||||
.Select(e => e.Lecture)
|
||||
.ToListAsync();
|
||||
|
||||
return BuildIcs(lectures, userId);
|
||||
}
|
||||
|
||||
public async Task<string> GetEnrollmentIcsAsync(int userId, int lectureId)
|
||||
{
|
||||
if (!await _db.Users.AnyAsync(u => u.Id == userId))
|
||||
throw new NotFoundException("User", userId);
|
||||
|
||||
var lecture = await _db.Lectures
|
||||
.Include(l => l.Teacher)
|
||||
.Include(l => l.Location)
|
||||
.FirstOrDefaultAsync(l => l.Id == lectureId)
|
||||
?? throw new NotFoundException("Lecture", lectureId);
|
||||
|
||||
return BuildIcs([lecture], userId);
|
||||
}
|
||||
|
||||
public async Task<string> GetCalendarSubscriptionTokenAsync(int userId)
|
||||
{
|
||||
if (!await _db.Users.AnyAsync(u => u.Id == userId))
|
||||
throw new NotFoundException("User", userId);
|
||||
|
||||
Span<byte> payload = stackalloc byte[CalendarTokenPayloadLength];
|
||||
payload[0] = CalendarTokenVersion;
|
||||
BinaryPrimitives.WriteInt32BigEndian(payload[1..], userId);
|
||||
|
||||
var signature = SignCalendarTokenPayload(payload);
|
||||
var tokenBytes = new byte[CalendarTokenPayloadLength + CalendarTokenSignatureLength];
|
||||
payload.CopyTo(tokenBytes);
|
||||
signature.CopyTo(tokenBytes.AsSpan(CalendarTokenPayloadLength));
|
||||
|
||||
return ToBase64Url(tokenBytes);
|
||||
}
|
||||
|
||||
public async Task<string> GetEnrollmentsIcsBySubscriptionTokenAsync(string token)
|
||||
{
|
||||
var userId = ValidateCalendarSubscriptionToken(token);
|
||||
return await GetMyEnrollmentsIcsAsync(userId);
|
||||
}
|
||||
|
||||
private int ValidateCalendarSubscriptionToken(string token)
|
||||
{
|
||||
var tokenBytes = FromBase64Url(token);
|
||||
if (tokenBytes.Length != CalendarTokenPayloadLength + CalendarTokenSignatureLength)
|
||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
||||
|
||||
var payload = tokenBytes.AsSpan(0, CalendarTokenPayloadLength);
|
||||
var signature = tokenBytes.AsSpan(CalendarTokenPayloadLength, CalendarTokenSignatureLength);
|
||||
if (payload[0] != CalendarTokenVersion)
|
||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
||||
|
||||
var expectedSignature = SignCalendarTokenPayload(payload);
|
||||
if (!CryptographicOperations.FixedTimeEquals(signature, expectedSignature))
|
||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
||||
|
||||
var userId = BinaryPrimitives.ReadInt32BigEndian(payload[1..]);
|
||||
if (userId <= 0)
|
||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
private byte[] SignCalendarTokenPayload(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var calendarKey = DeriveCalendarTokenKey();
|
||||
return HMACSHA256.HashData(calendarKey, payload);
|
||||
}
|
||||
|
||||
private byte[] DeriveCalendarTokenKey()
|
||||
{
|
||||
var jwtSecret = _config["Jwt:Secret"];
|
||||
if (string.IsNullOrWhiteSpace(jwtSecret))
|
||||
throw new InvalidOperationException("Jwt:Secret is not configured.");
|
||||
|
||||
return HMACSHA256.HashData(
|
||||
Encoding.UTF8.GetBytes(jwtSecret),
|
||||
Encoding.UTF8.GetBytes(CalendarTokenKeyContext));
|
||||
}
|
||||
|
||||
private static string ToBase64Url(ReadOnlySpan<byte> bytes) =>
|
||||
Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
private static byte[] FromBase64Url(string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildIcs(List<Domain.Entities.Lecture> lectures, int userId)
|
||||
{
|
||||
var calendar = new Calendar
|
||||
{
|
||||
Method = "PUBLISH",
|
||||
ProductId = "-//UniVerse//Lectures Calendar//EN"
|
||||
};
|
||||
|
||||
foreach (var lecture in lectures)
|
||||
{
|
||||
var location = lecture.Location is null
|
||||
? string.Empty
|
||||
: $"{lecture.Location.Building}{(string.IsNullOrWhiteSpace(lecture.Location.Room) ? string.Empty : $", ауд. {lecture.Location.Room}")}";
|
||||
|
||||
var teacherName = lecture.Teacher?.DisplayName
|
||||
?? lecture.Teacher?.Email
|
||||
?? "не указан";
|
||||
|
||||
calendar.Events.Add(new CalendarEvent
|
||||
{
|
||||
Uid = $"lecture-{lecture.Id}-user-{userId}@universe.local",
|
||||
Summary = lecture.Title,
|
||||
Description = $"{lecture.Description}\nПреподаватель: {teacherName}",
|
||||
Location = location,
|
||||
DtStart = new CalDateTime(DateTime.SpecifyKind(lecture.StartsAt, DateTimeKind.Utc)),
|
||||
DtEnd = new CalDateTime(DateTime.SpecifyKind(lecture.EndsAt, DateTimeKind.Utc)),
|
||||
DtStamp = new CalDateTime(DateTime.UtcNow)
|
||||
});
|
||||
}
|
||||
|
||||
return new CalendarSerializer().SerializeToString(calendar) ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter)
|
||||
{
|
||||
var query = _db.Users.AsQueryable();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||
<PackageReference Include="Quartz" Version="3.18.1" />
|
||||
<PackageReference Include="Ical.Net" Version="5.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -81,3 +81,30 @@ export function extractItems<T>(payload: T[] | { items?: T[] } | undefined): T[]
|
||||
if (Array.isArray(payload)) return payload
|
||||
return payload?.items ?? []
|
||||
}
|
||||
|
||||
|
||||
export async function apiRequestBlob(
|
||||
path: string,
|
||||
options: RequestInit & { query?: Record<string, unknown> } = {},
|
||||
): Promise<Blob> {
|
||||
const headers = new Headers(options.headers)
|
||||
if (!headers.has('Accept')) headers.set('Accept', 'text/calendar')
|
||||
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
|
||||
const response = await fetch(makeUrl(path, options.query), {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await parseResponse(response)
|
||||
const message =
|
||||
typeof body === 'object' && body && 'message' in body
|
||||
? String((body as { message: unknown }).message)
|
||||
: `API request failed with status ${response.status}`
|
||||
throw new ApiError(message, response.status, body)
|
||||
}
|
||||
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequest, extractItems } from './client'
|
||||
import { apiRequest, apiRequestBlob, extractItems } from './client'
|
||||
import type {
|
||||
AchievementDto,
|
||||
AuthResponse,
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
UpdateReviewPromptRequest,
|
||||
UserAchievementDto,
|
||||
AdminDashboardStatsDto,
|
||||
CalendarSubscriptionDto,
|
||||
CurrentUserDto,
|
||||
UserDto,
|
||||
UserQuery,
|
||||
@@ -76,6 +77,11 @@ export const usersApi = {
|
||||
)
|
||||
return extractItems(payload)
|
||||
},
|
||||
downloadMyEnrollmentsIcs: () => apiRequestBlob('/users/me/enrollments.ics'),
|
||||
downloadEnrollmentIcs: (lectureId: string | number) =>
|
||||
apiRequestBlob(`/users/me/enrollments/${lectureId}.ics`),
|
||||
getCalendarSubscription: () =>
|
||||
apiRequest<CalendarSubscriptionDto>('/users/me/enrollments/calendar-subscription'),
|
||||
async myAchievements() {
|
||||
const payload = await apiRequest<
|
||||
PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]
|
||||
|
||||
@@ -83,6 +83,10 @@ export interface AdminDashboardStatsDto {
|
||||
pendingReviewsCount: number
|
||||
}
|
||||
|
||||
export interface CalendarSubscriptionDto {
|
||||
feedUrl: string
|
||||
}
|
||||
|
||||
export interface EnrollmentSlotRuleDto {
|
||||
level: number
|
||||
slots: number
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function downloadFile(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usersApi } from '@/api'
|
||||
import { downloadFile } from '@/utils/downloadFile'
|
||||
import { useLecturesStore } from '@/stores/lectures'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
@@ -66,6 +68,16 @@ const levelProgressText = computed(() =>
|
||||
: `${userXp.value} XP`,
|
||||
)
|
||||
|
||||
async function downloadLectureIcs(id: string) {
|
||||
try {
|
||||
const blob = await usersApi.downloadEnrollmentIcs(id)
|
||||
downloadFile(blob, `lecture-${id}.ics`)
|
||||
addToast?.("Файл календаря скачан", "success")
|
||||
} catch (err) {
|
||||
addToast?.(err instanceof Error ? err.message : "Не удалось скачать .ics", "error")
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
lectures.all.length ? Promise.resolve() : lectures.fetchLectures(),
|
||||
@@ -125,7 +137,7 @@ async function registerLecture(id: string) {
|
||||
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">
|
||||
Открыть
|
||||
</button>
|
||||
<button class="btn-secondary">Добавить в календарь</button>
|
||||
<button class="btn-secondary" @click="downloadLectureIcs(nextLecture.id)">Скачать .ics</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { usersApi } from '@/api'
|
||||
import { downloadFile } from '@/utils/downloadFile'
|
||||
import { useLecturesStore } from '@/stores/lectures'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
@@ -37,6 +39,16 @@ onMounted(async () => {
|
||||
await lecturesStore.fetchLecture(lectureId.value)
|
||||
})
|
||||
|
||||
async function downloadLectureIcs(id: string) {
|
||||
try {
|
||||
const blob = await usersApi.downloadEnrollmentIcs(id)
|
||||
downloadFile(blob, `lecture-${id}.ics`)
|
||||
addToast?.("Файл календаря скачан", "success")
|
||||
} catch (err) {
|
||||
addToast?.(err instanceof Error ? err.message : "Не удалось скачать .ics", "error")
|
||||
}
|
||||
}
|
||||
|
||||
async function registerLecture() {
|
||||
if (!lecture.value) return
|
||||
if (slotRegistrationDisabled.value) {
|
||||
@@ -90,7 +102,7 @@ async function registerLecture() {
|
||||
<button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)">
|
||||
Отменить запись
|
||||
</button>
|
||||
<button class="btn-secondary">Добавить в календарь</button>
|
||||
<button class="btn-secondary" @click="downloadLectureIcs(lecture.id)">Скачать .ics</button>
|
||||
<button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">
|
||||
Оставить отзыв
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usersApi } from '@/api'
|
||||
import { downloadFile } from '@/utils/downloadFile'
|
||||
import { useLecturesStore } from '@/stores/lectures'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
@@ -14,6 +16,11 @@ const router = useRouter()
|
||||
const activeTab = ref<'upcoming' | 'history'>('upcoming')
|
||||
const cancelModal = ref(false)
|
||||
const selectedId = ref<string | null>(null)
|
||||
const calendarSubscriptionUrl = ref<string | null>(null)
|
||||
const calendarActionPending = ref(false)
|
||||
const addToast = inject('addToast') as
|
||||
| ((message: string, type?: 'success' | 'error' | 'info') => void)
|
||||
| undefined
|
||||
|
||||
const upcoming = computed(() =>
|
||||
lecturesStore.registeredLectures.map((l) => ({ ...l, status: 'registered' })),
|
||||
@@ -26,6 +33,98 @@ onMounted(async () => {
|
||||
if (auth.user) await lecturesStore.fetchRegisteredForCurrentUser()
|
||||
})
|
||||
|
||||
async function downloadLectureIcs(id: string) {
|
||||
try {
|
||||
const blob = await usersApi.downloadEnrollmentIcs(id)
|
||||
downloadFile(blob, `lecture-${id}.ics`)
|
||||
} catch (err) {
|
||||
addToast?.(err instanceof Error ? err.message : 'Не удалось скачать .ics', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAllIcs() {
|
||||
try {
|
||||
const blob = await usersApi.downloadMyEnrollmentsIcs()
|
||||
downloadFile(blob, 'my-lectures.ics')
|
||||
} catch (err) {
|
||||
addToast?.(err instanceof Error ? err.message : 'Не удалось скачать .ics', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function getCalendarSubscriptionUrl() {
|
||||
if (calendarSubscriptionUrl.value) return calendarSubscriptionUrl.value
|
||||
const subscription = await usersApi.getCalendarSubscription()
|
||||
calendarSubscriptionUrl.value = subscription.feedUrl
|
||||
return subscription.feedUrl
|
||||
}
|
||||
|
||||
async function copyText(value: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
return true
|
||||
} catch {
|
||||
// Browser may block async clipboard writes after awaiting the subscription request.
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
const copied = document.execCommand('copy')
|
||||
textarea.remove()
|
||||
return copied
|
||||
}
|
||||
|
||||
async function copyCalendarSubscriptionUrl() {
|
||||
try {
|
||||
calendarActionPending.value = true
|
||||
const feedUrl = await getCalendarSubscriptionUrl()
|
||||
if (!await copyText(feedUrl)) throw new Error('Браузер заблокировал копирование ссылки')
|
||||
addToast?.('ICS-ссылка скопирована', 'success')
|
||||
} catch (err) {
|
||||
addToast?.(err instanceof Error ? err.message : 'Не удалось скопировать ссылку', 'error')
|
||||
} finally {
|
||||
calendarActionPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncWithGoogleCalendar() {
|
||||
const googleWindow = window.open('about:blank', '_blank')
|
||||
|
||||
try {
|
||||
calendarActionPending.value = true
|
||||
const feedUrl = await getCalendarSubscriptionUrl()
|
||||
const copied = await copyText(feedUrl)
|
||||
|
||||
const googleUrl = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(feedUrl)}`
|
||||
if (googleWindow) {
|
||||
googleWindow.opener = null
|
||||
googleWindow.location.href = googleUrl
|
||||
} else {
|
||||
window.open(googleUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
addToast?.(
|
||||
copied
|
||||
? 'ICS-ссылка скопирована. Google Calendar открыт в новой вкладке.'
|
||||
: 'Google Calendar открыт. Если ссылка не подставилась, скопируйте ICS-ссылку отдельно.',
|
||||
copied ? 'success' : 'info',
|
||||
)
|
||||
} catch (err) {
|
||||
googleWindow?.close()
|
||||
addToast?.(
|
||||
err instanceof Error ? err.message : 'Не удалось подготовить ссылку для Google Calendar',
|
||||
'error',
|
||||
)
|
||||
} finally {
|
||||
calendarActionPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCancel(id: string) {
|
||||
selectedId.value = id
|
||||
cancelModal.value = true
|
||||
@@ -46,7 +145,25 @@ async function confirmCancel() {
|
||||
Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn-secondary">Экспорт в календарь</button>
|
||||
<div class="calendar-actions">
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="calendarActionPending"
|
||||
@click="syncWithGoogleCalendar"
|
||||
>
|
||||
Синхронизировать с Google Calendar
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
:disabled="calendarActionPending"
|
||||
@click="copyCalendarSubscriptionUrl"
|
||||
>
|
||||
Скопировать ICS-ссылку
|
||||
</button>
|
||||
<button class="btn-secondary" @click="downloadAllIcs">
|
||||
Скачать все мои лекции (.ics)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
@@ -76,7 +193,7 @@ async function confirmCancel() {
|
||||
</div>
|
||||
<div class="lecture-actions">
|
||||
<StatusBadge status="registered" />
|
||||
<button class="btn-secondary btn-sm">Добавить в календарь</button>
|
||||
<button class="btn-secondary btn-sm" @click="downloadLectureIcs(item.id)">Скачать .ics</button>
|
||||
<button class="btn-danger btn-sm" @click="openCancel(item.id)">Отменить</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
@@ -133,6 +250,13 @@ async function confirmCancel() {
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.calendar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
|
||||
Reference in New Issue
Block a user