Skip to Content
Schéma de la Base de Données (Database Schema)

Schéma de la Base de Données (Database Schema)

Ce document décrit en détail la structure de la base de données relationnelle PostgreSQL utilisée par le backend NestJS de la marketplace, gérée à l’aide de Prisma ORM (schema.prisma).


1. Diagramme de Relation d’Entités (ERD)

Le diagramme suivant illustre les relations physiques et logiques entre les différentes tables de la base de données.


2. Description Détaillée des Tables

👤 User (Utilisateur)

La table User stocke les informations de tous les acteurs enregistrés sur la plateforme, qu’ils soient acheteurs, vendeurs ou administrateurs.

ChampTypeContraintes & Valeurs par défautDescription
idString@id, UUIDIdentifiant unique de l’utilisateur.
firstNameString-Prénom de l’utilisateur.
lastNameString-Nom de famille.
usernameString@uniqueNom d’utilisateur unique utilisé pour l’authentification et les profils.
emailString@uniqueAdresse e-mail unique (sert d’identifiant principal).
passwordString-Hash du mot de passe de l’utilisateur (bcrypt).
phoneString-Numéro de téléphone pour les contacts/livraisons.
roleUserRoleEnum (BUYER, SELLER, ADMIN)Rôle déterminant les droits d’accès.
isActiveBoolean@default(true)Statut du compte (permet la désactivation/suspension logique, tous rôles confondus).
countryIdString?Clé Étrangère optionnellePays de l’utilisateur (Country.id).
vendorStatusVendorStatus?Enum (PENDING, APPROVED, REJECTED), optionnelStatut de validation vendeur — pertinent uniquement pour role = SELLER, reste null pour BUYER/ADMIN.
vendorBusinessNameString?OptionnelNom de l’exploitation/activité déclaré à l’inscription vendeur.
vendorActivityTypeString?OptionnelType de production (maraîchage, élevage…) déclaré à l’inscription.
vendorDescriptionString?OptionnelCourte description libre de l’activité, facultative.
vendorRejectionReasonString?OptionnelMotif de refus renseigné par l’admin si vendorStatus = REJECTED.
createdAtDateTime@default(now())Date et heure de création du compte.
updatedAtDateTime@updatedAtDate et heure de la dernière mise à jour.

[!NOTE] Recommandation d’analyste : Le champ isActive permet un soft-ban ou une désactivation temporaire de compte par un administrateur. Cela évite de supprimer physiquement l’utilisateur de la base de données, ce qui romprait l’intégrité référentielle des commandes historiques passées par ce dernier.

[!IMPORTANT] isActive ≠ vendorStatus : ce sont deux statuts indépendants. isActive est un blocage de compte (fraude, litige…) qui s’applique à n’importe quel rôle. vendorStatus est spécifique aux vendeurs auto-inscrits et ne concerne que l’étape de validation avant la première publication de produit. Un vendeur APPROVED peut très bien être isActive: false plus tard si son compte est bloqué pour une autre raison. Voir Validation des comptes vendeur pour le détail du flux.


🌍 Country (Pays)

Table de référence générique introduite pour internationaliser la plateforme : devise, TVA et frais de port par pays. Désactivable (isActive) plutôt que supprimable, vu le nombre de tables qui la référencent.

ChampTypeContraintes & Valeurs par défautDescription
idString@id, UUIDIdentifiant unique du pays.
nameString@uniqueNom du pays (ex: “Cameroun”).
isoCodeString@uniqueCode ISO (ex: CM).
currencyCodeString-Code devise (ex: XAF).
vatRateFloat@default(0)Taux de TVA applicable aux commandes livrées dans ce pays.
shippingFeeDecimalDecimal(10,2), @default(0)Frais de port forfaitaire pour ce pays.
isActiveBoolean@default(true)Permet de désactiver un pays sans casser les données historiques qui le référencent.

