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

25
.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

View File

@@ -1,6 +1,6 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StoreService", "StoreService\StoreService.csproj", "{5DC91A89-84D9-4C5E-9539-43757A2474D5}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LctMonolith", "LctMonolith\LctMonolith.csproj", "{EA37C010-2A5C-4B2E-A6B9-1E7EBB39D497}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -8,9 +8,9 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5DC91A89-84D9-4C5E-9539-43757A2474D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5DC91A89-84D9-4C5E-9539-43757A2474D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5DC91A89-84D9-4C5E-9539-43757A2474D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5DC91A89-84D9-4C5E-9539-43757A2474D5}.Release|Any CPU.Build.0 = Release|Any CPU
{EA37C010-2A5C-4B2E-A6B9-1E7EBB39D497}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EA37C010-2A5C-4B2E-A6B9-1E7EBB39D497}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EA37C010-2A5C-4B2E-A6B9-1E7EBB39D497}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EA37C010-2A5C-4B2E-A6B9-1E7EBB39D497}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AILogger_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6887107ccf514c9f8d699e80e2bd9cb91f000_003Fc5_003Ffedbb157_003FILogger_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>

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

@@ -4,39 +4,39 @@
<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" />
<!-- EF Core -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0" PrivateAssets="All" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<!-- Serilog -->
<!-- Serilog core and sinks -->
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
<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" />
<PackageReference Include="Serilog.Sinks.OpenSearch" Version="1.3.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<!-- Swagger -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<!-- Newtonsoft Json -->
<!-- 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>
<Folder Include="Database/Entities/" />
<Folder Include="Repositories/" />
<Folder Include="Middleware/" />
<Folder Include="Services/" />
<Folder Include="Models/" />
<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

@@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5141",
"applicationUrl": "http://localhost:5217",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -14,7 +14,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7238;http://localhost:5141",
"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": "*"
}

View File

@@ -1,76 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using StoreService.Models;
using StoreService.Services;
namespace StoreService.Controllers;
/// <summary>
/// Endpoints for order lifecycle: create, query, pay and redeem.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
#region Fields
private readonly IOrderService _orderService;
#endregion
#region Ctor
public OrdersController(IOrderService orderService)
{
_orderService = orderService;
}
#endregion
#region Endpoints
/// <summary>
/// Creates a new order for specified user and store items. Prices are calculated with active discounts.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status201Created)]
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request, CancellationToken ct)
{
var order = await _orderService.CreateOrderAsync(request, ct);
return CreatedAtAction(nameof(Get), new { id = order.Id }, order);
}
/// <summary>
/// Retrieves an order by id.
/// </summary>
[HttpGet("{id:long}")]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get([FromRoute] long id, CancellationToken ct)
{
var order = await _orderService.GetOrderAsync(id, ct);
return order == null ? NotFound() : Ok(order);
}
/// <summary>
/// Pays (confirms) an order. Sets the paid date. Idempotent except it fails if already paid.
/// </summary>
[HttpPost("{id:long}/pay")]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Pay([FromRoute] long id, CancellationToken ct)
{
var order = await _orderService.PayOrderAsync(id, ct);
return Ok(order);
}
/// <summary>
/// Marks items as redeemed (granted to user inventory) after payment.
/// </summary>
[HttpPost("{id:long}/redeem")]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Redeem([FromRoute] long id, CancellationToken ct)
{
var order = await _orderService.RedeemOrderItemsAsync(id, ct);
return Ok(order);
}
#endregion
}

View File

