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:
parent
f2228617ca
commit
af6b5b76ef
@ -18,14 +18,19 @@ namespace ManagerService.Tests.Controllers
|
||||
{
|
||||
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
|
||||
.Setup(s => s.ChatAsync(It.IsAny<AiChatRequest>()))
|
||||
.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) =>
|
||||
|
||||
@ -31,7 +31,10 @@ namespace ManagerService.Tests.Controllers
|
||||
return new AuthenticationController(
|
||||
NullLogger<AuthenticationController>.Instance,
|
||||
tokensService,
|
||||
db);
|
||||
db,
|
||||
profileLogic,
|
||||
new FakeEmailService(),
|
||||
FakeConfiguration.Create());
|
||||
}
|
||||
|
||||
// Note: en mode DEBUG, email est toujours surchargé en "test@email.be"
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,15 @@ namespace ManagerService.Tests.Controllers
|
||||
{
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -9,13 +9,15 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace ManagerService.Tests.Controllers
|
||||
{
|
||||
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 userService = new UserDatabaseService(cfg);
|
||||
@ -25,7 +27,9 @@ namespace ManagerService.Tests.Controllers
|
||||
NullLogger<UserController>.Instance,
|
||||
userService,
|
||||
profileLogic,
|
||||
db);
|
||||
db,
|
||||
emailService ?? new FakeEmailService(),
|
||||
FakeConfiguration.Create());
|
||||
|
||||
FakeUser.SetUser(controller, FakeUser.Create(callerRole, callerInstanceId));
|
||||
return controller;
|
||||
@ -82,24 +86,24 @@ namespace ManagerService.Tests.Controllers
|
||||
// ── CREATE ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CreateUser_DuplicateEmail_Returns409()
|
||||
public async Task CreateUser_DuplicateEmail_Returns409()
|
||||
{
|
||||
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.SaveChanges();
|
||||
|
||||
var result = BuildController(db).CreateUser(MakeNewUser("dup@a.be"));
|
||||
var result = await BuildController(db).CreateUser(MakeNewUser("dup@a.be"));
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUser_InstanceAdminCreatingInstanceAdmin_Returns403()
|
||||
public async Task CreateUser_InstanceAdminCreatingInstanceAdmin_Returns403()
|
||||
{
|
||||
// InstanceAdmin (role=1) ne peut pas créer SuperAdmin (role=0)
|
||||
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));
|
||||
|
||||
var statusResult = Assert.IsType<ObjectResult>(result);
|
||||
@ -107,11 +111,11 @@ namespace ManagerService.Tests.Controllers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUser_ValidDto_HashesPassword()
|
||||
public async Task CreateUser_ValidDto_HashesPassword()
|
||||
{
|
||||
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);
|
||||
var user = db.Users.First();
|
||||
@ -119,14 +123,38 @@ namespace ManagerService.Tests.Controllers
|
||||
}
|
||||
|
||||
[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();
|
||||
var emails = new FakeEmailService();
|
||||
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
using ManagerService.Data;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ManagerService.Tests.Infrastructure
|
||||
{
|
||||
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>()
|
||||
.UseInMemoryDatabase(System.Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
return new MyInfoMateDbContext(options);
|
||||
return new MyInfoMateDbContext(options, httpContextAccessor ?? new HttpContextAccessor());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
ManagerService.Tests/Infrastructure/FakeConfiguration.cs
Normal file
28
ManagerService.Tests/Infrastructure/FakeConfiguration.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
53
ManagerService.Tests/Infrastructure/FakeEmailService.cs
Normal file
53
ManagerService.Tests/Infrastructure/FakeEmailService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,13 @@ namespace ManagerService.Controllers
|
||||
[OpenApiTag("AI", Description = "Assistant IA")]
|
||||
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 MyInfoMateDbContext _context;
|
||||
private readonly ILogger<AiController> _logger;
|
||||
@ -37,6 +44,39 @@ namespace ManagerService.Controllers
|
||||
private bool IsSuperAdmin() =>
|
||||
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>
|
||||
/// Traduit un texte HTML vers plusieurs langues via IA
|
||||
/// </summary>
|
||||
@ -55,22 +95,13 @@ namespace ManagerService.Controllers
|
||||
if (instance == null || !instance.IsAssistant)
|
||||
return Forbid();
|
||||
|
||||
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é");
|
||||
var quotaError = CheckQuota(instance);
|
||||
if (quotaError != null)
|
||||
return quotaError;
|
||||
|
||||
var result = await _assistantService.TranslateAsync(request);
|
||||
|
||||
instance.AiTokensThisMonth += result.TokensUsed;
|
||||
_context.SaveChanges();
|
||||
RecordUsage(instance, result.TokensUsed);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
@ -109,22 +140,13 @@ namespace ManagerService.Controllers
|
||||
if (appInstance == null || !appInstance.IsAssistant)
|
||||
return Forbid();
|
||||
|
||||
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é");
|
||||
var quotaError = CheckQuota(instance);
|
||||
if (quotaError != null)
|
||||
return quotaError;
|
||||
|
||||
var result = await _assistantService.ChatAsync(request);
|
||||
|
||||
instance.AiTokensThisMonth += result.TokensUsed;
|
||||
_context.SaveChanges();
|
||||
RecordUsage(instance, result.TokensUsed);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
using Manager.Services;
|
||||
using ManagerService.Data;
|
||||
using ManagerService.DTOs;
|
||||
using ManagerService.Helpers;
|
||||
using ManagerService.Service.Services;
|
||||
using ManagerService.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Mqtt.Client.AspNetCore.Services;
|
||||
using NSwag.Annotations;
|
||||
@ -11,6 +14,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ManagerService.Service.Controllers
|
||||
{
|
||||
@ -28,13 +32,19 @@ namespace ManagerService.Service.Controllers
|
||||
private readonly DeviceDatabaseService _DeviceDatabaseService;
|
||||
private readonly ConfigurationDatabaseService _ConfigurationDatabaseService;*/
|
||||
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;
|
||||
_tokensService = tokensService;
|
||||
_myInfoMateDbContext = myInfoMateDbContext;
|
||||
_profileLogic = profileLogic;
|
||||
_emailService = emailService;
|
||||
_configuration = configuration;
|
||||
//_UserDatabaseService = UserDatabaseService;
|
||||
//_DeviceDatabaseService = DeviceDatabaseService;
|
||||
//_ConfigurationDatabaseService = ConfigurationDatabaseService;
|
||||
@ -110,5 +120,84 @@ namespace ManagerService.Service.Controllers
|
||||
{
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,7 +137,7 @@ namespace ManagerService.Controllers
|
||||
if (_myInfoMateDbContext.Instances.Any(i => i.Name == instance.Name))
|
||||
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(
|
||||
System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
|
||||
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||
@ -437,7 +437,7 @@ namespace ManagerService.Controllers
|
||||
return new NotFoundObjectResult("Instance not found");
|
||||
|
||||
if (string.IsNullOrEmpty(instance.WebSlug))
|
||||
instance.WebSlug = GenerateUniqueSlug(instance.Name);
|
||||
instance.WebSlug = SlugHelper.GenerateUniqueSlug(_myInfoMateDbContext, instance.Name);
|
||||
|
||||
instance.PublicApiKey = "ap_" + Convert.ToBase64String(
|
||||
System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
|
||||
@ -453,26 +453,5 @@ namespace ManagerService.Controllers
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
305
ManagerService/Controllers/OnboardingController.cs
Normal file
305
ManagerService/Controllers/OnboardingController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -654,10 +654,8 @@ namespace ManagerService.Controllers
|
||||
}
|
||||
existingGuidedStep.ZoneRadiusMeters = guidedStepDTO.zoneRadiusMeters;
|
||||
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.IsStepLocked = guidedStepDTO.isStepLocked;
|
||||
existingGuidedStep.TimerSeconds = guidedStepDTO.timerSeconds;
|
||||
existingGuidedStep.TimerExpiredMessage = guidedStepDTO.timerExpiredMessage;
|
||||
|
||||
|
||||
132
ManagerService/Controllers/StripeWebhookController.cs
Normal file
132
ManagerService/Controllers/StripeWebhookController.cs
Normal 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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Manager.Services;
|
||||
using ManagerService.Data;
|
||||
using ManagerService.DTOs;
|
||||
@ -10,6 +11,7 @@ using ManagerService.Service;
|
||||
using ManagerService.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSwag.Annotations;
|
||||
|
||||
@ -24,14 +26,18 @@ namespace ManagerService.Controllers
|
||||
private readonly ILogger<UserController> _logger;
|
||||
private readonly ProfileLogic _profileLogic;
|
||||
private readonly MyInfoMateDbContext _myInfoMateDbContext;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IConfiguration _configuration;
|
||||
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;
|
||||
_userService = userService;
|
||||
_profileLogic = profileLogic;
|
||||
_myInfoMateDbContext = myInfoMateDbContext;
|
||||
_emailService = emailService;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
private string? GetCallerInstanceId() =>
|
||||
@ -109,7 +115,7 @@ namespace ManagerService.Controllers
|
||||
[ProducesResponseType(typeof(string), 409)]
|
||||
[ProducesResponseType(typeof(string), 500)]
|
||||
[HttpPost]
|
||||
public ObjectResult CreateUser([FromBody] UserDetailDTO newUserDTO)
|
||||
public async Task<ObjectResult> CreateUser([FromBody] UserDetailDTO newUserDTO)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -119,9 +125,6 @@ namespace ManagerService.Controllers
|
||||
if (newUserDTO.instanceId == null)
|
||||
throw new ArgumentNullException("InstanceId is null");
|
||||
|
||||
if (newUserDTO.password == null)
|
||||
throw new ArgumentNullException("Password is null");
|
||||
|
||||
var requestedRole = newUserDTO.role ?? UserRole.ContentEditor;
|
||||
if (requestedRole < GetCallerRole())
|
||||
throw new UnauthorizedAccessException("Cannot assign a role higher than your own");
|
||||
@ -139,11 +142,30 @@ namespace ManagerService.Controllers
|
||||
if (_myInfoMateDbContext.Users.Any(u => u.Email == newUser.Email))
|
||||
throw new InvalidOperationException("This Email is already used");
|
||||
|
||||
newUser.Password = _profileLogic.HashPassword(newUserDTO.password);
|
||||
// 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);
|
||||
}
|
||||
|
||||
_myInfoMateDbContext.Add(newUser);
|
||||
_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());
|
||||
}
|
||||
catch (ArgumentNullException ex)
|
||||
|
||||
@ -10,6 +10,13 @@ namespace ManagerService.DTOs
|
||||
public AppType AppType { get; set; }
|
||||
public string ConfigurationId { get; set; } // null = scope instance, fourni = scope configuration
|
||||
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();
|
||||
/// <summary>
|
||||
/// true = interaction vocale (lunettes, assistant vocal).
|
||||
|
||||
7
ManagerService/DTOs/ForgotPasswordDTO.cs
Normal file
7
ManagerService/DTOs/ForgotPasswordDTO.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace ManagerService.DTOs
|
||||
{
|
||||
public class ForgotPasswordDTO
|
||||
{
|
||||
public string email { get; set; }
|
||||
}
|
||||
}
|
||||
@ -18,10 +18,7 @@ namespace ManagerService.DTOs
|
||||
public string imageUrl { get; set; }
|
||||
public List<TranslationDTO> audioIds { 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 isStepLocked { get; set; }
|
||||
public int? timerSeconds { get; set; }
|
||||
public List<TranslationDTO> timerExpiredMessage { get; set; }
|
||||
public List<QuizQuestion> quizQuestions { get; set; }
|
||||
|
||||
@ -17,6 +17,11 @@ namespace ManagerService.DTOs
|
||||
|
||||
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? publicApiKey { get; set; }
|
||||
@ -31,6 +36,15 @@ namespace ManagerService.DTOs
|
||||
public int? statsHistoryDays { 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; }
|
||||
}
|
||||
}
|
||||
|
||||
16
ManagerService/DTOs/OnboardingRegisterDTO.cs
Normal file
16
ManagerService/DTOs/OnboardingRegisterDTO.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
11
ManagerService/DTOs/OnboardingRegisterResultDTO.cs
Normal file
11
ManagerService/DTOs/OnboardingRegisterResultDTO.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
8
ManagerService/DTOs/SetPasswordDTO.cs
Normal file
8
ManagerService/DTOs/SetPasswordDTO.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace ManagerService.DTOs
|
||||
{
|
||||
public class SetPasswordDTO
|
||||
{
|
||||
public string token { get; set; }
|
||||
public string newPassword { get; set; }
|
||||
}
|
||||
}
|
||||
10
ManagerService/DTOs/ValidateVatResultDTO.cs
Normal file
10
ManagerService/DTOs/ValidateVatResultDTO.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
53
ManagerService/Data/ContentEmbedding.cs
Normal file
53
ManagerService/Data/ContentEmbedding.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,25 @@ namespace ManagerService.Data
|
||||
|
||||
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? PublicApiKey { get; set; }
|
||||
@ -59,6 +78,31 @@ namespace ManagerService.Data
|
||||
|
||||
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)
|
||||
{
|
||||
@ -74,6 +118,10 @@ namespace ManagerService.Data
|
||||
isWeb = IsWeb,
|
||||
isVR = IsVR,
|
||||
isAssistant = IsAssistant,
|
||||
guideName = GuideName,
|
||||
guidePersonaPrompt = GuidePersonaPrompt,
|
||||
guideVoiceId = GuideVoiceId,
|
||||
guideFallbackMessages = GuideFallbackMessages,
|
||||
webSlug = WebSlug,
|
||||
publicApiKey = PublicApiKey,
|
||||
subscriptionPlanId = SubscriptionPlanId,
|
||||
@ -85,6 +133,13 @@ namespace ManagerService.Data
|
||||
hasStats = HasStats,
|
||||
statsHistoryDays = StatsHistoryDays,
|
||||
hasAdvancedStats = HasAdvancedStats,
|
||||
isTrialActive = IsTrialActive,
|
||||
trialEndsAt = TrialEndsAt,
|
||||
trialAiTokensUsed = TrialAiTokensUsed,
|
||||
billingAddress = BillingAddress,
|
||||
billingCountry = BillingCountry,
|
||||
vatNumber = VatNumber,
|
||||
vatRate = VatRate,
|
||||
applicationInstanceDTOs = applicationInstanceDTOs
|
||||
};
|
||||
}
|
||||
@ -100,6 +155,14 @@ namespace ManagerService.Data
|
||||
IsWeb = instanceDTO.isWeb ?? false;
|
||||
IsVR = instanceDTO.isVR ?? 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)
|
||||
WebSlug = instanceDTO.webSlug;
|
||||
if (instanceDTO.publicApiKey != null)
|
||||
@ -116,6 +179,14 @@ namespace ManagerService.Data
|
||||
StatsHistoryDays = instanceDTO.statsHistoryDays.Value;
|
||||
if (instanceDTO.hasAdvancedStats.HasValue)
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,12 @@ namespace ManagerService.Data
|
||||
{
|
||||
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;
|
||||
|
||||
public MyInfoMateDbContext(DbContextOptions<MyInfoMateDbContext> options, IHttpContextAccessor httpContextAccessor)
|
||||
@ -64,6 +70,12 @@ namespace ManagerService.Data
|
||||
// Audit
|
||||
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)
|
||||
{
|
||||
StampAuditableEntities();
|
||||
@ -104,12 +116,18 @@ namespace ManagerService.Data
|
||||
|
||||
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>();
|
||||
|
||||
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())
|
||||
&& 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
|
||||
{
|
||||
@ -154,10 +172,12 @@ namespace ManagerService.Data
|
||||
NewValues = newValues
|
||||
};
|
||||
|
||||
AuditLogs.Add(log);
|
||||
entries.Add(log);
|
||||
}
|
||||
|
||||
if (entries.Count > 0)
|
||||
AuditLogs.AddRange(entries);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
@ -191,6 +211,74 @@ namespace ManagerService.Data
|
||||
v => JsonSerializer.Serialize(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>()
|
||||
.Property<string>("Discriminator")
|
||||
.HasMaxLength(50);
|
||||
@ -357,13 +445,6 @@ namespace ManagerService.Data
|
||||
v => JsonSerializer.Serialize(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
|
||||
modelBuilder.Entity<SectionParcours>()
|
||||
.HasOne(sp => sp.BaseMap)
|
||||
@ -429,6 +510,24 @@ namespace ManagerService.Data
|
||||
.Property(i => i.StatsHistoryDays).ValueGeneratedNever();
|
||||
modelBuilder.Entity<Instance>()
|
||||
.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
|
||||
modelBuilder.Entity<SubscriptionPlan>().HasData(
|
||||
@ -449,7 +548,7 @@ namespace ManagerService.Data
|
||||
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é
|
||||
HasStats = true,
|
||||
StatsHistoryDays = 30,
|
||||
StatsHistoryDays = StatsRetentionDays,
|
||||
HasAdvancedStats = false,
|
||||
},
|
||||
new SubscriptionPlan
|
||||
@ -459,8 +558,18 @@ namespace ManagerService.Data
|
||||
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é
|
||||
HasStats = true,
|
||||
StatsHistoryDays = 0,
|
||||
StatsHistoryDays = StatsRetentionDays,
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@ -42,6 +42,30 @@ namespace ManagerService.Data
|
||||
|
||||
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()
|
||||
{
|
||||
return new ResourceDTO()
|
||||
@ -59,13 +83,27 @@ namespace ManagerService.Data
|
||||
|
||||
public enum ResourceType
|
||||
{
|
||||
Image,
|
||||
Video,
|
||||
ImageUrl,
|
||||
VideoUrl,
|
||||
Audio,
|
||||
PDF,
|
||||
JSON,
|
||||
JSONUrl
|
||||
// ⚠️ Persisté en int. Ne JAMAIS réordonner ni insérer au milieu : les valeurs
|
||||
// existantes en base se décaleraient (les PDF deviendraient des JSON).
|
||||
// Toute nouvelle valeur s'ajoute à la fin.
|
||||
Image, // 0
|
||||
Video, // 1
|
||||
ImageUrl, // 2
|
||||
VideoUrl, // 3
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,20 +45,12 @@ namespace ManagerService.Data.SubSection
|
||||
[Column(TypeName = "jsonb")]
|
||||
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
|
||||
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
|
||||
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)
|
||||
public int? TimerSeconds { get; set; }
|
||||
|
||||
@ -81,10 +73,7 @@ namespace ManagerService.Data.SubSection
|
||||
imageUrl = ImageUrl,
|
||||
audioIds = AudioIds,
|
||||
contents = Contents,
|
||||
factContent = FactContent,
|
||||
isHiddenInitially = IsHiddenInitially,
|
||||
isStepTimer = IsStepTimer,
|
||||
isStepLocked = IsStepLocked,
|
||||
timerSeconds = TimerSeconds,
|
||||
timerExpiredMessage = TimerExpiredMessage,
|
||||
quizQuestions = QuizQuestions
|
||||
@ -104,10 +93,7 @@ namespace ManagerService.Data.SubSection
|
||||
ImageUrl = dto.imageUrl;
|
||||
AudioIds = dto.audioIds ?? new List<TranslationDTO>();
|
||||
Contents = dto.contents ?? new List<ContentDTO>();
|
||||
FactContent = dto.factContent ?? new List<TranslationDTO>();
|
||||
IsHiddenInitially = dto.isHiddenInitially;
|
||||
IsStepTimer = dto.isStepTimer;
|
||||
IsStepLocked = dto.isStepLocked;
|
||||
TimerSeconds = dto.timerSeconds;
|
||||
TimerExpiredMessage = dto.timerExpiredMessage ?? new List<TranslationDTO>();
|
||||
Order = dto.order.GetValueOrDefault();
|
||||
|
||||
@ -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")
|
||||
|
||||
// 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
|
||||
}
|
||||
]
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@ -53,6 +53,11 @@ namespace ManagerService.Data
|
||||
|
||||
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()
|
||||
{
|
||||
return new UserDetailDTO()
|
||||
|
||||
54
ManagerService/Data/VisitorQuestion.cs
Normal file
54
ManagerService/Data/VisitorQuestion.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
20
ManagerService/Deployment/Dockerfile.postgres
Normal file
20
ManagerService/Deployment/Dockerfile.postgres
Normal 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.
|
||||
@ -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"]}]
|
||||
|
||||
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).
|
||||
@ -3,7 +3,11 @@ version: '3.9'
|
||||
|
||||
services:
|
||||
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
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
|
||||
@ -3,7 +3,13 @@ version: '3.9'
|
||||
|
||||
services:
|
||||
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
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
|
||||
59
ManagerService/EmailTemplates/EmailLayout.cs
Normal file
59
ManagerService/EmailTemplates/EmailLayout.cs
Normal 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>";
|
||||
}
|
||||
}
|
||||
}
|
||||
27
ManagerService/Helpers/PasswordTokenHelper.cs
Normal file
27
ManagerService/Helpers/PasswordTokenHelper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
9
ManagerService/Helpers/ResendSettings.cs
Normal file
9
ManagerService/Helpers/ResendSettings.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
33
ManagerService/Helpers/SlugHelper.cs
Normal file
33
ManagerService/Helpers/SlugHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
ManagerService/Helpers/StripeSettings.cs
Normal file
9
ManagerService/Helpers/StripeSettings.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@ -24,10 +24,12 @@
|
||||
<PackageReference Include="FirebaseAdmin" Version="3.1.0" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.17" />
|
||||
<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="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<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.IdentityModel.Tokens.Jwt" Version="7.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
1720
ManagerService/Migrations/20260717143211_AddTrialAndBillingFieldsToInstance.Designer.cs
generated
Normal file
1720
ManagerService/Migrations/20260717143211_AddTrialAndBillingFieldsToInstance.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1726
ManagerService/Migrations/20260717144118_AddPasswordTokenToUser.Designer.cs
generated
Normal file
1726
ManagerService/Migrations/20260717144118_AddPasswordTokenToUser.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1738
ManagerService/Migrations/20260717150642_AddTrialLifecycleFieldsToInstance.Designer.cs
generated
Normal file
1738
ManagerService/Migrations/20260717150642_AddTrialLifecycleFieldsToInstance.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1738
ManagerService/Migrations/20260805145925_FixEssentielAiTokenQuota.Designer.cs
generated
Normal file
1738
ManagerService/Migrations/20260805145925_FixEssentielAiTokenQuota.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1729
ManagerService/Migrations/20260806113507_RemoveDeadGuidedStepFlags.Designer.cs
generated
Normal file
1729
ManagerService/Migrations/20260806113507_RemoveDeadGuidedStepFlags.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1741
ManagerService/Migrations/20260807143641_AddGuideIaConfigToInstance.Designer.cs
generated
Normal file
1741
ManagerService/Migrations/20260807143641_AddGuideIaConfigToInstance.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1802
ManagerService/Migrations/20260809191757_AddContentEmbedding.Designer.cs
generated
Normal file
1802
ManagerService/Migrations/20260809191757_AddContentEmbedding.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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", ",,");
|
||||
}
|
||||
}
|
||||
}
|
||||
1823
ManagerService/Migrations/20260809192339_AddResourceStorageAndAiColumns.Designer.cs
generated
Normal file
1823
ManagerService/Migrations/20260809192339_AddResourceStorageAndAiColumns.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1883
ManagerService/Migrations/20260809192444_AddVisitorQuestion.Designer.cs
generated
Normal file
1883
ManagerService/Migrations/20260809192444_AddVisitorQuestion.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1883
ManagerService/Migrations/20260809194720_UnifyStatsRetentionTo13Months.Designer.cs
generated
Normal file
1883
ManagerService/Migrations/20260809194720_UnifyStatsRetentionTo13Months.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NetTopologySuite.Geometries;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
@ -25,6 +26,7 @@ namespace ManagerService.Migrations
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("ManagerService.Data.ApiKey", b =>
|
||||
@ -293,6 +295,65 @@ namespace ManagerService.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@ -368,18 +429,39 @@ namespace ManagerService.Migrations
|
||||
b.Property<string>("AiUsageMonthKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BillingAddress")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BillingCountry")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("DateCreation")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("DateUpdate")
|
||||
.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")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HasStats")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsAssistant")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@ -392,6 +474,9 @@ namespace ManagerService.Migrations
|
||||
b.Property<bool>("IsTablet")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsTrialActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVR")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@ -414,9 +499,36 @@ namespace ManagerService.Migrations
|
||||
b.Property<long>("StorageQuotaBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StripeCustomerId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SubscriptionPlanId")
|
||||
.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")
|
||||
.HasColumnType("text");
|
||||
|
||||
@ -475,12 +587,30 @@ namespace ManagerService.Migrations
|
||||
b.Property<string>("Id")
|
||||
.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")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("DateUpdate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IncludeInAiKnowledge")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("InstanceId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@ -492,6 +622,9 @@ namespace ManagerService.Migrations
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StoragePath")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@ -821,9 +954,6 @@ namespace ManagerService.Migrations
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("FactContent")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Geometry>("Geometry")
|
||||
.HasColumnType("geometry");
|
||||
|
||||
@ -837,12 +967,6 @@ namespace ManagerService.Migrations
|
||||
b.Property<bool>("IsGeoTriggered")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsHiddenInitially")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStepLocked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStepTimer")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@ -1041,7 +1165,7 @@ namespace ManagerService.Migrations
|
||||
HasAdvancedStats = false,
|
||||
HasStats = true,
|
||||
Name = "Standard",
|
||||
StatsHistoryDays = 30,
|
||||
StatsHistoryDays = 395,
|
||||
StorageQuotaBytes = 10737418240L
|
||||
},
|
||||
new
|
||||
@ -1051,8 +1175,18 @@ namespace ManagerService.Migrations
|
||||
HasAdvancedStats = true,
|
||||
HasStats = true,
|
||||
Name = "Premium",
|
||||
StatsHistoryDays = 0,
|
||||
StatsHistoryDays = 395,
|
||||
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()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("PasswordTokenExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordTokenHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@ -1146,6 +1286,66 @@ namespace ManagerService.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasBaseType("ManagerService.Data.Section");
|
||||
|
||||
@ -52,6 +52,8 @@ namespace ManagerService.Security
|
||||
var instance = await _db.Instances.FirstOrDefaultAsync(i => i.PublicApiKey == value);
|
||||
if (instance == null)
|
||||
return AuthenticateResult.Fail("Invalid API Key");
|
||||
if (!instance.IsActive)
|
||||
return AuthenticateResult.Fail("This instance has been deactivated");
|
||||
|
||||
instanceId = instance.Id;
|
||||
appType = "Web";
|
||||
|
||||
@ -3,6 +3,7 @@ using ManagerService.DTOs;
|
||||
using Microsoft.Extensions.AI;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
@ -17,13 +18,15 @@ namespace ManagerService.Services
|
||||
{
|
||||
private readonly IChatClient _chatClient;
|
||||
private readonly MyInfoMateDbContext _context;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
private const int MaxHistoryMessages = 10;
|
||||
|
||||
public AssistantService(IChatClient chatClient, MyInfoMateDbContext context)
|
||||
public AssistantService(IChatClient chatClient, MyInfoMateDbContext context, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_chatClient = chatClient;
|
||||
_context = context;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public async Task<AiTranslateResponse> TranslateAsync(AiTranslateRequest request)
|
||||
@ -151,12 +154,58 @@ namespace ManagerService.Services
|
||||
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)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
ChatOptions options;
|
||||
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)
|
||||
{
|
||||
// Scope configuration : l'IA connaît uniquement les sections de cette configuration
|
||||
@ -175,9 +224,10 @@ namespace ManagerService.Services
|
||||
|
||||
var systemPrompt = isVoiceMode ? $"""
|
||||
Tu es le guide audio de visite de "{config?.Label ?? "cette application"}".
|
||||
{guideIdentity}
|
||||
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.
|
||||
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 :
|
||||
{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.
|
||||
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.
|
||||
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.
|
||||
8. NE propose JAMAIS de navigation — le visiteur ne peut pas toucher l'écran.
|
||||
9. Ne mentionne JAMAIS les identifiants techniques (id, guid).
|
||||
10. Chaque réponse doit tenir en moins de 20 secondes à l'oral.
|
||||
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].
|
||||
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].
|
||||
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 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).
|
||||
""" : $"""
|
||||
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}.
|
||||
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 :
|
||||
{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.
|
||||
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".
|
||||
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 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.
|
||||
@ -295,7 +347,7 @@ namespace ManagerService.Services
|
||||
var resource = _context.Resources.FirstOrDefault(r => r.Id == resourceId);
|
||||
if (resource?.Url != null)
|
||||
{
|
||||
using var client = new System.Net.Http.HttpClient();
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var json = await client.GetStringAsync(resource.Url);
|
||||
var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(json, RemoteAgendaJsonSettings.Lenient);
|
||||
if (remoteEvents != null)
|
||||
@ -401,7 +453,7 @@ namespace ManagerService.Services
|
||||
if (resource?.Url == null) return "URL de l'agenda non trouvée.";
|
||||
|
||||
try {
|
||||
using var client = new System.Net.Http.HttpClient();
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var json = await client.GetStringAsync(resource.Url);
|
||||
var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(json, RemoteAgendaJsonSettings.Lenient);
|
||||
var e = remoteEvents?.FirstOrDefault(ev => ev.name == eventId);
|
||||
@ -534,9 +586,10 @@ namespace ManagerService.Services
|
||||
|
||||
var instanceSystemPrompt = isVoiceMode ? $"""
|
||||
Tu es le guide audio de visite.
|
||||
{guideIdentity}
|
||||
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.
|
||||
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 :
|
||||
{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].
|
||||
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.
|
||||
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].
|
||||
7. Politesses ("merci", "au revoir") → réponse courte et chaleureuse + [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é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.
|
||||
""" : $"""
|
||||
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}.
|
||||
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 :
|
||||
{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.
|
||||
5. Pour orienter vers une visite spécifique (jeu, quiz...) → "navigate_to_configuration".
|
||||
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 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);
|
||||
if (resource?.Url != null)
|
||||
{
|
||||
using var client = new System.Net.Http.HttpClient();
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var json = await client.GetStringAsync(resource.Url);
|
||||
var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(json, RemoteAgendaJsonSettings.Lenient);
|
||||
if (remoteEvents != null)
|
||||
@ -713,7 +768,7 @@ namespace ManagerService.Services
|
||||
if (resource?.Url == null) return "URL de l'agenda non trouvée.";
|
||||
|
||||
try {
|
||||
using var client = new System.Net.Http.HttpClient();
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var json = await client.GetStringAsync(resource.Url);
|
||||
var remoteEvents = Newtonsoft.Json.JsonConvert.DeserializeObject<List<RemoteEventAgendaDTO>>(json, RemoteAgendaJsonSettings.Lenient);
|
||||
var e = remoteEvents?.FirstOrDefault(ev => ev.name == eventId);
|
||||
|
||||
152
ManagerService/Services/GoogleEmbeddingService.cs
Normal file
152
ManagerService/Services/GoogleEmbeddingService.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
33
ManagerService/Services/IEmailService.cs
Normal file
33
ManagerService/Services/IEmailService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
26
ManagerService/Services/IEmbeddingService.cs
Normal file
26
ManagerService/Services/IEmbeddingService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
136
ManagerService/Services/ResendEmailService.cs
Normal file
136
ManagerService/Services/ResendEmailService.cs
Normal 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>")));
|
||||
}
|
||||
}
|
||||
78
ManagerService/Services/StripeService.cs
Normal file
78
ManagerService/Services/StripeService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
ManagerService/Services/TrialLifecycleService.cs
Normal file
98
ManagerService/Services/TrialLifecycleService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
ManagerService/Services/VisitEventPurgeService.cs
Normal file
59
ManagerService/Services/VisitEventPurgeService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -115,6 +115,8 @@ namespace ManagerService
|
||||
var tokenSettings = tokensConfiguration.Get<TokensSettings>();
|
||||
|
||||
services.Configure<TokensSettings>(tokensConfiguration);
|
||||
services.Configure<StripeSettings>(Configuration.GetSection("Stripe"));
|
||||
services.Configure<ResendSettings>(Configuration.GetSection("Resend"));
|
||||
|
||||
foreach (var policy in ManagerService.Service.Security.PoliciesConfiguration)
|
||||
services.AddAuthorization(options =>
|
||||
@ -171,6 +173,8 @@ namespace ManagerService
|
||||
services.AddScoped<DeviceDatabaseService>();
|
||||
services.AddScoped<InstanceDatabaseService>();
|
||||
services.AddScoped<ApiKeyDatabaseService>();
|
||||
services.AddScoped<IEmailService, ResendEmailService>();
|
||||
services.AddScoped<StripeService>();
|
||||
|
||||
// Assistant IA — choisir un provider (package NuGet : Microsoft.Extensions.AI.OpenAI) :
|
||||
// OpenAI : new OpenAIClient(new ApiKeyCredential(apiKey)).AsChatClient("gpt-4o-mini")
|
||||
@ -185,6 +189,7 @@ namespace ManagerService
|
||||
.UseFunctionInvocation()
|
||||
.Build());
|
||||
services.AddScoped<IAssistantService, AssistantService>();
|
||||
services.AddScoped<IEmbeddingService, GoogleEmbeddingService>();
|
||||
|
||||
// Push Notifications
|
||||
var firebaseCredentialsPath = Configuration["Firebase:CredentialsPath"];
|
||||
@ -199,6 +204,8 @@ namespace ManagerService
|
||||
services.AddHttpClient();
|
||||
services.AddScoped<AgendaSyncService>();
|
||||
services.AddScoped<WeatherSyncService>();
|
||||
services.AddScoped<TrialLifecycleService>();
|
||||
services.AddScoped<VisitEventPurgeService>();
|
||||
|
||||
var connectionString = Configuration.GetConnectionString("PostgresConnection");
|
||||
|
||||
@ -212,11 +219,12 @@ namespace ManagerService
|
||||
|
||||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
|
||||
dataSourceBuilder.UseNetTopologySuite();
|
||||
dataSourceBuilder.UseVector();
|
||||
dataSourceBuilder.EnableDynamicJson();
|
||||
var dataSource = dataSourceBuilder.Build();
|
||||
|
||||
services.AddDbContext<MyInfoMateDbContext>(options =>
|
||||
options.UseNpgsql(dataSource, o => o.UseNetTopologySuite())
|
||||
options.UseNpgsql(dataSource, o => o.UseNetTopologySuite().UseVector())
|
||||
.EnableSensitiveDataLogging()
|
||||
.LogTo(Console.WriteLine, LogLevel.Information)
|
||||
);
|
||||
@ -256,7 +264,7 @@ namespace ManagerService
|
||||
.AllowCredentials()
|
||||
#else
|
||||
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()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials()
|
||||
@ -287,6 +295,17 @@ namespace ManagerService
|
||||
s => s.SyncAllAsync(),
|
||||
"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 =>
|
||||
{
|
||||
endpoints.MapControllers();
|
||||
|
||||
@ -23,5 +23,9 @@
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"AppUrls": {
|
||||
"ManagerApp": "http://localhost:9090",
|
||||
"Landing": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +42,20 @@
|
||||
},
|
||||
"Firebase": {
|
||||
"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"
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user