Self-service onboarding (Stripe + Resend) + Postgres v3 schema (pgvector, media, visitor questions)

75 fichiers, ~1 mois de travail depuis f222861 (17/07). Contenu :

Onboarding self-service — jamais exécuté de bout en bout
  OnboardingController, StripeWebhookController, StripeService, ResendEmailService
  + IEmailService (10 templates), EmailTemplates/, TrialLifecycleService (Hangfire),
  PasswordTokenHelper, SlugHelper, 5 DTOs. Essai 14 j, Stripe customer/Checkout/Tax,
  mot de passe oublié + invitation user, plafond IA d'essai.

Schéma Postgres v3 — passe pendant que la base est vide
  ContentEmbedding + index HNSW (vector_cosine_ops), IEmbeddingService +
  GoogleEmbeddingService (gemini-embedding-001, 768 dims), Deployment/Dockerfile.postgres
  (postgis 3.4.3 + pgvector 0.8.6, épinglé par digest — un tag mobile rejouerait le
  warning de collation glibc). Colonnes Resource : StoragePath, FileName,
  IncludeInAiKnowledge, AiIndexStatus + nouveaux ResourceType ajoutés EN FIN d'enum.

Guide IA
  Champs Guide* sur Instance + InstanceDTO, AssistantService lit la configuration client
  dans les 4 blocs de prompt (ton codé en dur retiré, règle hors-sujet ajoutée aux deux
  variantes qui n'en avaient pas), IHttpClientFactory à la place des new HttpClient().
  Table VisitorQuestion + ConversationId sur AiChatRequest.

Stats
  Rétention unifiée à 13 mois (instances ET plans), VisitEventPurgeService.

Nettoyage
  IsStepLocked / IsHiddenInitially / FactContent supprimés de GuidedStep — IsStepLocked
  rendait une étape définitivement infranchissable même après réussite.
  SectionMap allégé (-57 lignes).

Tests
  SectionParcoursControllerTests, FakeConfiguration, FakeEmailService.
  ContentEmbedding a cassé 116 tests sur 124 (EF InMemory ne connaît pas Vector) :
  l'entité est exclue quand le provider n'est pas Npgsql. Conséquence assumée —
  le vector store n'est couvert par aucun test. dotnet test 124/124.

10 migrations EF. Base locale à jour, dotnet build 0 erreur.
Rien n'est en prod : la bascule Mongo → Postgres est décrite dans DOCS/STATUS.md §1quinquies.

⚠️ appsettings.json contient les clés Stripe (test) et Resend (prod) en clair — à rotationner.
This commit is contained in:
Thomas Fransolet 2026-08-09 22:11:48 +02:00
parent f2228617ca
commit af6b5b76ef
74 changed files with 20913 additions and 205 deletions

View File

@ -18,14 +18,19 @@ namespace ManagerService.Tests.Controllers
{ {
private static readonly AiChatResponse FakeResponse = new AiChatResponse { Reply = "OK", TokensUsed = 42 }; private static readonly AiChatResponse FakeResponse = new AiChatResponse { Reply = "OK", TokensUsed = 42 };
private AiController BuildController(MyInfoMateDbContext db, Mock<IAssistantService>? mockService = null) // Chat vérifie les droits de l'appelant (IsSuperAdmin / instance du token) :
// sans utilisateur, User est null et le contrôleur renvoie un 500.
private AiController BuildController(MyInfoMateDbContext db, Mock<IAssistantService>? mockService = null,
string callerRole = Permissions.SuperAdmin, string callerInstanceId = "i1")
{ {
mockService ??= new Mock<IAssistantService>(); mockService ??= new Mock<IAssistantService>();
mockService mockService
.Setup(s => s.ChatAsync(It.IsAny<AiChatRequest>())) .Setup(s => s.ChatAsync(It.IsAny<AiChatRequest>()))
.ReturnsAsync(FakeResponse); .ReturnsAsync(FakeResponse);
return new AiController(mockService.Object, db, NullLogger<AiController>.Instance); var controller = new AiController(mockService.Object, db, NullLogger<AiController>.Instance);
FakeUser.SetUser(controller, FakeUser.Create(callerRole, callerInstanceId));
return controller;
} }
private static AiChatRequest MakeRequest(string instanceId, AppType appType = AppType.Tablet) => private static AiChatRequest MakeRequest(string instanceId, AppType appType = AppType.Tablet) =>

View File

@ -31,7 +31,10 @@ namespace ManagerService.Tests.Controllers
return new AuthenticationController( return new AuthenticationController(
NullLogger<AuthenticationController>.Instance, NullLogger<AuthenticationController>.Instance,
tokensService, tokensService,
db); db,
profileLogic,
new FakeEmailService(),
FakeConfiguration.Create());
} }
// Note: en mode DEBUG, email est toujours surchargé en "test@email.be" // Note: en mode DEBUG, email est toujours surchargé en "test@email.be"

View File

@ -0,0 +1,194 @@
using ManagerService.Controllers;
using ManagerService.Data;
using ManagerService.Data.SubSection;
using ManagerService.DTOs;
using ManagerService.Tests.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using System.Collections.Generic;
using System.Linq;
using Xunit;
namespace ManagerService.Tests.Controllers
{
/// <summary>
/// Couvre la persistance des règles de progression d'un parcours — les 3 booléens
/// derrière la question « Comment le visiteur progresse-t-il ? » de manager-app
/// (Libre / Dans l'ordre / Étape par étape) — et le nettoyage des champs morts.
/// </summary>
public class SectionParcoursControllerTests
{
private SectionParcoursController BuildController(MyInfoMateDbContext db)
{
var controller = new SectionParcoursController(
FakeConfiguration.Create(),
NullLogger<SectionParcoursController>.Instance,
db);
FakeUser.SetUser(controller, FakeUser.Create(Permissions.SuperAdmin));
return controller;
}
private static SectionParcours SeedSection(MyInfoMateDbContext db, string id = "sp1", bool showMap = true)
{
var section = new SectionParcours
{
Id = id,
InstanceId = "inst-test",
ConfigurationId = "cfg1",
Label = "Parcours de test",
Title = new List<TranslationDTO>(),
Description = new List<TranslationDTO>(),
ShowMap = showMap,
};
db.Sections.Add(section);
db.SaveChanges();
return section;
}
private static GuidedPathDTO MakePath(string title = "Parcours") => new GuidedPathDTO
{
instanceId = "inst-test",
title = new List<TranslationDTO> { new TranslationDTO { language = "FR", value = title } },
description = new List<TranslationDTO>(),
steps = new List<GuidedStepDTO>(),
order = 0,
};
// ── Modes de progression ─────────────────────────────────────────────
[Theory]
// Libre : le visiteur choisit ses étapes dans l'ordre qu'il veut
[InlineData(false, false, false)]
// Dans l'ordre : séquence imposée, sans obligation de réussite
[InlineData(true, false, false)]
// Étape par étape : séquence + validation, étapes futures masquées
[InlineData(true, true, true)]
public void CreateGuidedPath_PersistsProgressionFlags(bool isLinear, bool requireSuccess, bool hideNext)
{
using var db = DbContextFactory.Create();
SeedSection(db);
var dto = MakePath();
dto.isLinear = isLinear;
dto.requireSuccessToAdvance = requireSuccess;
dto.hideNextStepsUntilComplete = hideNext;
var result = BuildController(db).CreateGuidedPath("sp1", dto);
Assert.Equal(200, result.StatusCode);
var saved = db.GuidedPaths.Single();
Assert.Equal(isLinear, saved.IsLinear);
Assert.Equal(requireSuccess, saved.RequireSuccessToAdvance);
Assert.Equal(hideNext, saved.HideNextStepsUntilComplete);
}
[Fact]
public void UpdateGuidedPath_SwitchingToFreeNavigation_ClearsSequencingFlags()
{
using var db = DbContextFactory.Create();
SeedSection(db);
var created = BuildController(db).CreateGuidedPath("sp1", MakePath());
var path = Assert.IsType<GuidedPathDTO>(created.Value);
// Passage « Étape par étape » → « Libre » (ce que fait applyProgressionMode côté manager-app)
path.isLinear = false;
path.requireSuccessToAdvance = false;
path.hideNextStepsUntilComplete = false;
var result = BuildController(db).UpdateGuidedPath(path);
Assert.Equal(200, result.StatusCode);
var saved = db.GuidedPaths.Single();
Assert.False(saved.IsLinear);
Assert.False(saved.RequireSuccessToAdvance);
Assert.False(saved.HideNextStepsUntilComplete);
}
[Fact]
public void CreateGuidedPath_KeepsGameModeMessages()
{
using var db = DbContextFactory.Create();
SeedSection(db);
var dto = MakePath();
dto.isGameMode = true;
dto.gameMessageDebut = new List<TranslationAndResourceDTO>
{
new TranslationAndResourceDTO { language = "FR", value = "Bienvenue dans l'escape" }
};
dto.gameMessageFin = new List<TranslationAndResourceDTO>
{
new TranslationAndResourceDTO { language = "FR", value = "Bravo !" }
};
BuildController(db).CreateGuidedPath("sp1", dto);
var saved = db.GuidedPaths.Single();
Assert.True(saved.IsGameMode);
Assert.Equal("Bienvenue dans l'escape", saved.GameMessageDebut.Single().value);
Assert.Equal("Bravo !", saved.GameMessageFin.Single().value);
}
[Fact]
public void CreateGuidedPath_UnknownSection_Returns404()
{
using var db = DbContextFactory.Create();
var result = BuildController(db).CreateGuidedPath("does-not-exist", MakePath());
Assert.Equal(404, result.StatusCode);
}
// ── Étapes ───────────────────────────────────────────────────────────
[Fact]
public void CreateGuidedStep_PersistsGeoTriggerAndTimer()
{
using var db = DbContextFactory.Create();
SeedSection(db);
var created = BuildController(db).CreateGuidedPath("sp1", MakePath());
var pathId = Assert.IsType<GuidedPathDTO>(created.Value).id;
var step = new GuidedStepDTO
{
title = new List<TranslationDTO>(),
description = new List<TranslationDTO>(),
isGeoTriggered = true,
zoneRadiusMeters = 30,
isStepTimer = true,
timerSeconds = 120,
order = 0,
};
var result = BuildController(db).CreateGuidedStep(pathId, step);
Assert.Equal(200, result.StatusCode);
var saved = db.GuidedSteps.Single();
Assert.True(saved.IsGeoTriggered);
Assert.Equal(30, saved.ZoneRadiusMeters);
Assert.True(saved.IsStepTimer);
Assert.Equal(120, saved.TimerSeconds);
}
[Fact]
public void GetAllGuidedPathFromSection_ReturnsPathsOrderedByOrder()
{
using var db = DbContextFactory.Create();
SeedSection(db);
var controller = BuildController(db);
var second = MakePath("Second"); second.order = 1;
var first = MakePath("Premier"); first.order = 0;
controller.CreateGuidedPath("sp1", second);
controller.CreateGuidedPath("sp1", first);
var result = BuildController(db).GetAllGuidedPathFromSection("sp1");
Assert.Equal(200, result.StatusCode);
var paths = Assert.IsAssignableFrom<IEnumerable<GuidedPathDTO>>(result.Value).ToList();
Assert.Equal(2, paths.Count);
Assert.Equal(new int?[] { 0, 1 }, paths.Select(p => p.order).ToArray());
}
}
}

View File

@ -9,7 +9,15 @@ namespace ManagerService.Tests.Controllers
{ {
public class StatsControllerTests public class StatsControllerTests
{ {
private StatsController BuildController(MyInfoMateDbContext db) => new StatsController(db); // GetSummary vérifie les droits de l'appelant (IsSuperAdmin / instance du token) :
// sans utilisateur, User est null et le contrôleur renvoie un 500.
private StatsController BuildController(MyInfoMateDbContext db,
string callerRole = Permissions.SuperAdmin, string callerInstanceId = "i1")
{
var controller = new StatsController(db);
FakeUser.SetUser(controller, FakeUser.Create(callerRole, callerInstanceId));
return controller;
}
// ── TRACK EVENT ────────────────────────────────────────────────────── // ── TRACK EVENT ──────────────────────────────────────────────────────

View File

@ -9,13 +9,15 @@ using Microsoft.Extensions.Logging.Abstractions;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Xunit; using Xunit;
namespace ManagerService.Tests.Controllers namespace ManagerService.Tests.Controllers
{ {
public class UserControllerTests public class UserControllerTests
{ {
private UserController BuildController(MyInfoMateDbContext db, string callerRole = "Manager.superadmin", string callerInstanceId = "inst-test") private UserController BuildController(MyInfoMateDbContext db, string callerRole = "Manager.superadmin", string callerInstanceId = "inst-test",
FakeEmailService emailService = null)
{ {
var cfg = FakeMongoConfig.Create(); var cfg = FakeMongoConfig.Create();
var userService = new UserDatabaseService(cfg); var userService = new UserDatabaseService(cfg);
@ -25,7 +27,9 @@ namespace ManagerService.Tests.Controllers
NullLogger<UserController>.Instance, NullLogger<UserController>.Instance,
userService, userService,
profileLogic, profileLogic,
db); db,
emailService ?? new FakeEmailService(),
FakeConfiguration.Create());
FakeUser.SetUser(controller, FakeUser.Create(callerRole, callerInstanceId)); FakeUser.SetUser(controller, FakeUser.Create(callerRole, callerInstanceId));
return controller; return controller;
@ -82,24 +86,24 @@ namespace ManagerService.Tests.Controllers
// ── CREATE ─────────────────────────────────────────────────────────── // ── CREATE ───────────────────────────────────────────────────────────
[Fact] [Fact]
public void CreateUser_DuplicateEmail_Returns409() public async Task CreateUser_DuplicateEmail_Returns409()
{ {
using var db = DbContextFactory.Create(); using var db = DbContextFactory.Create();
db.Users.Add(new User { Id = "u1", Email = "dup@a.be", Password = "x", LastName = "A", Token = "t1", InstanceId = "inst-test" }); db.Users.Add(new User { Id = "u1", Email = "dup@a.be", Password = "x", LastName = "A", Token = "t1", InstanceId = "inst-test" });
db.SaveChanges(); db.SaveChanges();
var result = BuildController(db).CreateUser(MakeNewUser("dup@a.be")); var result = await BuildController(db).CreateUser(MakeNewUser("dup@a.be"));
Assert.IsType<ConflictObjectResult>(result); Assert.IsType<ConflictObjectResult>(result);
} }
[Fact] [Fact]
public void CreateUser_InstanceAdminCreatingInstanceAdmin_Returns403() public async Task CreateUser_InstanceAdminCreatingInstanceAdmin_Returns403()
{ {
// InstanceAdmin (role=1) ne peut pas créer SuperAdmin (role=0) // InstanceAdmin (role=1) ne peut pas créer SuperAdmin (role=0)
using var db = DbContextFactory.Create(); using var db = DbContextFactory.Create();
var result = BuildController(db, "Manager.instanceadmin") var result = await BuildController(db, "Manager.instanceadmin")
.CreateUser(MakeNewUser("new@a.be", role: UserRole.SuperAdmin)); .CreateUser(MakeNewUser("new@a.be", role: UserRole.SuperAdmin));
var statusResult = Assert.IsType<ObjectResult>(result); var statusResult = Assert.IsType<ObjectResult>(result);
@ -107,11 +111,11 @@ namespace ManagerService.Tests.Controllers
} }
[Fact] [Fact]
public void CreateUser_ValidDto_HashesPassword() public async Task CreateUser_ValidDto_HashesPassword()
{ {
using var db = DbContextFactory.Create(); using var db = DbContextFactory.Create();
var result = BuildController(db).CreateUser(MakeNewUser("new@a.be")); var result = await BuildController(db).CreateUser(MakeNewUser("new@a.be"));
Assert.IsType<OkObjectResult>(result); Assert.IsType<OkObjectResult>(result);
var user = db.Users.First(); var user = db.Users.First();
@ -119,14 +123,38 @@ namespace ManagerService.Tests.Controllers
} }
[Fact] [Fact]
public void CreateUser_NullPassword_Returns400() public async Task CreateUser_NullPassword_InvitesUserByEmail()
{ {
// Sans mot de passe, l'utilisateur n'est plus refusé : il est créé avec un
// mot de passe aléatoire et reçoit un lien d'invitation pour définir le sien.
using var db = DbContextFactory.Create(); using var db = DbContextFactory.Create();
var emails = new FakeEmailService();
var dto = new UserDetailDTO { email = "x@x.be", lastName = "X", instanceId = "inst-test", password = null }; var dto = new UserDetailDTO { email = "x@x.be", lastName = "X", instanceId = "inst-test", password = null };
var result = BuildController(db).CreateUser(dto); var result = await BuildController(db, emailService: emails).CreateUser(dto);
Assert.IsType<BadRequestObjectResult>(result); Assert.IsType<OkObjectResult>(result);
var user = db.Users.Single();
Assert.False(string.IsNullOrEmpty(user.Password));
Assert.False(string.IsNullOrEmpty(user.PasswordTokenHash));
Assert.NotNull(user.PasswordTokenExpiresAt);
var invitation = Assert.Single(emails.Sent);
Assert.Equal(nameof(FakeEmailService.SendUserInvitationEmailAsync), invitation.Kind);
Assert.Equal("x@x.be", invitation.To);
}
[Fact]
public async Task CreateUser_WithPassword_DoesNotSendInvitation()
{
using var db = DbContextFactory.Create();
var emails = new FakeEmailService();
await BuildController(db, emailService: emails).CreateUser(MakeNewUser("with-pwd@a.be"));
Assert.Empty(emails.Sent);
Assert.Null(db.Users.Single().PasswordTokenHash);
} }
// ── UPDATE ─────────────────────────────────────────────────────────── // ── UPDATE ───────────────────────────────────────────────────────────

View File

@ -1,17 +1,23 @@
using ManagerService.Data; using ManagerService.Data;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ManagerService.Tests.Infrastructure namespace ManagerService.Tests.Infrastructure
{ {
public static class DbContextFactory public static class DbContextFactory
{ {
public static MyInfoMateDbContext Create() /// <summary>
/// Contexte EF InMemory isolé. Le <see cref="IHttpContextAccessor"/> sert à
/// l'audit (<c>ChangeTracker</c> lit l'utilisateur courant) : un accesseur sans
/// HttpContext suffit, l'audit retombe alors sur un utilisateur anonyme.
/// </summary>
public static MyInfoMateDbContext Create(IHttpContextAccessor httpContextAccessor = null)
{ {
var options = new DbContextOptionsBuilder<MyInfoMateDbContext>() var options = new DbContextOptionsBuilder<MyInfoMateDbContext>()
.UseInMemoryDatabase(System.Guid.NewGuid().ToString()) .UseInMemoryDatabase(System.Guid.NewGuid().ToString())
.Options; .Options;
return new MyInfoMateDbContext(options); return new MyInfoMateDbContext(options, httpContextAccessor ?? new HttpContextAccessor());
} }
} }
} }

View File

@ -0,0 +1,28 @@
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
namespace ManagerService.Tests.Infrastructure
{
public static class FakeConfiguration
{
/// <summary>
/// Configuration en mémoire avec les clés que lisent les contrôleurs
/// (les URLs d'app servent à construire les liens des emails).
/// </summary>
public static IConfiguration Create(Dictionary<string, string> overrides = null)
{
var values = new Dictionary<string, string>
{
["AppUrls:ManagerApp"] = "https://manager.test.local",
["AppUrls:Landing"] = "https://www.test.local",
};
if (overrides != null)
{
foreach (var kv in overrides) values[kv.Key] = kv.Value;
}
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
}
}

View File

@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ManagerService.Services;
namespace ManagerService.Tests.Infrastructure
{
/// <summary>
/// Enregistre les envois au lieu de les effectuer, pour pouvoir asserter
/// qu'un flux déclenche bien le bon email sans dépendre de Resend.
/// </summary>
public class FakeEmailService : IEmailService
{
public record SentEmail(string Kind, string To);
public List<SentEmail> Sent { get; } = new();
private Task Record(string kind, string to)
{
Sent.Add(new SentEmail(kind, to));
return Task.CompletedTask;
}
public Task SendWelcomeEmailAsync(string toEmail, string firstName, string loginUrl)
=> Record(nameof(SendWelcomeEmailAsync), toEmail);
public Task SendTrialStartedEmailAsync(string toEmail, string firstName, System.DateTime trialEndsAt)
=> Record(nameof(SendTrialStartedEmailAsync), toEmail);
public Task SendTrialCheckInEmailAsync(string toEmail, string firstName)
=> Record(nameof(SendTrialCheckInEmailAsync), toEmail);
public Task SendTrialEndingReminderEmailAsync(string toEmail, string firstName, System.DateTime trialEndsAt, string checkoutUrl)
=> Record(nameof(SendTrialEndingReminderEmailAsync), toEmail);
public Task SendTrialLastDayEmailAsync(string toEmail, string firstName, string checkoutUrl)
=> Record(nameof(SendTrialLastDayEmailAsync), toEmail);
public Task SendPaymentConfirmedEmailAsync(string toEmail, string firstName)
=> Record(nameof(SendPaymentConfirmedEmailAsync), toEmail);
public Task SendPaymentFailedEmailAsync(string toEmail, string firstName, string customerPortalUrl)
=> Record(nameof(SendPaymentFailedEmailAsync), toEmail);
public Task SendUserInvitationEmailAsync(string toEmail, string setPasswordUrl)
=> Record(nameof(SendUserInvitationEmailAsync), toEmail);
public Task SendPasswordResetEmailAsync(string toEmail, string firstName, string resetUrl)
=> Record(nameof(SendPasswordResetEmailAsync), toEmail);
public Task NotifyAdminAsync(string subject, string message)
=> Record(nameof(NotifyAdminAsync), subject);
}
}

View File

@ -17,6 +17,13 @@ namespace ManagerService.Controllers
[OpenApiTag("AI", Description = "Assistant IA")] [OpenApiTag("AI", Description = "Assistant IA")]
public class AiController : ControllerBase public class AiController : ControllerBase
{ {
/// <summary>
/// Plafond IA cumulé sur toute la durée de l'essai gratuit (14 jours), distinct du
/// compteur mensuel : ~30 requêtes * ~10k tokens/req estimés. Empêche qu'un essai à
/// cheval sur deux mois calendaires obtienne deux fois le quota mensuel du plan.
/// </summary>
private const long TrialAiTokensCap = 300_000;
private readonly IAssistantService _assistantService; private readonly IAssistantService _assistantService;
private readonly MyInfoMateDbContext _context; private readonly MyInfoMateDbContext _context;
private readonly ILogger<AiController> _logger; private readonly ILogger<AiController> _logger;
@ -37,6 +44,39 @@ namespace ManagerService.Controllers
private bool IsSuperAdmin() => private bool IsSuperAdmin() =>
User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.SuperAdmin); User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.SuperAdmin);
/// <summary>
/// Remet le compteur mensuel à zéro si le mois a changé, puis vérifie le quota mensuel
/// du plan et, pendant l'essai gratuit, le plafond cumulé de l'essai.
/// Retourne null si la requête peut passer, sinon la réponse d'erreur à renvoyer.
/// </summary>
private IActionResult? CheckQuota(Instance instance)
{
var monthKey = DateTime.UtcNow.ToString("yyyy-MM");
if (instance.AiUsageMonthKey != monthKey)
{
instance.AiTokensThisMonth = 0;
instance.AiUsageMonthKey = monthKey;
_context.SaveChanges();
}
var quota = instance.AiTokensPerMonth;
if (quota > 0 && instance.AiTokensThisMonth >= quota)
return StatusCode(429, "Quota IA mensuel dépassé");
if (instance.IsTrialActive && instance.TrialAiTokensUsed >= TrialAiTokensCap)
return StatusCode(429, "Quota IA de la période d'essai dépassé");
return null;
}
private void RecordUsage(Instance instance, long tokensUsed)
{
instance.AiTokensThisMonth += tokensUsed;
if (instance.IsTrialActive)
instance.TrialAiTokensUsed += tokensUsed;
_context.SaveChanges();
}
/// <summary> /// <summary>
/// Traduit un texte HTML vers plusieurs langues via IA /// Traduit un texte HTML vers plusieurs langues via IA
/// </summary> /// </summary>
@ -55,22 +95,13 @@ namespace ManagerService.Controllers
if (instance == null || !instance.IsAssistant) if (instance == null || !instance.IsAssistant)
return Forbid(); return Forbid();
var monthKey = DateTime.UtcNow.ToString("yyyy-MM"); var quotaError = CheckQuota(instance);
if (instance.AiUsageMonthKey != monthKey) if (quotaError != null)
{ return quotaError;
instance.AiTokensThisMonth = 0;
instance.AiUsageMonthKey = monthKey;
_context.SaveChanges();
}
var quota = instance.AiTokensPerMonth;
if (quota > 0 && instance.AiTokensThisMonth >= quota)
return StatusCode(429, "Quota IA mensuel dépassé");
var result = await _assistantService.TranslateAsync(request); var result = await _assistantService.TranslateAsync(request);
instance.AiTokensThisMonth += result.TokensUsed; RecordUsage(instance, result.TokensUsed);
_context.SaveChanges();
return Ok(result); return Ok(result);
} }
@ -109,22 +140,13 @@ namespace ManagerService.Controllers
if (appInstance == null || !appInstance.IsAssistant) if (appInstance == null || !appInstance.IsAssistant)
return Forbid(); return Forbid();
var monthKey = DateTime.UtcNow.ToString("yyyy-MM"); var quotaError = CheckQuota(instance);
if (instance.AiUsageMonthKey != monthKey) if (quotaError != null)
{ return quotaError;
instance.AiTokensThisMonth = 0;
instance.AiUsageMonthKey = monthKey;
_context.SaveChanges();
}
var quota = instance.AiTokensPerMonth;
if (quota > 0 && instance.AiTokensThisMonth >= quota)
return StatusCode(429, "Quota IA mensuel dépassé");
var result = await _assistantService.ChatAsync(request); var result = await _assistantService.ChatAsync(request);
instance.AiTokensThisMonth += result.TokensUsed; RecordUsage(instance, result.TokensUsed);
_context.SaveChanges();
return Ok(result); return Ok(result);
} }

View File

@ -1,9 +1,12 @@
using Manager.Services; using Manager.Services;
using ManagerService.Data; using ManagerService.Data;
using ManagerService.DTOs; using ManagerService.DTOs;
using ManagerService.Helpers;
using ManagerService.Service.Services; using ManagerService.Service.Services;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Mqtt.Client.AspNetCore.Services; using Mqtt.Client.AspNetCore.Services;
using NSwag.Annotations; using NSwag.Annotations;
@ -11,6 +14,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net; using System.Net;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
namespace ManagerService.Service.Controllers namespace ManagerService.Service.Controllers
{ {
@ -28,13 +32,19 @@ namespace ManagerService.Service.Controllers
private readonly DeviceDatabaseService _DeviceDatabaseService; private readonly DeviceDatabaseService _DeviceDatabaseService;
private readonly ConfigurationDatabaseService _ConfigurationDatabaseService;*/ private readonly ConfigurationDatabaseService _ConfigurationDatabaseService;*/
private readonly MyInfoMateDbContext _myInfoMateDbContext; private readonly MyInfoMateDbContext _myInfoMateDbContext;
private readonly ProfileLogic _profileLogic;
private readonly IEmailService _emailService;
private readonly IConfiguration _configuration;
public AuthenticationController(ILogger<AuthenticationController> logger, TokensService tokensService, MyInfoMateDbContext myInfoMateDbContext/*UserDatabaseService UserDatabaseService, DeviceDatabaseService DeviceDatabaseService, ConfigurationDatabaseService ConfigurationDatabaseService*/) public AuthenticationController(ILogger<AuthenticationController> logger, TokensService tokensService, MyInfoMateDbContext myInfoMateDbContext, ProfileLogic profileLogic, IEmailService emailService, IConfiguration configuration/*UserDatabaseService UserDatabaseService, DeviceDatabaseService DeviceDatabaseService, ConfigurationDatabaseService ConfigurationDatabaseService*/)
{ {
_logger = logger; _logger = logger;
_tokensService = tokensService; _tokensService = tokensService;
_myInfoMateDbContext = myInfoMateDbContext; _myInfoMateDbContext = myInfoMateDbContext;
_profileLogic = profileLogic;
_emailService = emailService;
_configuration = configuration;
//_UserDatabaseService = UserDatabaseService; //_UserDatabaseService = UserDatabaseService;
//_DeviceDatabaseService = DeviceDatabaseService; //_DeviceDatabaseService = DeviceDatabaseService;
//_ConfigurationDatabaseService = ConfigurationDatabaseService; //_ConfigurationDatabaseService = ConfigurationDatabaseService;
@ -110,5 +120,84 @@ namespace ManagerService.Service.Controllers
{ {
return Authenticate(login.email.ToLower(), login.password); return Authenticate(login.email.ToLower(), login.password);
} }
/// <summary>
/// Request a password reset link by email. Always returns 200 (does not reveal
/// whether the email exists) to avoid leaking which addresses have an account.
/// </summary>
/// <param name="dto">Email of the account</param>
[AllowAnonymous]
[HttpPost("forgot-password")]
[Consumes("application/json")]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
public async Task<ObjectResult> ForgotPassword([FromBody] ForgotPasswordDTO dto)
{
try
{
var email = dto?.email?.Trim().ToLowerInvariant();
var user = string.IsNullOrEmpty(email)
? null
: _myInfoMateDbContext.Users.FirstOrDefault(u => u.Email.ToLower() == email);
if (user != null)
{
var rawToken = PasswordTokenHelper.GenerateRawToken();
user.PasswordTokenHash = PasswordTokenHelper.Hash(rawToken);
user.PasswordTokenExpiresAt = PasswordTokenHelper.DefaultExpiry();
_myInfoMateDbContext.SaveChanges();
var managerAppUrl = _configuration["AppUrls:ManagerApp"];
var resetUrl = $"{managerAppUrl}/set-password?token={rawToken}";
await _emailService.SendPasswordResetEmailAsync(user.Email, user.FirstName, resetUrl);
}
return new OkObjectResult("If this email is registered, a reset link has been sent.");
}
catch (Exception ex)
{
_logger?.LogError(ex, "Forgot-password error");
return new OkObjectResult("If this email is registered, a reset link has been sent.");
}
}
/// <summary>
/// Consume a set-password/reset/invite token and set a new password
/// </summary>
/// <param name="dto">Token and new password</param>
[AllowAnonymous]
[HttpPost("set-password")]
[Consumes("application/json")]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
public ObjectResult SetPassword([FromBody] SetPasswordDTO dto)
{
try
{
if (string.IsNullOrEmpty(dto?.token) || string.IsNullOrEmpty(dto?.newPassword) || dto.newPassword.Length < 8)
throw new ArgumentException("Token and a password of at least 8 characters are required");
var tokenHash = PasswordTokenHelper.Hash(dto.token);
var user = _myInfoMateDbContext.Users.FirstOrDefault(u => u.PasswordTokenHash == tokenHash);
if (user == null || user.PasswordTokenExpiresAt == null || user.PasswordTokenExpiresAt < DateTime.UtcNow)
throw new ArgumentException("This link is invalid or has expired");
user.Password = _profileLogic.HashPassword(dto.newPassword);
user.PasswordTokenHash = null;
user.PasswordTokenExpiresAt = null;
_myInfoMateDbContext.SaveChanges();
return new OkObjectResult("Password updated");
}
catch (ArgumentException ex)
{
return new BadRequestObjectResult(ex.Message) { };
}
catch (Exception ex)
{
_logger?.LogError(ex, "Set-password error");
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
} }
} }

View File

@ -137,7 +137,7 @@ namespace ManagerService.Controllers
if (_myInfoMateDbContext.Instances.Any(i => i.Name == instance.Name)) if (_myInfoMateDbContext.Instances.Any(i => i.Name == instance.Name))
throw new InvalidOperationException("This name is already used"); throw new InvalidOperationException("This name is already used");
instance.WebSlug = GenerateUniqueSlug(instance.Name); instance.WebSlug = SlugHelper.GenerateUniqueSlug(_myInfoMateDbContext, instance.Name);
instance.PublicApiKey = "ap_" + Convert.ToBase64String( instance.PublicApiKey = "ap_" + Convert.ToBase64String(
System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('='); .Replace("+", "-").Replace("/", "_").TrimEnd('=');
@ -437,7 +437,7 @@ namespace ManagerService.Controllers
return new NotFoundObjectResult("Instance not found"); return new NotFoundObjectResult("Instance not found");
if (string.IsNullOrEmpty(instance.WebSlug)) if (string.IsNullOrEmpty(instance.WebSlug))
instance.WebSlug = GenerateUniqueSlug(instance.Name); instance.WebSlug = SlugHelper.GenerateUniqueSlug(_myInfoMateDbContext, instance.Name);
instance.PublicApiKey = "ap_" + Convert.ToBase64String( instance.PublicApiKey = "ap_" + Convert.ToBase64String(
System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
@ -453,26 +453,5 @@ namespace ManagerService.Controllers
return new ObjectResult(ex.Message) { StatusCode = 500 }; return new ObjectResult(ex.Message) { StatusCode = 500 };
} }
} }
private string GenerateUniqueSlug(string name)
{
var slug = name.ToLowerInvariant();
slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[àáâãäå]", "a");
slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[èéêë]", "e");
slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[ìíîï]", "i");
slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[òóôõö]", "o");
slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[ùúûü]", "u");
slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[ç]", "c");
slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[ñ]", "n");
slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[^a-z0-9\s-]", "");
slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[\s-]+", "-").Trim('-');
var baseSlug = slug;
var counter = 1;
while (_myInfoMateDbContext.Instances.Any(i => i.WebSlug == slug))
slug = $"{baseSlug}-{counter++}";
return slug;
}
} }
} }

View File

@ -0,0 +1,305 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using ManagerService.Data;
using ManagerService.DTOs;
using ManagerService.Helpers;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NSwag.Annotations;
namespace ManagerService.Controllers
{
/// <summary>
/// Self-service sign-up for the Essentiel plan: creates the Instance + the first
/// InstanceAdmin User, starts the 14-day trial, no card required.
/// </summary>
[ApiController, Route("api/onboarding")]
[OpenApiTag("Onboarding", Description = "Self-service sign-up (Essentiel plan)")]
public class OnboardingController : ControllerBase
{
private const string EssentielPlanId = "plan-essentiel";
private const int TrialDurationDays = 14;
private readonly MyInfoMateDbContext _myInfoMateDbContext;
private readonly ProfileLogic _profileLogic;
private readonly IEmailService _emailService;
private readonly IConfiguration _configuration;
private readonly ILogger<OnboardingController> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly StripeService _stripeService;
private readonly IHexIdGeneratorService _idService = new HexIdGeneratorService();
public OnboardingController(
MyInfoMateDbContext myInfoMateDbContext,
ProfileLogic profileLogic,
IEmailService emailService,
IConfiguration configuration,
ILogger<OnboardingController> logger,
IHttpClientFactory httpClientFactory,
StripeService stripeService)
{
_myInfoMateDbContext = myInfoMateDbContext;
_profileLogic = profileLogic;
_emailService = emailService;
_configuration = configuration;
_logger = logger;
_httpClientFactory = httpClientFactory;
_stripeService = stripeService;
}
/// <summary>
/// Check whether a web slug is available
/// </summary>
/// <param name="slug">Desired slug</param>
[AllowAnonymous]
[ProducesResponseType(typeof(object), 200)]
[HttpGet("check-slug/{slug}")]
public ObjectResult CheckSlug(string slug)
{
var sanitized = SanitizeSlug(slug);
var available = sanitized.Length >= 3 && !_myInfoMateDbContext.Instances.Any(i => i.WebSlug == sanitized);
return new OkObjectResult(new { slug = sanitized, available });
}
/// <summary>
/// Validate a VAT number against VIES and return the applicable VAT rate
/// </summary>
/// <param name="country">ISO country code (e.g. BE, FR)</param>
/// <param name="vatNumber">VAT number, with or without the country prefix</param>
[AllowAnonymous]
[ProducesResponseType(typeof(ValidateVatResultDTO), 200)]
[HttpPost("validate-vat")]
public async Task<ObjectResult> ValidateVat([FromQuery] string country, [FromQuery] string vatNumber)
{
const decimal domesticRate = 21m;
var euCountries = new[] { "BE", "FR", "LU", "NL", "DE", "IT", "ES", "PL" };
if (string.IsNullOrWhiteSpace(vatNumber))
{
return new OkObjectResult(new ValidateVatResultDTO
{
valid = null,
vatRate = domesticRate,
message = "Aucun numéro de TVA fourni — TVA belge appliquée par défaut."
});
}
var cleaned = new string(vatNumber.Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant();
var vatCountryCode = cleaned.Length >= 2 ? cleaned.Substring(0, 2) : "";
var vatDigits = cleaned.Length > 2 ? cleaned.Substring(2) : "";
if (!euCountries.Contains(vatCountryCode) || vatDigits.Length < 5)
{
return new OkObjectResult(new ValidateVatResultDTO
{
valid = false,
vatRate = domesticRate,
message = "Numéro de TVA invalide."
});
}
try
{
var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(6);
var payload = JsonSerializer.Serialize(new { countryCode = vatCountryCode, vatNumber = vatDigits });
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await client.PostAsync("https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number", content);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var isValid = doc.RootElement.TryGetProperty("valid", out var validProp) && validProp.GetBoolean();
return new OkObjectResult(new ValidateVatResultDTO
{
valid = isValid,
vatRate = isValid
? (vatCountryCode == "BE" ? domesticRate : 0m)
: domesticRate,
message = isValid
? (vatCountryCode == "BE" ? "TVA belge 21% appliquée." : "TVA intracommunautaire — 0% (autoliquidation).")
: "Numéro de TVA invalide selon VIES."
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "VIES lookup failed for {Country} {VatNumber} — falling back to domestic rate", vatCountryCode, vatDigits);
return new OkObjectResult(new ValidateVatResultDTO
{
valid = null,
vatRate = domesticRate,
message = "Service de validation TVA temporairement indisponible — TVA belge appliquée, à revalider ultérieurement."
});
}
}
/// <summary>
/// Create the Instance + first InstanceAdmin User and start the 14-day free trial
/// </summary>
/// <param name="dto">Registration form data</param>
[AllowAnonymous]
[ProducesResponseType(typeof(OnboardingRegisterResultDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 409)]
[ProducesResponseType(typeof(string), 500)]
[HttpPost("register")]
public async Task<ObjectResult> Register([FromBody] OnboardingRegisterDTO dto)
{
try
{
if (dto == null)
throw new ArgumentNullException(nameof(dto), "Registration data is missing");
if (string.IsNullOrWhiteSpace(dto.organizationName))
throw new ArgumentNullException(nameof(dto.organizationName), "Organization name is required");
if (string.IsNullOrWhiteSpace(dto.email))
throw new ArgumentNullException(nameof(dto.email), "Email is required");
if (string.IsNullOrWhiteSpace(dto.password) || dto.password.Length < 8)
throw new ArgumentNullException(nameof(dto.password), "Password must be at least 8 characters");
if (string.IsNullOrWhiteSpace(dto.slug))
throw new ArgumentNullException(nameof(dto.slug), "Slug is required");
var email = dto.email.Trim().ToLowerInvariant();
if (_myInfoMateDbContext.Users.Any(u => u.Email.ToLower() == email))
throw new InvalidOperationException("This email is already used");
var slug = SanitizeSlug(dto.slug);
if (slug.Length < 3)
throw new ArgumentNullException(nameof(dto.slug), "Slug must be at least 3 characters");
if (_myInfoMateDbContext.Instances.Any(i => i.WebSlug == slug))
throw new InvalidOperationException("This address is already taken");
var plan = _myInfoMateDbContext.SubscriptionPlans.FirstOrDefault(p => p.Id == EssentielPlanId);
if (plan == null)
throw new InvalidOperationException("Essentiel plan is not configured");
var trialEndsAt = DateTime.UtcNow.AddDays(TrialDurationDays);
var instance = new Instance
{
Id = _idService.GenerateHexId(),
Name = dto.organizationName.Trim(),
DateCreation = DateTime.UtcNow,
IsWeb = true,
SubscriptionPlanId = plan.Id,
StorageQuotaBytes = plan.StorageQuotaBytes,
AiTokensPerMonth = plan.AiTokensPerMonth,
HasStats = plan.HasStats,
StatsHistoryDays = plan.StatsHistoryDays,
HasAdvancedStats = plan.HasAdvancedStats,
WebSlug = slug,
PublicApiKey = "ap_" + Convert.ToBase64String(
System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('='),
IsTrialActive = true,
TrialEndsAt = trialEndsAt,
BillingAddress = dto.billingAddress,
BillingCountry = dto.billingCountry,
VatNumber = dto.vatNumber,
};
var user = new User
{
Id = _idService.GenerateHexId(),
Email = email,
Password = _profileLogic.HashPassword(dto.password),
FirstName = dto.firstName?.Trim(),
LastName = dto.lastName?.Trim(),
InstanceId = instance.Id,
Role = UserRole.InstanceAdmin,
Token = Guid.NewGuid().ToString(),
DateCreation = DateTime.UtcNow,
};
instance.StripeCustomerId = await _stripeService.CreateCustomerAsync(instance);
_myInfoMateDbContext.Instances.Add(instance);
_myInfoMateDbContext.Users.Add(user);
_myInfoMateDbContext.SaveChanges();
var managerAppUrl = _configuration["AppUrls:ManagerApp"];
var landingUrl = _configuration["AppUrls:Landing"];
await _emailService.SendWelcomeEmailAsync(user.Email, user.FirstName, $"{managerAppUrl}/login");
await _emailService.SendTrialStartedEmailAsync(user.Email, user.FirstName, trialEndsAt);
await _emailService.NotifyAdminAsync(
"Nouveau compte MyInfoMate",
$"Organisation: {instance.Name}\nEmail: {user.Email}\nSlug: {instance.WebSlug}");
return new OkObjectResult(new OnboardingRegisterResultDTO
{
instanceId = instance.Id,
slug = instance.WebSlug,
webUrl = $"https://app.myinfomate.be/{instance.WebSlug}",
managerAppUrl = managerAppUrl,
trialEndsAt = trialEndsAt.ToString("O"),
});
}
catch (ArgumentNullException ex)
{
return new BadRequestObjectResult(ex.Message) { };
}
catch (InvalidOperationException ex)
{
return new ConflictObjectResult(ex.Message) { };
}
catch (Exception ex)
{
_logger.LogError(ex, "Onboarding registration failed");
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Create a Stripe Checkout Session to convert the caller's instance from trial to a
/// paid Essentiel subscription. Stripe Checkout is hosted by Stripe — this just returns
/// the URL to redirect to.
/// </summary>
[Authorize(Policy = ManagerService.Service.Security.Policies.AppReadAccess)]
[ProducesResponseType(typeof(object), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpPost("checkout-session")]
public async Task<ObjectResult> CreateCheckoutSession()
{
try
{
var instanceId = User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value;
var instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == instanceId);
if (instance == null)
return new NotFoundObjectResult("Instance not found");
var managerAppUrl = _configuration["AppUrls:ManagerApp"];
var url = await _stripeService.CreateCheckoutSessionAsync(
instance,
successUrl: $"{managerAppUrl}/main/web",
cancelUrl: $"{managerAppUrl}/main/web");
return new OkObjectResult(new { url });
}
catch (Exception ex)
{
_logger.LogError(ex, "Checkout session creation failed");
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
private static string SanitizeSlug(string raw)
{
var slug = (raw ?? "").ToLowerInvariant();
slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[^a-z0-9-]", "-");
slug = System.Text.RegularExpressions.Regex.Replace(slug, @"-+", "-").Trim('-');
return slug;
}
}
}

View File

@ -654,10 +654,8 @@ namespace ManagerService.Controllers
} }
existingGuidedStep.ZoneRadiusMeters = guidedStepDTO.zoneRadiusMeters; existingGuidedStep.ZoneRadiusMeters = guidedStepDTO.zoneRadiusMeters;
existingGuidedStep.ImageUrl = guidedStepDTO.imageUrl; existingGuidedStep.ImageUrl = guidedStepDTO.imageUrl;
existingGuidedStep.IsHiddenInitially = guidedStepDTO.isHiddenInitially;
existingGuidedStep.QuizQuestions = guidedStepDTO.quizQuestions; // <20> convertir si besoin ? existingGuidedStep.QuizQuestions = guidedStepDTO.quizQuestions; // <20> convertir si besoin ?
existingGuidedStep.IsStepTimer = guidedStepDTO.isStepTimer; existingGuidedStep.IsStepTimer = guidedStepDTO.isStepTimer;
existingGuidedStep.IsStepLocked = guidedStepDTO.isStepLocked;
existingGuidedStep.TimerSeconds = guidedStepDTO.timerSeconds; existingGuidedStep.TimerSeconds = guidedStepDTO.timerSeconds;
existingGuidedStep.TimerExpiredMessage = guidedStepDTO.timerExpiredMessage; existingGuidedStep.TimerExpiredMessage = guidedStepDTO.timerExpiredMessage;

View File

@ -0,0 +1,132 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ManagerService.Data;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NSwag.Annotations;
using Stripe;
using Stripe.Checkout;
namespace ManagerService.Controllers
{
[AllowAnonymous]
[ApiController, Route("api/webhooks/stripe")]
[OpenApiTag("Webhooks", Description = "Stripe webhook events")]
public class StripeWebhookController : ControllerBase
{
private readonly MyInfoMateDbContext _myInfoMateDbContext;
private readonly StripeService _stripeService;
private readonly IEmailService _emailService;
private readonly IConfiguration _configuration;
private readonly ILogger<StripeWebhookController> _logger;
public StripeWebhookController(
MyInfoMateDbContext myInfoMateDbContext,
StripeService stripeService,
IEmailService emailService,
IConfiguration configuration,
ILogger<StripeWebhookController> logger)
{
_myInfoMateDbContext = myInfoMateDbContext;
_stripeService = stripeService;
_emailService = emailService;
_configuration = configuration;
_logger = logger;
}
[HttpPost]
public async Task<ObjectResult> HandleWebhook()
{
var json = await new StreamReader(Request.Body).ReadToEndAsync();
Event stripeEvent;
try
{
stripeEvent = _stripeService.ConstructWebhookEvent(json, Request.Headers["Stripe-Signature"]);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Stripe webhook signature verification failed");
return new BadRequestObjectResult("Invalid signature");
}
try
{
switch (stripeEvent.Type)
{
case "checkout.session.completed":
await HandleCheckoutSessionCompleted((Session)stripeEvent.Data.Object);
break;
case "invoice.payment_failed":
await HandleInvoicePaymentFailed((Invoice)stripeEvent.Data.Object);
break;
default:
_logger.LogInformation("Unhandled Stripe event type {Type}", stripeEvent.Type);
break;
}
return new OkObjectResult("ok");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling Stripe event {Type}", stripeEvent.Type);
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
private async Task HandleCheckoutSessionCompleted(Session session)
{
var instanceId = session.Metadata != null && session.Metadata.TryGetValue("instanceId", out var id)
? id
: null;
var instance = instanceId != null
? _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == instanceId)
: _myInfoMateDbContext.Instances.FirstOrDefault(i => i.StripeCustomerId == session.CustomerId);
if (instance == null)
{
_logger.LogWarning("checkout.session.completed: no matching instance for customer {CustomerId}", session.CustomerId);
return;
}
instance.IsTrialActive = false;
instance.StripeSubscriptionId = session.SubscriptionId;
_myInfoMateDbContext.SaveChanges();
var user = _myInfoMateDbContext.Users.FirstOrDefault(u => u.InstanceId == instance.Id);
if (user != null)
await _emailService.SendPaymentConfirmedEmailAsync(user.Email, user.FirstName);
await _emailService.NotifyAdminAsync(
"Conversion essai -> payant",
$"Instance: {instance.Name} ({instance.Id})\nSubscription: {instance.StripeSubscriptionId}");
}
private async Task HandleInvoicePaymentFailed(Invoice invoice)
{
var instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.StripeCustomerId == invoice.CustomerId);
if (instance == null)
{
_logger.LogWarning("invoice.payment_failed: no matching instance for customer {CustomerId}", invoice.CustomerId);
return;
}
var user = _myInfoMateDbContext.Users.FirstOrDefault(u => u.InstanceId == instance.Id);
if (user != null)
{
var managerAppUrl = _configuration["AppUrls:ManagerApp"];
await _emailService.SendPaymentFailedEmailAsync(user.Email, user.FirstName, $"{managerAppUrl}/billing");
}
await _emailService.NotifyAdminAsync(
"Paiement échoué",
$"Instance: {instance.Name} ({instance.Id})");
}
}
}

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Security.Claims; using System.Security.Claims;
using System.Threading.Tasks;
using Manager.Services; using Manager.Services;
using ManagerService.Data; using ManagerService.Data;
using ManagerService.DTOs; using ManagerService.DTOs;
@ -10,6 +11,7 @@ using ManagerService.Service;
using ManagerService.Services; using ManagerService.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NSwag.Annotations; using NSwag.Annotations;
@ -24,14 +26,18 @@ namespace ManagerService.Controllers
private readonly ILogger<UserController> _logger; private readonly ILogger<UserController> _logger;
private readonly ProfileLogic _profileLogic; private readonly ProfileLogic _profileLogic;
private readonly MyInfoMateDbContext _myInfoMateDbContext; private readonly MyInfoMateDbContext _myInfoMateDbContext;
private readonly IEmailService _emailService;
private readonly IConfiguration _configuration;
IHexIdGeneratorService idService = new HexIdGeneratorService(); IHexIdGeneratorService idService = new HexIdGeneratorService();
public UserController(ILogger<UserController> logger, UserDatabaseService userService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext) public UserController(ILogger<UserController> logger, UserDatabaseService userService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext, IEmailService emailService, IConfiguration configuration)
{ {
_logger = logger; _logger = logger;
_userService = userService; _userService = userService;
_profileLogic = profileLogic; _profileLogic = profileLogic;
_myInfoMateDbContext = myInfoMateDbContext; _myInfoMateDbContext = myInfoMateDbContext;
_emailService = emailService;
_configuration = configuration;
} }
private string? GetCallerInstanceId() => private string? GetCallerInstanceId() =>
@ -109,7 +115,7 @@ namespace ManagerService.Controllers
[ProducesResponseType(typeof(string), 409)] [ProducesResponseType(typeof(string), 409)]
[ProducesResponseType(typeof(string), 500)] [ProducesResponseType(typeof(string), 500)]
[HttpPost] [HttpPost]
public ObjectResult CreateUser([FromBody] UserDetailDTO newUserDTO) public async Task<ObjectResult> CreateUser([FromBody] UserDetailDTO newUserDTO)
{ {
try try
{ {
@ -119,9 +125,6 @@ namespace ManagerService.Controllers
if (newUserDTO.instanceId == null) if (newUserDTO.instanceId == null)
throw new ArgumentNullException("InstanceId is null"); throw new ArgumentNullException("InstanceId is null");
if (newUserDTO.password == null)
throw new ArgumentNullException("Password is null");
var requestedRole = newUserDTO.role ?? UserRole.ContentEditor; var requestedRole = newUserDTO.role ?? UserRole.ContentEditor;
if (requestedRole < GetCallerRole()) if (requestedRole < GetCallerRole())
throw new UnauthorizedAccessException("Cannot assign a role higher than your own"); throw new UnauthorizedAccessException("Cannot assign a role higher than your own");
@ -139,11 +142,30 @@ namespace ManagerService.Controllers
if (_myInfoMateDbContext.Users.Any(u => u.Email == newUser.Email)) if (_myInfoMateDbContext.Users.Any(u => u.Email == newUser.Email))
throw new InvalidOperationException("This Email is already used"); throw new InvalidOperationException("This Email is already used");
// No password provided: invite the user by email instead — they set their own
// password via a token link (same mechanism as onboarding/forgot-password).
string inviteRawToken = null;
if (string.IsNullOrEmpty(newUserDTO.password))
{
newUser.Password = _profileLogic.HashPassword(PasswordUtils.GetUniqueKey());
inviteRawToken = PasswordTokenHelper.GenerateRawToken();
newUser.PasswordTokenHash = PasswordTokenHelper.Hash(inviteRawToken);
newUser.PasswordTokenExpiresAt = PasswordTokenHelper.DefaultExpiry();
}
else
{
newUser.Password = _profileLogic.HashPassword(newUserDTO.password); newUser.Password = _profileLogic.HashPassword(newUserDTO.password);
}
_myInfoMateDbContext.Add(newUser); _myInfoMateDbContext.Add(newUser);
_myInfoMateDbContext.SaveChanges(); _myInfoMateDbContext.SaveChanges();
if (inviteRawToken != null)
{
var managerAppUrl = _configuration["AppUrls:ManagerApp"];
await _emailService.SendUserInvitationEmailAsync(newUser.Email, $"{managerAppUrl}/set-password?token={inviteRawToken}");
}
return new OkObjectResult(newUser.ToDTO()); return new OkObjectResult(newUser.ToDTO());
} }
catch (ArgumentNullException ex) catch (ArgumentNullException ex)

View File

@ -10,6 +10,13 @@ namespace ManagerService.DTOs
public AppType AppType { get; set; } public AppType AppType { get; set; }
public string ConfigurationId { get; set; } // null = scope instance, fourni = scope configuration public string ConfigurationId { get; set; } // null = scope instance, fourni = scope configuration
public string Language { get; set; } public string Language { get; set; }
/// <summary>
/// GUID généré par le client, stable sur une session de conversation.
/// History étant reconstruit côté client à chaque appel, c'est le seul moyen
/// de relier deux questions d'un même visiteur côté serveur.
/// Optionnel : un appel sans ConversationId reste valide, la ligne journalisée est isolée.
/// </summary>
public string ConversationId { get; set; }
public List<AiChatMessage> History { get; set; } = new(); public List<AiChatMessage> History { get; set; } = new();
/// <summary> /// <summary>
/// true = interaction vocale (lunettes, assistant vocal). /// true = interaction vocale (lunettes, assistant vocal).

View File

@ -0,0 +1,7 @@
namespace ManagerService.DTOs
{
public class ForgotPasswordDTO
{
public string email { get; set; }
}
}

View File

@ -18,10 +18,7 @@ namespace ManagerService.DTOs
public string imageUrl { get; set; } public string imageUrl { get; set; }
public List<TranslationDTO> audioIds { get; set; } public List<TranslationDTO> audioIds { get; set; }
public List<ContentDTO> contents { get; set; } public List<ContentDTO> contents { get; set; }
public List<TranslationDTO> factContent { get; set; }
public bool isHiddenInitially { get; set; }
public bool isStepTimer { get; set; } public bool isStepTimer { get; set; }
public bool isStepLocked { get; set; }
public int? timerSeconds { get; set; } public int? timerSeconds { get; set; }
public List<TranslationDTO> timerExpiredMessage { get; set; } public List<TranslationDTO> timerExpiredMessage { get; set; }
public List<QuizQuestion> quizQuestions { get; set; } public List<QuizQuestion> quizQuestions { get; set; }

View File

@ -17,6 +17,11 @@ namespace ManagerService.DTOs
public bool? isAssistant { get; set; } public bool? isAssistant { get; set; }
public string? guideName { get; set; }
public string? guidePersonaPrompt { get; set; }
public string? guideVoiceId { get; set; }
public List<TranslationDTO>? guideFallbackMessages { get; set; }
public string? webSlug { get; set; } public string? webSlug { get; set; }
public string? publicApiKey { get; set; } public string? publicApiKey { get; set; }
@ -31,6 +36,15 @@ namespace ManagerService.DTOs
public int? statsHistoryDays { get; set; } public int? statsHistoryDays { get; set; }
public bool? hasAdvancedStats { get; set; } public bool? hasAdvancedStats { get; set; }
public bool? isTrialActive { get; set; }
public DateTime? trialEndsAt { get; set; }
public long? trialAiTokensUsed { get; set; }
public string? billingAddress { get; set; }
public string? billingCountry { get; set; }
public string? vatNumber { get; set; }
public decimal? vatRate { get; set; }
public List<ApplicationInstanceDTO> applicationInstanceDTOs { get; set; } public List<ApplicationInstanceDTO> applicationInstanceDTOs { get; set; }
} }
} }

View File

@ -0,0 +1,16 @@
namespace ManagerService.DTOs
{
public class OnboardingRegisterDTO
{
public string organizationName { get; set; }
public string firstName { get; set; }
public string lastName { get; set; }
public string email { get; set; }
public string password { get; set; }
public string slug { get; set; }
public string? billingAddress { get; set; }
public string? billingCountry { get; set; }
public string? vatNumber { get; set; }
}
}

View File

@ -0,0 +1,11 @@
namespace ManagerService.DTOs
{
public class OnboardingRegisterResultDTO
{
public string instanceId { get; set; }
public string slug { get; set; }
public string webUrl { get; set; }
public string managerAppUrl { get; set; }
public string trialEndsAt { get; set; }
}
}

View File

@ -0,0 +1,8 @@
namespace ManagerService.DTOs
{
public class SetPasswordDTO
{
public string token { get; set; }
public string newPassword { get; set; }
}
}

View File

@ -0,0 +1,10 @@
namespace ManagerService.DTOs
{
public class ValidateVatResultDTO
{
/// <summary>true = valid, false = invalid, null = could not be checked (VIES unavailable)</summary>
public bool? valid { get; set; }
public decimal vatRate { get; set; }
public string message { get; set; }
}
}

View File

@ -0,0 +1,53 @@
using System;
using Pgvector;
namespace ManagerService.Data
{
/// <summary>
/// Un morceau de contenu indexé pour la recherche sémantique du guide IA.
/// Un contenu produit N lignes : une par morceau et par langue.
/// </summary>
public class ContentEmbedding
{
/// <summary>Dimension du vecteur, imposée par le modèle d'embedding (gemini-embedding-001).</summary>
public const int Dimensions = 768;
public long Id { get; set; }
/// <summary>Client propriétaire. Filtre strict à la recherche : jamais de résultat d'une autre instance.</summary>
public string InstanceId { get; set; }
/// <summary>
/// Configuration d'origine, null pour une ressource (Resource n'appartient qu'à une instance).
/// Sert à privilégier la visite en cours au classement — jamais à filtrer.
/// </summary>
public string? ConfigurationId { get; set; }
/// <summary>Id de la Section ou de la Resource dont ce morceau provient.</summary>
public string ContentId { get; set; }
public ContentSourceType ContentType { get; set; }
/// <summary>Position du morceau dans le contenu source.</summary>
public int ChunkIndex { get; set; }
/// <summary>Page du document, null hors contenu paginé. Sert à citer la source.</summary>
public int? PageNumber { get; set; }
/// <summary>Langue de ce morceau (FR, NL, EN...). La recherche ne filtre pas dessus.</summary>
public string Language { get; set; }
/// <summary>Le texte réellement injecté dans le prompt. L'embedding ne sert qu'à retrouver cette ligne.</summary>
public string Text { get; set; }
public Vector Embedding { get; set; }
public DateTime UpdatedAt { get; set; }
}
public enum ContentSourceType
{
Section = 0,
Resource = 1
}
}

View File

@ -36,6 +36,25 @@ namespace ManagerService.Data
public bool IsAssistant { get; set; } public bool IsAssistant { get; set; }
/// <summary>Nom du guide affiché au visiteur, libre. Ex: "Léon".</summary>
public string? GuideName { get; set; }
/// <summary>
/// Personnalité du guide : instruction envoyée au modèle, jamais affichée.
/// Écrite dans une seule langue — le modèle répond dans celle du visiteur.
/// </summary>
public string? GuidePersonaPrompt { get; set; }
/// <summary>Voix Gemini TTS : "Sulafat" (Viva, féminine) ou "Umbriel" (Marco, masculine).</summary>
public string? GuideVoiceId { get; set; }
/// <summary>
/// Formulations utilisées quand le guide ne trouve pas de réponse.
/// Liste à plat : plusieurs entrées peuvent partager la même langue,
/// une est tirée au hasard parmi celles de la langue du visiteur.
/// </summary>
public List<TranslationDTO>? GuideFallbackMessages { get; set; }
public string? WebSlug { get; set; } public string? WebSlug { get; set; }
public string? PublicApiKey { get; set; } public string? PublicApiKey { get; set; }
@ -59,6 +78,31 @@ namespace ManagerService.Data
public bool HasAdvancedStats { get; set; } = false; public bool HasAdvancedStats { get; set; } = false;
public bool IsActive { get; set; } = true;
public bool IsTrialActive { get; set; } = false;
public DateTime? TrialEndsAt { get; set; }
public long TrialAiTokensUsed { get; set; } = 0;
public bool TrialCheckInEmailSent { get; set; } = false;
public bool TrialReminderEmailSent { get; set; } = false;
public bool TrialLastDayEmailSent { get; set; } = false;
public string? BillingAddress { get; set; }
public string? BillingCountry { get; set; }
public string? VatNumber { get; set; }
public decimal? VatRate { get; set; }
public string? StripeCustomerId { get; set; }
public string? StripeSubscriptionId { get; set; }
public InstanceDTO ToDTO(List<ApplicationInstanceDTO> applicationInstanceDTOs) public InstanceDTO ToDTO(List<ApplicationInstanceDTO> applicationInstanceDTOs)
{ {
@ -74,6 +118,10 @@ namespace ManagerService.Data
isWeb = IsWeb, isWeb = IsWeb,
isVR = IsVR, isVR = IsVR,
isAssistant = IsAssistant, isAssistant = IsAssistant,
guideName = GuideName,
guidePersonaPrompt = GuidePersonaPrompt,
guideVoiceId = GuideVoiceId,
guideFallbackMessages = GuideFallbackMessages,
webSlug = WebSlug, webSlug = WebSlug,
publicApiKey = PublicApiKey, publicApiKey = PublicApiKey,
subscriptionPlanId = SubscriptionPlanId, subscriptionPlanId = SubscriptionPlanId,
@ -85,6 +133,13 @@ namespace ManagerService.Data
hasStats = HasStats, hasStats = HasStats,
statsHistoryDays = StatsHistoryDays, statsHistoryDays = StatsHistoryDays,
hasAdvancedStats = HasAdvancedStats, hasAdvancedStats = HasAdvancedStats,
isTrialActive = IsTrialActive,
trialEndsAt = TrialEndsAt,
trialAiTokensUsed = TrialAiTokensUsed,
billingAddress = BillingAddress,
billingCountry = BillingCountry,
vatNumber = VatNumber,
vatRate = VatRate,
applicationInstanceDTOs = applicationInstanceDTOs applicationInstanceDTOs = applicationInstanceDTOs
}; };
} }
@ -100,6 +155,14 @@ namespace ManagerService.Data
IsWeb = instanceDTO.isWeb ?? false; IsWeb = instanceDTO.isWeb ?? false;
IsVR = instanceDTO.isVR ?? false; IsVR = instanceDTO.isVR ?? false;
IsAssistant = instanceDTO.isAssistant ?? false; IsAssistant = instanceDTO.isAssistant ?? false;
if (instanceDTO.guideName != null)
GuideName = instanceDTO.guideName;
if (instanceDTO.guidePersonaPrompt != null)
GuidePersonaPrompt = instanceDTO.guidePersonaPrompt;
if (instanceDTO.guideVoiceId != null)
GuideVoiceId = instanceDTO.guideVoiceId;
if (instanceDTO.guideFallbackMessages != null)
GuideFallbackMessages = instanceDTO.guideFallbackMessages;
if (instanceDTO.webSlug != null) if (instanceDTO.webSlug != null)
WebSlug = instanceDTO.webSlug; WebSlug = instanceDTO.webSlug;
if (instanceDTO.publicApiKey != null) if (instanceDTO.publicApiKey != null)
@ -116,6 +179,14 @@ namespace ManagerService.Data
StatsHistoryDays = instanceDTO.statsHistoryDays.Value; StatsHistoryDays = instanceDTO.statsHistoryDays.Value;
if (instanceDTO.hasAdvancedStats.HasValue) if (instanceDTO.hasAdvancedStats.HasValue)
HasAdvancedStats = instanceDTO.hasAdvancedStats.Value; HasAdvancedStats = instanceDTO.hasAdvancedStats.Value;
if (instanceDTO.billingAddress != null)
BillingAddress = instanceDTO.billingAddress;
if (instanceDTO.billingCountry != null)
BillingCountry = instanceDTO.billingCountry;
if (instanceDTO.vatNumber != null)
VatNumber = instanceDTO.vatNumber;
if (instanceDTO.vatRate.HasValue)
VatRate = instanceDTO.vatRate.Value;
return this; return this;
} }

View File

@ -16,6 +16,12 @@ namespace ManagerService.Data
{ {
public class MyInfoMateDbContext : DbContext public class MyInfoMateDbContext : DbContext
{ {
/// <summary>
/// Fenêtre d'historique des statistiques, en jours — 13 mois.
/// Sert à la fois de valeur seed des plans et de seuil de purge des VisitEvent.
/// </summary>
public const int StatsRetentionDays = 395;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
public MyInfoMateDbContext(DbContextOptions<MyInfoMateDbContext> options, IHttpContextAccessor httpContextAccessor) public MyInfoMateDbContext(DbContextOptions<MyInfoMateDbContext> options, IHttpContextAccessor httpContextAccessor)
@ -64,6 +70,12 @@ namespace ManagerService.Data
// Audit // Audit
public DbSet<AuditLog> AuditLogs { get; set; } public DbSet<AuditLog> AuditLogs { get; set; }
// Guide IA — recherche sémantique
public DbSet<ContentEmbedding> ContentEmbeddings { get; set; }
// Guide IA — journal des questions visiteurs
public DbSet<VisitorQuestion> VisitorQuestions { get; set; }
public override int SaveChanges(bool acceptAllChangesOnSuccess) public override int SaveChanges(bool acceptAllChangesOnSuccess)
{ {
StampAuditableEntities(); StampAuditableEntities();
@ -104,12 +116,18 @@ namespace ManagerService.Data
private List<AuditLog> BuildAuditEntries() private List<AuditLog> BuildAuditEntries()
{ {
var userId = _httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier); var userId = _httpContextAccessor?.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier);
var entries = new List<AuditLog>(); var entries = new List<AuditLog>();
foreach (var entry in ChangeTracker.Entries() // Matérialisé avant la boucle : ajouter un AuditLog au contexte modifie le
// ChangeTracker, ce qui invaliderait l'énumération en cours
// ("Collection was modified"). Les logs sont donc ajoutés après la boucle.
var auditedEntries = ChangeTracker.Entries()
.Where(e => AuditedTypes.Contains(e.Entity.GetType()) .Where(e => AuditedTypes.Contains(e.Entity.GetType())
&& e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)) && e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.ToList();
foreach (var entry in auditedEntries)
{ {
var action = entry.State switch var action = entry.State switch
{ {
@ -154,10 +172,12 @@ namespace ManagerService.Data
NewValues = newValues NewValues = newValues
}; };
AuditLogs.Add(log);
entries.Add(log); entries.Add(log);
} }
if (entries.Count > 0)
AuditLogs.AddRange(entries);
return entries; return entries;
} }
@ -191,6 +211,74 @@ namespace ManagerService.Data
v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options)); v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<Instance>()
.Property(i => i.GuideFallbackMessages)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
// Guide IA — recherche sémantique.
// Le type vector n'existe que sur Npgsql : les tests tournent sur EF InMemory,
// qui échoue à la validation du modèle s'il rencontre une propriété Vector.
// L'entité y est donc exclue — elle n'a de sens que face à une vraie base.
if (!Database.IsNpgsql())
{
modelBuilder.Ignore<ContentEmbedding>();
}
else
{
// L'extension est déclarée ici pour que la migration la crée : aucun
// CREATE EXTENSION à lancer à la main sur dev, preprod ou prod.
modelBuilder.HasPostgresExtension("vector");
modelBuilder.Entity<ContentEmbedding>(entity =>
{
entity.Property(e => e.Embedding)
.HasColumnType($"vector({ContentEmbedding.Dimensions})")
.IsRequired();
// Le projet n'active pas les reference types nullable : sans IsRequired()
// explicite, EF génère tout en nullable — y compris InstanceId, qui porte
// la séparation entre deux clients.
entity.Property(e => e.InstanceId).IsRequired();
entity.Property(e => e.ContentId).IsRequired();
entity.Property(e => e.Text).IsRequired();
entity.Property(e => e.Language).HasMaxLength(5).IsRequired();
// Recherche par similarité cosinus.
entity.HasIndex(e => e.Embedding)
.HasMethod("hnsw")
.HasOperators("vector_cosine_ops");
// Filtre appliqué à chaque recherche.
entity.HasIndex(e => new { e.InstanceId, e.ContentType });
// Hangfire rejoue les jobs (retry, redémarrage de conteneur). Sans cette
// contrainte, un second passage dupliquerait silencieusement les morceaux,
// qui remonteraient ensuite deux fois dans chaque réponse du guide.
entity.HasIndex(e => new { e.ContentType, e.ContentId, e.ChunkIndex })
.IsUnique();
});
}
modelBuilder.Entity<VisitorQuestion>(entity =>
{
entity.Property(e => e.CitedContentIds)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<string>>(v, options));
entity.Property(e => e.Language).HasMaxLength(5);
// Volume par période et questions sans réponse du mois.
entity.HasIndex(e => new { e.InstanceId, e.CreatedAt });
// Reconstitution d'une conversation, et purge à 90 jours.
entity.HasIndex(e => e.ConversationId);
});
modelBuilder.Entity<Section>() modelBuilder.Entity<Section>()
.Property<string>("Discriminator") .Property<string>("Discriminator")
.HasMaxLength(50); .HasMaxLength(50);
@ -357,13 +445,6 @@ namespace ManagerService.Data
v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<ContentDTO>>(v, options)); v => JsonSerializer.Deserialize<List<ContentDTO>>(v, options));
modelBuilder.Entity<GuidedStep>()
.Property(gs => gs.FactContent)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
// SectionParcours: lien optionnel vers une SectionMap de base // SectionParcours: lien optionnel vers une SectionMap de base
modelBuilder.Entity<SectionParcours>() modelBuilder.Entity<SectionParcours>()
.HasOne(sp => sp.BaseMap) .HasOne(sp => sp.BaseMap)
@ -429,6 +510,24 @@ namespace ManagerService.Data
.Property(i => i.StatsHistoryDays).ValueGeneratedNever(); .Property(i => i.StatsHistoryDays).ValueGeneratedNever();
modelBuilder.Entity<Instance>() modelBuilder.Entity<Instance>()
.Property(i => i.HasAdvancedStats).ValueGeneratedNever(); .Property(i => i.HasAdvancedStats).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.IsActive).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.IsTrialActive).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.TrialAiTokensUsed).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.TrialCheckInEmailSent).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.TrialReminderEmailSent).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.TrialLastDayEmailSent).ValueGeneratedNever();
// Fenêtre d'historique lisible, identique pour tous les plans qui ont les stats.
// 13 mois : un dossier de subside est annuel, et il faut pouvoir comparer au même
// mois de l'année précédente. La différenciation commerciale se fait sur la
// profondeur (HasAdvancedStats), pas sur la durée — décidé le 2026-08-09.
// Au-delà, les VisitEvent sont purgés (job `visit-events-purge`).
// Seed : plans d'abonnement par défaut // Seed : plans d'abonnement par défaut
modelBuilder.Entity<SubscriptionPlan>().HasData( modelBuilder.Entity<SubscriptionPlan>().HasData(
@ -449,7 +548,7 @@ namespace ManagerService.Data
StorageQuotaBytes = 10L * 1024 * 1024 * 1024, // 10 GB StorageQuotaBytes = 10L * 1024 * 1024 * 1024, // 10 GB
AiTokensPerMonth = 5_000_000, // ~500 req/mois * ~10k tokens/req estimés, à ajuster selon l'usage réel observé AiTokensPerMonth = 5_000_000, // ~500 req/mois * ~10k tokens/req estimés, à ajuster selon l'usage réel observé
HasStats = true, HasStats = true,
StatsHistoryDays = 30, StatsHistoryDays = StatsRetentionDays,
HasAdvancedStats = false, HasAdvancedStats = false,
}, },
new SubscriptionPlan new SubscriptionPlan
@ -459,8 +558,18 @@ namespace ManagerService.Data
StorageQuotaBytes = 50L * 1024 * 1024 * 1024, // 50 GB StorageQuotaBytes = 50L * 1024 * 1024 * 1024, // 50 GB
AiTokensPerMonth = 20_000_000, // ~2000 req/mois * ~10k tokens/req estimés, à ajuster selon l'usage réel observé AiTokensPerMonth = 20_000_000, // ~2000 req/mois * ~10k tokens/req estimés, à ajuster selon l'usage réel observé
HasStats = true, HasStats = true,
StatsHistoryDays = 0, StatsHistoryDays = StatsRetentionDays,
HasAdvancedStats = true, HasAdvancedStats = true,
},
new SubscriptionPlan
{
Id = "plan-essentiel",
Name = "Essentiel",
StorageQuotaBytes = 1L * 1024 * 1024 * 1024, // 1 GB
AiTokensPerMonth = 500_000, // ~50 req/mois * ~10k tokens/req estimés — IA reste surtout un add-on payant sur ce plan
HasStats = true,
StatsHistoryDays = StatsRetentionDays,
HasAdvancedStats = false,
} }
); );
} }