@@ -1,127 +0,0 @@
using Microsoft.EntityFrameworkCore;
using StoreService.Database.Entities;
namespace StoreService.Database;
/// <summary>
/// Entity Framework Core database context for the Store microservice.
/// Defines DbSets and configures entity relationships & constraints.
/// </summary>
public class ApplicationContext : DbContext
{
#region Ctor
public ApplicationContext(DbContextOptions<ApplicationContext> options) : base(options)
{
}
#endregion
#region DbSets
public DbSet<StoreCategory> StoreCategories => Set<StoreCategory>();
public DbSet<StoreItem> StoreItems => Set<StoreItem>();
public DbSet<StoreDiscount> StoreDiscounts => Set<StoreDiscount>();
public DbSet<StoreDiscountItem> StoreDiscountItems => Set<StoreDiscountItem>();
public DbSet<StoreOrder> StoreOrders => Set<StoreOrder>();
public DbSet<StoreOrderItem> StoreOrderItems => Set<StoreOrderItem>();
public DbSet<StoreOrderItemDiscount> StoreOrderItemDiscounts => Set<StoreOrderItemDiscount>();
#endregion
#region ModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// store_category
modelBuilder.Entity<StoreCategory>(b =>
{
b.ToTable("store_category");
b.HasKey(x => x.Id);
b.Property(x => x.Title).HasMaxLength(256).IsRequired();
});
// store_item
modelBuilder.Entity<StoreItem>(b =>
{
b.ToTable("store_item");
b.HasKey(x => x.Id);
b.Property(x => x.ManaBuyPrice).IsRequired();
b.Property(x => x.ManaSellPrice).IsRequired();
b.Property(x => x.InventoryLimit).IsRequired();
b.Property(x => x.UnlimitedPurchase).HasDefaultValue(false);
b.HasOne(x => x.Category)
.WithMany(c => c.Items)
.HasForeignKey(x => x.StoreCategoryId)
.OnDelete(DeleteBehavior.Restrict);
});
// store_discount
modelBuilder.Entity<StoreDiscount>(b =>
{
b.ToTable("store_discount");
b.HasKey(x => x.Id);
b.Property(x => x.Percentage).HasPrecision(5, 2); // up to 100.00
b.Property(x => x.FromDate).IsRequired();
b.Property(x => x.UntilDate).IsRequired();
b.Property(x => x.IsCanceled).HasDefaultValue(false);
});
// store_discount_item (many-to-many manual mapping)
modelBuilder.Entity<StoreDiscountItem>(b =>
{
b.ToTable("store_discount_item");
b.HasKey(x => x.Id);
b.HasOne(x => x.Discount)
.WithMany(d => d.DiscountItems)
.HasForeignKey(x => x.StoreDiscountId)
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.StoreItem)
.WithMany(i => i.DiscountItems)
.HasForeignKey(x => x.StoreItemId)
.OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.StoreDiscountId, x.StoreItemId }).IsUnique();
});
// store_order
modelBuilder.Entity<StoreOrder>(b =>
{
b.ToTable("store_order");
b.HasKey(x => x.Id);
b.Property(x => x.UserId).IsRequired();
b.Property(x => x.CostUpdateDate).IsRequired();
b.Property(x => x.ItemsRedeemed).HasDefaultValue(false);
});
// store_order_item
modelBuilder.Entity<StoreOrderItem>(b =>
{
b.ToTable("store_order_item");
b.HasKey(x => x.Id);
b.Property(x => x.CalculatedPrice).IsRequired();
b.HasOne(x => x.Order)
.WithMany(o => o.OrderItems)
.HasForeignKey(x => x.StoreOrderId)
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.StoreItem)
.WithMany(i => i.OrderItems)
.HasForeignKey(x => x.StoreItemId)
.OnDelete(DeleteBehavior.Restrict);
});
// store_order_item_discount
modelBuilder.Entity<StoreOrderItemDiscount>(b =>
{
b.ToTable("store_order_item_discount");
b.HasKey(x => x.Id);
b.HasOne(x => x.OrderItem)
.WithMany(oi => oi.AppliedDiscounts)
.HasForeignKey(x => x.StoreOrderItemId)
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.Discount)
.WithMany(d => d.OrderItemDiscounts)
.HasForeignKey(x => x.StoreDiscountId)
.OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => new { x.StoreOrderItemId, x.StoreDiscountId }).IsUnique();
});
}
#endregion
}

View File

@@ -1,17 +0,0 @@
namespace StoreService.Database.Entities;
/// <summary>
/// Category grouping store items.
/// </summary>
public class StoreCategory
{
#region Properties
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
#endregion
#region Navigation
public ICollection<StoreItem> Items { get; set; } = new List<StoreItem>();
#endregion
}

View File

