feat random bullshit GO!

This commit is contained in:
elar1s
2025-09-29 21:46:30 +03:00
parent 02934b1fd9
commit 63c89c48d5
71 changed files with 1976 additions and 1196 deletions

View File

@@ -0,0 +1,46 @@
using System.Net;
using System.Text.Json;
using Serilog;
namespace LctMonolith.Application.Middleware;
/// <summary>
/// Global error handling middleware capturing unhandled exceptions and converting to standardized JSON response.
/// </summary>
public class ErrorHandlingMiddleware
{
private readonly RequestDelegate _next;
public ErrorHandlingMiddleware(RequestDelegate next) => _next = next;
public async Task Invoke(HttpContext ctx)
{
try
{
await _next(ctx);
}
catch (OperationCanceledException)
{
// Client aborted request (non-standard 499 code used by some proxies)
if (!ctx.Response.HasStarted)
{
ctx.Response.StatusCode = 499; // Client Closed Request (custom)
}
}
catch (Exception ex)
{
Log.Error(ex, "Unhandled exception");
if (ctx.Response.HasStarted) throw;
ctx.Response.ContentType = "application/json";
ctx.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var payload = new { error = new { message = ex.Message, traceId = ctx.TraceIdentifier } };
await ctx.Response.WriteAsync(JsonSerializer.Serialize(payload));
}
}
}
public static class ErrorHandlingMiddlewareExtensions
{
/// <summary>Adds global error handling middleware.</summary>
public static IApplicationBuilder UseErrorHandling(this IApplicationBuilder app) => app.UseMiddleware<ErrorHandlingMiddleware>();
}

View File

@@ -0,0 +1,14 @@
namespace LctMonolith.Application.Options;
/// <summary>
/// JWT issuing configuration loaded from appsettings (section Jwt).
/// </summary>
public class JwtOptions
{
public string Key { get; set; } = string.Empty;
public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
public int AccessTokenMinutes { get; set; } = 60;
public int RefreshTokenDays { get; set; } = 7;
}

View File

@@ -0,0 +1,83 @@
using System.Security.Claims;
using LctMonolith.Domain.Entities;
using LctMonolith.Services;
using LctMonolith.Services.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace LctMonolith.Controllers;
/// <summary>
/// Authentication endpoints (mocked local identity + JWT issuing).
/// </summary>
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly UserManager<AppUser> _userManager;
private readonly SignInManager<AppUser> _signInManager;
private readonly ITokenService _tokenService;
public AuthController(UserManager<AppUser> userManager, SignInManager<AppUser> signInManager, ITokenService tokenService)
{
_userManager = userManager;
_signInManager = signInManager;
_tokenService = tokenService;
}
/// <summary>Registers a new user (simplified).</summary>
[HttpPost("register")]
[AllowAnonymous]
public async Task<ActionResult<TokenPair>> Register(AuthRequest req, CancellationToken ct)
{
var existing = await _userManager.FindByEmailAsync(req.Email);
if (existing != null) return Conflict("Email already registered");
var user = new AppUser { UserName = req.Email, Email = req.Email, FirstName = req.FirstName, LastName = req.LastName };
var result = await _userManager.CreateAsync(user, req.Password);
if (!result.Succeeded) return BadRequest(result.Errors);
var tokens = await _tokenService.IssueAsync(user, ct);
return Ok(tokens);
}
/// <summary>Login with email + password.</summary>
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<TokenPair>> Login(AuthRequest req, CancellationToken ct)
{
var user = await _userManager.FindByEmailAsync(req.Email);
if (user == null) return Unauthorized();
var passOk = await _signInManager.CheckPasswordSignInAsync(user, req.Password, lockoutOnFailure: false);
if (!passOk.Succeeded) return Unauthorized();
var tokens = await _tokenService.IssueAsync(user, ct);
return Ok(tokens);
}
/// <summary>Refresh access token by refresh token.</summary>
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<ActionResult<TokenPair>> Refresh(RefreshRequest req, CancellationToken ct)
{
var pair = await _tokenService.RefreshAsync(req.RefreshToken, ct);
return Ok(pair);
}
/// <summary>Revoke refresh token (logout).</summary>
[HttpPost("revoke")]
[Authorize]
public async Task<IActionResult> Revoke(RevokeRequest req, CancellationToken ct)
{
await _tokenService.RevokeAsync(req.RefreshToken, ct);
return NoContent();
}
/// <summary>Returns current user id (debug).</summary>
[HttpGet("me")]
[Authorize]
public ActionResult<object> Me()
{
var id = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name);
return Ok(new { userId = id });
}
}

View File

@@ -0,0 +1,33 @@
using System.Security.Claims;
using LctMonolith.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace LctMonolith.Controllers;
/// <summary>
/// Endpoints exposing gamification progress information.
/// </summary>
[ApiController]
[Route("api/gamification")]
[Authorize]
public class GamificationController : ControllerBase
{
private readonly IGamificationService _gamificationService;
public GamificationController(IGamificationService gamificationService)
{
_gamificationService = gamificationService;
}
private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
/// <summary>Returns current user progress snapshot (rank, xp, outstanding requirements).</summary>
[HttpGet("progress")]
public async Task<IActionResult> GetProgress(CancellationToken ct)
{
var snapshot = await _gamificationService.GetProgressAsync(GetUserId(), ct);
return Ok(snapshot);
}
}

View File

@@ -0,0 +1,53 @@
using System.Security.Claims;
using LctMonolith.Domain.Entities;
using LctMonolith.Services;
using LctMonolith.Services.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace LctMonolith.Controllers;
/// <summary>
/// Endpoints for listing and managing missions.
/// </summary>
[ApiController]
[Route("api/missions")]
[Authorize]
public class MissionsController : ControllerBase
{
private readonly IMissionService _missionService;
public MissionsController(IMissionService missionService)
{
_missionService = missionService;
}
private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
/// <summary>Returns missions currently available to the authenticated user.</summary>
[HttpGet]
public async Task<ActionResult<IEnumerable<Mission>>> GetAvailable(CancellationToken ct)
{
var userId = GetUserId();
var list = await _missionService.GetAvailableMissionsAsync(userId, ct);
return Ok(list);
}
/// <summary>Create a mission (HR functionality for now any authenticated user).</summary>
[HttpPost]
public async Task<ActionResult<Mission>> Create(CreateMissionModel model, CancellationToken ct)
{
var mission = await _missionService.CreateMissionAsync(model, ct);
return CreatedAtAction(nameof(GetAvailable), new { id = mission.Id }, mission);
}
/// <summary>Update mission status for current user (submit/complete/etc.).</summary>
[HttpPatch("{missionId:guid}/status")]
public async Task<ActionResult> UpdateStatus(Guid missionId, UpdateMissionStatusRequest req, CancellationToken ct)
{
var userId = GetUserId();
var result = await _missionService.UpdateStatusAsync(userId, missionId, req.Status, req.SubmissionData, ct);
return Ok(new { result.MissionId, result.Status, result.UpdatedAt });
}
}

View File

@@ -0,0 +1,57 @@
using System.Security.Claims;
using LctMonolith.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace LctMonolith.Controllers;
/// <summary>
/// In-app user notifications API.
/// </summary>
[ApiController]
[Route("api/notifications")]
[Authorize]
public class NotificationsController : ControllerBase
{
private readonly INotificationService _notifications;
public NotificationsController(INotificationService notifications)
{
_notifications = notifications;
}
private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
/// <summary>Get up to 100 unread notifications.</summary>
[HttpGet("unread")]
public async Task<IActionResult> GetUnread(CancellationToken ct)
{
var list = await _notifications.GetUnreadAsync(GetUserId(), ct);
return Ok(list);
}
/// <summary>Get recent notifications (paged by take).</summary>
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] int take = 100, CancellationToken ct = default)
{
var list = await _notifications.GetAllAsync(GetUserId(), take, ct);
return Ok(list);
}
/// <summary>Mark a notification as read.</summary>
[HttpPost("mark/{id:guid}")]
public async Task<IActionResult> MarkRead(Guid id, CancellationToken ct)
{
await _notifications.MarkReadAsync(GetUserId(), id, ct);
return NoContent();
}
/// <summary>Mark all notifications as read.</summary>
[HttpPost("mark-all")]
public async Task<IActionResult> MarkAll(CancellationToken ct)
{
var cnt = await _notifications.MarkAllReadAsync(GetUserId(), ct);
return Ok(new { updated = cnt });
}
}

