Thomas Fransolet af6b5b76ef 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.
2026-08-09 22:11:48 +02:00

143 lines
5.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 dun 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);
}
}
}
}
}