Skip to content

Créer un modèle Mongoose et son interface Angular

Rémi Bernard edited this page Jun 16, 2025 · 1 revision

Ce guide vous accompagne dans la création de modèles de données robustes et cohérents entre le backend (Mongoose/MongoDB) et le frontend (Angular/TypeScript).

📝 Note importante : Dans ce guide, les commentaires dans le code sont en français pour faciliter l'apprentissage. Dans l'application réelle, tous les commentaires doivent être écrits en anglais.

Comprendre l'Architecture

Pourquoi Deux Systèmes de Modèles ?

Backend (Mongoose) : Gère la persistance et la logique métier

  • Définit la structure exacte en base de données
  • Applique les validations avant sauvegarde
  • Contient la logique métier (hachage de mots de passe, calculs)
  • Optimise les performances avec des index

Frontend (TypeScript) : Gère les types et l'interface utilisateur

  • Fournit la vérification de types à la compilation
  • Structure les données pour l'interface utilisateur
  • Définit les contrats d'API (requêtes/réponses)
  • Facilite le développement avec l'autocomplétion

Flux de Données

[Interface Angular] ←→ [API Express] ←→ [Modèle Mongoose] ←→ [MongoDB]
    TypeScript              JSON            JavaScript         BSON

Fondamentaux des Modèles Mongoose

1. Structure de Base d'un Modèle

// Étape 1 : Définir l'interface TypeScript
export interface IMonModel extends Document {
    _id: mongoose.Types.ObjectId;
    name: string;
    createdAt: Date;
    updatedAt: Date;
    
    // Méthodes que vous pouvez appeler sur un document
    getDisplayName(): string;
}

// Étape 2 : Créer le schéma Mongoose
const monModelSchema = new Schema<IMonModel>({
    name: {
        type: String,
        required: [true, 'Le nom est obligatoire'],
        trim: true
    }
}, {
    timestamps: true  // Ajoute automatiquement createdAt et updatedAt
});

// Étape 3 : Ajouter les méthodes
monModelSchema.methods.getDisplayName = function(): string {
    return this.name.toUpperCase();
};

// Étape 4 : Créer et exporter le modèle
const MonModel = mongoose.model<IMonModel>('MonModel', monModelSchema);
export default MonModel;

Explication :

  • L'interface IMonModel définit le "contrat" TypeScript
  • Le schéma définit la structure réelle en base de données
  • timestamps: true ajoute automatiquement les champs de date
  • Les méthodes sont ajoutées au prototype pour être disponibles sur chaque document

2. Types de Champs et Validations

Champs Texte

name: {
    type: String,
    required: [true, 'Le nom est obligatoire'],
    unique: true,                    // Doit être unique en base
    trim: true,                      // Supprime les espaces en début/fin
    lowercase: true,                 // Convertit en minuscules
    maxlength: [50, 'Nom trop long'], // Limite de caractères
    minlength: [2, 'Nom trop court'], // Minimum de caractères
    match: [/^[a-zA-Z]+$/, 'Seules les lettres sont autorisées']
}

Champs Numériques

age: {
    type: Number,
    required: true,
    min: [0, 'L\\'âge ne peut pas être négatif'],
    max: [120, 'Âge non réaliste'],
    validate: {
        validator: function(value: number) {
            return Number.isInteger(value);  // Validation personnalisée
        },
        message: 'L\\'âge doit être un nombre entier'
    }
}

Champs Date

birthdate: {
    type: Date,
    required: true,
    validate: {
        validator: function(date: Date) {
            return date < new Date();  // Date dans le passé
        },
        message: 'La date de naissance doit être dans le passé'
    }
}

Énumérations (Choix Limités)

status: {
    type: String,
    enum: {
        values: ['active', 'inactive', 'pending'],
        message: 'Le statut doit être active, inactive ou pending'
    },
    default: 'pending'
}

Tableaux

tags: [{
    type: String,
    trim: true
}],

roles: {
    type: [String],
    enum: ['student', 'teacher', 'admin'],
    default: ['student'],
    validate: {
        validator: function(roles: string[]) {
            return roles.length > 0;  // Au moins un rôle
        },
        message: 'Un utilisateur doit avoir au moins un rôle'
    }
}

Référence : Voir /server/src/models/user.ts pour un exemple complet avec tous ces types de validations.

3. Champs Spéciaux et Références

Références à d'Autres Modèles

author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',                    // Référence au modèle User
    required: true
},

