Author SHA1 Message Date
Admin a081b85efc Restore the invitations tab, render library tracks on first paint and re-measure the tab indicator
CI/CD / BackEnd (push) Successful in 1m45s
CI/CD / WebPlayer (push) Successful in 52s
CI/CD / Deploy (push) Skipped
2026-09-25 02:40:50 +02:00
Admin 76b62ec384 Document coauthor invitations, invite dialog and coauthor helpers 2026-09-25 02:24:42 +02:00
Admin 2a7e805480 Link track coauthors next to the artist in rows and the player 2026-09-25 02:23:43 +02:00
Admin dddb57c98b Add coauthor invitations tab with accept and reject 2026-09-25 02:22:46 +02:00
Admin 058a902a81 Invite and remove track coauthors from the library songs list 2026-09-25 02:21:37 +02:00
Admin 153c33bf51 Add TagInput chip input 2026-09-25 02:17:53 +02:00
Admin 2b2f4093fb Map track coauthors, coauthor invites and invitations 2026-09-25 02:17:13 +02:00
Admin 8a5b1bd353 Regenerate API schema for track coauthors 2026-09-25 02:15:32 +02:00
Admin 03f5936944 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.
2026-09-25 02:14:23 +02:00
Admin a888a44b41 Document the library components and test dropped folder reading 2026-09-25 01:53:59 +02:00
62 changed files with 3676 additions and 36 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()
@@ -0,0 +1,4 @@
namespace Musify.Api.DataTransferObjects.Tracks
{
public record InviteCoauthorsRequest(IReadOnlyList<long> UserIds);
}
@@ -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<IResult> 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<IResult> RejectInvitation(
IMediator mediator,
CurrentUser currentUser,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new RespondToCoauthorInvitationCommand(currentUser.RequiredId, id, false), cancellationToken);
return result.ToHttpResult();
}
}
}
@@ -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<ValidationFilter<InviteCoauthorsRequest>>()
.RequireAuthorization()
.Produces<IReadOnlyList<TrackCoauthorInviteReference>>()
.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<IResult> 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<IResult> 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<IResult> SetTrackPublished(
IMediator mediator,
CurrentUser currentUser,
@@ -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<PaginatedResponse<UserSummaryResponse>>();
group.MapGet("/me/coauthor-invitations", GetCoauthorInvitations)
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetCoauthorInvitations")
.WithSummary("Get your pending coauthor invitations with the track title and owner")
.RequireAuthorization()
.Produces<PaginatedResponse<CoauthorInvitationResponse>>()
.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<IResult> 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<IResult> GetUserById(
IMediator mediator,
long id,
+1
View File
@@ -45,6 +45,7 @@ app.UseAuthorization();
app
.MapAlbumEndpoints()
.MapCoauthorEndpoints()
.MapConfigEndpoints()
.MapGenreEndpoints()
.MapLikeEndpoints()
@@ -0,0 +1,21 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Tracks;
namespace Musify.Api.Validators.Tracks
{
public sealed class InviteCoauthorsRequestValidator : AbstractValidator<InviteCoauthorsRequest>
{
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);
}
}
}
@@ -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<Guid> SeedTrackAsync(long ownerId, string title, bool isPublished = true)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
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<JsonElement> 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<Guid> 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());
}
}
}
@@ -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<InviteCoauthorsCommandHandler>());
private RemoveCoauthorCommandHandler CreateRemoveHandler() => new(Database, NoOpLogger<RemoveCoauthorCommandHandler>());
private RespondToCoauthorInvitationCommandHandler CreateRespondHandler() => new(Database, NoOpLogger<RespondToCoauthorInvitationCommandHandler>());
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);
}
}
}
@@ -64,6 +64,8 @@ namespace Musify.Application.Tests.TestSupport
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);
+4 -3
View File
@@ -44,12 +44,12 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
- `src/lib/server/` is server only code (see below).
- `src/lib/components/shell/` holds the app shell: Sidebar, TopBar, TabsBar, MobileHeader, AccountMenu, and `navLinks.ts`.
- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.
- `src/lib/components/music/` holds the components that connect the UI to app state (the player, album data): TrackList, PlayAllButton, TrackContextMenu, AlbumContextMenu.
- `src/lib/components/music/` holds the components that connect the UI to app state (the player, album data): TrackList, PlayAllButton, TrackContextMenu, AlbumContextMenu, GenrePicker, TrackStatusBadge. The `library/` subfolder holds the manage-music sections (tracks, albums editor, upload queue, coauthors dialog, invitations inbox).
- `src/lib/components/ui/` holds the design system, which is presentation only. ESLint forbids importing `$lib/player/*`, `$lib/data/*`, `$lib/server/*`, `components/music`, `components/shell` and `components/player` from it; it can use `$lib/state/*`, `$lib/utils/*`, `$lib/actions/*`, `$lib/theme/*` and `$lib/types`. It is split into five categories:
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
- `overlay/` for anything that floats above the content (Modal, ContextMenu, GlassMenu, ConfirmDialog, and so on).
- `media/` for stateless music display components that receive props (Artwork, MediaCard, ListRow, PlayButton, and so on).
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone).
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone, TagInput).
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading).
- The root `+layout.svelte` only holds the globals (fonts, theme, `DialogHost`). Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`); `(app)/+layout.server.ts` guards the group and loads the shell data, and `(app)/+error.svelte` renders inside the shell. Unknown URLs hit the catch-all `(app)/[...rest]/` (it throws a 404) so they also get the shell (unknown `/api/...` URLs hit `api/[...rest]/`, a JSON 404); the root `+error.svelte` only covers errors outside the group or in the group layout load. Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
@@ -70,7 +70,7 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`).
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token.
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `ChipGroup`, `SegmentedControl`, `Tabs`, `SearchInput`, `StatusBadge`, `SelectionBar`, `DataGrid`, `FileDropzone`, `TagInput`, `CoverForm`.
- **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility.
## App shell
@@ -109,5 +109,6 @@ Server helpers:
- `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, `toCount`, `toListeningStats`, ...) turn DTOs into the domain types. Every load and `/api` endpoint goes through them, so `Number()` conversions on DTOs never reach routes or the client.
- `actions/` holds `authedAction(handler)` (returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler) plus the shared `follow` and `playlist` actions.
- `forms/` holds `fields.ts` (`formString`, `formFile`) and the form parsers (`parseAlbumForm`, `parsePlaylistForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload.
- `coauthors.ts` holds the coauthor helpers: invite, remove, accept or reject with per status error messages, the pending invitations list and count, and the user search behind `/api/users/search`.
- `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting.
- Page sizes come from `$lib/config`; do not hardcode them.
+2 -1
View File
@@ -17,6 +17,7 @@ src/routes/
new/ /library/albums/new Create an album
[id]/ /library/albums/:id Edit an album
uploads/ /library/uploads Upload queue
invitations/ /library/invitations Coauthor invitations inbox
albums/ /albums Your albums
[id]/ /albums/:id Album detail
playlists/ /playlists Your playlists
@@ -35,7 +36,7 @@ Global chrome on every app page: sidebar (desktop) or tab bar (mobile), top bar
- **Home `/`**: a greeting, weekly listening stats, "continue listening" (recently played merged with the in-session history), the featured playlist with its first tracks (play and shuffle load the full list on demand), the user's mixes and playlists. Anonymous visitors are redirected to `/explore`.
- **Explore `/explore`**: genre tiles when there is no query, otherwise search results grouped by songs, albums, users and playlists with filter chips and counts taken from the API totals. Songs paginate with infinite scroll. Track and album context menus offer play next, add to queue and add to playlist.
- **Liked `/liked`**: the songs the user liked, newest first, with unlike per row.
- **Library `/library`, `/library/albums`, `/library/uploads`**: the manage-music area, one route per section behind shared tabs. Songs lists the user's uploads with a confirmed delete; Albums lists the user's albums and edits them at `/library/albums/new` and `/library/albums/:id`; Uploads is the upload queue: the browser sends each file straight to storage through presigned URLs (`/api/uploads`, with progress), `/api/uploads/complete` creates the track unpublished and optionally adds it to an album, and `/api/uploads/status` follows the processing. Genres and cover apply to the whole queue and the queue survives tab changes. `/upload` redirects here.
- **Library `/library`, `/library/albums`, `/library/uploads`, `/library/invitations`**: the manage-music area, one route per section behind shared tabs. Songs lists the user's uploads, filtered on the server (`?q=` and `?status=`) with counters, and lets the owner publish, unpublish and bulk delete them (a track is created unpublished and is only visible to its owner until published; processing tracks refresh on their own); Albums lists the user's albums and edits them at `/library/albums/new` and `/library/albums/:id`; Uploads is the upload queue: the browser sends each file straight to storage through presigned URLs (`/api/uploads`, with progress), `/api/uploads/complete` creates the track unpublished and optionally adds it to an album, and `/api/uploads/status` follows the processing. Genres and cover apply to the whole queue and the queue survives tab changes. `/upload` redirects here. Each song has a Coautores action that opens a dialog to invite users (searched through `/api/users/search`, picked as chips in a `TagInput`) and to see or remove the invitations with their status (pending, accepted, rejected). Invitations sent to the user land in the Invitaciones tab, which shows a count badge and lets them accept or reject each one; accepted coauthors are linked next to the artist wherever a track is shown (lists and the player).
- **Albums `/albums`, `/albums/:id`**: list and create albums. The owner can edit, delete and add or remove tracks from their library.
- **Playlists `/playlists`, `/playlists/:id`**: list and create playlists. The owner can edit, delete and remove tracks; everyone else gets a read only view.
- **Mix `/mixes/:id`**: a generated mix with play, shuffle and a track menu.
+358
View File
@@ -178,6 +178,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/coauthor-invitations/{id}/accept": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Accept a pending coauthor invitation addressed to you */
post: operations["AcceptCoauthorInvitation"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/coauthor-invitations/{id}/reject": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Reject a pending coauthor invitation addressed to you */
post: operations["RejectCoauthorInvitation"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/config/playback": {
parameters: {
query?: never;
@@ -609,6 +643,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/tracks/{id}/coauthors": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Invite users to coauthor one of your tracks. Returns every invitation of the track with its status. A rejected invitation is reset to pending */
post: operations["InviteCoauthors"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/tracks/{id}/coauthors/{userId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Remove a coauthor or pending invitation from a track. Allowed to the track owner and to the coauthor themself */
delete: operations["RemoveCoauthor"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/tracks/upload-urls": {
parameters: {
query?: never;
@@ -643,6 +711,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/users/me/coauthor-invitations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get your pending coauthor invitations with the track title and owner */
get: operations["GetCoauthorInvitations"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}": {
parameters: {
query?: never;
@@ -843,6 +928,19 @@ export interface components {
genre: components["schemas"]["Genre"];
incompatibleWith: components["schemas"]["Genre"][];
};
CoauthorInvitationResponse: {
/** Format: uuid */
id: string;
/** Format: uuid */
trackId: string;
trackTitle: string;
artist: null | string;
ownerUserId: string;
/** Format: date-time */
invitedAt: string;
};
/** @enum {unknown} */
CoauthorStatus: "Pending" | "Accepted" | "Rejected";
CreateAlbumRequest: {
title: string;
description: null | string;
@@ -895,6 +993,9 @@ export interface components {
[key: string]: string[];
};
};
InviteCoauthorsRequest: {
userIds: (number | string)[];
};
ListeningStatsResponse: {
/** Format: int32 */
tracksThisWeek: number | string;
@@ -936,6 +1037,19 @@ export interface components {
hasNextPage: boolean;
hasPreviousPage: boolean;
};
PaginatedResponseOfCoauthorInvitationResponse: {
items: components["schemas"]["CoauthorInvitationResponse"][];
/** Format: int32 */
pageNumber: number | string;
/** Format: int32 */
pageSize: number | string;
/** Format: int32 */
pageCount: number | string;
/** Format: int32 */
totalItemCount: number | string;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
PaginatedResponseOfPlayListApplicationResponse: {
items: components["schemas"]["PlayListApplicationResponse"][];
/** Format: int32 */
@@ -1087,6 +1201,7 @@ export interface components {
tags: components["schemas"]["Genre"][];
isExplicit: boolean;
isPublished: boolean;
coauthors: components["schemas"]["TrackCoauthorReference"][];
};
TrackAudioUpload: {
/** Format: uuid */
@@ -1098,6 +1213,15 @@ export interface components {
contentType: string;
uploadUrl: string;
};
TrackCoauthorInviteReference: {
userId: string;
name: string;
status: components["schemas"]["CoauthorStatus"];
};
TrackCoauthorReference: {
id: string;
name: string;
};
TrackStatusCountsResponse: {
/** Format: int32 */
total: number | string;
@@ -1213,6 +1337,8 @@ export interface components {
isExplicit: boolean;
isPublished: boolean;
albums: components["schemas"]["TrackAlbumReference"][];
coauthors: components["schemas"]["TrackCoauthorReference"][];
coauthorInvites: components["schemas"]["TrackCoauthorInviteReference"][];
};
};
responses: never;
@@ -1798,6 +1924,102 @@ export interface operations {
};
};
};
AcceptCoauthorInvitation: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
RejectCoauthorInvitation: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetPlaybackPublicConfig: {
parameters: {
query?: never;
@@ -2822,6 +3044,7 @@ export interface operations {
pageSize?: number | string;
status?: components["schemas"]["ProcessingStatus"][];
published?: boolean;
includeCoauthored?: boolean;
};
header?: never;
path: {
@@ -2979,6 +3202,111 @@ export interface operations {
};
};
};
InviteCoauthors: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["InviteCoauthorsRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TrackCoauthorInviteReference"][];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
RemoveCoauthor: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
userId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
RequestTrackUploadUrls: {
parameters: {
query?: never;
@@ -3057,6 +3385,36 @@ export interface operations {
};
};
};
GetCoauthorInvitations: {
parameters: {
query?: {
pageNumber?: number | string;
pageSize?: number | string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfCoauthorInvitationResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetUserById: {
parameters: {
query?: never;
@@ -21,6 +21,7 @@
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
coauthors={track.coauthors}
explicit={track.explicit}
{active}
>
@@ -0,0 +1,214 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ActionResult, SubmitFunction } from '@sveltejs/kit';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import X from '@lucide/svelte/icons/x';
import type { CoauthorInvite, LibraryTrack } from '$lib/types';
import { COAUTHOR_INVITES_MAX, SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import { COAUTHOR_STATUS, inviteCandidates, type UserOption } from '$lib/data/coauthors';
import Modal from '$lib/components/ui/overlay/Modal.svelte';
import TagInput from '$lib/components/ui/forms/TagInput.svelte';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import Avatar from '$lib/components/ui/media/Avatar.svelte';
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
interface Props {
open: boolean;
onClose: () => void;
track: LibraryTrack;
}
let { open, onClose, track }: Props = $props();
const listId = $props.id();
let invites = $state<CoauthorInvite[]>([]);
let selected = $state<UserOption[]>([]);
let search = $state('');
let results = $state<UserOption[]>([]);
let error = $state('');
let sending = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
let searchToken = 0;
$effect(() => {
if (!open) return;
track.id;
untrack(() => {
invites = track.coauthorInvites;
selected = [];
search = '';
results = [];
error = '';
sending = false;
});
});
const candidates = $derived(
inviteCandidates(results, {
ownerId: track.ownerUserId,
selectedIds: selected.map((user) => user.id),
invites
})
);
const full = $derived(selected.length >= COAUTHOR_INVITES_MAX);
function onSearchInput() {
clearTimeout(timer);
const query = search.trim();
const token = ++searchToken;
if (query.length < SEARCH_MIN_LENGTH) {
results = [];
return;
}
timer = setTimeout(async () => {
try {
const res = await fetch(`/api/users/search?${new URLSearchParams({ q: query })}`);
if (!res.ok) throw new Error(String(res.status));
const found = (await res.json()) as UserOption[];
if (token === searchToken) results = found;
} catch {
if (token === searchToken) {
results = [];
error = 'No se pudo buscar usuarios.';
}
}
}, SEARCH_DEBOUNCE_MS);
}
function select(user: UserOption) {
if (full || selected.some((item) => item.id === user.id)) return;
selected = [...selected, user];
search = '';
results = [];
searchToken++;
}
function deselect(id: string) {
selected = selected.filter((user) => user.id !== id);
}
function failureMessage(result: ActionResult, fallback: string): string {
return result.type === 'failure' && typeof result.data?.message === 'string'
? result.data.message
: fallback;
}
const inviteSubmit: SubmitFunction = () => {
sending = true;
error = '';
return async ({ result }) => {
sending = false;
if (result.type === 'success') {
invites = (result.data as { invites: CoauthorInvite[] }).invites;
selected = [];
await invalidateAll();
} else {
error = failureMessage(result, 'No se pudieron enviar las invitaciones.');
}
};
};
const removeSubmit: SubmitFunction = ({ formData }) => {
const userId = String(formData.get('userId'));
const previous = invites;
invites = invites.filter((invite) => invite.userId !== userId);
error = '';
return async ({ result }) => {
if (result.type === 'success') {
await invalidateAll();
return;
}
invites = previous;
error = failureMessage(result, 'No se pudo quitar al coautor.');
};
};
</script>
<Modal {open} {onClose} eyebrow={track.title} title="Coautores" maxWidth="max-w-lg">
<p class="mt-2 text-sm text-fg-2">
Los usuarios que invites aparecerán como coautores cuando acepten la invitación.
</p>
{#if error}
<Alert tone="danger" class="mt-4">{error}</Alert>
{/if}
<section class="mt-5" aria-label="Invitaciones enviadas">
{#if invites.length > 0}
<ul class="flex flex-col gap-1">
{#each invites as invite (invite.userId)}
<li class="flex items-center gap-3 rounded-control py-1.5 pl-2 hover:bg-hover">
<Avatar name={invite.name} size="sm" />
<a
href="/user/{invite.userId}"
class="min-w-0 flex-1 truncate text-sm text-fg hover:underline"
>
{invite.name}
</a>
<StatusBadge
label={COAUTHOR_STATUS[invite.status].label}
tone={COAUTHOR_STATUS[invite.status].tone}
/>
<form method="POST" action="?/removeCoauthor" use:enhance={removeSubmit}>
<input type="hidden" name="trackId" value={track.id} />
<input type="hidden" name="userId" value={invite.userId} />
<IconButton label="Quitar a {invite.name}" type="submit" size="xs">
<X class="size-icon-sm" />
</IconButton>
</form>
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-fg-2">Todavía no has invitado a nadie.</p>
{/if}
</section>
<form method="POST" action="?/inviteCoauthors" use:enhance={inviteSubmit} class="mt-6">
<input type="hidden" name="trackId" value={track.id} />
{#each selected as user (user.id)}
<input type="hidden" name="userIds" value={user.id} />
{/each}
<label for="{listId}-search" class="mb-2 block text-sm font-medium text-fg-2">
Invitar usuarios
</label>
<TagInput
id="{listId}-search"
label="Buscar usuarios"
placeholder={full ? `Máximo ${COAUTHOR_INVITES_MAX} usuarios` : 'Buscar por nombre'}
disabled={full || sending}
controls="{listId}-results"
tags={selected.map((user) => ({ id: user.id, label: user.name }))}
bind:value={search}
oninput={onSearchInput}
onadd={() => {
if (candidates.length > 0) select(candidates[0]);
}}
onremove={deselect}
/>
<ul id="{listId}-results" aria-label="Resultados" class="mt-2 flex flex-col gap-0.5">
{#each candidates as user (user.id)}
<li>
<button
type="button"
onclick={() => select(user)}
class="flex w-full items-center gap-3 rounded-control p-1.5 text-left text-sm text-fg focus-ring transition-colors hover:bg-hover"
>
<Avatar name={user.name} src={user.profilePictureUrl} size="sm" />
<span class="min-w-0 flex-1 truncate">{user.name}</span>
</button>
</li>
{/each}
</ul>
<div class="mt-5 flex justify-end gap-2.5">
<Button variant="secondary" onclick={onClose}>Cerrar</Button>
<Button type="submit" disabled={selected.length === 0 || sending}>
{selected.length > 1 ? `Invitar a ${selected.length} usuarios` : 'Enviar invitación'}
</Button>
</div>
</form>
</Modal>
@@ -0,0 +1,92 @@
<script lang="ts">
import type { SubmitFunction } from '@sveltejs/kit';
import { flip } from 'svelte/animate';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import Mail from '@lucide/svelte/icons/mail';
import type { CoauthorInvitation } from '$lib/types';
import { fmtDate } from '$lib/utils/format';
import { collapse } from '$lib/utils/transitions';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import ListRow from '$lib/components/ui/media/ListRow.svelte';
interface Props {
invitations: CoauthorInvitation[];
}
let { invitations }: Props = $props();
let hiddenIds = $state<string[]>([]);
let error = $state('');
const visible = $derived(invitations.filter((invitation) => !hiddenIds.includes(invitation.id)));
const respondSubmit: SubmitFunction = ({ formData }) => {
const id = String(formData.get('invitationId'));
hiddenIds = [...hiddenIds, id];
error = '';
return async ({ result, update }) => {
if (result.type === 'success') {
await update({ reset: false });
hiddenIds = hiddenIds.filter((item) => item !== id);
return;
}
hiddenIds = hiddenIds.filter((item) => item !== id);
error =
result.type === 'failure' && typeof result.data?.message === 'string'
? result.data.message
: 'No se pudo responder a la invitación.';
await invalidateAll();
};
};
</script>
{#if error}
<Alert tone="danger" class="mt-4">{error}</Alert>
{/if}
{#if visible.length > 0}
<ul class="mt-4 flex flex-col">
{#each visible as invitation, i (invitation.id)}
<li
animate:flip={{ duration: 200 }}
out:collapse
class="animate-enter"
style="--i:{Math.min(i, 9)}"
>
<ListRow
trackId={invitation.trackId}
title={invitation.trackTitle}
subtitle={invitation.artist}
subtitleHref={invitation.ownerUserId}
size="lg"
class="flex-wrap sm:flex-nowrap"
>
{#snippet trailing()}
<span class="ml-auto hidden text-xs text-fg-2 sm:block">
{fmtDate(invitation.invitedAt)}
</span>
<div class="flex shrink-0 items-center gap-2">
<form method="POST" action="?/reject" use:enhance={respondSubmit}>
<input type="hidden" name="invitationId" value={invitation.id} />
<Button type="submit" size="sm" variant="secondary">Rechazar</Button>
</form>
<form method="POST" action="?/accept" use:enhance={respondSubmit}>
<input type="hidden" name="invitationId" value={invitation.id} />
<Button type="submit" size="sm">Aceptar</Button>
</form>
</div>
{/snippet}
</ListRow>
</li>
{/each}
</ul>
{:else}
<EmptyState
icon={Mail}
title="No tienes invitaciones"
description="Cuando otro artista te invite a ser coautor de una canción, la verás aquí para aceptarla o rechazarla."
/>
{/if}
@@ -32,6 +32,7 @@
import SearchInput from '$lib/components/ui/primitives/SearchInput.svelte';
import ChipGroup from '$lib/components/ui/primitives/ChipGroup.svelte';
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
import CoauthorsDialog from '$lib/components/music/library/CoauthorsDialog.svelte';
import ManageTrackList from '$lib/components/music/library/ManageTrackList.svelte';
interface Props {
@@ -56,6 +57,8 @@
(track) => track.id
);
untrack(() => list.reset(tracks));
let search = $state('');
let selectedIds = $state<string[]>([]);
let pendingDelete = $state<LibraryTrack[]>([]);
@@ -65,6 +68,8 @@
let publishValue = $state('true');
let timer: ReturnType<typeof setTimeout> | undefined;
let coauthorTrackId = $state('');
let coauthorsOpen = $state(false);
let hiddenIds = $state<string[]>([]);
let overrides = $state<Record<string, boolean>>({});
let deleteError = $state('');
@@ -162,6 +167,7 @@
};
};
const coauthorTrack = $derived(items.find((track) => track.id === coauthorTrackId));
const hasFilters = $derived(query !== '' || filter !== 'all');
const counters = $derived(filterCounts(counts));
const filterOptions = $derived(
@@ -206,6 +212,11 @@
else publish([track]);
}
function openCoauthors(track: LibraryTrack) {
coauthorTrackId = track.id;
coauthorsOpen = true;
}
function unpublishSelected() {
pendingUnpublish = selected.filter((track) => track.published);
}
@@ -274,6 +285,7 @@
bind:selectedIds
onPlay={togglePlay}
onPublish={requestToggle}
onCoauthors={openCoauthors}
onDelete={(track) => (pendingDelete = [track])}
/>
<InfiniteScroll
@@ -306,6 +318,14 @@
</EmptyState>
{/if}
{#if coauthorTrack}
<CoauthorsDialog
open={coauthorsOpen}
onClose={() => (coauthorsOpen = false)}
track={coauthorTrack}
/>
{/if}
<ConfirmDialog
open={pendingDelete.length > 0}
onClose={() => {
@@ -18,12 +18,14 @@
class: `${TEXT} tabular-nums`
},
{ id: 'publish', width: COL.publish },
{ id: 'coauthors', width: COL.action, mobile: COL.actionMobile },
{ id: 'delete', width: COL.action, mobile: COL.actionMobile }
];
</script>
<script lang="ts">
import X from '@lucide/svelte/icons/x';
import Users from '@lucide/svelte/icons/users';
import type { LibraryTrack } from '$lib/types';
import { player } from '$lib/player/player.svelte';
import { genreLabels } from '$lib/data/genres';
@@ -41,6 +43,7 @@
selectedIds: string[];
onPlay: (index: number) => void;
onPublish: (track: LibraryTrack) => void;
onCoauthors: (track: LibraryTrack) => void;
onDelete: (track: LibraryTrack) => void;
class?: string;
}
@@ -50,6 +53,7 @@
selectedIds = $bindable(),
onPlay,
onPublish,
onCoauthors,
onDelete,
class: klass = ''
}: Props = $props();
@@ -106,6 +110,15 @@
{track.published ? 'Despublicar' : 'Publicar'}
</Button>
</span>
{:else if column.id === 'coauthors'}
<IconButton
label="Coautores"
size="xs"
pressed={track.coauthorInvites.length > 0}
onclick={() => onCoauthors(track)}
>
<Users class="size-icon-sm" />
</IconButton>
{:else}
<IconButton label="Borrar canción" size="xs" revealOnHover onclick={() => onDelete(track)}>
<X class="size-icon-sm" />
@@ -60,6 +60,7 @@
title={player.current.title}
artist={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
coauthors={player.current.coauthors}
explicit={player.current.explicit}
active
/>
@@ -123,6 +124,7 @@
title={track.title}
subtitle={track.artist || '—'}
subtitleHref={track.ownerUserId}
coauthors={track.coauthors}
explicit={track.explicit}
trackId={track.id}
class="p-2"
@@ -42,6 +42,7 @@
<ArtistLink
name={current?.artist || '—'}
ownerUserId={current?.ownerUserId}
coauthors={current?.coauthors}
class="mt-0.5 text-xs text-fg-2"
/>
</div>
@@ -0,0 +1,99 @@
<script lang="ts" module>
export interface Tag {
id: string;
label: string;
}
</script>
<script lang="ts">
import X from '@lucide/svelte/icons/x';
interface Props {
tags: Tag[];
value?: string;
label: string;
id?: string;
placeholder?: string;
disabled?: boolean;
controls?: string;
onadd?: (text: string) => void;
onremove: (id: string) => void;
oninput?: () => void;
class?: string;
}
let {
tags,
value = $bindable(''),
label,
id,
placeholder,
disabled = false,
controls,
onadd,
onremove,
oninput,
class: klass = ''
}: Props = $props();
let input: HTMLInputElement | undefined = $state();
function commit() {
const text = value.trim();
if (!text) return;
onadd?.(text);
}
function onkeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
commit();
} else if (event.key === 'Backspace' && value === '' && tags.length > 0) {
onremove(tags[tags.length - 1].id);
}
}
function remove(tagId: string) {
onremove(tagId);
input?.focus();
}
</script>
<div
role="group"
aria-label={label}
class="flex min-h-11 flex-wrap items-center gap-2 rounded-control bg-surface-2 px-3 py-2 transition focus-within:bg-surface-hover {disabled
? 'opacity-40'
: ''} {klass}"
>
<ul class="contents">
{#each tags as tag (tag.id)}
<li class="flex items-center gap-1 rounded-full bg-btn py-1 pr-1 pl-3 text-xs text-fg">
<span class="max-w-40 truncate">{tag.label}</span>
<button
type="button"
{disabled}
aria-label="Quitar {tag.label}"
onclick={() => remove(tag.id)}
class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 focus-ring transition-colors hover:bg-btn-hover hover:text-fg"
>
<X class="size-icon-xs" />
</button>
</li>
{/each}
</ul>
<input
bind:this={input}
bind:value
{id}
{placeholder}
{disabled}
{oninput}
{onkeydown}
type="text"
autocomplete="off"
aria-label={label}
aria-controls={controls}
class="min-w-32 flex-1 bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
/>
</div>
@@ -2,14 +2,30 @@
interface Props {
name?: string | null;
ownerUserId?: string | null;
coauthors?: { id: string; name: string }[];
class?: string;
}
let { name, ownerUserId, class: klass = '' }: Props = $props();
let { name, ownerUserId, coauthors = [], class: klass = '' }: Props = $props();
</script>
{#snippet person(label: string, id?: string | null)}
{#if id}
<a href="/user/{id}" onclick={(e) => e.stopPropagation()} class="hover:underline">{label}</a>
{:else}
{label}
{/if}
{/snippet}
{#if name}
{#if ownerUserId}
{#if coauthors.length > 0}
<span class="block max-w-full truncate {klass}">
{@render person(name, ownerUserId)}{#each coauthors as coauthor (coauthor.id)}, {@render person(
coauthor.name,
coauthor.id
)}{/each}
</span>
{:else if ownerUserId}
<a
href="/user/{ownerUserId}"
onclick={(e) => e.stopPropagation()}
@@ -13,6 +13,7 @@
title: string;
subtitle?: string | null;
subtitleHref?: string | null;
coauthors?: { id: string; name: string }[];
explicit?: boolean;
trackId?: string;
trackIds?: string[];
@@ -35,6 +36,7 @@
title,
subtitle,
subtitleHref,
coauthors,
explicit = false,
trackId,
trackIds,
@@ -60,6 +62,7 @@
{title}
artist={subtitle}
ownerUserId={subtitleHref}
{coauthors}
{trackId}
{trackIds}
{coverSrc}
@@ -10,6 +10,7 @@
title: string;
artist?: string | null;
ownerUserId?: string | null;
coauthors?: { id: string; name: string }[];
coverSrc?: string | null;
size?: 'xs' | 'sm' | 'lg';
explicit?: boolean;
@@ -26,6 +27,7 @@
title,
artist,
ownerUserId,
coauthors,
coverSrc,
size = 'sm',
explicit = false,
@@ -60,6 +62,6 @@
>{title}</span
>
</div>
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-2" />
<ArtistLink name={artist} {ownerUserId} {coauthors} class="mt-0.5 text-xs text-fg-2" />
</div>
</div>
@@ -3,6 +3,7 @@
href: string;
label: string;
active: boolean;
count?: number;
}
interface Props {
@@ -20,16 +21,29 @@
const activeHref = $derived(tabs.find((tab) => tab.active)?.href);
$effect(() => {
let nav = $state<HTMLElement>();
function measure() {
const link = activeHref ? links[activeHref] : undefined;
if (!link) return;
if (!link || link.offsetWidth === 0) return;
indicator = { x: link.offsetLeft, width: link.offsetWidth };
measured = true;
if (!animated) requestAnimationFrame(() => (animated = true));
}
$effect(measure);
$effect(() => {
if (!nav) return;
const observer = new ResizeObserver(() => measure());
observer.observe(nav);
for (const link of Object.values(links)) if (link) observer.observe(link);
return () => observer.disconnect();
});
</script>
<nav
bind:this={nav}
aria-label={label}
class="relative inline-flex max-w-full rounded-full bg-surface-2 p-1 {klass}"
>
@@ -51,6 +65,16 @@
: 'text-fg-2 hover:text-fg'}"
>
{tab.label}
{#if tab.count}
<span
class="ml-1.5 inline-grid min-w-5 place-items-center rounded-full px-1.5 text-xs tabular-nums {tab.active
? 'bg-ink/15 text-ink'
: 'bg-accent-tint text-accent-soft'}"
aria-label="{tab.count} pendientes"
>
{tab.count}
</span>
{/if}
</a>
{/each}
</nav>
+3
View File
@@ -22,3 +22,6 @@ export const EXPLORE_USERS_LIMIT = 12;
export const FOLLOW_LIST_PAGE_SIZE = 30;
export const MENU_WIDTH = 240;
export const SUBMENU_WIDTH = 256;
export const COAUTHOR_SEARCH_LIMIT = 8;
export const COAUTHOR_INVITES_MAX = 20;
export const COAUTHOR_INVITATIONS_PAGE_SIZE = 50;
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { inviteCandidates } from '$lib/data/coauthors';
const user = (id: string) => ({ id, name: `User ${id}`, profilePictureUrl: null });
describe('inviteCandidates', () => {
it('hides the owner, selected users and users with an active invitation', () => {
const result = inviteCandidates([user('owner'), user('a'), user('b'), user('c'), user('d')], {
ownerId: 'owner',
selectedIds: ['a'],
invites: [
{ userId: 'b', name: 'B', status: 'Pending' },
{ userId: 'c', name: 'C', status: 'Accepted' }
]
});
expect(result.map((item) => item.id)).toEqual(['d']);
});
it('keeps users who rejected so they can be invited again', () => {
const result = inviteCandidates([user('a')], {
ownerId: null,
selectedIds: [],
invites: [{ userId: 'a', name: 'A', status: 'Rejected' }]
});
expect(result.map((item) => item.id)).toEqual(['a']);
});
});
+30
View File
@@ -0,0 +1,30 @@
import type { CoauthorInvite, CoauthorStatus } from '$lib/types';
import type { TrackTone } from '$lib/data/libraryTracks';
export interface UserOption {
id: string;
name: string;
profilePictureUrl: string | null;
}
export const COAUTHOR_STATUS: Record<CoauthorStatus, { label: string; tone: TrackTone }> = {
Pending: { label: 'Pendiente', tone: 'neutral' },
Accepted: { label: 'Aceptado', tone: 'success' },
Rejected: { label: 'Rechazado', tone: 'danger' }
};
export function inviteCandidates(
results: UserOption[],
{
ownerId,
selectedIds,
invites
}: { ownerId: string | null; selectedIds: string[]; invites: CoauthorInvite[] }
): UserOption[] {
const blocked = new Set([
...selectedIds,
...invites.filter((invite) => invite.status !== 'Rejected').map((invite) => invite.userId)
]);
if (ownerId) blocked.add(ownerId);
return results.filter((user) => !blocked.has(user.id));
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { filesFromDrop } from './uploadQueue';
function fileEntry(name: string) {
const file = new File(['x'], name);
return {
isFile: true,
isDirectory: false,
file: (resolve: (file: File) => void) => resolve(file)
};
}
function directoryEntry(children: unknown[]) {
let served = false;
return {
isFile: false,
isDirectory: true,
createReader: () => ({
readEntries: (resolve: (entries: unknown[]) => void) => {
resolve(served ? [] : children);
served = true;
}
})
};
}
function transfer(entries: unknown[], files: File[] = []) {
return {
items: entries.map((entry) => ({ webkitGetAsEntry: () => entry })),
files
} as unknown as DataTransfer;
}
describe('filesFromDrop', () => {
it('collects files from nested folders', async () => {
const dropped = transfer([
fileEntry('a.mp3'),
directoryEntry([fileEntry('b.mp3'), directoryEntry([fileEntry('c.mp3')])])
]);
const files = await filesFromDrop(dropped);
expect(files.map((file) => file.name).sort()).toEqual(['a.mp3', 'b.mp3', 'c.mp3']);
});
it('falls back to the plain file list without entries', async () => {
const plain = new File(['x'], 'd.mp3');
const dropped = {
items: [{ webkitGetAsEntry: () => null }],
files: [plain]
} as unknown as DataTransfer;
expect(await filesFromDrop(dropped)).toEqual([plain]);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { isActionFailure } from '@sveltejs/kit';
import { describe, expect, it, vi } from 'vitest';
import type { ApiClient } from '$lib/server/api';
import {
inviteCoauthors,
loadInvitationCount,
parseUserIds,
removeCoauthor,
respondToInvitation,
searchUsers
} from '$lib/server/coauthors';
const asApi = (fake: unknown) => fake as ApiClient;
function failed(status: number) {
return { error: { status }, response: { status } };
}
describe('parseUserIds', () => {
it('trims, drops blanks and removes duplicates', () => {
expect(parseUserIds([' a ', '', 'b', 'a'])).toEqual(['a', 'b']);
});
});
describe('inviteCoauthors', () => {
it('rejects an empty selection without calling the API', async () => {
const POST = vi.fn();
const result = await inviteCoauthors(asApi({ POST }), 't1', ['', ' ']);
expect(isActionFailure(result)).toBe(true);
expect(POST).not.toHaveBeenCalled();
});
it('rejects more users than the API accepts', async () => {
const POST = vi.fn();
const ids = Array.from({ length: 21 }, (_, i) => String(i));
const result = await inviteCoauthors(asApi({ POST }), 't1', ids);
expect(isActionFailure(result)).toBe(true);
expect(POST).not.toHaveBeenCalled();
});
it('returns the full invite list of the track', async () => {
const POST = vi.fn(async () => ({
data: [
{ userId: 'u1', name: 'Ana', status: 'Pending' },
{ userId: 'u2', name: 'Leo', status: 'Accepted' }
]
}));
const result = await inviteCoauthors(asApi({ POST }), 't1', ['u1', 'u1']);
expect(POST).toHaveBeenCalledWith('/tracks/{id}/coauthors', {
params: { path: { id: 't1' } },
body: { userIds: ['u1'] }
});
expect(result).toEqual({
invites: [
{ userId: 'u1', name: 'Ana', status: 'Pending' },
{ userId: 'u2', name: 'Leo', status: 'Accepted' }
]
});
});
it.each([
[400, 'ti mismo'],
[403, 'autor'],
[409, 'ya tiene']
])('explains a %i response', async (status, text) => {
const POST = vi.fn(async () => failed(status));
const result = await inviteCoauthors(asApi({ POST }), 't1', ['u1']);
expect(isActionFailure(result)).toBe(true);
if (!isActionFailure(result)) return;
expect(result.status).toBe(status);
expect(result.data).toMatchObject({ message: expect.stringContaining(text) });
});
});
describe('removeCoauthor', () => {
it('returns the removed user', async () => {
const DELETE = vi.fn(async () => ({}));
expect(await removeCoauthor(asApi({ DELETE }), 't1', 'u1')).toEqual({ removed: 'u1' });
expect(DELETE).toHaveBeenCalledWith('/tracks/{id}/coauthors/{userId}', {
params: { path: { id: 't1', userId: 'u1' } }
});
});
it('fails when the API refuses', async () => {
const DELETE = vi.fn(async () => failed(403));
expect(isActionFailure(await removeCoauthor(asApi({ DELETE }), 't1', 'u1'))).toBe(true);
});
});
describe('respondToInvitation', () => {
it('calls the endpoint matching the decision', async () => {
const POST = vi.fn(async () => ({}));
await respondToInvitation(asApi({ POST }), 'i1', 'accept');
await respondToInvitation(asApi({ POST }), 'i1', 'reject');
expect(POST).toHaveBeenNthCalledWith(1, '/coauthor-invitations/{id}/accept', {
params: { path: { id: 'i1' } }
});
expect(POST).toHaveBeenNthCalledWith(2, '/coauthor-invitations/{id}/reject', {
params: { path: { id: 'i1' } }
});
});
it('reports an already answered invitation', async () => {
const POST = vi.fn(async () => failed(409));
const result = await respondToInvitation(asApi({ POST }), 'i1', 'accept');
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) expect(result.status).toBe(409);
});
});
describe('loadInvitationCount', () => {
it('reads the total of pending invitations', async () => {
const GET = vi.fn(async () => ({ data: { items: [], totalItemCount: '3' } }));
expect(await loadInvitationCount(asApi({ GET }))).toBe(3);
});
it('falls back to zero when the request fails', async () => {
const GET = vi.fn(async () => failed(500));
expect(await loadInvitationCount(asApi({ GET }))).toBe(0);
});
});
describe('searchUsers', () => {
it('keeps only what the invite UI needs', async () => {
const GET = vi.fn(async () => ({
data: {
items: [
{
id: 'u1',
name: 'Ana',
firstName: 'A',
secondName: null,
profilePictureUrl: null,
isFollowedByViewer: true
}
]
}
}));
expect(await searchUsers(asApi({ GET }), 'an')).toEqual([
{ id: 'u1', name: 'Ana', profilePictureUrl: null }
]);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { fail } from '@sveltejs/kit';
import { apiErrorDetail, unwrapOrError, type ApiClient } from '$lib/server/api';
import { toCoauthorInvitation, toCoauthorInvite, toCount, toPage } from '$lib/server/mappers';
import {
COAUTHOR_INVITATIONS_PAGE_SIZE,
COAUTHOR_INVITES_MAX,
COAUTHOR_SEARCH_LIMIT
} from '$lib/config';
const INVITE_ERRORS: Record<number, string> = {
400: 'No puedes invitarte a ti mismo.',
403: 'Solo el autor de la canción puede invitar coautores.',
404: 'No se encontró la canción o alguno de los usuarios.',
409: 'Alguno de los usuarios ya tiene una invitación para esta canción.'
};
const REMOVE_ERRORS: Record<number, string> = {
403: 'No puedes quitar a este coautor.',
404: 'La invitación ya no existe.'
};
const RESPOND_ERRORS: Record<number, string> = {
403: 'Esta invitación no es tuya.',
404: 'La invitación ya no existe.',
409: 'Esta invitación ya se respondió.'
};
function failure(
result: { error?: unknown; response?: { status: number } },
messages: Record<number, string>,
fallback: string
) {
const status = result.response?.status ?? 502;
return fail(status >= 400 && status < 600 ? status : 502, {
message: messages[status] ?? fallback,
detail: apiErrorDetail(result.error)
});
}
export function parseUserIds(ids: string[]): string[] {
return [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
}
export async function inviteCoauthors(api: ApiClient, trackId: string, userIds: string[]) {
const unique = parseUserIds(userIds);
if (unique.length === 0) return fail(400, { message: 'Elige al menos un usuario.' });
if (unique.length > COAUTHOR_INVITES_MAX) {
return fail(400, {
message: `Puedes invitar a ${COAUTHOR_INVITES_MAX} usuarios como máximo cada vez.`
});
}
const result = await api.POST('/tracks/{id}/coauthors', {
params: { path: { id: trackId } },
body: { userIds: unique }
});
if (result.error || !result.data) {
return failure(result, INVITE_ERRORS, 'No se pudieron enviar las invitaciones.');
}
return { invites: result.data.map(toCoauthorInvite) };
}
export async function removeCoauthor(api: ApiClient, trackId: string, userId: string) {
const result = await api.DELETE('/tracks/{id}/coauthors/{userId}', {
params: { path: { id: trackId, userId } }
});
if (result.error) return failure(result, REMOVE_ERRORS, 'No se pudo quitar al coautor.');
return { removed: userId };
}
export async function respondToInvitation(
api: ApiClient,
invitationId: string,
decision: 'accept' | 'reject'
) {
const params = { path: { id: invitationId } };
const result =
decision === 'accept'
? await api.POST('/coauthor-invitations/{id}/accept', { params })
: await api.POST('/coauthor-invitations/{id}/reject', { params });
if (result.error) {
return failure(
result,
RESPOND_ERRORS,
decision === 'accept' ? 'No se pudo aceptar la invitación.' : 'No se pudo rechazar.'
);
}
return { invitationId, decision };
}
export async function loadInvitations(api: ApiClient) {
const result = await api.GET('/users/me/coauthor-invitations', {
params: { query: { pageNumber: 1, pageSize: COAUTHOR_INVITATIONS_PAGE_SIZE } }
});
return toPage(
unwrapOrError(result, 'No se pudieron cargar las invitaciones.'),
toCoauthorInvitation
);
}
export async function loadInvitationCount(api: ApiClient): Promise<number> {
const result = await api.GET('/users/me/coauthor-invitations', {
params: { query: { pageNumber: 1, pageSize: 1 } }
});
return toCount(result.data?.totalItemCount);
}
export async function searchUsers(api: ApiClient, query: string) {
const result = await api.GET('/users', {
params: {
query: { usernameSearch: query, pageNumber: 1, pageSize: COAUTHOR_SEARCH_LIMIT }
}
});
return (result.data?.items ?? []).map(({ id, name, profilePictureUrl }) => ({
id,
name,
profilePictureUrl
}));
}
+40 -3
View File
@@ -5,6 +5,8 @@ import {
toCount,
toDatedTrack,
toLikedTrack,
toCoauthorInvitation,
toLibraryTrack,
toListeningStats,
toMix,
toPage,
@@ -24,7 +26,8 @@ const trackDto: components['schemas']['TrackApplicationResponse'] = {
ownerUserId: '42',
tags: [],
isExplicit: true,
isPublished: true
isPublished: true,
coauthors: [{ id: 'u2', name: 'Guest' }]
};
describe('toTrack', () => {
@@ -36,7 +39,8 @@ describe('toTrack', () => {
ownerUserId: '42',
explicit: true,
duration: 183.5,
listensCount: 12
listensCount: 12,
coauthors: [{ id: 'u2', name: 'Guest' }]
});
});
it('falls back to 0 for invalid numbers', () => {
@@ -70,7 +74,8 @@ describe('toMix', () => {
ownerUserId: null,
explicit: false,
duration: 60,
listensCount: 3
listensCount: 3,
coauthors: []
}
]);
});
@@ -167,3 +172,35 @@ describe('toListeningStats', () => {
});
});
});
describe('toLibraryTrack', () => {
it('keeps the coauthor invites with their status', () => {
const track = toLibraryTrack({
...trackDto,
albums: [],
coauthorInvites: [
{ userId: 'u2', name: 'Guest', status: 'Accepted' },
{ userId: 'u3', name: 'Other', status: 'Pending' }
]
});
expect(track.coauthorInvites).toEqual([
{ userId: 'u2', name: 'Guest', status: 'Accepted' },
{ userId: 'u3', name: 'Other', status: 'Pending' }
]);
expect(track.coauthors).toEqual([{ id: 'u2', name: 'Guest' }]);
});
});
describe('toCoauthorInvitation', () => {
it('copies the invitation fields', () => {
const dto = {
id: 'i1',
trackId: 't1',
trackTitle: 'Song',
artist: null,
ownerUserId: '42',
invitedAt: '2026-01-02T00:00:00Z'
};
expect(toCoauthorInvitation(dto)).toEqual(dto);
});
});
+25 -3
View File
@@ -1,6 +1,8 @@
import type { components } from '$lib/api/schema';
import type {
Album,
CoauthorInvitation,
CoauthorInvite,
LibraryTrack,
TrackStatusCounts,
LikedTrack,
@@ -13,6 +15,8 @@ import type {
type TrackDto = components['schemas']['TrackApplicationResponse'];
type UserTrackDto = components['schemas']['UserTrackApplicationResponse'];
type CoauthorInviteDto = components['schemas']['TrackCoauthorInviteReference'];
type CoauthorInvitationDto = components['schemas']['CoauthorInvitationResponse'];
type StatusCountsDto = components['schemas']['TrackStatusCountsResponse'];
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
type PlaylistDto = components['schemas']['PlayListApplicationResponse'];
@@ -34,7 +38,8 @@ export function toTrack(dto: TrackDto): Track {
ownerUserId: dto.ownerUserId,
explicit: dto.isExplicit,
duration: Number(dto.duration) || 0,
listensCount: Number(dto.listensCount) || 0
listensCount: Number(dto.listensCount) || 0,
coauthors: dto.coauthors.map(({ id, name }) => ({ id, name }))
};
}
@@ -42,6 +47,21 @@ export function toDatedTrack(dto: TrackDto): Track & { date: string } {
return { ...toTrack(dto), date: dto.createdAt };
}
export function toCoauthorInvite({ userId, name, status }: CoauthorInviteDto): CoauthorInvite {
return { userId, name, status };
}
export function toCoauthorInvitation(dto: CoauthorInvitationDto): CoauthorInvitation {
return {
id: dto.id,
trackId: dto.trackId,
trackTitle: dto.trackTitle,
artist: dto.artist,
ownerUserId: dto.ownerUserId,
invitedAt: dto.invitedAt
};
}
export function toLibraryTrack(dto: UserTrackDto): LibraryTrack {
return {
...toDatedTrack(dto),
@@ -49,7 +69,8 @@ export function toLibraryTrack(dto: UserTrackDto): LibraryTrack {
genres: dto.tags,
albums: dto.albums,
albumTitle: dto.albums.length > 0 ? dto.albums.map((album) => album.title).join(', ') : null,
published: dto.isPublished
published: dto.isPublished,
coauthorInvites: dto.coauthorInvites.map(toCoauthorInvite)
};
}
@@ -75,7 +96,8 @@ export function toMixTrack(item: MixItemDto): Track {
ownerUserId: null,
explicit: false,
duration: Number(item.durationSeconds) || 0,
listensCount: Number(item.listensCount) || 0
listensCount: Number(item.listensCount) || 0,
coauthors: []
};
}
+24
View File
@@ -7,6 +7,28 @@ export interface SessionUser {
export type FollowList = 'followers' | 'following';
export interface TrackCoauthor {
id: string;
name: string;
}
export type CoauthorStatus = 'Pending' | 'Accepted' | 'Rejected';
export interface CoauthorInvite {
userId: string;
name: string;
status: CoauthorStatus;
}
export interface CoauthorInvitation {
id: string;
trackId: string;
trackTitle: string;
artist: string | null;
ownerUserId: string;
invitedAt: string;
}
export interface Track {
id: string;
title: string;
@@ -15,6 +37,7 @@ export interface Track {
explicit: boolean;
duration: number;
listensCount: number;
coauthors: TrackCoauthor[];
}
export interface GenreOption {
@@ -31,6 +54,7 @@ export interface LibraryTrack extends Track {
albumTitle: string | null;
albums: { id: string; title: string }[];
published: boolean;
coauthorInvites: CoauthorInvite[];
}
export interface TrackStatusCounts {
+2
View File
@@ -117,6 +117,7 @@
title={item.title}
subtitle={item.artist}
subtitleHref={item.ownerUserId}
coauthors={item.coauthors}
explicit={item.explicit}
trackId={item.id}
class="animate-enter"
@@ -182,6 +183,7 @@
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
coauthors={track.coauthors}
explicit={track.explicit}
>
{#snippet leading()}
@@ -67,6 +67,7 @@
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
coauthors={track.coauthors}
explicit={track.explicit}
active={player.currentId === track.id}
size="lg"
@@ -0,0 +1,9 @@
import type { LayoutServerLoad } from './$types';
import { apiFor } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { loadInvitationCount } from '$lib/server/coauthors';
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url);
return { invitationCount: await loadInvitationCount(apiFor({ fetch, locals })) };
};
@@ -14,6 +14,11 @@
href: '/library/uploads',
label: 'Subidas',
description: 'Sube varias canciones a la vez y añádelas a tu biblioteca.'
},
{
href: '/library/invitations',
label: 'Invitaciones',
description: 'Responde a las invitaciones para figurar como coautor de una canción.'
}
];
</script>
@@ -24,7 +29,7 @@
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import Tabs from '$lib/components/ui/primitives/Tabs.svelte';
let { children } = $props();
let { data, children } = $props();
const pathname = $derived(page.url.pathname.replace(/\/$/, ''));
const current = $derived(
@@ -35,7 +40,12 @@
) ?? SECTIONS[0]
);
const tabs = $derived(
SECTIONS.map(({ href, label }) => ({ href, label, active: href === current.href }))
SECTIONS.map(({ href, label }) => ({
href,
label,
active: href === current.href,
count: href === '/library/invitations' ? data.invitationCount : undefined
}))
);
</script>
@@ -6,6 +6,7 @@ import { formIds, formString } from '$lib/server/forms/fields';
import { tagged } from '$lib/server/actions/tagged';
import { loadLibraryTracks, loadStatusCounts } from '$lib/server/libraryTracks';
import { deleteTracks, setTracksPublished } from '$lib/server/tracks';
import { inviteCoauthors, removeCoauthor } from '$lib/server/coauthors';
import { parseTrackFilter } from '$lib/data/libraryTracks';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
@@ -44,5 +45,18 @@ export const actions: Actions = {
const result = await setTracksPublished(api, trackIds, isPublished);
if (isActionFailure(result)) return result;
return { ...result, isPublished };
}),
inviteCoauthors: tagged('inviteCoauthors', async ({ api, form }) => {
const trackId = formString(form, 'trackId');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
return inviteCoauthors(api, trackId, formIds(form, 'userIds'));
}),
removeCoauthor: tagged('removeCoauthor', async ({ api, form }) => {
const trackId = formString(form, 'trackId');
const userId = formString(form, 'userId');
if (!trackId || !userId) return fail(400, { message: 'Falta el coautor.' });
return removeCoauthor(api, trackId, userId);
})
};
@@ -5,7 +5,13 @@
let { data, form } = $props();
const errorMessage = $derived(
form && 'message' in form && form.action !== 'saveAlbum' ? form.message : undefined
form &&
'message' in form &&
form.action !== 'saveAlbum' &&
form.action !== 'inviteCoauthors' &&
form.action !== 'removeCoauthor'
? form.message
: undefined
);
const published = $derived(
form?.action === 'setPublished' ? (form as { skipped?: number; isPublished?: boolean }) : null
@@ -0,0 +1,25 @@
import type { PageServerLoad, Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { apiFor } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { formString } from '$lib/server/forms/fields';
import { tagged } from '$lib/server/actions/tagged';
import { loadInvitations, respondToInvitation } from '$lib/server/coauthors';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url);
return { invitations: await loadInvitations(apiFor({ fetch, locals })) };
};
function respond(decision: 'accept' | 'reject') {
return tagged(decision, async ({ api, form }) => {
const invitationId = formString(form, 'invitationId');
if (!invitationId) return fail(400, { message: 'Falta la invitación.' });
return respondToInvitation(api, invitationId, decision);
});
}
export const actions: Actions = {
accept: respond('accept'),
reject: respond('reject')
};
@@ -0,0 +1,7 @@
<script lang="ts">
import LibraryInvitationsSection from '$lib/components/music/library/LibraryInvitationsSection.svelte';
let { data } = $props();
</script>
<LibraryInvitationsSection invitations={data.invitations.items} />
@@ -0,0 +1,13 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { apiFor } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { searchUsers } from '$lib/server/coauthors';
import { SEARCH_MIN_LENGTH } from '$lib/config';
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
requireUser(locals, url);
const query = url.searchParams.get('q')?.trim() ?? '';
if (query.length < SEARCH_MIN_LENGTH) return json([]);
return json(await searchUsers(apiFor({ fetch, locals }), query));
};