Add migration, need to check if migration with apikey correct.. ! + add roles and security (role and apikey) - need to be tested + visitevent stats + ai etc + all need to be tested !

This commit is contained in:
Thomas Fransolet 2026-03-13 17:45:01 +01:00
parent bde1666b42
commit a452f4af04
46 changed files with 6753 additions and 156 deletions

View File

@ -0,0 +1,66 @@
using ManagerService.Data;
using ManagerService.DTOs;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSwag.Annotations;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace ManagerService.Controllers
{
[Authorize(Policy = ManagerService.Service.Security.Policies.Viewer)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("AI", Description = "Assistant IA")]
public class AiController : ControllerBase
{
private readonly AssistantService _assistantService;
private readonly MyInfoMateDbContext _context;
private readonly ILogger<AiController> _logger;
public AiController(
AssistantService assistantService,
MyInfoMateDbContext context,
ILogger<AiController> logger)
{
_assistantService = assistantService;
_context = context;
_logger = logger;
}
/// <summary>
/// Envoie un message à l'assistant IA, scopé à l'instance et optionnellement à une configuration
/// </summary>
[HttpPost("chat")]
[ProducesResponseType(typeof(AiChatResponse), 200)]
[ProducesResponseType(403)]
[ProducesResponseType(typeof(string), 500)]
public async Task<IActionResult> Chat([FromBody] AiChatRequest request)
{
try
{
// Vérifie que l'instance a activé la fonctionnalité assistant
var instance = _context.Instances.FirstOrDefault(i => i.Id == request.InstanceId);
if (instance == null || !instance.IsAssistant)
return Forbid();
// Vérifie que l'app concernée a activé l'assistant
var appInstance = _context.ApplicationInstances
.FirstOrDefault(ai => ai.InstanceId == request.InstanceId && ai.AppType == request.AppType);
if (appInstance == null || !appInstance.IsAssistant)
return Forbid();
var result = await _assistantService.ChatAsync(request);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erreur lors de l'appel à l'assistant IA");
return new ObjectResult("Une erreur est survenue") { StatusCode = 500 };
}
}
}
}

View File

