using Butter.Dtos; using Butter.Dtos.Tag; using Lactose.Mapper; using Lactose.Models; using Lactose.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; namespace Lactose.Controllers; //TODO: logging /// /// Manages tags — hierarchical categorization of assets. /// /// Tag repository. [Authorize] [ApiController] [Route("api/[controller]")] public class TagController( //TODO: add logging //ILogger logger, ITagRepository tagRepository ) : ControllerBase { /// /// Gets a tag by its ID, optionally including its children and ancestors. /// /// The tag ID. /// Whether to include child tags. /// Whether to include ancestor tags. /// The tag with its hierarchy, or 404 if not found. [HttpGet("{id}")] public ActionResult Get(Guid id, bool children = false, bool ancestors = false) { Tag? tag = tagRepository.Find(id); if (tag == null) { return NotFound(); } var tagDto = TagsMapper.ToTagDto(tag); tagDto.Parent = tag.Parent == null ? null : TagsMapper.ToTagDto(tag.Parent); if (children) { tagDto.Children = []; tagRepository.GetChildren(tag.Id).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t))); } //TODO: Protect from overflow if (ancestors) { Tag? index = tag.Parent; TagDto? indexDto = tagDto; List ancestorsTags = []; // This is redundant, because circular references shouldn't be present while (index != null && !ancestorsTags.Contains(tag)) { ancestorsTags.Add(index); indexDto.Parent = TagsMapper.ToTagDto(index); index = index.Parent; indexDto = indexDto.Parent; } } return tagDto; } /// /// Searches tags with pagination and optional search term. /// /// Pagination and search parameters. /// A list of tags matching the search criteria. [HttpGet] public ActionResult> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) { pagedSearch.Search ??= string.Empty; if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); } if(pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest($"PageSize must be between 1 and {PagedParametersDto.MaxPageSize}"); } List tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize); List tagsDto = []; tags.ForEach( t => { var tagDto = TagsMapper.ToTagDto(t); tagDto.Parent = t.Parent == null ? null : TagsMapper.ToTagDto(t.Parent); tagsDto.Add(tagDto); } ); return tagsDto; } /// /// Updates an existing tag. /// /// The tag ID. /// The updated tag data. /// 200 on success, 404 if not found, 409 if name conflict. [Authorize(Roles = "Maintainer,Curator,Admin")] [HttpPost("{id}")] public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto) { IStatusCodeActionResult result = UpdateTag(id, tagDto.Name, tagDto.Parent); if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); } return result; } /// /// Updates multiple tags in a bulk operation. /// /// The bulk update data with IDs and shared update values. /// 200 on success, or the first error response. [Authorize(Roles = "Maintainer,Curator,Admin")] [HttpPost] public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto tagBulkDto) { foreach (Guid id in tagBulkDto.Ids) { IStatusCodeActionResult result = UpdateTag(id, tagBulkDto.Data.Name, tagBulkDto.Data.Parent); if (result.StatusCode != StatusCodes.Status200OK) { return result; } } tagRepository.Save(); return Ok(); } /// /// Deletes a tag by its ID (soft delete). /// /// The tag ID. /// 200 on success, 404 if not found. [Authorize(Roles = "Maintainer,Curator,Admin")] [HttpDelete("{id}")] public ActionResult Delete([FromRoute] Guid id) { IStatusCodeActionResult result = DeleteTag(id); if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); } return (ActionResult)result; } /// /// Deletes multiple tags in a bulk operation. /// /// The list of tag IDs to delete. /// 200 on success, or the first error response. [Authorize(Roles = "Maintainer,Curator,Admin")] [HttpDelete] public IActionResult BulkDelete([FromBody] List ids) { foreach (Guid id in ids) { IStatusCodeActionResult result = DeleteTag(id); if (result.StatusCode != StatusCodes.Status200OK) { return result; } } tagRepository.Save(); return Ok(); } /// /// Creates a new tag. /// /// The tag creation data. /// 200 on success, 409 if a tag with the same name already exists, 400 if parent not found. [Authorize(Roles = "Maintainer,Curator,Admin")] [HttpPut] public ActionResult Create([FromBody] TagCreateDto tagDto) { if (tagRepository.FindByName(tagDto.Name) != null) { return Conflict($"Tag with name {tagDto.Name} already exist"); } var tag = new Tag() { Name = tagDto.Name }; if (tagDto.Parent != null && tagRepository.Find(tagDto.Parent.Value) == null) { return BadRequest($"No Tag with id {tagDto.Parent} found"); } tag.ParentId = tagDto.Parent; tagRepository.Insert(tag); tagRepository.Save(); return Ok(); } IStatusCodeActionResult DeleteTag(Guid id) { Tag? tag = tagRepository.Find(id); if (tag == null) { return NotFound($"Tag {id} not found"); } tagRepository.Delete(tag); return Ok(); } IStatusCodeActionResult UpdateTag(Guid id, string? name, Guid? parentId) { Tag? tag = tagRepository.Find(id); if (tag == null) { return NotFound($"Tag {id} not found"); } if (name != null) { Tag? foundTag = tagRepository.FindByName(name); if (foundTag != null && foundTag.Id != tag.Id) { return Conflict($"Tag with name '{name}' already exists"); } tag.Name = name; } if (parentId == null) { return Ok(); } if (parentId.Value.Equals(Guid.Empty)) { tag.Parent = null; } else { Tag? parent = tagRepository.Find(parentId.Value); if (parent == null) { return NotFound($"Parent tag on {id} was not found"); } tag.Parent = parent; } return Ok(); } }