using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System.Text.Json; using UniVerse.Application.DTOs.Sync; using UniVerse.Application.Interfaces; using UniVerse.Domain.Entities; using UniVerse.Domain.Enums; using UniVerse.Infrastructure.Data; namespace UniVerse.Infrastructure.Services; public class ScheduleSyncService : IScheduleSyncService { private readonly AppDbContext _db; private readonly IModeusApiClient _modeus; private readonly ILogger _logger; private static SyncStatusDto _lastStatus = new(null, "idle", null); public ScheduleSyncService(AppDbContext db, IModeusApiClient modeus, ILogger logger) { _db = db; _modeus = modeus; _logger = logger; } public async Task SyncScheduleAsync(SyncScheduleRequest request) { const string stage = "schedule"; int created = 0, updated = 0, skipped = 0; try { var events = await _modeus.SearchEventsAsync(request); var embeddedRoomCapacityById = BuildRoomCapacityLookup(events.Embedded?.Rooms); IReadOnlyDictionary? syncedRoomCapacityById = null; foreach (var ev in events.EventItems) { if (string.IsNullOrWhiteSpace(ev.Id) || string.IsNullOrWhiteSpace(ev.Name)) { skipped++; continue; } var courseUnitId = GetHrefId(ev.Links?.CourseUnitRealization?.Href); var courseUnit = events.Embedded?.CourseUnitRealizations? .FirstOrDefault(c => c.Id == courseUnitId); var courseExternalId = courseUnit?.Id ?? ev.TypeId ?? ev.Id; var courseName = courseUnit?.Name ?? ev.Name; var location = await UpsertEventLocationAsync(events, ev.Id); var teacher = await UpsertEventTeacherAsync(events, ev.Id); var roomId = GetEventRoomId(events, ev.Id); var maxEnrollments = GetRoomCapacity(embeddedRoomCapacityById, roomId); if (maxEnrollments is null && !string.IsNullOrWhiteSpace(roomId)) { syncedRoomCapacityById ??= await LoadRoomCapacityLookupAsync(); maxEnrollments = GetRoomCapacity(syncedRoomCapacityById, roomId); } var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0; var startsAt = EnsureUtc(ev.StartsAt); var endsAt = EnsureUtc(ev.EndsAt); var existing = await _db.Lectures.FirstOrDefaultAsync(l => l.ExternalId == ev.Id); if (existing != null) { existing.Title = ev.Name; existing.Description = ev.Description; existing.StartsAt = startsAt; existing.EndsAt = endsAt; existing.LocationId = location?.Id; existing.TeacherId = teacher?.Id; existing.MaxEnrollments = lectureCapacity; existing.UpdatedAt = DateTime.UtcNow; updated++; } else { var course = await _db.Courses.FirstOrDefaultAsync(c => c.ExternalId == courseExternalId); if (course == null) { course = new Course { Name = courseName, ExternalId = courseExternalId, IsSynced = true }; _db.Courses.Add(course); await _db.SaveChangesAsync(); } _db.Lectures.Add(new Lecture { CourseId = course.Id, TeacherId = teacher?.Id, LocationId = location?.Id, Title = ev.Name, Description = ev.Description, ExternalId = ev.Id, StartsAt = startsAt, EndsAt = endsAt, MaxEnrollments = lectureCapacity }); created++; } } await _db.SaveChangesAsync(); var result = new SyncResultDto(created, updated, skipped, null); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "completed", result); return result; } catch (Exception ex) { _logger.LogError(ex, "Schedule sync failed"); var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails( ex, stage, created, updated, skipped, [ $"requestJson={BuildScheduleRequestJson(request)}", $"timeMin={request.TimeMin:O}", $"timeMax={request.TimeMax:O}" ])); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); return result; } } public async Task SyncRoomsAsync() { const string stage = "rooms"; int created = 0, updated = 0, skipped = 0; try { var rooms = await _modeus.SearchRoomsAsync(); foreach (var room in rooms?.RoomItems ?? []) { if (room is null || string.IsNullOrWhiteSpace(room.Id) || string.IsNullOrWhiteSpace(room.Name)) { skipped++; continue; } var existing = await _db.Locations.FirstOrDefaultAsync(l => l.ExternalId == room.Id); if (existing != null) { existing.Name = room.Name; existing.Room = room.NameShort; existing.Building = room.Building?.Name ?? room.Building?.NameShort; existing.Address = room.Building?.Address; updated++; } else { _db.Locations.Add(new Location { Name = room.Name, Room = room.NameShort, Building = room.Building?.Name ?? room.Building?.NameShort, Address = room.Building?.Address, ExternalId = room.Id }); created++; } } await _db.SaveChangesAsync(); var result = new SyncResultDto(created, updated, skipped, null); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "completed", result); return result; } catch (Exception ex) { _logger.LogError(ex, "Rooms sync failed"); var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails( ex, stage, created, updated, skipped, ["request=name:, sort:+building.name,+name, deleted:false, page size:100"])); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); return result; } } public async Task> SearchEmployeesAsync(string fullname) { var employees = await _modeus.SearchEmployeeAsync(fullname); return employees.Select(e => new EmployeeDto(e.Id, e.FullName, e.Department)).ToList(); } public Task GetLastSyncStatusAsync() => Task.FromResult(_lastStatus); private async Task UpsertEventTeacherAsync(ModeusEventsResponse events, string eventId) { var personId = events.Embedded?.EventAttendees? .Where(attendee => string.Equals(attendee.RoleId, "TEACH", StringComparison.OrdinalIgnoreCase)) .Select(attendee => new { EventId = GetHrefId(attendee.Links?.Event?.Href), PersonId = GetHrefId(attendee.Links?.Person?.Href) }) .FirstOrDefault(link => link.EventId == eventId) ?.PersonId; if (string.IsNullOrWhiteSpace(personId)) return null; var person = events.Embedded?.Persons?.FirstOrDefault(item => item.Id == personId); var fullName = BuildPersonFullName(person); if (string.IsNullOrWhiteSpace(fullName)) return null; var existingProfile = await _db.TeacherProfiles .Include(profile => profile.User) .ThenInclude(user => user.Roles) .FirstOrDefaultAsync(profile => profile.ModeusId == personId); var subId = existingProfile?.User.MicrosoftId; if (string.IsNullOrWhiteSpace(subId)) subId = await TryGetTeacherSubIdAsync(fullName); User? ssoUser = null; if (!string.IsNullOrWhiteSpace(subId)) { ssoUser = await _db.Users .Include(item => item.Roles) .Include(item => item.TeacherProfile) .FirstOrDefaultAsync(item => item.MicrosoftId == subId); } if (existingProfile != null && ssoUser != null && existingProfile.UserId != ssoUser.Id) return await MergeTeacherPlaceholderAsync(existingProfile, ssoUser, fullName, subId); if (existingProfile != null) { existingProfile.User.DisplayName = fullName; if (!string.IsNullOrWhiteSpace(subId)) existingProfile.User.MicrosoftId = subId; existingProfile.User.UpdatedAt = DateTime.UtcNow; EnsureTeacherRole(existingProfile.User); return existingProfile.User; } if (ssoUser != null) { ssoUser.DisplayName = fullName; ssoUser.UpdatedAt = DateTime.UtcNow; EnsureTeacherRole(ssoUser); EnsureTeacherProfile(ssoUser, personId); await _db.SaveChangesAsync(); return ssoUser; } var email = BuildModeusTeacherEmail(personId); var user = await _db.Users .Include(item => item.Roles) .Include(item => item.TeacherProfile) .FirstOrDefaultAsync(item => item.Email == email); if (user == null) { user = new User { Email = email, DisplayName = fullName, MicrosoftId = subId, IsActive = true, TeacherProfile = new TeacherProfile { ModeusId = personId } }; user.Roles.Add(new UserRoleAssignment { User = user, Role = UserRole.Teacher }); _db.Users.Add(user); await _db.SaveChangesAsync(); return user; } user.DisplayName = fullName; if (!string.IsNullOrWhiteSpace(subId)) user.MicrosoftId = subId; user.UpdatedAt = DateTime.UtcNow; if (user.TeacherProfile == null) user.TeacherProfile = new TeacherProfile { UserId = user.Id, ModeusId = personId }; else user.TeacherProfile.ModeusId = personId; EnsureTeacherRole(user); await _db.SaveChangesAsync(); return user; } private async Task TryGetTeacherSubIdAsync(string fullName) { try { return await _modeus.GetSubIdByFullNameAsync(fullName); } catch (Exception ex) { _logger.LogWarning(ex, "Could not resolve SSO sub id for teacher {TeacherFullName}. A placeholder teacher will be used until a future sync succeeds.", fullName); return null; } } private async Task MergeTeacherPlaceholderAsync( TeacherProfile placeholderProfile, User targetUser, string fullName, string? subId) { var placeholderUser = placeholderProfile.User; var lectures = await _db.Lectures .Where(lecture => lecture.TeacherId == placeholderUser.Id) .ToListAsync(); foreach (var lecture in lectures) lecture.TeacherId = targetUser.Id; targetUser.DisplayName = fullName; if (!string.IsNullOrWhiteSpace(subId)) targetUser.MicrosoftId = subId; targetUser.UpdatedAt = DateTime.UtcNow; EnsureTeacherRole(targetUser); if (targetUser.TeacherProfile == null) { placeholderProfile.UserId = targetUser.Id; placeholderProfile.User = targetUser; targetUser.TeacherProfile = placeholderProfile; placeholderUser.TeacherProfile = null; } else { targetUser.TeacherProfile.ModeusId = placeholderProfile.ModeusId; _db.TeacherProfiles.Remove(placeholderProfile); } if (await CanDeletePlaceholderUserAsync(placeholderUser.Id)) _db.Users.Remove(placeholderUser); await _db.SaveChangesAsync(); return targetUser; } private async Task CanDeletePlaceholderUserAsync(int userId) => !await _db.StudentProfiles.AnyAsync(profile => profile.UserId == userId) && !await _db.RefreshTokens.AnyAsync(token => token.UserId == userId) && !await _db.LectureEnrollments.AnyAsync(enrollment => enrollment.UserId == userId) && !await _db.Reviews.AnyAsync(review => review.UserId == userId) && !await _db.UserAchievements.AnyAsync(achievement => achievement.UserId == userId) && !await _db.CoinTransactions.AnyAsync(transaction => transaction.UserId == userId) && !await _db.UserNotifications.AnyAsync(notification => notification.UserId == userId); private static void EnsureTeacherProfile(User user, string modeusId) { if (user.TeacherProfile == null) user.TeacherProfile = new TeacherProfile { UserId = user.Id, ModeusId = modeusId }; else user.TeacherProfile.ModeusId = modeusId; } private static void EnsureTeacherRole(User user) { if (!user.Roles.Any(role => role.Role == UserRole.Teacher)) user.Roles.Add(new UserRoleAssignment { UserId = user.Id, Role = UserRole.Teacher }); } private async Task UpsertEventLocationAsync(ModeusEventsResponse events, string eventId) { var roomId = GetEventRoomId(events, eventId); if (string.IsNullOrWhiteSpace(roomId)) return null; var room = events.Embedded?.Rooms?.FirstOrDefault(item => item.Id == roomId); if (room == null || string.IsNullOrWhiteSpace(room.Name)) return null; var existing = await _db.Locations.FirstOrDefaultAsync(location => location.ExternalId == room.Id); if (existing != null) { existing.Name = room.Name; existing.Room = room.NameShort; existing.Building = room.Building?.Name ?? room.Building?.NameShort; existing.Address = room.Building?.Address; return existing; } var location = new Location { Name = room.Name, Room = room.NameShort, Building = room.Building?.Name ?? room.Building?.NameShort, Address = room.Building?.Address, ExternalId = room.Id }; _db.Locations.Add(location); await _db.SaveChangesAsync(); return location; } private static string? GetEventRoomId(ModeusEventsResponse events, string eventId) => events.Embedded?.EventRooms? .Select(eventRoom => new { EventId = GetHrefId(eventRoom.Links?.Event?.Href), RoomId = GetHrefId(eventRoom.Links?.Room?.Href) }) .FirstOrDefault(link => link.EventId == eventId) ?.RoomId; private async Task> LoadRoomCapacityLookupAsync() { try { var rooms = await _modeus.SearchRoomsAsync(); return BuildRoomCapacityLookup(rooms.RoomItems); } catch (Exception ex) { _logger.LogWarning(ex, "Could not load room capacities from Modeus rooms search."); return new Dictionary(); } } private static IReadOnlyDictionary BuildRoomCapacityLookup(IEnumerable? rooms) { var result = new Dictionary(); foreach (var room in rooms ?? []) { var capacity = NormalizeCapacity(room.WorkingCapacity) ?? NormalizeCapacity(room.TotalCapacity); if (!string.IsNullOrWhiteSpace(room.Id) && capacity.HasValue) result.TryAdd(room.Id, capacity.Value); } return result; } private static int? GetRoomCapacity(IReadOnlyDictionary roomCapacityById, string? roomId) => !string.IsNullOrWhiteSpace(roomId) && roomCapacityById.TryGetValue(roomId, out var capacity) ? capacity : null; private static int? GetEventTeamSize(ModeusEventsResponse events, string eventId) => NormalizeCapacity(events.Embedded?.EventTeams? .FirstOrDefault(team => team.EventId == eventId)?.Size); private static int? NormalizeCapacity(int? capacity) => capacity is > 0 ? capacity : null; private static string BuildModeusTeacherEmail(string personId) => $"modeus-{personId}@modeus.local".ToLowerInvariant(); private static string? BuildPersonFullName(ModeusPerson? person) { if (person == null) return null; if (!string.IsNullOrWhiteSpace(person.FullName)) return person.FullName.Trim(); var parts = new[] { person.LastName, person.FirstName, person.MiddleName } .Where(part => !string.IsNullOrWhiteSpace(part)) .Select(part => part!.Trim()); var fullName = string.Join(" ", parts); return string.IsNullOrWhiteSpace(fullName) ? null : fullName; } private static IReadOnlyList BuildErrorDetails( Exception exception, string stage, int created, int updated, int skipped, IReadOnlyList context) { var details = new List { $"stage={stage}", $"exceptionType={exception.GetType().FullName}", $"message={exception.Message}", $"partialResult=created:{created}, updated:{updated}, skipped:{skipped}" }; if (exception is HttpRequestException httpException && httpException.StatusCode.HasValue) details.Add($"httpStatus={(int)httpException.StatusCode.Value} {httpException.StatusCode.Value}"); if (exception.InnerException != null) details.Add($"innerException={exception.InnerException.GetType().FullName}: {exception.InnerException.Message}"); details.AddRange(context); return details; } private static string BuildScheduleRequestJson(SyncScheduleRequest request) { var body = new Dictionary { ["size"] = request.Size is > 0 ? request.Size.Value : 900, ["timeMin"] = request.TimeMin, ["timeMax"] = request.TimeMax }; AddNonEmpty(body, "roomId", request.RoomId); AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId); AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId); AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId); AddNonEmpty(body, "specialtyCode", request.SpecialtyCode); AddNonEmpty(body, "learningStartYear", request.LearningStartYear); AddNonEmpty(body, "profileName", request.ProfileName); AddNonEmpty(body, "curriculumId", request.CurriculumId); AddNonEmpty(body, "typeId", request.TypeId); return JsonSerializer.Serialize(body); } private static void AddNonEmpty( IDictionary body, string key, IReadOnlyList? values) { if (values is { Count: > 0 }) body[key] = values; } private static string? GetHrefId(string? href) { if (string.IsNullOrWhiteSpace(href)) return null; var index = href.LastIndexOf('/'); return index >= 0 && index < href.Length - 1 ? href[(index + 1)..] : href; } private static DateTime EnsureUtc(DateTime value) => value.Kind switch { DateTimeKind.Utc => value, DateTimeKind.Local => value.ToUniversalTime(), _ => DateTime.SpecifyKind(value, DateTimeKind.Local).ToUniversalTime() }; }