@@ -1,36 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace StoreService.Database.Entities;
/// <summary>
/// Percentage discount that can apply to one or more store items within a time window.
/// </summary>
public class StoreDiscount
{
#region Properties
public long Id { get; set; }
public decimal Percentage { get; set; } // 0 - 100 (%)
public string? Description { get; set; }
public DateTime FromDate { get; set; }
public DateTime UntilDate { get; set; }
public bool IsCanceled { get; set; }
#endregion
#region Navigation
public ICollection<StoreDiscountItem> DiscountItems { get; set; } = new List<StoreDiscountItem>();
public ICollection<StoreOrderItemDiscount> OrderItemDiscounts { get; set; } = new List<StoreOrderItemDiscount>();
#endregion
#region Helpers
/// <summary>
/// Checks whether discount is active at provided moment (default: now) ignoring cancellation flag (if canceled returns false).
/// </summary>
public bool IsActive(DateTime? at = null)
{
if (IsCanceled) return false;
var moment = at ?? DateTime.UtcNow;
return moment >= FromDate && moment <= UntilDate;
}
#endregion
}

View File

@@ -1,19 +0,0 @@
namespace StoreService.Database.Entities;
/// <summary>
/// Join entity linking discounts to store items.
/// </summary>
public class StoreDiscountItem
{
#region Properties
public long Id { get; set; }
public long StoreDiscountId { get; set; }
public long StoreItemId { get; set; }
#endregion
#region Navigation
public StoreDiscount? Discount { get; set; }
public StoreItem? StoreItem { get; set; }
#endregion
}

View File

@@ -1,25 +0,0 @@
namespace StoreService.Database.Entities;
/// <summary>
/// Store item with pricing and purchase rules.
/// </summary>
public class StoreItem
{
#region Properties
public long Id { get; set; }
public long ItemId { get; set; } // FK to external Item service (not modeled here)
public long StoreCategoryId { get; set; } // FK to StoreCategory
public long? RankId { get; set; } // Minimum rank required to buy (external system)
public int ManaBuyPrice { get; set; }
public int ManaSellPrice { get; set; }
public bool UnlimitedPurchase { get; set; }
public int InventoryLimit { get; set; }
#endregion
#region Navigation
public StoreCategory? Category { get; set; }
public ICollection<StoreDiscountItem> DiscountItems { get; set; } = new List<StoreDiscountItem>();
public ICollection<StoreOrderItem> OrderItems { get; set; } = new List<StoreOrderItem>();
#endregion
}

View File

@@ -1,20 +0,0 @@
namespace StoreService.Database.Entities;
/// <summary>
/// Represents a purchase order created by a user.
/// </summary>
public class StoreOrder
{
#region Properties
public long Id { get; set; }
public long UserId { get; set; }
public DateTime CostUpdateDate { get; set; } = DateTime.UtcNow; // updated when prices recalculated
public DateTime? PaidDate { get; set; } // when payment succeeded
public bool ItemsRedeemed { get; set; } // becomes true once items granted to inventory
#endregion
#region Navigation
public ICollection<StoreOrderItem> OrderItems { get; set; } = new List<StoreOrderItem>();
#endregion
}

View File

@@ -1,21 +0,0 @@
namespace StoreService.Database.Entities;
/// <summary>
/// Item line inside an order with captured calculated price for history.
/// </summary>
public class StoreOrderItem
{
#region Properties
public long Id { get; set; }
public long StoreOrderId { get; set; }
public long StoreItemId { get; set; }
public int CalculatedPrice { get; set; }
#endregion
#region Navigation
public StoreOrder? Order { get; set; }
public StoreItem? StoreItem { get; set; }
public ICollection<StoreOrderItemDiscount> AppliedDiscounts { get; set; } = new List<StoreOrderItemDiscount>();
#endregion
}

View File

@@ -1,19 +0,0 @@
namespace StoreService.Database.Entities;
/// <summary>
/// Captures which discounts were applied to order items at purchase time.
/// </summary>
public class StoreOrderItemDiscount
{
#region Properties
public long Id { get; set; }
public long StoreOrderItemId { get; set; }
public long? StoreDiscountId { get; set; } // can be null if discount later removed but kept for history
#endregion
#region Navigation
public StoreOrderItem? OrderItem { get; set; }
public StoreDiscount? Discount { get; set; }
#endregion
}

View File

@@ -1,21 +0,0 @@
using Microsoft.EntityFrameworkCore;
using StoreService.Database;
namespace StoreService.Extensions;
/// <summary>
/// Database related DI registrations.
/// </summary>
public static class DatabaseExtensions
{
/// <summary>
/// Registers the EF Core DbContext (PostgreSQL).
/// </summary>
public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<ApplicationContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("StoreDb")));
return services;
}
}

View File