View File

@ -42,6 +42,30 @@ namespace ManagerService.Data
public long SizeBytes { get; set; } = 0; public long SizeBytes { get; set; } = 0;
/// <summary>
/// Clé relative dans le stockage (`pictures/{instanceId}/{resourceId}`), source de vérité.
/// L'URL absolue à jeton reste dans Url, mais elle n'est plus ce dont on part.
/// </summary>
public string StoragePath { get; set; }
/// <summary>
/// Vrai nom de fichier ("brochure-2026.pdf"). Ni Label (libellé libre) ni Url (jeton)
/// ne le donnent — nécessaire pour choisir l'extracteur et citer la source.
/// </summary>
public string FileName { get; set; }
/// <summary>Inclure ce document dans les connaissances du guide IA. Exposé dans l'UI en V2 seulement.</summary>
public bool IncludeInAiKnowledge { get; set; } = false;
public AiIndexStatus AiIndexStatus { get; set; } = AiIndexStatus.NotIndexed;
/// <summary>Raison de l'échec d'indexation, affichée telle quelle au client.</summary>
public string AiIndexMessage { get; set; }
public DateTime? AiIndexedAt { get; set; }
public int AiChunkCount { get; set; } = 0;
public ResourceDTO ToDTO() public ResourceDTO ToDTO()
{ {
return new ResourceDTO() return new ResourceDTO()
@ -59,13 +83,27 @@ namespace ManagerService.Data
public enum ResourceType public enum ResourceType
{ {
Image, // ⚠️ Persisté en int. Ne JAMAIS réordonner ni insérer au milieu : les valeurs
Video, // existantes en base se décaleraient (les PDF deviendraient des JSON).
ImageUrl, // Toute nouvelle valeur s'ajoute à la fin.
VideoUrl, Image, // 0
Audio, Video, // 1
PDF, ImageUrl, // 2
JSON, VideoUrl, // 3
JSONUrl Audio, // 4
PDF, // 5
JSON, // 6
JSONUrl, // 7
Word, // 8
PowerPoint, // 9
Text // 10
}
public enum AiIndexStatus
{
NotIndexed = 0,
Pending = 1,
Indexed = 2,
Failed = 3
} }
} }

