Thomas Fransolet bd484db48a Lot J : agrégats de thèmes, job de regroupement, interrupteur de collecte
Table QuestionThemeMonthly (instance, mois, thème, compteur). C'est elle qui rend
tenable le §8.4 des CGU : le regroupement vivait dans ThemeId, colonne de la ligne
VisitorQuestion, donc la purge du 90e jour l'emportait avec la question et le
client perdait tout au 91e. Elle ne porte que des compteurs — aucune donnée
personnelle, ce qui est précisément ce qui l'autorise à survivre. Insights lit
désormais les thèmes dans cette table, pas dans les questions de la fenêtre.

Liste fixe de 8 thèmes, pas de thèmes découverts par l'IA : des libellés
régénérés à chaque passage donneraient « Horaires » en janvier et « Questions
d'horaires » en février, deux lignes distinctes et une courbe qui ne veut rien
dire — alors que la table existe pour porter cet historique.

Le job tourne à 2 h, la purge à 3 h 30 : une question purgée avant d'avoir été
classée ne compte dans aucun agrégat et rien ne peut la rattraper. Le plafond de
500 par passage ne perd rien, il retarde — les plus anciennes d'abord, un passage
par jour, avertissement si le retard dépasse un passage. Les jetons ne sont pas
décomptés du quota client : il n'a pas demandé ces appels. Un lot en échec n'est
pas marqué « Autre » pour s'en débarrasser, ce serait une perte définitive
maquillée en résultat ; et la relecture se fait par numéro, jamais par position,
pour qu'une ligne manquante ne décale pas les suivantes.

Instance.IsVisitorQuestionCollectionEnabled (défaut true) + garde dans Chat : le
client est responsable de traitement, la collecte était inconditionnelle.

Ajout d'une fabrique design-time : EF construisait tout l'hôte pour trouver le
contexte, et l'hôte ouvre une connexion au démarrage — générer une migration
exigeait donc une base joignable, impossible sur une machine sans Postgres ni
Docker.

dotnet test : 211 passés, 15 sautés, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 15:58:35 +02:00

221 lines
8.7 KiB
C#

