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,26 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/meal-attendance")]
public class MealAttendanceController : ControllerBase
{
private readonly IMealAttendanceService _svc;
public MealAttendanceController(IMealAttendanceService svc) => _svc = svc;
/// <summary>Today's live counts. Public — feeds the volunteer counter page on first load.</summary>
[HttpGet("today")]
[AllowAnonymous]
public async Task<IActionResult> GetToday()
=> Ok(await _svc.GetOrCreateAsync(_svc.Today));
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
[HttpGet]
[Authorize]
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
=> Ok(await _svc.GetRangeAsync(from, to));
}
@@ -0,0 +1,10 @@
namespace ROLAC.API.DTOs.MealAttendance;
/// <summary>The current head-count for one Sunday, broadcast over SignalR.</summary>
public class AttendanceCountsDto
{
public string Date { get; set; } = ""; // yyyy-MM-dd (local)
public int Adult { get; set; }
public int Youth { get; set; }
public int Kid { get; set; }
}
+12
View File
@@ -22,6 +22,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
public DbSet<Check> Checks => Set<Check>();
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -284,6 +285,17 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
.HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Restrict);
});
// ── MealAttendance (one shared row per Sunday) ───────────────────────
builder.Entity<MealAttendance>(entity =>
{
entity.Property(e => e.AdultCount).HasDefaultValue(0);
entity.Property(e => e.YouthCount).HasDefaultValue(0);
entity.Property(e => e.KidCount).HasDefaultValue(0);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.AttendanceDate).IsUnique();
});
// ── MonthlyStatement ─────────────────────────────────────────────────
builder.Entity<MonthlyStatement>(entity =>
{
+17
View File
@@ -0,0 +1,17 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// One row per Sunday holding the live shared head-count for the three
/// age groups. Volunteers increment these concurrently from the public
/// counter page; the columns are updated with atomic SQL increments.
/// </summary>
public class MealAttendance : AuditableEntity
{
public int Id { get; set; }
public DateOnly AttendanceDate { get; set; }
public int AdultCount { get; set; }
public int YouthCount { get; set; }
public int KidCount { get; set; }
}
+39
View File
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.SignalR;
using ROLAC.API.Services;
namespace ROLAC.API.Hubs;
/// <summary>
/// Real-time hub backing the public Sunday attendance counter. Anonymous
/// (no [Authorize]) so volunteers can use it without logging in. Every
/// increment is broadcast to all connected clients so multiple people can
/// count the same Sunday together and see each other's changes instantly.
/// </summary>
public class AttendanceHub : Hub
{
private readonly IMealAttendanceService _svc;
public AttendanceHub(IMealAttendanceService svc) => _svc = svc;
// Push the current counts to a client the moment it connects.
public override async Task OnConnectedAsync()
{
var counts = await _svc.GetOrCreateAsync(_svc.Today);
await Clients.Caller.SendAsync("ReceiveCounts", counts);
await base.OnConnectedAsync();
}
// Apply a batched delta for one age group, then broadcast the new totals to everyone.
public async Task Increment(string category, int delta)
{
var counts = await _svc.IncrementAsync(_svc.Today, category, delta);
await Clients.All.SendAsync("ReceiveCounts", counts);
}
// Overwrite one age group with an absolute value, then broadcast the new totals to everyone.
public async Task SetCount(string category, int value)
{
var counts = await _svc.SetAsync(_svc.Today, category, value);
await Clients.All.SendAsync("ReceiveCounts", counts);
}
}
@@ -885,6 +885,56 @@ namespace ROLAC.API.Migrations
b.ToTable("GivingCategories");
});
modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AdultCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateOnly>("AttendanceDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int>("KidCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int>("YouthCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.HasKey("Id");
b.HasIndex("AttendanceDate")
.IsUnique();
b.ToTable("MealAttendances");
});
modelBuilder.Entity("ROLAC.API.Entities.Member", b =>
{
b.Property<int>("Id")
+5
View File
@@ -133,6 +133,10 @@ builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
ROLAC.API.Services.Disbursement.CheckPrintService>();
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
// Real-time hub for the live Sunday attendance counter.
builder.Services.AddSignalR();
// ---------------------------------------------------------------------------
// Swagger / MVC
@@ -207,6 +211,7 @@ app.UseCors("Angular");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<ROLAC.API.Hubs.AttendanceHub>("/hubs/attendance");
app.MapHealthChecks("/health");
app.Run();
@@ -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,
};
}