// Tableau de références
participants: [{
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User'
}]

Champs Calculés (Virtuels)

// Dans le schéma
userSchema.virtual('fullName').get(function() {
    return `${this.firstName} ${this.lastName}`;
});

// Pour inclure les virtuels dans JSON
userSchema.set('toJSON', { virtuals: true });

Champs avec Valeurs par Défaut Dynamiques

registrationNumber: {
    type: String,
    default: function() {
        return 'REG-' + Date.now();  // Génère un numéro unique
    }
},

createdBy: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    default: function() {
        // Récupère l'utilisateur depuis le contexte si disponible
        return this.context?.userId || null;
    }
}

4. Index pour les Performances

// Index simple pour recherches fréquentes
userSchema.index({ email: 1 });

// Index composé pour requêtes combinées
userSchema.index({ department: 1, status: 1 });

// Index de texte pour recherche full-text
userSchema.index({ 
    firstName: 'text', 
    lastName: 'text', 
    email: 'text' 
});

// Index partiel (seulement sur certains documents)
userSchema.index(
    { email: 1 }, 
    { partialFilterExpression: { isActive: true } }
);

// Index avec TTL (Time To Live) pour expiration automatique
sessionSchema.index(
    { createdAt: 1 }, 
    { expireAfterSeconds: 3600 }  // Expire après 1 heure
);

Quand utiliser quoi :

  • Index simple : Recherche par un seul champ fréquent
  • Index composé : Requêtes avec plusieurs conditions
  • Index texte : Recherche full-text dans le contenu
  • Index partiel : Économise l'espace si seuls certains documents sont concernés
  • TTL : Pour les sessions, tokens temporaires, logs

5. Middlewares (Hooks) - Logique Automatique

Pre-save : Avant Sauvegarde

userSchema.pre('save', async function(next) {
    // 'this' fait référence au document en cours de sauvegarde
    
    // Seulement si le mot de passe a été modifié
    if (!this.isModified('password')) return next();
    
    try {
        // Hacher le mot de passe
        const salt = await bcrypt.genSalt(12);
        this.password = await bcrypt.hash(this.password, salt);
        next();
    } catch (error) {
        next(error);
    }
});

// Validation métier complexe
userSchema.pre('save', function(next) {
    // Un admin ne peut pas être inactif
    if (this.roles.includes('admin') && !this.isActive) {
        return next(new Error('Un administrateur ne peut pas être inactif'));
    }
    next();
});

Post-save : Après Sauvegarde

userSchema.post('save', function(doc) {
    console.log('Nouvel utilisateur créé:', doc.email);
    
    // Envoyer un email de bienvenue (asynchrone)
    EmailService.sendWelcomeEmail(doc.email);
});

userSchema.post('save', function(error, doc, next) {
    // Gestion d'erreur spécifique
    if (error.name === 'MongoError' && error.code === 11000) {
        next(new Error('Cet email existe déjà'));
    } else {
        next(error);
    }
});

Autres Hooks Utiles

// Avant suppression
userSchema.pre('remove', async function(next) {
    // Supprimer les données liées
    await Post.deleteMany({ author: this._id });
    next();
});

// Avant mise à jour
userSchema.pre('findOneAndUpdate', function(next) {
    // Mettre à jour le champ modifiedAt
    this.set({ modifiedAt: new Date() });
    next();
});

6. Méthodes Personnalisées

Méthodes d'Instance (sur un document)

userSchema.methods.comparePassword = async function(candidatePassword: string): Promise<boolean> {
    try {
        return await bcrypt.compare(candidatePassword, this.password);
    } catch (error) {
        throw new Error('Erreur lors de la comparaison des mots de passe');
    }
};

userSchema.methods.hasRole = function(role: string): boolean {
    return this.roles.includes(role);
};

