From c29a48163decbabcfa0f694a19742545ef3c6898 Mon Sep 17 00:00:00 2001 From: SEAN Date: Thu, 26 Feb 2026 09:44:28 +0900 Subject: [PATCH] =?UTF-8?q?improvement:=20Notification=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B5=AC=EC=B6=95=20(#247)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain: NotificationCategory enum, Notification entity, INotificationRepository - Infrastructure: NotificationConfiguration, NotificationRepository, AppDbContext/DI 등록 - Migration: AddNotificationTable 생성 및 적용 - Application: DTO 7개, INotificationService, NotificationService, DI 등록 - API: NotificationController (summary, list, read, read-all) Closes #247 --- .../Controllers/NotificationController.cs | 68 + .../DTOs/Notification/NotificationItemDto.cs | 30 + .../NotificationListRequestDto.cs | 24 + .../NotificationListResponseDto.cs | 16 + .../NotificationReadRequestDto.cs | 11 + .../NotificationReadResponseDto.cs | 9 + .../NotificationSummaryRequestDto.cs | 9 + .../NotificationSummaryResponseDto.cs | 12 + SPMS.Application/DependencyInjection.cs | 1 + .../Interfaces/INotificationService.cs | 11 + .../Services/NotificationService.cs | 104 ++ SPMS.Domain/Entities/Notification.cs | 18 + SPMS.Domain/Enums/NotificationCategory.cs | 10 + .../Interfaces/INotificationRepository.cs | 15 + SPMS.Infrastructure/AppDbContext.cs | 1 + SPMS.Infrastructure/DependencyInjection.cs | 1 + ...226003916_AddNotificationTable.Designer.cs | 1249 +++++++++++++++++ .../20260226003916_AddNotificationTable.cs | 63 + .../Migrations/AppDbContextModelSnapshot.cs | 59 + .../NotificationConfiguration.cs | 30 + .../Repositories/NotificationRepository.cs | 60 + 21 files changed, 1801 insertions(+) create mode 100644 SPMS.API/Controllers/NotificationController.cs create mode 100644 SPMS.Application/DTOs/Notification/NotificationItemDto.cs create mode 100644 SPMS.Application/DTOs/Notification/NotificationListRequestDto.cs create mode 100644 SPMS.Application/DTOs/Notification/NotificationListResponseDto.cs create mode 100644 SPMS.Application/DTOs/Notification/NotificationReadRequestDto.cs create mode 100644 SPMS.Application/DTOs/Notification/NotificationReadResponseDto.cs create mode 100644 SPMS.Application/DTOs/Notification/NotificationSummaryRequestDto.cs create mode 100644 SPMS.Application/DTOs/Notification/NotificationSummaryResponseDto.cs create mode 100644 SPMS.Application/Interfaces/INotificationService.cs create mode 100644 SPMS.Application/Services/NotificationService.cs create mode 100644 SPMS.Domain/Entities/Notification.cs create mode 100644 SPMS.Domain/Enums/NotificationCategory.cs create mode 100644 SPMS.Domain/Interfaces/INotificationRepository.cs create mode 100644 SPMS.Infrastructure/Migrations/20260226003916_AddNotificationTable.Designer.cs create mode 100644 SPMS.Infrastructure/Migrations/20260226003916_AddNotificationTable.cs create mode 100644 SPMS.Infrastructure/Persistence/Configurations/NotificationConfiguration.cs create mode 100644 SPMS.Infrastructure/Persistence/Repositories/NotificationRepository.cs diff --git a/SPMS.API/Controllers/NotificationController.cs b/SPMS.API/Controllers/NotificationController.cs new file mode 100644 index 0000000..56ab1cd --- /dev/null +++ b/SPMS.API/Controllers/NotificationController.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using SPMS.Application.DTOs.Notification; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; +using SPMS.Domain.Exceptions; + +namespace SPMS.API.Controllers; + +[ApiController] +[Route("v1/in/notification")] +[Authorize] +[ApiExplorerSettings(GroupName = "notification")] +public class NotificationController : ControllerBase +{ + private readonly INotificationService _notificationService; + + public NotificationController(INotificationService notificationService) + { + _notificationService = notificationService; + } + + [HttpPost("summary")] + [SwaggerOperation(Summary = "알림 요약 조회", Description = "최근 N건의 알림과 미읽 건수를 반환합니다. 헤더 뱃지용.")] + public async Task GetSummaryAsync([FromBody] NotificationSummaryRequestDto request) + { + var adminId = GetAdminId(); + var result = await _notificationService.GetSummaryAsync(adminId, request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("list")] + [SwaggerOperation(Summary = "알림 목록 조회", Description = "알림 목록을 페이지 단위로 조회합니다. 카테고리/기간/읽음 필터를 지원합니다.")] + public async Task GetListAsync([FromBody] NotificationListRequestDto request) + { + var adminId = GetAdminId(); + var result = await _notificationService.GetListAsync(adminId, request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("read")] + [SwaggerOperation(Summary = "알림 읽음 처리", Description = "단건 알림을 읽음 처리합니다. 이미 읽은 알림은 무시(멱등).")] + public async Task MarkAsReadAsync([FromBody] NotificationReadRequestDto request) + { + var adminId = GetAdminId(); + var result = await _notificationService.MarkAsReadAsync(adminId, request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("read-all")] + [SwaggerOperation(Summary = "알림 전체 읽음", Description = "해당 관리자의 모든 미읽 알림을 일괄 읽음 처리합니다.")] + public async Task MarkAllAsReadAsync() + { + var adminId = GetAdminId(); + var result = await _notificationService.MarkAllAsReadAsync(adminId); + return Ok(ApiResponse.Success(result)); + } + + private long GetAdminId() + { + var adminIdClaim = User.FindFirst("adminId")?.Value; + if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId)) + throw new SpmsException(ErrorCodes.Unauthorized, "인증 정보가 올바르지 않습니다.", 401); + + return adminId; + } +} diff --git a/SPMS.Application/DTOs/Notification/NotificationItemDto.cs b/SPMS.Application/DTOs/Notification/NotificationItemDto.cs new file mode 100644 index 0000000..8a6f4b4 --- /dev/null +++ b/SPMS.Application/DTOs/Notification/NotificationItemDto.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Notification; + +public class NotificationItemDto +{ + [JsonPropertyName("notification_id")] + public long NotificationId { get; set; } + + [JsonPropertyName("category")] + public string Category { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + + [JsonPropertyName("link_url")] + public string? LinkUrl { get; set; } + + [JsonPropertyName("is_read")] + public bool IsRead { get; set; } + + [JsonPropertyName("read_at")] + public DateTime? ReadAt { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } +} diff --git a/SPMS.Application/DTOs/Notification/NotificationListRequestDto.cs b/SPMS.Application/DTOs/Notification/NotificationListRequestDto.cs new file mode 100644 index 0000000..c123798 --- /dev/null +++ b/SPMS.Application/DTOs/Notification/NotificationListRequestDto.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Notification; + +public class NotificationListRequestDto +{ + [JsonPropertyName("page")] + public int Page { get; set; } = 1; + + [JsonPropertyName("size")] + public int Size { get; set; } = 20; + + [JsonPropertyName("category")] + public string? Category { get; set; } + + [JsonPropertyName("from")] + public DateTime? From { get; set; } + + [JsonPropertyName("to")] + public DateTime? To { get; set; } + + [JsonPropertyName("is_read")] + public bool? IsRead { get; set; } +} diff --git a/SPMS.Application/DTOs/Notification/NotificationListResponseDto.cs b/SPMS.Application/DTOs/Notification/NotificationListResponseDto.cs new file mode 100644 index 0000000..f9f5ce2 --- /dev/null +++ b/SPMS.Application/DTOs/Notification/NotificationListResponseDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using SPMS.Application.DTOs.Notice; + +namespace SPMS.Application.DTOs.Notification; + +public class NotificationListResponseDto +{ + [JsonPropertyName("items")] + public List Items { get; set; } = new(); + + [JsonPropertyName("pagination")] + public PaginationDto Pagination { get; set; } = new(); + + [JsonPropertyName("unread_count")] + public int UnreadCount { get; set; } +} diff --git a/SPMS.Application/DTOs/Notification/NotificationReadRequestDto.cs b/SPMS.Application/DTOs/Notification/NotificationReadRequestDto.cs new file mode 100644 index 0000000..999bc16 --- /dev/null +++ b/SPMS.Application/DTOs/Notification/NotificationReadRequestDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Notification; + +public class NotificationReadRequestDto +{ + [Required] + [JsonPropertyName("notification_id")] + public long NotificationId { get; set; } +} diff --git a/SPMS.Application/DTOs/Notification/NotificationReadResponseDto.cs b/SPMS.Application/DTOs/Notification/NotificationReadResponseDto.cs new file mode 100644 index 0000000..cefea8a --- /dev/null +++ b/SPMS.Application/DTOs/Notification/NotificationReadResponseDto.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Notification; + +public class NotificationReadResponseDto +{ + [JsonPropertyName("unread_count")] + public int UnreadCount { get; set; } +} diff --git a/SPMS.Application/DTOs/Notification/NotificationSummaryRequestDto.cs b/SPMS.Application/DTOs/Notification/NotificationSummaryRequestDto.cs new file mode 100644 index 0000000..b4cf590 --- /dev/null +++ b/SPMS.Application/DTOs/Notification/NotificationSummaryRequestDto.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Notification; + +public class NotificationSummaryRequestDto +{ + [JsonPropertyName("limit")] + public int Limit { get; set; } = 5; +} diff --git a/SPMS.Application/DTOs/Notification/NotificationSummaryResponseDto.cs b/SPMS.Application/DTOs/Notification/NotificationSummaryResponseDto.cs new file mode 100644 index 0000000..51a505c --- /dev/null +++ b/SPMS.Application/DTOs/Notification/NotificationSummaryResponseDto.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Notification; + +public class NotificationSummaryResponseDto +{ + [JsonPropertyName("recent_items")] + public List RecentItems { get; set; } = new(); + + [JsonPropertyName("unread_count")] + public int UnreadCount { get; set; } +} diff --git a/SPMS.Application/DependencyInjection.cs b/SPMS.Application/DependencyInjection.cs index b37e4b5..b8eacce 100644 --- a/SPMS.Application/DependencyInjection.cs +++ b/SPMS.Application/DependencyInjection.cs @@ -23,6 +23,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/SPMS.Application/Interfaces/INotificationService.cs b/SPMS.Application/Interfaces/INotificationService.cs new file mode 100644 index 0000000..1aaca7e --- /dev/null +++ b/SPMS.Application/Interfaces/INotificationService.cs @@ -0,0 +1,11 @@ +using SPMS.Application.DTOs.Notification; + +namespace SPMS.Application.Interfaces; + +public interface INotificationService +{ + Task GetSummaryAsync(long adminId, NotificationSummaryRequestDto request); + Task GetListAsync(long adminId, NotificationListRequestDto request); + Task MarkAsReadAsync(long adminId, NotificationReadRequestDto request); + Task MarkAllAsReadAsync(long adminId); +} diff --git a/SPMS.Application/Services/NotificationService.cs b/SPMS.Application/Services/NotificationService.cs new file mode 100644 index 0000000..523e2c2 --- /dev/null +++ b/SPMS.Application/Services/NotificationService.cs @@ -0,0 +1,104 @@ +using SPMS.Application.DTOs.Notice; +using SPMS.Application.DTOs.Notification; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; +using SPMS.Domain.Enums; +using SPMS.Domain.Exceptions; +using SPMS.Domain.Interfaces; + +namespace SPMS.Application.Services; + +public class NotificationService : INotificationService +{ + private readonly INotificationRepository _notificationRepository; + private readonly IUnitOfWork _unitOfWork; + + public NotificationService( + INotificationRepository notificationRepository, + IUnitOfWork unitOfWork) + { + _notificationRepository = notificationRepository; + _unitOfWork = unitOfWork; + } + + public async Task GetSummaryAsync(long adminId, NotificationSummaryRequestDto request) + { + var limit = request.Limit > 0 ? request.Limit : 5; + var recentItems = await _notificationRepository.GetRecentAsync(adminId, limit); + var unreadCount = await _notificationRepository.GetUnreadCountAsync(adminId); + + return new NotificationSummaryResponseDto + { + RecentItems = recentItems.Select(MapToItemDto).ToList(), + UnreadCount = unreadCount + }; + } + + public async Task GetListAsync(long adminId, NotificationListRequestDto request) + { + // category 문자열 → enum 파싱 + NotificationCategory? category = null; + if (!string.IsNullOrWhiteSpace(request.Category)) + { + if (!Enum.TryParse(request.Category, true, out var parsed)) + throw new SpmsException(ErrorCodes.BadRequest, $"유효하지 않은 카테고리: {request.Category}"); + category = parsed; + } + + var (items, totalCount) = await _notificationRepository.GetPagedAsync( + adminId, category, request.From, request.To, request.IsRead, request.Page, request.Size); + + var unreadCount = await _notificationRepository.GetUnreadCountAsync(adminId); + var totalPages = (int)Math.Ceiling((double)totalCount / request.Size); + + return new NotificationListResponseDto + { + Items = items.Select(MapToItemDto).ToList(), + Pagination = new PaginationDto + { + Page = request.Page, + Size = request.Size, + TotalCount = totalCount, + TotalPages = totalPages + }, + UnreadCount = unreadCount + }; + } + + public async Task MarkAsReadAsync(long adminId, NotificationReadRequestDto request) + { + var notification = await _notificationRepository.GetByIdAndAdminAsync(request.NotificationId, adminId); + if (notification == null) + throw new SpmsException(ErrorCodes.NotFound, "알림을 찾을 수 없습니다.", 404); + + // 멱등성: 이미 읽은 알림은 스킵 + if (!notification.IsRead) + { + notification.IsRead = true; + notification.ReadAt = DateTime.UtcNow; + _notificationRepository.Update(notification); + await _unitOfWork.SaveChangesAsync(); + } + + var unreadCount = await _notificationRepository.GetUnreadCountAsync(adminId); + return new NotificationReadResponseDto { UnreadCount = unreadCount }; + } + + public async Task MarkAllAsReadAsync(long adminId) + { + await _notificationRepository.MarkAllAsReadAsync(adminId); + return new NotificationReadResponseDto { UnreadCount = 0 }; + } + + private static NotificationItemDto MapToItemDto(Domain.Entities.Notification n) => new() + { + NotificationId = n.Id, + Category = n.Category.ToString().ToLower(), + Title = n.Title, + Content = n.Content, + LinkUrl = n.LinkUrl, + IsRead = n.IsRead, + ReadAt = n.ReadAt, + CreatedAt = n.CreatedAt + }; +} diff --git a/SPMS.Domain/Entities/Notification.cs b/SPMS.Domain/Entities/Notification.cs new file mode 100644 index 0000000..1e3081f --- /dev/null +++ b/SPMS.Domain/Entities/Notification.cs @@ -0,0 +1,18 @@ +using SPMS.Domain.Enums; + +namespace SPMS.Domain.Entities; + +public class Notification : BaseEntity +{ + public long TargetAdminId { get; set; } + public NotificationCategory Category { get; set; } + public string Title { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string? LinkUrl { get; set; } + public bool IsRead { get; set; } + public DateTime? ReadAt { get; set; } + public DateTime CreatedAt { get; set; } + + // Navigation + public Admin TargetAdmin { get; set; } = null!; +} diff --git a/SPMS.Domain/Enums/NotificationCategory.cs b/SPMS.Domain/Enums/NotificationCategory.cs new file mode 100644 index 0000000..503679f --- /dev/null +++ b/SPMS.Domain/Enums/NotificationCategory.cs @@ -0,0 +1,10 @@ +namespace SPMS.Domain.Enums; + +public enum NotificationCategory : byte +{ + Send = 0, // 발송 + Certificate = 1, // 인증서 + Service = 2, // 서비스 + Failure = 3, // 실패 + System = 4 // 시스템 +} diff --git a/SPMS.Domain/Interfaces/INotificationRepository.cs b/SPMS.Domain/Interfaces/INotificationRepository.cs new file mode 100644 index 0000000..327a23e --- /dev/null +++ b/SPMS.Domain/Interfaces/INotificationRepository.cs @@ -0,0 +1,15 @@ +using SPMS.Domain.Entities; +using SPMS.Domain.Enums; + +namespace SPMS.Domain.Interfaces; + +public interface INotificationRepository : IRepository +{ + Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( + long adminId, NotificationCategory? category, DateTime? from, DateTime? to, + bool? isRead, int page, int size); + Task GetUnreadCountAsync(long adminId); + Task> GetRecentAsync(long adminId, int limit); + Task GetByIdAndAdminAsync(long id, long adminId); + Task MarkAllAsReadAsync(long adminId); +} diff --git a/SPMS.Infrastructure/AppDbContext.cs b/SPMS.Infrastructure/AppDbContext.cs index 5a27fbe..739837b 100644 --- a/SPMS.Infrastructure/AppDbContext.cs +++ b/SPMS.Infrastructure/AppDbContext.cs @@ -26,6 +26,7 @@ public class AppDbContext : DbContext public DbSet Faqs => Set(); public DbSet AppConfigs => Set(); public DbSet Tags => Set(); + public DbSet Notifications => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index 4b0ea89..935d6d0 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -44,6 +44,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // External Services services.AddScoped(); diff --git a/SPMS.Infrastructure/Migrations/20260226003916_AddNotificationTable.Designer.cs b/SPMS.Infrastructure/Migrations/20260226003916_AddNotificationTable.Designer.cs new file mode 100644 index 0000000..c3e2e6b --- /dev/null +++ b/SPMS.Infrastructure/Migrations/20260226003916_AddNotificationTable.Designer.cs @@ -0,0 +1,1249 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SPMS.Infrastructure; + +#nullable disable + +namespace SPMS.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226003916_AddNotificationTable")] + partial class AddNotificationTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("SPMS.Domain.Entities.Admin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdminCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("varchar(8)"); + + b.Property("AgreePrivacy") + .HasColumnType("tinyint(1)"); + + b.Property("AgreeTerms") + .HasColumnType("tinyint(1)"); + + b.Property("AgreedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EmailVerified") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("EmailVerifiedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("MustChangePassword") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("RefreshToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("Role") + .HasColumnType("tinyint"); + + b.Property("TempPasswordIssuedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AdminCode") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Admin", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.AppConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ConfigValue") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "ConfigKey") + .IsUnique(); + + b.ToTable("AppConfig", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Banner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("LinkType") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("LinkUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Position") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId"); + + b.ToTable("Banner", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.DailyStat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FailCnt") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("OpenCnt") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("SentCnt") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("StatDate") + .HasColumnType("date"); + + b.Property("SuccessCnt") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "StatDate") + .IsUnique(); + + b.ToTable("DailyStat", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AgreeUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("AppVersion") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeviceModel") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("DeviceToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("MarketingAgreed") + .HasColumnType("tinyint(1)"); + + b.Property("MktAgreeUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OsVersion") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Platform") + .HasColumnType("tinyint"); + + b.Property("PushAgreed") + .HasColumnType("tinyint(1)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Tags") + .HasColumnType("json"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "DeviceToken"); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Faq", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Answer") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId"); + + b.ToTable("Faq", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.FileEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FileType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("MimeType") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ServiceId"); + + b.ToTable("File", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("CustomData") + .HasColumnType("json"); + + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + + b.Property("ImageUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("LinkType") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("LinkUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("MessageCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("MessageCode") + .IsUnique(); + + b.HasIndex("ServiceId"); + + b.ToTable("Message", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Notice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("IsPinned") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ServiceId"); + + b.ToTable("Notice", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasColumnType("tinyint unsigned"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("LinkUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReadAt") + .HasColumnType("datetime(6)"); + + b.Property("TargetAdminId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("TargetAdminId", "CreatedAt"); + + b.HasIndex("TargetAdminId", "IsRead"); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdminId") + .HasColumnType("bigint"); + + b.Property("Amount") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("PaidAt") + .HasColumnType("datetime(6)"); + + b.Property("PaymentKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PaymentMethod") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("tinyint"); + + b.Property("TierAfter") + .HasColumnType("tinyint"); + + b.Property("TierBefore") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AdminId"); + + b.HasIndex("ServiceId"); + + b.ToTable("Payment", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.PushOpenLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DeviceId") + .HasColumnType("bigint"); + + b.Property("MessageId") + .HasColumnType("bigint"); + + b.Property("OpenedAt") + .HasColumnType("datetime(6)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("MessageId"); + + b.HasIndex("ServiceId", "OpenedAt"); + + b.ToTable("PushOpenLog", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.PushSendLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DeviceId") + .HasColumnType("bigint"); + + b.Property("FailReason") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MessageId") + .HasColumnType("bigint"); + + b.Property("SentAt") + .HasColumnType("datetime(6)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("MessageId"); + + b.HasIndex("ServiceId", "SentAt"); + + b.ToTable("PushSendLog", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("ApiKeyCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApnsAuthType") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("ApnsBundleId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ApnsCertExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("ApnsCertPassword") + .HasColumnType("text"); + + b.Property("ApnsCertificate") + .HasColumnType("text"); + + b.Property("ApnsKeyId") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("ApnsPrivateKey") + .HasColumnType("text"); + + b.Property("ApnsTeamId") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FcmCredentials") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ServiceCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("varchar(8)"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Status") + .HasColumnType("tinyint"); + + b.Property("SubStartedAt") + .HasColumnType("datetime(6)"); + + b.Property("SubTier") + .HasColumnType("tinyint"); + + b.Property("Tags") + .HasColumnType("json"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WebhookEvents") + .HasColumnType("longtext"); + + b.Property("WebhookUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ServiceCode") + .IsUnique(); + + b.ToTable("Service", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.ServiceIp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("varchar(45)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId"); + + b.ToTable("ServiceIp", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.SystemLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("AdminId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasColumnType("json"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("varchar(45)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("TargetId") + .HasColumnType("bigint"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AdminId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ServiceId"); + + b.ToTable("SystemLog", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ServiceId", "Name") + .IsUnique(); + + b.ToTable("Tag", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.WebhookLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EventType") + .HasColumnType("tinyint"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("json"); + + b.Property("ResponseBody") + .HasColumnType("text"); + + b.Property("ResponseCode") + .HasColumnType("int"); + + b.Property("SentAt") + .HasColumnType("datetime(6)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("tinyint"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "SentAt"); + + b.ToTable("WebhookLog", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.AppConfig", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Banner", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.DailyStat", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Device", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany("Devices") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Faq", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.FileEntity", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Message", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany("Messages") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Notice", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Notification", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "TargetAdmin") + .WithMany() + .HasForeignKey("TargetAdminId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("TargetAdmin"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Payment", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "Admin") + .WithMany() + .HasForeignKey("AdminId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Admin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.PushOpenLog", b => + { + b.HasOne("SPMS.Domain.Entities.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Message", "Message") + .WithMany() + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("Message"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.PushSendLog", b => + { + b.HasOne("SPMS.Domain.Entities.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Message", "Message") + .WithMany() + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("Message"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Service", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.ServiceIp", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany("ServiceIps") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.SystemLog", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "Admin") + .WithMany() + .HasForeignKey("AdminId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Admin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Tag", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany("TagList") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.WebhookLog", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Service", b => + { + b.Navigation("Devices"); + + b.Navigation("Messages"); + + b.Navigation("ServiceIps"); + + b.Navigation("TagList"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SPMS.Infrastructure/Migrations/20260226003916_AddNotificationTable.cs b/SPMS.Infrastructure/Migrations/20260226003916_AddNotificationTable.cs new file mode 100644 index 0000000..77f8d86 --- /dev/null +++ b/SPMS.Infrastructure/Migrations/20260226003916_AddNotificationTable.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SPMS.Infrastructure.Migrations +{ + /// + public partial class AddNotificationTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Notification", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + TargetAdminId = table.Column(type: "bigint", nullable: false), + Category = table.Column(type: "tinyint unsigned", nullable: false), + Title = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Content = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + LinkUrl = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + IsRead = table.Column(type: "tinyint(1)", nullable: false, defaultValue: false), + ReadAt = table.Column(type: "datetime(6)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Notification", x => x.Id); + table.ForeignKey( + name: "FK_Notification_Admin_TargetAdminId", + column: x => x.TargetAdminId, + principalTable: "Admin", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_Notification_TargetAdminId_CreatedAt", + table: "Notification", + columns: new[] { "TargetAdminId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Notification_TargetAdminId_IsRead", + table: "Notification", + columns: new[] { "TargetAdminId", "IsRead" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Notification"); + } + } +} diff --git a/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index f6a570e..90c21f7 100644 --- a/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -536,6 +536,54 @@ namespace SPMS.Infrastructure.Migrations b.ToTable("Notice", (string)null); }); + modelBuilder.Entity("SPMS.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasColumnType("tinyint unsigned"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("LinkUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReadAt") + .HasColumnType("datetime(6)"); + + b.Property("TargetAdminId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("TargetAdminId", "CreatedAt"); + + b.HasIndex("TargetAdminId", "IsRead"); + + b.ToTable("Notification", (string)null); + }); + modelBuilder.Entity("SPMS.Domain.Entities.Payment", b => { b.Property("Id") @@ -1029,6 +1077,17 @@ namespace SPMS.Infrastructure.Migrations b.Navigation("Service"); }); + modelBuilder.Entity("SPMS.Domain.Entities.Notification", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "TargetAdmin") + .WithMany() + .HasForeignKey("TargetAdminId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("TargetAdmin"); + }); + modelBuilder.Entity("SPMS.Domain.Entities.Payment", b => { b.HasOne("SPMS.Domain.Entities.Admin", "Admin") diff --git a/SPMS.Infrastructure/Persistence/Configurations/NotificationConfiguration.cs b/SPMS.Infrastructure/Persistence/Configurations/NotificationConfiguration.cs new file mode 100644 index 0000000..dd8e58f --- /dev/null +++ b/SPMS.Infrastructure/Persistence/Configurations/NotificationConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SPMS.Domain.Entities; + +namespace SPMS.Infrastructure.Persistence.Configurations; + +public class NotificationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Notification"); + + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).ValueGeneratedOnAdd(); + + builder.Property(e => e.Title).HasMaxLength(200).IsRequired(); + builder.Property(e => e.Content).HasMaxLength(1000).IsRequired(); + builder.Property(e => e.LinkUrl).HasMaxLength(500); + builder.Property(e => e.IsRead).IsRequired().HasDefaultValue(false); + builder.Property(e => e.CreatedAt).IsRequired(); + + builder.HasOne(e => e.TargetAdmin) + .WithMany() + .HasForeignKey(e => e.TargetAdminId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(e => new { e.TargetAdminId, e.CreatedAt }); + builder.HasIndex(e => new { e.TargetAdminId, e.IsRead }); + } +} diff --git a/SPMS.Infrastructure/Persistence/Repositories/NotificationRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/NotificationRepository.cs new file mode 100644 index 0000000..b81c790 --- /dev/null +++ b/SPMS.Infrastructure/Persistence/Repositories/NotificationRepository.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using SPMS.Domain.Entities; +using SPMS.Domain.Enums; +using SPMS.Domain.Interfaces; + +namespace SPMS.Infrastructure.Persistence.Repositories; + +public class NotificationRepository : Repository, INotificationRepository +{ + public NotificationRepository(AppDbContext context) : base(context) { } + + public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( + long adminId, NotificationCategory? category, DateTime? from, DateTime? to, + bool? isRead, int page, int size) + { + IQueryable query = _dbSet.Where(n => n.TargetAdminId == adminId); + + if (category.HasValue) + query = query.Where(n => n.Category == category.Value); + + if (from.HasValue) + query = query.Where(n => n.CreatedAt >= from.Value); + + if (to.HasValue) + query = query.Where(n => n.CreatedAt <= to.Value); + + if (isRead.HasValue) + query = query.Where(n => n.IsRead == isRead.Value); + + var totalCount = await query.CountAsync(); + + var items = await query + .OrderByDescending(n => n.CreatedAt) + .Skip((page - 1) * size) + .Take(size) + .ToListAsync(); + + return (items, totalCount); + } + + public async Task GetUnreadCountAsync(long adminId) + => await _dbSet.CountAsync(n => n.TargetAdminId == adminId && !n.IsRead); + + public async Task> GetRecentAsync(long adminId, int limit) + => await _dbSet + .Where(n => n.TargetAdminId == adminId) + .OrderByDescending(n => n.CreatedAt) + .Take(limit) + .ToListAsync(); + + public async Task GetByIdAndAdminAsync(long id, long adminId) + => await _dbSet.FirstOrDefaultAsync(n => n.Id == id && n.TargetAdminId == adminId); + + public async Task MarkAllAsReadAsync(long adminId) + => await _dbSet + .Where(n => n.TargetAdminId == adminId && !n.IsRead) + .ExecuteUpdateAsync(s => s + .SetProperty(n => n.IsRead, true) + .SetProperty(n => n.ReadAt, DateTime.UtcNow)); +} -- 2.45.1