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
|
||||
ManagerService/bin
|
||||
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))
|
||||
return Forbid();
|
||||
|
||||
var plainKey = await _apiKeyService.CreateAsync(instanceId, request.Name, request.AppType);
|
||||
var plainKey = await _apiKeyService.CreateAsync(instanceId, request.Name, request.AppType, request.DateExpiration);
|
||||
return Ok(new { key = plainKey });
|
||||
}
|
||||
|
||||
@ -64,5 +64,6 @@ namespace ManagerService.Controllers
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public ApiKeyAppType AppType { get; set; }
|
||||
public DateTime? DateExpiration { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
/// Create new event
|
||||
/// </summary>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using Hangfire;
|
||||
using Manager.DTOs;
|
||||
using Manager.Helpers;
|
||||
using Manager.Interfaces.Models;
|
||||
@ -205,8 +206,12 @@ namespace ManagerService.Controllers
|
||||
(dto as AgendaDTO).events = eventAgendaDTOs;
|
||||
break;
|
||||
case SectionType.Event:
|
||||
var sectionEvent = _myInfoMateDbContext.Sections.OfType<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).GlobalMapAnnotations = sectionEvent.GlobalMapAnnotations?.Select(ma => ma.ToDTO()).ToList() ?? new();
|
||||
break;
|
||||
case SectionType.Game:
|
||||
Resource resource = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == (dto as GameDTO).puzzleImageId);
|
||||
@ -367,12 +372,12 @@ namespace ManagerService.Controllers
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Aucune ville trouvée.");
|
||||
Console.WriteLine("Aucune ville trouv<EFBFBD>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)
|
||||
{
|
||||
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);
|
||||
@ -684,13 +689,13 @@ namespace ManagerService.Controllers
|
||||
// UPDATE OTHER ORDER
|
||||
var sections = _myInfoMateDbContext.Sections.Where(s => s.ConfigurationId == newSection.configurationId && !s.IsSubSection).OrderBy(s => s.Order).ToList();
|
||||
|
||||
// Retirer la question déplacée
|
||||
// Retirer la question d<EFBFBD>plac<EFBFBD>e
|
||||
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);
|
||||
|
||||
// Réassigner les ordres en 0-based
|
||||
// R<EFBFBD>assigner les ordres en 0-based
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
{
|
||||
sections[i].Order = i;
|
||||
@ -787,7 +792,7 @@ namespace ManagerService.Controllers
|
||||
if (jsonElement.ValueKind == JsonValueKind.Null)
|
||||
throw new ArgumentNullException("Section param is null");
|
||||
|
||||
// Désérialisation de jsonElement en SectionDTO
|
||||
// D<EFBFBD>s<EFBFBD>rialisation de jsonElement en SectionDTO
|
||||
sectionDTO = JsonConvert.DeserializeObject<SectionDTO>(jsonElement.ToString());
|
||||
}
|
||||
else
|
||||
@ -808,15 +813,15 @@ namespace ManagerService.Controllers
|
||||
// If subsection, check if order changed
|
||||
var subSections = _myInfoMateDbContext.Sections.Where(s => s.ParentId == existingSection.ParentId).OrderBy(s => s.Order).ToList();
|
||||
|
||||
// Retirer la sous section déplacée
|
||||
// Retirer la sous section d<EFBFBD>plac<EFBFBD>e
|
||||
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;
|
||||
newIndex = Math.Clamp(newIndex, 0, subSections.Count);
|
||||
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++)
|
||||
{
|
||||
subSections[i].Order = i;
|
||||
@ -832,6 +837,13 @@ namespace ManagerService.Controllers
|
||||
_myInfoMateDbContext.SaveChanges();
|
||||
|
||||
MqttClientService.PublishMessage($"config/{existingSection.ConfigurationId}", JsonConvert.SerializeObject(new PlayerMessageDTO() { configChanged = true }));
|
||||
|
||||
if (existingSection.Type == SectionType.Agenda)
|
||||
{
|
||||
var sa = _myInfoMateDbContext.Sections.OfType<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));
|
||||
|
||||
@ -381,5 +381,87 @@ namespace ManagerService.Controllers
|
||||
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.SectionMapId = guidedPathDTO.sectionMapId;
|
||||
existingGuidedPath.SectionEventId = guidedPathDTO.sectionEventId;
|
||||
existingGuidedPath.SectionGameId = guidedPathDTO.sectionGameId;
|
||||
existingGuidedPath.IsLinear = guidedPathDTO.isLinear;
|
||||
existingGuidedPath.RequireSuccessToAdvance = guidedPathDTO.requireSuccessToAdvance;
|
||||
existingGuidedPath.HideNextStepsUntilComplete = guidedPathDTO.hideNextStepsUntilComplete;
|
||||
@ -658,7 +659,7 @@ namespace ManagerService.Controllers
|
||||
existingGuidedStep.ImageUrl = guidedStepDTO.imageUrl;
|
||||
existingGuidedStep.TriggerGeoPointId = guidedStepDTO.triggerGeoPointId;
|
||||
existingGuidedStep.IsHiddenInitially = guidedStepDTO.isHiddenInitially;
|
||||
existingGuidedStep.QuizQuestions = guidedStepDTO.quizQuestions; // à convertir si besoin ?
|
||||
existingGuidedStep.QuizQuestions = guidedStepDTO.quizQuestions; // <EFBFBD> convertir si besoin ?
|
||||
existingGuidedStep.IsStepTimer = guidedStepDTO.isStepTimer;
|
||||
existingGuidedStep.IsStepLocked = guidedStepDTO.isStepLocked;
|
||||
existingGuidedStep.TimerSeconds = guidedStepDTO.timerSeconds;
|
||||
|
||||
@ -263,6 +263,59 @@ namespace ManagerService.Controllers
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Top articles lus
|
||||
summary.TopArticles = events
|
||||
.Where(e => e.EventType == VisitEventType.ArticleRead && e.SectionId != null)
|
||||
.GroupBy(e => e.SectionId!)
|
||||
.Select(g => new ArticleStatDTO { SectionId = g.Key, Reads = g.Count() })
|
||||
.OrderByDescending(a => a.Reads)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
// Top menu items
|
||||
var menuEvents = events.Where(e => e.EventType == VisitEventType.MenuItemTap && e.Metadata != null).ToList();
|
||||
var menuGroups = new Dictionary<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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@ -31,6 +31,7 @@ namespace ManagerService.DTOs
|
||||
public string SectionId { get; set; }
|
||||
public string SectionTitle { get; set; }
|
||||
public string SectionType { get; set; }
|
||||
public string? ImageUrl { get; set; }
|
||||
}
|
||||
|
||||
public class AiChatResponse
|
||||
|
||||
@ -26,6 +26,10 @@ namespace ManagerService.DTOs
|
||||
|
||||
public ResourceDTO resource { get; set; } // Background image
|
||||
|
||||
public string? videoResourceId { get; set; }
|
||||
|
||||
public ResourceDTO? videoResource { get; set; }
|
||||
|
||||
public EventAddressDTO address { get; set; }
|
||||
|
||||
public string phone { get; set; }
|
||||
@ -36,6 +40,12 @@ namespace ManagerService.DTOs
|
||||
|
||||
public string sectionEventId { get; set; }
|
||||
|
||||
public bool isSynced { get; set; }
|
||||
|
||||
public string? idVideoYoutube { get; set; }
|
||||
|
||||
public string? videoLink { get; set; }
|
||||
|
||||
}
|
||||
|
||||
public class EventAddressDTO
|
||||
|
||||
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<QuizStatDTO> QuizStats { 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
|
||||
@ -63,4 +66,24 @@ namespace ManagerService.DTOs
|
||||
public int Completions { get; set; }
|
||||
public int AvgDurationSeconds { get; set; }
|
||||
}
|
||||
|
||||
public class ArticleStatDTO
|
||||
{
|
||||
public string SectionId { get; set; }
|
||||
public int Reads { get; set; }
|
||||
}
|
||||
|
||||
public class MenuItemStatDTO
|
||||
{
|
||||
public string TargetSectionId { get; set; }
|
||||
public string MenuItemTitle { get; set; }
|
||||
public int Taps { get; set; }
|
||||
}
|
||||
|
||||
public class QrScanStatDTO
|
||||
{
|
||||
public int TotalScans { get; set; }
|
||||
public int ValidScans { get; set; }
|
||||
public int InvalidScans { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ namespace Manager.DTOs
|
||||
{
|
||||
public class MapDTO : SectionDTO
|
||||
{
|
||||
public bool isListViewEnabled { get; set; } = false;
|
||||
public int? zoom { get; set; } // Default = 18
|
||||
public MapTypeApp? mapType { get; set; } // Default = Hybrid for Google
|
||||
public MapTypeMapBox? mapTypeMapbox { get; set; } // Default = standard for MapBox
|
||||
|
||||
@ -9,7 +9,9 @@ namespace Manager.DTOs
|
||||
{
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime EndDate { get; set; }
|
||||
public string? BaseSectionMapId { get; set; }
|
||||
public List<string> ParcoursIds { get; set; }
|
||||
public List<MapAnnotationDTO> GlobalMapAnnotations { get; set; } = new();
|
||||
public List<ProgrammeBlock> Programme { get; set; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
@ -222,6 +222,22 @@ namespace ManagerService.Data
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(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 string? VideoResourceId { get; set; }
|
||||
|
||||
[ForeignKey("VideoResourceId")]
|
||||
public Resource? VideoResource { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public EventAddress Address { get; set; }
|
||||
|
||||
@ -45,6 +50,12 @@ namespace ManagerService.Data.SubSection
|
||||
|
||||
public string Email { get; set; }
|
||||
|
||||
public bool IsSynced { get; set; } = false;
|
||||
|
||||
public string? IdVideoYoutube { get; set; }
|
||||
|
||||
public string? VideoLink { get; set; }
|
||||
|
||||
public string SectionAgendaId { get; set; }
|
||||
|
||||
[ForeignKey("SectionAgendaId")]
|
||||
@ -69,6 +80,8 @@ namespace ManagerService.Data.SubSection
|
||||
website = Website,
|
||||
resourceId = ResourceId,
|
||||
resource = Resource?.ToDTO(),
|
||||
videoResourceId = VideoResourceId,
|
||||
videoResource = VideoResource?.ToDTO(),
|
||||
address = Address != null ? new EventAddressDTO
|
||||
{
|
||||
address = Address.Address,
|
||||
@ -84,7 +97,10 @@ namespace ManagerService.Data.SubSection
|
||||
} : null,
|
||||
phone = Phone,
|
||||
email = Email,
|
||||
sectionAgendaId = SectionAgendaId
|
||||
sectionAgendaId = SectionAgendaId,
|
||||
isSynced = IsSynced,
|
||||
idVideoYoutube = IdVideoYoutube,
|
||||
videoLink = VideoLink
|
||||
};
|
||||
}
|
||||
|
||||
@ -98,6 +114,7 @@ namespace ManagerService.Data.SubSection
|
||||
DateTo = dto.dateTo;
|
||||
Website = dto.website;
|
||||
ResourceId = dto.resourceId;
|
||||
VideoResourceId = dto.videoResourceId;
|
||||
//Resource = dto.Resource != null ? Resource.From(dto.Resource) : null,
|
||||
Address = dto.address != null ? new EventAddress
|
||||
{
|
||||
@ -114,6 +131,9 @@ namespace ManagerService.Data.SubSection
|
||||
} : null;
|
||||
Phone = dto.phone;
|
||||
Email = dto.email;
|
||||
IsSynced = false;
|
||||
IdVideoYoutube = dto.idVideoYoutube;
|
||||
VideoLink = dto.videoLink;
|
||||
SectionAgendaId = dto.sectionAgendaId;
|
||||
return this;
|
||||
}
|
||||
@ -128,6 +148,7 @@ namespace ManagerService.Data.SubSection
|
||||
DateTo = dto.dateTo;
|
||||
Website = dto.website;
|
||||
ResourceId = dto.resourceId;
|
||||
VideoResourceId = dto.videoResourceId;
|
||||
Address = dto.address != null ? new EventAddress
|
||||
{
|
||||
Address = dto.address.address,
|
||||
@ -143,6 +164,9 @@ namespace ManagerService.Data.SubSection
|
||||
} : null;
|
||||
Phone = dto.phone;
|
||||
Email = dto.email;
|
||||
IsSynced = false;
|
||||
IdVideoYoutube = dto.idVideoYoutube;
|
||||
VideoLink = dto.videoLink;
|
||||
SectionAgendaId = dto.sectionAgendaId;
|
||||
SectionEventId = dto.sectionEventId;
|
||||
}
|
||||
|
||||
@ -17,6 +17,10 @@ namespace ManagerService.Data.SubSection
|
||||
{
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime EndDate { get; set; }
|
||||
public string? BaseSectionMapId { get; set; }
|
||||
[ForeignKey(nameof(BaseSectionMapId))]
|
||||
public SectionMap? BaseMap { get; set; }
|
||||
public List<MapAnnotation> GlobalMapAnnotations { get; set; } = new();
|
||||
public List<ProgrammeBlock> Programme { get; set; } = new();
|
||||
[Column(TypeName = "jsonb")]
|
||||
public List<string> ParcoursIds { get; set; } = new(); // Liens vers GeoPoints spécifiques
|
||||
@ -62,6 +66,7 @@ namespace ManagerService.Data.SubSection
|
||||
{
|
||||
[Key]
|
||||
public string Id { get; set; }
|
||||
public string? SectionEventId { get; set; } // FK for global event-level annotation (null = block annotation)
|
||||
[Column(TypeName = "jsonb")]
|
||||
public List<TranslationDTO> Type { get; set; } // "first_aid", "parking", etc.
|
||||
[Column(TypeName = "jsonb")]
|
||||
|
||||
@ -15,6 +15,7 @@ namespace ManagerService.Data.SubSection
|
||||
public class SectionMap : Section
|
||||
{
|
||||
public int MapZoom { get; set; } // Default = 18
|
||||
public bool IsListViewEnabled { get; set; } = false;
|
||||
public MapTypeApp? MapMapType { get; set; } // Default = Hybrid for Google
|
||||
public MapTypeMapBox? MapTypeMapbox { get; set; } // Default = standard for MapBox
|
||||
public MapProvider? MapMapProvider { get; set; } // Default = Google
|
||||
|
||||
@ -55,6 +55,7 @@ namespace ManagerService.Data
|
||||
GameComplete,
|
||||
AgendaEventTap,
|
||||
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")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("IdVideoYoutube")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsSynced")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
@ -530,6 +536,12 @@ namespace ManagerService.Migrations
|
||||
b.Property<string>("Type")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("VideoLink")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("VideoResourceId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Website")
|
||||
.HasColumnType("text");
|
||||
|
||||
@ -541,6 +553,8 @@ namespace ManagerService.Migrations
|
||||
|
||||
b.HasIndex("SectionEventId");
|
||||
|
||||
b.HasIndex("VideoResourceId");
|
||||
|
||||
b.ToTable("EventAgendas");
|
||||
});
|
||||
|
||||
@ -799,6 +813,9 @@ namespace ManagerService.Migrations
|
||||
b.Property<string>("ProgrammeBlockId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SectionEventId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<List<TranslationDTO>>("Type")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
@ -808,6 +825,8 @@ namespace ManagerService.Migrations
|
||||
|
||||
b.HasIndex("ProgrammeBlockId");
|
||||
|
||||
b.HasIndex("SectionEventId");
|
||||
|
||||
b.ToTable("MapAnnotations");
|
||||
});
|
||||
|
||||
@ -969,6 +988,9 @@ namespace ManagerService.Migrations
|
||||
{
|
||||
b.HasBaseType("ManagerService.Data.Section");
|
||||
|
||||
b.Property<string>("BaseSectionMapId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@ -978,6 +1000,8 @@ namespace ManagerService.Migrations
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasIndex("BaseSectionMapId");
|
||||
|
||||
b.HasDiscriminator().HasValue("Event");
|
||||
});
|
||||
|
||||
@ -1014,6 +1038,9 @@ namespace ManagerService.Migrations
|
||||
{
|
||||
b.HasBaseType("ManagerService.Data.Section");
|
||||
|
||||
b.Property<bool>("IsListViewEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<List<CategorieDTO>>("MapCategories")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
@ -1211,11 +1238,17 @@ namespace ManagerService.Migrations
|
||||
.WithMany()
|
||||
.HasForeignKey("SectionEventId");
|
||||
|
||||
b.HasOne("ManagerService.Data.Resource", "VideoResource")
|
||||
.WithMany()
|
||||
.HasForeignKey("VideoResourceId");
|
||||
|
||||
b.Navigation("Resource");
|
||||
|
||||
b.Navigation("SectionAgenda");
|
||||
|
||||
b.Navigation("SectionEvent");
|
||||
|
||||
b.Navigation("VideoResource");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ManagerService.Data.SubSection.GeoPoint", b =>
|
||||
@ -1308,6 +1341,11 @@ namespace ManagerService.Migrations
|
||||
.WithMany("MapAnnotations")
|
||||
.HasForeignKey("ProgrammeBlockId");
|
||||
|
||||
b.HasOne("ManagerService.Data.SubSection.SectionEvent", null)
|
||||
.WithMany("GlobalMapAnnotations")
|
||||
.HasForeignKey("SectionEventId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("IconResource");
|
||||
});
|
||||
|
||||
@ -1318,6 +1356,16 @@ namespace ManagerService.Migrations
|
||||
.HasForeignKey("SectionEventId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b =>
|
||||
{
|
||||
b.HasOne("ManagerService.Data.SubSection.SectionMap", "BaseMap")
|
||||
.WithMany()
|
||||
.HasForeignKey("BaseSectionMapId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("BaseMap");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ManagerService.Data.SubSection.SectionGame", b =>
|
||||
{
|
||||
b.HasOne("ManagerService.Data.Resource", "GamePuzzleImage")
|
||||
@ -1363,6 +1411,8 @@ namespace ManagerService.Migrations
|
||||
|
||||
modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b =>
|
||||
{
|
||||
b.Navigation("GlobalMapAnnotations");
|
||||
|
||||
b.Navigation("Programme");
|
||||
});
|
||||
|
||||
|
||||
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>
|
||||
/// Creates a new API key with a hashed secret (returned once in plain text).
|
||||
/// </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))
|
||||
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||
@ -40,6 +40,7 @@ namespace ManagerService.Services
|
||||
KeyHash = keyHash,
|
||||
IsActive = true,
|
||||
DateCreation = DateTime.UtcNow,
|
||||
DateExpiration = dateExpiration.HasValue ? DateTime.SpecifyKind(dateExpiration.Value, DateTimeKind.Utc) : null,
|
||||
};
|
||||
|
||||
_db.ApiKeys.Add(apiKey);
|
||||
|
||||
@ -3,7 +3,13 @@ using ManagerService.DTOs;
|
||||
using Microsoft.Extensions.AI;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using Manager.DTOs;
|
||||
using ManagerService.Data.SubSection;
|
||||
|
||||
namespace ManagerService.Services
|
||||
{
|
||||
@ -20,10 +26,38 @@ namespace ManagerService.Services
|
||||
_context = context;
|
||||
}
|
||||
|
||||
private static string StripHtml(string? html) =>
|
||||
string.IsNullOrEmpty(html) ? "" : System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", "").Trim();
|
||||
|
||||
private static DateTime? ParseFilterDate(string? s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return null;
|
||||
// Priorité au format ISO yyyy-MM-dd (language-neutral), puis fallbacks
|
||||
if (DateTime.TryParseExact(s,
|
||||
new[] { "yyyy-MM-dd", "dd/MM/yyyy", "MM/dd/yyyy", "dd-MM-yyyy" },
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out var dt1))
|
||||
return dt1;
|
||||
if (DateTime.TryParse(s, out var dt2)) return dt2;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (DateTime saturday, DateTime sunday) GetCurrentWeekend()
|
||||
{
|
||||
var now = DateTime.Now.Date;
|
||||
// Si on est dimanche, le weekend en cours a commencé hier
|
||||
if (now.DayOfWeek == DayOfWeek.Sunday)
|
||||
return (now.AddDays(-1), now);
|
||||
var daysToSat = ((int)DayOfWeek.Saturday - (int)now.DayOfWeek + 7) % 7;
|
||||
var sat = now.AddDays(daysToSat);
|
||||
return (sat, sat.AddDays(1));
|
||||
}
|
||||
|
||||
public async Task<AiChatResponse> ChatAsync(AiChatRequest request)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
ChatOptions options;
|
||||
var (weekendSat, weekendSun) = GetCurrentWeekend();
|
||||
|
||||
if (request.ConfigurationId != null)
|
||||
{
|
||||
@ -33,24 +67,31 @@ namespace ManagerService.Services
|
||||
|
||||
var sectionsSummary = string.Join("\n", sections.Select(s =>
|
||||
{
|
||||
var title = s.Title?.FirstOrDefault(t => t.language == request.Language)?.value
|
||||
var title = StripHtml(s.Title?.FirstOrDefault(t => t.language == request.Language)?.value
|
||||
?? s.Title?.FirstOrDefault()?.value
|
||||
?? s.Label;
|
||||
?? s.Label);
|
||||
return $"- id:{s.Id} | type:{s.Type} | titre:\"{title}\"";
|
||||
}));
|
||||
|
||||
messages.Add(new ChatMessage(ChatRole.System, $"""
|
||||
Tu es l'assistant de visite de "{config?.Label ?? "cette application"}".
|
||||
Tu réponds en {request.Language}. Tu es chaleureux, concis et utile.
|
||||
Tu es l'assistant de visite de l'application "{config?.Label ?? "cette application"}".
|
||||
Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}.
|
||||
Le weekend en cours (ou prochain) : samedi {weekendSat:dd/MM/yyyy} – dimanche {weekendSun:dd/MM/yyyy}.
|
||||
Ton but est d'accompagner le visiteur dans son expérience. Tu réponds en {request.Language}. Tu es chaleureux, concis et proactif.
|
||||
|
||||
Voici les sections disponibles dans cette application :
|
||||
Voici les sections disponibles :
|
||||
{sectionsSummary}
|
||||
|
||||
Pour obtenir le détail d'une section spécifique, utilise l'outil GetSectionDetail.
|
||||
Pour afficher une liste d'éléments (événements, activités...) de façon visuelle, appelle show_cards avec les titres et sous-titres.
|
||||
Pour proposer à l'utilisateur d'aller directement dans une section, appelle navigate_to_section.
|
||||
Accompagne toujours tes outils d'un texte explicatif.
|
||||
Ne parle jamais de sections qui ne sont pas dans cette liste.
|
||||
RÈGLES STRICTES — tu dois les respecter sans exception :
|
||||
1. Tu ne réponds JAMAIS "je ne peux pas" ou "je n'ai pas accès" si un outil existe pour répondre. Tu appelles l'outil, tu reçois les données, TU les présentes.
|
||||
2. Toute question sur des événements, le planning, les dates, les activités → appelle "GetUpcomingEvents" IMMÉDIATEMENT. Si l'utilisateur précise une période, calcule les dates et passe-les en paramètres dateFrom/dateTo au format ISO yyyy-MM-dd. Exemples : "ce weekend" → dateFrom={weekendSat:yyyy-MM-dd}&dateTo={weekendSun:yyyy-MM-dd} ; "la semaine prochaine" → lundi au dimanche suivant.
|
||||
3. Pour présenter une liste d'événements, appelle TOUJOURS "show_cards" avec les titres et dates — ne liste jamais les événements en texte brut.
|
||||
4. Après avoir présenté des résultats d'événements, appelle TOUJOURS "navigate_to_section" avec l'ID de la section agenda trouvée dans la liste ci-dessus.
|
||||
5. Si l'utilisateur veut voir les activités ou lieux → utilise "GetMapPoints".
|
||||
6. Pour les détails d'un item spécifique → utilise "GetItemDetails".
|
||||
- Ne mentionne JAMAIS les identifiants techniques (id, guid) dans tes réponses finales.
|
||||
- NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats.
|
||||
- NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide.
|
||||
"""));
|
||||
|
||||
foreach (var h in request.History.TakeLast(MaxHistoryMessages))
|
||||
@ -61,31 +102,199 @@ namespace ManagerService.Services
|
||||
NavigationActionDTO? navigation = null;
|
||||
List<AiCardDTO>? cards = null;
|
||||
|
||||
// Outil scopé : vérifie que la section appartient bien à cette configuration
|
||||
var scopedSections = sections;
|
||||
var tools = new List<AITool>
|
||||
{
|
||||
AIFunctionFactory.Create(
|
||||
(string sectionId) =>
|
||||
{
|
||||
var section = scopedSections.FirstOrDefault(s => s.Id == sectionId);
|
||||
if (section == null) return "Section non trouvée dans cette application.";
|
||||
|
||||
var title = section.Title?.FirstOrDefault(t => t.language == request.Language)?.value
|
||||
?? section.Title?.FirstOrDefault()?.value
|
||||
?? section.Label;
|
||||
var desc = section.Description?.FirstOrDefault(t => t.language == request.Language)?.value
|
||||
?? section.Description?.FirstOrDefault()?.value
|
||||
?? "";
|
||||
var section = sections.FirstOrDefault(s => s.Id == sectionId);
|
||||
if (section == null) return "Section non trouvée.";
|
||||
var title = section.Title?.FirstOrDefault(t => t.language == request.Language)?.value ?? section.Label;
|
||||
var desc = section.Description?.FirstOrDefault(t => t.language == request.Language)?.value ?? "";
|
||||
return $"Titre: {title}\nDescription: {desc}\nType: {section.Type}";
|
||||
},
|
||||
"GetSectionDetail",
|
||||
"Récupère le titre et la description complète d'une section à partir de son id"
|
||||
"Récupère le titre et la description d'une section."
|
||||
),
|
||||
AIFunctionFactory.Create(
|
||||
async (string? dateFrom, string? dateTo) =>
|
||||
{
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var horizon = today.AddMonths(3);
|
||||
|
||||
var filterFrom = ParseFilterDate(dateFrom);
|
||||
var filterTo = ParseFilterDate(dateTo);
|
||||
|
||||
var agendaSections = _context.Sections
|
||||
.OfType<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(
|
||||
(string sectionId, string sectionTitle, string sectionType) =>
|
||||
{
|
||||
navigation = new NavigationActionDTO { SectionId = sectionId, SectionTitle = sectionTitle, SectionType = sectionType };
|
||||
var imageUrl = sections.FirstOrDefault(s => s.Id == sectionId)?.ImageSource;
|
||||
navigation = new NavigationActionDTO { SectionId = sectionId, SectionTitle = sectionTitle, SectionType = sectionType, ImageUrl = imageUrl };
|
||||
return Task.FromResult("Navigation proposée à l'utilisateur.");
|
||||
},
|
||||
"navigate_to_section",
|
||||
@ -107,33 +316,65 @@ namespace ManagerService.Services
|
||||
)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
options = new ChatOptions { Tools = tools };
|
||||
var response = await _chatClient.GetResponseAsync(messages, options);
|
||||
return new AiChatResponse { Reply = response.Text ?? "", Cards = cards, Navigation = navigation };
|
||||
}
|
||||
catch (System.ClientModel.ClientResultException ex)
|
||||
{
|
||||
// Log plus de détails si possible sur l'erreur 400 de Google/OpenAI
|
||||
throw new System.Exception($"AI Service Error (Status: {ex.Status}): {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Scope instance : l'IA connaît les configurations disponibles
|
||||
var configurations = _context.Configurations.Where(c => c.InstanceId == request.InstanceId).ToList();
|
||||
// Scope instance : l'IA connaît les configurations disponibles pour cette application spécifiquement (AppType)
|
||||
var configurations = _context.AppConfigurationLinks
|
||||
.Where(l => l.ApplicationInstance.InstanceId == request.InstanceId && l.ApplicationInstance.AppType == request.AppType && l.IsActive)
|
||||
.Select(l => l.Configuration)
|
||||
.ToList();
|
||||
var configIds = configurations.Select(c => c.Id).ToList();
|
||||
|
||||
// Récupère les types de sections par configuration pour aider l'IA à choisir
|
||||
var typesByConfig = _context.Sections
|
||||
.Where(s => configIds.Contains(s.ConfigurationId))
|
||||
.Select(s => new { s.ConfigurationId, s.Type })
|
||||
.ToList()
|
||||
.GroupBy(s => s.ConfigurationId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(s => s.Type).Distinct().ToList());
|
||||
|
||||
var configsSummary = string.Join("\n", configurations.Select(c =>
|
||||
{
|
||||
var title = c.Title?.FirstOrDefault(t => t.language == request.Language)?.value
|
||||
var title = StripHtml(c.Title?.FirstOrDefault(t => t.language == request.Language)?.value
|
||||
?? c.Title?.FirstOrDefault()?.value
|
||||
?? c.Label;
|
||||
return $"- id:{c.Id} | titre:\"{title}\"";
|
||||
?? c.Label);
|
||||
|
||||
typesByConfig.TryGetValue(c.Id, out var types);
|
||||
var typesStr = types != null && types.Any() ? $" | types disponibles: {string.Join(", ", types)}" : "";
|
||||
|
||||
return $"- id:{c.Id} | titre:\"{title}\"{typesStr}";
|
||||
}));
|
||||
|
||||
messages.Add(new ChatMessage(ChatRole.System, $"""
|
||||
Tu es l'assistant de visite.
|
||||
Tu réponds en {request.Language}. Tu es chaleureux, concis et utile.
|
||||
Tu es l'assistant de visite principal. Ton rôle est d'orienter l'utilisateur vers la bonne expérience de visite.
|
||||
Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}.
|
||||
Le weekend en cours (ou prochain) : samedi {weekendSat:dd/MM/yyyy} – dimanche {weekendSun:dd/MM/yyyy}.
|
||||
Tu réponds en {request.Language}. Tu es chaleureux, concis et proactif.
|
||||
|
||||
Voici les expériences de visite disponibles :
|
||||
Voici les expériences (configurations) disponibles :
|
||||
{configsSummary}
|
||||
|
||||
Aide le visiteur à trouver ce qui l'intéresse parmi ces expériences.
|
||||
Pour orienter le visiteur vers une visite spécifique, appelle navigate_to_configuration.
|
||||
Accompagne toujours tes outils d'un texte explicatif.
|
||||
RÈGLES STRICTES — tu dois les respecter sans exception :
|
||||
1. Tu ne réponds JAMAIS "je ne peux pas" ou "je n'ai pas accès" si un outil existe pour répondre. Tu appelles l'outil, tu reçois les données, TU les présentes.
|
||||
2. Toute question sur des événements, le planning, les dates → appelle "SearchEventsGlobal" IMMÉDIATEMENT. Si l'utilisateur précise une période, calcule les dates et passe-les en paramètres dateFrom/dateTo au format ISO yyyy-MM-dd. Exemples : "ce weekend" → dateFrom={weekendSat:yyyy-MM-dd}&dateTo={weekendSun:yyyy-MM-dd} ; "la semaine prochaine" → lundi au dimanche suivant.
|
||||
3. Pour présenter une liste d'événements, appelle TOUJOURS "show_cards" avec les titres et dates — ne liste jamais les événements en texte brut.
|
||||
4. Après avoir présenté des résultats d'événements, appelle TOUJOURS "navigate_to_configuration" avec l'ID de la visite concernée pour que l'utilisateur puisse y accéder directement.
|
||||
5. Pour orienter vers une visite spécifique (jeu, quiz...) → "navigate_to_configuration".
|
||||
6. Pour les détails d'un item → "GetItemDetailsGlobal".
|
||||
- NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats.
|
||||
- NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide.
|
||||
"""));
|
||||
|
||||
foreach (var h in request.History.TakeLast(MaxHistoryMessages))
|
||||
@ -145,10 +386,192 @@ namespace ManagerService.Services
|
||||
|
||||
var tools = new List<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(
|
||||
(string configurationId, string configurationTitle) =>
|
||||
{
|
||||
navigation = new NavigationActionDTO { SectionId = configurationId, SectionTitle = configurationTitle, SectionType = "Configuration" };
|
||||
var imageUrl = configurations.FirstOrDefault(c => c.Id == configurationId)?.ImageSource;
|
||||
navigation = new NavigationActionDTO { SectionId = configurationId, SectionTitle = configurationTitle, SectionType = "Configuration", ImageUrl = imageUrl };
|
||||
return Task.FromResult("Navigation vers la visite proposée.");
|
||||
},
|
||||
"navigate_to_configuration",
|
||||
@ -156,10 +579,18 @@ namespace ManagerService.Services
|
||||
)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
options = new ChatOptions { Tools = tools };
|
||||
var response = await _chatClient.GetResponseAsync(messages, options);
|
||||
return new AiChatResponse { Reply = response.Text ?? "", Navigation = navigation };
|
||||
}
|
||||
catch (System.ClientModel.ClientResultException ex)
|
||||
{
|
||||
// Log plus de détails si possible sur l'erreur 400 de Google/OpenAI
|
||||
throw new System.Exception($"AI Service Error (Status: {ex.Status}): {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ using ManagerService.Data.SubSection;
|
||||
using ManagerService.DTOs;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ManagerService.Services
|
||||
@ -141,6 +142,7 @@ namespace ManagerService.Services
|
||||
StartDate = sectionEventDTO.StartDate,
|
||||
EndDate = sectionEventDTO.EndDate,
|
||||
ParcoursIds = sectionEventDTO.ParcoursIds,
|
||||
BaseSectionMapId = sectionEventDTO.BaseSectionMapId,
|
||||
//Programmes = // TODO specific
|
||||
},
|
||||
SectionType.Map => new SectionMap
|
||||
@ -170,7 +172,8 @@ namespace ManagerService.Services
|
||||
MapResourceId = mapDTO.iconResourceId,
|
||||
MapCenterLatitude = mapDTO.centerLatitude,
|
||||
MapCenterLongitude = mapDTO.centerLongitude,
|
||||
MapCategories = mapDTO.categories
|
||||
MapCategories = mapDTO.categories,
|
||||
IsListViewEnabled = mapDTO.isListViewEnabled
|
||||
},
|
||||
SectionType.Menu => new SectionMenu
|
||||
{
|
||||
@ -443,6 +446,8 @@ namespace ManagerService.Services
|
||||
StartDate = sectionEvent.StartDate,
|
||||
EndDate = sectionEvent.EndDate,
|
||||
ParcoursIds = sectionEvent.ParcoursIds,
|
||||
BaseSectionMapId = sectionEvent.BaseSectionMapId,
|
||||
GlobalMapAnnotations = sectionEvent.GlobalMapAnnotations?.Select(ma => ma.ToDTO()).ToList() ?? new(),
|
||||
// Programme TODO specific
|
||||
},
|
||||
SectionMap map => new MapDTO
|
||||
@ -473,6 +478,7 @@ namespace ManagerService.Services
|
||||
centerLatitude = map.MapCenterLatitude,
|
||||
centerLongitude = map.MapCenterLongitude,
|
||||
categories = map.MapCategories,
|
||||
isListViewEnabled = map.IsListViewEnabled,
|
||||
//points = null // map.MapPoints // TODO specific
|
||||
},
|
||||
SectionMenu menu => new MenuDTO
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
using FirebaseAdmin;
|
||||
using Google.Apis.Auth.OAuth2;
|
||||
using Hangfire;
|
||||
using Hangfire.PostgreSql;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using System.ClientModel;
|
||||
@ -116,6 +120,7 @@ namespace ManagerService
|
||||
{
|
||||
options.AddPolicy(policy.Name, policyAdmin =>
|
||||
{
|
||||
policyAdmin.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, "ApiKey");
|
||||
foreach (var claim in policy.Claims)
|
||||
policyAdmin.RequireClaim(ManagerService.Service.Security.ClaimTypes.Permission, claim);
|
||||
});
|
||||
@ -173,12 +178,35 @@ namespace ManagerService
|
||||
new OpenAIClient(
|
||||
new ApiKeyCredential(Configuration["AI:ApiKey"]!),
|
||||
new OpenAIClientOptions { Endpoint = new Uri("https://generativelanguage.googleapis.com/v1beta/openai/") }
|
||||
).AsChatClient("gemini-2.5-flash-lite"));
|
||||
).AsChatClient("gemini-2.5-flash-lite")
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation()
|
||||
.Build());
|
||||
services.AddScoped<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");
|
||||
|
||||
// Hangfire
|
||||
services.AddHangfire(config => config
|
||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UsePostgreSqlStorage(c => c.UseNpgsqlConnection(connectionString)));
|
||||
services.AddHangfireServer();
|
||||
|
||||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
|
||||
dataSourceBuilder.UseNetTopologySuite();
|
||||
dataSourceBuilder.EnableDynamicJson();
|
||||
@ -215,7 +243,7 @@ namespace ManagerService
|
||||
app.UseCors(
|
||||
#if DEBUG
|
||||
options => options
|
||||
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "http://localhost:50479")
|
||||
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "http://localhost:9090")
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials()
|
||||
@ -228,6 +256,16 @@ namespace ManagerService
|
||||
#endif
|
||||
);
|
||||
|
||||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||
{
|
||||
Authorization = new[] { new HangfireDashboardAuthorizationFilter() }
|
||||
});
|
||||
|
||||
RecurringJob.AddOrUpdate<AgendaSyncService>(
|
||||
"agenda-sync-daily",
|
||||
s => s.SyncAllAsync(),
|
||||
Cron.Daily());
|
||||
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapControllers();
|
||||
|
||||
@ -31,7 +31,10 @@
|
||||
"SupportedLanguages": [ "FR", "NL", "EN", "DE", "IT", "ES", "PL", "CN", "AR", "UK" ],
|
||||
"OpenWeatherApiKey": "d489973b4c09ddc5fb56bd7b9270bbef",
|
||||
"AI": {
|
||||
"ApiKey": "AIzaSyC7lJ8w1eQL4aZGNFLMabig5ul6yn66nug"
|
||||
"ApiKey": "AIzaSyCIf-mzp4Nzm5VwHL7LLzitt9z_bOOGMwc"
|
||||
},
|
||||
"Firebase": {
|
||||
"CredentialsPath": "firebase-adminsdk.json"
|
||||
}
|
||||
//"Urls": "http://[::]:80"
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user