View File

@@ -0,0 +1,42 @@
using System.Security.Claims;
using LctMonolith.Services;
using LctMonolith.Services.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace LctMonolith.Controllers;
/// <summary>
/// Store endpoints for listing items and purchasing.
/// </summary>
[ApiController]
[Route("api/store")]
[Authorize]
public class StoreController : ControllerBase
{
private readonly IStoreService _storeService;
public StoreController(IStoreService storeService)
{
_storeService = storeService;
}
private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
/// <summary>List active store items.</summary>
[HttpGet("items")]
public async Task<IActionResult> GetItems(CancellationToken ct)
{
var items = await _storeService.GetActiveItemsAsync(ct);
return Ok(items);
}
/// <summary>Purchase an item for the authenticated user.</summary>
[HttpPost("purchase")]
public async Task<IActionResult> Purchase(PurchaseRequest req, CancellationToken ct)
{
var inv = await _storeService.PurchaseAsync(GetUserId(), req.ItemId, req.Quantity, ct);
return Ok(new { inv.StoreItemId, inv.Quantity, inv.AcquiredAt });
}
}

23
LctMonolith/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["LctMonolith/LctMonolith.csproj", "LctMonolith/"]
RUN dotnet restore "LctMonolith/LctMonolith.csproj"
COPY . .
WORKDIR "/src/LctMonolith"
RUN dotnet build "./LctMonolith.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./LctMonolith.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "LctMonolith.dll"]

View File