using ManagerService.DTOs;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ManagerService.Data
{
/// <summary>
/// Instance Information
/// </summary>
public class Instance : IAuditableEntity
{
[Key]
[Required]
public string Id { get; set; }
[Required]
public string Name { get; set; } // UNIQUE !..
public DateTime DateCreation { get; set; }
public DateTime DateUpdate { get; set; }
public string PinCode { get; set; }
public bool IsPushNotification { get; set; }
public bool IsMobile { get; set; }
public bool IsTablet { get; set; }
public bool IsWeb { get; set; }
public bool IsVR { get; set; }
public bool IsAssistant { get; set; }
/// <summary>
/// Appose le filigrane du lieu sur les images téléversées. Remplace le
/// `instanceId == "633ee379…"` en dur de ResourceController.Create.
/// </summary>
public bool IsImageWatermark { 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>
/// Le client autorise-t-il l'enregistrement des questions posées par ses visiteurs ?
///
/// ⚠️ **C'est lui le responsable de traitement**, pas nous : il doit pouvoir refuser
/// la collecte. Elle était inconditionnelle dès que l'assistant était actif.
///
/// Défaut à <c>true</c> : couvert par les CGU §8, et le désactiver par défaut viderait
/// l'onglet « Ce que demandent vos visiteurs » et le rapport de trous de contenu chez
/// tout le monde — une fonction qu'il paie, perdue sans l'avoir demandé.
///
/// ⚠️ Couper la collecte n'efface rien : les questions déjà enregistrées vivent
/// jusqu'à leur purge à 90 jours, et les agrégats de thèmes leur survivent.
/// </summary>
public bool IsVisitorQuestionCollectionEnabled { get; set; } = true;
/// <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; }
public string? SubscriptionPlanId { get; set; }
[ForeignKey("SubscriptionPlanId")]
public SubscriptionPlan? SubscriptionPlan { get; set; }
public long AiTokensThisMonth { get; set; } = 0;
public string AiUsageMonthKey { get; set; } = "";
public long StorageQuotaBytes { get; set; } = 0;
public long AiTokensPerMonth { get; set; } = 0;
public bool HasStats { get; set; } = false;
public int StatsHistoryDays { get; set; } = 30;
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)
{
return new InstanceDTO()
{
id = Id,
name = Name,
dateCreation = DateCreation,
pinCode = PinCode,
isPushNotification = IsPushNotification,
isMobile = IsMobile,
isTablet = IsTablet,
isWeb = IsWeb,
isVR = IsVR,
isAssistant = IsAssistant,
isImageWatermark = IsImageWatermark,
guideName = GuideName,
guidePersonaPrompt = GuidePersonaPrompt,
guideVoiceId = GuideVoiceId,
isVisitorQuestionCollectionEnabled = IsVisitorQuestionCollectionEnabled,
guideFallbackMessages = GuideFallbackMessages,
webSlug = WebSlug,
publicApiKey = PublicApiKey,
subscriptionPlanId = SubscriptionPlanId,
subscriptionPlan = SubscriptionPlan?.ToDTO(),
aiTokensThisMonth = AiTokensThisMonth,
aiUsageMonthKey = AiUsageMonthKey,
storageQuotaBytes = StorageQuotaBytes,
aiTokensPerMonth = AiTokensPerMonth,
hasStats = HasStats,
statsHistoryDays = StatsHistoryDays,
hasAdvancedStats = HasAdvancedStats,
isTrialActive = IsTrialActive,
trialEndsAt = TrialEndsAt,
trialAiTokensUsed = TrialAiTokensUsed,
billingAddress = BillingAddress,
billingCountry = BillingCountry,
vatNumber = VatNumber,
vatRate = VatRate,
applicationInstanceDTOs = applicationInstanceDTOs
};
}
public Instance FromDTO(InstanceDTO instanceDTO)
{
Name = instanceDTO.name;
DateCreation = instanceDTO.dateCreation != null ? instanceDTO.dateCreation.Value : DateTime.Now.ToUniversalTime();
PinCode = instanceDTO.pinCode;
IsPushNotification = instanceDTO.isPushNotification ?? false;
IsMobile = instanceDTO.isMobile ?? false;
IsTablet = instanceDTO.isTablet ?? false;
IsWeb = instanceDTO.isWeb ?? false;
IsVR = instanceDTO.isVR ?? false;
IsAssistant = instanceDTO.isAssistant ?? false;
IsImageWatermark = instanceDTO.isImageWatermark ?? false;
if (instanceDTO.guideName != null)
GuideName = instanceDTO.guideName;
if (instanceDTO.guidePersonaPrompt != null)
GuidePersonaPrompt = instanceDTO.guidePersonaPrompt;
if (instanceDTO.guideVoiceId != null)
GuideVoiceId = instanceDTO.guideVoiceId;
if (instanceDTO.isVisitorQuestionCollectionEnabled != null)
IsVisitorQuestionCollectionEnabled = instanceDTO.isVisitorQuestionCollectionEnabled.Value;
if (instanceDTO.guideFallbackMessages != null)
GuideFallbackMessages = instanceDTO.guideFallbackMessages;
if (instanceDTO.webSlug != null)
WebSlug = instanceDTO.webSlug;
if (instanceDTO.publicApiKey != null)
PublicApiKey = instanceDTO.publicApiKey;
if (instanceDTO.subscriptionPlanId != null)
SubscriptionPlanId = instanceDTO.subscriptionPlanId;
if (instanceDTO.storageQuotaBytes.HasValue)
StorageQuotaBytes = instanceDTO.storageQuotaBytes.Value;
if (instanceDTO.aiTokensPerMonth.HasValue)
AiTokensPerMonth = instanceDTO.aiTokensPerMonth.Value;
if (instanceDTO.hasStats.HasValue)
HasStats = instanceDTO.hasStats.Value;
if (instanceDTO.statsHistoryDays.HasValue)
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;
}
}
}