Add Trigger-Engine (Phase H0)

- 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 <noreply@anthropic.com>
This commit is contained in:
beo3000 2025-12-26 14:10:28 +01:00
parent 29cebcbb81
commit e8df51d67f
8 changed files with 538 additions and 0 deletions

View File

@ -0,0 +1,76 @@
using Koogle.Domain.Enums;
namespace Koogle.Application.DTOs;
/// <summary>
/// Data transfer object representing a trigger.
/// </summary>
public record TriggerDto
{
/// <summary>
/// Unique identifier of the trigger.
/// </summary>
public Guid Id { get; init; }
/// <summary>
/// Name of the trigger.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// The type of expense trigger.
/// </summary>
public ExpenseTriggerType ExpenseTriggerType { get; init; }
/// <summary>
/// Expenses linked to this trigger for the current club.
/// </summary>
public List<ExpenseSummaryDto> LinkedExpenses { get; init; } = [];
}
/// <summary>
/// Data transfer object for linking/unlinking an expense to a trigger.
/// </summary>
public record ExpenseTriggerLinkDto
{
/// <summary>
/// The expense identifier.
/// </summary>
public Guid ExpenseId { get; init; }
/// <summary>
/// The trigger identifier.
/// </summary>
public Guid TriggerId { get; init; }
}
/// <summary>
/// Data transfer object for firing a trigger.
/// </summary>
public record FireTriggerDto
{
/// <summary>
/// The trigger type to fire.
/// </summary>
public ExpenseTriggerType TriggerType { get; init; }
/// <summary>
/// The person receiving the expense(s).
/// </summary>
public Guid PersonId { get; init; }
/// <summary>
/// The day context for the expense.
/// </summary>
public Guid DayId { get; init; }
/// <summary>
/// Optional game context for the expense.
/// </summary>
public Guid? GameId { get; init; }
/// <summary>
/// Multiplier for expense price (e.g., remaining points). Default is 1.
/// </summary>
public int Multiplier { get; init; } = 1;
}

View File

@ -31,6 +31,7 @@ namespace Koogle.Application
services.AddScoped<IPersonExpenseService, PersonExpenseService>();
services.AddScoped<IDashboardService, DashboardService>();
services.AddScoped<IEmailService, StubEmailService>();
services.AddScoped<ITriggerService, TriggerService>();
return services;
}

View File

@ -0,0 +1,68 @@
using Koogle.Application.DTOs;
using Koogle.Domain.Enums;
namespace Koogle.Application.Interfaces;
/// <summary>
/// Service interface for managing triggers and firing automatic expense assignments.
/// </summary>
public interface ITriggerService
{
/// <summary>
/// Gets all available trigger types with their associated expenses for the current club.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>A list of trigger DTOs with their linked expenses.</returns>
Task<List<TriggerDto>> GetAllTriggersAsync(CancellationToken ct = default);
/// <summary>
/// Gets all expenses linked to a specific trigger type for the current club.
/// </summary>
/// <param name="type">The expense trigger type.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A list of expenses linked to the trigger type.</returns>
Task<List<ExpenseDto>> GetExpensesForTriggerAsync(ExpenseTriggerType type, CancellationToken ct = default);
/// <summary>
/// Checks if a trigger type has any linked expenses for the current club.
/// </summary>
/// <param name="type">The expense trigger type.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if the trigger has linked expenses; otherwise, false.</returns>
Task<bool> HasExpensesForTriggerAsync(ExpenseTriggerType type, CancellationToken ct = default);
/// <summary>
/// Fires a trigger, creating PersonExpenses for all linked expenses.
/// </summary>
/// <param name="type">The trigger type (e.g., ExpensePoint, Circle, NinePins).</param>
/// <param name="personId">The person receiving the expense(s).</param>
/// <param name="dayId">The day context for the expense.</param>
/// <param name="gameId">Optional game context for the expense.</param>
/// <param name="multiplier">Multiplier for expense price (e.g., remaining points in Scheiss-Spiel). Default is 1.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A list of created PersonExpense DTOs.</returns>
Task<List<PersonExpenseDto>> FireTriggerAsync(
ExpenseTriggerType type,
Guid personId,
Guid dayId,
Guid? gameId = null,
int multiplier = 1,
CancellationToken ct = default);
/// <summary>
/// Links an expense to a trigger for the current club.
/// </summary>
/// <param name="expenseId">The expense to link.</param>
/// <param name="triggerId">The trigger to link to.</param>
/// <param name="ct">Cancellation token.</param>
Task LinkExpenseToTriggerAsync(Guid expenseId, Guid triggerId, CancellationToken ct = default);
/// <summary>
/// Unlinks an expense from a trigger for the current club.
/// </summary>
/// <param name="expenseId">The expense to unlink.</param>
/// <param name="triggerId">The trigger to unlink from.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if unlinked successfully; otherwise, false.</returns>
Task<bool> UnlinkExpenseFromTriggerAsync(Guid expenseId, Guid triggerId, CancellationToken ct = default);
}

View File

