Compare commits

...

16 Commits

Author SHA1 Message Date
Chris Chen cfd344f48c Update dashboard.component.html
ci-cd-vm / ci-cd (push) Successful in 1m45s
2026-06-24 12:34:56 -07:00
Chris Chen 4dc7ff7df7 Update member.model.ts
ci-cd-vm / ci-cd (push) Successful in 1m38s
2026-06-24 12:07:02 -07:00
Chris Chen e9aad74df6 update quick add.
ci-cd-vm / ci-cd (push) Successful in 1m40s
2026-06-24 12:01:55 -07:00
Chris Chen e768f53ccc feat(giving): show Sunday attendance per session and add edit action 2026-06-24 11:40:44 -07:00
Chris Chen b0e2e112fc feat(giving): add sundayAttendanceCount model field and attendance setCounts API
ci-cd-vm / ci-cd (push) Successful in 2m21s
2026-06-24 11:35:34 -07:00
Chris Chen 28eba8a3ea feat(giving): include Sunday attendance total in offering session list 2026-06-24 11:24:31 -07:00
Chris Chen 7eb6a4db78 feat(attendance): add PUT /api/meal-attendance/{date} to overwrite a Sunday's counts 2026-06-24 11:18:27 -07:00
Chris Chen 7dc03f3bc0 docs(attendance): explain SetCountsAsync divergence from ExecuteUpdate path 2026-06-24 11:17:19 -07:00
Chris Chen 8d91bbeb31 feat(attendance): add SetCountsAsync to set all three age groups for a date 2026-06-24 11:14:09 -07:00
Chris Chen 182f8bf74c Merge branch 'feature/member-invitation-links'
ci-cd-vm / ci-cd (push) Successful in 2m31s
Add member invitation links: passwordless first login with forced password
set. Admins generate a single-use, 7-day link (copy or email); the member
opens it to set their own password and is logged straight in. Auto-creates a
passwordless account for members without one; re-issuing revokes prior links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:54:55 -07:00
Chris Chen a88567fea6 Track AddUserInvitations migration files
Force-add the EF migration excluded by the Migrations/ gitignore rule, so
the UserInvitations table migration is versioned alongside the feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:54:45 -07:00
Chris Chen e53cea7a82 Add init link. 2026-06-24 10:53:13 -07:00
Chris Chen e88ea7917f add church profile.
ci-cd-vm / ci-cd (push) Successful in 2m31s
2026-06-24 08:21:31 -07:00
Chris Chen 99585a1c0e Update dashboard.component.ts
ci-cd-vm / ci-cd (push) Successful in 3m0s
2026-06-23 20:38:11 -07:00
Chris Chen d327a5146c Merge branch 'feature/change-password' 2026-06-23 20:36:26 -07:00
Chris Chen 4276ca890b WIP 2026-06-23 20:36:18 -07:00
83 changed files with 5400 additions and 109 deletions
@@ -169,6 +169,48 @@ public class AuthServiceTests
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once); um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
} }
[Fact]
public async Task Login_LinkedMember_ReturnsMemberInfo()
{
var db = BuildDb();
db.Members.Add(new Member
{
Id = 7,
NickName = "Johnny",
FirstName_en = "John",
LastName_en = "Chen",
LastName_zh = "陳",
CreatedBy = "seed",
UpdatedBy = "seed",
});
await db.SaveChangesAsync();
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = 7 };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, db);
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
Assert.NotNull(response.User.MemberInfo);
Assert.Equal(7, response.User.MemberInfo!.Id);
Assert.Equal("Johnny", response.User.MemberInfo.NickName);
Assert.Equal("Chen", response.User.MemberInfo.LastName_en);
}
[Fact]
public async Task Login_AdminOnlyAccount_ReturnsNullMemberInfo()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = null };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
Assert.Null(response.User.MemberInfo);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Refresh tests // Refresh tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -0,0 +1,60 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class MealAttendanceServiceTests
{
// MealAttendance is auditable, so the InMemory provider requires CreatedBy/UpdatedBy
// to be set before insert. Wire in the AuditSaveChangesInterceptor (as the other
// service tests do) so those columns are stamped automatically on SaveChanges.
private static AppDbContext BuildDb()
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(
new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
[Fact]
public async Task SetCountsAsync_CreatesRowWhenMissing_AndReturnsTotals()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
var result = await svc.SetCountsAsync(date, adult: 40, youth: 12, kid: 8);
Assert.Equal("2026-05-31", result.Date);
Assert.Equal(40, result.Adult);
Assert.Equal(12, result.Youth);
Assert.Equal(8, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date));
}
[Fact]
public async Task SetCountsAsync_OverwritesExistingRow_AndClampsNegativesToZero()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
await svc.SetCountsAsync(date, 40, 12, 8);
var result = await svc.SetCountsAsync(date, adult: 50, youth: -3, kid: 0);
Assert.Equal(50, result.Adult);
Assert.Equal(0, result.Youth); // negative clamped to zero
Assert.Equal(0, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date)); // still one row
}
}
@@ -1,6 +1,5 @@
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Options;
using ROLAC.API.Services.Notifications; using ROLAC.API.Services.Notifications;
using Xunit; using Xunit;
@@ -8,6 +7,14 @@ namespace ROLAC.API.Tests.Services.Notifications;
public class LineMessageChannelTests public class LineMessageChannelTests
{ {
// Stub settings provider returning fixed SMTP/Line values for the channel under test.
private sealed class StubSettings : INotificationSettingsService
{
public SmtpOptions GetSmtp() => new();
public LineOptions GetLine() => new() { ChannelAccessToken = "tok", ChannelSecret = "sec" };
public void Reload() { }
}
// Captures the outgoing request and returns a canned response. // Captures the outgoing request and returns a canned response.
private sealed class CapturingHandler : HttpMessageHandler private sealed class CapturingHandler : HttpMessageHandler
{ {
@@ -28,8 +35,7 @@ public class LineMessageChannelTests
private static LineMessageChannel BuildChannel(CapturingHandler handler) private static LineMessageChannel BuildChannel(CapturingHandler handler)
{ {
var http = new HttpClient(handler); var http = new HttpClient(handler);
var options = Options.Create(new LineOptions { ChannelAccessToken = "tok", ChannelSecret = "sec" }); return new LineMessageChannel(http, new StubSettings());
return new LineMessageChannel(http, options);
} }
[Fact] [Fact]
@@ -164,4 +164,27 @@ public class OfferingSessionServiceTests
Assert.Equal("PP-456", line.PayPalTransactionId); Assert.Equal("PP-456", line.PayPalTransactionId);
Assert.Equal("C-789", line.CheckNumber); Assert.Equal("C-789", line.CheckNumber);
} }
[Fact]
public async Task GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
var withDate = new DateOnly(2026, 5, 31);
var withoutDate = new DateOnly(2026, 5, 24);
await svc.CreateAsync(BuildRequest(catId, withDate));
await svc.CreateAsync(BuildRequest(catId, withoutDate));
db.MealAttendances.Add(new MealAttendance
{ AttendanceDate = withDate, AdultCount = 40, YouthCount = 12, KidCount = 8 });
await db.SaveChangesAsync();
var page = await svc.GetPagedAsync(1, 20, null, null);
var withItem = page.Items.Single(i => i.SessionDate == "2026-05-31");
var withoutItem = page.Items.Single(i => i.SessionDate == "2026-05-24");
Assert.Equal(60, withItem.SundayAttendanceCount); // 40 + 12 + 8
Assert.Null(withoutItem.SundayAttendanceCount); // no attendance row -> null
}
} }
+2
View File
@@ -23,6 +23,7 @@ public static class Modules
public const string Permissions = "Permissions"; public const string Permissions = "Permissions";
public const string SystemLogs = "SystemLogs"; public const string SystemLogs = "SystemLogs";
public const string AuditLogs = "AuditLogs"; public const string AuditLogs = "AuditLogs";
public const string Settings = "Settings";
/// <summary>All modules, in display order — drives the admin matrix UI.</summary> /// <summary>All modules, in display order — drives the admin matrix UI.</summary>
public static readonly IReadOnlyList<string> All = public static readonly IReadOnlyList<string> All =
@@ -43,6 +44,7 @@ public static class Modules
Permissions, Permissions,
SystemLogs, SystemLogs,
AuditLogs, AuditLogs,
Settings,
]; ];
public static bool IsValid(string module) => All.Contains(module); public static bool IsValid(string module) => All.Contains(module);
+44 -1
View File
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Auth; using ROLAC.API.DTOs.Auth;
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -16,13 +17,16 @@ public class AuthController : ControllerBase
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
private readonly IAuthService _authService; private readonly IAuthService _authService;
private readonly IInvitationService _invitations;
private readonly UserManager<AppUser> _userManager; private readonly UserManager<AppUser> _userManager;
private readonly IWebHostEnvironment _env; private readonly IWebHostEnvironment _env;
public AuthController( public AuthController(
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env) IAuthService authService, IInvitationService invitations,
UserManager<AppUser> userManager, IWebHostEnvironment env)
{ {
_authService = authService; _authService = authService;
_invitations = invitations;
_userManager = userManager; _userManager = userManager;
_env = env; _env = env;
} }
@@ -186,6 +190,45 @@ public class AuthController : ControllerBase
return NoContent(); return NoContent();
} }
// -------------------------------------------------------------------------
// GET /api/auth/invitation/validate?token=...
// -------------------------------------------------------------------------
/// <summary>
/// Checks whether an invitation token can still be used. Anonymous so the public
/// "set your password" page can decide what to show before the member types anything.
/// </summary>
[HttpGet("invitation/validate")]
[AllowAnonymous]
[ProducesResponseType(typeof(ValidateInvitationResult), StatusCodes.Status200OK)]
public async Task<IActionResult> ValidateInvitation([FromQuery] string token)
=> Ok(await _invitations.ValidateAsync(token));
// -------------------------------------------------------------------------
// POST /api/auth/accept-invitation
// -------------------------------------------------------------------------
/// <summary>
/// Consumes an invitation: sets the account password and, on success, logs the member in
/// (issues the access token + refresh cookie) so first login lands straight on the portal.
/// </summary>
[HttpPost("accept-invitation")]
[AllowAnonymous]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> AcceptInvitation([FromBody] AcceptInvitationRequest request)
{
var (user, error) = await _invitations.AcceptAsync(request.Token, request.NewPassword);
if (user is null)
return BadRequest(new { message = error });
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var device = Request.Headers.UserAgent.FirstOrDefault();
var (response, raw) = await _authService.IssueSessionAsync(user, ip, device);
SetRefreshCookie(raw);
return Ok(response);
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Private helpers // Private helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin endpoints for generating and e-mailing first-login invitation links.
/// The public consume/validate endpoints live on <see cref="AuthController"/> so they can set the
/// refresh-token cookie and stay anonymous.
/// </summary>
[ApiController]
[Route("api/invitations")]
[Authorize]
public class InvitationsController : ControllerBase
{
private readonly IInvitationService _invitations;
public InvitationsController(IInvitationService invitations) => _invitations = invitations;
/// <summary>POST /api/invitations — generate a link for a member; returns { token, expiresAt }.</summary>
[HttpPost]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateInvitationRequest request)
{
try { return Ok(await _invitations.CreateAsync(request)); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
/// <summary>POST /api/invitations/send — e-mail an already-generated link to the member.</summary>
[HttpPost("send")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Send([FromBody] SendInvitationRequest request)
{
try { await _invitations.SendEmailAsync(request.MemberId, request.Link); return NoContent(); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
}
@@ -2,7 +2,6 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using ROLAC.API.DTOs.Notifications; using ROLAC.API.DTOs.Notifications;
using ROLAC.API.Services.Notifications; using ROLAC.API.Services.Notifications;
@@ -22,14 +21,14 @@ public sealed class LineWebhookController : ControllerBase
private readonly ILineNotificationService _line; private readonly ILineNotificationService _line;
private readonly IMessageChannel _channel; private readonly IMessageChannel _channel;
private readonly LineOptions _options; private readonly INotificationSettingsService _settings;
public LineWebhookController( public LineWebhookController(
ILineNotificationService line, IMessageChannel channel, IOptions<LineOptions> options) ILineNotificationService line, IMessageChannel channel, INotificationSettingsService settings)
{ {
_line = line; _line = line;
_channel = channel; _channel = channel;
_options = options.Value; _settings = settings;
} }
[HttpPost("webhook")] [HttpPost("webhook")]
@@ -40,7 +39,7 @@ public sealed class LineWebhookController : ControllerBase
var rawBody = await reader.ReadToEndAsync(ct); var rawBody = await reader.ReadToEndAsync(ct);
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault(); var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature)) if (!LineSignature.IsValid(_settings.GetLine().ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
return BadRequest(); return BadRequest();
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts); var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.MealAttendance;
using ROLAC.API.Services; using ROLAC.API.Services;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
@@ -23,4 +24,10 @@ public class MealAttendanceController : ControllerBase
[Authorize] [Authorize]
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to) public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
=> Ok(await _svc.GetRangeAsync(from, to)); => Ok(await _svc.GetRangeAsync(from, to));
/// <summary>Overwrite a specific Sunday's counts (back-office editor). Authenticated only.</summary>
[HttpPut("{date}")]
[Authorize]
public async Task<IActionResult> SetCounts(DateOnly date, [FromBody] SetAttendanceRequest body)
=> Ok(await _svc.SetCountsAsync(date, body.Adult, body.Youth, body.Kid));
} }
@@ -64,6 +64,7 @@ public class OfferingEntryController : ControllerBase
NickName = request.NickName, NickName = request.NickName,
FirstName_zh = request.FirstName_zh, FirstName_zh = request.FirstName_zh,
LastName_zh = request.LastName_zh, LastName_zh = request.LastName_zh,
Entity = request.Entity,
PhoneCell = request.PhoneCell, PhoneCell = request.PhoneCell,
Status = "Visitor", Status = "Visitor",
Country = "USA", Country = "USA",
@@ -73,6 +74,7 @@ public class OfferingEntryController : ControllerBase
{ {
Id = id, NickName = request.NickName, Id = id, NickName = request.NickName,
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en, FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
Entity = request.Entity,
}); });
} }
@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Settings;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Controllers;
/// <summary>
/// Site-wide and notification (SMTP/Line) settings, surfaced by the Church Profile → Site /
/// Notification tabs. Gated by the <c>Settings</c> permission module (super_admin bypasses).
/// </summary>
[ApiController]
[Route("api/settings")]
[Authorize]
public class SettingsController : ControllerBase
{
private readonly ISettingsService _settings;
private readonly IEmailService _email;
private readonly ILineNotificationService _line;
private readonly CurrentUserAccessor _currentUser;
public SettingsController(
ISettingsService settings,
IEmailService email,
ILineNotificationService line,
CurrentUserAccessor currentUser)
{
_settings = settings;
_email = email;
_line = line;
_currentUser = currentUser;
}
// ── Site settings ────────────────────────────────────────────────────────
[HttpGet("site")]
[HasPermission(Modules.Settings, PermissionActions.Read)]
public async Task<IActionResult> GetSite() => Ok(await _settings.GetSiteAsync());
[HttpPut("site")]
[HasPermission(Modules.Settings, PermissionActions.Write)]
public async Task<IActionResult> UpdateSite([FromBody] UpdateSiteSettingRequest request)
{
await _settings.UpdateSiteAsync(request);
return NoContent();
}
// ── Notification settings ──────────────────────────────────────────────────
[HttpGet("notification")]
[HasPermission(Modules.Settings, PermissionActions.Read)]
public async Task<IActionResult> GetNotification()
{
var dto = await _settings.GetNotificationAsync();
dto.WebhookUrl = $"{Request.Scheme}://{Request.Host}/api/line/webhook";
return Ok(dto);
}
[HttpPut("notification")]
[HasPermission(Modules.Settings, PermissionActions.Write)]
public async Task<IActionResult> UpdateNotification([FromBody] UpdateNotificationSettingRequest request)
{
await _settings.UpdateNotificationAsync(request);
return NoContent();
}
[HttpPost("notification/test-email")]
[HasPermission(Modules.Settings, PermissionActions.Write)]
public async Task<IActionResult> TestEmail([FromBody] TestEmailRequest request, CancellationToken ct)
{
var to = string.IsNullOrWhiteSpace(request.ToAddress) ? _currentUser.Email : request.ToAddress;
if (string.IsNullOrWhiteSpace(to))
return BadRequest(new { message = "No recipient — provide an address or set an email on your account." });
var result = await _email.SendAsync(new EmailMessage(
MemberIds: Array.Empty<int>(),
Addresses: new[] { to },
Subject: "ROLAC test email / 測試郵件",
HtmlBody: "<p>This is a test email from ROLAC notification settings.</p>"
+ "<p>這是來自 ROLAC 通知設定的測試郵件。</p>",
SentByUserId: _currentUser.UserIdOrSystem), ct);
return Ok(result);
}
[HttpPost("notification/test-line")]
[HasPermission(Modules.Settings, PermissionActions.Write)]
public async Task<IActionResult> TestLine([FromBody] TestLineRequest request, CancellationToken ct)
{
if (request.MemberId is null && request.GroupId is null)
return BadRequest(new { message = "Choose a bound member or group to receive the test." });
var result = await _line.SendLineAsync(
body: "ROLAC 測試訊息 / This is a test Line message from ROLAC.",
memberIds: request.MemberId is { } m ? new[] { m } : Array.Empty<int>(),
groupIds: request.GroupId is { } g ? new[] { g } : Array.Empty<int>(),
sentByUserId: _currentUser.UserIdOrSystem,
ct);
return Ok(result);
}
}
+18
View File
@@ -25,4 +25,22 @@ public class UserInfo
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side. /// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
/// </summary> /// </summary>
public Dictionary<string, ModuleActions> Permissions { get; set; } = []; public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
/// <summary>
/// The church member linked to this login account, or null for admin-only
/// accounts (no MemberId) and accounts whose member record was deleted.
/// Lets the SPA greet the user by their real name.
/// </summary>
public MemberInfo? MemberInfo { get; set; }
}
/// <summary>Minimal member identity for greeting the signed-in user.</summary>
public class MemberInfo
{
public int Id { get; set; }
public string? NickName { get; set; }
public string FirstName_en { get; set; } = "";
public string LastName_en { get; set; } = "";
public string? FirstName_zh { get; set; }
public string? LastName_zh { get; set; }
} }
@@ -5,6 +5,10 @@ public class ChurchProfileDto
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public string? NameZh { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Website { get; set; }
public string? Address { get; set; } public string? Address { get; set; }
public string? City { get; set; } public string? City { get; set; }
public string? State { get; set; } public string? State { get; set; }
@@ -18,6 +22,10 @@ public class ChurchProfileDto
public class UpdateChurchProfileRequest public class UpdateChurchProfileRequest
{ {
[Required, MaxLength(200)] public string Name { get; set; } = ""; [Required, MaxLength(200)] public string Name { get; set; } = "";
[MaxLength(200)] public string? NameZh { get; set; }
[MaxLength(50)] public string? Phone { get; set; }
[MaxLength(200), EmailAddress] public string? Email { get; set; }
[MaxLength(300)] public string? Website { get; set; }
[MaxLength(500)] public string? Address { get; set; } [MaxLength(500)] public string? Address { get; set; }
[MaxLength(100)] public string? City { get; set; } [MaxLength(100)] public string? City { get; set; }
[MaxLength(50)] public string? State { get; set; } [MaxLength(50)] public string? State { get; set; }
@@ -9,4 +9,5 @@ public class MemberTypeaheadDto
public string? NickName { get; set; } public string? NickName { get; set; }
public string FirstName_en { get; set; } = ""; public string FirstName_en { get; set; } = "";
public string LastName_en { get; set; } = ""; public string LastName_en { get; set; } = "";
public string? Entity { get; set; } // company / business name (公司行號), if any
} }
@@ -11,4 +11,5 @@ public class OfferingSessionListItemDto
public decimal Difference { get; set; } public decimal Difference { get; set; }
public int LineCount { get; set; } public int LineCount { get; set; }
public bool HasProof { get; set; } public bool HasProof { get; set; }
public int? SundayAttendanceCount { get; set; } // null = no attendance recorded for the date
} }
@@ -11,5 +11,6 @@ public class QuickAddMemberRequest
[MaxLength(100)] public string? NickName { get; set; } [MaxLength(100)] public string? NickName { get; set; }
[MaxLength(100)] public string? FirstName_zh { get; set; } [MaxLength(100)] public string? FirstName_zh { get; set; }
[MaxLength(100)] public string? LastName_zh { get; set; } [MaxLength(100)] public string? LastName_zh { get; set; }
[MaxLength(200)] public string? Entity { get; set; }
[MaxLength(30)] public string? PhoneCell { get; set; } [MaxLength(30)] public string? PhoneCell { get; set; }
} }
@@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Invitations;
/// <summary>
/// Admin request to generate a first-login invitation link for a member. If the member has no
/// account yet, one is auto-created (no password) using <see cref="Email"/> or the member's email.
/// </summary>
public class CreateInvitationRequest
{
[Required]
public int MemberId { get; set; }
/// <summary>Optional override for the login email when the member has none on file.</summary>
public string? Email { get; set; }
/// <summary>Roles to assign when an account is created. Defaults to ["member"].</summary>
public List<string>? Roles { get; set; }
}
/// <summary>Result of generating an invitation — the raw token is returned ONCE.</summary>
public class CreateInvitationResult
{
public string Token { get; set; } = null!;
public DateTime ExpiresAt { get; set; }
}
/// <summary>Admin request to e-mail an already-generated invitation link to the member.</summary>
public class SendInvitationRequest
{
[Required]
public int MemberId { get; set; }
[Required]
public string Link { get; set; } = null!;
}
/// <summary>Public result describing whether an invitation token can still be used.</summary>
public class ValidateInvitationResult
{
public bool Valid { get; set; }
public bool Expired { get; set; }
public string? MemberName { get; set; }
public string? Email { get; set; }
}
/// <summary>Public request to consume an invitation and set the account password.</summary>
public class AcceptInvitationRequest
{
[Required]
public string Token { get; set; } = null!;
[Required]
[StringLength(128, MinimumLength = 8)]
public string NewPassword { get; set; } = null!;
}
@@ -0,0 +1,9 @@
namespace ROLAC.API.DTOs.MealAttendance;
/// <summary>Absolute head-counts to write for one Sunday, from the back-office editor.</summary>
public class SetAttendanceRequest
{
public int Adult { get; set; }
public int Youth { get; set; }
public int Kid { get; set; }
}
@@ -8,6 +8,7 @@ public class CreateMemberRequest
[MaxLength(100)] public string? NickName { get; set; } [MaxLength(100)] public string? NickName { get; set; }
[MaxLength(100)] public string? FirstName_zh { get; set; } [MaxLength(100)] public string? FirstName_zh { get; set; }
[MaxLength(100)] public string? LastName_zh { get; set; } [MaxLength(100)] public string? LastName_zh { get; set; }
[MaxLength(200)] public string? Entity { get; set; }
[MaxLength(10)] public string? Gender { get; set; } [MaxLength(10)] public string? Gender { get; set; }
public DateOnly? DateOfBirth { get; set; } public DateOnly? DateOfBirth { get; set; }
public DateOnly? BaptismDate { get; set; } public DateOnly? BaptismDate { get; set; }
@@ -8,6 +8,7 @@ public class MemberListItemDto
public string? NickName { get; set; } public string? NickName { get; set; }
public string? FirstName_zh { get; set; } public string? FirstName_zh { get; set; }
public string? LastName_zh { get; set; } public string? LastName_zh { get; set; }
public string? Entity { get; set; }
public string Status { get; set; } = ""; public string Status { get; set; } = "";
public string? Email { get; set; } public string? Email { get; set; }
public string? PhoneCell { get; set; } public string? PhoneCell { get; set; }
@@ -0,0 +1,80 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Settings;
// ── Site settings ──────────────────────────────────────────────────────────
public class SiteSettingDto
{
public string SiteTitle { get; set; } = "";
public string? SiteTitleZh { get; set; }
public string DefaultLanguage { get; set; } = "en";
public string TimeZone { get; set; } = "";
public string DateFormat { get; set; } = "";
public string Currency { get; set; } = "";
}
public class UpdateSiteSettingRequest
{
[Required, MaxLength(200)] public string SiteTitle { get; set; } = "";
[MaxLength(200)] public string? SiteTitleZh { get; set; }
[Required, MaxLength(10)] public string DefaultLanguage { get; set; } = "en";
[Required, MaxLength(100)] public string TimeZone { get; set; } = "";
[Required, MaxLength(50)] public string DateFormat { get; set; } = "";
[Required, MaxLength(10)] public string Currency { get; set; } = "";
}
// ── Notification settings ──────────────────────────────────────────────────
// Secrets are never returned. The DTO exposes only whether each secret is configured; the UI
// shows a write-only field where a blank value on update means "keep the stored secret".
public class NotificationSettingDto
{
public bool EnableEmail { get; set; }
public string SmtpHost { get; set; } = "";
public int SmtpPort { get; set; }
public bool SmtpUseSsl { get; set; }
public string SmtpUser { get; set; } = "";
public string FromAddress { get; set; } = "";
public string FromName { get; set; } = "";
public bool HasSmtpPassword { get; set; }
public bool EnableLine { get; set; }
public bool HasLineChannelAccessToken { get; set; }
public bool HasLineChannelSecret { get; set; }
/// <summary>Read-only webhook URL to register in the Line console (derived from the request).</summary>
public string WebhookUrl { get; set; } = "";
}
public class UpdateNotificationSettingRequest
{
public bool EnableEmail { get; set; }
[MaxLength(200)] public string SmtpHost { get; set; } = "";
[Range(0, 65535)] public int SmtpPort { get; set; } = 587;
public bool SmtpUseSsl { get; set; } = true;
[MaxLength(200)] public string SmtpUser { get; set; } = "";
[MaxLength(200)] public string? FromAddress { get; set; }
[MaxLength(200)] public string? FromName { get; set; }
/// <summary>Blank = keep the stored password unchanged.</summary>
[MaxLength(500)] public string? SmtpPassword { get; set; }
public bool EnableLine { get; set; }
/// <summary>Blank = keep the stored token unchanged.</summary>
[MaxLength(500)] public string? LineChannelAccessToken { get; set; }
/// <summary>Blank = keep the stored secret unchanged.</summary>
[MaxLength(200)] public string? LineChannelSecret { get; set; }
}
// ── Test-send requests ─────────────────────────────────────────────────────
public class TestEmailRequest
{
/// <summary>Optional override; defaults to the current user's email when omitted.</summary>
[MaxLength(200), EmailAddress] public string? ToAddress { get; set; }
}
public class TestLineRequest
{
public int? MemberId { get; set; }
public int? GroupId { get; set; }
}
+53
View File
@@ -11,6 +11,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<UserInvitation> UserInvitations => Set<UserInvitation>();
public DbSet<Member> Members => Set<Member>(); public DbSet<Member> Members => Set<Member>();
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>(); public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>(); public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
@@ -32,6 +33,9 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>(); public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>(); public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
public DbSet<NotificationSetting> NotificationSettings => Set<NotificationSetting>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
base.OnModelCreating(builder); base.OnModelCreating(builder);
@@ -53,6 +57,23 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Ignore(e => e.IsActive); entity.Ignore(e => e.IsActive);
}); });
// ── UserInvitation (single-use, expiring first-login links) ─────────
builder.Entity<UserInvitation>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.TokenHash).IsUnique();
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
entity.Property(e => e.CreatedBy).HasMaxLength(450).IsRequired();
entity.HasIndex(e => e.UserId);
entity.HasOne(e => e.User).WithMany()
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
entity.Ignore(e => e.IsExpired);
entity.Ignore(e => e.IsUsed);
entity.Ignore(e => e.IsRevoked);
entity.Ignore(e => e.IsActive);
});
// ── AppUser (unchanged + new unique index on MemberId) ────────────── // ── AppUser (unchanged + new unique index on MemberId) ──────────────
builder.Entity<AppUser>(entity => builder.Entity<AppUser>(entity =>
{ {
@@ -97,6 +118,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.NickName).HasMaxLength(100); entity.Property(e => e.NickName).HasMaxLength(100);
entity.Property(e => e.FirstName_zh).HasMaxLength(100); entity.Property(e => e.FirstName_zh).HasMaxLength(100);
entity.Property(e => e.LastName_zh).HasMaxLength(100); entity.Property(e => e.LastName_zh).HasMaxLength(100);
entity.Property(e => e.Entity).HasMaxLength(200);
entity.Property(e => e.Gender).HasMaxLength(10); entity.Property(e => e.Gender).HasMaxLength(10);
entity.Property(e => e.BaptismChurch).HasMaxLength(200); entity.Property(e => e.BaptismChurch).HasMaxLength(200);
entity.Property(e => e.Email).HasMaxLength(200); entity.Property(e => e.Email).HasMaxLength(200);
@@ -245,12 +267,43 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.BankName).HasMaxLength(200); entity.Property(e => e.BankName).HasMaxLength(200);
entity.Property(e => e.BankAccountNumber).HasMaxLength(50); entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50); entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
entity.Property(e => e.NameZh).HasMaxLength(200);
entity.Property(e => e.Phone).HasMaxLength(50);
entity.Property(e => e.Email).HasMaxLength(200);
entity.Property(e => e.Website).HasMaxLength(300);
entity.Property(e => e.CreatedBy).HasMaxLength(450); entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450);
// Optimistic-concurrency token for safe check-number allocation. // Optimistic-concurrency token for safe check-number allocation.
entity.Property(e => e.xmin).IsRowVersion(); entity.Property(e => e.xmin).IsRowVersion();
}); });
// ── SiteSetting (singleton presentation/locale settings) ─────────────
builder.Entity<SiteSetting>(entity =>
{
entity.Property(e => e.SiteTitle).HasMaxLength(200).IsRequired();
entity.Property(e => e.SiteTitleZh).HasMaxLength(200);
entity.Property(e => e.DefaultLanguage).HasMaxLength(10).IsRequired();
entity.Property(e => e.TimeZone).HasMaxLength(100).IsRequired();
entity.Property(e => e.DateFormat).HasMaxLength(50).IsRequired();
entity.Property(e => e.Currency).HasMaxLength(10).IsRequired();
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
});
// ── NotificationSetting (singleton SMTP + Line settings) ─────────────
builder.Entity<NotificationSetting>(entity =>
{
entity.Property(e => e.SmtpHost).HasMaxLength(200);
entity.Property(e => e.SmtpUser).HasMaxLength(200);
entity.Property(e => e.SmtpPassword).HasMaxLength(500);
entity.Property(e => e.FromAddress).HasMaxLength(200);
entity.Property(e => e.FromName).HasMaxLength(200);
entity.Property(e => e.LineChannelAccessToken).HasMaxLength(500);
entity.Property(e => e.LineChannelSecret).HasMaxLength(200);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
});
// ── Check (disbursement) ───────────────────────────────────────────── // ── Check (disbursement) ─────────────────────────────────────────────
builder.Entity<Check>(entity => builder.Entity<Check>(entity =>
{ {
+47
View File
@@ -208,6 +208,50 @@ public static class DbSeeder
} }
} }
public static async Task SeedSiteSettingAsync(AppDbContext db)
{
// Singleton row holding site-wide presentation/locale settings.
if (!await db.SiteSettings.AnyAsync())
{
db.SiteSettings.Add(new SiteSetting
{
SiteTitle = "River Of Life Christian Church",
SiteTitleZh = "生命河靈糧堂",
DefaultLanguage = "en",
TimeZone = "America/Los_Angeles",
DateFormat = "yyyy-MM-dd",
Currency = "USD",
});
await db.SaveChangesAsync();
}
}
public static async Task SeedNotificationSettingAsync(AppDbContext db, IConfiguration config)
{
// Singleton row that becomes the runtime source of truth for SMTP + Line. Seed it once
// from the legacy "Smtp"/"Line" appsettings sections so existing config carries over.
if (!await db.NotificationSettings.AnyAsync())
{
var smtp = config.GetSection("Smtp");
var line = config.GetSection("Line");
db.NotificationSettings.Add(new NotificationSetting
{
EnableEmail = !string.IsNullOrWhiteSpace(smtp["Host"]),
SmtpHost = smtp["Host"] ?? "",
SmtpPort = int.TryParse(smtp["Port"], out var port) ? port : 587,
SmtpUseSsl = !bool.TryParse(smtp["UseSsl"], out var ssl) || ssl,
SmtpUser = smtp["User"] ?? "",
SmtpPassword = smtp["Password"] ?? "",
FromAddress = smtp["FromAddress"] ?? "",
FromName = smtp["FromName"] ?? "",
EnableLine = !string.IsNullOrWhiteSpace(line["ChannelAccessToken"]),
LineChannelAccessToken = line["ChannelAccessToken"] ?? "",
LineChannelSecret = line["ChannelSecret"] ?? "",
});
await db.SaveChangesAsync();
}
}
/// <summary> /// <summary>
/// Seeds roles and (in Development) the default admin account. /// Seeds roles and (in Development) the default admin account.
/// Called once on application startup after migrations have been applied. /// Called once on application startup after migrations have been applied.
@@ -217,6 +261,7 @@ public static class DbSeeder
var roleManager = services.GetRequiredService<RoleManager<AppRole>>(); var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
var userManager = services.GetRequiredService<UserManager<AppUser>>(); var userManager = services.GetRequiredService<UserManager<AppUser>>();
var env = services.GetRequiredService<IWebHostEnvironment>(); var env = services.GetRequiredService<IWebHostEnvironment>();
var config = services.GetRequiredService<IConfiguration>();
await SeedRolesAsync(roleManager); await SeedRolesAsync(roleManager);
@@ -226,6 +271,8 @@ public static class DbSeeder
await SeedMinistriesAsync(db); await SeedMinistriesAsync(db);
await SeedExpenseCategoriesAsync(db); await SeedExpenseCategoriesAsync(db);
await SeedChurchProfileAsync(db); await SeedChurchProfileAsync(db);
await SeedSiteSettingAsync(db);
await SeedNotificationSettingAsync(db, config);
if (env.IsDevelopment()) if (env.IsDevelopment())
await SeedAdminUserAsync(userManager); await SeedAdminUserAsync(userManager);
+4
View File
@@ -9,6 +9,10 @@ public class ChurchProfile : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
public string? NameZh { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Website { get; set; }
public string? Address { get; set; } public string? Address { get; set; }
public string? City { get; set; } public string? City { get; set; }
public string? State { get; set; } public string? State { get; set; }
+4 -1
View File
@@ -48,6 +48,8 @@ public static class AuditActions
public const string PasswordChanged = "PasswordChanged"; public const string PasswordChanged = "PasswordChanged";
public const string UserDeactivated = "UserDeactivated"; public const string UserDeactivated = "UserDeactivated";
public const string PermissionChanged = "PermissionChanged"; public const string PermissionChanged = "PermissionChanged";
public const string InvitationCreated = "InvitationCreated";
public const string InvitationAccepted = "InvitationAccepted";
public const string CheckIssued = "CheckIssued"; public const string CheckIssued = "CheckIssued";
public const string CheckVoided = "CheckVoided"; public const string CheckVoided = "CheckVoided";
public const string ExpenseApproved = "ExpenseApproved"; public const string ExpenseApproved = "ExpenseApproved";
@@ -56,7 +58,8 @@ public static class AuditActions
public static readonly IReadOnlyList<string> All = public static readonly IReadOnlyList<string> All =
[ [
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged, Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued, PasswordChanged, UserDeactivated, PermissionChanged,
InvitationCreated, InvitationAccepted, CheckIssued,
CheckVoided, ExpenseApproved, StatementFinalized, CheckVoided, ExpenseApproved, StatementFinalized,
]; ];
} }
+1
View File
@@ -10,6 +10,7 @@ public class Member : SoftDeleteEntity, IAuditable
public string? NickName { get; set; } public string? NickName { get; set; }
public string? FirstName_zh { get; set; } public string? FirstName_zh { get; set; }
public string? LastName_zh { get; set; } public string? LastName_zh { get; set; }
public string? Entity { get; set; } // company / business name (公司行號) — used for company-check offerings
public string? Gender { get; set; } // 'M' | 'F' | 'Other' public string? Gender { get; set; } // 'M' | 'F' | 'Other'
public DateOnly? DateOfBirth { get; set; } public DateOnly? DateOfBirth { get; set; }
public DateOnly? BaptismDate { get; set; } public DateOnly? BaptismDate { get; set; }
@@ -0,0 +1,32 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// Singleton (Id == 1) holding the editable SMTP + Line notification settings. This row — not the
/// "Smtp"/"Line" appsettings sections — is the runtime source of truth; those sections only seed
/// this row once on first startup. Read at send time via <c>INotificationSettingsService</c> so
/// edits apply without restarting the API.
///
/// Secrets (<see cref="SmtpPassword"/>, <see cref="LineChannelAccessToken"/>,
/// <see cref="LineChannelSecret"/>) are stored plaintext and protected by RBAC (the <c>Settings</c>
/// module / super_admin) per the project decision for this small single-VM internal app.
/// </summary>
public class NotificationSetting : AuditableEntity, IAuditable
{
public int Id { get; set; }
// ── Email (SMTP) ─────────────────────────────────────────────────────────
public bool EnableEmail { get; set; }
public string SmtpHost { get; set; } = "";
public int SmtpPort { get; set; } = 587;
public bool SmtpUseSsl { get; set; } = true; // true → STARTTLS
public string SmtpUser { get; set; } = "";
public string SmtpPassword { get; set; } = "";
public string FromAddress { get; set; } = "";
public string FromName { get; set; } = "";
// ── Line ─────────────────────────────────────────────────────────────────
public bool EnableLine { get; set; }
public string LineChannelAccessToken { get; set; } = "";
public string LineChannelSecret { get; set; } = "";
}
+18
View File
@@ -0,0 +1,18 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// Singleton (Id == 1) holding site-wide presentation and locale settings, edited from the
/// Church Profile → Site Settings tab (gated by the <c>Settings</c> permission module).
/// Seeded with sensible defaults on startup.
/// </summary>
public class SiteSetting : AuditableEntity, IAuditable
{
public int Id { get; set; }
public string SiteTitle { get; set; } = "";
public string? SiteTitleZh { get; set; }
public string DefaultLanguage { get; set; } = "en"; // "en" | "zh"
public string TimeZone { get; set; } = "America/Los_Angeles";
public string DateFormat { get; set; } = "yyyy-MM-dd";
public string Currency { get; set; } = "USD";
}
+35
View File
@@ -0,0 +1,35 @@
namespace ROLAC.API.Entities;
/// <summary>
/// A single-use, expiring invitation that lets a member set their own password and log in for
/// the first time — without an admin-generated temporary password. The raw token is e-mailed /
/// copied to the member; only its SHA-256 hash is stored here (same scheme as RefreshToken).
/// </summary>
public class UserInvitation
{
public int Id { get; set; }
public string UserId { get; set; } = null!;
public AppUser User { get; set; } = null!;
/// <summary>SHA-256 hex of the raw invitation token. Never store raw tokens.</summary>
public string TokenHash { get; set; } = null!;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>Id of the admin who generated the link.</summary>
public string CreatedBy { get; set; } = null!;
/// <summary>Set when the member consumes the link to set their password (single-use).</summary>
public DateTime? UsedAt { get; set; }
/// <summary>Set when superseded by a newer invitation for the same user (re-issue).</summary>
public DateTime? RevokedAt { get; set; }
// Computed helpers — NOT mapped to DB columns (ignored in OnModelCreating)
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
public bool IsUsed => UsedAt.HasValue;
public bool IsRevoked => RevokedAt.HasValue;
public bool IsActive => !IsUsed && !IsRevoked && !IsExpired;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddUserInvitations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserInvitations",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
RevokedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserInvitations", x => x.Id);
table.ForeignKey(
name: "FK_UserInvitations_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_UserInvitations_TokenHash",
table: "UserInvitations",
column: "TokenHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserInvitations_UserId",
table: "UserInvitations",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserInvitations");
}
}
}
@@ -463,14 +463,26 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450) .HasMaxLength(450)
.HasColumnType("character varying(450)"); .HasColumnType("character varying(450)");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<string>("NameZh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("NextCheckNumber") b.Property<int>("NextCheckNumber")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("Phone")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("State") b.Property<string>("State")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
@@ -483,6 +495,10 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450) .HasMaxLength(450)
.HasColumnType("character varying(450)"); .HasColumnType("character varying(450)");
b.Property<string>("Website")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("ZipCode") b.Property<string>("ZipCode")
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("character varying(20)"); .HasColumnType("character varying(20)");
@@ -1124,6 +1140,10 @@ namespace ROLAC.API.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<string>("Entity")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("FamilyUnitId") b.Property<int?>("FamilyUnitId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -1323,6 +1343,82 @@ namespace ROLAC.API.Migrations
b.ToTable("MonthlyStatements"); b.ToTable("MonthlyStatements");
}); });
modelBuilder.Entity("ROLAC.API.Entities.NotificationSetting", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<bool>("EnableEmail")
.HasColumnType("boolean");
b.Property<bool>("EnableLine")
.HasColumnType("boolean");
b.Property<string>("FromAddress")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FromName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("LineChannelAccessToken")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("LineChannelSecret")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SmtpHost")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SmtpPassword")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("SmtpPort")
.HasColumnType("integer");
b.Property<bool>("SmtpUseSsl")
.HasColumnType("boolean");
b.Property<string>("SmtpUser")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.ToTable("NotificationSettings");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b => modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1653,6 +1749,109 @@ namespace ROLAC.API.Migrations
b.ToTable("RolePermissions"); b.ToTable("RolePermissions");
}); });
modelBuilder.Entity("ROLAC.API.Entities.SiteSetting", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("DateFormat")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("DefaultLanguage")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("SiteTitle")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SiteTitleZh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("TimeZone")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.ToTable("SiteSettings");
});
modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime?>("UsedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("UserInvitations");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{ {
b.HasOne("ROLAC.API.Entities.AppRole", null) b.HasOne("ROLAC.API.Entities.AppRole", null)
@@ -1874,6 +2073,17 @@ namespace ROLAC.API.Migrations
b.Navigation("Role"); b.Navigation("Role");
}); });
modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b => modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
{ {
b.Navigation("RefreshTokens"); b.Navigation("RefreshTokens");
+6
View File
@@ -144,6 +144,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IMemberService, MemberService>(); builder.Services.AddScoped<IMemberService, MemberService>();
builder.Services.AddScoped<IUserManagementService, UserManagementService>(); builder.Services.AddScoped<IUserManagementService, UserManagementService>();
builder.Services.AddScoped<IInvitationService, InvitationService>();
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>(); builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
builder.Services.AddScoped<IGivingService, GivingService>(); builder.Services.AddScoped<IGivingService, GivingService>();
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>(); builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
@@ -155,14 +156,19 @@ builder.Services.AddScoped<IExpenseService, ExpenseService>();
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>(); builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>(); builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>(); builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
builder.Services.AddScoped<ISettingsService, SettingsService>();
builder.Services.AddScoped<IDisbursementService, DisbursementService>(); builder.Services.AddScoped<IDisbursementService, DisbursementService>();
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService, builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
ROLAC.API.Services.Disbursement.CheckPrintService>(); ROLAC.API.Services.Disbursement.CheckPrintService>();
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>(); builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
// ── Notifications (email via SMTP + Line) ────────────────────────────────── // ── Notifications (email via SMTP + Line) ──────────────────────────────────
// IOptions binding stays only as the one-time seed/fallback; the runtime source of truth is the
// DB-backed NotificationSetting row, read (and hot-reloaded) via INotificationSettingsService.
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp")); builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line")); builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
builder.Services.AddSingleton<ROLAC.API.Services.Notifications.INotificationSettingsService,
ROLAC.API.Services.Notifications.NotificationSettingsService>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher, builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>(); ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService, builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
+40 -6
View File
@@ -60,6 +60,22 @@ public class AuthService : IAuthService
throw new UnauthorizedAccessException("Account is inactive."); throw new UnauthorizedAccessException("Account is inactive.");
} }
_audit.Write(
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Login succeeded: {user.Email}",
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
return await IssueSessionAsync(user, ipAddress, deviceInfo);
}
// -------------------------------------------------------------------------
// Issue session (shared by login and passwordless flows like invitations)
// -------------------------------------------------------------------------
public async Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync(
AppUser user, string? ipAddress = null, string? deviceInfo = null)
{
var roles = await _userManager.GetRolesAsync(user); var roles = await _userManager.GetRolesAsync(user);
var accessToken = _tokenService.GenerateAccessToken(user, roles); var accessToken = _tokenService.GenerateAccessToken(user, roles);
var rawRefresh = _tokenService.GenerateRefreshToken(); var rawRefresh = _tokenService.GenerateRefreshToken();
@@ -79,12 +95,6 @@ public class AuthService : IAuthService
await _userManager.UpdateAsync(user); await _userManager.UpdateAsync(user);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_audit.Write(
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Login succeeded: {user.Email}",
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh); return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
} }
@@ -225,5 +235,29 @@ public class AuthService : IAuthService
Roles = roles, Roles = roles,
LanguagePreference = user.LanguagePreference, LanguagePreference = user.LanguagePreference,
Permissions = await _permissions.GetEffectivePermissionsAsync(roles), Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
MemberInfo = await BuildMemberInfoAsync(user),
}; };
/// <summary>
/// Loads the linked member's display fields, or null when the account has no
/// MemberId or its member record was soft-deleted (excluded by query filter).
/// </summary>
private async Task<MemberInfo?> BuildMemberInfoAsync(AppUser user)
{
if (user.MemberId is not int memberId)
return null;
return await _db.Members
.Where(member => member.Id == memberId)
.Select(member => new MemberInfo
{
Id = member.Id,
NickName = member.NickName,
FirstName_en = member.FirstName_en,
LastName_en = member.LastName_en,
FirstName_zh = member.FirstName_zh,
LastName_zh = member.LastName_zh,
})
.FirstOrDefaultAsync();
}
} }
@@ -15,7 +15,8 @@ public class ChurchProfileService : IChurchProfileService
var p = await GetOrCreateAsync(); var p = await GetOrCreateAsync();
return new ChurchProfileDto return new ChurchProfileDto
{ {
Id = p.Id, Name = p.Name, Address = p.Address, City = p.City, State = p.State, Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email,
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber, ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber, BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
}; };
@@ -24,7 +25,8 @@ public class ChurchProfileService : IChurchProfileService
public async Task UpdateAsync(UpdateChurchProfileRequest r) public async Task UpdateAsync(UpdateChurchProfileRequest r)
{ {
var p = await GetOrCreateAsync(); var p = await GetOrCreateAsync();
p.Name = r.Name; p.Address = r.Address; p.City = r.City; p.State = r.State; p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email;
p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State;
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber; p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber; p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
+10
View File
@@ -25,6 +25,16 @@ public interface IAuthService
string rawRefreshToken, string rawRefreshToken,
string? ipAddress = null); string? ipAddress = null);
/// <summary>
/// Issues a fresh access token + refresh token for an already-verified user (no password
/// check). Stores the refresh token and returns the raw value for the caller to put in the
/// HttpOnly cookie. Used by passwordless flows such as accepting an invitation link.
/// </summary>
Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync(
AppUser user,
string? ipAddress = null,
string? deviceInfo = null);
/// <summary> /// <summary>
/// Revokes the refresh token identified by its raw value. /// Revokes the refresh token identified by its raw value.
/// Silently succeeds if the token is not found. /// Silently succeeds if the token is not found.
@@ -0,0 +1,28 @@
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public interface IInvitationService
{
/// <summary>
/// Generates a single-use, 7-day invitation link for a member. Auto-creates the member's
/// login account (no password) when none exists, and revokes any prior unused invitation for
/// that account. Returns the raw token (shown once) and its expiry.
/// Throws <see cref="InvalidOperationException"/> when the member is missing or has no email.
/// </summary>
Task<CreateInvitationResult> CreateAsync(CreateInvitationRequest request);
/// <summary>Checks whether a raw token is still usable, without mutating it.</summary>
Task<ValidateInvitationResult> ValidateAsync(string rawToken);
/// <summary>
/// Consumes an invitation: validates the token, sets the account password (enforcing the
/// Identity policy), and marks the invitation used. Returns the account on success, or an
/// error message describing why it failed (invalid/expired/used token or a policy violation).
/// </summary>
Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword);
/// <summary>E-mails an already-generated invitation link to the member via IEmailService.</summary>
Task SendEmailAsync(int memberId, string link);
}
@@ -22,6 +22,13 @@ public interface IMealAttendanceService
/// </summary> /// </summary>
Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value); Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value);
/// <summary>
/// Overwrites all three age-group columns for <paramref name="date"/> with absolute
/// values (each clamped at zero), creating the row if it does not exist, and returns
/// the resulting authoritative counts. Used by the back-office Sunday-attendance editor.
/// </summary>
Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid);
/// <summary>Returns the daily counts within the inclusive date range, ordered by date (for the dashboard).</summary> /// <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); Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to);
} }
@@ -0,0 +1,17 @@
using ROLAC.API.DTOs.Settings;
namespace ROLAC.API.Services;
/// <summary>
/// Reads and writes the singleton SiteSetting and NotificationSetting rows. Notification secrets
/// are masked on read and treated as write-only on update (blank = keep). After a notification
/// update the runtime cache is reloaded so changes apply without an API restart.
/// </summary>
public interface ISettingsService
{
Task<SiteSettingDto> GetSiteAsync();
Task UpdateSiteAsync(UpdateSiteSettingRequest request);
Task<NotificationSettingDto> GetNotificationAsync();
Task UpdateNotificationAsync(UpdateNotificationSettingRequest request);
}
+237
View File
@@ -0,0 +1,237 @@
using System.Net;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Services;
public class InvitationService : IInvitationService
{
/// <summary>Lifetime of a freshly issued invitation link.</summary>
private const int InvitationLifetimeDays = 7;
private readonly UserManager<AppUser> _userManager;
private readonly AppDbContext _db;
private readonly ITokenService _tokenService;
private readonly IEmailService _emailService;
private readonly IAuditLogger _audit;
private readonly CurrentUserAccessor _currentUser;
public InvitationService(
UserManager<AppUser> userManager,
AppDbContext db,
ITokenService tokenService,
IEmailService emailService,
IAuditLogger audit,
CurrentUserAccessor currentUser)
{
_userManager = userManager;
_db = db;
_tokenService = tokenService;
_emailService = emailService;
_audit = audit;
_currentUser = currentUser;
}
// ── Create ───────────────────────────────────────────────────────────────
public async Task<CreateInvitationResult> CreateAsync(CreateInvitationRequest request)
{
var member = await _db.Members.FindAsync(request.MemberId)
?? throw new InvalidOperationException($"Member {request.MemberId} does not exist.");
var email = (request.Email ?? member.Email)?.Trim();
if (string.IsNullOrWhiteSpace(email))
throw new InvalidOperationException(
"This member has no email address. Add an email before creating an invitation.");
var user = await _userManager.Users.FirstOrDefaultAsync(u => u.MemberId == request.MemberId);
if (user is null)
user = await CreateAccountAsync(member, email, request.Roles);
var now = DateTime.UtcNow;
// Re-issue: revoke any prior unused invitation so only one link is ever live.
var existing = await _db.UserInvitations
.Where(invitation => invitation.UserId == user.Id
&& invitation.UsedAt == null
&& invitation.RevokedAt == null)
.ToListAsync();
foreach (var invitation in existing)
invitation.RevokedAt = now;
var rawToken = GenerateRawToken();
var expiresAt = now.AddDays(InvitationLifetimeDays);
_db.UserInvitations.Add(new UserInvitation
{
UserId = user.Id,
TokenHash = _tokenService.HashToken(rawToken),
ExpiresAt = expiresAt,
CreatedAt = now,
CreatedBy = _currentUser.UserIdOrSystem,
});
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.InvitationCreated, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Invitation link created for {user.Email}");
return new CreateInvitationResult { Token = rawToken, ExpiresAt = expiresAt };
}
/// <summary>Creates a passwordless login account linked to the member; mirrors UserManagementService.</summary>
private async Task<AppUser> CreateAccountAsync(Member member, string email, List<string>? roles)
{
if (await _userManager.FindByEmailAsync(email) is not null)
throw new InvalidOperationException($"Email '{email}' is already in use by another account.");
var user = new AppUser
{
UserName = email,
Email = email,
EmailConfirmed = true,
MemberId = member.Id,
LanguagePreference = member.LanguagePreference,
IsActive = true,
CreatedAt = DateTime.UtcNow,
};
// No-password overload: the member sets their own password via the invitation link.
var result = await _userManager.CreateAsync(user);
if (!result.Succeeded)
throw new InvalidOperationException(
string.Join("; ", result.Errors.Select(error => error.Description)));
var rolesToAssign = roles is { Count: > 0 } ? roles : new List<string> { "member" };
await _userManager.AddToRolesAsync(user, rolesToAssign);
return user;
}
// ── Validate ───────────────────────────────────────────────────────────────
public async Task<ValidateInvitationResult> ValidateAsync(string rawToken)
{
var invitation = await FindByRawTokenAsync(rawToken);
if (invitation is null || invitation.IsUsed || invitation.IsRevoked)
return new ValidateInvitationResult { Valid = false, Expired = false };
if (invitation.IsExpired)
return new ValidateInvitationResult { Valid = false, Expired = true };
var user = await _userManager.FindByIdAsync(invitation.UserId);
return new ValidateInvitationResult
{
Valid = true,
Expired = false,
Email = user?.Email,
MemberName = await ResolveMemberNameAsync(user),
};
}
// ── Accept ───────────────────────────────────────────────────────────────
public async Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword)
{
var invitation = await FindByRawTokenAsync(rawToken);
if (invitation is null || invitation.IsUsed || invitation.IsRevoked)
return (null, "This invitation link is invalid or has already been used.");
if (invitation.IsExpired)
return (null, "This invitation link has expired. Please ask for a new one.");
var user = await _userManager.FindByIdAsync(invitation.UserId);
if (user is null)
return (null, "The account for this invitation no longer exists.");
// Set the password — works whether or not one already exists, and enforces the policy.
var resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);
var result = await _userManager.ResetPasswordAsync(user, resetToken, newPassword);
if (!result.Succeeded)
return (null, string.Join(" ", result.Errors.Select(error => error.Description)));
invitation.UsedAt = DateTime.UtcNow;
user.EmailConfirmed = true;
user.IsActive = true;
await _userManager.UpdateAsync(user);
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.InvitationAccepted, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Invitation accepted — password set for {user.Email}",
userId: user.Id, userEmail: user.Email);
return (user, null);
}
// ── Send email ───────────────────────────────────────────────────────────
public async Task SendEmailAsync(int memberId, string link)
{
var member = await _db.Members.FindAsync(memberId)
?? throw new InvalidOperationException($"Member {memberId} does not exist.");
var name = WebUtility.HtmlEncode(member.NickName ?? member.FirstName_en);
var safeLink = WebUtility.HtmlEncode(link);
var subject = "Your River Of Life Christian Church account invitation";
var htmlBody =
$"<p>Hi {name},</p>" +
"<p>You've been invited to set up your account for the River Of Life Christian Church portal.</p>" +
$"<p>Click the link below to set your password and sign in. This link expires in {InvitationLifetimeDays} days and can only be used once.</p>" +
$"<p><a href=\"{safeLink}\">Set your password and sign in</a></p>" +
"<p>If the button doesn't work, copy and paste this address into your browser:</p>" +
$"<p>{safeLink}</p>";
var result = await _emailService.SendAsync(new EmailMessage(
MemberIds: new[] { memberId },
Addresses: Array.Empty<string>(),
Subject: subject,
HtmlBody: htmlBody));
if (result.SentCount == 0)
throw new InvalidOperationException(
result.Failures.Count > 0
? $"Failed to send email: {result.Failures[0].Error}"
: "No email address on file for this member.");
}
// ── Helpers ───────────────────────────────────────────────────────────────
private Task<UserInvitation?> FindByRawTokenAsync(string rawToken)
{
if (string.IsNullOrWhiteSpace(rawToken))
return Task.FromResult<UserInvitation?>(null);
var hash = _tokenService.HashToken(rawToken);
return _db.UserInvitations.FirstOrDefaultAsync(invitation => invitation.TokenHash == hash);
}
private async Task<string?> ResolveMemberNameAsync(AppUser? user)
{
if (user?.MemberId is not int memberId)
return null;
return await _db.Members
.Where(member => member.Id == memberId)
.Select(member => (member.NickName ?? member.FirstName_en) + " " + member.LastName_en)
.FirstOrDefaultAsync();
}
/// <summary>32 cryptographically-random bytes as a URL-safe base64 string.</summary>
private static string GenerateRawToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
}
@@ -82,6 +82,26 @@ public class MealAttendanceService : IMealAttendanceService
return await ReadAsync(date); 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) public async Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to)
{ {
var rows = await _db.MealAttendances.AsNoTracking() var rows = await _db.MealAttendances.AsNoTracking()
+5
View File
@@ -38,6 +38,7 @@ public class MemberService : IMemberService
(m.NickName != null && m.NickName.ToLower().Contains(s)) || (m.NickName != null && m.NickName.ToLower().Contains(s)) ||
(m.FirstName_zh != null && m.FirstName_zh.Contains(search)) || (m.FirstName_zh != null && m.FirstName_zh.Contains(search)) ||
(m.LastName_zh != null && m.LastName_zh.Contains(search)) || (m.LastName_zh != null && m.LastName_zh.Contains(search)) ||
(m.Entity != null && m.Entity.ToLower().Contains(s)) ||
(m.Email != null && m.Email.ToLower().Contains(s))); (m.Email != null && m.Email.ToLower().Contains(s)));
} }
@@ -74,6 +75,7 @@ public class MemberService : IMemberService
NickName = m.NickName, NickName = m.NickName,
FirstName_zh = m.FirstName_zh, FirstName_zh = m.FirstName_zh,
LastName_zh = m.LastName_zh, LastName_zh = m.LastName_zh,
Entity = m.Entity,
Status = m.Status, Status = m.Status,
Email = m.Email, Email = m.Email,
PhoneCell = m.PhoneCell, PhoneCell = m.PhoneCell,
@@ -105,6 +107,7 @@ public class MemberService : IMemberService
{ {
Id = m.Id, FirstName_en = m.FirstName_en, LastName_en = m.LastName_en, Id = m.Id, FirstName_en = m.FirstName_en, LastName_en = m.LastName_en,
NickName = m.NickName, FirstName_zh = m.FirstName_zh, LastName_zh = m.LastName_zh, NickName = m.NickName, FirstName_zh = m.FirstName_zh, LastName_zh = m.LastName_zh,
Entity = m.Entity,
Gender = m.Gender, DateOfBirth = m.DateOfBirth, BaptismDate = m.BaptismDate, Gender = m.Gender, DateOfBirth = m.DateOfBirth, BaptismDate = m.BaptismDate,
BaptismChurch = m.BaptismChurch, Email = m.Email, PhoneCell = m.PhoneCell, BaptismChurch = m.BaptismChurch, Email = m.Email, PhoneCell = m.PhoneCell,
PhoneHome = m.PhoneHome, Address = m.Address, City = m.City, State = m.State, PhoneHome = m.PhoneHome, Address = m.Address, City = m.City, State = m.State,
@@ -157,6 +160,7 @@ public class MemberService : IMemberService
{ {
FirstName_en = r.FirstName_en, LastName_en = r.LastName_en, FirstName_en = r.FirstName_en, LastName_en = r.LastName_en,
NickName = r.NickName, FirstName_zh = r.FirstName_zh, LastName_zh = r.LastName_zh, NickName = r.NickName, FirstName_zh = r.FirstName_zh, LastName_zh = r.LastName_zh,
Entity = r.Entity,
Gender = r.Gender, DateOfBirth = r.DateOfBirth, BaptismDate = r.BaptismDate, Gender = r.Gender, DateOfBirth = r.DateOfBirth, BaptismDate = r.BaptismDate,
BaptismChurch = r.BaptismChurch, Email = r.Email, PhoneCell = r.PhoneCell, BaptismChurch = r.BaptismChurch, Email = r.Email, PhoneCell = r.PhoneCell,
PhoneHome = r.PhoneHome, Address = r.Address, City = r.City, State = r.State, PhoneHome = r.PhoneHome, Address = r.Address, City = r.City, State = r.State,
@@ -169,6 +173,7 @@ public class MemberService : IMemberService
{ {
m.FirstName_en = r.FirstName_en; m.LastName_en = r.LastName_en; m.FirstName_en = r.FirstName_en; m.LastName_en = r.LastName_en;
m.NickName = r.NickName; m.FirstName_zh = r.FirstName_zh; m.LastName_zh = r.LastName_zh; m.NickName = r.NickName; m.FirstName_zh = r.FirstName_zh; m.LastName_zh = r.LastName_zh;
m.Entity = r.Entity;
m.Gender = r.Gender; m.DateOfBirth = r.DateOfBirth; m.BaptismDate = r.BaptismDate; m.Gender = r.Gender; m.DateOfBirth = r.DateOfBirth; m.BaptismDate = r.BaptismDate;
m.BaptismChurch = r.BaptismChurch; m.Email = r.Email; m.PhoneCell = r.PhoneCell; m.BaptismChurch = r.BaptismChurch; m.Email = r.Email; m.PhoneCell = r.PhoneCell;
m.PhoneHome = r.PhoneHome; m.Address = r.Address; m.City = r.City; m.State = r.State; m.PhoneHome = r.PhoneHome; m.Address = r.Address; m.City = r.City; m.State = r.State;
@@ -1,6 +1,5 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using Microsoft.Extensions.Options;
namespace ROLAC.API.Services.Notifications; namespace ROLAC.API.Services.Notifications;
@@ -11,12 +10,12 @@ public sealed class LineMessageChannel : IMessageChannel
private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply"; private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply";
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly LineOptions _options; private readonly INotificationSettingsService _settings;
public LineMessageChannel(HttpClient http, IOptions<LineOptions> options) public LineMessageChannel(HttpClient http, INotificationSettingsService settings)
{ {
_http = http; _http = http;
_options = options.Value; _settings = settings;
} }
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default) public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
@@ -36,7 +35,8 @@ public sealed class LineMessageChannel : IMessageChannel
{ {
Content = JsonContent.Create(payload), Content = JsonContent.Create(payload),
}; };
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ChannelAccessToken); request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", _settings.GetLine().ChannelAccessToken);
using var response = await _http.SendAsync(request, ct); using var response = await _http.SendAsync(request, ct);
if (response.IsSuccessStatusCode) return new MessageSendResult(true, null); if (response.IsSuccessStatusCode) return new MessageSendResult(true, null);
@@ -1,21 +1,22 @@
using MailKit.Net.Smtp; using MailKit.Net.Smtp;
using MailKit.Security; using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit; using MimeKit;
namespace ROLAC.API.Services.Notifications; namespace ROLAC.API.Services.Notifications;
/// <summary>Sends a single email via MailKit using the configured SMTP server.</summary> /// <summary>Sends a single email via MailKit using the current (DB-backed) SMTP settings.</summary>
public sealed class MailKitSmtpDispatcher : ISmtpDispatcher public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
{ {
private readonly SmtpOptions _options; private readonly INotificationSettingsService _settings;
public MailKitSmtpDispatcher(IOptions<SmtpOptions> options) => _options = options.Value; public MailKitSmtpDispatcher(INotificationSettingsService settings) => _settings = settings;
public async Task SendAsync(OutboundEmail email, CancellationToken ct = default) public async Task SendAsync(OutboundEmail email, CancellationToken ct = default)
{ {
var options = _settings.GetSmtp();
var message = new MimeMessage(); var message = new MimeMessage();
message.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress)); message.From.Add(new MailboxAddress(options.FromName, options.FromAddress));
message.To.Add(MailboxAddress.Parse(email.ToAddress)); message.To.Add(MailboxAddress.Parse(email.ToAddress));
message.Subject = email.Subject; message.Subject = email.Subject;
@@ -28,10 +29,10 @@ public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
message.Body = builder.ToMessageBody(); message.Body = builder.ToMessageBody();
using var client = new SmtpClient(); using var client = new SmtpClient();
var socketOptions = _options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto; var socketOptions = options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
await client.ConnectAsync(_options.Host, _options.Port, socketOptions, ct); await client.ConnectAsync(options.Host, options.Port, socketOptions, ct);
if (!string.IsNullOrEmpty(_options.User)) if (!string.IsNullOrEmpty(options.User))
await client.AuthenticateAsync(_options.User, _options.Password, ct); await client.AuthenticateAsync(options.User, options.Password, ct);
await client.SendAsync(message, ct); await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct); await client.DisconnectAsync(true, ct);
} }
@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Notifications;
/// <summary>
/// Supplies the current SMTP/Line settings from the <c>NotificationSetting</c> singleton row,
/// caching a snapshot in memory so send paths don't hit the DB on every message. Registered as a
/// singleton; the Settings UI calls <see cref="Reload"/> after an edit so changes take effect
/// without restarting the API. Falls back to the "Smtp"/"Line" appsettings sections if the row
/// has not been seeded yet.
/// </summary>
public interface INotificationSettingsService
{
SmtpOptions GetSmtp();
LineOptions GetLine();
void Reload();
}
public sealed class NotificationSettingsService : INotificationSettingsService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptions<SmtpOptions> _smtpFallback;
private readonly IOptions<LineOptions> _lineFallback;
private readonly object _gate = new();
private SmtpOptions? _smtp;
private LineOptions? _line;
public NotificationSettingsService(
IServiceScopeFactory scopeFactory,
IOptions<SmtpOptions> smtpFallback,
IOptions<LineOptions> lineFallback)
{
_scopeFactory = scopeFactory;
_smtpFallback = smtpFallback;
_lineFallback = lineFallback;
}
public SmtpOptions GetSmtp()
{
EnsureLoaded();
return _smtp!;
}
public LineOptions GetLine()
{
EnsureLoaded();
return _line!;
}
public void Reload()
{
lock (_gate)
{
_smtp = null;
_line = null;
}
}
private void EnsureLoaded()
{
lock (_gate)
{
if (_smtp is not null && _line is not null)
return;
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var row = db.NotificationSettings.AsNoTracking().OrderBy(s => s.Id).FirstOrDefault();
if (row is null)
{
// Not seeded yet — use the appsettings values so sends still work.
_smtp = _smtpFallback.Value;
_line = _lineFallback.Value;
return;
}
_smtp = new SmtpOptions
{
Host = row.SmtpHost,
Port = row.SmtpPort,
UseSsl = row.SmtpUseSsl,
User = row.SmtpUser,
Password = row.SmtpPassword,
FromAddress = row.FromAddress,
FromName = row.FromName,
};
_line = new LineOptions
{
ChannelAccessToken = row.LineChannelAccessToken,
ChannelSecret = row.LineChannelSecret,
};
}
}
}
@@ -45,6 +45,11 @@ public class OfferingSessionService : IOfferingSessionService
.Select(grp => new { Id = grp.Key, Count = grp.Count() }) .Select(grp => new { Id = grp.Key, Count = grp.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Count); .ToDictionaryAsync(x => x.Id, x => x.Count);
var dates = rows.Select(r => r.SessionDate).ToList();
var attendance = await _db.MealAttendances.AsNoTracking()
.Where(a => dates.Contains(a.AttendanceDate))
.ToDictionaryAsync(a => a.AttendanceDate, a => a.AdultCount + a.YouthCount + a.KidCount);
var items = rows.Select(s => new OfferingSessionListItemDto var items = rows.Select(s => new OfferingSessionListItemDto
{ {
Id = s.Id, SessionDate = s.SessionDate.ToString("yyyy-MM-dd"), Status = s.Status, Id = s.Id, SessionDate = s.SessionDate.ToString("yyyy-MM-dd"), Status = s.Status,
@@ -52,6 +57,7 @@ public class OfferingSessionService : IOfferingSessionService
SystemTotal = s.SystemTotal, Difference = s.Difference, SystemTotal = s.SystemTotal, Difference = s.Difference,
LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0, LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0,
HasProof = s.ProofPdfPath != null, HasProof = s.ProofPdfPath != null,
SundayAttendanceCount = attendance.TryGetValue(s.SessionDate, out var att) ? att : (int?)null,
}).ToList(); }).ToList();
return new PagedResult<OfferingSessionListItemDto> return new PagedResult<OfferingSessionListItemDto>
+115
View File
@@ -0,0 +1,115 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Settings;
using ROLAC.API.Entities;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Services;
public class SettingsService : ISettingsService
{
private readonly AppDbContext _db;
private readonly INotificationSettingsService _notificationSettings;
public SettingsService(AppDbContext db, INotificationSettingsService notificationSettings)
{
_db = db;
_notificationSettings = notificationSettings;
}
public async Task<SiteSettingDto> GetSiteAsync()
{
var s = await GetOrCreateSiteAsync();
return new SiteSettingDto
{
SiteTitle = s.SiteTitle,
SiteTitleZh = s.SiteTitleZh,
DefaultLanguage = s.DefaultLanguage,
TimeZone = s.TimeZone,
DateFormat = s.DateFormat,
Currency = s.Currency,
};
}
public async Task UpdateSiteAsync(UpdateSiteSettingRequest r)
{
var s = await GetOrCreateSiteAsync();
s.SiteTitle = r.SiteTitle;
s.SiteTitleZh = r.SiteTitleZh;
s.DefaultLanguage = r.DefaultLanguage;
s.TimeZone = r.TimeZone;
s.DateFormat = r.DateFormat;
s.Currency = r.Currency;
await _db.SaveChangesAsync();
}
public async Task<NotificationSettingDto> GetNotificationAsync()
{
var n = await GetOrCreateNotificationAsync();
return new NotificationSettingDto
{
EnableEmail = n.EnableEmail,
SmtpHost = n.SmtpHost,
SmtpPort = n.SmtpPort,
SmtpUseSsl = n.SmtpUseSsl,
SmtpUser = n.SmtpUser,
FromAddress = n.FromAddress,
FromName = n.FromName,
HasSmtpPassword = !string.IsNullOrEmpty(n.SmtpPassword),
EnableLine = n.EnableLine,
HasLineChannelAccessToken = !string.IsNullOrEmpty(n.LineChannelAccessToken),
HasLineChannelSecret = !string.IsNullOrEmpty(n.LineChannelSecret),
// WebhookUrl is filled by the controller (needs the request host).
};
}
public async Task UpdateNotificationAsync(UpdateNotificationSettingRequest r)
{
var n = await GetOrCreateNotificationAsync();
n.EnableEmail = r.EnableEmail;
n.SmtpHost = r.SmtpHost;
n.SmtpPort = r.SmtpPort;
n.SmtpUseSsl = r.SmtpUseSsl;
n.SmtpUser = r.SmtpUser;
n.FromAddress = r.FromAddress ?? "";
n.FromName = r.FromName ?? "";
n.EnableLine = r.EnableLine;
// Secrets are write-only: a blank value means "keep what's stored".
if (!string.IsNullOrWhiteSpace(r.SmtpPassword))
n.SmtpPassword = r.SmtpPassword;
if (!string.IsNullOrWhiteSpace(r.LineChannelAccessToken))
n.LineChannelAccessToken = r.LineChannelAccessToken;
if (!string.IsNullOrWhiteSpace(r.LineChannelSecret))
n.LineChannelSecret = r.LineChannelSecret;
await _db.SaveChangesAsync();
// Drop the cached snapshot so the new values are used on the next send — no restart needed.
_notificationSettings.Reload();
}
private async Task<SiteSetting> GetOrCreateSiteAsync()
{
var s = await _db.SiteSettings.OrderBy(x => x.Id).FirstOrDefaultAsync();
if (s is null)
{
s = new SiteSetting { SiteTitle = "Church" };
_db.SiteSettings.Add(s);
await _db.SaveChangesAsync();
}
return s;
}
private async Task<NotificationSetting> GetOrCreateNotificationAsync()
{
var n = await _db.NotificationSettings.OrderBy(x => x.Id).FirstOrDefaultAsync();
if (n is null)
{
n = new NotificationSetting();
_db.NotificationSettings.Add(n);
await _db.SaveChangesAsync();
}
return n;
}
}
+4
View File
@@ -24,11 +24,15 @@ import { OfferingEntryMobilePageComponent } from './features/giving/pages/offeri
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component'; import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component'; import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component';
import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component'; import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component';
import { AcceptInvitationComponent } from './features/accept-invitation/accept-invitation.component';
export const routes: Routes = [ export const routes: Routes = [
// Public routes // Public routes
{ path: 'login', component: LoginPage }, { path: 'login', component: LoginPage },
// Public first-login page — member sets their own password from a secret invitation link.
{ path: 'accept-invitation', component: AcceptInvitationComponent },
// Public Sunday meal attendance counter — no login required (volunteers on phones). // Public Sunday meal attendance counter — no login required (volunteers on phones).
{ path: 'attendance', component: AttendanceCounterPageComponent }, { path: 'attendance', component: AttendanceCounterPageComponent },
@@ -31,6 +31,7 @@ export const PermissionModules = {
Permissions: 'Permissions', Permissions: 'Permissions',
SystemLogs: 'SystemLogs', SystemLogs: 'SystemLogs',
AuditLogs: 'AuditLogs', AuditLogs: 'AuditLogs',
Settings: 'Settings',
} as const; } as const;
/** A required permission, used in route data and the *appHasPermission directive. */ /** A required permission, used in route data and the *appHasPermission directive. */
@@ -0,0 +1,158 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { LabelModule } from '@progress/kendo-angular-label';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { AuthService } from '../../shared/services/auth.service';
import {
passwordStrengthValidator,
passwordMatchValidator,
} from '../account/validators/password.validators';
type Step = 'loading' | 'invalid' | 'form';
@Component({
selector: 'app-accept-invitation',
standalone: true,
imports: [
CommonModule, ReactiveFormsModule,
InputsModule, LabelModule, ButtonsModule, IndicatorsModule,
],
template: `
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h1 class="text-xl font-semibold mb-1">River Of Life Christian Church</h1>
<!-- Validating the link -->
<ng-container *ngIf="step === 'loading'">
<div class="text-center py-6">
<kendo-loader></kendo-loader>
<p class="mt-2 text-gray-600">Checking your invitation…</p>
</div>
</ng-container>
<!-- Invalid / expired link -->
<ng-container *ngIf="step === 'invalid'">
<p class="text-base font-medium mb-2">This invitation can't be used</p>
<p class="text-gray-600 mb-4">{{ invalidMessage }}</p>
<button kendoButton themeColor="primary" (click)="goToLogin()">Go to sign in</button>
</ng-container>
<!-- Set password form -->
<ng-container *ngIf="step === 'form'">
<p class="text-gray-600 mb-4">
Welcome<span *ngIf="memberName">, <strong>{{ memberName }}</strong></span>. Set a password to
finish creating your account and sign in.
</p>
<form [formGroup]="form" class="k-form k-form-vertical" (ngSubmit)="onSubmit()">
<div class="grid grid-cols-1 gap-y-3">
<kendo-formfield>
<kendo-label text="New Password *"></kendo-label>
<kendo-textbox formControlName="newPassword" type="password" [clearButton]="false"></kendo-textbox>
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['required']">Required.</kendo-formerror>
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['passwordStrength']">
Must be at least 8 characters with an uppercase letter, a lowercase letter,
a digit, and a special character.
</kendo-formerror>
</kendo-formfield>
<kendo-formfield>
<kendo-label text="Confirm Password *"></kendo-label>
<kendo-textbox formControlName="confirmPassword" type="password" [clearButton]="false"></kendo-textbox>
<kendo-formerror *ngIf="form.get('confirmPassword')?.errors?.['required']">Required.</kendo-formerror>
<kendo-formerror *ngIf="form.errors?.['mismatch'] && form.get('confirmPassword')?.touched">
Passwords do not match.
</kendo-formerror>
</kendo-formfield>
<p *ngIf="errorMessage" class="k-color-error">{{ errorMessage }}</p>
<div class="mt-2">
<button kendoButton themeColor="primary" type="submit" [disabled]="form.invalid || submitting">
<span *ngIf="submitting">…</span>
Set password &amp; sign in
</button>
</div>
</div>
</form>
</ng-container>
</div>
</div>
`,
})
export class AcceptInvitationComponent implements OnInit {
step: Step = 'loading';
form: FormGroup;
submitting = false;
memberName: string | null = null;
invalidMessage = 'This invitation link is invalid or has already been used.';
errorMessage = '';
private token = '';
constructor(
private fb: FormBuilder,
private auth: AuthService,
private route: ActivatedRoute,
private router: Router,
) {
this.form = this.fb.group(
{
newPassword: ['', [Validators.required, passwordStrengthValidator()]],
confirmPassword: ['', [Validators.required]],
},
{ validators: passwordMatchValidator() },
);
}
ngOnInit(): void {
this.token = this.route.snapshot.queryParamMap.get('token') ?? '';
if (!this.token) {
this.step = 'invalid';
return;
}
this.auth.validateInvitation(this.token).subscribe({
next: (result) => {
if (result.valid) {
this.memberName = result.memberName ?? null;
this.step = 'form';
} else {
this.invalidMessage = result.expired
? 'This invitation link has expired. Please ask for a new one.'
: 'This invitation link is invalid or has already been used.';
this.step = 'invalid';
}
},
error: () => { this.step = 'invalid'; },
});
}
onSubmit(): void {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
this.submitting = true;
this.errorMessage = '';
this.auth.acceptInvitation(this.token, this.form.value.newPassword).subscribe({
next: () => {
this.router.navigate(['/user-portal/dashboard']);
},
error: (err) => {
this.errorMessage = err.error?.message ?? 'Could not set your password. The link may have expired.';
this.submitting = false;
},
});
}
goToLogin(): void {
this.router.navigate(['/login']);
}
}
@@ -45,7 +45,8 @@ export interface CheckDetailDto extends CheckListItemDto {
} }
export interface ChurchProfileDto { export interface ChurchProfileDto {
id: number; name: string; address: string | null; city: string | null; id: number; name: string; nameZh: string | null; phone: string | null;
email: string | null; website: string | null; address: string | null; city: string | null;
state: string | null; zipCode: string | null; bankName: string | null; state: string | null; zipCode: string | null; bankName: string | null;
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number; bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
} }
@@ -1,10 +1,30 @@
<div class="page"> <div class="page">
<div *ngIf="model" class="max-w-3xl"> <kendo-tabstrip>
<!-- ── Tab 1: Church Info (existing ChurchProfile permission) ──────────── -->
<kendo-tabstrip-tab title="Church Info / 教會資料" [selected]="true">
<ng-template kendoTabContent>
<div *ngIf="model" class="max-w-3xl pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2"> <label class="flex flex-col gap-1">
Church Name / 教會名稱 Church Name / 教會名稱
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox> <kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
</label> </label>
<label class="flex flex-col gap-1">
Church Name (ZH) / 教會名稱(中)
<kendo-textbox [(ngModel)]="model.nameZh"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Phone / 電話
<kendo-textbox [(ngModel)]="model.phone"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Email / 電子郵件
<kendo-textbox [(ngModel)]="model.email"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Website / 網站
<kendo-textbox [(ngModel)]="model.website" placeholder="https://"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2"> <label class="flex flex-col gap-1 md:col-span-2">
Address / 地址 Address / 地址
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox> <kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
@@ -46,4 +66,21 @@
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span> <span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div> </div>
</div> </div>
</ng-template>
</kendo-tabstrip-tab>
<!-- ── Tab 2: Site Settings (Settings permission) ─────────────────────── -->
<kendo-tabstrip-tab title="Site Settings / 網站設定" *appHasPermission="settingsPermission">
<ng-template kendoTabContent>
<app-site-settings-tab></app-site-settings-tab>
</ng-template>
</kendo-tabstrip-tab>
<!-- ── Tab 3: Notification Settings (Settings permission) ─────────────── -->
<kendo-tabstrip-tab title="Notifications / 通知設定" *appHasPermission="settingsPermission">
<ng-template kendoTabContent>
<app-notification-settings-tab></app-notification-settings-tab>
</ng-template>
</kendo-tabstrip-tab>
</kendo-tabstrip>
</div> </div>
@@ -3,13 +3,21 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs'; import { InputsModule } from '@progress/kendo-angular-inputs';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { DisbursementApiService } from '../../services/disbursement-api.service'; import { DisbursementApiService } from '../../services/disbursement-api.service';
import { ChurchProfileDto } from '../../models/disbursement.model'; import { ChurchProfileDto } from '../../models/disbursement.model';
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
import { PermissionModules } from '../../../../core/models/permission.model';
import { SiteSettingsTabComponent } from '../../../settings/components/site-settings-tab/site-settings-tab.component';
import { NotificationSettingsTabComponent } from '../../../settings/components/notification-settings-tab/notification-settings-tab.component';
@Component({ @Component({
selector: 'app-church-profile-page', selector: 'app-church-profile-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule], imports: [
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule,
HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,
],
templateUrl: './church-profile-page.component.html', templateUrl: './church-profile-page.component.html',
}) })
export class ChurchProfilePageComponent implements OnInit { export class ChurchProfilePageComponent implements OnInit {
@@ -17,6 +25,9 @@ export class ChurchProfilePageComponent implements OnInit {
saving = false; saving = false;
savedMsg = ''; savedMsg = '';
/** Settings module gates the Site / Notification tabs. */
readonly settingsPermission = { module: PermissionModules.Settings, action: 'read' as const };
constructor(private api: DisbursementApiService) {} constructor(private api: DisbursementApiService) {}
ngOnInit(): void { ngOnInit(): void {
@@ -2,8 +2,10 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">First name (EN) *<kendo-textbox [(ngModel)]="firstName_en"></kendo-textbox></label> <label class="flex flex-col gap-1">First name (EN) *<kendo-textbox [(ngModel)]="firstName_en"></kendo-textbox></label>
<label class="flex flex-col gap-1">Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label> <label class="flex flex-col gap-1">Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label>
<label class="flex flex-col gap-1 md:col-span-2">暱稱 · Nick name<kendo-textbox [(ngModel)]="nickName"></kendo-textbox></label>
<label class="flex flex-col gap-1">名 (中)<kendo-textbox [(ngModel)]="firstName_zh"></kendo-textbox></label> <label class="flex flex-col gap-1">名 (中)<kendo-textbox [(ngModel)]="firstName_zh"></kendo-textbox></label>
<label class="flex flex-col gap-1">姓 (中)<kendo-textbox [(ngModel)]="lastName_zh"></kendo-textbox></label> <label class="flex flex-col gap-1">姓 (中)<kendo-textbox [(ngModel)]="lastName_zh"></kendo-textbox></label>
<label class="flex flex-col gap-1 md:col-span-2">公司行號 · Company<kendo-textbox [(ngModel)]="entity"></kendo-textbox></label>
<label class="flex flex-col gap-1 md:col-span-2">Cell phone<kendo-textbox [(ngModel)]="phoneCell"></kendo-textbox></label> <label class="flex flex-col gap-1 md:col-span-2">Cell phone<kendo-textbox [(ngModel)]="phoneCell"></kendo-textbox></label>
</div> </div>
<kendo-dialog-actions> <kendo-dialog-actions>
@@ -19,8 +19,10 @@ export class MemberQuickAddDialogComponent {
firstName_en = ''; firstName_en = '';
lastName_en = ''; lastName_en = '';
nickName: string | null = null;
firstName_zh: string | null = null; firstName_zh: string | null = null;
lastName_zh: string | null = null; lastName_zh: string | null = null;
entity: string | null = null;
phoneCell: string | null = null; phoneCell: string | null = null;
saving = false; saving = false;
@@ -32,9 +34,10 @@ export class MemberQuickAddDialogComponent {
const req: CreateMemberRequest = { const req: CreateMemberRequest = {
firstName_en: this.firstName_en, firstName_en: this.firstName_en,
lastName_en: this.lastName_en, lastName_en: this.lastName_en,
nickName: null, nickName: this.nickName,
firstName_zh: this.firstName_zh, firstName_zh: this.firstName_zh,
lastName_zh: this.lastName_zh, lastName_zh: this.lastName_zh,
entity: this.entity,
gender: null, gender: null,
dateOfBirth: null, dateOfBirth: null,
baptismDate: null, baptismDate: null,
@@ -60,9 +63,10 @@ export class MemberQuickAddDialogComponent {
id, id,
firstName_en: this.firstName_en, firstName_en: this.firstName_en,
lastName_en: this.lastName_en, lastName_en: this.lastName_en,
nickName: null, nickName: this.nickName,
firstName_zh: this.firstName_zh, firstName_zh: this.firstName_zh,
lastName_zh: this.lastName_zh, lastName_zh: this.lastName_zh,
entity: this.entity,
status: 'Visitor', status: 'Visitor',
email: null, email: null,
phoneCell: this.phoneCell, phoneCell: this.phoneCell,
@@ -114,6 +114,7 @@ export interface OfferingSessionListItemDto {
difference: number; difference: number;
lineCount: number; lineCount: number;
hasProof: boolean; hasProof: boolean;
sundayAttendanceCount?: number | null;
} }
/** A row held in the client-side batch buffer before submit. */ /** A row held in the client-side batch buffer before submit. */
@@ -129,6 +130,7 @@ export interface MemberTypeaheadDto {
nickName: string | null; nickName: string | null;
firstName_en: string; firstName_en: string;
lastName_en: string; lastName_en: string;
entity: string | null;
} }
/** A day's session as the mobile page sees it. */ /** A day's session as the mobile page sees it. */
export interface OfferingEntrySummaryDto { export interface OfferingEntrySummaryDto {
@@ -158,6 +160,7 @@ export interface QuickAddMemberRequest {
nickName: string | null; nickName: string | null;
firstName_zh: string | null; firstName_zh: string | null;
lastName_zh: string | null; lastName_zh: string | null;
entity: string | null;
phoneCell: string | null; phoneCell: string | null;
} }
/** Returned from append + broadcast over the OfferingEntryHub. */ /** Returned from append + broadcast over the OfferingEntryHub. */
@@ -130,6 +130,10 @@
<label class="oe__label">中文姓 · Chinese last name</label> <label class="oe__label">中文姓 · Chinese last name</label>
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.lastName_zh" size="large"></kendo-textbox> <kendo-textbox class="oe__control" [(ngModel)]="quickAdd.lastName_zh" size="large"></kendo-textbox>
</div> </div>
<div class="oe__field">
<label class="oe__label">公司行號 · Company</label>
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.entity" size="large"></kendo-textbox>
</div>
<div class="oe__field"> <div class="oe__field">
<label class="oe__label">手機 · Cell phone</label> <label class="oe__label">手機 · Cell phone</label>
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.phoneCell" size="large"></kendo-textbox> <kendo-textbox class="oe__control" [(ngModel)]="quickAdd.phoneCell" size="large"></kendo-textbox>
@@ -151,10 +151,11 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
// is no nick name (or it's the same as the legal first name). // is no nick name (or it's the same as the legal first name).
private giverLabel(m: MemberTypeaheadDto): string { private giverLabel(m: MemberTypeaheadDto): string {
const legal = `${m.firstName_en} ${m.lastName_en}`.trim(); const legal = `${m.firstName_en} ${m.lastName_en}`.trim();
if (m.nickName && m.nickName !== m.firstName_en) { const base = (m.nickName && m.nickName !== m.firstName_en)
return `${m.nickName} ${m.lastName_en} (${legal})`; ? `${m.nickName} ${m.lastName_en} (${legal})`
} : legal;
return legal; // Append the company / business name so a company-check giver is unambiguous.
return m.entity ? `${base} · ${m.entity}` : base;
} }
onMemberSelected(id: number | null): void { onMemberSelected(id: number | null): void {
@@ -206,6 +207,7 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
nickName: this.trimToNull(this.quickAdd.nickName), nickName: this.trimToNull(this.quickAdd.nickName),
firstName_zh: this.trimToNull(this.quickAdd.firstName_zh), firstName_zh: this.trimToNull(this.quickAdd.firstName_zh),
lastName_zh: this.trimToNull(this.quickAdd.lastName_zh), lastName_zh: this.trimToNull(this.quickAdd.lastName_zh),
entity: this.trimToNull(this.quickAdd.entity),
phoneCell: this.trimToNull(this.quickAdd.phoneCell), phoneCell: this.trimToNull(this.quickAdd.phoneCell),
}; };
this.api.quickAddMember(request).subscribe({ this.api.quickAddMember(request).subscribe({
@@ -229,7 +231,7 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
private blankQuickAdd(): QuickAddMemberRequest { private blankQuickAdd(): QuickAddMemberRequest {
return { return {
firstName_en: '', lastName_en: '', nickName: null, firstName_en: '', lastName_en: '', nickName: null,
firstName_zh: null, lastName_zh: null, phoneCell: null, firstName_zh: null, lastName_zh: null, entity: null, phoneCell: null,
}; };
} }
@@ -36,7 +36,7 @@
<span class="card__zh">最近的奉獻紀錄</span> <span class="card__zh">最近的奉獻紀錄</span>
</div> </div>
<kendo-grid class="lined" [data]="sessions"> <kendo-grid class="lined clickable-rows" [data]="sessions" (cellClick)="onSessionCellClick($event)">
<kendo-grid-column field="sessionDate" title="Date" [width]="120"></kendo-grid-column> <kendo-grid-column field="sessionDate" title="Date" [width]="120"></kendo-grid-column>
<kendo-grid-column title="Status" [width]="130"> <kendo-grid-column title="Status" [width]="130">
<ng-template kendoGridCellTemplate let-s> <ng-template kendoGridCellTemplate let-s>
@@ -44,6 +44,9 @@
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
<kendo-grid-column field="lineCount" title="Lines" [width]="80"></kendo-grid-column> <kendo-grid-column field="lineCount" title="Lines" [width]="80"></kendo-grid-column>
<kendo-grid-column title="Attendance · 主日人數" [width]="140">
<ng-template kendoGridCellTemplate let-s>{{ s.sundayAttendanceCount ?? '—' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Proof" [width]="70"> <kendo-grid-column title="Proof" [width]="70">
<ng-template kendoGridCellTemplate let-s> <ng-template kendoGridCellTemplate let-s>
<span *ngIf="s.hasProof" title="Paper proof attached · 已附證明">📎</span> <span *ngIf="s.hasProof" title="Paper proof attached · 已附證明">📎</span>
@@ -51,15 +54,12 @@
</kendo-grid-column> </kendo-grid-column>
<kendo-grid-column field="systemTotal" title="System" [width]="120" format="c2"></kendo-grid-column> <kendo-grid-column field="systemTotal" title="System" [width]="120" format="c2"></kendo-grid-column>
<kendo-grid-column field="difference" title="Diff" [width]="110" format="c2"></kendo-grid-column> <kendo-grid-column field="difference" title="Diff" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column title="" [width]="110">
<ng-template kendoGridCellTemplate let-s>
<button kendoButton fillMode="flat" themeColor="primary" (click)="openView(s)">View</button>
</ng-template>
</kendo-grid-column>
<ng-template kendoGridNoRecordsTemplate> <ng-template kendoGridNoRecordsTemplate>
<div class="empty">No sessions yet — pick a date above to start.<br><span>尚無紀錄 — 選擇上方日期開始</span></div> <div class="empty">No sessions yet — pick a date above to start.<br><span>尚無紀錄 — 選擇上方日期開始</span></div>
</ng-template> </ng-template>
</kendo-grid> </kendo-grid>
<kendo-contextmenu #sessionMenu [items]="sessionMenuItems" (select)="onSessionMenuSelect($event)"></kendo-contextmenu>
<div class="hint-text-sm">點一列檢視 · 右鍵修改主日人數 / Click a row to view · right-click to edit attendance</div>
</section> </section>
</ng-container> </ng-container>
@@ -306,4 +306,25 @@
<app-member-quick-add-dialog *ngIf="showQuickAdd" (created)="onMemberQuickCreated($event)" <app-member-quick-add-dialog *ngIf="showQuickAdd" (created)="onMemberQuickCreated($event)"
(cancelled)="showQuickAdd = false"></app-member-quick-add-dialog> (cancelled)="showQuickAdd = false"></app-member-quick-add-dialog>
<!-- ============================ EDIT SUNDAY ATTENDANCE ============================ -->
<kendo-dialog *ngIf="attDialogOpen" title="修改主日參加人數 · Edit Sunday Attendance"
(close)="attDialogOpen = false" [width]="440" [maxWidth]="'95vw'">
<div class="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">成人 Adult
<kendo-numerictextbox [(ngModel)]="attForm.adult" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">青年 Youth
<kendo-numerictextbox [(ngModel)]="attForm.youth" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">兒童 Kid
<kendo-numerictextbox [(ngModel)]="attForm.kid" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
</div>
<div class="att-total">總數 Total: {{ attTotal }}</div>
<kendo-dialog-actions>
<button kendoButton (click)="attDialogOpen = false">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="attSaving" (click)="saveAttendance()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
</div> </div>
@@ -233,3 +233,13 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.rise { animation: none; opacity: 1; transform: none; } .rise { animation: none; opacity: 1; transform: none; }
} }
.clickable-rows {
.k-grid-table tr { cursor: pointer; }
}
.att-total {
margin-top: 0.75rem;
font-weight: 600;
text-align: right;
}
@@ -1,14 +1,16 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Observable, Subject, from, of, map, switchMap, takeUntil } from 'rxjs'; import { Observable, Subject, from, of, map, switchMap, takeUntil } from 'rxjs';
import { buildProofPdf } from '../../services/proof-pdf.builder'; import { buildProofPdf } from '../../services/proof-pdf.builder';
import { GridModule } from '@progress/kendo-angular-grid'; import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';
import { InputsModule } from '@progress/kendo-angular-inputs'; import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs'; import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { DialogsModule } from '@progress/kendo-angular-dialog'; import { DialogsModule } from '@progress/kendo-angular-dialog';
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { MealAttendanceApiService } from '../../../meal-attendance/services/meal-attendance-api.service';
import { OfferingSessionApiService } from '../../services/offering-session-api.service'; import { OfferingSessionApiService } from '../../services/offering-session-api.service';
import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.service'; import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.service';
import { GivingCategoryApiService } from '../../services/giving-category-api.service'; import { GivingCategoryApiService } from '../../services/giving-category-api.service';
@@ -30,7 +32,7 @@ type PageMode = 'landing' | 'workspace' | 'view';
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
DropDownsModule, DateInputsModule, DialogsModule, MemberQuickAddDialogComponent, DropDownsModule, DateInputsModule, DialogsModule, ContextMenuModule, MemberQuickAddDialogComponent,
], ],
templateUrl: './offering-session-page.component.html', templateUrl: './offering-session-page.component.html',
styleUrls: ['./offering-session-page.component.scss'], styleUrls: ['./offering-session-page.component.scss'],
@@ -74,12 +76,25 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
viewSession: OfferingSessionDto | null = null; viewSession: OfferingSessionDto | null = null;
confirmReopenOpen = false; confirmReopenOpen = false;
// Right-click actions on a Recent Sessions row.
@ViewChild('sessionMenu') sessionMenu!: ContextMenuComponent;
readonly sessionMenuItems = [{ text: 'View / 檢視' }, { text: '修改主日人數' }];
private contextSession: OfferingSessionListItemDto | null = null;
// Edit Sunday attendance dialog.
attDialogOpen = false;
attSaving = false;
private attDate: string | null = null; // yyyy-MM-dd of the session being edited
attForm = { adult: 0, youth: 0, kid: 0 };
get attTotal(): number { return this.attForm.adult + this.attForm.youth + this.attForm.kid; }
constructor( constructor(
private api: OfferingSessionApiService, private api: OfferingSessionApiService,
private categoryApi: GivingCategoryApiService, private categoryApi: GivingCategoryApiService,
private memberApi: MemberApiService, private memberApi: MemberApiService,
private signalr: OfferingEntrySignalrService, private signalr: OfferingEntrySignalrService,
) {} private mealAttendanceApi: MealAttendanceApiService,
) { }
ngOnInit(): void { ngOnInit(): void {
this.categoryApi.getAll(false).subscribe(c => { this.categoryApi.getAll(false).subscribe(c => {
@@ -162,6 +177,55 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
this.api.getPaged(1, 20).subscribe(r => this.sessions = r.items); this.api.getPaged(1, 20).subscribe(r => this.sessions = r.items);
} }
// Left-click anywhere on a row opens it; right-click opens the actions menu.
onSessionCellClick(event: CellClickEvent): void {
if (event.type === 'contextmenu') {
event.originalEvent.preventDefault();
this.contextSession = event.dataItem;
this.sessionMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
} else {
this.openView(event.dataItem);
}
}
onSessionMenuSelect(event: ContextMenuSelectEvent): void {
const session = this.contextSession;
if (!session) return;
if (event.item.text === 'View / 檢視') this.openView(session);
else if (event.item.text === '修改主日人數') this.openAttendanceEdit(session);
}
// Open the attendance editor, prefilling the three age groups from the existing row (zeros if none).
openAttendanceEdit(session: OfferingSessionListItemDto): void {
this.attDate = session.sessionDate;
this.attForm = { adult: 0, youth: 0, kid: 0 };
this.attSaving = false;
this.attDialogOpen = true;
this.mealAttendanceApi.getRange(session.sessionDate, session.sessionDate).subscribe(rows => {
const row = rows[0];
if (row) this.attForm = { adult: row.adult, youth: row.youth, kid: row.kid };
});
}
saveAttendance(): void {
if (!this.attDate) return;
const date = this.attDate;
this.attSaving = true;
this.mealAttendanceApi.setCounts(date, this.attForm).subscribe({
next: counts => {
const total = counts.adult + counts.youth + counts.kid;
const row = this.sessions.find(s => s.sessionDate === date);
if (row) row.sundayAttendanceCount = total;
this.attDialogOpen = false;
this.attSaving = false;
},
error: (err: { error?: { message?: string } }) => {
this.attSaving = false;
alert(err?.error?.message ?? 'Save failed.');
},
});
}
// ── Flow: landing → workspace / view ────────────────────────────────────── // ── Flow: landing → workspace / view ──────────────────────────────────────
/** Free date chosen on the landing screen — begin a brand-new session. */ /** Free date chosen on the landing screen — begin a brand-new session. */
@@ -275,7 +339,7 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
clearAnonymous(): void { clearAnonymous(): void {
this.entry.isAnonymous = false; this.entry.isAnonymous = false;
} }
lastAddedLine: OfferingBufferLine | null = null;
addLine(): void { addLine(): void {
if (this.entry.amount <= 0) return; if (this.entry.amount <= 0) return;
if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) return; if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) return;
@@ -287,6 +351,7 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
}; };
if (this.editingIndex !== null) { this.buffer[this.editingIndex] = line; this.editingIndex = null; } if (this.editingIndex !== null) { this.buffer[this.editingIndex] = line; this.editingIndex = null; }
else { this.buffer = [...this.buffer, line]; } else { this.buffer = [...this.buffer, line]; }
this.lastAddedLine = line;
this.resetEntry(); this.resetEntry();
} }
@@ -439,7 +504,7 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
private blankEntry(): OfferingBufferLine { private blankEntry(): OfferingBufferLine {
return { return {
memberId: null, givingCategoryId: 0, amount: 0, paymentMethod: 'Cash', memberId: null, givingCategoryId: this.lastAddedLine?.givingCategoryId, amount: 0, paymentMethod: this.lastAddedLine?.paymentMethod ?? 'Cash',
checkNumber: null, zelleReferenceCode: null, payPalTransactionId: null, checkNumber: null, zelleReferenceCode: null, payPalTransactionId: null,
isAnonymous: false, notes: null, memberName: null, categoryName: '', isAnonymous: false, notes: null, memberName: null, categoryName: '',
}; };
@@ -22,4 +22,9 @@ export class MealAttendanceApiService {
const params = new HttpParams().set('from', from).set('to', to); const params = new HttpParams().set('from', from).set('to', to);
return this.http.get<AttendanceCounts[]>(this.endpoint, { params }); return this.http.get<AttendanceCounts[]>(this.endpoint, { params });
} }
/** Overwrite a specific Sunday's counts (back-office editor). */
setCounts(date: string, counts: { adult: number; youth: number; kid: number }): Observable<AttendanceCounts> {
return this.http.put<AttendanceCounts>(`${this.endpoint}/${date}`, counts);
}
} }
@@ -0,0 +1,60 @@
<kendo-dialog title="Invitation Link" (close)="onClose()" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<!-- Ask for an email when the member has none on file -->
<ng-container *ngIf="step === 'needEmail'">
<p class="k-mb-4">
Create a first-login invitation for <strong>{{ memberName }}</strong>.
This member has no email on file — enter one to use as their login.
</p>
<form [formGroup]="emailForm" (ngSubmit)="generate()" class="k-form k-form-vertical">
<kendo-formfield>
<kendo-label text="Login Email *"></kendo-label>
<kendo-textbox formControlName="email"></kendo-textbox>
<kendo-formerror *ngIf="emailForm.get('email')?.errors?.['required']">Email is required.</kendo-formerror>
<kendo-formerror *ngIf="emailForm.get('email')?.errors?.['email']">Invalid email address.</kendo-formerror>
</kendo-formfield>
</form>
<p *ngIf="errorMessage" class="k-color-error k-mt-3">{{ errorMessage }}</p>
<kendo-dialog-actions>
<button kendoButton (click)="onClose()">Cancel</button>
<button kendoButton themeColor="primary" (click)="generate()">Create Link</button>
</kendo-dialog-actions>
</ng-container>
<!-- Generating spinner -->
<ng-container *ngIf="step === 'generating'">
<div class="k-text-center k-p-4">
<kendo-loader></kendo-loader>
<p class="k-mt-2">Creating invitation link…</p>
</div>
</ng-container>
<!-- Ready — show link to copy / email -->
<ng-container *ngIf="step === 'ready'">
<p class="k-mb-3">
Send this link to <strong>{{ memberName }}</strong>. They'll set their own password and sign in.
</p>
<div class="k-d-flex k-gap-2 k-align-items-center k-mb-2">
<kendo-textbox [value]="link" [readonly]="true" style="flex: 1"></kendo-textbox>
<button kendoButton (click)="copyLink()">{{ copied ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="k-font-size-sm k-mb-3">
Single use — expires {{ expiresAt | date:'medium' }}.
</p>
<button kendoButton themeColor="info" (click)="sendEmail()" [disabled]="isSending">
<span *ngIf="isSending"></span>
Send via email
</button>
<kendo-dialog-actions>
<button kendoButton themeColor="primary" (click)="onClose()">Done</button>
</kendo-dialog-actions>
</ng-container>
</kendo-dialog>
@@ -0,0 +1,106 @@
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { LabelModule } from '@progress/kendo-angular-label';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { MemberListItemDto, memberDisplayName } from '../../models/member.model';
import { InvitationApiService } from '../../services/invitation-api.service';
import { ToastService } from '../../../../core/services/toast.service';
type Step = 'needEmail' | 'generating' | 'ready';
@Component({
selector: 'app-invitation-dialog',
standalone: true,
imports: [
CommonModule, ReactiveFormsModule, DialogsModule, InputsModule,
LabelModule, ButtonsModule, IndicatorsModule,
],
templateUrl: './invitation-dialog.component.html',
})
export class InvitationDialogComponent implements OnInit {
@Input({ required: true }) member!: MemberListItemDto;
@Output() cancelled = new EventEmitter<void>();
step: Step = 'generating';
emailForm!: FormGroup;
link = '';
expiresAt: string | null = null;
copied = false;
isSending = false;
errorMessage = '';
get memberName(): string { return memberDisplayName(this.member); }
constructor(
private fb: FormBuilder,
private invitationApi: InvitationApiService,
private toast: ToastService,
) {}
ngOnInit(): void {
this.emailForm = this.fb.group({
email: [this.member.email ?? '', [Validators.required, Validators.email]],
});
// Auto-generate when the member already has an email; otherwise ask for one first.
if (this.member.email) {
this.generate();
} else {
this.step = 'needEmail';
}
}
/** Generate (or re-issue) the link. Uses the form email when the member has none on file. */
generate(): void {
if (this.step === 'needEmail') {
if (this.emailForm.invalid) { this.emailForm.markAllAsTouched(); return; }
}
const email = this.member.email ? undefined : this.emailForm.value.email;
this.step = 'generating';
this.errorMessage = '';
this.invitationApi.create(this.member.id, email).subscribe({
next: (result) => {
this.link = `${window.location.origin}/accept-invitation?token=${result.token}`;
this.expiresAt = result.expiresAt;
this.step = 'ready';
},
error: (err) => {
this.errorMessage = err.error?.message ?? 'Failed to create the invitation link.';
// Fall back to the email step so the admin can supply/correct an address.
this.step = 'needEmail';
},
});
}
copyLink(): void {
navigator.clipboard.writeText(this.link).then(() => {
this.copied = true;
setTimeout(() => (this.copied = false), 2000);
});
}
sendEmail(): void {
this.isSending = true;
this.invitationApi.sendEmail(this.member.id, this.link).subscribe({
next: () => {
this.toast.success(`Invitation emailed to ${this.memberName}.`);
this.isSending = false;
},
error: (err) => {
this.toast.error(err.error?.message ?? 'Failed to send the email.');
this.isSending = false;
},
});
}
onClose(): void {
this.cancelled.emit();
}
}
@@ -33,6 +33,11 @@
<kendo-textbox formControlName="lastName_zh"></kendo-textbox> <kendo-textbox formControlName="lastName_zh"></kendo-textbox>
</kendo-formfield> </kendo-formfield>
<kendo-formfield class="md:col-span-2">
<kendo-label text="公司行號 · Company / Entity"></kendo-label>
<kendo-textbox formControlName="entity" placeholder="e.g. ABC Trading Inc."></kendo-textbox>
</kendo-formfield>
<kendo-formfield> <kendo-formfield>
<kendo-label text="Gender"></kendo-label> <kendo-label text="Gender"></kendo-label>
<kendo-dropdownlist <kendo-dropdownlist
@@ -43,6 +43,7 @@ export class MemberFormDialogComponent implements OnInit {
nickName: [this.member?.nickName ?? null, Validators.maxLength(100)], nickName: [this.member?.nickName ?? null, Validators.maxLength(100)],
firstName_zh: [this.member?.firstName_zh ?? null, Validators.maxLength(100)], firstName_zh: [this.member?.firstName_zh ?? null, Validators.maxLength(100)],
lastName_zh: [this.member?.lastName_zh ?? null, Validators.maxLength(100)], lastName_zh: [this.member?.lastName_zh ?? null, Validators.maxLength(100)],
entity: [this.member?.entity ?? null, Validators.maxLength(200)],
gender: [this.member?.gender ?? null], gender: [this.member?.gender ?? null],
dateOfBirth: [this.member?.dateOfBirth ?? null], dateOfBirth: [this.member?.dateOfBirth ?? null],
status: [this.member?.status ?? 'Member', Validators.required], status: [this.member?.status ?? 'Member', Validators.required],
@@ -7,6 +7,7 @@ export interface MemberListItemDto {
nickName: string | null; nickName: string | null;
firstName_zh: string | null; firstName_zh: string | null;
lastName_zh: string | null; lastName_zh: string | null;
entity: string | null;
status: MemberStatus; status: MemberStatus;
email: string | null; email: string | null;
phoneCell: string | null; phoneCell: string | null;
@@ -39,6 +40,7 @@ export interface CreateMemberRequest {
nickName: string | null; nickName: string | null;
firstName_zh: string | null; firstName_zh: string | null;
lastName_zh: string | null; lastName_zh: string | null;
entity: string | null;
gender: string | null; gender: string | null;
dateOfBirth: string | null; dateOfBirth: string | null;
baptismDate: string | null; baptismDate: string | null;
@@ -78,7 +80,12 @@ export interface MemberQueryParams {
/** Display name: NickName (if present) else FirstName_en, plus LastName_en */ /** Display name: NickName (if present) else FirstName_en, plus LastName_en */
export function memberDisplayName( export function memberDisplayName(
m: Pick<MemberListItemDto, 'nickName' | 'firstName_en' | 'lastName_en'> m: Pick<MemberListItemDto, 'nickName' | 'firstName_en' | 'lastName_en' | 'entity'>
): string { ): string {
return `${m.nickName ?? m.firstName_en} ${m.lastName_en}`; const legal = `${m.firstName_en} ${m.lastName_en}`.trim();
const base = (m.nickName && m.nickName !== m.firstName_en)
? `${m.nickName} ${m.lastName_en} (${legal})`
: legal;
// Append the company / business name so a company-check giver is unambiguous.
return m.entity ? `${base} · ${m.entity}` : base;
} }
@@ -63,13 +63,15 @@
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
<kendo-grid-column title="Actions" [width]="210"> <kendo-grid-column title="Actions" [width]="290">
<ng-template kendoGridCellTemplate let-row> <ng-template kendoGridCellTemplate let-row>
<div class="k-d-flex k-gap-2"> <div class="k-d-flex k-gap-2">
<button kendoButton size="small" (click)="openEditDialog(row)">Edit</button> <button kendoButton size="small" (click)="openEditDialog(row)">Edit</button>
<button kendoButton size="small" themeColor="error" (click)="deleteMember(row)">Delete</button> <button kendoButton size="small" themeColor="error" (click)="deleteMember(row)">Delete</button>
<button *ngIf="!row.linkedUserId" kendoButton size="small" themeColor="info" <button *ngIf="!row.linkedUserId" kendoButton size="small" themeColor="info"
(click)="openCreateUserDialog(row)">+ Account</button> (click)="openCreateUserDialog(row)">+ Account</button>
<button *appHasPermission="['Users', 'write']" kendoButton size="small" themeColor="warning"
(click)="openInviteDialog(row)">Invite</button>
</div> </div>
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
@@ -92,3 +94,10 @@
(created)="onUserCreated()" (created)="onUserCreated()"
(cancelled)="closeCreateUserDialog()"> (cancelled)="closeCreateUserDialog()">
</app-create-user-dialog> </app-create-user-dialog>
<!-- Invitation Link Dialog -->
<app-invitation-dialog
*ngIf="showInviteDialog && selectedMemberForInvite"
[member]="selectedMemberForInvite"
(cancelled)="closeInviteDialog()">
</app-invitation-dialog>
@@ -9,12 +9,14 @@ import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { MemberApiService } from '../../services/member-api.service'; import { MemberApiService } from '../../services/member-api.service';
import { MemberFormDialogComponent } from '../../components/member-form-dialog/member-form-dialog.component'; import { MemberFormDialogComponent } from '../../components/member-form-dialog/member-form-dialog.component';
import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component'; import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component';
import { InvitationDialogComponent } from '../../components/invitation-dialog/invitation-dialog.component';
import { import {
MemberListItemDto, MemberDto, CreateMemberRequest, MemberListItemDto, MemberDto, CreateMemberRequest,
PagedResult, memberDisplayName PagedResult, memberDisplayName
} from '../../models/member.model'; } from '../../models/member.model';
import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists'; import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive'; import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
@Component({ @Component({
selector: 'app-members-page', selector: 'app-members-page',
@@ -22,7 +24,8 @@ import { PageHeaderActionsDirective } from '../../../../shared/directives/page-h
imports: [ imports: [
CommonModule, FormsModule, GridModule, InputsModule, CommonModule, FormsModule, GridModule, InputsModule,
ButtonsModule, IndicatorsModule, DropDownsModule, ButtonsModule, IndicatorsModule, DropDownsModule,
MemberFormDialogComponent, CreateUserDialogComponent, PageHeaderActionsDirective, MemberFormDialogComponent, CreateUserDialogComponent, InvitationDialogComponent,
PageHeaderActionsDirective, HasPermissionDirective,
], ],
templateUrl: './members-page.component.html', templateUrl: './members-page.component.html',
styleUrls: ['./members-page.component.scss'], styleUrls: ['./members-page.component.scss'],
@@ -46,8 +49,10 @@ export class MembersPageComponent implements OnInit {
// Dialogs // Dialogs
showMemberDialog = false; showMemberDialog = false;
showCreateUserDialog = false; showCreateUserDialog = false;
showInviteDialog = false;
editingMember: MemberDto | null = null; editingMember: MemberDto | null = null;
selectedMemberForUser: MemberListItemDto | null = null; selectedMemberForUser: MemberListItemDto | null = null;
selectedMemberForInvite: MemberListItemDto | null = null;
readonly memberDisplayName = memberDisplayName; readonly memberDisplayName = memberDisplayName;
@@ -139,4 +144,18 @@ export class MembersPageComponent implements OnInit {
this.closeCreateUserDialog(); this.closeCreateUserDialog();
this.loadData(); this.loadData();
} }
// ── Invitation Link ─────────────────────────────────────────────────────────
openInviteDialog(member: MemberListItemDto): void {
this.selectedMemberForInvite = member;
this.showInviteDialog = true;
}
closeInviteDialog(): void {
this.showInviteDialog = false;
this.selectedMemberForInvite = null;
// An invitation may have just created an account, so refresh the grid.
this.loadData();
}
} }
@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
export interface CreateInvitationResult {
/** Raw, single-use token — returned once; build the link from it client-side. */
token: string;
/** ISO timestamp when the link stops working. */
expiresAt: string;
}
@Injectable({ providedIn: 'root' })
export class InvitationApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('invitations');
}
/** Generate (or re-issue) a first-login invitation link for a member. */
create(memberId: number, email?: string): Observable<CreateInvitationResult> {
return this.http.post<CreateInvitationResult>(this.endpoint, { memberId, email });
}
/** E-mail an already-generated link to the member. */
sendEmail(memberId: number, link: string): Observable<void> {
return this.http.post<void>(`${this.endpoint}/send`, { memberId, link });
}
}
@@ -0,0 +1,104 @@
<div *ngIf="model" class="max-w-3xl pt-4 flex flex-col gap-6">
<!-- ── Email (SMTP) ─────────────────────────────────────────────────────── -->
<section class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">Email (SMTP) / 電子郵件</h3>
<label class="flex items-center gap-2 text-sm">
Enabled / 啟用
<kendo-switch [(ngModel)]="model.enableEmail"></kendo-switch>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">
SMTP Host / 主機
<kendo-textbox [(ngModel)]="model.smtpHost" placeholder="smtp.example.com"></kendo-textbox>
</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col gap-1">
Port / 連接埠
<kendo-numerictextbox [(ngModel)]="model.smtpPort" [min]="0" [max]="65535" [decimals]="0" format="#"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">
Use SSL / 加密
<kendo-switch [(ngModel)]="model.smtpUseSsl"></kendo-switch>
</label>
</div>
<label class="flex flex-col gap-1">
SMTP User / 帳號
<kendo-textbox [(ngModel)]="model.smtpUser"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
SMTP Password / 密碼
<kendo-textbox [(ngModel)]="smtpPassword" type="password"
[placeholder]="model.hasSmtpPassword ? ' stored (blank = keep) / 已設定' : ''"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
From Address / 寄件地址
<kendo-textbox [(ngModel)]="model.fromAddress" placeholder="noreply@church.org"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
From Name / 寄件人名稱
<kendo-textbox [(ngModel)]="model.fromName"></kendo-textbox>
</label>
</div>
<div class="flex flex-wrap items-end gap-3">
<label class="flex flex-col gap-1 grow max-w-xs">
<span class="text-sm">Test recipient (blank = you) / 測試收件人</span>
<kendo-textbox [(ngModel)]="testEmailTo" placeholder="you@example.com"></kendo-textbox>
</label>
<button kendoButton [disabled]="testingEmail" (click)="sendTestEmail()">Send test email / 寄送測試</button>
<span class="text-sm" [style.color]="testEmailMsg.startsWith('Sent') ? '#065f46' : '#b91c1c'">{{ testEmailMsg }}</span>
</div>
</section>
<hr class="border-gray-200" />
<!-- ── Line ─────────────────────────────────────────────────────────────── -->
<section class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">Line / Line 通知</h3>
<label class="flex items-center gap-2 text-sm">
Enabled / 啟用
<kendo-switch [(ngModel)]="model.enableLine"></kendo-switch>
</label>
</div>
<div class="grid grid-cols-1 gap-y-3">
<label class="flex flex-col gap-1">
Channel Access Token / 頻道存取權杖
<kendo-textbox [(ngModel)]="lineToken" type="password"
[placeholder]="model.hasLineChannelAccessToken ? ' stored (blank = keep) / 已設定' : ''"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Channel Secret / 頻道密鑰
<kendo-textbox [(ngModel)]="lineSecret" type="password"
[placeholder]="model.hasLineChannelSecret ? ' stored (blank = keep) / 已設定' : ''"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Webhook URL (register in Line console) / Webhook 網址
<kendo-textbox [ngModel]="model.webhookUrl" [readonly]="true"></kendo-textbox>
</label>
</div>
<div class="flex flex-wrap items-end gap-3">
<label class="flex flex-col gap-1">
<span class="text-sm">Test member ID / 會員編號</span>
<kendo-numerictextbox [(ngModel)]="testLineMemberId" [decimals]="0" format="#" [min]="1" class="w-40"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">or Group ID / 群組編號</span>
<kendo-numerictextbox [(ngModel)]="testLineGroupId" [decimals]="0" format="#" [min]="1" class="w-40"></kendo-numerictextbox>
</label>
<button kendoButton [disabled]="testingLine" (click)="sendTestLine()">Send test Line / 寄送測試</button>
<span class="text-sm" [style.color]="testLineMsg.startsWith('Sent') ? '#065f46' : '#b91c1c'">{{ testLineMsg }}</span>
</div>
</section>
<div class="flex items-center gap-3">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
@@ -0,0 +1,106 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { SettingsApiService } from '../../services/settings-api.service';
import {
NotificationSettingDto, UpdateNotificationSettingRequest, NotificationResult,
} from '../../models/settings.model';
@Component({
selector: 'app-notification-settings-tab',
standalone: true,
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
templateUrl: './notification-settings-tab.component.html',
})
export class NotificationSettingsTabComponent implements OnInit {
model: NotificationSettingDto | null = null;
saving = false;
savedMsg = '';
// Write-only secret inputs — blank means "keep the stored value".
smtpPassword = '';
lineToken = '';
lineSecret = '';
// Test-send state.
testEmailTo = '';
testEmailMsg = '';
testingEmail = false;
testLineMemberId: number | null = null;
testLineGroupId: number | null = null;
testLineMsg = '';
testingLine = false;
constructor(private api: SettingsApiService) {}
ngOnInit(): void {
this.load();
}
private load(): void {
this.api.getNotification().subscribe(n => (this.model = n));
}
save(): void {
if (!this.model || this.saving) return;
this.saving = true;
this.savedMsg = '';
const m = this.model;
const request: UpdateNotificationSettingRequest = {
enableEmail: m.enableEmail,
smtpHost: m.smtpHost,
smtpPort: m.smtpPort,
smtpUseSsl: m.smtpUseSsl,
smtpUser: m.smtpUser,
fromAddress: m.fromAddress,
fromName: m.fromName,
smtpPassword: this.smtpPassword || null,
enableLine: m.enableLine,
lineChannelAccessToken: this.lineToken || null,
lineChannelSecret: this.lineSecret || null,
};
this.api.updateNotification(request).subscribe({
next: () => {
this.saving = false;
this.savedMsg = 'Saved / 已儲存';
// Clear secret inputs and refresh the "configured" flags.
this.smtpPassword = this.lineToken = this.lineSecret = '';
this.load();
},
error: () => { this.saving = false; },
});
}
sendTestEmail(): void {
if (this.testingEmail) return;
this.testingEmail = true;
this.testEmailMsg = '';
this.api.testEmail({ toAddress: this.testEmailTo || null }).subscribe({
next: result => { this.testingEmail = false; this.testEmailMsg = this.describe(result); },
error: err => { this.testingEmail = false; this.testEmailMsg = this.errorText(err); },
});
}
sendTestLine(): void {
if (this.testingLine) return;
this.testingLine = true;
this.testLineMsg = '';
this.api.testLine({ memberId: this.testLineMemberId, groupId: this.testLineGroupId }).subscribe({
next: result => { this.testingLine = false; this.testLineMsg = this.describe(result); },
error: err => { this.testingLine = false; this.testLineMsg = this.errorText(err); },
});
}
private describe(result: NotificationResult): string {
if (result.failedCount > 0)
return `Failed / 失敗:${result.failures.map(f => f.error).join('; ')}`;
return result.sentCount > 0 ? 'Sent ✓ / 已發送' : 'Nothing sent / 未發送';
}
private errorText(err: { error?: { message?: string } }): string {
return err?.error?.message ?? 'Failed / 失敗';
}
}
@@ -0,0 +1,35 @@
<div *ngIf="model" class="max-w-3xl pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">
Site Title (EN) / 網站名稱
<kendo-textbox [(ngModel)]="model.siteTitle"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Site Title (ZH) / 網站名稱(中)
<kendo-textbox [(ngModel)]="model.siteTitleZh"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Default Language / 預設語言
<kendo-dropdownlist
[data]="languages" textField="text" valueField="value" [valuePrimitive]="true"
[(ngModel)]="model.defaultLanguage"></kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
Time Zone / 時區
<kendo-textbox [(ngModel)]="model.timeZone" placeholder="America/Los_Angeles"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Date Format / 日期格式
<kendo-textbox [(ngModel)]="model.dateFormat" placeholder="yyyy-MM-dd"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Currency / 貨幣
<kendo-textbox [(ngModel)]="model.currency" placeholder="USD"></kendo-textbox>
</label>
</div>
<div class="flex items-center gap-3 mt-4">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
@@ -0,0 +1,42 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { SettingsApiService } from '../../services/settings-api.service';
import { SiteSettingDto } from '../../models/settings.model';
@Component({
selector: 'app-site-settings-tab',
standalone: true,
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule, DropDownsModule],
templateUrl: './site-settings-tab.component.html',
})
export class SiteSettingsTabComponent implements OnInit {
model: SiteSettingDto | null = null;
saving = false;
savedMsg = '';
readonly languages = [
{ text: 'English', value: 'en' },
{ text: '中文 Chinese', value: 'zh' },
];
constructor(private api: SettingsApiService) {}
ngOnInit(): void {
this.api.getSite().subscribe(s => (this.model = s));
}
save(): void {
if (!this.model || this.saving) return;
this.saving = true;
this.savedMsg = '';
this.api.updateSite(this.model).subscribe({
next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; },
// Errors surface globally via httpErrorInterceptor.
error: () => { this.saving = false; },
});
}
}
@@ -0,0 +1,66 @@
// Mirrors ROLAC.API.DTOs.Settings — site + notification settings edited from the
// Church Profile tabbed page (Settings permission module).
export interface SiteSettingDto {
siteTitle: string;
siteTitleZh: string | null;
defaultLanguage: string; // 'en' | 'zh'
timeZone: string;
dateFormat: string;
currency: string;
}
export type UpdateSiteSettingRequest = SiteSettingDto;
export interface NotificationSettingDto {
enableEmail: boolean;
smtpHost: string;
smtpPort: number;
smtpUseSsl: boolean;
smtpUser: string;
fromAddress: string;
fromName: string;
/** True when a password is stored — secrets themselves are never returned. */
hasSmtpPassword: boolean;
enableLine: boolean;
hasLineChannelAccessToken: boolean;
hasLineChannelSecret: boolean;
/** Read-only webhook URL to register in the Line console. */
webhookUrl: string;
}
export interface UpdateNotificationSettingRequest {
enableEmail: boolean;
smtpHost: string;
smtpPort: number;
smtpUseSsl: boolean;
smtpUser: string;
fromAddress: string | null;
fromName: string | null;
/** Leave blank/omit to keep the stored password. */
smtpPassword?: string | null;
enableLine: boolean;
/** Leave blank/omit to keep the stored token. */
lineChannelAccessToken?: string | null;
/** Leave blank/omit to keep the stored secret. */
lineChannelSecret?: string | null;
}
export interface TestEmailRequest {
toAddress?: string | null;
}
export interface TestLineRequest {
memberId?: number | null;
groupId?: number | null;
}
/** Mirrors ROLAC.API NotificationResult. */
export interface NotificationResult {
sentCount: number;
failedCount: number;
failures: { target: string; error: string }[];
}
@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
SiteSettingDto, UpdateSiteSettingRequest,
NotificationSettingDto, UpdateNotificationSettingRequest,
TestEmailRequest, TestLineRequest, NotificationResult,
} from '../models/settings.model';
@Injectable({ providedIn: 'root' })
export class SettingsApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('settings');
}
getSite(): Observable<SiteSettingDto> {
return this.http.get<SiteSettingDto>(`${this.endpoint}/site`);
}
updateSite(request: UpdateSiteSettingRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/site`, request);
}
getNotification(): Observable<NotificationSettingDto> {
return this.http.get<NotificationSettingDto>(`${this.endpoint}/notification`);
}
updateNotification(request: UpdateNotificationSettingRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/notification`, request);
}
testEmail(request: TestEmailRequest): Observable<NotificationResult> {
return this.http.post<NotificationResult>(`${this.endpoint}/notification/test-email`, request);
}
testLine(request: TestLineRequest): Observable<NotificationResult> {
return this.http.post<NotificationResult>(`${this.endpoint}/notification/test-line`, request);
}
}
@@ -81,6 +81,10 @@ export class UserHeaderComponent implements OnInit, OnDestroy {
} }
public getDisplayName(): string { public getDisplayName(): string {
const member = this.currentUser?.memberInfo;
if (member) {
return `${member.nickName ?? member.firstName_en} ${member.lastName_en}`;
}
return this.currentUser?.email || ''; return this.currentUser?.email || '';
} }
@@ -8,7 +8,7 @@
</div> </div>
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="stats-grid"> <!-- <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon active"> <div class="stat-icon active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -62,7 +62,7 @@
<div class="stat-label">Total Value</div> <div class="stat-label">Total Value</div>
</div> </div>
</div> </div>
</div> </div> -->
<!-- Sunday Attendance Trend --> <!-- Sunday Attendance Trend -->
<div class="section"> <div class="section">
@@ -98,8 +98,8 @@
</kendo-chart-series-item> </kendo-chart-series-item>
<!-- Invisible line series whose only job is to carry the total label on top of each bar. --> <!-- Invisible line series whose only job is to carry the total label on top of each bar. -->
<kendo-chart-series-item type="line" [data]="attendanceTotal" [visibleInLegend]="false" <kendo-chart-series-item type="line" [data]="attendanceTotal" [visibleInLegend]="false"
color="transparent" [width]="0" [markers]="{ visible: false }" color="transparent" [width]="0" [markers]="{ visible: false }" [highlight]="{ visible: false }"
[highlight]="{ visible: false }" [tooltip]="{ visible: false }"> [tooltip]="{ visible: false }">
<kendo-chart-series-item-labels [visible]="true" position="above" color="#374151" <kendo-chart-series-item-labels [visible]="true" position="above" color="#374151"
font="bold 13px sans-serif" [content]="totalLabelContent"> font="bold 13px sans-serif" [content]="totalLabelContent">
</kendo-chart-series-item-labels> </kendo-chart-series-item-labels>
@@ -80,6 +80,10 @@ export class DashboardComponent implements OnInit {
} }
getDisplayName(): string { getDisplayName(): string {
const member = this.currentUser?.memberInfo;
if (member) {
return `${member.nickName ?? member.firstName_en} ${member.lastName_en}`;
}
return this.currentUser?.email || ''; return this.currentUser?.email || '';
} }
@@ -332,6 +332,10 @@ export class UserPortalComponent implements OnInit, OnDestroy {
} }
getDisplayName(): string { getDisplayName(): string {
const member = this.currentUser?.memberInfo;
if (member) {
return `${member.nickName ?? member.firstName_en} ${member.lastName_en}`;
}
return this.currentUser?.email || ''; return this.currentUser?.email || '';
} }
} }
@@ -7,6 +7,16 @@ import { ModuleActions } from '../../core/models/permission.model';
// ── Public interfaces ───────────────────────────────────────────────────────── // ── Public interfaces ─────────────────────────────────────────────────────────
/** Matches the C# MemberInfo DTO exactly. */
export interface MemberInfo {
id: number;
nickName: string | null;
firstName_en: string;
lastName_en: string;
firstName_zh: string | null;
lastName_zh: string | null;
}
/** Matches the C# UserInfo DTO exactly. */ /** Matches the C# UserInfo DTO exactly. */
export interface UserInfo { export interface UserInfo {
id: string; id: string;
@@ -18,6 +28,12 @@ export interface UserInfo {
* camelCase dictionary-key policy). Absent for legacy/secret-link tokens. * camelCase dictionary-key policy). Absent for legacy/secret-link tokens.
*/ */
permissions?: Record<string, ModuleActions>; permissions?: Record<string, ModuleActions>;
/**
* The church member linked to this account, or absent for admin-only
* accounts and accounts whose member record was deleted. Flows through
* login, refresh, and /me so the greeting survives a page reload.
*/
memberInfo?: MemberInfo;
} }
/** Matches the C# LoginResponse DTO exactly. */ /** Matches the C# LoginResponse DTO exactly. */
@@ -48,6 +64,14 @@ export interface LoginResult {
message?: string; message?: string;
} }
/** Matches the C# ValidateInvitationResult DTO. */
export interface ValidateInvitationResult {
valid: boolean;
expired: boolean;
memberName?: string;
email?: string;
}
export interface TokenVerificationResult { export interface TokenVerificationResult {
isValid: boolean; isValid: boolean;
/** Constructed from JWT claims when using secret-link login. */ /** Constructed from JWT claims when using secret-link login. */
@@ -161,6 +185,36 @@ export class AuthService {
); );
} }
/**
* Checks whether an invitation token is still usable (anonymous). Used by the
* public "set your password" page to decide what to show before the member types.
*/
validateInvitation(token: string): Observable<ValidateInvitationResult> {
return this.http.get<ValidateInvitationResult>(
`${this.apiConfig.authUrl}/invitation/validate`,
{ params: { token } }
);
}
/**
* Consumes an invitation: sets the password and logs the member in. On success the
* server returns a normal login payload, so we store the access token + user (and the
* refresh cookie is set server-side) exactly like login(). Errors propagate to the caller.
*/
acceptInvitation(token: string, newPassword: string): Observable<UserInfo> {
return this.http.post<ApiLoginResponse>(
`${this.apiConfig.authUrl}/accept-invitation`,
{ token, newPassword },
{ withCredentials: true }
).pipe(
tap(response => {
this.accessToken$.next(response.accessToken);
this.currentUser$.next(response.user);
}),
map(response => response.user)
);
}
/** /**
* Clears in-memory auth state immediately, then fires a fire-and-forget * Clears in-memory auth state immediately, then fires a fire-and-forget
* POST to revoke the server-side refresh token cookie. * POST to revoke the server-side refresh token cookie.
@@ -0,0 +1,537 @@
# Offering Session — 顯示與修改主日參加人數 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 finance/offering-session 的 Recent Sessions 表格中,每筆 session 顯示該日的主日參加人數(MealAttendance 三分類之和),並以右鍵 context menu 提供修改該日參加人數的 Action。
**Architecture:** 後端 `OfferingSessionService.GetPagedAsync` 以 session 的 `SessionDate` 一次性 join `MealAttendance` 求和,填入 DTO 的新 nullable 欄位。修改走新的 REST 端點 `PUT /api/meal-attendance/{date}`SignalR 的 `SetCount` 只能改本週日,無法改任意日期),由新的 `IMealAttendanceService.SetCountsAsync` 一次寫三欄(load + set + SaveChangesclamp 至 0,無 row 則建立)。前端在 Kendo grid 加欄、加右鍵選單、加編輯 Dialog。
**Tech Stack:** C# / EF Core (PostgreSQL, InMemory for tests) / ASP.NET Core / xUnit + MoqAngular standalone component + Kendo UI (Grid / ContextMenu / Dialog / NumericTextBox)。
---
## File Structure
**Backend (modify):**
- `API/ROLAC.API/DTOs/MealAttendance/SetAttendanceRequest.cs`**create**: PUT body `{ adult, youth, kid }`.
- `API/ROLAC.API/Services/IMealAttendanceService.cs` — add `SetCountsAsync`.
- `API/ROLAC.API/Services/MealAttendanceService.cs` — implement `SetCountsAsync`.
- `API/ROLAC.API/Controllers/MealAttendanceController.cs` — add `PUT /{date}`.
- `API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs` — add `SundayAttendanceCount`.
- `API/ROLAC.API/Services/OfferingSessionService.cs` — populate attendance in `GetPagedAsync`.
**Backend (tests):**
- `API/ROLAC.API.Tests/Services/MealAttendanceServiceTests.cs`**create**.
- `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs` — add one test.
**Frontend (modify):**
- `APP/src/app/features/giving/models/giving.model.ts` — add `sundayAttendanceCount`.
- `APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts` — add `setCounts`.
- `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts` — column data, context menu, edit dialog.
- `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html` — column, menu, dialog markup.
- `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss` — total-line style (optional).
**Build/test commands** (Visual Studio locks `bin/Debug`; always use Release for CLI — per project convention):
- Backend tests: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`
- Frontend build: run from `APP/`: `npm run build`
---
## Task 1: Backend service — `SetCountsAsync`
**Files:**
- Modify: `API/ROLAC.API/Services/IMealAttendanceService.cs`
- Modify: `API/ROLAC.API/Services/MealAttendanceService.cs`
- Test: `API/ROLAC.API.Tests/Services/MealAttendanceServiceTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `API/ROLAC.API.Tests/Services/MealAttendanceServiceTests.cs`:
```csharp
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class MealAttendanceServiceTests
{
private static AppDbContext BuildDb() =>
new(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
[Fact]
public async Task SetCountsAsync_CreatesRowWhenMissing_AndReturnsTotals()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
var result = await svc.SetCountsAsync(date, adult: 40, youth: 12, kid: 8);
Assert.Equal("2026-05-31", result.Date);
Assert.Equal(40, result.Adult);
Assert.Equal(12, result.Youth);
Assert.Equal(8, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date));
}
[Fact]
public async Task SetCountsAsync_OverwritesExistingRow_AndClampsNegativesToZero()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
await svc.SetCountsAsync(date, 40, 12, 8);
var result = await svc.SetCountsAsync(date, adult: 50, youth: -3, kid: 0);
Assert.Equal(50, result.Adult);
Assert.Equal(0, result.Youth); // negative clamped to zero
Assert.Equal(0, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date)); // still one row
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter MealAttendanceServiceTests`
Expected: FAIL — compile error, `MealAttendanceService` has no `SetCountsAsync`.
- [ ] **Step 3: Add the interface method**
In `API/ROLAC.API/Services/IMealAttendanceService.cs`, add after the existing `SetAsync` declaration (before `GetRangeAsync`):
```csharp
/// <summary>
/// Overwrites all three age-group columns for <paramref name="date"/> with absolute
/// values (each clamped at zero), creating the row if it does not exist, and returns
/// the resulting authoritative counts. Used by the back-office Sunday-attendance editor.
/// </summary>
Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid);
```
- [ ] **Step 4: Implement the method**
In `API/ROLAC.API/Services/MealAttendanceService.cs`, add after `SetAsync` (before `GetRangeAsync`):
```csharp
public async Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid)
{
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);
}
```
(`ToDto` and the `MealAttendance` entity are already in this file's scope.)
- [ ] **Step 5: Run test to verify it passes**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter MealAttendanceServiceTests`
Expected: PASS (2 tests).
- [ ] **Step 6: Commit**
```bash
git add API/ROLAC.API/Services/IMealAttendanceService.cs API/ROLAC.API/Services/MealAttendanceService.cs API/ROLAC.API.Tests/Services/MealAttendanceServiceTests.cs
git commit -m "feat(attendance): add SetCountsAsync to set all three age groups for a date"
```
---
## Task 2: Backend endpoint — `PUT /api/meal-attendance/{date}`
**Files:**
- Create: `API/ROLAC.API/DTOs/MealAttendance/SetAttendanceRequest.cs`
- Modify: `API/ROLAC.API/Controllers/MealAttendanceController.cs`
- [ ] **Step 1: Create the request DTO**
Create `API/ROLAC.API/DTOs/MealAttendance/SetAttendanceRequest.cs`:
```csharp
namespace ROLAC.API.DTOs.MealAttendance;
/// <summary>Absolute head-counts to write for one Sunday, from the back-office editor.</summary>
public class SetAttendanceRequest
{
public int Adult { get; set; }
public int Youth { get; set; }
public int Kid { get; set; }
}
```
- [ ] **Step 2: Add the controller action**
In `API/ROLAC.API/Controllers/MealAttendanceController.cs`, add a `using` for the DTO namespace if not present (it already uses `ROLAC.API.Services`; add `using ROLAC.API.DTOs.MealAttendance;`), then add this action after `GetRange`:
```csharp
/// <summary>Overwrite a specific Sunday's counts (back-office editor). Authenticated only.</summary>
[HttpPut("{date}")]
[Authorize]
public async Task<IActionResult> SetCounts(DateOnly date, [FromBody] SetAttendanceRequest body)
=> Ok(await _svc.SetCountsAsync(date, body.Adult, body.Youth, body.Kid));
```
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 4: Commit**
```bash
git add API/ROLAC.API/DTOs/MealAttendance/SetAttendanceRequest.cs API/ROLAC.API/Controllers/MealAttendanceController.cs
git commit -m "feat(attendance): add PUT /api/meal-attendance/{date} to overwrite a Sunday's counts"
```
---
## Task 3: Backend — include attendance total in offering session list
**Files:**
- Modify: `API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs`
- Modify: `API/ROLAC.API/Services/OfferingSessionService.cs:48-55` (the `items` projection in `GetPagedAsync`)
- Test: `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs`
- [ ] **Step 1: Add the DTO field**
In `API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs`, add after `HasProof`:
```csharp
public int? SundayAttendanceCount { get; set; } // null = no attendance recorded for the date
```
- [ ] **Step 2: Write the failing test**
In `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs`, add this test (helpers `BuildDb`, `BuildAccessor`, `NoOpFileStorage`, `SeedCategoryAsync`, `BuildRequest` already exist in the file; add `using ROLAC.API.Entities;` is already present):
```csharp
[Fact]
public async Task GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
var withDate = new DateOnly(2026, 5, 31);
var withoutDate = new DateOnly(2026, 5, 24);
await svc.CreateAsync(BuildRequest(catId, withDate));
await svc.CreateAsync(BuildRequest(catId, withoutDate));
db.MealAttendances.Add(new MealAttendance
{ AttendanceDate = withDate, AdultCount = 40, YouthCount = 12, KidCount = 8 });
await db.SaveChangesAsync();
var page = await svc.GetPagedAsync(1, 20, null, null);
var withItem = page.Items.Single(i => i.SessionDate == "2026-05-31");
var withoutItem = page.Items.Single(i => i.SessionDate == "2026-05-24");
Assert.Equal(60, withItem.SundayAttendanceCount); // 40 + 12 + 8
Assert.Null(withoutItem.SundayAttendanceCount); // no attendance row → null
}
```
- [ ] **Step 3: Run test to verify it fails**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists`
Expected: FAIL — `SundayAttendanceCount` is null for the row that has attendance (not yet populated).
- [ ] **Step 4: Populate the field in `GetPagedAsync`**
In `API/ROLAC.API/Services/OfferingSessionService.cs`, inside `GetPagedAsync`, after the `counts` dictionary block (currently ends at line 46) and before `var items = rows.Select(...)` (line 48), insert:
```csharp
var dates = rows.Select(r => r.SessionDate).ToList();
var attendance = await _db.MealAttendances.AsNoTracking()
.Where(a => dates.Contains(a.AttendanceDate))
.ToDictionaryAsync(a => a.AttendanceDate, a => a.AdultCount + a.YouthCount + a.KidCount);
```
Then in the `new OfferingSessionListItemDto { ... }` initializer (currently lines 48-55), add this line after `HasProof = s.ProofPdfPath != null,`:
```csharp
SundayAttendanceCount = attendance.TryGetValue(s.SessionDate, out var att) ? att : (int?)null,
```
- [ ] **Step 5: Run the full test class to verify it passes**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter OfferingSessionServiceTests`
Expected: PASS (existing tests + the new one).
- [ ] **Step 6: Commit**
```bash
git add API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs API/ROLAC.API/Services/OfferingSessionService.cs API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs
git commit -m "feat(giving): include Sunday attendance total in offering session list"
```
---
## Task 4: Frontend — model field + attendance API `setCounts`
**Files:**
- Modify: `APP/src/app/features/giving/models/giving.model.ts:107-117`
- Modify: `APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts`
- [ ] **Step 1: Add the model field**
In `APP/src/app/features/giving/models/giving.model.ts`, in `OfferingSessionListItemDto`, add after `hasProof: boolean;`:
```typescript
sundayAttendanceCount?: number | null;
```
- [ ] **Step 2: Add the `setCounts` API method**
In `APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts`, add this method after `getRange`:
```typescript
/** Overwrite a specific Sunday's counts (back-office editor). */
setCounts(date: string, counts: { adult: number; youth: number; kid: number }): Observable<AttendanceCounts> {
return this.http.put<AttendanceCounts>(`${this.endpoint}/${date}`, counts);
}
```
- [ ] **Step 3: Commit**
```bash
git add APP/src/app/features/giving/models/giving.model.ts APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts
git commit -m "feat(giving): add sundayAttendanceCount model field and attendance setCounts API"
```
---
## Task 5: Frontend — grid column, context menu, edit dialog
**Files:**
- Modify: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts`
- Modify: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html:39-62`
- Modify: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss`
- [ ] **Step 1: Add imports and inject the attendance API (component.ts)**
In `APP/.../offering-session-page.component.ts`:
a) Update the grid import to also pull `CellClickEvent` (line 6):
```typescript
import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';
```
b) Add these imports near the other Kendo imports (after line 11):
```typescript
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { MealAttendanceApiService } from '../../../meal-attendance/services/meal-attendance-api.service';
```
c) Add `ViewChild` to the `@angular/core` import (line 1):
```typescript
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
```
d) Add `ContextMenuModule` to the component `imports` array (line 31-34):
```typescript
imports: [
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
DropDownsModule, DateInputsModule, DialogsModule, ContextMenuModule, MemberQuickAddDialogComponent,
],
```
e) Inject the attendance API in the constructor (after `private signalr: OfferingEntrySignalrService,` on line 81):
```typescript
private mealAttendanceApi: MealAttendanceApiService,
```
- [ ] **Step 2: Add context-menu + dialog state and handlers (component.ts)**
Add these members after `confirmReopenOpen = false;` (line 75):
```typescript
// Right-click actions on a Recent Sessions row.
@ViewChild('sessionMenu') sessionMenu!: ContextMenuComponent;
readonly sessionMenuItems = [{ text: 'View / 檢視' }, { text: '修改主日人數' }];
private contextSession: OfferingSessionListItemDto | null = null;
// Edit Sunday attendance dialog.
attDialogOpen = false;
attSaving = false;
private attDate: string | null = null; // yyyy-MM-dd of the session being edited
attForm = { adult: 0, youth: 0, kid: 0 };
get attTotal(): number { return this.attForm.adult + this.attForm.youth + this.attForm.kid; }
```
Add these methods after `loadSessions()` (line 161-163):
```typescript
// Left-click anywhere on a row opens it; right-click opens the actions menu.
onSessionCellClick(event: CellClickEvent): void {
if (event.type === 'contextmenu') {
event.originalEvent.preventDefault();
this.contextSession = event.dataItem;
this.sessionMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
} else {
this.openView(event.dataItem);
}
}
onSessionMenuSelect(event: ContextMenuSelectEvent): void {
const session = this.contextSession;
if (!session) return;
if (event.item.text === 'View / 檢視') this.openView(session);
else if (event.item.text === '修改主日人數') this.openAttendanceEdit(session);
}
// Open the attendance editor, prefilling the three age groups from the existing row (zeros if none).
openAttendanceEdit(session: OfferingSessionListItemDto): void {
this.attDate = session.sessionDate;
this.attForm = { adult: 0, youth: 0, kid: 0 };
this.attSaving = false;
this.attDialogOpen = true;
this.mealAttendanceApi.getRange(session.sessionDate, session.sessionDate).subscribe(rows => {
const row = rows[0];
if (row) this.attForm = { adult: row.adult, youth: row.youth, kid: row.kid };
});
}
saveAttendance(): void {
if (!this.attDate) return;
const date = this.attDate;
this.attSaving = true;
this.mealAttendanceApi.setCounts(date, this.attForm).subscribe({
next: counts => {
const total = counts.adult + counts.youth + counts.kid;
const row = this.sessions.find(s => s.sessionDate === date);
if (row) row.sundayAttendanceCount = total;
this.attDialogOpen = false;
this.attSaving = false;
},
error: (err: { error?: { message?: string } }) => {
this.attSaving = false;
alert(err?.error?.message ?? 'Save failed.');
},
});
}
```
- [ ] **Step 3: Update the Recent Sessions grid markup (component.html)**
Replace the whole `<kendo-grid class="lined" ...>...</kendo-grid>` block (lines 39-62) with:
```html
<kendo-grid class="lined clickable-rows" [data]="sessions" (cellClick)="onSessionCellClick($event)">
<kendo-grid-column field="sessionDate" title="Date" [width]="120"></kendo-grid-column>
<kendo-grid-column title="Status" [width]="130">
<ng-template kendoGridCellTemplate let-s>
<span class="pill" [ngClass]="'pill--' + s.status.toLowerCase()">{{ s.status }}</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="lineCount" title="Lines" [width]="80"></kendo-grid-column>
<kendo-grid-column title="Attendance · 主日人數" [width]="140">
<ng-template kendoGridCellTemplate let-s>{{ s.sundayAttendanceCount ?? '—' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Proof" [width]="70">
<ng-template kendoGridCellTemplate let-s>
<span *ngIf="s.hasProof" title="Paper proof attached · 已附證明">📎</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="systemTotal" title="System" [width]="120" format="c2"></kendo-grid-column>
<kendo-grid-column field="difference" title="Diff" [width]="110" format="c2"></kendo-grid-column>
<ng-template kendoGridNoRecordsTemplate>
<div class="empty">No sessions yet — pick a date above to start.<br><span>尚無紀錄 — 選擇上方日期開始</span></div>
</ng-template>
</kendo-grid>
<kendo-contextmenu #sessionMenu [items]="sessionMenuItems" (select)="onSessionMenuSelect($event)"></kendo-contextmenu>
<div class="hint-text-sm">點一列檢視 · 右鍵修改主日人數 / Click a row to view · right-click to edit attendance</div>
```
(The old inline "View" action column is removed — View is now a left-click and a context-menu item.)
- [ ] **Step 4: Add the attendance edit dialog (component.html)**
Add this dialog at the end of the file, just before the final closing `</div>` of the `.off` container (after the existing view-mode/`workspace` blocks and any existing dialogs):
```html
<!-- ============================ EDIT SUNDAY ATTENDANCE ============================ -->
<kendo-dialog *ngIf="attDialogOpen" title="修改主日參加人數 · Edit Sunday Attendance"
(close)="attDialogOpen = false" [width]="440" [maxWidth]="'95vw'">
<div class="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">成人 Adult
<kendo-numerictextbox [(ngModel)]="attForm.adult" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">青年 Youth
<kendo-numerictextbox [(ngModel)]="attForm.youth" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">兒童 Kid
<kendo-numerictextbox [(ngModel)]="attForm.kid" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
</div>
<div class="att-total">總數 Total: {{ attTotal }}</div>
<kendo-dialog-actions>
<button kendoButton (click)="attDialogOpen = false">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="attSaving" (click)="saveAttendance()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
```
- [ ] **Step 5: Add minimal styles (component.scss)**
Append to `APP/.../offering-session-page.component.scss`:
```scss
.clickable-rows {
.k-grid-table tr { cursor: pointer; }
}
.att-total {
margin-top: 0.75rem;
font-weight: 600;
text-align: right;
}
```
- [ ] **Step 6: Build the frontend to verify it compiles**
Run from `APP/`: `npm run build`
Expected: Build completes with no template/TS errors. (Per project convention, the scoped unit-test runner can't load this component's external `.html` template, so verification is via build + manual preview rather than a component unit test.)
- [ ] **Step 7: Manual verification (preview)**
Start the app, open finance/offering-session landing. Confirm:
- The Recent Sessions grid shows an `Attendance · 主日人數` column (a number for dates with a MealAttendance row, `—` otherwise).
- Left-click a row opens the read-only session view (unchanged behaviour).
- Right-click a row shows a menu with `View / 檢視` and `修改主日人數`.
- `修改主日人數` opens a dialog with three numeric fields prefilled from the day's counts, a live Total, and Save persists — the grid cell updates to the new total without a full reload.
- [ ] **Step 8: Commit**
```bash
git add APP/src/app/features/giving/pages/offering-session-page/
git commit -m "feat(giving): show Sunday attendance per session and add edit action"
```
---
## Self-Review Notes
- **Spec coverage:** 顯示總數 → Task 3 (backend) + Task 5 step 3 (column). 修改 Action → Task 1/2 (backend write path) + Task 5 (context menu + dialog). 右鍵 context menu / Date 可點 → Task 5 steps 2-3. 沿用 MealAttendance、三分類編輯、nullable 顯示 `—`、REST(非 SignalR)寫入 → 全部涵蓋。
- **Out of scope (per spec):** Recent Sessions grid 的手機卡片版未一併重構;optional SignalR 廣播(date == ServiceDay 時同步即時計數器)未實作。
- **Type consistency:** `SetCountsAsync(DateOnly, int, int, int)` 簽名在 interface / impl / controller / 前端 `setCounts(date, {adult,youth,kid})` 一致;`sundayAttendanceCount` 在 DTOC# `SundayAttendanceCount`)與前端 model 對應;`AttendanceCounts` 前端模型已有 `adult/youth/kid`
@@ -0,0 +1,68 @@
# Offering Session — 顯示與修改主日參加人數
Date: 2026-06-24
## Goal
在 finance/offering-session 的 **Recent Sessions** 表格中,每筆 session 顯示該日的「主日參加人數」總數,並新增一個 Action 可修改該日的參加人數。
## Confirmed Decisions
1. **資料來源** — 沿用既有 `MealAttendance`。主日參加人數 = 該日 `AdultCount + YouthCount + KidCount`。修改 Action 改的是同一筆 `MealAttendance` 紀錄。
2. **顯示** — 後端 joinDTO 加 nullable 欄位;該日無紀錄時顯示 `—`
3. **編輯介面** — Kendo Dialog 分別編輯三個分類(成人/青年/兒童),總數即時計算。
4. **Action 擺放** — 依既有慣例,Date 欄位可點擊觸發 View;View 與「修改主日人數」都進右鍵 context menu(沿用 expense-categories 範式)。
## Key Constraint
`AttendanceHub.SetCount` 只作用在 `ServiceDay`(本週日),無法改任意日期。因此編輯過去某場 session 的日期,**必須走新的 REST 端點**,不可用 SignalR。
## Backend Changes
### 1. DTO
`API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs`
- 新增 `public int? SundayAttendanceCount { get; set; }`nullable:該日無 attendance row 為 null)。
### 2. OfferingSessionService.GetPaged
- 取得當頁 sessions 後,以這些 `SessionDate` 一次查 `MealAttendance`(單一 set-based 查詢,依日期分組求和),把總數填入各 DTO 的 `SundayAttendanceCount`;無紀錄者留 null。
- 不可對每筆 session 各發一次查詢(避免 N+1)。
### 3. IMealAttendanceService + MealAttendanceService
- 新增 `Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid)`
- 沿用既有 clamp-at-zero 語意,一次寫三欄並回傳 `AttendanceCountsDto`;該日無 row 則建立(沿用 `GetOrCreateAsync` 的建立邏輯)。
### 4. MealAttendanceController
- 新增 `PUT /api/meal-attendance/{date}``[Authorize]`(與既有 `GetRange` 一致),body `{ adult, youth, kid }` → 回傳 `AttendanceCountsDto`
- **Optionalplan 階段決定)**:若 `date == ServiceDay`,順手透過 `AttendanceHub` 廣播 `ReceiveCounts`,讓正在跑的即時計數器同步。預設先不做,避免增加耦合。
## Frontend Changes
### 1. Model
`APP/.../giving/models/giving.model.ts`
- `OfferingSessionListItemDto``sundayAttendanceCount?: number | null`
### 2. Attendance API service
`APP/.../meal-attendance/services/meal-attendance-api.service.ts`
- 新增 `setCounts(date: string, counts: { adult: number; youth: number; kid: number }): Observable<AttendanceCounts>` → 呼叫 `PUT /api/meal-attendance/{date}`
### 3. offering-session-page component
`APP/.../giving/pages/offering-session-page/offering-session-page.component.{ts,html}`
- imports 加 `ContextMenuModule``DialogsModule``InputsModule`NumericTextBox)。
- Recent Sessions grid
- 在 "Lines" 後新增欄 `Attendance · 主日人數`,顯示 `s.sundayAttendanceCount ?? '—'`
- 移除原本獨立的 "View" 按鈕欄;改為 `(cellClick)``event.type === 'contextmenu'` → 開 context menu;否則 `openView`。Date 欄加可點擊樣式(`clickable-rows`)。
- `kendo-contextmenu`items`View``修改主日人數`
- 修改主日人數 Dialog
- 開啟時以 `mealAttendanceApi.getRange(date, date)` 取該日 breakdown 預填(無紀錄則三欄為 0)。
- 三個 `kendo-numerictextbox`(成人/青年/兒童,min 0),即時顯示總數。
- Save → `setCounts(date, …)`;成功後就地把該列 `sundayAttendanceCount` 更新為三者之和。Cancel 關閉。
## Permissions
PUT 端點用 `[Authorize]`,與既有 `GetRange` 一致。
## Out of Scope
- Recent Sessions grid 目前尚無手機卡片版;本次只新增欄位與 action,不一併重構 mobile 版面(可另開 task)。
## Testing
- Backend`SetCountsAsync` 對負值 clamp 為 0、該日無 row 時建立;`GetPaged` 正確帶入 attendance 總數且無 N+1。
- Frontenddialog 總數計算(成人+青年+兒童)與存檔後就地更新該列。(前端測試環境較脆弱,採最小範圍 inline-template 測試。)