feat: добавил отправку уведомлений по почте
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 10s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m17s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 12s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 10s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m17s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 12s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
This commit is contained in:
@@ -30,5 +30,14 @@ LLM_MODEL=
|
|||||||
MODEUS_API_BASE_URL=
|
MODEUS_API_BASE_URL=
|
||||||
MODEUS_API_KEY=
|
MODEUS_API_KEY=
|
||||||
|
|
||||||
|
# Email SMTP
|
||||||
|
EMAIL_SMTP_HOST=
|
||||||
|
EMAIL_SMTP_PORT=587
|
||||||
|
EMAIL_SMTP_ENABLE_SSL=true
|
||||||
|
EMAIL_SMTP_USERNAME=
|
||||||
|
EMAIL_SMTP_PASSWORD=
|
||||||
|
EMAIL_SMTP_FROM_ADDRESS=no-reply@universe.local
|
||||||
|
EMAIL_SMTP_FROM_NAME=UniVerse
|
||||||
|
|
||||||
# Gamification
|
# Gamification
|
||||||
GAMIFICATION_XP_THRESHOLDS=[0, 100, 300, 600, 1000, 1500, 2500, 4000]
|
GAMIFICATION_XP_THRESHOLDS=[0, 100, 300, 600, 1000, 1500, 2500, 4000]
|
||||||
@@ -152,6 +152,12 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
|
|||||||
yield return E("sync/status GET [Admin]", "GET", "api/v1/sync/status", "Admin", forbidden: ["Student", "Teacher"]);
|
yield return E("sync/status GET [Admin]", "GET", "api/v1/sync/status", "Admin", forbidden: ["Student", "Teacher"]);
|
||||||
yield return E("sync/rooms POST [Admin]", "POST", "api/v1/sync/rooms", "Admin", forbidden: ["Student", "Teacher"]);
|
yield return E("sync/rooms POST [Admin]", "POST", "api/v1/sync/rooms", "Admin", forbidden: ["Student", "Teacher"]);
|
||||||
yield return E("sync/employees POST [Admin]", "POST", "api/v1/sync/employees?fullname=test","Admin",forbidden: ["Student", "Teacher"]);
|
yield return E("sync/employees POST [Admin]", "POST", "api/v1/sync/employees?fullname=test","Admin",forbidden: ["Student", "Teacher"]);
|
||||||
|
|
||||||
|
// ── Notifications — Admin only ─────────────────────────────────────────
|
||||||
|
yield return E("notifications/send POST [Admin]", "POST", "api/v1/notifications/send", "Admin", forbidden: ["Student", "Teacher"],
|
||||||
|
body: """{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello"}""");
|
||||||
|
yield return E("notifications/schedule POST [Admin]", "POST", "api/v1/notifications/schedule", "Admin", forbidden: ["Student", "Teacher"],
|
||||||
|
body: $$"""{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello","sendAt":"{{DateTimeOffset.UtcNow.AddMinutes(5):O}}"}""");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using UniVerse.Application.DTOs.Courses;
|
|||||||
using UniVerse.Application.DTOs.Gamification;
|
using UniVerse.Application.DTOs.Gamification;
|
||||||
using UniVerse.Application.DTOs.Lectures;
|
using UniVerse.Application.DTOs.Lectures;
|
||||||
using UniVerse.Application.DTOs.Locations;
|
using UniVerse.Application.DTOs.Locations;
|
||||||
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
using UniVerse.Application.DTOs.Reviews;
|
using UniVerse.Application.DTOs.Reviews;
|
||||||
using UniVerse.Application.DTOs.Sync;
|
using UniVerse.Application.DTOs.Sync;
|
||||||
using UniVerse.Application.DTOs.Tags;
|
using UniVerse.Application.DTOs.Tags;
|
||||||
@@ -96,6 +97,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
ReplaceWithSubstitute<IScheduleSyncService>(services, CreateSyncServiceStub());
|
ReplaceWithSubstitute<IScheduleSyncService>(services, CreateSyncServiceStub());
|
||||||
ReplaceWithSubstitute<ILlmAnalysisService>(services, Substitute.For<ILlmAnalysisService>());
|
ReplaceWithSubstitute<ILlmAnalysisService>(services, Substitute.For<ILlmAnalysisService>());
|
||||||
ReplaceWithSubstitute<ILlmClient>(services, Substitute.For<ILlmClient>());
|
ReplaceWithSubstitute<ILlmClient>(services, Substitute.For<ILlmClient>());
|
||||||
|
ReplaceWithSubstitute<INotificationService>(services, CreateNotificationServiceStub());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,9 +117,9 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
new AuthResponse("access_token", DateTime.UtcNow.AddHours(1),
|
new AuthResponse("access_token", DateTime.UtcNow.AddHours(1),
|
||||||
new UserAuthDto(1, "test@test.com", "Test User", UserRole.Student)),
|
new UserAuthDto(1, "test@test.com", "Test User", UserRole.Student)),
|
||||||
"refresh_token");
|
"refresh_token");
|
||||||
stub.LoginWithMicrosoftAsync(Arg.Any<string>(), Arg.Any<string?>())
|
stub.LoginWithMicrosoftAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string?>())
|
||||||
.Returns(authResult);
|
.Returns(authResult);
|
||||||
stub.DevLoginAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<UserRole>())
|
stub.DevLoginAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<UserRole>(), Arg.Any<string?>())
|
||||||
.Returns(authResult);
|
.Returns(authResult);
|
||||||
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
|
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
|
||||||
stub.GetCurrentUserAsync(Arg.Any<int>())
|
stub.GetCurrentUserAsync(Arg.Any<int>())
|
||||||
@@ -125,6 +127,16 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
return stub;
|
return stub;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static INotificationService CreateNotificationServiceStub()
|
||||||
|
{
|
||||||
|
var stub = Substitute.For<INotificationService>();
|
||||||
|
stub.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
stub.ScheduleAsync(Arg.Any<ScheduleNotificationRequest>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new ScheduledNotificationResponse("test-job", DateTimeOffset.UtcNow.AddMinutes(5)));
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
private static IUserService CreateUserServiceStub()
|
private static IUserService CreateUserServiceStub()
|
||||||
{
|
{
|
||||||
var stub = Substitute.For<IUserService>();
|
var stub = Substitute.For<IUserService>();
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public class AuthController : ControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
|
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
|
||||||
{
|
{
|
||||||
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri);
|
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri, GetClientIpAddress());
|
||||||
SetRefreshTokenCookie(result.RefreshToken);
|
SetRefreshTokenCookie(result.RefreshToken);
|
||||||
return Ok(result.Response);
|
return Ok(result.Response);
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,7 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft");
|
var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft");
|
||||||
|
|
||||||
var result = await _auth.LoginWithMicrosoftAsync(code, redirectUri);
|
var result = await _auth.LoginWithMicrosoftAsync(code, redirectUri, GetClientIpAddress());
|
||||||
SetRefreshTokenCookie(result.RefreshToken);
|
SetRefreshTokenCookie(result.RefreshToken);
|
||||||
|
|
||||||
var returnUrl = Request.Cookies[MicrosoftReturnUrlCookieName] ?? _config["AzureAd:PostLoginRedirectUri"];
|
var returnUrl = Request.Cookies[MicrosoftReturnUrlCookieName] ?? _config["AzureAd:PostLoginRedirectUri"];
|
||||||
@@ -184,7 +184,7 @@ public class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
if (!HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
|
if (!HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
|
||||||
return NotFound();
|
return NotFound();
|
||||||
var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, request.Role);
|
var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, request.Role, GetClientIpAddress());
|
||||||
SetRefreshTokenCookie(result.RefreshToken);
|
SetRefreshTokenCookie(result.RefreshToken);
|
||||||
return Ok(result.Response);
|
return Ok(result.Response);
|
||||||
}
|
}
|
||||||
@@ -245,6 +245,21 @@ public class AuthController : ControllerBase
|
|||||||
return Ok(user);
|
return Ok(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string? GetClientIpAddress()
|
||||||
|
{
|
||||||
|
if (Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
|
||||||
|
{
|
||||||
|
var firstForwardedAddress = forwardedFor.ToString().Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(firstForwardedAddress))
|
||||||
|
return firstForwardedAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Request.Headers.TryGetValue("X-Real-IP", out var realIp) && !string.IsNullOrWhiteSpace(realIp))
|
||||||
|
return realIp;
|
||||||
|
|
||||||
|
return HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
private void SetRefreshTokenCookie(string token)
|
private void SetRefreshTokenCookie(string token)
|
||||||
{
|
{
|
||||||
Response.Cookies.Append("refreshToken", token, new CookieOptions
|
Response.Cookies.Append("refreshToken", token, new CookieOptions
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
|
using UniVerse.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Отправка и планирование уведомлений через доступные каналы.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/notifications")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public class NotificationsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly INotificationService _notifications;
|
||||||
|
|
||||||
|
public NotificationsController(INotificationService notifications)
|
||||||
|
{
|
||||||
|
_notifications = notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Отправить уведомление немедленно.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Канал задаётся строкой, например `email`. Новые провайдеры добавляются через `INotificationProvider`.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="request">Канал, получатель, тема и текст уведомления.</param>
|
||||||
|
/// <response code="202">Уведомление принято к отправке.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
[HttpPost("send")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> Send([FromBody] SendNotificationRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var message = new NotificationMessage(
|
||||||
|
request.Channel,
|
||||||
|
request.Recipient,
|
||||||
|
request.Subject,
|
||||||
|
request.Body,
|
||||||
|
request.RecipientName,
|
||||||
|
request.Metadata);
|
||||||
|
|
||||||
|
await _notifications.SendAsync(message, cancellationToken);
|
||||||
|
return Accepted();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Запланировать отложенную отправку уведомления через Quartz.NET.</summary>
|
||||||
|
/// <param name="request">Уведомление и момент отправки.</param>
|
||||||
|
/// <response code="202">Уведомление поставлено в очередь Quartz.NET.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
[HttpPost("schedule")]
|
||||||
|
[ProducesResponseType(typeof(ScheduledNotificationResponse), StatusCodes.Status202Accepted)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<ActionResult<ScheduledNotificationResponse>> Schedule([FromBody] ScheduleNotificationRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = await _notifications.ScheduleAsync(request, cancellationToken);
|
||||||
|
return Accepted(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
|
using Quartz;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using UniVerse.Api.BackgroundServices;
|
using UniVerse.Api.BackgroundServices;
|
||||||
using UniVerse.Api.Filters;
|
using UniVerse.Api.Filters;
|
||||||
@@ -12,6 +13,7 @@ using UniVerse.Application.Interfaces;
|
|||||||
using UniVerse.Infrastructure.Services;
|
using UniVerse.Infrastructure.Services;
|
||||||
using UniVerse.Infrastructure.Data;
|
using UniVerse.Infrastructure.Data;
|
||||||
using UniVerse.Infrastructure.ExternalServices;
|
using UniVerse.Infrastructure.ExternalServices;
|
||||||
|
using UniVerse.Infrastructure.Notifications;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -89,6 +91,17 @@ builder.Services.AddScoped<IGamificationService, GamificationService>();
|
|||||||
builder.Services.AddScoped<IAchievementService, AchievementService>();
|
builder.Services.AddScoped<IAchievementService, AchievementService>();
|
||||||
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
|
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
|
||||||
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
|
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
|
||||||
|
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||||
|
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
|
||||||
|
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
|
||||||
|
builder.Services.AddTransient<NotificationJob>();
|
||||||
|
builder.Services.Configure<EmailNotificationOptions>(builder.Configuration.GetSection("Email:Smtp"));
|
||||||
|
|
||||||
|
builder.Services.AddQuartz();
|
||||||
|
builder.Services.AddQuartzHostedService(options =>
|
||||||
|
{
|
||||||
|
options.WaitForJobsToComplete = true;
|
||||||
|
});
|
||||||
|
|
||||||
// --- HTTP Clients ---
|
// --- HTTP Clients ---
|
||||||
builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
|
builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||||
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -33,5 +33,16 @@
|
|||||||
"System": "Information"
|
"System": "Information"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Email": {
|
||||||
|
"Smtp": {
|
||||||
|
"Host": "",
|
||||||
|
"Port": 587,
|
||||||
|
"EnableSsl": true,
|
||||||
|
"UserName": "",
|
||||||
|
"Password": "",
|
||||||
|
"FromAddress": "no-reply@universe.local",
|
||||||
|
"FromName": "UniVerse"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace UniVerse.Application.DTOs.Notifications;
|
||||||
|
|
||||||
|
public static class NotificationChannels
|
||||||
|
{
|
||||||
|
public const string Email = "email";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record NotificationMessage(
|
||||||
|
string Channel,
|
||||||
|
string Recipient,
|
||||||
|
string Subject,
|
||||||
|
string Body,
|
||||||
|
string? RecipientName = null,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||||
|
|
||||||
|
public record SendNotificationRequest(
|
||||||
|
string Channel,
|
||||||
|
string Recipient,
|
||||||
|
string Subject,
|
||||||
|
string Body,
|
||||||
|
string? RecipientName = null,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||||
|
|
||||||
|
public record ScheduleNotificationRequest(
|
||||||
|
string Channel,
|
||||||
|
string Recipient,
|
||||||
|
string Subject,
|
||||||
|
string Body,
|
||||||
|
DateTimeOffset SendAt,
|
||||||
|
string? RecipientName = null,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||||
|
|
||||||
|
public record ScheduledNotificationResponse(string JobId, DateTimeOffset SendAt);
|
||||||
@@ -5,8 +5,8 @@ namespace UniVerse.Application.Interfaces;
|
|||||||
|
|
||||||
public interface IAuthService
|
public interface IAuthService
|
||||||
{
|
{
|
||||||
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null);
|
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null);
|
||||||
Task<AuthResult> DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role);
|
Task<AuthResult> DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role, string? ipAddress = null);
|
||||||
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
||||||
Task RevokeRefreshTokenAsync(string refreshToken);
|
Task RevokeRefreshTokenAsync(string refreshToken);
|
||||||
Task<UserDto> GetCurrentUserAsync(int userId);
|
Task<UserDto> GetCurrentUserAsync(int userId);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
|
|
||||||
|
namespace UniVerse.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface INotificationProvider
|
||||||
|
{
|
||||||
|
string Channel { get; }
|
||||||
|
Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
|
|
||||||
|
namespace UniVerse.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface INotificationScheduler
|
||||||
|
{
|
||||||
|
Task<ScheduledNotificationResponse> ScheduleAsync(NotificationMessage message, DateTimeOffset sendAt, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
|
|
||||||
|
namespace UniVerse.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface INotificationService
|
||||||
|
{
|
||||||
|
Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default);
|
||||||
|
Task<ScheduledNotificationResponse> ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace UniVerse.Infrastructure.Notifications;
|
||||||
|
|
||||||
|
public class EmailNotificationOptions
|
||||||
|
{
|
||||||
|
public string Host { get; set; } = string.Empty;
|
||||||
|
public int Port { get; set; } = 587;
|
||||||
|
public bool EnableSsl { get; set; } = true;
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
public string FromAddress { get; set; } = string.Empty;
|
||||||
|
public string? FromName { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Mail;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
|
using UniVerse.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace UniVerse.Infrastructure.Notifications;
|
||||||
|
|
||||||
|
public class EmailNotificationProvider : INotificationProvider
|
||||||
|
{
|
||||||
|
private readonly EmailNotificationOptions _options;
|
||||||
|
private readonly ILogger<EmailNotificationProvider> _logger;
|
||||||
|
|
||||||
|
public EmailNotificationProvider(IOptions<EmailNotificationOptions> options, ILogger<EmailNotificationProvider> logger)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Channel => NotificationChannels.Email;
|
||||||
|
|
||||||
|
public async Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ValidateOptions();
|
||||||
|
|
||||||
|
using var mailMessage = new MailMessage
|
||||||
|
{
|
||||||
|
From = new MailAddress(_options.FromAddress, _options.FromName),
|
||||||
|
Subject = message.Subject,
|
||||||
|
Body = message.Body,
|
||||||
|
IsBodyHtml = false
|
||||||
|
};
|
||||||
|
mailMessage.To.Add(new MailAddress(message.Recipient, message.RecipientName));
|
||||||
|
|
||||||
|
using var client = new SmtpClient(_options.Host, _options.Port)
|
||||||
|
{
|
||||||
|
EnableSsl = _options.EnableSsl
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.UserName))
|
||||||
|
{
|
||||||
|
client.Credentials = new NetworkCredential(_options.UserName, _options.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Sending email notification to {Recipient}", message.Recipient);
|
||||||
|
await client.SendMailAsync(mailMessage, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateOptions()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_options.Host))
|
||||||
|
throw new InvalidOperationException("Email:Smtp:Host is not configured.");
|
||||||
|
if (string.IsNullOrWhiteSpace(_options.FromAddress))
|
||||||
|
throw new InvalidOperationException("Email:Smtp:FromAddress is not configured.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Quartz;
|
||||||
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
|
using UniVerse.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace UniVerse.Infrastructure.Notifications;
|
||||||
|
|
||||||
|
[DisallowConcurrentExecution]
|
||||||
|
public class NotificationJob : IJob
|
||||||
|
{
|
||||||
|
public const string MessageDataKey = "message";
|
||||||
|
|
||||||
|
private readonly INotificationService _notifications;
|
||||||
|
private readonly ILogger<NotificationJob> _logger;
|
||||||
|
|
||||||
|
public NotificationJob(INotificationService notifications, ILogger<NotificationJob> logger)
|
||||||
|
{
|
||||||
|
_notifications = notifications;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var payload = context.MergedJobDataMap.GetString(MessageDataKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(payload))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Notification job {JobKey} does not contain message payload", context.JobDetail.Key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = JsonSerializer.Deserialize<NotificationMessage>(payload)
|
||||||
|
?? throw new InvalidOperationException("Scheduled notification payload cannot be deserialized.");
|
||||||
|
|
||||||
|
await _notifications.SendAsync(message, context.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
|
using UniVerse.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace UniVerse.Infrastructure.Notifications;
|
||||||
|
|
||||||
|
public class NotificationService : INotificationService
|
||||||
|
{
|
||||||
|
private readonly IEnumerable<INotificationProvider> _providers;
|
||||||
|
private readonly INotificationScheduler _scheduler;
|
||||||
|
private readonly ILogger<NotificationService> _logger;
|
||||||
|
|
||||||
|
public NotificationService(
|
||||||
|
IEnumerable<INotificationProvider> providers,
|
||||||
|
INotificationScheduler scheduler,
|
||||||
|
ILogger<NotificationService> logger)
|
||||||
|
{
|
||||||
|
_providers = providers;
|
||||||
|
_scheduler = scheduler;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(message.Channel);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(message.Recipient);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(message.Subject);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(message.Body);
|
||||||
|
|
||||||
|
var provider = _providers.FirstOrDefault(p => string.Equals(p.Channel, message.Channel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? throw new InvalidOperationException($"Notification provider for channel '{message.Channel}' is not registered.");
|
||||||
|
|
||||||
|
_logger.LogInformation("Dispatching notification through {Channel} to {Recipient}", message.Channel, message.Recipient);
|
||||||
|
await provider.SendAsync(message, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ScheduledNotificationResponse> ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var message = new NotificationMessage(
|
||||||
|
request.Channel,
|
||||||
|
request.Recipient,
|
||||||
|
request.Subject,
|
||||||
|
request.Body,
|
||||||
|
request.RecipientName,
|
||||||
|
request.Metadata);
|
||||||
|
|
||||||
|
return _scheduler.ScheduleAsync(message, request.SendAt, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Quartz;
|
||||||
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
|
using UniVerse.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace UniVerse.Infrastructure.Notifications;
|
||||||
|
|
||||||
|
public class QuartzNotificationScheduler : INotificationScheduler
|
||||||
|
{
|
||||||
|
private const string NotificationGroup = "notifications";
|
||||||
|
|
||||||
|
private readonly ISchedulerFactory _schedulerFactory;
|
||||||
|
private readonly ILogger<QuartzNotificationScheduler> _logger;
|
||||||
|
|
||||||
|
public QuartzNotificationScheduler(ISchedulerFactory schedulerFactory, ILogger<QuartzNotificationScheduler> logger)
|
||||||
|
{
|
||||||
|
_schedulerFactory = schedulerFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ScheduledNotificationResponse> ScheduleAsync(NotificationMessage message, DateTimeOffset sendAt, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (sendAt <= DateTimeOffset.UtcNow)
|
||||||
|
throw new ArgumentException("Scheduled notification time must be in the future.", nameof(sendAt));
|
||||||
|
|
||||||
|
var scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
|
||||||
|
var jobId = Guid.NewGuid().ToString("N");
|
||||||
|
var jobKey = new JobKey(jobId, NotificationGroup);
|
||||||
|
var payload = JsonSerializer.Serialize(message);
|
||||||
|
|
||||||
|
var job = JobBuilder.Create<NotificationJob>()
|
||||||
|
.WithIdentity(jobKey)
|
||||||
|
.UsingJobData(NotificationJob.MessageDataKey, payload)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var trigger = TriggerBuilder.Create()
|
||||||
|
.WithIdentity($"{jobId}.trigger", NotificationGroup)
|
||||||
|
.ForJob(job)
|
||||||
|
.StartAt(sendAt)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await scheduler.ScheduleJob(job, trigger, cancellationToken);
|
||||||
|
_logger.LogInformation("Scheduled notification job {JobId} for {SendAt}", jobId, sendAt);
|
||||||
|
|
||||||
|
return new ScheduledNotificationResponse(jobId, sendAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ using System.Security.Cryptography;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using UniVerse.Application.DTOs.Auth;
|
using UniVerse.Application.DTOs.Auth;
|
||||||
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
using UniVerse.Application.DTOs.Users;
|
using UniVerse.Application.DTOs.Users;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Application.Mappings;
|
using UniVerse.Application.Mappings;
|
||||||
@@ -22,15 +24,24 @@ public class AuthService : IAuthService
|
|||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
private readonly IGamificationService _gamification;
|
private readonly IGamificationService _gamification;
|
||||||
|
private readonly INotificationService _notifications;
|
||||||
|
private readonly ILogger<AuthService> _logger;
|
||||||
|
|
||||||
public AuthService(AppDbContext db, IConfiguration config, IGamificationService gamification)
|
public AuthService(
|
||||||
|
AppDbContext db,
|
||||||
|
IConfiguration config,
|
||||||
|
IGamificationService gamification,
|
||||||
|
INotificationService notifications,
|
||||||
|
ILogger<AuthService> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_config = config;
|
_config = config;
|
||||||
_gamification = gamification;
|
_gamification = gamification;
|
||||||
|
_notifications = notifications;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null)
|
public async Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null)
|
||||||
{
|
{
|
||||||
var tenantId = _config["AzureAd:TenantId"];
|
var tenantId = _config["AzureAd:TenantId"];
|
||||||
var clientId = _config["AzureAd:ClientId"];
|
var clientId = _config["AzureAd:ClientId"];
|
||||||
@@ -38,7 +49,7 @@ public class AuthService : IAuthService
|
|||||||
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
|
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
|
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
|
||||||
throw new UnauthorizedException("Microsoft authentication is not configured (AzureAd:TenantId/ClientId/ClientSecret).");
|
throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret).");
|
||||||
|
|
||||||
var effectiveRedirectUri = redirectUri
|
var effectiveRedirectUri = redirectUri
|
||||||
?? _config["AzureAd:RedirectUri"]
|
?? _config["AzureAd:RedirectUri"]
|
||||||
@@ -60,7 +71,7 @@ public class AuthService : IAuthService
|
|||||||
}
|
}
|
||||||
catch (MsalException ex)
|
catch (MsalException ex)
|
||||||
{
|
{
|
||||||
throw new UnauthorizedException($"Microsoft authentication failed: {ex.Message}");
|
throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse claims directly from the ID token provided by Microsoft
|
// Parse claims directly from the ID token provided by Microsoft
|
||||||
@@ -71,7 +82,7 @@ public class AuthService : IAuthService
|
|||||||
var name = idToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
|
var name = idToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(email))
|
if (string.IsNullOrEmpty(email))
|
||||||
throw new UnauthorizedException("Email not found in Microsoft token claims.");
|
throw new UnauthorizedException("Email не найден в токене Microsoft.");
|
||||||
|
|
||||||
// Automatically provision user
|
// Automatically provision user
|
||||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
|
||||||
@@ -93,11 +104,12 @@ public class AuthService : IAuthService
|
|||||||
}
|
}
|
||||||
else if (!user.IsActive)
|
else if (!user.IsActive)
|
||||||
{
|
{
|
||||||
throw new ForbiddenException("Account is deactivated.");
|
throw new ForbiddenException("Аккаунт деактивирован.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var accessToken = GenerateAccessToken(user);
|
var accessToken = GenerateAccessToken(user);
|
||||||
var refreshToken = await GenerateRefreshTokenAsync(user.Id);
|
var refreshToken = await GenerateRefreshTokenAsync(user.Id);
|
||||||
|
await TrySendLoginNotificationAsync(user, ipAddress);
|
||||||
|
|
||||||
return new AuthResult(
|
return new AuthResult(
|
||||||
new AuthResponse(
|
new AuthResponse(
|
||||||
@@ -109,7 +121,7 @@ public class AuthService : IAuthService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthResult> DevLoginAsync(string email, string? displayName, UserRole role)
|
public async Task<AuthResult> DevLoginAsync(string email, string? displayName, UserRole role, string? ipAddress = null)
|
||||||
{
|
{
|
||||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
|
||||||
|
|
||||||
@@ -139,10 +151,11 @@ public class AuthService : IAuthService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user.IsActive)
|
if (!user.IsActive)
|
||||||
throw new ForbiddenException("Account is deactivated.");
|
throw new ForbiddenException("Аккаунт деактивирован.");
|
||||||
|
|
||||||
var accessToken = GenerateAccessToken(user);
|
var accessToken = GenerateAccessToken(user);
|
||||||
var refreshToken = await GenerateRefreshTokenAsync(user.Id);
|
var refreshToken = await GenerateRefreshTokenAsync(user.Id);
|
||||||
|
await TrySendLoginNotificationAsync(user, ipAddress);
|
||||||
|
|
||||||
return new AuthResult(
|
return new AuthResult(
|
||||||
new AuthResponse(
|
new AuthResponse(
|
||||||
@@ -161,7 +174,7 @@ public class AuthService : IAuthService
|
|||||||
.FirstOrDefaultAsync(rt => rt.Token == refreshToken);
|
.FirstOrDefaultAsync(rt => rt.Token == refreshToken);
|
||||||
|
|
||||||
if (token == null || !token.IsActive)
|
if (token == null || !token.IsActive)
|
||||||
throw new ForbiddenException("Invalid or expired refresh token.");
|
throw new ForbiddenException("Неверный или просроченный токен обновления.");
|
||||||
|
|
||||||
// Revoke old token
|
// Revoke old token
|
||||||
token.RevokedAt = DateTime.UtcNow;
|
token.RevokedAt = DateTime.UtcNow;
|
||||||
@@ -199,6 +212,33 @@ public class AuthService : IAuthService
|
|||||||
return user.ToDto(_gamification.CalculateLevel(user.Xp));
|
return user.ToDto(_gamification.CalculateLevel(user.Xp));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task TrySendLoginNotificationAsync(User user, string? ipAddress)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loginIpAddress = string.IsNullOrWhiteSpace(ipAddress) ? "unknown" : ipAddress;
|
||||||
|
var loginTime = DateTimeOffset.UtcNow;
|
||||||
|
var message = new NotificationMessage(
|
||||||
|
NotificationChannels.Email,
|
||||||
|
user.Email,
|
||||||
|
"Вход в аккаунт UniVerse",
|
||||||
|
$"Здравствуйте, {user.DisplayName ?? user.Email}!\n\nБыл выполнен вход в ваш аккаунт UniVerse в {loginTime:O} с IP-адреса: {loginIpAddress}.\n\nЕсли это были не вы, пожалуйста, немедленно свяжитесь с поддержкой.",
|
||||||
|
user.DisplayName,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["event"] = "account_login",
|
||||||
|
["ip_address"] = loginIpAddress,
|
||||||
|
["login_time_utc"] = loginTime.ToString("O")
|
||||||
|
});
|
||||||
|
|
||||||
|
await _notifications.SendAsync(message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to send login notification to user {UserId}", user.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private string GenerateAccessToken(User user)
|
private string GenerateAccessToken(User user)
|
||||||
{
|
{
|
||||||
var key = new SymmetricSecurityKey(
|
var key = new SymmetricSecurityKey(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||||
|
<PackageReference Include="Quartz" Version="3.18.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ services:
|
|||||||
- ModeusApi:BaseUrl=${MODEUS_API_BASE_URL}
|
- ModeusApi:BaseUrl=${MODEUS_API_BASE_URL}
|
||||||
- ModeusApi:ApiKey=${MODEUS_API_KEY}
|
- ModeusApi:ApiKey=${MODEUS_API_KEY}
|
||||||
|
|
||||||
|
- Email:Smtp:Host=${EMAIL_SMTP_HOST}
|
||||||
|
- Email:Smtp:Port=${EMAIL_SMTP_PORT:-587}
|
||||||
|
- Email:Smtp:EnableSsl=${EMAIL_SMTP_ENABLE_SSL:-true}
|
||||||
|
- Email:Smtp:UserName=${EMAIL_SMTP_USERNAME}
|
||||||
|
- Email:Smtp:Password=${EMAIL_SMTP_PASSWORD}
|
||||||
|
- Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local}
|
||||||
|
- Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse}
|
||||||
|
|
||||||
- Gamification:XpThresholds=${GAMIFICATION_XP_THRESHOLDS:-[0, 100, 300, 600, 1000, 1500, 2500, 4000]}
|
- Gamification:XpThresholds=${GAMIFICATION_XP_THRESHOLDS:-[0, 100, 300, 600, 1000, 1500, 2500, 4000]}
|
||||||
|
|
||||||
- ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE:-universe};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
|
- ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE:-universe};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ services:
|
|||||||
- ModeusApi:BaseUrl=${MODEUS_API_BASE_URL}
|
- ModeusApi:BaseUrl=${MODEUS_API_BASE_URL}
|
||||||
- ModeusApi:ApiKey=${MODEUS_API_KEY}
|
- ModeusApi:ApiKey=${MODEUS_API_KEY}
|
||||||
|
|
||||||
|
- Email:Smtp:Host=${EMAIL_SMTP_HOST}
|
||||||
|
- Email:Smtp:Port=${EMAIL_SMTP_PORT:-587}
|
||||||
|
- Email:Smtp:EnableSsl=${EMAIL_SMTP_ENABLE_SSL:-true}
|
||||||
|
- Email:Smtp:UserName=${EMAIL_SMTP_USERNAME}
|
||||||
|
- Email:Smtp:Password=${EMAIL_SMTP_PASSWORD}
|
||||||
|
- Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local}
|
||||||
|
- Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse}
|
||||||
|
|
||||||
- Gamification:XpThresholds=${GAMIFICATION_XP_THRESHOLDS:-[0, 100, 300, 600, 1000, 1500, 2500, 4000]}
|
- Gamification:XpThresholds=${GAMIFICATION_XP_THRESHOLDS:-[0, 100, 300, 600, 1000, 1500, 2500, 4000]}
|
||||||
|
|
||||||
- ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
|
- ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
|
||||||
|
|||||||
Reference in New Issue
Block a user