wip service, fix some assistant ia bug, wip section agenda update, section event annotation etc

This commit is contained in:
Thomas Fransolet 2026-03-25 10:56:54 +01:00
parent bad25bf5b3
commit d5353eea9c
30 changed files with 4186 additions and 63 deletions

3
.gitignore vendored
View File

@ -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
View 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
```

View File

@ -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; }
}
}

View File

@ -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>

View File

@ -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));

View File

@ -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 };
}
}
}
}

View File

@ -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;

View File

@ -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)

View File

@ -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

View File

@ -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

View 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; }
}
}

View File

@ -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; }
}
}

View File

@ -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

View File

@ -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();
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}

View File

@ -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")]

View File

@ -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

View File

@ -55,6 +55,7 @@ namespace ManagerService.Data
GameComplete,
AgendaEventTap,
MenuItemTap,
AssistantMessage
AssistantMessage,
ArticleRead
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}
}

View File

@ -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");
});

View 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 ?? "" });
}
}
}

View File

@ -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);

View File

@ -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
)
};
options = new ChatOptions { Tools = tools };
var response = await _chatClient.GetResponseAsync(messages, options);
return new AiChatResponse { Reply = response.Text ?? "", Cards = cards, Navigation = navigation };
try
{
options = new ChatOptions { Tools = tools };
var response = await _chatClient.GetResponseAsync(messages, options);
return new AiChatResponse { Reply = response.Text ?? "", Cards = cards, Navigation = navigation };
}
catch (System.ClientModel.ClientResultException ex)
{
// Log plus de détails si possible sur l'erreur 400 de Google/OpenAI
throw new System.Exception($"AI Service Error (Status: {ex.Status}): {ex.Message}", ex);
}
}
else
{
// Scope instance : l'IA connaît les configurations disponibles
var configurations = _context.Configurations.Where(c => c.InstanceId == request.InstanceId).ToList();
// Scope instance : l'IA connaît les configurations disponibles pour cette application spécifiquement (AppType)
var configurations = _context.AppConfigurationLinks
.Where(l => l.ApplicationInstance.InstanceId == request.InstanceId && l.ApplicationInstance.AppType == request.AppType && l.IsActive)
.Select(l => l.Configuration)
.ToList();
var configIds = configurations.Select(c => c.Id).ToList();
// Récupère les types de sections par configuration pour aider l'IA à choisir
var typesByConfig = _context.Sections
.Where(s => configIds.Contains(s.ConfigurationId))
.Select(s => new { s.ConfigurationId, s.Type })
.ToList()
.GroupBy(s => s.ConfigurationId)
.ToDictionary(g => g.Key, g => g.Select(s => s.Type).Distinct().ToList());
var configsSummary = string.Join("\n", configurations.Select(c =>
{
var title = c.Title?.FirstOrDefault(t => t.language == request.Language)?.value
var title = StripHtml(c.Title?.FirstOrDefault(t => t.language == request.Language)?.value
?? c.Title?.FirstOrDefault()?.value
?? c.Label;
return $"- id:{c.Id} | titre:\"{title}\"";
?? c.Label);
typesByConfig.TryGetValue(c.Id, out var types);
var typesStr = types != null && types.Any() ? $" | types disponibles: {string.Join(", ", types)}" : "";
return $"- id:{c.Id} | titre:\"{title}\"{typesStr}";
}));
messages.Add(new ChatMessage(ChatRole.System, $"""
Tu es l'assistant de visite.
Tu réponds en {request.Language}. Tu es chaleureux, concis et utile.
Tu es l'assistant de visite principal. Ton rôle est d'orienter l'utilisateur vers la bonne expérience de visite.
Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}.
Le weekend en cours (ou prochain) : samedi {weekendSat:dd/MM/yyyy} dimanche {weekendSun:dd/MM/yyyy}.
Tu réponds en {request.Language}. Tu es chaleureux, concis et proactif.
Voici les expériences de visite disponibles :
Voici les expériences (configurations) disponibles :
{configsSummary}
Aide le visiteur à trouver ce qui l'intéresse parmi ces expériences.
Pour orienter le visiteur vers une visite spécifique, appelle navigate_to_configuration.
Accompagne toujours tes outils d'un texte explicatif.
RÈGLES STRICTES tu dois les respecter sans exception :
1. Tu ne réponds JAMAIS "je ne peux pas" ou "je n'ai pas accès" si un outil existe pour répondre. Tu appelles l'outil, tu reçois les données, TU les présentes.
2. Toute question sur des événements, le planning, les dates appelle "SearchEventsGlobal" IMMÉDIATEMENT. Si l'utilisateur précise une période, calcule les dates et passe-les en paramètres dateFrom/dateTo au format ISO yyyy-MM-dd. Exemples : "ce weekend" dateFrom={weekendSat:yyyy-MM-dd}&dateTo={weekendSun:yyyy-MM-dd} ; "la semaine prochaine" lundi au dimanche suivant.
3. Pour présenter une liste d'événements, appelle TOUJOURS "show_cards" avec les titres et dates ne liste jamais les événements en texte brut.
4. Après avoir présenté des résultats d'événements, appelle TOUJOURS "navigate_to_configuration" avec l'ID de la visite concernée pour que l'utilisateur puisse y accéder directement.
5. Pour orienter vers une visite spécifique (jeu, quiz...) "navigate_to_configuration".
6. Pour les détails d'un item "GetItemDetailsGlobal".
- NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats.
- NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide.
"""));
foreach (var h in request.History.TakeLast(MaxHistoryMessages))
@ -145,10 +386,192 @@ namespace ManagerService.Services
var tools = new List<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,9 +579,17 @@ namespace ManagerService.Services
)
};
options = new ChatOptions { Tools = tools };
var response = await _chatClient.GetResponseAsync(messages, options);
return new AiChatResponse { Reply = response.Text ?? "", Navigation = navigation };
try
{
options = new ChatOptions { Tools = tools };
var response = await _chatClient.GetResponseAsync(messages, options);
return new AiChatResponse { Reply = response.Text ?? "", Navigation = navigation };
}
catch (System.ClientModel.ClientResultException ex)
{
// Log plus de détails si possible sur l'erreur 400 de Google/OpenAI
throw new System.Exception($"AI Service Error (Status: {ex.Status}): {ex.Message}", ex);
}
}
}
}

View File

@ -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

View File

@ -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();

View File

@ -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"
}