Référencé par User.countryId, Address.countryId, Product.countryId (dénormalisé) et PaymentMethod.countryId (optionnel). Voir Gestion des Pays pour le détail du module.


📍 Address (Adresse)

La table Address gère les coordonnées géographiques de livraison associées aux comptes utilisateurs.

ChampTypeContraintes & Valeurs par défautDescription
idString@id, UUIDIdentifiant unique de l’adresse.
userIdStringClé ÉtrangèreRéférence à l’utilisateur propriétaire (User.id).
addressLine1String-Ligne d’adresse principale (Rue, numéro, etc.).
addressLine2StringOptionnel (?)Complément d’adresse (Appartement, étage, etc.).
cityString-Ville.
provinceStringOptionnel (?)État, région ou province.
countryString-Pays en texte libre — champ historique, conservé pour compatibilité.
countryIdString?Clé Étrangère optionnelleRelation vers Country — source de vérité pour tout calcul (TVA, frais de port, filtrage). Peut rester null pour les adresses créées avant l’introduction du modèle Country.
postalCodeString?Optionnel (?)Code postal.
latitude / longitudeFloat?OptionnelsGéocodées automatiquement (best-effort, jamais bloquant) à partir de ville/province/pays. Restent null si l’appel échoue ou si l’adresse est trop imprécise.
isDefaultBoolean@default(false)Indique si c’est l’adresse de livraison par défaut.
createdAt / updatedAtDateTime-Métadonnées d’audit.

[!IMPORTANT] country (texte) vs countryId (relation) : les deux ne doivent jamais diverger — le texte libre historique reste affiché tel quel, mais tout calcul métier (TVA, frais de port, filtrage catalogue) passe exclusivement par countryId. Voir Gestion des Adresses pour le détail du géocodage.


🗂️ ProductCategory (Catégorie de Produits)

Permet de regrouper les produits dans des catégories thématiques pour simplifier la recherche et la navigation.

ChampTypeContraintes & Valeurs par défautDescription
idString@id, UUIDIdentifiant unique de la catégorie.
nameString@uniqueNom de la catégorie (ex: “Électronique”).
slugString@uniqueIdentifiant textuel pour les URLs propres (ex: electronique).
descriptionStringOptionnel (?)Description de la catégorie.

📦 Product (Produit)

La table Product représente le catalogue d’articles proposés par les vendeurs sur la marketplace.

ChampTypeContraintes & Valeurs par défautDescription
idString@id, UUIDIdentifiant unique du produit.
nameString-Nom commercial de l’article.
slugString@uniqueURL-friendly slug unique pour le SEO.
descriptionStringOptionnel (?)Description détaillée de l’article.
priceDecimalDecimal(10,2)Prix unitaire de vente de l’article.
stockInt-Quantité actuellement disponible en stock.
skuString@unique, Optionnel (?)Code d’identification unique interne (Stock Keeping Unit).
sellerIdStringClé Étrangère, @indexRéférence au vendeur (User.id).
categoryIdStringClé Étrangère, @indexRéférence à sa catégorie (ProductCategory.id).
countryIdString?Clé Étrangère optionnelleDénormalisé depuis le pays du vendeur à la création, pour filtrer le catalogue sans jointure. null = visible dans tous les pays (produits/vendeurs créés avant l’introduction du modèle Country).
isActiveBoolean@default(true)Permet d’activer/désactiver la visibilité du produit.
avgRatingFloat@default(0)Note moyenne calculée à partir des Review associées.
reviewCountInt@default(0)Nombre d’avis reçus.
sponsorshipStatusSponsorshipStatusEnum (NONE, PENDING, ACTIVE), @default(NONE)Statut de mise en avant payante du produit.
sponsorshipPriceDecimal?Decimal(10,2)Tarif verrouillé au moment de la demande de sponsorisation.
sponsorshipPricingModeSponsorshipPricingMode?Enum (FIXED, PERCENTAGE)Mode tarifaire choisi par le vendeur parmi les options actives au moment de la demande.
sponsorshipDurationDaysInt?-Durée verrouillée au moment de la demande.
sponsorshipExpiresAtDateTime?-Calculée à l’approbation admin (now() + sponsorshipDurationDays).

