diff --git a/SPMS.API/Controllers/ServiceController.cs b/SPMS.API/Controllers/ServiceController.cs index 309c982..01d28ab 100644 --- a/SPMS.API/Controllers/ServiceController.cs +++ b/SPMS.API/Controllers/ServiceController.cs @@ -36,7 +36,7 @@ public class ServiceController : ControllerBase [HttpPost("register")] [SwaggerOperation( Summary = "서비스 통합 등록", - Description = "서비스 생성과 플랫폼 자격증명 등록을 단일 호출로 완료합니다. FCM/APNs 자격증명은 선택사항이며, 제공 시 검증 실패하면 전체 롤백됩니다.")] + Description = "서비스 생성과 플랫폼 자격증명 등록을 단일 호출로 완료합니다. FCM/APNs 자격증명은 선택사항이며, 제공 시 검증 실패하면 전체 롤백됩니다. APNs는 AuthType(p8/p12)에 따라 필수 필드가 달라집니다.")] [SwaggerResponse(200, "등록 성공", typeof(ApiResponse))] [SwaggerResponse(400, "잘못된 요청")] [SwaggerResponse(409, "서비스명 중복")] @@ -161,9 +161,9 @@ public class ServiceController : ControllerBase [HttpPost("{serviceCode}/apns")] [SwaggerOperation( Summary = "APNs 키 등록", - Description = "APNs 푸시 발송을 위한 인증 정보를 등록합니다. .p8 파일 내용, Key ID(10자리), Team ID(10자리), Bundle ID가 필요합니다.")] + Description = "APNs 푸시 발송을 위한 인증 정보를 등록합니다. AuthType=p8: Key ID(10자리), Team ID(10자리), Private Key(.p8) 필수. AuthType=p12: CertificateBase64(Base64 인코딩된 .p12), CertPassword 필수. 타입 전환 시 이전 타입 필드는 자동 초기화됩니다.")] [SwaggerResponse(200, "등록 성공", typeof(ApiResponse))] - [SwaggerResponse(400, "잘못된 요청 (유효하지 않은 키 형식)")] + [SwaggerResponse(400, "잘못된 요청 (유효하지 않은 키/인증서 형식)")] [SwaggerResponse(401, "인증되지 않은 요청")] [SwaggerResponse(403, "권한 없음")] [SwaggerResponse(404, "서비스를 찾을 수 없음")] diff --git a/SPMS.Application/DTOs/Service/ApnsCredentialsRequestDto.cs b/SPMS.Application/DTOs/Service/ApnsCredentialsRequestDto.cs index 72224bd..ee454cf 100644 --- a/SPMS.Application/DTOs/Service/ApnsCredentialsRequestDto.cs +++ b/SPMS.Application/DTOs/Service/ApnsCredentialsRequestDto.cs @@ -7,14 +7,21 @@ public class ApnsCredentialsRequestDto [Required(ErrorMessage = "Bundle ID는 필수입니다.")] public string BundleId { get; set; } = string.Empty; - [Required(ErrorMessage = "Key ID는 필수입니다.")] + [Required(ErrorMessage = "인증 타입은 필수입니다.")] + [RegularExpression("^(p8|p12)$", ErrorMessage = "AuthType은 p8 또는 p12만 가능합니다.")] + public string AuthType { get; set; } = "p8"; + + // p8 필드 (AuthType=p8일 때 서비스에서 필수 검증) [StringLength(10, MinimumLength = 10, ErrorMessage = "Key ID는 10자리여야 합니다.")] - public string KeyId { get; set; } = string.Empty; + public string? KeyId { get; set; } - [Required(ErrorMessage = "Team ID는 필수입니다.")] [StringLength(10, MinimumLength = 10, ErrorMessage = "Team ID는 10자리여야 합니다.")] - public string TeamId { get; set; } = string.Empty; + public string? TeamId { get; set; } - [Required(ErrorMessage = "Private Key는 필수입니다.")] - public string PrivateKey { get; set; } = string.Empty; + public string? PrivateKey { get; set; } + + // p12 필드 (AuthType=p12일 때 서비스에서 필수 검증) + public string? CertificateBase64 { get; set; } + + public string? CertPassword { get; set; } } diff --git a/SPMS.Application/DTOs/Service/CredentialsResponseDto.cs b/SPMS.Application/DTOs/Service/CredentialsResponseDto.cs index 12ef26f..1722b25 100644 --- a/SPMS.Application/DTOs/Service/CredentialsResponseDto.cs +++ b/SPMS.Application/DTOs/Service/CredentialsResponseDto.cs @@ -9,9 +9,14 @@ public class CredentialsResponseDto public class ApnsCredentialsInfoDto { public string BundleId { get; set; } = string.Empty; - public string KeyId { get; set; } = string.Empty; - public string TeamId { get; set; } = string.Empty; + public string? AuthType { get; set; } // "p8" or "p12" + // p8 메타 + public string? KeyId { get; set; } + public string? TeamId { get; set; } public bool HasPrivateKey { get; set; } + // p12 메타 + public bool HasCertificate { get; set; } + public DateTime? CertExpiresAt { get; set; } } public class FcmCredentialsInfoDto diff --git a/SPMS.Application/DTOs/Service/RegisterServiceRequestDto.cs b/SPMS.Application/DTOs/Service/RegisterServiceRequestDto.cs index cba3df2..0c84546 100644 --- a/SPMS.Application/DTOs/Service/RegisterServiceRequestDto.cs +++ b/SPMS.Application/DTOs/Service/RegisterServiceRequestDto.cs @@ -33,14 +33,21 @@ public class ApnsCredentialDto [Required(ErrorMessage = "Bundle ID는 필수입니다.")] public string BundleId { get; set; } = string.Empty; - [Required(ErrorMessage = "Key ID는 필수입니다.")] + [Required(ErrorMessage = "인증 타입은 필수입니다.")] + [RegularExpression("^(p8|p12)$", ErrorMessage = "AuthType은 p8 또는 p12만 가능합니다.")] + public string AuthType { get; set; } = "p8"; + + // p8 필드 [StringLength(10, MinimumLength = 10, ErrorMessage = "Key ID는 10자리여야 합니다.")] - public string KeyId { get; set; } = string.Empty; + public string? KeyId { get; set; } - [Required(ErrorMessage = "Team ID는 필수입니다.")] [StringLength(10, MinimumLength = 10, ErrorMessage = "Team ID는 10자리여야 합니다.")] - public string TeamId { get; set; } = string.Empty; + public string? TeamId { get; set; } - [Required(ErrorMessage = "Private Key는 필수입니다.")] - public string PrivateKey { get; set; } = string.Empty; + public string? PrivateKey { get; set; } + + // p12 필드 + public string? CertificateBase64 { get; set; } + + public string? CertPassword { get; set; } } diff --git a/SPMS.Application/Services/ServiceManagementService.cs b/SPMS.Application/Services/ServiceManagementService.cs index 306990d..ec38a1d 100644 --- a/SPMS.Application/Services/ServiceManagementService.cs +++ b/SPMS.Application/Services/ServiceManagementService.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text.Json; using SPMS.Application.DTOs.Service; using SPMS.Application.Interfaces; @@ -347,13 +348,50 @@ public class ServiceManagementService : IServiceManagementService 404); } - ValidateApnsCredentials(request.PrivateKey); - - // Encrypt and store service.ApnsBundleId = request.BundleId; - service.ApnsKeyId = request.KeyId; - service.ApnsTeamId = request.TeamId; - service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.PrivateKey); + service.ApnsAuthType = request.AuthType; + + if (request.AuthType == "p12") + { + // p12 필수 필드 검증 + if (string.IsNullOrEmpty(request.CertificateBase64)) + throw new SpmsException(ErrorCodes.BadRequest, "p12 인증서(CertificateBase64)는 필수입니다.", 400); + if (string.IsNullOrEmpty(request.CertPassword)) + throw new SpmsException(ErrorCodes.BadRequest, "p12 비밀번호(CertPassword)는 필수입니다.", 400); + + var expiresAt = ValidateApnsP12Credentials(request.CertificateBase64, request.CertPassword); + + service.ApnsCertificate = _credentialEncryptionService.Encrypt(request.CertificateBase64); + service.ApnsCertPassword = _credentialEncryptionService.Encrypt(request.CertPassword); + service.ApnsCertExpiresAt = expiresAt; + + // p8 필드 초기화 + service.ApnsKeyId = null; + service.ApnsTeamId = null; + service.ApnsPrivateKey = null; + } + else + { + // p8 필수 필드 검증 + if (string.IsNullOrEmpty(request.KeyId)) + throw new SpmsException(ErrorCodes.BadRequest, "Key ID는 필수입니다.", 400); + if (string.IsNullOrEmpty(request.TeamId)) + throw new SpmsException(ErrorCodes.BadRequest, "Team ID는 필수입니다.", 400); + if (string.IsNullOrEmpty(request.PrivateKey)) + throw new SpmsException(ErrorCodes.BadRequest, "Private Key는 필수입니다.", 400); + + ValidateApnsP8Credentials(request.PrivateKey); + + service.ApnsKeyId = request.KeyId; + service.ApnsTeamId = request.TeamId; + service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.PrivateKey); + + // p12 필드 초기화 + service.ApnsCertificate = null; + service.ApnsCertPassword = null; + service.ApnsCertExpiresAt = null; + } + service.UpdatedAt = DateTime.UtcNow; _serviceRepository.Update(service); @@ -397,8 +435,30 @@ public class ServiceManagementService : IServiceManagementService if (request.Fcm != null) ValidateFcmCredentials(request.Fcm.ServiceAccountJson); + DateTime? apnsCertExpiresAt = null; if (request.Apns != null) - ValidateApnsCredentials(request.Apns.PrivateKey); + { + if (request.Apns.AuthType == "p12") + { + if (string.IsNullOrEmpty(request.Apns.CertificateBase64)) + throw new SpmsException(ErrorCodes.BadRequest, "p12 인증서(CertificateBase64)는 필수입니다.", 400); + if (string.IsNullOrEmpty(request.Apns.CertPassword)) + throw new SpmsException(ErrorCodes.BadRequest, "p12 비밀번호(CertPassword)는 필수입니다.", 400); + + apnsCertExpiresAt = ValidateApnsP12Credentials(request.Apns.CertificateBase64, request.Apns.CertPassword); + } + else + { + if (string.IsNullOrEmpty(request.Apns.KeyId)) + throw new SpmsException(ErrorCodes.BadRequest, "Key ID는 필수입니다.", 400); + if (string.IsNullOrEmpty(request.Apns.TeamId)) + throw new SpmsException(ErrorCodes.BadRequest, "Team ID는 필수입니다.", 400); + if (string.IsNullOrEmpty(request.Apns.PrivateKey)) + throw new SpmsException(ErrorCodes.BadRequest, "Private Key는 필수입니다.", 400); + + ValidateApnsP8Credentials(request.Apns.PrivateKey); + } + } // 3. 트랜잭션 시작 using var transaction = await _unitOfWork.BeginTransactionAsync(); @@ -439,9 +499,20 @@ public class ServiceManagementService : IServiceManagementService if (request.Apns != null) { service.ApnsBundleId = request.Apns.BundleId; - service.ApnsKeyId = request.Apns.KeyId; - service.ApnsTeamId = request.Apns.TeamId; - service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.Apns.PrivateKey); + service.ApnsAuthType = request.Apns.AuthType; + + if (request.Apns.AuthType == "p12") + { + service.ApnsCertificate = _credentialEncryptionService.Encrypt(request.Apns.CertificateBase64!); + service.ApnsCertPassword = _credentialEncryptionService.Encrypt(request.Apns.CertPassword!); + service.ApnsCertExpiresAt = apnsCertExpiresAt; + } + else + { + service.ApnsKeyId = request.Apns.KeyId; + service.ApnsTeamId = request.Apns.TeamId; + service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.Apns.PrivateKey!); + } } await _serviceRepository.AddAsync(service); @@ -497,7 +568,7 @@ public class ServiceManagementService : IServiceManagementService } } - private static void ValidateApnsCredentials(string privateKey) + private static void ValidateApnsP8Credentials(string privateKey) { if (!privateKey.Contains("BEGIN PRIVATE KEY") || !privateKey.Contains("END PRIVATE KEY")) { @@ -508,6 +579,38 @@ public class ServiceManagementService : IServiceManagementService } } + /// + /// p12 인증서 Base64를 파싱하여 유효성 검증하고 만료일을 반환합니다. + /// + private static DateTime ValidateApnsP12Credentials(string certBase64, string certPassword) + { + byte[] certBytes; + try + { + certBytes = Convert.FromBase64String(certBase64); + } + catch (FormatException) + { + throw new SpmsException( + ErrorCodes.InvalidCredentials, + "유효하지 않은 Base64 형식입니다. p12 인증서를 Base64로 인코딩하여 전송해주세요.", + 400); + } + + try + { + using var cert = X509CertificateLoader.LoadPkcs12(certBytes, certPassword); + return cert.NotAfter; + } + catch (CryptographicException) + { + throw new SpmsException( + ErrorCodes.InvalidCredentials, + "p12 인증서를 읽을 수 없습니다. 인증서 파일 또는 비밀번호를 확인해주세요.", + 400); + } + } + public async Task GetCredentialsAsync(string serviceCode) { var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode); @@ -522,14 +625,17 @@ public class ServiceManagementService : IServiceManagementService var response = new CredentialsResponseDto(); // APNs info (meta only, no private key) - if (!string.IsNullOrEmpty(service.ApnsKeyId)) + if (!string.IsNullOrEmpty(service.ApnsBundleId)) { response.Apns = new ApnsCredentialsInfoDto { - BundleId = service.ApnsBundleId ?? string.Empty, - KeyId = service.ApnsKeyId ?? string.Empty, - TeamId = service.ApnsTeamId ?? string.Empty, - HasPrivateKey = !string.IsNullOrEmpty(service.ApnsPrivateKey) + BundleId = service.ApnsBundleId, + AuthType = service.ApnsAuthType, + KeyId = service.ApnsKeyId, + TeamId = service.ApnsTeamId, + HasPrivateKey = !string.IsNullOrEmpty(service.ApnsPrivateKey), + HasCertificate = !string.IsNullOrEmpty(service.ApnsCertificate), + CertExpiresAt = service.ApnsCertExpiresAt }; } diff --git a/SPMS.Domain/Entities/Service.cs b/SPMS.Domain/Entities/Service.cs index 2d606e3..f6936b4 100644 --- a/SPMS.Domain/Entities/Service.cs +++ b/SPMS.Domain/Entities/Service.cs @@ -13,6 +13,10 @@ public class Service : BaseEntity public string? ApnsKeyId { get; set; } public string? ApnsTeamId { get; set; } public string? ApnsPrivateKey { get; set; } + public string? ApnsAuthType { get; set; } // "p8" or "p12" + public string? ApnsCertificate { get; set; } // p12 Base64 (암호화 저장) + public string? ApnsCertPassword { get; set; } // p12 비밀번호 (암호화 저장) + public DateTime? ApnsCertExpiresAt { get; set; } // p12 인증서 만료일 public string? FcmCredentials { get; set; } public string? WebhookUrl { get; set; } public string? WebhookEvents { get; set; } diff --git a/SPMS.Infrastructure/Migrations/20260225035708_AddApnsP12Support.Designer.cs b/SPMS.Infrastructure/Migrations/20260225035708_AddApnsP12Support.Designer.cs new file mode 100644 index 0000000..06fa68d --- /dev/null +++ b/SPMS.Infrastructure/Migrations/20260225035708_AddApnsP12Support.Designer.cs @@ -0,0 +1,1130 @@ +// +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("20260225035708_AddApnsP12Support")] + partial class AddApnsP12Support + { + /// + 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.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.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.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.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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SPMS.Infrastructure/Migrations/20260225035708_AddApnsP12Support.cs b/SPMS.Infrastructure/Migrations/20260225035708_AddApnsP12Support.cs new file mode 100644 index 0000000..32ba0ff --- /dev/null +++ b/SPMS.Infrastructure/Migrations/20260225035708_AddApnsP12Support.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SPMS.Infrastructure.Migrations +{ + /// + public partial class AddApnsP12Support : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ApnsAuthType", + table: "Service", + type: "varchar(10)", + maxLength: 10, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "ApnsCertExpiresAt", + table: "Service", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + name: "ApnsCertPassword", + table: "Service", + type: "text", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "ApnsCertificate", + table: "Service", + type: "text", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ApnsAuthType", + table: "Service"); + + migrationBuilder.DropColumn( + name: "ApnsCertExpiresAt", + table: "Service"); + + migrationBuilder.DropColumn( + name: "ApnsCertPassword", + table: "Service"); + + migrationBuilder.DropColumn( + name: "ApnsCertificate", + table: "Service"); + } + } +} diff --git a/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index fe19178..250eb93 100644 --- a/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -675,10 +675,23 @@ namespace SPMS.Infrastructure.Migrations 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)"); diff --git a/SPMS.Infrastructure/Persistence/Configurations/ServiceConfiguration.cs b/SPMS.Infrastructure/Persistence/Configurations/ServiceConfiguration.cs index b8c54d1..b9f4a72 100644 --- a/SPMS.Infrastructure/Persistence/Configurations/ServiceConfiguration.cs +++ b/SPMS.Infrastructure/Persistence/Configurations/ServiceConfiguration.cs @@ -24,6 +24,10 @@ public class ServiceConfiguration : IEntityTypeConfiguration builder.Property(e => e.ApnsKeyId).HasMaxLength(10); builder.Property(e => e.ApnsTeamId).HasMaxLength(10); builder.Property(e => e.ApnsPrivateKey).HasColumnType("text"); + builder.Property(e => e.ApnsAuthType).HasMaxLength(10); + builder.Property(e => e.ApnsCertificate).HasColumnType("text"); + builder.Property(e => e.ApnsCertPassword).HasColumnType("text"); + builder.Property(e => e.ApnsCertExpiresAt); builder.Property(e => e.FcmCredentials).HasColumnType("text"); builder.Property(e => e.WebhookUrl).HasMaxLength(500); builder.Property(e => e.Tags).HasColumnType("json");