@@ -1,16 +0,0 @@
using StoreService.Services;
namespace StoreService.Extensions;
/// <summary>
/// Domain service layer DI registrations.
/// </summary>
public static class DomainServicesExtensions
{
public static IServiceCollection AddDomainServices(this IServiceCollection services)
{
services.AddScoped<IOrderService, OrderService>();
return services;
}
}

View File

@@ -1,24 +0,0 @@
using Serilog;
namespace StoreService.Extensions;
/// <summary>
/// Logging related extensions (Serilog configuration).
/// </summary>
public static class LoggingExtensions
{
/// <summary>
/// Adds Serilog configuration for the host using appsettings.json (Serilog section).
/// </summary>
public static IHostBuilder AddSerilogLogging(this IHostBuilder hostBuilder)
{
hostBuilder.UseSerilog((ctx, services, cfg) =>
{
cfg.ReadFrom.Configuration(ctx.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext();
});
return hostBuilder;
}
}

View File

@@ -1,18 +0,0 @@
using StoreService.Repositories;
namespace StoreService.Extensions;
/// <summary>
/// Repository & UnitOfWork registrations.
/// </summary>
public static class RepositoryExtensions
{
public static IServiceCollection AddRepositories(this IServiceCollection services)
{
// Open generic registration for repositories
services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>));
// Unit of work (will receive repositories via constructor injection)
services.AddScoped<IUnitOfWork, UnitOfWork>();
return services;
}
}

View File

@@ -1,79 +0,0 @@
using System.Net;
using System.Text.Json;
using System.ComponentModel.DataAnnotations;
namespace StoreService.Middleware;
/// <summary>
/// Global error handling middleware capturing unhandled exceptions and returning structured JSON errors.
/// </summary>
public class ErrorHandlingMiddleware
{
#region Fields
private readonly RequestDelegate _next;
private readonly ILogger _logger;
#endregion
#region Ctor
public ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware > logger)
{
_next = next;
_logger = logger;
}
#endregion
#region Invoke
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
#endregion
#region Helpers
private async Task HandleExceptionAsync(HttpContext context, Exception ex)
{
var status = ex switch
{
ValidationException => (int)HttpStatusCode.BadRequest,
ArgumentException => (int)HttpStatusCode.BadRequest,
KeyNotFoundException => (int)HttpStatusCode.NotFound,
InvalidOperationException => StatusCodes.Status409Conflict,
_ => (int)HttpStatusCode.InternalServerError
};
var errorId = Guid.NewGuid().ToString();
if (status >= 500)
{
_logger.LogError(ex, "Unhandled server exception {ErrorId}", errorId);
}
else
{
_logger.LogWarning(ex, "Handled domain exception {ErrorId} -> {Status}", errorId, status);
}
var env = context.RequestServices.GetRequiredService<IHostEnvironment>();
var problem = new
{
traceId = context.TraceIdentifier,
errorId,
status,
message = ex.Message,
details = env.IsDevelopment() ? ex.StackTrace : null
};
context.Response.ContentType = "application/json";
context.Response.StatusCode = status;
await context.Response.WriteAsync(JsonSerializer.Serialize(problem));
}
#endregion
}

View File

@@ -1,19 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace StoreService.Models;
/// <summary>
/// Request body to create a new order consisting of store item identifiers.
/// </summary>
public class CreateOrderRequest
{
/// <summary>Identifier of the user creating the order.</summary>
[Required]
public long UserId { get; set; }
/// <summary>Collection of store item ids to include (unique). Duplicates are ignored.</summary>
[Required]
[MinLength(1)]
public List<long> StoreItemIds { get; set; } = new();
}

View File

@@ -1,15 +0,0 @@
namespace StoreService.Models;
/// <summary>
/// Result DTO representing an order summary.
/// </summary>
public class OrderDto
{
public long Id { get; set; }
public long UserId { get; set; }
public DateTime CostUpdateDate { get; set; }
public DateTime? PaidDate { get; set; }
public bool ItemsRedeemed { get; set; }
public List<OrderItemDto> Items { get; set; } = new();
}

View File

@@ -1,13 +0,0 @@
namespace StoreService.Models;
/// <summary>
/// Order line DTO.
/// </summary>
public class OrderItemDto
{
public long Id { get; set; }
public long StoreItemId { get; set; }
public int CalculatedPrice { get; set; }
public List<long> AppliedDiscountIds { get; set; } = new();
}

View File