View File

@ -45,20 +45,12 @@ namespace ManagerService.Data.SubSection
[Column(TypeName = "jsonb")] [Column(TypeName = "jsonb")]
public List<ContentDTO> Contents { get; set; } = new(); public List<ContentDTO> Contents { get; set; } = new();
[Column(TypeName = "jsonb")]
public List<TranslationDTO> FactContent { get; set; } = new();
public bool IsHiddenInitially { get; set; } = false;
// Exemple pour escape game // Exemple pour escape game
public List<QuizQuestion>? QuizQuestions { get; set; } // One or multiple question public List<QuizQuestion>? QuizQuestions { get; set; } // One or multiple question
// Option : si true, cette étape a un compte à rebourds, false sinon // Option : si true, cette étape a un compte à rebourds, false sinon
public bool IsStepTimer { get; set; } = false; public bool IsStepTimer { get; set; } = false;
// Option : si true, cette étape doit être validée avant de passer à la suivante
public bool IsStepLocked { get; set; } = true;
// Timer en secondes (durée max pour valider cette étape, optionnel) // Timer en secondes (durée max pour valider cette étape, optionnel)
public int? TimerSeconds { get; set; } public int? TimerSeconds { get; set; }
@ -81,10 +73,7 @@ namespace ManagerService.Data.SubSection
imageUrl = ImageUrl, imageUrl = ImageUrl,
audioIds = AudioIds, audioIds = AudioIds,
contents = Contents, contents = Contents,
factContent = FactContent,
isHiddenInitially = IsHiddenInitially,
isStepTimer = IsStepTimer, isStepTimer = IsStepTimer,
isStepLocked = IsStepLocked,
timerSeconds = TimerSeconds, timerSeconds = TimerSeconds,
timerExpiredMessage = TimerExpiredMessage, timerExpiredMessage = TimerExpiredMessage,
quizQuestions = QuizQuestions quizQuestions = QuizQuestions
@ -104,10 +93,7 @@ namespace ManagerService.Data.SubSection
ImageUrl = dto.imageUrl; ImageUrl = dto.imageUrl;
AudioIds = dto.audioIds ?? new List<TranslationDTO>(); AudioIds = dto.audioIds ?? new List<TranslationDTO>();
Contents = dto.contents ?? new List<ContentDTO>(); Contents = dto.contents ?? new List<ContentDTO>();
FactContent = dto.factContent ?? new List<TranslationDTO>();
IsHiddenInitially = dto.isHiddenInitially;
IsStepTimer = dto.isStepTimer; IsStepTimer = dto.isStepTimer;
IsStepLocked = dto.isStepLocked;
TimerSeconds = dto.timerSeconds; TimerSeconds = dto.timerSeconds;
TimerExpiredMessage = dto.timerExpiredMessage ?? new List<TranslationDTO>(); TimerExpiredMessage = dto.timerExpiredMessage ?? new List<TranslationDTO>();
Order = dto.order.GetValueOrDefault(); Order = dto.order.GetValueOrDefault();

