Files
ROLAC/docs/superpowers/plans/2026-06-25-church-ai-settings.md
T
2026-06-25 13:03:53 -07:00

32 KiB
Raw Blame History

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

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/<timestamp>_AddChurchAiSettings.cs

  • Step 1: Add the 5 properties to the entity

In ChurchProfile.cs, after the BankRoutingNumber property (line 22), insert:

    // ── 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<ChurchProfile>(entity => { ... }) block, just before the entity.Property(e => e.xmin).IsRowVersion(); line (around line 313), insert:

            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):

dotnet ef migrations add AddChurchAiSettings --project API/ROLAC.API/ROLAC.API.csproj --configuration Release

Expected: a new Migrations/<timestamp>_AddChurchAiSettings.cs is created whose Up() calls migrationBuilder.AddColumn<string> 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:

dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release

Expected: Build succeeded, 0 errors.

  • Step 5: Commit
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):

    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):

    [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:

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<AppDbContext>()
            .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
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,):

            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:

        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):

    /// <summary>Mask a stored secret for display: 6 bullets + last 4 chars; fully masked when ≤4 chars.</summary>
    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
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChurchProfileServiceTests"

Expected: PASS (4 tests).

  • Step 6: Commit
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:

namespace ROLAC.API.Services.Ai;

/// <summary>Active AI configuration resolved from the ChurchProfile singleton (blanks filled with defaults).</summary>
public sealed record ChurchAiConfig(
    string Provider,
    string ClaudeModel, string? ClaudeApiKey,
    string GeminiModel, string? GeminiApiKey);

/// <summary>Reads the church's AI settings from the database for the current request.</summary>
public interface IChurchAiConfigProvider
{
    Task<ChurchAiConfig> GetAsync(CancellationToken ct = default);
}
  • Step 2: Create the implementation

Create API/ROLAC.API/Services/Ai/ChurchAiConfigProvider.cs:

using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;

namespace ROLAC.API.Services.Ai;

/// <summary>
/// Loads AI settings from the singleton <c>ChurchProfile</c> 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).
/// </summary>
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<ChurchAiConfig> 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
dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release

Expected: Build succeeded, 0 errors.

  • Step 4: Commit
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 1832) with:

    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<ClaudeExpenseAiService> _logger;

    public ClaudeExpenseAiService(
        HttpClient http,
        IChurchAiConfigProvider config,
        AppDbContext db,
        ILogger<ClaudeExpenseAiService> logger)
        : base(db)
    {
        _http = http;
        _config = config;
        _logger = logger;
    }

At the top of CallModelAsync, replace the key check (lines 3640) with:

        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:

            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 1529) with:

    private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta";

    private readonly HttpClient _http;
    private readonly IChurchAiConfigProvider _config;
    private readonly ILogger<GeminiExpenseAiService> _logger;

    public GeminiExpenseAiService(
        HttpClient http,
        IChurchAiConfigProvider config,
        AppDbContext db,
        ILogger<GeminiExpenseAiService> logger)
        : base(db)
    {
        _http = http;
        _config = config;
        _logger = logger;
    }

At the top of CallModelAsync, replace the key check (lines 3337) with:

        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:

            var url = $"{BaseUrl}/models/{cfg.GeminiModel}:generateContent";

Change the header line to:

            request.Headers.Add("X-goog-api-key", cfg.GeminiApiKey);
  • Step 3: Delete the now-unused Options classes
git rm API/ROLAC.API/Services/Ai/ClaudeOptions.cs API/ROLAC.API/Services/Ai/GeminiOptions.cs
  • Step 4: Verify it builds
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
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:

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<AppDbContext>()
            .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<ClaudeExpenseAiService>.Instance);
        var gemini = new GeminiExpenseAiService(
            new HttpClient(), cfg, db, NullLogger<GeminiExpenseAiService>.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<ClaudeExpenseAiService>(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<GeminiExpenseAiService>(svc);
    }
}
  • Step 2: Run to confirm it fails
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:

namespace ROLAC.API.Services.Ai;