@@ -1,3 +0,0 @@
// Legacy aggregated models file kept intentionally empty after splitting into individual files.
// CreateOrderRequest, OrderDto, and OrderItemDto now reside in separate files.

View File

@@ -1,63 +0,0 @@
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Serilog;
using StoreService.Database;
using StoreService.Middleware;
using StoreService.Repositories;
using StoreService.Services;
using StoreService.Extensions; // added for DI extension
using Newtonsoft.Json; // added
var builder = WebApplication.CreateBuilder(args);
#region Serilog
// Use new logging extension
builder.Host.AddSerilogLogging();
#endregion
#region Services
builder.Services
.AddControllers()
.AddNewtonsoftJson(options =>
{
options.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore;
options.SerializerSettings.Formatting = Formatting.None;
options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename);
if (File.Exists(xmlPath))
{
options.IncludeXmlComments(xmlPath, includeControllerXmlComments: true);
}
});
// Granular DI registration chain
builder.Services
.AddDatabase(builder.Configuration)
.AddRepositories()
.AddDomainServices();
#endregion
var app = builder.Build();
#region Middleware
app.UseSerilogRequestLogging();
app.UseGlobalErrorHandling();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
#endregion
app.Run();

View File

@@ -1,79 +0,0 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using StoreService.Database;
namespace StoreService.Repositories;
/// <summary>
/// Generic repository implementation wrapping EF Core DbSet.
/// </summary>
/// <typeparam name="TEntity">Entity type.</typeparam>
public class GenericRepository<TEntity> : IGenericRepository<TEntity> where TEntity : class
{
#region Fields
private readonly ApplicationContext _context;
private readonly DbSet<TEntity> _dbSet;
#endregion
#region Ctor
public GenericRepository(ApplicationContext context)
{
_context = context;
_dbSet = context.Set<TEntity>();
}
#endregion
#region Query
public virtual IQueryable<TEntity> Get(
Expression<Func<TEntity, bool>>? filter = null,
Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>? orderBy = null,
params Expression<Func<TEntity, object>>[] includes)
{
IQueryable<TEntity> query = _dbSet;
if (filter != null)
query = query.Where(filter);
foreach (var include in includes)
query = query.Include(include);
return orderBy != null ? orderBy(query) : query;
}
public virtual async Task<TEntity?> FirstOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, params Expression<Func<TEntity, object>>[] includes)
{
IQueryable<TEntity> query = _dbSet;
foreach (var include in includes)
query = query.Include(include);
return await query.FirstOrDefaultAsync(predicate);
}
public virtual TEntity? GetById(object id) => _dbSet.Find(id);
public virtual async Task<TEntity?> GetByIdAsync(object id) => await _dbSet.FindAsync(id);
#endregion
#region Mutations
public virtual void Add(TEntity entity) => _dbSet.Add(entity);
public virtual async Task AddAsync(TEntity entity) => await _dbSet.AddAsync(entity);
public virtual void AddRange(IEnumerable<TEntity> entities) => _dbSet.AddRange(entities);
public virtual void Update(TEntity entity) => _dbSet.Update(entity);
public virtual void Delete(object id)
{
var entity = _dbSet.Find(id) ?? throw new KeyNotFoundException($"Entity {typeof(TEntity).Name} with id '{id}' not found");
Delete(entity);
}
public virtual void Delete(TEntity entity)
{
if (_context.Entry(entity).State == EntityState.Detached)
_dbSet.Attach(entity);
_dbSet.Remove(entity);
}
public virtual void DeleteRange(IEnumerable<TEntity> entities) => _dbSet.RemoveRange(entities);
#endregion
}

View File

@@ -1,36 +0,0 @@
using System.Linq.Expressions;
namespace StoreService.Repositories;
/// <summary>
/// Generic repository abstraction for simple CRUD & query operations.
/// </summary>
/// <typeparam name="TEntity">Entity type.</typeparam>
public interface IGenericRepository<TEntity> where TEntity : class
{
#region Query
IQueryable<TEntity> Get(
Expression<Func<TEntity, bool>>? filter = null,
Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>? orderBy = null,
params Expression<Func<TEntity, object>>[] includes);
Task<TEntity?> FirstOrDefaultAsync(Expression<Func<TEntity, bool>> predicate,
params Expression<Func<TEntity, object>>[] includes);
TEntity? GetById(object id);
Task<TEntity?> GetByIdAsync(object id);
#endregion
#region Mutations
void Add(TEntity entity);
Task AddAsync(TEntity entity);
void AddRange(IEnumerable<TEntity> entities);
void Update(TEntity entity);
void Delete(object id);
void Delete(TEntity entity);
void DeleteRange(IEnumerable<TEntity> entities);
#endregion
}