[!IMPORTANT] Stockage des Prix (Précision Décimale) : Le champ price utilise le type SQL db.Decimal(10, 2) (10 chiffres significatifs dont 2 après la virgule). L’usage de types à virgule flottante (comme Float ou Double) est strictement proscrit pour les données financières afin d’éviter les erreurs d’arrondi binaire cumulatives lors des calculs de totaux de paniers.

[!NOTE] Les champs sponsorship* sont remis à null/NONE au refus ou à la désactivation. Voir Sponsorisation des produits pour le détail complet du workflow.


🖼️ ProductImage (Image de Produit)

Stocke les liens vers les fichiers d’images de chaque produit, permettant d’avoir un carrousel d’illustrations.

ChampTypeContraintes & Valeurs par défautDescription
idString@id, UUIDIdentifiant unique de l’image.
productIdStringClé Étrangère, @indexRéférence au produit associé (Product.id).
urlString-URL de l’image hébergée.
positionInt-Ordre d’affichage de l’image (0, 1, 2…).
isPrimaryBoolean@default(false)Indique si c’est la photo principale de couverture.

🏅 Certification (Certification produit)

Labels/certifications que peut porter un produit (ex: bio, label qualité). Relation many-to-many implicite avec Product.

ChampTypeContraintes & Valeurs par défautDescription
idString@id, UUIDIdentifiant unique.
codeString@uniqueCode court (ex: BIO).
labelString-Libellé affiché.
iconString?OptionnelIcône associée.
isActiveBoolean@default(true)Permet de désactiver une certification sans la supprimer (préserve l’historique des produits qui la portent déjà).

⭐ Review (Avis)

Avis client laissé sur un produit après un achat effectif.

ChampTypeContraintes & Valeurs par défautDescription
idString@id, UUIDIdentifiant unique.
productIdStringClé Étrangère, @indexProduit noté.
buyerIdStringClé Étrangère, @indexAuteur de l’avis.
orderIdStringClé ÉtrangèreCommande justifiant le droit de laisser un avis (achat vérifié).
ratingInt-Note.
commentString?OptionnelCommentaire libre.

[!NOTE] Contrainte @@unique([productId, buyerId]) : un acheteur ne peut laisser qu’un seul avis par produit.


🛒 Order (Commande)

Cette table centralise la transaction d’achat initiée par un acheteur (BUYER).

ChampTypeContraintes & Valeurs par défautDescription
idString@id, UUIDIdentifiant de commande unique.
buyerIdStringClé Étrangère, @indexRéférence à l’acheteur (User.id).
shippingAddressIdStringClé ÉtrangèreRéférence à l’adresse de livraison sélectionnée (Address.id).
statusOrderStatusEnum (PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED)Statut du cycle de vie de la commande.
currencyCodeString@default("XAF")Devise figée au moment de la commande, dérivée du pays de l’adresse de livraison à cet instant.
subtotalDecimalDecimal(10,2), @default(0)Somme des lignes (quantité × prix unitaire), avant frais de port et TVA.
shippingFeeDecimalDecimal(10,2), @default(0)Frais de port figés au moment de la commande.
vatAmountDecimalDecimal(10,2), @default(0)Montant de TVA figé au moment de la commande.
totalAmountDecimalDecimal(10,2)Montant final = subtotal + shippingFee + vatAmount. Nom conservé tel quel : c’est ce que lit le module de paiement (CinetPay).

[!IMPORTANT] Pourquoi figer devise/TVA/frais de port sur la commande ? Un changement ultérieur des réglages d’un Country (nouveau taux de TVA, nouveaux frais de port) ne doit jamais modifier rétroactivement une commande déjà passée. Ces valeurs sont calculées une fois à la création de la commande à partir du Country lié à l’adresse de livraison, puis copiées sur Order de façon immuable — voir Gestion des Commandes.