@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Identity;
namespace LctMonolith.Domain.Entities;
/// <summary>
/// Application user (candidate or employee) participating in gamification.
/// Extends IdentityUser with Guid primary key.
/// </summary>
public class AppUser : IdentityUser<Guid>
{
/// <summary>User given (first) name.</summary>
public string? FirstName { get; set; }
/// <summary>User family (last) name.</summary>
public string? LastName { get; set; }
/// <summary>Date of birth.</summary>
public DateOnly? BirthDate { get; set; }
/// <summary>Current accumulated experience points.</summary>
public int Experience { get; set; }
/// <summary>Current mana (in-game currency).</summary>
public int Mana { get; set; }
/// <summary>Current rank reference.</summary>
public Guid? RankId { get; set; }
public Rank? Rank { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<UserCompetency> Competencies { get; set; } = new List<UserCompetency>();
public ICollection<UserMission> Missions { get; set; } = new List<UserMission>();
public ICollection<UserInventoryItem> Inventory { get; set; } = new List<UserInventoryItem>();
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
public ICollection<EventLog> Events { get; set; } = new List<EventLog>();
public ICollection<Notification> Notifications { get; set; } = new List<Notification>();
}

View File

@@ -0,0 +1,95 @@
namespace LctMonolith.Domain.Entities;
/// <summary>Artifact definition (unique reward objects).</summary>
public class Artifact
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = null!;
public string? Description { get; set; }
public string? ImageUrl { get; set; }
public ArtifactRarity Rarity { get; set; }
public ICollection<UserArtifact> Users { get; set; } = new List<UserArtifact>();
public ICollection<MissionArtifactReward> MissionRewards { get; set; } = new List<MissionArtifactReward>();
}
/// <summary>Mapping artifact to user ownership.</summary>
public class UserArtifact
{
public Guid UserId { get; set; }
public AppUser User { get; set; } = null!;
public Guid ArtifactId { get; set; }
public Artifact Artifact { get; set; } = null!;
public DateTime ObtainedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>Reward mapping: mission grants artifact(s).</summary>
public class MissionArtifactReward
{
public Guid MissionId { get; set; }
public Mission Mission { get; set; } = null!;
public Guid ArtifactId { get; set; }
public Artifact Artifact { get; set; } = null!;
}
/// <summary>Item in store that can be purchased with mana.</summary>
public class StoreItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = null!;
public string? Description { get; set; }
public int Price { get; set; }
public bool IsActive { get; set; } = true;
public int? Stock { get; set; }
public ICollection<UserInventoryItem> UserInventory { get; set; } = new List<UserInventoryItem>();
}
/// <summary>User owned store item record.</summary>
public class UserInventoryItem
{
public Guid UserId { get; set; }
public AppUser User { get; set; } = null!;
public Guid StoreItemId { get; set; }
public StoreItem StoreItem { get; set; } = null!;
public int Quantity { get; set; } = 1;
public DateTime AcquiredAt { get; set; } = DateTime.UtcNow;
public bool IsReturned { get; set; }
}
/// <summary>Transaction record for purchases/returns/sales.</summary>
public class Transaction
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public AppUser User { get; set; } = null!;
public TransactionType Type { get; set; }
public Guid? StoreItemId { get; set; }
public StoreItem? StoreItem { get; set; }
public int ManaAmount { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>System event log for auditing user actions and progression.</summary>
public class EventLog
{
public Guid Id { get; set; } = Guid.NewGuid();
public EventType Type { get; set; }
public Guid UserId { get; set; }
public AppUser User { get; set; } = null!;
public string? Data { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>Refresh token for JWT auth.</summary>
public class RefreshToken
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Token { get; set; } = null!;
public DateTime ExpiresAt { get; set; }
public bool IsRevoked { get; set; }
public Guid UserId { get; set; }
public AppUser User { get; set; } = null!;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,41 @@
namespace LctMonolith.Domain.Entities;
/// <summary>
/// Competency (skill) that can be progressed by completing missions.
/// </summary>
public class Competency
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = null!;
public string? Description { get; set; }
public ICollection<UserCompetency> UserCompetencies { get; set; } = new List<UserCompetency>();
public ICollection<MissionCompetencyReward> MissionRewards { get; set; } = new List<MissionCompetencyReward>();
public ICollection<RankRequiredCompetency> RankRequirements { get; set; } = new List<RankRequiredCompetency>();
}
/// <summary>Per-user competency level.</summary>
public class UserCompetency
{
public Guid UserId { get; set; }
public AppUser User { get; set; } = null!;
public Guid CompetencyId { get; set; }
public Competency Competency { get; set; } = null!;
/// <summary>Current level (integer simple scale).</summary>
public int Level { get; set; }
/// <summary>Optional numeric progress inside level (e.g., partial points).</summary>
public int ProgressPoints { get; set; }
}
/// <summary>Reward mapping: mission increases competency level points.</summary>
public class MissionCompetencyReward
{
public Guid MissionId { get; set; }
public Mission Mission { get; set; } = null!;
public Guid CompetencyId { get; set; }
public Competency Competency { get; set; } = null!;
/// <summary>Increment value in levels (could be 0 or 1) or points depending on design.</summary>
public int LevelDelta { get; set; }
public int ProgressPointsDelta { get; set; }
}

View File

@@ -0,0 +1,54 @@
namespace LctMonolith.Domain.Entities;
/// <summary>Mission category taxonomy.</summary>
public enum MissionCategory
{
Quest = 0,
Recruiting = 1,
Lecture = 2,
Simulator = 3
}
/// <summary>Status of a mission for a specific user.</summary>
public enum MissionStatus
{
Locked = 0,
Available = 1,
InProgress = 2,
Submitted = 3,
Completed = 4,
Rejected = 5
}
/// <summary>Rarity level of an artifact.</summary>
public enum ArtifactRarity
{
Common = 0,
Rare = 1,
Epic = 2,
Legendary = 3
}
/// <summary>Type of transactional operation in store.</summary>
public enum TransactionType
{
Purchase = 0,
Return = 1,
Sale = 2
}
/// <summary>Auditable event types enumerated in requirements.</summary>
public enum EventType
{
SkillProgress = 1,
MissionStatusChanged = 2,
RankChanged = 3,
ItemPurchased = 4,
ArtifactObtained = 5,
RewardGranted = 6,
ProfileChanged = 7,
AuthCredentialsChanged = 8,
ItemReturned = 9,
ItemSold = 10
}

View File

@@ -0,0 +1,44 @@
namespace LctMonolith.Domain.Entities;
/// <summary>
/// Mission (task) definition configured by HR.
/// </summary>
public class Mission
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = null!;
public string? Description { get; set; }
/// <summary>Optional branch (path) name for grouping / visualization.</summary>
public string? Branch { get; set; }
public MissionCategory Category { get; set; }
/// <summary>Minimum rank required to access the mission (nullable = available from start).</summary>
public Guid? MinRankId { get; set; }
public Rank? MinRank { get; set; }
/// <summary>Experience reward on completion.</summary>
public int ExperienceReward { get; set; }
/// <summary>Mana reward on completion.</summary>
public int ManaReward { get; set; }
public bool IsActive { get; set; } = true;
public ICollection<MissionCompetencyReward> CompetencyRewards { get; set; } = new List<MissionCompetencyReward>();
public ICollection<MissionArtifactReward> ArtifactRewards { get; set; } = new List<MissionArtifactReward>();
public ICollection<UserMission> UserMissions { get; set; } = new List<UserMission>();
public ICollection<RankRequiredMission> RanksRequiring { get; set; } = new List<RankRequiredMission>();
}
/// <summary>Per-user mission status and progression.</summary>
public class UserMission
{
public Guid UserId { get; set; }
public AppUser User { get; set; } = null!;
public Guid MissionId { get; set; }
public Mission Mission { get; set; } = null!;
public MissionStatus Status { get; set; } = MissionStatus.Available;
/// <summary>Date/time of last status change.</summary>
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Optional submission payload (e.g., link, text, attachments pointer).</summary>
public string? SubmissionData { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace LctMonolith.Domain.Entities;
/// <summary>User notification (in-app).</summary>
public class Notification
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public AppUser User { get; set; } = null!;
/// <summary>Short classification tag (e.g., rank, mission, store).</summary>
public string Type { get; set; } = null!;
public string Title { get; set; } = null!;
public string Message { get; set; } = null!;
public bool IsRead { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ReadAt { get; set; }
}

View File

@@ -0,0 +1,40 @@
namespace LctMonolith.Domain.Entities;
/// <summary>
/// Linear rank in progression ladder. User must meet XP, key mission and competency requirements.
/// </summary>
public class Rank
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Display name (e.g., "Искатель", "Пилот-кандидат").</summary>
public string Name { get; set; } = null!;
/// <summary>Ordering position. Lower value = earlier rank.</summary>
public int Order { get; set; }
/// <summary>Required cumulative experience to attain this rank.</summary>
public int RequiredExperience { get; set; }
public ICollection<RankRequiredMission> RequiredMissions { get; set; } = new List<RankRequiredMission>();
public ICollection<RankRequiredCompetency> RequiredCompetencies { get; set; } = new List<RankRequiredCompetency>();
public ICollection<AppUser> Users { get; set; } = new List<AppUser>();
}
/// <summary>Mapping of rank to required mission.</summary>
public class RankRequiredMission
{
public Guid RankId { get; set; }
public Rank Rank { get; set; } = null!;
public Guid MissionId { get; set; }
public Mission Mission { get; set; } = null!;
}
/// <summary>Mapping of rank to required competency minimum level.</summary>
public class RankRequiredCompetency
{
public Guid RankId { get; set; }
public Rank Rank { get; set; } = null!;
public Guid CompetencyId { get; set; }
public Competency Competency { get; set; } = null!;
/// <summary>Minimum level required for the competency.</summary>
public int MinLevel { get; set; }
}

View File

@@ -0,0 +1,95 @@
using LctMonolith.Domain.Entities;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace LctMonolith.Infrastructure.Data;
/// <summary>
/// Main EF Core database context for gamification module (PostgreSQL provider expected).
/// </summary>
public class AppDbContext : IdentityDbContext<AppUser, IdentityRole<Guid>, Guid>
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Rank> Ranks => Set<Rank>();
public DbSet<RankRequiredMission> RankRequiredMissions => Set<RankRequiredMission>();
public DbSet<RankRequiredCompetency> RankRequiredCompetencies => Set<RankRequiredCompetency>();
public DbSet<Mission> Missions => Set<Mission>();
public DbSet<UserMission> UserMissions => Set<UserMission>();
public DbSet<MissionCompetencyReward> MissionCompetencyRewards => Set<MissionCompetencyReward>();
public DbSet<MissionArtifactReward> MissionArtifactRewards => Set<MissionArtifactReward>();
public DbSet<Competency> Competencies => Set<Competency>();
public DbSet<UserCompetency> UserCompetencies => Set<UserCompetency>();
public DbSet<Artifact> Artifacts => Set<Artifact>();
public DbSet<UserArtifact> UserArtifacts => Set<UserArtifact>();
public DbSet<StoreItem> StoreItems => Set<StoreItem>();
public DbSet<UserInventoryItem> UserInventoryItems => Set<UserInventoryItem>();
public DbSet<Transaction> Transactions => Set<Transaction>();
public DbSet<EventLog> EventLogs => Set<EventLog>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<Notification> Notifications => Set<Notification>();
protected override void OnModelCreating(ModelBuilder b)
{
base.OnModelCreating(b);
// Rank required mission composite key
b.Entity<RankRequiredMission>().HasKey(x => new { x.RankId, x.MissionId });
b.Entity<RankRequiredMission>()
.HasOne(x => x.Rank).WithMany(r => r.RequiredMissions).HasForeignKey(x => x.RankId);
b.Entity<RankRequiredMission>()
.HasOne(x => x.Mission).WithMany(m => m.RanksRequiring).HasForeignKey(x => x.MissionId);
// Rank required competency composite key
b.Entity<RankRequiredCompetency>().HasKey(x => new { x.RankId, x.CompetencyId });
b.Entity<RankRequiredCompetency>()
.HasOne(x => x.Rank).WithMany(r => r.RequiredCompetencies).HasForeignKey(x => x.RankId);
b.Entity<RankRequiredCompetency>()
.HasOne(x => x.Competency).WithMany(c => c.RankRequirements).HasForeignKey(x => x.CompetencyId);
// UserMission composite key
b.Entity<UserMission>().HasKey(x => new { x.UserId, x.MissionId });
b.Entity<UserMission>()
.HasOne(x => x.User).WithMany(u => u.Missions).HasForeignKey(x => x.UserId);
b.Entity<UserMission>()
.HasOne(x => x.Mission).WithMany(m => m.UserMissions).HasForeignKey(x => x.MissionId);
// UserCompetency composite key
b.Entity<UserCompetency>().HasKey(x => new { x.UserId, x.CompetencyId });
b.Entity<UserCompetency>()
.HasOne(x => x.User).WithMany(u => u.Competencies).HasForeignKey(x => x.UserId);
b.Entity<UserCompetency>()
.HasOne(x => x.Competency).WithMany(c => c.UserCompetencies).HasForeignKey(x => x.CompetencyId);
// Mission competency reward composite key
b.Entity<MissionCompetencyReward>().HasKey(x => new { x.MissionId, x.CompetencyId });
// Mission artifact reward composite key
b.Entity<MissionArtifactReward>().HasKey(x => new { x.MissionId, x.ArtifactId });
// UserArtifact composite key
b.Entity<UserArtifact>().HasKey(x => new { x.UserId, x.ArtifactId });
// UserInventory composite key
b.Entity<UserInventoryItem>().HasKey(x => new { x.UserId, x.StoreItemId });
// Refresh token index unique
b.Entity<RefreshToken>().HasIndex(x => x.Token).IsUnique();
// ---------- Added performance indexes ----------
b.Entity<AppUser>().HasIndex(u => u.RankId);
b.Entity<Mission>().HasIndex(m => m.MinRankId);
b.Entity<UserMission>().HasIndex(um => new { um.UserId, um.Status });
b.Entity<UserCompetency>().HasIndex(uc => uc.CompetencyId); // for querying all users by competency
b.Entity<EventLog>().HasIndex(e => new { e.UserId, e.Type, e.CreatedAt });
b.Entity<StoreItem>().HasIndex(i => i.IsActive);
b.Entity<Transaction>().HasIndex(t => new { t.UserId, t.CreatedAt });
b.Entity<Notification>().HasIndex(n => new { n.UserId, n.IsRead, n.CreatedAt });
}
}

View File

@@ -0,0 +1,48 @@
using LctMonolith.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Serilog;
using System.Collections.Generic;
namespace LctMonolith.Infrastructure.Data;
/// <summary>
/// Development database seeder for initial ranks, competencies, sample store items.
/// Idempotent: checks existence before inserting.
/// </summary>
public static class DbSeeder
{
public static async Task SeedAsync(AppDbContext db, CancellationToken ct = default)
{
await db.Database.EnsureCreatedAsync(ct);
if (!await db.Ranks.AnyAsync(ct))
{
var ranks = new List<Rank>
{
new() { Name = "Искатель", Order = 0, RequiredExperience = 0 },
new() { Name = "Пилот-кандидат", Order = 1, RequiredExperience = 500 },
new() { Name = "Принятый в экипаж", Order = 2, RequiredExperience = 1500 }
};
db.Ranks.AddRange(ranks);
Log.Information("Seeded {Count} ranks", ranks.Count);
}
if (!await db.Competencies.AnyAsync(ct))
{
var comps = new[]
{
"Вера в дело","Стремление к большему","Общение","Аналитика","Командование","Юриспруденция","Трёхмерное мышление","Базовая экономика","Основы аэронавигации"
}.Select(n => new Competency { Name = n });
db.Competencies.AddRange(comps);
Log.Information("Seeded competencies");
}
if (!await db.StoreItems.AnyAsync(ct))
{
db.StoreItems.AddRange(new StoreItem { Name = "Футболка Алабуга", Price = 100 }, new StoreItem { Name = "Брелок Буран", Price = 50 });
Log.Information("Seeded store items");
}
await db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,56 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using LctMonolith.Infrastructure.Data;
namespace LctMonolith.Infrastructure.Repositories;
/// <summary>
/// Generic repository implementation for common CRUD and query composition.
/// </summary>
public class GenericRepository<TEntity> : IGenericRepository<TEntity> where TEntity : class
{
protected readonly AppDbContext Context;
protected readonly DbSet<TEntity> Set;
public GenericRepository(AppDbContext context)
{
Context = context;
Set = context.Set<TEntity>();
}
public IQueryable<TEntity> Query(
Expression<Func<TEntity, bool>>? filter = null,
Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>? orderBy = null,
params Expression<Func<TEntity, object>>[] includes)
{
IQueryable<TEntity> query = Set;
if (filter != null) query = query.Where(filter);
if (includes != null)
{
foreach (var include in includes)
query = query.Include(include);
}
if (orderBy != null) query = orderBy(query);
return query;
}
public async Task<TEntity?> GetByIdAsync(object id) => await Set.FindAsync(id) ?? null;
public ValueTask<TEntity?> FindAsync(params object[] keyValues) => Set.FindAsync(keyValues);
public async Task AddAsync(TEntity entity, CancellationToken ct = default) => await Set.AddAsync(entity, ct);
public async Task AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken ct = default) => await Set.AddRangeAsync(entities, ct);
public void Update(TEntity entity) => Set.Update(entity);
public void Remove(TEntity entity) => Set.Remove(entity);
public async Task RemoveByIdAsync(object id, CancellationToken ct = default)
{
var entity = await Set.FindAsync([id], ct);
if (entity == null) throw new KeyNotFoundException($"Entity {typeof(TEntity).Name} id={id} not found");
Set.Remove(entity);
}
}

View File

@@ -0,0 +1,25 @@
using System.Linq.Expressions;
namespace LctMonolith.Infrastructure.Repositories;
/// <summary>
/// Generic repository abstraction for aggregate root / entity access. Read operations return IQueryable for composition.
/// </summary>
public interface IGenericRepository<TEntity> where TEntity : class
{
IQueryable<TEntity> Query(
Expression<Func<TEntity, bool>>? filter = null,
Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>? orderBy = null,
params Expression<Func<TEntity, object>>[] includes);
Task<TEntity?> GetByIdAsync(object id);
ValueTask<TEntity?> FindAsync(params object[] keyValues);
Task AddAsync(TEntity entity, CancellationToken ct = default);
Task AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken ct = default);
void Update(TEntity entity);
void Remove(TEntity entity);
Task RemoveByIdAsync(object id, CancellationToken ct = default);
}

View File

@@ -0,0 +1,34 @@
using LctMonolith.Domain.Entities;
using LctMonolith.Infrastructure.Repositories;
namespace LctMonolith.Infrastructure.UnitOfWork;
/// <summary>
/// Unit of Work aggregates repositories and transaction boundary.
/// </summary>
public interface IUnitOfWork
{
IGenericRepository<AppUser> Users { get; }
IGenericRepository<Rank> Ranks { get; }
IGenericRepository<RankRequiredMission> RankRequiredMissions { get; }
IGenericRepository<RankRequiredCompetency> RankRequiredCompetencies { get; }
IGenericRepository<Mission> Missions { get; }
IGenericRepository<UserMission> UserMissions { get; }
IGenericRepository<MissionCompetencyReward> MissionCompetencyRewards { get; }
IGenericRepository<MissionArtifactReward> MissionArtifactRewards { get; }
IGenericRepository<Competency> Competencies { get; }
IGenericRepository<UserCompetency> UserCompetencies { get; }
IGenericRepository<Artifact> Artifacts { get; }
IGenericRepository<UserArtifact> UserArtifacts { get; }
IGenericRepository<StoreItem> StoreItems { get; }
IGenericRepository<UserInventoryItem> UserInventoryItems { get; }
IGenericRepository<Transaction> Transactions { get; }
IGenericRepository<EventLog> EventLogs { get; }
IGenericRepository<RefreshToken> RefreshTokens { get; }
IGenericRepository<Notification> Notifications { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
Task BeginTransactionAsync(CancellationToken ct = default);
Task CommitAsync(CancellationToken ct = default);
Task RollbackAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,99 @@
using LctMonolith.Domain.Entities;
using LctMonolith.Infrastructure.Data;
using LctMonolith.Infrastructure.Repositories;
using Microsoft.EntityFrameworkCore.Storage;
namespace LctMonolith.Infrastructure.UnitOfWork;
/// <summary>
/// Unit of Work implementation encapsulating repositories and DB transaction scope.
/// </summary>
public class UnitOfWork : IUnitOfWork, IAsyncDisposable
{
private readonly AppDbContext _ctx;
private IDbContextTransaction? _tx;
public UnitOfWork(AppDbContext ctx)
{
_ctx = ctx;
}
private IGenericRepository<AppUser>? _users;
private IGenericRepository<Rank>? _ranks;
private IGenericRepository<RankRequiredMission>? _rankRequiredMissions;
private IGenericRepository<RankRequiredCompetency>? _rankRequiredCompetencies;
private IGenericRepository<Mission>? _missions;
private IGenericRepository<UserMission>? _userMissions;
private IGenericRepository<MissionCompetencyReward>? _missionCompetencyRewards;
private IGenericRepository<MissionArtifactReward>? _missionArtifactRewards;
private IGenericRepository<Competency>? _competencies;
private IGenericRepository<UserCompetency>? _userCompetencies;
private IGenericRepository<Artifact>? _artifacts;
private IGenericRepository<UserArtifact>? _userArtifacts;
private IGenericRepository<StoreItem>? _storeItems;
private IGenericRepository<UserInventoryItem>? _userInventoryItems;
private IGenericRepository<Transaction>? _transactions;
private IGenericRepository<EventLog>? _eventLogs;
private IGenericRepository<RefreshToken>? _refreshTokens;
private IGenericRepository<Notification>? _notifications;
public IGenericRepository<AppUser> Users => _users ??= new GenericRepository<AppUser>(_ctx);
public IGenericRepository<Rank> Ranks => _ranks ??= new GenericRepository<Rank>(_ctx);
public IGenericRepository<RankRequiredMission> RankRequiredMissions => _rankRequiredMissions ??= new GenericRepository<RankRequiredMission>(_ctx);
public IGenericRepository<RankRequiredCompetency> RankRequiredCompetencies => _rankRequiredCompetencies ??= new GenericRepository<RankRequiredCompetency>(_ctx);
public IGenericRepository<Mission> Missions => _missions ??= new GenericRepository<Mission>(_ctx);
public IGenericRepository<UserMission> UserMissions => _userMissions ??= new GenericRepository<UserMission>(_ctx);
public IGenericRepository<MissionCompetencyReward> MissionCompetencyRewards => _missionCompetencyRewards ??= new GenericRepository<MissionCompetencyReward>(_ctx);
public IGenericRepository<MissionArtifactReward> MissionArtifactRewards => _missionArtifactRewards ??= new GenericRepository<MissionArtifactReward>(_ctx);
public IGenericRepository<Competency> Competencies => _competencies ??= new GenericRepository<Competency>(_ctx);
public IGenericRepository<UserCompetency> UserCompetencies => _userCompetencies ??= new GenericRepository<UserCompetency>(_ctx);
public IGenericRepository<Artifact> Artifacts => _artifacts ??= new GenericRepository<Artifact>(_ctx);
public IGenericRepository<UserArtifact> UserArtifacts => _userArtifacts ??= new GenericRepository<UserArtifact>(_ctx);
public IGenericRepository<StoreItem> StoreItems => _storeItems ??= new GenericRepository<StoreItem>(_ctx);
public IGenericRepository<UserInventoryItem> UserInventoryItems => _userInventoryItems ??= new GenericRepository<UserInventoryItem>(_ctx);
public IGenericRepository<Transaction> Transactions => _transactions ??= new GenericRepository<Transaction>(_ctx);
public IGenericRepository<EventLog> EventLogs => _eventLogs ??= new GenericRepository<EventLog>(_ctx);
public IGenericRepository<RefreshToken> RefreshTokens => _refreshTokens ??= new GenericRepository<RefreshToken>(_ctx);
public IGenericRepository<Notification> Notifications => _notifications ??= new GenericRepository<Notification>(_ctx);
public Task<int> SaveChangesAsync(CancellationToken ct = default) => _ctx.SaveChangesAsync(ct);
public async Task BeginTransactionAsync(CancellationToken ct = default)
{
if (_tx != null) throw new InvalidOperationException("Transaction already started");
_tx = await _ctx.Database.BeginTransactionAsync(ct);
}
public async Task CommitAsync(CancellationToken ct = default)
{
if (_tx == null) return;
try
{
await _ctx.SaveChangesAsync(ct);
await _tx.CommitAsync(ct);
}
catch
{
await RollbackAsync(ct);
throw;
}
finally
{
await _tx.DisposeAsync();
_tx = null;
}
}
public async Task RollbackAsync(CancellationToken ct = default)
{
if (_tx == null) return;
await _tx.RollbackAsync(ct);
await _tx.DisposeAsync();
_tx = null;
}
public async ValueTask DisposeAsync()
{
if (_tx != null) await _tx.DisposeAsync();
}
}

View File

@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.9" />
<!-- Serilog core and sinks -->
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
<!-- Updated to satisfy Serilog.AspNetCore dependency (>=8.0.0) -->
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Elasticsearch" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.0" />
<!-- Data / Identity / Auth -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0" PrivateAssets="All" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0" PrivateAssets="All" />
<!-- JSON -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.0" />
<!-- Updated to match transitive requirement (>=8.0.1) -->
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@LctMonolith_HostAddress = http://localhost:5217
GET {{LctMonolith_HostAddress}}/weatherforecast/
Accept: application/json
###

135
LctMonolith/Program.cs Normal file
View File

@@ -0,0 +1,135 @@
using Serilog;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using LctMonolith.Infrastructure.Data;
using LctMonolith.Domain.Entities;
using Microsoft.AspNetCore.Identity;
using LctMonolith.Infrastructure.UnitOfWork;
using LctMonolith.Application.Middleware;
using LctMonolith.Services;
using LctMonolith.Application.Options; // Added for JwtOptions
var builder = WebApplication.CreateBuilder(args);
// Serilog configuration
builder.Host.UseSerilog((ctx, services, loggerConfig) =>
loggerConfig
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "LctMonolith"));
// Configuration values
var connectionString = builder.Configuration.GetConnectionString("Default") ?? "Host=localhost;Port=5432;Database=lct2025;Username=postgres;Password=postgres";
var jwtSection = builder.Configuration.GetSection("Jwt");
var jwtKey = jwtSection["Key"] ?? "Dev_Insecure_Key_Change_Me";
var jwtIssuer = jwtSection["Issuer"] ?? "LctMonolith";
var jwtAudience = jwtSection["Audience"] ?? "LctMonolithAudience";
var accessMinutes = int.TryParse(jwtSection["AccessTokenMinutes"], out var m) ? m : 60;
var refreshDays = int.TryParse(jwtSection["RefreshTokenDays"], out var d) ? d : 7;
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
builder.Services.Configure<JwtOptions>(o =>
{
o.Key = jwtKey;
o.Issuer = jwtIssuer;
o.Audience = jwtAudience;
o.AccessTokenMinutes = accessMinutes;
o.RefreshTokenDays = refreshDays;
});
// DbContext
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(connectionString));
// Identity Core
builder.Services.AddIdentityCore<AppUser>(options =>
{
options.Password.RequireDigit = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireLowercase = false;
options.Password.RequiredLength = 6;
})
.AddRoles<IdentityRole<Guid>>()
.AddEntityFrameworkStores<AppDbContext>()
.AddSignInManager<SignInManager<AppUser>>()
.AddDefaultTokenProviders();
// Authentication & JWT
builder.Services.AddAuthentication(o =>
{
o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = signingKey,
ClockSkew = TimeSpan.FromMinutes(2)
};
});
// Controllers + NewtonsoftJson
builder.Services.AddControllers()
.AddNewtonsoftJson();
// OpenAPI
builder.Services.AddOpenApi();
// Health checks
builder.Services.AddHealthChecks();
// UnitOfWork
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
// Domain services
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IGamificationService, GamificationService>();
builder.Services.AddScoped<IMissionService, MissionService>();
builder.Services.AddScoped<IStoreService, StoreService>();
builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddScoped<IInventoryService, InventoryService>();
builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
// CORS
builder.Services.AddCors(p => p.AddDefaultPolicy(policy =>
policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin()));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
await DbSeeder.SeedAsync(db); // seed dev data
}
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseCors();
app.UseHttpsRedirection();
app.UseErrorHandling();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready");
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5217",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7144;http://localhost:5217",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,34 @@
using LctMonolith.Infrastructure.UnitOfWork;
using LctMonolith.Services.Models;
using Microsoft.EntityFrameworkCore;
namespace LctMonolith.Services;
/// <summary>
/// Provides aggregated analytics metrics for dashboards.
/// </summary>
public class AnalyticsService : IAnalyticsService
{
private readonly IUnitOfWork _uow;
public AnalyticsService(IUnitOfWork uow) => _uow = uow;
public async Task<AnalyticsSummary> GetSummaryAsync(CancellationToken ct = default)
{
var totalUsers = await _uow.Users.Query().CountAsync(ct);
var totalMissions = await _uow.Missions.Query().CountAsync(ct);
var completedMissions = await _uow.UserMissions.Query(um => um.Status == Domain.Entities.MissionStatus.Completed).CountAsync(ct);
var totalArtifacts = await _uow.Artifacts.Query().CountAsync(ct);
var totalStoreItems = await _uow.StoreItems.Query().CountAsync(ct);
var totalExperience = await _uow.Users.Query().SumAsync(u => (long)u.Experience, ct);
return new AnalyticsSummary
{
TotalUsers = totalUsers,
TotalMissions = totalMissions,
CompletedMissions = completedMissions,
TotalArtifacts = totalArtifacts,
TotalStoreItems = totalStoreItems,
TotalExperience = totalExperience
};
}
}

