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.
This commit is contained in:
2026-09-25 02:14:23 +02:00
parent a888a44b41
commit 03f5936944
29 changed files with 2267 additions and 18 deletions
@@ -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<PaginatedResponse<CoauthorInvitationResponse>>;
public class GetCoauthorInvitationsQueryHandler(IDatabase database)
: IQueryHandler<GetCoauthorInvitationsQuery, PaginatedResponse<CoauthorInvitationResponse>>
{
public async ValueTask<PaginatedResponse<CoauthorInvitationResponse>> 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);
}
}
}
@@ -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<long> UserIds)
: ICommand<ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>>;
public class InviteCoauthorsCommandHandler(
IDatabase database,
ILogger<InviteCoauthorsCommandHandler> logger)
: ICommandHandler<InviteCoauthorsCommand, ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>>
{
public async ValueTask<ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>> 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);
}
}
}
@@ -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<ErrorOr<Success>>;
public class RemoveCoauthorCommandHandler(
IDatabase database,
ILogger<RemoveCoauthorCommandHandler> logger)
: ICommandHandler<RemoveCoauthorCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> 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;
}
}
}
@@ -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<ErrorOr<Success>>;
public class RespondToCoauthorInvitationCommandHandler(
IDatabase database,
ILogger<RespondToCoauthorInvitationCommandHandler> logger)
: ICommandHandler<RespondToCoauthorInvitationCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> 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;
}
}
}
@@ -0,0 +1,10 @@
namespace Musify.Application.Coauthors.Responses
{
public record CoauthorInvitationResponse(
Guid Id,
Guid TrackId,
string TrackTitle,
string? Artist,
long OwnerUserId,
DateTime InvitedAt);
}
@@ -32,6 +32,8 @@ namespace Musify.Application.Contracts
public DbSet<UserFollow> UserFollows { get; }
public DbSet<TrackCoauthor> TrackCoauthors { get; }
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
@@ -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);
}
}
@@ -15,7 +15,8 @@ namespace Musify.Application.Tracks
int PageSize,
IReadOnlyList<ProcessingStatus>? Statuses = null,
long? ViewerId = null,
bool? IsPublished = null)
bool? IsPublished = null,
bool? IncludeCoauthored = null)
: IQuery<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>;
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 };
}
}
}
@@ -15,7 +15,8 @@ namespace Musify.Application.Tracks.Responses
long OwnerUserId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit,
bool IsPublished)
bool IsPublished,
IReadOnlyList<TrackCoauthorReference> 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,
[]);
}
}
@@ -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);
}
@@ -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<UserTrackApplicationResponse> SelectUserResponse(this IQueryable<Track> query) =>
public static IQueryable<UserTrackApplicationResponse> SelectUserResponse(this IQueryable<Track> 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<TrackCoauthorInviteReference>()));
}
}
@@ -17,5 +17,7 @@ namespace Musify.Application.Tracks.Responses
IReadOnlyCollection<Genre> Tags,
bool IsExplicit,
bool IsPublished,
IReadOnlyList<TrackAlbumReference> Albums);
IReadOnlyList<TrackAlbumReference> Albums,
IReadOnlyList<TrackCoauthorReference> Coauthors,
IReadOnlyList<TrackCoauthorInviteReference> CoauthorInvites);
}
@@ -45,5 +45,7 @@ namespace Musify.Domain.Entities
public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>();
public ICollection<TrackTag> Tags { get; set; } = new List<TrackTag>();
public ICollection<TrackCoauthor> Coauthors { get; set; } = new List<TrackCoauthor>();
}
}
@@ -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!;
}
}
@@ -0,0 +1,9 @@
namespace Musify.Domain.ValueObjects
{
public enum CoauthorStatus
{
Pending = 0,
Accepted = 1,
Rejected = 2
}
}
@@ -59,6 +59,8 @@ namespace Musify.Infrastructure.Persistence
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
public DbSet<TrackCoauthor> TrackCoauthors => Set<TrackCoauthor>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackCoauthors : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TrackCoauthors",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TrackId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
InvitedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
RespondedAt = table.Column<DateTime>(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" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TrackCoauthors");
}
}
}
@@ -467,6 +467,37 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("Tracks");
});
modelBuilder.Entity("Musify.Domain.Entities.TrackCoauthor", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("InvitedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("RespondedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<long>("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<Guid>("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");
@@ -122,6 +122,25 @@ namespace Musify.Infrastructure.Persistence
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
.IsUnique();
modelBuilder.Entity<TrackCoauthor>()
.HasOne(coauthor => coauthor.Track)
.WithMany(track => track.Coauthors)
.HasForeignKey(coauthor => coauthor.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TrackCoauthor>()
.HasOne(coauthor => coauthor.User)
.WithMany()
.HasForeignKey(coauthor => coauthor.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TrackCoauthor>()
.HasIndex(coauthor => new { coauthor.TrackId, coauthor.UserId })
.IsUnique();
modelBuilder.Entity<TrackCoauthor>()
.HasIndex(coauthor => new { coauthor.UserId, coauthor.Status });
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Follower)
.WithMany()