📋 OrderItem (Ligne de Commande)

La table d’association OrderItem représente chaque article inclus dans une commande, avec sa quantité et le prix facturé au moment de l’achat.

ChampTypeContraintes & Valeurs par défautDescription
idString@id, UUIDIdentifiant unique de la ligne.
orderIdStringClé Étrangère, @indexRéférence à la commande parente (Order.id).
productIdStringClé Étrangère, @indexRéférence au produit acheté (Product.id).
quantityInt-Quantité achetée.
unitPriceDecimalDecimal(10,2)Prix unitaire facturé lors de la transaction.

💳 Payment (Paiement)

La table Payment conserve la trace de la transaction financière liée à une commande.

ChampTypeContraintes & Valeurs par défautDescription
idString@id, UUIDIdentifiant unique de la transaction de paiement.
orderIdStringClé Étrangère, @uniqueRéférence à la commande unique payée (Order.id).
methodIdStringClé ÉtrangèreRéférence au moyen de paiement (PaymentMethod.id).
transactionIdString@uniqueID de transaction unique généré ou fourni par la passerelle.
gatewayReferenceString?OptionnelRéférence de la transaction chez la passerelle de paiement (CinetPay…), renseignée après l’initialisation du paiement en ligne.
gatewayRawPayloadJson?OptionnelDernière réponse brute de vérification de la passerelle (debug/audit). Ne fait jamais foi seule pour le statut : seul un appel de vérification serveur-à-serveur fait autorité.
statusPaymentStatusEnum (PENDING, PAID, FAILED, REFUNDED)Statut de la transaction financière.

[!NOTE] Voir Gestion des Paiements pour le détail de l’intégration passerelle (CinetPay) qui alimente gatewayReference/gatewayRawPayload.


⚙️ PaymentMethod (Méthode de Paiement)

La table PaymentMethod définit dynamiquement les options de paiement disponibles sur la marketplace (Mobile Money, cartes, cash, etc.).

ChampTypeContraintes & Valeurs par défautDescription
idString@id, UUIDIdentifiant unique du moyen de paiement.
nameString-Libellé d’affichage (ex: “MTN Mobile Money”).
slugString@uniqueIdentifiant textuel unique pour l’API et l’URL (ex: "mtn-momo").
typePaymentMethodTypeEnum (ONLINE, OFFLINE)Catégorie de flux de paiement (en ligne vs. manuel).
logoUrlStringOptionnel (?)URL de l’icône/logo du moyen de paiement.
isActiveBoolean@default(true)Statut d’activation pour affichage dans le panier.
countryIdString?Clé Étrangère optionnellenull = disponible dans tous les pays (ex: PayPal). Renseigné = réservé aux acheteurs de ce pays (ex: Orange Money Cameroun).

🛠️ SystemSettings (Réglages globaux)

Ligne unique (id fixe "singleton") de réglages globaux activables par un administrateur — modèle ouvert à d’autres interrupteurs de modules futurs.

ChampTypeDescription
sponsorshipEnabledBooleanInterrupteur global du module de sponsorisation.
sponsorshipFixedEnabled / sponsorshipFixedPrice / sponsorshipFixedDurationDaysBoolean / Decimal / IntVolet tarif fixe (prix + durée propres).
sponsorshipPercentageEnabled / sponsorshipPercentage / sponsorshipPercentageDurationDaysBoolean / Float / IntVolet tarif au pourcentage (+ durée propre).

Détail complet dans Sponsorisation des produits.


📜 AuditLog (Journal d’audit)

Journal des actions critiques (admin/vendeur) pour la page admin “Journal d’activité”.