userSchema.methods.canEditPost = function(post: any): boolean {
    return this.isAdmin() || post.author.toString() === this._id.toString();
};

// Utilisation : const user = await User.findById(id); user.hasRole('admin');

Méthodes Statiques (sur le modèle)

userSchema.statics.findByEmail = function(email: string) {
    return this.findOne({ email: email.toLowerCase() }).select('+password');
};

userSchema.statics.findActiveUsers = function() {
    return this.find({ isActive: true }).sort({ createdAt: -1 });
};

userSchema.statics.getUserStats = async function() {
    const stats = await this.aggregate([
        {
            $group: {
                _id: null,
                totalUsers: { $sum: 1 },
                activeUsers: { $sum: { $cond: ['$isActive', 1, 0] } }
            }
        }
    ]);
    return stats[0] || { totalUsers: 0, activeUsers: 0 };
};

// Utilisation : const users = await User.findActiveUsers();

7. Gestion de la Sécurité

Exclusion de Champs Sensibles

password: {
    type: String,
    required: true,
    select: false  // Jamais inclus par défaut dans les requêtes
},

// Dans les options du schéma
{
    toJSON: {
        transform: function(doc, ret) {
            delete ret.password;    // Supprimer du JSON
            delete ret.__v;         // Supprimer le champ version MongoDB
            return ret;
        }
    }
}

Champs de Suivi (Audit)

createdBy: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
},
modifiedBy: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User'
},
deletedAt: {
    type: Date,
    default: null  // Soft delete
},
version: {
    type: Number,
    default: 1     // Versioning manuel
}

Référence : Voir /server/src/models/user.ts pour l'implémentation complète de la sécurité.

Interfaces Angular Correspondantes

1. Structure de Base Frontend

// Interface principale - miroir simplifié du modèle Mongoose
export interface User {
    _id: string;                 // ObjectId devient string en JSON
    email: string;
    firstName: string;
    lastName: string;
    roles: UserRole[];
    isActive: boolean;
    createdAt: Date;
    updatedAt: Date;
    
    // Propriétés calculées (pas en base)
    fullName?: string;           // Calculé côté frontend ou backend
}

// Types dérivés pour contraindre les valeurs
export type UserRole = 'student' | 'teacher' | 'admin';
export type UserStatus = 'active' | 'inactive' | 'pending';

2. Interfaces de Requête (Input)

Création

export interface CreateUserRequest {
    email: string;
    password: string;
    firstName: string;
    lastName: string;
    birthdate: string;           // ISO string pour les dates
    department?: string;         // Optionnel
    roles?: UserRole[];          // Optionnel avec défaut côté backend
}

Mise à Jour

export interface UpdateUserRequest {
    firstName?: string;          // Tous optionnels pour mise à jour partielle
    lastName?: string;
    department?: string;
    avatar?: string;
}

export interface UpdateUserRolesRequest {
    roles: UserRole[];           // Spécialisé pour une opération spécifique
}

export interface ChangePasswordRequest {
    currentPassword: string;
    newPassword: string;
}

3. Interfaces de Réponse (Output)

Réponse Standard

export interface ApiResponse<T> {
    success: boolean;
    message?: string;
    data: T;
}

// Usage
ApiResponse<User>               // Un utilisateur
ApiResponse<User[]>             // Liste simple
ApiResponse<UsersResponse>      // Liste avec pagination

Réponses Paginées

export interface PaginationInfo {
    currentPage: number;
    totalPages: number;
    totalItems: number;         // Nom générique
    limit: number;
    hasNextPage: boolean;
    hasPrevPage: boolean;
}

export interface UsersResponse {
    users: User[];              // Nom spécifique à l'entité
    pagination: PaginationInfo;
}

Réponses de Statistiques

export interface UserStats {
    overview: {
        totalUsers: number;
        activeUsers: number;
        inactiveUsers: number;
        verifiedUsers: number;
    };
    distributions: {
        byRole: Array<{ _id: UserRole; count: number; }>;
        byDepartment: Array<{ _id: string; count: number; }>;
    };
    recent: User[];             // Derniers utilisateurs
    trends?: {                  // Optionnel selon le contexte
        weeklyGrowth: number;
        monthlyGrowth: number;
    };
}

