using CMSBLL.ContentBlock; using CMSBLL.ContentContainer; using CMSBLL.ContentContainerType; using CMSBLL.PageTemplate; using CMSBLL.SitePage; using CMSBLL.SiteTemplate; using CMSDAL.CustomCode.Site; using CMSDAL.DTO.ContentBlock; using CMSDAL.DTO.ContentContainer; using CMSDAL.DTO.SitePage; using CMSDAL.DTO.Site; using CMSDAL.DTO.SiteTemplate; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.EventLogPublish; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using Newtonsoft.Json; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Text.RegularExpressions; using static GB5Shared.GB5Constant.Constant; namespace CMSBLL.Site; public class SiteBLL : ISiteBLL { // 0=Cloned (independent copy), 1=Linked (shared Content, edits propagate everywhere) — same // convention as SitePageBLL's own private SHARINGMODE_LINKED constant. Every page a template // instantiation creates is Cloned: each new site gets its own independent copy, never // accidentally sharing Content with another site's page. Proving SharingMode=Linked reuse is // a deliberate manual follow-up step against two already-instantiated sites, not something // template instantiation itself does automatically (see this method's own doc comment). private const byte SHARING_MODE_CLONED = 0; private readonly ISiteDAL _dal; private readonly ISiteTemplateBLL _siteTemplateBll; private readonly IPageTemplateBLL _pageTemplateBll; private readonly ISitePageBLL _sitePageBll; private readonly IContentContainerBLL _contentContainerBll; private readonly IContentContainerTypeBLL _contentContainerTypeBll; private readonly IContentBlockBLL _contentBlockBll; private readonly BaseEntityAppService _entityApp; private readonly EventLogPublish _eventLogPublish; public SiteBLL( ISiteDAL dal, ISiteTemplateBLL siteTemplateBll, IPageTemplateBLL pageTemplateBll, ISitePageBLL sitePageBll, IContentContainerBLL contentContainerBll, IContentContainerTypeBLL contentContainerTypeBll, IContentBlockBLL contentBlockBll, BaseEntityAppService entityApp, EventLogPublish eventLogPublish) { _dal = dal; _siteTemplateBll = siteTemplateBll; _pageTemplateBll = pageTemplateBll; _sitePageBll = sitePageBll; _contentContainerBll = contentContainerBll; _contentContainerTypeBll = contentContainerTypeBll; _contentBlockBll = contentBlockBll; _entityApp = entityApp; _eventLogPublish = eventLogPublish; } public async Task GetSite(int siteId, LoginDTO loginDTO, CancellationToken ct = default) { try { return await _dal.GetSite(siteId, loginDTO, ct); } catch { throw; } } public async Task GetSelectListSite(int firstNumber, int maxResult, CriteriaDTO criteriaDTO, LoginDTO loginDTO, CancellationToken ct = default) { try { return await _dal.GetSelectListSite(firstNumber, maxResult, criteriaDTO, loginDTO, ct); } catch { throw; } } public async Task SaveSite(SiteDTO dto, LoginDTO loginDTO, CancellationToken ct = default) { var isNew = dto.SiteId == 0; var eventTypeId = isNew ? EventTypeConstant.SAVECMSSITEEVENTTYPEID : EventTypeConstant.UPDATECMSSITEEVENTTYPEID; try { GB5Trace.Step("validate-site", new { dto.SiteId, isNew }); // A Site is always tenant-owned — unlike most CMS reference tables there is no // -1 "system-wide" sentinel for a website. Reject explicitly rather than let a // caller silently create a site every tenant can see. // NOT `dto.TenantId <= 0` — live end-to-end testing caught that real GB5 tenant // IDs are frequently large negative numbers (the platform's "negative AutoNumber" // convention); only the literal -1 is the system-wide/not-applicable sentinel. if (dto.TenantId == -1) throw new ValidationException("Site must belong to a specific tenant."); if (string.IsNullOrWhiteSpace(dto.SiteCode)) throw new ValidationException("SiteCode is required."); if (string.IsNullOrWhiteSpace(dto.SiteName)) throw new ValidationException("SiteName is required."); dto.ModifiedById = loginDTO.UserId; dto.ModifiedOn = DateTime.UtcNow; if (isNew) { dto.CreatedById = loginDTO.UserId; dto.CreatedOn = DateTime.UtcNow; } GB5Trace.Step("save-site", new { dto.SiteId, isNew }); await _entityApp.ExecuteSaveAsync( EntityConstant.OBJECTCMSSITE, eventTypeId, dto, loginDTO, persistFunc: async tx => isNew ? await _dal.SaveSite(dto, loginDTO, tx, ct).ConfigureAwait(false) : await _dal.UpdateSite(dto, loginDTO, tx, ct).ConfigureAwait(false), isNewEntity: isNew); GB5Trace.Step("event-publish", new { EventTypeId = eventTypeId }); await _eventLogPublish.PublishEventLogAsync( isNew ? "CMS Site Created" : "CMS Site Updated", dto, eventTypeId, dto.SiteId, loginDTO, ct: ct); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {dto.SiteId}" : SuccessResponse.UpdateSuccess; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-site-validation-failed", vex); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-site-failed", ex); throw; } } public async Task DeleteSite(int siteId, LoginDTO loginDTO, CancellationToken ct = default) { try { return await _dal.DeleteSite(siteId, loginDTO, ct); } catch { throw; } } // Site/Page platform Phase 14 — "Create site from template" per the plan's §4: deserialize // each PageTemplate's ContainerTreeJson manifest and call the EXISTING SitePageBLL/ // ContentContainerBLL/ContentBlockBLL save methods in a loop. No new composition engine. // // Composition, not one supertransaction: each call below (SaveSite, SavePage per page, // SaveAsync per container/block) already routes through its own entity's existing save // pipeline independently, rather than this method opening one shared DbTransaction spanning // Site+Content+Container+Block — none of those BLLs currently accept an externally-supplied // transaction to join (each hard-wires its own BaseEntityAppService.ExecuteSaveAsync call). // Retrofitting that would be a much larger, cross-cutting change to five different BLLs for // one orchestrator. The trade-off is honest: a mid-materialization failure leaves the Site // and whichever pages already succeeded as real, valid, durable rows (not orphaned) rather // than an all-or-nothing rollback — arguably the safer failure mode for content authoring // (a human building the same site by hand would leave exactly the same partial state), and // every step that DOES complete is still fully audited via its own entity's normal // GB5Trace/EventLogPublish, so nothing here silently bypasses tracing. public async Task CreateFromTemplate(CreateSiteFromTemplateDTO dto, LoginDTO loginDTO, CancellationToken ct = default) { try { GB5Trace.Step("validate-create-from-template", new { dto.SiteTemplateCode, dto.SiteCode }); if (string.IsNullOrWhiteSpace(dto.SiteTemplateCode)) throw new ValidationException("SiteTemplateCode is required."); if (string.IsNullOrWhiteSpace(dto.SiteCode) || string.IsNullOrWhiteSpace(dto.SiteName)) throw new ValidationException("SiteCode and SiteName are required."); // dto.TenantId is never trusted directly — a caller can only ever instantiate a // template into their OWN tenant, so this is derived from the login rather than // validated against a client-submitted value (same hardening as // DataSourceBLL.SaveDataSource). Every container/block save below still // independently re-verifies this same fact via CrossTenantGuard against the // newly-created Content row's true tenant — this is belt-and-suspenders with that, // not a substitute for it. dto.TenantId = loginDTO.ClientId; var siteTemplate = await _siteTemplateBll.GetByCodeTypedAsync(dto.SiteTemplateCode, loginDTO, ct) ?? throw new ValidationException($"SiteTemplate '{dto.SiteTemplateCode}' not found."); var pageTemplates = await _pageTemplateBll.GetBySiteTemplateTypedAsync(siteTemplate.SiteTemplateId, loginDTO, ct); if (pageTemplates.Count == 0) throw new ValidationException($"SiteTemplate '{dto.SiteTemplateCode}' has no page templates to instantiate."); var newSite = new SiteDTO { SiteCode = dto.SiteCode, SiteName = dto.SiteName, DesignSystemId = dto.DesignSystemId ?? siteTemplate.DefaultDesignSystemId, SiteStatus = 0, // Draft — the author reviews/publishes pages before going live Status = 1, TenantId = dto.TenantId, }; await SaveSite(newSite, loginDTO, ct); GB5Trace.Step("template-site-created", new { newSite.SiteId, dto.SiteTemplateCode }); foreach (var pageTemplate in pageTemplates.OrderBy(p => p.SortOrder)) await MaterializePageAsync(newSite.SiteId, dto.TenantId, pageTemplate, loginDTO, ct); return $"{SuccessResponse.SaveSuccessMessage} {newSite.SiteId}"; } catch (ValidationException vex) { GB5Trace.MarkFailed("create-from-template-validation-failed", vex); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("create-from-template-failed", ex); throw; } } private async Task MaterializePageAsync(int siteId, int tenantId, CMSDAL.DTO.PageTemplate.PageTemplateDTO pageTemplate, LoginDTO loginDTO, CancellationToken ct) { // SitePageBLL.SavePage already owns creating both the Content row and the SitePage // wrapper in one call (see its own doc comment) — this does not duplicate that. var sitePageDto = new SitePageDTO { SiteId = siteId, ContentId = 0, UrlPath = pageTemplate.UrlPathPattern, IsHomePage = pageTemplate.IsHomePage, SharingMode = SHARING_MODE_CLONED, Status = 1, TenantId = tenantId, ContentTitle = pageTemplate.PageTemplateName, ContentSlug = SlugifyUrlPath(pageTemplate.UrlPathPattern), }; await _sitePageBll.SavePage(sitePageDto, loginDTO, ct); GB5Trace.Step("template-page-created", new { sitePageDto.SitePageId, sitePageDto.ContentId, pageTemplate.PageTemplateCode }); var containers = JsonConvert.DeserializeObject>(pageTemplate.ContainerTreeJson) ?? new(); foreach (var container in containers) { var containerType = await _contentContainerTypeBll.GetByCodeTypedAsync(container.ContainerTypeCode, loginDTO, ct) ?? throw new InvalidOperationException( $"PageTemplate '{pageTemplate.PageTemplateCode}' references unknown ContainerTypeCode '{container.ContainerTypeCode}'."); var containerDto = new ContentContainerDTO { ContentId = sitePageDto.ContentId, ContainerTypeId = containerType.ContainerTypeId, PositionNo = container.PositionNo, ContainerLabel = container.ContainerLabel, Status = 1, TenantId = tenantId, }; await _contentContainerBll.SaveAsync(containerDto, loginDTO, ct); foreach (var block in container.Blocks) { var blockId = await _contentBlockBll.GetBlockIdByCodeAsync(block.BlockCode ?? string.Empty, loginDTO, ct) ?? throw new InvalidOperationException( $"PageTemplate '{pageTemplate.PageTemplateCode}' references unknown BlockCode '{block.BlockCode}'."); var blockDto = new ContentBlockDTO { ContentContainerId = containerDto.ContentContainerId, BlockId = blockId, ZoneName = block.ZoneName, PositionNo = block.PositionNo, PropsJson = block.PropsJson, Status = 1, TenantId = tenantId, }; await _contentBlockBll.SaveAsync(blockDto, loginDTO, ct); } } } private static string SlugifyUrlPath(string urlPath) { if (string.IsNullOrWhiteSpace(urlPath) || urlPath == "/") return "home"; var trimmed = urlPath.Trim('/').ToLowerInvariant(); return Regex.Replace(trimmed, @"[^a-z0-9]+", "-").Trim('-'); } }