From 03f593694469a2e7ae779fee6549d0c6971a3c95 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 25 Sep 2026 02:14:23 +0200 Subject: [PATCH] Add track coauthors with invitations, acceptance and coauthor-aware track responses Owners invite existing users to coauthor a track; invitees accept or reject, and either side can remove a coauthor. Only accepted coauthors appear in track responses and in the coauthor's public track list. The owner also receives the full invitation list with statuses on the user tracks response. --- .../Coauthors/GetCoauthorInvitations.cs | 35 + .../Coauthors/InviteCoauthors.cs | 84 ++ .../Coauthors/RemoveCoauthor.cs | 47 + .../Coauthors/RespondToCoauthorInvitation.cs | 44 + .../Responses/CoauthorInvitationResponse.cs | 10 + .../Musify.Application/Contracts/IDatabase.cs | 2 + .../Tracks/GetLastTrackListenedByUserId.cs | 5 +- .../Tracks/GetTracksByUserId.cs | 30 +- .../Responses/TrackApplicationResponse.cs | 6 +- .../Responses/TrackCoauthorResponses.cs | 8 + .../Tracks/Responses/TrackProjections.cs | 25 +- .../Responses/UserTrackApplicationResponse.cs | 4 +- backend/Core/Musify.Domain/Entities/Track.cs | 2 + .../Musify.Domain/Entities/TrackCoauthor.cs | 29 + .../ValueObjects/CoauthorStatus.cs | 9 + .../Persistence/Database.cs | 2 + ...260925001154_AddTrackCoauthors.Designer.cs | 1142 +++++++++++++++++ .../20260925001154_AddTrackCoauthors.cs | 61 + .../Migrations/DatabaseModelSnapshot.cs | 52 + .../Persistence/ModelConfiguration.cs | 19 + .../Tracks/InviteCoauthorsRequest.cs | 4 + .../Musify.Api/Endpoints/CoauthorEndpoints.cs | 57 + .../Musify.Api/Endpoints/TrackEndpoints.cs | 49 +- .../Musify.Api/Endpoints/UserEndpoints.cs | 20 + backend/Hosts/Musify.Api/Program.cs | 1 + .../Tracks/InviteCoauthorsRequestValidator.cs | 21 + .../Endpoints/CoauthorEndpointsTests.cs | 142 ++ .../Coauthors/CoauthorHandlersTests.cs | 373 ++++++ .../TestSupport/TestDatabase.cs | 2 + 29 files changed, 2267 insertions(+), 18 deletions(-) create mode 100644 backend/Core/Musify.Application/Coauthors/GetCoauthorInvitations.cs create mode 100644 backend/Core/Musify.Application/Coauthors/InviteCoauthors.cs create mode 100644 backend/Core/Musify.Application/Coauthors/RemoveCoauthor.cs create mode 100644 backend/Core/Musify.Application/Coauthors/RespondToCoauthorInvitation.cs create mode 100644 backend/Core/Musify.Application/Coauthors/Responses/CoauthorInvitationResponse.cs create mode 100644 backend/Core/Musify.Application/Tracks/Responses/TrackCoauthorResponses.cs create mode 100644 backend/Core/Musify.Domain/Entities/TrackCoauthor.cs create mode 100644 backend/Core/Musify.Domain/ValueObjects/CoauthorStatus.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260925001154_AddTrackCoauthors.Designer.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260925001154_AddTrackCoauthors.cs create mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/Tracks/InviteCoauthorsRequest.cs create mode 100644 backend/Hosts/Musify.Api/Endpoints/CoauthorEndpoints.cs create mode 100644 backend/Hosts/Musify.Api/Validators/Tracks/InviteCoauthorsRequestValidator.cs create mode 100644 backend/Tests/Musify.Api.Tests/Endpoints/CoauthorEndpointsTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Coauthors/CoauthorHandlersTests.cs diff --git a/backend/Core/Musify.Application/Coauthors/GetCoauthorInvitations.cs b/backend/Core/Musify.Application/Coauthors/GetCoauthorInvitations.cs new file mode 100644 index 0000000..1537557 --- /dev/null +++ b/backend/Core/Musify.Application/Coauthors/GetCoauthorInvitations.cs @@ -0,0 +1,35 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Coauthors.Responses; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Coauthors +{ + public record GetCoauthorInvitationsQuery(long UserId, int PageNumber, int PageSize) + : IQuery>; + + public class GetCoauthorInvitationsQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetCoauthorInvitationsQuery request, CancellationToken cancellationToken) + { + return await database.TrackCoauthors + .AsNoTracking() + .Where(coauthor => coauthor.UserId == request.UserId + && coauthor.Status == CoauthorStatus.Pending + && coauthor.Track.LifeCycleStatus == LifeCycleStatus.Active) + .OrderByDescending(coauthor => coauthor.InvitedAt) + .ThenBy(coauthor => coauthor.Id) + .Select(coauthor => new CoauthorInvitationResponse( + coauthor.Id, + coauthor.TrackId, + coauthor.Track.Title, + coauthor.Track.Owner.Name, + coauthor.Track.OwnerUserId, + coauthor.InvitedAt)) + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } + } +} diff --git a/backend/Core/Musify.Application/Coauthors/InviteCoauthors.cs b/backend/Core/Musify.Application/Coauthors/InviteCoauthors.cs new file mode 100644 index 0000000..2ecfe36 --- /dev/null +++ b/backend/Core/Musify.Application/Coauthors/InviteCoauthors.cs @@ -0,0 +1,84 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Coauthors +{ + public record InviteCoauthorsCommand(long OwnerId, Guid TrackId, IReadOnlyList UserIds) + : ICommand>>; + + public class InviteCoauthorsCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler>> + { + public async ValueTask>> Handle(InviteCoauthorsCommand request, CancellationToken cancellationToken) + { + var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.OwnerId, cancellationToken); + if (found.IsError) + return found.Errors; + + var userIds = request.UserIds.Distinct().ToList(); + if (userIds.Contains(request.OwnerId)) + return Error.Validation("Coauthor.Self", "You cannot invite yourself as a coauthor."); + + var existingUserIds = await database.Users + .AsNoTracking() + .Where(user => userIds.Contains(user.Id)) + .Select(user => user.Id) + .ToListAsync(cancellationToken); + + var missing = userIds.Where(id => !existingUserIds.Contains(id)).Select(id => (long?)id).FirstOrDefault(); + if (missing != null) + return AppErrors.NotFound("User", missing.Value); + + var existing = await database.TrackCoauthors + .Where(coauthor => coauthor.TrackId == request.TrackId && userIds.Contains(coauthor.UserId)) + .ToListAsync(cancellationToken); + + var alreadyInvited = existing.FirstOrDefault(coauthor => coauthor.Status != CoauthorStatus.Rejected); + if (alreadyInvited != null) + return AppErrors.Conflict("Coauthor.AlreadyInvited", $"User {alreadyInvited.UserId} was already invited to this track."); + + var now = DateTime.UtcNow; + foreach (var rejected in existing) + { + rejected.Status = CoauthorStatus.Pending; + rejected.InvitedAt = now; + rejected.RespondedAt = null; + } + + var reinvitedIds = existing.Select(coauthor => coauthor.UserId).ToHashSet(); + foreach (var userId in userIds.Where(id => !reinvitedIds.Contains(id))) + { + await database.TrackCoauthors.AddAsync(new TrackCoauthor + { + TrackId = request.TrackId, + UserId = userId, + InvitedAt = now + }, cancellationToken); + } + + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("Coauthor.AlreadyInvited", "A user was already invited to this track."), cancellationToken); + if (saved.IsError) + return saved.Errors; + + logger.LogInformation("Owner {OwnerId} invited {Count} coauthors to track {TrackId}", request.OwnerId, userIds.Count, request.TrackId); + + return await database.TrackCoauthors + .AsNoTracking() + .Where(coauthor => coauthor.TrackId == request.TrackId) + .OrderBy(coauthor => coauthor.InvitedAt) + .ThenBy(coauthor => coauthor.UserId) + .Select(coauthor => new TrackCoauthorInviteReference(coauthor.UserId, coauthor.User.Name, coauthor.Status)) + .ToListAsync(cancellationToken); + } + } +} diff --git a/backend/Core/Musify.Application/Coauthors/RemoveCoauthor.cs b/backend/Core/Musify.Application/Coauthors/RemoveCoauthor.cs new file mode 100644 index 0000000..f6e6f01 --- /dev/null +++ b/backend/Core/Musify.Application/Coauthors/RemoveCoauthor.cs @@ -0,0 +1,47 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Coauthors +{ + public record RemoveCoauthorCommand(long UserId, Guid TrackId, long CoauthorUserId) + : ICommand>; + + public class RemoveCoauthorCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(RemoveCoauthorCommand request, CancellationToken cancellationToken) + { + var ownerUserId = await database.Tracks + .AsNoTracking() + .Where(track => track.Id == request.TrackId && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => (long?)track.OwnerUserId) + .SingleOrDefaultAsync(cancellationToken); + + if (ownerUserId == null) + return AppErrors.NotFound("Track", request.TrackId); + + if (ownerUserId != request.UserId && request.CoauthorUserId != request.UserId) + return AppErrors.Forbidden("Track", request.TrackId); + + var coauthor = await database.TrackCoauthors + .SingleOrDefaultAsync(c => c.TrackId == request.TrackId && c.UserId == request.CoauthorUserId, cancellationToken); + if (coauthor == null) + return AppErrors.NotFound("Coauthor", request.CoauthorUserId); + + database.TrackCoauthors.Remove(coauthor); + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("User {UserId} removed coauthor {CoauthorUserId} from track {TrackId}", + request.UserId, request.CoauthorUserId, request.TrackId); + + return Result.Success; + } + } +} diff --git a/backend/Core/Musify.Application/Coauthors/RespondToCoauthorInvitation.cs b/backend/Core/Musify.Application/Coauthors/RespondToCoauthorInvitation.cs new file mode 100644 index 0000000..71e9d11 --- /dev/null +++ b/backend/Core/Musify.Application/Coauthors/RespondToCoauthorInvitation.cs @@ -0,0 +1,44 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Coauthors +{ + public record RespondToCoauthorInvitationCommand(long UserId, Guid InvitationId, bool Accept) + : ICommand>; + + public class RespondToCoauthorInvitationCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(RespondToCoauthorInvitationCommand request, CancellationToken cancellationToken) + { + var invitation = await database.TrackCoauthors + .Include(coauthor => coauthor.Track) + .SingleOrDefaultAsync(coauthor => coauthor.Id == request.InvitationId, cancellationToken); + + if (invitation == null || invitation.Track.LifeCycleStatus != LifeCycleStatus.Active) + return AppErrors.NotFound("CoauthorInvitation", request.InvitationId); + + if (invitation.UserId != request.UserId) + return AppErrors.Forbidden("CoauthorInvitation", request.InvitationId); + + if (invitation.Status != CoauthorStatus.Pending) + return AppErrors.Conflict("CoauthorInvitation.NotPending", "The invitation was already answered."); + + invitation.Status = request.Accept ? CoauthorStatus.Accepted : CoauthorStatus.Rejected; + invitation.RespondedAt = DateTime.UtcNow; + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("User {UserId} {Action} coauthor invitation {InvitationId}", + request.UserId, request.Accept ? "accepted" : "rejected", request.InvitationId); + + return Result.Success; + } + } +} diff --git a/backend/Core/Musify.Application/Coauthors/Responses/CoauthorInvitationResponse.cs b/backend/Core/Musify.Application/Coauthors/Responses/CoauthorInvitationResponse.cs new file mode 100644 index 0000000..0728745 --- /dev/null +++ b/backend/Core/Musify.Application/Coauthors/Responses/CoauthorInvitationResponse.cs @@ -0,0 +1,10 @@ +namespace Musify.Application.Coauthors.Responses +{ + public record CoauthorInvitationResponse( + Guid Id, + Guid TrackId, + string TrackTitle, + string? Artist, + long OwnerUserId, + DateTime InvitedAt); +} diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index 840a894..c460451 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -32,6 +32,8 @@ namespace Musify.Application.Contracts public DbSet UserFollows { get; } + public DbSet TrackCoauthors { get; } + public Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); public DbSet Set() where TEntity : class; diff --git a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs index 76f5367..77165b4 100644 --- a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs @@ -15,12 +15,11 @@ namespace Musify.Application.Tracks { return await database.ListeningHistories .AsNoTracking() - .Include(t => t.Track.Owner) - .Include(t => t.Track.Tags) .Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active && t.Track.IsPublished) .OrderByDescending(t => t.ListenedAt) .ThenBy(t => t.Id) - .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) + .Select(t => t.Track) + .SelectResponse() .FirstOrDefaultAsync(cancellationToken); } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index 0a68fc3..6ed47dc 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -15,7 +15,8 @@ namespace Musify.Application.Tracks int PageSize, IReadOnlyList? Statuses = null, long? ViewerId = null, - bool? IsPublished = null) + bool? IsPublished = null, + bool? IncludeCoauthored = null) : IQuery>>; public class GetTracksByUserIdQueryHandler(IDatabase database) @@ -25,11 +26,19 @@ namespace Musify.Application.Tracks { var tracksQuery = database.Tracks .AsNoTracking() - .Active() - .Where(track => track.OwnerUserId == request.UserId); + .Active(); - if (request.ViewerId != request.UserId) - tracksQuery = tracksQuery.WherePublished(); + var isOwnerView = request.ViewerId == request.UserId; + var includeCoauthored = request.IncludeCoauthored ?? !isOwnerView; + + tracksQuery = includeCoauthored + ? tracksQuery.Where(track => track.OwnerUserId == request.UserId + || track.Coauthors.Any(coauthor => coauthor.UserId == request.UserId && coauthor.Status == CoauthorStatus.Accepted)) + : tracksQuery.Where(track => track.OwnerUserId == request.UserId); + + tracksQuery = isOwnerView + ? tracksQuery.Where(track => track.OwnerUserId == request.UserId || track.IsPublished) + : tracksQuery.WherePublished(); if (!string.IsNullOrWhiteSpace(request.Name)) { @@ -46,11 +55,18 @@ namespace Musify.Application.Tracks if (request.IsPublished is { } isPublished) tracksQuery = tracksQuery.Where(track => track.IsPublished == isPublished); - return await tracksQuery + var page = await tracksQuery .OrderByDescending(track => track.CreatedAt) .ThenBy(track => track.Id) - .SelectUserResponse() + .SelectUserResponse(includeInvites: request.ViewerId != null) .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + + // Invitations are private to the track owner. + var items = page.Items + .Select(track => track.OwnerUserId == request.ViewerId ? track : track with { CoauthorInvites = [] }) + .ToList(); + + return page with { Items = items }; } } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index 952f1da..920b501 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -15,7 +15,8 @@ namespace Musify.Application.Tracks.Responses long OwnerUserId, IReadOnlyCollection Tags, bool IsExplicit, - bool IsPublished) + bool IsPublished, + IReadOnlyList Coauthors) { public static TrackApplicationResponse FromEntity(Track track, int listensCount) => new( @@ -30,6 +31,7 @@ namespace Musify.Application.Tracks.Responses track.OwnerUserId, track.Tags.Select(tag => tag.Tag).ToList(), track.IsExplicit, - track.IsPublished); + track.IsPublished, + []); } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackCoauthorResponses.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackCoauthorResponses.cs new file mode 100644 index 0000000..d30c2fe --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackCoauthorResponses.cs @@ -0,0 +1,8 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks.Responses +{ + public record TrackCoauthorReference(long Id, string Name); + + public record TrackCoauthorInviteReference(long UserId, string Name, CoauthorStatus Status); +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs index 398bfcd..cd553e0 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs @@ -27,9 +27,15 @@ namespace Musify.Application.Tracks.Responses track.OwnerUserId, track.Tags.Select(tag => tag.Tag).ToList(), track.IsExplicit, - track.IsPublished)); + track.IsPublished, + track.Coauthors + .Where(coauthor => coauthor.Status == CoauthorStatus.Accepted) + .OrderBy(coauthor => coauthor.RespondedAt) + .ThenBy(coauthor => coauthor.UserId) + .Select(coauthor => new TrackCoauthorReference(coauthor.UserId, coauthor.User.Name)) + .ToList())); - public static IQueryable SelectUserResponse(this IQueryable query) => + public static IQueryable SelectUserResponse(this IQueryable query, bool includeInvites = false) => query.Select(track => new UserTrackApplicationResponse( track.Id, track.Title, @@ -48,6 +54,19 @@ namespace Musify.Application.Tracks.Responses .OrderBy(albumTrack => albumTrack.AddedAt) .ThenBy(albumTrack => albumTrack.AlbumId) .Select(albumTrack => new TrackAlbumReference(albumTrack.AlbumId, albumTrack.Album.Title)) - .ToList())); + .ToList(), + track.Coauthors + .Where(coauthor => coauthor.Status == CoauthorStatus.Accepted) + .OrderBy(coauthor => coauthor.RespondedAt) + .ThenBy(coauthor => coauthor.UserId) + .Select(coauthor => new TrackCoauthorReference(coauthor.UserId, coauthor.User.Name)) + .ToList(), + includeInvites + ? track.Coauthors + .OrderBy(coauthor => coauthor.InvitedAt) + .ThenBy(coauthor => coauthor.UserId) + .Select(coauthor => new TrackCoauthorInviteReference(coauthor.UserId, coauthor.User.Name, coauthor.Status)) + .ToList() + : new List())); } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs index 575e04a..1a2f067 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs @@ -17,5 +17,7 @@ namespace Musify.Application.Tracks.Responses IReadOnlyCollection Tags, bool IsExplicit, bool IsPublished, - IReadOnlyList Albums); + IReadOnlyList Albums, + IReadOnlyList Coauthors, + IReadOnlyList CoauthorInvites); } diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index eb08b11..5dd72b8 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -45,5 +45,7 @@ namespace Musify.Domain.Entities public ICollection ListeningHistories { get; set; } = new List(); public ICollection Tags { get; set; } = new List(); + + public ICollection Coauthors { get; set; } = new List(); } } diff --git a/backend/Core/Musify.Domain/Entities/TrackCoauthor.cs b/backend/Core/Musify.Domain/Entities/TrackCoauthor.cs new file mode 100644 index 0000000..0957c71 --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/TrackCoauthor.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Musify.Domain.ValueObjects; + +namespace Musify.Domain.Entities +{ + [Table("TrackCoauthors")] + public class TrackCoauthor + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + public required Guid TrackId { get; set; } + + public required long UserId { get; set; } + + public CoauthorStatus Status { get; set; } = CoauthorStatus.Pending; + + public DateTime InvitedAt { get; set; } = DateTime.UtcNow; + + public DateTime? RespondedAt { get; set; } + + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + + [ForeignKey(nameof(UserId))] + public User User { get; set; } = null!; + } +} diff --git a/backend/Core/Musify.Domain/ValueObjects/CoauthorStatus.cs b/backend/Core/Musify.Domain/ValueObjects/CoauthorStatus.cs new file mode 100644 index 0000000..3a32b29 --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/CoauthorStatus.cs @@ -0,0 +1,9 @@ +namespace Musify.Domain.ValueObjects +{ + public enum CoauthorStatus + { + Pending = 0, + Accepted = 1, + Rejected = 2 + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 149ec10..ea606be 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -59,6 +59,8 @@ namespace Musify.Infrastructure.Persistence public DbSet UserFollows => Set(); + public DbSet TrackCoauthors => Set(); + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) { var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260925001154_AddTrackCoauthors.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260925001154_AddTrackCoauthors.Designer.cs new file mode 100644 index 0000000..95b2ed4 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260925001154_AddTrackCoauthors.Designer.cs @@ -0,0 +1,1142 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260925001154_AddTrackCoauthors")] + partial class AddTrackCoauthors + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "none", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint") + .HasColumnName("UserId"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackCoauthor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("InvitedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "UserId") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("TrackCoauthors"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany("PlayLists") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackCoauthor", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Coauthors") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("Coauthors"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260925001154_AddTrackCoauthors.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260925001154_AddTrackCoauthors.cs new file mode 100644 index 0000000..f0eea79 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260925001154_AddTrackCoauthors.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddTrackCoauthors : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TrackCoauthors", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TrackId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "integer", nullable: false), + InvitedAt = table.Column(type: "timestamp with time zone", nullable: false), + RespondedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackCoauthors", x => x.Id); + table.ForeignKey( + name: "FK_TrackCoauthors_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TrackCoauthors_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TrackCoauthors_TrackId_UserId", + table: "TrackCoauthors", + columns: new[] { "TrackId", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TrackCoauthors_UserId_Status", + table: "TrackCoauthors", + columns: new[] { "UserId", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TrackCoauthors"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index b341083..7aa1b53 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -467,6 +467,37 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("Tracks"); }); + modelBuilder.Entity("Musify.Domain.Entities.TrackCoauthor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("InvitedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "UserId") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("TrackCoauthors"); + }); + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => { b.Property("Id") @@ -989,6 +1020,25 @@ namespace Musify.Infrastructure.Persistence.Migrations .IsRequired(); }); + modelBuilder.Entity("Musify.Domain.Entities.TrackCoauthor", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Coauthors") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => { b.HasOne("Musify.Domain.Entities.Track", "Track") @@ -1068,6 +1118,8 @@ namespace Musify.Infrastructure.Persistence.Migrations { b.Navigation("AlbumTracks"); + b.Navigation("Coauthors"); + b.Navigation("ListeningHistories"); b.Navigation("PlayListTracks"); diff --git a/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs b/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs index 4e20f7d..bdbba92 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs @@ -122,6 +122,25 @@ namespace Musify.Infrastructure.Persistence .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) .IsUnique(); + modelBuilder.Entity() + .HasOne(coauthor => coauthor.Track) + .WithMany(track => track.Coauthors) + .HasForeignKey(coauthor => coauthor.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(coauthor => coauthor.User) + .WithMany() + .HasForeignKey(coauthor => coauthor.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(coauthor => new { coauthor.TrackId, coauthor.UserId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(coauthor => new { coauthor.UserId, coauthor.Status }); + modelBuilder.Entity() .HasOne(follow => follow.Follower) .WithMany() diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/InviteCoauthorsRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/InviteCoauthorsRequest.cs new file mode 100644 index 0000000..706c36d --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/InviteCoauthorsRequest.cs @@ -0,0 +1,4 @@ +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record InviteCoauthorsRequest(IReadOnlyList UserIds); +} diff --git a/backend/Hosts/Musify.Api/Endpoints/CoauthorEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/CoauthorEndpoints.cs new file mode 100644 index 0000000..4aeaab7 --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/CoauthorEndpoints.cs @@ -0,0 +1,57 @@ +using Mediator; +using Musify.Api.Authentication; +using Musify.Api.Extensions; +using Musify.Application.Coauthors; + +namespace Musify.Api.Endpoints +{ + public static class CoauthorEndpoints + { + public static IEndpointRouteBuilder MapCoauthorEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/coauthor-invitations") + .WithTags("Coauthors") + .RequireAuthorization(); + + group.MapPost("/{id:guid}/accept", AcceptInvitation) + .WithName("AcceptCoauthorInvitation") + .WithSummary("Accept a pending coauthor invitation addressed to you") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/reject", RejectInvitation) + .WithName("RejectCoauthorInvitation") + .WithSummary("Reject a pending coauthor invitation addressed to you") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + + return app; + } + + private static async Task AcceptInvitation( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new RespondToCoauthorInvitationCommand(currentUser.RequiredId, id, true), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task RejectInvitation( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new RespondToCoauthorInvitationCommand(currentUser.RequiredId, id, false), cancellationToken); + return result.ToHttpResult(); + } + } +} diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 791ecae..5e71ee1 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -5,6 +5,7 @@ using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; using Musify.Api.Filters; using Musify.Api.Models; +using Musify.Application.Coauthors; using Musify.Application.Contracts; using Musify.Application.Pictures; using Musify.Application.Shared; @@ -121,6 +122,27 @@ namespace Musify.Api.Endpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); + group.MapPost("/{id:guid}/coauthors", InviteCoauthors) + .WithName("InviteCoauthors") + .WithSummary("Invite users to coauthor one of your tracks. Returns every invitation of the track with its status. A rejected invitation is reset to pending") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces>() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + + group.MapDelete("/{id:guid}/coauthors/{userId:long}", RemoveCoauthor) + .WithName("RemoveCoauthor") + .WithSummary("Remove a coauthor or pending invitation from a track. Allowed to the track owner and to the coauthor themself") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + group.MapPost("/upload-urls", RequestTrackUploadUrls) .WithName("RequestTrackUploadUrls") .WithSummary("Request pre-signed URLs to upload the audio of one or many tracks and an optional picture shared by all of them") @@ -198,14 +220,37 @@ namespace Musify.Api.Endpoints [AsParameters] PageQuery page, CancellationToken cancellationToken, ProcessingStatus[]? status = null, - bool? published = null) + bool? published = null, + bool? includeCoauthored = null) { var result = await mediator.Send( - new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize, status, currentUser.Id, published), + new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize, status, currentUser.Id, published, includeCoauthored), cancellationToken); return result.ToHttpResult(); } + private static async Task InviteCoauthors( + IMediator mediator, + CurrentUser currentUser, + Guid id, + InviteCoauthorsRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new InviteCoauthorsCommand(currentUser.RequiredId, id, request.UserIds), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task RemoveCoauthor( + IMediator mediator, + CurrentUser currentUser, + Guid id, + long userId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new RemoveCoauthorCommand(currentUser.RequiredId, id, userId), cancellationToken); + return result.ToHttpResult(); + } + private static async Task SetTrackPublished( IMediator mediator, CurrentUser currentUser, diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index bbd8e72..0ef2eec 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -3,6 +3,8 @@ using Musify.Api.Authentication; using Musify.Api.Extensions; using Musify.Api.Filters; using Musify.Api.Models; +using Musify.Application.Coauthors; +using Musify.Application.Coauthors.Responses; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; @@ -24,6 +26,14 @@ namespace Musify.Api.Endpoints .WithSummary("Get paginated users") .Produces>(); + group.MapGet("/me/coauthor-invitations", GetCoauthorInvitations) + .AddEndpointFilter>() + .WithName("GetCoauthorInvitations") + .WithSummary("Get your pending coauthor invitations with the track title and owner") + .RequireAuthorization() + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); + group.MapGet("/{id:long}", GetUserById) .WithName("GetUserById") .WithSummary("Get a user by id") @@ -108,6 +118,16 @@ namespace Musify.Api.Endpoints return Results.Ok(result); } + private static async Task GetCoauthorInvitations( + IMediator mediator, + CurrentUser currentUser, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetCoauthorInvitationsQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken); + return Results.Ok(result); + } + private static async Task GetUserById( IMediator mediator, long id, diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index fd874db..1b611f1 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -45,6 +45,7 @@ app.UseAuthorization(); app .MapAlbumEndpoints() + .MapCoauthorEndpoints() .MapConfigEndpoints() .MapGenreEndpoints() .MapLikeEndpoints() diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/InviteCoauthorsRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/InviteCoauthorsRequestValidator.cs new file mode 100644 index 0000000..7f9edae --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Tracks/InviteCoauthorsRequestValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Tracks; + +namespace Musify.Api.Validators.Tracks +{ + public sealed class InviteCoauthorsRequestValidator : AbstractValidator + { + public const int MaxUsersPerRequest = 20; + + public InviteCoauthorsRequestValidator() + { + RuleFor(x => x.UserIds) + .NotEmpty() + .Must(ids => ids.Count <= MaxUsersPerRequest) + .WithMessage($"At most {MaxUsersPerRequest} users can be invited at once."); + + RuleForEach(x => x.UserIds) + .GreaterThan(0); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/CoauthorEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/CoauthorEndpointsTests.cs new file mode 100644 index 0000000..1537d43 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/CoauthorEndpointsTests.cs @@ -0,0 +1,142 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Api.Tests.TestSupport; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Xunit; + +namespace Musify.Api.Tests.Endpoints +{ + [Collection(ApiCollection.Name)] + public sealed class CoauthorEndpointsTests(ApiTestFixture fixture) + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private async Task SeedTrackAsync(long ownerId, string title, bool isPublished = true) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + OwnerUserId = ownerId, + IsPublished = isPublished, + Audio = new TrackAudio { TranscodeStatus = ProcessingStatus.Completed, FolderName = "folder" } + }; + database.Tracks.Add(track); + await database.SaveChangesAsync(Ct); + return track.Id; + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + var text = await response.Content.ReadAsStringAsync(Ct); + return JsonDocument.Parse(text).RootElement.Clone(); + } + + private static string Id(long id) => id.ToString(CultureInfo.InvariantCulture); + + private async Task InviteAndGetInvitationIdAsync(long ownerId, Guid trackId, long guestId) + { + var invite = await fixture.CreateAuthenticatedClient(ownerId) + .PostAsJsonAsync($"/tracks/{trackId}/coauthors", new InviteCoauthorsRequest([guestId]), Ct); + Assert.Equal(HttpStatusCode.OK, invite.StatusCode); + + var invitations = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(guestId) + .GetAsync("/users/me/coauthor-invitations?pageNumber=1&pageSize=10", Ct)); + return invitations.GetProperty("items").EnumerateArray() + .Single(item => item.GetProperty("trackId").GetGuid() == trackId) + .GetProperty("id").GetGuid(); + } + + [Fact] + public async Task Coauthors_FullFlow_ExposesOnlyAcceptedCoauthorsPublicly() + { + var owner = await fixture.SeedUserAsync(name: "coauthor-owner"); + var guest = await fixture.SeedUserAsync(name: "coauthor-guest"); + var trackId = await SeedTrackAsync(owner, "Shared song"); + var ownerClient = fixture.CreateAuthenticatedClient(owner); + var guestClient = fixture.CreateAuthenticatedClient(guest); + + var invitationId = await InviteAndGetInvitationIdAsync(owner, trackId, guest); + + var beforeAccept = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/tracks/{trackId}", Ct)); + var ownerListBefore = await ReadJsonAsync(await ownerClient.GetAsync($"/tracks/users/{Id(owner)}", Ct)); + var duplicate = await ownerClient.PostAsJsonAsync($"/tracks/{trackId}/coauthors", new InviteCoauthorsRequest([guest]), Ct); + var accept = await guestClient.PostAsync($"/coauthor-invitations/{invitationId}/accept", null, Ct); + var acceptAgain = await guestClient.PostAsync($"/coauthor-invitations/{invitationId}/reject", null, Ct); + var afterAccept = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/tracks/{trackId}", Ct)); + var ownerListAfter = await ReadJsonAsync(await ownerClient.GetAsync($"/tracks/users/{Id(owner)}", Ct)); + var guestProfile = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/tracks/users/{Id(guest)}", Ct)); + var guestLibrary = await ReadJsonAsync(await guestClient.GetAsync($"/tracks/users/{Id(guest)}", Ct)); + + Assert.Equal(0, beforeAccept.GetProperty("coauthors").GetArrayLength()); + var pendingInvite = ownerListBefore.GetProperty("items")[0].GetProperty("coauthorInvites")[0]; + Assert.Equal("Pending", pendingInvite.GetProperty("status").GetString()); + Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, accept.StatusCode); + Assert.Equal(HttpStatusCode.Conflict, acceptAgain.StatusCode); + Assert.Equal("coauthor-guest", afterAccept.GetProperty("coauthors")[0].GetProperty("name").GetString()); + Assert.Equal("Accepted", ownerListAfter.GetProperty("items")[0].GetProperty("coauthorInvites")[0].GetProperty("status").GetString()); + Assert.Equal(trackId, guestProfile.GetProperty("items").EnumerateArray().Single().GetProperty("id").GetGuid()); + Assert.Equal(0, guestProfile.GetProperty("items")[0].GetProperty("coauthorInvites").GetArrayLength()); + Assert.Equal(0, guestLibrary.GetProperty("items").GetArrayLength()); + } + + [Fact] + public async Task Coauthors_Permissions_AreEnforced() + { + var owner = await fixture.SeedUserAsync(); + var guest = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var trackId = await SeedTrackAsync(owner, "Guarded"); + var invitationId = await InviteAndGetInvitationIdAsync(owner, trackId, guest); + + var strangerInvites = await fixture.CreateAuthenticatedClient(stranger) + .PostAsJsonAsync($"/tracks/{trackId}/coauthors", new InviteCoauthorsRequest([stranger]), Ct); + var self = await fixture.CreateAuthenticatedClient(owner) + .PostAsJsonAsync($"/tracks/{trackId}/coauthors", new InviteCoauthorsRequest([owner]), Ct); + var strangerAccepts = await fixture.CreateAuthenticatedClient(stranger) + .PostAsync($"/coauthor-invitations/{invitationId}/accept", null, Ct); + var strangerRemoves = await fixture.CreateAuthenticatedClient(stranger) + .DeleteAsync($"/tracks/{trackId}/coauthors/{Id(guest)}", Ct); + var anonymous = await fixture.CreateAnonymousClient() + .GetAsync("/users/me/coauthor-invitations?pageNumber=1&pageSize=10", Ct); + + Assert.Equal(HttpStatusCode.Forbidden, strangerInvites.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, self.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, strangerAccepts.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, strangerRemoves.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); + } + + [Fact] + public async Task Coauthors_OwnerRemovesAndReinvitesRejected_Works() + { + var owner = await fixture.SeedUserAsync(); + var guest = await fixture.SeedUserAsync(); + var trackId = await SeedTrackAsync(owner, "Again"); + var ownerClient = fixture.CreateAuthenticatedClient(owner); + var guestClient = fixture.CreateAuthenticatedClient(guest); + var invitationId = await InviteAndGetInvitationIdAsync(owner, trackId, guest); + + var reject = await guestClient.PostAsync($"/coauthor-invitations/{invitationId}/reject", null, Ct); + var reinvite = await ownerClient.PostAsJsonAsync($"/tracks/{trackId}/coauthors", new InviteCoauthorsRequest([guest]), Ct); + var accept = await guestClient.PostAsync($"/coauthor-invitations/{invitationId}/accept", null, Ct); + var withdraw = await guestClient.DeleteAsync($"/tracks/{trackId}/coauthors/{Id(guest)}", Ct); + var track = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/tracks/{trackId}", Ct)); + + Assert.Equal(HttpStatusCode.NoContent, reject.StatusCode); + Assert.Equal(HttpStatusCode.OK, reinvite.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, accept.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, withdraw.StatusCode); + Assert.Equal(0, track.GetProperty("coauthors").GetArrayLength()); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Coauthors/CoauthorHandlersTests.cs b/backend/Tests/Musify.Application.Tests/Coauthors/CoauthorHandlersTests.cs new file mode 100644 index 0000000..a8c1691 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Coauthors/CoauthorHandlersTests.cs @@ -0,0 +1,373 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Coauthors; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Coauthors +{ + public sealed class CoauthorHandlersTests : HandlerTestBase + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private InviteCoauthorsCommandHandler CreateInviteHandler() => new(Database, NoOpLogger()); + + private RemoveCoauthorCommandHandler CreateRemoveHandler() => new(Database, NoOpLogger()); + + private RespondToCoauthorInvitationCommandHandler CreateRespondHandler() => new(Database, NoOpLogger()); + + private static TrackCoauthor Coauthor(Track track, User user, CoauthorStatus status) => + new() { TrackId = track.Id, UserId = user.Id, Status = status }; + + [Fact] + public async Task Invite_Owner_CreatesPendingInvitations() + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, guest, track); + + var result = await CreateInviteHandler().Handle(new InviteCoauthorsCommand(owner.Id, track.Id, [guest.Id]), Ct); + + Assert.False(result.IsError); + var invite = Assert.Single(result.Value); + Assert.Equal(guest.Id, invite.UserId); + Assert.Equal("guest", invite.Name); + Assert.Equal(CoauthorStatus.Pending, invite.Status); + } + + [Fact] + public async Task Invite_NotTheOwner_IsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, other, track); + + var result = await CreateInviteHandler().Handle(new InviteCoauthorsCommand(other.Id, track.Id, [other.Id]), Ct); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task Invite_Self_IsRejected() + { + var owner = TestEntities.User(1, "owner"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var result = await CreateInviteHandler().Handle(new InviteCoauthorsCommand(owner.Id, track.Id, [owner.Id]), Ct); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + Assert.Empty(Database.TrackCoauthors); + } + + [Fact] + public async Task Invite_UnknownUser_IsNotFound() + { + var owner = TestEntities.User(1, "owner"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var result = await CreateInviteHandler().Handle(new InviteCoauthorsCommand(owner.Id, track.Id, [99]), Ct); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Theory] + [InlineData(CoauthorStatus.Pending)] + [InlineData(CoauthorStatus.Accepted)] + public async Task Invite_AlreadyInvited_IsConflict(CoauthorStatus status) + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, guest, track, Coauthor(track, guest, status)); + + var result = await CreateInviteHandler().Handle(new InviteCoauthorsCommand(owner.Id, track.Id, [guest.Id]), Ct); + + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + Assert.Equal(status, Assert.Single(Database.TrackCoauthors).Status); + } + + [Fact] + public async Task Invite_PreviouslyRejected_ResetsToPending() + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var track = TestEntities.Track(owner); + var rejected = Coauthor(track, guest, CoauthorStatus.Rejected); + rejected.RespondedAt = DateTime.UtcNow; + await SeedAsync(owner, guest, track, rejected); + + var result = await CreateInviteHandler().Handle(new InviteCoauthorsCommand(owner.Id, track.Id, [guest.Id]), Ct); + + Assert.False(result.IsError); + var stored = await Database.TrackCoauthors.AsNoTracking().SingleAsync(Ct); + Assert.Equal(CoauthorStatus.Pending, stored.Status); + Assert.Null(stored.RespondedAt); + } + + [Fact] + public async Task Accept_Invitee_MakesThemAnAcceptedCoauthor() + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var track = TestEntities.Track(owner); + var invitation = Coauthor(track, guest, CoauthorStatus.Pending); + await SeedAsync(owner, guest, track, invitation); + + var result = await CreateRespondHandler().Handle(new RespondToCoauthorInvitationCommand(guest.Id, invitation.Id, Accept: true), Ct); + + Assert.False(result.IsError); + var stored = await Database.TrackCoauthors.AsNoTracking().SingleAsync(Ct); + Assert.Equal(CoauthorStatus.Accepted, stored.Status); + Assert.NotNull(stored.RespondedAt); + } + + [Fact] + public async Task Reject_Invitee_MarksTheInvitationRejected() + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var track = TestEntities.Track(owner); + var invitation = Coauthor(track, guest, CoauthorStatus.Pending); + await SeedAsync(owner, guest, track, invitation); + + var result = await CreateRespondHandler().Handle(new RespondToCoauthorInvitationCommand(guest.Id, invitation.Id, Accept: false), Ct); + + Assert.False(result.IsError); + Assert.Equal(CoauthorStatus.Rejected, (await Database.TrackCoauthors.AsNoTracking().SingleAsync(Ct)).Status); + } + + [Fact] + public async Task Respond_NotTheInvitee_IsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var track = TestEntities.Track(owner); + var invitation = Coauthor(track, guest, CoauthorStatus.Pending); + await SeedAsync(owner, guest, track, invitation); + + var result = await CreateRespondHandler().Handle(new RespondToCoauthorInvitationCommand(owner.Id, invitation.Id, Accept: true), Ct); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + Assert.Equal(CoauthorStatus.Pending, (await Database.TrackCoauthors.AsNoTracking().SingleAsync(Ct)).Status); + } + + [Theory] + [InlineData(CoauthorStatus.Accepted, true)] + [InlineData(CoauthorStatus.Accepted, false)] + [InlineData(CoauthorStatus.Rejected, true)] + [InlineData(CoauthorStatus.Rejected, false)] + public async Task Respond_AlreadyAnswered_IsConflict(CoauthorStatus status, bool accept) + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var track = TestEntities.Track(owner); + var invitation = Coauthor(track, guest, status); + await SeedAsync(owner, guest, track, invitation); + + var result = await CreateRespondHandler().Handle(new RespondToCoauthorInvitationCommand(guest.Id, invitation.Id, accept), Ct); + + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + Assert.Equal(status, (await Database.TrackCoauthors.AsNoTracking().SingleAsync(Ct)).Status); + } + + [Fact] + public async Task Respond_UnknownInvitation_IsNotFound() + { + var result = await CreateRespondHandler().Handle(new RespondToCoauthorInvitationCommand(1, Guid.NewGuid(), Accept: true), Ct); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Remove_OwnerOrCoauthorThemself_DeletesTheRow() + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var second = TestEntities.User(3, "second"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, guest, second, track, + Coauthor(track, guest, CoauthorStatus.Accepted), + Coauthor(track, second, CoauthorStatus.Pending)); + + var byOwner = await CreateRemoveHandler().Handle(new RemoveCoauthorCommand(owner.Id, track.Id, second.Id), Ct); + var byCoauthor = await CreateRemoveHandler().Handle(new RemoveCoauthorCommand(guest.Id, track.Id, guest.Id), Ct); + + Assert.False(byOwner.IsError); + Assert.False(byCoauthor.IsError); + Assert.Empty(Database.TrackCoauthors); + } + + [Fact] + public async Task Remove_AnotherUser_IsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var stranger = TestEntities.User(3, "stranger"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, guest, stranger, track, Coauthor(track, guest, CoauthorStatus.Accepted)); + + var result = await CreateRemoveHandler().Handle(new RemoveCoauthorCommand(stranger.Id, track.Id, guest.Id), Ct); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + Assert.Single(Database.TrackCoauthors); + } + + [Fact] + public async Task Remove_NoSuchCoauthor_IsNotFound() + { + var owner = TestEntities.User(1, "owner"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var result = await CreateRemoveHandler().Handle(new RemoveCoauthorCommand(owner.Id, track.Id, 2), Ct); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task GetInvitations_ReturnsOnlyPendingOnesOfTheCaller() + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var pendingTrack = TestEntities.Track(owner, "Pending one"); + var acceptedTrack = TestEntities.Track(owner, "Accepted one"); + var rejectedTrack = TestEntities.Track(owner, "Rejected one"); + var otherTrack = TestEntities.Track(owner, "For someone else"); + await SeedAsync(owner, guest, pendingTrack, acceptedTrack, rejectedTrack, otherTrack, + Coauthor(pendingTrack, guest, CoauthorStatus.Pending), + Coauthor(acceptedTrack, guest, CoauthorStatus.Accepted), + Coauthor(rejectedTrack, guest, CoauthorStatus.Rejected), + Coauthor(otherTrack, owner, CoauthorStatus.Pending)); + + var result = await new GetCoauthorInvitationsQueryHandler(Database) + .Handle(new GetCoauthorInvitationsQuery(guest.Id, 1, 10), Ct); + + var invitation = Assert.Single(result.Items); + Assert.Equal("Pending one", invitation.TrackTitle); + Assert.Equal("owner", invitation.Artist); + Assert.Equal(owner.Id, invitation.OwnerUserId); + } + + [Fact] + public async Task GetTrackById_ShowsOnlyAcceptedCoauthors() + { + var owner = TestEntities.User(1, "owner"); + var accepted = TestEntities.User(2, "accepted"); + var pending = TestEntities.User(3, "pending"); + var rejected = TestEntities.User(4, "rejected"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, accepted, pending, rejected, track, + Coauthor(track, accepted, CoauthorStatus.Accepted), + Coauthor(track, pending, CoauthorStatus.Pending), + Coauthor(track, rejected, CoauthorStatus.Rejected)); + + var result = await new GetTrackByIdQueryHandler(Database).Handle(new GetTrackByIdQuery(track.Id, owner.Id), Ct); + + var coauthor = Assert.Single(result.Value.Coauthors); + Assert.Equal(accepted.Id, coauthor.Id); + Assert.Equal("accepted", coauthor.Name); + } + + [Fact] + public async Task GetTrackById_UnpublishedTrack_StaysHiddenFromItsCoauthor() + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var track = TestEntities.Track(owner, isPublished: false); + await SeedAsync(owner, guest, track, Coauthor(track, guest, CoauthorStatus.Accepted)); + + var result = await new GetTrackByIdQueryHandler(Database).Handle(new GetTrackByIdQuery(track.Id, guest.Id), Ct); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task GetTracksByUserId_PublicProfile_IncludesPublishedCoauthoredTracksOnly() + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var own = TestEntities.Track(guest, "Own"); + var shared = TestEntities.Track(owner, "Shared"); + var hidden = TestEntities.Track(owner, "Hidden", isPublished: false); + var pending = TestEntities.Track(owner, "Pending"); + var rejected = TestEntities.Track(owner, "Rejected"); + await SeedAsync(owner, guest, own, shared, hidden, pending, rejected, + Coauthor(shared, guest, CoauthorStatus.Accepted), + Coauthor(hidden, guest, CoauthorStatus.Accepted), + Coauthor(pending, guest, CoauthorStatus.Pending), + Coauthor(rejected, guest, CoauthorStatus.Rejected)); + + var result = await new GetTracksByUserIdQueryHandler(Database) + .Handle(new GetTracksByUserIdQuery(guest.Id, null, 1, 10, ViewerId: owner.Id), Ct); + + Assert.Equal(["Own", "Shared"], result.Value.Items.Select(track => track.Title).Order().ToList()); + } + + [Fact] + public async Task GetTracksByUserId_OwnLibrary_ExcludesCoauthoredTracksByDefault() + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var own = TestEntities.Track(guest, "Own"); + var shared = TestEntities.Track(owner, "Shared"); + await SeedAsync(owner, guest, own, shared, Coauthor(shared, guest, CoauthorStatus.Accepted)); + var handler = new GetTracksByUserIdQueryHandler(Database); + + var library = await handler.Handle(new GetTracksByUserIdQuery(guest.Id, null, 1, 10, ViewerId: guest.Id), Ct); + var withCoauthored = await handler.Handle( + new GetTracksByUserIdQuery(guest.Id, null, 1, 10, ViewerId: guest.Id, IncludeCoauthored: true), Ct); + + Assert.Equal(["Own"], library.Value.Items.Select(track => track.Title).ToList()); + Assert.Equal(2, withCoauthored.Value.Items.Count); + } + + [Fact] + public async Task GetTracksByUserId_Invites_AreVisibleOnlyToTheOwner() + { + var owner = TestEntities.User(1, "owner"); + var accepted = TestEntities.User(2, "accepted"); + var pending = TestEntities.User(3, "pending"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, accepted, pending, track, + Coauthor(track, accepted, CoauthorStatus.Accepted), + Coauthor(track, pending, CoauthorStatus.Pending)); + var handler = new GetTracksByUserIdQueryHandler(Database); + + var asOwner = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 10, ViewerId: owner.Id), Ct); + var asStranger = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 10, ViewerId: pending.Id), Ct); + var anonymous = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 10), Ct); + + var ownerItem = Assert.Single(asOwner.Value.Items); + Assert.Equal(2, ownerItem.CoauthorInvites.Count); + Assert.Contains(ownerItem.CoauthorInvites, invite => invite.Name == "pending" && invite.Status == CoauthorStatus.Pending); + Assert.Equal(accepted.Id, Assert.Single(ownerItem.Coauthors).Id); + Assert.Empty(Assert.Single(asStranger.Value.Items).CoauthorInvites); + Assert.Empty(Assert.Single(anonymous.Value.Items).CoauthorInvites); + Assert.Equal(accepted.Id, Assert.Single(Assert.Single(anonymous.Value.Items).Coauthors).Id); + } + + [Fact] + public async Task GetTracksByUserId_CoauthoredTrack_KeepsSharedListensCount() + { + var owner = TestEntities.User(1, "owner"); + var guest = TestEntities.User(2, "guest"); + var listener = TestEntities.User(3, "listener"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, guest, listener, track, Coauthor(track, guest, CoauthorStatus.Accepted), + TestEntities.ListeningHistory(listener.Id, track.Id), + TestEntities.ListeningHistory(listener.Id, track.Id)); + + var result = await new GetTracksByUserIdQueryHandler(Database) + .Handle(new GetTracksByUserIdQuery(guest.Id, null, 1, 10), Ct); + + Assert.Equal(2, Assert.Single(result.Value.Items).ListensCount); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index a08d919..919dcd6 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -64,6 +64,8 @@ namespace Musify.Application.Tests.TestSupport public DbSet UserFollows => Set(); + public DbSet TrackCoauthors => Set(); + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) { var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);