4. Interfaces de Filtrage

Filtres de Base Réutilisables

export interface BaseFilters {
    page?: number;
    limit?: number;
    search?: string;            // Recherche textuelle générale
    sortBy?: string;
    sortOrder?: 'asc' | 'desc';
}

export interface DateRangeFilter {
    startDate?: string;         // ISO string
    endDate?: string;
}

Filtres Spécialisés

export interface UserFilters extends BaseFilters {
    role?: UserRole;
    department?: string;
    isActive?: boolean;
    isEmailVerified?: boolean;
    lastLoginAfter?: string;    // ISO string
    
    // Contraindre les champs de tri possibles
    sortBy?: 'createdAt' | 'updatedAt' | 'email' | 'firstName' | 'lastName' | 'lastLogin';
}

export interface CourseUnitFilters extends BaseFilters {
    minCapacity?: number;
    maxCapacity?: number;
    hasImage?: boolean;
    
    sortBy?: 'createdAt' | 'updatedAt' | 'name' | 'code' | 'capacity';
}

5. Interfaces d'État (State Management)

// État générique réutilisable
export interface EntityState<T, F = any> {
    items: T[];
    selectedItem: T | null;
    isLoading: boolean;
    isCreating: boolean;
    isUpdating: boolean;
    error: string | null;
    filters: F;
    pagination: PaginationInfo | null;
}

// État spécialisé
export interface UserState extends EntityState<User, UserFilters> {
    // Propriétés supplémentaires spécifiques aux utilisateurs
    currentUser: User | null;
    userStats: UserStats | null;
}

6. Types Utilitaires Avancés

// Extraire seulement les champs modifiables
export type UserUpdateFields = Pick<User, 'firstName' | 'lastName' | 'department' | 'avatar'>;

// Tous les champs optionnels pour mise à jour partielle
export type PartialUserUpdate = Partial<UserUpdateFields>;

// Utilisateur sans données sensibles
export type PublicUser = Omit<User, 'email' | 'roles'>;

// Utilisateur avec relations chargées
export interface UserWithRelations extends User {
    courses: CourseUnit[];
    posts: Post[];
    department?: Department;    // Relation peuplée
}

// Types pour les formulaires
export type UserFormData = Omit<User, '_id' | 'createdAt' | 'updatedAt'>;

Référence : Voir /client/src/core/models/user.models.ts pour tous ces patterns en action.

Synchronisation et Cohérence

1. Correspondance des Types

Mongoose TypeScript Transformation Exemple
ObjectId string Automatique via JSON _id: ObjectId_id: string
Date Date new Date() côté client createdAt: ISOStringcreatedAt: Date
Number number Directe age: 25
String string Directe name: \"John\"
Boolean boolean Directe isActive: true
Array T[] Directe roles: string[]
Mixed/Any unknown Typage explicite metadata: anymetadata: UserMetadata

2. Transformation Côté Backend

// Dans le schéma Mongoose
{
    toJSON: {
        transform: function(doc, ret, options) {
            // Sécurité : supprimer les champs sensibles
            delete ret.password;
            delete ret.__v;
            
            // Nettoyage : convertir les ObjectId
            ret.id = ret._id.toString();
            delete ret._id;
            
            // Amélioration : ajouter des champs calculés
            ret.fullName = `${ret.firstName} ${ret.lastName}`;
            
            // Conditionnalité : selon le contexte
            if (!options.includePrivate) {
                delete ret.email;
            }
            
            return ret;
        }
    }
}

3. Transformation Côté Frontend

// Dans un service Angular
export class UserService {
    private transformUser(data: any): User {
        return {
            ...data,
            // Conversion des dates ISO en objets Date
            createdAt: new Date(data.createdAt),
            updatedAt: new Date(data.updatedAt),
            lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
            
            // Calculs côté frontend si nécessaire
            fullName: `${data.firstName} ${data.lastName}`,
            
            // Normalisation des données
            email: data.email?.toLowerCase(),
            
            // Valeurs par défaut
            avatar: data.avatar || '/assets/default-avatar.png'
        };
    }
    
