Skip to content

Commit

Permalink
Merge pull request #148 from fluentcms/138-revise-pageservice-based-o…
Browse files Browse the repository at this point in the history
…n-latest-changes-for-siteservice-and-hostservice

138 revise pageservice based on latest changes for siteservice and hostservice
  • Loading branch information
pournasserian authored Nov 16, 2023
2 parents f0d41b7 + b21da0d commit b26bb41
Show file tree
Hide file tree
Showing 15 changed files with 253 additions and 109 deletions.
14 changes: 3 additions & 11 deletions src/FluentCMS.Api/Controllers/PagesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,18 @@ public PagesController(IPageService pageService, IMapper mapper)
[HttpPost]
public async Task<IApiPagingResult<PageResponse>> GetAll([FromBody] PageSearchRequest request)
{
var pages = await _pageService.GetBySiteIdAndParentId(request.SiteId, request.ParentId);
return new ApiPagingResult<PageResponse>(_mapper.Map<List<PageResponse>>(pages));
var pages = (await _pageService.GetBySiteId(request.SiteId));
return new ApiPagingResult<PageResponse>(_mapper.Map<List<PageResponse>>(pages.ToList()));
}

[HttpGet("{id}")]
public async Task<IApiResult<PageResponse>> GetById([FromRoute] Guid id)
{
var page = await _pageService.GetById(id);
var pageResponse = _mapper.Map<PageResponse>(page);
// map recursive?
await MapChildren(id, pageResponse);
return new ApiResult<PageResponse>(pageResponse);
}

private async Task MapChildren(Guid id, PageResponse pageResponse)
{
var childrenPage = await _pageService.GetByParentId(id);
pageResponse.Children = childrenPage.Select(x => _mapper.Map<PageResponse>(x));
}

[HttpPost]
public async Task<IApiResult<PageResponse>> Create(PageCreateRequest request)
Expand All @@ -62,8 +55,7 @@ public async Task<IApiResult<PageResponse>> Update(PageUpdateRequest request)
[HttpDelete("{id}")]
public async Task<IApiResult<bool>> Delete([FromRoute] Guid id)
{
var page = await _pageService.GetById(id);
await _pageService.Delete(page);
await _pageService.Delete(id);
return new ApiResult<bool>(true);
}
}
14 changes: 14 additions & 0 deletions src/FluentCMS.Api/MappingProfiles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ public MappingProfiles()
// Page
CreateMap<Page, PageResponse>();

CreateMap<List<Page>, List<PageResponse>>().ConstructUsing((x, ctx) =>
{
return MapPagesWithParentId(x, ctx, null,"");
static List<PageResponse> MapPagesWithParentId(List<Page> x, ResolutionContext ctx, Guid? parentId, string pathPrefix)
{
var items = ctx.Mapper.Map<List<PageResponse>>(x.Where(x => x.ParentId == parentId));
foreach (var item in items)
{
item.Path = string.Join("/", pathPrefix , item.Path);
item.Children = MapPagesWithParentId(x, ctx, item.Id, item.Path);
}
return items.ToList();
}
});
// User
//CreateMap<User, UserResponse>()
// .ForMember(x => x.UserRoles, cfg => cfg.MapFrom(y => y.UserRoles.Select(z => z.RoleId.ToString())));
Expand Down
1 change: 0 additions & 1 deletion src/FluentCMS.Api/Models/Pages/PageSearchRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@

public class PageSearchRequest
{
public Guid? ParentId { get; set; }
public Guid SiteId { get; set; }
}
3 changes: 3 additions & 0 deletions src/FluentCMS.Entities/Page.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ public class Page : AuditEntity
public Guid? ParentId { get; set; }
public int Order { get; set; }
public required string Path { get; set; }
public IEnumerable<Guid> AdminRoleIds { get; set; } = new List<Guid>();
public IEnumerable<Guid> ViewRoleIds { get; set; } = new List<Guid>();

}
2 changes: 0 additions & 2 deletions src/FluentCMS.Repositories/Abstractions/IPageRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,4 @@ public interface IPageRepository : IGenericRepository<Page>
{
Task<IEnumerable<Page>> GetBySiteId(Guid siteId, CancellationToken cancellationToken = default);
Task<Page> GetByPath(string path);
Task<IEnumerable<Page>> GetByParentId(Guid id);
Task<IEnumerable<Page>> GetBySiteIdAndParentId(Guid siteId, Guid? parentId = null);
}
10 changes: 0 additions & 10 deletions src/FluentCMS.Repositories/LiteDb/LiteDbPageRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,4 @@ public async Task<Page> GetByPath(string path)
{
return await Collection.FindOneAsync(x => x.Path == path);
}

