add attendance
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
using ROLAC.API.DTOs.MealAttendance;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public interface IMealAttendanceService
|
||||
{
|
||||
/// <summary>Today's date in the server's local time zone (the church's "current Sunday").</summary>
|
||||
DateOnly Today { get; }
|
||||
|
||||
/// <summary>Returns the counts for <paramref name="date"/>, creating a zeroed row if none exists.</summary>
|
||||
Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically adds <paramref name="delta"/> (may be negative) to one age-group column,
|
||||
/// clamped at zero, and returns the resulting authoritative counts.
|
||||
/// </summary>
|
||||
Task<AttendanceCountsDto> IncrementAsync(DateOnly date, string category, int delta);
|
||||
|
||||
/// <summary>
|
||||
/// Sets one age-group column to an absolute <paramref name="value"/> (clamped at zero),
|
||||
/// overwriting the current count, and returns the resulting authoritative counts.
|
||||
/// </summary>
|
||||
Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value);
|
||||
|
||||
/// <summary>Returns the daily counts within the inclusive date range, ordered by date (for the dashboard).</summary>
|
||||
Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user