From bcaa3e2f256b4836a9b6b4d65a9d3a48c4bf757e Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 13:03:53 -0700 Subject: [PATCH] Add implementation plan: Church Profile AI Settings tab Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-25-church-ai-settings.md | 849 ++++++++++++++++++ 1 file changed, 849 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-church-ai-settings.md diff --git a/docs/superpowers/plans/2026-06-25-church-ai-settings.md b/docs/superpowers/plans/2026-06-25-church-ai-settings.md new file mode 100644 index 0000000..2045afd --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-church-ai-settings.md @@ -0,0 +1,849 @@ +# Church Profile AI Settings Tab — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Store the AI provider/model/API-key in the `ChurchProfile` DB record and edit them from a new "AI 設定" tab on the Church Profile page; the expense-AI services read their config from the DB per request instead of from `appsettings.json`. + +**Architecture:** Add 5 columns to the singleton `ChurchProfile` entity. The GET endpoint returns masked keys; PUT updates a key only when a new value is typed. A new `IChurchAiConfigProvider` reads the active config from the DB, and a new `IExpenseAiServiceFactory` picks Claude vs Gemini per request (replacing the boot-time `Ai:Provider` DI binding). The two AI services drop `IOptions` and read model+key from the config provider; `BaseUrl`/`AnthropicVersion` become code constants. + +**Tech Stack:** C# / .NET 8 / EF Core (Npgsql) / xUnit + EF InMemory; Angular 20 standalone + Kendo UI v20 + Tailwind v4. + +**Spec:** [docs/superpowers/specs/2026-06-25-church-ai-settings-design.md](../specs/2026-06-25-church-ai-settings-design.md) + +**Build/test reminders (from project memory):** +- CLI builds/tests must use `-c Release` (VS holds the Debug DLL lock). `dotnet ef` must pass `--configuration Release`. +- Backend tests: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`. +- Frontend build: `cd APP && npx ng build` (no unit test added — the page uses an external `templateUrl`, which the scoped test runner can't load). + +--- + +### Task 1: Add AI columns to ChurchProfile entity + migration + +**Files:** +- Modify: `API/ROLAC.API/Entities/ChurchProfile.cs` +- Modify: `API/ROLAC.API/Data/AppDbContext.cs:295-314` (ChurchProfile entity block) +- Create (generated): `API/ROLAC.API/Migrations/_AddChurchAiSettings.cs` + +- [ ] **Step 1: Add the 5 properties to the entity** + +In `ChurchProfile.cs`, after the `BankRoutingNumber` property (line 22), insert: + +```csharp + // ── AI assist provider settings (editable via Church Profile → AI 設定 tab) ── + public string AiProvider { get; set; } = "Claude"; // "Claude" | "Gemini" + public string? ClaudeModel { get; set; } = "claude-haiku-4-5-20251001"; + public string? ClaudeApiKey { get; set; } // secret, stored plaintext + public string? GeminiModel { get; set; } = "gemini-2.5-flash-lite"; + public string? GeminiApiKey { get; set; } // secret, stored plaintext +``` + +- [ ] **Step 2: Configure the columns in AppDbContext** + +In `AppDbContext.cs`, inside the `builder.Entity(entity => { ... })` block, just before the `entity.Property(e => e.xmin).IsRowVersion();` line (around line 313), insert: + +```csharp + entity.Property(e => e.AiProvider).HasMaxLength(20).HasDefaultValue("Claude"); + entity.Property(e => e.ClaudeModel).HasMaxLength(100).HasDefaultValue("claude-haiku-4-5-20251001"); + entity.Property(e => e.ClaudeApiKey).HasMaxLength(500); + entity.Property(e => e.GeminiModel).HasMaxLength(100).HasDefaultValue("gemini-2.5-flash-lite"); + entity.Property(e => e.GeminiApiKey).HasMaxLength(500); +``` + +(The `HasDefaultValue` on provider/model columns backfills the existing singleton row during migration. API-key columns stay null — DB-only means an admin must enter the keys in the new tab before AI works again.) + +- [ ] **Step 3: Generate the migration** + +Run from the repo root (`E:/VSProject/ROLAC`): + +```bash +dotnet ef migrations add AddChurchAiSettings --project API/ROLAC.API/ROLAC.API.csproj --configuration Release +``` + +Expected: a new `Migrations/_AddChurchAiSettings.cs` is created whose `Up()` calls `migrationBuilder.AddColumn` five times (AiProvider, ClaudeModel, ClaudeApiKey, GeminiModel, GeminiApiKey) on table `ChurchProfiles`, with `defaultValue: "Claude"` / `"claude-haiku-4-5-20251001"` / `"gemini-2.5-flash-lite"` on the three non-secret columns. `AppDbContextModelSnapshot.cs` is updated. + +- [ ] **Step 4: Verify it builds** + +Run: + +```bash +dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release +``` + +Expected: Build succeeded, 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add API/ROLAC.API/Entities/ChurchProfile.cs API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/Migrations/ +git commit -m "feat(church-profile): add AI provider/model/key columns + migration" +``` + +--- + +### Task 2: DTOs + ChurchProfileService (masked read, leave-unchanged write) + +**Files:** +- Modify: `API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs` +- Modify: `API/ROLAC.API/Services/ChurchProfileService.cs` +- Create: `API/ROLAC.API.Tests/Services/ChurchProfileServiceTests.cs` + +- [ ] **Step 1: Add fields to both DTOs** + +In `ChurchProfileDtos.cs`, add to `ChurchProfileDto` (after `NextCheckNumber`, line 19): + +```csharp + public string AiProvider { get; set; } = "Claude"; + public string? ClaudeModel { get; set; } + public string? ClaudeApiKeyMasked { get; set; } + public string? GeminiModel { get; set; } + public string? GeminiApiKeyMasked { get; set; } +``` + +And add to `UpdateChurchProfileRequest` (after `NextCheckNumber`, line 36): + +```csharp + [MaxLength(20)] public string AiProvider { get; set; } = "Claude"; + [MaxLength(100)] public string? ClaudeModel { get; set; } + [MaxLength(500)] public string? ClaudeApiKey { get; set; } // null/blank = leave unchanged + [MaxLength(100)] public string? GeminiModel { get; set; } + [MaxLength(500)] public string? GeminiApiKey { get; set; } // null/blank = leave unchanged +``` + +- [ ] **Step 2: Write the failing tests** + +Create `API/ROLAC.API.Tests/Services/ChurchProfileServiceTests.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Disbursement; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class ChurchProfileServiceTests +{ + private static AppDbContext NewDb() => + new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + + private static UpdateChurchProfileRequest Req( + string provider = "Claude", string? claudeKey = null, string? geminiKey = null, + string? claudeModel = "m", string? geminiModel = "m") => + new() + { + Name = "C", NextCheckNumber = 1001, AiProvider = provider, + ClaudeModel = claudeModel, GeminiModel = geminiModel, + ClaudeApiKey = claudeKey, GeminiApiKey = geminiKey, + }; + + [Fact] + public async Task GetAsync_masks_stored_api_keys() + { + using var db = NewDb(); + db.ChurchProfiles.Add(new ChurchProfile + { + Name = "C", ClaudeApiKey = "sk-ant-abcd1234", GeminiApiKey = "AIzaXYZ9876", + }); + await db.SaveChangesAsync(); + + var dto = await new ChurchProfileService(db).GetAsync(); + + Assert.Equal("••••••1234", dto.ClaudeApiKeyMasked); + Assert.Equal("••••••9876", dto.GeminiApiKeyMasked); + } + + [Fact] + public async Task UpdateAsync_blank_key_keeps_existing() + { + using var db = NewDb(); + db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" }); + await db.SaveChangesAsync(); + + await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: null)); + + var p = await db.ChurchProfiles.FirstAsync(); + Assert.Equal("sk-keep-0001", p.ClaudeApiKey); + } + + [Fact] + public async Task UpdateAsync_nonblank_key_replaces() + { + using var db = NewDb(); + db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" }); + await db.SaveChangesAsync(); + + await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: "sk-new-9999")); + + var p = await db.ChurchProfiles.FirstAsync(); + Assert.Equal("sk-new-9999", p.ClaudeApiKey); + } + + [Fact] + public async Task UpdateAsync_sets_provider_and_models() + { + using var db = NewDb(); + db.ChurchProfiles.Add(new ChurchProfile { Name = "C" }); + await db.SaveChangesAsync(); + + await new ChurchProfileService(db).UpdateAsync( + Req(provider: "Gemini", claudeModel: "claude-x", geminiModel: "gemini-y")); + + var p = await db.ChurchProfiles.FirstAsync(); + Assert.Equal("Gemini", p.AiProvider); + Assert.Equal("claude-x", p.ClaudeModel); + Assert.Equal("gemini-y", p.GeminiModel); + } +} +``` + +- [ ] **Step 3: Run the tests to confirm they fail** + +```bash +dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChurchProfileServiceTests" +``` + +Expected: FAIL — assertions on `ClaudeApiKeyMasked` (null vs masked) and the unchanged service not yet honoring keys/provider. + +- [ ] **Step 4: Implement masking + key-preserving update in the service** + +In `ChurchProfileService.cs`, extend the `GetAsync` object initializer (after `NextCheckNumber = p.NextCheckNumber,`): + +```csharp + AiProvider = p.AiProvider, + ClaudeModel = p.ClaudeModel, + ClaudeApiKeyMasked = Mask(p.ClaudeApiKey), + GeminiModel = p.GeminiModel, + GeminiApiKeyMasked = Mask(p.GeminiApiKey), +``` + +In `UpdateAsync`, after the `p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;` line, insert: + +```csharp + p.AiProvider = string.IsNullOrWhiteSpace(r.AiProvider) ? "Claude" : r.AiProvider; + p.ClaudeModel = r.ClaudeModel; + p.GeminiModel = r.GeminiModel; + // Leave-unchanged semantics: only overwrite a stored key when a new value is supplied. + if (!string.IsNullOrWhiteSpace(r.ClaudeApiKey)) p.ClaudeApiKey = r.ClaudeApiKey; + if (!string.IsNullOrWhiteSpace(r.GeminiApiKey)) p.GeminiApiKey = r.GeminiApiKey; +``` + +Add this private helper at the end of the class (before the final closing brace): + +```csharp + /// Mask a stored secret for display: 6 bullets + last 4 chars; fully masked when ≤4 chars. + private static string Mask(string? key) + { + if (string.IsNullOrEmpty(key)) return ""; + if (key.Length <= 4) return new string('•', key.Length); + return new string('•', 6) + key[^4..]; + } +``` + +- [ ] **Step 5: Run the tests to confirm they pass** + +```bash +dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChurchProfileServiceTests" +``` + +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit** + +```bash +git add API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs API/ROLAC.API/Services/ChurchProfileService.cs API/ROLAC.API.Tests/Services/ChurchProfileServiceTests.cs +git commit -m "feat(church-profile): masked-read + leave-unchanged write for AI keys" +``` + +--- + +### Task 3: Church AI config provider (DB-backed) + +**Files:** +- Create: `API/ROLAC.API/Services/Ai/ChurchAiConfig.cs` +- Create: `API/ROLAC.API/Services/Ai/ChurchAiConfigProvider.cs` + +- [ ] **Step 1: Create the config record + interface** + +Create `API/ROLAC.API/Services/Ai/ChurchAiConfig.cs`: + +```csharp +namespace ROLAC.API.Services.Ai; + +/// Active AI configuration resolved from the ChurchProfile singleton (blanks filled with defaults). +public sealed record ChurchAiConfig( + string Provider, + string ClaudeModel, string? ClaudeApiKey, + string GeminiModel, string? GeminiApiKey); + +/// Reads the church's AI settings from the database for the current request. +public interface IChurchAiConfigProvider +{ + Task GetAsync(CancellationToken ct = default); +} +``` + +- [ ] **Step 2: Create the implementation** + +Create `API/ROLAC.API/Services/Ai/ChurchAiConfigProvider.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; + +namespace ROLAC.API.Services.Ai; + +/// +/// Loads AI settings from the singleton ChurchProfile row, substituting default model names +/// for any blank field so a freshly migrated install still names a valid model. The API keys are +/// passed through as-is (null when unset → the calling service treats AI as disabled). +/// +public sealed class ChurchAiConfigProvider : IChurchAiConfigProvider +{ + private const string DefaultClaudeModel = "claude-haiku-4-5-20251001"; + private const string DefaultGeminiModel = "gemini-2.5-flash-lite"; + + private readonly AppDbContext _db; + public ChurchAiConfigProvider(AppDbContext db) => _db = db; + + public async Task GetAsync(CancellationToken ct = default) + { + var p = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync(ct); + + var provider = string.IsNullOrWhiteSpace(p?.AiProvider) ? "Claude" : p!.AiProvider; + var claudeModel = string.IsNullOrWhiteSpace(p?.ClaudeModel) ? DefaultClaudeModel : p!.ClaudeModel!; + var geminiModel = string.IsNullOrWhiteSpace(p?.GeminiModel) ? DefaultGeminiModel : p!.GeminiModel!; + + return new ChurchAiConfig(provider, claudeModel, p?.ClaudeApiKey, geminiModel, p?.GeminiApiKey); + } +} +``` + +- [ ] **Step 3: Verify it builds** + +```bash +dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release +``` + +Expected: Build succeeded, 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add API/ROLAC.API/Services/Ai/ChurchAiConfig.cs API/ROLAC.API/Services/Ai/ChurchAiConfigProvider.cs +git commit -m "feat(ai): add DB-backed church AI config provider" +``` + +--- + +### Task 4: AI services read from DB config (drop IOptions) + +**Files:** +- Modify: `API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs` +- Modify: `API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs` +- Delete: `API/ROLAC.API/Services/Ai/ClaudeOptions.cs` +- Delete: `API/ROLAC.API/Services/Ai/GeminiOptions.cs` + +- [ ] **Step 1: Rewire ClaudeExpenseAiService** + +In `ClaudeExpenseAiService.cs`: + +Replace the `using Microsoft.Extensions.Options;` line (line 3) with nothing (remove it). + +Replace the fields + constructor (lines 18–32) with: + +```csharp + private const string BaseUrl = "https://api.anthropic.com/v1"; + private const string AnthropicVersion = "2023-06-01"; + + private readonly HttpClient _http; + private readonly IChurchAiConfigProvider _config; + private readonly ILogger _logger; + + public ClaudeExpenseAiService( + HttpClient http, + IChurchAiConfigProvider config, + AppDbContext db, + ILogger logger) + : base(db) + { + _http = http; + _config = config; + _logger = logger; + } +``` + +At the top of `CallModelAsync`, replace the key check (lines 36–40) with: + +```csharp + var cfg = await _config.GetAsync(ct); + if (string.IsNullOrWhiteSpace(cfg.ClaudeApiKey)) + { + _logger.LogWarning("Claude API key is not configured; expense AI assist is disabled."); + return null; + } +``` + +In the payload, change `model = _options.Model,` to `model = cfg.ClaudeModel,`. + +Change the URL line `var url = $"{_options.BaseUrl}/messages";` to `var url = $"{BaseUrl}/messages";`. + +Change the two header lines to: + +```csharp + request.Headers.Add("x-api-key", cfg.ClaudeApiKey); + request.Headers.Add("anthropic-version", AnthropicVersion); +``` + +- [ ] **Step 2: Rewire GeminiExpenseAiService** + +In `GeminiExpenseAiService.cs`: + +Remove the `using Microsoft.Extensions.Options;` line (line 3). + +Replace the fields + constructor (lines 15–29) with: + +```csharp + private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta"; + + private readonly HttpClient _http; + private readonly IChurchAiConfigProvider _config; + private readonly ILogger _logger; + + public GeminiExpenseAiService( + HttpClient http, + IChurchAiConfigProvider config, + AppDbContext db, + ILogger logger) + : base(db) + { + _http = http; + _config = config; + _logger = logger; + } +``` + +At the top of `CallModelAsync`, replace the key check (lines 33–37) with: + +```csharp + var cfg = await _config.GetAsync(ct); + if (string.IsNullOrWhiteSpace(cfg.GeminiApiKey)) + { + _logger.LogWarning("Gemini API key is not configured; expense AI assist is disabled."); + return null; + } +``` + +Change the URL line to: + +```csharp + var url = $"{BaseUrl}/models/{cfg.GeminiModel}:generateContent"; +``` + +Change the header line to: + +```csharp + request.Headers.Add("X-goog-api-key", cfg.GeminiApiKey); +``` + +- [ ] **Step 3: Delete the now-unused Options classes** + +```bash +git rm API/ROLAC.API/Services/Ai/ClaudeOptions.cs API/ROLAC.API/Services/Ai/GeminiOptions.cs +``` + +- [ ] **Step 4: Verify it builds** + +```bash +dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release +``` + +Expected: build FAILS only in `Program.cs` (still references `ClaudeOptions`/`GeminiOptions` and the old DI binding) — that is fixed in Task 5. The two service files themselves must have no errors. If any error points at `ClaudeExpenseAiService.cs` or `GeminiExpenseAiService.cs`, fix before proceeding. + +- [ ] **Step 5: Commit** + +```bash +git add API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs +git commit -m "refactor(ai): read provider config from DB, drop IOptions/Options classes" +``` + +(Commit even though Program.cs is temporarily broken — Task 5 is the immediate next step and restores the build. If you prefer a green tree at every commit, combine Task 4 + Task 5 into one commit.) + +--- + +### Task 5: Provider factory + controller + DI wiring + +**Files:** +- Create: `API/ROLAC.API/Services/Ai/ExpenseAiServiceFactory.cs` +- Modify: `API/ROLAC.API/Controllers/ExpenseAiController.cs` +- Modify: `API/ROLAC.API/Program.cs:182-200` +- Create: `API/ROLAC.API.Tests/Services/ExpenseAiServiceFactoryTests.cs` + +- [ ] **Step 1: Write the failing factory tests** + +Create `API/ROLAC.API.Tests/Services/ExpenseAiServiceFactoryTests.cs`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using ROLAC.API.Data; +using ROLAC.API.Entities; +using ROLAC.API.Services.Ai; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class ExpenseAiServiceFactoryTests +{ + private static AppDbContext NewDb() => + new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + + private static ExpenseAiServiceFactory Build(AppDbContext db) + { + var cfg = new ChurchAiConfigProvider(db); + var claude = new ClaudeExpenseAiService( + new HttpClient(), cfg, db, NullLogger.Instance); + var gemini = new GeminiExpenseAiService( + new HttpClient(), cfg, db, NullLogger.Instance); + return new ExpenseAiServiceFactory(cfg, claude, gemini); + } + + [Fact] + public async Task Resolves_Claude_by_default() + { + using var db = NewDb(); + db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Claude" }); + await db.SaveChangesAsync(); + + var svc = await Build(db).ResolveAsync(); + + Assert.IsType(svc); + } + + [Fact] + public async Task Resolves_Gemini_when_selected() + { + using var db = NewDb(); + db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Gemini" }); + await db.SaveChangesAsync(); + + var svc = await Build(db).ResolveAsync(); + + Assert.IsType(svc); + } +} +``` + +- [ ] **Step 2: Run to confirm it fails** + +```bash +dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ExpenseAiServiceFactoryTests" +``` + +Expected: FAIL to compile — `ExpenseAiServiceFactory` / `IExpenseAiServiceFactory` do not exist yet. + +- [ ] **Step 3: Create the factory** + +Create `API/ROLAC.API/Services/Ai/ExpenseAiServiceFactory.cs`: + +```csharp +namespace ROLAC.API.Services.Ai; + +/// Selects the active expense-AI provider per request from ChurchProfile.AiProvider. +public interface IExpenseAiServiceFactory +{ + Task ResolveAsync(CancellationToken ct = default); +} + +public sealed class ExpenseAiServiceFactory : IExpenseAiServiceFactory +{ + private readonly IChurchAiConfigProvider _config; + private readonly ClaudeExpenseAiService _claude; + private readonly GeminiExpenseAiService _gemini; + + public ExpenseAiServiceFactory( + IChurchAiConfigProvider config, + ClaudeExpenseAiService claude, + GeminiExpenseAiService gemini) + { + _config = config; + _claude = claude; + _gemini = gemini; + } + + public async Task ResolveAsync(CancellationToken ct = default) + { + var cfg = await _config.GetAsync(ct); + return cfg.Provider.Equals("Gemini", StringComparison.OrdinalIgnoreCase) ? _gemini : _claude; + } +} +``` + +- [ ] **Step 4: Update the controller to resolve per request** + +Replace the body of `ExpenseAiController.cs` (lines 12–26, the class body) so it injects the factory instead of `IExpenseAiService`: + +```csharp +public class ExpenseAiController : ControllerBase +{ + private readonly IExpenseAiServiceFactory _factory; + public ExpenseAiController(IExpenseAiServiceFactory factory) => _factory = factory; + + [HttpPost("assist")] + public async Task Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Text)) + return BadRequest("Text is required."); + + var svc = await _factory.ResolveAsync(ct); + var suggestion = await svc.SuggestAsync(request.Text, request.Amount, ct); + return Ok(suggestion); + } +} +``` + +- [ ] **Step 5: Update DI registration in Program.cs** + +In `Program.cs`, replace the AI block (lines 182–200) with: + +```csharp +// ── AI assist (expense translation + category suggestion) ────────────────── +// Backend proxy so the API key stays server-side. Provider + model + key come from the +// ChurchProfile DB record (editable via Church Profile → AI 設定); the factory picks Claude +// or Gemini per request based on ChurchProfile.AiProvider. +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +``` + +(This removes the two `Configure<...Options>` lines and the entire `var aiProvider = ...` if/else binding.) + +- [ ] **Step 6: Run the factory tests + full build** + +```bash +dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release +dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ExpenseAiServiceFactoryTests" +``` + +Expected: Build succeeded; 2 factory tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add API/ROLAC.API/Services/Ai/ExpenseAiServiceFactory.cs API/ROLAC.API/Controllers/ExpenseAiController.cs API/ROLAC.API/Program.cs API/ROLAC.API.Tests/Services/ExpenseAiServiceFactoryTests.cs +git commit -m "feat(ai): runtime provider selection via factory + DI wiring" +``` + +--- + +### Task 6: Frontend — model types + "AI 設定" tab + +**Files:** +- Modify: `APP/src/app/features/disbursement/models/disbursement.model.ts:47-54` +- Modify: `APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts` +- Modify: `APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html` + +- [ ] **Step 1: Extend the model types** + +In `disbursement.model.ts`, replace the `ChurchProfileDto` interface and `UpdateChurchProfileRequest` alias (lines 47–54) with: + +```typescript +export interface ChurchProfileDto { + 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; + bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number; + aiProvider: string; + claudeModel: string | null; claudeApiKeyMasked: string | null; + geminiModel: string | null; geminiApiKeyMasked: string | null; +} + +export type UpdateChurchProfileRequest = + Omit + & { claudeApiKey: string | null; geminiApiKey: string | null }; +``` + +- [ ] **Step 2: Update the component class** + +In `church-profile-page.component.ts`: + +Add the DropDowns module import after the existing Kendo imports (after line 6): + +```typescript +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +``` + +Add `UpdateChurchProfileRequest` to the model import (line 8): + +```typescript +import { ChurchProfileDto, UpdateChurchProfileRequest } from '../../models/disbursement.model'; +``` + +Add `DropDownsModule` to the `imports` array of the `@Component` decorator (append to the list on lines 17–20): + +```typescript + CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule, DropDownsModule, + HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent, +``` + +Add these members after `savedMsg = '';` (line 26): + +```typescript + /** Bound to the password inputs; blank means "keep the saved key". Reset after each save. */ + claudeApiKeyInput = ''; + geminiApiKeyInput = ''; + + readonly aiProviders = [ + { text: 'Claude', value: 'Claude' }, + { text: 'Gemini', value: 'Gemini' }, + ]; +``` + +Replace the `save()` method (lines 37–49) with: + +```typescript + save(): void { + if (!this.model || this.saving) return; + this.saving = true; + this.savedMsg = ''; + const { id, claudeApiKeyMasked, geminiApiKeyMasked, ...rest } = this.model; + const req: UpdateChurchProfileRequest = { + ...rest, + claudeApiKey: this.claudeApiKeyInput.trim() || null, + geminiApiKey: this.geminiApiKeyInput.trim() || null, + }; + this.api.updateChurchProfile(req).subscribe({ + next: () => { + this.saving = false; + this.savedMsg = 'Saved / 已儲存'; + // Clear the key inputs and reload so the masked placeholders reflect the new keys. + this.claudeApiKeyInput = ''; + this.geminiApiKeyInput = ''; + this.api.getChurchProfile().subscribe(p => (this.model = p)); + }, + error: () => { + // Error message is shown globally by httpErrorInterceptor. + this.saving = false; + }, + }); + } +``` + +- [ ] **Step 3: Add the tab to the template** + +In `church-profile-page.component.html`, insert this block immediately before the closing `` (after the Notifications tab, line 84): + +```html + + + +
+
+ + + + + + + + +