View File

@@ -1,32 +0,0 @@
using Microsoft.EntityFrameworkCore.Storage;
using StoreService.Database.Entities;
namespace StoreService.Repositories;
/// <summary>
/// Unit of work pattern abstraction encapsulating repositories and transactions.
/// </summary>
public interface IUnitOfWork : IAsyncDisposable, IDisposable
{
#region Repositories
IGenericRepository<StoreCategory> StoreCategories { get; }
IGenericRepository<StoreItem> StoreItems { get; }
IGenericRepository<StoreDiscount> StoreDiscounts { get; }
IGenericRepository<StoreDiscountItem> StoreDiscountItems { get; }
IGenericRepository<StoreOrder> StoreOrders { get; }
IGenericRepository<StoreOrderItem> StoreOrderItems { get; }
IGenericRepository<StoreOrderItemDiscount> StoreOrderItemDiscounts { get; }
#endregion
#region Save
bool SaveChanges();
Task<bool> SaveChangesAsync(CancellationToken ct = default);
#endregion
#region Transactions
Task BeginTransactionAsync(CancellationToken ct = default);
Task CommitTransactionAsync(CancellationToken ct = default);
Task RollbackTransactionAsync(CancellationToken ct = default);
#endregion
}

View File

@@ -1,99 +0,0 @@
using Microsoft.EntityFrameworkCore.Storage;
using StoreService.Database;
using StoreService.Database.Entities;
namespace StoreService.Repositories;
/// <summary>
/// Coordinates repository access and database transactions.
/// </summary>
public class UnitOfWork : IUnitOfWork
{
#region Fields
private readonly ApplicationContext _context;
private IDbContextTransaction? _transaction;
#endregion
#region Ctor
public UnitOfWork(
ApplicationContext context,
IGenericRepository<StoreCategory> storeCategories,
IGenericRepository<StoreItem> storeItems,
IGenericRepository<StoreDiscount> storeDiscounts,
IGenericRepository<StoreDiscountItem> storeDiscountItems,
IGenericRepository<StoreOrder> storeOrders,
IGenericRepository<StoreOrderItem> storeOrderItems,
IGenericRepository<StoreOrderItemDiscount> storeOrderItemDiscounts)
{
_context = context;
StoreCategories = storeCategories;
StoreItems = storeItems;
StoreDiscounts = storeDiscounts;
StoreDiscountItems = storeDiscountItems;
StoreOrders = storeOrders;
StoreOrderItems = storeOrderItems;
StoreOrderItemDiscounts = storeOrderItemDiscounts;
}
#endregion
#region Repositories
public IGenericRepository<StoreCategory> StoreCategories { get; }
public IGenericRepository<StoreItem> StoreItems { get; }
public IGenericRepository<StoreDiscount> StoreDiscounts { get; }
public IGenericRepository<StoreDiscountItem> StoreDiscountItems { get; }
public IGenericRepository<StoreOrder> StoreOrders { get; }
public IGenericRepository<StoreOrderItem> StoreOrderItems { get; }
public IGenericRepository<StoreOrderItemDiscount> StoreOrderItemDiscounts { get; }
#endregion
#region Save
public bool SaveChanges() => _context.SaveChanges() > 0;
public async Task<bool> SaveChangesAsync(CancellationToken ct = default) => (await _context.SaveChangesAsync(ct)) > 0;
#endregion
#region Transactions
public async Task BeginTransactionAsync(CancellationToken ct = default)
{
if (_transaction != null) throw new InvalidOperationException("Transaction already started");
_transaction = await _context.Database.BeginTransactionAsync(ct);
}
public async Task CommitTransactionAsync(CancellationToken ct = default)
{
if (_transaction == null) throw new InvalidOperationException("No transaction started");
try
{
await _transaction.CommitAsync(ct);
}
catch
{
await RollbackTransactionAsync(ct);
throw;
}
finally
{
await _transaction.DisposeAsync();
_transaction = null;
}
}
public async Task RollbackTransactionAsync(CancellationToken ct = default)
{
if (_transaction == null) return;
await _transaction.RollbackAsync(ct);
await _transaction.DisposeAsync();
_transaction = null;
}
#endregion
#region Dispose
public void Dispose() => _transaction?.Dispose();
public async ValueTask DisposeAsync()
{
if (_transaction != null)
await _transaction.DisposeAsync();
}
#endregion
}

