using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Dapr.Client; using FrameworkDAL.CustomCode.GOP; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.GOP; using GB5Shared.EventLogPublish; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using System.ComponentModel.DataAnnotations; using static GB5Shared.GB5Constant.Constant; namespace FrameworkBLL.GOP { // ============================================================ // CanonicalModelBLL — reusable, typed schema per business entity/document // (e.g. "PO", "LEAVE", "RESUME"), so every Mapper that produces or consumes // that document shares one contract instead of each inventing its own // open-ended shape. Referenced by MappingDefinitionDTO.SourceCanonicalModelId // / TargetCanonicalModelId. // // Rebuilt 2026-07-23 on the modern INT/TENANTID convention — the previous // Guid-based version (GBGatewayDTO.CanonicalModel) was a disconnected // prototype with no live consumer. // ============================================================ public class CanonicalModelBLL : ICanonicalModelBLL { private readonly ICanonicalModelDAL _CanonicalModelDAL; private readonly IQueryExecutor _QueryExecutor; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly EventLogPublish _EventLog; private readonly DaprClient _DaprClient; private readonly CacheKeyGeneration _KeyGen = new(); // The Dapr state store name + bare (unprefixed) key convention BaseEndpoint's own // GetCacheKey()-driven pipeline uses (see BaseEndPoint.cs's HandleAsyncCore — // DaprClient.GetStateAsync/DeleteStateAsync("statestore", cacheKey, ...), no prefix). // NOTE: this is deliberately NOT IDALCache — IDALCache.InvalidateAsync prepends its // own "dal:" prefix and is meant for BLL-internal GetOrSetAsync caching (the pattern // ReportViewBLL uses, whose Get* endpoints all have GetCacheKey() commented OUT for // exactly this reason). GetCanonicalModel/GetCanonicalModelList/GetCanonicalFieldsByModel // have GetCacheKey() ACTIVE, so they go through BaseEndpoint's own bare-key cache instead — // using IDALCache here invalidated a key nothing ever reads (confirmed live 2026-09-06: // a Clone stayed invisible on-screen indefinitely even with IDALCache "invalidated"). private const string StateStore = "statestore"; public CanonicalModelBLL( ICanonicalModelDAL canonicalModelDAL, IQueryExecutor queryExecutor, AutoNumber autoNumber, IValidation validation, EventLogPublish eventLog, DaprClient daprClient) { _CanonicalModelDAL = canonicalModelDAL; _QueryExecutor = queryExecutor; _AutoNumber = autoNumber; _Validation = validation; _EventLog = eventLog; _DaprClient = daprClient; } private async Task InvalidateCacheKey(string cacheKey, CancellationToken ct) { try { await _DaprClient.DeleteStateAsync(StateStore, cacheKey, cancellationToken: ct); } catch (Exception) { // A statestore outage here must not fail the write that already committed — // matches BaseEndpoint's own cache-invalidation try/catch swallow. } } private async Task InvalidateModelCaches(int canonicalModelId, LoginDTO loginDTO, CancellationToken ct) { await InvalidateCacheKey( _KeyGen.KeyGeneration(canonicalModelId, EntityConstant.CANONICALMODEL, CacheKeyLevel.CLIENT_LEVEL, loginDTO), ct); await InvalidateCacheKey( _KeyGen.KeyGeneration(0, EntityConstant.CANONICALMODEL, CacheKeyLevel.CLIENT_LEVEL, loginDTO), ct); } // ── Canonical Model ──────────────────────────────────────────────────────── public async Task SaveCanonicalModel( GopCanonicalModelDTO dto, LoginDTO loginDTO, CancellationToken ct = default) { await _Validation.NotEmpty(dto.EntityCode, nameof(dto.EntityCode)); await _Validation.NotEmpty(dto.ModelName, nameof(dto.ModelName)); bool isNew = dto.CanonicalModelId == 0; GB5Trace.Step("save-canonical-model", new { dto.EntityCode, isNew }); var trans = await _QueryExecutor.BeginTransactionAsync(loginDTO); try { if (isNew) { var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.CANONICALMODEL, loginDTO); dto.CanonicalModelId = auto.StartNumber; dto.IsActive = true; await _CanonicalModelDAL.Insert(dto, loginDTO, trans, ct); } else { var existing = await _CanonicalModelDAL.GetById(dto.CanonicalModelId, loginDTO, ct); GuardSourceType(existing?.SourceType, loginDTO.SourceType); await _CanonicalModelDAL.Update(dto, loginDTO, trans, ct); } await _QueryExecutor.CommitAsync(trans); await InvalidateModelCaches(dto.CanonicalModelId, loginDTO, ct); await _EventLog.PublishEventLogAsync( isNew ? "Canonical Model Created" : "Canonical Model Updated", dto, EventTypeConstant.GOPCANONICALMODELSAVEDEVENTTYPEID, dto.CanonicalModelId, loginDTO, ct: ct); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {dto.CanonicalModelId}" : $"{SuccessResponse.UpdateSuccessMessage} {dto.CanonicalModelId}"; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(trans); GB5Trace.MarkFailed("save-canonical-model-failed", ex); throw; } } public async Task GetCanonicalModel( int canonicalModelId, LoginDTO loginDTO, CancellationToken ct = default) => await _CanonicalModelDAL.GetById(canonicalModelId, loginDTO, ct); public async Task GetCanonicalModelByEntityCode( string entityCode, LoginDTO loginDTO, CancellationToken ct = default) => await _CanonicalModelDAL.GetByEntityCode(entityCode, loginDTO, ct); public async Task> GetCanonicalModelList( LoginDTO loginDTO, CancellationToken ct = default) => await _CanonicalModelDAL.GetList(loginDTO, ct); public async Task DeactivateCanonicalModel( int canonicalModelId, LoginDTO loginDTO, CancellationToken ct = default) { var existing = await _CanonicalModelDAL.GetById(canonicalModelId, loginDTO, ct); GuardSourceType(existing?.SourceType, loginDTO.SourceType); var trans = await _QueryExecutor.BeginTransactionAsync(loginDTO); try { await _CanonicalModelDAL.Deactivate(canonicalModelId, loginDTO, trans, ct); await _QueryExecutor.CommitAsync(trans); await InvalidateModelCaches(canonicalModelId, loginDTO, ct); return SuccessResponse.DeleteSuccessMessage; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(trans); GB5Trace.MarkFailed("deactivate-canonical-model-failed", ex); throw; } } // ── Canonical Field ──────────────────────────────────────────────────────── public async Task SaveCanonicalField( GopCanonicalFieldDTO dto, LoginDTO loginDTO, CancellationToken ct = default) { await _Validation.NotEmpty(dto.FieldPath, nameof(dto.FieldPath)); await _Validation.NotEmpty(dto.FieldName, nameof(dto.FieldName)); if (dto.CanonicalModelId == 0) throw new ArgumentException("CanonicalModelId is required."); var trans = await _QueryExecutor.BeginTransactionAsync(loginDTO); try { var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.CANONICALFIELD, loginDTO); dto.CanonicalFieldId = auto.StartNumber; await _CanonicalModelDAL.InsertField(dto, loginDTO, trans, ct); await _QueryExecutor.CommitAsync(trans); await InvalidateFieldsCache(dto.CanonicalModelId, loginDTO, ct); return $"{SuccessResponse.SaveSuccessMessage} {dto.CanonicalFieldId}"; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(trans); GB5Trace.MarkFailed("save-canonical-field-failed", ex); throw; } } public async Task DeleteCanonicalField( int canonicalFieldId, LoginDTO loginDTO, CancellationToken ct = default) { // Fetch the parent model id BEFORE delete — DeleteField's own SQL only // touches the row by CanonicalFieldId, so this is the only place that still // knows which GetCanonicalFieldsByModel cache entry needs invalidating. var parentModelId = await _CanonicalModelDAL.GetFieldModelId(canonicalFieldId, loginDTO, ct); var trans = await _QueryExecutor.BeginTransactionAsync(loginDTO); try { await _CanonicalModelDAL.DeleteField(canonicalFieldId, loginDTO, trans, ct); await _QueryExecutor.CommitAsync(trans); if (parentModelId.HasValue) await InvalidateFieldsCache(parentModelId.Value, loginDTO, ct); return SuccessResponse.DeleteSuccessMessage; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(trans); GB5Trace.MarkFailed("delete-canonical-field-failed", ex); throw; } } private async Task InvalidateFieldsCache(int canonicalModelId, LoginDTO loginDTO, CancellationToken ct) => await InvalidateCacheKey( _KeyGen.KeyGeneration(canonicalModelId, EntityConstant.CANONICALMODEL, CacheKeyLevel.CLIENT_LEVEL, loginDTO), ct); public async Task> GetCanonicalFieldsByModel( int canonicalModelId, LoginDTO loginDTO, CancellationToken ct = default) => await _CanonicalModelDAL.GetFieldsByModel(canonicalModelId, loginDTO, ct); // Library-tier protection — same convention as ReportViewBLL.GuardSourceType: a session // can only edit/deactivate rows whose SourceType is >= its own (Framework/DevAdmin // library rows stay read-only to ordinary tenant sessions). private static void GuardSourceType(byte? recordSourceType, int loginSourceType) { if (recordSourceType.HasValue && recordSourceType.Value < loginSourceType) throw new ValidationException("Insufficient privilege to modify this library canonical model — clone it instead."); } } }