+ Leave a key blank to keep the saved one. / 金鑰留空表示沿用已儲存的設定。 +

+
+ +
+ + {{ savedMsg }} +
+
+
+
+``` + +(The password fields use the `kendoTextBox` directive on a native `` — Kendo's `` element has no password mode. This is the intended exception to the "use Kendo element, not directive" convention.) + +- [ ] **Step 4: Verify the frontend builds** + +```bash +cd APP && npx ng build +``` + +Expected: build succeeds with no template/type errors (notably no "property does not exist on ChurchProfileDto" and no missing-module error for `kendo-dropdownlist`). + +- [ ] **Step 5: Commit** + +```bash +git add APP/src/app/features/disbursement/models/disbursement.model.ts APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html +git commit -m "feat(church-profile): AI 設定 tab (provider/model/key) with masked keys" +``` + +--- + +### Task 7: Full verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full backend test suite** + +```bash +dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release +``` + +Expected: all tests pass (including the 4 ChurchProfileService + 2 factory tests added here, and no regressions in existing AI/expense tests). + +- [ ] **Step 2: Confirm the full API + APP build** + +```bash +dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release +cd APP && npx ng build && cd .. +``` + +Expected: both succeed, 0 errors. + +- [ ] **Step 3 (optional, manual): smoke-test the tab** + +Run the API + `ng serve` (per the build-run memory: temporary `apiUrl` repoint + `--port 4200`), log in as `super_admin`, open Church Profile → **AI 設定**, set provider + a model + paste a key, Save. Reload: the key field shows the masked placeholder (`••••••…`) and the model/provider persist. Then file an expense and use AI assist to confirm the DB-stored key drives the call. + +- [ ] **Step 4: Final commit (if any verification fixups were needed)** + +```bash +git add -A +git commit -m "test(church-profile): verify AI settings end-to-end" +``` + +--- + +## Notes / accepted behavior + +- **DB-only migration consequence:** after the migration runs, `ClaudeApiKey`/`GeminiApiKey` are empty, so AI assist is disabled until an admin enters a key in the new tab. This is the explicitly chosen behavior (spec decision 3, option B). +- **appsettings AI config is now dead** (`Ai` / `Claude` / `Gemini` sections). Left inert per spec "out of scope"; can be removed in a separate cleanup. +- **No "clear key" in v1** — the UI can replace a key but not blank an existing one. +- **`BaseUrl` / `AnthropicVersion`** live as constants in the service classes; changing an endpoint/proxy needs a code edit (accepted).