    async getUsers(filters: UserFilters): Promise<UsersResponse> {
        const response = await this.http.get<ApiResponse<UsersResponse>>('/api/users', {
            params: this.buildQueryParams(filters)
        }).toPromise();
        
        // Transformer chaque utilisateur
        const users = response.data.users.map(user => this.transformUser(user));
        
        return {
            ...response.data,
            users
        };
    }
}

4. Validation Cohérente

Patterns Partagés

// /client/src/core/models/_shared.models.ts
export const VALIDATION_PATTERNS = {
    EMAIL: /^\\w+([.-]?\\w+)*@\\w+([.-]?\\w+)*(\\.\\w{2,})+$/,
    PASSWORD: /^.{6,}$/,
    COURSE_CODE: /^[A-Z0-9]+$/,
    PHONE: /^\\+?[\\d\\s\\-\\(\\)]+$/,
    SLUG: /^[a-z0-9-]+$/
} as const;

export const VALIDATION_MESSAGES = {
    EMAIL: 'Format d\\'email invalide',
    PASSWORD: 'Le mot de passe doit contenir au moins 6 caractères',
    REQUIRED: 'Ce champ est obligatoire'
} as const;

Pour créer de nouveaux patterns, vous pouvez vous aider de ce site : Regex101

Backend (Mongoose)

email: {
    type: String,
    required: [true, VALIDATION_MESSAGES.REQUIRED],
    match: [VALIDATION_PATTERNS.EMAIL, VALIDATION_MESSAGES.EMAIL]
}

Frontend (Angular)

// Dans un formulaire
emailControl = new FormControl('', [
    Validators.required,
    Validators.pattern(VALIDATION_PATTERNS.EMAIL)
]);

// Ou dans un validateur personnalisé
export function emailValidator(control: AbstractControl): ValidationErrors | null {
    if (!control.value) return null;
    
    const valid = VALIDATION_PATTERNS.EMAIL.test(control.value);
    return valid ? null : { email: { message: VALIDATION_MESSAGES.EMAIL } };
}

Cas d'Usage Avancés

1. Relations et Population

Modèle avec Relations

// Modèle Post avec relation vers User
const postSchema = new Schema({
    title: String,
    content: String,
    author: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User',
        required: true
    },
    tags: [{
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Tag'
    }]
});

// Méthode pour charger avec relations
postSchema.statics.findWithAuthor = function(filters = {}) {
    return this.find(filters)
        .populate('author', 'firstName lastName email')  // Champs spécifiques
        .populate('tags');
};

Interface Frontend avec Relations

// Version de base (références seulement)
export interface Post {
    _id: string;
    title: string;
    content: string;
    author: string;      // Juste l'ID
    tags: string[];      // Juste les IDs
}

// Version avec relations peuplées
export interface PostWithRelations extends Omit<Post, 'author' | 'tags'> {
    author: PublicUser;  // Objet complet
    tags: Tag[];         // Objets complets
}

// Version partielle pour les listings
export interface PostSummary {
    _id: string;
    title: string;
    author: {
        _id: string;
        firstName: string;
        lastName: string;
    };
    createdAt: Date;
}

2. Soft Delete et Archivage

Backend avec Soft Delete

const userSchema = new Schema({
    // ... autres champs
    deletedAt: {
        type: Date,
        default: null
    },
    isArchived: {
        type: Boolean,
        default: false
    }
});

// Middleware pour exclure les supprimés par défaut
userSchema.pre(/^find/, function(next) {
    if (!this.getOptions().includeDeleted) {
        this.find({ deletedAt: null });
    }
    next();
});

// Méthodes pour soft delete
userSchema.methods.softDelete = function() {
    this.deletedAt = new Date();
    this.isActive = false;
    return this.save();
};

userSchema.methods.restore = function() {
    this.deletedAt = null;
    this.isActive = true;
    return this.save();
};

// Requêtes spécialisées
userSchema.statics.findDeleted = function() {
    return this.find({ deletedAt: { $ne: null } });
};

userSchema.statics.findWithDeleted = function(filter = {}) {
    return this.find(filter, null, { includeDeleted: true });
};

