Modèle de données
La base PostgreSQL 15 du backend est décrite par un seul fichier, backend/prisma/schema.prisma, et créée par les migrations de backend/prisma/migrations/. Le client est Prisma 7 avec l'adaptateur @prisma/adapter-pg, configuré dans backend/src/config/prisma.config.ts. L'URL de connexion est lue par backend/prisma.config.ts pour les commandes Prisma, et par DATABASE_URL pour l'application.
Le schéma compte 11 modèles et 5 énumérations. Le détail de chaque colonne est dans le dictionnaire de données, les index et contraintes dans Index et contraintes.
Diagramme
erDiagram
User ||--o{ User : "parraine"
User ||--o{ Token : "possède"
User ||--o| IdentityDocument : "envoie"
User |o--o{ IdentityDocument : "examine"
User ||--o{ Ticket : "bénéficie"
LotteryRound ||--o{ Ticket : "contient"
User ||--o{ PointTransaction : "mouvements"
User |o--o{ Partner : "gère"
Partner ||--o{ RewardCatalog : "propose"
User ||--o{ ClaimedReward : "obtient"
RewardCatalog ||--o{ ClaimedReward : "échangée"
User |o--o{ Analytics : "émet"
User {
string id PK "uuid v7"
string walletAddress UK "minuscules"
string walletEncryptedKey "AES-256-GCM, nullable"
string welcomeGrantTxHash "nullable"
string email UK "nullable"
string passwordHash "bcrypt, nullable"
string firstName "prénom, nullable"
string lastName "nom, nullable"
Role role
boolean isTwoFactorEnabled
TwoFactorMethod twoFactorMethod "nullable"
string twoFactorSecret "chiffré, nullable"
string_array twoFactorBackupCodes "empreintes"
boolean isEmailVerified
AgeVerificationStatus ageVerificationStatus "NONE par défaut"
timestamptz ageVerifiedAt "nullable"
string ageVerificationNote "motif du refus, nullable"
int pointsBalance "CHECK >= 0"
string referralCode UK
string referredById FK "nullable"
timestamptz createdAt
timestamptz updatedAt
timestamptz deletedAt "nullable"
}
IdentityDocument {
string id PK
string userId FK, UK
AgeVerificationStatus status
bytes frontEncrypted "AES-256-GCM, nullable"
bytes backEncrypted "AES-256-GCM, nullable"
string mimeType
timestamptz submittedAt
timestamptz reviewedAt "nullable"
string reviewerId FK "nullable"
}
Token {
string id PK
string token UK "empreinte SHA-256"
TokenType type
string userId FK
timestamptz expiresAt
timestamptz createdAt
}
LotteryRound {
string id PK
int contractRoundId UK "roundId du contrat"
Status status
float prizePool "CHECK >= 0"
string winnerWallet "nullable"
string chainlinkRequestId "nullable"
string ticketPriceWei "nullable"
timestamptz endsAt "nullable"
timestamptz drawRequestedAt "nullable"
int winningTicket "nullable"
string prizeWei "nullable"
timestamptz prizeClaimedAt "nullable"
timestamptz startedAt
timestamptz drawnAt "nullable"
}
Ticket {
string id PK
string transactionHash "indexé, non unique"
int blockNumber
int ticketIndex "unique par tour, nullable"
string payerWallet "nullable"
string userId FK
string lotteryId FK
timestamptz purchasedAt
}
ChainCursor {
string id PK "BlockLucky"
int lastBlock
timestamptz updatedAt
}
PointTransaction {
string id PK
string userId FK
int amount "signé"
string actionType
string description "nullable"
timestamptz createdAt
}
Partner {
string id PK
string name
string logoUrl
boolean isActive
string ownerId FK "nullable"
timestamptz createdAt
}
RewardCatalog {
string id PK
string partnerId FK
string title
int costInPoints "CHECK > 0"
int totalStock "CHECK >= 0"
int remainingStock "CHECK >= 0"
boolean isActive
timestamptz createdAt
}
ClaimedReward {
string id PK
string userId FK
string rewardId FK
string uniqueCode UK
boolean isUsed
timestamptz claimedAt
}
Analytics {
string id PK
string userId FK "nullable"
string eventType
string utmSource "nullable"
json metadata "nullable"
timestamptz timestamp
}ChainCursor n'a aucune relation : c'est une table technique de l'indexeur.
Domaines
Les modèles se répartissent en quatre domaines.
| Domaine | Modèles | Source de vérité |
|---|---|---|
| Comptes et sécurité | User, Token, IdentityDocument |
PostgreSQL |
| Loterie | LotteryRound, Ticket, ChainCursor |
Le contrat BlockLucky en mode onchain ; PostgreSQL en mode simulé |
| EtherPoints et récompenses | PointTransaction, Partner, RewardCatalog, ClaimedReward |
PostgreSQL |
| Analytique et audit | Analytics |
PostgreSQL |
En mode onchain, les tables de la loterie sont une projection : elles peuvent être reconstruites en relisant les événements du contrat (voir Reconstruire depuis la chaîne). Les autres tables n'existent qu'en base et doivent être sauvegardées (voir Performance et sauvegardes).
Relations
Toutes les clés primaires sont des chaînes. Sauf ChainCursor, elles sont générées par Prisma avec uuid(7) : des UUID version 7, dont le début encode l'heure de création. Ils restent uniques sans coordination et s'insèrent dans l'ordre dans les index B-tree, contrairement aux UUID v4 aléatoires.
Les comportements à la suppression (ON DELETE) sont ceux des clés étrangères créées par les migrations. Prisma applique RESTRICT par défaut à une relation obligatoire et SET NULL à une relation facultative.
User et User : parrainage
| Propriété | Valeur |
|---|---|
| Champs | User.referredById vers User.id |
| Cardinalité | Un parrain pour zéro ou plusieurs filleuls ; un filleul a au plus un parrain |
| Nom de la relation Prisma | Referrals (referredBy d'un côté, referrals de l'autre) |
| Suppression du parrain | SET NULL |
Le parrain est fixé à l'inscription par un code de parrainage, ou à la création d'un compte destinataire lors d'un cadeau de ticket (l'expéditeur devient le parrain). Le parrainage rapporte 100 EtherPoints au parrain.
User et Token
| Propriété | Valeur |
|---|---|
| Champs | Token.userId vers User.id |
| Cardinalité | Un compte, zéro ou plusieurs jetons |
| Suppression du compte | CASCADE : les jetons sont supprimés avec le compte |
Avec IdentityDocument.userId, ce sont les deux seules relations en cascade : un jeton ou une pièce d'identité n'ont aucun sens sans leur compte.
User et IdentityDocument : vérification d'âge
| Propriété | IdentityDocument.userId |
IdentityDocument.reviewerId |
|---|---|---|
| Cible | User.id, compte qui envoie la pièce |
User.id, agent de la Mairie qui a statué |
| Nom de la relation Prisma | IdentityOwner (user et identityDocument) |
IdentityReviewer (reviewer et reviewedIdentities) |
| Cardinalité | Un compte a zéro ou une demande (index unique) | Un agent a statué sur zéro ou plusieurs demandes |
| Suppression du compte | CASCADE |
SET NULL |
Ajoutée par la migration 20261112090000_password_accounts_identity_verification. Le statut est porté deux fois : IdentityDocument.status décrit la demande, User.ageVerificationStatus décrit le compte et sert au contrôle d'accès, sans jointure. Les deux sont écrits dans la même transaction à l'envoi et à la décision. Les colonnes d'images sont mises à NULL dès la décision ; la ligne garde la date, l'agent et le statut.
User, LotteryRound et Ticket
| Propriété | Ticket.userId |
Ticket.lotteryId |
|---|---|---|
| Cible | User.id |
LotteryRound.id |
| Cardinalité | Un compte, zéro ou plusieurs tickets | Un tour, zéro ou plusieurs tickets |
| Suppression | RESTRICT |
RESTRICT |
Ticket relie un compte à un tour. Le compte est le bénéficiaire du ticket (player de l'événement), pas forcément celui qui a payé : l'adresse du payeur est conservée à part dans payerWallet, sans clé étrangère, car le payeur peut ne pas avoir de compte.
RESTRICT empêche de supprimer un compte ou un tour qui a des tickets. C'est voulu : un ticket est une trace d'un achat réel sur la chaîne. La suppression RGPD n'efface donc pas la ligne User ; elle l'anonymise.
Le tour est rattaché par son identifiant interne id, mais retrouvé depuis la chaîne par contractRoundId, unique.
User et PointTransaction
| Propriété | Valeur |
|---|---|
| Champs | PointTransaction.userId vers User.id |
| Cardinalité | Un compte, zéro ou plusieurs mouvements |
| Suppression | RESTRICT |
Le solde User.pointsBalance est une valeur dénormalisée : il doit toujours être égal à la somme des amount du compte. Le service backend/src/services/ledger.service.ts écrit le mouvement et modifie le solde dans une même transaction. Aucune contrainte de base ne vérifie l'égalité ; seule la positivité du solde est garantie par CHECK.
User et Partner : gestion d'un commerce
| Propriété | Valeur |
|---|---|
| Champs | Partner.ownerId vers User.id |
| Nom de la relation Prisma | PartnerOwner (owner et ownedPartners) |
| Cardinalité | Un compte gère zéro ou plusieurs commerces ; un commerce a zéro ou un gestionnaire |
| Suppression du compte | SET NULL |
Ajoutée par la migration 20261110004931_security_backup_codes_partner_owner pour qu'un commerçant ne puisse agir que sur ses propres commerces.
Partner, RewardCatalog et ClaimedReward
| Relation | Champs | Suppression |
|---|---|---|
| Commerce et offres | RewardCatalog.partnerId vers Partner.id |
RESTRICT |
| Offre et échanges | ClaimedReward.rewardId vers RewardCatalog.id |
RESTRICT |
| Compte et échanges | ClaimedReward.userId vers User.id |
RESTRICT |
Un commerce propose plusieurs offres. Un échange relie un citoyen à une offre et porte un code promo unique. RESTRICT empêche de supprimer une offre déjà échangée : pour retirer une offre ou un commerce, on passe isActive à faux.
User et Analytics
| Propriété | Valeur |
|---|---|
| Champs | Analytics.userId vers User.id |
| Cardinalité | Zéro ou un compte par événement |
| Suppression | SET NULL |
La relation est facultative : un visiteur non connecté peut produire des événements. La même table sert de journal d'audit d'administration, avec un eventType préfixé ADMIN_AUDIT:.
Types et conventions
| Convention | Règle | Raison |
|---|---|---|
| Dates | TIMESTAMPTZ(3) partout, millisecondes |
Une date sans fuseau est ambiguë ; la migration 20261109213843 a converti les colonnes d'origine en les interprétant comme UTC |
| Montants en wei | String (ticketPriceWei, prizeWei) |
Un montant en wei dépasse la capacité d'un entier 64 bits et d'un flottant ; la chaîne décimale garde la valeur exacte |
| Cagnotte affichée | Float (prizePool), en ETH |
Valeur d'affichage historique ; le montant exact est sur la chaîne |
| Adresses Ethereum | String, en minuscules |
Comparaison simple sans tenir compte de la casse de la somme de contrôle EIP-55 |
| Énumérations | Types PostgreSQL natifs (CREATE TYPE … AS ENUM) |
Valeurs contrôlées par la base |
actionType |
String libre en base, contrôlé par le type TypeScript LedgerActionType |
Ajouter un type de mouvement ne demande pas de migration |
Ce qui n'est pas en base
- Le solde en ethers fictifs des portefeuilles : il est lu sur la chaîne à chaque demande.
- Les remboursements (
RefundClaimed), la commission accumulée et la configuration des tours : seulement sur la chaîne. - Les badges POAP : seulement sur la chaîne, lus par
badgeOfettokenURI. - Les préférences, quêtes réalisées et lots de codes promo des partenaires : dans Redis, sans persistance en base (voir Données Redis).