From e8df51d67f12fb8ab685000dd499fec83a286b91 Mon Sep 17 00:00:00 2001 From: beo3000 Date: Fri, 26 Dec 2025 14:10:28 +0100 Subject: [PATCH] Add Trigger-Engine (Phase H0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ITriggerRepository interface + TriggerRepository implementation - ITriggerService interface + TriggerService implementation - TriggerDto, ExpenseTriggerLinkDto, FireTriggerDto - FireTriggerAsync creates PersonExpenses with multiplier - DI registration for both services - Fix test mock for IClubRepository 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/Koogle.Application/DTOs/TriggerDto.cs | 76 ++++++ src/Koogle.Application/DependencyInjection.cs | 1 + .../Interfaces/ITriggerService.cs | 68 ++++++ .../Services/TriggerService.cs | 228 ++++++++++++++++++ .../Interfaces/ITriggerRepository.cs | 67 +++++ .../DependencyInjection.cs | 1 + .../Repositories/TriggerRepository.cs | 86 +++++++ .../ClubServiceIntegrationTests.cs | 11 + 8 files changed, 538 insertions(+) create mode 100644 src/Koogle.Application/DTOs/TriggerDto.cs create mode 100644 src/Koogle.Application/Interfaces/ITriggerService.cs create mode 100644 src/Koogle.Application/Services/TriggerService.cs create mode 100644 src/Koogle.Domain/Interfaces/ITriggerRepository.cs create mode 100644 src/Koogle.Infrastructure/Repositories/TriggerRepository.cs diff --git a/src/Koogle.Application/DTOs/TriggerDto.cs b/src/Koogle.Application/DTOs/TriggerDto.cs new file mode 100644 index 0000000..a13bf69 --- /dev/null +++ b/src/Koogle.Application/DTOs/TriggerDto.cs @@ -0,0 +1,76 @@ +using Koogle.Domain.Enums; + +namespace Koogle.Application.DTOs; + +/// +/// Data transfer object representing a trigger. +/// +public record TriggerDto +{ + /// + /// Unique identifier of the trigger. + /// + public Guid Id { get; init; } + + /// + /// Name of the trigger. + /// + public string Name { get; init; } = string.Empty; + + /// + /// The type of expense trigger. + /// + public ExpenseTriggerType ExpenseTriggerType { get; init; } + + /// + /// Expenses linked to this trigger for the current club. + /// + public List LinkedExpenses { get; init; } = []; +} + +/// +/// Data transfer object for linking/unlinking an expense to a trigger. +/// +public record ExpenseTriggerLinkDto +{ + /// + /// The expense identifier. + /// + public Guid ExpenseId { get; init; } + + /// + /// The trigger identifier. + /// + public Guid TriggerId { get; init; } +} + +/// +/// Data transfer object for firing a trigger. +/// +public record FireTriggerDto +{ + /// + /// The trigger type to fire. + /// + public ExpenseTriggerType TriggerType { get; init; } + + /// + /// The person receiving the expense(s). + /// + public Guid PersonId { get; init; } + + /// + /// The day context for the expense. + /// + public Guid DayId { get; init; } + + /// + /// Optional game context for the expense. + /// + public Guid? GameId { get; init; } + + /// + /// Multiplier for expense price (e.g., remaining points). Default is 1. + /// + public int Multiplier { get; init; } = 1; +} diff --git a/src/Koogle.Application/DependencyInjection.cs b/src/Koogle.Application/DependencyInjection.cs index c9f7b99..1ff724a 100644 --- a/src/Koogle.Application/DependencyInjection.cs +++ b/src/Koogle.Application/DependencyInjection.cs @@ -31,6 +31,7 @@ namespace Koogle.Application services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Koogle.Application/Interfaces/ITriggerService.cs b/src/Koogle.Application/Interfaces/ITriggerService.cs new file mode 100644 index 0000000..6240bfd --- /dev/null +++ b/src/Koogle.Application/Interfaces/ITriggerService.cs @@ -0,0 +1,68 @@ +using Koogle.Application.DTOs; +using Koogle.Domain.Enums; + +namespace Koogle.Application.Interfaces; + +/// +/// Service interface for managing triggers and firing automatic expense assignments. +/// +public interface ITriggerService +{ + /// + /// Gets all available trigger types with their associated expenses for the current club. + /// + /// Cancellation token. + /// A list of trigger DTOs with their linked expenses. + Task> GetAllTriggersAsync(CancellationToken ct = default); + + /// + /// Gets all expenses linked to a specific trigger type for the current club. + /// + /// The expense trigger type. + /// Cancellation token. + /// A list of expenses linked to the trigger type. + Task> GetExpensesForTriggerAsync(ExpenseTriggerType type, CancellationToken ct = default); + + /// + /// Checks if a trigger type has any linked expenses for the current club. + /// + /// The expense trigger type. + /// Cancellation token. + /// True if the trigger has linked expenses; otherwise, false. + Task HasExpensesForTriggerAsync(ExpenseTriggerType type, CancellationToken ct = default); + + /// + /// Fires a trigger, creating PersonExpenses for all linked expenses. + /// + /// The trigger type (e.g., ExpensePoint, Circle, NinePins). + /// The person receiving the expense(s). + /// The day context for the expense. + /// Optional game context for the expense. + /// Multiplier for expense price (e.g., remaining points in Scheiss-Spiel). Default is 1. + /// Cancellation token. + /// A list of created PersonExpense DTOs. + Task> FireTriggerAsync( + ExpenseTriggerType type, + Guid personId, + Guid dayId, + Guid? gameId = null, + int multiplier = 1, + CancellationToken ct = default); + + /// + /// Links an expense to a trigger for the current club. + /// + /// The expense to link. + /// The trigger to link to. + /// Cancellation token. + Task LinkExpenseToTriggerAsync(Guid expenseId, Guid triggerId, CancellationToken ct = default); + + /// + /// Unlinks an expense from a trigger for the current club. + /// + /// The expense to unlink. + /// The trigger to unlink from. + /// Cancellation token. + /// True if unlinked successfully; otherwise, false. + Task UnlinkExpenseFromTriggerAsync(Guid expenseId, Guid triggerId, CancellationToken ct = default); +} diff --git a/src/Koogle.Application/Services/TriggerService.cs b/src/Koogle.Application/Services/TriggerService.cs new file mode 100644 index 0000000..a8d94ee --- /dev/null +++ b/src/Koogle.Application/Services/TriggerService.cs @@ -0,0 +1,228 @@ +using AutoMapper; +using Koogle.Application.DTOs; +using Koogle.Application.Interfaces; +using Koogle.Domain.Entities; +using Koogle.Domain.Enums; +using Koogle.Domain.Interfaces; +using KoogleApp.Data; +using Microsoft.EntityFrameworkCore; + +namespace Koogle.Application.Services; + +/// +/// Service for managing triggers and firing automatic expense assignments. +/// +public class TriggerService : ITriggerService +{ + private readonly IDbContextFactory _contextFactory; + private readonly ITriggerRepository _triggerRepository; + private readonly ICurrentClubContext _clubContext; + private readonly IMapper _mapper; + + /// + /// Initializes a new instance of the class. + /// + /// The database context factory. + /// The trigger repository. + /// The current club context. + /// The AutoMapper instance. + public TriggerService( + IDbContextFactory contextFactory, + ITriggerRepository triggerRepository, + ICurrentClubContext clubContext, + IMapper mapper) + { + _contextFactory = contextFactory; + _triggerRepository = triggerRepository; + _clubContext = clubContext; + _mapper = mapper; + } + + /// + public async Task> GetAllTriggersAsync(CancellationToken ct = default) + { + var triggers = await _triggerRepository.GetAllAsync(ct); + var expenseTriggers = await _triggerRepository.GetExpenseTriggersByClubIdAsync(_clubContext.ClubId, ct); + + var result = triggers.Select(t => new TriggerDto + { + Id = t.Id, + Name = t.Name, + ExpenseTriggerType = t.ExpenseTriggerType, + LinkedExpenses = expenseTriggers + .Where(et => et.TriggerId == t.Id && !et.Expense.IsDeleted) + .Select(et => new ExpenseSummaryDto + { + Id = et.Expense.Id, + Name = et.Expense.Name, + Price = et.Expense.Price, + IsOneClick = et.Expense.IsOneClick + }) + .ToList() + }).ToList(); + + return result; + } + + /// + public async Task> GetExpensesForTriggerAsync(ExpenseTriggerType type, CancellationToken ct = default) + { + var expenses = await _triggerRepository.GetExpensesByTriggerTypeAsync(_clubContext.ClubId, type, ct); + return _mapper.Map>(expenses); + } + + /// + public async Task HasExpensesForTriggerAsync(ExpenseTriggerType type, CancellationToken ct = default) + { + var expenses = await _triggerRepository.GetExpensesByTriggerTypeAsync(_clubContext.ClubId, type, ct); + return expenses.Count > 0; + } + + /// + public async Task> FireTriggerAsync( + ExpenseTriggerType type, + Guid personId, + Guid dayId, + Guid? gameId = null, + int multiplier = 1, + CancellationToken ct = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(ct); + + // Get all expenses linked to this trigger type for the current club + var expenses = await _triggerRepository.GetExpensesByTriggerTypeAsync(_clubContext.ClubId, type, ct); + + if (expenses.Count == 0) + return []; + + // Validate day exists and is not closed + var day = await context.Days + .Where(d => d.Id == dayId && d.ClubId == _clubContext.ClubId && !d.IsDeleted) + .FirstOrDefaultAsync(ct) + ?? throw new InvalidOperationException($"Day with id {dayId} not found."); + + if (day.Status == DayStatus.Closed) + throw new InvalidOperationException("Cannot add expenses to a closed day."); + + // Validate person exists + var person = await context.Persons + .Where(p => p.Id == personId && p.ClubId == _clubContext.ClubId && !p.IsDeleted) + .FirstOrDefaultAsync(ct) + ?? throw new InvalidOperationException($"Person with id {personId} not found."); + + var createdExpenses = new List(); + + foreach (var expense in expenses) + { + // Calculate price with multiplier + var price = expense.Price * multiplier; + + var personExpense = new PersonExpense + { + Id = Guid.NewGuid(), + PersonId = personId, + ExpenseId = expense.Id, + DayId = dayId, + GameId = gameId, + ClubId = _clubContext.ClubId, + Name = expense.Name, + Price = price, + ExpenseType = expense.ExpenseType, + PersonExpenseStatus = PersonExpenseStatus.Open, + AssignedById = Guid.Empty, // TODO: Get from ICurrentUserService when available + CreatedAt = DateTime.UtcNow, + IsDeleted = false + }; + + context.PersonExpenses.Add(personExpense); + createdExpenses.Add(personExpense); + } + + await context.SaveChangesAsync(ct); + + // Reload with navigation properties + var result = new List(); + foreach (var pe in createdExpenses) + { + var loaded = await context.PersonExpenses + .Where(p => p.Id == pe.Id) + .Include(p => p.Person) + .Include(p => p.Day) + .FirstOrDefaultAsync(ct); + + if (loaded is not null) + { + result.Add(MapToDto(loaded)); + } + } + + return result; + } + + /// + public async Task LinkExpenseToTriggerAsync(Guid expenseId, Guid triggerId, CancellationToken ct = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(ct); + + // Validate expense exists for current club + var expense = await context.Expenses + .Where(e => e.Id == expenseId && e.ClubId == _clubContext.ClubId && !e.IsDeleted) + .FirstOrDefaultAsync(ct) + ?? throw new InvalidOperationException($"Expense with id {expenseId} not found."); + + // Validate trigger exists + var trigger = await _triggerRepository.GetByIdAsync(triggerId, ct) + ?? throw new InvalidOperationException($"Trigger with id {triggerId} not found."); + + // Check if mapping already exists + var existing = await context.ExpenseTriggers + .FirstOrDefaultAsync(et => + et.ClubId == _clubContext.ClubId && + et.ExpenseId == expenseId && + et.TriggerId == triggerId, ct); + + if (existing is not null) + return; // Already linked + + var expenseTrigger = new ExpenseTrigger + { + ClubId = _clubContext.ClubId, + ExpenseId = expenseId, + TriggerId = triggerId, + AssignedAt = DateTime.UtcNow, + AssignedById = Guid.Empty // TODO: Get from ICurrentUserService when available + }; + + await _triggerRepository.AddExpenseTriggerAsync(expenseTrigger, ct); + } + + /// + public async Task UnlinkExpenseFromTriggerAsync(Guid expenseId, Guid triggerId, CancellationToken ct = default) + { + return await _triggerRepository.RemoveExpenseTriggerAsync(_clubContext.ClubId, expenseId, triggerId, ct); + } + + /// + /// Maps a PersonExpense entity to a PersonExpenseDto. + /// + private static PersonExpenseDto MapToDto(PersonExpense entity) + { + return new PersonExpenseDto + { + Id = entity.Id, + PersonId = entity.PersonId, + PersonName = entity.Person?.Name ?? string.Empty, + ExpenseId = entity.ExpenseId, + DayId = entity.DayId, + DayPostDate = entity.Day?.PostDate ?? DateTime.MinValue, + GameId = entity.GameId, + ClubId = entity.ClubId, + Name = entity.Name, + Price = entity.Price, + ExpenseType = entity.ExpenseType, + PersonExpenseStatus = entity.PersonExpenseStatus, + AssignedById = entity.AssignedById, + CreatedAt = entity.CreatedAt + }; + } +} diff --git a/src/Koogle.Domain/Interfaces/ITriggerRepository.cs b/src/Koogle.Domain/Interfaces/ITriggerRepository.cs new file mode 100644 index 0000000..7c6b8c4 --- /dev/null +++ b/src/Koogle.Domain/Interfaces/ITriggerRepository.cs @@ -0,0 +1,67 @@ +using Koogle.Domain.Entities; +using Koogle.Domain.Enums; + +namespace Koogle.Domain.Interfaces; + +/// +/// Repository interface for managing trigger entities and expense-trigger relationships. +/// +public interface ITriggerRepository +{ + /// + /// Retrieves all triggers. + /// + /// Cancellation token. + /// A list of all triggers. + Task> GetAllAsync(CancellationToken ct = default); + + /// + /// Retrieves a trigger by its unique identifier. + /// + /// The unique identifier of the trigger. + /// Cancellation token. + /// The trigger if found; otherwise, null. + Task GetByIdAsync(Guid id, CancellationToken ct = default); + + /// + /// Retrieves a trigger by its type. + /// + /// The expense trigger type. + /// Cancellation token. + /// The trigger if found; otherwise, null. + Task GetByTypeAsync(ExpenseTriggerType type, CancellationToken ct = default); + + /// + /// Retrieves all expense-trigger mappings for a specific club. + /// + /// The unique identifier of the club. + /// Cancellation token. + /// A list of expense-trigger mappings for the club. + Task> GetExpenseTriggersByClubIdAsync(Guid clubId, CancellationToken ct = default); + + /// + /// Retrieves all expenses linked to a specific trigger type for a club. + /// + /// The unique identifier of the club. + /// The expense trigger type. + /// Cancellation token. + /// A list of expenses linked to the trigger type. + Task> GetExpensesByTriggerTypeAsync(Guid clubId, ExpenseTriggerType type, CancellationToken ct = default); + + /// + /// Adds an expense-trigger mapping. + /// + /// The expense-trigger mapping to add. + /// Cancellation token. + Task AddExpenseTriggerAsync(ExpenseTrigger expenseTrigger, CancellationToken ct = default); + + /// + /// Removes an expense-trigger mapping. + /// + /// The club identifier. + /// The expense identifier. + /// The trigger identifier. + /// Cancellation token. + /// True if removed successfully; otherwise, false. + Task RemoveExpenseTriggerAsync(Guid clubId, Guid expenseId, Guid triggerId, CancellationToken ct = default); +} diff --git a/src/Koogle.Infrastructure/DependencyInjection.cs b/src/Koogle.Infrastructure/DependencyInjection.cs index 08dde99..5f7eae5 100644 --- a/src/Koogle.Infrastructure/DependencyInjection.cs +++ b/src/Koogle.Infrastructure/DependencyInjection.cs @@ -79,6 +79,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Services //services.AddScoped(); diff --git a/src/Koogle.Infrastructure/Repositories/TriggerRepository.cs b/src/Koogle.Infrastructure/Repositories/TriggerRepository.cs new file mode 100644 index 0000000..d65cf62 --- /dev/null +++ b/src/Koogle.Infrastructure/Repositories/TriggerRepository.cs @@ -0,0 +1,86 @@ +using Koogle.Domain.Entities; +using Koogle.Domain.Enums; +using Koogle.Domain.Interfaces; +using KoogleApp.Data; +using Microsoft.EntityFrameworkCore; + +namespace Koogle.Infrastructure.Repositories; + +/// +/// Repository implementation for managing trigger entities and expense-trigger relationships. +/// +/// The database context factory for creating scoped contexts. +public class TriggerRepository(IDbContextFactory contextFactory) : ITriggerRepository +{ + /// + public async Task> GetAllAsync(CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + return await context.Triggers + .Where(t => !t.IsDeleted) + .OrderBy(t => t.Name) + .ToListAsync(ct); + } + + /// + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + return await context.Triggers + .FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted, ct); + } + + /// + public async Task GetByTypeAsync(ExpenseTriggerType type, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + return await context.Triggers + .FirstOrDefaultAsync(t => t.ExpenseTriggerType == type && !t.IsDeleted, ct); + } + + /// + public async Task> GetExpenseTriggersByClubIdAsync(Guid clubId, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + return await context.ExpenseTriggers + .Where(et => et.ClubId == clubId) + .Include(et => et.Expense) + .Include(et => et.Trigger) + .ToListAsync(ct); + } + + /// + public async Task> GetExpensesByTriggerTypeAsync(Guid clubId, ExpenseTriggerType type, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + return await context.ExpenseTriggers + .Where(et => et.ClubId == clubId && et.Trigger.ExpenseTriggerType == type) + .Select(et => et.Expense) + .Where(e => !e.IsDeleted) + .ToListAsync(ct); + } + + /// + public async Task AddExpenseTriggerAsync(ExpenseTrigger expenseTrigger, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + expenseTrigger.AssignedAt = DateTime.UtcNow; + await context.ExpenseTriggers.AddAsync(expenseTrigger, ct); + await context.SaveChangesAsync(ct); + } + + /// + public async Task RemoveExpenseTriggerAsync(Guid clubId, Guid expenseId, Guid triggerId, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + var entity = await context.ExpenseTriggers + .FirstOrDefaultAsync(et => et.ClubId == clubId && et.ExpenseId == expenseId && et.TriggerId == triggerId, ct); + + if (entity is null) + return false; + + context.ExpenseTriggers.Remove(entity); + await context.SaveChangesAsync(ct); + return true; + } +} diff --git a/test/Koogle.Tests/Integration/ClubServiceIntegrationTests.cs b/test/Koogle.Tests/Integration/ClubServiceIntegrationTests.cs index b389ac7..ae3643b 100644 --- a/test/Koogle.Tests/Integration/ClubServiceIntegrationTests.cs +++ b/test/Koogle.Tests/Integration/ClubServiceIntegrationTests.cs @@ -408,4 +408,15 @@ internal class ClubRepository : Koogle.Domain.Interfaces.IClubRepository await _context.SaveChangesAsync(ct); return true; } + + public async Task GetByNameAsync(string clubName, Guid? excludeGuid, CancellationToken ct = default) + { + var query = _context.Clubs + .Where(c => !c.IsDeleted && c.Name.ToLower() == clubName.ToLower()); + if (excludeGuid.HasValue) + { + query = query.Where(c => c.Id != excludeGuid.Value); + } + return await query.FirstOrDefaultAsync(ct); + } }