View File

@ -147,61 +147,4 @@ namespace ManagerService.Data.SubSection
// SectionMap "normal" comme avant => Via geopoints. Si il y a des guidedPath lié à la sectionMap alors il y a un parcours lié à la sectionMap, tu peux aussi avoir des geopoints classique mis là + des parcours. et aussi lier un guidedstep à un geopoint (pour le trigger et aussi pour "afficher plus") // SectionMap "normal" comme avant => Via geopoints. Si il y a des guidedPath lié à la sectionMap alors il y a un parcours lié à la sectionMap, tu peux aussi avoir des geopoints classique mis là + des parcours. et aussi lier un guidedstep à un geopoint (pour le trigger et aussi pour "afficher plus")
// parcours libre
/*{
"IsLinear": false,
"RequireSuccessToAdvance": false,
"HideNextStepsUntilComplete": false,
"Steps": [
{ "Title": "La tour", "GeoPointId": "...", "IsOptional": false },
{ "Title": "Les jardins", "GeoPointId": "...", "IsOptional": false }
]
}*/
// Parcours escape game
/*{
"IsLinear": true,
"RequireSuccessToAdvance": true,
"HideNextStepsUntilComplete": true,
"Steps": [
{ "Title": "Enigme 1", "RiddleQuestion": "Quel est le code ?", "RiddleAnswer": "1234" },
{ "Title": "Enigme 2", "RiddleQuestion": "Combien de colonnes ?", "RiddleAnswer": "6" }
]
}*/
/*
* {
"Id": "escape001",
"IsLinear": true,
"RequireSuccessToAdvance": true,
"HideNextStepsUntilComplete": true,
"Steps": [
{
"Id": "step1",
"Title": [{ "Lang": "fr", "Text": "Enigme 1" }],
"TriggerGeoPoint": {
"GeometryType": "Circle",
"Coordinates": [{ "Latitude": 49.5, "Longitude": 5.9 }],
"ZoneRadiusMeters": 30
},
"ValidationQuestion": [{ "Lang": "fr", "Text": "Quel est le code secret ?" }],
"ExpectedAnswer": [{ "Lang": "fr", "Text": "1234" }],
"IsStepTimer": true,
"TimerSeconds": 300,
"IsStepLocked": true
},
{
"Id": "step2",
"Title": [{ "Lang": "fr", "Text": "Enigme 2" }],
"TriggerGeoPoint": {
"GeometryType": "Point",
"Coordinates": [{ "Latitude": 49.51, "Longitude": 5.91 }]
},
"ValidationQuestion": [{ "Lang": "fr", "Text": "Combien de colonnes ?" }],
"ExpectedAnswer": [{ "Lang": "fr", "Text": "6" }],
"IsStepTimer": false,
"IsStepLocked": true
}
]
}
*/
} }