ChampTypeDescription
idStringIdentifiant unique.
actorIdString?Id de l’acteur (peut être absent pour un acteur système).
actorEmail / actorRoleString / UserRoleDénormalisés au moment de l’action.
actionStringLibellé lisible de l’action (ex: “Validation compte vendeur”).
severityAuditSeverityEnum (INFO, WARNING, CRITICAL).
targetType / targetId / targetLabelString?Cible de l’action, également dénormalisée.
metadataJson?Détails additionnels (ex: { from, to } pour un changement de statut).

[!IMPORTANT] Pas de clé étrangère vers User : l’acteur et la cible sont volontairement dénormalisés (nom/rôle capturés au moment de l’action) pour que l’historique reste lisible même si le compte concerné est supprimé ensuite. Détail complet dans Journal d’audit.


🤝 EnterpriseLead (Demande Grands Comptes)

Demande de contact “Grands Comptes” (gros acheteurs B2B) soumise publiquement depuis le store.

ChampTypeDescription
companyName / contactName / email / phoneStringCoordonnées du prospect.
estimatedVolume / messageString?Champs libres optionnels.
statusLeadStatusEnum (PENDING, CONTACTED, CLOSED).

Pas d’envoi d’email (aucune capacité SMTP dans ce repo) — traitement manuel par un admin, notifié en temps réel via SSE. Détail dans Grands Comptes.


3. Analyse des Relations Clés & Bonnes Pratiques

💵 Pourquoi OrderItem stocke-t-il un unitPrice indépendant de Product.price ?

Dans une architecture e-commerce, il s’agit d’une règle absolue de modélisation de données pour deux raisons critiques :

  1. Conservation de l’historique financier et légal : Le prix d’un produit dans la table Product est dynamique. Un vendeur peut augmenter son prix en raison de l’inflation ou le baisser lors d’une période de soldes. Si l’application calculait le montant des commandes passées en effectuant une jointure directe sur le prix actuel de la table Product, toutes les commandes passées verraient leur montant total fluctuer rétroactivement. Cela fausserait la comptabilité de la plateforme, les factures clients générées et les calculs de taxes fiscaux.
  2. Gestion des promotions et réductions à l’achat : Le champ unitPrice de OrderItem capture la valeur exacte payée par l’acheteur à la seconde où il a cliqué sur “Payer”. Il fige cette donnée de manière immuable.

Le même principe s’applique à Order.currencyCode/subtotal/shippingFee/vatAmount : figés à la création à partir du Country de l’adresse de livraison, pour ne jamais fluctuer si les réglages du pays changent ensuite.


🚚 Comment une commande est-elle liée à une adresse ?

Une commande (Order) fait référence à une adresse de livraison via le champ shippingAddressId lié à la table Address.

⚠️ Point d’attention sur la suppression/modification (Intégrité Historique)

Puisque shippingAddressId est une clé étrangère classique vers la table Address :

  • Si un utilisateur modifie une adresse existante dans son profil (par exemple pour corriger une faute de frappe), toutes les commandes passées liées à cette adresse afficheront la nouvelle adresse modifiée.
  • Si l’utilisateur supprime l’adresse de son profil, la base de données risque de bloquer la suppression en raison de la contrainte d’intégrité (Foreign Key Constraint), ou d’échouer.

💡 Solutions de contournement recommandées au niveau applicatif :

  • La Dénormalisation lors du Checkout (Recommandée) : Lors de la finalisation de la commande, copier les champs textuels de l’adresse (rue, ville, pays, code postal) directement dans des colonnes dédiées de la table Order (ex: shippingStreet, shippingCity…). La table Order ne dépend ainsi plus de la table Address pour l’historique.
  • Le Versioning / Soft Delete : Ne jamais supprimer physiquement une adresse. Si l’utilisateur la supprime, l’application applique un flag de suppression logique (isDeleted = true) pour la masquer de son profil tout en la conservant en base pour ne pas rompre les liaisons avec les commandes passées.

4. Rôles Utilisateurs (UserRole) et Logique Applicative

L’enum UserRole définit trois rôles distincts qui orchestrent l’accès et les fonctionnalités au sein du monorepo :

