add attendance

This commit is contained in:
Chris Chen
2026-06-20 19:33:04 -07:00
parent 2af169fa60
commit 87425b3276
24 changed files with 1357 additions and 5 deletions
@@ -0,0 +1,125 @@
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 Today => DateOnly.FromDateTime(DateTime.Now);
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<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,
};
}