View File

@@ -0,0 +1,161 @@
using System.Linq;
using LctMonolith.Domain.Entities;
using LctMonolith.Infrastructure.UnitOfWork;
using LctMonolith.Services.Models;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace LctMonolith.Services;
/// <summary>
/// Handles progression logic: mission completion rewards and rank advancement evaluation.
/// </summary>
public class GamificationService : IGamificationService
{
private readonly IUnitOfWork _uow;
private readonly INotificationService _notifications;
public GamificationService(IUnitOfWork uow, INotificationService notifications)
{
_uow = uow;
_notifications = notifications;
}
public async Task<ProgressSnapshot> GetProgressAsync(Guid userId, CancellationToken ct = default)
{
var user = await _uow.Users
.Query(u => u.Id == userId, null, u => u.Rank, u => u.Competencies)
.FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("User not found");
var ranks = await _uow.Ranks
.Query(null, q => q.OrderBy(r => r.Order), r => r.RequiredMissions, r => r.RequiredCompetencies)
.ToListAsync(ct);
var currentOrder = user.Rank?.Order ?? -1;
var nextRank = ranks.FirstOrDefault(r => r.Order == currentOrder + 1);
var snapshot = new ProgressSnapshot
{
Experience = user.Experience,
Mana = user.Mana,
CurrentRankId = user.RankId,
CurrentRankName = user.Rank?.Name,
NextRankId = nextRank?.Id,
NextRankName = nextRank?.Name,
RequiredExperienceForNextRank = nextRank?.RequiredExperience
};
if (nextRank != null)
{
// Outstanding missions
var userMissionIds = await _uow.UserMissions.Query(um => um.UserId == userId).Select(um => um.MissionId).ToListAsync(ct);
foreach (var rm in nextRank.RequiredMissions)
{
if (!userMissionIds.Contains(rm.MissionId))
snapshot.OutstandingMissionIds.Add(rm.MissionId);
}
// Outstanding competencies
foreach (var rc in nextRank.RequiredCompetencies)
{
var userComp = user.Competencies.FirstOrDefault(c => c.CompetencyId == rc.CompetencyId);
var level = userComp?.Level ?? 0;
if (level < rc.MinLevel)
{
snapshot.OutstandingCompetencies.Add(new OutstandingCompetency
{
CompetencyId = rc.CompetencyId,
CompetencyName = rc.Competency?.Name,
RequiredLevel = rc.MinLevel,
CurrentLevel = level
});
}
}
}
return snapshot;
}
public async Task ApplyMissionCompletionAsync(Guid userId, Mission mission, CancellationToken ct = default)
{
var user = await _uow.Users
.Query(u => u.Id == userId, null, u => u.Competencies, u => u.Rank)
.FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("User not found");
user.Experience += mission.ExperienceReward;
user.Mana += mission.ManaReward;
user.UpdatedAt = DateTime.UtcNow;
// Competency rewards
var compRewards = await _uow.MissionCompetencyRewards.Query(m => m.MissionId == mission.Id).ToListAsync(ct);
foreach (var reward in compRewards)
{
var uc = user.Competencies.FirstOrDefault(c => c.CompetencyId == reward.CompetencyId);
if (uc == null)
{
uc = new UserCompetency
{
UserId = userId,
CompetencyId = reward.CompetencyId,
Level = reward.LevelDelta,
ProgressPoints = reward.ProgressPointsDelta
};
await _uow.UserCompetencies.AddAsync(uc, ct);
}
else
{
uc.Level += reward.LevelDelta;
uc.ProgressPoints += reward.ProgressPointsDelta;
}
}
// Artifacts
var artRewards = await _uow.MissionArtifactRewards.Query(m => m.MissionId == mission.Id).ToListAsync(ct);
foreach (var ar in artRewards)
{
var existing = await _uow.UserArtifacts.FindAsync(userId, ar.ArtifactId);
if (existing == null)
{
await _uow.UserArtifacts.AddAsync(new UserArtifact
{
UserId = userId,
ArtifactId = ar.ArtifactId,
ObtainedAt = DateTime.UtcNow
}, ct);
}
}
await _uow.SaveChangesAsync(ct);
await EvaluateRankUpgradeAsync(userId, ct);
}
public async Task EvaluateRankUpgradeAsync(Guid userId, CancellationToken ct = default)
{
var user = await _uow.Users
.Query(u => u.Id == userId, null, u => u.Rank, u => u.Competencies)
.FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("User not found");
var ranks = await _uow.Ranks
.Query(null, q => q.OrderBy(r => r.Order), r => r.RequiredMissions, r => r.RequiredCompetencies)
.ToListAsync(ct);
var currentOrder = user.Rank?.Order ?? -1;
var nextRank = ranks.FirstOrDefault(r => r.Order == currentOrder + 1);
if (nextRank == null) return;
if (user.Experience < nextRank.RequiredExperience) return;
var completedMissionIds = await _uow.UserMissions
.Query(um => um.UserId == userId && um.Status == MissionStatus.Completed)
.Select(x => x.MissionId)
.ToListAsync(ct);
if (nextRank.RequiredMissions.Any(rm => !completedMissionIds.Contains(rm.MissionId))) return;
foreach (var rc in nextRank.RequiredCompetencies)
{
var uc = user.Competencies.FirstOrDefault(c => c.CompetencyId == rc.CompetencyId);
if (uc == null || uc.Level < rc.MinLevel)
return;
}
user.RankId = nextRank.Id;
user.Rank = nextRank;
user.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
Log.Information("User {UserId} promoted to rank {Rank}", userId, nextRank.Name);
await _notifications.CreateAsync(userId, "rank", "Повышение ранга", $"Вы получили ранг '{nextRank.Name}'", ct);
}
}