View File

@@ -1,17 +0,0 @@
using StoreService.Models;
namespace StoreService.Services;
/// <summary>
/// Service responsible for order lifecycle (create, pay, redeem) including price calculation.
/// </summary>
public interface IOrderService
{
#region Methods
Task<OrderDto> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct = default);
Task<OrderDto?> GetOrderAsync(long id, CancellationToken ct = default);
Task<OrderDto> PayOrderAsync(long id, CancellationToken ct = default);
Task<OrderDto> RedeemOrderItemsAsync(long id, CancellationToken ct = default);
#endregion
}

View File

@@ -1,172 +0,0 @@
using Microsoft.EntityFrameworkCore;
using StoreService.Database.Entities;
using StoreService.Models;
using StoreService.Repositories;
namespace StoreService.Services;
/// <summary>
/// Implements order creation, payment, redemption and price calculation logic.
/// </summary>
public class OrderService : IOrderService
{
#region Fields
private readonly IUnitOfWork _uow;
private readonly ILogger<OrderService> _logger;
#endregion
#region Ctor
public OrderService(IUnitOfWork uow, ILogger<OrderService> logger)
{
_uow = uow;
_logger = logger;
}
#endregion
#region Public Methods
/// <inheritdoc/>
public async Task<OrderDto> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct = default)
{
if (request.StoreItemIds.Count == 0)
throw new ArgumentException("No store items specified", nameof(request.StoreItemIds));
// Ensure uniqueness
var uniqueIds = request.StoreItemIds.Distinct().ToList();
// Load items with potential discount items and discounts for calculation.
var items = await _uow.StoreItems
.Get(i => uniqueIds.Contains(i.Id), includes: i => i.DiscountItems)
.Include(i => i.DiscountItems) // ensure collection loaded
.ToListAsync(ct);
if (items.Count != uniqueIds.Count)
{
var missing = uniqueIds.Except(items.Select(i => i.Id)).ToArray();
throw new KeyNotFoundException($"Store items not found: {string.Join(", ", missing)}");
}
// Preload discounts referenced by items for efficient calculation.
var discountIds = items.SelectMany(i => i.DiscountItems.Select(di => di.StoreDiscountId)).Distinct().ToList();
var discounts = await _uow.StoreDiscounts
.Get(d => discountIds.Contains(d.Id))
.ToListAsync(ct);
var now = DateTime.UtcNow;
var order = new StoreOrder
{
UserId = request.UserId,
CostUpdateDate = now,
OrderItems = new List<StoreOrderItem>()
};
foreach (var item in items)
{
var applicableDiscounts = discounts
.Where(d => item.DiscountItems.Any(di => di.StoreDiscountId == d.Id) && d.IsActive(now))
.ToList();
var calculatedPrice = CalculatePrice(item.ManaBuyPrice, applicableDiscounts);
var orderItem = new StoreOrderItem
{
StoreItemId = item.Id,
CalculatedPrice = calculatedPrice,
AppliedDiscounts = applicableDiscounts.Select(d => new StoreOrderItemDiscount
{
StoreDiscountId = d.Id
}).ToList()
};
order.OrderItems.Add(orderItem);
}
await _uow.StoreOrders.AddAsync(order);
await _uow.SaveChangesAsync(ct);
_logger.LogInformation("Created order {OrderId} for user {UserId}", order.Id, order.UserId);
return MapOrder(order);
}
/// <inheritdoc/>
public async Task<OrderDto?> GetOrderAsync(long id, CancellationToken ct = default)
{
var order = await _uow.StoreOrders
.Get(o => o.Id == id, includes: o => o.OrderItems)
.Include(o => o.OrderItems)
.ThenInclude(oi => oi.AppliedDiscounts)
.FirstOrDefaultAsync(ct);
return order == null ? null : MapOrder(order);
}
/// <inheritdoc/>
public async Task<OrderDto> PayOrderAsync(long id, CancellationToken ct = default)
{
var order = await _uow.StoreOrders
.Get(o => o.Id == id, includes: o => o.OrderItems)
.FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException($"Order {id} not found");
if (order.PaidDate != null)
throw new InvalidOperationException("Order already paid");
order.PaidDate = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
_logger.Information("Order {OrderId} paid", id);
return await GetOrderRequiredAsync(id, ct);
}
/// <inheritdoc/>
public async Task<OrderDto> RedeemOrderItemsAsync(long id, CancellationToken ct = default)
{
var order = await _uow.StoreOrders.Get(o => o.Id == id).FirstOrDefaultAsync(ct) ??
throw new KeyNotFoundException($"Order {id} not found");
if (order.PaidDate == null)
throw new InvalidOperationException("Order not paid yet");
if (order.ItemsRedeemed)
throw new InvalidOperationException("Order items already redeemed");
// TODO: integrate with external inventory service. For now we just toggle flag.
order.ItemsRedeemed = true;
await _uow.SaveChangesAsync(ct);
_logger.Information("Order {OrderId} items redeemed", id);
return await GetOrderRequiredAsync(id, ct);
}
#endregion
#region Helpers
private int CalculatePrice(int basePrice, IEnumerable<StoreDiscount> discounts)
{
// Assumption: sum discount percentages, cap at 90% to avoid free unless explicitly 100%. Documented decision.
var totalPercent = discounts.Sum(d => d.Percentage);
if (totalPercent > 100m) totalPercent = 100m; // absolute cap
var discounted = basePrice - (int)Math.Floor(basePrice * (decimal)totalPercent / 100m);
return Math.Max(0, discounted);
}
private async Task<OrderDto> GetOrderRequiredAsync(long id, CancellationToken ct)
{
var dto = await GetOrderAsync(id, ct);
if (dto == null) throw new KeyNotFoundException($"Order {id} not found after update");
return dto;
}
private static OrderDto MapOrder(StoreOrder order) => new()
{
Id = order.Id,
UserId = order.UserId,
CostUpdateDate = order.CostUpdateDate,
PaidDate = order.PaidDate,
ItemsRedeemed = order.ItemsRedeemed,
Items = order.OrderItems.Select(oi => new OrderItemDto
{
Id = oi.Id,
StoreItemId = oi.StoreItemId,
CalculatedPrice = oi.CalculatedPrice,
AppliedDiscountIds = oi.AppliedDiscounts.Select(ad => ad.StoreDiscountId ?? 0).ToList()
}).ToList()
};
#endregion
}

