Files
ROLAC/API/ROLAC.API/Services/MealAttendanceService.cs
T

153 lines
5.5 KiB
C#

using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.MealAttendance;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class MealAttendanceService : IMealAttendanceService
{
private readonly AppDbContext _db;
public MealAttendanceService(AppDbContext db) => _db = db;
// Server local time is assumed to match the church's local day.
public DateOnly ServiceDay
{
get
{
var today = DateOnly.FromDateTime(DateTime.Now);
return today.AddDays(-(int)today.DayOfWeek);
}
}
public async Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date)
{
await EnsureRowAsync(date);
return await ReadAsync(date);
}
public async Task<AttendanceCountsDto> IncrementAsync(DateOnly date, string category, int delta)
{
await EnsureRowAsync(date);
var rows = _db.MealAttendances.Where(a => a.AttendanceDate == date);
// Atomic, race-free server-side increment; never let a column go below zero.
switch (category.Trim().ToLowerInvariant())
{
case "adult":
await rows.ExecuteUpdateAsync(s => s.SetProperty(
a => a.AdultCount, a => a.AdultCount + delta < 0 ? 0 : a.AdultCount + delta));
break;
case "youth":
await rows.ExecuteUpdateAsync(s => s.SetProperty(
a => a.YouthCount, a => a.YouthCount + delta < 0 ? 0 : a.YouthCount + delta));
break;
case "kid":
await rows.ExecuteUpdateAsync(s => s.SetProperty(
a => a.KidCount, a => a.KidCount + delta < 0 ? 0 : a.KidCount + delta));
break;
default:
throw new ArgumentException($"Unknown attendance category '{category}'.", nameof(category));
}
return await ReadAsync(date);
}
public async Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value)
{
await EnsureRowAsync(date);
// Counts can never be negative; clamp before writing.
var clamped = value < 0 ? 0 : value;
var rows = _db.MealAttendances.Where(a => a.AttendanceDate == date);
switch (category.Trim().ToLowerInvariant())
{
case "adult":
await rows.ExecuteUpdateAsync(s => s.SetProperty(a => a.AdultCount, clamped));
break;
case "youth":
await rows.ExecuteUpdateAsync(s => s.SetProperty(a => a.YouthCount, clamped));
break;
case "kid":
await rows.ExecuteUpdateAsync(s => s.SetProperty(a => a.KidCount, clamped));
break;
default:
throw new ArgumentException($"Unknown attendance category '{category}'.", nameof(category));
}
return await ReadAsync(date);
}
public async Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid)
{
// Single-editor back-office path, so a tracked load + SaveChanges is fine here; no need for the
// race-safe EnsureRowAsync + ExecuteUpdateAsync pattern, which the EF InMemory test provider can't run.
var row = await _db.MealAttendances.FirstOrDefaultAsync(a => a.AttendanceDate == date);
if (row is null)
{
row = new MealAttendance { AttendanceDate = date };
_db.MealAttendances.Add(row);
}
// Counts can never be negative; clamp before writing.
row.AdultCount = adult < 0 ? 0 : adult;
row.YouthCount = youth < 0 ? 0 : youth;
row.KidCount = kid < 0 ? 0 : kid;
await _db.SaveChangesAsync();
return ToDto(row);
}
public async Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to)
{
var rows = await _db.MealAttendances.AsNoTracking()
.Where(a => a.AttendanceDate >= from && a.AttendanceDate <= to)
.OrderBy(a => a.AttendanceDate)
.ToListAsync();
return rows.Select(ToDto).ToList();
}
// ── helpers ──────────────────────────────────────────────────────────────
// Insert a zeroed row if today's row doesn't exist yet. A concurrent insert
// that wins the unique index throws DbUpdateException — harmless, the row exists.
private async Task EnsureRowAsync(DateOnly date)
{
if (await _db.MealAttendances.AsNoTracking().AnyAsync(a => a.AttendanceDate == date))
return;
var row = new MealAttendance { AttendanceDate = date };
_db.MealAttendances.Add(row);
try
{
await _db.SaveChangesAsync();
}
catch (DbUpdateException)
{
_db.Entry(row).State = EntityState.Detached;
}
}
private async Task<AttendanceCountsDto> ReadAsync(DateOnly date)
{
var row = await _db.MealAttendances.AsNoTracking()
.FirstOrDefaultAsync(a => a.AttendanceDate == date);
return row is null
? new AttendanceCountsDto { Date = date.ToString("yyyy-MM-dd") }
: ToDto(row);
}
private static AttendanceCountsDto ToDto(MealAttendance row) => new()
{
Date = row.AttendanceDate.ToString("yyyy-MM-dd"),
Adult = row.AdultCount,
Youth = row.YouthCount,
Kid = row.KidCount,
};
}