/// <summary>Selects the active expense-AI provider per request from <c>ChurchProfile.AiProvider</c>.</summary>
public interface IExpenseAiServiceFactory
{
    Task<IExpenseAiService> 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<IExpenseAiService> 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 1226, the class body) so it injects the factory instead of IExpenseAiService:

public class ExpenseAiController : ControllerBase
{
    private readonly IExpenseAiServiceFactory _factory;
    public ExpenseAiController(IExpenseAiServiceFactory factory) => _factory = factory;

    [HttpPost("assist")]
    public async Task<IActionResult> 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 182200) with:

// ── 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<ROLAC.API.Services.Ai.GeminiExpenseAiService>();
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.ClaudeExpenseAiService>();
builder.Services.AddScoped<ROLAC.API.Services.Ai.IChurchAiConfigProvider,
                           ROLAC.API.Services.Ai.ChurchAiConfigProvider>();
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseAiServiceFactory,
                           ROLAC.API.Services.Ai.ExpenseAiServiceFactory>();

(This removes the two Configure<...Options> lines and the entire var aiProvider = ... if/else binding.)

  • Step 6: Run the factory tests + full build
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
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 4754) with:

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<ChurchProfileDto, 'id' | 'claudeApiKeyMasked' | 'geminiApiKeyMasked'>
  & { 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):

import { DropDownsModule } from '@progress/kendo-angular-dropdowns';

Add UpdateChurchProfileRequest to the model import (line 8):

import { ChurchProfileDto, UpdateChurchProfileRequest } from '../../models/disbursement.model';

Add DropDownsModule to the imports array of the @Component decorator (append to the list on lines 1720):

    CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule, DropDownsModule,
    HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,

Add these members after savedMsg = ''; (line 26):

  /** 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 3749) with:

  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 </kendo-tabstrip> (after the Notifications tab, line 84):

    <!-- ── Tab 4: AI Settings (Settings permission) ───────────────────────── -->
    <kendo-tabstrip-tab title="AI 設定 / AI Settings" *appHasPermission="settingsPermission">
      <ng-template kendoTabContent>
        <div *ngIf="model" class="max-w-3xl pt-4">
          <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
            <label class="flex flex-col gap-1 md:col-span-2">
              AI Provider / AI 供應商
              <kendo-dropdownlist
                [data]="aiProviders" textField="text" valueField="value" [valuePrimitive]="true"
                [(ngModel)]="model.aiProvider"></kendo-dropdownlist>
            </label>

            <label class="flex flex-col gap-1">
              Claude Model / Claude 模型
              <kendo-textbox [(ngModel)]="model.claudeModel"></kendo-textbox>
            </label>
            <label class="flex flex-col gap-1">
              Claude API Key / Claude 金鑰
              <input kendoTextBox type="password" autocomplete="new-password"
                     [(ngModel)]="claudeApiKeyInput"
                     [placeholder]="model.claudeApiKeyMasked || 'Enter key / 輸入金鑰'" />
            </label>

            <label class="flex flex-col gap-1">
              Gemini Model / Gemini 模型
              <kendo-textbox [(ngModel)]="model.geminiModel"></kendo-textbox>
            </label>
            <label class="flex flex-col gap-1">
              Gemini API Key / Gemini 金鑰
              <input kendoTextBox type="password" autocomplete="new-password"
                     [(ngModel)]="geminiApiKeyInput"
                     [placeholder]="model.geminiApiKeyMasked || 'Enter key / 輸入金鑰'" />
            </label>

            <p class="md:col-span-2 text-sm" style="color:#6b7280;">
              Leave a key blank to keep the saved one. / 金鑰留空表示沿用已儲存的設定。
            </p>
          </div>

          <div class="flex items-center gap-3 mt-4">
            <button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
            <span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
          </div>
        </div>
      </ng-template>
    </kendo-tabstrip-tab>

(The password fields use the kendoTextBox directive on a native <input type="password"> — Kendo's <kendo-textbox> element has no password mode. This is the intended exception to the "use Kendo element, not directive" convention.)

  • Step 4: Verify the frontend builds
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
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
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
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)
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).