add attendance
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user