-
Notifications
You must be signed in to change notification settings - Fork 0
Créer un modèle Mongoose et son interface Angular
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.
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
[Interface Angular] ←→ [API Express] ←→ [Modèle Mongoose] ←→ [MongoDB]
TypeScript JSON JavaScript BSON
// É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
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']
}
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'
}
}
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é'
}
}
status: {
type: String,
enum: {
values: ['active', 'inactive', 'pending'],
message: 'Le statut doit être active, inactive ou pending'
},
default: 'pending'
}
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.
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'
}]
// 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 });
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;
}
}
// 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
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();
});
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);
}
});
// 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();
});
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');
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();
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;
}
}
}
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é.
// 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';
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
}
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;
}
export interface ApiResponse<T> {
success: boolean;
message?: string;
data: T;
}
// Usage
ApiResponse<User> // Un utilisateur
ApiResponse<User[]> // Liste simple
ApiResponse<UsersResponse> // Liste avec pagination
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;
}
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;
};
}
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;
}
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';
}
// É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;
}
// 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.
Mongoose | TypeScript | Transformation | Exemple |
---|---|---|---|
ObjectId | string | Automatique via JSON |
_id: ObjectId → _id: string
|
Date | Date |
new Date() côté client |
createdAt: ISOString → createdAt: 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: any → metadata: UserMetadata
|
// 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;
}
}
}
// 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
};
}
}
// /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
email: {
type: String,
required: [true, VALIDATION_MESSAGES.REQUIRED],
match: [VALIDATION_PATTERNS.EMAIL, VALIDATION_MESSAGES.EMAIL]
}
// 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 } };
}
// 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');
};
// 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;
}
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 });
};
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';
}
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();
});
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[];
}
// 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);
};
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;
};
}
// 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();
});
// 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();
});
// 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;
};
}
}
// 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
};
});
};
// 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);
});
// 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.
- 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
- 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
- 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
- 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 !