From d5353eea9cb22ce63256da97a4e4d82a142497eb Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Wed, 25 Mar 2026 10:56:54 +0100 Subject: [PATCH] wip service, fix some assistant ia bug, wip section agenda update, section event annotation etc --- .gitignore | 3 + CLAUDE.md | 62 + .../Controllers/ApiKeyController.cs | 3 +- .../Controllers/SectionAgendaController.cs | 36 + .../Controllers/SectionController.cs | 36 +- .../Controllers/SectionEventController.cs | 82 + .../Controllers/SectionMapController.cs | 3 +- ManagerService/Controllers/StatsController.cs | 53 + ManagerService/DTOs/AiChatDTO.cs | 1 + ManagerService/DTOs/EventAgendaDTO.cs | 10 + ManagerService/DTOs/RemoteEventAgendaDTO.cs | 96 ++ ManagerService/DTOs/StatsSummaryDTO.cs | 23 + ManagerService/DTOs/SubSection/MapDTO.cs | 1 + .../DTOs/SubSection/SectionEventDTO.cs | 2 + ManagerService/Data/MyInfoMateDbContext.cs | 16 + ManagerService/Data/SubSection/EventAgenda.cs | 28 +- .../Data/SubSection/SectionEvent.cs | 5 + ManagerService/Data/SubSection/SectionMap.cs | 1 + ManagerService/Data/VisitEvent.cs | 3 +- ...AddEventMapFeaturesAndListView.Designer.cs | 1418 ++++++++++++++++ ...17120000_AddEventMapFeaturesAndListView.cs | 94 ++ ..._AddVideoResourceToEventAgenda.Designer.cs | 1439 +++++++++++++++++ ...325095228_AddVideoResourceToEventAgenda.cs | 79 + .../MyInfoMateDbContextModelSnapshot.cs | 50 + ManagerService/Services/AgendaSyncService.cs | 134 ++ .../Services/ApiKeyDatabaseService.cs | 3 +- ManagerService/Services/AssistantService.cs | 513 +++++- ManagerService/Services/SectionFactory.cs | 8 +- ManagerService/Startup.cs | 42 +- ManagerService/appsettings.json | 5 +- 30 files changed, 4186 insertions(+), 63 deletions(-) create mode 100644 CLAUDE.md create mode 100644 ManagerService/DTOs/RemoteEventAgendaDTO.cs create mode 100644 ManagerService/Migrations/20260317120000_AddEventMapFeaturesAndListView.Designer.cs create mode 100644 ManagerService/Migrations/20260317120000_AddEventMapFeaturesAndListView.cs create mode 100644 ManagerService/Migrations/20260325095228_AddVideoResourceToEventAgenda.Designer.cs create mode 100644 ManagerService/Migrations/20260325095228_AddVideoResourceToEventAgenda.cs create mode 100644 ManagerService/Services/AgendaSyncService.cs diff --git a/.gitignore b/.gitignore index 0944ee7..217edaa 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ obj *.user ManagerService/bin ManagerService/obj + +# Firebase service account key (sensitive — never commit) +*firebase-adminsdk*.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b97ca19 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +# manager-service + +Backend ASP.NET Core 8 de la solution MyInfoMate. API REST consommée par toutes les apps. + +## Stack +- .NET 8 / ASP.NET Core Web API +- PostgreSQL + Entity Framework Core 9 (Npgsql) +- Hangfire (jobs planifiés, dashboard sur `/hangfire`) +- MQTT (MQTTnet) pour la communication temps réel avec les devices +- Firebase Admin (push notifications) +- Gemini 2.5-Flash-Lite via endpoint compatible OpenAI (AssistantService) +- NSwag pour la génération OpenAPI/Swagger + +## Structure +``` +ManagerService/ +├── Controllers/ # 16 contrôleurs REST (un par domaine) +├── DTOs/ # Objets de transfert, incluant RemoteEventAgendaDTO (APIs PHP externes) +├── Data/ # Modèles EF + MyInfoMateDbContext +│ └── SubSection/ # 13 sous-types de Section (SectionEvent, SectionMap, SectionQuiz, etc.) +├── Services/ # Logique métier (DatabaseService par entité, SectionFactory, AssistantService) +├── Helpers/ # Utilitaires (PasswordUtils, ImageHelper, GeometryMapper...) +├── Extensions/ # DI et config (MqttClientService, AppSettingsProvider...) +├── Security/ # Auth JWT + API Key, policies (ContentEditor, SuperAdmin...) +└── Migrations/ # Migrations EF Core +``` + +`Manager.Framework/` et `Manager.Interfaces/` sont des packages NuGet compilés, pas de source dans ce repo. + +## Base de données +- PostgreSQL, base `my_info_mate` +- JSONB pour le contenu multilingue (`List` pour Title, Description) +- Héritage TPH (Table Per Hierarchy) pour les types de Section (discriminateur sur la colonne `Discriminator`) +- NetTopologySuite pour les données géospatiales (cartes, annotations) +- Migrations : `dotnet ef migrations add ` puis `dotnet ef database update` + +## Auth & sécurité +- JWT Bearer pour les apps manager +- API Key (`X-Api-Key` header) pour les apps mobiles visiteurs +- Rôles : SuperAdmin, InstanceAdmin, ContentEditor, Viewer +- Langues supportées : FR, NL, EN, DE, IT, ES, PL, CN, AR, UK + +## APIs PHP externes +Certaines APIs tierces (agendas en ligne) retournent du JSON PHP-style : +- Champs en `snake_case` +- `false` à la place d'un objet ou `null` → géré par `FalseToNullConverter` dans `DTOs/RemoteEventAgendaDTO.cs` +- Dates au format `YYYYMMDD` (ex: `"20260327"`) → parsées dans `RemoteEventAgendaDTO.GetDateFrom()` +- Désérialisation tolérante aux erreurs via `RemoteAgendaJsonSettings.Lenient` + +## Docker +- Multi-stage build, image finale `aspnet:8.0`, port 80 +- `docker-compose.yml` avec Traefik, SSL Let's Encrypt +- Domaine API : `api.mymuseum.be` +- Config via fichier `.env` et volume `/etc/managerservice` + +## Commandes utiles +```bash +dotnet run # Lancer en local +dotnet ef migrations add # Nouvelle migration +dotnet ef database update # Appliquer les migrations +docker compose up -d # Lancer via Docker +``` diff --git a/ManagerService/Controllers/ApiKeyController.cs b/ManagerService/Controllers/ApiKeyController.cs index 031c45c..129ffda 100644 --- a/ManagerService/Controllers/ApiKeyController.cs +++ b/ManagerService/Controllers/ApiKeyController.cs @@ -43,7 +43,7 @@ namespace ManagerService.Controllers if (string.IsNullOrEmpty(instanceId)) return Forbid(); - var plainKey = await _apiKeyService.CreateAsync(instanceId, request.Name, request.AppType); + var plainKey = await _apiKeyService.CreateAsync(instanceId, request.Name, request.AppType, request.DateExpiration); return Ok(new { key = plainKey }); } @@ -64,5 +64,6 @@ namespace ManagerService.Controllers { public string Name { get; set; } public ApiKeyAppType AppType { get; set; } + public DateTime? DateExpiration { get; set; } } } diff --git a/ManagerService/Controllers/SectionAgendaController.cs b/ManagerService/Controllers/SectionAgendaController.cs index 96b6ec1..d9ba69f 100644 --- a/ManagerService/Controllers/SectionAgendaController.cs +++ b/ManagerService/Controllers/SectionAgendaController.cs @@ -75,6 +75,42 @@ namespace ManagerService.Controllers } } + /// + /// Get upcoming events from section (dateFrom >= today) + /// + /// Section id + [AllowAnonymous] + [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(string), 404)] + [ProducesResponseType(typeof(string), 500)] + [HttpGet("{sectionAgendaId}/events/upcoming")] + public ObjectResult GetUpcomingEventAgendas(string sectionAgendaId) + { + try + { + SectionAgenda sectionAgenda = _myInfoMateDbContext.Sections.OfType().Include(sa => sa.EventAgendas).ThenInclude(sa => sa.Resource).FirstOrDefault(sa => sa.Id == sectionAgendaId); + + if (sectionAgenda == null) + throw new KeyNotFoundException("Section agenda does not exist"); + + var today = DateTime.Today; + var upcoming = sectionAgenda.EventAgendas + .Where(ea => ea.DateFrom == null || ea.DateFrom >= today) + .OrderBy(ea => ea.DateFrom) + .Select(ea => ea.ToDTO()); + + return new OkObjectResult(upcoming); + } + catch (KeyNotFoundException ex) + { + return new NotFoundObjectResult(ex.Message) { }; + } + catch (Exception ex) + { + return new ObjectResult(ex.Message) { StatusCode = 500 }; + } + } + /// /// Create new event /// diff --git a/ManagerService/Controllers/SectionController.cs b/ManagerService/Controllers/SectionController.cs index 622aee6..a5d653a 100644 --- a/ManagerService/Controllers/SectionController.cs +++ b/ManagerService/Controllers/SectionController.cs @@ -1,3 +1,4 @@ +using Hangfire; using Manager.DTOs; using Manager.Helpers; using Manager.Interfaces.Models; @@ -205,8 +206,12 @@ namespace ManagerService.Controllers (dto as AgendaDTO).events = eventAgendaDTOs; break; case SectionType.Event: - var sectionEvent = _myInfoMateDbContext.Sections.OfType().Include(se => se.Programme).ThenInclude(se => se.MapAnnotations).FirstOrDefault(s => s.Id == id); - (dto as SectionEventDTO).Programme = sectionEvent.Programme; // TODO test ! Need dto ? + var sectionEvent = _myInfoMateDbContext.Sections.OfType() + .Include(se => se.Programme).ThenInclude(se => se.MapAnnotations) + .Include(se => se.GlobalMapAnnotations) + .FirstOrDefault(s => s.Id == section.Id); + (dto as SectionEventDTO).Programme = sectionEvent.Programme; // TODO test ! Need dto ? + (dto as SectionEventDTO).GlobalMapAnnotations = sectionEvent.GlobalMapAnnotations?.Select(ma => ma.ToDTO()).ToList() ?? new(); break; case SectionType.Game: Resource resource = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == (dto as GameDTO).puzzleImageId); @@ -367,12 +372,12 @@ namespace ManagerService.Controllers } else { - Console.WriteLine("Aucune ville trouvée."); + Console.WriteLine("Aucune ville trouv�e."); } } catch (HttpRequestException e) { - Console.WriteLine($"Une erreur s'est produite lors de la requête HTTP : {e.Message}"); + Console.WriteLine($"Une erreur s'est produite lors de la requ�te HTTP : {e.Message}"); } } } @@ -382,7 +387,7 @@ namespace ManagerService.Controllers } catch (Exception e) { - Console.WriteLine($"Une erreur s'est produite lors de la mise à jour des sections de type météo : {e.Message}"); + Console.WriteLine($"Une erreur s'est produite lors de la mise � jour des sections de type m�t�o : {e.Message}"); } return new OkObjectResult(sectionsToReturn); @@ -684,13 +689,13 @@ namespace ManagerService.Controllers // UPDATE OTHER ORDER var sections = _myInfoMateDbContext.Sections.Where(s => s.ConfigurationId == newSection.configurationId && !s.IsSubSection).OrderBy(s => s.Order).ToList(); - // Retirer la question déplacée + // Retirer la question d�plac�e sections.RemoveAll(q => q.Id == section.Id); - // Insérer à la première position (déjà en 0-based) + // Ins�rer � la premi�re position (d�j� en 0-based) sections.Insert(0, section); - // Réassigner les ordres en 0-based + // R�assigner les ordres en 0-based for (int i = 0; i < sections.Count; i++) { sections[i].Order = i; @@ -787,7 +792,7 @@ namespace ManagerService.Controllers if (jsonElement.ValueKind == JsonValueKind.Null) throw new ArgumentNullException("Section param is null"); - // Désérialisation de jsonElement en SectionDTO + // D�s�rialisation de jsonElement en SectionDTO sectionDTO = JsonConvert.DeserializeObject(jsonElement.ToString()); } else @@ -808,15 +813,15 @@ namespace ManagerService.Controllers // If subsection, check if order changed var subSections = _myInfoMateDbContext.Sections.Where(s => s.ParentId == existingSection.ParentId).OrderBy(s => s.Order).ToList(); - // Retirer la sous section déplacée + // Retirer la sous section d�plac�e subSections.RemoveAll(q => q.Id == existingSection.Id); - // Insérer à la nouvelle position (déjà en 0-based) + // Ins�rer � la nouvelle position (d�j� en 0-based) int newIndex = sectionDTO.order.Value; newIndex = Math.Clamp(newIndex, 0, subSections.Count); subSections.Insert(newIndex, existingSection); - // Réassigner les ordres en 0-based + // R�assigner les ordres en 0-based for (int i = 0; i < subSections.Count; i++) { subSections[i].Order = i; @@ -832,6 +837,13 @@ namespace ManagerService.Controllers _myInfoMateDbContext.SaveChanges(); MqttClientService.PublishMessage($"config/{existingSection.ConfigurationId}", JsonConvert.SerializeObject(new PlayerMessageDTO() { configChanged = true })); + + if (existingSection.Type == SectionType.Agenda) + { + var sa = _myInfoMateDbContext.Sections.OfType().FirstOrDefault(s => s.Id == existingSection.Id); + if (sa?.IsOnlineAgenda == true && sa.AgendaResourceIds?.Count > 0) + BackgroundJob.Enqueue(s => s.SyncSectionAsync(sa.Id)); + } } return new OkObjectResult(SectionFactory.ToDTO(existingSection)); diff --git a/ManagerService/Controllers/SectionEventController.cs b/ManagerService/Controllers/SectionEventController.cs index d56afab..8fc1434 100644 --- a/ManagerService/Controllers/SectionEventController.cs +++ b/ManagerService/Controllers/SectionEventController.cs @@ -381,5 +381,87 @@ namespace ManagerService.Controllers return new ObjectResult(ex.Message) { StatusCode = 500 }; } } + + /// + /// Get all global map annotations from a section event + /// + /// Section event id + [AllowAnonymous] + [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(string), 404)] + [ProducesResponseType(typeof(string), 500)] + [HttpGet("{sectionEventId}/global-map-annotations")] + public ObjectResult GetGlobalMapAnnotationsFromSectionEvent(string sectionEventId) + { + try + { + SectionEvent sectionEvent = _myInfoMateDbContext.Sections.OfType() + .Include(se => se.GlobalMapAnnotations).ThenInclude(ma => ma.IconResource) + .FirstOrDefault(se => se.Id == sectionEventId); + + if (sectionEvent == null) + throw new KeyNotFoundException("Section event does not exist"); + + return new OkObjectResult(sectionEvent.GlobalMapAnnotations.Select(ma => ma.ToDTO())); + } + catch (KeyNotFoundException ex) + { + return new NotFoundObjectResult(ex.Message) { }; + } + catch (Exception ex) + { + return new ObjectResult(ex.Message) { StatusCode = 500 }; + } + } + + /// + /// Create a new global map annotation on a section event + /// + /// Section event id + /// Map annotation + [ProducesResponseType(typeof(MapAnnotationDTO), 200)] + [ProducesResponseType(typeof(string), 400)] + [ProducesResponseType(typeof(string), 404)] + [ProducesResponseType(typeof(string), 500)] + [HttpPost("{sectionEventId}/global-map-annotations")] + public ObjectResult CreateGlobalMapAnnotation(string sectionEventId, [FromBody] MapAnnotationDTO mapAnnotationDTO) + { + try + { + if (sectionEventId == null) + throw new ArgumentNullException("SectionEventId param is null"); + + if (mapAnnotationDTO == null) + throw new ArgumentNullException("MapAnnotation param is null"); + + var existingSectionEvent = _myInfoMateDbContext.Sections.OfType() + .Include(se => se.GlobalMapAnnotations) + .FirstOrDefault(se => se.Id == sectionEventId); + + if (existingSectionEvent == null) + throw new KeyNotFoundException("Section event does not exist"); + + MapAnnotation mapAnnotation = new MapAnnotation().FromDTO(mapAnnotationDTO); + mapAnnotation.Id = idService.GenerateHexId(); + mapAnnotation.SectionEventId = sectionEventId; + existingSectionEvent.GlobalMapAnnotations.Add(mapAnnotation); + + _myInfoMateDbContext.SaveChanges(); + + return new OkObjectResult(mapAnnotation.ToDTO()); + } + catch (ArgumentNullException ex) + { + return new BadRequestObjectResult(ex.Message) { }; + } + catch (KeyNotFoundException ex) + { + return new NotFoundObjectResult(ex.Message) { }; + } + catch (Exception ex) + { + return new ObjectResult(ex.Message) { StatusCode = 500 }; + } + } } } diff --git a/ManagerService/Controllers/SectionMapController.cs b/ManagerService/Controllers/SectionMapController.cs index bb7b5bf..a92bfeb 100644 --- a/ManagerService/Controllers/SectionMapController.cs +++ b/ManagerService/Controllers/SectionMapController.cs @@ -448,6 +448,7 @@ namespace ManagerService.Controllers existingGuidedPath.Description = guidedPathDTO.description ?? new List(); existingGuidedPath.SectionMapId = guidedPathDTO.sectionMapId; existingGuidedPath.SectionEventId = guidedPathDTO.sectionEventId; + existingGuidedPath.SectionGameId = guidedPathDTO.sectionGameId; existingGuidedPath.IsLinear = guidedPathDTO.isLinear; existingGuidedPath.RequireSuccessToAdvance = guidedPathDTO.requireSuccessToAdvance; existingGuidedPath.HideNextStepsUntilComplete = guidedPathDTO.hideNextStepsUntilComplete; @@ -658,7 +659,7 @@ namespace ManagerService.Controllers existingGuidedStep.ImageUrl = guidedStepDTO.imageUrl; existingGuidedStep.TriggerGeoPointId = guidedStepDTO.triggerGeoPointId; existingGuidedStep.IsHiddenInitially = guidedStepDTO.isHiddenInitially; - existingGuidedStep.QuizQuestions = guidedStepDTO.quizQuestions; // à convertir si besoin ? + existingGuidedStep.QuizQuestions = guidedStepDTO.quizQuestions; // � convertir si besoin ? existingGuidedStep.IsStepTimer = guidedStepDTO.isStepTimer; existingGuidedStep.IsStepLocked = guidedStepDTO.isStepLocked; existingGuidedStep.TimerSeconds = guidedStepDTO.timerSeconds; diff --git a/ManagerService/Controllers/StatsController.cs b/ManagerService/Controllers/StatsController.cs index c1eb734..9f2649e 100644 --- a/ManagerService/Controllers/StatsController.cs +++ b/ManagerService/Controllers/StatsController.cs @@ -263,6 +263,59 @@ namespace ManagerService.Controllers }) .ToList(); + // Top articles lus + summary.TopArticles = events + .Where(e => e.EventType == VisitEventType.ArticleRead && e.SectionId != null) + .GroupBy(e => e.SectionId!) + .Select(g => new ArticleStatDTO { SectionId = g.Key, Reads = g.Count() }) + .OrderByDescending(a => a.Reads) + .Take(10) + .ToList(); + + // Top menu items + var menuEvents = events.Where(e => e.EventType == VisitEventType.MenuItemTap && e.Metadata != null).ToList(); + var menuGroups = new Dictionary(); + foreach (var ev in menuEvents) + { + try + { + var meta = JsonSerializer.Deserialize(ev.Metadata!); + if (meta.TryGetProperty("targetSectionId", out var idEl)) + { + string id = idEl.GetString() ?? ""; + string title = meta.TryGetProperty("menuItemTitle", out var titleEl) ? titleEl.GetString() ?? "" : id; + if (menuGroups.TryGetValue(id, out var existing)) + menuGroups[id] = (existing.title, existing.taps + 1); + else + menuGroups[id] = (title, 1); + } + } + catch { /* skip */ } + } + summary.TopMenuItems = menuGroups + .Select(kv => new MenuItemStatDTO { TargetSectionId = kv.Key, MenuItemTitle = kv.Value.title, Taps = kv.Value.taps }) + .OrderByDescending(m => m.Taps) + .Take(10) + .ToList(); + + // QR scans + var qrEvents = events.Where(e => e.EventType == VisitEventType.QrScan).ToList(); + var validQr = 0; var invalidQr = 0; + foreach (var ev in qrEvents) + { + try + { + if (ev.Metadata != null) + { + var meta = JsonSerializer.Deserialize(ev.Metadata); + if (meta.TryGetProperty("valid", out var validEl) && validEl.GetBoolean()) validQr++; + else invalidQr++; + } + } + catch { invalidQr++; } + } + summary.QrScans = new QrScanStatDTO { TotalScans = qrEvents.Count, ValidScans = validQr, InvalidScans = invalidQr }; + return Ok(summary); } catch (Exception ex) diff --git a/ManagerService/DTOs/AiChatDTO.cs b/ManagerService/DTOs/AiChatDTO.cs index 140f15d..b234756 100644 --- a/ManagerService/DTOs/AiChatDTO.cs +++ b/ManagerService/DTOs/AiChatDTO.cs @@ -31,6 +31,7 @@ namespace ManagerService.DTOs public string SectionId { get; set; } public string SectionTitle { get; set; } public string SectionType { get; set; } + public string? ImageUrl { get; set; } } public class AiChatResponse diff --git a/ManagerService/DTOs/EventAgendaDTO.cs b/ManagerService/DTOs/EventAgendaDTO.cs index 70af08e..3078ec6 100644 --- a/ManagerService/DTOs/EventAgendaDTO.cs +++ b/ManagerService/DTOs/EventAgendaDTO.cs @@ -26,6 +26,10 @@ namespace ManagerService.DTOs public ResourceDTO resource { get; set; } // Background image + public string? videoResourceId { get; set; } + + public ResourceDTO? videoResource { get; set; } + public EventAddressDTO address { get; set; } public string phone { get; set; } @@ -36,6 +40,12 @@ namespace ManagerService.DTOs public string sectionEventId { get; set; } + public bool isSynced { get; set; } + + public string? idVideoYoutube { get; set; } + + public string? videoLink { get; set; } + } public class EventAddressDTO diff --git a/ManagerService/DTOs/RemoteEventAgendaDTO.cs b/ManagerService/DTOs/RemoteEventAgendaDTO.cs new file mode 100644 index 0000000..9ef4b3f --- /dev/null +++ b/ManagerService/DTOs/RemoteEventAgendaDTO.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace ManagerService.DTOs +{ + /// + /// Lenient settings for deserializing online agenda JSON from PHP APIs. + /// Any field with an unexpected type (e.g. false instead of string/object) is silently set to null. + /// + public static class RemoteAgendaJsonSettings + { + public static readonly JsonSerializerSettings Lenient = new JsonSerializerSettings + { + Error = (_, args) => { args.ErrorContext.Handled = true; } + }; + } + + /// + /// Converts boolean false (PHP "no value" pattern) or null to null for object-typed fields. + /// + public class FalseToNullConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => true; + public override bool CanWrite => false; + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Boolean || reader.TokenType == JsonToken.Null) + { + reader.Skip(); + return null; + } + var jObject = Newtonsoft.Json.Linq.JObject.Load(reader); + var target = Activator.CreateInstance(objectType)!; + serializer.Populate(jObject.CreateReader(), target); + return target; + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + => throw new NotImplementedException(); + } + + + /// + /// Specialized DTO for parsing the simplified, localized JSON used by online agendas. + /// Matches the structure used in the tablet-app (snake_case). + /// + public class RemoteEventAgendaDTO + { + public string? name { get; set; } + public string? description { get; set; } + public string? type { get; set; } + public string? date_added { get; set; } + public string? date_from { get; set; } + public string? date_to { get; set; } + public string? date_hour { get; set; } + public string? website { get; set; } + public string? phone { get; set; } + public string? email { get; set; } + public string? id_video_youtube { get; set; } + public string? image { get; set; } + [JsonConverter(typeof(FalseToNullConverter))] + public RemoteEventAddressDTO? address { get; set; } + + public DateTime? GetDateFrom() => ParseDate(date_from); + public DateTime? GetDateTo() => ParseDate(date_to); + + private DateTime? ParseDate(string? dateStr) + { + if (string.IsNullOrEmpty(dateStr)) return null; + // Handle YYYYMMDD format (e.g. "20260327") + if (dateStr.Length == 8 && long.TryParse(dateStr, out _)) + if (DateTime.TryParseExact(dateStr, "yyyyMMdd", null, System.Globalization.DateTimeStyles.None, out var dt8)) + return dt8; + if (DateTime.TryParse(dateStr, out var dt)) return dt; + return null; + } + } + + public class RemoteEventAddressDTO + { + public string? address { get; set; } + public object? lat { get; set; } + public object? lng { get; set; } + public int? zoom { get; set; } + public string? place_id { get; set; } + public string? name { get; set; } + public string? street_number { get; set; } + public string? street_name { get; set; } + public string? city { get; set; } + public string? state { get; set; } + public string? post_code { get; set; } + public string? country { get; set; } + } +} diff --git a/ManagerService/DTOs/StatsSummaryDTO.cs b/ManagerService/DTOs/StatsSummaryDTO.cs index 18a2fff..bfa0912 100644 --- a/ManagerService/DTOs/StatsSummaryDTO.cs +++ b/ManagerService/DTOs/StatsSummaryDTO.cs @@ -15,6 +15,9 @@ namespace ManagerService.DTOs public List TopAgendaEvents { get; set; } = new(); public List QuizStats { get; set; } = new(); public List GameStats { get; set; } = new(); + public List TopArticles { get; set; } = new(); + public List TopMenuItems { get; set; } = new(); + public QrScanStatDTO QrScans { get; set; } = new(); } public class SectionStatDTO @@ -63,4 +66,24 @@ namespace ManagerService.DTOs public int Completions { get; set; } public int AvgDurationSeconds { get; set; } } + + public class ArticleStatDTO + { + public string SectionId { get; set; } + public int Reads { get; set; } + } + + public class MenuItemStatDTO + { + public string TargetSectionId { get; set; } + public string MenuItemTitle { get; set; } + public int Taps { get; set; } + } + + public class QrScanStatDTO + { + public int TotalScans { get; set; } + public int ValidScans { get; set; } + public int InvalidScans { get; set; } + } } diff --git a/ManagerService/DTOs/SubSection/MapDTO.cs b/ManagerService/DTOs/SubSection/MapDTO.cs index 9484810..2bce516 100644 --- a/ManagerService/DTOs/SubSection/MapDTO.cs +++ b/ManagerService/DTOs/SubSection/MapDTO.cs @@ -5,6 +5,7 @@ namespace Manager.DTOs { public class MapDTO : SectionDTO { + public bool isListViewEnabled { get; set; } = false; public int? zoom { get; set; } // Default = 18 public MapTypeApp? mapType { get; set; } // Default = Hybrid for Google public MapTypeMapBox? mapTypeMapbox { get; set; } // Default = standard for MapBox diff --git a/ManagerService/DTOs/SubSection/SectionEventDTO.cs b/ManagerService/DTOs/SubSection/SectionEventDTO.cs index 8623307..4e31a32 100644 --- a/ManagerService/DTOs/SubSection/SectionEventDTO.cs +++ b/ManagerService/DTOs/SubSection/SectionEventDTO.cs @@ -9,7 +9,9 @@ namespace Manager.DTOs { public DateTime StartDate { get; set; } public DateTime EndDate { get; set; } + public string? BaseSectionMapId { get; set; } public List ParcoursIds { get; set; } + public List GlobalMapAnnotations { get; set; } = new(); public List Programme { get; set; } = new(); } } diff --git a/ManagerService/Data/MyInfoMateDbContext.cs b/ManagerService/Data/MyInfoMateDbContext.cs index c97fe61..a3d6329 100644 --- a/ManagerService/Data/MyInfoMateDbContext.cs +++ b/ManagerService/Data/MyInfoMateDbContext.cs @@ -222,6 +222,22 @@ namespace ManagerService.Data .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize(v, options)); + + // SectionEvent: link to base SectionMap + modelBuilder.Entity() + .HasOne(se => se.BaseMap) + .WithMany() + .HasForeignKey(se => se.BaseSectionMapId) + .IsRequired(false) + .OnDelete(DeleteBehavior.SetNull); + + // MapAnnotation: global event-level annotations linked directly to SectionEvent + modelBuilder.Entity() + .HasOne() + .WithMany(se => se.GlobalMapAnnotations) + .HasForeignKey(ma => ma.SectionEventId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Cascade); } } } diff --git a/ManagerService/Data/SubSection/EventAgenda.cs b/ManagerService/Data/SubSection/EventAgenda.cs index 312ad43..6676b8a 100644 --- a/ManagerService/Data/SubSection/EventAgenda.cs +++ b/ManagerService/Data/SubSection/EventAgenda.cs @@ -38,6 +38,11 @@ namespace ManagerService.Data.SubSection public Resource Resource { get; set; } // Background image + public string? VideoResourceId { get; set; } + + [ForeignKey("VideoResourceId")] + public Resource? VideoResource { get; set; } + [Column(TypeName = "jsonb")] public EventAddress Address { get; set; } @@ -45,6 +50,12 @@ namespace ManagerService.Data.SubSection public string Email { get; set; } + public bool IsSynced { get; set; } = false; + + public string? IdVideoYoutube { get; set; } + + public string? VideoLink { get; set; } + public string SectionAgendaId { get; set; } [ForeignKey("SectionAgendaId")] @@ -68,7 +79,9 @@ namespace ManagerService.Data.SubSection dateTo = DateTo, website = Website, resourceId = ResourceId, - resource = Resource?.ToDTO(), + resource = Resource?.ToDTO(), + videoResourceId = VideoResourceId, + videoResource = VideoResource?.ToDTO(), address = Address != null ? new EventAddressDTO { address = Address.Address, @@ -84,7 +97,10 @@ namespace ManagerService.Data.SubSection } : null, phone = Phone, email = Email, - sectionAgendaId = SectionAgendaId + sectionAgendaId = SectionAgendaId, + isSynced = IsSynced, + idVideoYoutube = IdVideoYoutube, + videoLink = VideoLink }; } @@ -98,6 +114,7 @@ namespace ManagerService.Data.SubSection DateTo = dto.dateTo; Website = dto.website; ResourceId = dto.resourceId; + VideoResourceId = dto.videoResourceId; //Resource = dto.Resource != null ? Resource.From(dto.Resource) : null, Address = dto.address != null ? new EventAddress { @@ -114,6 +131,9 @@ namespace ManagerService.Data.SubSection } : null; Phone = dto.phone; Email = dto.email; + IsSynced = false; + IdVideoYoutube = dto.idVideoYoutube; + VideoLink = dto.videoLink; SectionAgendaId = dto.sectionAgendaId; return this; } @@ -128,6 +148,7 @@ namespace ManagerService.Data.SubSection DateTo = dto.dateTo; Website = dto.website; ResourceId = dto.resourceId; + VideoResourceId = dto.videoResourceId; Address = dto.address != null ? new EventAddress { Address = dto.address.address, @@ -143,6 +164,9 @@ namespace ManagerService.Data.SubSection } : null; Phone = dto.phone; Email = dto.email; + IsSynced = false; + IdVideoYoutube = dto.idVideoYoutube; + VideoLink = dto.videoLink; SectionAgendaId = dto.sectionAgendaId; SectionEventId = dto.sectionEventId; } diff --git a/ManagerService/Data/SubSection/SectionEvent.cs b/ManagerService/Data/SubSection/SectionEvent.cs index 17c40ba..b738ef8 100644 --- a/ManagerService/Data/SubSection/SectionEvent.cs +++ b/ManagerService/Data/SubSection/SectionEvent.cs @@ -17,6 +17,10 @@ namespace ManagerService.Data.SubSection { public DateTime StartDate { get; set; } public DateTime EndDate { get; set; } + public string? BaseSectionMapId { get; set; } + [ForeignKey(nameof(BaseSectionMapId))] + public SectionMap? BaseMap { get; set; } + public List GlobalMapAnnotations { get; set; } = new(); public List Programme { get; set; } = new(); [Column(TypeName = "jsonb")] public List ParcoursIds { get; set; } = new(); // Liens vers GeoPoints spécifiques @@ -62,6 +66,7 @@ namespace ManagerService.Data.SubSection { [Key] public string Id { get; set; } + public string? SectionEventId { get; set; } // FK for global event-level annotation (null = block annotation) [Column(TypeName = "jsonb")] public List Type { get; set; } // "first_aid", "parking", etc. [Column(TypeName = "jsonb")] diff --git a/ManagerService/Data/SubSection/SectionMap.cs b/ManagerService/Data/SubSection/SectionMap.cs index f485c95..8e8a0eb 100644 --- a/ManagerService/Data/SubSection/SectionMap.cs +++ b/ManagerService/Data/SubSection/SectionMap.cs @@ -15,6 +15,7 @@ namespace ManagerService.Data.SubSection public class SectionMap : Section { public int MapZoom { get; set; } // Default = 18 + public bool IsListViewEnabled { get; set; } = false; public MapTypeApp? MapMapType { get; set; } // Default = Hybrid for Google public MapTypeMapBox? MapTypeMapbox { get; set; } // Default = standard for MapBox public MapProvider? MapMapProvider { get; set; } // Default = Google diff --git a/ManagerService/Data/VisitEvent.cs b/ManagerService/Data/VisitEvent.cs index ed3bd14..86b5cf0 100644 --- a/ManagerService/Data/VisitEvent.cs +++ b/ManagerService/Data/VisitEvent.cs @@ -55,6 +55,7 @@ namespace ManagerService.Data GameComplete, AgendaEventTap, MenuItemTap, - AssistantMessage + AssistantMessage, + ArticleRead } } diff --git a/ManagerService/Migrations/20260317120000_AddEventMapFeaturesAndListView.Designer.cs b/ManagerService/Migrations/20260317120000_AddEventMapFeaturesAndListView.Designer.cs new file mode 100644 index 0000000..4d27190 --- /dev/null +++ b/ManagerService/Migrations/20260317120000_AddEventMapFeaturesAndListView.Designer.cs @@ -0,0 +1,1418 @@ +// +using System; +using System.Collections.Generic; +using Manager.DTOs; +using ManagerService.DTOs; +using ManagerService.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ManagerService.Migrations +{ + [DbContext(typeof(MyInfoMateDbContext))] + [Migration("20260317120000_AddEventMapFeaturesAndListView")] + partial class AddEventMapFeaturesAndListView + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ManagerService.Data.ApiKey", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AppType") + .HasColumnType("integer"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("KeyHash") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ManagerService.Data.AppConfigurationLink", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ApplicationInstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConfigurationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeviceId") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDate") + .HasColumnType("boolean"); + + b.Property("IsHour") + .HasColumnType("boolean"); + + b.Property("IsSectionImageBackground") + .HasColumnType("boolean"); + + b.Property("LayoutMainPage") + .HasColumnType("integer"); + + b.Property("LoaderImageId") + .HasColumnType("text"); + + b.Property("LoaderImageUrl") + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PrimaryColor") + .HasColumnType("text"); + + b.Property("RoundedValue") + .HasColumnType("integer"); + + b.Property("ScreenPercentageSectionsMainPage") + .HasColumnType("integer"); + + b.Property("SecondaryColor") + .HasColumnType("text"); + + b.Property("WeightMasonryGrid") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationInstanceId"); + + b.HasIndex("ConfigurationId"); + + b.HasIndex("DeviceId"); + + b.ToTable("AppConfigurationLinks"); + }); + + modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AppType") + .HasColumnType("integer"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsAssistant") + .HasColumnType("boolean"); + + b.PrimitiveCollection>("Languages") + .HasColumnType("text[]"); + + b.Property("LayoutMainPage") + .HasColumnType("integer"); + + b.Property("LoaderImageId") + .HasColumnType("text"); + + b.Property("LoaderImageUrl") + .HasColumnType("text"); + + b.Property("MainImageId") + .HasColumnType("text"); + + b.Property("MainImageUrl") + .HasColumnType("text"); + + b.Property("PrimaryColor") + .HasColumnType("text"); + + b.Property("SecondaryColor") + .HasColumnType("text"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.ToTable("ApplicationInstances"); + }); + + modelBuilder.Entity("ManagerService.Data.Configuration", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("ImageId") + .HasColumnType("text"); + + b.Property("ImageSource") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsOffline") + .HasColumnType("boolean"); + + b.Property("IsQRCode") + .HasColumnType("boolean"); + + b.Property("IsSearchNumber") + .HasColumnType("boolean"); + + b.Property("IsSearchText") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("Languages") + .HasColumnType("text[]"); + + b.Property("LoaderImageId") + .HasColumnType("text"); + + b.Property("LoaderImageUrl") + .HasColumnType("text"); + + b.Property("PrimaryColor") + .HasColumnType("text"); + + b.Property("SecondaryColor") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("Configurations"); + }); + + modelBuilder.Entity("ManagerService.Data.Device", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BatteryLevel") + .HasColumnType("text"); + + b.Property("ConfigurationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Connected") + .HasColumnType("boolean"); + + b.Property("ConnectionLevel") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Identifier") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IpAddressETH") + .HasColumnType("text"); + + b.Property("IpAddressWLAN") + .HasColumnType("text"); + + b.Property("LastBatteryLevel") + .HasColumnType("timestamp with time zone"); + + b.Property("LastConnectionLevel") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConfigurationId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("ManagerService.Data.Instance", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAssistant") + .HasColumnType("boolean"); + + b.Property("IsMobile") + .HasColumnType("boolean"); + + b.Property("IsPushNotification") + .HasColumnType("boolean"); + + b.Property("IsStatistic") + .HasColumnType("boolean"); + + b.Property("IsTablet") + .HasColumnType("boolean"); + + b.Property("IsVR") + .HasColumnType("boolean"); + + b.Property("IsWeb") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PinCode") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ManagerService.Data.PushNotification", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("HangfireJobId") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("PushNotifications"); + }); + + modelBuilder.Entity("ManagerService.Data.Resource", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Url") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Resources"); + }); + + modelBuilder.Entity("ManagerService.Data.Section", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BeaconId") + .HasColumnType("integer"); + + b.Property("ConfigurationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("jsonb"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ImageId") + .HasColumnType("text"); + + b.Property("ImageSource") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsBeacon") + .HasColumnType("boolean"); + + b.Property("IsSubSection") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("Latitude") + .HasColumnType("text"); + + b.Property("Longitude") + .HasColumnType("text"); + + b.Property("MeterZoneGPS") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ParentId") + .HasColumnType("text"); + + b.Property("SectionMenuId") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionMenuId"); + + b.ToTable("Sections"); + + b.HasDiscriminator().HasValue("Base"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.EventAgenda", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("jsonb"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone"); + + b.Property("DateFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("DateTo") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("ResourceId") + .HasColumnType("text"); + + b.Property("SectionAgendaId") + .HasColumnType("text"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("text"); + + b.Property("Website") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId"); + + b.HasIndex("SectionAgendaId"); + + b.HasIndex("SectionEventId"); + + b.ToTable("EventAgendas"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GeoPoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CategorieId") + .HasColumnType("integer"); + + b.Property("Contents") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Email") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("ImageResourceId") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PolyColor") + .HasColumnType("text"); + + b.Property("Prices") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Schedules") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("SectionMapId") + .HasColumnType("text"); + + b.Property("Site") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.HasIndex("SectionMapId"); + + b.ToTable("GeoPoints"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("jsonb"); + + b.Property("HideNextStepsUntilComplete") + .HasColumnType("boolean"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsLinear") + .HasColumnType("boolean"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("RequireSuccessToAdvance") + .HasColumnType("boolean"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("SectionGameId") + .HasColumnType("text"); + + b.Property("SectionMapId") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.HasIndex("SectionGameId"); + + b.HasIndex("SectionMapId"); + + b.ToTable("GuidedPaths"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("jsonb"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("GuidedPathId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsHiddenInitially") + .HasColumnType("boolean"); + + b.Property("IsStepLocked") + .HasColumnType("boolean"); + + b.Property("IsStepTimer") + .HasColumnType("boolean"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("TimerExpiredMessage") + .HasColumnType("jsonb"); + + b.Property("TimerSeconds") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TriggerGeoPointId") + .HasColumnType("integer"); + + b.Property("ZoneRadiusMeters") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("GuidedPathId"); + + b.HasIndex("TriggerGeoPointId"); + + b.ToTable("GuidedSteps"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.QuizQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GuidedStepId") + .HasColumnType("text"); + + b.Property("IsSlidingPuzzle") + .HasColumnType("boolean"); + + b.Property>("Label") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PuzzleCols") + .HasColumnType("integer"); + + b.Property("PuzzleImageId") + .HasColumnType("text"); + + b.Property("PuzzleRows") + .HasColumnType("integer"); + + b.Property("ResourceId") + .HasColumnType("text"); + + b.Property>("Responses") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SectionQuizId") + .HasColumnType("text"); + + b.Property("ValidationQuestionType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GuidedStepId"); + + b.HasIndex("PuzzleImageId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("SectionQuizId"); + + b.ToTable("QuizQuestions"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+MapAnnotation", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("GeometryType") + .HasColumnType("integer"); + + b.Property("Icon") + .HasColumnType("text"); + + b.Property("IconResourceId") + .HasColumnType("text"); + + b.Property>("Label") + .HasColumnType("jsonb"); + + b.Property("PolyColor") + .HasColumnType("text"); + + b.Property("ProgrammeBlockId") + .HasColumnType("text"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property>("Type") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("IconResourceId"); + + b.HasIndex("ProgrammeBlockId"); + + b.HasIndex("SectionEventId"); + + b.ToTable("MapAnnotations"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property>("Description") + .HasColumnType("jsonb"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property>("Title") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.ToTable("ProgrammeBlocks"); + }); + + modelBuilder.Entity("ManagerService.Data.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("ManagerService.Data.VisitEvent", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AppType") + .HasColumnType("integer"); + + b.Property("ConfigurationId") + .HasColumnType("text"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("SectionId") + .HasColumnType("text"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("Timestamp"); + + b.ToTable("VisitEvents"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionAgenda", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("AgendaMapProvider") + .HasColumnType("integer"); + + b.Property>("AgendaResourceIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("IsOnlineAgenda") + .HasColumnType("boolean"); + + b.HasDiscriminator().HasValue("Agenda"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionArticle", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("ArticleAudioIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("ArticleContent") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("ArticleContents") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ArticleIsContentTop") + .HasColumnType("boolean"); + + b.Property("ArticleIsReadAudioAuto") + .HasColumnType("boolean"); + + b.HasDiscriminator().HasValue("Article"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("BaseSectionMapId") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property>("ParcoursIds") + .HasColumnType("jsonb"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.HasIndex("BaseSectionMapId"); + + b.HasDiscriminator().HasValue("Event"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionGame", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("GameMessageDebut") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("GameMessageFin") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("GamePuzzleCols") + .HasColumnType("integer"); + + b.Property("GamePuzzleImageId") + .HasColumnType("text"); + + b.Property("GamePuzzleRows") + .HasColumnType("integer"); + + b.Property("GameType") + .HasColumnType("integer"); + + b.HasIndex("GamePuzzleImageId"); + + b.HasDiscriminator().HasValue("Game"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMap", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("IsListViewEnabled") + .HasColumnType("boolean"); + + b.Property>("MapCategories") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MapCenterLatitude") + .HasColumnType("text"); + + b.Property("MapCenterLongitude") + .HasColumnType("text"); + + b.Property("MapMapProvider") + .HasColumnType("integer"); + + b.Property("MapMapType") + .HasColumnType("integer"); + + b.Property("MapResourceId") + .HasColumnType("text"); + + b.Property("MapTypeMapbox") + .HasColumnType("integer"); + + b.Property("MapZoom") + .HasColumnType("integer"); + + b.HasIndex("MapResourceId"); + + b.HasDiscriminator().HasValue("Map"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMenu", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.HasDiscriminator().HasValue("Menu"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionPdf", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("PDFOrderedTranslationAndResources") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasDiscriminator().HasValue("PDF"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionQuiz", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("QuizBadLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("QuizGoodLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("QuizGreatLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("QuizMediumLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasDiscriminator().HasValue("Quiz"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionSlider", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("SliderContents") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasDiscriminator().HasValue("Slider"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionVideo", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("VideoSource") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue("Video"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionWeather", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("WeatherCity") + .HasColumnType("text"); + + b.Property("WeatherResult") + .HasColumnType("text"); + + b.Property("WeatherUpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasDiscriminator().HasValue("Weather"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionWeb", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("WebSource") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue("Web"); + }); + + modelBuilder.Entity("ManagerService.Data.ApiKey", b => + { + b.HasOne("ManagerService.Data.Instance", "Instance") + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("ManagerService.Data.AppConfigurationLink", b => + { + b.HasOne("ManagerService.Data.ApplicationInstance", "ApplicationInstance") + .WithMany("Configurations") + .HasForeignKey("ApplicationInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ManagerService.Data.Configuration", "Configuration") + .WithMany() + .HasForeignKey("ConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ManagerService.Data.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId"); + + b.Navigation("ApplicationInstance"); + + b.Navigation("Configuration"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.Navigation("SectionEvent"); + }); + + modelBuilder.Entity("ManagerService.Data.Device", b => + { + b.HasOne("ManagerService.Data.Configuration", "Configuration") + .WithMany() + .HasForeignKey("ConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Configuration"); + }); + + modelBuilder.Entity("ManagerService.Data.Section", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionMenu", null) + .WithMany("MenuSections") + .HasForeignKey("SectionMenuId"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.EventAgenda", b => + { + b.HasOne("ManagerService.Data.Resource", "Resource") + .WithMany() + .HasForeignKey("ResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionAgenda", "SectionAgenda") + .WithMany("EventAgendas") + .HasForeignKey("SectionAgendaId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.Navigation("Resource"); + + b.Navigation("SectionAgenda"); + + b.Navigation("SectionEvent"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GeoPoint", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.HasOne("ManagerService.Data.SubSection.SectionMap", "SectionMap") + .WithMany("MapPoints") + .HasForeignKey("SectionMapId"); + + b.Navigation("SectionEvent"); + + b.Navigation("SectionMap"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.HasOne("ManagerService.Data.SubSection.SectionGame", "SectionGame") + .WithMany() + .HasForeignKey("SectionGameId"); + + b.HasOne("ManagerService.Data.SubSection.SectionMap", "SectionMap") + .WithMany() + .HasForeignKey("SectionMapId"); + + b.Navigation("SectionEvent"); + + b.Navigation("SectionGame"); + + b.Navigation("SectionMap"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b => + { + b.HasOne("ManagerService.Data.SubSection.GuidedPath", "GuidedPath") + .WithMany("Steps") + .HasForeignKey("GuidedPathId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ManagerService.Data.SubSection.GeoPoint", "TriggerGeoPoint") + .WithMany() + .HasForeignKey("TriggerGeoPointId"); + + b.Navigation("GuidedPath"); + + b.Navigation("TriggerGeoPoint"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.QuizQuestion", b => + { + b.HasOne("ManagerService.Data.SubSection.GuidedStep", "GuidedStep") + .WithMany("QuizQuestions") + .HasForeignKey("GuidedStepId"); + + b.HasOne("ManagerService.Data.Resource", "PuzzleImage") + .WithMany() + .HasForeignKey("PuzzleImageId"); + + b.HasOne("ManagerService.Data.Resource", "Resource") + .WithMany() + .HasForeignKey("ResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionQuiz", "SectionQuiz") + .WithMany("QuizQuestions") + .HasForeignKey("SectionQuizId"); + + b.Navigation("GuidedStep"); + + b.Navigation("PuzzleImage"); + + b.Navigation("Resource"); + + b.Navigation("SectionQuiz"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+MapAnnotation", b => + { + b.HasOne("ManagerService.Data.Resource", "IconResource") + .WithMany() + .HasForeignKey("IconResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", null) + .WithMany("MapAnnotations") + .HasForeignKey("ProgrammeBlockId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent", null) + .WithMany("GlobalMapAnnotations") + .HasForeignKey("SectionEventId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("IconResource"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", null) + .WithMany("Programme") + .HasForeignKey("SectionEventId"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionMap", "BaseMap") + .WithMany() + .HasForeignKey("BaseSectionMapId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BaseMap"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionGame", b => + { + b.HasOne("ManagerService.Data.Resource", "GamePuzzleImage") + .WithMany() + .HasForeignKey("GamePuzzleImageId"); + + b.Navigation("GamePuzzleImage"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMap", b => + { + b.HasOne("ManagerService.Data.Resource", "MapResource") + .WithMany() + .HasForeignKey("MapResourceId"); + + b.Navigation("MapResource"); + }); + + modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b => + { + b.Navigation("Configurations"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b => + { + b.Navigation("QuizQuestions"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", b => + { + b.Navigation("MapAnnotations"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionAgenda", b => + { + b.Navigation("EventAgendas"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.Navigation("GlobalMapAnnotations"); + + b.Navigation("Programme"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMap", b => + { + b.Navigation("MapPoints"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMenu", b => + { + b.Navigation("MenuSections"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionQuiz", b => + { + b.Navigation("QuizQuestions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ManagerService/Migrations/20260317120000_AddEventMapFeaturesAndListView.cs b/ManagerService/Migrations/20260317120000_AddEventMapFeaturesAndListView.cs new file mode 100644 index 0000000..2a1e8f8 --- /dev/null +++ b/ManagerService/Migrations/20260317120000_AddEventMapFeaturesAndListView.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ManagerService.Migrations +{ + /// + public partial class AddEventMapFeaturesAndListView : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Add BaseSectionMapId to Sections table (SectionEvent → SectionMap link) + migrationBuilder.AddColumn( + name: "BaseSectionMapId", + table: "Sections", + type: "text", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Sections_BaseSectionMapId", + table: "Sections", + column: "BaseSectionMapId"); + + migrationBuilder.AddForeignKey( + name: "FK_Sections_Sections_BaseSectionMapId", + table: "Sections", + column: "BaseSectionMapId", + principalTable: "Sections", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + // Add SectionEventId to MapAnnotations (global event-level annotations) + migrationBuilder.AddColumn( + name: "SectionEventId", + table: "MapAnnotations", + type: "text", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_MapAnnotations_SectionEventId", + table: "MapAnnotations", + column: "SectionEventId"); + + migrationBuilder.AddForeignKey( + name: "FK_MapAnnotations_Sections_SectionEventId", + table: "MapAnnotations", + column: "SectionEventId", + principalTable: "Sections", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + // Add IsListViewEnabled to Sections table (SectionMap feature) + migrationBuilder.AddColumn( + name: "IsListViewEnabled", + table: "Sections", + type: "boolean", + nullable: true, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Sections_Sections_BaseSectionMapId", + table: "Sections"); + + migrationBuilder.DropIndex( + name: "IX_Sections_BaseSectionMapId", + table: "Sections"); + + migrationBuilder.DropColumn( + name: "BaseSectionMapId", + table: "Sections"); + + migrationBuilder.DropForeignKey( + name: "FK_MapAnnotations_Sections_SectionEventId", + table: "MapAnnotations"); + + migrationBuilder.DropIndex( + name: "IX_MapAnnotations_SectionEventId", + table: "MapAnnotations"); + + migrationBuilder.DropColumn( + name: "SectionEventId", + table: "MapAnnotations"); + + migrationBuilder.DropColumn( + name: "IsListViewEnabled", + table: "Sections"); + } + } +} diff --git a/ManagerService/Migrations/20260325095228_AddVideoResourceToEventAgenda.Designer.cs b/ManagerService/Migrations/20260325095228_AddVideoResourceToEventAgenda.Designer.cs new file mode 100644 index 0000000..c28b672 --- /dev/null +++ b/ManagerService/Migrations/20260325095228_AddVideoResourceToEventAgenda.Designer.cs @@ -0,0 +1,1439 @@ +// +using System; +using System.Collections.Generic; +using Manager.DTOs; +using ManagerService.DTOs; +using ManagerService.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ManagerService.Migrations +{ + [DbContext(typeof(MyInfoMateDbContext))] + [Migration("20260325095228_AddVideoResourceToEventAgenda")] + partial class AddVideoResourceToEventAgenda + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ManagerService.Data.ApiKey", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AppType") + .HasColumnType("integer"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("KeyHash") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ManagerService.Data.AppConfigurationLink", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ApplicationInstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConfigurationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeviceId") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDate") + .HasColumnType("boolean"); + + b.Property("IsHour") + .HasColumnType("boolean"); + + b.Property("IsSectionImageBackground") + .HasColumnType("boolean"); + + b.Property("LayoutMainPage") + .HasColumnType("integer"); + + b.Property("LoaderImageId") + .HasColumnType("text"); + + b.Property("LoaderImageUrl") + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PrimaryColor") + .HasColumnType("text"); + + b.Property("RoundedValue") + .HasColumnType("integer"); + + b.Property("ScreenPercentageSectionsMainPage") + .HasColumnType("integer"); + + b.Property("SecondaryColor") + .HasColumnType("text"); + + b.Property("WeightMasonryGrid") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationInstanceId"); + + b.HasIndex("ConfigurationId"); + + b.HasIndex("DeviceId"); + + b.ToTable("AppConfigurationLinks"); + }); + + modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AppType") + .HasColumnType("integer"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsAssistant") + .HasColumnType("boolean"); + + b.PrimitiveCollection>("Languages") + .HasColumnType("text[]"); + + b.Property("LayoutMainPage") + .HasColumnType("integer"); + + b.Property("LoaderImageId") + .HasColumnType("text"); + + b.Property("LoaderImageUrl") + .HasColumnType("text"); + + b.Property("MainImageId") + .HasColumnType("text"); + + b.Property("MainImageUrl") + .HasColumnType("text"); + + b.Property("PrimaryColor") + .HasColumnType("text"); + + b.Property("SecondaryColor") + .HasColumnType("text"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.ToTable("ApplicationInstances"); + }); + + modelBuilder.Entity("ManagerService.Data.Configuration", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("ImageId") + .HasColumnType("text"); + + b.Property("ImageSource") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsOffline") + .HasColumnType("boolean"); + + b.Property("IsQRCode") + .HasColumnType("boolean"); + + b.Property("IsSearchNumber") + .HasColumnType("boolean"); + + b.Property("IsSearchText") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("Languages") + .HasColumnType("text[]"); + + b.Property("LoaderImageId") + .HasColumnType("text"); + + b.Property("LoaderImageUrl") + .HasColumnType("text"); + + b.Property("PrimaryColor") + .HasColumnType("text"); + + b.Property("SecondaryColor") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("Configurations"); + }); + + modelBuilder.Entity("ManagerService.Data.Device", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BatteryLevel") + .HasColumnType("text"); + + b.Property("ConfigurationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Connected") + .HasColumnType("boolean"); + + b.Property("ConnectionLevel") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Identifier") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IpAddressETH") + .HasColumnType("text"); + + b.Property("IpAddressWLAN") + .HasColumnType("text"); + + b.Property("LastBatteryLevel") + .HasColumnType("timestamp with time zone"); + + b.Property("LastConnectionLevel") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConfigurationId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("ManagerService.Data.Instance", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAssistant") + .HasColumnType("boolean"); + + b.Property("IsMobile") + .HasColumnType("boolean"); + + b.Property("IsPushNotification") + .HasColumnType("boolean"); + + b.Property("IsStatistic") + .HasColumnType("boolean"); + + b.Property("IsTablet") + .HasColumnType("boolean"); + + b.Property("IsVR") + .HasColumnType("boolean"); + + b.Property("IsWeb") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PinCode") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ManagerService.Data.PushNotification", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("HangfireJobId") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("PushNotifications"); + }); + + modelBuilder.Entity("ManagerService.Data.Resource", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Url") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Resources"); + }); + + modelBuilder.Entity("ManagerService.Data.Section", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BeaconId") + .HasColumnType("integer"); + + b.Property("ConfigurationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("jsonb"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ImageId") + .HasColumnType("text"); + + b.Property("ImageSource") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsBeacon") + .HasColumnType("boolean"); + + b.Property("IsSubSection") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("Latitude") + .HasColumnType("text"); + + b.Property("Longitude") + .HasColumnType("text"); + + b.Property("MeterZoneGPS") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ParentId") + .HasColumnType("text"); + + b.Property("SectionMenuId") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionMenuId"); + + b.ToTable("Sections"); + + b.HasDiscriminator().HasValue("Base"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.EventAgenda", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("jsonb"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone"); + + b.Property("DateFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("DateTo") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IdVideoYoutube") + .HasColumnType("text"); + + b.Property("IsSynced") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("ResourceId") + .HasColumnType("text"); + + b.Property("SectionAgendaId") + .HasColumnType("text"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("text"); + + b.Property("VideoLink") + .HasColumnType("text"); + + b.Property("VideoResourceId") + .HasColumnType("text"); + + b.Property("Website") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ResourceId"); + + b.HasIndex("SectionAgendaId"); + + b.HasIndex("SectionEventId"); + + b.HasIndex("VideoResourceId"); + + b.ToTable("EventAgendas"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GeoPoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CategorieId") + .HasColumnType("integer"); + + b.Property("Contents") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Email") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("ImageResourceId") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PolyColor") + .HasColumnType("text"); + + b.Property("Prices") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Schedules") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("SectionMapId") + .HasColumnType("text"); + + b.Property("Site") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.HasIndex("SectionMapId"); + + b.ToTable("GeoPoints"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("jsonb"); + + b.Property("HideNextStepsUntilComplete") + .HasColumnType("boolean"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsLinear") + .HasColumnType("boolean"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("RequireSuccessToAdvance") + .HasColumnType("boolean"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("SectionGameId") + .HasColumnType("text"); + + b.Property("SectionMapId") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.HasIndex("SectionGameId"); + + b.HasIndex("SectionMapId"); + + b.ToTable("GuidedPaths"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("jsonb"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("GuidedPathId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsHiddenInitially") + .HasColumnType("boolean"); + + b.Property("IsStepLocked") + .HasColumnType("boolean"); + + b.Property("IsStepTimer") + .HasColumnType("boolean"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("TimerExpiredMessage") + .HasColumnType("jsonb"); + + b.Property("TimerSeconds") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TriggerGeoPointId") + .HasColumnType("integer"); + + b.Property("ZoneRadiusMeters") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("GuidedPathId"); + + b.HasIndex("TriggerGeoPointId"); + + b.ToTable("GuidedSteps"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.QuizQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GuidedStepId") + .HasColumnType("text"); + + b.Property("IsSlidingPuzzle") + .HasColumnType("boolean"); + + b.Property>("Label") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PuzzleCols") + .HasColumnType("integer"); + + b.Property("PuzzleImageId") + .HasColumnType("text"); + + b.Property("PuzzleRows") + .HasColumnType("integer"); + + b.Property("ResourceId") + .HasColumnType("text"); + + b.Property>("Responses") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SectionQuizId") + .HasColumnType("text"); + + b.Property("ValidationQuestionType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GuidedStepId"); + + b.HasIndex("PuzzleImageId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("SectionQuizId"); + + b.ToTable("QuizQuestions"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+MapAnnotation", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("GeometryType") + .HasColumnType("integer"); + + b.Property("Icon") + .HasColumnType("text"); + + b.Property("IconResourceId") + .HasColumnType("text"); + + b.Property>("Label") + .HasColumnType("jsonb"); + + b.Property("PolyColor") + .HasColumnType("text"); + + b.Property("ProgrammeBlockId") + .HasColumnType("text"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property>("Type") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("IconResourceId"); + + b.HasIndex("ProgrammeBlockId"); + + b.HasIndex("SectionEventId"); + + b.ToTable("MapAnnotations"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property>("Description") + .HasColumnType("jsonb"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property>("Title") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.ToTable("ProgrammeBlocks"); + }); + + modelBuilder.Entity("ManagerService.Data.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("ManagerService.Data.VisitEvent", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AppType") + .HasColumnType("integer"); + + b.Property("ConfigurationId") + .HasColumnType("text"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("SectionId") + .HasColumnType("text"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("Timestamp"); + + b.ToTable("VisitEvents"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionAgenda", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("AgendaMapProvider") + .HasColumnType("integer"); + + b.Property>("AgendaResourceIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("IsOnlineAgenda") + .HasColumnType("boolean"); + + b.HasDiscriminator().HasValue("Agenda"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionArticle", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("ArticleAudioIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("ArticleContent") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("ArticleContents") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ArticleIsContentTop") + .HasColumnType("boolean"); + + b.Property("ArticleIsReadAudioAuto") + .HasColumnType("boolean"); + + b.HasDiscriminator().HasValue("Article"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("BaseSectionMapId") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property>("ParcoursIds") + .HasColumnType("jsonb"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.HasIndex("BaseSectionMapId"); + + b.HasDiscriminator().HasValue("Event"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionGame", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("GameMessageDebut") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("GameMessageFin") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("GamePuzzleCols") + .HasColumnType("integer"); + + b.Property("GamePuzzleImageId") + .HasColumnType("text"); + + b.Property("GamePuzzleRows") + .HasColumnType("integer"); + + b.Property("GameType") + .HasColumnType("integer"); + + b.HasIndex("GamePuzzleImageId"); + + b.HasDiscriminator().HasValue("Game"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMap", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("IsListViewEnabled") + .HasColumnType("boolean"); + + b.Property>("MapCategories") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MapCenterLatitude") + .HasColumnType("text"); + + b.Property("MapCenterLongitude") + .HasColumnType("text"); + + b.Property("MapMapProvider") + .HasColumnType("integer"); + + b.Property("MapMapType") + .HasColumnType("integer"); + + b.Property("MapResourceId") + .HasColumnType("text"); + + b.Property("MapTypeMapbox") + .HasColumnType("integer"); + + b.Property("MapZoom") + .HasColumnType("integer"); + + b.HasIndex("MapResourceId"); + + b.HasDiscriminator().HasValue("Map"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMenu", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.HasDiscriminator().HasValue("Menu"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionPdf", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("PDFOrderedTranslationAndResources") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasDiscriminator().HasValue("PDF"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionQuiz", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("QuizBadLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("QuizGoodLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("QuizGreatLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("QuizMediumLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasDiscriminator().HasValue("Quiz"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionSlider", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("SliderContents") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasDiscriminator().HasValue("Slider"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionVideo", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("VideoSource") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue("Video"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionWeather", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("WeatherCity") + .HasColumnType("text"); + + b.Property("WeatherResult") + .HasColumnType("text"); + + b.Property("WeatherUpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasDiscriminator().HasValue("Weather"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionWeb", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("WebSource") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue("Web"); + }); + + modelBuilder.Entity("ManagerService.Data.ApiKey", b => + { + b.HasOne("ManagerService.Data.Instance", "Instance") + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("ManagerService.Data.AppConfigurationLink", b => + { + b.HasOne("ManagerService.Data.ApplicationInstance", "ApplicationInstance") + .WithMany("Configurations") + .HasForeignKey("ApplicationInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ManagerService.Data.Configuration", "Configuration") + .WithMany() + .HasForeignKey("ConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ManagerService.Data.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId"); + + b.Navigation("ApplicationInstance"); + + b.Navigation("Configuration"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.Navigation("SectionEvent"); + }); + + modelBuilder.Entity("ManagerService.Data.Device", b => + { + b.HasOne("ManagerService.Data.Configuration", "Configuration") + .WithMany() + .HasForeignKey("ConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Configuration"); + }); + + modelBuilder.Entity("ManagerService.Data.Section", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionMenu", null) + .WithMany("MenuSections") + .HasForeignKey("SectionMenuId"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.EventAgenda", b => + { + b.HasOne("ManagerService.Data.Resource", "Resource") + .WithMany() + .HasForeignKey("ResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionAgenda", "SectionAgenda") + .WithMany("EventAgendas") + .HasForeignKey("SectionAgendaId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.HasOne("ManagerService.Data.Resource", "VideoResource") + .WithMany() + .HasForeignKey("VideoResourceId"); + + b.Navigation("Resource"); + + b.Navigation("SectionAgenda"); + + b.Navigation("SectionEvent"); + + b.Navigation("VideoResource"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GeoPoint", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.HasOne("ManagerService.Data.SubSection.SectionMap", "SectionMap") + .WithMany("MapPoints") + .HasForeignKey("SectionMapId"); + + b.Navigation("SectionEvent"); + + b.Navigation("SectionMap"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.HasOne("ManagerService.Data.SubSection.SectionGame", "SectionGame") + .WithMany() + .HasForeignKey("SectionGameId"); + + b.HasOne("ManagerService.Data.SubSection.SectionMap", "SectionMap") + .WithMany() + .HasForeignKey("SectionMapId"); + + b.Navigation("SectionEvent"); + + b.Navigation("SectionGame"); + + b.Navigation("SectionMap"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b => + { + b.HasOne("ManagerService.Data.SubSection.GuidedPath", "GuidedPath") + .WithMany("Steps") + .HasForeignKey("GuidedPathId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ManagerService.Data.SubSection.GeoPoint", "TriggerGeoPoint") + .WithMany() + .HasForeignKey("TriggerGeoPointId"); + + b.Navigation("GuidedPath"); + + b.Navigation("TriggerGeoPoint"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.QuizQuestion", b => + { + b.HasOne("ManagerService.Data.SubSection.GuidedStep", "GuidedStep") + .WithMany("QuizQuestions") + .HasForeignKey("GuidedStepId"); + + b.HasOne("ManagerService.Data.Resource", "PuzzleImage") + .WithMany() + .HasForeignKey("PuzzleImageId"); + + b.HasOne("ManagerService.Data.Resource", "Resource") + .WithMany() + .HasForeignKey("ResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionQuiz", "SectionQuiz") + .WithMany("QuizQuestions") + .HasForeignKey("SectionQuizId"); + + b.Navigation("GuidedStep"); + + b.Navigation("PuzzleImage"); + + b.Navigation("Resource"); + + b.Navigation("SectionQuiz"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+MapAnnotation", b => + { + b.HasOne("ManagerService.Data.Resource", "IconResource") + .WithMany() + .HasForeignKey("IconResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", null) + .WithMany("MapAnnotations") + .HasForeignKey("ProgrammeBlockId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent", null) + .WithMany("GlobalMapAnnotations") + .HasForeignKey("SectionEventId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("IconResource"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", null) + .WithMany("Programme") + .HasForeignKey("SectionEventId"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionMap", "BaseMap") + .WithMany() + .HasForeignKey("BaseSectionMapId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BaseMap"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionGame", b => + { + b.HasOne("ManagerService.Data.Resource", "GamePuzzleImage") + .WithMany() + .HasForeignKey("GamePuzzleImageId"); + + b.Navigation("GamePuzzleImage"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMap", b => + { + b.HasOne("ManagerService.Data.Resource", "MapResource") + .WithMany() + .HasForeignKey("MapResourceId"); + + b.Navigation("MapResource"); + }); + + modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b => + { + b.Navigation("Configurations"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b => + { + b.Navigation("QuizQuestions"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", b => + { + b.Navigation("MapAnnotations"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionAgenda", b => + { + b.Navigation("EventAgendas"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.Navigation("GlobalMapAnnotations"); + + b.Navigation("Programme"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMap", b => + { + b.Navigation("MapPoints"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMenu", b => + { + b.Navigation("MenuSections"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionQuiz", b => + { + b.Navigation("QuizQuestions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ManagerService/Migrations/20260325095228_AddVideoResourceToEventAgenda.cs b/ManagerService/Migrations/20260325095228_AddVideoResourceToEventAgenda.cs new file mode 100644 index 0000000..9832a02 --- /dev/null +++ b/ManagerService/Migrations/20260325095228_AddVideoResourceToEventAgenda.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ManagerService.Migrations +{ + /// + public partial class AddVideoResourceToEventAgenda : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IdVideoYoutube", + table: "EventAgendas", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsSynced", + table: "EventAgendas", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "VideoLink", + table: "EventAgendas", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "VideoResourceId", + table: "EventAgendas", + type: "text", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_EventAgendas_VideoResourceId", + table: "EventAgendas", + column: "VideoResourceId"); + + migrationBuilder.AddForeignKey( + name: "FK_EventAgendas_Resources_VideoResourceId", + table: "EventAgendas", + column: "VideoResourceId", + principalTable: "Resources", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_EventAgendas_Resources_VideoResourceId", + table: "EventAgendas"); + + migrationBuilder.DropIndex( + name: "IX_EventAgendas_VideoResourceId", + table: "EventAgendas"); + + migrationBuilder.DropColumn( + name: "IdVideoYoutube", + table: "EventAgendas"); + + migrationBuilder.DropColumn( + name: "IsSynced", + table: "EventAgendas"); + + migrationBuilder.DropColumn( + name: "VideoLink", + table: "EventAgendas"); + + migrationBuilder.DropColumn( + name: "VideoResourceId", + table: "EventAgendas"); + } + } +} diff --git a/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs b/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs index 0eeb02b..11c3f52 100644 --- a/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs +++ b/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs @@ -511,6 +511,12 @@ namespace ManagerService.Migrations b.Property("Email") .HasColumnType("text"); + b.Property("IdVideoYoutube") + .HasColumnType("text"); + + b.Property("IsSynced") + .HasColumnType("boolean"); + b.Property("Label") .IsRequired() .HasColumnType("jsonb"); @@ -530,6 +536,12 @@ namespace ManagerService.Migrations b.Property("Type") .HasColumnType("text"); + b.Property("VideoLink") + .HasColumnType("text"); + + b.Property("VideoResourceId") + .HasColumnType("text"); + b.Property("Website") .HasColumnType("text"); @@ -541,6 +553,8 @@ namespace ManagerService.Migrations b.HasIndex("SectionEventId"); + b.HasIndex("VideoResourceId"); + b.ToTable("EventAgendas"); }); @@ -799,6 +813,9 @@ namespace ManagerService.Migrations b.Property("ProgrammeBlockId") .HasColumnType("text"); + b.Property("SectionEventId") + .HasColumnType("text"); + b.Property>("Type") .HasColumnType("jsonb"); @@ -808,6 +825,8 @@ namespace ManagerService.Migrations b.HasIndex("ProgrammeBlockId"); + b.HasIndex("SectionEventId"); + b.ToTable("MapAnnotations"); }); @@ -969,6 +988,9 @@ namespace ManagerService.Migrations { b.HasBaseType("ManagerService.Data.Section"); + b.Property("BaseSectionMapId") + .HasColumnType("text"); + b.Property("EndDate") .HasColumnType("timestamp with time zone"); @@ -978,6 +1000,8 @@ namespace ManagerService.Migrations b.Property("StartDate") .HasColumnType("timestamp with time zone"); + b.HasIndex("BaseSectionMapId"); + b.HasDiscriminator().HasValue("Event"); }); @@ -1014,6 +1038,9 @@ namespace ManagerService.Migrations { b.HasBaseType("ManagerService.Data.Section"); + b.Property("IsListViewEnabled") + .HasColumnType("boolean"); + b.Property>("MapCategories") .IsRequired() .HasColumnType("jsonb"); @@ -1211,11 +1238,17 @@ namespace ManagerService.Migrations .WithMany() .HasForeignKey("SectionEventId"); + b.HasOne("ManagerService.Data.Resource", "VideoResource") + .WithMany() + .HasForeignKey("VideoResourceId"); + b.Navigation("Resource"); b.Navigation("SectionAgenda"); b.Navigation("SectionEvent"); + + b.Navigation("VideoResource"); }); modelBuilder.Entity("ManagerService.Data.SubSection.GeoPoint", b => @@ -1308,6 +1341,11 @@ namespace ManagerService.Migrations .WithMany("MapAnnotations") .HasForeignKey("ProgrammeBlockId"); + b.HasOne("ManagerService.Data.SubSection.SectionEvent", null) + .WithMany("GlobalMapAnnotations") + .HasForeignKey("SectionEventId") + .OnDelete(DeleteBehavior.Cascade); + b.Navigation("IconResource"); }); @@ -1318,6 +1356,16 @@ namespace ManagerService.Migrations .HasForeignKey("SectionEventId"); }); + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionMap", "BaseMap") + .WithMany() + .HasForeignKey("BaseSectionMapId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BaseMap"); + }); + modelBuilder.Entity("ManagerService.Data.SubSection.SectionGame", b => { b.HasOne("ManagerService.Data.Resource", "GamePuzzleImage") @@ -1363,6 +1411,8 @@ namespace ManagerService.Migrations modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => { + b.Navigation("GlobalMapAnnotations"); + b.Navigation("Programme"); }); diff --git a/ManagerService/Services/AgendaSyncService.cs b/ManagerService/Services/AgendaSyncService.cs new file mode 100644 index 0000000..53e6c67 --- /dev/null +++ b/ManagerService/Services/AgendaSyncService.cs @@ -0,0 +1,134 @@ +using Manager.DTOs; +using ManagerService.Data; +using ManagerService.Data.SubSection; +using ManagerService.DTOs; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +namespace ManagerService.Services +{ + public class AgendaSyncService + { + private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IHttpClientFactory _httpClientFactory; + + public AgendaSyncService(ILogger logger, IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory) + { + _logger = logger; + _scopeFactory = scopeFactory; + _httpClientFactory = httpClientFactory; + } + + public async Task SyncAllAsync() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var sections = db.Sections.OfType() + .Where(sa => sa.IsOnlineAgenda && sa.AgendaResourceIds != null && sa.AgendaResourceIds.Count > 0) + .Select(sa => sa.Id) + .ToList(); + + foreach (var id in sections) + { + try { await SyncSectionAsync(id); } + catch (Exception ex) { _logger.LogError(ex, "Error syncing agenda section {Id}", id); } + } + } + + public async Task SyncSectionAsync(string sectionAgendaId) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var section = db.Sections.OfType() + .Include(sa => sa.EventAgendas) + .FirstOrDefault(sa => sa.Id == sectionAgendaId); + + if (section == null || !section.IsOnlineAgenda || section.AgendaResourceIds == null) + return; + + var http = _httpClientFactory.CreateClient(); + + foreach (var resourceRef in section.AgendaResourceIds) + { + if (string.IsNullOrEmpty(resourceRef.value) || string.IsNullOrEmpty(resourceRef.language)) + continue; + + var resource = db.Resources.FirstOrDefault(r => r.Id == resourceRef.value); + if (resource == null || string.IsNullOrEmpty(resource.Url)) + continue; + + List remoteEvents; + try + { + var json = await http.GetStringAsync(resource.Url); + remoteEvents = JsonConvert.DeserializeObject>(json, RemoteAgendaJsonSettings.Lenient) ?? new(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch agenda JSON for section {Id} language {Lang}", sectionAgendaId, resourceRef.language); + continue; + } + + foreach (var remote in remoteEvents) + { + var dateFrom = remote.GetDateFrom(); + + // Match on DateFrom (date part only), restricted to IsSynced events + var existing = section.EventAgendas.FirstOrDefault(ea => + ea.IsSynced && + ea.DateFrom.HasValue && + dateFrom.HasValue && + ea.DateFrom.Value.Date == dateFrom.Value.Date); + + if (existing == null) + { + existing = new EventAgenda + { + Label = new List(), + Description = new List(), + SectionAgendaId = sectionAgendaId, + IsSynced = true, + }; + section.EventAgendas.Add(existing); + db.EventAgendas.Add(existing); + } + + // Update / set translation for this language + SetTranslation(existing.Label, resourceRef.language, remote.name); + SetTranslation(existing.Description, resourceRef.language, remote.description); + + // Non-translated fields (last language wins, acceptable) + existing.DateFrom = dateFrom; + existing.DateTo = remote.GetDateTo(); + existing.Phone = remote.phone; + existing.Email = remote.email; + existing.Website = remote.website; + existing.IdVideoYoutube = remote.id_video_youtube; + existing.IsSynced = true; + } + } + + db.SaveChanges(); + _logger.LogInformation("Synced agenda section {Id}", sectionAgendaId); + } + + private static void SetTranslation(List list, string language, string? value) + { + var existing = list.FirstOrDefault(t => t.language == language); + if (existing != null) + existing.value = value ?? ""; + else + list.Add(new TranslationDTO { language = language, value = value ?? "" }); + } + } +} diff --git a/ManagerService/Services/ApiKeyDatabaseService.cs b/ManagerService/Services/ApiKeyDatabaseService.cs index cf14efc..a359e1c 100644 --- a/ManagerService/Services/ApiKeyDatabaseService.cs +++ b/ManagerService/Services/ApiKeyDatabaseService.cs @@ -23,7 +23,7 @@ namespace ManagerService.Services /// /// Creates a new API key with a hashed secret (returned once in plain text). /// - public async Task CreateAsync(string instanceId, string name, ApiKeyAppType appType) + public async Task CreateAsync(string instanceId, string name, ApiKeyAppType appType, DateTime? dateExpiration = null) { var plainKey = "ak_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) .Replace("+", "-").Replace("/", "_").TrimEnd('='); @@ -40,6 +40,7 @@ namespace ManagerService.Services KeyHash = keyHash, IsActive = true, DateCreation = DateTime.UtcNow, + DateExpiration = dateExpiration.HasValue ? DateTime.SpecifyKind(dateExpiration.Value, DateTimeKind.Utc) : null, }; _db.ApiKeys.Add(apiKey); diff --git a/ManagerService/Services/AssistantService.cs b/ManagerService/Services/AssistantService.cs index 9080ae3..3b3b533 100644 --- a/ManagerService/Services/AssistantService.cs +++ b/ManagerService/Services/AssistantService.cs @@ -3,7 +3,13 @@ using ManagerService.DTOs; using Microsoft.Extensions.AI; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; +using System; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using Manager.DTOs; +using ManagerService.Data.SubSection; namespace ManagerService.Services { @@ -20,10 +26,38 @@ namespace ManagerService.Services _context = context; } + private static string StripHtml(string? html) => + string.IsNullOrEmpty(html) ? "" : System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", "").Trim(); + + private static DateTime? ParseFilterDate(string? s) + { + if (string.IsNullOrEmpty(s)) return null; + // Priorité au format ISO yyyy-MM-dd (language-neutral), puis fallbacks + if (DateTime.TryParseExact(s, + new[] { "yyyy-MM-dd", "dd/MM/yyyy", "MM/dd/yyyy", "dd-MM-yyyy" }, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var dt1)) + return dt1; + if (DateTime.TryParse(s, out var dt2)) return dt2; + return null; + } + + private static (DateTime saturday, DateTime sunday) GetCurrentWeekend() + { + var now = DateTime.Now.Date; + // Si on est dimanche, le weekend en cours a commencé hier + if (now.DayOfWeek == DayOfWeek.Sunday) + return (now.AddDays(-1), now); + var daysToSat = ((int)DayOfWeek.Saturday - (int)now.DayOfWeek + 7) % 7; + var sat = now.AddDays(daysToSat); + return (sat, sat.AddDays(1)); + } + public async Task ChatAsync(AiChatRequest request) { var messages = new List(); ChatOptions options; + var (weekendSat, weekendSun) = GetCurrentWeekend(); if (request.ConfigurationId != null) { @@ -33,24 +67,31 @@ namespace ManagerService.Services var sectionsSummary = string.Join("\n", sections.Select(s => { - var title = s.Title?.FirstOrDefault(t => t.language == request.Language)?.value + var title = StripHtml(s.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? s.Title?.FirstOrDefault()?.value - ?? s.Label; + ?? s.Label); return $"- id:{s.Id} | type:{s.Type} | titre:\"{title}\""; })); messages.Add(new ChatMessage(ChatRole.System, $""" - Tu es l'assistant de visite de "{config?.Label ?? "cette application"}". - Tu réponds en {request.Language}. Tu es chaleureux, concis et utile. + Tu es l'assistant de visite de l'application "{config?.Label ?? "cette application"}". + Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}. + Le weekend en cours (ou prochain) : samedi {weekendSat:dd/MM/yyyy} – dimanche {weekendSun:dd/MM/yyyy}. + Ton but est d'accompagner le visiteur dans son expérience. Tu réponds en {request.Language}. Tu es chaleureux, concis et proactif. - Voici les sections disponibles dans cette application : + Voici les sections disponibles : {sectionsSummary} - Pour obtenir le détail d'une section spécifique, utilise l'outil GetSectionDetail. - Pour afficher une liste d'éléments (événements, activités...) de façon visuelle, appelle show_cards avec les titres et sous-titres. - Pour proposer à l'utilisateur d'aller directement dans une section, appelle navigate_to_section. - Accompagne toujours tes outils d'un texte explicatif. - Ne parle jamais de sections qui ne sont pas dans cette liste. + RÈGLES STRICTES — tu dois les respecter sans exception : + 1. Tu ne réponds JAMAIS "je ne peux pas" ou "je n'ai pas accès" si un outil existe pour répondre. Tu appelles l'outil, tu reçois les données, TU les présentes. + 2. Toute question sur des événements, le planning, les dates, les activités → appelle "GetUpcomingEvents" IMMÉDIATEMENT. Si l'utilisateur précise une période, calcule les dates et passe-les en paramètres dateFrom/dateTo au format ISO yyyy-MM-dd. Exemples : "ce weekend" → dateFrom={weekendSat:yyyy-MM-dd}&dateTo={weekendSun:yyyy-MM-dd} ; "la semaine prochaine" → lundi au dimanche suivant. + 3. Pour présenter une liste d'événements, appelle TOUJOURS "show_cards" avec les titres et dates — ne liste jamais les événements en texte brut. + 4. Après avoir présenté des résultats d'événements, appelle TOUJOURS "navigate_to_section" avec l'ID de la section agenda trouvée dans la liste ci-dessus. + 5. Si l'utilisateur veut voir les activités ou lieux → utilise "GetMapPoints". + 6. Pour les détails d'un item spécifique → utilise "GetItemDetails". + - Ne mentionne JAMAIS les identifiants techniques (id, guid) dans tes réponses finales. + - NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats. + - NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide. """)); foreach (var h in request.History.TakeLast(MaxHistoryMessages)) @@ -61,31 +102,199 @@ namespace ManagerService.Services NavigationActionDTO? navigation = null; List? cards = null; - // Outil scopé : vérifie que la section appartient bien à cette configuration - var scopedSections = sections; var tools = new List { AIFunctionFactory.Create( (string sectionId) => { - var section = scopedSections.FirstOrDefault(s => s.Id == sectionId); - if (section == null) return "Section non trouvée dans cette application."; - - var title = section.Title?.FirstOrDefault(t => t.language == request.Language)?.value - ?? section.Title?.FirstOrDefault()?.value - ?? section.Label; - var desc = section.Description?.FirstOrDefault(t => t.language == request.Language)?.value - ?? section.Description?.FirstOrDefault()?.value - ?? ""; + var section = sections.FirstOrDefault(s => s.Id == sectionId); + if (section == null) return "Section non trouvée."; + var title = section.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? section.Label; + var desc = section.Description?.FirstOrDefault(t => t.language == request.Language)?.value ?? ""; return $"Titre: {title}\nDescription: {desc}\nType: {section.Type}"; }, "GetSectionDetail", - "Récupère le titre et la description complète d'une section à partir de son id" + "Récupère le titre et la description d'une section." + ), + AIFunctionFactory.Create( + async (string? dateFrom, string? dateTo) => + { + var today = DateTime.UtcNow.Date; + var horizon = today.AddMonths(3); + + var filterFrom = ParseFilterDate(dateFrom); + var filterTo = ParseFilterDate(dateTo); + + var agendaSections = _context.Sections + .OfType() + .Where(s => s.ConfigurationId == request.ConfigurationId) + .ToList(); + + var allEvents = new List<(DateTime sort, string line)>(); + + foreach (var agenda in agendaSections) + { + try + { + if (agenda.IsOnlineAgenda) + { + var resourceId = agenda.AgendaResourceIds?.FirstOrDefault(r => r.language == request.Language)?.value + ?? agenda.AgendaResourceIds?.FirstOrDefault()?.value; + + if (resourceId != null) + { + var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId); + if (resource?.Url != null) + { + using var client = new System.Net.Http.HttpClient(); + var json = await client.GetStringAsync(resource.Url); + var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject>(json, RemoteAgendaJsonSettings.Lenient); + if (remoteEvents != null) + { + foreach (var e in remoteEvents) + { + var dtFrom = e.GetDateFrom(); + var dtTo = e.GetDateTo(); + var end = dtTo ?? dtFrom; + if (end == null || end.Value.Date < today || dtFrom?.Date > horizon) continue; + if (filterFrom.HasValue && end.Value.Date < filterFrom.Value) continue; + if (filterTo.HasValue && dtFrom?.Date > filterTo.Value) continue; + var dateStr = dtFrom.HasValue ? dtFrom.Value.ToString("dd/MM/yyyy") : "Date non précisée"; + if (dtTo.HasValue && dtTo.Value.Date != dtFrom?.Date) + dateStr += $" → {dtTo.Value.ToString("dd/MM/yyyy")}"; + allEvents.Add((dtFrom ?? DateTime.MaxValue, $"- [En ligne] AgendaId:{agenda.Id} | Titre:{e.name ?? "Sans titre"} | Date:{dateStr}")); + } + } + } + } + } + else + { + var qFrom = filterFrom ?? today; + var qTo = filterTo ?? horizon; + var events = _context.EventAgendas + .Where(e => e.SectionAgendaId == agenda.Id && (e.DateTo ?? e.DateFrom) >= qFrom && e.DateFrom <= qTo) + .OrderBy(e => e.DateFrom) + .ToList(); + foreach (var e in events) + { + var title = e.Label?.FirstOrDefault(t => t.language == request.Language)?.value ?? e.Label?.FirstOrDefault()?.value ?? "Sans titre"; + var dateStr = e.DateFrom.HasValue ? e.DateFrom.Value.ToString("dd/MM/yyyy") : "Date non précisée"; + if (e.DateTo.HasValue && e.DateTo.Value.Date != e.DateFrom?.Date) + dateStr += $" → {e.DateTo.Value.ToString("dd/MM/yyyy")}"; + allEvents.Add((e.DateFrom ?? DateTime.MaxValue, $"- SectionId:{agenda.Id} | EventId:{e.Id} | Titre:{title} | Date:{dateStr}")); + } + } + } + catch (Exception ex) + { + allEvents.Add((DateTime.MaxValue, $"- [ERREUR agenda {agenda.Label}] {ex.Message}")); + } + } + + if (!allEvents.Any()) + { + var period = filterFrom.HasValue ? $" du {filterFrom.Value:dd/MM/yyyy} au {(filterTo ?? filterFrom.Value):dd/MM/yyyy}" : " dans les 3 prochains mois"; + return $"Aucun événement à venir{period}."; + } + var lines = allEvents.OrderBy(x => x.sort).Take(50).Select(x => x.line); + return string.Join("\n", lines); + }, + "GetUpcomingEvents", + "Retourne les événements à venir (max 50, triés par date). Paramètres optionnels dateFrom et dateTo au format dd/MM/yyyy pour filtrer côté serveur (ex: ce weekend → dateFrom=21/03/2026&dateTo=22/03/2026, la semaine prochaine → lundi au dimanche suivant)." + ), + AIFunctionFactory.Create( + () => + { + var points = _context.Sections + .OfType() + .Where(s => s.ConfigurationId == request.ConfigurationId) + .SelectMany(s => s.MapPoints) + .ToList(); + + if (!points.Any()) return "Aucun point d'intérêt trouvé."; + + return string.Join("\n", points.Select(p => { + var title = p.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? p.Title?.FirstOrDefault()?.value ?? "Sans titre"; + return $"- ID:{p.Id} | Titre:{title}"; + })); + }, + "GetMapPoints", + "Liste tous les points d'intérêt (musées, monuments, activités) disponibles sur la carte." + ), + AIFunctionFactory.Create( + async (string type, string id) => + { + if (id.StartsWith("remote:")) + { + // Format: remote:[agendaId]:[eventId] + var parts = id.Split(':'); + if (parts.Length < 3) return "Format d'ID distant invalide."; + var agendaId = parts[1]; + var eventId = parts[2]; + + var agenda = _context.Sections.OfType().FirstOrDefault(s => s.Id == agendaId); + if (agenda == null) return "Agenda non trouvé."; + + var resourceId = agenda.AgendaResourceIds?.FirstOrDefault(r => r.language == request.Language)?.value + ?? agenda.AgendaResourceIds?.FirstOrDefault()?.value; + + if (resourceId == null) return "Ressource de l'agenda non trouvée."; + var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId); + if (resource?.Url == null) return "URL de l'agenda non trouvée."; + + try { + using var client = new System.Net.Http.HttpClient(); + var json = await client.GetStringAsync(resource.Url); + var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject>(json, RemoteAgendaJsonSettings.Lenient); + var e = remoteEvents?.FirstOrDefault(ev => ev.name == eventId); + if (e == null) return "Événement distant non trouvé."; + + var title = e.name ?? "Sans titre"; + var desc = e.description ?? ""; + var dtStart = e.GetDateFrom(); + var dtEnd = e.GetDateTo(); + var dateStr = dtStart.HasValue ? $"Du {dtStart.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; + dateStr += dtEnd.HasValue ? $" au {dtEnd.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; + return $"[ÉVÉNEMENT (EN LIGNE)]\nTitre: {title}\nDate: {dateStr}\nDescription: {desc}\nContact: {e.email} / {e.phone}\nSite: {e.website}"; + } catch (Exception ex) { return $"Erreur lors de la récupération des détails : {ex.Message}"; } + } + + if (type.ToLower() == "event") + { + int.TryParse(id, out var intId); + var e = _context.EventAgendas.FirstOrDefault(ev => ev.Id == intId); + if (e == null) return "Événement non trouvé."; + var title = e.Label?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Sans titre"; + var desc = e.Description?.FirstOrDefault(t => t.language == request.Language)?.value ?? ""; + var dateStr = e.DateFrom.HasValue ? $"Du {e.DateFrom.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; + dateStr += e.DateTo.HasValue ? $" au {e.DateTo.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; + return $"[ÉVÉNEMENT]\nTitre: {title}\nDate: {dateStr}\nDescription: {desc}\nContact: {e.Email} / {e.Phone}\nSite: {e.Website}"; + } + else + { + int.TryParse(id, out var intId); + var p = _context.Sections.OfType() + .SelectMany(s => s.MapPoints) + .FirstOrDefault(pt => pt.Id == intId); + if (p == null) return "Point d'intérêt non trouvé."; + var title = p.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Sans titre"; + var desc = p.Description?.FirstOrDefault(t => t.language == request.Language)?.value ?? ""; + var prices = p.Prices?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Non spécifié"; + var schedules = p.Schedules?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Non spécifié"; + var contact = $"{p.Email?.FirstOrDefault()?.value} / {p.Phone?.FirstOrDefault()?.value}"; + var site = p.Site?.FirstOrDefault()?.value; + return $"[LIEU/ACTIVITÉ]\nTitre: {title}\nDescription: {desc}\nPrix: {prices}\nHoraires: {schedules}\nContact: {contact}\nSite: {site}"; + } + }, + "GetItemDetails", + "Récupère les détails complets (prix, horaires, contact, site) d'un événement (type='event') ou d'un point d'intérêt (type='poi') via son ID." ), AIFunctionFactory.Create( (string sectionId, string sectionTitle, string sectionType) => { - navigation = new NavigationActionDTO { SectionId = sectionId, SectionTitle = sectionTitle, SectionType = sectionType }; + var imageUrl = sections.FirstOrDefault(s => s.Id == sectionId)?.ImageSource; + navigation = new NavigationActionDTO { SectionId = sectionId, SectionTitle = sectionTitle, SectionType = sectionType, ImageUrl = imageUrl }; return Task.FromResult("Navigation proposée à l'utilisateur."); }, "navigate_to_section", @@ -107,33 +316,65 @@ namespace ManagerService.Services ) }; - options = new ChatOptions { Tools = tools }; - var response = await _chatClient.GetResponseAsync(messages, options); - return new AiChatResponse { Reply = response.Text ?? "", Cards = cards, Navigation = navigation }; + try + { + options = new ChatOptions { Tools = tools }; + var response = await _chatClient.GetResponseAsync(messages, options); + return new AiChatResponse { Reply = response.Text ?? "", Cards = cards, Navigation = navigation }; + } + catch (System.ClientModel.ClientResultException ex) + { + // Log plus de détails si possible sur l'erreur 400 de Google/OpenAI + throw new System.Exception($"AI Service Error (Status: {ex.Status}): {ex.Message}", ex); + } } else { - // Scope instance : l'IA connaît les configurations disponibles - var configurations = _context.Configurations.Where(c => c.InstanceId == request.InstanceId).ToList(); + // Scope instance : l'IA connaît les configurations disponibles pour cette application spécifiquement (AppType) + var configurations = _context.AppConfigurationLinks + .Where(l => l.ApplicationInstance.InstanceId == request.InstanceId && l.ApplicationInstance.AppType == request.AppType && l.IsActive) + .Select(l => l.Configuration) + .ToList(); + var configIds = configurations.Select(c => c.Id).ToList(); + + // Récupère les types de sections par configuration pour aider l'IA à choisir + var typesByConfig = _context.Sections + .Where(s => configIds.Contains(s.ConfigurationId)) + .Select(s => new { s.ConfigurationId, s.Type }) + .ToList() + .GroupBy(s => s.ConfigurationId) + .ToDictionary(g => g.Key, g => g.Select(s => s.Type).Distinct().ToList()); var configsSummary = string.Join("\n", configurations.Select(c => { - var title = c.Title?.FirstOrDefault(t => t.language == request.Language)?.value + var title = StripHtml(c.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? c.Title?.FirstOrDefault()?.value - ?? c.Label; - return $"- id:{c.Id} | titre:\"{title}\""; + ?? c.Label); + + typesByConfig.TryGetValue(c.Id, out var types); + var typesStr = types != null && types.Any() ? $" | types disponibles: {string.Join(", ", types)}" : ""; + + return $"- id:{c.Id} | titre:\"{title}\"{typesStr}"; })); messages.Add(new ChatMessage(ChatRole.System, $""" - Tu es l'assistant de visite. - Tu réponds en {request.Language}. Tu es chaleureux, concis et utile. + Tu es l'assistant de visite principal. Ton rôle est d'orienter l'utilisateur vers la bonne expérience de visite. + Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}. + Le weekend en cours (ou prochain) : samedi {weekendSat:dd/MM/yyyy} – dimanche {weekendSun:dd/MM/yyyy}. + Tu réponds en {request.Language}. Tu es chaleureux, concis et proactif. - Voici les expériences de visite disponibles : + Voici les expériences (configurations) disponibles : {configsSummary} - Aide le visiteur à trouver ce qui l'intéresse parmi ces expériences. - Pour orienter le visiteur vers une visite spécifique, appelle navigate_to_configuration. - Accompagne toujours tes outils d'un texte explicatif. + RÈGLES STRICTES — tu dois les respecter sans exception : + 1. Tu ne réponds JAMAIS "je ne peux pas" ou "je n'ai pas accès" si un outil existe pour répondre. Tu appelles l'outil, tu reçois les données, TU les présentes. + 2. Toute question sur des événements, le planning, les dates → appelle "SearchEventsGlobal" IMMÉDIATEMENT. Si l'utilisateur précise une période, calcule les dates et passe-les en paramètres dateFrom/dateTo au format ISO yyyy-MM-dd. Exemples : "ce weekend" → dateFrom={weekendSat:yyyy-MM-dd}&dateTo={weekendSun:yyyy-MM-dd} ; "la semaine prochaine" → lundi au dimanche suivant. + 3. Pour présenter une liste d'événements, appelle TOUJOURS "show_cards" avec les titres et dates — ne liste jamais les événements en texte brut. + 4. Après avoir présenté des résultats d'événements, appelle TOUJOURS "navigate_to_configuration" avec l'ID de la visite concernée pour que l'utilisateur puisse y accéder directement. + 5. Pour orienter vers une visite spécifique (jeu, quiz...) → "navigate_to_configuration". + 6. Pour les détails d'un item → "GetItemDetailsGlobal". + - NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats. + - NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide. """)); foreach (var h in request.History.TakeLast(MaxHistoryMessages)) @@ -145,10 +386,192 @@ namespace ManagerService.Services var tools = new List { + AIFunctionFactory.Create( + async (string? dateFrom, string? dateTo) => + { + var today = DateTime.UtcNow.Date; + var horizon = today.AddMonths(3); + + var filterFrom = ParseFilterDate(dateFrom); + var filterTo = ParseFilterDate(dateTo); + + var agendaSections = _context.Sections + .OfType() + .Where(s => configIds.Contains(s.ConfigurationId)) + .ToList(); + + var allEvents = new List<(DateTime sort, string line)>(); + + foreach (var agenda in agendaSections) + { + try + { + if (agenda.IsOnlineAgenda) + { + var resourceId = agenda.AgendaResourceIds?.FirstOrDefault(r => r.language == request.Language)?.value + ?? agenda.AgendaResourceIds?.FirstOrDefault()?.value; + + if (resourceId != null) + { + var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId); + if (resource?.Url != null) + { + using var client = new System.Net.Http.HttpClient(); + var json = await client.GetStringAsync(resource.Url); + var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject>(json, RemoteAgendaJsonSettings.Lenient); + if (remoteEvents != null) + { + var config = configurations.FirstOrDefault(c => c.Id == agenda.ConfigurationId); + var configTitle = StripHtml(config?.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? config?.Label ?? "Inconnue"); + foreach (var e in remoteEvents) + { + var dtFrom = e.GetDateFrom(); + var dtTo = e.GetDateTo(); + var end = dtTo ?? dtFrom; + if (end == null || end.Value.Date < today || dtFrom?.Date > horizon) continue; + if (filterFrom.HasValue && end.Value.Date < filterFrom.Value) continue; + if (filterTo.HasValue && dtFrom?.Date > filterTo.Value) continue; + var dateStr = dtFrom.HasValue ? dtFrom.Value.ToString("dd/MM/yyyy") : "Date non précisée"; + if (dtTo.HasValue && dtTo.Value.Date != dtFrom?.Date) + dateStr += $" → {dtTo.Value.ToString("dd/MM/yyyy")}"; + allEvents.Add((dtFrom ?? DateTime.MaxValue, $"- [Visite: {configTitle}] ConfigId:{agenda.ConfigurationId} | Titre:{e.name ?? "Sans titre"} | Date:{dateStr}")); + } + } + } + } + } + else + { + var qFrom = filterFrom ?? today; + var qTo = filterTo ?? horizon; + var events = _context.EventAgendas + .Where(e => configIds.Contains(e.SectionAgenda.ConfigurationId) && (e.DateTo ?? e.DateFrom) >= qFrom && e.DateFrom <= qTo) + .OrderBy(e => e.DateFrom) + .ToList(); + foreach (var e in events) + { + var config = configurations.FirstOrDefault(c => c.Id == e.SectionAgenda.ConfigurationId); + var configTitle = StripHtml(config?.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? config?.Label ?? "Inconnue"); + var title = e.Label?.FirstOrDefault(t => t.language == request.Language)?.value ?? e.Label?.FirstOrDefault()?.value ?? "Sans titre"; + var dateStr = e.DateFrom.HasValue ? e.DateFrom.Value.ToString("dd/MM/yyyy") : "Date non précisée"; + if (e.DateTo.HasValue && e.DateTo.Value.Date != e.DateFrom?.Date) + dateStr += $" → {e.DateTo.Value.ToString("dd/MM/yyyy")}"; + allEvents.Add((e.DateFrom ?? DateTime.MaxValue, $"- [Visite: {configTitle}] ConfigId:{e.SectionAgenda.ConfigurationId} | EventId:{e.Id} | Titre:{title} | Date:{dateStr}")); + } + } + } + catch (Exception ex) + { + allEvents.Add((DateTime.MaxValue, $"- [ERREUR agenda {agenda.Label}] {ex.Message}")); + } + } + + if (!allEvents.Any()) + { + var period = filterFrom.HasValue ? $" du {filterFrom.Value:dd/MM/yyyy} au {(filterTo ?? filterFrom.Value):dd/MM/yyyy}" : " dans les 3 prochains mois"; + return $"Aucun événement à venir{period}."; + } + var lines = allEvents.OrderBy(x => x.sort).Take(50).Select(x => x.line); + return string.Join("\n", lines); + }, + "SearchEventsGlobal", + "Retourne les événements à venir (max 50, triés par date). Paramètres optionnels dateFrom et dateTo au format dd/MM/yyyy pour filtrer côté serveur (ex: ce weekend → dateFrom=21/03/2026&dateTo=22/03/2026, la semaine prochaine → lundi au dimanche suivant)." + ), + AIFunctionFactory.Create( + () => + { + var points = _context.Sections + .OfType() + .Where(s => configIds.Contains(s.ConfigurationId)) + .SelectMany(s => s.MapPoints) + .ToList(); + + if (!points.Any()) return "Aucun point d'intérêt trouvé."; + + return string.Join("\n", points.Select(p => { + var title = p.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? p.Title?.FirstOrDefault()?.value ?? "Sans titre"; + return $"- ID:{p.Id} | Titre:{title}"; + })); + }, + "GetMapPointsGlobal", + "Liste tous les points d'intérêt disponibles sur les cartes autorisées." + ), + AIFunctionFactory.Create( + async (string type, string id) => + { + if (id.StartsWith("remote:")) + { + // Format: remote:[agendaId]:[eventId] + var parts = id.Split(':'); + if (parts.Length < 3) return "Format d'ID distant invalide."; + var agendaId = parts[1]; + var eventId = parts[2]; + + var agenda = _context.Sections.OfType().FirstOrDefault(s => s.Id == agendaId && configIds.Contains(s.ConfigurationId)); + if (agenda == null) return "Agenda non trouvé ou non autorisé."; + + var resourceId = agenda.AgendaResourceIds?.FirstOrDefault(r => r.language == request.Language)?.value + ?? agenda.AgendaResourceIds?.FirstOrDefault()?.value; + + if (resourceId == null) return "Ressource de l'agenda non trouvée."; + var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId); + if (resource?.Url == null) return "URL de l'agenda non trouvée."; + + try { + using var client = new System.Net.Http.HttpClient(); + var json = await client.GetStringAsync(resource.Url); + var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject>(json, RemoteAgendaJsonSettings.Lenient); + var e = remoteEvents?.FirstOrDefault(ev => ev.name == eventId); + if (e == null) return "Événement distant non trouvé."; + + var title = e.name ?? "Sans titre"; + var desc = e.description ?? ""; + var dtStart = e.GetDateFrom(); + var dtEnd = e.GetDateTo(); + var dateStr = dtStart.HasValue ? $"Du {dtStart.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; + dateStr += dtEnd.HasValue ? $" au {dtEnd.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; + return $"[ÉVÉNEMENT (EN LIGNE)]\nTitre: {title}\nDate: {dateStr}\nDescription: {desc}\nContact: {e.email} / {e.phone}\nSite: {e.website}"; + } catch (Exception ex) { return $"Erreur lors de la récupération des détails : {ex.Message}"; } + } + + if (type.ToLower() == "event") + { + int.TryParse(id, out var intId); + var e = _context.EventAgendas + .Include(ev => ev.SectionAgenda) + .FirstOrDefault(ev => ev.Id == intId && configIds.Contains(ev.SectionAgenda.ConfigurationId)); + if (e == null) return "Événement non trouvé ou non autorisé."; + var title = e.Label?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Sans titre"; + var desc = e.Description?.FirstOrDefault(t => t.language == request.Language)?.value ?? ""; + var dateStr = e.DateFrom.HasValue ? $"Du {e.DateFrom.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; + dateStr += e.DateTo.HasValue ? $" au {e.DateTo.Value.ToString("dd/MM/yyyy HH:mm")}" : ""; + return $"[ÉVÉNEMENT]\nTitre: {title}\nDate: {dateStr}\nDescription: {desc}\nContact: {e.Email} / {e.Phone}\nSite: {e.Website}"; + } + else + { + int.TryParse(id, out var intId); + var p = _context.Sections.OfType() + .Where(s => configIds.Contains(s.ConfigurationId)) + .SelectMany(s => s.MapPoints) + .FirstOrDefault(pt => pt.Id == intId); + if (p == null) return "Point d'intérêt non trouvé ou non autorisé."; + var title = p.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Sans titre"; + var desc = p.Description?.FirstOrDefault(t => t.language == request.Language)?.value ?? ""; + var prices = p.Prices?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Non spécifié"; + var schedules = p.Schedules?.FirstOrDefault(t => t.language == request.Language)?.value ?? "Non spécifié"; + var contact = $"{p.Email?.FirstOrDefault()?.value} / {p.Phone?.FirstOrDefault()?.value}"; + var site = p.Site?.FirstOrDefault()?.value; + return $"[LIEU/ACTIVITÉ]\nTitre: {title}\nDescription: {desc}\nPrix: {prices}\nHoraires: {schedules}\nContact: {contact}\nSite: {site}"; + } + }, + "GetItemDetailsGlobal", + "Détails d'un lieu ou événement au niveau instance." + ), AIFunctionFactory.Create( (string configurationId, string configurationTitle) => { - navigation = new NavigationActionDTO { SectionId = configurationId, SectionTitle = configurationTitle, SectionType = "Configuration" }; + var imageUrl = configurations.FirstOrDefault(c => c.Id == configurationId)?.ImageSource; + navigation = new NavigationActionDTO { SectionId = configurationId, SectionTitle = configurationTitle, SectionType = "Configuration", ImageUrl = imageUrl }; return Task.FromResult("Navigation vers la visite proposée."); }, "navigate_to_configuration", @@ -156,9 +579,17 @@ namespace ManagerService.Services ) }; - options = new ChatOptions { Tools = tools }; - var response = await _chatClient.GetResponseAsync(messages, options); - return new AiChatResponse { Reply = response.Text ?? "", Navigation = navigation }; + try + { + options = new ChatOptions { Tools = tools }; + var response = await _chatClient.GetResponseAsync(messages, options); + return new AiChatResponse { Reply = response.Text ?? "", Navigation = navigation }; + } + catch (System.ClientModel.ClientResultException ex) + { + // Log plus de détails si possible sur l'erreur 400 de Google/OpenAI + throw new System.Exception($"AI Service Error (Status: {ex.Status}): {ex.Message}", ex); + } } } } diff --git a/ManagerService/Services/SectionFactory.cs b/ManagerService/Services/SectionFactory.cs index f5ef648..6a757f5 100644 --- a/ManagerService/Services/SectionFactory.cs +++ b/ManagerService/Services/SectionFactory.cs @@ -4,6 +4,7 @@ using ManagerService.Data.SubSection; using ManagerService.DTOs; using Newtonsoft.Json; using System; +using System.Linq; using System.Text.Json; namespace ManagerService.Services @@ -141,6 +142,7 @@ namespace ManagerService.Services StartDate = sectionEventDTO.StartDate, EndDate = sectionEventDTO.EndDate, ParcoursIds = sectionEventDTO.ParcoursIds, + BaseSectionMapId = sectionEventDTO.BaseSectionMapId, //Programmes = // TODO specific }, SectionType.Map => new SectionMap @@ -170,7 +172,8 @@ namespace ManagerService.Services MapResourceId = mapDTO.iconResourceId, MapCenterLatitude = mapDTO.centerLatitude, MapCenterLongitude = mapDTO.centerLongitude, - MapCategories = mapDTO.categories + MapCategories = mapDTO.categories, + IsListViewEnabled = mapDTO.isListViewEnabled }, SectionType.Menu => new SectionMenu { @@ -443,6 +446,8 @@ namespace ManagerService.Services StartDate = sectionEvent.StartDate, EndDate = sectionEvent.EndDate, ParcoursIds = sectionEvent.ParcoursIds, + BaseSectionMapId = sectionEvent.BaseSectionMapId, + GlobalMapAnnotations = sectionEvent.GlobalMapAnnotations?.Select(ma => ma.ToDTO()).ToList() ?? new(), // Programme TODO specific }, SectionMap map => new MapDTO @@ -473,6 +478,7 @@ namespace ManagerService.Services centerLatitude = map.MapCenterLatitude, centerLongitude = map.MapCenterLongitude, categories = map.MapCategories, + isListViewEnabled = map.IsListViewEnabled, //points = null // map.MapPoints // TODO specific }, SectionMenu menu => new MenuDTO diff --git a/ManagerService/Startup.cs b/ManagerService/Startup.cs index c2d1420..905e110 100644 --- a/ManagerService/Startup.cs +++ b/ManagerService/Startup.cs @@ -1,3 +1,7 @@ +using FirebaseAdmin; +using Google.Apis.Auth.OAuth2; +using Hangfire; +using Hangfire.PostgreSql; using Microsoft.Extensions.AI; using OpenAI; using System.ClientModel; @@ -116,6 +120,7 @@ namespace ManagerService { options.AddPolicy(policy.Name, policyAdmin => { + policyAdmin.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, "ApiKey"); foreach (var claim in policy.Claims) policyAdmin.RequireClaim(ManagerService.Service.Security.ClaimTypes.Permission, claim); }); @@ -173,12 +178,35 @@ namespace ManagerService new OpenAIClient( new ApiKeyCredential(Configuration["AI:ApiKey"]!), new OpenAIClientOptions { Endpoint = new Uri("https://generativelanguage.googleapis.com/v1beta/openai/") } - ).AsChatClient("gemini-2.5-flash-lite")); + ).AsChatClient("gemini-2.5-flash-lite") + .AsBuilder() + .UseFunctionInvocation() + .Build()); services.AddScoped(); + // Push Notifications + var firebaseCredentialsPath = Configuration["Firebase:CredentialsPath"]; + if (!string.IsNullOrEmpty(firebaseCredentialsPath) && FirebaseApp.DefaultInstance == null) + { + FirebaseApp.Create(new AppOptions + { + Credential = GoogleCredential.FromFile(firebaseCredentialsPath) + }); + } + services.AddSingleton(); + services.AddHttpClient(); + services.AddScoped(); var connectionString = Configuration.GetConnectionString("PostgresConnection"); + // Hangfire + services.AddHangfire(config => config + .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UsePostgreSqlStorage(c => c.UseNpgsqlConnection(connectionString))); + services.AddHangfireServer(); + var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); dataSourceBuilder.UseNetTopologySuite(); dataSourceBuilder.EnableDynamicJson(); @@ -215,7 +243,7 @@ namespace ManagerService app.UseCors( #if DEBUG options => options - .SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "http://localhost:50479") + .SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "http://localhost:9090") .AllowAnyMethod() .AllowAnyHeader() .AllowCredentials() @@ -228,6 +256,16 @@ namespace ManagerService #endif ); + app.UseHangfireDashboard("/hangfire", new DashboardOptions + { + Authorization = new[] { new HangfireDashboardAuthorizationFilter() } + }); + + RecurringJob.AddOrUpdate( + "agenda-sync-daily", + s => s.SyncAllAsync(), + Cron.Daily()); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); diff --git a/ManagerService/appsettings.json b/ManagerService/appsettings.json index 720ad8c..0e6c391 100644 --- a/ManagerService/appsettings.json +++ b/ManagerService/appsettings.json @@ -31,7 +31,10 @@ "SupportedLanguages": [ "FR", "NL", "EN", "DE", "IT", "ES", "PL", "CN", "AR", "UK" ], "OpenWeatherApiKey": "d489973b4c09ddc5fb56bd7b9270bbef", "AI": { - "ApiKey": "AIzaSyC7lJ8w1eQL4aZGNFLMabig5ul6yn66nug" + "ApiKey": "AIzaSyCIf-mzp4Nzm5VwHL7LLzitt9z_bOOGMwc" + }, + "Firebase": { + "CredentialsPath": "firebase-adminsdk.json" } //"Urls": "http://[::]:80" }