From 2df09752048840117d6014aa4152db171944a86b Mon Sep 17 00:00:00 2001 From: lop4cwru Date: Mon, 30 Mar 2026 16:19:55 -0400 Subject: [PATCH 01/17] re-initial commit adding back all the changes from the earlier branches so it's consistent with main --- LineUp.AppHost/AppHost.cs | 15 ++++--- .../Controllers/SwapRequestController.cs | 43 +++++++++++++++++++ LineUp.Backend/LineUpContext.cs | 1 + .../Migrations/20260322220958_UniqueEmail.cs | 6 ++- LineUp.Core/Models/SwapRequest.cs | 22 ++++++++++ 5 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 LineUp.Backend/Controllers/SwapRequestController.cs create mode 100644 LineUp.Core/Models/SwapRequest.cs diff --git a/LineUp.AppHost/AppHost.cs b/LineUp.AppHost/AppHost.cs index 4be8ede..fba39b5 100644 --- a/LineUp.AppHost/AppHost.cs +++ b/LineUp.AppHost/AppHost.cs @@ -57,24 +57,27 @@ .WaitFor(api); } -builder.Eventing.Subscribe((e, ct) => { - switch (e.Resource.Name) +builder.Eventing.Subscribe( + (e, ct) => { - case "api": + switch (e.Resource.Name) + { + case "api": { var endpoint = api.GetEndpoint("http"); Console.WriteLine($"Backend: {endpoint.Url}"); Console.WriteLine($"Scalar: {endpoint.Url}/scalar"); break; } - case "web": + case "web": { var endpoint = web.GetEndpoint("http"); Console.WriteLine($"Frontend: {endpoint.Url}"); break; } + } + return Task.CompletedTask; } - return Task.CompletedTask; -}); +); builder.Build().Run(); diff --git a/LineUp.Backend/Controllers/SwapRequestController.cs b/LineUp.Backend/Controllers/SwapRequestController.cs new file mode 100644 index 0000000..76cc051 --- /dev/null +++ b/LineUp.Backend/Controllers/SwapRequestController.cs @@ -0,0 +1,43 @@ +using System.Security.Claims; +using LineUp.Backend.Models; +using LineUp.Core.Attributes; +using LineUp.Core.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LineUp.Backend.Controllers; + +[Route("api/Swap")] +[ApiController] +public class SwapRequestController(LineUpContext context) : ControllerBase +{ + [HttpGet("{guid:guid}/processSwap")] + public IActionResult ProcessSwap(Guid guid) + { + SwapRequest? swap = context.SwapRequests.FirstOrDefault(s => s.Guid == guid); + if (swap == null) + { + return NotFound(); + } + List fromPartyA = swap.FromPartyA; + List fromPartyB = swap.FromPartyB; + //set the ShiftOwner on all partyBshifts to A and vice versa + if (fromPartyA == null || fromPartyB == null) + { + return NotFound(); + } + Availability partyA = fromPartyA[0].Availability; + Availability partyB = fromPartyA[0].Availability; + foreach (ShiftAssignment shift in fromPartyB) + { + shift.Availability = partyA; + } + foreach (ShiftAssignment shift in fromPartyA) + { + shift.Availability = partyB; + } + context.SaveChanges(); + return Ok(); + } +} diff --git a/LineUp.Backend/LineUpContext.cs b/LineUp.Backend/LineUpContext.cs index d5dbb13..a945491 100644 --- a/LineUp.Backend/LineUpContext.cs +++ b/LineUp.Backend/LineUpContext.cs @@ -13,6 +13,7 @@ public class LineUpContext : DbContext public DbSet QuestionOptions { get; set; } public DbSet FormQuestionAnswers { get; set; } public DbSet ShiftAssignments { get; set; } + public DbSet SwapRequests { get; set; } public LineUpContext(DbContextOptions options) : base(options) { } diff --git a/LineUp.Backend/Migrations/20260322220958_UniqueEmail.cs b/LineUp.Backend/Migrations/20260322220958_UniqueEmail.cs index c5837f9..1b208fc 100644 --- a/LineUp.Backend/Migrations/20260322220958_UniqueEmail.cs +++ b/LineUp.Backend/Migrations/20260322220958_UniqueEmail.cs @@ -14,7 +14,8 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "IX_Availabilities_Id_UserEmail", table: "Availabilities", columns: new[] { "Id", "UserEmail" }, - unique: true); + unique: true + ); } /// @@ -22,7 +23,8 @@ protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropIndex( name: "IX_Availabilities_Id_UserEmail", - table: "Availabilities"); + table: "Availabilities" + ); } } } diff --git a/LineUp.Core/Models/SwapRequest.cs b/LineUp.Core/Models/SwapRequest.cs new file mode 100644 index 0000000..acecb39 --- /dev/null +++ b/LineUp.Core/Models/SwapRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using LineUp.Core.Attributes; +using Microsoft.EntityFrameworkCore; + +namespace LineUp.Core.Models; + +[Index(nameof(Guid))] +public class SwapRequest +{ + public int Id { get; set; } + public Guid Guid { get; init; } = Guid.NewGuid(); + public required List FromPartyA { get; set; } + + public required List FromPartyB { get; set; } + + public bool partyAConfirm = false; + + public bool partyBConfirm = false; + + [JsonDoNotSerialize] + public required Schedule Schedule { get; set; } +} From 05f484e41decfef53278aeac3a5ef47f28d8ac9e Mon Sep 17 00:00:00 2001 From: lop4cwru Date: Tue, 31 Mar 2026 18:57:27 -0400 Subject: [PATCH 02/17] Swap migrations and a lil bit of frontend --- .../20260330203452_SwapSystem.Designer.cs | 442 ++++++++++++++++++ .../Migrations/20260330203452_SwapSystem.cs | 126 +++++ .../Migrations/LineUpContextModelSnapshot.cs | 59 +++ lineup-client/src/pages/Availability.tsx | 8 + 4 files changed, 635 insertions(+) create mode 100644 LineUp.Backend/Migrations/20260330203452_SwapSystem.Designer.cs create mode 100644 LineUp.Backend/Migrations/20260330203452_SwapSystem.cs diff --git a/LineUp.Backend/Migrations/20260330203452_SwapSystem.Designer.cs b/LineUp.Backend/Migrations/20260330203452_SwapSystem.Designer.cs new file mode 100644 index 0000000..06e0878 --- /dev/null +++ b/LineUp.Backend/Migrations/20260330203452_SwapSystem.Designer.cs @@ -0,0 +1,442 @@ +// +using System; +using LineUp.Backend; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LineUp.Backend.Migrations +{ + [DbContext(typeof(LineUpContext))] + [Migration("20260330203452_SwapSystem")] + partial class SwapSystem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LineUp.Core.Models.Availability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.PrimitiveCollection("AvailabilitySlots") + .IsRequired() + .HasColumnType("timestamp with time zone[]"); + + b.Property("Guid") + .HasColumnType("uuid"); + + b.Property("PreferencesId") + .HasColumnType("uuid"); + + b.Property("ScheduleId") + .HasColumnType("integer"); + + b.Property("UserEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Guid"); + + b.HasIndex("PreferencesId"); + + b.HasIndex("ScheduleId"); + + b.HasIndex("Id", "UserEmail") + .IsUnique(); + + b.ToTable("Availabilities"); + }); + + modelBuilder.Entity("LineUp.Core.Models.AvailabilityPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("AvailabilityPreferences"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Forms.Form", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.HasKey("Id"); + + b.ToTable("Forms"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Forms.FormQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FormId") + .HasColumnType("integer"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FormId"); + + b.ToTable("FormQuestions"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Forms.FormQuestionAnswer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnswerId") + .HasColumnType("integer"); + + b.Property("AnswerText") + .IsRequired() + .HasColumnType("text"); + + b.Property("AvailabilityId") + .HasColumnType("integer"); + + b.Property("FormQuestionId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AvailabilityId"); + + b.HasIndex("FormQuestionId"); + + b.ToTable("FormQuestionAnswers"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Forms.QuestionOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FormQuestionId") + .HasColumnType("integer"); + + b.Property("OptionText") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FormQuestionId"); + + b.ToTable("QuestionOptions"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Schedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Auth0UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection("DateCoverage") + .IsRequired() + .HasColumnType("date[]"); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("FormId") + .HasColumnType("integer"); + + b.Property("Guid") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SchedulePreferencesId") + .HasColumnType("uuid"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.HasKey("Id"); + + b.HasIndex("FormId") + .IsUnique(); + + b.HasIndex("SchedulePreferencesId"); + + b.HasIndex("Auth0UserId", "Guid"); + + b.ToTable("Schedules"); + }); + + modelBuilder.Entity("LineUp.Core.Models.SchedulePreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MaximumShiftDurationMinutes") + .HasColumnType("integer"); + + b.Property("MaximumShiftsPerWorker") + .HasColumnType("integer"); + + b.Property("MinutesPerSlot") + .HasColumnType("integer"); + + b.Property("ShiftIntervals") + .HasColumnType("integer"); + + b.Property("UsersPerShift") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("SchedulePreferences"); + }); + + modelBuilder.Entity("LineUp.Core.Models.ShiftAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvailabilityId") + .HasColumnType("integer"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ScheduleId") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SwapRequestId") + .HasColumnType("integer"); + + b.Property("SwapRequestId1") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AvailabilityId"); + + b.HasIndex("ScheduleId"); + + b.HasIndex("SwapRequestId"); + + b.HasIndex("SwapRequestId1"); + + b.ToTable("ShiftAssignments"); + }); + + modelBuilder.Entity("LineUp.Core.Models.SwapRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Guid") + .HasColumnType("uuid"); + + b.Property("ScheduleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Guid"); + + b.HasIndex("ScheduleId"); + + b.ToTable("SwapRequests"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Availability", b => + { + b.HasOne("LineUp.Core.Models.AvailabilityPreferences", "Preferences") + .WithMany() + .HasForeignKey("PreferencesId"); + + b.HasOne("LineUp.Core.Models.Schedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Preferences"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Forms.FormQuestion", b => + { + b.HasOne("LineUp.Core.Models.Forms.Form", null) + .WithMany("Questions") + .HasForeignKey("FormId"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Forms.FormQuestionAnswer", b => + { + b.HasOne("LineUp.Core.Models.Availability", null) + .WithMany("FormAnswers") + .HasForeignKey("AvailabilityId"); + + b.HasOne("LineUp.Core.Models.Forms.FormQuestion", "Question") + .WithMany() + .HasForeignKey("FormQuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Forms.QuestionOptions", b => + { + b.HasOne("LineUp.Core.Models.Forms.FormQuestion", null) + .WithMany("Options") + .HasForeignKey("FormQuestionId"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Schedule", b => + { + b.HasOne("LineUp.Core.Models.Forms.Form", "Form") + .WithOne("Schedule") + .HasForeignKey("LineUp.Core.Models.Schedule", "FormId"); + + b.HasOne("LineUp.Core.Models.SchedulePreferences", "SchedulePreferences") + .WithMany() + .HasForeignKey("SchedulePreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Form"); + + b.Navigation("SchedulePreferences"); + }); + + modelBuilder.Entity("LineUp.Core.Models.ShiftAssignment", b => + { + b.HasOne("LineUp.Core.Models.Availability", "Availability") + .WithMany() + .HasForeignKey("AvailabilityId"); + + b.HasOne("LineUp.Core.Models.Schedule", null) + .WithMany("ShiftAssignments") + .HasForeignKey("ScheduleId"); + + b.HasOne("LineUp.Core.Models.SwapRequest", null) + .WithMany("FromPartyA") + .HasForeignKey("SwapRequestId"); + + b.HasOne("LineUp.Core.Models.SwapRequest", null) + .WithMany("FromPartyB") + .HasForeignKey("SwapRequestId1"); + + b.Navigation("Availability"); + }); + + modelBuilder.Entity("LineUp.Core.Models.SwapRequest", b => + { + b.HasOne("LineUp.Core.Models.Schedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Availability", b => + { + b.Navigation("FormAnswers"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Forms.Form", b => + { + b.Navigation("Questions"); + + b.Navigation("Schedule") + .IsRequired(); + }); + + modelBuilder.Entity("LineUp.Core.Models.Forms.FormQuestion", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("LineUp.Core.Models.Schedule", b => + { + b.Navigation("ShiftAssignments"); + }); + + modelBuilder.Entity("LineUp.Core.Models.SwapRequest", b => + { + b.Navigation("FromPartyA"); + + b.Navigation("FromPartyB"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LineUp.Backend/Migrations/20260330203452_SwapSystem.cs b/LineUp.Backend/Migrations/20260330203452_SwapSystem.cs new file mode 100644 index 0000000..4c8080a --- /dev/null +++ b/LineUp.Backend/Migrations/20260330203452_SwapSystem.cs @@ -0,0 +1,126 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LineUp.Backend.Migrations +{ + /// + public partial class SwapSystem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SwapRequestId", + table: "ShiftAssignments", + type: "integer", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "SwapRequestId1", + table: "ShiftAssignments", + type: "integer", + nullable: true + ); + + migrationBuilder.CreateTable( + name: "SwapRequests", + columns: table => new + { + Id = table + .Column(type: "integer", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), + Guid = table.Column(type: "uuid", nullable: false), + ScheduleId = table.Column(type: "integer", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_SwapRequests", x => x.Id); + table.ForeignKey( + name: "FK_SwapRequests_Schedules_ScheduleId", + column: x => x.ScheduleId, + principalTable: "Schedules", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_ShiftAssignments_SwapRequestId", + table: "ShiftAssignments", + column: "SwapRequestId" + ); + + migrationBuilder.CreateIndex( + name: "IX_ShiftAssignments_SwapRequestId1", + table: "ShiftAssignments", + column: "SwapRequestId1" + ); + + migrationBuilder.CreateIndex( + name: "IX_SwapRequests_Guid", + table: "SwapRequests", + column: "Guid" + ); + + migrationBuilder.CreateIndex( + name: "IX_SwapRequests_ScheduleId", + table: "SwapRequests", + column: "ScheduleId" + ); + + migrationBuilder.AddForeignKey( + name: "FK_ShiftAssignments_SwapRequests_SwapRequestId", + table: "ShiftAssignments", + column: "SwapRequestId", + principalTable: "SwapRequests", + principalColumn: "Id" + ); + + migrationBuilder.AddForeignKey( + name: "FK_ShiftAssignments_SwapRequests_SwapRequestId1", + table: "ShiftAssignments", + column: "SwapRequestId1", + principalTable: "SwapRequests", + principalColumn: "Id" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ShiftAssignments_SwapRequests_SwapRequestId", + table: "ShiftAssignments" + ); + + migrationBuilder.DropForeignKey( + name: "FK_ShiftAssignments_SwapRequests_SwapRequestId1", + table: "ShiftAssignments" + ); + + migrationBuilder.DropTable(name: "SwapRequests"); + + migrationBuilder.DropIndex( + name: "IX_ShiftAssignments_SwapRequestId", + table: "ShiftAssignments" + ); + + migrationBuilder.DropIndex( + name: "IX_ShiftAssignments_SwapRequestId1", + table: "ShiftAssignments" + ); + + migrationBuilder.DropColumn(name: "SwapRequestId", table: "ShiftAssignments"); + + migrationBuilder.DropColumn(name: "SwapRequestId1", table: "ShiftAssignments"); + } + } +} diff --git a/LineUp.Backend/Migrations/LineUpContextModelSnapshot.cs b/LineUp.Backend/Migrations/LineUpContextModelSnapshot.cs index 67f3a26..25002fe 100644 --- a/LineUp.Backend/Migrations/LineUpContextModelSnapshot.cs +++ b/LineUp.Backend/Migrations/LineUpContextModelSnapshot.cs @@ -267,15 +267,48 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("StartTime") .HasColumnType("timestamp with time zone"); + b.Property("SwapRequestId") + .HasColumnType("integer"); + + b.Property("SwapRequestId1") + .HasColumnType("integer"); + b.HasKey("Id"); b.HasIndex("AvailabilityId"); b.HasIndex("ScheduleId"); + b.HasIndex("SwapRequestId"); + + b.HasIndex("SwapRequestId1"); + b.ToTable("ShiftAssignments"); }); + modelBuilder.Entity("LineUp.Core.Models.SwapRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Guid") + .HasColumnType("uuid"); + + b.Property("ScheduleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Guid"); + + b.HasIndex("ScheduleId"); + + b.ToTable("SwapRequests"); + }); + modelBuilder.Entity("LineUp.Core.Models.Availability", b => { b.HasOne("LineUp.Core.Models.AvailabilityPreferences", "Preferences") @@ -349,9 +382,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) .WithMany("ShiftAssignments") .HasForeignKey("ScheduleId"); + b.HasOne("LineUp.Core.Models.SwapRequest", null) + .WithMany("FromPartyA") + .HasForeignKey("SwapRequestId"); + + b.HasOne("LineUp.Core.Models.SwapRequest", null) + .WithMany("FromPartyB") + .HasForeignKey("SwapRequestId1"); + b.Navigation("Availability"); }); + modelBuilder.Entity("LineUp.Core.Models.SwapRequest", b => + { + b.HasOne("LineUp.Core.Models.Schedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Schedule"); + }); + modelBuilder.Entity("LineUp.Core.Models.Availability", b => { b.Navigation("FormAnswers"); @@ -374,6 +426,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("ShiftAssignments"); }); + + modelBuilder.Entity("LineUp.Core.Models.SwapRequest", b => + { + b.Navigation("FromPartyA"); + + b.Navigation("FromPartyB"); + }); #pragma warning restore 612, 618 } } diff --git a/lineup-client/src/pages/Availability.tsx b/lineup-client/src/pages/Availability.tsx index 6d12460..f863adb 100644 --- a/lineup-client/src/pages/Availability.tsx +++ b/lineup-client/src/pages/Availability.tsx @@ -174,6 +174,14 @@ const Availability = () => { )} + ) : ( <> From 414a365319a91a4180e3b936080284f04bdd50e9 Mon Sep 17 00:00:00 2001 From: lop4cwru Date: Fri, 3 Apr 2026 23:35:50 -0400 Subject: [PATCH 03/17] reinstate the base functionality that I forgot to bring over the first time --- LineUp.Backend.Tests/CRUDTests.cs | 1 - .../Controllers/ScheduleController.cs | 39 +++++++++++++++++++ LineUp.Backend/LineUpContext.cs | 1 + 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/LineUp.Backend.Tests/CRUDTests.cs b/LineUp.Backend.Tests/CRUDTests.cs index 95921e9..070248b 100644 --- a/LineUp.Backend.Tests/CRUDTests.cs +++ b/LineUp.Backend.Tests/CRUDTests.cs @@ -237,7 +237,6 @@ public async Task DeleteSchedule_Test() public async Task CreateAvailability_Test() { // Arrange - sampleAvailability.Schedule = sampleSchedule; var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; diff --git a/LineUp.Backend/Controllers/ScheduleController.cs b/LineUp.Backend/Controllers/ScheduleController.cs index 60e05f9..3b7d129 100644 --- a/LineUp.Backend/Controllers/ScheduleController.cs +++ b/LineUp.Backend/Controllers/ScheduleController.cs @@ -261,4 +261,43 @@ await context.Availabilities.AnyAsync(a => availabilityToInsert ); } + + [HttpPost("{guid:guid}/requestSwap")] + public IActionResult RequestSwap(Guid guid, [FromBody] List shiftCollection) + { + Schedule? schedule = context.Schedules.FirstOrDefault(s => s.Guid == guid); + if (schedule == null || shiftCollection == null || shiftCollection[0] == null) + { + return NotFound(); + } + //Sort through the shifts (assume an unsorted list) + Availability partyA = shiftCollection[0].Availability; + List partyAShifts = []; + Availability partyB = null; + List partyBShifts = []; + foreach (ShiftAssignment shift in shiftCollection) + { + Availability shiftOwner = shift.Availability; + if (shiftOwner == partyA) + partyAShifts.Append(shift); + else if (partyB == null) + { + partyB = shiftOwner; + partyBShifts.Append(shift); + } + else if (shiftOwner == partyB) + partyBShifts.Append(shift); + else + return BadRequest("More than two parties identified"); + } + SwapRequest swapRequest = new SwapRequest + { + FromPartyA = partyAShifts, + FromPartyB = partyBShifts, + Schedule = schedule, + }; + context.SwapRequests.Add(swapRequest); + context.SaveChanges(); + return Ok(swapRequest.Guid); + } } diff --git a/LineUp.Backend/LineUpContext.cs b/LineUp.Backend/LineUpContext.cs index 5b246cf..de516a5 100644 --- a/LineUp.Backend/LineUpContext.cs +++ b/LineUp.Backend/LineUpContext.cs @@ -13,6 +13,7 @@ public class LineUpContext : DbContext public virtual DbSet QuestionOptions { get; set; } public virtual DbSet FormQuestionAnswers { get; set; } public virtual DbSet ShiftAssignments { get; set; } + public virtual DbSet SwapRequests { get; set; } public LineUpContext(DbContextOptions options) : base(options) { } From f38a892704b0b02edb5f562f2ae793830d4d22e7 Mon Sep 17 00:00:00 2001 From: lop4cwru Date: Sat, 4 Apr 2026 14:18:52 -0400 Subject: [PATCH 04/17] Progress... (aka I figured out how to make a page redirect) --- .../Controllers/SwapRequestController.cs | 4 +- lineup-client/src/App.tsx | 6 ++ lineup-client/src/pages/Availability.tsx | 2 +- lineup-client/src/pages/RequestSwap.tsx | 60 +++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 lineup-client/src/pages/RequestSwap.tsx diff --git a/LineUp.Backend/Controllers/SwapRequestController.cs b/LineUp.Backend/Controllers/SwapRequestController.cs index 76cc051..c5b0f5f 100644 --- a/LineUp.Backend/Controllers/SwapRequestController.cs +++ b/LineUp.Backend/Controllers/SwapRequestController.cs @@ -8,7 +8,7 @@ namespace LineUp.Backend.Controllers; -[Route("api/Swap")] +[Route("api/swap")] [ApiController] public class SwapRequestController(LineUpContext context) : ControllerBase { @@ -28,7 +28,7 @@ public IActionResult ProcessSwap(Guid guid) return NotFound(); } Availability partyA = fromPartyA[0].Availability; - Availability partyB = fromPartyA[0].Availability; + Availability partyB = fromPartyB[0].Availability; foreach (ShiftAssignment shift in fromPartyB) { shift.Availability = partyA; diff --git a/lineup-client/src/App.tsx b/lineup-client/src/App.tsx index 3b71d81..cc28522 100644 --- a/lineup-client/src/App.tsx +++ b/lineup-client/src/App.tsx @@ -7,6 +7,7 @@ import Error from "@/pages/Error"; import Home from "@/pages/Home"; import NewSchedule from "@/pages/NewSchedule"; import ViewEditSchedule from "@/pages/ViewEditSchedule"; +import RequestSwap from "@/pages/RequestSwap"; import { queryClient } from "@/utils/api"; import { loaderQuery } from "@/utils/db"; import { createBrowserRouter, Navigate, Outlet, RouterProvider, type LoaderFunctionArgs } from "react-router"; @@ -62,6 +63,11 @@ const router = createBrowserRouter([ element: , loader: scheduleLoader, }, + { + path: ":guid/requestSwap", // matches /schedule/:guid/edit + element: , + loader: scheduleLoader, + }, ], }, { diff --git a/lineup-client/src/pages/Availability.tsx b/lineup-client/src/pages/Availability.tsx index f863adb..5e225b2 100644 --- a/lineup-client/src/pages/Availability.tsx +++ b/lineup-client/src/pages/Availability.tsx @@ -177,7 +177,7 @@ const Availability = () => { + > + {text[dateString] ?? ""} + ); }; diff --git a/lineup-client/src/components/calendar.css b/lineup-client/src/components/calendar.css index 2adb24c..56077af 100644 --- a/lineup-client/src/components/calendar.css +++ b/lineup-client/src/components/calendar.css @@ -81,11 +81,17 @@ text-overflow: ellipsis; color: var(--background); - &button { - cursor: pointer; + &.clicked { + background-color: var(--primary-active-hover); } +} + +button.calendarInnerCell { + cursor: pointer; + color: var(--primary); + font-size: 12px; &.clicked { - background-color: var(--primary-active-hover); + color: var(--background); } } diff --git a/lineup-client/src/pages/RequestSwap.tsx b/lineup-client/src/pages/RequestSwap.tsx index de8f115..a5a4506 100644 --- a/lineup-client/src/pages/RequestSwap.tsx +++ b/lineup-client/src/pages/RequestSwap.tsx @@ -1,7 +1,7 @@ import { Calendar } from "@/components/Calendar"; import { ColoredCell, FillableCell } from "@/components/CalendarCells"; import { MousePopup } from "@/components/MousePopup"; -import { queryClient, useApi } from "@/utils/api"; +import { useApi } from "@/utils/api"; import { addToasts, loaderQuery } from "@/utils/db"; import { parseTimeString } from "@/utils/time"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -12,49 +12,219 @@ const RequestSwap = () => { const navigate = useNavigate(); const { fetchWithAuth } = useApi(); const { guid } = useParams(); - const { data } = useQuery(loaderQuery("/api/schedule/{}/requestSwap", guid!)); + const { data } = useQuery(loaderQuery("/api/schedule/{}", guid!)); const [focusedTime, setFocusedTime] = useState(null); - // const storageKey = `availability-${guid}`; const backgroundColors = Array.from({ length: 10 }, (_, i) => `hsl(${Math.round((360 / 10) * i)}, 100%, 80%)`); console.log(backgroundColors); console.log(data); - type SwapRequestProps = { - //define what will be sent to the backend function ScheduleController.cs/RequestSwap() - shifts: string[]; //List; - // userName: string; - // userEmail: string; - // availabilitySlots: string[]; // full of ISO strings - }; + const [email, setEmail] = useState(""); + const [selectedCells, setSelectedCells] = useState([]); + const [userFound, setUserFound] = useState(false); + + const confirmEmailmutation = useMutation({ + // + mutationFn: async (email: string) => { + const res = await fetchWithAuth(`/api/schedule/${guid}/getByEmail?email=${email}`, { + method: "GET", + }); - const updateAvailabilityMutation = useMutation({ - mutationFn: async (updatedAvailability: SwapRequestProps) => { + if (res.status == 406) { + throw new Error("Availability Not Found"); + } else if (!res.ok) { + throw new Error("Failed to send Swap Request"); + } + return res; + }, + onSuccess: (res) => { + console.log("Email Mutation:" + res.json); + setUserFound(true); + setSelectedCells(["H"]); //res.json + //.Availability.Availiabilityslots --- note + }, + }); + + const CreateSwapRequestMutation = useMutation({ + //creates a SwapRequest in the DB + mutationFn: async (shifts: string[]) => { const res = await fetchWithAuth(`/api/schedule/${guid}/requestSwap`, { method: "POST", - body: JSON.stringify(updatedAvailability), + body: JSON.stringify(shifts), headers: { "Content-Type": "application/json", }, }); + shifts.forEach((element) => { + //note each shift assignment + console.log(element); + }); + if (!res.ok) { - throw new Error("Failed to edit availability"); + throw new Error("Failed to create Swap Request"); } - return true; + return res; }, - // onSuccess: () => { - // try { - // localStorage.removeItem(storageKey); - // } catch { - // // ignore storage errors - // } - // queryClient.invalidateQueries({ queryKey: ["availability"] }); - // navigate("/"); - // }, + onSuccess: () => {}, }); - return
hello :wave:
; + + if (!data) return
Loading...
; + + const scheduleGenerated = true; + const [assignmentColors, assignmentText] = mapAssignments(); + + function mapAssignments() { + const colors: { [key: string]: string } = {}; + const text: { [key: string]: string } = {}; + const nameToColor: { [key: string]: string } = {}; + + if (!scheduleGenerated) { + return [colors, text]; + } + + for (const availability of data.shiftAssignments) { + if (availability.startTime in text) { + text[availability.startTime] = text[availability.startTime] + ", " + availability.userName; + } else { + text[availability.startTime] = availability.userName; + } + } + + let numColors = 0; + for (const time of Object.keys(text)) { + if (text[time] in nameToColor) { + colors[time] = nameToColor[text[time]]; + } else { + nameToColor[text[time]] = backgroundColors[numColors % backgroundColors.length]; + colors[time] = backgroundColors[numColors % backgroundColors.length]; + numColors++; + } + } + return [colors, text]; + } + + const handleInputChange = (event: React.ChangeEvent) => { + const { value } = event.target; + setEmail(value); + }; + + return ( +
+ <> +
+ Schedule for {data.name} +
+ {userFound ? ( + <> +
+ Please select the shifts that you would like to swap. +
+
+ +
) => { + event.preventDefault(); + addToasts( + CreateSwapRequestMutation.mutateAsync(selectedCells), + undefined, + "Request created! Please check your email to authenticate this request.", + ); //remove success message + }} + > + { + const next = typeof cells === "function" ? cells(selectedCells) : cells; + setSelectedCells(next); + }} + minutesPerCell={data.schedulePreferences?.minutesPerSlot || 15} + dates={ + data.dateCoverage?.map((d: string) => { + const [year, month, day] = d.split("-").map(Number); + return new Date(year, month - 1, day); + }) ?? [] + } + range={{ + start: parseTimeString(data.startTime)!, + end: parseTimeString(data.endTime)!, + }} + colors={assignmentColors} + text={assignmentText} + setFocusedCell={setFocusedTime} + /> +
+ +
+ + + ) : ( + <> + { + const [year, month, day] = d.split("-").map(Number); + return new Date(year, month - 1, day); + }) ?? [] + } + range={{ + start: parseTimeString(data.startTime)!, + end: parseTimeString(data.endTime)!, + }} + colors={assignmentColors} + text={assignmentText} + setFocusedCell={setFocusedTime} + /> + +
+
{focusedTime && assignmentText[focusedTime]}
+ {focusedTime && ( +
+ {new Intl.DateTimeFormat("en-US", { + weekday: "long", + hour: "2-digit", + minute: "2-digit", + hour12: true, + timeZone: "UTC", + }).format(new Date(focusedTime))} +
+ )} +
+
+
) => { + event.preventDefault(); + addToasts(confirmEmailmutation.mutateAsync(email)); + }} + > + +
+ + +
+
+ + )} + +
+ ); }; export default RequestSwap; From 4dcd0e352dc1e2a6577a6a982b15a7a9be2dedf2 Mon Sep 17 00:00:00 2001 From: lop4cwru Date: Sun, 5 Apr 2026 06:04:09 -0400 Subject: [PATCH 06/17] As far as I'm getting before passing out I really tried, man. Anyway, progress is stuck at the "validate selection" portion: I need a way of guaranteeing that exactly two people are swapping, one is known (the user that gave their email and created the request) and the second is going to be selected from a button on the left, if I can get that to render right. Still needs to happen: - shift assignments need to be correctly read and interpreted by the backend into a SwapRequest object (shouldn't be that bad, but ScheduleController.RequestSwap() needs to have the input changed from List to a string array of times, then parse each of those times to find which party (A or B) owns that shift, *then* it can add that into the backend. - Send out the email confirmation to the creating user when they send the request to the backend (probably a new backend function) - Send out the email request to the other swapping user (will probably use function created from above) - Create a landing page for the two requested swappers that just shows the trade offer - process that (just make the backend call "api/swap/{swapRequestGuid}/processSwap) and that should work - send an email to the manager informing of the swap - add a preference for the manager to block/allow swaps and the corresponding functionality for that (i.e. disable the request swap button or block it w/ a ternary) --- lineup-client/src/App.css | 5 ++++ lineup-client/src/pages/RequestSwap.tsx | 36 ++++++++++++++++++++----- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/lineup-client/src/App.css b/lineup-client/src/App.css index 7a91690..b303cfb 100644 --- a/lineup-client/src/App.css +++ b/lineup-client/src/App.css @@ -201,3 +201,8 @@ .swapBtn { margin-left: 1em; } + +.swapPartnerLabel { + display: flex; + align-items: left; +} diff --git a/lineup-client/src/pages/RequestSwap.tsx b/lineup-client/src/pages/RequestSwap.tsx index a5a4506..cfc370e 100644 --- a/lineup-client/src/pages/RequestSwap.tsx +++ b/lineup-client/src/pages/RequestSwap.tsx @@ -15,13 +15,15 @@ const RequestSwap = () => { const { data } = useQuery(loaderQuery("/api/schedule/{}", guid!)); const [focusedTime, setFocusedTime] = useState(null); const backgroundColors = Array.from({ length: 10 }, (_, i) => `hsl(${Math.round((360 / 10) * i)}, 100%, 80%)`); - console.log(backgroundColors); + // console.log(backgroundColors); - console.log(data); + // console.log(data); const [email, setEmail] = useState(""); const [selectedCells, setSelectedCells] = useState([]); const [userFound, setUserFound] = useState(false); + const [swapPartner, setSwapPartner] = useState(""); + const [swapPartnerGuid, setSwapPartnerGuid] = useState(""); const confirmEmailmutation = useMutation({ // @@ -35,13 +37,12 @@ const RequestSwap = () => { } else if (!res.ok) { throw new Error("Failed to send Swap Request"); } - return res; + const resJson = await res.json(); + return resJson; }, - onSuccess: (res) => { - console.log("Email Mutation:" + res.json); + onSuccess: (resJson) => { setUserFound(true); - setSelectedCells(["H"]); //res.json - //.Availability.Availiabilityslots --- note + // setSelectedCells(resJson.availabilitySlots); //This would make all the availiability light up at the start, but that feels confusing if others have shifts at that time. }, }); @@ -110,6 +111,20 @@ const RequestSwap = () => { setEmail(value); }; + const renderPartnerSelect = (name: string, guid: string) => ( + <> + + + ); + return (
<> @@ -156,6 +171,13 @@ const RequestSwap = () => { setFocusedCell={setFocusedTime} />
+
+ + +
From f708107ec868559bf2f5cc1d5ee82e79af0418e6 Mon Sep 17 00:00:00 2001 From: lop4cwru Date: Mon, 6 Apr 2026 16:58:36 -0400 Subject: [PATCH 07/17] I mean if they'll let me count this as fuzz testing I won't complain --- LineUp.Backend.Tests/CRUDTests.cs | 79 +++++++++++++++++++++++++++ LineUp.Core/Models/ShiftAssignment.cs | 3 +- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/LineUp.Backend.Tests/CRUDTests.cs b/LineUp.Backend.Tests/CRUDTests.cs index 070248b..9e582ef 100644 --- a/LineUp.Backend.Tests/CRUDTests.cs +++ b/LineUp.Backend.Tests/CRUDTests.cs @@ -392,4 +392,83 @@ public async Task UpdateAvailability_Test() Assert.IsType(failedAvailabilityUpdateResult); } } + + [Fact] + public async Task CreateRandomSchedule_Test() + { + // Arrange + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + Random random = new Random(); + + List randomCoverage = new List(); + + for (int i = 0; i < random.Next(10); i++) + { + randomCoverage.Add(DateOnly.FromDateTime(DateTime.UtcNow.AddDays(i))); + } + + Schedule randomSchedule = new Schedule + { + Guid = Guid.Empty, + Auth0UserId = "always-test-on-schedule", + DateCoverage = randomCoverage.ToArray(), + StartTime = new TimeOnly(9, 0), + EndTime = new TimeOnly(17, 0), + SchedulePreferences = new SchedulePreferences + { + MinutesPerSlot = 30, + ShiftIntervals = 30, + UsersPerShift = 1, + MaximumShiftDurationMinutes = 120, + MaximumShiftsPerWorker = 1, + }, + Name = "Test Schedule", + }; + + using (var context = new LineUpContext(options)) + { + context.Database.EnsureCreated(); + + var controller = new ScheduleController(context); + + // Create a mock ClaimsPrincipal with the required NameIdentifier claim + var claims = new List { new Claim(ClaimTypes.NameIdentifier, "test-user-123") }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = principal }, + }; + + var scheduleDto = new ScheduleDto + { + DateCoverage = randomSchedule.DateCoverage, + StartTime = randomSchedule.StartTime, + EndTime = randomSchedule.EndTime, + SchedulePreferences = randomSchedule.SchedulePreferences, + Name = randomSchedule.Name, + }; + + // Act + var result = await controller.CreateSchedule(scheduleDto); + + // Assert + var createdResult = Assert.IsType(result); + Assert.Equal(nameof(ScheduleController.GetSchedule), createdResult.ActionName); + Assert.NotNull(createdResult.Value); + + var returnedSchedule = Assert.IsType(createdResult.Value); + Assert.Equal("test-user-123", returnedSchedule.Auth0UserId); + Assert.Equal(randomSchedule.Name, returnedSchedule.Name); + Assert.Equal(randomSchedule.StartTime, returnedSchedule.StartTime); + Assert.Equal(randomSchedule.EndTime, returnedSchedule.EndTime); + + // Verify that the schedule was actually saved to the database + var savedSchedules = await context.Schedules.CountAsync(); + Assert.Equal(1, savedSchedules); + } + } } diff --git a/LineUp.Core/Models/ShiftAssignment.cs b/LineUp.Core/Models/ShiftAssignment.cs index 1b3dd5f..fc88673 100644 --- a/LineUp.Core/Models/ShiftAssignment.cs +++ b/LineUp.Core/Models/ShiftAssignment.cs @@ -8,12 +8,11 @@ public class ShiftAssignment public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } - + public string? UserName => Availability?.UserName; public int? AvailabilityDbId => Availability?.Id; public Availability? Availability { get; set; } // Navigation properties, ignored in JSON to not loop forever - public int? ScheduleId { get; set; } [JsonDoNotSerialize] public Schedule? Schedule => Availability?.Schedule; From 4026e2f3019b6d7f3c17da3987a2abacbe602d58 Mon Sep 17 00:00:00 2001 From: lop4cwru Date: Fri, 17 Apr 2026 18:02:17 -0400 Subject: [PATCH 08/17] eughhhhhh --- LineUp.Backend.Tests/CRUDTests.cs | 149 +++++++++++++ LineUp.Backend.Tests/SwapTests.cs | 196 ++++++++++++++++++ .../Controllers/ScheduleController.cs | 56 ++++- aspire.config.json | 5 + lineup-client/src/pages/NewSchedule.tsx | 15 ++ lineup-client/src/pages/RequestSwap.tsx | 32 ++- 6 files changed, 432 insertions(+), 21 deletions(-) create mode 100644 LineUp.Backend.Tests/SwapTests.cs create mode 100644 aspire.config.json diff --git a/LineUp.Backend.Tests/CRUDTests.cs b/LineUp.Backend.Tests/CRUDTests.cs index 9e582ef..cc0a6c7 100644 --- a/LineUp.Backend.Tests/CRUDTests.cs +++ b/LineUp.Backend.Tests/CRUDTests.cs @@ -471,4 +471,153 @@ public async Task CreateRandomSchedule_Test() Assert.Equal(1, savedSchedules); } } + /* + [Fact] + public async Task CreateRandomAvailability_Test() + { + // Arrange + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + Random random = new Random(); + int totalDays = random.Next(10); + + List randomCoverage = new List(); + + for (int i = 0; i < totalDays; i++) + { + randomCoverage.Add(DateOnly.FromDateTime(DateTime.UtcNow.AddDays(i))); + } + + Schedule randomSchedule = new Schedule + { + Guid = Guid.Empty, + Auth0UserId = "always-test-on-schedule", + DateCoverage = randomCoverage.ToArray(), + StartTime = new TimeOnly(9, 0), + EndTime = new TimeOnly(17, 0), + SchedulePreferences = new SchedulePreferences + { + MinutesPerSlot = 30, + ShiftIntervals = 30, + UsersPerShift = 1, + MaximumShiftDurationMinutes = 120, + MaximumShiftsPerWorker = 1, + }, + Name = "Test Schedule", + }; + + List randomAvailSlots = new List(); + + for (int i = 0; i < totalDays; i++) + { + randomAvailSlots.Add(DateTime.UtcNow.Date.AddHours(9)); + DateTime.UtcNow.Date.AddHours(9).AddMinutes(30); + DateTime.UtcNow.Date.AddHours(10); + DateTime.UtcNow.Date.AddHours(10).AddMinutes(30); + DateTime.UtcNow.Date.AddHours(11); + DateTime.UtcNow.Date.AddHours(11).AddMinutes(30); + randomCoverage.Add(DateOnly.FromDateTime(DateTime.UtcNow.AddDays(i))); + } + + Availability randomAvailability = new Availability + { + UserName = "Test Availability", + UserEmail = "test@email.com", + AvailabilitySlots = + [ + // Day 0: 9:00 - 12:00 + DateTime.UtcNow.Date.AddHours(9), + DateTime.UtcNow.Date.AddHours(9).AddMinutes(30), + DateTime.UtcNow.Date.AddHours(10), + DateTime.UtcNow.Date.AddHours(10).AddMinutes(30), + DateTime.UtcNow.Date.AddHours(11), + DateTime.UtcNow.Date.AddHours(11).AddMinutes(30), + // Day 1: 13:00 - 17:00 + DateTime.UtcNow.Date.AddDays(1).AddHours(13), + DateTime.UtcNow.Date.AddDays(1).AddHours(13).AddMinutes(30), + DateTime.UtcNow.Date.AddDays(1).AddHours(14), + DateTime.UtcNow.Date.AddDays(1).AddHours(14).AddMinutes(30), + DateTime.UtcNow.Date.AddDays(1).AddHours(15), + DateTime.UtcNow.Date.AddDays(1).AddHours(15).AddMinutes(30), + DateTime.UtcNow.Date.AddDays(1).AddHours(16), + DateTime.UtcNow.Date.AddDays(1).AddHours(16).AddMinutes(30), + ], + Schedule = new Schedule + { + Guid = Guid.Empty, + Auth0UserId = "replace this schedule", + DateCoverage = [], + StartTime = new TimeOnly(0, 0), + EndTime = new TimeOnly(0, 0), + SchedulePreferences = new SchedulePreferences + { + MinutesPerSlot = 30, + ShiftIntervals = 30, + UsersPerShift = 1, + MaximumShiftDurationMinutes = 120, + MaximumShiftsPerWorker = 1, + }, + Name = "ReplaceThisScheduleWithSampleSchedule", + }, + Preferences = new AvailabilityPreferences(), + }; + + using (var context = new LineUpContext(options)) + { + context.Database.EnsureCreated(); + + var controller = new ScheduleController(context); + + // Create a mock ClaimsPrincipal with the required NameIdentifier claim + var claims = new List { new Claim(ClaimTypes.NameIdentifier, "test-user-123") }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = principal }, + }; + + var scheduleDto = new ScheduleDto + { + DateCoverage = randomSchedule.DateCoverage, + StartTime = randomSchedule.StartTime, + EndTime = randomSchedule.EndTime, + SchedulePreferences = randomSchedule.SchedulePreferences, + Name = randomSchedule.Name, + }; + + var result = await controller.CreateSchedule(scheduleDto); + CreatedAtActionResult scheduleCreatedResult = Assert.IsType( + result + ); + Schedule returnedSchedule = Assert.IsType(scheduleCreatedResult.Value); + Guid guid = returnedSchedule.Guid; + + var availabilityDto = new AvailabilityCreateDto + { + AvailabilitySlots = randomAvailability.AvailabilitySlots, + UserName = randomAvailability.UserName, + UserEmail = randomAvailability.UserEmail, + Preferences = randomAvailability.Preferences, + FormAnswers = randomAvailability.FormAnswers, + }; + + // Act + var availabilityCreateResult = await controller.CreateAvailability( + guid, + availabilityDto + ); + var failedAvailabilityCreateResult = await controller.CreateAvailability( + Guid.Empty, + availabilityDto + ); + + // Assert + Assert.IsType(availabilityCreateResult); + Assert.IsType(failedAvailabilityCreateResult); + } + } + //*/ } diff --git a/LineUp.Backend.Tests/SwapTests.cs b/LineUp.Backend.Tests/SwapTests.cs new file mode 100644 index 0000000..218a24d --- /dev/null +++ b/LineUp.Backend.Tests/SwapTests.cs @@ -0,0 +1,196 @@ +using System.Net; +using System.Security.Claims; +using Azure; +using LineUp.Backend.Controllers; +using LineUp.Backend.Models; +using LineUp.Core.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace LineUp.Backend.Tests; + +public class SwapTests +{ + Schedule sampleSchedule = new Schedule + { + Guid = Guid.Empty, + Auth0UserId = "always-test-on-schedule", + DateCoverage = + [ + DateOnly.FromDateTime(DateTime.UtcNow), + DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1)), + DateOnly.FromDateTime(DateTime.UtcNow.AddDays(2)), + ], + StartTime = new TimeOnly(9, 0), + EndTime = new TimeOnly(12, 0), + SchedulePreferences = new SchedulePreferences + { + MinutesPerSlot = 30, + ShiftIntervals = 30, + UsersPerShift = 1, + MaximumShiftDurationMinutes = 120, + MaximumShiftsPerWorker = 1, + }, + Name = "Test Schedule", + }; + + Availability sample1 = new Availability + { + UserName = "Test Availability", + UserEmail = "test@email.com", + AvailabilitySlots = + [ + // Day 0: 9:00 - 12:00 + DateTime.UtcNow.Date.AddHours(9), + DateTime.UtcNow.Date.AddHours(9).AddMinutes(30), + DateTime.UtcNow.Date.AddHours(10), + DateTime.UtcNow.Date.AddHours(10).AddMinutes(30), + DateTime.UtcNow.Date.AddHours(11), + DateTime.UtcNow.Date.AddHours(11).AddMinutes(30), + ], + Schedule = new Schedule + { + Guid = Guid.Empty, + Auth0UserId = "replace this schedule", + DateCoverage = [], + StartTime = new TimeOnly(0, 0), + EndTime = new TimeOnly(0, 0), + SchedulePreferences = new SchedulePreferences + { + MinutesPerSlot = 30, + ShiftIntervals = 30, + UsersPerShift = 1, + MaximumShiftDurationMinutes = 120, + MaximumShiftsPerWorker = 1, + }, + Name = "ReplaceThisScheduleWithSampleSchedule", + }, + Preferences = new AvailabilityPreferences(), + }; + Availability sample2 = new Availability + { + UserName = "Test Availability", + UserEmail = "test@email.com", + AvailabilitySlots = + [ + // Day 1: 9:00 - 12:00 + DateTime.UtcNow.Date.AddDays(1).AddHours(9), + DateTime.UtcNow.Date.AddDays(1).AddHours(9).AddMinutes(30), + DateTime.UtcNow.Date.AddDays(1).AddHours(10), + DateTime.UtcNow.Date.AddDays(1).AddHours(10).AddMinutes(30), + DateTime.UtcNow.Date.AddDays(1).AddHours(11), + DateTime.UtcNow.Date.AddDays(1).AddHours(11).AddMinutes(30), + ], + Schedule = new Schedule + { + Guid = Guid.Empty, + Auth0UserId = "replace this schedule", + DateCoverage = [], + StartTime = new TimeOnly(0, 0), + EndTime = new TimeOnly(0, 0), + SchedulePreferences = new SchedulePreferences + { + MinutesPerSlot = 30, + ShiftIntervals = 30, + UsersPerShift = 1, + MaximumShiftDurationMinutes = 120, + MaximumShiftsPerWorker = 1, + }, + Name = "ReplaceThisScheduleWithSampleSchedule", + }, + Preferences = new AvailabilityPreferences(), + }; + Availability sample3 = new Availability + { + UserName = "Test Availability", + UserEmail = "test@email.com", + AvailabilitySlots = + [ + // Day 2: 9:00 - 12:00 + DateTime.UtcNow.Date.AddDays(2).AddHours(9), + DateTime.UtcNow.Date.AddDays(2).AddHours(9).AddMinutes(30), + DateTime.UtcNow.Date.AddDays(2).AddHours(10), + DateTime.UtcNow.Date.AddDays(2).AddHours(10).AddMinutes(30), + DateTime.UtcNow.Date.AddDays(2).AddHours(11), + DateTime.UtcNow.Date.AddDays(2).AddHours(11).AddMinutes(30), + ], + Schedule = new Schedule + { + Guid = Guid.Empty, + Auth0UserId = "replace this schedule", + DateCoverage = [], + StartTime = new TimeOnly(0, 0), + EndTime = new TimeOnly(0, 0), + SchedulePreferences = new SchedulePreferences + { + MinutesPerSlot = 30, + ShiftIntervals = 30, + UsersPerShift = 1, + MaximumShiftDurationMinutes = 120, + MaximumShiftsPerWorker = 1, + }, + Name = "ReplaceThisScheduleWithSampleSchedule", + }, + Preferences = new AvailabilityPreferences(), + }; + + //[Fact] + public async Task SwapAccepted_Test() + { + // Arrange + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + using (var context = new LineUpContext(options)) + { + context.Database.EnsureCreated(); + + var controller = new ScheduleController(context); + + // Create a mock ClaimsPrincipal with the required NameIdentifier claim + var claims = new List { new Claim(ClaimTypes.NameIdentifier, "test-user-123") }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = principal }, + }; + + var scheduleDto = new ScheduleDto + { + DateCoverage = sampleSchedule.DateCoverage, + StartTime = sampleSchedule.StartTime, + EndTime = sampleSchedule.EndTime, + SchedulePreferences = sampleSchedule.SchedulePreferences, + Name = sampleSchedule.Name, + }; + var result = await controller.CreateSchedule(scheduleDto); + CreatedAtActionResult scheduleCreatedResult = Assert.IsType( + result + ); + Schedule returnedSchedule = Assert.IsType(scheduleCreatedResult.Value); + Guid guid = returnedSchedule.Guid; + + var availability1Dto = new AvailabilityCreateDto + { + AvailabilitySlots = sample1.AvailabilitySlots, + UserName = sample1.UserName, + UserEmail = sample1.UserEmail, + Preferences = sample1.Preferences, + FormAnswers = sample1.FormAnswers, + }; + var availabilityCreateResult = await controller.CreateAvailability( + guid, + availability1Dto + ); + + // Act + + // Assert + Assert.IsType(availabilityCreateResult); + } + } +} diff --git a/LineUp.Backend/Controllers/ScheduleController.cs b/LineUp.Backend/Controllers/ScheduleController.cs index db6fbe5..f2d291c 100644 --- a/LineUp.Backend/Controllers/ScheduleController.cs +++ b/LineUp.Backend/Controllers/ScheduleController.cs @@ -1,9 +1,12 @@ +using System.Diagnostics; using System.Security.Claims; using LineUp.Backend.Models; using LineUp.Core.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using NuGet.Protocol; +using SQLitePCL; namespace LineUp.Backend.Controllers; @@ -274,29 +277,60 @@ public async Task GetAvailability(Guid guid, [FromQuery] string e } [HttpPost("{guid:guid}/requestSwap")] - public IActionResult RequestSwap(Guid guid, [FromBody] List shiftCollection) + public async Task RequestSwap(Guid guid, [FromBody] DateTime[] shiftStartTimes) { Schedule? schedule = context.Schedules.FirstOrDefault(s => s.Guid == guid); - if (schedule == null || shiftCollection == null || shiftCollection[0] == null) + if (schedule == null || shiftStartTimes == null || !shiftStartTimes.Any()) { return NotFound(); } + List shiftCollection = new List(); + var scheduleResult = await context.Schedules.FirstOrDefaultAsync(s => s.Guid == guid); + + if (scheduleResult == null) + return BadRequest("The provided schedule could not be found."); + int scheduleID = scheduleResult.Id; + + try + { //Attempt to find the shift assignments from the backend. + foreach (DateTime start in shiftStartTimes) + { + var result = await context + .ShiftAssignments.Include(a => a.AvailabilityDbId) + .FirstOrDefaultAsync(s => s.StartTime == start && s.ScheduleId == scheduleID); //**TODO: This will not work if there is more than one worker per shift.** + Console.WriteLine(result.ToJson()); + if (result == null || result is not ShiftAssignment) // throw an error if no shift assigned at that time + throw new FileNotFoundException(); + else + shiftCollection.Add(result); + } + } + catch (FileNotFoundException e) + { + return UnprocessableEntity( + "The database did not recognize one or more of the times as shifts." + ); + } + if (shiftCollection.Count < 1) + return UnprocessableEntity("No shift assignments were found for the time specified."); + //Sort through the shifts (assume an unsorted list) - Availability partyA = shiftCollection[0].Availability; + Console.WriteLine(shiftCollection.ToJson()); + int partyAId = (int)shiftCollection[0].AvailabilityDbId; List partyAShifts = []; - Availability partyB = null; + int partyBId = -1; List partyBShifts = []; foreach (ShiftAssignment shift in shiftCollection) { - Availability shiftOwner = shift.Availability; - if (shiftOwner == partyA) - partyAShifts.Append(shift); - else if (partyB == null) + int shiftOwner = (int)shift.AvailabilityDbId; + if (shiftOwner == partyAId) + partyAShifts.Add(shift); + else if (partyBId == -1) { - partyB = shiftOwner; - partyBShifts.Append(shift); + partyBId = shiftOwner; + partyBShifts.Add(shift); } - else if (shiftOwner == partyB) + else if (shiftOwner == partyBId) partyBShifts.Append(shift); else return BadRequest("More than two parties identified"); diff --git a/aspire.config.json b/aspire.config.json new file mode 100644 index 0000000..515419f --- /dev/null +++ b/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "LineUp.AppHost/LineUp.AppHost.csproj" + } +} \ No newline at end of file diff --git a/lineup-client/src/pages/NewSchedule.tsx b/lineup-client/src/pages/NewSchedule.tsx index 474a125..e01d955 100644 --- a/lineup-client/src/pages/NewSchedule.tsx +++ b/lineup-client/src/pages/NewSchedule.tsx @@ -400,6 +400,21 @@ const NewSchedule = () => { onChange={handleInputChange} />
+ {/*
+ +
+ +
========== use this to let the schedule creator enable a swap ====*/}
+ {console.log("name = " + name + " and id = " + id)} ); + type AvailabilityObject = { + name: string; + AvailabilityDbId: number; + }; + return (
<> @@ -174,8 +180,14 @@ const RequestSwap = () => {
- {console.log("name = " + name + " and id = " + id)} - - ); - - type AvailabilityObject = { - name: string; - AvailabilityDbId: number; - }; + // const respondentNames = Array.from( + // new Set( + // (data.shiftAssignments as { userName: string; availabilityDbId: number }[]).map((shift) => ({ + // userName: shift.userName, + // id: shift.availabilityDbId, + // })), + // ), + // ); + const respondentNames: { name: string; id: number }[] = data.shiftAssignments.reduce( + (nameSet: { name: string; id: number }[], assignment: any) => { + if ( + nameSet.every( + (existingName) => existingName.id !== assignment.availabilityDbId, + ) /*&& assignment.availabilityDbId != */ + ) { + nameSet.push({ name: assignment.userName, id: assignment.availabilityDbId }); + } + return nameSet; + }, + [], + ); return (
<>
Schedule for {data.name}
- {userFound ? ( + {userId !== null ? ( <>
Please select the shifts that you would like to swap. @@ -179,16 +181,29 @@ const RequestSwap = () => {
- */} +
+
    + {respondentNames.map((shift) => ( +
  • { + setSelectedSwapPartnerAvailabilityId((prev) => { + console.log(`selected name: ${shift.name}, selected id: ${shift.id}`); + return prev === shift.id ? null : shift.id; + }); + }} + > + {shift.name} +
  • + ))} +
+
+
+ + + ) : ( + <> + { + const [year, month, day] = d.split("-").map(Number); + return new Date(year, month - 1, day); + }) ?? [] + } + range={{ + start: parseTimeString(data.startTime)!, + end: parseTimeString(data.endTime)!, + }} + colors={assignmentColors} + text={assignmentText} + setFocusedCell={setFocusedTime} + /> + +
+
{focusedTime && assignmentText[focusedTime]}
+ {focusedTime && ( +
+ {new Intl.DateTimeFormat("en-US", { + weekday: "long", + hour: "2-digit", + minute: "2-digit", + hour12: true, + timeZone: "UTC", + }).format(new Date(focusedTime))} +
+ )} +
+
+
) => { + event.preventDefault(); + addToasts(confirmEmailmutation.mutateAsync(email)); + }} + >
+ + )} + +
+ ); + + // return ( + //
+ // <> + //
+ // Schedule for {data.name} + //
+ // {userId !== null ? ( + // <> + //
+ // Please select the shifts that you would like to swap. + //
+ //
+ + //
) => { + // event.preventDefault(); + // addToasts( + // CreateSwapRequestMutation.mutateAsync({ + // shiftStartTimes: selectedCells, + // requesterId: userId, + // recipientId: selectedSwapPartnerAvailabilityId, + // }), + // undefined, + // "Request created! Please check your email to authenticate this request.", + // ); //remove success message + // }} + // > + // { + // const next = typeof cells === "function" ? cells(selectedCells) : cells; + // setSelectedCells(next); + // }} + // minutesPerCell={data.schedulePreferences?.minutesPerSlot || 15} + // dates={ + // data.dateCoverage?.map((d: string) => { + // const [year, month, day] = d.split("-").map(Number); + // return new Date(year, month - 1, day); + // }) ?? [] + // } + // range={{ + // start: parseTimeString(data.startTime)!, + // end: parseTimeString(data.endTime)!, + // }} + // colors={/*selectionColors*/ assignmentColors} + // text={assignmentText} + // setFocusedCell={setFocusedTime} + // /> + //
+ //
+ // + // {/* */} + //
+ //
    + // {respondentNames.map((shift) => ( + //
  • { + // setSelectedSwapPartnerAvailabilityId((prev) => { + // console.log(`selected name: ${shift.name}, selected id: ${shift.id}`); + // return prev === shift.id ? null : shift.id; + // }); + // setSelectedSwapPartner((prev) => { + // return prev === shift.name ? null : shift.name; + // }); + // }} + // > + // {shift.name} + //
  • + // ))} + //
+ //
+ //
+ // + //
+ // + // + // ) : ( + // <> + // { + // const [year, month, day] = d.split("-").map(Number); + // return new Date(year, month - 1, day); + // }) ?? [] + // } + // range={{ + // start: parseTimeString(data.startTime)!, + // end: parseTimeString(data.endTime)!, + // }} + // colors={assignmentColors} + // text={assignmentText} + // setFocusedCell={setFocusedTime} + // /> + // + //
+ //
{focusedTime && assignmentText[focusedTime]}
+ // {focusedTime && ( + //
+ // {new Intl.DateTimeFormat("en-US", { + // weekday: "long", + // hour: "2-digit", + // minute: "2-digit", + // hour12: true, + // timeZone: "UTC", + // }).format(new Date(focusedTime))} + //
+ // )} + //
+ //
+ //
) => { + // event.preventDefault(); + // addToasts(confirmEmailmutation.mutateAsync(email)); + // }} + // > + // + //
+ // + // + //
+ //
+ // + // )} + // + //
+ // ); +}; + +export default ViewSwapRequest;