using CMSBLL.Common; using CMSBLL.ContentBlock; using CMSBLL.DataSourceProxy; using CMSBLL.SitePage; using CMSDAL.CustomCode.Site; using CMSDAL.DTO.ContentBlock; using CMSDAL.DTO.Site; using CMSDAL.DTO.SiteDocument; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System.Linq; namespace CMSBLL.SiteDocument; public class SiteDocumentBLL : ISiteDocumentBLL { private const string COMPONENT_REF_BLOCK_CODE = "COMPONENT_REF"; private const string DATA_BINDING_PROP_KEY = "__dataBinding"; // Stamped onto a ServerSideRenderMerge block's own PropsJson alongside its merged data — // there is no other visible signal that a prop came from a live fetch rather than authored // content, once merged. Phase 13's static-export tooling reads this (and the presence of // DATA_BINDING_PROP_KEY for ClientSideFetch blocks) to report which data is a frozen // snapshot vs. still resolved live from a visitor's own browser after export. private const string DATA_BINDING_MODE_PROP_KEY = "__dataBindingMode"; private const string DATA_BINDING_MODE_SERVER = "server"; private readonly ISiteDAL _siteDal; private readonly IContentBlockBLL _contentBlockBll; private readonly ISitePageBLL _sitePageBll; private readonly IDataSourceProxyBLL _dataSourceProxyBll; public SiteDocumentBLL( ISiteDAL siteDal, IContentBlockBLL contentBlockBll, ISitePageBLL sitePageBll, IDataSourceProxyBLL dataSourceProxyBll) { _siteDal = siteDal; _contentBlockBll = contentBlockBll; _sitePageBll = sitePageBll; _dataSourceProxyBll = dataSourceProxyBll; } // siteTenantId comes from SiteResolverBLL's already-resolved SiteResolutionDTO — the ONLY // trusted source of tenant context for an anonymous public request. loginDTO's own ClientId // has no necessary relationship to the tenant that owns this site (the caller may have no // login context at all, or may be a service account shared across every tenant), so every // tenant-filtered query this method makes must run against a rebased login carrying // siteTenantId, never the caller's original ClientId. Without this, GetSite/ // GetFullDocumentAsync/GetPageSeoMetadata's own "AND TENANTID = @TenantId" filters would // silently return nothing for any caller whose ClientId isn't already that exact tenant. public async Task ComposeAsync(int siteId, int siteTenantId, int pageContentId, LoginDTO loginDTO, CancellationToken ct = default) { try { GB5Trace.Step("compose-site-document", new { siteId, siteTenantId, pageContentId }); var siteLogin = LoginTenantRebase.RebaseTenant(loginDTO, siteTenantId); var siteJson = await _siteDal.GetSite(siteId, siteLogin, ct); var site = JsonConvert.DeserializeObject(siteJson); var page = await FetchDocumentAsync(pageContentId, siteLogin, ct) ?? throw new InvalidOperationException($"Content {pageContentId} not found."); await ResolveComponentRefsAsync(page, siteLogin, ct); await ResolveDataBindingsAsync(page, siteLogin, ct); var seo = await _sitePageBll.GetPageSeoMetadata(pageContentId, siteLogin, ct); var result = new SiteDocumentDTO { Page = page, Seo = seo }; if (site?.HeaderComponentContentId is int headerContentId) { result.Header = await FetchDocumentAsync(headerContentId, siteLogin, ct); if (result.Header is not null) { await ResolveComponentRefsAsync(result.Header, siteLogin, ct); await ResolveDataBindingsAsync(result.Header, siteLogin, ct); } } if (site?.FooterComponentContentId is int footerContentId) { result.Footer = await FetchDocumentAsync(footerContentId, siteLogin, ct); if (result.Footer is not null) { await ResolveComponentRefsAsync(result.Footer, siteLogin, ct); await ResolveDataBindingsAsync(result.Footer, siteLogin, ct); } } return result; } catch (Exception ex) { GB5Trace.MarkFailed("compose-site-document-failed", ex); throw; } } private async Task FetchDocumentAsync(int contentId, LoginDTO loginDTO, CancellationToken ct) { var json = await _contentBlockBll.GetFullDocumentAsync(contentId, loginDTO, ct); return JsonConvert.DeserializeObject(json); } // Inlines each COMPONENT_REF block's referenced component ONE level deep only — a // referenced component's own COMPONENT_REF blocks are left unresolved. This is what keeps // resolution trivially cycle-safe without a visited-set: there is no recursive call here. private async Task ResolveComponentRefsAsync(ContentFullDocumentDTO document, LoginDTO loginDTO, CancellationToken ct) { var refBlocks = document.Containers .SelectMany(c => c.Blocks) .Where(b => b.BlockCode == COMPONENT_REF_BLOCK_CODE); foreach (var block in refBlocks) { int? componentContentId = null; try { componentContentId = JObject.Parse(block.PropsJson)["componentContentId"]?.Value(); } catch (JsonException) { // Malformed PropsJson on a COMPONENT_REF block — leave ReferencedComponent null // rather than fail the whole page render for one bad block. } if (componentContentId is int id && id > 0) block.ReferencedComponent = await FetchDocumentAsync(id, loginDTO, ct); } } // Dynamic data binding (Phase 12) — walks every block in the document, INCLUDING one level // into any already-resolved COMPONENT_REF's ReferencedComponent (a shared header/footer // component can itself have bound blocks), and merges each bound block's resolved data into // its own PropsJson. Deliberately not recursive beyond that one level, for the same // cycle-safety reason ResolveComponentRefsAsync stops at one level. private async Task ResolveDataBindingsAsync(ContentFullDocumentDTO document, LoginDTO loginDTO, CancellationToken ct) { var topLevelBlocks = document.Containers.SelectMany(c => c.Blocks).ToList(); var referencedBlocks = topLevelBlocks .Where(b => b.ReferencedComponent is not null) .SelectMany(b => b.ReferencedComponent!.Containers.SelectMany(c => c.Blocks)); foreach (var block in topLevelBlocks.Concat(referencedBlocks)) await ResolveBlockDataBindingAsync(block, loginDTO, ct); } private async Task ResolveBlockDataBindingAsync(ContentBlockDTO block, LoginDTO loginDTO, CancellationToken ct) { var serverMergePatch = await _dataSourceProxyBll.ResolveServerMergePatchAsync(block.ContentBlockId, loginDTO, ct); if (serverMergePatch is not null) { var patch = JObject.Parse(serverMergePatch); patch[DATA_BINDING_MODE_PROP_KEY] = DATA_BINDING_MODE_SERVER; MergePropsPatch(block, patch); return; } var clientFetchDescriptor = await _dataSourceProxyBll.GetClientFetchDescriptorAsync(block.ContentBlockId, loginDTO, ct); if (clientFetchDescriptor is not null) { var descriptorPatch = new JObject { [DATA_BINDING_PROP_KEY] = JObject.FromObject(clientFetchDescriptor) }; MergePropsPatch(block, descriptorPatch); } } private static void MergePropsPatch(ContentBlockDTO block, JObject patch) { if (!patch.Properties().Any()) return; JObject props; try { props = JObject.Parse(block.PropsJson); } catch (JsonException) { props = new JObject(); } props.Merge(patch, new JsonMergeSettings { MergeArrayHandling = MergeArrayHandling.Replace }); block.PropsJson = props.ToString(Formatting.None); } }