Frontend avec États de Suppression

export interface User {
    _id: string;
    // ... autres champs
    deletedAt?: Date;
    isArchived: boolean;
}

export interface UserFilters extends BaseFilters {
    includeDeleted?: boolean;
    includeArchived?: boolean;
    deletedOnly?: boolean;
}

// Types d'état dérivés
export type UserStatus = 'active' | 'deleted' | 'archived';

// Fonction utilitaire
export function getUserStatus(user: User): UserStatus {
    if (user.deletedAt) return 'deleted';
    if (user.isArchived) return 'archived';
    return 'active';
}

3. Versioning et Historique

Modèle avec Versioning

const documentSchema = new Schema({
    title: String,
    content: String,
    version: {
        type: Number,
        default: 1
    },
    author: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User'
    }
});

// Schéma pour l'historique
const documentHistorySchema = new Schema({
    documentId: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Document',
        required: true
    },
    version: Number,
    title: String,
    content: String,
    changedBy: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User'
    },
    changeReason: String,
    createdAt: {
        type: Date,
        default: Date.now
    }
});

// Middleware pour sauvegarder l'historique
documentSchema.pre('save', async function(next) {
    if (this.isNew) return next();
    
    // Sauvegarder la version précédente
    const DocumentHistory = mongoose.model('DocumentHistory');
    await DocumentHistory.create({
        documentId: this._id,
        version: this.version,
        title: this.title,
        content: this.content,
        changedBy: this.modifiedBy
    });
    
    // Incrémenter la version
    this.version += 1;
    next();
});

Interface avec Historique

export interface Document {
    _id: string;
    title: string;
    content: string;
    version: number;
    author: string;
}

export interface DocumentHistory {
    _id: string;
    documentId: string;
    version: number;
    title: string;
    content: string;
    changedBy: string;
    changeReason?: string;
    createdAt: Date;
}

export interface DocumentWithHistory extends Document {
    history: DocumentHistory[];
}

4. Recherche et Indexation Avancée

Search Index et Agrégation

// Index de recherche textuelle
userSchema.index({
    firstName: 'text',
    lastName: 'text',
    email: 'text',
    department: 'text'
}, {
    weights: {
        firstName: 10,    // Plus important
        lastName: 10,
        email: 5,
        department: 1     // Moins important
    },
    name: 'user_search_index'
});

// Méthode de recherche avancée
userSchema.statics.advancedSearch = function(query: string, filters = {}) {
    const pipeline = [
        // Recherche textuelle
        {
            $match: {
                $text: { $search: query },
                ...filters
            }
        },
        // Score de pertinence
        {
            $addFields: {
                score: { $meta: 'textScore' }
            }
        },
        // Tri par score
        {
            $sort: { score: -1 }
        },
        // Agrégation pour enrichir
        {
            $lookup: {
                from: 'departments',
                localField: 'department',
                foreignField: '_id',
                as: 'departmentInfo'
            }
        }
    ];
    
    return this.aggregate(pipeline);
};

Interface de Recherche

export interface SearchQuery {
    query: string;
    filters?: UserFilters;
    includeHighlights?: boolean;
    maxResults?: number;
}

export interface SearchResult<T> {
    items: T[];
    total: number;
    query: string;
    suggestions?: string[];      // Suggestions de recherche
    facets?: SearchFacet[];      // Facettes pour filtrage
}

export interface SearchFacet {
    field: string;
    label: string;
    values: Array<{
        value: any;
        count: number;
        selected?: boolean;
    }>;
}

export interface UserSearchResult extends User {
    score?: number;              // Score de pertinence
    highlights?: {               // Texte mis en évidence
        firstName?: string;
        lastName?: string;
        email?: string;
    };
}

Sécurité et Validation Avancée

1. Validation Métier Complexe

// Validation cross-field
userSchema.pre('save', function(next) {
    // Un étudiant ne peut pas être dans le département admin
    if (this.roles.includes('student') && this.department === 'administration') {
        return next(new Error('Un étudiant ne peut pas être dans le département administration'));
    }
    
    // Un admin doit avoir un email de domaine spécifique
    if (this.roles.includes('admin') && !this.email.endsWith('@company.com')) {
        return next(new Error('Un administrateur doit avoir un email @company.com'));
    }
    
    next();
});

