using System.Text.Json; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace MeterVault.Infrastructure.Backup; /// /// Exports/imports the configuration snapshot (SDD §10). Import restores into an empty instance, /// remapping surrogate ids so foreign keys stay consistent regardless of the original ids. /// public sealed class ExportService(MeterVaultDbContext db) { private static readonly JsonSerializerOptions Json = new() { WriteIndented = true, ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles, }; private readonly MeterVaultDbContext _db = db; /// /// Strips encrypted connector secrets from an export. They are ciphertext, not plaintext, so /// this is not a §6.4 requirement — but the export exists for portability (§9), and ciphertext /// is bound to the originating instance's key ring, so it is useless anywhere it could be /// restored and merely widens the blast radius if the key ring also leaks. Env-var references /// survive: they name a variable and reveal nothing. Restoring means re-entering the secrets. /// private static List RedactSecrets(List endpoints) { foreach (var endpoint in endpoints) { endpoint.Config = endpoint.Type == EndpointType.HomeAssistant ? (Ingestion.HaEndpointConfig.Parse(endpoint.Config) with { TokenEnc = null }).ToJson() : (Ingestion.EndpointConfig.Parse(endpoint.Config) with { PasswordEnc = null }).ToJson(); } return endpoints; } public async Task ExportJsonAsync(CancellationToken cancellationToken = default) { // Load without navigation includes so serialization is a clean, cycle-free tree. var document = new ExportDocument { EnergyTypes = await _db.EnergyTypes.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), CostCategories = await _db.CostCategories.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), Meters = await _db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), IngestionEndpoints = RedactSecrets( await _db.IngestionEndpoints.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false)), MeterSources = await _db.MeterSources.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), Tanks = await _db.Tanks.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), Tariffs = await _db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), CostCategoryMembers = await _db.CostCategoryMembers.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), ManualCosts = await _db.ManualCosts.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), MeterEvents = await _db.MeterEvents.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), AppSettings = await _db.AppSettings.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), }; return JsonSerializer.Serialize(document, Json); } public async Task ImportJsonAsync(string json, CancellationToken cancellationToken = default) { var doc = JsonSerializer.Deserialize(json, Json) ?? throw new InvalidOperationException("Empty or invalid export document."); await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); var typeMap = new Dictionary(); foreach (var type in doc.EnergyTypes) { var old = type.Id; type.Id = 0; _db.EnergyTypes.Add(type); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); typeMap[old] = type.Id; } var categoryMap = await InsertMappedAsync(doc.CostCategories, c => c.Id, (c, _) => c.Id = 0, cancellationToken).ConfigureAwait(false); var meterMap = new Dictionary(); foreach (var meter in doc.Meters) { var old = meter.Id; meter.Id = 0; meter.EnergyTypeId = typeMap.GetValueOrDefault(meter.EnergyTypeId, meter.EnergyTypeId); meter.EnergyType = null; _db.Meters.Add(meter); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); meterMap[old] = meter.Id; } var endpointMap = await InsertMappedAsync(doc.IngestionEndpoints, e => e.Id, (e, _) => e.Id = 0, cancellationToken).ConfigureAwait(false); foreach (var source in doc.MeterSources) { source.Id = 0; source.MeterId = meterMap.GetValueOrDefault(source.MeterId, source.MeterId); source.EndpointId = source.EndpointId is { } eid ? endpointMap.GetValueOrDefault(eid, eid) : null; source.Meter = null; source.Endpoint = null; _db.MeterSources.Add(source); } foreach (var tank in doc.Tanks) { tank.Id = 0; tank.MeterId = meterMap.GetValueOrDefault(tank.MeterId, tank.MeterId); tank.Meter = null; _db.Tanks.Add(tank); } foreach (var tariff in doc.Tariffs) { tariff.Id = 0; tariff.ScopeId = tariff.ScopeType switch { TariffScope.Meter when tariff.ScopeId is { } id => meterMap.GetValueOrDefault(id, id), TariffScope.EnergyType when tariff.ScopeId is { } id => typeMap.GetValueOrDefault((short)id, (short)id), _ => tariff.ScopeId, }; _db.Tariffs.Add(tariff); } foreach (var member in doc.CostCategoryMembers) { member.Id = 0; member.CategoryId = categoryMap.GetValueOrDefault(member.CategoryId, member.CategoryId); member.MeterId = member.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null; member.EnergyTypeId = member.EnergyTypeId is { } tid ? typeMap.GetValueOrDefault(tid, tid) : null; member.Category = null; _db.CostCategoryMembers.Add(member); } foreach (var cost in doc.ManualCosts) { cost.Id = 0; cost.CategoryId = cost.CategoryId is { } cid ? categoryMap.GetValueOrDefault(cid, cid) : null; cost.MeterId = cost.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null; cost.ImportBatchId = null; // the original import batch isn't part of the export _db.ManualCosts.Add(cost); } foreach (var meterEvent in doc.MeterEvents) { meterEvent.Id = 0; meterEvent.MeterId = meterMap.GetValueOrDefault(meterEvent.MeterId, meterEvent.MeterId); meterEvent.ImportBatchId = null; _db.MeterEvents.Add(meterEvent); } foreach (var setting in doc.AppSettings) { if (!await _db.AppSettings.AnyAsync(s => s.Key == setting.Key, cancellationToken).ConfigureAwait(false)) { _db.AppSettings.Add(setting); } } await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await tx.CommitAsync(cancellationToken).ConfigureAwait(false); } private async Task> InsertMappedAsync( List items, Func getId, Action clearId, CancellationToken cancellationToken) where T : class { var map = new Dictionary(); foreach (var item in items) { var old = getId(item); clearId(item, 0); _db.Add(item); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); map[old] = getId(item); } return map; } }