using CMSBLL.Content; using CMSDAL.CustomCode.Content; using CMSDAL.CustomCode.Site; using CMSDAL.CustomCode.SitePage; using CMSDAL.DTO.Content; using CMSDAL.DTO.Seo; using CMSDAL.DTO.SitePage; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.EventLogPublish; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System.ComponentModel.DataAnnotations; using static GB5Shared.GB5Constant.Constant; namespace CMSBLL.SitePage; public class SitePageBLL : ISitePageBLL { // 0=Cloned (independent copy), 1=Linked (shared Content, edits propagate everywhere) private const byte SHARINGMODE_LINKED = 1; private readonly ISitePageDAL _dal; private readonly IContentDAL _contentDal; private readonly ISiteDAL _siteDal; private readonly IContentBLL _contentBll; private readonly ICrossTenantGuard _guard; private readonly BaseEntityAppService _entityApp; private readonly EventLogPublish _eventLogPublish; public SitePageBLL( ISitePageDAL dal, IContentDAL contentDal, ISiteDAL siteDal, IContentBLL contentBll, ICrossTenantGuard guard, BaseEntityAppService entityApp, EventLogPublish eventLogPublish) { _dal = dal; _contentDal = contentDal; _siteDal = siteDal; _contentBll = contentBll; _guard = guard; _entityApp = entityApp; _eventLogPublish = eventLogPublish; } public async Task GetSitePage(int sitePageId, LoginDTO loginDTO, CancellationToken ct = default) { try { return await _dal.GetSitePage(sitePageId, loginDTO, ct); } catch { throw; } } public async Task GetRouteAsync(int siteId, string urlPath, LoginDTO loginDTO, CancellationToken ct = default) { try { return await _dal.GetRouteByUrlPathAsync(siteId, urlPath, loginDTO, ct); } catch { throw; } } public async Task SavePage(SitePageDTO dto, LoginDTO loginDTO, CancellationToken ct = default) { var isNewPage = dto.SitePageId == 0; var isNewContent = dto.ContentId == 0; var eventTypeId = isNewPage ? EventTypeConstant.SAVECMSSITEPAGEEVENTTYPEID : EventTypeConstant.UPDATECMSSITEPAGEEVENTTYPEID; try { GB5Trace.Step("validate-sitepage", new { dto.SitePageId, dto.SiteId, isNewPage }); // NOT `dto.TenantId <= 0` — real GB5 tenant IDs are frequently large negative // numbers; only the literal -1 is the system-wide/not-applicable sentinel. See // SiteBLL.SaveSite's matching check for how this was caught. if (dto.TenantId == -1) throw new ValidationException("SitePage must belong to a specific tenant."); if (string.IsNullOrWhiteSpace(dto.UrlPath)) throw new ValidationException("UrlPath is required."); // Only a brand-new page attaches to a caller-supplied SiteId — without this check, // any tenant could attach a page to another tenant's Site by guessing its SiteId. if (isNewPage) { var ownerTenantId = await _siteDal.GetSiteTenantIdAsync(dto.SiteId, loginDTO, ct); _guard.EnsureOwnedByTenant(ownerTenantId, loginDTO.ClientId, "Site", dto.SiteId); } dto.ModifiedById = loginDTO.UserId; dto.ModifiedOn = DateTime.UtcNow; if (isNewPage) { dto.CreatedById = loginDTO.UserId; dto.CreatedOn = DateTime.UtcNow; } GB5Trace.Step("save-sitepage", new { dto.SitePageId, isNewPage, isNewContent }); await _entityApp.ExecuteSaveAsync( EntityConstant.OBJECTCMSSITEPAGE, eventTypeId, dto, loginDTO, persistFunc: async tx => { if (isNewContent) { // New page: create its Content row in the 'SITE_PAGES' system Space/ // ContentType, in the same transaction as the SitePage wrapper below. var (contentTypeId, spaceId) = await _dal.GetSitePageContentTypeAsync(loginDTO, ct).ConfigureAwait(false); var now = DateTime.UtcNow; var newContent = new ContentDTO { ContentTypeId = contentTypeId, ContentCMSSpaceId = spaceId, ContentTitle = dto.ContentTitle, ContentSlug = dto.ContentSlug, ContentStatus = 0, // Draft ContentDataJson = dto.ContentDataJson ?? "{}", ContentMetaDataJson = dto.ContentMetaDataJson ?? "{}", ContentPublishedVersion = 0, ContentCurrentVersion = 1, // TCONTENT.PUBLISHEDBYID/EXPIRESAT are NOT NULL even for a fresh // Draft (pre-existing DB constraint, see the foundation migration's // comment) — ContentDTO's nullable properties don't reflect that, // so both are given concrete placeholder values here rather than // left null. ContentPublishDate = now, ContentPublishedById = loginDTO.UserId, ContentExpiresAt = now.AddYears(100), ContentVersion = 1, ContentCreatedById = loginDTO.UserId, ContentCreatedOn = now, ContentModifiedById = loginDTO.UserId, ContentModifiedOn = now, ContentTenantId = dto.TenantId, ContentRecordStatus = 1 }; dto.ContentId = await _contentDal.SaveContent(newContent, loginDTO, tx, ct).ConfigureAwait(false); } else if (dto.SharingMode != SHARINGMODE_LINKED) { // Existing, independently-owned (Cloned) page — merge the authorable // fields into the full current Content row before updating, so columns // this call doesn't touch (status, versions, publish info) aren't // clobbered back to UPDATE_CONTENT's bound defaults. var existingJson = await _contentDal.GetContent(dto.ContentId, loginDTO).ConfigureAwait(false); var existingContent = JsonConvert.DeserializeObject(existingJson) ?? throw new InvalidOperationException($"Content {dto.ContentId} not found."); existingContent.ContentTitle = dto.ContentTitle; existingContent.ContentSlug = dto.ContentSlug; existingContent.ContentDataJson = dto.ContentDataJson ?? existingContent.ContentDataJson; existingContent.ContentMetaDataJson = dto.ContentMetaDataJson ?? existingContent.ContentMetaDataJson; existingContent.ContentModifiedById = loginDTO.UserId; existingContent.ContentModifiedOn = DateTime.UtcNow; await _contentDal.UpdateContent(existingContent, loginDTO, tx, ct).ConfigureAwait(false); } // SharingMode == Linked on an existing page: never touch Content here — // it's shared, so editing it is done through the normal Content/ // ContentContainer/ContentBlock endpoints, which apply to every SitePage // referencing that ContentId, not just this one. return isNewPage ? await _dal.SaveSitePage(dto, loginDTO, tx, ct).ConfigureAwait(false) : await _dal.UpdateSitePage(dto, loginDTO, tx, ct).ConfigureAwait(false); }, isNewEntity: isNewPage); GB5Trace.Step("event-publish", new { EventTypeId = eventTypeId }); await _eventLogPublish.PublishEventLogAsync( isNewPage ? "CMS Site Page Created" : "CMS Site Page Updated", dto, eventTypeId, dto.SitePageId, loginDTO, ct: ct); return isNewPage ? $"{SuccessResponse.SaveSuccessMessage} {dto.SitePageId}" : SuccessResponse.UpdateSuccess; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-sitepage-validation-failed", vex); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-sitepage-failed", ex); throw; } } public async Task DeletePage(int sitePageId, LoginDTO loginDTO, CancellationToken ct = default) { try { return await _dal.DeleteSitePage(sitePageId, loginDTO, ct); } catch { throw; } } public async Task PublishPage(int sitePageId, LoginDTO loginDTO, CancellationToken ct = default) => await _contentBll.PublishContentAsync(await ResolveContentIdAsync(sitePageId, loginDTO, ct), loginDTO, ct); // "Unpublish" maps onto Content's existing Published -> Archived transition — there is no // separate unpublish state in the Content workflow (Draft/InReview/Approved/Published/ // Archived), and inventing one here would fork that state machine for pages only. public async Task UnpublishPage(int sitePageId, LoginDTO loginDTO, CancellationToken ct = default) => await _contentBll.ArchiveContentAsync(await ResolveContentIdAsync(sitePageId, loginDTO, ct), loginDTO, ct); private async Task ResolveContentIdAsync(int sitePageId, LoginDTO loginDTO, CancellationToken ct) { var json = await _dal.GetSitePage(sitePageId, loginDTO, ct); var page = JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException($"SitePage {sitePageId} not found."); return page.ContentId; } private const string SEO_METADATA_KEY = "seo"; public async Task GetPageSeoMetadata(int contentId, LoginDTO loginDTO, CancellationToken ct = default) { try { var json = await _contentDal.GetContent(contentId, loginDTO); var content = JsonConvert.DeserializeObject(json); if (string.IsNullOrWhiteSpace(content?.ContentMetaDataJson)) return null; var metaObj = JObject.Parse(content.ContentMetaDataJson); return metaObj[SEO_METADATA_KEY]?.ToObject(); } catch { throw; } } // A thin JSON-merge over ContentMetaDataJson, but NOT a bypass of the entity-save pipeline // — it fetches the full current Content, merges only the "seo" key into its MetaDataJson, // and routes the write through the already-fixed ContentBLL.SaveContent (ExecuteSaveAsync + // GB5Trace + EventLogPublish), exactly the pattern the "Cloned" update branch in SavePage // above uses, so no other Content field is ever clobbered and no save bypasses tracing. public async Task SavePageSeoMetadata(int contentId, PageSeoMetadataDTO seo, LoginDTO loginDTO, CancellationToken ct = default) { try { var json = await _contentDal.GetContent(contentId, loginDTO); var content = JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException($"Content {contentId} not found."); var metaObj = string.IsNullOrWhiteSpace(content.ContentMetaDataJson) ? new JObject() : JObject.Parse(content.ContentMetaDataJson); metaObj[SEO_METADATA_KEY] = JObject.FromObject(seo); content.ContentMetaDataJson = metaObj.ToString(Formatting.None); return await _contentBll.SaveContent(content, loginDTO, ct); } catch { throw; } } }