diff --git a/LineUp.Backend.Tests/CRUDTests.cs b/LineUp.Backend.Tests/CRUDTests.cs index f57d7d0..ac6fda9 100644 --- a/LineUp.Backend.Tests/CRUDTests.cs +++ b/LineUp.Backend.Tests/CRUDTests.cs @@ -241,7 +241,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; @@ -411,4 +410,83 @@ public async Task UpdateAvailability_Test() ); } } + + [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.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 b6e3c1c..bd0e942 100644 --- a/LineUp.Backend/Controllers/ScheduleController.cs +++ b/LineUp.Backend/Controllers/ScheduleController.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Security.Claims; using LineUp.Backend.Models; using LineUp.Backend.Services; @@ -5,6 +6,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using NuGet.Protocol; +using SQLitePCL; namespace LineUp.Backend.Controllers; @@ -296,4 +299,84 @@ await context.Availabilities.AnyAsync(a => availabilityToInsert ); } + + [HttpGet("{guid:guid}/getByEmail")] + public async Task GetAvailability(Guid guid, [FromQuery] string email) + { + var result = await context + .Availabilities.Include(a => a.Schedule) + .FirstOrDefaultAsync(a => a.UserEmail == email && a.Schedule.Guid == guid); + if (result != null) + return Ok(result); + return StatusCode(StatusCodes.Status406NotAcceptable); + } + + [HttpPost("{guid:guid}/requestSwap")] + public async Task RequestSwap(Guid guid, [FromBody] SwapRequestDto request) + { + DateTime[] shiftStartTimes = request.shiftStartTimes; + int requesterId = request.RequesterId; + int recipientId = request.RecipientId; + + Schedule? schedule = context.Schedules.FirstOrDefault(s => s.Guid == guid); + if (schedule == null || shiftStartTimes == null || !shiftStartTimes.Any()) + { + return BadRequest("Request does not specify all necessary fields."); + } + List requesterShiftCollection = new List(); + List recipientShiftCollection = new List(); + var scheduleResult = await context.Schedules.FirstOrDefaultAsync(s => s.Guid == guid); + + if (scheduleResult == null) + return NotFound("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.FirstOrDefaultAsync(s => + s.ScheduleId == scheduleID + && s.StartTime == start + && s.Availability.Id == requesterId + ); //Attempt to find if the given shift belongs to requester + if (result != null) + requesterShiftCollection.Add(result); + else + { + result = await context.ShiftAssignments.FirstOrDefaultAsync(s => + s.ScheduleId == scheduleID + && s.StartTime == start + && s.Availability.Id == recipientId + ); //Attempt to find if the given shift belongs to recipient + if (result != null) + requesterShiftCollection.Add(result); + else + { //If it doesn't belong to either, throw an exception + throw new FileNotFoundException(); + } + } + } + } + catch (FileNotFoundException e) + { + return UnprocessableEntity( + "One or more of the dates provided did not have a shift assigned to either party." + ); + } + if (requesterShiftCollection.Count < 1 && recipientShiftCollection.Count < 1) + return UnprocessableEntity("No shift assignments were found for the time specified."); + + //Sort through the shifts (assume an unsorted list) + SwapRequest swapRequest = new SwapRequest + { + FromPartyA = requesterShiftCollection, + FromPartyB = recipientShiftCollection, + Schedule = schedule, + }; + Console.WriteLine("\n Creating Swap Request: " + swapRequest.ToJson()); + context.SwapRequests.Add(swapRequest); + context.SaveChanges(); + return Ok(swapRequest.Guid); + } } diff --git a/LineUp.Backend/Controllers/SwapRequestController.cs b/LineUp.Backend/Controllers/SwapRequestController.cs new file mode 100644 index 0000000..c5b0f5f --- /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 = fromPartyB[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 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) { } 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 751477c..86a816a 100644 --- a/LineUp.Backend/Migrations/LineUpContextModelSnapshot.cs +++ b/LineUp.Backend/Migrations/LineUpContextModelSnapshot.cs @@ -268,15 +268,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") @@ -350,9 +383,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"); @@ -375,6 +427,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.Backend/Models/SwapRequestDto.cs b/LineUp.Backend/Models/SwapRequestDto.cs new file mode 100644 index 0000000..e02a017 --- /dev/null +++ b/LineUp.Backend/Models/SwapRequestDto.cs @@ -0,0 +1,12 @@ +using LineUp.Core.Models; + +namespace LineUp.Backend.Models; + +public class SwapRequestDto +{ + public required DateTime[] shiftStartTimes { get; set; } + + public required int RequesterId { get; set; } + + public required int RecipientId { get; set; } +} diff --git a/LineUp.Core/Models/ShiftAssignment.cs b/LineUp.Core/Models/ShiftAssignment.cs index 0bdea65..3e174ec 100644 --- a/LineUp.Core/Models/ShiftAssignment.cs +++ b/LineUp.Core/Models/ShiftAssignment.cs @@ -8,14 +8,13 @@ public class ShiftAssignment public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } - + public string? UserName => Availability?.UserName; public int? AvailabilityDbId => Availability?.Id; [JsonDoNotSerialize] 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; 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; } +} diff --git a/lineup-client/src/App.css b/lineup-client/src/App.css index 89cbb81..2e78c28 100644 --- a/lineup-client/src/App.css +++ b/lineup-client/src/App.css @@ -261,3 +261,12 @@ text-align: inherit; border-radius: 0; } + +.swapBtn { + margin-left: 1em; +} + +.swapPartnerLabel { + display: flex; + align-items: left; +} diff --git a/lineup-client/src/App.tsx b/lineup-client/src/App.tsx index eefa51f..fb25e7e 100644 --- a/lineup-client/src/App.tsx +++ b/lineup-client/src/App.tsx @@ -6,6 +6,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 { authorizedLoaderQuery, unauthorizedLoaderQuery } from "@/utils/db"; import { createBrowserRouter, Navigate, Outlet, RouterProvider, type LoaderFunctionArgs } from "react-router"; @@ -61,6 +62,11 @@ const router = createBrowserRouter([ element: , loader: scheduleLoader, }, + { + path: ":guid/requestSwap", // matches /schedule/:guid/edit + element: , + loader: scheduleLoader, + }, ], }, { diff --git a/lineup-client/src/components/Calendar.tsx b/lineup-client/src/components/Calendar.tsx index 34565bc..5551bc6 100644 --- a/lineup-client/src/components/Calendar.tsx +++ b/lineup-client/src/components/Calendar.tsx @@ -22,6 +22,7 @@ interface CalendarProps { setFocusedCell?: React.Dispatch>; selectedCells?: string[]; setSelectedCells?: React.Dispatch>; + selectableCells?: string[]; } // Children are each cell of the calendar @@ -35,6 +36,7 @@ const Calendar = ({ text, selectedCells, setSelectedCells, + selectableCells, }: CalendarProps) => { const [isPointerDown, setIsPointerDown] = useState(false); const [isEnablingCells, setIsEnablingCells] = useState(false); @@ -42,6 +44,12 @@ const Calendar = ({ const numRows = calculateNumRows(); const pageDates = getPageDates(currentPage); + useEffect(() => { + if (setSelectedCells && selectableCells) { + setSelectedCells((cells) => cells.filter((cell) => selectableCells.includes(cell))); + } + }, [selectableCells, setSelectedCells]); + // Calculates the number of rows a calender should have based on the time range and minutes per cell function calculateNumRows() { if (rangeIs24Hours(range)) { @@ -205,7 +213,7 @@ const Calendar = ({ className="calendarLabel unstyledButton" style={extraColMargin(col)} onClick={() => colClicked(date)} - disabled={!setSelectedCells} + disabled={!setSelectedCells || selectableCells !== undefined} > {dayNumberToWeekday(date.getDay())}
@@ -235,6 +243,7 @@ const Calendar = ({ date={date} selectedCells={selectedCells} setSelectedCells={setSelectedCells} + selectableCells={selectableCells} isPointerDown={isPointerDown} setIsPointerDown={setIsPointerDown} isEnablingCells={isEnablingCells} diff --git a/lineup-client/src/components/CalendarCells.tsx b/lineup-client/src/components/CalendarCells.tsx index 28e1f5f..7e786e5 100644 --- a/lineup-client/src/components/CalendarCells.tsx +++ b/lineup-client/src/components/CalendarCells.tsx @@ -7,6 +7,7 @@ export interface CalendarCellProps { date: Date; selectedCells?: string[]; setSelectedCells?: React.Dispatch>; + selectableCells?: string[]; isPointerDown: boolean; setIsPointerDown: React.Dispatch>; isEnablingCells: boolean; @@ -21,10 +22,13 @@ const FillableCell = ({ date, selectedCells, setSelectedCells, + selectableCells, isPointerDown, setIsPointerDown, isEnablingCells, setIsEnablingCells, + text, + colors, }: CalendarCellProps) => { const dateString = standardizeDateAndTime(date, time); const isClicked = selectedCells?.includes(dateString); @@ -77,8 +81,12 @@ const FillableCell = ({ onPointerEnter={onPointerEnter} onPointerMove={onPointerMove} className={"unstyledButton calendarInnerCell" + (isClicked ? " clicked" : "")} + style={{ backgroundColor: !isClicked ? (colors[dateString] ?? "transparent") : undefined }} + disabled={!selectableCells?.includes(dateString)} title={dayNumberToWeekday(date.getDay()) + " " + formatTime(time)} - > + > + {text[dateString] ?? ""} + ); }; diff --git a/lineup-client/src/components/calendar.css b/lineup-client/src/components/calendar.css index 0747838..d6ac4ba 100644 --- a/lineup-client/src/components/calendar.css +++ b/lineup-client/src/components/calendar.css @@ -90,11 +90,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/Availability.tsx b/lineup-client/src/pages/Availability.tsx index a483c5a..5384011 100644 --- a/lineup-client/src/pages/Availability.tsx +++ b/lineup-client/src/pages/Availability.tsx @@ -191,6 +191,14 @@ const Availability = () => { )} + ) : ( <> diff --git a/lineup-client/src/pages/NewSchedule.tsx b/lineup-client/src/pages/NewSchedule.tsx index 3584bc3..432fca1 100644 --- a/lineup-client/src/pages/NewSchedule.tsx +++ b/lineup-client/src/pages/NewSchedule.tsx @@ -421,6 +421,21 @@ const NewSchedule = () => { onChange={handleInputChange} /> + {/*
+ +
+ +
========== use this to let the schedule creator enable a swap ====*/}
+
+ + + ) : ( + <> + { + 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;