View File

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

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -1,47 +0,0 @@
{
"ConnectionStrings": {
"StoreDb": "Host=localhost;Port=5432;Database=store_db;Username=store_user;Password=store_password"
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Debug", "Serilog.Sinks.OpenSearch" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
},
"Enrich": [ "FromLogContext", "WithThreadId", "WithEnvironmentName", "WithProcessId" ],
"WriteTo": [
{ "Name": "Console" },
{ "Name": "Debug" },
{
"Name": "File",
"Args": {
"path": "Logs/log-.txt",
"rollingInterval": "Day",
"retainedFileCountLimit": 14,
"shared": true
}
},
{
"Name": "OpenSearch",
"Args": {
"nodeUris": "http://localhost:9200",
"indexFormat": "store-service-logs-{0:yyyy.MM.dd}",
"autoRegisterTemplate": true,
"numberOfShards": 1,
"numberOfReplicas": 0
}
}
]
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -1,4 +0,0 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0")]

View File

@@ -1,22 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("StoreService")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("StoreService")]
[assembly: System.Reflection.AssemblyTitleAttribute("StoreService")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// Generated by the MSBuild WriteCodeFragment class.

View File

@@ -1,17 +0,0 @@
// <auto-generated/>
global using global::Microsoft.AspNetCore.Builder;
global using global::Microsoft.AspNetCore.Hosting;
global using global::Microsoft.AspNetCore.Http;
global using global::Microsoft.AspNetCore.Routing;
global using global::Microsoft.Extensions.Configuration;
global using global::Microsoft.Extensions.DependencyInjection;
global using global::Microsoft.Extensions.Hosting;
global using global::Microsoft.Extensions.Logging;
global using global::System;
global using global::System.Collections.Generic;
global using global::System.IO;
global using global::System.Linq;
global using global::System.Net.Http;
global using global::System.Net.Http.Json;
global using global::System.Threading;
global using global::System.Threading.Tasks;