Compare commits

...

43 Commits

Author SHA1 Message Date
Chris Chen 609ce6a439 WIP
ci-cd-vm / ci-cd (push) Successful in 1m49s
2026-06-24 21:47:22 -07:00
Chris Chen 46a4298a71 WIP 2026-06-24 21:37:41 -07:00
Chris Chen 9f91683633 docs: sync DB_SCHEMA with Form 990 functional-expense schema
ci-cd-vm / ci-cd (push) Successful in 2m41s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:54:00 -07:00
Chris Chen 5aaac3246d feat(web): Form 990 functional-expenses report page, route, and nav
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:49:02 -07:00
Chris Chen 677cb8f054 feat(web): default functional class on the ministry form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:45:40 -07:00
Chris Chen f79dab163d feat(web): functional-class override on the expense form 2026-06-24 19:43:03 -07:00
Chris Chen 4438c351e2 feat(web): map expense categories to Form 990 lines in the category admin page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:38:17 -07:00
Chris Chen 1a03a1cbba feat(finance): expose Form 990 line catalog endpoint
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:36:01 -07:00
Chris Chen 3f61e9ceaf feat(web): add Form990Report permission and expense functional-class/line fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:33:37 -07:00
Chris Chen b41297f972 feat(finance): Form 990 functional-expenses report endpoint 2026-06-24 19:30:33 -07:00
Chris Chen a5de2dbbb1 feat(finance): Form 990 Part IX functional-expense aggregation service
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:26:59 -07:00
Chris Chen 1fa36ae62f fix(finance): make Form990 row DTO use properties (System.Text.Json skips fields) 2026-06-24 19:23:54 -07:00
Chris Chen 1353b5571f feat(finance): add Form 990 report DTOs and permission module
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:23:05 -07:00
Chris Chen 4e83f27703 feat(seed): default Administration ministry to Management & General
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:21:04 -07:00
Chris Chen d5e1732505 feat(seed): seed Form 990 line catalog and default subcategory mappings 2026-06-24 19:17:51 -07:00
Chris Chen ae757bee3d test(seed): use Assert.Single predicate overload (xUnit2031) 2026-06-24 19:15:10 -07:00
Chris Chen 6e04b64466 feat(seed): add IT/Professional/Finance categories and rename overlapping subcategories
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:12:57 -07:00
Chris Chen f70a7b5a58 feat(db): migration for Form 990 lines, category mapping, functional class
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:09:00 -07:00
Chris Chen b6b110254a feat(expense): add per-expense FunctionalClass override 2026-06-24 19:05:07 -07:00
Chris Chen d3e6b5aed5 feat(ministry): add DefaultFunctionalClass for Form 990 functional split 2026-06-24 19:00:36 -07:00
Chris Chen ac84097254 test(expense): assert Form990LineCode projection resolves
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:57:14 -07:00
Chris Chen 971bf165cc feat(expense): map category group/subcategory to Form 990 lines 2026-06-24 18:53:13 -07:00
Chris Chen f1faa0d435 feat(expense): add Form990ExpenseLine catalog entity and functional-class constants 2026-06-24 18:47:42 -07:00
Chris Chen 9dbb1d38d8 WIP 2026-06-24 18:45:22 -07:00
Chris Chen e908e35530 docs: implementation plan for sub-project A (Form 990 functional expenses)
17 TDD tasks: Form990ExpenseLine catalog + category mapping, Ministry
DefaultFunctionalClass, Expense FunctionalClass override, EF migration,
seed (new categories/renames/line catalog/mappings/ministry defaults),
Form990ReportService Part IX aggregation + controller, and the frontend
(category line mapping, expense + ministry functional-class controls,
report page/route/nav). DB_SCHEMA sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:41:35 -07:00
Chris Chen b51f22cfba docs: expand 990 expense-line catalog and add categories to cover gaps
Add 990 lines 5/8/11b/11c/20 to the catalog and new natural categories
(Personnel officer comp + pension, Missions foreign support, Printing
advertising, plus Professional Services / Information Technology /
Finance & Banking groups) so the category tree covers the common Part IX
lines instead of dumping uncovered lines into 24.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:30:49 -07:00
Chris Chen 764464e785 docs: spec for sub-project A — expense 990 functional expenses (Part IX)
Audit-readiness at Form 990 Part IX level: functional class (Program/M&G/
Fundraising) via Ministry default + per-expense override, Form990ExpenseLine
catalog + subcategory/group mapping, duplicate-category cleanup, and the
Part IX functional-expense matrix report. 1099 (B) and revenue Part VIII (C)
are separate specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:24:17 -07:00
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
129 changed files with 11242 additions and 160 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,142 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class DbSeederForm990Tests
{
private static AppDbContext BuildDb()
{
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "seed") })) };
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 SeedExpenseCategories_AddsNewGroups_RenamesDuplicates_AndIsIdempotent()
{
using var db = BuildDb();
var fnb = new ExpenseCategoryGroup { Name_en = "Food & Beverage", Name_zh = "餐飲", SortOrder = 3 };
db.ExpenseCategoryGroups.Add(fnb);
await db.SaveChangesAsync();
db.ExpenseSubCategories.Add(new ExpenseSubCategory { GroupId = fnb.Id, Name_en = "Consumables", Name_zh = "消耗品" });
await db.SaveChangesAsync();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedExpenseCategoriesAsync(db); // idempotent second run
var groups = await db.ExpenseCategoryGroups.ToListAsync();
Assert.Contains(groups, g => g.Name_en == "Professional Services");
Assert.Contains(groups, g => g.Name_en == "Information Technology");
Assert.Contains(groups, g => g.Name_en == "Finance & Banking");
var fnbSubs = await db.ExpenseSubCategories.Where(s => s.GroupId == fnb.Id).ToListAsync();
Assert.DoesNotContain(fnbSubs, s => s.Name_en == "Consumables");
Assert.Contains(fnbSubs, s => s.Name_en == "Disposable Tableware");
Assert.Single(groups, g => g.Name_en == "Professional Services");
}
[Fact]
public async Task SeedMinistries_SetsAdministrationToManagementGeneral_OthersProgram()
{
using var db = BuildDb();
await DbSeeder.SeedMinistriesAsync(db);
var admin = await db.Ministries.FirstAsync(m => m.Name_en == "Administration");
var worship = await db.Ministries.FirstAsync(m => m.Name_en == "Worship");
Assert.Equal("ManagementGeneral", admin.DefaultFunctionalClass);
Assert.Equal("Program", worship.DefaultFunctionalClass);
// Activity/shepherding ministries are an attribution axis only; they default to Program
// so adding them never distorts the 990 functional columns.
var cellGroups = await db.Ministries.FirstAsync(m => m.Name_en == "Cell Groups");
var specialEvents = await db.Ministries.FirstAsync(m => m.Name_en == "Special Events");
Assert.Equal("Program", cellGroups.DefaultFunctionalClass);
Assert.Equal("Program", specialEvents.DefaultFunctionalClass);
}
[Fact]
public async Task SeedForm990Lines_CreatesCatalog_AndMapsKnownSubcategories()
{
using var db = BuildDb();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db); // idempotent
Assert.Equal(1, await db.Form990ExpenseLines.CountAsync(l => l.LineCode == "7"));
Assert.True(await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == "24"));
var salary = await db.ExpenseSubCategories.Include(s => s.Form990Line)
.FirstAsync(s => s.Name_en == "Salary & Wages");
Assert.Equal("7", salary.Form990Line!.LineCode);
var audit = await db.ExpenseSubCategories.Include(s => s.Form990Line)
.FirstAsync(s => s.Name_en == "Accounting & Audit");
Assert.Equal("11c", audit.Form990Line!.LineCode);
}
[Fact]
public async Task SeedForm990Lines_MapsAuditCorrectedSubcategories_OffTheLine24CatchAll()
{
using var db = BuildDb();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
async Task<string> CodeOf(string subEn) =>
(await db.ExpenseSubCategories.Include(s => s.Form990Line)
.FirstAsync(s => s.Name_en == subEn)).Form990Line!.LineCode;
// Newly mapped subcategories that previously fell through to line 24.
Assert.Equal("13", await CodeOf("Bank & Processing Fees"));
Assert.Equal("13", await CodeOf("Rental"));
Assert.Equal("13", await CodeOf("Maintenance & Repair"));
Assert.Equal("13", await CodeOf("Cleaning Supplies"));
Assert.Equal("13", await CodeOf("Craft Supplies"));
// Building repairs & maintenance are part of Occupancy (line 16), not equipment (line 13).
Assert.Equal("16", await CodeOf("Repairs & Maintenance"));
// Appreciation/outreach gifts are deliberately mapped to Other (line 24), not left unmapped.
Assert.Equal("24", await CodeOf("Gifts"));
// Visitation is a travel/program cost, not a grant to an individual.
Assert.Equal("17", await CodeOf("Visit Expenses"));
// Missions support paid to individual missionaries → line 2, not line 1 (organizations).
Assert.Equal("2", await CodeOf("Missionary Support"));
}
[Fact]
public async Task SeedForm990Lines_RemapsExistingBadMapping_ButNotAdminOverride()
{
using var db = BuildDb();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
// Simulate a database seeded by the OLD code: Visit Expenses on line 2, Missionary
// Support on line 1. Also simulate an admin who deliberately moved one elsewhere.
var lineByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
var visit = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Visit Expenses");
var missionary = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Missionary Support");
var transfer = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Offering Transfer");
visit.Form990LineId = lineByCode["2"]; // old (wrong) value → should be corrected
missionary.Form990LineId = lineByCode["1"]; // old (wrong) value → should be corrected
transfer.Form990LineId = lineByCode["24"]; // admin override → must be left alone
await db.SaveChangesAsync();
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
await db.Entry(visit).ReloadAsync();
await db.Entry(missionary).ReloadAsync();
await db.Entry(transfer).ReloadAsync();
Assert.Equal(lineByCode["17"], visit.Form990LineId); // corrected 2 → 17
Assert.Equal(lineByCode["2"], missionary.Form990LineId); // corrected 1 → 2
Assert.Equal(lineByCode["24"], transfer.Form990LineId); // admin edit preserved
}
}
@@ -58,4 +58,23 @@ public class ExpenseCategoryServiceTests
await Assert.ThrowsAsync<KeyNotFoundException>(() => await Assert.ThrowsAsync<KeyNotFoundException>(() =>
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" })); svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
} }
[Fact]
public async Task CreateAndGet_RoundTrips_Form990LineId()
{
using var db = BuildDb();
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 1, LineCode = "24", Name_en = "Other" });
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries" });
await db.SaveChangesAsync();
var svc = new ExpenseCategoryService(db);
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Personnel", Form990LineId = 1 });
var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Salary & Wages", Form990LineId = 7 });
var all = await svc.GetAllAsync(includeInactive: true);
var sub = all.Single(g => g.Id == gid).SubCategories.Single(s => s.Id == sid);
Assert.Equal(7, sub.Form990LineId);
Assert.Equal("7", sub.Form990LineCode);
Assert.Equal(1, all.Single(g => g.Id == gid).Form990LineId);
Assert.Equal("24", all.Single(g => g.Id == gid).Form990LineCode);
}
} }
@@ -248,6 +248,27 @@ public class ExpenseServiceTests
Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id)); Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id));
} }
[Fact]
public async Task Create_PersistsFunctionalClass_AndGetReturnsIt()
{
var db = BuildDb("u1");
db.Ministries.Add(new ROLAC.API.Entities.Ministry { Id = 1, Name_en = "Admin" });
db.ExpenseCategoryGroups.Add(new ROLAC.API.Entities.ExpenseCategoryGroup { Id = 1, Name_en = "Other" });
db.ExpenseSubCategories.Add(new ROLAC.API.Entities.ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
await db.SaveChangesAsync();
var svc = SvcAs(db, new FakeStorage(), "u1");
var id = await svc.CreateAsync(new CreateExpenseRequest
{
Type = "VendorPayment", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
Amount = 50m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 1),
FunctionalClass = "ManagementGeneral",
}, isFinance: true);
var dto = await svc.GetByIdAsync(id);
Assert.Equal("ManagementGeneral", dto!.FunctionalClass);
}
[Fact] [Fact]
public async Task Receipt_SaveThenOpen_RoundTrips() public async Task Receipt_SaveThenOpen_RoundTrips()
{ {
@@ -0,0 +1,85 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class Form990ReportServiceTests
{
private static AppDbContext BuildDb()
{
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
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);
}
private static async Task SeedAsync(AppDbContext db)
{
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries", SortOrder = 5 });
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 24, LineCode = "24", Name_en = "Other", SortOrder = 21 });
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "ManagementGeneral" });
db.Ministries.Add(new Ministry { Id = 2, Name_en = "Worship", DefaultFunctionalClass = "Program" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel", Form990LineId = 24 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Salary", Form990LineId = 7 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 1, Name_en = "Misc", Form990LineId = null });
await db.SaveChangesAsync();
}
private static Expense Exp(int min, int sub, decimal amt, string status, string? fc = null) => new()
{
MinistryId = min, CategoryGroupId = 1, SubCategoryId = sub, Type = "VendorPayment",
Status = status, Amount = amt, Description = "x", ExpenseDate = new DateOnly(2026, 5, 10),
FunctionalClass = fc,
};
[Fact]
public async Task Statement_AggregatesByLineAndFunction_WithFallbackAndUnmappedCount()
{
using var db = BuildDb();
await SeedAsync(db);
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
db.Expenses.Add(Exp(1, 1, 40m, "Approved"));
db.Expenses.Add(Exp(2, 2, 25m, "Paid"));
db.Expenses.Add(Exp(2, 1, 999m, "Draft"));
db.Expenses.Add(Exp(1, 1, 10m, "Paid", fc: "Program"));
await db.SaveChangesAsync();
var svc = new Form990ReportService(db);
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
var line7 = stmt.Rows.Single(r => r.LineCode == "7");
Assert.Equal(110m, line7.Program);
Assert.Equal(40m, line7.ManagementGeneral);
Assert.Equal(150m, line7.Total);
var line24 = stmt.Rows.Single(r => r.LineCode == "24");
Assert.Equal(25m, line24.Program);
Assert.Equal(1, stmt.UnmappedExpenseCount);
Assert.Equal(175m, stmt.GrandTotal);
Assert.Equal(135m, stmt.ProgramTotal);
Assert.Equal(40m, stmt.ManagementGeneralTotal);
}
[Fact]
public async Task Statement_RespectsDateRange()
{
using var db = BuildDb();
await SeedAsync(db);
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
var older = Exp(2, 1, 500m, "Paid"); older.ExpenseDate = new DateOnly(2026, 1, 1);
db.Expenses.Add(older);
await db.SaveChangesAsync();
var svc = new Form990ReportService(db);
var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31));
Assert.Equal(100m, stmt.GrandTotal);
}
}
@@ -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
}
}
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Moq; using Moq;
using ROLAC.API.Data; using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors; using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Ministry;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Services; using ROLAC.API.Services;
using Xunit; using Xunit;
@@ -41,4 +42,19 @@ public class MinistryServiceTests
Assert.Equal("A", active[0].Name_en); Assert.Equal("A", active[0].Name_en);
Assert.Equal(3, all.Count); Assert.Equal(3, all.Count);
} }
[Fact]
public async Task Create_DefaultsFunctionalClassToProgram_AndUpdateChangesIt()
{
using var db = BuildDb();
var svc = new MinistryService(db);
var id = await svc.CreateAsync(new CreateMinistryRequest { Name_en = "Worship" });
var afterCreate = (await svc.GetAllAsync(true)).Single(m => m.Id == id);
Assert.Equal("Program", afterCreate.DefaultFunctionalClass);
await svc.UpdateAsync(id, new UpdateMinistryRequest { Name_en = "Worship", DefaultFunctionalClass = "ManagementGeneral" });
var afterUpdate = (await svc.GetAllAsync(true)).Single(m => m.Id == id);
Assert.Equal("ManagementGeneral", afterUpdate.DefaultFunctionalClass);
}
} }
@@ -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
}
} }
+4
View File
@@ -16,6 +16,7 @@ public static class Modules
public const string OfferingSessions = "OfferingSessions"; public const string OfferingSessions = "OfferingSessions";
public const string Ministries = "Ministries"; public const string Ministries = "Ministries";
public const string FinanceDashboard = "FinanceDashboard"; public const string FinanceDashboard = "FinanceDashboard";
public const string Form990Report = "Form990Report";
public const string MonthlyStatements = "MonthlyStatements"; public const string MonthlyStatements = "MonthlyStatements";
public const string ChurchProfile = "ChurchProfile"; public const string ChurchProfile = "ChurchProfile";
public const string Disbursements = "Disbursements"; public const string Disbursements = "Disbursements";
@@ -23,6 +24,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 =
@@ -36,6 +38,7 @@ public static class Modules
OfferingSessions, OfferingSessions,
Ministries, Ministries,
FinanceDashboard, FinanceDashboard,
Form990Report,
MonthlyStatements, MonthlyStatements,
ChurchProfile, ChurchProfile,
Disbursements, Disbursements,
@@ -43,6 +46,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,21 @@
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/form990-report")]
[HasPermission(Modules.Form990Report, PermissionActions.Read)]
public class Form990ReportController : ControllerBase
{
private readonly IForm990ReportService _svc;
public Form990ReportController(IForm990ReportService svc) => _svc = svc;
[HttpGet("lines")]
public async Task<IActionResult> Lines() => Ok(await _svc.GetLinesAsync());
[HttpGet("functional-expenses")]
public async Task<IActionResult> FunctionalExpenses([FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
=> Ok(await _svc.GetFunctionalExpenseStatementAsync(from, to));
}
@@ -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));
} }
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Ministry;
using ROLAC.API.Services; using ROLAC.API.Services;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
@@ -13,6 +15,31 @@ public class MinistriesController : ControllerBase
public MinistriesController(IMinistryService svc) => _svc = svc; public MinistriesController(IMinistryService svc) => _svc = svc;
[HttpGet] [HttpGet]
[HasPermission(Modules.Ministries, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false) public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive)); => Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost]
[HasPermission(Modules.Ministries, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMinistryRequest request)
{
var id = await _svc.CreateAsync(request);
return CreatedAtAction(nameof(GetAll), new { id }, new { id });
}
[HttpPut("{id:int}")]
[HasPermission(Modules.Ministries, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMinistryRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
[HttpDelete("{id:int}")]
[HasPermission(Modules.Ministries, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(int id)
{
try { await _svc.DeactivateAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
} }
@@ -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,6 +9,8 @@ public class ExpenseSubCategoryDto
public string? Name_zh { get; set; } public string? Name_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public int? Form990LineId { get; set; }
public string? Form990LineCode { get; set; }
} }
public class ExpenseCategoryGroupDto public class ExpenseCategoryGroupDto
@@ -18,6 +20,8 @@ public class ExpenseCategoryGroupDto
public string? Name_zh { get; set; } public string? Name_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public int? Form990LineId { get; set; }
public string? Form990LineCode { get; set; }
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = []; public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
} }
@@ -26,6 +30,7 @@ public class CreateExpenseGroupRequest
[Required, MaxLength(200)] public string Name_en { get; set; } = ""; [Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; } [MaxLength(200)] public string? Name_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public int? Form990LineId { get; set; }
} }
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
{ {
@@ -38,6 +43,7 @@ public class CreateExpenseSubCategoryRequest
[Required, MaxLength(200)] public string Name_en { get; set; } = ""; [Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; } [MaxLength(200)] public string? Name_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public int? Form990LineId { get; set; }
} }
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
{ {
@@ -20,6 +20,7 @@ public class ExpenseListItemDto
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
public bool HasReceipt { get; set; } public bool HasReceipt { get; set; }
public string? CheckNumber { get; set; } public string? CheckNumber { get; set; }
public string? FunctionalClass { get; set; }
} }
public class ExpenseDto : ExpenseListItemDto public class ExpenseDto : ExpenseListItemDto
@@ -45,6 +46,7 @@ public class CreateExpenseRequest
[MaxLength(50)] public string? CheckNumber { get; set; } [MaxLength(50)] public string? CheckNumber { get; set; }
[Required] public DateOnly ExpenseDate { get; set; } [Required] public DateOnly ExpenseDate { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
[MaxLength(20)] public string? FunctionalClass { get; set; }
} }
public class UpdateExpenseRequest : CreateExpenseRequest { } public class UpdateExpenseRequest : CreateExpenseRequest { }
@@ -0,0 +1,35 @@
namespace ROLAC.API.DTOs.Finance;
/// <summary>One Part IX row: a 990 line split across the three functional columns.</summary>
public class FunctionalExpenseRowDto
{
public string LineCode { get; set; } = "";
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public decimal Program { get; set; }
public decimal ManagementGeneral { get; set; }
public decimal Fundraising { get; set; }
public decimal Total { get; set; }
}
/// <summary>The full Part IX Statement of Functional Expenses for a date range.</summary>
public class FunctionalExpenseStatementDto
{
public List<FunctionalExpenseRowDto> Rows { get; set; } = [];
public decimal ProgramTotal { get; set; }
public decimal ManagementGeneralTotal { get; set; }
public decimal FundraisingTotal { get; set; }
public decimal GrandTotal { get; set; }
/// <summary>Expenses with no explicit 990 mapping (counted under line 24). Prompts mapping cleanup.</summary>
public int UnmappedExpenseCount { get; set; }
}
/// <summary>A single IRS Form 990 expense line from the catalog (used to populate mapping dropdowns).</summary>
public class Form990ExpenseLineDto
{
public int Id { get; set; }
public string LineCode { get; set; } = "";
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public int SortOrder { 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,12 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Ministry;
public class CreateMinistryRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
[MaxLength(500)] public string? Description_en { get; set; }
[MaxLength(500)] public string? Description_zh { get; set; }
public int SortOrder { get; set; }
[MaxLength(20)] public string? DefaultFunctionalClass { get; set; }
}
@@ -5,6 +5,9 @@ public class MinistryDto
public int Id { get; set; } public int Id { get; set; }
public string Name_en { get; set; } = ""; public string Name_en { get; set; } = "";
public string? Name_zh { get; set; } public string? Name_zh { get; set; }
public string? Description_en { get; set; }
public string? Description_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public string DefaultFunctionalClass { get; set; } = "Program";
} }
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Ministry;
public class UpdateMinistryRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
[MaxLength(500)] public string? Description_en { get; set; }
[MaxLength(500)] public string? Description_zh { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
[MaxLength(20)] public string? DefaultFunctionalClass { 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; }
}
+72 -1
View File
@@ -10,7 +10,8 @@ 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>();
@@ -19,6 +20,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<Ministry> Ministries => Set<Ministry>(); public DbSet<Ministry> Ministries => Set<Ministry>();
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>(); public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>(); public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
public DbSet<Expense> Expenses => Set<Expense>(); public DbSet<Expense> Expenses => Set<Expense>();
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>(); public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>(); public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
@@ -32,6 +34,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 +58,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 +119,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);
@@ -178,6 +201,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
{ {
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired(); entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200); entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.DefaultFunctionalClass).HasMaxLength(20).HasDefaultValue("Program");
});
// ── Form990ExpenseLine (Part IX natural-expense line catalog) ─────────
builder.Entity<Form990ExpenseLine>(entity =>
{
entity.Property(e => e.LineCode).HasMaxLength(10).IsRequired();
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.LineCode).IsUnique();
}); });
// ── ExpenseCategoryGroup ───────────────────────────────────────────── // ── ExpenseCategoryGroup ─────────────────────────────────────────────
@@ -187,6 +222,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.Name_zh).HasMaxLength(200); entity.Property(e => e.Name_zh).HasMaxLength(200);
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);
entity.HasOne(e => e.Form990Line).WithMany()
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
}); });
// ── ExpenseSubCategory ─────────────────────────────────────────────── // ── ExpenseSubCategory ───────────────────────────────────────────────
@@ -198,6 +235,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasOne(e => e.Group).WithMany(g => g.SubCategories) entity.HasOne(e => e.Group).WithMany(g => g.SubCategories)
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Form990Line).WithMany()
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
}); });
// ── Expense ────────────────────────────────────────────────────────── // ── Expense ──────────────────────────────────────────────────────────
@@ -207,6 +246,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.Type).HasMaxLength(30).IsRequired(); entity.Property(e => e.Type).HasMaxLength(30).IsRequired();
entity.Property(e => e.Status).HasMaxLength(30).HasDefaultValue("Draft"); entity.Property(e => e.Status).HasMaxLength(30).HasDefaultValue("Draft");
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)"); entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.Description).HasMaxLength(500).IsRequired(); entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
entity.Property(e => e.VendorName).HasMaxLength(200); entity.Property(e => e.VendorName).HasMaxLength(200);
@@ -245,12 +285,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 =>
{ {
+242 -11
View File
@@ -28,6 +28,8 @@ public static class DbSeeder
("Hospitality", "招待", 8), ("Hospitality", "招待", 8),
("Children", "兒牧", 9), ("Children", "兒牧", 9),
("Catering", "餐飲", 10), ("Catering", "餐飲", 10),
("Cell Groups", "小組牧養", 11),
("Special Events", "特別活動", 12),
]; ];
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)]) // (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
@@ -35,15 +37,115 @@ public static class DbSeeder
[ [
("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]), ("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]),
("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]), ("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]),
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Consumables","消耗品")]), ("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Disposable Tableware","一次性餐具")]),
("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]), ("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]),
("Materials", "教材", 5, [("Printing","印刷費用"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]), ("Materials", "教材", 5, [("Curriculum Printing","教材印刷"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾")]), ("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾"),("Repairs & Maintenance","修繕維護")]),
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報")]), ("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報"),("Advertising & Promotion","廣告推廣")]),
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Travel","差旅")]), ("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Foreign Missions Support","國外宣教支援"),("Travel","差旅")]),
("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]), ("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]),
("Other", "其他", 10, [("Miscellaneous","雜支")]), ("Other", "其他", 10, [("Miscellaneous","雜支"),("Gifts","禮品")]),
("Personnel", "人事", 11, [("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]), ("Personnel", "人事", 11, [("Officer / Key Employee Compensation","主要職員薪酬"),("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Retirement / Pension","退休金"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
("Professional Services", "專業服務", 12, [("Legal","法律服務"),("Accounting & Audit","會計與審計"),("Other Professional","其他專業服務")]),
("Information Technology", "資訊科技", 13, [("Software & Subscriptions","軟體與訂閱"),("Website & Hosting","網站與主機"),("Internet & Telecom","網路與電信")]),
("Finance & Banking", "財務與銀行", 14, [("Interest","利息支出"),("Bank & Processing Fees","銀行/金流手續費")]),
];
// (LineCode, Name_en, Name_zh, Sort)
private static readonly (string Code, string En, string Zh, int Sort)[] Form990LineSeed =
[
("1", "Grants to domestic organizations", "對國內機構之捐贈", 1),
("2", "Grants to domestic individuals", "對國內個人之捐贈", 2),
("3", "Grants to foreign organizations/individuals", "對國外之捐贈", 3),
("5", "Compensation of current officers / key employees", "主要職員/負責人薪酬", 4),
("7", "Other salaries and wages", "薪資", 5),
("8", "Pension plan accruals and contributions", "退休金提撥", 6),
("9", "Other employee benefits", "員工福利", 7),
("10", "Payroll taxes", "薪資稅", 8),
("11b", "Legal fees", "法律服務費", 9),
("11c", "Accounting fees", "會計與審計費", 10),
("11g", "Other fees for services (non-employee)", "其他勞務報酬(非員工)", 11),
("12", "Advertising and promotion", "廣告與推廣", 12),
("13", "Office expenses", "辦公費用", 13),
("14", "Information technology", "資訊科技", 14),
("16", "Occupancy", "場地佔用", 15),
("17", "Travel", "差旅", 16),
("19", "Conferences, conventions, and meetings", "會議與研習", 17),
("20", "Interest", "利息", 18),
("22", "Depreciation", "折舊", 19),
("23", "Insurance", "保險", 20),
("24", "Other expenses", "其他費用", 21),
];
// (GroupEn, SubEn, LineCode) — default natural-category → 990 line mapping.
private static readonly (string GroupEn, string SubEn, string Code)[] Form990SubMappingSeed =
[
("Personnel", "Officer / Key Employee Compensation", "5"),
("Personnel", "Salary & Wages", "7"),
("Personnel", "Payroll Taxes", "10"),
("Personnel", "Employee Benefits", "9"),
("Personnel", "Retirement / Pension","8"),
("Personnel", "Workers Compensation","9"),
("Personnel", "Honorarium", "11g"),
("Personnel", "Contract Labor", "11g"),
("Personnel", "Staff Training", "19"),
("Facility", "Rent", "16"),
("Facility", "Utilities", "16"),
("Facility", "Property Insurance", "23"),
("Facility", "Decoration", "24"),
// Building repairs & maintenance (plumbing, electrical, painting) are part of Occupancy.
("Facility", "Repairs & Maintenance", "16"),
("Training", "Course Fees", "19"),
("Training", "Conference", "19"),
("Training", "Books", "24"),
("Training", "Travel", "17"),
("Missions", "Travel", "17"),
// Domestic missions support is paid to individual missionaries/families → line 2 (grants to individuals).
("Missions", "Offering Transfer", "2"),
("Missions", "Missionary Support", "2"),
("Missions", "Foreign Missions Support", "3"),
("Benevolence", "Emergency Aid", "2"),
("Benevolence", "Condolence Gifts", "2"),
// Visitation is the church's own travel/program cost, not a grant to an individual.
("Benevolence", "Visit Expenses", "17"),
("Consumables", "Office Supplies", "13"),
// General supplies belong with office expenses (line 13), not the "Other" catch-all.
("Consumables", "Batteries", "13"),
("Consumables", "Accessories", "13"),
("Consumables", "Cleaning Supplies", "13"),
// IRS line 13 covers equipment rental and maintenance.
("Equipment", "Rental", "13"),
("Equipment", "Maintenance & Repair", "13"),
("Printing", "Bulletins", "13"),
("Printing", "Order of Service", "13"),
("Printing", "Posters", "12"),
("Printing", "Advertising & Promotion", "12"),
("Materials", "Curriculum Printing", "13"),
// Classroom/craft supplies fall under IRS line 13 office expenses ("supplies… classroom…").
("Materials", "Craft Supplies", "13"),
("Professional Services", "Legal", "11b"),
("Professional Services", "Accounting & Audit", "11c"),
("Professional Services", "Other Professional", "11g"),
("Information Technology", "Software & Subscriptions", "14"),
("Information Technology", "Website & Hosting", "14"),
("Information Technology", "Internet & Telecom", "14"),
("Finance & Banking", "Interest", "20"),
// Bank/processing fees are office expenses per IRS line 13 (consistent with Interest → 20).
("Finance & Banking", "Bank & Processing Fees", "13"),
// Appreciation/outreach gifts have no natural 990 line; mapped to 24 explicitly so this
// deliberate "Other" choice doesn't inflate UnmappedExpenseCount. (Benevolence gifts → line 2.)
("Other", "Gifts", "24"),
];
// One-time corrections for subcategories that were mapped to the WRONG line in an earlier
// seed. The normal mapping loop below only fills NULLs, so it cannot fix an existing bad
// value — this block does. Idempotent: each row fires only while the subcategory still holds
// the OLD line, so it never clobbers a deliberate admin re-mapping. (GroupEn, SubEn, Old, New)
private static readonly (string GroupEn, string SubEn, string OldCode, string NewCode)[] Form990RemapSeed =
[
("Benevolence", "Visit Expenses", "2", "17"),
("Missions", "Missionary Support", "1", "2"),
("Missions", "Offering Transfer", "1", "2"),
]; ];
private static readonly (string Name, string Description)[] Roles = private static readonly (string Name, string Description)[] Roles =
@@ -87,13 +189,32 @@ public static class DbSeeder
("finance", Modules.MonthlyStatements, true, true, false, true), ("finance", Modules.MonthlyStatements, true, true, false, true),
("finance", Modules.ChurchProfile, true, true, false, false), ("finance", Modules.ChurchProfile, true, true, false, false),
("finance", Modules.Disbursements, true, true, true, true), ("finance", Modules.Disbursements, true, true, true, true),
("finance", Modules.Form990Report, true, false, false, false),
// Logs — read-only. System logs are technical (pastor only); audit logs have // Logs — read-only. System logs are technical (pastor only); audit logs have
// governance value, so finance and board members can read them too. // governance value, so finance and board members can read them too.
("pastor", Modules.SystemLogs, true, false, false, false), ("pastor", Modules.SystemLogs, true, false, false, false),
("pastor", Modules.AuditLogs, true, false, false, false), ("pastor", Modules.AuditLogs, true, false, false, false),
("finance", Modules.AuditLogs, true, false, false, false), ("finance", Modules.AuditLogs, true, false, false, false),
("board_member", Modules.AuditLogs, true, false, false, false), ("board_member", Modules.AuditLogs, true, false, false, false),
("pastor", Modules.Form990Report, true, false, false, false),
("board_member", Modules.Form990Report, true, false, false, false),
// Ministries — secretary maintains the list; coworker_chair edits; ministry
// leaders and pastor read.
("secretary", Modules.Ministries, true, true, true, false),
("coworker_chair", Modules.Ministries, true, true, false, false),
("ministry_leader", Modules.Ministries, true, false, false, false),
("pastor", Modules.Ministries, true, false, false, false),
// Meal attendance — secretary and coworkers record; finance and pastor read.
("secretary", Modules.MealAttendance, true, true, false, false),
("coworker", Modules.MealAttendance, true, true, false, false),
("finance", Modules.MealAttendance, true, false, false, false),
("pastor", Modules.MealAttendance, true, false, false, false),
// Users, Permissions, and Settings are intentionally super_admin-only:
// super_admin bypasses all checks, so no seed rows are needed here.
]; ];
public static async Task SeedRolePermissionsAsync(AppDbContext db) public static async Task SeedRolePermissionsAsync(AppDbContext db)
@@ -163,13 +284,35 @@ public static class DbSeeder
foreach (var (en, zh, sort) in MinistrySeed) foreach (var (en, zh, sort) in MinistrySeed)
{ {
if (!await db.Ministries.AnyAsync(m => m.Name_en == en)) if (!await db.Ministries.AnyAsync(m => m.Name_en == en))
db.Ministries.Add(new Ministry { Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true }); db.Ministries.Add(new Ministry
{
Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true,
DefaultFunctionalClass = en == "Administration"
? FunctionalClasses.ManagementGeneral
: FunctionalClasses.Program,
});
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public static async Task SeedExpenseCategoriesAsync(AppDbContext db) public static async Task SeedExpenseCategoriesAsync(AppDbContext db)
{ {
// One-time renames to remove same-name-different-parent ambiguity. Idempotent:
// only fires while the old name still exists. (New installs never hit this.)
var renames = new (string GroupEn, string OldSub, string NewEn, string NewZh)[]
{
("Food & Beverage", "Consumables", "Disposable Tableware", "一次性餐具"),
("Materials", "Printing", "Curriculum Printing", "教材印刷"),
};
foreach (var (groupEn, oldSub, newEn, newZh) in renames)
{
var grp = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == groupEn);
if (grp is null) continue;
var sub = await db.ExpenseSubCategories.FirstOrDefaultAsync(s => s.GroupId == grp.Id && s.Name_en == oldSub);
if (sub is not null) { sub.Name_en = newEn; sub.Name_zh = newZh; }
}
await db.SaveChangesAsync();
foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed) foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed)
{ {
var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn); var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn);
@@ -192,6 +335,46 @@ public static class DbSeeder
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public static async Task SeedForm990ExpenseLinesAsync(AppDbContext db)
{
foreach (var (code, en, zh, sort) in Form990LineSeed)
{
if (!await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == code))
db.Form990ExpenseLines.Add(new Form990ExpenseLine
{ LineCode = code, Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true });
}
await db.SaveChangesAsync();
var linesByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
var fallbackId = linesByCode["24"];
// Every group defaults to line 24 (safety net); precise mapping lives on subcategories.
foreach (var group in await db.ExpenseCategoryGroups.ToListAsync())
group.Form990LineId ??= fallbackId;
// Subcategory default mappings — only set when not already mapped (never clobber an admin edit).
var subsByKey = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
foreach (var (groupEn, subEn, code) in Form990SubMappingSeed)
{
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
if (sub is not null && sub.Form990LineId is null && linesByCode.TryGetValue(code, out var lineId))
sub.Form990LineId = lineId;
}
// Correct earlier mis-mappings on existing databases (see Form990RemapSeed). Only fires
// while the subcategory still holds the OLD line, so a later admin edit is never clobbered.
foreach (var (groupEn, subEn, oldCode, newCode) in Form990RemapSeed)
{
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
if (sub is null) continue;
if (linesByCode.TryGetValue(oldCode, out var oldId)
&& linesByCode.TryGetValue(newCode, out var newId)
&& sub.Form990LineId == oldId)
sub.Form990LineId = newId;
}
await db.SaveChangesAsync();
}
public static async Task SeedChurchProfileAsync(AppDbContext db) public static async Task SeedChurchProfileAsync(AppDbContext db)
{ {
// Singleton row used by the disbursement module (issuer info + check counter). // Singleton row used by the disbursement module (issuer info + check counter).
@@ -208,6 +391,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 +444,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);
@@ -225,7 +453,10 @@ public static class DbSeeder
await SeedGivingCategoriesAsync(db); await SeedGivingCategoriesAsync(db);
await SeedMinistriesAsync(db); await SeedMinistriesAsync(db);
await SeedExpenseCategoriesAsync(db); await SeedExpenseCategoriesAsync(db);
await SeedForm990ExpenseLinesAsync(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; }
+1
View File
@@ -9,6 +9,7 @@ public class Expense : SoftDeleteEntity, IAuditable
public int SubCategoryId { get; set; } public int SubCategoryId { get; set; }
public string Type { get; set; } = "StaffReimbursement"; // VendorPayment | StaffReimbursement public string Type { get; set; } = "StaffReimbursement"; // VendorPayment | StaffReimbursement
public string Status { get; set; } = "Draft"; // see state machine public string Status { get; set; } = "Draft"; // see state machine
public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass
public decimal Amount { get; set; } public decimal Amount { get; set; }
public string Description { get; set; } = null!; public string Description { get; set; } = null!;
public string? VendorName { get; set; } public string? VendorName { get; set; }
@@ -9,5 +9,8 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public int? Form990LineId { get; set; }
public Form990ExpenseLine? Form990Line { get; set; }
public List<ExpenseSubCategory> SubCategories { get; set; } = []; public List<ExpenseSubCategory> SubCategories { get; set; } = [];
} }
@@ -10,5 +10,8 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public int? Form990LineId { get; set; }
public Form990ExpenseLine? Form990Line { get; set; }
public ExpenseCategoryGroup? Group { get; set; } public ExpenseCategoryGroup? Group { get; set; }
} }
@@ -0,0 +1,13 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>A row of IRS Form 990 Part IX (natural expense line), e.g. "7 — Other salaries and wages".</summary>
public class Form990ExpenseLine : AuditableEntity, IAuditable
{
public int Id { get; set; }
public string LineCode { get; set; } = null!; // "7", "11b", "16", "24"
public string Name_en { get; set; } = null!;
public string? Name_zh { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,18 @@
namespace ROLAC.API.Entities;
/// <summary>
/// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in
/// Ministry.DefaultFunctionalClass and Expense.FunctionalClass.
/// </summary>
public static class FunctionalClasses
{
public const string Program = "Program";
public const string ManagementGeneral = "ManagementGeneral";
public const string Fundraising = "Fundraising";
public static readonly IReadOnlyList<string> All = [Program, ManagementGeneral, Fundraising];
/// <summary>Returns the value if valid, otherwise Program (the safe default).</summary>
public static string Normalize(string? value) =>
value is not null && All.Contains(value) ? value : Program;
}
+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; }
+1
View File
@@ -11,4 +11,5 @@ public class Ministry : IAuditable
public string? Description_zh { get; set; } public string? Description_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public string DefaultFunctionalClass { get; set; } = "Program";
} }
@@ -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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddForm990FunctionalExpenses : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DefaultFunctionalClass",
table: "Ministries",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Program");
migrationBuilder.AddColumn<int>(
name: "Form990LineId",
table: "ExpenseSubCategories",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FunctionalClass",
table: "Expenses",
type: "character varying(20)",
maxLength: 20,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Form990LineId",
table: "ExpenseCategoryGroups",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "Form990ExpenseLines",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
LineCode = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
Name_en = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Name_zh = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Form990ExpenseLines", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ExpenseSubCategories_Form990LineId",
table: "ExpenseSubCategories",
column: "Form990LineId");
migrationBuilder.CreateIndex(
name: "IX_ExpenseCategoryGroups_Form990LineId",
table: "ExpenseCategoryGroups",
column: "Form990LineId");
migrationBuilder.CreateIndex(
name: "IX_Form990ExpenseLines_LineCode",
table: "Form990ExpenseLines",
column: "LineCode",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_ExpenseCategoryGroups_Form990ExpenseLines_Form990LineId",
table: "ExpenseCategoryGroups",
column: "Form990LineId",
principalTable: "Form990ExpenseLines",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_ExpenseSubCategories_Form990ExpenseLines_Form990LineId",
table: "ExpenseSubCategories",
column: "Form990LineId",
principalTable: "Form990ExpenseLines",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ExpenseCategoryGroups_Form990ExpenseLines_Form990LineId",
table: "ExpenseCategoryGroups");
migrationBuilder.DropForeignKey(
name: "FK_ExpenseSubCategories_Form990ExpenseLines_Form990LineId",
table: "ExpenseSubCategories");
migrationBuilder.DropTable(
name: "Form990ExpenseLines");
migrationBuilder.DropIndex(
name: "IX_ExpenseSubCategories_Form990LineId",
table: "ExpenseSubCategories");
migrationBuilder.DropIndex(
name: "IX_ExpenseCategoryGroups_Form990LineId",
table: "ExpenseCategoryGroups");
migrationBuilder.DropColumn(
name: "DefaultFunctionalClass",
table: "Ministries");
migrationBuilder.DropColumn(
name: "Form990LineId",
table: "ExpenseSubCategories");
migrationBuilder.DropColumn(
name: "FunctionalClass",
table: "Expenses");
migrationBuilder.DropColumn(
name: "Form990LineId",
table: "ExpenseCategoryGroups");
}
}
}
@@ -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)");
@@ -539,6 +555,10 @@ namespace ROLAC.API.Migrations
b.Property<DateOnly>("ExpenseDate") b.Property<DateOnly>("ExpenseDate")
.HasColumnType("date"); .HasColumnType("date");
b.Property<string>("FunctionalClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -641,6 +661,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450) .HasMaxLength(450)
.HasColumnType("character varying(450)"); .HasColumnType("character varying(450)");
b.Property<int?>("Form990LineId")
.HasColumnType("integer");
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -666,6 +689,8 @@ namespace ROLAC.API.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Form990LineId");
b.ToTable("ExpenseCategoryGroups"); b.ToTable("ExpenseCategoryGroups");
}); });
@@ -685,6 +710,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450) .HasMaxLength(450)
.HasColumnType("character varying(450)"); .HasColumnType("character varying(450)");
b.Property<int?>("Form990LineId")
.HasColumnType("integer");
b.Property<int>("GroupId") b.Property<int>("GroupId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -713,6 +741,8 @@ namespace ROLAC.API.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Form990LineId");
b.HasIndex("GroupId"); b.HasIndex("GroupId");
b.ToTable("ExpenseSubCategories"); b.ToTable("ExpenseSubCategories");
@@ -756,6 +786,58 @@ namespace ROLAC.API.Migrations
b.ToTable("FamilyUnits"); b.ToTable("FamilyUnits");
}); });
modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", 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>("IsActive")
.HasColumnType("boolean");
b.Property<string>("LineCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("Name_en")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name_zh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("LineCode")
.IsUnique();
b.ToTable("Form990ExpenseLines");
});
modelBuilder.Entity("ROLAC.API.Entities.Giving", b => modelBuilder.Entity("ROLAC.API.Entities.Giving", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1124,6 +1206,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");
@@ -1225,6 +1311,13 @@ namespace ROLAC.API.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DefaultFunctionalClass")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Program");
b.Property<string>("Description_en") b.Property<string>("Description_en")
.HasColumnType("text"); .HasColumnType("text");
@@ -1323,6 +1416,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 +1822,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)
@@ -1767,14 +2039,31 @@ namespace ROLAC.API.Migrations
b.Navigation("SubCategory"); b.Navigation("SubCategory");
}); });
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
{
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
.WithMany()
.HasForeignKey("Form990LineId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Form990Line");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
{ {
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
.WithMany()
.HasForeignKey("Form990LineId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "Group") b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "Group")
.WithMany("SubCategories") .WithMany("SubCategories")
.HasForeignKey("GroupId") .HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.Navigation("Form990Line");
b.Navigation("Group"); b.Navigation("Group");
}); });
@@ -1874,6 +2163,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");
+7
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>();
@@ -154,15 +155,21 @@ builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
builder.Services.AddScoped<IExpenseService, ExpenseService>(); 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<IForm990ReportService, Form990ReportService>();
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();
@@ -22,21 +22,28 @@ public class ExpenseCategoryService : IExpenseCategoryService
.OrderBy(s => s.SortOrder).ThenBy(s => s.Name_en) .OrderBy(s => s.SortOrder).ThenBy(s => s.Name_en)
.ToListAsync(); .ToListAsync();
var lineCodes = await _db.Form990ExpenseLines.AsNoTracking()
.ToDictionaryAsync(l => l.Id, l => l.LineCode);
return groups.Select(g => new ExpenseCategoryGroupDto return groups.Select(g => new ExpenseCategoryGroupDto
{ {
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh, Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
SortOrder = g.SortOrder, IsActive = g.IsActive, SortOrder = g.SortOrder, IsActive = g.IsActive,
Form990LineId = g.Form990LineId,
Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null,
SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto
{ {
Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh, Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh,
SortOrder = s.SortOrder, IsActive = s.IsActive, SortOrder = s.SortOrder, IsActive = s.IsActive,
Form990LineId = s.Form990LineId,
Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null,
}).ToList(), }).ToList(),
}).ToList(); }).ToList();
} }
public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r) public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r)
{ {
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true }; var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
_db.ExpenseCategoryGroups.Add(g); _db.ExpenseCategoryGroups.Add(g);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return g.Id; return g.Id;
@@ -46,7 +53,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{ {
var g = await _db.ExpenseCategoryGroups.FindAsync(id) var g = await _db.ExpenseCategoryGroups.FindAsync(id)
?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found."); ?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found.");
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
@@ -62,7 +69,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{ {
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId); var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found."); if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found.");
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true }; var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
_db.ExpenseSubCategories.Add(s); _db.ExpenseSubCategories.Add(s);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return s.Id; return s.Id;
@@ -72,7 +79,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{ {
var s = await _db.ExpenseSubCategories.FindAsync(id) var s = await _db.ExpenseSubCategories.FindAsync(id)
?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found."); ?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found.");
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
+4 -1
View File
@@ -97,6 +97,7 @@ public class ExpenseService : IExpenseService
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
HasReceipt = e.ReceiptBlobPath != null, HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber, CheckNumber = e.CheckNumber,
FunctionalClass = e.FunctionalClass,
}).ToList(); }).ToList();
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
@@ -122,6 +123,7 @@ public class ExpenseService : IExpenseService
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes, CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, ReviewedAt = e.ReviewedAt, PaidAt = e.PaidAt, SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, ReviewedAt = e.ReviewedAt, PaidAt = e.PaidAt,
FunctionalClass = e.FunctionalClass,
}; };
} }
@@ -132,6 +134,7 @@ public class ExpenseService : IExpenseService
MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, SubCategoryId = r.SubCategoryId, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, SubCategoryId = r.SubCategoryId,
Type = r.Type, Amount = r.Amount, Description = r.Description, VendorName = r.VendorName, Type = r.Type, Amount = r.Amount, Description = r.Description, VendorName = r.VendorName,
CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes, CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes,
FunctionalClass = r.FunctionalClass,
}; };
if (r.Type == "VendorPayment") if (r.Type == "VendorPayment")
@@ -179,7 +182,7 @@ public class ExpenseService : IExpenseService
e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId; e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId;
e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber; e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.FunctionalClass = r.FunctionalClass;
if (e.Type == "VendorPayment") e.VendorName = r.VendorName; if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
@@ -0,0 +1,91 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Finance;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
/// <summary>
/// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional
/// Expenses. Expense scope matches FinanceDashboardService: Paid + Approved only.
/// Single function per expense (direct-charge); no cost splitting.
/// </summary>
public class Form990ReportService : IForm990ReportService
{
private readonly AppDbContext _db;
public Form990ReportService(AppDbContext db) => _db = db;
public async Task<List<Form990ExpenseLineDto>> GetLinesAsync() =>
await _db.Form990ExpenseLines.AsNoTracking().Where(l => l.IsActive)
.OrderBy(l => l.SortOrder)
.Select(l => new Form990ExpenseLineDto
{
Id = l.Id,
LineCode = l.LineCode,
Name_en = l.Name_en,
Name_zh = l.Name_zh,
SortOrder = l.SortOrder,
})
.ToListAsync();
public async Task<FunctionalExpenseStatementDto> GetFunctionalExpenseStatementAsync(DateOnly? from, DateOnly? to)
{
var lines = await _db.Form990ExpenseLines.AsNoTracking()
.Where(l => l.IsActive).OrderBy(l => l.SortOrder).ToListAsync();
var fallbackId = lines.FirstOrDefault(l => l.LineCode == "24")?.Id;
var expenses = _db.Expenses.Where(e => e.Status == "Paid" || e.Status == "Approved");
if (from.HasValue) expenses = expenses.Where(e => e.ExpenseDate >= from.Value);
if (to.HasValue) expenses = expenses.Where(e => e.ExpenseDate <= to.Value);
var rows = await (
from e in expenses
join m in _db.Ministries on e.MinistryId equals m.Id
join sub in _db.ExpenseSubCategories on e.SubCategoryId equals sub.Id
join grp in _db.ExpenseCategoryGroups on e.CategoryGroupId equals grp.Id
select new
{
e.Amount,
e.FunctionalClass,
MinistryDefault = m.DefaultFunctionalClass,
SubLineId = sub.Form990LineId,
GroupLineId = grp.Form990LineId,
}).ToListAsync();
var acc = new Dictionary<int, (decimal P, decimal M, decimal F)>();
var unmapped = 0;
foreach (var r in rows)
{
var function = FunctionalClasses.Normalize(r.FunctionalClass ?? r.MinistryDefault);
var lineId = r.SubLineId ?? r.GroupLineId ?? fallbackId;
if (lineId is null) continue;
if (r.SubLineId is null) unmapped++;
var cur = acc.GetValueOrDefault(lineId.Value);
acc[lineId.Value] = function switch
{
FunctionalClasses.ManagementGeneral => (cur.P, cur.M + r.Amount, cur.F),
FunctionalClasses.Fundraising => (cur.P, cur.M, cur.F + r.Amount),
_ => (cur.P + r.Amount, cur.M, cur.F),
};
}
var dto = new FunctionalExpenseStatementDto { UnmappedExpenseCount = unmapped };
foreach (var line in lines)
{
var v = acc.GetValueOrDefault(line.Id);
dto.Rows.Add(new FunctionalExpenseRowDto
{
LineCode = line.LineCode, Name_en = line.Name_en, Name_zh = line.Name_zh,
Program = v.P, ManagementGeneral = v.M, Fundraising = v.F, Total = v.P + v.M + v.F,
});
dto.ProgramTotal += v.P;
dto.ManagementGeneralTotal += v.M;
dto.FundraisingTotal += v.F;
}
dto.GrandTotal = dto.ProgramTotal + dto.ManagementGeneralTotal + dto.FundraisingTotal;
return dto;
}
}
+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,8 @@
using ROLAC.API.DTOs.Finance;
namespace ROLAC.API.Services;
public interface IForm990ReportService
{
Task<FunctionalExpenseStatementDto> GetFunctionalExpenseStatementAsync(DateOnly? from, DateOnly? to);
Task<List<Form990ExpenseLineDto>> GetLinesAsync();
}
@@ -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);
} }
@@ -4,4 +4,7 @@ namespace ROLAC.API.Services;
public interface IMinistryService public interface IMinistryService
{ {
Task<List<MinistryDto>> GetAllAsync(bool includeInactive); Task<List<MinistryDto>> GetAllAsync(bool includeInactive);
Task<int> CreateAsync(CreateMinistryRequest request);
Task UpdateAsync(int id, UpdateMinistryRequest request);
Task DeactivateAsync(int id); // soft-disable: IsActive = false
} }
@@ -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;
+36
View File
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data; using ROLAC.API.Data;
using ROLAC.API.DTOs.Ministry; using ROLAC.API.DTOs.Ministry;
using ROLAC.API.Entities;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -18,8 +19,43 @@ public class MinistryService : IMinistryService
.Select(m => new MinistryDto .Select(m => new MinistryDto
{ {
Id = m.Id, Name_en = m.Name_en, Name_zh = m.Name_zh, Id = m.Id, Name_en = m.Name_en, Name_zh = m.Name_zh,
Description_en = m.Description_en, Description_zh = m.Description_zh,
SortOrder = m.SortOrder, IsActive = m.IsActive, SortOrder = m.SortOrder, IsActive = m.IsActive,
DefaultFunctionalClass = m.DefaultFunctionalClass,
}) })
.ToListAsync(); .ToListAsync();
} }
public async Task<int> CreateAsync(CreateMinistryRequest r)
{
var entity = new Ministry
{
Name_en = r.Name_en, Name_zh = r.Name_zh,
Description_en = r.Description_en, Description_zh = r.Description_zh,
SortOrder = r.SortOrder, IsActive = true,
DefaultFunctionalClass = ROLAC.API.Entities.FunctionalClasses.Normalize(r.DefaultFunctionalClass),
};
_db.Ministries.Add(entity);
await _db.SaveChangesAsync();
return entity.Id;
}
public async Task UpdateAsync(int id, UpdateMinistryRequest r)
{
var m = await _db.Ministries.FindAsync(id)
?? throw new KeyNotFoundException($"Ministry {id} not found.");
m.Name_en = r.Name_en; m.Name_zh = r.Name_zh;
m.Description_en = r.Description_en; m.Description_zh = r.Description_zh;
m.IsActive = r.IsActive; m.SortOrder = r.SortOrder;
m.DefaultFunctionalClass = ROLAC.API.Entities.FunctionalClasses.Normalize(r.DefaultFunctionalClass);
await _db.SaveChangesAsync();
}
public async Task DeactivateAsync(int id)
{
var m = await _db.Ministries.FindAsync(id)
?? throw new KeyNotFoundException($"Ministry {id} not found.");
m.IsActive = false;
await _db.SaveChangesAsync();
}
} }
@@ -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;
}
}
+24
View File
@@ -7,6 +7,7 @@ import { PermissionGuard } from './core/guards/permission.guard';
import { PermissionModules } from './core/models/permission.model'; import { PermissionModules } from './core/models/permission.model';
import { PermissionsPageComponent } from './features/permissions/pages/permissions-page/permissions-page.component'; import { PermissionsPageComponent } from './features/permissions/pages/permissions-page/permissions-page.component';
import { MembersPageComponent } from './features/members/pages/members-page/members-page.component'; import { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
import { MinistriesPageComponent } from './features/ministry/pages/ministries-page/ministries-page.component';
import { UsersPageComponent } from './features/users/pages/users-page/users-page.component'; import { UsersPageComponent } from './features/users/pages/users-page/users-page.component';
import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component'; import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component';
import { GivingsPageComponent } from './features/giving/pages/givings-page/givings-page.component'; import { GivingsPageComponent } from './features/giving/pages/givings-page/givings-page.component';
@@ -19,16 +20,21 @@ import { FinanceDashboardPageComponent } from './features/finance-dashboard/page
import { DisbursementPageComponent } from './features/disbursement/pages/disbursement-page/disbursement-page.component'; import { DisbursementPageComponent } from './features/disbursement/pages/disbursement-page/disbursement-page.component';
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component'; import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component'; import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
import { Form990ReportPageComponent } from './features/finance-report/pages/form990-report-page/form990-report-page.component';
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component'; import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component'; import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
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 },
@@ -61,6 +67,15 @@ export const routes: Routes = [
title: 'Member Management', titleZh: '會友管理', section: 'Admin', title: 'Member Management', titleZh: '會友管理', section: 'Admin',
}, },
}, },
{
path: 'admin/ministries',
component: MinistriesPageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.Ministries, action: 'read' },
title: 'Ministry Management', titleZh: '事工管理', section: 'Admin',
},
},
{ {
path: 'admin/users', path: 'admin/users',
component: UsersPageComponent, component: UsersPageComponent,
@@ -192,6 +207,15 @@ export const routes: Routes = [
title: 'Church Profile', titleZh: '教會資料', section: 'Finance', title: 'Church Profile', titleZh: '教會資料', section: 'Finance',
}, },
}, },
{
path: 'finance/form-990-report',
component: Form990ReportPageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.Form990Report, action: 'read' },
title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance',
},
},
] ]
}, },
@@ -24,6 +24,7 @@ export const PermissionModules = {
OfferingSessions: 'OfferingSessions', OfferingSessions: 'OfferingSessions',
Ministries: 'Ministries', Ministries: 'Ministries',
FinanceDashboard: 'FinanceDashboard', FinanceDashboard: 'FinanceDashboard',
Form990Report: 'Form990Report',
MonthlyStatements: 'MonthlyStatements', MonthlyStatements: 'MonthlyStatements',
ChurchProfile: 'ChurchProfile', ChurchProfile: 'ChurchProfile',
Disbursements: 'Disbursements', Disbursements: 'Disbursements',
@@ -31,6 +32,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,49 +1,86 @@
<div class="page"> <div class="page">
<div *ngIf="model" class="max-w-3xl"> <kendo-tabstrip>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3"> <!-- ── Tab 1: Church Info (existing ChurchProfile permission) ──────────── -->
<label class="flex flex-col gap-1 md:col-span-2"> <kendo-tabstrip-tab title="Church Info / 教會資料" [selected]="true">
Church Name / 教會名稱 <ng-template kendoTabContent>
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox> <div *ngIf="model" class="max-w-3xl pt-4">
</label> <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">
Address / 地址 Church Name / 教會名稱
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox> <kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
City / 城市 Church Name (ZH) / 教會名稱(中)
<kendo-textbox [(ngModel)]="model.city"></kendo-textbox> <kendo-textbox [(ngModel)]="model.nameZh"></kendo-textbox>
</label> </label>
<div class="grid grid-cols-2 gap-2"> <label class="flex flex-col gap-1">
<label class="flex flex-col gap-1"> Phone / 電話
State / 州 <kendo-textbox [(ngModel)]="model.phone"></kendo-textbox>
<kendo-textbox [(ngModel)]="model.state"></kendo-textbox> </label>
</label> <label class="flex flex-col gap-1">
<label class="flex flex-col gap-1"> Email / 電子郵件
Zip / 郵遞區號 <kendo-textbox [(ngModel)]="model.email"></kendo-textbox>
<kendo-textbox [(ngModel)]="model.zipCode"></kendo-textbox> </label>
</label> <label class="flex flex-col gap-1 md:col-span-2">
</div> Website / 網站
<label class="flex flex-col gap-1"> <kendo-textbox [(ngModel)]="model.website" placeholder="https://"></kendo-textbox>
Bank Name / 銀行名稱 </label>
<kendo-textbox [(ngModel)]="model.bankName"></kendo-textbox> <label class="flex flex-col gap-1 md:col-span-2">
</label> Address / 地址
<label class="flex flex-col gap-1"> <kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
Bank Account # / 銀行帳號 </label>
<kendo-textbox [(ngModel)]="model.bankAccountNumber"></kendo-textbox> <label class="flex flex-col gap-1">
</label> City / 城市
<label class="flex flex-col gap-1"> <kendo-textbox [(ngModel)]="model.city"></kendo-textbox>
Routing # / 路由號碼 </label>
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox> <div class="grid grid-cols-2 gap-2">
</label> <label class="flex flex-col gap-1">
<label class="flex flex-col gap-1"> State / 州
Next Check # / 下一張支票號碼 <kendo-textbox [(ngModel)]="model.state"></kendo-textbox>
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox> </label>
</label> <label class="flex flex-col gap-1">
</div> Zip / 郵遞區號
<kendo-textbox [(ngModel)]="model.zipCode"></kendo-textbox>
</label>
</div>
<label class="flex flex-col gap-1">
Bank Name / 銀行名稱
<kendo-textbox [(ngModel)]="model.bankName"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Bank Account # / 銀行帳號
<kendo-textbox [(ngModel)]="model.bankAccountNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Routing # / 路由號碼
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Next Check # / 下一張支票號碼
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
</label>
</div>
<div class="flex items-center gap-3 mt-4"> <div class="flex items-center gap-3 mt-4">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button> <button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<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 {
@@ -59,6 +59,19 @@
</kendo-dropdownlist> </kendo-dropdownlist>
</label> </label>
<!-- Functional Class override -->
<label class="flex flex-col gap-1">
<span>Functional Class / 功能別</span>
<kendo-dropdownlist
[data]="functionalClassOptions"
textField="label"
valueField="value"
[valuePrimitive]="true"
[defaultItem]="{ value: null, label: '(Inherit ministry / 沿用事工)' }"
[(ngModel)]="form.functionalClass">
</kendo-dropdownlist>
</label>
<!-- Amount --> <!-- Amount -->
<label class="flex flex-col gap-1">Amount <label class="flex flex-col gap-1">Amount
<kendo-numerictextbox <kendo-numerictextbox
@@ -12,7 +12,7 @@ import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model'; import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import { import {
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest, MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
ExpenseListItemDto, ExpenseListItemDto, FunctionalClass,
} from '../../models/expense.model'; } from '../../models/expense.model';
export interface ExpenseFormResult { export interface ExpenseFormResult {
@@ -52,6 +52,12 @@ export class ExpenseFormDialogComponent implements OnInit {
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */ /** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
continueEntry = false; continueEntry = false;
readonly functionalClassOptions: { value: FunctionalClass; label: string }[] = [
{ value: 'Program', label: 'Program / 事工服務' },
{ value: 'ManagementGeneral', label: 'Management & General / 管理' },
{ value: 'Fundraising', label: 'Fundraising / 募款' },
];
/** The on-behalf reimbursement create flow is the only place continuous entry applies. */ /** The on-behalf reimbursement create flow is the only place continuous entry applies. */
get showContinueEntry(): boolean { get showContinueEntry(): boolean {
return this.mode === 'reimbursement' && this.allowMemberPick && !this.expense; return this.mode === 'reimbursement' && this.allowMemberPick && !this.expense;
@@ -67,6 +73,7 @@ export class ExpenseFormDialogComponent implements OnInit {
checkNumber: '', checkNumber: '',
memberId: null as number | null, memberId: null as number | null,
expenseDate: new Date(), expenseDate: new Date(),
functionalClass: null as FunctionalClass | null,
}; };
receipt: File | null = null; receipt: File | null = null;
@@ -101,6 +108,7 @@ export class ExpenseFormDialogComponent implements OnInit {
checkNumber: expense.checkNumber ?? '', checkNumber: expense.checkNumber ?? '',
memberId: expense.memberId, memberId: expense.memberId,
expenseDate: new Date(year, month - 1, day), expenseDate: new Date(year, month - 1, day),
functionalClass: expense.functionalClass ?? null,
}; };
} }
@@ -146,6 +154,7 @@ export class ExpenseFormDialogComponent implements OnInit {
checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null, checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
expenseDate, expenseDate,
notes: null, notes: null,
functionalClass: this.form.functionalClass,
}; };
// The request and receipt are snapshotted here, so resetting the form right // The request and receipt are snapshotted here, so resetting the form right
// after emitting is safe even though the parent saves asynchronously. // after emitting is safe even though the parent saves asynchronously.
@@ -1,5 +1,6 @@
export type ExpenseType = 'VendorPayment' | 'StaffReimbursement'; export type ExpenseType = 'VendorPayment' | 'StaffReimbursement';
export type ExpenseStatus = 'Draft' | 'PendingApproval' | 'Approved' | 'Paid' | 'Rejected'; export type ExpenseStatus = 'Draft' | 'PendingApproval' | 'Approved' | 'Paid' | 'Rejected';
export type FunctionalClass = 'Program' | 'ManagementGeneral' | 'Fundraising';
export interface PagedResult<T> { export interface PagedResult<T> {
items: T[]; totalCount: number; page: number; pageSize: number; totalPages: number; items: T[]; totalCount: number; page: number; pageSize: number; totalPages: number;
@@ -7,11 +8,11 @@ export interface PagedResult<T> {
export interface MinistryDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; } export interface MinistryDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; }
export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; } export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; form990LineId: number | null; form990LineCode: string | null; }
export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; } export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; form990LineId: number | null; form990LineCode: string | null; }
export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; } export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { isActive: boolean; } export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { isActive: boolean; }
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; } export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
export interface UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; } export interface UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; }
export interface ExpenseListItemDto { export interface ExpenseListItemDto {
@@ -19,7 +20,7 @@ export interface ExpenseListItemDto {
ministryId: number; ministryName: string; categoryGroupId: number; categoryGroupName: string; ministryId: number; ministryName: string; categoryGroupId: number; categoryGroupName: string;
subCategoryId: number; subCategoryName: string; vendorName: string | null; subCategoryId: number; subCategoryName: string; vendorName: string | null;
memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean; memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean;
checkNumber: string | null; checkNumber: string | null; functionalClass: FunctionalClass | null;
} }
export interface ExpenseDto extends ExpenseListItemDto { export interface ExpenseDto extends ExpenseListItemDto {
notes: string | null; reviewNotes: string | null; notes: string | null; reviewNotes: string | null;
@@ -28,7 +29,7 @@ export interface ExpenseDto extends ExpenseListItemDto {
export interface CreateExpenseRequest { export interface CreateExpenseRequest {
type: ExpenseType; ministryId: number; categoryGroupId: number; subCategoryId: number; type: ExpenseType; ministryId: number; categoryGroupId: number; subCategoryId: number;
amount: number; description: string; vendorName: string | null; memberId: number | null; amount: number; description: string; vendorName: string | null; memberId: number | null;
checkNumber: string | null; expenseDate: string; notes: string | null; checkNumber: string | null; expenseDate: string; notes: string | null; functionalClass: FunctionalClass | null;
} }
export type UpdateExpenseRequest = CreateExpenseRequest; export type UpdateExpenseRequest = CreateExpenseRequest;
export interface RejectExpenseRequest { reviewNotes: string | null; } export interface RejectExpenseRequest { reviewNotes: string | null; }
@@ -61,6 +61,15 @@
Sort order Sort order
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox> <kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label> </label>
<label class="flex flex-col gap-1 md:col-span-2">
<span>Form 990 Line / 990 行</span>
<kendo-dropdownlist
[data]="form990Lines"
textField="label" valueField="id" [valuePrimitive]="true"
[defaultItem]="{ id: null, label: '(Unmapped / 未對應)' }"
[(ngModel)]="groupForm.form990LineId">
</kendo-dropdownlist>
</label>
<label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2"> <label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active <input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
</label> </label>
@@ -89,6 +98,15 @@
Sort order Sort order
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox> <kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label> </label>
<label class="flex flex-col gap-1 md:col-span-2">
<span>Form 990 Line / 990 行</span>
<kendo-dropdownlist
[data]="form990Lines"
textField="label" valueField="id" [valuePrimitive]="true"
[defaultItem]="{ id: null, label: '(Unmapped / 未對應)' }"
[(ngModel)]="subForm.form990LineId">
</kendo-dropdownlist>
</label>
<label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2"> <label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active <input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
</label> </label>
@@ -5,14 +5,16 @@ import { GridModule, CellClickEvent, RowClassArgs } from '@progress/kendo-angula
import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog'; import { DialogsModule } from '@progress/kendo-angular-dialog';
import { InputsModule } from '@progress/kendo-angular-inputs'; import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu'; import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service'; import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model'; import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
@Component({ @Component({
selector: 'app-expense-categories-page', selector: 'app-expense-categories-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule, ContextMenuModule], imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule, DropDownsModule, ContextMenuModule],
templateUrl: './expense-categories-page.component.html', templateUrl: './expense-categories-page.component.html',
styleUrls: ['./expense-categories-page.component.scss'], styleUrls: ['./expense-categories-page.component.scss'],
}) })
@@ -20,6 +22,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
groups: ExpenseCategoryGroupDto[] = []; groups: ExpenseCategoryGroupDto[] = [];
selectedGroup: ExpenseCategoryGroupDto | null = null; selectedGroup: ExpenseCategoryGroupDto | null = null;
loading = false; loading = false;
form990Lines: Form990ExpenseLineDto[] = [];
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent; @ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
@ViewChild('subMenu') subMenu!: ContextMenuComponent; @ViewChild('subMenu') subMenu!: ContextMenuComponent;
@@ -30,15 +33,18 @@ export class ExpenseCategoriesPageComponent implements OnInit {
groupDialogOpen = false; groupDialogOpen = false;
editingGroupId: number | null = null; editingGroupId: number | null = null;
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true }; groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
subDialogOpen = false; subDialogOpen = false;
editingSubId: number | null = null; editingSubId: number | null = null;
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true }; subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
constructor(private api: ExpenseCategoryApiService) {} constructor(private api: ExpenseCategoryApiService) {}
ngOnInit(): void { this.load(); } ngOnInit(): void {
this.load();
this.api.getForm990Lines().subscribe(lines => { this.form990Lines = lines; });
}
load(): void { load(): void {
this.loading = true; this.loading = true;
@@ -101,16 +107,16 @@ export class ExpenseCategoriesPageComponent implements OnInit {
openNewGroup(): void { openNewGroup(): void {
this.editingGroupId = null; this.editingGroupId = null;
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true }; this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
this.groupDialogOpen = true; this.groupDialogOpen = true;
} }
openEditGroup(g: ExpenseCategoryGroupDto): void { openEditGroup(g: ExpenseCategoryGroupDto): void {
this.editingGroupId = g.id; this.editingGroupId = g.id;
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive }; this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId };
this.groupDialogOpen = true; this.groupDialogOpen = true;
} }
saveGroup(): void { saveGroup(): void {
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder }; const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId };
const done = () => { this.groupDialogOpen = false; this.load(); }; const done = () => { this.groupDialogOpen = false; this.load(); };
if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done); if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done);
else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done); else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done);
@@ -123,17 +129,17 @@ export class ExpenseCategoriesPageComponent implements OnInit {
openNewSub(): void { openNewSub(): void {
if (!this.selectedGroup) return; if (!this.selectedGroup) return;
this.editingSubId = null; this.editingSubId = null;
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true }; this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
this.subDialogOpen = true; this.subDialogOpen = true;
} }
openEditSub(s: ExpenseSubCategoryDto): void { openEditSub(s: ExpenseSubCategoryDto): void {
this.editingSubId = s.id; this.editingSubId = s.id;
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive }; this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId };
this.subDialogOpen = true; this.subDialogOpen = true;
} }
saveSub(): void { saveSub(): void {
if (!this.selectedGroup) return; if (!this.selectedGroup) return;
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder }; const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId };
const done = () => { this.subDialogOpen = false; this.load(); }; const done = () => { this.subDialogOpen = false; this.load(); };
if (this.editingSubId == null) this.api.createSub(body).subscribe(done); if (this.editingSubId == null) this.api.createSub(body).subscribe(done);
else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done); else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done);
@@ -7,11 +7,12 @@ import {
ExpenseCategoryGroupDto, CreateExpenseGroupRequest, UpdateExpenseGroupRequest, ExpenseCategoryGroupDto, CreateExpenseGroupRequest, UpdateExpenseGroupRequest,
CreateExpenseSubCategoryRequest, UpdateExpenseSubCategoryRequest, CreateExpenseSubCategoryRequest, UpdateExpenseSubCategoryRequest,
} from '../models/expense.model'; } from '../models/expense.model';
import { Form990ExpenseLineDto } from '../../finance-report/models/form990-report.model';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ExpenseCategoryApiService { export class ExpenseCategoryApiService {
private readonly endpoint: string; private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) { constructor(private http: HttpClient, private apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('expense-categories'); this.endpoint = apiConfig.getApiUrl('expense-categories');
} }
getAll(includeInactive = false): Observable<ExpenseCategoryGroupDto[]> { getAll(includeInactive = false): Observable<ExpenseCategoryGroupDto[]> {
@@ -29,4 +30,8 @@ export class ExpenseCategoryApiService {
createSub(r: CreateExpenseSubCategoryRequest): Observable<{ id: number }> { return this.http.post<{ id: number }>(`${this.endpoint}/subcategories`, r); } createSub(r: CreateExpenseSubCategoryRequest): Observable<{ id: number }> { return this.http.post<{ id: number }>(`${this.endpoint}/subcategories`, r); }
updateSub(id: number, r: UpdateExpenseSubCategoryRequest): Observable<void> { return this.http.put<void>(`${this.endpoint}/subcategories/${id}`, r); } updateSub(id: number, r: UpdateExpenseSubCategoryRequest): Observable<void> { return this.http.put<void>(`${this.endpoint}/subcategories/${id}`, r); }
deactivateSub(id: number): Observable<void> { return this.http.delete<void>(`${this.endpoint}/subcategories/${id}`); } deactivateSub(id: number): Observable<void> { return this.http.delete<void>(`${this.endpoint}/subcategories/${id}`); }
getForm990Lines(): Observable<Form990ExpenseLineDto[]> {
return this.http.get<Form990ExpenseLineDto[]>(this.apiConfig.getApiUrl('form990-report') + '/lines')
.pipe(map(rows => rows.map(r => ({ ...r, label: `${r.lineCode}${r.name_en}${r.name_zh ? ' / ' + r.name_zh : ''}` }))));
}
} }
@@ -0,0 +1,27 @@
export interface Form990ExpenseLineDto {
id: number;
lineCode: string;
name_en: string;
name_zh: string | null;
sortOrder: number;
label?: string; // bilingual "code — name", filled by service
}
export interface FunctionalExpenseRowDto {
lineCode: string;
name_en: string;
name_zh: string | null;
program: number;
managementGeneral: number;
fundraising: number;
total: number;
}
export interface FunctionalExpenseStatementDto {
rows: FunctionalExpenseRowDto[];
programTotal: number;
managementGeneralTotal: number;
fundraisingTotal: number;
grandTotal: number;
unmappedExpenseCount: number;
}
@@ -0,0 +1,39 @@
<div class="flex flex-wrap items-end gap-3 mb-4">
<label class="flex flex-col gap-1"><span>From / 起</span>
<kendo-datepicker [(value)]="from"></kendo-datepicker></label>
<label class="flex flex-col gap-1"><span>To / 迄</span>
<kendo-datepicker [(value)]="to"></kendo-datepicker></label>
<button kendoButton themeColor="primary" (click)="load()">Apply / 套用</button>
</div>
<div *ngIf="statement?.unmappedExpenseCount" class="mb-3 p-2 rounded bg-amber-50 text-amber-800 text-sm">
{{ statement?.unmappedExpenseCount }} expense(s) have no Form 990 mapping — counted under line 24.
尚有支出未對應 990 行,已暫計入 line 24。
</div>
<div class="hidden md:block">
<kendo-grid [data]="statement?.rows ?? []">
<kendo-grid-column field="lineCode" title="Line" [width]="80"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Description / 說明"></kendo-grid-column>
<kendo-grid-column field="program" title="Program" format="{0:c2}" [width]="140"></kendo-grid-column>
<kendo-grid-column field="managementGeneral" title="Mgmt & General" format="{0:c2}" [width]="150"></kendo-grid-column>
<kendo-grid-column field="fundraising" title="Fundraising" format="{0:c2}" [width]="140"></kendo-grid-column>
<kendo-grid-column field="total" title="Total" format="{0:c2}" [width]="140"></kendo-grid-column>
</kendo-grid>
<div class="flex justify-end gap-8 mt-2 font-semibold" *ngIf="statement">
<span>Program: {{ statement.programTotal | currency }}</span>
<span>M&amp;G: {{ statement.managementGeneralTotal | currency }}</span>
<span>Fundraising: {{ statement.fundraisingTotal | currency }}</span>
<span>Total: {{ statement.grandTotal | currency }}</span>
</div>
</div>
<div class="md:hidden flex flex-col gap-3">
<div *ngFor="let row of statement?.rows ?? []" class="rounded border p-3">
<div class="font-semibold">{{ row.lineCode }} — {{ row.name_en }}</div>
<div class="text-sm flex justify-between"><span>Program</span><span>{{ row.program | currency }}</span></div>
<div class="text-sm flex justify-between"><span>M&amp;G</span><span>{{ row.managementGeneral | currency }}</span></div>
<div class="text-sm flex justify-between"><span>Fundraising</span><span>{{ row.fundraising | currency }}</span></div>
<div class="text-sm flex justify-between font-semibold"><span>Total</span><span>{{ row.total | currency }}</span></div>
</div>
</div>
@@ -0,0 +1,46 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule } from '@progress/kendo-angular-grid';
import { DatePickerModule } from '@progress/kendo-angular-dateinputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { Form990ReportApiService } from '../../services/form990-report-api.service';
import { FunctionalExpenseStatementDto } from '../../models/form990-report.model';
@Component({
selector: 'app-form990-report-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, DatePickerModule, ButtonsModule],
templateUrl: './form990-report-page.component.html',
})
export class Form990ReportPageComponent implements OnInit {
from: Date = new Date(new Date().getFullYear(), 0, 1);
to: Date = new Date(new Date().getFullYear(), 11, 31);
statement: FunctionalExpenseStatementDto | null = null;
loading = false;
constructor(private api: Form990ReportApiService) {}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
const fmt = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
this.api.getFunctionalExpenses(fmt(this.from), fmt(this.to)).subscribe({
next: (statement) => {
this.statement = statement;
this.loading = false;
},
error: () => {
this.loading = false;
},
});
}
}
@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import { FunctionalExpenseStatementDto } from '../models/form990-report.model';
@Injectable({ providedIn: 'root' })
export class Form990ReportApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('form990-report');
}
getFunctionalExpenses(from?: string, to?: string): Observable<FunctionalExpenseStatementDto> {
let params = new HttpParams();
if (from) { params = params.set('from', from); }
if (to) { params = params.set('to', to); }
return this.http.get<FunctionalExpenseStatementDto>(
`${this.endpoint}/functional-expenses`,
{ params }
);
}
}
@@ -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();
} }
@@ -341,7 +406,7 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
switchMap(id => this.pendingProofFiles.length === 0 switchMap(id => this.pendingProofFiles.length === 0
? of(void 0) ? of(void 0)
: from(buildProofPdf(this.pendingProofFiles)).pipe( : from(buildProofPdf(this.pendingProofFiles)).pipe(
switchMap(({ blob }) => this.api.uploadProof(id, blob)))), switchMap(({ blob }) => this.api.uploadProof(id, blob)))),
).subscribe({ ).subscribe({
next: () => { next: () => {
this.submitting = false; this.submitting = false;
@@ -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>

Some files were not shown because too many files have changed in this diff Show More