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.
143 lines
5.4 KiB
C#
143 lines
5.4 KiB
C#
using Manager.DTOs;
|
||
using ManagerService.DTOs;
|
||
using NetTopologySuite.Geometries;
|
||
using System.Collections.Generic;
|
||
using System.ComponentModel.DataAnnotations.Schema;
|
||
using System.ComponentModel.DataAnnotations;
|
||
using System.Linq;
|
||
using ManagerService.Helpers;
|
||
|
||
namespace ManagerService.Data.SubSection
|
||
{
|
||
public class GuidedStep // Étape d’un parcours
|
||
{
|
||
[Key]
|
||
public string Id { get; set; }
|
||
|
||
[Required]
|
||
public string GuidedPathId { get; set; }
|
||
|
||
[ForeignKey("GuidedPathId")]
|
||
public GuidedPath GuidedPath { get; set; }
|
||
|
||
public int Order { get; set; }
|
||
|
||
[Required]
|
||
[Column(TypeName = "jsonb")]
|
||
public List<TranslationDTO> Title { get; set; }
|
||
|
||
[Column(TypeName = "jsonb")]
|
||
public List<TranslationDTO> Description { get; set; }
|
||
|
||
public Geometry? Geometry { get; set; } // Polygon ou centre du cercle
|
||
|
||
// Option : si true, cette étape doit être atteinte géographiquement (zone/rayon) pour être validée
|
||
public bool IsGeoTriggered { get; set; } = false;
|
||
|
||
public double? ZoneRadiusMeters { get; set; } // Optionnel, utile si zone cercle ou point
|
||
|
||
public string ImageUrl { get; set; }
|
||
|
||
// Rich content (calqué sur SectionArticle)
|
||
[Column(TypeName = "jsonb")]
|
||
public List<TranslationDTO> AudioIds { get; set; } = new();
|
||
|
||
[Column(TypeName = "jsonb")]
|
||
public List<ContentDTO> Contents { get; set; } = new();
|
||
|
||
// Exemple pour escape game
|
||
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;
|
||
|
||
// Timer en secondes (durée max pour valider cette étape, optionnel)
|
||
public int? TimerSeconds { get; set; }
|
||
|
||
// Option : message ou action à effectuer si timer expire (ex: afficher aide, fin du jeu...)
|
||
[Column(TypeName = "jsonb")]
|
||
public List<TranslationDTO> TimerExpiredMessage { get; set; }
|
||
|
||
public GuidedStepDTO ToDTO()
|
||
{
|
||
return new GuidedStepDTO
|
||
{
|
||
id = Id,
|
||
guidedPathId = GuidedPathId,
|
||
order = Order,
|
||
title = Title,
|
||
description = Description,
|
||
geometry = Geometry?.ToDto(),
|
||
isGeoTriggered = IsGeoTriggered,
|
||
zoneRadiusMeters = ZoneRadiusMeters,
|
||
imageUrl = ImageUrl,
|
||
audioIds = AudioIds,
|
||
contents = Contents,
|
||
isStepTimer = IsStepTimer,
|
||
timerSeconds = TimerSeconds,
|
||
timerExpiredMessage = TimerExpiredMessage,
|
||
quizQuestions = QuizQuestions
|
||
};
|
||
}
|
||
|
||
public GuidedStep FromDTO(GuidedStepDTO dto)
|
||
{
|
||
if (dto == null) return null;
|
||
|
||
GuidedPathId = dto.guidedPathId;
|
||
Title = dto.title ?? new List<TranslationDTO>();
|
||
Description = dto.description ?? new List<TranslationDTO>();
|
||
Geometry = dto.geometry.FromDto();
|
||
IsGeoTriggered = dto.isGeoTriggered;
|
||
ZoneRadiusMeters = dto.zoneRadiusMeters;
|
||
ImageUrl = dto.imageUrl;
|
||
AudioIds = dto.audioIds ?? new List<TranslationDTO>();
|
||
Contents = dto.contents ?? new List<ContentDTO>();
|
||
IsStepTimer = dto.isStepTimer;
|
||
TimerSeconds = dto.timerSeconds;
|
||
TimerExpiredMessage = dto.timerExpiredMessage ?? new List<TranslationDTO>();
|
||
Order = dto.order.GetValueOrDefault();
|
||
SyncQuizQuestions(dto.quizQuestions);
|
||
return this;
|
||
}
|
||
|
||
// Merges incoming quiz questions into the tracked QuizQuestions collection by Id,
|
||
// instead of replacing the reference outright — a blind reassignment would make EF
|
||
// treat every incoming question (including already-persisted ones) as a brand new
|
||
// row to insert, which fails with a duplicate key error as soon as a step already
|
||
// has a saved question and a new one is added alongside it.
|
||
public void SyncQuizQuestions(List<QuizQuestion>? dtoQuestions)
|
||
{
|
||
dtoQuestions ??= new List<QuizQuestion>();
|
||
QuizQuestions ??= new List<QuizQuestion>();
|
||
|
||
var dtoIds = dtoQuestions.Where(q => q.Id != 0).Select(q => q.Id).ToHashSet();
|
||
QuizQuestions.RemoveAll(q => !dtoIds.Contains(q.Id));
|
||
|
||
foreach (var qDto in dtoQuestions)
|
||
{
|
||
var existing = qDto.Id != 0 ? QuizQuestions.FirstOrDefault(q => q.Id == qDto.Id) : null;
|
||
if (existing != null)
|
||
{
|
||
existing.Label = qDto.Label;
|
||
existing.ResourceId = qDto.ResourceId;
|
||
existing.Order = qDto.Order;
|
||
existing.Responses = qDto.Responses;
|
||
existing.ValidationQuestionType = qDto.ValidationQuestionType;
|
||
existing.PuzzleImageId = qDto.PuzzleImageId;
|
||
existing.PuzzleRows = qDto.PuzzleRows;
|
||
existing.PuzzleCols = qDto.PuzzleCols;
|
||
existing.IsSlidingPuzzle = qDto.IsSlidingPuzzle;
|
||
}
|
||
else
|
||
{
|
||
qDto.Id = 0;
|
||
QuizQuestions.Add(qDto);
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
}
|