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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user