public async Task<IEnumerable<Page>> GetByParentId(Guid parentId)
{
return await Collection.FindAsync(x => x.ParentId == parentId);
}

public async Task<IEnumerable<Page>> GetBySiteIdAndParentId(Guid siteId, Guid? parentId = null)
{
return await Collection.FindAsync(x => x.ParentId == parentId && x.SiteId == siteId);
}
}
6 changes: 0 additions & 6 deletions src/FluentCMS.Repositories/MongoDb/MongoDbPageRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,6 @@ public MongoDbPageRepository(IMongoDBContext mongoDbContext) : base(mongoDbConte
{
}

public Task<IEnumerable<Page>> GetByParentId(Guid id)
{
// TODO: implement here
return Task.FromResult<IEnumerable<Page>>([]);
}

public Task<Page> GetByPath(string path)
{
// TODO: implement here
Expand Down
199 changes: 148 additions & 51 deletions src/FluentCMS.Services/PageService.cs
Original file line number Diff line number Diff line change
@@ -1,107 +1,204 @@
using FluentCMS.Entities;
using FluentCMS.Repositories.Abstractions;
using System.Collections.Immutable;

namespace FluentCMS.Services;

public interface IPageService
{
Task<IEnumerable<Page>> GetBySiteId(Guid siteId, CancellationToken cancellationToken = default);
Task<Page> GetById(Guid id, CancellationToken cancellationToken = default);
Task<IEnumerable<Page>> GetByParentId(Guid id);
Task<Page> Create(Page page, CancellationToken cancellationToken = default);
Task<Page> Update(Page page, CancellationToken cancellationToken = default);
Task Delete(Page page, CancellationToken cancellationToken = default);
Task<IEnumerable<Page>> GetBySiteIdAndParentId(Guid siteId, Guid? parentId = null);
Task Delete(Guid id, CancellationToken cancellationToken = default);
}

public class PageService : IPageService
public class PageService : BaseService<Page>, IPageService
{
private readonly IPageRepository _pageRepository;
private readonly IApplicationContext _applicationContext;
private readonly ISiteRepository _siteRepository;

public PageService(IPageRepository pageRepository, IApplicationContext applicationContext)
public PageService(IApplicationContext applicationContext, IPageRepository pageRepository, ISiteRepository siteRepository) : base(applicationContext)
{
_pageRepository = pageRepository;
_applicationContext = applicationContext;
_siteRepository = siteRepository;
}

public async Task<IEnumerable<Page>> GetBySiteId(Guid siteId, CancellationToken cancellationToken = default)
public async Task<Page> Create(Page page, CancellationToken cancellationToken = default)
{
return await _pageRepository.GetBySiteId(siteId, cancellationToken);
}
// Check if site id exists
var site = (await _siteRepository.GetById(page.SiteId, cancellationToken)) ??
throw new AppException(ExceptionCodes.SiteNotFound);

public async Task<Page> GetById(Guid id, CancellationToken cancellationToken = default)
{
var page = await _pageRepository.GetById(id, cancellationToken) ?? throw new Exception(id.ToString());
// check if user is siteAdmin or superAdmin
if (!Current.IsInRole(site.AdminRoleIds))
throw new AppPermissionException();

return page;
// Fetch pages beforehand to avoid multiple db calls
var pages = (await _pageRepository.GetAll(cancellationToken)).ToList();

//If Parent Id is assigned
if (page.ParentId != null)
ValidateParentPage(page, pages);

// fetch list of all pages
ValidateUrl(page, pages);

// prepare entity for db
PrepareForCreate(page);

return await _pageRepository.Create(page, cancellationToken) ??
throw new AppException(ExceptionCodes.PageUnableToCreate);
}

public async Task<Page> Create(Page page, CancellationToken cancellationToken = default)
private static void ValidateUrl(Page page, List<Page> pages)
{
// TODO: check permissions, only admins can create a page
// Except for the first site, which is created by the system
//urls can be cached in a dictionary to avoid multiple list traversal
var cachedUrls = new Dictionary<Guid, string>();

// normalizing the page path to lowercase
NormalizePath(page);
await CheckForDuplicatePath(page);
// Build Url based on parent
var fullUrl = BuildFullPath(page, pages, cachedUrls);

page.CreatedBy = _applicationContext.Current?.User?.UserName ?? string.Empty;
page.LastUpdatedBy = _applicationContext.Current?.User?.UserName ?? string.Empty;
// Check if url is unique
if (pages.Any(x => BuildFullPath(x, pages, cachedUrls).Equals(fullUrl)))
throw new AppException(ExceptionCodes.PageUrlNotUnique);

var newPage = await _pageRepository.Create(page, cancellationToken);
return newPage ?? throw new Exception("Page not created");
}

private async Task CheckForDuplicatePath(Page page)
private static string BuildFullPath(Page page, IEnumerable<Page> pages, Dictionary<Guid, string> cachedUrls)
{
// check if the page path is unique
var samePathPage = await _pageRepository.GetByPath(page.Path);
if (samePathPage != null && samePathPage?.Id != page.Id)
//Traverse the pages to root (ParentId == null) and keep them in an array
var parents = new List<string> { page.Path };
var parentId = page.ParentId;

while (parentId != null)
{
throw new ApplicationException("Page path must be unique");
if (cachedUrls.ContainsKey(parentId.Value))
{
parents.Add(cachedUrls[parentId.Value]);
continue;
}
var parent = pages.Single(x => x.Id == parentId);
parents.Add(parent.Path);
cachedUrls[parent.Id] = parent.Path;
parentId = parent.ParentId;
}
//Build Path string from List of Parents
return string.Join("/", parents.Reverse<string>());
}

public async Task<Page> Update(Page page, CancellationToken cancellationToken = default)
private void ValidateParentPage(Page page, List<Page> pages)
{
// TODO: check permissions, only admins can create a page
// Except for the first site, which is created by the system
var parent = pages.SingleOrDefault(x => x.Id == page.ParentId);

// normalizing the page path to lowercase
NormalizePath(page);
// If parent id is not a valid page id
if (parent is null)
throw new AppException(ExceptionCodes.PageParentPageNotFound);

// check if the page path is unique
await CheckForDuplicatePath(page);
// If parent id is not on the same site
if (parent.SiteId != page.SiteId)
throw new AppException(ExceptionCodes.PageParentMustBeOnTheSameSite);

var newPage = await _pageRepository.Update(page, cancellationToken);
return newPage ?? throw new Exception("Page not created");
// if page viewRoles are a subset of parent view roles
if (!page.ViewRoleIds.ToImmutableHashSet().IsSubsetOf(parent.ViewRoleIds))
throw new AppException(ExceptionCodes.PageViewPermissionsAreNotASubsetOfParent);
}

private static void NormalizePath(Page page)
public async Task Delete(Guid id, CancellationToken cancellationToken = default)
{
page.Path = page.Path.ToLower();
//fetch original page from db
var originalPage = await _pageRepository.GetById(id, cancellationToken) ??
throw new AppException(ExceptionCodes.PageNotFound);

// fetch site
var site = (await _siteRepository.GetById(originalPage.SiteId, cancellationToken)) ??
throw new AppException(ExceptionCodes.SiteNotFound);

// check if user is siteAdmin, superAdmin or PageAdmin
if (!HasAdminPermission(site, originalPage))
throw new AppPermissionException();

// check that it does not have any children
var pages = (await _pageRepository.GetAll(cancellationToken)).ToList();
if (pages.Any(x => x.ParentId == id && x.SiteId == originalPage.SiteId))
throw new AppException(ExceptionCodes.PageHasChildren);

await _pageRepository.Delete(id, cancellationToken);
}

public Task Delete(Page page, CancellationToken cancellationToken = default)
public async Task<Page> GetById(Guid id, CancellationToken cancellationToken = default)
{
return _pageRepository.Delete(page.Id);
//fetch page from db
var page = await _pageRepository.GetById(id, cancellationToken) ??
throw new AppException(ExceptionCodes.PageNotFound);

// fetch site
var site = (await _siteRepository.GetById(page.SiteId, cancellationToken)) ??
throw new AppException(ExceptionCodes.SiteNotFound);

// check current user has permission to view page or is site admin or page admin
return HasViewPermission(site, page) ? page : throw new AppPermissionException();
}

public Task<IEnumerable<Page>> GetByParentId(Guid id)
public async Task<IEnumerable<Page>> GetBySiteId(Guid siteId, CancellationToken cancellationToken = default)
{
return _pageRepository.GetByParentId(id);
//fetch site from db
var site = await _siteRepository.GetById(siteId, cancellationToken) ??
throw new AppException(ExceptionCodes.SiteNotFound);

// fetch pages from db
var pages = await _pageRepository.GetBySiteId(siteId, cancellationToken);

// if current user is page viewer or page admin or site admin
return pages.Where(page => HasViewPermission(site, page));
}

public async Task<IEnumerable<Page>> GetBySiteIdAndParentId(Guid siteId, Guid? parentId)
public async Task<Page> Update(Page page, CancellationToken cancellationToken = default)
{
var sitePages = await _pageRepository.GetBySiteIdAndParentId(siteId);
if (parentId is null)
//fetch original page from db
var originalPage = await _pageRepository.GetById(page.Id, cancellationToken) ??
throw new AppException(ExceptionCodes.PageNotFound);

// Check if site id exists
var site = (await _siteRepository.GetById(page.SiteId, cancellationToken)) ??
throw new AppException(ExceptionCodes.SiteNotFound);

// check if user is siteAdmin or superAdmin or pageAdmin
if (!HasAdminPermission(site, originalPage))
throw new AppPermissionException();

// site id cannot be changed
if (originalPage.SiteId != page.SiteId)
throw new AppException(ExceptionCodes.PageSiteIdCannotBeChanged);

// fetch list of all pages
var pages = (await _pageRepository.GetAll(cancellationToken)).ToList();

//If Parent Id is changed validate again
if (page.ParentId != originalPage.ParentId)
{
return sitePages;
//validate parent
ValidateParentPage(page, pages);

//TODO: we should validate children permissions too!
}
// todo: handle edge-case that multiple sites contain the same parent page
var childPages = await _pageRepository.GetBySiteIdAndParentId(parentId.Value);
return childPages;

ValidateUrl(page, pages);

// prepare entity for db
PrepareForUpdate(page);

return await _pageRepository.Update(page, cancellationToken)
?? throw new AppException(ExceptionCodes.PageUnableToUpdate);
}

private bool HasAdminPermission(Site site, Page page)
{
return Current.IsInRole(site.AdminRoleIds) || Current.IsInRole(page.AdminRoleIds);
}

private bool HasViewPermission(Site site, Page page)
{
return HasAdminPermission(site, page) || Current.IsInRole(page.ViewRoleIds);
}
}
23 changes: 23 additions & 0 deletions src/FluentCMS.Shared/Exceptions/ExceptionCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,27 @@ public class ExceptionCodes

#endregion

#region Page

public const string PageUnableToCreate = "Page.UnableToCreated";
public const string PageUnableToUpdate = "Page.UnableToUpdate";
public const string PageUnableToDelete = "Page.UnableToDelete";
public const string PagePathMustBeUnique = "Page.PathMustBeUnique";
public const string PageNotFound = "Page.NotFound";
public const string PageParentPageNotFound = "Page.ParentPageNotFound";
public const string PageParentMustBeOnTheSameSite = "Page.ParentMustBeOnTheSameSite";
public const string PageUrlNotUnique = "Page.UrlNotUnique";
public const string PageViewPermissionsAreNotASubsetOfParent = "Page.ViewPermissionsAreNotASubsetOfParent";
public const string PageSiteIdCannotBeChanged = "Page.SiteIdCannotBeChanged";
public const string PageHasChildren = "Page.PageHasChildren";


#endregion

#region Site

public const string SiteNotFound = "Site.NotFound";


#endregion
}
Loading

0 comments on commit b26bb41

Please sign in to comment.