@ -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;
/// <summary>
/// Service for managing triggers and firing automatic expense assignments.
/// </summary>
public class TriggerService : ITriggerService
{
private readonly IDbContextFactory<AppDbContext> _contextFactory;
private readonly ITriggerRepository _triggerRepository;
private readonly ICurrentClubContext _clubContext;
private readonly IMapper _mapper;
/// <summary>
/// Initializes a new instance of the <see cref="TriggerService"/> class.
/// </summary>
/// <param name="contextFactory">The database context factory.</param>
/// <param name="triggerRepository">The trigger repository.</param>
/// <param name="clubContext">The current club context.</param>
/// <param name="mapper">The AutoMapper instance.</param>
public TriggerService(
IDbContextFactory<AppDbContext> contextFactory,
ITriggerRepository triggerRepository,
ICurrentClubContext clubContext,
IMapper mapper)
{
_contextFactory = contextFactory;
_triggerRepository = triggerRepository;
_clubContext = clubContext;
_mapper = mapper;
}
/// <inheritdoc />
public async Task<List<TriggerDto>> 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;
}
/// <inheritdoc />
public async Task<List<ExpenseDto>> GetExpensesForTriggerAsync(ExpenseTriggerType type, CancellationToken ct = default)
{
var expenses = await _triggerRepository.GetExpensesByTriggerTypeAsync(_clubContext.ClubId, type, ct);
return _mapper.Map<List<ExpenseDto>>(expenses);
}
/// <inheritdoc />
public async Task<bool> HasExpensesForTriggerAsync(ExpenseTriggerType type, CancellationToken ct = default)
{
var expenses = await _triggerRepository.GetExpensesByTriggerTypeAsync(_clubContext.ClubId, type, ct);
return expenses.Count > 0;
}
/// <inheritdoc />
public async Task<List<PersonExpenseDto>> 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<PersonExpense>();
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<PersonExpenseDto>();
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;
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
public async Task<bool> UnlinkExpenseFromTriggerAsync(Guid expenseId, Guid triggerId, CancellationToken ct = default)
{
return await _triggerRepository.RemoveExpenseTriggerAsync(_clubContext.ClubId, expenseId, triggerId, ct);
}
/// <summary>
/// Maps a PersonExpense entity to a PersonExpenseDto.
/// </summary>
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
};
}
}

View File

@ -0,0 +1,67 @@
using Koogle.Domain.Entities;
using Koogle.Domain.Enums;
namespace Koogle.Domain.Interfaces;
/// <summary>
/// Repository interface for managing trigger entities and expense-trigger relationships.
/// </summary>
public interface ITriggerRepository
{
/// <summary>
/// Retrieves all triggers.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>A list of all triggers.</returns>
Task<List<Trigger>> GetAllAsync(CancellationToken ct = default);
/// <summary>
/// Retrieves a trigger by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the trigger.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The trigger if found; otherwise, null.</returns>
Task<Trigger?> GetByIdAsync(Guid id, CancellationToken ct = default);
/// <summary>
/// Retrieves a trigger by its type.
/// </summary>
/// <param name="type">The expense trigger type.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The trigger if found; otherwise, null.</returns>
Task<Trigger?> GetByTypeAsync(ExpenseTriggerType type, CancellationToken ct = default);
/// <summary>
/// Retrieves all expense-trigger mappings for a specific club.
/// </summary>
/// <param name="clubId">The unique identifier of the club.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A list of expense-trigger mappings for the club.</returns>
Task<List<ExpenseTrigger>> GetExpenseTriggersByClubIdAsync(Guid clubId, CancellationToken ct = default);
/// <summary>
/// Retrieves all expenses linked to a specific trigger type for a club.
/// </summary>
/// <param name="clubId">The unique identifier of the club.</param>
/// <param name="type">The expense trigger type.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A list of expenses linked to the trigger type.</returns>
Task<List<Expense>> GetExpensesByTriggerTypeAsync(Guid clubId, ExpenseTriggerType type, CancellationToken ct = default);
/// <summary>
/// Adds an expense-trigger mapping.
/// </summary>
/// <param name="expenseTrigger">The expense-trigger mapping to add.</param>
/// <param name="ct">Cancellation token.</param>
Task AddExpenseTriggerAsync(ExpenseTrigger expenseTrigger, CancellationToken ct = default);
/// <summary>
/// Removes an expense-trigger mapping.
/// </summary>
/// <param name="clubId">The club identifier.</param>
/// <param name="expenseId">The expense identifier.</param>
/// <param name="triggerId">The trigger identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if removed successfully; otherwise, false.</returns>
Task<bool> RemoveExpenseTriggerAsync(Guid clubId, Guid expenseId, Guid triggerId, CancellationToken ct = default);
}

View File

@ -79,6 +79,7 @@ public static class DependencyInjection
services.AddScoped<IExpenseRepository, ExpenseRepository>();
services.AddScoped<IDayRepository, DayRepository>();
services.AddScoped<IPersonExpenseRepository, PersonExpenseRepository>();
services.AddScoped<ITriggerRepository, TriggerRepository>();
// Services
//services.AddScoped<IEmailService, StubEmailService>();

View File

@ -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;
/// <summary>
/// Repository implementation for managing trigger entities and expense-trigger relationships.
/// </summary>
/// <param name="contextFactory">The database context factory for creating scoped contexts.</param>
public class TriggerRepository(IDbContextFactory<AppDbContext> contextFactory) : ITriggerRepository
{
/// <inheritdoc />
public async Task<List<Trigger>> 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);
}
/// <inheritdoc />
public async Task<Trigger?> 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);
}
/// <inheritdoc />
public async Task<Trigger?> 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);
}
/// <inheritdoc />
public async Task<List<ExpenseTrigger>> 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);
}
/// <inheritdoc />
public async Task<List<Expense>> 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);
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
public async Task<bool> 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;
}
}

View File

@ -408,4 +408,15 @@ internal class ClubRepository : Koogle.Domain.Interfaces.IClubRepository
await _context.SaveChangesAsync(ct);
return true;
}
public async Task<Club?> 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);
}
}