diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..38bece4 --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/LCT2025.sln b/LCT2025.sln index 2bfd4ed..ee320c6 100644 --- a/LCT2025.sln +++ b/LCT2025.sln @@ -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 diff --git a/LCT2025.sln.DotSettings.user b/LCT2025.sln.DotSettings.user new file mode 100644 index 0000000..6652580 --- /dev/null +++ b/LCT2025.sln.DotSettings.user @@ -0,0 +1,2 @@ + + ForceIncluded \ No newline at end of file diff --git a/LctMonolith/Application/Middleware/ErrorHandlingMiddleware.cs b/LctMonolith/Application/Middleware/ErrorHandlingMiddleware.cs new file mode 100644 index 0000000..fa30be0 --- /dev/null +++ b/LctMonolith/Application/Middleware/ErrorHandlingMiddleware.cs @@ -0,0 +1,46 @@ +using System.Net; +using System.Text.Json; +using Serilog; + +namespace LctMonolith.Application.Middleware; + +/// +/// Global error handling middleware capturing unhandled exceptions and converting to standardized JSON response. +/// +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 +{ + /// Adds global error handling middleware. + public static IApplicationBuilder UseErrorHandling(this IApplicationBuilder app) => app.UseMiddleware(); +} diff --git a/LctMonolith/Application/Options/JwtOptions.cs b/LctMonolith/Application/Options/JwtOptions.cs new file mode 100644 index 0000000..a44b98c --- /dev/null +++ b/LctMonolith/Application/Options/JwtOptions.cs @@ -0,0 +1,14 @@ +namespace LctMonolith.Application.Options; + +/// +/// JWT issuing configuration loaded from appsettings (section Jwt). +/// +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; +} + diff --git a/LctMonolith/Controllers/AuthController.cs b/LctMonolith/Controllers/AuthController.cs new file mode 100644 index 0000000..b9acfef --- /dev/null +++ b/LctMonolith/Controllers/AuthController.cs @@ -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; + +/// +/// Authentication endpoints (mocked local identity + JWT issuing). +/// +[ApiController] +[Route("api/auth")] +public class AuthController : ControllerBase +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ITokenService _tokenService; + + public AuthController(UserManager userManager, SignInManager signInManager, ITokenService tokenService) + { + _userManager = userManager; + _signInManager = signInManager; + _tokenService = tokenService; + } + + /// Registers a new user (simplified). + [HttpPost("register")] + [AllowAnonymous] + public async Task> 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); + } + + /// Login with email + password. + [HttpPost("login")] + [AllowAnonymous] + public async Task> 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); + } + + /// Refresh access token by refresh token. + [HttpPost("refresh")] + [AllowAnonymous] + public async Task> Refresh(RefreshRequest req, CancellationToken ct) + { + var pair = await _tokenService.RefreshAsync(req.RefreshToken, ct); + return Ok(pair); + } + + /// Revoke refresh token (logout). + [HttpPost("revoke")] + [Authorize] + public async Task Revoke(RevokeRequest req, CancellationToken ct) + { + await _tokenService.RevokeAsync(req.RefreshToken, ct); + return NoContent(); + } + + /// Returns current user id (debug). + [HttpGet("me")] + [Authorize] + public ActionResult Me() + { + var id = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name); + return Ok(new { userId = id }); + } +} + diff --git a/LctMonolith/Controllers/GamificationController.cs b/LctMonolith/Controllers/GamificationController.cs new file mode 100644 index 0000000..4908305 --- /dev/null +++ b/LctMonolith/Controllers/GamificationController.cs @@ -0,0 +1,33 @@ +using System.Security.Claims; +using LctMonolith.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LctMonolith.Controllers; + +/// +/// Endpoints exposing gamification progress information. +/// +[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)!); + + /// Returns current user progress snapshot (rank, xp, outstanding requirements). + [HttpGet("progress")] + public async Task GetProgress(CancellationToken ct) + { + var snapshot = await _gamificationService.GetProgressAsync(GetUserId(), ct); + return Ok(snapshot); + } +} + diff --git a/LctMonolith/Controllers/MissionsController.cs b/LctMonolith/Controllers/MissionsController.cs new file mode 100644 index 0000000..bc315db --- /dev/null +++ b/LctMonolith/Controllers/MissionsController.cs @@ -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; + +/// +/// Endpoints for listing and managing missions. +/// +[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)!); + + /// Returns missions currently available to the authenticated user. + [HttpGet] + public async Task>> GetAvailable(CancellationToken ct) + { + var userId = GetUserId(); + var list = await _missionService.GetAvailableMissionsAsync(userId, ct); + return Ok(list); + } + + /// Create a mission (HR functionality – for now any authenticated user). + [HttpPost] + public async Task> Create(CreateMissionModel model, CancellationToken ct) + { + var mission = await _missionService.CreateMissionAsync(model, ct); + return CreatedAtAction(nameof(GetAvailable), new { id = mission.Id }, mission); + } + + /// Update mission status for current user (submit/complete/etc.). + [HttpPatch("{missionId:guid}/status")] + public async Task 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 }); + } +} + diff --git a/LctMonolith/Controllers/NotificationsController.cs b/LctMonolith/Controllers/NotificationsController.cs new file mode 100644 index 0000000..f07d889 --- /dev/null +++ b/LctMonolith/Controllers/NotificationsController.cs @@ -0,0 +1,57 @@ +using System.Security.Claims; +using LctMonolith.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LctMonolith.Controllers; + +/// +/// In-app user notifications API. +/// +[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)!); + + /// Get up to 100 unread notifications. + [HttpGet("unread")] + public async Task GetUnread(CancellationToken ct) + { + var list = await _notifications.GetUnreadAsync(GetUserId(), ct); + return Ok(list); + } + + /// Get recent notifications (paged by take). + [HttpGet] + public async Task GetAll([FromQuery] int take = 100, CancellationToken ct = default) + { + var list = await _notifications.GetAllAsync(GetUserId(), take, ct); + return Ok(list); + } + + /// Mark a notification as read. + [HttpPost("mark/{id:guid}")] + public async Task MarkRead(Guid id, CancellationToken ct) + { + await _notifications.MarkReadAsync(GetUserId(), id, ct); + return NoContent(); + } + + /// Mark all notifications as read. + [HttpPost("mark-all")] + public async Task MarkAll(CancellationToken ct) + { + var cnt = await _notifications.MarkAllReadAsync(GetUserId(), ct); + return Ok(new { updated = cnt }); + } +} + diff --git a/LctMonolith/Controllers/StoreController.cs b/LctMonolith/Controllers/StoreController.cs new file mode 100644 index 0000000..77c05fb --- /dev/null +++ b/LctMonolith/Controllers/StoreController.cs @@ -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; + +/// +/// Store endpoints for listing items and purchasing. +/// +[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)!); + + /// List active store items. + [HttpGet("items")] + public async Task GetItems(CancellationToken ct) + { + var items = await _storeService.GetActiveItemsAsync(ct); + return Ok(items); + } + + /// Purchase an item for the authenticated user. + [HttpPost("purchase")] + public async Task 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 }); + } +} + diff --git a/LctMonolith/Dockerfile b/LctMonolith/Dockerfile new file mode 100644 index 0000000..583c741 --- /dev/null +++ b/LctMonolith/Dockerfile @@ -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"] diff --git a/LctMonolith/Domain/Entities/AppUser.cs b/LctMonolith/Domain/Entities/AppUser.cs new file mode 100644 index 0000000..63d091f --- /dev/null +++ b/LctMonolith/Domain/Entities/AppUser.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Identity; + +namespace LctMonolith.Domain.Entities; + +/// +/// Application user (candidate or employee) participating in gamification. +/// Extends IdentityUser with Guid primary key. +/// +public class AppUser : IdentityUser +{ + /// User given (first) name. + public string? FirstName { get; set; } + /// User family (last) name. + public string? LastName { get; set; } + /// Date of birth. + public DateOnly? BirthDate { get; set; } + + /// Current accumulated experience points. + public int Experience { get; set; } + /// Current mana (in-game currency). + public int Mana { get; set; } + + /// Current rank reference. + 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 Competencies { get; set; } = new List(); + public ICollection Missions { get; set; } = new List(); + public ICollection Inventory { get; set; } = new List(); + public ICollection Transactions { get; set; } = new List(); + public ICollection RefreshTokens { get; set; } = new List(); + public ICollection Events { get; set; } = new List(); + public ICollection Notifications { get; set; } = new List(); +} diff --git a/LctMonolith/Domain/Entities/ArtifactsAndStore.cs b/LctMonolith/Domain/Entities/ArtifactsAndStore.cs new file mode 100644 index 0000000..1a072c8 --- /dev/null +++ b/LctMonolith/Domain/Entities/ArtifactsAndStore.cs @@ -0,0 +1,95 @@ +namespace LctMonolith.Domain.Entities; + +/// Artifact definition (unique reward objects). +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 Users { get; set; } = new List(); + public ICollection MissionRewards { get; set; } = new List(); +} + +/// Mapping artifact to user ownership. +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; +} + +/// Reward mapping: mission grants artifact(s). +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!; +} + +/// Item in store that can be purchased with mana. +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 UserInventory { get; set; } = new List(); +} + +/// User owned store item record. +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; } +} + +/// Transaction record for purchases/returns/sales. +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; +} + +/// System event log for auditing user actions and progression. +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; +} + +/// Refresh token for JWT auth. +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; +} + diff --git a/LctMonolith/Domain/Entities/Competency.cs b/LctMonolith/Domain/Entities/Competency.cs new file mode 100644 index 0000000..b9e99c9 --- /dev/null +++ b/LctMonolith/Domain/Entities/Competency.cs @@ -0,0 +1,41 @@ +namespace LctMonolith.Domain.Entities; + +/// +/// Competency (skill) that can be progressed by completing missions. +/// +public class Competency +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = null!; + public string? Description { get; set; } + + public ICollection UserCompetencies { get; set; } = new List(); + public ICollection MissionRewards { get; set; } = new List(); + public ICollection RankRequirements { get; set; } = new List(); +} + +/// Per-user competency level. +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!; + /// Current level (integer simple scale). + public int Level { get; set; } + /// Optional numeric progress inside level (e.g., partial points). + public int ProgressPoints { get; set; } +} + +/// Reward mapping: mission increases competency level points. +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!; + /// Increment value in levels (could be 0 or 1) or points depending on design. + public int LevelDelta { get; set; } + public int ProgressPointsDelta { get; set; } +} + diff --git a/LctMonolith/Domain/Entities/Enums.cs b/LctMonolith/Domain/Entities/Enums.cs new file mode 100644 index 0000000..9f71201 --- /dev/null +++ b/LctMonolith/Domain/Entities/Enums.cs @@ -0,0 +1,54 @@ +namespace LctMonolith.Domain.Entities; + +/// Mission category taxonomy. +public enum MissionCategory +{ + Quest = 0, + Recruiting = 1, + Lecture = 2, + Simulator = 3 +} + +/// Status of a mission for a specific user. +public enum MissionStatus +{ + Locked = 0, + Available = 1, + InProgress = 2, + Submitted = 3, + Completed = 4, + Rejected = 5 +} + +/// Rarity level of an artifact. +public enum ArtifactRarity +{ + Common = 0, + Rare = 1, + Epic = 2, + Legendary = 3 +} + +/// Type of transactional operation in store. +public enum TransactionType +{ + Purchase = 0, + Return = 1, + Sale = 2 +} + +/// Auditable event types enumerated in requirements. +public enum EventType +{ + SkillProgress = 1, + MissionStatusChanged = 2, + RankChanged = 3, + ItemPurchased = 4, + ArtifactObtained = 5, + RewardGranted = 6, + ProfileChanged = 7, + AuthCredentialsChanged = 8, + ItemReturned = 9, + ItemSold = 10 +} + diff --git a/LctMonolith/Domain/Entities/Mission.cs b/LctMonolith/Domain/Entities/Mission.cs new file mode 100644 index 0000000..cc32f87 --- /dev/null +++ b/LctMonolith/Domain/Entities/Mission.cs @@ -0,0 +1,44 @@ +namespace LctMonolith.Domain.Entities; + +/// +/// Mission (task) definition configured by HR. +/// +public class Mission +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Title { get; set; } = null!; + public string? Description { get; set; } + /// Optional branch (path) name for grouping / visualization. + public string? Branch { get; set; } + public MissionCategory Category { get; set; } + /// Minimum rank required to access the mission (nullable = available from start). + public Guid? MinRankId { get; set; } + public Rank? MinRank { get; set; } + /// Experience reward on completion. + public int ExperienceReward { get; set; } + /// Mana reward on completion. + public int ManaReward { get; set; } + + public bool IsActive { get; set; } = true; + + public ICollection CompetencyRewards { get; set; } = new List(); + public ICollection ArtifactRewards { get; set; } = new List(); + public ICollection UserMissions { get; set; } = new List(); + public ICollection RanksRequiring { get; set; } = new List(); +} + +/// Per-user mission status and progression. +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; + /// Date/time of last status change. + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + /// Optional submission payload (e.g., link, text, attachments pointer). + public string? SubmissionData { get; set; } +} + diff --git a/LctMonolith/Domain/Entities/Notification.cs b/LctMonolith/Domain/Entities/Notification.cs new file mode 100644 index 0000000..e51107e --- /dev/null +++ b/LctMonolith/Domain/Entities/Notification.cs @@ -0,0 +1,16 @@ +namespace LctMonolith.Domain.Entities; + +/// User notification (in-app). +public class Notification +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public AppUser User { get; set; } = null!; + /// Short classification tag (e.g., rank, mission, store). + 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; } +} diff --git a/LctMonolith/Domain/Entities/Rank.cs b/LctMonolith/Domain/Entities/Rank.cs new file mode 100644 index 0000000..a41ac8b --- /dev/null +++ b/LctMonolith/Domain/Entities/Rank.cs @@ -0,0 +1,40 @@ +namespace LctMonolith.Domain.Entities; + +/// +/// Linear rank in progression ladder. User must meet XP, key mission and competency requirements. +/// +public class Rank +{ + public Guid Id { get; set; } = Guid.NewGuid(); + /// Display name (e.g., "Искатель", "Пилот-кандидат"). + public string Name { get; set; } = null!; + /// Ordering position. Lower value = earlier rank. + public int Order { get; set; } + /// Required cumulative experience to attain this rank. + public int RequiredExperience { get; set; } + + public ICollection RequiredMissions { get; set; } = new List(); + public ICollection RequiredCompetencies { get; set; } = new List(); + public ICollection Users { get; set; } = new List(); +} + +/// Mapping of rank to required mission. +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!; +} + +/// Mapping of rank to required competency minimum level. +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!; + /// Minimum level required for the competency. + public int MinLevel { get; set; } +} + diff --git a/LctMonolith/Infrastructure/Data/AppDbContext.cs b/LctMonolith/Infrastructure/Data/AppDbContext.cs new file mode 100644 index 0000000..b206f3f --- /dev/null +++ b/LctMonolith/Infrastructure/Data/AppDbContext.cs @@ -0,0 +1,95 @@ +using LctMonolith.Domain.Entities; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace LctMonolith.Infrastructure.Data; + +/// +/// Main EF Core database context for gamification module (PostgreSQL provider expected). +/// +public class AppDbContext : IdentityDbContext, Guid> +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Ranks => Set(); + public DbSet RankRequiredMissions => Set(); + public DbSet RankRequiredCompetencies => Set(); + + public DbSet Missions => Set(); + public DbSet UserMissions => Set(); + public DbSet MissionCompetencyRewards => Set(); + public DbSet MissionArtifactRewards => Set(); + + public DbSet Competencies => Set(); + public DbSet UserCompetencies => Set(); + + public DbSet Artifacts => Set(); + public DbSet UserArtifacts => Set(); + + public DbSet StoreItems => Set(); + public DbSet UserInventoryItems => Set(); + public DbSet Transactions => Set(); + + public DbSet EventLogs => Set(); + public DbSet RefreshTokens => Set(); + public DbSet Notifications => Set(); + + protected override void OnModelCreating(ModelBuilder b) + { + base.OnModelCreating(b); + + // Rank required mission composite key + b.Entity().HasKey(x => new { x.RankId, x.MissionId }); + b.Entity() + .HasOne(x => x.Rank).WithMany(r => r.RequiredMissions).HasForeignKey(x => x.RankId); + b.Entity() + .HasOne(x => x.Mission).WithMany(m => m.RanksRequiring).HasForeignKey(x => x.MissionId); + + // Rank required competency composite key + b.Entity().HasKey(x => new { x.RankId, x.CompetencyId }); + b.Entity() + .HasOne(x => x.Rank).WithMany(r => r.RequiredCompetencies).HasForeignKey(x => x.RankId); + b.Entity() + .HasOne(x => x.Competency).WithMany(c => c.RankRequirements).HasForeignKey(x => x.CompetencyId); + + // UserMission composite key + b.Entity().HasKey(x => new { x.UserId, x.MissionId }); + b.Entity() + .HasOne(x => x.User).WithMany(u => u.Missions).HasForeignKey(x => x.UserId); + b.Entity() + .HasOne(x => x.Mission).WithMany(m => m.UserMissions).HasForeignKey(x => x.MissionId); + + // UserCompetency composite key + b.Entity().HasKey(x => new { x.UserId, x.CompetencyId }); + b.Entity() + .HasOne(x => x.User).WithMany(u => u.Competencies).HasForeignKey(x => x.UserId); + b.Entity() + .HasOne(x => x.Competency).WithMany(c => c.UserCompetencies).HasForeignKey(x => x.CompetencyId); + + // Mission competency reward composite key + b.Entity().HasKey(x => new { x.MissionId, x.CompetencyId }); + + // Mission artifact reward composite key + b.Entity().HasKey(x => new { x.MissionId, x.ArtifactId }); + + // UserArtifact composite key + b.Entity().HasKey(x => new { x.UserId, x.ArtifactId }); + + // UserInventory composite key + b.Entity().HasKey(x => new { x.UserId, x.StoreItemId }); + + // Refresh token index unique + b.Entity().HasIndex(x => x.Token).IsUnique(); + + // ---------- Added performance indexes ---------- + b.Entity().HasIndex(u => u.RankId); + b.Entity().HasIndex(m => m.MinRankId); + b.Entity().HasIndex(um => new { um.UserId, um.Status }); + b.Entity().HasIndex(uc => uc.CompetencyId); // for querying all users by competency + b.Entity().HasIndex(e => new { e.UserId, e.Type, e.CreatedAt }); + b.Entity().HasIndex(i => i.IsActive); + b.Entity().HasIndex(t => new { t.UserId, t.CreatedAt }); + b.Entity().HasIndex(n => new { n.UserId, n.IsRead, n.CreatedAt }); + } +} diff --git a/LctMonolith/Infrastructure/Data/DbSeeder.cs b/LctMonolith/Infrastructure/Data/DbSeeder.cs new file mode 100644 index 0000000..805d018 --- /dev/null +++ b/LctMonolith/Infrastructure/Data/DbSeeder.cs @@ -0,0 +1,48 @@ +using LctMonolith.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Serilog; +using System.Collections.Generic; + +namespace LctMonolith.Infrastructure.Data; + +/// +/// Development database seeder for initial ranks, competencies, sample store items. +/// Idempotent: checks existence before inserting. +/// +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 + { + 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); + } +} diff --git a/LctMonolith/Infrastructure/Repositories/GenericRepository.cs b/LctMonolith/Infrastructure/Repositories/GenericRepository.cs new file mode 100644 index 0000000..0f3796a --- /dev/null +++ b/LctMonolith/Infrastructure/Repositories/GenericRepository.cs @@ -0,0 +1,56 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using LctMonolith.Infrastructure.Data; + +namespace LctMonolith.Infrastructure.Repositories; + +/// +/// Generic repository implementation for common CRUD and query composition. +/// +public class GenericRepository : IGenericRepository where TEntity : class +{ + protected readonly AppDbContext Context; + protected readonly DbSet Set; + + public GenericRepository(AppDbContext context) + { + Context = context; + Set = context.Set(); + } + + public IQueryable Query( + Expression>? filter = null, + Func, IOrderedQueryable>? orderBy = null, + params Expression>[] includes) + { + IQueryable 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 GetByIdAsync(object id) => await Set.FindAsync(id) ?? null; + + public ValueTask 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 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); + } +} + diff --git a/LctMonolith/Infrastructure/Repositories/IGenericRepository.cs b/LctMonolith/Infrastructure/Repositories/IGenericRepository.cs new file mode 100644 index 0000000..b856629 --- /dev/null +++ b/LctMonolith/Infrastructure/Repositories/IGenericRepository.cs @@ -0,0 +1,25 @@ +using System.Linq.Expressions; + +namespace LctMonolith.Infrastructure.Repositories; + +/// +/// Generic repository abstraction for aggregate root / entity access. Read operations return IQueryable for composition. +/// +public interface IGenericRepository where TEntity : class +{ + IQueryable Query( + Expression>? filter = null, + Func, IOrderedQueryable>? orderBy = null, + params Expression>[] includes); + + Task GetByIdAsync(object id); + ValueTask FindAsync(params object[] keyValues); + + Task AddAsync(TEntity entity, CancellationToken ct = default); + Task AddRangeAsync(IEnumerable entities, CancellationToken ct = default); + + void Update(TEntity entity); + void Remove(TEntity entity); + Task RemoveByIdAsync(object id, CancellationToken ct = default); +} + diff --git a/LctMonolith/Infrastructure/UnitOfWork/IUnitOfWork.cs b/LctMonolith/Infrastructure/UnitOfWork/IUnitOfWork.cs new file mode 100644 index 0000000..7cae5de --- /dev/null +++ b/LctMonolith/Infrastructure/UnitOfWork/IUnitOfWork.cs @@ -0,0 +1,34 @@ +using LctMonolith.Domain.Entities; +using LctMonolith.Infrastructure.Repositories; + +namespace LctMonolith.Infrastructure.UnitOfWork; + +/// +/// Unit of Work aggregates repositories and transaction boundary. +/// +public interface IUnitOfWork +{ + IGenericRepository Users { get; } + IGenericRepository Ranks { get; } + IGenericRepository RankRequiredMissions { get; } + IGenericRepository RankRequiredCompetencies { get; } + IGenericRepository Missions { get; } + IGenericRepository UserMissions { get; } + IGenericRepository MissionCompetencyRewards { get; } + IGenericRepository MissionArtifactRewards { get; } + IGenericRepository Competencies { get; } + IGenericRepository UserCompetencies { get; } + IGenericRepository Artifacts { get; } + IGenericRepository UserArtifacts { get; } + IGenericRepository StoreItems { get; } + IGenericRepository UserInventoryItems { get; } + IGenericRepository Transactions { get; } + IGenericRepository EventLogs { get; } + IGenericRepository RefreshTokens { get; } + IGenericRepository Notifications { get; } + + Task SaveChangesAsync(CancellationToken ct = default); + Task BeginTransactionAsync(CancellationToken ct = default); + Task CommitAsync(CancellationToken ct = default); + Task RollbackAsync(CancellationToken ct = default); +} diff --git a/LctMonolith/Infrastructure/UnitOfWork/UnitOfWork.cs b/LctMonolith/Infrastructure/UnitOfWork/UnitOfWork.cs new file mode 100644 index 0000000..9d94bd9 --- /dev/null +++ b/LctMonolith/Infrastructure/UnitOfWork/UnitOfWork.cs @@ -0,0 +1,99 @@ +using LctMonolith.Domain.Entities; +using LctMonolith.Infrastructure.Data; +using LctMonolith.Infrastructure.Repositories; +using Microsoft.EntityFrameworkCore.Storage; + +namespace LctMonolith.Infrastructure.UnitOfWork; + +/// +/// Unit of Work implementation encapsulating repositories and DB transaction scope. +/// +public class UnitOfWork : IUnitOfWork, IAsyncDisposable +{ + private readonly AppDbContext _ctx; + private IDbContextTransaction? _tx; + + public UnitOfWork(AppDbContext ctx) + { + _ctx = ctx; + } + + private IGenericRepository? _users; + private IGenericRepository? _ranks; + private IGenericRepository? _rankRequiredMissions; + private IGenericRepository? _rankRequiredCompetencies; + private IGenericRepository? _missions; + private IGenericRepository? _userMissions; + private IGenericRepository? _missionCompetencyRewards; + private IGenericRepository? _missionArtifactRewards; + private IGenericRepository? _competencies; + private IGenericRepository? _userCompetencies; + private IGenericRepository? _artifacts; + private IGenericRepository? _userArtifacts; + private IGenericRepository? _storeItems; + private IGenericRepository? _userInventoryItems; + private IGenericRepository? _transactions; + private IGenericRepository? _eventLogs; + private IGenericRepository? _refreshTokens; + private IGenericRepository? _notifications; + + public IGenericRepository Users => _users ??= new GenericRepository(_ctx); + public IGenericRepository Ranks => _ranks ??= new GenericRepository(_ctx); + public IGenericRepository RankRequiredMissions => _rankRequiredMissions ??= new GenericRepository(_ctx); + public IGenericRepository RankRequiredCompetencies => _rankRequiredCompetencies ??= new GenericRepository(_ctx); + public IGenericRepository Missions => _missions ??= new GenericRepository(_ctx); + public IGenericRepository UserMissions => _userMissions ??= new GenericRepository(_ctx); + public IGenericRepository MissionCompetencyRewards => _missionCompetencyRewards ??= new GenericRepository(_ctx); + public IGenericRepository MissionArtifactRewards => _missionArtifactRewards ??= new GenericRepository(_ctx); + public IGenericRepository Competencies => _competencies ??= new GenericRepository(_ctx); + public IGenericRepository UserCompetencies => _userCompetencies ??= new GenericRepository(_ctx); + public IGenericRepository Artifacts => _artifacts ??= new GenericRepository(_ctx); + public IGenericRepository UserArtifacts => _userArtifacts ??= new GenericRepository(_ctx); + public IGenericRepository StoreItems => _storeItems ??= new GenericRepository(_ctx); + public IGenericRepository UserInventoryItems => _userInventoryItems ??= new GenericRepository(_ctx); + public IGenericRepository Transactions => _transactions ??= new GenericRepository(_ctx); + public IGenericRepository EventLogs => _eventLogs ??= new GenericRepository(_ctx); + public IGenericRepository RefreshTokens => _refreshTokens ??= new GenericRepository(_ctx); + public IGenericRepository Notifications => _notifications ??= new GenericRepository(_ctx); + + public Task 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(); + } +} diff --git a/StoreService/StoreService.csproj b/LctMonolith/LctMonolith.csproj similarity index 58% rename from StoreService/StoreService.csproj rename to LctMonolith/LctMonolith.csproj index 81a80ec..6451f74 100644 --- a/StoreService/StoreService.csproj +++ b/LctMonolith/LctMonolith.csproj @@ -4,39 +4,39 @@ net9.0 enable enable + Linux true 1591 - - - - - - + - - - - - - - - + + + + + + + + + + + + + + - - - - - + + .dockerignore + diff --git a/LctMonolith/LctMonolith.http b/LctMonolith/LctMonolith.http new file mode 100644 index 0000000..ab5fd18 --- /dev/null +++ b/LctMonolith/LctMonolith.http @@ -0,0 +1,6 @@ +@LctMonolith_HostAddress = http://localhost:5217 + +GET {{LctMonolith_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/LctMonolith/Program.cs b/LctMonolith/Program.cs new file mode 100644 index 0000000..9358148 --- /dev/null +++ b/LctMonolith/Program.cs @@ -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(o => +{ + o.Key = jwtKey; + o.Issuer = jwtIssuer; + o.Audience = jwtAudience; + o.AccessTokenMinutes = accessMinutes; + o.RefreshTokenDays = refreshDays; +}); + +// DbContext +builder.Services.AddDbContext(opt => + opt.UseNpgsql(connectionString)); + +// Identity Core +builder.Services.AddIdentityCore(options => + { + options.Password.RequireDigit = false; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireLowercase = false; + options.Password.RequiredLength = 6; + }) + .AddRoles>() + .AddEntityFrameworkStores() + .AddSignInManager>() + .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(); + +// Domain services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// 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(); + 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(); diff --git a/StoreService/Properties/launchSettings.json b/LctMonolith/Properties/launchSettings.json similarity index 80% rename from StoreService/Properties/launchSettings.json rename to LctMonolith/Properties/launchSettings.json index 78c5fb2..529ed3a 100644 --- a/StoreService/Properties/launchSettings.json +++ b/LctMonolith/Properties/launchSettings.json @@ -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" } diff --git a/LctMonolith/Services/AnalyticsService.cs b/LctMonolith/Services/AnalyticsService.cs new file mode 100644 index 0000000..ef83780 --- /dev/null +++ b/LctMonolith/Services/AnalyticsService.cs @@ -0,0 +1,34 @@ +using LctMonolith.Infrastructure.UnitOfWork; +using LctMonolith.Services.Models; +using Microsoft.EntityFrameworkCore; + +namespace LctMonolith.Services; + +/// +/// Provides aggregated analytics metrics for dashboards. +/// +public class AnalyticsService : IAnalyticsService +{ + private readonly IUnitOfWork _uow; + public AnalyticsService(IUnitOfWork uow) => _uow = uow; + + public async Task 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 + }; + } +} + diff --git a/LctMonolith/Services/GamificationService.cs b/LctMonolith/Services/GamificationService.cs new file mode 100644 index 0000000..6e93f02 --- /dev/null +++ b/LctMonolith/Services/GamificationService.cs @@ -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; + +/// +/// Handles progression logic: mission completion rewards and rank advancement evaluation. +/// +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 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); + } +} diff --git a/LctMonolith/Services/IWeatherService.cs b/LctMonolith/Services/IWeatherService.cs new file mode 100644 index 0000000..0ebafcc --- /dev/null +++ b/LctMonolith/Services/IWeatherService.cs @@ -0,0 +1,3 @@ +// Removed legacy Weather service placeholder. +// Intentionally left blank. + diff --git a/LctMonolith/Services/Interfaces.cs b/LctMonolith/Services/Interfaces.cs new file mode 100644 index 0000000..b071c4e --- /dev/null +++ b/LctMonolith/Services/Interfaces.cs @@ -0,0 +1,58 @@ +using LctMonolith.Domain.Entities; +using LctMonolith.Services.Models; + +namespace LctMonolith.Services; + +/// Service for issuing JWT and refresh tokens. +public interface ITokenService +{ + Task IssueAsync(AppUser user, CancellationToken ct = default); + Task RefreshAsync(string refreshToken, CancellationToken ct = default); + Task RevokeAsync(string refreshToken, CancellationToken ct = default); +} + +/// Gamification progression logic (awards, rank upgrade). +public interface IGamificationService +{ + Task GetProgressAsync(Guid userId, CancellationToken ct = default); + Task ApplyMissionCompletionAsync(Guid userId, Mission mission, CancellationToken ct = default); + Task EvaluateRankUpgradeAsync(Guid userId, CancellationToken ct = default); +} + +/// Mission management and user mission state transitions. +public interface IMissionService +{ + Task CreateMissionAsync(CreateMissionModel model, CancellationToken ct = default); + Task> GetAvailableMissionsAsync(Guid userId, CancellationToken ct = default); + Task UpdateStatusAsync(Guid userId, Guid missionId, MissionStatus status, string? submissionData, CancellationToken ct = default); +} + +/// Store and inventory operations. +public interface IStoreService +{ + Task> GetActiveItemsAsync(CancellationToken ct = default); + Task PurchaseAsync(Guid userId, Guid itemId, int quantity, CancellationToken ct = default); +} + +/// User notifications (in-app) management. +public interface INotificationService +{ + Task CreateAsync(Guid userId, string type, string title, string message, CancellationToken ct = default); + Task> GetUnreadAsync(Guid userId, CancellationToken ct = default); + Task> GetAllAsync(Guid userId, int take = 100, CancellationToken ct = default); + Task MarkReadAsync(Guid userId, Guid notificationId, CancellationToken ct = default); + Task MarkAllReadAsync(Guid userId, CancellationToken ct = default); +} + +/// Inventory querying (owned artifacts, store items). +public interface IInventoryService +{ + Task> GetStoreInventoryAsync(Guid userId, CancellationToken ct = default); + Task> GetArtifactsAsync(Guid userId, CancellationToken ct = default); +} + +/// Basic analytics / aggregated metrics. +public interface IAnalyticsService +{ + Task GetSummaryAsync(CancellationToken ct = default); +} diff --git a/LctMonolith/Services/InventoryService.cs b/LctMonolith/Services/InventoryService.cs new file mode 100644 index 0000000..d2c87b4 --- /dev/null +++ b/LctMonolith/Services/InventoryService.cs @@ -0,0 +1,35 @@ +using LctMonolith.Domain.Entities; +using LctMonolith.Infrastructure.UnitOfWork; +using Microsoft.EntityFrameworkCore; + +namespace LctMonolith.Services; + +/// +/// Provides read-only access to user-owned inventory (store items and artifacts). +/// +public class InventoryService : IInventoryService +{ + private readonly IUnitOfWork _uow; + + public InventoryService(IUnitOfWork uow) + { + _uow = uow; + } + + public async Task> 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> 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); + } +} + diff --git a/LctMonolith/Services/MissionService.cs b/LctMonolith/Services/MissionService.cs new file mode 100644 index 0000000..a6e252f --- /dev/null +++ b/LctMonolith/Services/MissionService.cs @@ -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; + +/// +/// Mission management and user mission state transitions. +/// +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 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> 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 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); + } +} diff --git a/LctMonolith/Services/Models/Models.cs b/LctMonolith/Services/Models/Models.cs new file mode 100644 index 0000000..8bc0a00 --- /dev/null +++ b/LctMonolith/Services/Models/Models.cs @@ -0,0 +1,99 @@ +using LctMonolith.Domain.Entities; + +namespace LctMonolith.Services.Models; + +/// Returned access+refresh token pair with expiry metadata. +public record TokenPair(string AccessToken, DateTime AccessTokenExpiresAt, string RefreshToken, DateTime RefreshTokenExpiresAt); + +/// Mission creation request model. +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 CompetencyRewards { get; set; } = new(); + public List ArtifactRewardIds { get; set; } = new(); +} + +/// Competency reward definition for mission creation. +public class CompetencyRewardModel +{ + public Guid CompetencyId { get; set; } + public int LevelDelta { get; set; } + public int ProgressPointsDelta { get; set; } +} + +/// Progress snapshot for UI: rank, xp, remaining requirements. +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 OutstandingMissionIds { get; set; } = new(); + public List OutstandingCompetencies { get; set; } = new(); +} + +/// Competency requirement still unmet for next rank. +public class OutstandingCompetency +{ + public Guid CompetencyId { get; set; } + public string? CompetencyName { get; set; } + public int RequiredLevel { get; set; } + public int CurrentLevel { get; set; } +} + +/// Request to update mission status with optional submission data. +public class UpdateMissionStatusRequest +{ + public MissionStatus Status { get; set; } + public string? SubmissionData { get; set; } +} + +/// Store purchase request. +public class PurchaseRequest +{ + public Guid ItemId { get; set; } + public int Quantity { get; set; } = 1; +} + +/// Authentication request (login/register) simple mock. +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; } +} + +/// Refresh token request. +public class RefreshRequest +{ + public string RefreshToken { get; set; } = null!; +} + +/// Revoke refresh token request. +public class RevokeRequest +{ + public string RefreshToken { get; set; } = null!; +} + +/// Analytics summary for admin dashboard. +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; +} diff --git a/LctMonolith/Services/NotificationService.cs b/LctMonolith/Services/NotificationService.cs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/LctMonolith/Services/NotificationService.cs @@ -0,0 +1 @@ + diff --git a/LctMonolith/Services/StoreService.cs b/LctMonolith/Services/StoreService.cs new file mode 100644 index 0000000..e25ed6f --- /dev/null +++ b/LctMonolith/Services/StoreService.cs @@ -0,0 +1,75 @@ +using LctMonolith.Domain.Entities; +using LctMonolith.Infrastructure.UnitOfWork; +using Microsoft.EntityFrameworkCore; +using Serilog; +using System.Text.Json; + +namespace LctMonolith.Services; + +/// +/// Store purchase operations and inventory management. +/// +public class StoreService : IStoreService +{ + private readonly IUnitOfWork _uow; + + public StoreService(IUnitOfWork uow) + { + _uow = uow; + } + + public async Task> GetActiveItemsAsync(CancellationToken ct = default) + { + return await _uow.StoreItems.Query(i => i.IsActive).ToListAsync(ct); + } + + public async Task 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; + } +} + diff --git a/LctMonolith/Services/TokenService.cs b/LctMonolith/Services/TokenService.cs new file mode 100644 index 0000000..717ab83 --- /dev/null +++ b/LctMonolith/Services/TokenService.cs @@ -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; + +/// +/// Issues and refreshes JWT + refresh tokens. +/// +public class TokenService : ITokenService +{ + private readonly IUnitOfWork _uow; + private readonly UserManager _userManager; + private readonly JwtOptions _options; + private readonly SigningCredentials _creds; + + public TokenService(IUnitOfWork uow, UserManager userManager, IOptions 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 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 + { + 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 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 bytes = stackalloc byte[64]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToBase64String(bytes); + } +} + +internal static class EfAsyncExtensions +{ + public static Task FirstOrDefaultAsync(this IQueryable query, CancellationToken ct = default) => Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.FirstOrDefaultAsync(query, ct); +} diff --git a/LctMonolith/appsettings.Development.json b/LctMonolith/appsettings.Development.json new file mode 100644 index 0000000..e62cba1 --- /dev/null +++ b/LctMonolith/appsettings.Development.json @@ -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" + } + } +} diff --git a/LctMonolith/appsettings.json b/LctMonolith/appsettings.json new file mode 100644 index 0000000..c30e24e --- /dev/null +++ b/LctMonolith/appsettings.json @@ -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": "*" +} diff --git a/StoreService/Controllers/OrdersController.cs b/StoreService/Controllers/OrdersController.cs deleted file mode 100644 index c9048a9..0000000 --- a/StoreService/Controllers/OrdersController.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using StoreService.Models; -using StoreService.Services; - -namespace StoreService.Controllers; - -/// -/// Endpoints for order lifecycle: create, query, pay and redeem. -/// -[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 - /// - /// Creates a new order for specified user and store items. Prices are calculated with active discounts. - /// - [HttpPost] - [ProducesResponseType(typeof(OrderDto), StatusCodes.Status201Created)] - public async Task Create([FromBody] CreateOrderRequest request, CancellationToken ct) - { - var order = await _orderService.CreateOrderAsync(request, ct); - return CreatedAtAction(nameof(Get), new { id = order.Id }, order); - } - - /// - /// Retrieves an order by id. - /// - [HttpGet("{id:long}")] - [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task Get([FromRoute] long id, CancellationToken ct) - { - var order = await _orderService.GetOrderAsync(id, ct); - return order == null ? NotFound() : Ok(order); - } - - /// - /// Pays (confirms) an order. Sets the paid date. Idempotent except it fails if already paid. - /// - [HttpPost("{id:long}/pay")] - [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - public async Task Pay([FromRoute] long id, CancellationToken ct) - { - var order = await _orderService.PayOrderAsync(id, ct); - return Ok(order); - } - - /// - /// Marks items as redeemed (granted to user inventory) after payment. - /// - [HttpPost("{id:long}/redeem")] - [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - public async Task Redeem([FromRoute] long id, CancellationToken ct) - { - var order = await _orderService.RedeemOrderItemsAsync(id, ct); - return Ok(order); - } - #endregion -} - diff --git a/StoreService/Database/ApplicationContext.cs b/StoreService/Database/ApplicationContext.cs deleted file mode 100644 index 1d248c1..0000000 --- a/StoreService/Database/ApplicationContext.cs +++ /dev/null @@ -1,127 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using StoreService.Database.Entities; - -namespace StoreService.Database; - -/// -/// Entity Framework Core database context for the Store microservice. -/// Defines DbSets and configures entity relationships & constraints. -/// -public class ApplicationContext : DbContext -{ - #region Ctor - public ApplicationContext(DbContextOptions options) : base(options) - { - } - #endregion - - #region DbSets - public DbSet StoreCategories => Set(); - public DbSet StoreItems => Set(); - public DbSet StoreDiscounts => Set(); - public DbSet StoreDiscountItems => Set(); - public DbSet StoreOrders => Set(); - public DbSet StoreOrderItems => Set(); - public DbSet StoreOrderItemDiscounts => Set(); - #endregion - - #region ModelCreating - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - // store_category - modelBuilder.Entity(b => - { - b.ToTable("store_category"); - b.HasKey(x => x.Id); - b.Property(x => x.Title).HasMaxLength(256).IsRequired(); - }); - - // store_item - modelBuilder.Entity(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(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(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(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(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(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 -} - diff --git a/StoreService/Database/Entities/StoreCategory.cs b/StoreService/Database/Entities/StoreCategory.cs deleted file mode 100644 index 76bafb4..0000000 --- a/StoreService/Database/Entities/StoreCategory.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace StoreService.Database.Entities; - -/// -/// Category grouping store items. -/// -public class StoreCategory -{ - #region Properties - public long Id { get; set; } - public string Title { get; set; } = string.Empty; - #endregion - - #region Navigation - public ICollection Items { get; set; } = new List(); - #endregion -} - diff --git a/StoreService/Database/Entities/StoreDiscount.cs b/StoreService/Database/Entities/StoreDiscount.cs deleted file mode 100644 index 79c9c53..0000000 --- a/StoreService/Database/Entities/StoreDiscount.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace StoreService.Database.Entities; - -/// -/// Percentage discount that can apply to one or more store items within a time window. -/// -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 DiscountItems { get; set; } = new List(); - public ICollection OrderItemDiscounts { get; set; } = new List(); - #endregion - - #region Helpers - /// - /// Checks whether discount is active at provided moment (default: now) ignoring cancellation flag (if canceled returns false). - /// - public bool IsActive(DateTime? at = null) - { - if (IsCanceled) return false; - var moment = at ?? DateTime.UtcNow; - return moment >= FromDate && moment <= UntilDate; - } - #endregion -} - diff --git a/StoreService/Database/Entities/StoreDiscountItem.cs b/StoreService/Database/Entities/StoreDiscountItem.cs deleted file mode 100644 index cec4cd4..0000000 --- a/StoreService/Database/Entities/StoreDiscountItem.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace StoreService.Database.Entities; - -/// -/// Join entity linking discounts to store items. -/// -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 -} - diff --git a/StoreService/Database/Entities/StoreItem.cs b/StoreService/Database/Entities/StoreItem.cs deleted file mode 100644 index d85c152..0000000 --- a/StoreService/Database/Entities/StoreItem.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace StoreService.Database.Entities; - -/// -/// Store item with pricing and purchase rules. -/// -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 DiscountItems { get; set; } = new List(); - public ICollection OrderItems { get; set; } = new List(); - #endregion -} - diff --git a/StoreService/Database/Entities/StoreOrder.cs b/StoreService/Database/Entities/StoreOrder.cs deleted file mode 100644 index b3e7234..0000000 --- a/StoreService/Database/Entities/StoreOrder.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace StoreService.Database.Entities; - -/// -/// Represents a purchase order created by a user. -/// -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 OrderItems { get; set; } = new List(); - #endregion -} - diff --git a/StoreService/Database/Entities/StoreOrderItem.cs b/StoreService/Database/Entities/StoreOrderItem.cs deleted file mode 100644 index 2ba005a..0000000 --- a/StoreService/Database/Entities/StoreOrderItem.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace StoreService.Database.Entities; - -/// -/// Item line inside an order with captured calculated price for history. -/// -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 AppliedDiscounts { get; set; } = new List(); - #endregion -} - diff --git a/StoreService/Database/Entities/StoreOrderItemDiscount.cs b/StoreService/Database/Entities/StoreOrderItemDiscount.cs deleted file mode 100644 index 8d7a0b7..0000000 --- a/StoreService/Database/Entities/StoreOrderItemDiscount.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace StoreService.Database.Entities; - -/// -/// Captures which discounts were applied to order items at purchase time. -/// -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 -} - diff --git a/StoreService/Extensions/DatabaseExtensions.cs b/StoreService/Extensions/DatabaseExtensions.cs deleted file mode 100644 index ff4b652..0000000 --- a/StoreService/Extensions/DatabaseExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using StoreService.Database; - -namespace StoreService.Extensions; - -/// -/// Database related DI registrations. -/// -public static class DatabaseExtensions -{ - /// - /// Registers the EF Core DbContext (PostgreSQL). - /// - public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration) - { - services.AddDbContext(options => - options.UseNpgsql(configuration.GetConnectionString("StoreDb"))); - return services; - } -} - diff --git a/StoreService/Extensions/DomainServicesExtensions.cs b/StoreService/Extensions/DomainServicesExtensions.cs deleted file mode 100644 index 0a152f3..0000000 --- a/StoreService/Extensions/DomainServicesExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using StoreService.Services; - -namespace StoreService.Extensions; - -/// -/// Domain service layer DI registrations. -/// -public static class DomainServicesExtensions -{ - public static IServiceCollection AddDomainServices(this IServiceCollection services) - { - services.AddScoped(); - return services; - } -} - diff --git a/StoreService/Extensions/LoggingExtensions.cs b/StoreService/Extensions/LoggingExtensions.cs deleted file mode 100644 index f10cfdd..0000000 --- a/StoreService/Extensions/LoggingExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Serilog; - -namespace StoreService.Extensions; - -/// -/// Logging related extensions (Serilog configuration). -/// -public static class LoggingExtensions -{ - /// - /// Adds Serilog configuration for the host using appsettings.json (Serilog section). - /// - public static IHostBuilder AddSerilogLogging(this IHostBuilder hostBuilder) - { - hostBuilder.UseSerilog((ctx, services, cfg) => - { - cfg.ReadFrom.Configuration(ctx.Configuration) - .ReadFrom.Services(services) - .Enrich.FromLogContext(); - }); - return hostBuilder; - } -} - diff --git a/StoreService/Extensions/RepositoryExtensions.cs b/StoreService/Extensions/RepositoryExtensions.cs deleted file mode 100644 index 46d21b7..0000000 --- a/StoreService/Extensions/RepositoryExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using StoreService.Repositories; - -namespace StoreService.Extensions; - -/// -/// Repository & UnitOfWork registrations. -/// -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(); - return services; - } -} diff --git a/StoreService/Middleware/ErrorHandlingMiddleware.cs b/StoreService/Middleware/ErrorHandlingMiddleware.cs deleted file mode 100644 index f4714d5..0000000 --- a/StoreService/Middleware/ErrorHandlingMiddleware.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Net; -using System.Text.Json; -using System.ComponentModel.DataAnnotations; - -namespace StoreService.Middleware; - -/// -/// Global error handling middleware capturing unhandled exceptions and returning structured JSON errors. -/// -public class ErrorHandlingMiddleware -{ - #region Fields - private readonly RequestDelegate _next; - private readonly ILogger _logger; - #endregion - - #region Ctor - public ErrorHandlingMiddleware(RequestDelegate next, ILogger 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(); - - 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 -} - - diff --git a/StoreService/Models/CreateOrderRequest.cs b/StoreService/Models/CreateOrderRequest.cs deleted file mode 100644 index aa2bcc4..0000000 --- a/StoreService/Models/CreateOrderRequest.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace StoreService.Models; - -/// -/// Request body to create a new order consisting of store item identifiers. -/// -public class CreateOrderRequest -{ - /// Identifier of the user creating the order. - [Required] - public long UserId { get; set; } - - /// Collection of store item ids to include (unique). Duplicates are ignored. - [Required] - [MinLength(1)] - public List StoreItemIds { get; set; } = new(); -} - diff --git a/StoreService/Models/OrderDto.cs b/StoreService/Models/OrderDto.cs deleted file mode 100644 index 5a1f39b..0000000 --- a/StoreService/Models/OrderDto.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace StoreService.Models; - -/// -/// Result DTO representing an order 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 Items { get; set; } = new(); -} - diff --git a/StoreService/Models/OrderItemDto.cs b/StoreService/Models/OrderItemDto.cs deleted file mode 100644 index b0ebe0f..0000000 --- a/StoreService/Models/OrderItemDto.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace StoreService.Models; - -/// -/// Order line DTO. -/// -public class OrderItemDto -{ - public long Id { get; set; } - public long StoreItemId { get; set; } - public int CalculatedPrice { get; set; } - public List AppliedDiscountIds { get; set; } = new(); -} - diff --git a/StoreService/Models/OrderModels.cs b/StoreService/Models/OrderModels.cs deleted file mode 100644 index 8fe486f..0000000 --- a/StoreService/Models/OrderModels.cs +++ /dev/null @@ -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. - diff --git a/StoreService/Program.cs b/StoreService/Program.cs deleted file mode 100644 index 798f8e2..0000000 --- a/StoreService/Program.cs +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/StoreService/Repositories/GenericRepository.cs b/StoreService/Repositories/GenericRepository.cs deleted file mode 100644 index 29ed26c..0000000 --- a/StoreService/Repositories/GenericRepository.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Linq.Expressions; -using Microsoft.EntityFrameworkCore; -using StoreService.Database; - -namespace StoreService.Repositories; - -/// -/// Generic repository implementation wrapping EF Core DbSet. -/// -/// Entity type. -public class GenericRepository : IGenericRepository where TEntity : class -{ - #region Fields - private readonly ApplicationContext _context; - private readonly DbSet _dbSet; - #endregion - - #region Ctor - public GenericRepository(ApplicationContext context) - { - _context = context; - _dbSet = context.Set(); - } - #endregion - - #region Query - public virtual IQueryable Get( - Expression>? filter = null, - Func, IOrderedQueryable>? orderBy = null, - params Expression>[] includes) - { - IQueryable 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 FirstOrDefaultAsync(Expression> predicate, params Expression>[] includes) - { - IQueryable 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 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 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 entities) => _dbSet.RemoveRange(entities); - #endregion -} - diff --git a/StoreService/Repositories/IGenericRepository.cs b/StoreService/Repositories/IGenericRepository.cs deleted file mode 100644 index d8c1693..0000000 --- a/StoreService/Repositories/IGenericRepository.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Linq.Expressions; - -namespace StoreService.Repositories; - -/// -/// Generic repository abstraction for simple CRUD & query operations. -/// -/// Entity type. -public interface IGenericRepository where TEntity : class -{ - #region Query - IQueryable Get( - Expression>? filter = null, - Func, IOrderedQueryable>? orderBy = null, - params Expression>[] includes); - - Task FirstOrDefaultAsync(Expression> predicate, - params Expression>[] includes); - - TEntity? GetById(object id); - Task GetByIdAsync(object id); - #endregion - - #region Mutations - void Add(TEntity entity); - Task AddAsync(TEntity entity); - void AddRange(IEnumerable entities); - - void Update(TEntity entity); - - void Delete(object id); - void Delete(TEntity entity); - void DeleteRange(IEnumerable entities); - #endregion -} - diff --git a/StoreService/Repositories/IUnitOfWork.cs b/StoreService/Repositories/IUnitOfWork.cs deleted file mode 100644 index 1789baf..0000000 --- a/StoreService/Repositories/IUnitOfWork.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.EntityFrameworkCore.Storage; -using StoreService.Database.Entities; - -namespace StoreService.Repositories; - -/// -/// Unit of work pattern abstraction encapsulating repositories and transactions. -/// -public interface IUnitOfWork : IAsyncDisposable, IDisposable -{ - #region Repositories - IGenericRepository StoreCategories { get; } - IGenericRepository StoreItems { get; } - IGenericRepository StoreDiscounts { get; } - IGenericRepository StoreDiscountItems { get; } - IGenericRepository StoreOrders { get; } - IGenericRepository StoreOrderItems { get; } - IGenericRepository StoreOrderItemDiscounts { get; } - #endregion - - #region Save - bool SaveChanges(); - Task SaveChangesAsync(CancellationToken ct = default); - #endregion - - #region Transactions - Task BeginTransactionAsync(CancellationToken ct = default); - Task CommitTransactionAsync(CancellationToken ct = default); - Task RollbackTransactionAsync(CancellationToken ct = default); - #endregion -} - diff --git a/StoreService/Repositories/UnitOfWork.cs b/StoreService/Repositories/UnitOfWork.cs deleted file mode 100644 index 7b3a12b..0000000 --- a/StoreService/Repositories/UnitOfWork.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Microsoft.EntityFrameworkCore.Storage; -using StoreService.Database; -using StoreService.Database.Entities; - -namespace StoreService.Repositories; - -/// -/// Coordinates repository access and database transactions. -/// -public class UnitOfWork : IUnitOfWork -{ - #region Fields - private readonly ApplicationContext _context; - private IDbContextTransaction? _transaction; - #endregion - - #region Ctor - public UnitOfWork( - ApplicationContext context, - IGenericRepository storeCategories, - IGenericRepository storeItems, - IGenericRepository storeDiscounts, - IGenericRepository storeDiscountItems, - IGenericRepository storeOrders, - IGenericRepository storeOrderItems, - IGenericRepository storeOrderItemDiscounts) - { - _context = context; - StoreCategories = storeCategories; - StoreItems = storeItems; - StoreDiscounts = storeDiscounts; - StoreDiscountItems = storeDiscountItems; - StoreOrders = storeOrders; - StoreOrderItems = storeOrderItems; - StoreOrderItemDiscounts = storeOrderItemDiscounts; - } - #endregion - - #region Repositories - public IGenericRepository StoreCategories { get; } - public IGenericRepository StoreItems { get; } - public IGenericRepository StoreDiscounts { get; } - public IGenericRepository StoreDiscountItems { get; } - public IGenericRepository StoreOrders { get; } - public IGenericRepository StoreOrderItems { get; } - public IGenericRepository StoreOrderItemDiscounts { get; } - #endregion - - #region Save - public bool SaveChanges() => _context.SaveChanges() > 0; - - public async Task 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 -} diff --git a/StoreService/Services/IOrderService.cs b/StoreService/Services/IOrderService.cs deleted file mode 100644 index 4db00f9..0000000 --- a/StoreService/Services/IOrderService.cs +++ /dev/null @@ -1,17 +0,0 @@ -using StoreService.Models; - -namespace StoreService.Services; - -/// -/// Service responsible for order lifecycle (create, pay, redeem) including price calculation. -/// -public interface IOrderService -{ - #region Methods - Task CreateOrderAsync(CreateOrderRequest request, CancellationToken ct = default); - Task GetOrderAsync(long id, CancellationToken ct = default); - Task PayOrderAsync(long id, CancellationToken ct = default); - Task RedeemOrderItemsAsync(long id, CancellationToken ct = default); - #endregion -} - diff --git a/StoreService/Services/OrderService.cs b/StoreService/Services/OrderService.cs deleted file mode 100644 index 9bc0849..0000000 --- a/StoreService/Services/OrderService.cs +++ /dev/null @@ -1,172 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using StoreService.Database.Entities; -using StoreService.Models; -using StoreService.Repositories; - -namespace StoreService.Services; - -/// -/// Implements order creation, payment, redemption and price calculation logic. -/// -public class OrderService : IOrderService -{ - #region Fields - private readonly IUnitOfWork _uow; - private readonly ILogger _logger; - #endregion - - #region Ctor - public OrderService(IUnitOfWork uow, ILogger logger) - { - _uow = uow; - _logger = logger; - } - #endregion - - #region Public Methods - /// - public async Task 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() - }; - - 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); - } - - /// - public async Task 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); - } - - /// - public async Task 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); - } - - /// - public async Task 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 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 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 -} - diff --git a/StoreService/StoreService.http b/StoreService/StoreService.http deleted file mode 100644 index df06b0c..0000000 --- a/StoreService/StoreService.http +++ /dev/null @@ -1,6 +0,0 @@ -@StoreService_HostAddress = http://localhost:5141 - -GET {{StoreService_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/StoreService/appsettings.Development.json b/StoreService/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/StoreService/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/StoreService/appsettings.json b/StoreService/appsettings.json deleted file mode 100644 index 9c7216f..0000000 --- a/StoreService/appsettings.json +++ /dev/null @@ -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": "*" -} diff --git a/StoreService/obj/Debug/net9.0/.NETCoreApp,Version=v9.0.AssemblyAttributes.cs b/StoreService/obj/Debug/net9.0/.NETCoreApp,Version=v9.0.AssemblyAttributes.cs deleted file mode 100644 index feda5e9..0000000 --- a/StoreService/obj/Debug/net9.0/.NETCoreApp,Version=v9.0.AssemblyAttributes.cs +++ /dev/null @@ -1,4 +0,0 @@ -// -using System; -using System.Reflection; -[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0")] diff --git a/StoreService/obj/Debug/net9.0/StoreService.AssemblyInfo.cs b/StoreService/obj/Debug/net9.0/StoreService.AssemblyInfo.cs deleted file mode 100644 index 71902a9..0000000 --- a/StoreService/obj/Debug/net9.0/StoreService.AssemblyInfo.cs +++ /dev/null @@ -1,22 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -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. - diff --git a/StoreService/obj/Debug/net9.0/StoreService.GlobalUsings.g.cs b/StoreService/obj/Debug/net9.0/StoreService.GlobalUsings.g.cs deleted file mode 100644 index 025530a..0000000 --- a/StoreService/obj/Debug/net9.0/StoreService.GlobalUsings.g.cs +++ /dev/null @@ -1,17 +0,0 @@ -// -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;