feat random bullshit GO!
This commit is contained in:
@@ -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>();
|
||||
}
|
||||
14
LctMonolith/Application/Options/JwtOptions.cs
Normal file
14
LctMonolith/Application/Options/JwtOptions.cs
Normal 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;
|
||||
}
|
||||
|
||||
83
LctMonolith/Controllers/AuthController.cs
Normal file
83
LctMonolith/Controllers/AuthController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
33
LctMonolith/Controllers/GamificationController.cs
Normal file
33
LctMonolith/Controllers/GamificationController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
53
LctMonolith/Controllers/MissionsController.cs
Normal file
53
LctMonolith/Controllers/MissionsController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
57
LctMonolith/Controllers/NotificationsController.cs
Normal file
57
LctMonolith/Controllers/NotificationsController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
42
LctMonolith/Controllers/StoreController.cs
Normal file
42
LctMonolith/Controllers/StoreController.cs
Normal 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
23
LctMonolith/Dockerfile
Normal 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"]
|
||||
37
LctMonolith/Domain/Entities/AppUser.cs
Normal file
37
LctMonolith/Domain/Entities/AppUser.cs
Normal 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>();
|
||||
}
|
||||
95
LctMonolith/Domain/Entities/ArtifactsAndStore.cs
Normal file
95
LctMonolith/Domain/Entities/ArtifactsAndStore.cs
Normal 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;
|
||||
}
|
||||
|
||||
41
LctMonolith/Domain/Entities/Competency.cs
Normal file
41
LctMonolith/Domain/Entities/Competency.cs
Normal 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; }
|
||||
}
|
||||
|
||||
54
LctMonolith/Domain/Entities/Enums.cs
Normal file
54
LctMonolith/Domain/Entities/Enums.cs
Normal 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
|
||||
}
|
||||
|
||||
44
LctMonolith/Domain/Entities/Mission.cs
Normal file
44
LctMonolith/Domain/Entities/Mission.cs
Normal 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; }
|
||||
}
|
||||
|
||||
16
LctMonolith/Domain/Entities/Notification.cs
Normal file
16
LctMonolith/Domain/Entities/Notification.cs
Normal 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; }
|
||||
}
|
||||
40
LctMonolith/Domain/Entities/Rank.cs
Normal file
40
LctMonolith/Domain/Entities/Rank.cs
Normal 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; }
|
||||
}
|
||||
|
||||
95
LctMonolith/Infrastructure/Data/AppDbContext.cs
Normal file
95
LctMonolith/Infrastructure/Data/AppDbContext.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
48
LctMonolith/Infrastructure/Data/DbSeeder.cs
Normal file
48
LctMonolith/Infrastructure/Data/DbSeeder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
56
LctMonolith/Infrastructure/Repositories/GenericRepository.cs
Normal file
56
LctMonolith/Infrastructure/Repositories/GenericRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
34
LctMonolith/Infrastructure/UnitOfWork/IUnitOfWork.cs
Normal file
34
LctMonolith/Infrastructure/UnitOfWork/IUnitOfWork.cs
Normal 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);
|
||||
}
|
||||
99
LctMonolith/Infrastructure/UnitOfWork/UnitOfWork.cs
Normal file
99
LctMonolith/Infrastructure/UnitOfWork/UnitOfWork.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
42
LctMonolith/LctMonolith.csproj
Normal file
42
LctMonolith/LctMonolith.csproj
Normal 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>
|
||||
6
LctMonolith/LctMonolith.http
Normal file
6
LctMonolith/LctMonolith.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@LctMonolith_HostAddress = http://localhost:5217
|
||||
|
||||
GET {{LctMonolith_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
135
LctMonolith/Program.cs
Normal file
135
LctMonolith/Program.cs
Normal 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();
|
||||
23
LctMonolith/Properties/launchSettings.json
Normal file
23
LctMonolith/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
LctMonolith/Services/AnalyticsService.cs
Normal file
34
LctMonolith/Services/AnalyticsService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
161
LctMonolith/Services/GamificationService.cs
Normal file
161
LctMonolith/Services/GamificationService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
3
LctMonolith/Services/IWeatherService.cs
Normal file
3
LctMonolith/Services/IWeatherService.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Removed legacy Weather service placeholder.
|
||||
// Intentionally left blank.
|
||||
|
||||
58
LctMonolith/Services/Interfaces.cs
Normal file
58
LctMonolith/Services/Interfaces.cs
Normal 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);
|
||||
}
|
||||
35
LctMonolith/Services/InventoryService.cs
Normal file
35
LctMonolith/Services/InventoryService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
112
LctMonolith/Services/MissionService.cs
Normal file
112
LctMonolith/Services/MissionService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
99
LctMonolith/Services/Models/Models.cs
Normal file
99
LctMonolith/Services/Models/Models.cs
Normal 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;
|
||||
}
|
||||
1
LctMonolith/Services/NotificationService.cs
Normal file
1
LctMonolith/Services/NotificationService.cs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
75
LctMonolith/Services/StoreService.cs
Normal file
75
LctMonolith/Services/StoreService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
98
LctMonolith/Services/TokenService.cs
Normal file
98
LctMonolith/Services/TokenService.cs
Normal 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);
|
||||
}
|
||||
35
LctMonolith/appsettings.Development.json
Normal file
35
LctMonolith/appsettings.Development.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
LctMonolith/appsettings.json
Normal file
36
LctMonolith/appsettings.json
Normal 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": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user