wip service, fix some assistant ia bug, wip section agenda update, section event annotation etc
This commit is contained in:
parent
bad25bf5b3
commit
d5353eea9c
3
.gitignore
vendored
3
.gitignore
vendored
@ -54,3 +54,6 @@ obj
|
|||||||
*.user
|
*.user
|
||||||
ManagerService/bin
|
ManagerService/bin
|
||||||
ManagerService/obj
|
ManagerService/obj
|
||||||
|
|
||||||
|
# Firebase service account key (sensitive — never commit)
|
||||||
|
*firebase-adminsdk*.json
|
||||||
|
|||||||
62
CLAUDE.md
Normal file
62
CLAUDE.md
Normal file
@ -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<TranslationDTO>` 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 <Nom>` 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 <Nom> # Nouvelle migration
|
||||||
|
dotnet ef database update # Appliquer les migrations
|
||||||
|
docker compose up -d # Lancer via Docker
|
||||||
|
```
|
||||||
@ -43,7 +43,7 @@ namespace ManagerService.Controllers
|
|||||||
if (string.IsNullOrEmpty(instanceId))
|
if (string.IsNullOrEmpty(instanceId))
|
||||||
return Forbid();
|
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 });
|
return Ok(new { key = plainKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,5 +64,6 @@ namespace ManagerService.Controllers
|
|||||||
{
|
{
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public ApiKeyAppType AppType { get; set; }
|
public ApiKeyAppType AppType { get; set; }
|
||||||
|
public DateTime? DateExpiration { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,6 +75,42 @@ namespace ManagerService.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get upcoming events from section (dateFrom >= today)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sectionAgendaId">Section id</param>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(List<EventAgendaDTO>), 200)]
|
||||||
|
[ProducesResponseType(typeof(string), 404)]
|
||||||
|
[ProducesResponseType(typeof(string), 500)]
|
||||||
|
[HttpGet("{sectionAgendaId}/events/upcoming")]
|
||||||
|
public ObjectResult GetUpcomingEventAgendas(string sectionAgendaId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SectionAgenda sectionAgenda = _myInfoMateDbContext.Sections.OfType<SectionAgenda>().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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create new event
|
/// Create new event
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using Hangfire;
|
||||||
using Manager.DTOs;
|
using Manager.DTOs;
|
||||||
using Manager.Helpers;
|
using Manager.Helpers;
|
||||||
using Manager.Interfaces.Models;
|
using Manager.Interfaces.Models;
|
||||||
@ -205,8 +206,12 @@ namespace ManagerService.Controllers
|
|||||||
(dto as AgendaDTO).events = eventAgendaDTOs;
|
(dto as AgendaDTO).events = eventAgendaDTOs;
|
||||||
break;
|
break;
|
||||||
case SectionType.Event:
|
case SectionType.Event:
|
||||||
var sectionEvent = _myInfoMateDbContext.Sections.OfType<SectionEvent>().Include(se => se.Programme).ThenInclude(se => se.MapAnnotations).FirstOrDefault(s => s.Id == id);
|
var sectionEvent = _myInfoMateDbContext.Sections.OfType<SectionEvent>()
|
||||||
|
.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).Programme = sectionEvent.Programme; // TODO test ! Need dto ?
|
||||||
|
(dto as SectionEventDTO).GlobalMapAnnotations = sectionEvent.GlobalMapAnnotations?.Select(ma => ma.ToDTO()).ToList() ?? new();
|
||||||
break;
|
break;
|
||||||
case SectionType.Game:
|
case SectionType.Game:
|
||||||
Resource resource = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == (dto as GameDTO).puzzleImageId);
|
Resource resource = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == (dto as GameDTO).puzzleImageId);
|
||||||
@ -367,12 +372,12 @@ namespace ManagerService.Controllers
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine("Aucune ville trouvée.");
|
Console.WriteLine("Aucune ville trouv<EFBFBD>e.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (HttpRequestException 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<EFBFBD>te HTTP : {e.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -382,7 +387,7 @@ namespace ManagerService.Controllers
|
|||||||
}
|
}
|
||||||
catch (Exception e)
|
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 <EFBFBD> jour des sections de type m<>t<EFBFBD>o : {e.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OkObjectResult(sectionsToReturn);
|
return new OkObjectResult(sectionsToReturn);
|
||||||
@ -684,13 +689,13 @@ namespace ManagerService.Controllers
|
|||||||
// UPDATE OTHER ORDER
|
// UPDATE OTHER ORDER
|
||||||
var sections = _myInfoMateDbContext.Sections.Where(s => s.ConfigurationId == newSection.configurationId && !s.IsSubSection).OrderBy(s => s.Order).ToList();
|
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<EFBFBD>plac<EFBFBD>e
|
||||||
sections.RemoveAll(q => q.Id == section.Id);
|
sections.RemoveAll(q => q.Id == section.Id);
|
||||||
|
|
||||||
// Insérer à la première position (déjà en 0-based)
|
// Ins<EFBFBD>rer <20> la premi<6D>re position (d<>j<EFBFBD> en 0-based)
|
||||||
sections.Insert(0, section);
|
sections.Insert(0, section);
|
||||||
|
|
||||||
// Réassigner les ordres en 0-based
|
// R<EFBFBD>assigner les ordres en 0-based
|
||||||
for (int i = 0; i < sections.Count; i++)
|
for (int i = 0; i < sections.Count; i++)
|
||||||
{
|
{
|
||||||
sections[i].Order = i;
|
sections[i].Order = i;
|
||||||
@ -787,7 +792,7 @@ namespace ManagerService.Controllers
|
|||||||
if (jsonElement.ValueKind == JsonValueKind.Null)
|
if (jsonElement.ValueKind == JsonValueKind.Null)
|
||||||
throw new ArgumentNullException("Section param is null");
|
throw new ArgumentNullException("Section param is null");
|
||||||
|
|
||||||
// Désérialisation de jsonElement en SectionDTO
|
// D<EFBFBD>s<EFBFBD>rialisation de jsonElement en SectionDTO
|
||||||
sectionDTO = JsonConvert.DeserializeObject<SectionDTO>(jsonElement.ToString());
|
sectionDTO = JsonConvert.DeserializeObject<SectionDTO>(jsonElement.ToString());
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@ -808,15 +813,15 @@ namespace ManagerService.Controllers
|
|||||||
// If subsection, check if order changed
|
// If subsection, check if order changed
|
||||||
var subSections = _myInfoMateDbContext.Sections.Where(s => s.ParentId == existingSection.ParentId).OrderBy(s => s.Order).ToList();
|
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<EFBFBD>plac<EFBFBD>e
|
||||||
subSections.RemoveAll(q => q.Id == existingSection.Id);
|
subSections.RemoveAll(q => q.Id == existingSection.Id);
|
||||||
|
|
||||||
// Insérer à la nouvelle position (déjà en 0-based)
|
// Ins<EFBFBD>rer <20> la nouvelle position (d<>j<EFBFBD> en 0-based)
|
||||||
int newIndex = sectionDTO.order.Value;
|
int newIndex = sectionDTO.order.Value;
|
||||||
newIndex = Math.Clamp(newIndex, 0, subSections.Count);
|
newIndex = Math.Clamp(newIndex, 0, subSections.Count);
|
||||||
subSections.Insert(newIndex, existingSection);
|
subSections.Insert(newIndex, existingSection);
|
||||||
|
|
||||||
// Réassigner les ordres en 0-based
|
// R<EFBFBD>assigner les ordres en 0-based
|
||||||
for (int i = 0; i < subSections.Count; i++)
|
for (int i = 0; i < subSections.Count; i++)
|
||||||
{
|
{
|
||||||
subSections[i].Order = i;
|
subSections[i].Order = i;
|
||||||
@ -832,6 +837,13 @@ namespace ManagerService.Controllers
|
|||||||
_myInfoMateDbContext.SaveChanges();
|
_myInfoMateDbContext.SaveChanges();
|
||||||
|
|
||||||
MqttClientService.PublishMessage($"config/{existingSection.ConfigurationId}", JsonConvert.SerializeObject(new PlayerMessageDTO() { configChanged = true }));
|
MqttClientService.PublishMessage($"config/{existingSection.ConfigurationId}", JsonConvert.SerializeObject(new PlayerMessageDTO() { configChanged = true }));
|
||||||
|
|
||||||
|
if (existingSection.Type == SectionType.Agenda)
|
||||||
|
{
|
||||||
|
var sa = _myInfoMateDbContext.Sections.OfType<SectionAgenda>().FirstOrDefault(s => s.Id == existingSection.Id);
|
||||||
|
if (sa?.IsOnlineAgenda == true && sa.AgendaResourceIds?.Count > 0)
|
||||||
|
BackgroundJob.Enqueue<AgendaSyncService>(s => s.SyncSectionAsync(sa.Id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OkObjectResult(SectionFactory.ToDTO(existingSection));
|
return new OkObjectResult(SectionFactory.ToDTO(existingSection));
|
||||||
|
|||||||
@ -381,5 +381,87 @@ namespace ManagerService.Controllers
|
|||||||
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all global map annotations from a section event
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sectionEventId">Section event id</param>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(List<MapAnnotationDTO>), 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<SectionEvent>()
|
||||||
|
.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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new global map annotation on a section event
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sectionEventId">Section event id</param>
|
||||||
|
/// <param name="mapAnnotationDTO">Map annotation</param>
|
||||||
|
[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<SectionEvent>()
|
||||||
|
.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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -448,6 +448,7 @@ namespace ManagerService.Controllers
|
|||||||
existingGuidedPath.Description = guidedPathDTO.description ?? new List<TranslationDTO>();
|
existingGuidedPath.Description = guidedPathDTO.description ?? new List<TranslationDTO>();
|
||||||
existingGuidedPath.SectionMapId = guidedPathDTO.sectionMapId;
|
existingGuidedPath.SectionMapId = guidedPathDTO.sectionMapId;
|
||||||
existingGuidedPath.SectionEventId = guidedPathDTO.sectionEventId;
|
existingGuidedPath.SectionEventId = guidedPathDTO.sectionEventId;
|
||||||
|
existingGuidedPath.SectionGameId = guidedPathDTO.sectionGameId;
|
||||||
existingGuidedPath.IsLinear = guidedPathDTO.isLinear;
|
existingGuidedPath.IsLinear = guidedPathDTO.isLinear;
|
||||||
existingGuidedPath.RequireSuccessToAdvance = guidedPathDTO.requireSuccessToAdvance;
|
existingGuidedPath.RequireSuccessToAdvance = guidedPathDTO.requireSuccessToAdvance;
|
||||||
existingGuidedPath.HideNextStepsUntilComplete = guidedPathDTO.hideNextStepsUntilComplete;
|
existingGuidedPath.HideNextStepsUntilComplete = guidedPathDTO.hideNextStepsUntilComplete;
|
||||||
@ -658,7 +659,7 @@ namespace ManagerService.Controllers
|
|||||||
existingGuidedStep.ImageUrl = guidedStepDTO.imageUrl;
|
existingGuidedStep.ImageUrl = guidedStepDTO.imageUrl;
|
||||||
existingGuidedStep.TriggerGeoPointId = guidedStepDTO.triggerGeoPointId;
|
existingGuidedStep.TriggerGeoPointId = guidedStepDTO.triggerGeoPointId;
|
||||||
existingGuidedStep.IsHiddenInitially = guidedStepDTO.isHiddenInitially;
|
existingGuidedStep.IsHiddenInitially = guidedStepDTO.isHiddenInitially;
|
||||||
existingGuidedStep.QuizQuestions = guidedStepDTO.quizQuestions; // à convertir si besoin ?
|
existingGuidedStep.QuizQuestions = guidedStepDTO.quizQuestions; // <EFBFBD> convertir si besoin ?
|
||||||
existingGuidedStep.IsStepTimer = guidedStepDTO.isStepTimer;
|
existingGuidedStep.IsStepTimer = guidedStepDTO.isStepTimer;
|
||||||
existingGuidedStep.IsStepLocked = guidedStepDTO.isStepLocked;
|
existingGuidedStep.IsStepLocked = guidedStepDTO.isStepLocked;
|
||||||
existingGuidedStep.TimerSeconds = guidedStepDTO.timerSeconds;
|
existingGuidedStep.TimerSeconds = guidedStepDTO.timerSeconds;
|
||||||
|
|||||||
@ -263,6 +263,59 @@ namespace ManagerService.Controllers
|
|||||||
})
|
})
|
||||||
.ToList();
|
.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<string, (string title, int taps)>();
|
||||||
|
foreach (var ev in menuEvents)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = JsonSerializer.Deserialize<JsonElement>(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<JsonElement>(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);
|
return Ok(summary);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@ -31,6 +31,7 @@ namespace ManagerService.DTOs
|
|||||||
public string SectionId { get; set; }
|
public string SectionId { get; set; }
|
||||||
public string SectionTitle { get; set; }
|
public string SectionTitle { get; set; }
|
||||||
public string SectionType { get; set; }
|
public string SectionType { get; set; }
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AiChatResponse
|
public class AiChatResponse
|
||||||
|
|||||||
@ -26,6 +26,10 @@ namespace ManagerService.DTOs
|
|||||||
|
|
||||||
public ResourceDTO resource { get; set; } // Background image
|
public ResourceDTO resource { get; set; } // Background image
|
||||||
|
|
||||||
|
public string? videoResourceId { get; set; }
|
||||||
|
|
||||||
|
public ResourceDTO? videoResource { get; set; }
|
||||||
|
|
||||||
public EventAddressDTO address { get; set; }
|
public EventAddressDTO address { get; set; }
|
||||||
|
|
||||||
public string phone { get; set; }
|
public string phone { get; set; }
|
||||||
@ -36,6 +40,12 @@ namespace ManagerService.DTOs
|
|||||||
|
|
||||||
public string sectionEventId { get; set; }
|
public string sectionEventId { get; set; }
|
||||||
|
|
||||||
|
public bool isSynced { get; set; }
|
||||||
|
|
||||||
|
public string? idVideoYoutube { get; set; }
|
||||||
|
|
||||||
|
public string? videoLink { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class EventAddressDTO
|
public class EventAddressDTO
|
||||||
|
|||||||
96
ManagerService/DTOs/RemoteEventAgendaDTO.cs
Normal file
96
ManagerService/DTOs/RemoteEventAgendaDTO.cs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace ManagerService.DTOs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class RemoteAgendaJsonSettings
|
||||||
|
{
|
||||||
|
public static readonly JsonSerializerSettings Lenient = new JsonSerializerSettings
|
||||||
|
{
|
||||||
|
Error = (_, args) => { args.ErrorContext.Handled = true; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts boolean false (PHP "no value" pattern) or null to null for object-typed fields.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specialized DTO for parsing the simplified, localized JSON used by online agendas.
|
||||||
|
/// Matches the structure used in the tablet-app (snake_case).
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,9 @@ namespace ManagerService.DTOs
|
|||||||
public List<AgendaEventStatDTO> TopAgendaEvents { get; set; } = new();
|
public List<AgendaEventStatDTO> TopAgendaEvents { get; set; } = new();
|
||||||
public List<QuizStatDTO> QuizStats { get; set; } = new();
|
public List<QuizStatDTO> QuizStats { get; set; } = new();
|
||||||
public List<GameStatDTO> GameStats { get; set; } = new();
|
public List<GameStatDTO> GameStats { get; set; } = new();
|
||||||
|
public List<ArticleStatDTO> TopArticles { get; set; } = new();
|
||||||
|
public List<MenuItemStatDTO> TopMenuItems { get; set; } = new();
|
||||||
|
public QrScanStatDTO QrScans { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SectionStatDTO
|
public class SectionStatDTO
|
||||||
@ -63,4 +66,24 @@ namespace ManagerService.DTOs
|
|||||||
public int Completions { get; set; }
|
public int Completions { get; set; }
|
||||||
public int AvgDurationSeconds { 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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ namespace Manager.DTOs
|
|||||||
{
|
{
|
||||||
public class MapDTO : SectionDTO
|
public class MapDTO : SectionDTO
|
||||||
{
|
{
|
||||||
|
public bool isListViewEnabled { get; set; } = false;
|
||||||
public int? zoom { get; set; } // Default = 18
|
public int? zoom { get; set; } // Default = 18
|
||||||
public MapTypeApp? mapType { get; set; } // Default = Hybrid for Google
|
public MapTypeApp? mapType { get; set; } // Default = Hybrid for Google
|
||||||
public MapTypeMapBox? mapTypeMapbox { get; set; } // Default = standard for MapBox
|
public MapTypeMapBox? mapTypeMapbox { get; set; } // Default = standard for MapBox
|
||||||
|
|||||||
@ -9,7 +9,9 @@ namespace Manager.DTOs
|
|||||||
{
|
{
|
||||||
public DateTime StartDate { get; set; }
|
public DateTime StartDate { get; set; }
|
||||||
public DateTime EndDate { get; set; }
|
public DateTime EndDate { get; set; }
|
||||||
|
public string? BaseSectionMapId { get; set; }
|
||||||
public List<string> ParcoursIds { get; set; }
|
public List<string> ParcoursIds { get; set; }
|
||||||
|
public List<MapAnnotationDTO> GlobalMapAnnotations { get; set; } = new();
|
||||||
public List<ProgrammeBlock> Programme { get; set; } = new();
|
public List<ProgrammeBlock> Programme { get; set; } = new();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -222,6 +222,22 @@ namespace ManagerService.Data
|
|||||||
.HasConversion(
|
.HasConversion(
|
||||||
v => JsonSerializer.Serialize(v, options),
|
v => JsonSerializer.Serialize(v, options),
|
||||||
v => JsonSerializer.Deserialize<EventAddress>(v, options));
|
v => JsonSerializer.Deserialize<EventAddress>(v, options));
|
||||||
|
|
||||||
|
// SectionEvent: link to base SectionMap
|
||||||
|
modelBuilder.Entity<SectionEvent>()
|
||||||
|
.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<MapAnnotation>()
|
||||||
|
.HasOne<SectionEvent>()
|
||||||
|
.WithMany(se => se.GlobalMapAnnotations)
|
||||||
|
.HasForeignKey(ma => ma.SectionEventId)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,11 @@ namespace ManagerService.Data.SubSection
|
|||||||
|
|
||||||
public Resource Resource { get; set; } // Background image
|
public Resource Resource { get; set; } // Background image
|
||||||
|
|
||||||
|
public string? VideoResourceId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey("VideoResourceId")]
|
||||||
|
public Resource? VideoResource { get; set; }
|
||||||
|
|
||||||
[Column(TypeName = "jsonb")]
|
[Column(TypeName = "jsonb")]
|
||||||
public EventAddress Address { get; set; }
|
public EventAddress Address { get; set; }
|
||||||
|
|
||||||
@ -45,6 +50,12 @@ namespace ManagerService.Data.SubSection
|
|||||||
|
|
||||||
public string Email { get; set; }
|
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; }
|
public string SectionAgendaId { get; set; }
|
||||||
|
|
||||||
[ForeignKey("SectionAgendaId")]
|
[ForeignKey("SectionAgendaId")]
|
||||||
@ -69,6 +80,8 @@ namespace ManagerService.Data.SubSection
|
|||||||
website = Website,
|
website = Website,
|
||||||
resourceId = ResourceId,
|
resourceId = ResourceId,
|
||||||
resource = Resource?.ToDTO(),
|
resource = Resource?.ToDTO(),
|
||||||
|
videoResourceId = VideoResourceId,
|
||||||
|
videoResource = VideoResource?.ToDTO(),
|
||||||
address = Address != null ? new EventAddressDTO
|
address = Address != null ? new EventAddressDTO
|
||||||
{
|
{
|
||||||
address = Address.Address,
|
address = Address.Address,
|
||||||
@ -84,7 +97,10 @@ namespace ManagerService.Data.SubSection
|
|||||||
} : null,
|
} : null,
|
||||||
phone = Phone,
|
phone = Phone,
|
||||||
email = Email,
|
email = Email,
|
||||||
sectionAgendaId = SectionAgendaId
|
sectionAgendaId = SectionAgendaId,
|
||||||
|
isSynced = IsSynced,
|
||||||
|
idVideoYoutube = IdVideoYoutube,
|
||||||
|
videoLink = VideoLink
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,6 +114,7 @@ namespace ManagerService.Data.SubSection
|
|||||||
DateTo = dto.dateTo;
|
DateTo = dto.dateTo;
|
||||||
Website = dto.website;
|
Website = dto.website;
|
||||||
ResourceId = dto.resourceId;
|
ResourceId = dto.resourceId;
|
||||||
|
VideoResourceId = dto.videoResourceId;
|
||||||
//Resource = dto.Resource != null ? Resource.From(dto.Resource) : null,
|
//Resource = dto.Resource != null ? Resource.From(dto.Resource) : null,
|
||||||
Address = dto.address != null ? new EventAddress
|
Address = dto.address != null ? new EventAddress
|
||||||
{
|
{
|
||||||
@ -114,6 +131,9 @@ namespace ManagerService.Data.SubSection
|
|||||||
} : null;
|
} : null;
|
||||||
Phone = dto.phone;
|
Phone = dto.phone;
|
||||||
Email = dto.email;
|
Email = dto.email;
|
||||||
|
IsSynced = false;
|
||||||
|
IdVideoYoutube = dto.idVideoYoutube;
|
||||||
|
VideoLink = dto.videoLink;
|
||||||
SectionAgendaId = dto.sectionAgendaId;
|
SectionAgendaId = dto.sectionAgendaId;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@ -128,6 +148,7 @@ namespace ManagerService.Data.SubSection
|
|||||||
DateTo = dto.dateTo;
|
DateTo = dto.dateTo;
|
||||||
Website = dto.website;
|
Website = dto.website;
|
||||||
ResourceId = dto.resourceId;
|
ResourceId = dto.resourceId;
|
||||||
|
VideoResourceId = dto.videoResourceId;
|
||||||
Address = dto.address != null ? new EventAddress
|
Address = dto.address != null ? new EventAddress
|
||||||
{
|
{
|
||||||
Address = dto.address.address,
|
Address = dto.address.address,
|
||||||
@ -143,6 +164,9 @@ namespace ManagerService.Data.SubSection
|
|||||||
} : null;
|
} : null;
|
||||||
Phone = dto.phone;
|
Phone = dto.phone;
|
||||||
Email = dto.email;
|
Email = dto.email;
|
||||||
|
IsSynced = false;
|
||||||
|
IdVideoYoutube = dto.idVideoYoutube;
|
||||||
|
VideoLink = dto.videoLink;
|
||||||
SectionAgendaId = dto.sectionAgendaId;
|
SectionAgendaId = dto.sectionAgendaId;
|
||||||
SectionEventId = dto.sectionEventId;
|
SectionEventId = dto.sectionEventId;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,10 @@ namespace ManagerService.Data.SubSection
|
|||||||
{
|
{
|
||||||
public DateTime StartDate { get; set; }
|
public DateTime StartDate { get; set; }
|
||||||
public DateTime EndDate { get; set; }
|
public DateTime EndDate { get; set; }
|
||||||
|
public string? BaseSectionMapId { get; set; }
|
||||||
|
[ForeignKey(nameof(BaseSectionMapId))]
|
||||||
|
public SectionMap? BaseMap { get; set; }
|
||||||
|
public List<MapAnnotation> GlobalMapAnnotations { get; set; } = new();
|
||||||
public List<ProgrammeBlock> Programme { get; set; } = new();
|
public List<ProgrammeBlock> Programme { get; set; } = new();
|
||||||
[Column(TypeName = "jsonb")]
|
[Column(TypeName = "jsonb")]
|
||||||
public List<string> ParcoursIds { get; set; } = new(); // Liens vers GeoPoints spécifiques
|
public List<string> ParcoursIds { get; set; } = new(); // Liens vers GeoPoints spécifiques
|
||||||
@ -62,6 +66,7 @@ namespace ManagerService.Data.SubSection
|
|||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public string Id { get; set; }
|
public string Id { get; set; }
|
||||||
|
public string? SectionEventId { get; set; } // FK for global event-level annotation (null = block annotation)
|
||||||
[Column(TypeName = "jsonb")]
|
[Column(TypeName = "jsonb")]
|
||||||
public List<TranslationDTO> Type { get; set; } // "first_aid", "parking", etc.
|
public List<TranslationDTO> Type { get; set; } // "first_aid", "parking", etc.
|
||||||
[Column(TypeName = "jsonb")]
|
[Column(TypeName = "jsonb")]
|
||||||
|
|||||||
@ -15,6 +15,7 @@ namespace ManagerService.Data.SubSection
|
|||||||
public class SectionMap : Section
|
public class SectionMap : Section
|
||||||
{
|
{
|
||||||
public int MapZoom { get; set; } // Default = 18
|
public int MapZoom { get; set; } // Default = 18
|
||||||
|
public bool IsListViewEnabled { get; set; } = false;
|
||||||
public MapTypeApp? MapMapType { get; set; } // Default = Hybrid for Google
|
public MapTypeApp? MapMapType { get; set; } // Default = Hybrid for Google
|
||||||
public MapTypeMapBox? MapTypeMapbox { get; set; } // Default = standard for MapBox
|
public MapTypeMapBox? MapTypeMapbox { get; set; } // Default = standard for MapBox
|
||||||
public MapProvider? MapMapProvider { get; set; } // Default = Google
|
public MapProvider? MapMapProvider { get; set; } // Default = Google
|
||||||
|
|||||||
@ -55,6 +55,7 @@ namespace ManagerService.Data
|
|||||||
GameComplete,
|
GameComplete,
|
||||||
AgendaEventTap,
|
AgendaEventTap,
|
||||||
MenuItemTap,
|
MenuItemTap,
|
||||||
AssistantMessage
|
AssistantMessage,
|
||||||
|
ArticleRead
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1418
ManagerService/Migrations/20260317120000_AddEventMapFeaturesAndListView.Designer.cs
generated
Normal file
1418
ManagerService/Migrations/20260317120000_AddEventMapFeaturesAndListView.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,94 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ManagerService.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEventMapFeaturesAndListView : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Add BaseSectionMapId to Sections table (SectionEvent → SectionMap link)
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
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<string>(
|
||||||
|
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<bool>(
|
||||||
|
name: "IsListViewEnabled",
|
||||||
|
table: "Sections",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: true,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1439
ManagerService/Migrations/20260325095228_AddVideoResourceToEventAgenda.Designer.cs
generated
Normal file
1439
ManagerService/Migrations/20260325095228_AddVideoResourceToEventAgenda.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,79 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ManagerService.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddVideoResourceToEventAgenda : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "IdVideoYoutube",
|
||||||
|
table: "EventAgendas",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsSynced",
|
||||||
|
table: "EventAgendas",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "VideoLink",
|
||||||
|
table: "EventAgendas",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -511,6 +511,12 @@ namespace ManagerService.Migrations
|
|||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("IdVideoYoutube")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSynced")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("Label")
|
b.Property<string>("Label")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("jsonb");
|
.HasColumnType("jsonb");
|
||||||
@ -530,6 +536,12 @@ namespace ManagerService.Migrations
|
|||||||
b.Property<string>("Type")
|
b.Property<string>("Type")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("VideoLink")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("VideoResourceId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Website")
|
b.Property<string>("Website")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@ -541,6 +553,8 @@ namespace ManagerService.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SectionEventId");
|
b.HasIndex("SectionEventId");
|
||||||
|
|
||||||
|
b.HasIndex("VideoResourceId");
|
||||||
|
|
||||||
b.ToTable("EventAgendas");
|
b.ToTable("EventAgendas");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -799,6 +813,9 @@ namespace ManagerService.Migrations
|
|||||||
b.Property<string>("ProgrammeBlockId")
|
b.Property<string>("ProgrammeBlockId")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("SectionEventId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<List<TranslationDTO>>("Type")
|
b.Property<List<TranslationDTO>>("Type")
|
||||||
.HasColumnType("jsonb");
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
@ -808,6 +825,8 @@ namespace ManagerService.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ProgrammeBlockId");
|
b.HasIndex("ProgrammeBlockId");
|
||||||
|
|
||||||
|
b.HasIndex("SectionEventId");
|
||||||
|
|
||||||
b.ToTable("MapAnnotations");
|
b.ToTable("MapAnnotations");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -969,6 +988,9 @@ namespace ManagerService.Migrations
|
|||||||
{
|
{
|
||||||
b.HasBaseType("ManagerService.Data.Section");
|
b.HasBaseType("ManagerService.Data.Section");
|
||||||
|
|
||||||
|
b.Property<string>("BaseSectionMapId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<DateTime>("EndDate")
|
b.Property<DateTime>("EndDate")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
@ -978,6 +1000,8 @@ namespace ManagerService.Migrations
|
|||||||
b.Property<DateTime>("StartDate")
|
b.Property<DateTime>("StartDate")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasIndex("BaseSectionMapId");
|
||||||
|
|
||||||
b.HasDiscriminator().HasValue("Event");
|
b.HasDiscriminator().HasValue("Event");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1014,6 +1038,9 @@ namespace ManagerService.Migrations
|
|||||||
{
|
{
|
||||||
b.HasBaseType("ManagerService.Data.Section");
|
b.HasBaseType("ManagerService.Data.Section");
|
||||||
|
|
||||||
|
b.Property<bool>("IsListViewEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<List<CategorieDTO>>("MapCategories")
|
b.Property<List<CategorieDTO>>("MapCategories")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("jsonb");
|
.HasColumnType("jsonb");
|
||||||
@ -1211,11 +1238,17 @@ namespace ManagerService.Migrations
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("SectionEventId");
|
.HasForeignKey("SectionEventId");
|
||||||
|
|
||||||
|
b.HasOne("ManagerService.Data.Resource", "VideoResource")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("VideoResourceId");
|
||||||
|
|
||||||
b.Navigation("Resource");
|
b.Navigation("Resource");
|
||||||
|
|
||||||
b.Navigation("SectionAgenda");
|
b.Navigation("SectionAgenda");
|
||||||
|
|
||||||
b.Navigation("SectionEvent");
|
b.Navigation("SectionEvent");
|
||||||
|
|
||||||
|
b.Navigation("VideoResource");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ManagerService.Data.SubSection.GeoPoint", b =>
|
modelBuilder.Entity("ManagerService.Data.SubSection.GeoPoint", b =>
|
||||||
@ -1308,6 +1341,11 @@ namespace ManagerService.Migrations
|
|||||||
.WithMany("MapAnnotations")
|
.WithMany("MapAnnotations")
|
||||||
.HasForeignKey("ProgrammeBlockId");
|
.HasForeignKey("ProgrammeBlockId");
|
||||||
|
|
||||||
|
b.HasOne("ManagerService.Data.SubSection.SectionEvent", null)
|
||||||
|
.WithMany("GlobalMapAnnotations")
|
||||||
|
.HasForeignKey("SectionEventId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
b.Navigation("IconResource");
|
b.Navigation("IconResource");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1318,6 +1356,16 @@ namespace ManagerService.Migrations
|
|||||||
.HasForeignKey("SectionEventId");
|
.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 =>
|
modelBuilder.Entity("ManagerService.Data.SubSection.SectionGame", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ManagerService.Data.Resource", "GamePuzzleImage")
|
b.HasOne("ManagerService.Data.Resource", "GamePuzzleImage")
|
||||||
@ -1363,6 +1411,8 @@ namespace ManagerService.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b =>
|
modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("GlobalMapAnnotations");
|
||||||
|
|
||||||
b.Navigation("Programme");
|
b.Navigation("Programme");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
134
ManagerService/Services/AgendaSyncService.cs
Normal file
134
ManagerService/Services/AgendaSyncService.cs
Normal file
@ -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<AgendaSyncService> _logger;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
public AgendaSyncService(ILogger<AgendaSyncService> 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<MyInfoMateDbContext>();
|
||||||
|
|
||||||
|
var sections = db.Sections.OfType<SectionAgenda>()
|
||||||
|
.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<MyInfoMateDbContext>();
|
||||||
|
|
||||||
|
var section = db.Sections.OfType<SectionAgenda>()
|
||||||
|
.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<RemoteEventAgendaDTO> remoteEvents;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await http.GetStringAsync(resource.Url);
|
||||||
|
remoteEvents = JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(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<TranslationDTO>(),
|
||||||
|
Description = new List<TranslationDTO>(),
|
||||||
|
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<TranslationDTO> 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 ?? "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,7 +23,7 @@ namespace ManagerService.Services
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new API key with a hashed secret (returned once in plain text).
|
/// Creates a new API key with a hashed secret (returned once in plain text).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> CreateAsync(string instanceId, string name, ApiKeyAppType appType)
|
public async Task<string> CreateAsync(string instanceId, string name, ApiKeyAppType appType, DateTime? dateExpiration = null)
|
||||||
{
|
{
|
||||||
var plainKey = "ak_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
var plainKey = "ak_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||||
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||||
@ -40,6 +40,7 @@ namespace ManagerService.Services
|
|||||||
KeyHash = keyHash,
|
KeyHash = keyHash,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
DateCreation = DateTime.UtcNow,
|
DateCreation = DateTime.UtcNow,
|
||||||
|
DateExpiration = dateExpiration.HasValue ? DateTime.SpecifyKind(dateExpiration.Value, DateTimeKind.Utc) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.ApiKeys.Add(apiKey);
|
_db.ApiKeys.Add(apiKey);
|
||||||
|
|||||||
@ -3,7 +3,13 @@ using ManagerService.DTOs;
|
|||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Manager.DTOs;
|
||||||
|
using ManagerService.Data.SubSection;
|
||||||
|
|
||||||
namespace ManagerService.Services
|
namespace ManagerService.Services
|
||||||
{
|
{
|
||||||
@ -20,10 +26,38 @@ namespace ManagerService.Services
|
|||||||
_context = context;
|
_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<AiChatResponse> ChatAsync(AiChatRequest request)
|
public async Task<AiChatResponse> ChatAsync(AiChatRequest request)
|
||||||
{
|
{
|
||||||
var messages = new List<ChatMessage>();
|
var messages = new List<ChatMessage>();
|
||||||
ChatOptions options;
|
ChatOptions options;
|
||||||
|
var (weekendSat, weekendSun) = GetCurrentWeekend();
|
||||||
|
|
||||||
if (request.ConfigurationId != null)
|
if (request.ConfigurationId != null)
|
||||||
{
|
{
|
||||||
@ -33,24 +67,31 @@ namespace ManagerService.Services
|
|||||||
|
|
||||||
var sectionsSummary = string.Join("\n", sections.Select(s =>
|
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.Title?.FirstOrDefault()?.value
|
||||||
?? s.Label;
|
?? s.Label);
|
||||||
return $"- id:{s.Id} | type:{s.Type} | titre:\"{title}\"";
|
return $"- id:{s.Id} | type:{s.Type} | titre:\"{title}\"";
|
||||||
}));
|
}));
|
||||||
|
|
||||||
messages.Add(new ChatMessage(ChatRole.System, $"""
|
messages.Add(new ChatMessage(ChatRole.System, $"""
|
||||||
Tu es l'assistant de visite de "{config?.Label ?? "cette application"}".
|
Tu es l'assistant de visite de l'application "{config?.Label ?? "cette application"}".
|
||||||
Tu réponds en {request.Language}. Tu es chaleureux, concis et utile.
|
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}
|
{sectionsSummary}
|
||||||
|
|
||||||
Pour obtenir le détail d'une section spécifique, utilise l'outil GetSectionDetail.
|
RÈGLES STRICTES — tu dois les respecter sans exception :
|
||||||
Pour afficher une liste d'éléments (événements, activités...) de façon visuelle, appelle show_cards avec les titres et sous-titres.
|
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.
|
||||||
Pour proposer à l'utilisateur d'aller directement dans une section, appelle navigate_to_section.
|
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.
|
||||||
Accompagne toujours tes outils d'un texte explicatif.
|
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.
|
||||||
Ne parle jamais de sections qui ne sont pas dans cette liste.
|
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))
|
foreach (var h in request.History.TakeLast(MaxHistoryMessages))
|
||||||
@ -61,31 +102,199 @@ namespace ManagerService.Services
|
|||||||
NavigationActionDTO? navigation = null;
|
NavigationActionDTO? navigation = null;
|
||||||
List<AiCardDTO>? cards = null;
|
List<AiCardDTO>? cards = null;
|
||||||
|
|
||||||
// Outil scopé : vérifie que la section appartient bien à cette configuration
|
|
||||||
var scopedSections = sections;
|
|
||||||
var tools = new List<AITool>
|
var tools = new List<AITool>
|
||||||
{
|
{
|
||||||
AIFunctionFactory.Create(
|
AIFunctionFactory.Create(
|
||||||
(string sectionId) =>
|
(string sectionId) =>
|
||||||
{
|
{
|
||||||
var section = scopedSections.FirstOrDefault(s => s.Id == sectionId);
|
var section = sections.FirstOrDefault(s => s.Id == sectionId);
|
||||||
if (section == null) return "Section non trouvée dans cette application.";
|
if (section == null) return "Section non trouvée.";
|
||||||
|
var title = section.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? section.Label;
|
||||||
var title = section.Title?.FirstOrDefault(t => t.language == request.Language)?.value
|
var desc = section.Description?.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
|
|
||||||
?? "";
|
|
||||||
return $"Titre: {title}\nDescription: {desc}\nType: {section.Type}";
|
return $"Titre: {title}\nDescription: {desc}\nType: {section.Type}";
|
||||||
},
|
},
|
||||||
"GetSectionDetail",
|
"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<ManagerService.Data.SubSection.SectionAgenda>()
|
||||||
|
.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<List<RemoteEventAgendaDTO>>(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<ManagerService.Data.SubSection.SectionMap>()
|
||||||
|
.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<SectionAgenda>().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<List<RemoteEventAgendaDTO>>(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<ManagerService.Data.SubSection.SectionMap>()
|
||||||
|
.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(
|
AIFunctionFactory.Create(
|
||||||
(string sectionId, string sectionTitle, string sectionType) =>
|
(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.");
|
return Task.FromResult("Navigation proposée à l'utilisateur.");
|
||||||
},
|
},
|
||||||
"navigate_to_section",
|
"navigate_to_section",
|
||||||
@ -107,33 +316,65 @@ namespace ManagerService.Services
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
options = new ChatOptions { Tools = tools };
|
options = new ChatOptions { Tools = tools };
|
||||||
var response = await _chatClient.GetResponseAsync(messages, options);
|
var response = await _chatClient.GetResponseAsync(messages, options);
|
||||||
return new AiChatResponse { Reply = response.Text ?? "", Cards = cards, Navigation = navigation };
|
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
|
else
|
||||||
{
|
{
|
||||||
// Scope instance : l'IA connaît les configurations disponibles
|
// Scope instance : l'IA connaît les configurations disponibles pour cette application spécifiquement (AppType)
|
||||||
var configurations = _context.Configurations.Where(c => c.InstanceId == request.InstanceId).ToList();
|
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 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.Title?.FirstOrDefault()?.value
|
||||||
?? c.Label;
|
?? c.Label);
|
||||||
return $"- id:{c.Id} | titre:\"{title}\"";
|
|
||||||
|
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, $"""
|
messages.Add(new ChatMessage(ChatRole.System, $"""
|
||||||
Tu es l'assistant de visite.
|
Tu es l'assistant de visite principal. Ton rôle est d'orienter l'utilisateur vers la bonne expérience de visite.
|
||||||
Tu réponds en {request.Language}. Tu es chaleureux, concis et utile.
|
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}
|
{configsSummary}
|
||||||
|
|
||||||
Aide le visiteur à trouver ce qui l'intéresse parmi ces expériences.
|
RÈGLES STRICTES — tu dois les respecter sans exception :
|
||||||
Pour orienter le visiteur vers une visite spécifique, appelle navigate_to_configuration.
|
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.
|
||||||
Accompagne toujours tes outils d'un texte explicatif.
|
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))
|
foreach (var h in request.History.TakeLast(MaxHistoryMessages))
|
||||||
@ -145,10 +386,192 @@ namespace ManagerService.Services
|
|||||||
|
|
||||||
var tools = new List<AITool>
|
var tools = new List<AITool>
|
||||||
{
|
{
|
||||||
|
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<ManagerService.Data.SubSection.SectionAgenda>()
|
||||||
|
.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<List<RemoteEventAgendaDTO>>(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<ManagerService.Data.SubSection.SectionMap>()
|
||||||
|
.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<SectionAgenda>().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<List<RemoteEventAgendaDTO>>(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<ManagerService.Data.SubSection.SectionMap>()
|
||||||
|
.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(
|
AIFunctionFactory.Create(
|
||||||
(string configurationId, string configurationTitle) =>
|
(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.");
|
return Task.FromResult("Navigation vers la visite proposée.");
|
||||||
},
|
},
|
||||||
"navigate_to_configuration",
|
"navigate_to_configuration",
|
||||||
@ -156,10 +579,18 @@ namespace ManagerService.Services
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
options = new ChatOptions { Tools = tools };
|
options = new ChatOptions { Tools = tools };
|
||||||
var response = await _chatClient.GetResponseAsync(messages, options);
|
var response = await _chatClient.GetResponseAsync(messages, options);
|
||||||
return new AiChatResponse { Reply = response.Text ?? "", Navigation = navigation };
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ using ManagerService.Data.SubSection;
|
|||||||
using ManagerService.DTOs;
|
using ManagerService.DTOs;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace ManagerService.Services
|
namespace ManagerService.Services
|
||||||
@ -141,6 +142,7 @@ namespace ManagerService.Services
|
|||||||
StartDate = sectionEventDTO.StartDate,
|
StartDate = sectionEventDTO.StartDate,
|
||||||
EndDate = sectionEventDTO.EndDate,
|
EndDate = sectionEventDTO.EndDate,
|
||||||
ParcoursIds = sectionEventDTO.ParcoursIds,
|
ParcoursIds = sectionEventDTO.ParcoursIds,
|
||||||
|
BaseSectionMapId = sectionEventDTO.BaseSectionMapId,
|
||||||
//Programmes = // TODO specific
|
//Programmes = // TODO specific
|
||||||
},
|
},
|
||||||
SectionType.Map => new SectionMap
|
SectionType.Map => new SectionMap
|
||||||
@ -170,7 +172,8 @@ namespace ManagerService.Services
|
|||||||
MapResourceId = mapDTO.iconResourceId,
|
MapResourceId = mapDTO.iconResourceId,
|
||||||
MapCenterLatitude = mapDTO.centerLatitude,
|
MapCenterLatitude = mapDTO.centerLatitude,
|
||||||
MapCenterLongitude = mapDTO.centerLongitude,
|
MapCenterLongitude = mapDTO.centerLongitude,
|
||||||
MapCategories = mapDTO.categories
|
MapCategories = mapDTO.categories,
|
||||||
|
IsListViewEnabled = mapDTO.isListViewEnabled
|
||||||
},
|
},
|
||||||
SectionType.Menu => new SectionMenu
|
SectionType.Menu => new SectionMenu
|
||||||
{
|
{
|
||||||
@ -443,6 +446,8 @@ namespace ManagerService.Services
|
|||||||
StartDate = sectionEvent.StartDate,
|
StartDate = sectionEvent.StartDate,
|
||||||
EndDate = sectionEvent.EndDate,
|
EndDate = sectionEvent.EndDate,
|
||||||
ParcoursIds = sectionEvent.ParcoursIds,
|
ParcoursIds = sectionEvent.ParcoursIds,
|
||||||
|
BaseSectionMapId = sectionEvent.BaseSectionMapId,
|
||||||
|
GlobalMapAnnotations = sectionEvent.GlobalMapAnnotations?.Select(ma => ma.ToDTO()).ToList() ?? new(),
|
||||||
// Programme TODO specific
|
// Programme TODO specific
|
||||||
},
|
},
|
||||||
SectionMap map => new MapDTO
|
SectionMap map => new MapDTO
|
||||||
@ -473,6 +478,7 @@ namespace ManagerService.Services
|
|||||||
centerLatitude = map.MapCenterLatitude,
|
centerLatitude = map.MapCenterLatitude,
|
||||||
centerLongitude = map.MapCenterLongitude,
|
centerLongitude = map.MapCenterLongitude,
|
||||||
categories = map.MapCategories,
|
categories = map.MapCategories,
|
||||||
|
isListViewEnabled = map.IsListViewEnabled,
|
||||||
//points = null // map.MapPoints // TODO specific
|
//points = null // map.MapPoints // TODO specific
|
||||||
},
|
},
|
||||||
SectionMenu menu => new MenuDTO
|
SectionMenu menu => new MenuDTO
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
using FirebaseAdmin;
|
||||||
|
using Google.Apis.Auth.OAuth2;
|
||||||
|
using Hangfire;
|
||||||
|
using Hangfire.PostgreSql;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using OpenAI;
|
using OpenAI;
|
||||||
using System.ClientModel;
|
using System.ClientModel;
|
||||||
@ -116,6 +120,7 @@ namespace ManagerService
|
|||||||
{
|
{
|
||||||
options.AddPolicy(policy.Name, policyAdmin =>
|
options.AddPolicy(policy.Name, policyAdmin =>
|
||||||
{
|
{
|
||||||
|
policyAdmin.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, "ApiKey");
|
||||||
foreach (var claim in policy.Claims)
|
foreach (var claim in policy.Claims)
|
||||||
policyAdmin.RequireClaim(ManagerService.Service.Security.ClaimTypes.Permission, claim);
|
policyAdmin.RequireClaim(ManagerService.Service.Security.ClaimTypes.Permission, claim);
|
||||||
});
|
});
|
||||||
@ -173,12 +178,35 @@ namespace ManagerService
|
|||||||
new OpenAIClient(
|
new OpenAIClient(
|
||||||
new ApiKeyCredential(Configuration["AI:ApiKey"]!),
|
new ApiKeyCredential(Configuration["AI:ApiKey"]!),
|
||||||
new OpenAIClientOptions { Endpoint = new Uri("https://generativelanguage.googleapis.com/v1beta/openai/") }
|
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<AssistantService>();
|
services.AddScoped<AssistantService>();
|
||||||
|
|
||||||
|
// Push Notifications
|
||||||
|
var firebaseCredentialsPath = Configuration["Firebase:CredentialsPath"];
|
||||||
|
if (!string.IsNullOrEmpty(firebaseCredentialsPath) && FirebaseApp.DefaultInstance == null)
|
||||||
|
{
|
||||||
|
FirebaseApp.Create(new AppOptions
|
||||||
|
{
|
||||||
|
Credential = GoogleCredential.FromFile(firebaseCredentialsPath)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
services.AddSingleton<NotificationService>();
|
||||||
|
services.AddHttpClient();
|
||||||
|
services.AddScoped<AgendaSyncService>();
|
||||||
|
|
||||||
var connectionString = Configuration.GetConnectionString("PostgresConnection");
|
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);
|
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
|
||||||
dataSourceBuilder.UseNetTopologySuite();
|
dataSourceBuilder.UseNetTopologySuite();
|
||||||
dataSourceBuilder.EnableDynamicJson();
|
dataSourceBuilder.EnableDynamicJson();
|
||||||
@ -215,7 +243,7 @@ namespace ManagerService
|
|||||||
app.UseCors(
|
app.UseCors(
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
options => options
|
options => options
|
||||||
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "http://localhost:50479")
|
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "http://localhost:9090")
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowCredentials()
|
.AllowCredentials()
|
||||||
@ -228,6 +256,16 @@ namespace ManagerService
|
|||||||
#endif
|
#endif
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||||
|
{
|
||||||
|
Authorization = new[] { new HangfireDashboardAuthorizationFilter() }
|
||||||
|
});
|
||||||
|
|
||||||
|
RecurringJob.AddOrUpdate<AgendaSyncService>(
|
||||||
|
"agenda-sync-daily",
|
||||||
|
s => s.SyncAllAsync(),
|
||||||
|
Cron.Daily());
|
||||||
|
|
||||||
app.UseEndpoints(endpoints =>
|
app.UseEndpoints(endpoints =>
|
||||||
{
|
{
|
||||||
endpoints.MapControllers();
|
endpoints.MapControllers();
|
||||||
|
|||||||
@ -31,7 +31,10 @@
|
|||||||
"SupportedLanguages": [ "FR", "NL", "EN", "DE", "IT", "ES", "PL", "CN", "AR", "UK" ],
|
"SupportedLanguages": [ "FR", "NL", "EN", "DE", "IT", "ES", "PL", "CN", "AR", "UK" ],
|
||||||
"OpenWeatherApiKey": "d489973b4c09ddc5fb56bd7b9270bbef",
|
"OpenWeatherApiKey": "d489973b4c09ddc5fb56bd7b9270bbef",
|
||||||
"AI": {
|
"AI": {
|
||||||
"ApiKey": "AIzaSyC7lJ8w1eQL4aZGNFLMabig5ul6yn66nug"
|
"ApiKey": "AIzaSyCIf-mzp4Nzm5VwHL7LLzitt9z_bOOGMwc"
|
||||||
|
},
|
||||||
|
"Firebase": {
|
||||||
|
"CredentialsPath": "firebase-adminsdk.json"
|
||||||
}
|
}
|
||||||
//"Urls": "http://[::]:80"
|
//"Urls": "http://[::]:80"
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user