using Fitness_core_BE.DTOs.Facility; using Fitness_core_BE.Helpers; using Fitness_core_BE.Infra.UoW; using Fitness_core_BE.Interfaces; using Fitness_core_BE.Interfaces.Services; using Fitness_core_BE.Models; using Microsoft.EntityFrameworkCore; using System.Text.Json; namespace Fitness_core_BE.Services { public class ClassService : IClassService { private static readonly string[] ValidRecurrenceTypes = ["Daily", "Weekly", "Monthly", "Custom"]; private const int GenerationHorizonDays = 56; // 8 weeks private readonly IFacilityClassRepository _classRepository; private readonly IFacilityRepository _facilityRepository; private readonly IUnitOfWork _uow; private readonly UserRoleHelper _userRoleHelper; public ClassService( IFacilityClassRepository classRepository, IFacilityRepository facilityRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper) { _classRepository = classRepository; _facilityRepository = facilityRepository; _uow = uow; _userRoleHelper = userRoleHelper; } public async Task GetFacilityClasses(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean(); var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var classes = await _classRepository.GetFacilityClasses(clientId, includeInactive); return classes.Select(MapToDto).ToList(); } public async Task CreateFacilityClass(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var facilityId = Guid.Parse(data["facilityId"].GetString()!); var facility = await _facilityRepository.GetFacilityById(facilityId); if (facility == null || facility.FKClientId != clientId) throw new KeyNotFoundException("Facility not found"); if (!facility.SupportsClasses) throw new ArgumentException("This facility does not support classes"); if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required"); var recurrenceType = data.ContainsKey("recurrenceType") ? data["recurrenceType"].GetString() ?? "Weekly" : "Weekly"; if (!ValidRecurrenceTypes.Contains(recurrenceType)) throw new ArgumentException($"recurrenceType must be one of: {string.Join(", ", ValidRecurrenceTypes)}"); var isUnlimitedCapacity = data.ContainsKey("isUnlimitedCapacity") && data["isUnlimitedCapacity"].GetBoolean(); int? capacity = data.ContainsKey("capacity") && data["capacity"].ValueKind != JsonValueKind.Null ? data["capacity"].GetInt32() : null; if (!isUnlimitedCapacity && capacity.HasValue && capacity.Value < 1) throw new ArgumentException("capacity must be at least 1 when set"); var durationMinutes = data.ContainsKey("durationMinutes") ? data["durationMinutes"].GetInt32() : 60; if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1"); var facilityClass = new FacilityClass { FKClientId = clientId, FKFacilityId = facilityId, Name = data["name"].GetString() ?? "", Description = data.ContainsKey("description") ? data["description"].GetString() : null, RecurrenceType = recurrenceType, DurationMinutes = durationMinutes, Capacity = isUnlimitedCapacity ? null : capacity, IsUnlimitedCapacity = isUnlimitedCapacity }; if (recurrenceType == "Custom") { // Occurrences are explicit -- no rule fields, no generation watermark ever // consulted for this class. facilityClass.OccurrencesGeneratedThrough = DateTime.UtcNow.Date; var result = await _classRepository.CreateFacilityClass(facilityClass); await _uow.SaveChangesAsync(); // persist the id before occurrences reference it await AddCustomOccurrencesAsync(facilityClass, data); await _uow.CommitAsync(); result.Facility = facility; return MapToDto(result); } else { if (!data.ContainsKey("startTimeOfDay")) throw new KeyNotFoundException("startTimeOfDay is required"); facilityClass.StartTimeOfDay = TimeSpan.Parse(data["startTimeOfDay"].GetString()!); facilityClass.WeekDaysMask = data.ContainsKey("weekDaysMask") ? data["weekDaysMask"].GetInt32() : 0; facilityClass.MonthDays = data.ContainsKey("monthDays") ? data["monthDays"].GetString() : null; facilityClass.SeriesStartDate = data.ContainsKey("seriesStartDate") && data["seriesStartDate"].ValueKind != JsonValueKind.Null ? DateTime.Parse(data["seriesStartDate"].GetString()!).Date : DateTime.UtcNow.Date; facilityClass.SeriesEndDate = data.ContainsKey("seriesEndDate") && data["seriesEndDate"].ValueKind != JsonValueKind.Null ? DateTime.Parse(data["seriesEndDate"].GetString()!).Date : null; if (recurrenceType == "Weekly" && facilityClass.WeekDaysMask == 0) throw new ArgumentException("Select at least one day of the week"); if (recurrenceType == "Monthly" && string.IsNullOrWhiteSpace(facilityClass.MonthDays)) throw new ArgumentException("Select at least one day of the month"); // Nothing generated yet -- the next GetSessions call does it lazily. facilityClass.OccurrencesGeneratedThrough = facilityClass.SeriesStartDate.AddDays(-1); var result = await _classRepository.CreateFacilityClass(facilityClass); await _uow.CommitAsync(); result.Facility = facility; return MapToDto(result); } } catch { await _uow.RollbackAsync(); throw; } } public async Task UpdateFacilityClass(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var facilityClassId = Guid.Parse(data["facilityClassId"].GetString()!); var existing = await _classRepository.GetFacilityClassById(facilityClassId) ?? throw new KeyNotFoundException("Class not found"); if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name; if (data.ContainsKey("description")) existing.Description = data["description"].GetString(); if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean(); if (data.ContainsKey("isUnlimitedCapacity")) existing.IsUnlimitedCapacity = data["isUnlimitedCapacity"].GetBoolean(); if (data.ContainsKey("capacity")) { var capacity = data["capacity"].ValueKind != JsonValueKind.Null ? data["capacity"].GetInt32() : (int?)null; if (!existing.IsUnlimitedCapacity && capacity.HasValue && capacity.Value < 1) throw new ArgumentException("capacity must be at least 1 when set"); existing.Capacity = existing.IsUnlimitedCapacity ? null : capacity; } // Only these recurrence-rule fields trigger a regeneration -- editing the name, // description, capacity or active flag doesn't touch existing occurrences. var recurrenceKeys = new[] { "recurrenceType", "startTimeOfDay", "durationMinutes", "weekDaysMask", "monthDays", "seriesStartDate", "seriesEndDate" }; var recurrenceChanged = recurrenceKeys.Any(data.ContainsKey); if (recurrenceChanged && existing.RecurrenceType != "Custom") { if (data.ContainsKey("recurrenceType")) { var recurrenceType = data["recurrenceType"].GetString() ?? existing.RecurrenceType; if (!ValidRecurrenceTypes.Contains(recurrenceType)) throw new ArgumentException($"recurrenceType must be one of: {string.Join(", ", ValidRecurrenceTypes)}"); existing.RecurrenceType = recurrenceType; } if (data.ContainsKey("startTimeOfDay")) existing.StartTimeOfDay = TimeSpan.Parse(data["startTimeOfDay"].GetString()!); if (data.ContainsKey("durationMinutes")) { var durationMinutes = data["durationMinutes"].GetInt32(); if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1"); existing.DurationMinutes = durationMinutes; } if (data.ContainsKey("weekDaysMask")) existing.WeekDaysMask = data["weekDaysMask"].GetInt32(); if (data.ContainsKey("monthDays")) existing.MonthDays = data["monthDays"].GetString(); if (data.ContainsKey("seriesStartDate") && data["seriesStartDate"].ValueKind != JsonValueKind.Null) existing.SeriesStartDate = DateTime.Parse(data["seriesStartDate"].GetString()!).Date; if (data.ContainsKey("seriesEndDate")) existing.SeriesEndDate = data["seriesEndDate"].ValueKind != JsonValueKind.Null ? DateTime.Parse(data["seriesEndDate"].GetString()!).Date : null; if (existing.RecurrenceType == "Weekly" && existing.WeekDaysMask == 0) throw new ArgumentException("Select at least one day of the week"); if (existing.RecurrenceType == "Monthly" && string.IsNullOrWhiteSpace(existing.MonthDays)) throw new ArgumentException("Select at least one day of the month"); // Anything past or already booked is left alone -- only future, unbooked // occurrences are cleared so the next read regenerates them under the new rule. await _classRepository.DeleteFutureUnbookedOccurrencesAsync(existing.FacilityClassId, DateTime.UtcNow); existing.OccurrencesGeneratedThrough = existing.SeriesStartDate > DateTime.UtcNow.Date ? existing.SeriesStartDate.AddDays(-1) : DateTime.UtcNow.Date.AddDays(-1); } else if (existing.RecurrenceType == "Custom" && data.ContainsKey("addCustomOccurrences")) { await AddCustomOccurrencesAsync(existing, data, "addCustomOccurrences"); } var result = await _classRepository.UpdateFacilityClass(existing); await _uow.CommitAsync(); return MapToDto(result); } catch { await _uow.RollbackAsync(); throw; } } public async Task GetOccurrences(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); var facilityClassId = Guid.Parse(data["facilityClassId"].GetString()!); bool includeCancelled = data.ContainsKey("includeCancelled") && data["includeCancelled"].GetBoolean(); var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var facilityClass = await _classRepository.GetFacilityClassById(facilityClassId); if (facilityClass == null || facilityClass.FKClientId != clientId) throw new KeyNotFoundException("Class not found"); var occurrences = await _classRepository.GetOccurrencesForClassAsync(facilityClassId, includeCancelled); return occurrences.Select(o => new { facilitySessionId = o.FacilitySessionId, startTime = o.StartTime, endTime = o.EndTime, capacity = o.Capacity, isUnlimitedCapacity = o.IsUnlimitedCapacity, isCancelled = o.IsCancelled, notHeldReason = o.NotHeldReason, notHeldAt = o.NotHeldAt }).ToList(); } public async Task GetSchedulingConflicts(object payload, HttpContext httpContext) { var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var conflicts = await _classRepository.GetSchedulingConflictsAsync(clientId); return conflicts.Select(b => new SchedulingConflictDto { BookingId = b.BookingId, FacilityId = b.FKFacilityId, FacilityName = b.Facility?.Name, GuestName = b.IsGuest ? b.GuestName : null, StartTime = b.StartTime, EndTime = b.EndTime }).ToList(); } public async Task EnsureOccurrencesGeneratedAsync(Guid clientId) { var horizon = DateTime.UtcNow.Date.AddDays(GenerationHorizonDays); var classes = await _classRepository.GetClassesNeedingGenerationAsync(clientId, horizon); if (classes.Count == 0) return; await _uow.BeginAsync(); try { foreach (var facilityClass in classes) await GenerateOccurrencesForClassAsync(facilityClass, horizon); await _uow.CommitAsync(); } catch (DbUpdateException) { // Two concurrent calendar loads raced to generate the same occurrence -- the // unique (FKFacilityClassId, StartTime) index caught it. Roll back this whole // batch rather than partially applying it; the next read regenerates safely // (watermarks simply won't have advanced this one time). await _uow.RollbackAsync(); } } private async Task GenerateOccurrencesForClassAsync(FacilityClass facilityClass, DateTime horizon) { var genFrom = facilityClass.OccurrencesGeneratedThrough > facilityClass.SeriesStartDate ? facilityClass.OccurrencesGeneratedThrough.AddDays(1) : facilityClass.SeriesStartDate; var genTo = facilityClass.SeriesEndDate.HasValue && facilityClass.SeriesEndDate.Value < horizon ? facilityClass.SeriesEndDate.Value : horizon; if (genFrom > genTo) { facilityClass.OccurrencesGeneratedThrough = horizon; return; } var existingStartTimes = await _classRepository.GetExistingOccurrenceStartTimesAsync( facilityClass.FacilityClassId, LocalToUtc(genFrom), LocalToUtc(genTo.AddDays(1))); var monthDays = !string.IsNullOrWhiteSpace(facilityClass.MonthDays) ? facilityClass.MonthDays.Split(',').Select(d => int.Parse(d.Trim())).ToHashSet() : new HashSet(); var toAdd = new List(); for (var date = genFrom.Date; date <= genTo.Date; date = date.AddDays(1)) { bool runsToday = facilityClass.RecurrenceType switch { "Daily" => true, "Weekly" => (facilityClass.WeekDaysMask & (1 << (int)date.DayOfWeek)) != 0, "Monthly" => monthDays.Contains(date.Day), _ => false }; if (!runsToday) continue; var utcStart = LocalToUtc(date.Date + facilityClass.StartTimeOfDay); if (existingStartTimes.Contains(utcStart)) continue; toAdd.Add(new FacilitySession { FKFacilityId = facilityClass.FKFacilityId, FKClientId = facilityClass.FKClientId, StartTime = utcStart, EndTime = utcStart.AddMinutes(facilityClass.DurationMinutes), Capacity = facilityClass.Capacity, IsUnlimitedCapacity = facilityClass.IsUnlimitedCapacity, FKFacilityClassId = facilityClass.FacilityClassId }); } if (toAdd.Count > 0) await _classRepository.AddOccurrencesAsync(toAdd); facilityClass.OccurrencesGeneratedThrough = genTo; } private static DateTime LocalToUtc(DateTime localUnspecified) => TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(localUnspecified, DateTimeKind.Unspecified), TimeZoneHelper.SriLanka); private async Task AddCustomOccurrencesAsync(FacilityClass facilityClass, Dictionary data, string key = "customOccurrences") { if (!data.ContainsKey(key) || data[key].ValueKind != JsonValueKind.Array) return; var occurrences = new List(); foreach (var occEl in data[key].EnumerateArray()) { var startTime = DateTime.Parse(occEl.GetProperty("startTime").GetString()!).ToUniversalTime(); var durationMinutes = occEl.GetProperty("durationMinutes").GetInt32(); if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1"); occurrences.Add(new FacilitySession { FKFacilityId = facilityClass.FKFacilityId, FKClientId = facilityClass.FKClientId, StartTime = startTime, EndTime = startTime.AddMinutes(durationMinutes), Capacity = facilityClass.Capacity, IsUnlimitedCapacity = facilityClass.IsUnlimitedCapacity, FKFacilityClassId = facilityClass.FacilityClassId }); } if (occurrences.Count > 0) await _classRepository.AddOccurrencesAsync(occurrences); } private static FacilityClassDto MapToDto(FacilityClass fc) => new() { FacilityClassId = fc.FacilityClassId, FacilityId = fc.FKFacilityId, FacilityName = fc.Facility?.Name, Name = fc.Name, Description = fc.Description, RecurrenceType = fc.RecurrenceType, StartTimeOfDay = fc.RecurrenceType == "Custom" ? null : fc.StartTimeOfDay.ToString("hh\\:mm"), DurationMinutes = fc.DurationMinutes, WeekDaysMask = fc.WeekDaysMask, MonthDays = fc.MonthDays, SeriesStartDate = fc.SeriesStartDate, SeriesEndDate = fc.SeriesEndDate, Capacity = fc.Capacity, IsUnlimitedCapacity = fc.IsUnlimitedCapacity, IsActive = fc.IsActive, CreatedAt = fc.CreatedAt }; } }