diff --git a/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs b/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs index febc25d..5e14dab 100644 --- a/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs +++ b/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs @@ -19,8 +19,66 @@ public interface IModeusApiClient } // Modeus API response models -public record ModeusEvent(string Id, string Name, DateTime StartsAt, DateTime EndsAt, string? RoomId, string? TeacherId, string? TypeId); -public record ModeusEventsResponse(List Events); +public class ModeusEvent +{ + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string? NameShort { get; init; } + public string? Description { get; init; } + public string? TypeId { get; init; } + public DateTime StartsAt { get; init; } + public DateTime EndsAt { get; init; } + + [JsonPropertyName("_links")] + public ModeusEventLinks? Links { get; init; } +} + +public class ModeusEventLinks +{ + [JsonPropertyName("course-unit-realization")] + public ModeusHrefLink? CourseUnitRealization { get; init; } +} + +public class ModeusEventsResponse +{ + [JsonPropertyName("_embedded")] + public ModeusEventsEmbedded? Embedded { get; init; } + public List? Events { get; init; } + public ModeusPage? Page { get; init; } + + [JsonIgnore] + public IReadOnlyList EventItems => Embedded?.Events ?? Events ?? []; +} +public class ModeusEventsEmbedded +{ + public List? Events { get; init; } + + [JsonPropertyName("course-unit-realizations")] + public List? CourseUnitRealizations { get; init; } + + [JsonPropertyName("event-rooms")] + public List? EventRooms { get; init; } + + [JsonPropertyName("event-teams")] + public List? EventTeams { get; init; } + + public List? Rooms { get; init; } +} +public record ModeusHrefLink(string? Href); +public record ModeusCourseUnitRealization(string Id, string Name, string? NameShort); +public class ModeusEventRoom +{ + public string Id { get; init; } = string.Empty; + + [JsonPropertyName("_links")] + public ModeusEventRoomLinks? Links { get; init; } +} +public class ModeusEventRoomLinks +{ + public ModeusHrefLink? Event { get; init; } + public ModeusHrefLink? Room { get; init; } +} +public record ModeusEventTeam(string EventId, int? Size); public record ModeusBuilding(string? Id, string? Name, string? NameShort, string? Address); public record ModeusRoom(string Id, string Name, string? NameShort, ModeusBuilding? Building, int? TotalCapacity, int? WorkingCapacity); public record ModeusRoomsEmbedded(List? Rooms); diff --git a/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs index 07e5031..7ee438a 100644 --- a/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs +++ b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs @@ -1,4 +1,5 @@ using System.Net.Http.Json; +using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using UniVerse.Application.DTOs.Sync; @@ -21,11 +22,30 @@ public class ModeusApiClient : IModeusApiClient public async Task SearchEventsAsync(SyncScheduleRequest request) { - var body = new { specialtyCode = request.SpecialtyCode, timeMin = request.TimeMin, timeMax = request.TimeMax, typeId = request.TypeId }; + const int pageSize = 900; + var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode) + ? [] + : request.SpecialtyCode + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var typeIds = request.TypeId ?? []; + var body = new Dictionary + { + ["size"] = pageSize, + ["timeMin"] = request.TimeMin, + ["timeMax"] = request.TimeMax, + ["specialtyCode"] = specialtyCodes + }; + + if (typeIds.Count > 0) + body["typeId"] = typeIds; + var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body); + var requestJson = JsonSerializer.Serialize(body); await EnsureSuccessAsync(response, "Modeus events search", - $"specialtyCode={request.SpecialtyCode ?? ""}, timeMin={request.TimeMin:O}, timeMax={request.TimeMax:O}, typeId=[{string.Join(", ", request.TypeId ?? [])}]"); - return await response.Content.ReadFromJsonAsync() ?? new(new()); + BuildEventsRequestSummary(pageSize, specialtyCodes, request.TimeMin, request.TimeMax, typeIds, requestJson)); + return await ReadJsonAsync(response, "Modeus events search", + BuildEventsRequestSummary(pageSize, specialtyCodes, request.TimeMin, request.TimeMax, typeIds, requestJson)) + ?? new ModeusEventsResponse(); } public async Task SearchRoomsAsync() @@ -50,7 +70,9 @@ public class ModeusApiClient : IModeusApiClient await EnsureSuccessAsync(response, "Modeus rooms search", $"name=, sort=+building.name,+name, size={pageSize}, page={page}, deleted=false"); - var payload = await response.Content.ReadFromJsonAsync() ?? new ModeusRoomsResponse(); + var payload = await ReadJsonAsync(response, "Modeus rooms search", + $"name=, sort=+building.name,+name, size={pageSize}, page={page}, deleted=false") + ?? new ModeusRoomsResponse(); allRooms.AddRange(payload.RoomItems); totalPages = payload.Page?.TotalPages ?? page + 1; @@ -66,15 +88,54 @@ public class ModeusApiClient : IModeusApiClient if (response.IsSuccessStatusCode) return; var responseBody = await response.Content.ReadAsStringAsync(); - if (responseBody.Length > 2000) - responseBody = string.Concat(responseBody.AsSpan(0, 2000), "..."); throw new HttpRequestException( - $"{operation} failed with HTTP {(int)response.StatusCode} {response.ReasonPhrase}. Request: {requestSummary}. Response body: {responseBody}", + $"{operation} failed with HTTP {(int)response.StatusCode} {response.ReasonPhrase}. Request: {requestSummary}. Response body: {Truncate(responseBody)}", null, response.StatusCode); } + private static string BuildEventsRequestSummary( + int size, + IReadOnlyList specialtyCodes, + DateTime? timeMin, + DateTime? timeMax, + IReadOnlyList typeIds, + string requestJson) + { + var typeFilter = typeIds.Count > 0 ? $"typeId=[{string.Join(", ", typeIds)}]" : "typeId="; + return $"size={size}, specialtyCode=[{string.Join(", ", specialtyCodes)}], timeMin={timeMin:O}, timeMax={timeMax:O}, {typeFilter}. Request JSON: {requestJson}"; + } + + private static async Task ReadJsonAsync(HttpResponseMessage response, string operation, string requestSummary) + { + var responseBody = await response.Content.ReadAsStringAsync(); + var contentType = response.Content.Headers.ContentType?.ToString() ?? ""; + var contentLength = response.Content.Headers.ContentLength?.ToString() ?? ""; + + if (string.IsNullOrWhiteSpace(responseBody)) + { + throw new HttpRequestException( + $"{operation} returned HTTP {(int)response.StatusCode} {response.ReasonPhrase} with an empty response body. Request: {requestSummary}. Content-Type: {contentType}. Content-Length: {contentLength}.", + null, + response.StatusCode); + } + + try + { + return JsonSerializer.Deserialize(responseBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"{operation} returned invalid JSON. Request: {requestSummary}. Content-Type: {contentType}. Response body: {Truncate(responseBody)}", + ex); + } + } + + private static string Truncate(string value) => + value.Length > 2000 ? string.Concat(value.AsSpan(0, 2000), "...") : value; + public async Task> SearchEmployeeAsync(string fullname) { var response = await _http.GetFromJsonAsync>( diff --git a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs index 35c3aad..250127d 100644 --- a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs +++ b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using System.Text.Json; using UniVerse.Application.DTOs.Sync; using UniVerse.Application.Interfaces; using UniVerse.Domain.Entities; @@ -26,15 +27,58 @@ public class ScheduleSyncService : IScheduleSyncService try { var events = await _modeus.SearchEventsAsync(request); - foreach (var ev in events.Events) + 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 maxEnrollments = events.Embedded?.EventTeams? + .FirstOrDefault(team => team.EventId == ev.Id)?.Size ?? 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) { updated++; existing.StartsAt = ev.StartsAt; existing.EndsAt = ev.EndsAt; existing.UpdatedAt = DateTime.UtcNow; } + if (existing != null) + { + existing.Title = ev.Name; + existing.Description = ev.Description; + existing.StartsAt = startsAt; + existing.EndsAt = endsAt; + existing.LocationId = location?.Id; + existing.MaxEnrollments = maxEnrollments; + existing.UpdatedAt = DateTime.UtcNow; + updated++; + } else { - var course = await _db.Courses.FirstOrDefaultAsync(c => c.ExternalId == ev.TypeId); - if (course == null) { course = new Course { Name = ev.Name, ExternalId = ev.TypeId, IsSynced = true }; _db.Courses.Add(course); await _db.SaveChangesAsync(); } - _db.Lectures.Add(new Lecture { CourseId = course.Id, Title = ev.Name, ExternalId = ev.Id, StartsAt = ev.StartsAt, EndsAt = ev.EndsAt }); + 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, + LocationId = location?.Id, + Title = ev.Name, + Description = ev.Description, + ExternalId = ev.Id, + StartsAt = startsAt, + EndsAt = endsAt, + MaxEnrollments = maxEnrollments + }); created++; } } @@ -46,6 +90,9 @@ public class ScheduleSyncService : IScheduleSyncService catch (Exception ex) { _logger.LogError(ex, "Schedule sync failed"); + var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode) + ? [] + : request.SpecialtyCode.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails( ex, stage, @@ -53,10 +100,14 @@ public class ScheduleSyncService : IScheduleSyncService updated, skipped, [ - $"specialtyCode={request.SpecialtyCode ?? ""}", + "size=900", + $"requestJson={BuildScheduleRequestJson(request)}", + $"specialtyCode=[{string.Join(", ", specialtyCodes)}]", $"timeMin={request.TimeMin:O}", $"timeMax={request.TimeMax:O}", - $"typeId=[{string.Join(", ", request.TypeId ?? [])}]" + request.TypeId is { Count: > 0 } + ? $"typeId=[{string.Join(", ", request.TypeId)}]" + : "typeId=" ])); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); return result; @@ -129,6 +180,48 @@ public class ScheduleSyncService : IScheduleSyncService public Task GetLastSyncStatusAsync() => Task.FromResult(_lastStatus); + private async Task UpsertEventLocationAsync(ModeusEventsResponse events, string eventId) + { + var roomId = events.Embedded?.EventRooms? + .Select(eventRoom => new + { + EventId = GetHrefId(eventRoom.Links?.Event?.Href), + RoomId = GetHrefId(eventRoom.Links?.Room?.Href) + }) + .FirstOrDefault(link => link.EventId == eventId) + ?.RoomId; + + 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 IReadOnlyList BuildErrorDetails( Exception exception, string stage, @@ -154,4 +247,43 @@ public class ScheduleSyncService : IScheduleSyncService details.AddRange(context); return details; } + + private static string BuildScheduleRequestJson(SyncScheduleRequest request) + { + var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode) + ? [] + : request.SpecialtyCode.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + var body = new Dictionary + { + ["size"] = 900, + ["timeMin"] = request.TimeMin, + ["timeMax"] = request.TimeMax, + ["specialtyCode"] = specialtyCodes + }; + + if (request.TypeId is { Count: > 0 }) + body["typeId"] = request.TypeId; + + return JsonSerializer.Serialize(body); + } + + 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() + }; } diff --git a/frontend/src/views/admin/AdminLecturesView.vue b/frontend/src/views/admin/AdminLecturesView.vue index 1aa281f..872e4df 100644 --- a/frontend/src/views/admin/AdminLecturesView.vue +++ b/frontend/src/views/admin/AdminLecturesView.vue @@ -51,14 +51,16 @@ function toInputDateTime(date: Date) { return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16) } -const now = new Date() -const inTwoWeeks = new Date(now) +const todayStart = new Date() +todayStart.setHours(0, 0, 0, 0) +const inTwoWeeks = new Date(todayStart) inTwoWeeks.setDate(inTwoWeeks.getDate() + 14) +inTwoWeeks.setHours(23, 59, 0, 0) const syncForm = ref({ specialtyCode: '', typeIds: [] as ApiScheduleTypeId[], - timeMin: toInputDateTime(now), + timeMin: toInputDateTime(todayStart), timeMax: toInputDateTime(inTwoWeeks), })