┌────────────── UserRole Enum ──────────────┐ │ │ ▼ ▼ [ BUYER ] [ SELLER ] (Espace Client) (Espace Vendeur) - Parcourir les produits - Créer/Gérer ses produits - Passer des commandes - Gérer ses stocks - Payer - Suivre ses ventes │ │ └───────────────────┬───────────────────────┘ │ ▼ [ ADMIN ] (Console Globale) - Modérer les produits - Gérer les catégories - Suspendre des comptes - Valider les comptes vendeur

🛍️ BUYER (Acheteur)

  • Champ d’action : Limité au site vitrine public (apps/store).
  • Logique Métier :
    • Il peut lire les tables Product (uniquement si isActive: true), ProductCategory et ProductImage.
    • Il peut créer, modifier et supprimer ses propres adresses (Address) dans la limite où userId == currentUser.id.
    • Il est le seul rôle à pouvoir initier une commande (Order), effectuer un paiement (Payment), et laisser un avis (Review) sur un produit acheté.
  • Sécurité : Il ne possède aucun accès aux APIs de création ou mise à jour de produits, ni au portail d’administration.

🏬 SELLER (Vendeur)

  • Champ d’action : Accès à la boutique publique (apps/store) pour ses achats personnels ET accès à la console vendeur (apps/dashboard).
  • Logique Métier :
    • Il a le droit d’ajouter des produits dans la table Product (son ID utilisateur sera injecté dans le champ sellerId) — à condition que son vendorStatus soit APPROVED s’il s’est auto-inscrit (voir Validation des comptes vendeur). Un vendeur créé directement par un admin est APPROVED d’office.
    • Il peut mettre à jour les stocks et le prix de ses propres produits.
    • Il ne peut voir que les lignes de commandes (OrderItem) qui correspondent à des produits dont il est le vendeur (Product.sellerId == currentUser.id).
  • Sécurité : Une validation applicative stricte (Guard + logique de service Prisma) doit garantir qu’un vendeur ne peut pas modifier un produit appartenant à un autre vendeur (vérification systématique de la correspondance du sellerId avec l’utilisateur connecté).

👑 ADMIN (Administrateur)

  • Champ d’action : Console d’administration globale de la plateforme (apps/dashboard).
  • Logique Métier :
    • Gestion des structures de données : Il est le seul habilité à créer, modifier ou supprimer des enregistrements dans ProductCategory, Country, Certification (garantissant la cohérence taxonomique de la plateforme).
    • Modération : Il peut désactiver n’importe quel produit (Product.isActive = false) ou n’importe quel utilisateur (User.isActive = false) en cas de litige ou non-respect des règles.
    • Validation vendeur : Il approuve ou refuse les comptes SELLER auto-inscrits (User.vendorStatus).
    • Supervision financière : Il dispose d’un accès global en lecture sur toutes les tables Order et Payment pour le support client et l’audit comptable, ainsi qu’un accès aux rapports agrégés (voir Rapports) et au journal d’audit (voir Journal d’audit).

🔒 Enforcement Logique (Backend NestJS)

Dans apps/api, ces rôles sont appliqués de manière déclarative sur les points d’entrée de l’API à l’aide de décorateurs personnalisés et d’un Guard NestJS :

// Exemple de restriction d'un endpoint aux vendeurs et administrateurs @Post('products') @Roles(UserRole.SELLER, UserRole.ADMIN) @UseGuards(JwtAuthGuard, RolesGuard) async createProduct(@Body() dto: CreateProductDto, @Request() req) { return this.productsService.create(dto, req.user); }

[!NOTE] Le rôle (RolesGuard) et le statut de validation vendeur (vendorStatus) sont deux mécanismes distincts et cumulatifs : un SELLER passe d’abord le RolesGuard, puis ProductsService.create() vérifie en plus son vendorStatus avant d’autoriser la création d’un produit.

Last updated on