// Validation basée sur l'état actuel vs nouveau
userSchema.pre('save', async function(next) {
    if (this.isNew) return next();
    
    const original = await this.constructor.findById(this._id);
    
    // Ne peut pas rétrograder son propre rôle d'admin
    if (original.roles.includes('admin') && 
        !this.roles.includes('admin') && 
        this.modifiedBy?.toString() === this._id.toString()) {
        return next(new Error('Vous ne pouvez pas supprimer votre propre rôle admin'));
    }
    
    next();
});

2. Sanitisation et Transformation

// Middleware de sanitisation
userSchema.pre('save', function(next) {
    // Nettoyer et normaliser l'email
    if (this.email) {
        this.email = this.email.toLowerCase().trim();
    }
    
    // Nettoyer les noms (supprimer caractères spéciaux)
    if (this.firstName) {
        this.firstName = this.firstName.replace(/[^a-zA-ZÀ-ÿ\\s]/g, '').trim();
    }
    
    if (this.lastName) {
        this.lastName = this.lastName.replace(/[^a-zA-ZÀ-ÿ\\s]/g, '').trim();
    }
    
    // Normaliser le département
    if (this.department) {
        this.department = this.department.toLowerCase().replace(/\\s+/g, '-');
    }
    
    next();
});

3. Validation Frontend Avancée

// Validateurs personnalisés Angular
export class CustomValidators {
    static emailDomain(allowedDomains: string[]) {
        return (control: AbstractControl): ValidationErrors | null => {
            if (!control.value) return null;
            
            const email = control.value.toLowerCase();
            const domain = email.split('@')[1];
            
            if (!allowedDomains.includes(domain)) {
                return {
                    emailDomain: {
                        requiredDomains: allowedDomains,
                        actualDomain: domain
                    }
                };
            }
            return null;
        };
    }
    
    static uniqueEmail(userService: UserService, currentUserId?: string) {
        return (control: AbstractControl): Observable<ValidationErrors | null> => {
            if (!control.value) return of(null);
            
            return userService.checkEmailExists(control.value, currentUserId).pipe(
                map(exists => exists ? { emailExists: true } : null),
                catchError(() => of(null))
            );
        };
    }
    