@ -0,0 +1,68 @@
using ManagerService.Data;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NSwag.Annotations;
using System;
using System.Threading.Tasks;
namespace ManagerService.Controllers
{
[Authorize(Policy = ManagerService.Service.Security.Policies.InstanceAdmin)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("ApiKey", Description = "API Key management for mobile apps")]
public class ApiKeyController : ControllerBase
{
private readonly ApiKeyDatabaseService _apiKeyService;
public ApiKeyController(ApiKeyDatabaseService apiKeyService)
{
_apiKeyService = apiKeyService;
}
/// <summary>List API keys for the caller's instance</summary>
[HttpGet]
public async Task<IActionResult> GetApiKeys()
{
var instanceId = User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value;
if (string.IsNullOrEmpty(instanceId))
return Forbid();
var keys = await _apiKeyService.GetByInstanceAsync(instanceId);
return Ok(keys);
}
/// <summary>Create a new API key (plain key returned once)</summary>
[HttpPost]
public async Task<IActionResult> CreateApiKey([FromBody] CreateApiKeyRequest request)
{
if (request == null || string.IsNullOrEmpty(request.Name))
return BadRequest("Name is required");
var instanceId = User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value;
if (string.IsNullOrEmpty(instanceId))
return Forbid();
var plainKey = await _apiKeyService.CreateAsync(instanceId, request.Name, request.AppType);
return Ok(new { key = plainKey });
}
/// <summary>Revoke an API key</summary>
[HttpDelete("{id}")]
public async Task<IActionResult> RevokeApiKey(string id)
{
var instanceId = User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value;
if (string.IsNullOrEmpty(instanceId))
return Forbid();
var success = await _apiKeyService.RevokeAsync(id, instanceId);
return success ? NoContent() : NotFound();
}
}
public class CreateApiKeyRequest
{
public string Name { get; set; }
public ApiKeyAppType AppType { get; set; }
}
}

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Manager.Services;
@ -14,7 +14,7 @@ using NSwag.Annotations;
namespace ManagerService.Controllers
{
[Authorize] // TODO Add ROLES (Roles = "Admin")
[Authorize(Policy = ManagerService.Service.Security.Policies.ContentEditor)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("ApplicationInstance", Description = "Application instance management")]
public class ApplicationInstanceController : ControllerBase
@ -49,8 +49,13 @@ namespace ManagerService.Controllers
try
{
List<ApplicationInstance> applicationInstances = _myInfoMateDbContext.ApplicationInstances.Include(s => s.SectionEvent).Where(ai => ai.InstanceId == instanceId).ToList();
var instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == instanceId);
return new OkObjectResult(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).OrderBy(c => c.appType));
return new OkObjectResult(applicationInstances.Select(ai => {
var dto = ai.ToDTO(_myInfoMateDbContext);
dto.isStatistic = instance?.IsStatistic ?? false;
return dto;
}).OrderBy(c => c.appType));
}
catch (Exception ex)
{

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
@ -22,7 +22,7 @@ using NSwag.Annotations;
namespace ManagerService.Controllers
{
[Authorize] // TODO Add ROLES (Roles = "Admin")
[Authorize(Policy = ManagerService.Service.Security.Policies.ContentEditor)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("Configuration", Description = "Configuration management")]
public class ConfigurationController : ControllerBase
@ -49,7 +49,7 @@ namespace ManagerService.Controllers
/// Get a list of all configuration (summary)
/// </summary>
/// <param name="id">id instance</param>
[AllowAnonymous]
[Authorize(Policy = ManagerService.Service.Security.Policies.AppReadAccess)]
[ProducesResponseType(typeof(List<ConfigurationDTO>), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet]
@ -80,7 +80,7 @@ namespace ManagerService.Controllers
/// Get Confuguration list by instanceId' pincode
/// </summary>
/// <param name="pinCode">Code pin</param>
[AllowAnonymous]
[Authorize(Policy = ManagerService.Service.Security.Policies.AppReadAccess)]
[ProducesResponseType(typeof(List<ConfigurationDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@ -114,7 +114,7 @@ namespace ManagerService.Controllers
/// Get a specific display configuration
/// </summary>
/// <param name="id">id configuration</param>
[AllowAnonymous]
[Authorize(Policy = ManagerService.Service.Security.Policies.AppReadAccess)]
[ProducesResponseType(typeof(ConfigurationDTO), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@ -177,12 +177,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}");
}
}
}
@ -191,7 +191,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(configuration.ToDTO(sectionIds));
@ -380,7 +380,7 @@ namespace ManagerService.Controllers
/// </summary>
/// <param name="id">Id of configuration to export</param>
/// <param name="language">Language to export</param>
[AllowAnonymous]
[Authorize(Policy = ManagerService.Service.Security.Policies.AppReadAccess)]
[ProducesResponseType(typeof(FileContentResult), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Manager.Services;
@ -15,7 +15,7 @@ using NSwag.Annotations;
namespace ManagerService.Controllers
{
[Authorize] // TODO Add ROLES (Roles = "Admin")
[Authorize(Policy = ManagerService.Service.Security.Policies.InstanceAdmin)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("Device", Description = "Device management")]
public class DeviceController : ControllerBase

View File

@ -1,6 +1,7 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Manager.Services;
using ManagerService.Data;
using ManagerService.DTOs;
@ -13,7 +14,7 @@ using NSwag.Annotations;
namespace ManagerService.Controllers
{
[Authorize] // TODO Add ROLES (Roles = "Admin")
[Authorize(Policy = ManagerService.Service.Security.Policies.SuperAdmin)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("Instance", Description = "Instance management")]
public class InstanceController : ControllerBase
@ -24,15 +25,17 @@ namespace ManagerService.Controllers
private UserDatabaseService _userService;
private readonly ILogger<InstanceController> _logger;
private readonly ProfileLogic _profileLogic;
private readonly ApiKeyDatabaseService _apiKeyService;
IHexIdGeneratorService idService = new HexIdGeneratorService();
public InstanceController(ILogger<InstanceController> logger, InstanceDatabaseService instanceService, UserDatabaseService userService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext)
public InstanceController(ILogger<InstanceController> logger, InstanceDatabaseService instanceService, UserDatabaseService userService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext, ApiKeyDatabaseService apiKeyService)
{
_logger = logger;
_instanceService = instanceService;
_userService = userService;
_profileLogic = profileLogic;
_myInfoMateDbContext = myInfoMateDbContext;
_apiKeyService = apiKeyService;
}
/// <summary>
@ -223,6 +226,33 @@ namespace ManagerService.Controllers
}
/// <summary>
/// Bootstrap: get (or create) an API key for a Flutter app by PIN code
/// </summary>
/// <param name="pinCode">Instance PIN code</param>
/// <param name="appType">App type (VisitApp, TabletApp, Other)</param>
[AllowAnonymous]
[ProducesResponseType(typeof(object), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("app-key")]
public async Task<ObjectResult> GetAppKeyByPin([FromQuery] string pinCode, [FromQuery] ApiKeyAppType appType)
{
try
{
var instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.PinCode == pinCode);
if (instance == null)
return new NotFoundObjectResult("Instance not found");
var key = await _apiKeyService.GetOrCreateByPinAsync(instance.Id, appType);
return new OkObjectResult(new { key, instanceId = instance.Id });
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Delete an instance
/// </summary>

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
@ -19,7 +19,7 @@ using NSwag.Annotations;
namespace ManagerService.Controllers
{
[Authorize] // TODO Add ROLES (Roles = "Admin")
[Authorize(Policy = ManagerService.Service.Security.Policies.ContentEditor)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("Resource", Description = "Resource management")]
public class ResourceController : ControllerBase

View File

@ -1,4 +1,4 @@
using ManagerService.Data;
using ManagerService.Data;
using ManagerService.Data.SubSection;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
@ -15,7 +15,7 @@ using ManagerService.Helpers;
namespace ManagerService.Controllers
{
[Authorize] // TODO Add ROLES (Roles = "Admin")
[Authorize(Policy = ManagerService.Service.Security.Policies.ContentEditor)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("Section agenda", Description = "Section agenda management")]
public class SectionAgendaController : ControllerBase

View File

@ -1,4 +1,4 @@
using Manager.DTOs;
using Manager.DTOs;
using Manager.Helpers;
using Manager.Interfaces.Models;
using Manager.Services;
@ -26,7 +26,7 @@ using static ManagerService.Data.SubSection.SectionEvent;
namespace ManagerService.Controllers
{
[Authorize] // TODO Add ROLES (Roles = "Admin")
[Authorize(Policy = ManagerService.Service.Security.Policies.ContentEditor)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("Section", Description = "Section management")]
public class SectionController : ControllerBase
@ -367,12 +367,12 @@ namespace ManagerService.Controllers
}
else
{
Console.WriteLine("Aucune ville trouvée.");
Console.WriteLine("Aucune ville trouvée.");
}
}
catch (HttpRequestException e)
{
Console.WriteLine($"Une erreur s'est produite lors de la requête HTTP : {e.Message}");
Console.WriteLine($"Une erreur s'est produite lors de la requête HTTP : {e.Message}");
}
}
}
@ -382,7 +382,7 @@ namespace ManagerService.Controllers
}
catch (Exception e)
{
Console.WriteLine($"Une erreur s'est produite lors de la mise à jour des sections de type météo : {e.Message}");
Console.WriteLine($"Une erreur s'est produite lors de la mise à jour des sections de type météo : {e.Message}");
}
return new OkObjectResult(sectionsToReturn);
@ -684,13 +684,13 @@ namespace ManagerService.Controllers
// UPDATE OTHER ORDER
var sections = _myInfoMateDbContext.Sections.Where(s => s.ConfigurationId == newSection.configurationId && !s.IsSubSection).OrderBy(s => s.Order).ToList();
// Retirer la question déplacée
// Retirer la question déplacée
sections.RemoveAll(q => q.Id == section.Id);
// Insérer à la première position (déjà en 0-based)
// Insérer à la première position (déjà en 0-based)
sections.Insert(0, section);
// Réassigner les ordres en 0-based
// Réassigner les ordres en 0-based
for (int i = 0; i < sections.Count; i++)
{
sections[i].Order = i;
@ -787,7 +787,7 @@ namespace ManagerService.Controllers
if (jsonElement.ValueKind == JsonValueKind.Null)
throw new ArgumentNullException("Section param is null");
// Désérialisation de jsonElement en SectionDTO
// Désérialisation de jsonElement en SectionDTO
sectionDTO = JsonConvert.DeserializeObject<SectionDTO>(jsonElement.ToString());
}
else
@ -808,15 +808,15 @@ namespace ManagerService.Controllers
// If subsection, check if order changed
var subSections = _myInfoMateDbContext.Sections.Where(s => s.ParentId == existingSection.ParentId).OrderBy(s => s.Order).ToList();
// Retirer la sous section déplacée
// Retirer la sous section déplacée
subSections.RemoveAll(q => q.Id == existingSection.Id);
// Insérer à la nouvelle position (déjà en 0-based)
// Insérer à la nouvelle position (déjà en 0-based)
int newIndex = sectionDTO.order.Value;
newIndex = Math.Clamp(newIndex, 0, subSections.Count);
subSections.Insert(newIndex, existingSection);
// Réassigner les ordres en 0-based
// Réassigner les ordres en 0-based
for (int i = 0; i < subSections.Count; i++)
{
subSections[i].Order = i;

View File

@ -1,4 +1,4 @@
using ManagerService.Data;
using ManagerService.Data;
using ManagerService.Data.SubSection;
using ManagerService.DTOs;
using ManagerService.Helpers;
@ -16,7 +16,7 @@ using static ManagerService.Data.SubSection.SectionEvent;
namespace ManagerService.Controllers
{
[Authorize] // TODO Add ROLES (Roles = "Admin")
[Authorize(Policy = ManagerService.Service.Security.Policies.ContentEditor)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("Section event", Description = "Section event management")]
public class SectionEventController : ControllerBase

View File

@ -1,4 +1,4 @@
using Manager.DTOs;
using Manager.DTOs;
using ManagerService.Data;
using ManagerService.Data.SubSection;
using ManagerService.DTOs;
@ -16,7 +16,7 @@ using System.Linq;
namespace ManagerService.Controllers
{
[Authorize] // TODO Add ROLES (Roles = "Admin")
[Authorize(Policy = ManagerService.Service.Security.Policies.ContentEditor)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("Section map", Description = "Section map management")]
public class SectionMapController : ControllerBase
@ -658,7 +658,7 @@ namespace ManagerService.Controllers
existingGuidedStep.ImageUrl = guidedStepDTO.imageUrl;
existingGuidedStep.TriggerGeoPointId = guidedStepDTO.triggerGeoPointId;
existingGuidedStep.IsHiddenInitially = guidedStepDTO.isHiddenInitially;
existingGuidedStep.QuizQuestions = guidedStepDTO.quizQuestions; // à convertir si besoin ?
existingGuidedStep.QuizQuestions = guidedStepDTO.quizQuestions; // à convertir si besoin ?
existingGuidedStep.IsStepTimer = guidedStepDTO.isStepTimer;
existingGuidedStep.IsStepLocked = guidedStepDTO.isStepLocked;
existingGuidedStep.TimerSeconds = guidedStepDTO.timerSeconds;

View File

@ -1,4 +1,4 @@
using Manager.DTOs;
using Manager.DTOs;
using Manager.Helpers;
using ManagerService.Data;
using ManagerService.Data.SubSection;
@ -21,7 +21,7 @@ using System.Text.Json;
namespace ManagerService.Controllers
{
[Authorize] // TODO Add ROLES (Roles = "Admin")
[Authorize(Policy = ManagerService.Service.Security.Policies.ContentEditor)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("Section quiz", Description = "Section quiz management")]
public class SectionQuizController : ControllerBase
@ -173,15 +173,15 @@ namespace ManagerService.Controllers
var questions = existingSection.QuizQuestions.OrderBy(q => q.Order).ToList();
// Retirer la question déplacée
// Retirer la question déplacée
questions.RemoveAll(q => q.Id == existingQuestion.Id);
// Insérer à la nouvelle position (déjà en 0-based)
// Insérer à la nouvelle position (déjà en 0-based)
int newIndex = questionDTO.order.Value;
newIndex = Math.Clamp(newIndex, 0, questions.Count);
questions.Insert(newIndex, existingQuestion);
// Réassigner les ordres en 0-based
// Réassigner les ordres en 0-based
for (int i = 0; i < questions.Count; i++)
{
questions[i].Order = i;

View File

@ -0,0 +1,274 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using ManagerService.Data;
using ManagerService.DTOs;
using ManagerService.Helpers;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NSwag.Annotations;
namespace ManagerService.Controllers
{
[ApiController, Route("api/[controller]")]
[OpenApiTag("Stats", Description = "Visit statistics tracking and summary")]
public class StatsController : ControllerBase
{
private readonly MyInfoMateDbContext _db;
IHexIdGeneratorService _idService = new HexIdGeneratorService();
public StatsController(MyInfoMateDbContext db)
{
_db = db;
}
/// <summary>Track a single visit event (anonymous)</summary>
[AllowAnonymous]
[HttpPost("event")]
[ProducesResponseType(204)]
[ProducesResponseType(typeof(string), 400)]
public IActionResult TrackEvent([FromBody] VisitEventDTO dto)
{
try
{
if (dto == null || string.IsNullOrEmpty(dto.instanceId))
return BadRequest("instanceId is required");
if (!Enum.TryParse<VisitEventType>(dto.eventType, ignoreCase: true, out var eventType))
return BadRequest($"Unknown eventType: {dto.eventType}");
if (!Enum.TryParse<AppType>(dto.appType, ignoreCase: true, out var appType))
appType = AppType.Mobile;
var visitEvent = new VisitEvent
{
Id = _idService.GenerateHexId(),
InstanceId = dto.instanceId,
ConfigurationId = dto.configurationId,
SectionId = dto.sectionId,
SessionId = dto.sessionId ?? "unknown",
EventType = eventType,
AppType = appType,
Language = dto.language,
DurationSeconds = dto.durationSeconds,
Metadata = dto.metadata,
Timestamp = dto.timestamp?.ToUniversalTime() ?? DateTime.UtcNow
};
_db.VisitEvents.Add(visitEvent);
_db.SaveChanges();
return NoContent();
}
catch (Exception ex)
{
return StatusCode(500, ex.Message);
}
}
/// <summary>Get aggregated statistics for an instance</summary>
[Authorize(Policy = ManagerService.Service.Security.Policies.Viewer)]
[HttpGet("summary")]
[ProducesResponseType(typeof(StatsSummaryDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
public IActionResult GetSummary([FromQuery] string instanceId, [FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] string? appType)
{
try
{
if (string.IsNullOrEmpty(instanceId))
return BadRequest("instanceId is required");
var fromDate = (from ?? DateTime.UtcNow.AddDays(-30)).ToUniversalTime();
var toDate = (to ?? DateTime.UtcNow).ToUniversalTime();
var eventsQuery = _db.VisitEvents
.Where(e => e.InstanceId == instanceId && e.Timestamp >= fromDate && e.Timestamp <= toDate);
if (!string.IsNullOrEmpty(appType) && Enum.TryParse<AppType>(appType, ignoreCase: true, out var appTypeFilter))
eventsQuery = eventsQuery.Where(e => e.AppType == appTypeFilter);
var events = eventsQuery.ToList();
var summary = new StatsSummaryDTO();
// Sessions
summary.TotalSessions = events.Select(e => e.SessionId).Distinct().Count();
// Avg visit duration (from SectionLeave events with duration)
var leaveEvents = events.Where(e => e.EventType == VisitEventType.SectionLeave && e.DurationSeconds.HasValue).ToList();
if (leaveEvents.Any())
{
var sessionsWithDuration = leaveEvents
.GroupBy(e => e.SessionId)
.Select(g => g.Sum(e => e.DurationSeconds!.Value));
summary.AvgVisitDurationSeconds = (int)sessionsWithDuration.Average();
}
// Top sections
var sectionViews = events
.Where(e => e.EventType == VisitEventType.SectionView && e.SectionId != null)
.GroupBy(e => e.SectionId!)
.Select(g =>
{
var leaveDurations = leaveEvents
.Where(l => l.SectionId == g.Key)
.Select(l => l.DurationSeconds!.Value)
.ToList();
return new SectionStatDTO
{
SectionId = g.Key,
SectionTitle = g.Key, // apps can enrich with section titles client-side
Views = g.Count(),
AvgDurationSeconds = leaveDurations.Any() ? (int)leaveDurations.Average() : 0
};
})
.OrderByDescending(s => s.Views)
.Take(10)
.ToList();
summary.TopSections = sectionViews;
// Visits by day
summary.VisitsByDay = events
.Where(e => e.EventType == VisitEventType.SectionView)
.GroupBy(e => e.Timestamp.Date.ToString("yyyy-MM-dd"))
.Select(g => new DayStatDTO
{
Date = g.Key,
Total = g.Count(),
Mobile = g.Count(e => e.AppType == AppType.Mobile),
Tablet = g.Count(e => e.AppType == AppType.Tablet)
})
.OrderBy(d => d.Date)
.ToList();
// Language distribution
summary.LanguageDistribution = events
.Where(e => e.Language != null)
.GroupBy(e => e.Language!)
.ToDictionary(g => g.Key, g => g.Count());
// AppType distribution
summary.AppTypeDistribution = events
.GroupBy(e => e.AppType.ToString())
.ToDictionary(g => g.Key, g => g.Count());
// Top POIs
var poiEvents = events.Where(e => e.EventType == VisitEventType.MapPoiTap && e.Metadata != null).ToList();
var poiGroups = new Dictionary<int, (string title, int taps, string sectionId)>();
foreach (var ev in poiEvents)
{
try
{
var meta = JsonSerializer.Deserialize<JsonElement>(ev.Metadata!);
if (meta.TryGetProperty("geoPointId", out var idEl) && meta.TryGetProperty("geoPointTitle", out var titleEl))
{
int id = idEl.GetInt32();
string title = titleEl.GetString() ?? "";
string sectionId = ev.SectionId ?? "";
if (poiGroups.TryGetValue(id, out var existing))
poiGroups[id] = (existing.title, existing.taps + 1, existing.sectionId);
else
poiGroups[id] = (title, 1, sectionId);
}
}
catch { /* skip malformed metadata */ }
}
summary.TopPois = poiGroups
.Select(kv => new PoiStatDTO { GeoPointId = kv.Key, Title = kv.Value.title, Taps = kv.Value.taps, SectionId = kv.Value.sectionId })
.OrderByDescending(p => p.Taps)
.Take(10)
.ToList();
// Top agenda events
var agendaEvents = events.Where(e => e.EventType == VisitEventType.AgendaEventTap && e.Metadata != null).ToList();
var agendaGroups = new Dictionary<string, (string title, int taps)>();
foreach (var ev in agendaEvents)
{
try
{
var meta = JsonSerializer.Deserialize<JsonElement>(ev.Metadata!);
if (meta.TryGetProperty("eventId", out var idEl) && meta.TryGetProperty("eventTitle", out var titleEl))
{
string id = idEl.GetString() ?? "";
string title = titleEl.GetString() ?? "";
if (agendaGroups.TryGetValue(id, out var existing))
agendaGroups[id] = (existing.title, existing.taps + 1);
else
agendaGroups[id] = (title, 1);
}
}
catch { /* skip */ }
}
summary.TopAgendaEvents = agendaGroups
.Select(kv => new AgendaEventStatDTO { EventId = kv.Key, EventTitle = kv.Value.title, Taps = kv.Value.taps })
.OrderByDescending(a => a.Taps)
.Take(10)
.ToList();
// Quiz stats
var quizEvents = events.Where(e => e.EventType == VisitEventType.QuizComplete && e.SectionId != null && e.Metadata != null).ToList();
summary.QuizStats = quizEvents
.GroupBy(e => e.SectionId!)
.Select(g =>
{
var scores = g.Select(e =>
{
try
{
var meta = JsonSerializer.Deserialize<JsonElement>(e.Metadata!);
double score = meta.TryGetProperty("score", out var s) ? s.GetDouble() : 0;
int total = meta.TryGetProperty("totalQuestions", out var t) ? t.GetInt32() : 1;
return (score, total);
}
catch { return (score: 0.0, total: 1); }
}).ToList();
return new QuizStatDTO
{
SectionId = g.Key,
SectionTitle = g.Key,
Completions = g.Count(),
AvgScore = scores.Any() ? scores.Average(s => s.score) : 0,
TotalQuestions = scores.Any() ? (int)scores.Average(s => s.total) : 0
};
})
.ToList();
// Game stats
var gameEvents = events.Where(e => e.EventType == VisitEventType.GameComplete && e.Metadata != null).ToList();
var gameGroups = new Dictionary<string, (int completions, List<int> durations)>();
foreach (var ev in gameEvents)
{
try
{
var meta = JsonSerializer.Deserialize<JsonElement>(ev.Metadata!);
if (meta.TryGetProperty("gameType", out var typeEl))
{
string gameType = typeEl.GetString() ?? "Unknown";
int duration = meta.TryGetProperty("durationSeconds", out var dEl) ? dEl.GetInt32() : 0;
if (!gameGroups.ContainsKey(gameType))
gameGroups[gameType] = (0, new List<int>());
gameGroups[gameType] = (gameGroups[gameType].completions + 1, gameGroups[gameType].durations.Append(duration).ToList());
}
}
catch { /* skip */ }
}
summary.GameStats = gameGroups
.Select(kv => new GameStatDTO
{
GameType = kv.Key,
Completions = kv.Value.completions,
AvgDurationSeconds = kv.Value.durations.Any() ? (int)kv.Value.durations.Average() : 0
})
.ToList();
return Ok(summary);
}
catch (Exception ex)
{
return StatusCode(500, ex.Message);
}
}
}
}

View File

@ -1,11 +1,12 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using Manager.Services;
using ManagerService.Data;
using ManagerService.DTOs;
using ManagerService.Helpers;
using ManagerService.Service.Services;
using ManagerService.Service;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -14,42 +15,55 @@ using NSwag.Annotations;
namespace ManagerService.Controllers
{
[Authorize] // TODO Add ROLES (Roles = "Admin")
[Authorize(Policy = ManagerService.Service.Security.Policies.InstanceAdmin)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("User", Description = "User management")]
public class UserController : ControllerBase
{
private UserDatabaseService _userService;
private TokensService _tokenService;
private readonly ILogger<UserController> _logger;
private readonly ProfileLogic _profileLogic;
private readonly MyInfoMateDbContext _myInfoMateDbContext;
IHexIdGeneratorService idService = new HexIdGeneratorService();
public UserController(ILogger<UserController> logger, UserDatabaseService userService, TokensService tokenService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext)
public UserController(ILogger<UserController> logger, UserDatabaseService userService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext)
{
_logger = logger;
_userService = userService;
_tokenService = tokenService;
_profileLogic = profileLogic;
_myInfoMateDbContext = myInfoMateDbContext;
}
private string? GetCallerInstanceId() =>
User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value;
private bool IsSuperAdmin() =>
User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.SuperAdmin);
private UserRole GetCallerRole()
{
if (User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.SuperAdmin)) return UserRole.SuperAdmin;
if (User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.InstanceAdmin)) return UserRole.InstanceAdmin;
if (User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.ContentEditor)) return UserRole.ContentEditor;
return UserRole.Viewer;
}
/// <summary>
/// Get a list of user
/// Get a list of user
/// </summary>
[ProducesResponseType(typeof(List<User>), 200)]
[ProducesResponseType(typeof(List<UserDetailDTO>), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet]
public ObjectResult Get()
{
try
{
//List<OldUser> users = _userService.GetAll();
List<User> users= _myInfoMateDbContext.Users.ToList();
var query = _myInfoMateDbContext.Users.AsQueryable();
return new OkObjectResult(users);
if (!IsSuperAdmin())
query = query.Where(u => u.InstanceId == GetCallerInstanceId());
return new OkObjectResult(query.ToList().Select(u => u.ToDTO()));
}
catch (Exception ex)
{
@ -57,11 +71,10 @@ namespace ManagerService.Controllers
}
}
/// <summary>
/// Get a specific user
/// Get a specific user
/// </summary>
/// <param name="id">id user</param>
/// <param name="id">id user</param>
[ProducesResponseType(typeof(UserDetailDTO), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@ -71,7 +84,6 @@ namespace ManagerService.Controllers
try
{
User user = _myInfoMateDbContext.Users.FirstOrDefault(i => i.Id == id);
//OldUser user = _userService.GetById(id);
if (user == null)
throw new KeyNotFoundException("This user was not found");
@ -91,8 +103,7 @@ namespace ManagerService.Controllers
/// <summary>
/// Create an user
/// </summary>
/// <param name="newUser">New user info</param>
//[AllowAnonymous]
/// <param name="newUserDTO">New user info</param>
[ProducesResponseType(typeof(UserDetailDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 409)]
@ -108,24 +119,27 @@ namespace ManagerService.Controllers
if (newUserDTO.instanceId == null)
throw new ArgumentNullException("InstanceId is null");
if (newUserDTO.password == null)
throw new ArgumentNullException("Password is null");
var requestedRole = newUserDTO.role ?? UserRole.ContentEditor;
if (requestedRole < GetCallerRole())
throw new UnauthorizedAccessException("Cannot assign a role higher than your own");
User newUser = new User();
newUser.InstanceId = newUserDTO.instanceId;
newUser.Email = newUserDTO.email;
newUser.FirstName = newUserDTO.firstName;
newUser.LastName = newUserDTO.lastName;
newUser.Token = _tokenService.GenerateToken(newUser.Email).ToString();
newUser.Role = requestedRole;
newUser.Token = Guid.NewGuid().ToString();
newUser.DateCreation = DateTime.Now.ToUniversalTime();
newUser.Id = idService.GenerateHexId();
List<User> users= _myInfoMateDbContext.Users.ToList();
//List<OldUser> users = _userService.GetAll();
if (users.Select(u => u.Email).Contains(newUser.Email))
if (_myInfoMateDbContext.Users.Any(u => u.Email == newUser.Email))
throw new InvalidOperationException("This Email is already used");
newUser.Password = _profileLogic.HashPassword(newUser.Password);
//OldUser userCreated = _userService.Create(newUser);
newUser.Password = _profileLogic.HashPassword(newUserDTO.password);
_myInfoMateDbContext.Add(newUser);
_myInfoMateDbContext.SaveChanges();
@ -136,6 +150,10 @@ namespace ManagerService.Controllers
{
return new BadRequestObjectResult(ex.Message) {};
}
catch (UnauthorizedAccessException ex)
{
return new ObjectResult(ex.Message) { StatusCode = 403 };
}
catch (InvalidOperationException ex)
{
return new ConflictObjectResult(ex.Message) {};
@ -146,11 +164,10 @@ namespace ManagerService.Controllers
}
}
/// <summary>
/// Update an user
/// </summary>
/// <param name="updatedUser">User to update</param>
/// <param name="updatedUser">User to update</param>
[ProducesResponseType(typeof(UserDetailDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
@ -163,16 +180,20 @@ namespace ManagerService.Controllers
if (updatedUser == null)
throw new ArgumentNullException("User param is null");
User user = _myInfoMateDbContext.Users.FirstOrDefault(u => u.Id == updatedUser.id);
//OldUser user = _userService.GetById(updatedUser.Id);
User user = _myInfoMateDbContext.Users.FirstOrDefault(u => u.Id == updatedUser.id);
if (user == null)
throw new KeyNotFoundException("User does not exist");
//OldUser userModified = _userService.Update(updatedUser.Id, updatedUser);
user.FirstName = updatedUser.firstName;
user.LastName = updatedUser.lastName;
// TODO other field ?
if (updatedUser.role.HasValue)
{
if (updatedUser.role.Value < GetCallerRole())
throw new UnauthorizedAccessException("Cannot assign a role higher than your own");
user.Role = updatedUser.role.Value;
}
_myInfoMateDbContext.SaveChanges();
@ -182,6 +203,10 @@ namespace ManagerService.Controllers
{
return new BadRequestObjectResult(ex.Message) {};
}
catch (UnauthorizedAccessException ex)
{
return new ObjectResult(ex.Message) { StatusCode = 403 };
}
catch (KeyNotFoundException ex)
{
return new NotFoundObjectResult(ex.Message) {};
@ -192,11 +217,10 @@ namespace ManagerService.Controllers
}
}
/// <summary>
/// Delete an user
/// </summary>
/// <param name="id">Id of user to delete</param>
/// <param name="id">Id of user to delete</param>
[ProducesResponseType(typeof(string), 202)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
@ -209,18 +233,15 @@ namespace ManagerService.Controllers
if (id == null)
throw new ArgumentNullException("User param is null");
User user = _myInfoMateDbContext.Users.FirstOrDefault(u => u.Id == id);
//OldUser user = _userService.GetById(id);
User user = _myInfoMateDbContext.Users.FirstOrDefault(u => u.Id == id);
if (user == null)
throw new KeyNotFoundException("User does not exist");
//_userService.Remove(id);
_myInfoMateDbContext.Remove(user);
_myInfoMateDbContext.SaveChanges();
return new ObjectResult("The user has been deleted") { StatusCode = 202 };
}
catch (ArgumentNullException ex)
{

View File

@ -0,0 +1,42 @@
using ManagerService.Data;
using System.Collections.Generic;
namespace ManagerService.DTOs
{
public class AiChatRequest
{
public string Message { get; set; }
public string InstanceId { get; set; }
public AppType AppType { get; set; }
public string ConfigurationId { get; set; } // null = scope instance, fourni = scope configuration
public string Language { get; set; }
public List<AiChatMessage> History { get; set; } = new();
}
public class AiChatMessage
{
public string Role { get; set; } // "user" | "assistant"
public string Content { get; set; }
}
public class AiCardDTO
{
public string Title { get; set; }
public string Subtitle { get; set; }
public string? Icon { get; set; }
}
public class NavigationActionDTO
{
public string SectionId { get; set; }
public string SectionTitle { get; set; }
public string SectionType { get; set; }
}
public class AiChatResponse
{
public string Reply { get; set; }
public List<AiCardDTO>? Cards { get; set; }
public NavigationActionDTO? Navigation { get; set; }
}
}

View File

@ -33,5 +33,9 @@ namespace ManagerService.DTOs
public string sectionEventId { get; set; }
public SectionEventDTO? sectionEventDTO { get; set; }
public bool isAssistant { get; set; }
public bool isStatistic { get; set; }
}
}

View File

@ -16,6 +16,8 @@ namespace ManagerService.DTOs
public bool isWeb { get; set; }
public bool isVR { get; set; }
public bool isAssistant { get; set; }
public List<ApplicationInstanceDTO> applicationInstanceDTOs { get; set; }
}
}

View File

@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
namespace ManagerService.DTOs
{
public class StatsSummaryDTO
{
public int TotalSessions { get; set; }
public int AvgVisitDurationSeconds { get; set; }
public List<SectionStatDTO> TopSections { get; set; } = new();
public List<DayStatDTO> VisitsByDay { get; set; } = new();
public Dictionary<string, int> LanguageDistribution { get; set; } = new();
public Dictionary<string, int> AppTypeDistribution { get; set; } = new();
public List<PoiStatDTO> TopPois { get; set; } = new();
public List<AgendaEventStatDTO> TopAgendaEvents { get; set; } = new();
public List<QuizStatDTO> QuizStats { get; set; } = new();
public List<GameStatDTO> GameStats { get; set; } = new();
}
public class SectionStatDTO
{
public string SectionId { get; set; }
public string SectionTitle { get; set; }
public int Views { get; set; }
public int AvgDurationSeconds { get; set; }
}
public class DayStatDTO
{
public string Date { get; set; } // "2026-03-12"
public int Total { get; set; }
public int Mobile { get; set; }
public int Tablet { get; set; }
}
public class PoiStatDTO
{
public int GeoPointId { get; set; }
public string Title { get; set; }
public int Taps { get; set; }
public string SectionId { get; set; }
}
public class AgendaEventStatDTO
{
public string EventId { get; set; }
public string EventTitle { get; set; }
public int Taps { get; set; }
}
public class QuizStatDTO
{
public string SectionId { get; set; }
public string SectionTitle { get; set; }
public double AvgScore { get; set; }
public int TotalQuestions { get; set; }
public int Completions { get; set; }
}
public class GameStatDTO
{
public string GameType { get; set; }
public int Completions { get; set; }
public int AvgDurationSeconds { get; set; }
}
}

View File

@ -1,4 +1,5 @@
using System;
using ManagerService.Data;
namespace ManagerService.DTOs
{
@ -12,5 +13,6 @@ namespace ManagerService.DTOs
public DateTimeOffset expiration { get; set; }
public string instanceId { get; set; }
public string pinCode { get; set; }
public UserRole role { get; set; }
}
}

View File

@ -1,4 +1,6 @@
namespace ManagerService.DTOs
using ManagerService.Data;
namespace ManagerService.DTOs
{
public class UserDetailDTO
{
@ -7,5 +9,7 @@
public string firstName { get; set; }
public string lastName { get; set; }
public string instanceId { get; set; }
public UserRole? role { get; set; }
public string? password { get; set; }
}
}

View File

@ -0,0 +1,18 @@
using System;
namespace ManagerService.DTOs
{
public class VisitEventDTO
{
public string instanceId { get; set; }
public string? configurationId { get; set; }
public string? sectionId { get; set; }
public string sessionId { get; set; }
public string eventType { get; set; } // VisitEventType as string
public string appType { get; set; } // "Mobile" / "Tablet"
public string? language { get; set; }
public int? durationSeconds { get; set; }
public string? metadata { get; set; } // JSON string
public DateTime? timestamp { get; set; }
}
}

View File

@ -0,0 +1,33 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace ManagerService.Data
{
public class ApiKey
{
[Key]
public string Id { get; set; }
/// <summary>Plain text key — only for PIN-bootstrapped keys (re-retrievable)</summary>
public string? Key { get; set; }
/// <summary>SHA-256 hash — for manually created keys (returned plain text only once)</summary>
public string? KeyHash { get; set; }
[Required]
public string Name { get; set; }
[Required]
public string InstanceId { get; set; }
public Instance Instance { get; set; }
public ApiKeyAppType AppType { get; set; }
public bool IsActive { get; set; } = true;
public DateTime DateCreation { get; set; }
public DateTime? DateExpiration { get; set; }
}
}

View File

@ -0,0 +1,4 @@
namespace ManagerService.Data
{
public enum ApiKeyAppType { VisitApp, TabletApp, Other }
}

View File

@ -44,7 +44,9 @@ namespace ManagerService.Data
public string? SectionEventId { get; set; } // Specific Mobile et web(?)
[ForeignKey("SectionEventId")]
public SectionEvent? SectionEvent { get; set; } // => To Display in large a event with countdown (in mobile app).
public SectionEvent? SectionEvent { get; set; } // => To Display in large a event with countdown (in mobile app).
public bool IsAssistant { get; set; } = false;
public ApplicationInstanceDTO ToDTO(MyInfoMateDbContext myInfoMateDbContext)
@ -72,7 +74,8 @@ namespace ManagerService.Data
layoutMainPage = LayoutMainPage,
languages = Languages,
sectionEventId = SectionEventId,
sectionEventDTO = sectionEventDTO
sectionEventDTO = sectionEventDTO,
isAssistant = IsAssistant
};
}
@ -90,6 +93,7 @@ namespace ManagerService.Data
Languages = dto.languages;
Configurations = dto.configurations;
SectionEventId = dto.sectionEventId;
IsAssistant = dto.isAssistant;
return this;
}

View File

@ -33,6 +33,8 @@ namespace ManagerService.Data
public bool IsVR { get; set; }
public bool IsAssistant { get; set; }
public InstanceDTO ToDTO(List<ApplicationInstanceDTO> applicationInstanceDTOs)
{
@ -48,6 +50,7 @@ namespace ManagerService.Data
isTablet = IsTablet,
isWeb = IsWeb,
isVR = IsVR,
isAssistant = IsAssistant,
applicationInstanceDTOs = applicationInstanceDTOs
};
}
@ -63,6 +66,7 @@ namespace ManagerService.Data
IsTablet = instanceDTO.isTablet;
IsWeb = instanceDTO.isWeb;
IsVR = instanceDTO.isVR;
IsAssistant = instanceDTO.isAssistant;
return this;
}

View File

@ -39,6 +39,12 @@ namespace ManagerService.Data
// Agenda
public DbSet<EventAgenda> EventAgendas { get; set; }
// Statistics
public DbSet<VisitEvent> VisitEvents { get; set; }
// API Keys
public DbSet<ApiKey> ApiKeys { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{

View File

@ -47,6 +47,8 @@ namespace ManagerService.Data
[Required]
public string InstanceId { get; set; }
public UserRole Role { get; set; } = UserRole.ContentEditor;
public UserDetailDTO ToDTO()
{
return new UserDetailDTO()
@ -56,6 +58,7 @@ namespace ManagerService.Data
firstName = FirstName,
lastName = LastName,
instanceId = InstanceId,
role = Role,
};
}

View File

@ -0,0 +1,10 @@
namespace ManagerService.Data
{
public enum UserRole
{
SuperAdmin = 0,
InstanceAdmin = 1,
ContentEditor = 2,
Viewer = 3
}
}

View File

@ -0,0 +1,60 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace ManagerService.Data
{
[Index(nameof(InstanceId))]
[Index(nameof(Timestamp))]
public class VisitEvent
{
[Key]
public string Id { get; set; }
[Required]
public string InstanceId { get; set; }
public string? ConfigurationId { get; set; }
public string? SectionId { get; set; }
[Required]
public string SessionId { get; set; }
public VisitEventType EventType { get; set; }
public AppType AppType { get; set; }
public string? Language { get; set; }
public int? DurationSeconds { get; set; }
/// <summary>
/// JSON string for sub-event data.
/// MapPoiTap: {"geoPointId":42,"geoPointTitle":"Fontaine","categoryId":3}
/// QrScan: {"valid":true,"sectionId":"abc"}
/// QuizComplete: {"score":7,"totalQuestions":10}
/// GameComplete: {"gameType":"Puzzle","durationSeconds":120}
/// AgendaEventTap: {"eventId":"xyz","eventTitle":"Atelier","eventDate":"2026-04-01"}
/// MenuItemTap: {"targetSectionId":"abc","menuItemTitle":"Carte"}
/// AssistantMessage: {"configurationId":"abc"}
/// </summary>
public string? Metadata { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
public enum VisitEventType
{
SectionView,
SectionLeave,
MapPoiTap,
QrScan,
QuizComplete,
GameComplete,
AgendaEventTap,
MenuItemTap,
AssistantMessage
}
}

View File

@ -12,6 +12,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.AI" Version="9.3.0-preview.1.25161.3" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.3.0-preview.1.25161.3" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.2.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.14.0" />
<PackageReference Include="MongoDB.Driver" Version="2.19.0" />

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddAIAssistant : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsAssistant",
table: "Instances",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsAssistant",
table: "ApplicationInstances",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsAssistant",
table: "Instances");
migrationBuilder.DropColumn(
name: "IsAssistant",
table: "ApplicationInstances");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddVisitEvent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "VisitEvents",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
InstanceId = table.Column<string>(type: "text", nullable: false),
ConfigurationId = table.Column<string>(type: "text", nullable: true),
SectionId = table.Column<string>(type: "text", nullable: true),
SessionId = table.Column<string>(type: "text", nullable: false),
EventType = table.Column<int>(type: "integer", nullable: false),
AppType = table.Column<int>(type: "integer", nullable: false),
Language = table.Column<string>(type: "text", nullable: true),
DurationSeconds = table.Column<int>(type: "integer", nullable: true),
Metadata = table.Column<string>(type: "text", nullable: true),
Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VisitEvents", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_VisitEvents_InstanceId",
table: "VisitEvents",
column: "InstanceId");
migrationBuilder.CreateIndex(
name: "IX_VisitEvents_Timestamp",
table: "VisitEvents",
column: "Timestamp");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "VisitEvents");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddUserRole : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Role",
table: "Users",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "ApiKeys",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
Key = table.Column<string>(type: "text", nullable: true),
KeyHash = table.Column<string>(type: "text", nullable: true),
Name = table.Column<string>(type: "text", nullable: false),
InstanceId = table.Column<string>(type: "text", nullable: false),
AppType = table.Column<int>(type: "integer", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
DateCreation = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateExpiration = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ApiKeys", x => x.Id);
table.ForeignKey(
name: "FK_ApiKeys_Instances_InstanceId",
column: x => x.InstanceId,
principalTable: "Instances",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ApiKeys_InstanceId",
table: "ApiKeys",
column: "InstanceId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApiKeys");
migrationBuilder.DropColumn(
name: "Role",
table: "Users");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddApiKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@ -27,6 +27,44 @@ namespace ManagerService.Migrations
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ManagerService.Data.ApiKey", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AppType")
.HasColumnType("integer");
b.Property<DateTime>("DateCreation")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateExpiration")
.HasColumnType("timestamp with time zone");
b.Property<string>("InstanceId")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("KeyHash")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("ManagerService.Data.AppConfigurationLink", b =>
{
b.Property<string>("Id")
@ -105,6 +143,9 @@ namespace ManagerService.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsAssistant")
.HasColumnType("boolean");
b.PrimitiveCollection<List<string>>("Languages")
.HasColumnType("text[]");
@ -258,6 +299,9 @@ namespace ManagerService.Migrations
b.Property<DateTime>("DateCreation")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsAssistant")
.HasColumnType("boolean");
b.Property<bool>("IsMobile")
.HasColumnType("boolean");
@ -778,6 +822,9 @@ namespace ManagerService.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<int>("Role")
.HasColumnType("integer");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("text");
@ -787,6 +834,52 @@ namespace ManagerService.Migrations
b.ToTable("Users");
});
modelBuilder.Entity("ManagerService.Data.VisitEvent", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AppType")
.HasColumnType("integer");
b.Property<string>("ConfigurationId")
.HasColumnType("text");
b.Property<int?>("DurationSeconds")
.HasColumnType("integer");
b.Property<int>("EventType")
.HasColumnType("integer");
b.Property<string>("InstanceId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Language")
.HasColumnType("text");
b.Property<string>("Metadata")
.HasColumnType("text");
b.Property<string>("SectionId")
.HasColumnType("text");
b.Property<string>("SessionId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Timestamp");
b.ToTable("VisitEvents");
});
modelBuilder.Entity("ManagerService.Data.SubSection.SectionAgenda", b =>
{
b.HasBaseType("ManagerService.Data.Section");
@ -998,6 +1091,17 @@ namespace ManagerService.Migrations
b.HasDiscriminator().HasValue("Web");
});
modelBuilder.Entity("ManagerService.Data.ApiKey", b =>
{
b.HasOne("ManagerService.Data.Instance", "Instance")
.WithMany()
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Instance");
});
modelBuilder.Entity("ManagerService.Data.AppConfigurationLink", b =>
{
b.HasOne("ManagerService.Data.ApplicationInstance", "ApplicationInstance")

View File

@ -1,8 +1,6 @@
using Manager.Interfaces.Models;
using System;
using ManagerService.Data;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace ManagerService.Service
{
@ -10,51 +8,45 @@ namespace ManagerService.Service
{
public const string Scope = "Manager-api";
/// <summary>
/// Permissions
/// </summary>
private class Permissions
public static class Permissions
{
/// <summary>
/// Admin access
/// </summary>
public const string Admin = "Manager.admin";
public const string SuperAdmin = "Manager.superadmin";
public const string InstanceAdmin = "Manager.instanceadmin";
public const string ContentEditor = "Manager.contenteditor";
public const string Viewer = "Manager.viewer";
public const string AppRead = "Manager.appread";
}
/// <summary>
/// Custom claims types
/// </summary>
public class ClaimTypes
public static class Policies
{
public const string Permission = "Permission";
public const string SuperAdmin = "Manager.SuperAdministration";
public const string InstanceAdmin = "Manager.Administration";
public const string ContentEditor = "Manager.Content";
public const string Viewer = "Manager.ReadOnly";
public const string AppReadAccess = "Manager.AppReadAccess";
}
/// <summary>
/// Permissions for each type of profile
/// </summary>
public static readonly Dictionary<System.Type, string[]> ProfilesConfiguration = new Dictionary<System.Type, string[]>()
public static readonly Dictionary<UserRole, string[]> RolePermissions = new()
{
// An admin has access to everything
//{ typeof(AdminProfile), new[] { Permissions.Admin} },
[UserRole.SuperAdmin] = new[] { Permissions.SuperAdmin, Permissions.InstanceAdmin, Permissions.ContentEditor, Permissions.Viewer },
[UserRole.InstanceAdmin] = new[] { Permissions.InstanceAdmin, Permissions.ContentEditor, Permissions.Viewer },
[UserRole.ContentEditor] = new[] { Permissions.ContentEditor, Permissions.Viewer },
[UserRole.Viewer] = new[] { Permissions.Viewer },
};
/// <summary>
/// Policies names
/// </summary>
public class Policies
public static class ClaimTypes
{
/// <summary>
/// Administration
/// </summary>
public const string Admin = "Manager.Administration";
public const string Permission = "Permission";
public const string InstanceId = "InstanceId";
public const string AppType = "AppType";
}
/// <summary>
/// Policies
/// </summary>
public static readonly Policy[] PoliciesConfiguration = new[]
{
new Policy() { Name = Policies.Admin, Claims = new[] { Permissions.Admin} }
new Policy { Name = Policies.SuperAdmin, Claims = new[] { Permissions.SuperAdmin } },
new Policy { Name = Policies.InstanceAdmin, Claims = new[] { Permissions.InstanceAdmin } },
new Policy { Name = Policies.ContentEditor, Claims = new[] { Permissions.ContentEditor } },
new Policy { Name = Policies.Viewer, Claims = new[] { Permissions.Viewer } },
};
}
}

View File

@ -0,0 +1,57 @@
using ManagerService.Data;
using ManagerService.Service;
using ManagerService.Service.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
namespace ManagerService.Security
{
public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private const string HeaderName = "X-Api-Key";
private readonly MyInfoMateDbContext _db;
public ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
MyInfoMateDbContext db)
: base(options, logger, encoder)
{
_db = db;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(HeaderName, out var keyValue))
return AuthenticateResult.NoResult();
var value = keyValue.ToString();
var keyHash = TokensService.GenerateSHA256String(value);
var apiKey = await _db.ApiKeys.FirstOrDefaultAsync(k =>
k.IsActive &&
(k.DateExpiration == null || k.DateExpiration > System.DateTime.UtcNow) &&
(k.Key == value || k.KeyHash == keyHash));
if (apiKey == null)
return AuthenticateResult.Fail("Invalid API Key");
var claims = new[]
{
new Claim(Service.Security.ClaimTypes.InstanceId, apiKey.InstanceId),
new Claim(Service.Security.ClaimTypes.AppType, apiKey.AppType.ToString()),
new Claim(Service.Security.ClaimTypes.Permission, Service.Security.Permissions.AppRead),
new Claim(Service.Security.ClaimTypes.Permission, Service.Security.Permissions.Viewer),
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name));
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
}
}
}

View File

@ -0,0 +1,125 @@
using ManagerService.Data;
using ManagerService.Helpers;
using ManagerService.Service.Services;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
namespace ManagerService.Services
{
public class ApiKeyDatabaseService
{
private readonly MyInfoMateDbContext _db;
private readonly IHexIdGeneratorService _idService = new HexIdGeneratorService();
public ApiKeyDatabaseService(MyInfoMateDbContext db)
{
_db = db;
}
/// <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)
{
var plainKey = "ak_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
var keyHash = TokensService.GenerateSHA256String(plainKey);
var apiKey = new ApiKey
{
Id = _idService.GenerateHexId(),
Name = name,
InstanceId = instanceId,
AppType = appType,
Key = null,
KeyHash = keyHash,
IsActive = true,
DateCreation = DateTime.UtcNow,
};
_db.ApiKeys.Add(apiKey);
await _db.SaveChangesAsync();
return plainKey;
}
/// <summary>
/// Returns (or creates) a persistent plain-text key for a given instance + appType (PIN flow).
/// </summary>
public async Task<string> GetOrCreateByPinAsync(string instanceId, ApiKeyAppType appType)
{
var existing = await _db.ApiKeys.FirstOrDefaultAsync(k =>
k.InstanceId == instanceId &&
k.AppType == appType &&
k.IsActive &&
k.Key != null);
if (existing != null)
return existing.Key!;
var plainKey = "ak_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
var apiKey = new ApiKey
{
Id = _idService.GenerateHexId(),
Name = $"Auto-{appType}-{instanceId}",
InstanceId = instanceId,
AppType = appType,
Key = plainKey,
KeyHash = null,
IsActive = true,
DateCreation = DateTime.UtcNow,
};
_db.ApiKeys.Add(apiKey);
await _db.SaveChangesAsync();
return plainKey;
}
/// <summary>Returns all API keys for an instance (without secret values).</summary>
public async Task<List<ApiKeyDTO>> GetByInstanceAsync(string instanceId)
{
return await _db.ApiKeys
.Where(k => k.InstanceId == instanceId)
.Select(k => new ApiKeyDTO
{
Id = k.Id,
Name = k.Name,
AppType = k.AppType,
IsActive = k.IsActive,
DateCreation = k.DateCreation,
DateExpiration = k.DateExpiration,
})
.ToListAsync();
}
/// <summary>Revokes (deactivates) an API key, verifying ownership.</summary>
public async Task<bool> RevokeAsync(string id, string callerInstanceId)
{
var key = await _db.ApiKeys.FindAsync(id);
if (key == null || key.InstanceId != callerInstanceId)
return false;
key.IsActive = false;
await _db.SaveChangesAsync();
return true;
}
}
public class ApiKeyDTO
{
public string Id { get; set; }
public string Name { get; set; }
public ApiKeyAppType AppType { get; set; }
public bool IsActive { get; set; }
public DateTime DateCreation { get; set; }
public DateTime? DateExpiration { get; set; }
}
}

View File

@ -0,0 +1,165 @@
using ManagerService.Data;
using ManagerService.DTOs;
using Microsoft.Extensions.AI;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace ManagerService.Services
{
public class AssistantService
{
private readonly IChatClient _chatClient;
private readonly MyInfoMateDbContext _context;
private const int MaxHistoryMessages = 10;
public AssistantService(IChatClient chatClient, MyInfoMateDbContext context)
{
_chatClient = chatClient;
_context = context;
}
public async Task<AiChatResponse> ChatAsync(AiChatRequest request)
{
var messages = new List<ChatMessage>();
ChatOptions options;
if (request.ConfigurationId != null)
{
// Scope configuration : l'IA connaît uniquement les sections de cette configuration
var sections = _context.Sections.Where(s => s.ConfigurationId == request.ConfigurationId).ToList();
var config = _context.Configurations.FirstOrDefault(c => c.Id == request.ConfigurationId);
var sectionsSummary = string.Join("\n", sections.Select(s =>
{
var title = s.Title?.FirstOrDefault(t => t.language == request.Language)?.value
?? s.Title?.FirstOrDefault()?.value
?? 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.
Voici les sections disponibles dans cette application :
{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.
"""));
foreach (var h in request.History.TakeLast(MaxHistoryMessages))
messages.Add(new ChatMessage(h.Role == "user" ? ChatRole.User : ChatRole.Assistant, h.Content));
messages.Add(new ChatMessage(ChatRole.User, request.Message));
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
?? "";
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"
),
AIFunctionFactory.Create(
(string sectionId, string sectionTitle, string sectionType) =>
{
navigation = new NavigationActionDTO { SectionId = sectionId, SectionTitle = sectionTitle, SectionType = sectionType };
return Task.FromResult("Navigation proposée à l'utilisateur.");
},
"navigate_to_section",
"Propose navigation to a specific section when the user wants to go there or see it."
),
AIFunctionFactory.Create(
(string[] titles, string[] subtitles, string[]? icons) =>
{
cards = titles.Select((t, i) => new AiCardDTO
{
Title = t,
Subtitle = i < subtitles.Length ? subtitles[i] : "",
Icon = icons != null && i < icons.Length ? icons[i] : null
}).ToList();
return Task.FromResult("Cartes affichées.");
},
"show_cards",
"Display structured info cards in the chat. Use for lists of items (events, activities...) that benefit from visual presentation."
)
};
options = new ChatOptions { Tools = tools };
var response = await _chatClient.GetResponseAsync(messages, options);
return new AiChatResponse { Reply = response.Text ?? "", Cards = cards, Navigation = navigation };
}
else
{
// Scope instance : l'IA connaît les configurations disponibles
var configurations = _context.Configurations.Where(c => c.InstanceId == request.InstanceId).ToList();
var configsSummary = string.Join("\n", configurations.Select(c =>
{
var title = c.Title?.FirstOrDefault(t => t.language == request.Language)?.value
?? c.Title?.FirstOrDefault()?.value
?? c.Label;
return $"- id:{c.Id} | titre:\"{title}\"";
}));
messages.Add(new ChatMessage(ChatRole.System, $"""
Tu es l'assistant de visite.
Tu réponds en {request.Language}. Tu es chaleureux, concis et utile.
Voici les expériences de visite 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.
"""));
foreach (var h in request.History.TakeLast(MaxHistoryMessages))
messages.Add(new ChatMessage(h.Role == "user" ? ChatRole.User : ChatRole.Assistant, h.Content));
messages.Add(new ChatMessage(ChatRole.User, request.Message));
NavigationActionDTO? navigation = null;
var tools = new List<AITool>
{
AIFunctionFactory.Create(
(string configurationId, string configurationTitle) =>
{
navigation = new NavigationActionDTO { SectionId = configurationId, SectionTitle = configurationTitle, SectionType = "Configuration" };
return Task.FromResult("Navigation vers la visite proposée.");
},
"navigate_to_configuration",
"Propose navigation to a specific visit/configuration when the user wants to start or explore it."
)
};
options = new ChatOptions { Tools = tools };
var response = await _chatClient.GetResponseAsync(messages, options);
return new AiChatResponse { Reply = response.Text ?? "", Navigation = navigation };
}
}
}
}

View File

@ -57,14 +57,18 @@ namespace ManagerService.Service.Services
{
try
{
var claims = new List<System.Security.Claims.Claim>();
var expiration = DateTime.UtcNow.AddMinutes(_tokenSettings.AccessTokenExpiration);
_profileLogic.TestPassword(user.Email, user.Password, password);
claims.Add(new Claim(ClaimTypes.Email, user.Email));
// TODO: add refresh token support
var claims = new List<System.Security.Claims.Claim>
{
new(ClaimTypes.Email, user.Email),
new(ClaimTypes.Name, $"{user.FirstName} {user.LastName}"),
new(Security.ClaimTypes.InstanceId, user.InstanceId),
};
foreach (var perm in Security.RolePermissions[user.Role])
claims.Add(new Claim(Security.ClaimTypes.Permission, perm));
var tokenHandler = new JwtSecurityTokenHandler();
var tokenDescriptor = new SecurityTokenDescriptor()
@ -75,8 +79,6 @@ namespace ManagerService.Service.Services
};
var token = tokenHandler.CreateToken(tokenDescriptor);
//var instance = _instanceService.GetById(user.InstanceId);
var instance = _myInfoMateDbContext.Instances.Find(user.InstanceId);
return new TokenDTO()
@ -87,7 +89,8 @@ namespace ManagerService.Service.Services
token_type = "Bearer",
scope = Security.Scope,
instanceId = user.InstanceId,
pinCode = instance.PinCode
pinCode = instance.PinCode,
role = user.Role
};
}
catch (UnauthorizedAccessException ex)
@ -102,30 +105,6 @@ namespace ManagerService.Service.Services
}
}
public object GenerateToken(string username)
{
var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_tokenSettings.Secret)); // Put the secret in a file or something
var claims = new Claim[] {
new Claim(ClaimTypes.Name, username),
new Claim(JwtRegisteredClaimNames.Email, "john.doe@blinkingcaret.com"),
new Claim(ClaimTypes.Role, "Admin")
};
var token = new JwtSecurityToken(
issuer: "Manager App",
audience: "Manager client",
claims: claims,
notBefore: DateTime.Now,
expires: DateTime.Now.AddDays(28),
signingCredentials: new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256)
);
string jwtToken = new JwtSecurityTokenHandler().WriteToken(token);
return jwtToken;
}
public static string GenerateSHA256String(string inputString)
{
SHA256 sha256 = SHA256Managed.Create();

View File

@ -1,3 +1,6 @@
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;
using Manager.Framework.Models;
using Manager.Helpers;
using Manager.Interfaces;
@ -6,8 +9,10 @@ using Manager.Services;
using ManagerService.Data;
using ManagerService.Extensions;
using ManagerService.Helpers;
using ManagerService.Security;
using ManagerService.Service;
using ManagerService.Service.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
@ -36,6 +41,7 @@ using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using ManagerService.Services;
namespace ManagerService
{
@ -105,16 +111,21 @@ namespace ManagerService
services.Configure<TokensSettings>(tokensConfiguration);
foreach (var policy in Security.PoliciesConfiguration)
foreach (var policy in ManagerService.Service.Security.PoliciesConfiguration)
services.AddAuthorization(options =>
{
options.AddPolicy(policy.Name, policyAdmin =>
{
foreach (var claim in policy.Claims)
policyAdmin.RequireClaim(Security.ClaimTypes.Permission, claim);
policyAdmin.RequireClaim(ManagerService.Service.Security.ClaimTypes.Permission, claim);
});
});
services.AddAuthorization(options =>
options.AddPolicy(ManagerService.Service.Security.Policies.AppReadAccess, policy =>
policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, "ApiKey")
.RequireAuthenticatedUser()));
services
.AddAuthentication(x =>
{
@ -135,7 +146,8 @@ namespace ManagerService
RequireExpirationTime = false,
ValidateLifetime = true
};
});
})
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>("ApiKey", _ => { });
#if RELEASE
//services.AddMqttClientHostedService();
@ -151,6 +163,18 @@ namespace ManagerService
services.AddScoped<ResourceDatabaseService>();
services.AddScoped<DeviceDatabaseService>();
services.AddScoped<InstanceDatabaseService>();
services.AddScoped<ApiKeyDatabaseService>();
// Assistant IA — choisir un provider (package NuGet : Microsoft.Extensions.AI.OpenAI) :
// OpenAI : new OpenAIClient(new ApiKeyCredential(apiKey)).AsChatClient("gpt-4o-mini")
// Gemini : endpoint OpenAI-compatible (ci-dessous)
// Anthropic : package Anthropic.SDK → new AnthropicClient(apiKey).Messages.AsChatClient("claude-haiku-4-5-20251001")
services.AddSingleton<IChatClient>(_ =>
new OpenAIClient(
new ApiKeyCredential(Configuration["AI:ApiKey"]!),
new OpenAIClientOptions { Endpoint = new Uri("https://generativelanguage.googleapis.com/v1beta/openai/") }
).AsChatClient("gemini-2.5-flash-lite"));
services.AddScoped<AssistantService>();
var connectionString = Configuration.GetConnectionString("PostgresConnection");
@ -162,7 +186,7 @@ namespace ManagerService
services.AddDbContext<MyInfoMateDbContext>(options =>
options.UseNpgsql(dataSource, o => o.UseNetTopologySuite())
.EnableSensitiveDataLogging() // montre les valeurs des paramètres
.EnableSensitiveDataLogging() // montre les valeurs des param<EFBFBD>tres
.LogTo(Console.WriteLine, LogLevel.Information)
);
}
@ -191,7 +215,7 @@ namespace ManagerService
app.UseCors(
#if DEBUG
options => options
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "http://localhost:64402")
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "http://localhost:50479")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
@ -232,7 +256,7 @@ namespace ManagerService
{
Scopes = new Dictionary<string, string>
{
{ Security.Scope, "Manager WebAPI" }
{ ManagerService.Service.Security.Scope, "Manager WebAPI" }
},
TokenUrl = "/api/authentication/Token",
AuthorizationUrl = "/authentication/Token",
@ -241,6 +265,14 @@ namespace ManagerService
});
config.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("bearer"));
config.AddSecurity("apikey", Enumerable.Empty<string>(), new OpenApiSecurityScheme
{
Type = OpenApiSecuritySchemeType.ApiKey,
Name = "X-Api-Key",
In = OpenApiSecurityApiKeyLocation.Header,
Description = "API Key for mobile apps"
});
config.PostProcess = document =>
{
document.Info.Title = "Manager Service";

View File

@ -29,6 +29,9 @@
"TokenExpiryInHours": 2
},
"SupportedLanguages": [ "FR", "NL", "EN", "DE", "IT", "ES", "PL", "CN", "AR", "UK" ],
"OpenWeatherApiKey": "d489973b4c09ddc5fb56bd7b9270bbef"
"OpenWeatherApiKey": "d489973b4c09ddc5fb56bd7b9270bbef",
"AI": {
"ApiKey": "AIzaSyC7lJ8w1eQL4aZGNFLMabig5ul6yn66nug"
}
//"Urls": "http://[::]:80"
}