View File

@@ -0,0 +1,3 @@
// Removed legacy Weather service placeholder.
// Intentionally left blank.

View File

@@ -0,0 +1,58 @@
using LctMonolith.Domain.Entities;
using LctMonolith.Services.Models;
namespace LctMonolith.Services;
/// <summary>Service for issuing JWT and refresh tokens.</summary>
public interface ITokenService
{
Task<TokenPair> IssueAsync(AppUser user, CancellationToken ct = default);
Task<TokenPair> RefreshAsync(string refreshToken, CancellationToken ct = default);
Task RevokeAsync(string refreshToken, CancellationToken ct = default);
}
/// <summary>Gamification progression logic (awards, rank upgrade).</summary>
public interface IGamificationService
{
Task<ProgressSnapshot> GetProgressAsync(Guid userId, CancellationToken ct = default);
Task ApplyMissionCompletionAsync(Guid userId, Mission mission, CancellationToken ct = default);
Task EvaluateRankUpgradeAsync(Guid userId, CancellationToken ct = default);
}
/// <summary>Mission management and user mission state transitions.</summary>
public interface IMissionService
{
Task<Mission> CreateMissionAsync(CreateMissionModel model, CancellationToken ct = default);
Task<IEnumerable<Mission>> GetAvailableMissionsAsync(Guid userId, CancellationToken ct = default);
Task<UserMission> UpdateStatusAsync(Guid userId, Guid missionId, MissionStatus status, string? submissionData, CancellationToken ct = default);
}
/// <summary>Store and inventory operations.</summary>
public interface IStoreService
{
Task<IEnumerable<StoreItem>> GetActiveItemsAsync(CancellationToken ct = default);
Task<UserInventoryItem> PurchaseAsync(Guid userId, Guid itemId, int quantity, CancellationToken ct = default);
}
/// <summary>User notifications (in-app) management.</summary>
public interface INotificationService
{
Task<Notification> CreateAsync(Guid userId, string type, string title, string message, CancellationToken ct = default);
Task<IEnumerable<Notification>> GetUnreadAsync(Guid userId, CancellationToken ct = default);
Task<IEnumerable<Notification>> GetAllAsync(Guid userId, int take = 100, CancellationToken ct = default);
Task MarkReadAsync(Guid userId, Guid notificationId, CancellationToken ct = default);
Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct = default);
}
/// <summary>Inventory querying (owned artifacts, store items).</summary>
public interface IInventoryService
{
Task<IEnumerable<UserInventoryItem>> GetStoreInventoryAsync(Guid userId, CancellationToken ct = default);
Task<IEnumerable<UserArtifact>> GetArtifactsAsync(Guid userId, CancellationToken ct = default);
}
/// <summary>Basic analytics / aggregated metrics.</summary>
public interface IAnalyticsService
{
Task<AnalyticsSummary> GetSummaryAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,35 @@
using LctMonolith.Domain.Entities;
using LctMonolith.Infrastructure.UnitOfWork;
using Microsoft.EntityFrameworkCore;
namespace LctMonolith.Services;
/// <summary>
/// Provides read-only access to user-owned inventory (store items and artifacts).
/// </summary>
public class InventoryService : IInventoryService
{
private readonly IUnitOfWork _uow;
public InventoryService(IUnitOfWork uow)
{
_uow = uow;
}
public async Task<IEnumerable<UserInventoryItem>> GetStoreInventoryAsync(Guid userId, CancellationToken ct = default)
{
return await _uow.UserInventoryItems
.Query(ii => ii.UserId == userId, null, ii => ii.StoreItem)
.OrderByDescending(i => i.AcquiredAt)
.ToListAsync(ct);
}
public async Task<IEnumerable<UserArtifact>> GetArtifactsAsync(Guid userId, CancellationToken ct = default)
{
return await _uow.UserArtifacts
.Query(a => a.UserId == userId, null, a => a.Artifact)
.OrderByDescending(a => a.ObtainedAt)
.ToListAsync(ct);
}
}

View File

@@ -0,0 +1,112 @@
using LctMonolith.Domain.Entities;
using LctMonolith.Infrastructure.UnitOfWork;
using LctMonolith.Services.Models;
using Microsoft.EntityFrameworkCore;
using Serilog;
using System.Text.Json;
using System.Linq;
namespace LctMonolith.Services;
/// <summary>
/// Mission management and user mission state transitions.
/// </summary>
public class MissionService : IMissionService
{
private readonly IUnitOfWork _uow;
private readonly IGamificationService _gamification;
public MissionService(IUnitOfWork uow, IGamificationService gamification)
{
_uow = uow;
_gamification = gamification;
}
public async Task<Mission> CreateMissionAsync(CreateMissionModel model, CancellationToken ct = default)
{
var mission = new Mission
{
Title = model.Title,
Description = model.Description,
Branch = model.Branch,
Category = model.Category,
MinRankId = model.MinRankId,
ExperienceReward = model.ExperienceReward,
ManaReward = model.ManaReward,
IsActive = true
};
await _uow.Missions.AddAsync(mission, ct);
foreach (var cr in model.CompetencyRewards)
{
await _uow.MissionCompetencyRewards.AddAsync(new MissionCompetencyReward
{
MissionId = mission.Id,
CompetencyId = cr.CompetencyId,
LevelDelta = cr.LevelDelta,
ProgressPointsDelta = cr.ProgressPointsDelta
}, ct);
}
foreach (var artId in model.ArtifactRewardIds.Distinct())
{
await _uow.MissionArtifactRewards.AddAsync(new MissionArtifactReward
{
MissionId = mission.Id,
ArtifactId = artId
}, ct);
}
await _uow.SaveChangesAsync(ct);
await LogEventAsync(EventType.MissionStatusChanged, null, new { action = "created", missionId = mission.Id }, ct);
return mission;
}
public async Task<IEnumerable<Mission>> GetAvailableMissionsAsync(Guid userId, CancellationToken ct = default)
{
var user = await _uow.Users.Query(u => u.Id == userId, null, u => u.Rank).FirstOrDefaultAsync(ct)
?? throw new KeyNotFoundException("User not found");
var missions = await _uow.Missions.Query(m => m.IsActive, null, m => m.MinRank).ToListAsync(ct);
var userOrder = user.Rank?.Order ?? int.MinValue;
return missions.Where(m => m.MinRank == null || m.MinRank.Order <= userOrder + 1);
}
public async Task<UserMission> UpdateStatusAsync(Guid userId, Guid missionId, MissionStatus status, string? submissionData, CancellationToken ct = default)
{
var mission = await _uow.Missions.Query(m => m.Id == missionId).FirstOrDefaultAsync(ct)
?? throw new KeyNotFoundException("Mission not found");
var userMission = await _uow.UserMissions.FindAsync(userId, missionId);
if (userMission == null)
{
userMission = new UserMission
{
UserId = userId,
MissionId = missionId,
Status = MissionStatus.Available
};
await _uow.UserMissions.AddAsync(userMission, ct);
}
userMission.Status = status;
userMission.SubmissionData = submissionData;
userMission.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
await LogEventAsync(EventType.MissionStatusChanged, userId, new { missionId, status }, ct);
if (status == MissionStatus.Completed)
{
await _gamification.ApplyMissionCompletionAsync(userId, mission, ct);
await LogEventAsync(EventType.RewardGranted, userId, new { missionId, mission.ExperienceReward, mission.ManaReward }, ct);
}
return userMission;
}
private async Task LogEventAsync(EventType type, Guid? userId, object data, CancellationToken ct)
{
if (userId == null && type != EventType.MissionStatusChanged) return;
var evt = new EventLog
{
Type = type,
UserId = userId ?? Guid.Empty,
Data = JsonSerializer.Serialize(data)
};
await _uow.EventLogs.AddAsync(evt, ct);
await _uow.SaveChangesAsync(ct);
Log.Debug("Event {Type} logged {Data}", type, evt.Data);
}
}

View File

@@ -0,0 +1,99 @@
using LctMonolith.Domain.Entities;
namespace LctMonolith.Services.Models;
/// <summary>Returned access+refresh token pair with expiry metadata.</summary>
public record TokenPair(string AccessToken, DateTime AccessTokenExpiresAt, string RefreshToken, DateTime RefreshTokenExpiresAt);
/// <summary>Mission creation request model.</summary>
public class CreateMissionModel
{
public string Title { get; set; } = null!;
public string? Description { get; set; }
public string? Branch { get; set; }
public MissionCategory Category { get; set; }
public Guid? MinRankId { get; set; }
public int ExperienceReward { get; set; }
public int ManaReward { get; set; }
public List<CompetencyRewardModel> CompetencyRewards { get; set; } = new();
public List<Guid> ArtifactRewardIds { get; set; } = new();
}
/// <summary>Competency reward definition for mission creation.</summary>
public class CompetencyRewardModel
{
public Guid CompetencyId { get; set; }
public int LevelDelta { get; set; }
public int ProgressPointsDelta { get; set; }
}
/// <summary>Progress snapshot for UI: rank, xp, remaining requirements.</summary>
public class ProgressSnapshot
{
public int Experience { get; set; }
public int Mana { get; set; }
public Guid? CurrentRankId { get; set; }
public string? CurrentRankName { get; set; }
public Guid? NextRankId { get; set; }
public string? NextRankName { get; set; }
public int? RequiredExperienceForNextRank { get; set; }
public int? ExperienceRemaining => RequiredExperienceForNextRank.HasValue ? Math.Max(0, RequiredExperienceForNextRank.Value - Experience) : null;
public List<Guid> OutstandingMissionIds { get; set; } = new();
public List<OutstandingCompetency> OutstandingCompetencies { get; set; } = new();
}
/// <summary>Competency requirement still unmet for next rank.</summary>
public class OutstandingCompetency
{
public Guid CompetencyId { get; set; }
public string? CompetencyName { get; set; }
public int RequiredLevel { get; set; }
public int CurrentLevel { get; set; }
}
/// <summary>Request to update mission status with optional submission data.</summary>
public class UpdateMissionStatusRequest
{
public MissionStatus Status { get; set; }
public string? SubmissionData { get; set; }
}
/// <summary>Store purchase request.</summary>
public class PurchaseRequest
{
public Guid ItemId { get; set; }
public int Quantity { get; set; } = 1;
}
/// <summary>Authentication request (login/register) simple mock.</summary>
public class AuthRequest
{
public string Email { get; set; } = null!;
public string Password { get; set; } = null!;
public string? FirstName { get; set; }
public string? LastName { get; set; }
}
/// <summary>Refresh token request.</summary>
public class RefreshRequest
{
public string RefreshToken { get; set; } = null!;
}
/// <summary>Revoke refresh token request.</summary>
public class RevokeRequest
{
public string RefreshToken { get; set; } = null!;
}
/// <summary>Analytics summary for admin dashboard.</summary>
public class AnalyticsSummary
{
public int TotalUsers { get; set; }
public int TotalMissions { get; set; }
public int CompletedMissions { get; set; }
public int TotalArtifacts { get; set; }
public int TotalStoreItems { get; set; }
public long TotalExperience { get; set; }
public DateTime GeneratedAtUtc { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,75 @@
using LctMonolith.Domain.Entities;
using LctMonolith.Infrastructure.UnitOfWork;
using Microsoft.EntityFrameworkCore;
using Serilog;
using System.Text.Json;
namespace LctMonolith.Services;
/// <summary>
/// Store purchase operations and inventory management.
/// </summary>
public class StoreService : IStoreService
{
private readonly IUnitOfWork _uow;
public StoreService(IUnitOfWork uow)
{
_uow = uow;
}
public async Task<IEnumerable<StoreItem>> GetActiveItemsAsync(CancellationToken ct = default)
{
return await _uow.StoreItems.Query(i => i.IsActive).ToListAsync(ct);
}
public async Task<UserInventoryItem> PurchaseAsync(Guid userId, Guid itemId, int quantity, CancellationToken ct = default)
{
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
var user = await _uow.Users.Query(u => u.Id == userId).FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("User not found");
var item = await _uow.StoreItems.Query(i => i.Id == itemId && i.IsActive).FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("Item not found or inactive");
var totalPrice = item.Price * quantity;
if (user.Mana < totalPrice) throw new InvalidOperationException("Insufficient mana");
if (item.Stock.HasValue && item.Stock.Value < quantity) throw new InvalidOperationException("Insufficient stock");
user.Mana -= totalPrice;
if (item.Stock.HasValue) item.Stock -= quantity;
var inv = await _uow.UserInventoryItems.FindAsync(userId, itemId);
if (inv == null)
{
inv = new UserInventoryItem
{
UserId = userId,
StoreItemId = itemId,
Quantity = quantity,
AcquiredAt = DateTime.UtcNow
};
await _uow.UserInventoryItems.AddAsync(inv, ct);
}
else
{
inv.Quantity += quantity;
}
await _uow.Transactions.AddAsync(new Transaction
{
UserId = userId,
StoreItemId = itemId,
Type = TransactionType.Purchase,
ManaAmount = -totalPrice
}, ct);
await _uow.EventLogs.AddAsync(new EventLog
{
Type = EventType.ItemPurchased,
UserId = userId,
Data = JsonSerializer.Serialize(new { itemId, quantity, totalPrice })
}, ct);
await _uow.SaveChangesAsync(ct);
Log.Information("User {User} purchased {Qty} of {Item}", userId, quantity, itemId);
return inv;
}
}

View File

@@ -0,0 +1,98 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using LctMonolith.Application.Options;
using LctMonolith.Domain.Entities;
using LctMonolith.Infrastructure.UnitOfWork;
using LctMonolith.Services.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Serilog;
namespace LctMonolith.Services;
/// <summary>
/// Issues and refreshes JWT + refresh tokens.
/// </summary>
public class TokenService : ITokenService
{
private readonly IUnitOfWork _uow;
private readonly UserManager<AppUser> _userManager;
private readonly JwtOptions _options;
private readonly SigningCredentials _creds;
public TokenService(IUnitOfWork uow, UserManager<AppUser> userManager, IOptions<JwtOptions> options)
{
_uow = uow;
_userManager = userManager;
_options = options.Value;
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key));
_creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
}
public async Task<TokenPair> IssueAsync(AppUser user, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var accessExp = now.AddMinutes(_options.AccessTokenMinutes);
var refreshExp = now.AddDays(_options.RefreshTokenDays);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var jwt = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: now,
expires: accessExp,
signingCredentials: _creds);
var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt);
var refreshToken = GenerateSecureToken();
var rt = new RefreshToken
{
Token = refreshToken,
UserId = user.Id,
ExpiresAt = refreshExp
};
await _uow.RefreshTokens.AddAsync(rt, ct);
await _uow.SaveChangesAsync(ct);
return new TokenPair(accessToken, accessExp, refreshToken, refreshExp);
}
public async Task<TokenPair> RefreshAsync(string refreshToken, CancellationToken ct = default)
{
var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct);
if (token == null || token.IsRevoked || token.ExpiresAt < DateTime.UtcNow)
throw new SecurityTokenException("Invalid refresh token");
var user = await _userManager.FindByIdAsync(token.UserId.ToString()) ?? throw new SecurityTokenException("User not found");
token.IsRevoked = true; // rotate
await _uow.SaveChangesAsync(ct);
return await IssueAsync(user, ct);
}
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
{
var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct);
if (token == null) return; // idempotent
token.IsRevoked = true;
await _uow.SaveChangesAsync(ct);
}
private static string GenerateSecureToken()
{
Span<byte> bytes = stackalloc byte[64];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes);
}
}
internal static class EfAsyncExtensions
{
public static Task<T?> FirstOrDefaultAsync<T>(this IQueryable<T> query, CancellationToken ct = default) => Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.FirstOrDefaultAsync(query, ct);
}

View File

@@ -0,0 +1,35 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=lct2025_dev;Username=postgres;Password=postgres"
},
"Jwt": {
"Key": "Dev_Insecure_Key_Change_Me",
"Issuer": "LctMonolith",
"Audience": "LctMonolithAudience",
"AccessTokenMinutes": 120,
"RefreshTokenDays": 7
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Debug" ],
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"WriteTo": [
{ "Name": "Console" },
{ "Name": "Debug" },
{ "Name": "File", "Args": { "path": "Logs/dev-log-.txt", "rollingInterval": "Day", "shared": true } }
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ],
"Properties": { "Application": "LctMonolith" }
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,36 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=lct2025;Username=postgres;Password=postgres"
},
"Jwt": {
"Key": "Dev_Insecure_Key_Change_Me",
"Issuer": "LctMonolith",
"Audience": "LctMonolithAudience",
"AccessTokenMinutes": 60,
"RefreshTokenDays": 7
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Debug" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"WriteTo": [
{ "Name": "Console" },
{ "Name": "Debug" },
{ "Name": "File", "Args": { "path": "Logs/log-.txt", "rollingInterval": "Day", "shared": true } }
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ],
"Properties": { "Application": "LctMonolith" }
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}