Merge branch 'develop' into feature/rate-limit

This commit is contained in:
2026-07-16 16:56:33 +00:00
9 changed files with 632 additions and 115 deletions
+18
View File
@@ -0,0 +1,18 @@
namespace Butter.Dtos.Album;
/// <summary>
/// Represents a request to merge multiple albums into a single destination album.
/// </summary>
public class AlbumMergeDto {
/// <summary>
/// Gets or sets the ID of the destination album that will receive all assets.
/// Must not be present in <see cref="SourceIds"/>.
/// </summary>
public required Guid DestinationId { get; set; }
/// <summary>
/// Gets or sets the list of source album IDs whose assets will be moved to the destination.
/// Source albums will be hard-deleted after the merge.
/// </summary>
public required List<Guid> SourceIds { get; set; } = [];
}
+18
View File
@@ -0,0 +1,18 @@
namespace Butter.Dtos.Person;
/// <summary>
/// Represents a request to merge multiple people into a single destination person.
/// </summary>
public class PersonMergeDto {
/// <summary>
/// Gets or sets the ID of the destination person that will receive all associated data.
/// Must not be present in <see cref="SourceIds"/>.
/// </summary>
public required Guid DestinationId { get; set; }
/// <summary>
/// Gets or sets the list of source person IDs whose albums, faces, and maintainers
/// will be reassigned to the destination. Source people will be hard-deleted.
/// </summary>
public required List<Guid> SourceIds { get; set; } = [];
}
+20
View File
@@ -186,6 +186,26 @@ public class AlbumController(
return Ok(album.Id);
}
/// <summary>
/// Merges multiple source albums into a destination album.
/// All assets from source albums are moved to the destination, then sources are hard-deleted.
/// </summary>
/// <param name="dto">The merge request with destination and source IDs.</param>
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
[HttpPost("merge")]
[Authorize(Roles = "Curator,Admin")]
public ActionResult Merge([FromBody] AlbumMergeDto dto) {
if (dto.SourceIds.Contains(dto.DestinationId))
return BadRequest("Destination album ID must not appear in source album IDs.");
if (albumRepository.Find(dto.DestinationId) == null)
return NotFound("Destination album not found.");
albumRepository.MergeAlbums(dto.DestinationId, dto.SourceIds);
albumRepository.Save();
return Ok();
}
/// <summary>
/// Removes multiple albums (soft delete).
/// </summary>
+25
View File
@@ -112,6 +112,7 @@ public class PersonController(
[HttpPost]
[Authorize(Roles = "Admin, Curator")]
public IActionResult BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var people = personRepository.FindBulk(personBulkDto.Ids).ToList();
foreach (var person in people) {
@@ -120,7 +121,11 @@ public class PersonController(
person.ProfileCropX = personBulkDto.Data.ProfileCropX;
person.ProfileCropY = personBulkDto.Data.ProfileCropY;
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom;
person.Visibility = personBulkDto.Data.Visibility ?? person.Visibility;
person.UpdatedAt = DateTime.UtcNow;
if (personBulkDto.Data.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
personRepository.SetMaintainers(person.Id, personBulkDto.Data.MaintainerUserIds);
}
personRepository.Save();
@@ -180,6 +185,26 @@ public class PersonController(
return Ok();
}
/// <summary>
/// Merges multiple source people into a destination person.
/// Albums, faces, and maintainers are reassigned to the destination, then source people are hard-deleted.
/// </summary>
/// <param name="dto">The merge request with destination and source IDs.</param>
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
[HttpPost("merge")]
[Authorize(Roles = "Admin, Curator")]
public IActionResult Merge([FromBody] PersonMergeDto dto) {
if (dto.SourceIds.Contains(dto.DestinationId))
return BadRequest("Destination person ID must not appear in source person IDs.");
if (personRepository.Find(dto.DestinationId) == null)
return NotFound("Destination person not found.");
personRepository.MergePeople(dto.DestinationId, dto.SourceIds);
personRepository.Save();
return Ok();
}
/// <summary>
/// Creates a new person.
/// </summary>
+27
View File
@@ -176,6 +176,33 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to)
=> context.Albums.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
/// <inheritdoc />
public void MergeAlbums(Guid destId, List<Guid> sourceIds) {
var dest = context.Albums
.AsSplitQuery()
.Include(a => a.Assets)
.FirstOrDefault(a => a.Id == destId);
if (dest == null) return;
var sources = context.Albums
.AsSplitQuery()
.Include(a => a.Assets)
.Where(a => sourceIds.Contains(a.Id))
.ToList();
var existingAssetIds = dest.Assets?.Select(a => a.Id).ToHashSet() ?? [];
foreach (var source in sources) {
if (source.Assets == null) continue;
foreach (var asset in source.Assets) {
if (existingAssetIds.Add(asset.Id))
dest.Assets?.Add(asset);
}
}
context.Albums.RemoveRange(sources);
}
/// <inheritdoc />
public void Remove(Album album) => context.Albums.Remove(album);
+8
View File
@@ -70,6 +70,14 @@ public interface IAlbumRepository : IDisposable {
/// <returns>A collection of albums created or updated within the specified date range.</returns>
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to);
/// <summary>
/// Merges multiple source albums into a destination album.
/// All assets from source albums are moved to the destination, then source albums are hard-deleted.
/// </summary>
/// <param name="destId">The ID of the destination album.</param>
/// <param name="sourceIds">The IDs of the source albums to merge.</param>
public void MergeAlbums(Guid destId, List<Guid> sourceIds);
/// <summary>
/// Removes an album from the database.
/// </summary>
@@ -89,6 +89,14 @@ public interface IPersonRepository : IDisposable {
/// <returns>A collection of matching people.</returns>
IEnumerable<Person> FindBulk(IEnumerable<Guid> ids);
/// <summary>
/// Merges multiple source people into a destination person.
/// Albums, faces, and maintainers are reassigned to the destination, then source people are hard-deleted.
/// </summary>
/// <param name="destId">The ID of the destination person.</param>
/// <param name="sourceIds">The IDs of the source people to merge.</param>
void MergePeople(Guid destId, List<Guid> sourceIds);
/// <summary>
/// Removes a person from the database.
/// </summary>
+33
View File
@@ -227,6 +227,39 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
}
}
/// <inheritdoc />
public void MergePeople(Guid destId, List<Guid> sourceIds) {
var dest = context.People
.AsSplitQuery()
.Include(p => p.Maintainers)
.FirstOrDefault(p => p.Id == destId);
if (dest == null) return;
// Reassign albums
var albums = context.Albums.Where(a => a.PersonOwnerId != null && sourceIds.Contains(a.PersonOwnerId.Value)).ToList();
foreach (var album in albums)
album.PersonOwnerId = destId;
// Reassign faces
var faces = context.Faces.Where(f => f.PersonId.HasValue && sourceIds.Contains(f.PersonId.Value)).ToList();
foreach (var face in faces)
face.PersonId = destId;
// Reassign maintainers
var existingMaintainerIds = dest.Maintainers?.Select(m => m.Id).ToHashSet() ?? [];
var sourceMaintainers = context.PersonMaintainers
.Where(pm => sourceIds.Contains(pm.PersonId))
.ToList();
foreach (var pm in sourceMaintainers) {
if (!existingMaintainerIds.Contains(pm.UserId))
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = destId, UserId = pm.UserId });
}
// Hard-delete source people
var people = context.People.Where(p => sourceIds.Contains(p.Id)).ToList();
context.People.RemoveRange(people);
}
/// <inheritdoc />
public void Dispose() => context.Dispose();
}
+475 -115
View File
File diff suppressed because it is too large Load Diff