    static passwordStrength() {
        return (control: AbstractControl): ValidationErrors | null => {
            if (!control.value) return null;
            
            const password = control.value;
            const errors: any = {};
            
            if (password.length < 8) {
                errors.minLength = true;
            }
            
            if (!/[A-Z]/.test(password)) {
                errors.requiresUppercase = true;
            }
            
            if (!/[a-z]/.test(password)) {
                errors.requiresLowercase = true;
            }
            
            if (!/\\d/.test(password)) {
                errors.requiresNumber = true;
            }
            
            if (!/[!@#$%^&*]/.test(password)) {
                errors.requiresSpecial = true;
            }
            
            return Object.keys(errors).length ? { passwordStrength: errors } : null;
        };
    }
}

Patterns Avancés et Optimisations

1. Pagination Avancée

// Backend - Pagination avec curseur pour performances
userSchema.statics.paginateWithCursor = function(options: {
    cursor?: string;
    limit?: number;
    sortBy?: string;
    sortOrder?: 'asc' | 'desc';
    filters?: any;
}) {
    const { cursor, limit = 10, sortBy = 'createdAt', sortOrder = 'desc', filters = {} } = options;
    
    const query: any = { ...filters };
    
    // Si un curseur est fourni, continuer à partir de là
    if (cursor) {
        const decodedCursor = Buffer.from(cursor, 'base64').toString();
        const [id, sortValue] = decodedCursor.split('|');
        
        if (sortOrder === 'desc') {
            query[sortBy] = { $lt: sortValue };
        } else {
            query[sortBy] = { $gt: sortValue };
        }
    }
    
    return this.find(query)
        .sort({ [sortBy]: sortOrder === 'desc' ? -1 : 1 })
        .limit(limit + 1)  // +1 pour savoir s'il y a une page suivante
        .then(results => {
            const hasNextPage = results.length > limit;
            const items = hasNextPage ? results.slice(0, -1) : results;
            
            let nextCursor = null;
            if (hasNextPage && items.length > 0) {
                const lastItem = items[items.length - 1];
                const cursorData = `${lastItem._id}|${lastItem[sortBy]}`;
                nextCursor = Buffer.from(cursorData).toString('base64');
            }
            
            return {
                items,
                hasNextPage,
                nextCursor
            };
        });
};

2. Mise en Cache et Performance

// Mise en cache des requêtes fréquentes
userSchema.statics.findByEmailCached = function(email: string) {
    const cacheKey = `user:email:${email.toLowerCase()}`;
    
    return Cache.get(cacheKey).then(cached => {
        if (cached) return cached;
        
        return this.findOne({ email: email.toLowerCase() }).then(user => {
            if (user) {
                Cache.set(cacheKey, user, { ttl: 300 }); // 5 minutes
            }
            return user;
        });
    });
};

// Invalidation de cache
userSchema.post('save', function(doc) {
    const cacheKey = `user:email:${doc.email.toLowerCase()}`;
    Cache.del(cacheKey);
});

userSchema.post('remove', function(doc) {
    const cacheKey = `user:email:${doc.email.toLowerCase()}`;
    Cache.del(cacheKey);
});

3. Monitoring et Logging

// Plugin pour logging automatique
function auditPlugin(schema: Schema, options: any) {
    schema.add({
        audit: {
            createdAt: { type: Date, default: Date.now },
            createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
            updatedAt: Date,
            updatedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
            version: { type: Number, default: 1 }
        }
    });
    
    schema.pre('save', function(next) {
        const now = new Date();
        
        if (this.isNew) {
            this.audit.createdAt = now;
            if (options.user) this.audit.createdBy = options.user;
        } else {
            this.audit.updatedAt = now;
            if (options.user) this.audit.updatedBy = options.user;
            this.audit.version += 1;
        }
        
        next();
    });
}

// Application du plugin
userSchema.plugin(auditPlugin);

Référence : Voir /server/src/models/ pour tous ces patterns en production.

Checklist Complète pour un Nouveau Modèle

Phase 1 : Conception

  • Définir les besoins métier et les cas d'usage
  • Identifier les relations avec d'autres entités
  • Planifier les types de requêtes fréquentes
  • Définir les règles de validation métier
  • Prévoir les besoins de sécurité et d'audit

Phase 2 : Backend (Mongoose)

  • Créer l'interface TypeScript avec extends Document
  • Définir le schéma avec tous les types de champs nécessaires
  • Ajouter les validations (required, format, métier)
  • Implémenter les index pour les performances
  • Configurer les options (timestamps, toJSON, etc.)
  • Ajouter les middlewares nécessaires (pre/post hooks)
  • Implémenter les méthodes d'instance
  • Implémenter les méthodes statiques
  • Configurer la gestion de sécurité
  • Tester toutes les validations et méthodes

Phase 3 : Frontend (TypeScript)

  • Créer l'interface principale de l'entité
  • Définir les types union nécessaires
  • Créer les interfaces de filtres (extends BaseFilters)
  • Définir les interfaces de requête (Create/Update)
  • Créer les interfaces de réponse (Response/Stats)
  • Ajouter les types utilitaires si nécessaire
  • Implémenter les validateurs côté client
  • Créer les constantes et patterns partagés
  • Vérifier la cohérence avec le backend
  • Documenter les interfaces complexes

Phase 4 : Intégration

  • Implémenter les transformations de données
  • Synchroniser les validations backend/frontend
  • Tester l'intégration complète
  • Optimiser les performances
  • Vérifier la sécurité
  • Documenter l'API
  • Créer les tests d'intégration

Ce guide complet vous donne toutes les clés pour créer des modèles robustes, sécurisés et performants. N'hésitez pas à vous référer aux fichiers existants dans /server/src/models/ et /client/src/core/models/ pour voir ces concepts en action !

Clone this wiki locally