diff --git a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs index bd98025..b94eb92 100644 --- a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs +++ b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs @@ -153,6 +153,10 @@ public class EndpointAuthorizationTests : IClassFixture(); + notifications.CreateUserNotificationAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt(1), callInfo.ArgAt(2), callInfo.ArgAt(3), false, DateTime.UtcNow)); + notifications.SendAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { @@ -114,7 +128,7 @@ public class GamificationServiceTests }) .Build(); - return new GamificationService(db, configuration, NullLogger.Instance); + return new GamificationService(db, configuration, notifications, NullLogger.Instance); } private static Achievement Achievement(int id, string name, string condition, int coinReward) => new() diff --git a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs index b00666a..c9868a3 100644 --- a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs +++ b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs @@ -134,6 +134,17 @@ public class ApiWebApplicationFactory : WebApplicationFactory .Returns(Task.CompletedTask); stub.ScheduleAsync(Arg.Any(), Arg.Any()) .Returns(new ScheduledNotificationResponse("test-job", DateTimeOffset.UtcNow.AddMinutes(5))); + stub.GetUserNotificationsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(PagedResult.Create([], 0, 1, 20)); + stub.MarkAllReadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + stub.CreateUserNotificationAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new UserNotificationDto(1, "achievement", "Title", "Body", false, DateTime.UtcNow)); return stub; } diff --git a/backend/UniVerse.Api/Controllers/NotificationsController.cs b/backend/UniVerse.Api/Controllers/NotificationsController.cs index 5cfc3ec..4cbb597 100644 --- a/backend/UniVerse.Api/Controllers/NotificationsController.cs +++ b/backend/UniVerse.Api/Controllers/NotificationsController.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; +using UniVerse.Application.DTOs.Common; using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.Interfaces; @@ -8,7 +10,7 @@ namespace UniVerse.Api.Controllers; /// Отправка и планирование уведомлений через доступные каналы. [ApiController] [Route("api/v1/notifications")] -[Authorize(Roles = "Admin")] +[Authorize] [Produces("application/json")] public class NotificationsController : ControllerBase { @@ -19,6 +21,34 @@ public class NotificationsController : ControllerBase _notifications = notifications; } + private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0"); + + /// Получить уведомления текущего пользователя. + /// Параметры пагинации. + /// Токен отмены запроса. + /// Список уведомлений. + /// Требуется аутентификация. + [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetMine( + [FromQuery] PaginationRequest pagination, + CancellationToken cancellationToken) => + Ok(await _notifications.GetUserNotificationsAsync(CurrentUserId, pagination, cancellationToken)); + + /// Отметить все уведомления текущего пользователя как прочитанные. + /// Токен отмены запроса. + /// Уведомления отмечены прочитанными. + /// Требуется аутентификация. + [HttpPatch("read-all")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task MarkAllRead(CancellationToken cancellationToken) + { + await _notifications.MarkAllReadAsync(CurrentUserId, cancellationToken); + return NoContent(); + } + /// Отправить уведомление немедленно. /// /// Канал задаётся строкой, например `email`. Новые провайдеры добавляются через `INotificationProvider`. @@ -27,6 +57,7 @@ public class NotificationsController : ControllerBase /// Уведомление принято к отправке. /// Требуется аутентификация. /// Требуется роль Admin. + [Authorize(Roles = "Admin")] [HttpPost("send")] [ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -50,6 +81,7 @@ public class NotificationsController : ControllerBase /// Уведомление поставлено в очередь Quartz.NET. /// Требуется аутентификация. /// Требуется роль Admin. + [Authorize(Roles = "Admin")] [HttpPost("schedule")] [ProducesResponseType(typeof(ScheduledNotificationResponse), StatusCodes.Status202Accepted)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] diff --git a/backend/UniVerse.Application/DTOs/Notifications/NotificationDtos.cs b/backend/UniVerse.Application/DTOs/Notifications/NotificationDtos.cs index f98a2ed..b2406c2 100644 --- a/backend/UniVerse.Application/DTOs/Notifications/NotificationDtos.cs +++ b/backend/UniVerse.Application/DTOs/Notifications/NotificationDtos.cs @@ -31,3 +31,12 @@ public record ScheduleNotificationRequest( IReadOnlyDictionary? Metadata = null); public record ScheduledNotificationResponse(string JobId, DateTimeOffset SendAt); + +public record UserNotificationDto( + int Id, + string Type, + string Title, + string Body, + bool IsRead, + DateTime CreatedAt +); diff --git a/backend/UniVerse.Application/Interfaces/INotificationService.cs b/backend/UniVerse.Application/Interfaces/INotificationService.cs index 8b83c4d..4d242b4 100644 --- a/backend/UniVerse.Application/Interfaces/INotificationService.cs +++ b/backend/UniVerse.Application/Interfaces/INotificationService.cs @@ -1,4 +1,5 @@ using UniVerse.Application.DTOs.Notifications; +using UniVerse.Application.DTOs.Common; namespace UniVerse.Application.Interfaces; @@ -6,4 +7,7 @@ public interface INotificationService { Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default); Task ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default); + Task CreateUserNotificationAsync(int userId, string type, string title, string body, CancellationToken cancellationToken = default); + Task> GetUserNotificationsAsync(int userId, PaginationRequest pagination, CancellationToken cancellationToken = default); + Task MarkAllReadAsync(int userId, CancellationToken cancellationToken = default); } diff --git a/backend/UniVerse.Domain/Entities/User.cs b/backend/UniVerse.Domain/Entities/User.cs index 8bd1d3e..742e9d2 100644 --- a/backend/UniVerse.Domain/Entities/User.cs +++ b/backend/UniVerse.Domain/Entities/User.cs @@ -23,5 +23,6 @@ public class User public ICollection Reviews { get; set; } = new List(); public ICollection UserAchievements { get; set; } = new List(); public ICollection CoinTransactions { get; set; } = new List(); + public ICollection Notifications { get; set; } = new List(); public ICollection RefreshTokens { get; set; } = new List(); } diff --git a/backend/UniVerse.Domain/Entities/UserNotification.cs b/backend/UniVerse.Domain/Entities/UserNotification.cs new file mode 100644 index 0000000..0441c02 --- /dev/null +++ b/backend/UniVerse.Domain/Entities/UserNotification.cs @@ -0,0 +1,14 @@ +namespace UniVerse.Domain.Entities; + +public class UserNotification +{ + public int Id { get; set; } + public int UserId { get; set; } + public string Type { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public bool IsRead { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public User User { get; set; } = null!; +} diff --git a/backend/UniVerse.Infrastructure/Data/AppDbContext.cs b/backend/UniVerse.Infrastructure/Data/AppDbContext.cs index c5e9bba..d322961 100644 --- a/backend/UniVerse.Infrastructure/Data/AppDbContext.cs +++ b/backend/UniVerse.Infrastructure/Data/AppDbContext.cs @@ -23,6 +23,7 @@ public class AppDbContext : DbContext public DbSet Achievements { get; set; } = null!; public DbSet UserAchievements { get; set; } = null!; public DbSet CoinTransactions { get; set; } = null!; + public DbSet UserNotifications { get; set; } = null!; public DbSet RefreshTokens { get; set; } = null!; static AppDbContext() diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/UserNotificationConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/UserNotificationConfiguration.cs new file mode 100644 index 0000000..8ec221f --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/UserNotificationConfiguration.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class UserNotificationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_notifications"); + + builder.HasKey(n => n.Id); + builder.Property(n => n.Id).HasColumnName("id"); + builder.Property(n => n.UserId).HasColumnName("user_id"); + builder.Property(n => n.Type).HasColumnName("type").HasMaxLength(50).IsRequired(); + builder.Property(n => n.Title).HasColumnName("title").HasMaxLength(255).IsRequired(); + builder.Property(n => n.Body).HasColumnName("body").HasMaxLength(1000).IsRequired(); + builder.Property(n => n.IsRead).HasColumnName("is_read").HasDefaultValue(false); + builder.Property(n => n.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + + builder.HasOne(n => n.User) + .WithMany(u => u.Notifications) + .HasForeignKey(n => n.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(n => new { n.UserId, n.CreatedAt }); + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260512123000_UserNotifications.cs b/backend/UniVerse.Infrastructure/Migrations/20260512123000_UserNotifications.cs new file mode 100644 index 0000000..f2de9b7 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260512123000_UserNotifications.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using UniVerse.Infrastructure.Data; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + /// + [DbContext(typeof(AppDbContext))] + [Migration("20260512123000_UserNotifications")] + public partial class UserNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "user_notifications", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "integer", nullable: false), + type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + title = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + body = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + is_read = table.Column(type: "boolean", nullable: false, defaultValue: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_user_notifications", x => x.id); + table.ForeignKey( + name: "FK_user_notifications_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_user_notifications_user_id_created_at", + table: "user_notifications", + columns: new[] { "user_id", "created_at" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "user_notifications"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 8b0a278..5c0db76 100644 --- a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -721,6 +721,56 @@ namespace UniVerse.Infrastructure.Migrations b.ToTable("user_achievements", (string)null); }); + modelBuilder.Entity("UniVerse.Domain.Entities.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("body"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("user_notifications", (string)null); + }); + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => { b.Property("UserId") @@ -905,6 +955,17 @@ namespace UniVerse.Infrastructure.Migrations b.Navigation("User"); }); + modelBuilder.Entity("UniVerse.Domain.Entities.UserNotification", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Notifications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => { b.HasOne("UniVerse.Domain.Entities.User", "User") @@ -958,6 +1019,8 @@ namespace UniVerse.Infrastructure.Migrations b.Navigation("Enrollments"); + b.Navigation("Notifications"); + b.Navigation("RefreshTokens"); b.Navigation("Reviews"); diff --git a/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs b/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs index 5057fa8..ca60efd 100644 --- a/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs +++ b/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs @@ -1,20 +1,27 @@ using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Common; using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.Interfaces; +using UniVerse.Domain.Entities; +using UniVerse.Infrastructure.Data; namespace UniVerse.Infrastructure.Notifications; public class NotificationService : INotificationService { + private readonly AppDbContext _db; private readonly IEnumerable _providers; private readonly INotificationScheduler _scheduler; private readonly ILogger _logger; public NotificationService( + AppDbContext db, IEnumerable providers, INotificationScheduler scheduler, ILogger logger) { + _db = db; _providers = providers; _scheduler = scheduler; _logger = logger; @@ -46,4 +53,68 @@ public class NotificationService : INotificationService return _scheduler.ScheduleAsync(message, request.SendAt, cancellationToken); } + + public async Task CreateUserNotificationAsync( + int userId, + string type, + string title, + string body, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(type); + ArgumentException.ThrowIfNullOrWhiteSpace(title); + ArgumentException.ThrowIfNullOrWhiteSpace(body); + + var notification = new UserNotification + { + UserId = userId, + Type = type, + Title = title, + Body = body + }; + + _db.UserNotifications.Add(notification); + await _db.SaveChangesAsync(cancellationToken); + + return ToDto(notification); + } + + public async Task> GetUserNotificationsAsync( + int userId, + PaginationRequest pagination, + CancellationToken cancellationToken = default) + { + var query = _db.UserNotifications.Where(n => n.UserId == userId); + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderByDescending(n => n.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .Select(n => new UserNotificationDto( + n.Id, + n.Type, + n.Title, + n.Body, + n.IsRead, + n.CreatedAt)) + .ToListAsync(cancellationToken); + + return PagedResult.Create(items, total, pagination.Page, pagination.PageSize); + } + + public async Task MarkAllReadAsync(int userId, CancellationToken cancellationToken = default) + { + await _db.UserNotifications + .Where(n => n.UserId == userId && !n.IsRead) + .ExecuteUpdateAsync(setters => setters.SetProperty(n => n.IsRead, true), cancellationToken); + } + + private static UserNotificationDto ToDto(UserNotification notification) => new( + notification.Id, + notification.Type, + notification.Title, + notification.Body, + notification.IsRead, + notification.CreatedAt + ); } diff --git a/backend/UniVerse.Infrastructure/Services/GamificationService.cs b/backend/UniVerse.Infrastructure/Services/GamificationService.cs index b4bb705..79dbd52 100644 --- a/backend/UniVerse.Infrastructure/Services/GamificationService.cs +++ b/backend/UniVerse.Infrastructure/Services/GamificationService.cs @@ -5,6 +5,7 @@ using System.Globalization; using UniVerse.Application.DTOs.Achievements; using UniVerse.Application.DTOs.Common; using UniVerse.Application.DTOs.Gamification; +using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.Interfaces; using UniVerse.Application.Mappings; using UniVerse.Domain.Entities; @@ -17,11 +18,16 @@ public class GamificationService : IGamificationService { private readonly AppDbContext _db; private readonly IConfiguration _config; + private readonly INotificationService _notifications; private readonly ILogger _logger; - public GamificationService(AppDbContext db, IConfiguration config, ILogger logger) + public GamificationService( + AppDbContext db, + IConfiguration config, + INotificationService notifications, + ILogger logger) { - _db = db; _config = config; _logger = logger; + _db = db; _config = config; _notifications = notifications; _logger = logger; } public async Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type, @@ -91,10 +97,43 @@ public class GamificationService : IGamificationService achievementId: achievement.Id, description: $"Achievement: {achievement.Name}"); earnedCoins += achievement.CoinReward; } + + await TryNotifyAchievementAsync(user, achievement); } await _db.SaveChangesAsync(); } + private async Task TryNotifyAchievementAsync(User user, Achievement achievement) + { + try + { + var title = $"Новое достижение: {achievement.Name}"; + var rewardText = achievement.CoinReward > 0 + ? $" Награда: {achievement.CoinReward} монет." + : string.Empty; + var body = $"{achievement.Description ?? "Вы выполнили условие достижения."}{rewardText}"; + + await _notifications.CreateUserNotificationAsync(user.Id, "achievement", title, body); + + await _notifications.SendAsync(new NotificationMessage( + NotificationChannels.Email, + user.Email, + title, + $"Здравствуйте, {user.DisplayName ?? user.Email}!\n\nПоздравляем: вы получили достижение «{achievement.Name}» в UniVerse.\n\n{body}", + user.DisplayName, + new Dictionary + { + ["event"] = "achievement_earned", + ["achievement_id"] = achievement.Id.ToString(), + ["achievement_name"] = achievement.Name + })); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send achievement notification {AchievementId} to user {UserId}", achievement.Id, user.Id); + } + } + private static bool TryParseCondition(string? condition, out string type, out int value) { type = string.Empty; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e15591a..d69dcf6 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -16,6 +16,7 @@ import type { UserAchievementDto, UserDto, UserQuery, + UserNotificationDto, UserStatsDto, } from './types' @@ -85,6 +86,14 @@ export const achievementsApi = { }, } +export const notificationsApi = { + async list() { + const payload = await apiRequest | UserNotificationDto[]>('/notifications') + return extractItems(payload) + }, + markAllRead: () => apiRequest('/notifications/read-all', { method: 'PATCH' }), +} + export const reviewsApi = { create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) => apiRequest('/reviews', { diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index 9716500..a3626c5 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -1,4 +1,4 @@ -import type { Achievement, CoinTransaction, Lecture, Review, User, UserRole } from '@/types' +import type { Achievement, CoinTransaction, Lecture, Notification, Review, User, UserRole } from '@/types' import type { AchievementDto, CoinTransactionDto, @@ -8,6 +8,7 @@ import type { UserDto, UserStatsDto, UserAchievementDto, + UserNotificationDto, } from './types' export function mapApiRole(role: string | undefined): UserRole { @@ -127,3 +128,14 @@ export function mapApiCoinTransaction(transaction: CoinTransactionDto): CoinTran type: transaction.amount >= 0 ? 'earned' : 'spent', } } + +export function mapApiNotification(notification: UserNotificationDto): Notification { + return { + id: String(notification.id), + type: notification.type, + title: notification.title, + body: notification.body, + read: notification.isRead, + createdAt: notification.createdAt, + } +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index accd091..1735e21 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -185,6 +185,15 @@ export interface CoinTransactionDto { createdAt: string } +export interface UserNotificationDto { + id: number + type: 'reminder' | 'schedule-change' | 'achievement' | 'coins' | 'recommendation' + title: string + body: string + isRead: boolean + createdAt: string +} + export interface LectureQuery { DateFrom?: string DateTo?: string diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index 9655813..6636fce 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { achievementsApi, usersApi } from '@/api' -import { mapApiAchievement, mapApiCoinTransaction } from '@/api/mappers' +import { achievementsApi, notificationsApi, usersApi } from '@/api' +import { mapApiAchievement, mapApiCoinTransaction, mapApiNotification } from '@/api/mappers' import type { Achievement, CoinTransaction, Notification } from '@/types' import { useAuthStore } from './auth' @@ -25,7 +25,10 @@ export const useUserStore = defineStore('user', () => { usersApi.achievements(id), usersApi.transactions(id), ]) - const achievementCatalog = await achievementsApi.list() + const [achievementCatalog, notificationPayload] = await Promise.all([ + achievementsApi.list(), + notificationsApi.list(), + ]) if (auth.user) { auth.setUser({ @@ -55,6 +58,7 @@ export const useUserStore = defineStore('user', () => { (a, b) => Number(a.id) - Number(b.id), ) coinHistory.value = transactions.map(mapApiCoinTransaction) + notifications.value = notificationPayload.map(mapApiNotification) } catch (err) { error.value = err instanceof Error ? err.message : 'Не удалось загрузить данные профиля.' } finally { @@ -62,7 +66,13 @@ export const useUserStore = defineStore('user', () => { } } - function markAllRead() { + async function fetchNotifications() { + const payload = await notificationsApi.list() + notifications.value = payload.map(mapApiNotification) + } + + async function markAllRead() { + await notificationsApi.markAllRead() notifications.value.forEach(n => (n.read = true)) } @@ -75,6 +85,7 @@ export const useUserStore = defineStore('user', () => { loading, error, fetchStudentData, + fetchNotifications, markAllRead, unreadCount, } diff --git a/frontend/src/views/student/NotificationsView.vue b/frontend/src/views/student/NotificationsView.vue index f571092..6e4b501 100644 --- a/frontend/src/views/student/NotificationsView.vue +++ b/frontend/src/views/student/NotificationsView.vue @@ -1,5 +1,5 @@