View File

@ -53,6 +53,11 @@ namespace ManagerService.Data
public UserRole Role { get; set; } = UserRole.ContentEditor; public UserRole Role { get; set; } = UserRole.ContentEditor;
/// <summary>SHA-256 hash of the pending set-password/reset token, if any.</summary>
public string? PasswordTokenHash { get; set; }
public DateTime? PasswordTokenExpiresAt { get; set; }
public UserDetailDTO ToDTO() public UserDetailDTO ToDTO()
{ {
return new UserDetailDTO() return new UserDetailDTO()

View File

@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
namespace ManagerService.Data
{
/// <summary>
/// Une question posée au guide IA, et ce qu'il a répondu.
/// Une ligne par tour de conversation — pas l'historique complet à chaque tour,
/// sinon le tour 5 réécrirait les tours 1 à 4.
///
/// ⚠️ RGPD : texte libre saisi par un visiteur, pouvant contenir des données
/// personnelles voire sensibles (« je suis en fauteuil, c'est accessible ? »).
/// Aucun identifiant nominatif — ConversationId est un GUID de session.
/// Les questions brutes se purgent à 90 jours, les agrégats survivent.
/// </summary>
public class VisitorQuestion
{
public long Id { get; set; }
/// <summary>GUID de session généré par le front, stable sur une conversation. Seul lien entre deux tours.</summary>
public string ConversationId { get; set; }
public string InstanceId { get; set; }
/// <summary>Null = question posée hors d'une visite (scope instance).</summary>
public string ConfigurationId { get; set; }
public AppType AppType { get; set; }
public bool IsVoice { get; set; }
public string Language { get; set; }
public string Question { get; set; }
public string Reply { get; set; }
public long TokensUsed { get; set; }
/// <summary>Faux = le guide n'a rien trouvé. C'est la colonne qui produit le rapport de trous de contenu.</summary>
public bool HasAnswer { get; set; }
/// <summary>Meilleur score de similarité du retrieval. 0 tant que le RAG n'est pas branché.</summary>
public double TopScore { get; set; }
/// <summary>Contenus cités dans la réponse (Section.Id / Resource.Id).</summary>
public List<string> CitedContentIds { get; set; }
/// <summary>Rempli a posteriori par le job de regroupement, null jusque-là.</summary>
public string ThemeId { get; set; }
public DateTime CreatedAt { get; set; }
}
}

View File

@ -0,0 +1,20 @@
# PostgreSQL 16 + PostGIS + pgvector.
#
# PostGIS reste indispensable (NetTopologySuite : cartes, zones de géodéclenchement),
# donc on part de l'image postgis et on y ajoute pgvector — l'inverse
# (pgvector/pgvector:pg16) ferait perdre PostGIS.
#
# Image épinglée par digest, pas par tag : postgis/postgis:16-3.4 est mobile.
# Si la glibc change sous une base existante, les index B-tree sur colonnes texte
# se retrouvent triés selon des règles qui ne sont plus celles du moteur
# (« collation version mismatch »), et une recherche indexée peut manquer des lignes.
# Digest relevé le 2026-08-09 — glibc 2.31 (Debian 11).
FROM postgis/postgis:16-3.4@sha256:44126d872ac91993766c341e369c539e8196614321765d36a6f1bab0419a5fa5
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql-16-pgvector \
&& rm -rf /var/lib/apt/lists/*
# Les extensions elles-mêmes ne sont pas créées ici : elles le sont par les
# migrations EF Core (HasPostgresExtension), pour qu'aucun environnement
# ne dépende d'un CREATE EXTENSION lancé à la main.

View File

@ -8,6 +8,21 @@ Pour le moment :
[{"maxAgeSeconds": 3600, "method": ["GET", "POST", "DELETE", "PUT"], "origin": ["http://localhost:49430", "https://manager.myinfomate.be", "https://manager.mymuseum.be", "https://fortsaintheribert.mymuseum.be", "https://fortsaintheribert.myinfomate.be", "https://visitnamur.myinfomate.be"]}] [{"maxAgeSeconds": 3600, "method": ["GET", "POST", "DELETE", "PUT"], "origin": ["http://localhost:49430", "https://manager.myinfomate.be", "https://manager.mymuseum.be", "https://fortsaintheribert.mymuseum.be", "https://fortsaintheribert.myinfomate.be", "https://visitnamur.myinfomate.be"]}]
Pour support postgis, attention image docker postgis + => ## Base de données — PostGIS + pgvector
CREATE EXTENSION postgis SCHEMA public; L'image Postgres est construite depuis `Dockerfile.postgres` : `postgis/postgis:16-3.4`
(épinglée **par digest**, pas par tag) + `postgresql-16-pgvector`.
- **PostGIS** est indispensable (NetTopologySuite : cartes, zones de géodéclenchement).
Ne pas basculer sur `pgvector/pgvector:pg16`, qui le ferait perdre.
- **Le digest est volontaire.** Le tag `16-3.4` est mobile ; si la glibc change sous une
base existante, les index texte se désalignent (« collation version mismatch ») et une
recherche indexée peut manquer des lignes. Arrivé en local le 2026-08-09.
Correction : `REINDEX DATABASE <db>;` **puis** `ALTER DATABASE <db> REFRESH COLLATION VERSION;`
— dans cet ordre, le refresh seul ne fait que masquer le symptôme.
Plus aucun `CREATE EXTENSION` à lancer à la main : les extensions sont déclarées dans le
`DbContext` (`HasPostgresExtension`) et créées par `dotnet ef database update`.
Après modification du Dockerfile : `docker compose -f docker-compose.dev.yaml up -d --build`
(le volume `postgres-data` est conservé — ne jamais passer `-v`, qui le supprimerait).

View File

@ -3,7 +3,11 @@ version: '3.9'
services: services:
postgres: postgres:
image: postgis/postgis:16-3.4 # PostGIS + pgvector — voir Dockerfile.postgres (image de base épinglée par digest)
build:
context: .
dockerfile: Dockerfile.postgres
image: myinfomate/postgres-pgvector:16-3.4
container_name: myim_postgres container_name: myim_postgres
environment: environment:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}

View File

@ -3,7 +3,13 @@ version: '3.9'
services: services:
postgres: postgres:
image: postgis/postgis:16-3.4 # PostGIS + pgvector — voir Dockerfile.postgres (image de base épinglée par digest).
# Sur un hôte distant qui ne build pas : pousser l'image sur le registry et
# remplacer le bloc build par image: registry.unov.be/myinfomate/postgres-pgvector:16-3.4
build:
context: .
dockerfile: Dockerfile.postgres
image: myinfomate/postgres-pgvector:16-3.4
container_name: myim_postgres container_name: myim_postgres
environment: environment:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}

View File

@ -0,0 +1,59 @@
using System;
namespace ManagerService.EmailTemplates
{
/// <summary>
/// Shared HTML shell for all transactional emails — MyInfoMate branding
/// (cyan #0df2df header, dark logo band, white content card).
/// </summary>
public static class EmailLayout
{
public static string Render(string title, string bodyHtml, string ctaText = null, string ctaUrl = null)
{
var ctaHtml = "";
if (!string.IsNullOrEmpty(ctaText) && !string.IsNullOrEmpty(ctaUrl))
{
ctaHtml = $@"
<table role=""presentation"" cellpadding=""0"" cellspacing=""0"" style=""margin: 28px 0 8px;"">
<tr>
<td style=""border-radius: 12px; background: #0df2df;"">
<a href=""{ctaUrl}"" style=""display: inline-block; padding: 14px 28px; font-family: Arial, sans-serif; font-size: 15px; font-weight: 700; color: #043b37; text-decoration: none;"">{ctaText}</a>
</td>
</tr>
</table>";
}
return $@"<!DOCTYPE html>
<html lang=""fr"">
<head><meta charset=""utf-8""><meta name=""viewport"" content=""width=device-width, initial-scale=1.0""></head>
<body style=""margin:0; padding:0; background:#f1f5f9; font-family: Arial, sans-serif;"">
<table role=""presentation"" width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""background:#f1f5f9; padding: 32px 0;"">
<tr>
<td align=""center"">
<table role=""presentation"" width=""560"" cellpadding=""0"" cellspacing=""0"" style=""background:#ffffff; border-radius: 16px; overflow:hidden;"">
<tr>
<td style=""background:#0a1222; padding: 24px 32px;"">
<span style=""font-size: 18px; font-weight: 800; color:#ffffff;"">MyInfoMate</span>
</td>
</tr>
<tr>
<td style=""padding: 32px;"">
<h1 style=""margin:0 0 16px; font-size: 20px; color:#0f172a;"">{title}</h1>
<div style=""font-size: 14px; line-height: 1.6; color:#334155;"">{bodyHtml}</div>
{ctaHtml}
</td>
</tr>
<tr>
<td style=""padding: 20px 32px; border-top: 1px solid #e2e8f0; font-size: 12px; color:#94a3b8;"">
MyInfoMate {DateTime.UtcNow.Year}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>";
}
}
}

View File

@ -0,0 +1,27 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace ManagerService.Helpers
{
/// <summary>
/// Generic token mechanism shared by the "set initial password" (onboarding welcome email),
/// "invite user" and "forgot password" flows. The raw token is only ever sent by email —
/// only its SHA-256 hash is stored on <see cref="Data.User.PasswordTokenHash"/>.
/// </summary>
public static class PasswordTokenHelper
{
private static readonly TimeSpan DefaultValidity = TimeSpan.FromHours(48);
public static string GenerateRawToken() => PasswordUtils.GetUniqueKey(40);
public static string Hash(string rawToken)
{
var bytes = Encoding.UTF8.GetBytes(rawToken);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static DateTime DefaultExpiry() => DateTime.UtcNow.Add(DefaultValidity);
}
}

View File

@ -0,0 +1,9 @@
namespace ManagerService.Helpers
{
public class ResendSettings
{
public string ApiKey { get; set; }
public string FromEmail { get; set; }
public string AdminEmail { get; set; }
}
}

View File

@ -0,0 +1,33 @@
using ManagerService.Data;
using System.Linq;
using System.Text.RegularExpressions;
namespace ManagerService.Helpers
{
public static class SlugHelper
{
/// <summary>
/// Generate a URL-safe slug from a name, appending "-1", "-2", etc. until unique among Instances.WebSlug.
/// </summary>
public static string GenerateUniqueSlug(MyInfoMateDbContext db, string name)
{
var slug = name.ToLowerInvariant();
slug = Regex.Replace(slug, @"[àáâãäå]", "a");
slug = Regex.Replace(slug, @"[èéêë]", "e");
slug = Regex.Replace(slug, @"[ìíîï]", "i");
slug = Regex.Replace(slug, @"[òóôõö]", "o");
slug = Regex.Replace(slug, @"[ùúûü]", "u");
slug = Regex.Replace(slug, @"[ç]", "c");
slug = Regex.Replace(slug, @"[ñ]", "n");
slug = Regex.Replace(slug, @"[^a-z0-9\s-]", "");
slug = Regex.Replace(slug, @"[\s-]+", "-").Trim('-');
var baseSlug = slug;
var counter = 1;
while (db.Instances.Any(i => i.WebSlug == slug))
slug = $"{baseSlug}-{counter++}";
return slug;
}
}
}

View File

@ -0,0 +1,9 @@
namespace ManagerService.Helpers
{
public class StripeSettings
{
public string SecretKey { get; set; }
public string WebhookSecret { get; set; }
public string EssentielPriceId { get; set; }
}
}

View File

@ -24,10 +24,12 @@
<PackageReference Include="FirebaseAdmin" Version="3.1.0" /> <PackageReference Include="FirebaseAdmin" Version="3.1.0" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.17" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.17" />
<PackageReference Include="Hangfire.PostgreSql" Version="1.20.10" /> <PackageReference Include="Hangfire.PostgreSql" Version="1.20.10" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
<PackageReference Include="Scrypt.NET" Version="1.3.0" /> <PackageReference Include="Scrypt.NET" Version="1.3.0" />
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" /> <PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" /> <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.Telegram" Version="0.2.1" /> <PackageReference Include="Serilog.Sinks.Telegram" Version="0.2.1" />
<PackageReference Include="Stripe.net" Version="52.1.1" />
<PackageReference Include="System.Drawing.Common" Version="6.0.0" /> <PackageReference Include="System.Drawing.Common" Version="6.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" />
</ItemGroup> </ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,121 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddTrialAndBillingFieldsToInstance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BillingAddress",
table: "Instances",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BillingCountry",
table: "Instances",
type: "text",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsTrialActive",
table: "Instances",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "StripeCustomerId",
table: "Instances",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "StripeSubscriptionId",
table: "Instances",
type: "text",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "TrialAiTokensUsed",
table: "Instances",
type: "bigint",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<DateTime>(
name: "TrialEndsAt",
table: "Instances",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "VatNumber",
table: "Instances",
type: "text",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "VatRate",
table: "Instances",
type: "numeric",
nullable: true);
migrationBuilder.InsertData(
table: "SubscriptionPlans",
columns: new[] { "Id", "AiTokensPerMonth", "HasAdvancedStats", "HasStats", "Name", "StatsHistoryDays", "StorageQuotaBytes" },
values: new object[] { "plan-essentiel", 50L, false, true, "Essentiel", 30, 1073741824L });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: "plan-essentiel");
migrationBuilder.DropColumn(
name: "BillingAddress",
table: "Instances");
migrationBuilder.DropColumn(
name: "BillingCountry",
table: "Instances");
migrationBuilder.DropColumn(
name: "IsTrialActive",
table: "Instances");
migrationBuilder.DropColumn(
name: "StripeCustomerId",
table: "Instances");
migrationBuilder.DropColumn(
name: "StripeSubscriptionId",
table: "Instances");
migrationBuilder.DropColumn(
name: "TrialAiTokensUsed",
table: "Instances");
migrationBuilder.DropColumn(
name: "TrialEndsAt",
table: "Instances");
migrationBuilder.DropColumn(
name: "VatNumber",
table: "Instances");
migrationBuilder.DropColumn(
name: "VatRate",
table: "Instances");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddPasswordTokenToUser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "PasswordTokenExpiresAt",
table: "Users",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PasswordTokenHash",
table: "Users",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PasswordTokenExpiresAt",
table: "Users");
migrationBuilder.DropColumn(
name: "PasswordTokenHash",
table: "Users");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddTrialLifecycleFieldsToInstance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// defaultValue: true — sinon toutes les instances existantes passeraient à
// IsActive = false et seraient immédiatement coupées par ApiKeyAuthenticationHandler.
migrationBuilder.AddColumn<bool>(
name: "IsActive",
table: "Instances",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "TrialCheckInEmailSent",
table: "Instances",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "TrialLastDayEmailSent",
table: "Instances",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "TrialReminderEmailSent",
table: "Instances",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsActive",
table: "Instances");
migrationBuilder.DropColumn(
name: "TrialCheckInEmailSent",
table: "Instances");
migrationBuilder.DropColumn(
name: "TrialLastDayEmailSent",
table: "Instances");
migrationBuilder.DropColumn(
name: "TrialReminderEmailSent",
table: "Instances");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class FixEssentielAiTokenQuota : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: "plan-essentiel",
column: "AiTokensPerMonth",
value: 500000L);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: "plan-essentiel",
column: "AiTokensPerMonth",
value: 50L);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class RemoveDeadGuidedStepFlags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FactContent",
table: "GuidedSteps");
migrationBuilder.DropColumn(
name: "IsHiddenInitially",
table: "GuidedSteps");
migrationBuilder.DropColumn(
name: "IsStepLocked",
table: "GuidedSteps");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FactContent",
table: "GuidedSteps",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsHiddenInitially",
table: "GuidedSteps",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsStepLocked",
table: "GuidedSteps",
type: "boolean",
nullable: false,
defaultValue: false);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddGuideIaConfigToInstance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GuideFallbackMessages",
table: "Instances",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GuideName",
table: "Instances",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GuidePersonaPrompt",
table: "Instances",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GuideVoiceId",
table: "Instances",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GuideFallbackMessages",
table: "Instances");
migrationBuilder.DropColumn(
name: "GuideName",
table: "Instances");
migrationBuilder.DropColumn(
name: "GuidePersonaPrompt",
table: "Instances");
migrationBuilder.DropColumn(
name: "GuideVoiceId",
table: "Instances");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,74 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddContentEmbedding : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:postgis", ",,")
.Annotation("Npgsql:PostgresExtension:vector", ",,")
.OldAnnotation("Npgsql:PostgresExtension:postgis", ",,");
migrationBuilder.CreateTable(
name: "ContentEmbeddings",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
InstanceId = table.Column<string>(type: "text", nullable: false),
ConfigurationId = table.Column<string>(type: "text", nullable: true),
ContentId = table.Column<string>(type: "text", nullable: false),
ContentType = table.Column<int>(type: "integer", nullable: false),
ChunkIndex = table.Column<int>(type: "integer", nullable: false),
PageNumber = table.Column<int>(type: "integer", nullable: true),
Language = table.Column<string>(type: "character varying(5)", maxLength: 5, nullable: false),
Text = table.Column<string>(type: "text", nullable: false),
Embedding = table.Column<Vector>(type: "vector(768)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ContentEmbeddings", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ContentEmbeddings_ContentType_ContentId_ChunkIndex",
table: "ContentEmbeddings",
columns: new[] { "ContentType", "ContentId", "ChunkIndex" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ContentEmbeddings_Embedding",
table: "ContentEmbeddings",
column: "Embedding")
.Annotation("Npgsql:IndexMethod", "hnsw")
.Annotation("Npgsql:IndexOperators", new[] { "vector_cosine_ops" });
migrationBuilder.CreateIndex(
name: "IX_ContentEmbeddings_InstanceId_ContentType",
table: "ContentEmbeddings",
columns: new[] { "InstanceId", "ContentType" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ContentEmbeddings");
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:postgis", ",,")
.OldAnnotation("Npgsql:PostgresExtension:postgis", ",,")
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,92 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddResourceStorageAndAiColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AiChunkCount",
table: "Resources",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "AiIndexMessage",
table: "Resources",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "AiIndexStatus",
table: "Resources",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<DateTime>(
name: "AiIndexedAt",
table: "Resources",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FileName",
table: "Resources",
type: "text",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IncludeInAiKnowledge",
table: "Resources",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "StoragePath",
table: "Resources",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AiChunkCount",
table: "Resources");
migrationBuilder.DropColumn(
name: "AiIndexMessage",
table: "Resources");
migrationBuilder.DropColumn(
name: "AiIndexStatus",
table: "Resources");
migrationBuilder.DropColumn(
name: "AiIndexedAt",
table: "Resources");
migrationBuilder.DropColumn(
name: "FileName",
table: "Resources");
migrationBuilder.DropColumn(
name: "IncludeInAiKnowledge",
table: "Resources");
migrationBuilder.DropColumn(
name: "StoragePath",
table: "Resources");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddVisitorQuestion : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "VisitorQuestions",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ConversationId = table.Column<string>(type: "text", nullable: true),
InstanceId = table.Column<string>(type: "text", nullable: true),
ConfigurationId = table.Column<string>(type: "text", nullable: true),
AppType = table.Column<int>(type: "integer", nullable: false),
IsVoice = table.Column<bool>(type: "boolean", nullable: false),
Language = table.Column<string>(type: "character varying(5)", maxLength: 5, nullable: true),
Question = table.Column<string>(type: "text", nullable: true),
Reply = table.Column<string>(type: "text", nullable: true),
TokensUsed = table.Column<long>(type: "bigint", nullable: false),
HasAnswer = table.Column<bool>(type: "boolean", nullable: false),
TopScore = table.Column<double>(type: "double precision", nullable: false),
CitedContentIds = table.Column<string>(type: "jsonb", nullable: true),
ThemeId = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VisitorQuestions", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_VisitorQuestions_ConversationId",
table: "VisitorQuestions",
column: "ConversationId");
migrationBuilder.CreateIndex(
name: "IX_VisitorQuestions_InstanceId_CreatedAt",
table: "VisitorQuestions",
columns: new[] { "InstanceId", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "VisitorQuestions");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class UnifyStatsRetentionTo13Months : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: "plan-essentiel",
column: "StatsHistoryDays",
value: 395);
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: "plan-premium",
column: "StatsHistoryDays",
value: 395);
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: "plan-standard",
column: "StatsHistoryDays",
value: 395);
// Les quotas sont copiés du plan vers l'instance à la création, et c'est
// l'instance que lit StatsController. Sans cette reprise, les clients
// existants resteraient plafonnés à 30 jours malgré le changement de plan.
migrationBuilder.Sql(@"
UPDATE ""Instances"" i
SET ""StatsHistoryDays"" = p.""StatsHistoryDays""
FROM ""SubscriptionPlans"" p
WHERE i.""SubscriptionPlanId"" = p.""Id"" AND i.""HasStats"" = true;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: "plan-essentiel",
column: "StatsHistoryDays",
value: 30);
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: "plan-premium",
column: "StatsHistoryDays",
value: 0);
migrationBuilder.UpdateData(
table: "SubscriptionPlans",
keyColumn: "Id",
keyValue: "plan-standard",
column: "StatsHistoryDays",
value: 30);
migrationBuilder.Sql(@"
UPDATE ""Instances"" i
SET ""StatsHistoryDays"" = p.""StatsHistoryDays""
FROM ""SubscriptionPlans"" p
WHERE i.""SubscriptionPlanId"" = p.""Id"" AND i.""HasStats"" = true;");
}
}
}

View File

@ -9,6 +9,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NetTopologySuite.Geometries; using NetTopologySuite.Geometries;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
#nullable disable #nullable disable
@ -25,6 +26,7 @@ namespace ManagerService.Migrations
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ManagerService.Data.ApiKey", b => modelBuilder.Entity("ManagerService.Data.ApiKey", b =>
@ -293,6 +295,65 @@ namespace ManagerService.Migrations
b.ToTable("Configurations"); b.ToTable("Configurations");
}); });
modelBuilder.Entity("ManagerService.Data.ContentEmbedding", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<int>("ChunkIndex")
.HasColumnType("integer");
b.Property<string>("ConfigurationId")
.HasColumnType("text");
b.Property<string>("ContentId")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ContentType")
.HasColumnType("integer");
b.Property<Vector>("Embedding")
.IsRequired()
.HasColumnType("vector(768)");
b.Property<string>("InstanceId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("character varying(5)");
b.Property<int?>("PageNumber")
.HasColumnType("integer");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Embedding");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" });
b.HasIndex("InstanceId", "ContentType");
b.HasIndex("ContentType", "ContentId", "ChunkIndex")
.IsUnique();
b.ToTable("ContentEmbeddings");
});
modelBuilder.Entity("ManagerService.Data.Device", b => modelBuilder.Entity("ManagerService.Data.Device", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@ -368,18 +429,39 @@ namespace ManagerService.Migrations
b.Property<string>("AiUsageMonthKey") b.Property<string>("AiUsageMonthKey")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("BillingAddress")
.HasColumnType("text");
b.Property<string>("BillingCountry")
.HasColumnType("text");
b.Property<DateTime>("DateCreation") b.Property<DateTime>("DateCreation")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateUpdate") b.Property<DateTime>("DateUpdate")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("GuideFallbackMessages")
.HasColumnType("jsonb");
b.Property<string>("GuideName")
.HasColumnType("text");
b.Property<string>("GuidePersonaPrompt")
.HasColumnType("text");
b.Property<string>("GuideVoiceId")
.HasColumnType("text");
b.Property<bool>("HasAdvancedStats") b.Property<bool>("HasAdvancedStats")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("HasStats") b.Property<bool>("HasStats")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsAssistant") b.Property<bool>("IsAssistant")
.HasColumnType("boolean"); .HasColumnType("boolean");
@ -392,6 +474,9 @@ namespace ManagerService.Migrations
b.Property<bool>("IsTablet") b.Property<bool>("IsTablet")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsTrialActive")
.HasColumnType("boolean");
b.Property<bool>("IsVR") b.Property<bool>("IsVR")
.HasColumnType("boolean"); .HasColumnType("boolean");
@ -414,9 +499,36 @@ namespace ManagerService.Migrations
b.Property<long>("StorageQuotaBytes") b.Property<long>("StorageQuotaBytes")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<string>("StripeCustomerId")
.HasColumnType("text");
b.Property<string>("StripeSubscriptionId")
.HasColumnType("text");
b.Property<string>("SubscriptionPlanId") b.Property<string>("SubscriptionPlanId")
.HasColumnType("text"); .HasColumnType("text");
b.Property<long>("TrialAiTokensUsed")
.HasColumnType("bigint");
b.Property<bool>("TrialCheckInEmailSent")
.HasColumnType("boolean");
b.Property<DateTime?>("TrialEndsAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("TrialLastDayEmailSent")
.HasColumnType("boolean");
b.Property<bool>("TrialReminderEmailSent")
.HasColumnType("boolean");
b.Property<string>("VatNumber")
.HasColumnType("text");
b.Property<decimal?>("VatRate")
.HasColumnType("numeric");
b.Property<string>("WebSlug") b.Property<string>("WebSlug")
.HasColumnType("text"); .HasColumnType("text");
@ -475,12 +587,30 @@ namespace ManagerService.Migrations
b.Property<string>("Id") b.Property<string>("Id")
.HasColumnType("text"); .HasColumnType("text");
b.Property<int>("AiChunkCount")
.HasColumnType("integer");
b.Property<string>("AiIndexMessage")
.HasColumnType("text");
b.Property<int>("AiIndexStatus")
.HasColumnType("integer");
b.Property<DateTime?>("AiIndexedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateCreation") b.Property<DateTime>("DateCreation")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateUpdate") b.Property<DateTime>("DateUpdate")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.HasColumnType("text");
b.Property<bool>("IncludeInAiKnowledge")
.HasColumnType("boolean");
b.Property<string>("InstanceId") b.Property<string>("InstanceId")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@ -492,6 +622,9 @@ namespace ManagerService.Migrations
b.Property<long>("SizeBytes") b.Property<long>("SizeBytes")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<string>("StoragePath")
.HasColumnType("text");
b.Property<int>("Type") b.Property<int>("Type")
.HasColumnType("integer"); .HasColumnType("integer");
@ -821,9 +954,6 @@ namespace ManagerService.Migrations
b.Property<string>("Description") b.Property<string>("Description")
.HasColumnType("jsonb"); .HasColumnType("jsonb");
b.Property<string>("FactContent")
.HasColumnType("jsonb");
b.Property<Geometry>("Geometry") b.Property<Geometry>("Geometry")
.HasColumnType("geometry"); .HasColumnType("geometry");
@ -837,12 +967,6 @@ namespace ManagerService.Migrations
b.Property<bool>("IsGeoTriggered") b.Property<bool>("IsGeoTriggered")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsHiddenInitially")
.HasColumnType("boolean");
b.Property<bool>("IsStepLocked")
.HasColumnType("boolean");
b.Property<bool>("IsStepTimer") b.Property<bool>("IsStepTimer")
.HasColumnType("boolean"); .HasColumnType("boolean");
@ -1041,7 +1165,7 @@ namespace ManagerService.Migrations
HasAdvancedStats = false, HasAdvancedStats = false,
HasStats = true, HasStats = true,
Name = "Standard", Name = "Standard",
StatsHistoryDays = 30, StatsHistoryDays = 395,
StorageQuotaBytes = 10737418240L StorageQuotaBytes = 10737418240L
}, },
new new
@ -1051,8 +1175,18 @@ namespace ManagerService.Migrations
HasAdvancedStats = true, HasAdvancedStats = true,
HasStats = true, HasStats = true,
Name = "Premium", Name = "Premium",
StatsHistoryDays = 0, StatsHistoryDays = 395,
StorageQuotaBytes = 53687091200L StorageQuotaBytes = 53687091200L
},
new
{
Id = "plan-essentiel",
AiTokensPerMonth = 500000L,
HasAdvancedStats = false,
HasStats = true,
Name = "Essentiel",
StatsHistoryDays = 395,
StorageQuotaBytes = 1073741824L
}); });
}); });
@ -1086,6 +1220,12 @@ namespace ManagerService.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<DateTime?>("PasswordTokenExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordTokenHash")
.HasColumnType("text");
b.Property<int>("Role") b.Property<int>("Role")
.HasColumnType("integer"); .HasColumnType("integer");
@ -1146,6 +1286,66 @@ namespace ManagerService.Migrations
b.ToTable("VisitEvents"); b.ToTable("VisitEvents");
}); });
modelBuilder.Entity("ManagerService.Data.VisitorQuestion", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<int>("AppType")
.HasColumnType("integer");
b.Property<string>("CitedContentIds")
.HasColumnType("jsonb");
b.Property<string>("ConfigurationId")
.HasColumnType("text");
b.Property<string>("ConversationId")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("HasAnswer")
.HasColumnType("boolean");
b.Property<string>("InstanceId")
.HasColumnType("text");
b.Property<bool>("IsVoice")
.HasColumnType("boolean");
b.Property<string>("Language")
.HasMaxLength(5)
.HasColumnType("character varying(5)");
b.Property<string>("Question")
.HasColumnType("text");
b.Property<string>("Reply")
.HasColumnType("text");
b.Property<string>("ThemeId")
.HasColumnType("text");
b.Property<long>("TokensUsed")
.HasColumnType("bigint");
b.Property<double>("TopScore")
.HasColumnType("double precision");
b.HasKey("Id");
b.HasIndex("ConversationId");
b.HasIndex("InstanceId", "CreatedAt");
b.ToTable("VisitorQuestions");
});
modelBuilder.Entity("ManagerService.Data.SubSection.SectionAgenda", b => modelBuilder.Entity("ManagerService.Data.SubSection.SectionAgenda", b =>
{ {
b.HasBaseType("ManagerService.Data.Section"); b.HasBaseType("ManagerService.Data.Section");

View File

@ -52,6 +52,8 @@ namespace ManagerService.Security
var instance = await _db.Instances.FirstOrDefaultAsync(i => i.PublicApiKey == value); var instance = await _db.Instances.FirstOrDefaultAsync(i => i.PublicApiKey == value);
if (instance == null) if (instance == null)
return AuthenticateResult.Fail("Invalid API Key"); return AuthenticateResult.Fail("Invalid API Key");
if (!instance.IsActive)
return AuthenticateResult.Fail("This instance has been deactivated");
instanceId = instance.Id; instanceId = instance.Id;
appType = "Web"; appType = "Web";

View File

@ -3,6 +3,7 @@ using ManagerService.DTOs;
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using System; using System;
@ -17,13 +18,15 @@ namespace ManagerService.Services
{ {
private readonly IChatClient _chatClient; private readonly IChatClient _chatClient;
private readonly MyInfoMateDbContext _context; private readonly MyInfoMateDbContext _context;
private readonly IHttpClientFactory _httpClientFactory;
private const int MaxHistoryMessages = 10; private const int MaxHistoryMessages = 10;
public AssistantService(IChatClient chatClient, MyInfoMateDbContext context) public AssistantService(IChatClient chatClient, MyInfoMateDbContext context, IHttpClientFactory httpClientFactory)
{ {
_chatClient = chatClient; _chatClient = chatClient;
_context = context; _context = context;
_httpClientFactory = httpClientFactory;
} }
public async Task<AiTranslateResponse> TranslateAsync(AiTranslateRequest request) public async Task<AiTranslateResponse> TranslateAsync(AiTranslateRequest request)
@ -151,12 +154,58 @@ namespace ManagerService.Services
return (sat, sat.AddDays(1)); return (sat, sat.AddDays(1));
} }
/// <summary>
/// Identité du guide telle que le client l'a configurée (écran Guide IA) : nom affiché
/// et prompt de personnalité. Chaîne vide si rien n'est configuré — le prompt garde
/// alors son rôle générique, sans ton imposé.
/// </summary>
private static string BuildGuideIdentity(Instance? instance)
{
var lines = new List<string>();
if (!string.IsNullOrWhiteSpace(instance?.GuideName))
lines.Add($"Tu t'appelles {instance.GuideName.Trim()}.");
if (!string.IsNullOrWhiteSpace(instance?.GuidePersonaPrompt))
lines.Add(instance.GuidePersonaPrompt.Trim());
return string.Join("\n", lines);
}
/// <summary>
/// Consigne de repli à insérer dans la règle hors-sujet, tirée au hasard parmi les
/// formulations du client dans la langue du visiteur. Le tirage est fait ici plutôt
/// que laissé au modèle, pour que la phrase du client soit reprise mot pour mot.
/// Sans formulation dans cette langue, le modèle décline en variant lui-même —
/// mieux qu'une phrase servie dans une langue que le visiteur ne lit pas.
/// </summary>
private static string BuildFallbackInstruction(Instance? instance, string language)
{
var messages = instance?.GuideFallbackMessages
?.Where(m => string.Equals(m.language, language, StringComparison.OrdinalIgnoreCase))
.Select(m => StripHtml(m.value))
.Where(v => !string.IsNullOrWhiteSpace(v))
.ToList();
if (messages == null || messages.Count == 0)
return "décline poliment en une phrase courte et naturelle, en variant la formulation à chaque fois";
var picked = messages[Random.Shared.Next(messages.Count)];
return $"réponds exactement ceci, sans rien y ajouter : « {picked} »";
}
public async Task<AiChatResponse> ChatAsync(AiChatRequest request) public async Task<AiChatResponse> ChatAsync(AiChatRequest request)
{ {
var messages = new List<ChatMessage>(); var messages = new List<ChatMessage>();
ChatOptions options; ChatOptions options;
var (weekendSat, weekendSun) = GetCurrentWeekend(); var (weekendSat, weekendSun) = GetCurrentWeekend();
// Find() plutôt qu'une requête : AiController a déjà chargé l'instance pour le
// contrôle de quota, dans le même scope EF — le ChangeTracker la rend sans aller en base.
var instance = _context.Instances.Find(request.InstanceId);
var guideIdentity = BuildGuideIdentity(instance);
var fallbackInstruction = BuildFallbackInstruction(instance, request.Language);
if (request.ConfigurationId != null) if (request.ConfigurationId != null)
{ {
// Scope configuration : l'IA connaît uniquement les sections de cette configuration // Scope configuration : l'IA connaît uniquement les sections de cette configuration
@ -175,9 +224,10 @@ namespace ManagerService.Services
var systemPrompt = isVoiceMode ? $""" var systemPrompt = isVoiceMode ? $"""
Tu es le guide audio de visite de "{config?.Label ?? "cette application"}". Tu es le guide audio de visite de "{config?.Label ?? "cette application"}".
{guideIdentity}
Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}. Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}.
Le visiteur t'écoute via des lunettes connectées Ray-Ban il ne voit PAS d'écran. Le visiteur t'écoute via des lunettes connectées Ray-Ban il ne voit PAS d'écran.
Tu réponds en {request.Language}. Tu es chaleureux, naturel et très concis (2-3 phrases max). Tu réponds en {request.Language}. Tes réponses sont très concises (2-3 phrases max).
Voici les sections disponibles : Voici les sections disponibles :
{sectionsSummary} {sectionsSummary}
@ -188,20 +238,21 @@ namespace ManagerService.Services
3. ZÉRO liste. Cite 2-3 éléments maximum directement dans une phrase fluide, séparés par des virgules. 3. ZÉRO liste. Cite 2-3 éléments maximum directement dans une phrase fluide, séparés par des virgules.
4. Pour les événements appelle "GetUpcomingEvents", puis cite exactement 2-3 des plus pertinents dans une seule phrase fluide. Même si l'outil en retourne 50, n'en cite que 2-3. Ajoute [FIN] à la fin. 4. Pour les événements appelle "GetUpcomingEvents", puis cite exactement 2-3 des plus pertinents dans une seule phrase fluide. Même si l'outil en retourne 50, n'en cite que 2-3. Ajoute [FIN] à la fin.
5. Pour les détails d'un item appelle "GetItemDetails" et résume en 2-3 phrases fluides. Ajoute [FIN] à la fin. 5. Pour les détails d'un item appelle "GetItemDetails" et résume en 2-3 phrases fluides. Ajoute [FIN] à la fin.
6. Pour les sections de type Article appelle "GetSectionDetail", résume le contenu en 2-3 phrases, puis demande "Veux-tu en savoir plus ?" (sans ajouter [FIN] car tu attends une réponse). 6. Pour les sections de type Article appelle "GetSectionDetail", résume le contenu en 2-3 phrases, puis propose au visiteur d'en savoir plus (sans ajouter [FIN] car tu attends une réponse).
7. Les sections de type Video, PDF, Slider, Quiz, Web, Game sont des contenus visuels. Si le visiteur en parle, mentionne leur existence mais précise qu'il doit les consulter sur l'écran. 7. Les sections de type Video, PDF, Slider, Quiz, Web, Game sont des contenus visuels. Si le visiteur en parle, mentionne leur existence mais précise qu'il doit les consulter sur l'écran.
8. NE propose JAMAIS de navigation le visiteur ne peut pas toucher l'écran. 8. NE propose JAMAIS de navigation le visiteur ne peut pas toucher l'écran.
9. Ne mentionne JAMAIS les identifiants techniques (id, guid). 9. Ne mentionne JAMAIS les identifiants techniques (id, guid).
10. Chaque réponse doit tenir en moins de 20 secondes à l'oral. 10. Chaque réponse doit tenir en moins de 20 secondes à l'oral.
11. Termine TOUJOURS par une phrase complète. 11. Termine TOUJOURS par une phrase complète.
12. Tu réponds UNIQUEMENT aux questions liées à cette visite et au contenu disponible dans les sections ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), décline poliment en une phrase courte et naturelle varie la formulation à chaque fois (ex: "Ma spécialité c'est cette visite, pas ce sujet !", "Je suis ton guide ici, pas un moteur de recherche !", "Bonne question, mais là je sèche — demande-moi plutôt ce qu'il y a à voir ici !") et ajoute [FIN]. 12. Tu réponds UNIQUEMENT aux questions liées à cette visite et au contenu disponible dans les sections ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), {fallbackInstruction} et ajoute [FIN].
13. Politesses et fin de conversation ("merci", "au revoir", "c'est tout", "ok merci") réponds chaleureusement en une phrase courte ("Bonne visite !" ou "Avec plaisir, bonne visite !") et ajoute [FIN]. 13. Politesses et fin de conversation ("merci", "au revoir", "c'est tout", "ok merci") réponds en une phrase courte et souhaite une bonne visite, puis ajoute [FIN].
14. Signal [FIN] : ajoute le token [FIN] à la toute fin quand ta réponse est une information pure sans question posée au visiteur. Ne l'ajoute PAS quand tu poses une question (article, proposition de détails). 14. Signal [FIN] : ajoute le token [FIN] à la toute fin quand ta réponse est une information pure sans question posée au visiteur. Ne l'ajoute PAS quand tu poses une question (article, proposition de détails).
""" : $""" """ : $"""
Tu es l'assistant de visite de l'application "{config?.Label ?? "cette application"}". Tu es le guide de visite de l'application "{config?.Label ?? "cette application"}".
{guideIdentity}
Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}. 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}. 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. Ton but est d'accompagner le visiteur dans son expérience. Tu réponds en {request.Language}. Tu es concis et proactif.
Voici les sections disponibles : Voici les sections disponibles :
{sectionsSummary} {sectionsSummary}
@ -213,6 +264,7 @@ namespace ManagerService.Services
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. 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". 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". 6. Pour les détails d'un item spécifique utilise "GetItemDetails".
7. Tu réponds UNIQUEMENT aux questions liées à cette visite et au contenu disponible dans les sections ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), {fallbackInstruction}.
- Ne mentionne JAMAIS les identifiants techniques (id, guid) dans tes réponses finales. - 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 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. - NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide.
@ -295,7 +347,7 @@ namespace ManagerService.Services
var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId); var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId);
if (resource?.Url != null) if (resource?.Url != null)
{ {
using var client = new System.Net.Http.HttpClient(); var client = _httpClientFactory.CreateClient();
var json = await client.GetStringAsync(resource.Url); var json = await client.GetStringAsync(resource.Url);
var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(json, RemoteAgendaJsonSettings.Lenient); var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(json, RemoteAgendaJsonSettings.Lenient);
if (remoteEvents != null) if (remoteEvents != null)
@ -401,7 +453,7 @@ namespace ManagerService.Services
if (resource?.Url == null) return "URL de l'agenda non trouvée."; if (resource?.Url == null) return "URL de l'agenda non trouvée.";
try { try {
using var client = new System.Net.Http.HttpClient(); var client = _httpClientFactory.CreateClient();
var json = await client.GetStringAsync(resource.Url); var json = await client.GetStringAsync(resource.Url);
var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(json, RemoteAgendaJsonSettings.Lenient); var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(json, RemoteAgendaJsonSettings.Lenient);
var e = remoteEvents?.FirstOrDefault(ev => ev.name == eventId); var e = remoteEvents?.FirstOrDefault(ev => ev.name == eventId);
@ -534,9 +586,10 @@ namespace ManagerService.Services
var instanceSystemPrompt = isVoiceMode ? $""" var instanceSystemPrompt = isVoiceMode ? $"""
Tu es le guide audio de visite. Tu es le guide audio de visite.
{guideIdentity}
Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}. Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}.
Le visiteur t'écoute via des lunettes connectées il ne voit PAS d'écran. Le visiteur t'écoute via des lunettes connectées il ne voit PAS d'écran.
Tu réponds en {request.Language}. Tu es chaleureux, naturel et très concis (2-3 phrases max). Tu réponds en {request.Language}. Tes réponses sont très concises (2-3 phrases max).
Voici les expériences (visites) disponibles : Voici les expériences (visites) disponibles :
{configsSummary} {configsSummary}
@ -547,14 +600,15 @@ namespace ManagerService.Services
3. Pour les événements appelle "SearchEventsGlobal", puis cite exactement 2-3 des plus pertinents dans une phrase fluide. Ajoute [FIN]. 3. Pour les événements appelle "SearchEventsGlobal", puis cite exactement 2-3 des plus pertinents dans une phrase fluide. Ajoute [FIN].
4. NE propose JAMAIS de navigation. Ne mentionne JAMAIS les identifiants techniques. 4. NE propose JAMAIS de navigation. Ne mentionne JAMAIS les identifiants techniques.
5. Chaque réponse doit tenir en moins de 20 secondes à l'oral. 5. Chaque réponse doit tenir en moins de 20 secondes à l'oral.
6. Tu réponds UNIQUEMENT aux questions liées à cette visite. Pour toute question hors-sujet, réponds : "Je suis uniquement là pour t'accompagner dans ta visite." et ajoute [FIN]. 6. Tu réponds UNIQUEMENT aux questions liées à cette visite. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), {fallbackInstruction} et ajoute [FIN].
7. Politesses ("merci", "au revoir") réponse courte et chaleureuse + [FIN]. 7. Politesses ("merci", "au revoir") réponds en une phrase courte et souhaite une bonne visite, puis ajoute [FIN].
8. Signal [FIN] : ajoute-le quand ta réponse est une information pure sans question au visiteur. 8. Signal [FIN] : ajoute-le quand ta réponse est une information pure sans question au visiteur.
""" : $""" """ : $"""
Tu es l'assistant de visite principal. Ton rôle est d'orienter l'utilisateur vers la bonne expérience de visite. Tu es le guide principal. Ton rôle est d'orienter le visiteur vers la bonne expérience de visite.
{guideIdentity}
Aujourd'hui nous sommes le {DateTime.Now:dddd dd MMMM yyyy}. 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}. 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. Tu réponds en {request.Language}. Tu es concis et proactif.
Voici les expériences (configurations) disponibles : Voici les expériences (configurations) disponibles :
{configsSummary} {configsSummary}
@ -566,6 +620,7 @@ namespace ManagerService.Services
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. 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". 5. Pour orienter vers une visite spécifique (jeu, quiz...) "navigate_to_configuration".
6. Pour les détails d'un item "GetItemDetailsGlobal". 6. Pour les détails d'un item "GetItemDetailsGlobal".
7. Tu réponds UNIQUEMENT aux questions liées à cette visite et aux expériences ci-dessus. Pour toute question hors-sujet (politique, actualité, recettes, vie personnelle), {fallbackInstruction}.
- NE POSE JAMAIS de question à la fin de ta réponse après avoir présenté des résultats. - 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. - NE TE RÉPÈTE PAS : dis les choses une seule fois de manière fluide.
"""; """;
@ -611,7 +666,7 @@ namespace ManagerService.Services
var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId); var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId);
if (resource?.Url != null) if (resource?.Url != null)
{ {
using var client = new System.Net.Http.HttpClient(); var client = _httpClientFactory.CreateClient();
var json = await client.GetStringAsync(resource.Url); var json = await client.GetStringAsync(resource.Url);
var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(json, RemoteAgendaJsonSettings.Lenient); var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(json, RemoteAgendaJsonSettings.Lenient);
if (remoteEvents != null) if (remoteEvents != null)
@ -713,7 +768,7 @@ namespace ManagerService.Services
if (resource?.Url == null) return "URL de l'agenda non trouvée."; if (resource?.Url == null) return "URL de l'agenda non trouvée.";
try { try {
using var client = new System.Net.Http.HttpClient(); var client = _httpClientFactory.CreateClient();
var json = await client.GetStringAsync(resource.Url); var json = await client.GetStringAsync(resource.Url);
var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(json, RemoteAgendaJsonSettings.Lenient); var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(json, RemoteAgendaJsonSettings.Lenient);
var e = remoteEvents?.FirstOrDefault(ev => ev.name == eventId); var e = remoteEvents?.FirstOrDefault(ev => ev.name == eventId);

View File

@ -0,0 +1,152 @@
using ManagerService.Data;
using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace ManagerService.Services
{
/// <summary>
/// Embeddings via l'endpoint OpenAI-compatible de Google — même hôte et même clé
/// que le chat (voir Startup), donc aucune configuration supplémentaire.
/// </summary>
public class GoogleEmbeddingService : IEmbeddingService
{
private const string Model = "gemini-embedding-001";
private const string Endpoint = "https://generativelanguage.googleapis.com/v1beta/openai/embeddings";
/// <summary>
/// L'API accepte des lots plus gros, mais un lot trop large fait échouer les ~150 morceaux
/// d'un document d'un coup sur un seul dépassement de limite de jetons.
/// </summary>
private const int BatchSize = 50;
private readonly IHttpClientFactory _httpClientFactory;
private readonly string _apiKey;
public int Dimensions => ContentEmbedding.Dimensions;
public GoogleEmbeddingService(IHttpClientFactory httpClientFactory, IConfiguration configuration)
{
_httpClientFactory = httpClientFactory;
_apiKey = configuration["AI:ApiKey"];
}
public async Task<float[]> EmbedAsync(string text, CancellationToken cancellationToken = default)
{
var result = await EmbedBatchAsync(new[] { text }, cancellationToken);
return result[0];
}
public async Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken cancellationToken = default)
{
if (texts == null || texts.Count == 0)
return Array.Empty<float[]>();
var results = new List<float[]>(texts.Count);
for (var offset = 0; offset < texts.Count; offset += BatchSize)
{
var batch = texts.Skip(offset).Take(BatchSize).ToArray();
results.AddRange(await EmbedOneBatchAsync(batch, cancellationToken));
}
return results;
}
private async Task<List<float[]>> EmbedOneBatchAsync(string[] batch, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _apiKey);
// dimensions est obligatoire : le modèle renvoie 3072 valeurs par défaut,
// ce que la colonne vector(768) refuserait.
var payload = new EmbeddingRequest
{
Model = Model,
Input = batch,
Dimensions = Dimensions
};
var response = await client.PostAsJsonAsync(Endpoint, payload, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new InvalidOperationException(
$"Embedding API error ({(int)response.StatusCode}): {body}");
}
var parsed = await response.Content.ReadFromJsonAsync<EmbeddingResponse>(cancellationToken: cancellationToken);
if (parsed?.Data == null || parsed.Data.Count != batch.Length)
throw new InvalidOperationException(
$"Embedding API returned {parsed?.Data?.Count ?? 0} vectors for {batch.Length} texts.");
// Vérifié le 2026-08-09 : Google ne renvoie pas le champ "index" du contrat
// OpenAI — chaque item ne porte que "embedding" et "object". Trier dessus
// donnerait un tri sur des zéros. L'ordre du tableau suit celui des entrées.
return parsed.Data
.Select(d => Normalize(d.Embedding))
.ToList();
}
/// <summary>
/// Normalise le vecteur en norme L2.
/// gemini-embedding-001 ne renvoie des vecteurs normalisés que sur sa dimension
/// native (3072). Toute dimension réduite sort brute, et la distance cosinus
/// de pgvector suppose des vecteurs normalisés — sans ça, le classement des
/// résultats est faussé sans qu'aucune erreur ne soit levée.
/// </summary>
private float[] Normalize(float[] vector)
{
if (vector == null || vector.Length != Dimensions)
throw new InvalidOperationException(
$"Embedding API returned {vector?.Length ?? 0} dimensions, expected {Dimensions}.");
double sumOfSquares = 0;
foreach (var value in vector)
sumOfSquares += (double)value * value;
var norm = Math.Sqrt(sumOfSquares);
if (norm == 0)
return vector;
var normalized = new float[vector.Length];
for (var i = 0; i < vector.Length; i++)
normalized[i] = (float)(vector[i] / norm);
return normalized;
}
private class EmbeddingRequest
{
[JsonPropertyName("model")]
public string Model { get; set; }
[JsonPropertyName("input")]
public string[] Input { get; set; }
[JsonPropertyName("dimensions")]
public int Dimensions { get; set; }
}
private class EmbeddingResponse
{
[JsonPropertyName("data")]
public List<EmbeddingData> Data { get; set; }
}
private class EmbeddingData
{
[JsonPropertyName("embedding")]
public float[] Embedding { get; set; }
}
}
}

View File

@ -0,0 +1,33 @@
using System;
using System.Threading.Tasks;
namespace ManagerService.Services
{
/// <summary>
/// Transactional emails for onboarding, account management and billing.
/// Implemented by <see cref="LoggingEmailService"/> for now (logs only) — replaced by a
/// Resend-backed implementation with the real HTML templates.
/// </summary>
public interface IEmailService
{
Task SendWelcomeEmailAsync(string toEmail, string firstName, string loginUrl);
Task SendTrialStartedEmailAsync(string toEmail, string firstName, DateTime trialEndsAt);
Task SendTrialCheckInEmailAsync(string toEmail, string firstName);
Task SendTrialEndingReminderEmailAsync(string toEmail, string firstName, DateTime trialEndsAt, string checkoutUrl);
Task SendTrialLastDayEmailAsync(string toEmail, string firstName, string checkoutUrl);
Task SendPaymentConfirmedEmailAsync(string toEmail, string firstName);
Task SendPaymentFailedEmailAsync(string toEmail, string firstName, string customerPortalUrl);
Task SendUserInvitationEmailAsync(string toEmail, string setPasswordUrl);
Task SendPasswordResetEmailAsync(string toEmail, string firstName, string resetUrl);
Task NotifyAdminAsync(string subject, string message);
}
}

View File

@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ManagerService.Services
{
/// <summary>
/// Transforme du texte en vecteur pour la recherche sémantique du guide IA.
/// La dimension est figée dans le schéma (`vector(768)` sur ContentEmbedding) :
/// changer de modèle ou de dimension impose un re-embed complet et une migration de colonne.
/// </summary>
public interface IEmbeddingService
{
int Dimensions { get; }
Task<float[]> EmbedAsync(string text, CancellationToken cancellationToken = default);
/// <summary>
/// Vectorise plusieurs textes en un minimum d'appels réseau.
/// Indispensable : un PDF de 100 pages produit ~150 morceaux, soit autant
/// d'allers-retours HTTP séquentiels si on n'embed qu'un texte à la fois.
/// L'ordre des résultats suit celui des textes fournis.
/// </summary>
Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken cancellationToken = default);
}
}

View File

@ -0,0 +1,136 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using ManagerService.EmailTemplates;
using ManagerService.Helpers;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ManagerService.Services
{
/// <summary>
/// Sends transactional emails via the Resend API (https://resend.com), using the shared
/// <see cref="EmailLayout"/> branded HTML shell for every template.
/// </summary>
public class ResendEmailService : IEmailService
{
private readonly ResendSettings _settings;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ResendEmailService> _logger;
public ResendEmailService(IOptions<ResendSettings> settings, IHttpClientFactory httpClientFactory, ILogger<ResendEmailService> logger)
{
_settings = settings.Value;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
private async Task SendAsync(string toEmail, string subject, string html)
{
try
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _settings.ApiKey);
var payload = JsonSerializer.Serialize(new
{
from = _settings.FromEmail,
to = new[] { toEmail },
subject,
html,
});
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await client.PostAsync("https://api.resend.com/emails", content);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
_logger.LogError("Resend API error {StatusCode} sending to {ToEmail}: {Body}", response.StatusCode, toEmail, body);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {ToEmail} via Resend", toEmail);
}
}
public Task SendWelcomeEmailAsync(string toEmail, string firstName, string loginUrl) => SendAsync(
toEmail,
"Bienvenue sur MyInfoMate !",
EmailLayout.Render(
$"Bienvenue{(string.IsNullOrEmpty(firstName) ? "" : $", {firstName}")} !",
"Votre espace MyInfoMate est prêt. Vous pouvez dès maintenant vous connecter et configurer votre lieu culturel.",
"Accéder à mon espace", loginUrl));
public Task SendTrialStartedEmailAsync(string toEmail, string firstName, DateTime trialEndsAt) => SendAsync(
toEmail,
"Votre essai gratuit a démarré",
EmailLayout.Render(
"Votre essai gratuit de 14 jours a démarré",
$"Vous avez accès à toutes les fonctionnalités du plan Essentiel jusqu'au {trialEndsAt:dd/MM/yyyy}, sans carte bancaire. " +
"Vos pages visiteur afficheront un léger filigrane « Aperçu » tant que l'essai n'est pas converti en abonnement."));
public Task SendTrialCheckInEmailAsync(string toEmail, string firstName) => SendAsync(
toEmail,
"Comment se passe votre essai MyInfoMate ?",
EmailLayout.Render(
"Comment ça se passe ?",
"Cela fait une semaine que vous testez MyInfoMate. Une question, besoin d'aide pour configurer votre contenu ? Répondez simplement à cet e-mail."));
public Task SendTrialEndingReminderEmailAsync(string toEmail, string firstName, DateTime trialEndsAt, string checkoutUrl) => SendAsync(
toEmail,
"Votre essai se termine bientôt",
EmailLayout.Render(
"Votre essai se termine dans quelques jours",
$"Votre essai gratuit se termine le {trialEndsAt:dd/MM/yyyy}. Passez à l'abonnement Essentiel (39€/mois HTVA) pour garder votre espace actif et retirer le filigrane « Aperçu ».",
"Activer mon abonnement", checkoutUrl));
public Task SendTrialLastDayEmailAsync(string toEmail, string firstName, string checkoutUrl) => SendAsync(
toEmail,
"Dernier jour de votre essai gratuit",
EmailLayout.Render(
"C'est le dernier jour de votre essai",
"Sans abonnement actif, votre espace sera désactivé demain. Activez votre abonnement dès maintenant pour ne pas perdre votre configuration.",
"Activer mon abonnement", checkoutUrl));
public Task SendPaymentConfirmedEmailAsync(string toEmail, string firstName) => SendAsync(
toEmail,
"Paiement confirmé — abonnement actif",
EmailLayout.Render(
"Votre abonnement est actif !",
"Merci ! Votre paiement a été confirmé et votre abonnement Essentiel est maintenant actif. Le filigrane « Aperçu » a été retiré de vos pages visiteur. Votre facture vous sera envoyée sous peu."));
public Task SendPaymentFailedEmailAsync(string toEmail, string firstName, string customerPortalUrl) => SendAsync(
toEmail,
"Échec du paiement de votre abonnement",
EmailLayout.Render(
"Le paiement de votre abonnement a échoué",
"Nous n'avons pas pu traiter le paiement de votre abonnement MyInfoMate. Merci de mettre à jour votre moyen de paiement pour éviter une interruption de service.",
"Mettre à jour mon moyen de paiement", customerPortalUrl));
public Task SendUserInvitationEmailAsync(string toEmail, string setPasswordUrl) => SendAsync(
toEmail,
"Vous êtes invité(e) sur MyInfoMate",
EmailLayout.Render(
"Vous avez été invité(e) sur MyInfoMate",
"Un administrateur vous a donné accès à son espace MyInfoMate. Définissez votre mot de passe pour commencer.",
"Définir mon mot de passe", setPasswordUrl));
public Task SendPasswordResetEmailAsync(string toEmail, string firstName, string resetUrl) => SendAsync(
toEmail,
"Réinitialisation de votre mot de passe",
EmailLayout.Render(
"Réinitialisation de mot de passe",
"Vous avez demandé à réinitialiser votre mot de passe MyInfoMate. Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.",
"Définir un nouveau mot de passe", resetUrl));
public Task NotifyAdminAsync(string subject, string message) => SendAsync(
_settings.AdminEmail,
$"[MyInfoMate] {subject}",
EmailLayout.Render(subject, message.Replace("\n", "<br>")));
}
}

View File

@ -0,0 +1,78 @@
using System.Threading.Tasks;
using ManagerService.Data;
using ManagerService.Helpers;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
namespace ManagerService.Services
{
/// <summary>
/// Stripe integration for the Essentiel plan self-service subscription (customer creation,
/// Checkout Session, webhook signature verification). Stripe Checkout is hosted by Stripe —
/// no card form is built in any of our own apps.
/// </summary>
public class StripeService
{
private readonly StripeSettings _settings;
private readonly CustomerService _customerService;
private readonly SessionService _checkoutSessionService;
public StripeService(IOptions<StripeSettings> settings)
{
_settings = settings.Value;
StripeConfiguration.ApiKey = _settings.SecretKey;
_customerService = new CustomerService();
_checkoutSessionService = new SessionService();
}
public async Task<string> CreateCustomerAsync(Instance instance)
{
var customer = await _customerService.CreateAsync(new CustomerCreateOptions
{
Name = instance.Name,
Metadata = new System.Collections.Generic.Dictionary<string, string>
{
{ "instanceId", instance.Id },
},
});
return customer.Id;
}
/// <summary>
/// Create a Stripe Checkout Session for the Essentiel plan subscription.
/// Stripe Tax is applied automatically based on the customer's billing details.
/// </summary>
public async Task<string> CreateCheckoutSessionAsync(Instance instance, string successUrl, string cancelUrl)
{
var options = new SessionCreateOptions
{
Mode = "subscription",
Customer = instance.StripeCustomerId,
LineItems = new System.Collections.Generic.List<SessionLineItemOptions>
{
new SessionLineItemOptions
{
Price = _settings.EssentielPriceId,
Quantity = 1,
},
},
AutomaticTax = new SessionAutomaticTaxOptions { Enabled = true },
SuccessUrl = successUrl,
CancelUrl = cancelUrl,
Metadata = new System.Collections.Generic.Dictionary<string, string>
{
{ "instanceId", instance.Id },
},
};
var session = await _checkoutSessionService.CreateAsync(options);
return session.Url;
}
public Event ConstructWebhookEvent(string json, string stripeSignatureHeader)
{
return EventUtility.ConstructEvent(json, stripeSignatureHeader, _settings.WebhookSecret);
}
}
}

View File

@ -0,0 +1,98 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using ManagerService.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace ManagerService.Services
{
/// <summary>
/// Daily Hangfire job driving the 14-day Essentiel trial: check-in (J+7), ending reminder
/// (J+10), last-day warning (J+14), then automatic deactivation if no subscription was
/// started. Flags on <see cref="Instance"/> (TrialCheckInEmailSent, etc.) prevent sending
/// the same email twice if the job runs more than once on the same day.
/// </summary>
public class TrialLifecycleService
{
private const int CheckInAfterDays = 7;
private const int ReminderAfterDays = 10;
private const int LastDayAfterDays = 13; // day before the 14-day trial ends
private readonly ILogger<TrialLifecycleService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public TrialLifecycleService(ILogger<TrialLifecycleService> logger, IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
public async Task RunAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MyInfoMateDbContext>();
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
var configuration = scope.ServiceProvider.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
var managerAppUrl = configuration["AppUrls:ManagerApp"];
var now = DateTime.UtcNow;
var trialInstances = db.Instances.Where(i => i.IsTrialActive).ToList();
foreach (var instance in trialInstances)
{
try
{
await ProcessInstanceAsync(instance, db, emailService, managerAppUrl, now);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing trial lifecycle for instance {InstanceId}", instance.Id);
}
}
}
private async Task ProcessInstanceAsync(Instance instance, MyInfoMateDbContext db, IEmailService emailService, string managerAppUrl, DateTime now)
{
var daysSinceCreation = (now - instance.DateCreation).TotalDays;
var user = db.Users.FirstOrDefault(u => u.InstanceId == instance.Id);
// Trial expired without an active subscription -> deactivate
if (instance.TrialEndsAt != null && instance.TrialEndsAt < now && string.IsNullOrEmpty(instance.StripeSubscriptionId))
{
instance.IsTrialActive = false;
instance.IsActive = false;
db.SaveChanges();
return;
}
if (user == null)
return;
if (daysSinceCreation >= CheckInAfterDays && !instance.TrialCheckInEmailSent)
{
await emailService.SendTrialCheckInEmailAsync(user.Email, user.FirstName);
instance.TrialCheckInEmailSent = true;
db.SaveChanges();
}
if (daysSinceCreation >= ReminderAfterDays && !instance.TrialReminderEmailSent && instance.TrialEndsAt != null)
{
var checkoutUrl = $"{managerAppUrl}/main/web";
await emailService.SendTrialEndingReminderEmailAsync(user.Email, user.FirstName, instance.TrialEndsAt.Value, checkoutUrl);
instance.TrialReminderEmailSent = true;
db.SaveChanges();
}
if (daysSinceCreation >= LastDayAfterDays && !instance.TrialLastDayEmailSent)
{
var checkoutUrl = $"{managerAppUrl}/main/web";
await emailService.SendTrialLastDayEmailAsync(user.Email, user.FirstName, checkoutUrl);
instance.TrialLastDayEmailSent = true;
db.SaveChanges();
}
}
}
}

View File

@ -0,0 +1,59 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using ManagerService.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ManagerService.Services
{
/// <summary>
/// Purge les VisitEvent au-delà de la fenêtre d'historique lisible.
/// La fenêtre commerciale est de 13 mois (<see cref="MyInfoMateDbContext.StatsRetentionDays"/>) :
/// garder indéfiniment des événements que personne ne peut plus lire coûte du stockage
/// et alourdit chaque requête d'agrégation, sans rien apporter au client.
///
/// ⚠️ Désactivé tant que <c>Stats:RetentionDays</c> n'est pas défini dans la configuration.
/// C'est une suppression définitive : ne l'activer qu'une fois la sauvegarde applicative
/// (pg_dump quotidien) en place — le snapshot OVH seul ne permet pas une restauration fine.
/// </summary>
public class VisitEventPurgeService
{
private readonly MyInfoMateDbContext _dbContext;
private readonly IConfiguration _configuration;
private readonly ILogger<VisitEventPurgeService> _logger;
public VisitEventPurgeService(
MyInfoMateDbContext dbContext,
IConfiguration configuration,
ILogger<VisitEventPurgeService> logger)
{
_dbContext = dbContext;
_configuration = configuration;
_logger = logger;
}
public async Task PurgeAsync()
{
var retentionDays = _configuration.GetValue<int>("Stats:RetentionDays");
if (retentionDays <= 0)
{
_logger.LogInformation(
"Purge des VisitEvent ignorée : Stats:RetentionDays non défini. " +
"Valeur attendue une fois le pg_dump en place : {Expected}.",
MyInfoMateDbContext.StatsRetentionDays);
return;
}
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
var deleted = await _dbContext.VisitEvents
.Where(e => e.Timestamp < cutoff)
.ExecuteDeleteAsync();
_logger.LogInformation(
"Purge des VisitEvent : {Deleted} événements antérieurs au {Cutoff:yyyy-MM-dd} supprimés.",
deleted, cutoff);
}
}
}

View File

@ -115,6 +115,8 @@ namespace ManagerService
var tokenSettings = tokensConfiguration.Get<TokensSettings>(); var tokenSettings = tokensConfiguration.Get<TokensSettings>();
services.Configure<TokensSettings>(tokensConfiguration); services.Configure<TokensSettings>(tokensConfiguration);
services.Configure<StripeSettings>(Configuration.GetSection("Stripe"));
services.Configure<ResendSettings>(Configuration.GetSection("Resend"));
foreach (var policy in ManagerService.Service.Security.PoliciesConfiguration) foreach (var policy in ManagerService.Service.Security.PoliciesConfiguration)
services.AddAuthorization(options => services.AddAuthorization(options =>
@ -171,6 +173,8 @@ namespace ManagerService
services.AddScoped<DeviceDatabaseService>(); services.AddScoped<DeviceDatabaseService>();
services.AddScoped<InstanceDatabaseService>(); services.AddScoped<InstanceDatabaseService>();
services.AddScoped<ApiKeyDatabaseService>(); services.AddScoped<ApiKeyDatabaseService>();
services.AddScoped<IEmailService, ResendEmailService>();
services.AddScoped<StripeService>();
// Assistant IA — choisir un provider (package NuGet : Microsoft.Extensions.AI.OpenAI) : // Assistant IA — choisir un provider (package NuGet : Microsoft.Extensions.AI.OpenAI) :
// OpenAI : new OpenAIClient(new ApiKeyCredential(apiKey)).AsChatClient("gpt-4o-mini") // OpenAI : new OpenAIClient(new ApiKeyCredential(apiKey)).AsChatClient("gpt-4o-mini")
@ -185,6 +189,7 @@ namespace ManagerService
.UseFunctionInvocation() .UseFunctionInvocation()
.Build()); .Build());
services.AddScoped<IAssistantService, AssistantService>(); services.AddScoped<IAssistantService, AssistantService>();
services.AddScoped<IEmbeddingService, GoogleEmbeddingService>();
// Push Notifications // Push Notifications
var firebaseCredentialsPath = Configuration["Firebase:CredentialsPath"]; var firebaseCredentialsPath = Configuration["Firebase:CredentialsPath"];
@ -199,6 +204,8 @@ namespace ManagerService
services.AddHttpClient(); services.AddHttpClient();
services.AddScoped<AgendaSyncService>(); services.AddScoped<AgendaSyncService>();
services.AddScoped<WeatherSyncService>(); services.AddScoped<WeatherSyncService>();
services.AddScoped<TrialLifecycleService>();
services.AddScoped<VisitEventPurgeService>();
var connectionString = Configuration.GetConnectionString("PostgresConnection"); var connectionString = Configuration.GetConnectionString("PostgresConnection");
@ -212,11 +219,12 @@ namespace ManagerService
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.UseNetTopologySuite(); dataSourceBuilder.UseNetTopologySuite();
dataSourceBuilder.UseVector();
dataSourceBuilder.EnableDynamicJson(); dataSourceBuilder.EnableDynamicJson();
var dataSource = dataSourceBuilder.Build(); var dataSource = dataSourceBuilder.Build();
services.AddDbContext<MyInfoMateDbContext>(options => services.AddDbContext<MyInfoMateDbContext>(options =>
options.UseNpgsql(dataSource, o => o.UseNetTopologySuite()) options.UseNpgsql(dataSource, o => o.UseNetTopologySuite().UseVector())
.EnableSensitiveDataLogging() .EnableSensitiveDataLogging()
.LogTo(Console.WriteLine, LogLevel.Information) .LogTo(Console.WriteLine, LogLevel.Information)
); );
@ -256,7 +264,7 @@ namespace ManagerService
.AllowCredentials() .AllowCredentials()
#else #else
options => options options => options
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "https://manager.mymuseum.be" || origin == "https://manager.myinfomate.be" || origin == "https://visitnamur.myinfomate.be" || origin == "https://fortsaintheribert.myinfomate.be" || origin == "https://app.myinfomate.be") .SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "https://manager.mymuseum.be" || origin == "https://manager.myinfomate.be" || origin == "https://visitnamur.myinfomate.be" || origin == "https://fortsaintheribert.myinfomate.be" || origin == "https://app.myinfomate.be" || origin == "https://myinfomate.be")
.AllowAnyMethod() .AllowAnyMethod()
.AllowAnyHeader() .AllowAnyHeader()
.AllowCredentials() .AllowCredentials()
@ -287,6 +295,17 @@ namespace ManagerService
s => s.SyncAllAsync(), s => s.SyncAllAsync(),
"0 13 * * *"); "0 13 * * *");
RecurringJob.AddOrUpdate<TrialLifecycleService>(
"trial-lifecycle-daily",
s => s.RunAsync(),
Cron.Daily());
// Ne supprime rien tant que Stats:RetentionDays n'est pas défini — voir VisitEventPurgeService.
RecurringJob.AddOrUpdate<VisitEventPurgeService>(
"visit-events-purge",
s => s.PurgeAsync(),
"0 3 * * *");
app.UseEndpoints(endpoints => app.UseEndpoints(endpoints =>
{ {
endpoints.MapControllers(); endpoints.MapControllers();

View File

@ -23,5 +23,9 @@
"Microsoft": "Warning", "Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information" "Microsoft.Hosting.Lifetime": "Information"
} }
},
"AppUrls": {
"ManagerApp": "http://localhost:9090",
"Landing": "http://localhost:3000"
} }
} }

View File

@ -42,6 +42,20 @@
}, },
"Firebase": { "Firebase": {
"CredentialsPath": "firebase-adminsdk.json" "CredentialsPath": "firebase-adminsdk.json"
},
"AppUrls": {
"ManagerApp": "https://manager.myinfomate.be",
"Landing": "https://myinfomate.be"
},
"Stripe": {
"SecretKey": "sk_test_51U14rjRLQgHvlM4X4ewATCOOIzdIfTZEJCwwZT9sfpWm9LkrISoSEHfgHbPhJTujdYdPdRyCnrDoo5Sf8NMyCrPT00RfYB8fSN",
"WebhookSecret": "whsec_oMjl06r2l5XyX76uwsFHSW9BqQp3AsoS",
"EssentielPriceId": "price_1U14zPRLQgHvlM4XijZzTvGW"
},
"Resend": {
"ApiKey": "re_auR5A7VS_MyeUB87ceiZp91hbXnwKriGN",
"FromEmail": "MyInfoMate <onboarding@myinfomate.be>",
"AdminEmail": "fransolet.thomas@gmail.com"
} }
//"Urls": "http://[::]:80" //"Urls": "http://[::]:80"
} }