09d3d2778d
Backend CI / build-and-test (push) Successful in 53s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 7s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 6m26s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 14s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
515 lines
20 KiB
C#
515 lines
20 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
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<ScheduleSyncService> _logger;
|
|
private static SyncStatusDto _lastStatus = new(null, "idle", null);
|
|
|
|
public ScheduleSyncService(AppDbContext db, IModeusApiClient modeus, ILogger<ScheduleSyncService> logger)
|
|
{
|
|
_db = db; _modeus = modeus; _logger = logger;
|
|
}
|
|
|
|
public async Task<SyncResultDto> 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<string, int>? 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 mandatoryAttendeesCount = GetMandatoryAttendeesCount(ev.IctisStats);
|
|
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.MandatoryAttendeesCount = mandatoryAttendeesCount;
|
|
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,
|
|
MandatoryAttendeesCount = mandatoryAttendeesCount
|
|
});
|
|
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,
|
|
[
|
|
"endpoint=POST /api/ictis?includeCounts=true",
|
|
$"timeMin={request.TimeMin:O}",
|
|
$"timeMax={request.TimeMax:O}"
|
|
]));
|
|
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
public async Task<SyncResultDto> 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:<empty>, sort:+building.name,+name, deleted:false, page size:100"]));
|
|
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
public async Task<List<EmployeeDto>> SearchEmployeesAsync(string fullname)
|
|
{
|
|
var employees = await _modeus.SearchEmployeeAsync(fullname);
|
|
return employees.Select(e => new EmployeeDto(e.Id, e.FullName, e.Department)).ToList();
|
|
}
|
|
|
|
public Task<SyncStatusDto> GetLastSyncStatusAsync() => Task.FromResult(_lastStatus);
|
|
|
|
private async Task<User?> 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<string?> 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<User> 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<bool> 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<Location?> 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<IReadOnlyDictionary<string, int>> 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<string, int>();
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, int> BuildRoomCapacityLookup(IEnumerable<ModeusRoom>? rooms)
|
|
{
|
|
var result = new Dictionary<string, int>();
|
|
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<string, int> 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 int GetMandatoryAttendeesCount(ModeusIctisStats? stats) =>
|
|
Math.Max(0, stats?.StudentCount ?? 0) + Math.Max(0, stats?.TeacherCount ?? 0);
|
|
|
|
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<string> BuildErrorDetails(
|
|
Exception exception,
|
|
string stage,
|
|
int created,
|
|
int updated,
|
|
int skipped,
|
|
IReadOnlyList<string> context)
|
|
{
|
|
var details = new List<string>
|
|
{
|
|
$"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? 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()
|
|
};
|
|
}
|