|
- using System.Collections.Concurrent;
- using Campaign_Tracker.Server.ExtensionData;
- using Campaign_Tracker.Server.LegacyData;
-
- namespace Campaign_Tracker.Server.Municipalities;
-
- /// <summary>
- /// In-memory municipality profile store for development and integration testing.
- /// Implements <see cref="ILegacyLinkedRecordProvider"/> so profiles are included in
- /// the nightly extension-to-legacy link integrity check (Story 1.8 AC #4).
- /// </summary>
- public sealed class InMemoryMunicipalityProfileRepository
- : IMunicipalityProfileRepository, ILegacyLinkedRecordProvider
- {
- private readonly ConcurrentDictionary<string, MunicipalityProfile> _profiles = new(StringComparer.OrdinalIgnoreCase);
- private readonly object _lock = new();
- private readonly ILegacyLinkValidator _validator;
- private readonly ILegacyDataAccess _legacyData;
- private readonly TimeProvider _timeProvider;
-
- public InMemoryMunicipalityProfileRepository(
- ILegacyLinkValidator validator,
- ILegacyDataAccess legacyData,
- TimeProvider timeProvider)
- {
- _validator = validator;
- _legacyData = legacyData;
- _timeProvider = timeProvider;
- }
-
- // ── AC #1: create with required JCode link ────────────────────────────────
-
- public async Task<MunicipalityProfileSaveResult> CreateAsync(
- string jCode,
- string? displayName,
- string actorIdentity,
- CancellationToken cancellationToken = default)
- {
- if (string.IsNullOrWhiteSpace(jCode))
- return MunicipalityProfileSaveResult.Failure("JCode is required.");
-
- // P8: normalize before validation so the link validator uses the same form that gets stored
- var normalizedJCode = jCode.Trim().ToUpperInvariant();
-
- // AC #4: validate before saving; never write if the link is invalid
- var linkRef = LegacyLinkReference.ForJurisdiction(normalizedJCode);
- var validation = await _validator.ValidateAsync(linkRef, cancellationToken);
- if (!validation.IsValid)
- return MunicipalityProfileSaveResult.Failure(validation.Error!);
-
- var now = _timeProvider.GetUtcNow();
- var profile = new MunicipalityProfile(
- ProfileId: Guid.NewGuid().ToString("N"),
- JCode: normalizedJCode,
- DisplayName: string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(),
- CreatedAt: now,
- UpdatedAt: now,
- UpdatedBy: actorIdentity);
-
- // P2: atomic check + insert under lock to prevent TOCTOU race on duplicate JCode
- lock (_lock)
- {
- if (_profiles.Values.Any(p => string.Equals(p.JCode, normalizedJCode, StringComparison.OrdinalIgnoreCase)))
- return MunicipalityProfileSaveResult.Failure(
- $"A municipality profile already exists for JCode '{normalizedJCode}'.");
-
- _profiles[profile.ProfileId] = profile;
- }
- return MunicipalityProfileSaveResult.Success(profile);
- }
-
- // ── AC #3: update with audit trail captured by caller ────────────────────
-
- public Task<MunicipalityProfileSaveResult> UpdateAsync(
- string profileId,
- string? displayName,
- string actorIdentity,
- CancellationToken cancellationToken = default)
- {
- // P2: wrap read-modify-write in lock to prevent lost updates under concurrent PUTs
- lock (_lock)
- {
- if (!_profiles.TryGetValue(profileId, out var existing))
- return Task.FromResult(MunicipalityProfileSaveResult.ProfileNotFound(profileId));
-
- var updated = existing with
- {
- DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(),
- UpdatedAt = _timeProvider.GetUtcNow(),
- UpdatedBy = actorIdentity,
- };
-
- _profiles[profileId] = updated;
- return Task.FromResult(MunicipalityProfileSaveResult.Success(updated));
- }
- }
-
- // ── AC #2: resolve combined extension + legacy view ──────────────────────
-
- public async Task<MunicipalityProfileView?> GetByIdAsync(
- string profileId,
- CancellationToken cancellationToken = default)
- {
- if (!_profiles.TryGetValue(profileId, out var profile))
- return null;
-
- return await BuildViewAsync(profile, cancellationToken);
- }
-
- public async Task<IReadOnlyList<MunicipalityProfileView>> GetAllAsync(
- CancellationToken cancellationToken = default)
- {
- var profiles = _profiles.Values
- .OrderBy(p => p.JCode, StringComparer.OrdinalIgnoreCase)
- .ToArray();
-
- var views = new List<MunicipalityProfileView>(profiles.Length);
- foreach (var profile in profiles)
- views.Add(await BuildViewAsync(profile, cancellationToken));
-
- return views;
- }
-
- // ── ILegacyLinkedRecordProvider ───────────────────────────────────────────
-
- Task<IReadOnlyList<ILegacyLinkedRecord>> ILegacyLinkedRecordProvider.GetAllAsync(
- CancellationToken cancellationToken)
- => Task.FromResult<IReadOnlyList<ILegacyLinkedRecord>>(
- _profiles.Values.Cast<ILegacyLinkedRecord>().ToArray());
-
- // ── helpers ───────────────────────────────────────────────────────────────
-
- private async Task<MunicipalityProfileView> BuildViewAsync(
- MunicipalityProfile profile,
- CancellationToken cancellationToken)
- {
- var jurisdiction = await _legacyData.GetJurisdictionAsync(profile.JCode, cancellationToken);
- return new MunicipalityProfileView(
- Profile: profile,
- LegacyName: jurisdiction?.Name,
- LegacyMailingAddress: jurisdiction?.MailingAddress,
- LegacyCityStateZip: jurisdiction?.CityStateZip);
- }
- }
|