Files
MilkyShots/Lactose/Controllers/TagController.cs
T
REDCODE 1b444dab93 feat(auth): complete Maintainer role — add PersonMaintainer checks, update controller authz, pass uploadedBy search param
- AlbumSearchParametersDto: add UploadedBy field
- IPersonRepository/PersonRepository: add IsMaintainerOf method
- AlbumController: allow Maintainer to update with scope check, pass uploadedBy to SearchQuery
- PersonController: allow Maintainer to update maintained persons
- AssetController: allow Maintainer to update/delete own assets
- TagController: allow Maintainer to CRUD all tags
- Fix Authorize(Roles) to include Maintainer where appropriate
- Update WepApiTest.http: curator accessLevel 1→2
2026-07-14 18:13:15 +02:00

217 lines
7.4 KiB
C#

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
/// <summary>
/// Manages tags — hierarchical categorization of assets.
/// </summary>
/// <param name="tagRepository">Tag repository.</param>
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class TagController(
//TODO: add logging
//ILogger<TagController> logger,
ITagRepository tagRepository
) : ControllerBase {
/// <summary>
/// Gets a tag by its ID, optionally including its children and ancestors.
/// </summary>
/// <param name="id">The tag ID.</param>
/// <param name="children">Whether to include child tags.</param>
/// <param name="ancestors">Whether to include ancestor tags.</param>
/// <returns>The tag with its hierarchy, or 404 if not found.</returns>
[HttpGet("{id}")]
public ActionResult<TagDto> 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<Tag> 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;
}
/// <summary>
/// Searches tags with pagination and optional search term.
/// </summary>
/// <param name="pagedSearch">Pagination and search parameters.</param>
/// <returns>A list of tags matching the search criteria.</returns>
[HttpGet]
public ActionResult<List<TagDto>> 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<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
List<TagDto> tagsDto = [];
tags.ForEach(
t => {
var tagDto = TagsMapper.ToTagDto(t);
tagDto.Parent = t.Parent == null ? null : TagsMapper.ToTagDto(t.Parent);
tagsDto.Add(tagDto);
}
);
return tagsDto;
}
/// <summary>
/// Updates an existing tag.
/// </summary>
/// <param name="id">The tag ID.</param>
/// <param name="tagDto">The updated tag data.</param>
/// <returns>200 on success, 404 if not found, 409 if name conflict.</returns>
[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;
}
/// <summary>
/// Updates multiple tags in a bulk operation.
/// </summary>
/// <param name="tagBulkDto">The bulk update data with IDs and shared update values.</param>
/// <returns>200 on success, or the first error response.</returns>
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpPost]
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<TagUpdateDto> 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();
}
/// <summary>
/// Deletes a tag by its ID (soft delete).
/// </summary>
/// <param name="id">The tag ID.</param>
/// <returns>200 on success, 404 if not found.</returns>
[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;
}
/// <summary>
/// Deletes multiple tags in a bulk operation.
/// </summary>
/// <param name="ids">The list of tag IDs to delete.</param>
/// <returns>200 on success, or the first error response.</returns>
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpDelete]
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
foreach (Guid id in ids) {
IStatusCodeActionResult result = DeleteTag(id);
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
}
tagRepository.Save();
return Ok();
}
/// <summary>
/// Creates a new tag.
/// </summary>
/// <param name="tagDto">The tag creation data.</param>
/// <returns>200 on success, 409 if a tag with the same name already exists, 400 if parent not found.</returns>
[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();
}
}