🔧 Backend & Administration
📋 Vue d'ensemble
Le backend LIPAIX est construit autour de PayloadCMS, un CMS headless auto-hébergé qui nous permet de gérer le contenu et l'administration de notre plateforme de manière flexible et performante.
🏗️ Architecture backend
Stack technologique
- PayloadCMS - CMS headless avec interface d'administration
- PostgreSQL - Base de données relationnelle
- Redis - Cache et sessions
- Node.js - Runtime JavaScript
- TypeScript - Typage statique
Structure des services
Backend Services
├── 🔧 PayloadCMS Core
│ ├── Collections (Shows, Users, Availabilities)
│ ├── Access Control (Rôles et permissions)
│ └── Admin Interface (Interface d'administration)
├── 🗄️ Database Layer
│ ├── PostgreSQL (données persistantes)
│ └── Redis (cache et sessions)
├── 🔌 API Layer
│ ├── REST API (endpoints publics)
│ ├── GraphQL API (queries avancées)
│ └── Admin API (gestion du contenu)
└── 🔐 Security Layer
├── Authentication (JWT)
├── Authorization (rôles)
└── Validation (schémas)🎭 Collections PayloadCMS
Show Collection (Événements)
Responsabilité : Gestion des spectacles et événements
Champs principaux :
title- Titre de l'événementdescription- Description détailléedate- Date et heuremaxPlayers- Nombre maximum de joueursstatus- Statut (draft, published, cancelled)format- Format de l'événement
Configuration d'accès :
export const Show: CollectionConfig = {
slug: 'shows',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'date', 'status', 'maxPlayers']
},
access: {
read: () => true, // Lecture publique
create: ({ req: { user } }) => {
return user?.role === 'admin';
},
update: ({ req: { user } }) => {
return user?.role === 'admin';
},
delete: ({ req: { user } }) => {
return user?.role === 'admin';
}
},
fields: [
{
name: 'title',
type: 'text',
required: true,
validation: (val) => val.length > 0
},
{
name: 'description',
type: 'richText',
required: true
},
{
name: 'date',
type: 'date',
required: true,
admin: {
date: {
pickerAppearance: 'dayAndTime'
}
}
},
{
name: 'maxPlayers',
type: 'number',
required: true,
min: 1,
max: 50
},
{
name: 'status',
type: 'select',
required: true,
options: [
{ label: 'Brouillon', value: 'draft' },
{ label: 'Publié', value: 'published' },
{ label: 'Annulé', value: 'cancelled' }
],
defaultValue: 'draft'
}
]
};Availability Collection (Disponibilités)
Responsabilité : Gestion des disponibilités des joueurs
Champs principaux :
player- Référence vers l'utilisateurshow- Référence vers l'événementstatus- Statut de disponibiliténotes- Notes additionnelles
Configuration :
export const Availability: CollectionConfig = {
slug: 'availabilities',
admin: {
useAsTitle: 'player',
defaultColumns: ['player', 'show', 'status', 'createdAt']
},
access: {
read: ({ req: { user } }) => {
// Les utilisateurs peuvent voir leurs propres disponibilités
if (user?.role === 'admin') return true;
return {
player: {
equals: user?.id
}
};
},
create: ({ req: { user } }) => {
// Les utilisateurs peuvent créer leurs disponibilités
return !!user;
},
update: ({ req: { user } }) => {
// Les utilisateurs peuvent modifier leurs disponibilités
if (user?.role === 'admin') return true;
return {
player: {
equals: user?.id
}
};
}
},
fields: [
{
name: 'player',
type: 'relationship',
relationTo: 'users',
required: true,
hasMany: false
},
{
name: 'show',
type: 'relationship',
relationTo: 'shows',
required: true,
hasMany: false
},
{
name: 'status',
type: 'select',
required: true,
options: [
{ label: 'Disponible', value: 'available' },
{ label: 'Indisponible', value: 'unavailable' },
{ label: 'Peut-être', value: 'maybe' }
]
},
{
name: 'notes',
type: 'textarea',
required: false
}
]
};User Collection (Utilisateurs)
Responsabilité : Gestion des utilisateurs et rôles
Champs principaux :
email- Adresse email (unique)name- Nom completrole- Rôle dans le systèmediscordId- ID Discord (optionnel)
Configuration :
export const User: CollectionConfig = {
slug: 'users',
auth: true, // Active l'authentification
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'email', 'role', 'createdAt']
},
access: {
read: ({ req: { user } }) => {
// Les utilisateurs peuvent voir leurs propres infos
if (user?.role === 'admin') return true;
return {
id: {
equals: user?.id
}
};
},
create: ({ req: { user } }) => {
// Seuls les admins peuvent créer des utilisateurs
return user?.role === 'admin';
},
update: ({ req: { user } }) => {
// Les utilisateurs peuvent modifier leurs infos
if (user?.role === 'admin') return true;
return {
id: {
equals: user?.id
}
};
}
},
fields: [
{
name: 'name',
type: 'text',
required: true
},
{
name: 'email',
type: 'email',
required: true,
unique: true
},
{
name: 'role',
type: 'select',
required: true,
options: [
{ label: 'Joueur', value: 'player' },
{ label: 'Admin', value: 'admin' }
],
defaultValue: 'player'
},
{
name: 'discordId',
type: 'text',
required: false,
admin: {
description: 'ID Discord pour l\'intégration avec le bot'
}
}
]
};🔐 Contrôle d'accès et sécurité
Système de rôles
Rôles disponibles :
player- Joueur standardadmin- Administrateur avec tous les droits
Permissions par rôles :
// Exemple de fonction de contrôle d'accès
const isAdmin = ({ req: { user } }) => user?.role === 'admin';
const isOwnerOrAdmin = ({ req: { user }, data }) => {
if (user?.role === 'admin') return true;
return data.player === user?.id;
};
// Utilisation dans les collections
access: {
read: isOwnerOrAdmin,
create: isAdmin,
update: isOwnerOrAdmin,
delete: isAdmin
}Validation des données
Schémas de validation :
// Validation personnalisée pour les dates
{
name: 'date',
type: 'date',
required: true,
validate: (val) => {
const date = new Date(val);
const now = new Date();
if (date < now) {
return 'La date ne peut pas être dans le passé';
}
return true;
}
}
// Validation des relations
{
name: 'maxPlayers',
type: 'number',
required: true,
validate: (val, { data }) => {
if (val < 1) {
return 'Le nombre minimum de joueurs est 1';
}
if (val > 50) {
return 'Le nombre maximum de joueurs est 50';
}
return true;
}
}🗄️ Gestion des données
Pattern Repository
Chaque collection a son repository pour l'accès aux données :
// Interface du repository
export interface ShowsRepository {
getNextFewShows(count: number): Promise<Show[]>;
getById(id: string): Promise<Show | null>;
getByStatus(status: ShowStatus): Promise<Show[]>;
save(show: Show): Promise<void>;
delete(id: string): Promise<void>;
}
// Implémentation avec PayloadCMS
export class PayloadShowsRepository implements ShowsRepository {
constructor(private payload: Payload) {}
async getNextFewShows(count: number): Promise<Show[]> {
const response = await this.payload.find({
collection: 'shows',
where: {
date: {
greater_than: new Date().toISOString()
},
status: {
equals: 'published'
}
},
limit: count,
sort: 'date'
});
return response.docs.map(doc => this.mapToShow(doc));
}
async getById(id: string): Promise<Show | null> {
try {
const doc = await this.payload.findByID({
collection: 'shows',
id
});
return doc ? this.mapToShow(doc) : null;
} catch (error) {
return null;
}
}
private mapToShow(doc: any): Show {
return {
id: doc.id,
title: doc.title,
description: doc.description,
date: new Date(doc.date),
maxPlayers: doc.maxPlayers,
status: doc.status
};
}
}Cache et performance
Stratégie de cache :
export class CachedShowsRepository implements ShowsRepository {
constructor(
private repository: ShowsRepository,
private cache: CacheService
) {}
async getNextFewShows(count: number): Promise<Show[]> {
const cacheKey = `next-shows-${count}`;
// Vérifier le cache d'abord
const cached = await this.cache.get(cacheKey);
if (cached) {
return cached;
}
// Récupérer depuis le repository
const shows = await this.repository.getNextFewShows(count);
// Mettre en cache (5 minutes)
await this.cache.set(cacheKey, shows, 300);
return shows;
}
}🔌 APIs et endpoints
REST API
Endpoints publics :
// GET /api/shows - Liste des événements publiés
export async function GET(request: Request) {
try {
const shows = await showsRepository.getNextFewShows(10);
return Response.json({
success: true,
data: shows
});
} catch (error) {
return Response.json({
success: false,
error: 'Erreur lors de la récupération des événements'
}, { status: 500 });
}
}
// GET /api/shows/[id] - Détails d'un événement
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const show = await showsRepository.getById(params.id);
if (!show) {
return Response.json({
success: false,
error: 'Événement non trouvé'
}, { status: 404 });
}
return Response.json({
success: true,
data: show
});
} catch (error) {
return Response.json({
success: false,
error: 'Erreur lors de la récupération de l\'événement'
}, { status: 500 });
}
}GraphQL API
Queries disponibles :
// Schéma GraphQL
export const typeDefs = `
type Show {
id: ID!
title: String!
description: String!
date: String!
maxPlayers: Int!
status: String!
}
type Query {
shows(limit: Int = 10): [Show!]!
show(id: ID!): Show
showsByStatus(status: String!): [Show!]!
}
`;
// Resolvers
export const resolvers = {
Query: {
shows: async (_, { limit }) => {
return await showsRepository.getNextFewShows(limit);
},
show: async (_, { id }) => {
return await showsRepository.getById(id);
},
showsByStatus: async (_, { status }) => {
return await showsRepository.getByStatus(status);
}
}
};🎨 Interface d'administration
Personnalisation de l'admin
Champs personnalisés :
// Champ personnalisé pour les descriptions
export const HeadlineField: React.FC<FieldProps> = ({ path, value, onChange }) => {
return (
<div className="headline-field">
<label className="block text-sm font-medium text-gray-700 mb-2">
Description courte
</label>
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Description courte de l'événement..."
/>
<p className="mt-1 text-sm text-gray-500">
Cette description apparaîtra dans les listes et aperçus
</p>
</div>
);
};
// Utilisation dans la collection
{
name: 'headline',
type: 'text',
required: true,
admin: {
components: {
Field: HeadlineField
}
}
}Vues personnalisées :
// Vue personnalisée pour les disponibilités
export const AvailabilitiesView: React.FC = () => {
const [availabilities, setAvailabilities] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchAvailabilities();
}, []);
const fetchAvailabilities = async () => {
try {
const response = await fetch('/api/availabilities');
const data = await response.json();
setAvailabilities(data.data);
} catch (error) {
console.error('Erreur lors de la récupération des disponibilités:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return <div>Chargement...</div>;
}
return (
<div className="availabilities-view">
<h1 className="text-2xl font-bold mb-6">Gestion des disponibilités</h1>
<div className="grid gap-4">
{availabilities.map((availability) => (
<div key={availability.id} className="border rounded-lg p-4">
<h3 className="font-semibold">{availability.player.name}</h3>
<p className="text-gray-600">{availability.show.title}</p>
<span className={`px-2 py-1 rounded text-sm ${
availability.status === 'available' ? 'bg-green-100 text-green-800' :
availability.status === 'unavailable' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{availability.status}
</span>
</div>
))}
</div>
</div>
);
};🧪 Tests et qualité
Tests des collections
// Test de la collection Show
describe('Show Collection', () => {
it('should create a show with valid data', async () => {
const showData = {
title: 'Match d\'impro',
description: 'Un match d\'improvisation',
date: new Date('2024-12-25T20:00:00Z'),
maxPlayers: 12,
status: 'draft'
};
const show = await payload.create({
collection: 'shows',
data: showData
});
expect(show.title).toBe(showData.title);
expect(show.status).toBe('draft');
});
it('should not create a show with invalid data', async () => {
const invalidData = {
title: '', // Titre vide
maxPlayers: -1 // Nombre négatif
};
await expect(
payload.create({
collection: 'shows',
data: invalidData
})
).rejects.toThrow();
});
});Tests des repositories
// Test du repository
describe('PayloadShowsRepository', () => {
let repository: PayloadShowsRepository;
let mockPayload: jest.Mocked<Payload>;
beforeEach(() => {
mockPayload = {
find: jest.fn(),
findByID: jest.fn()
} as any;
repository = new PayloadShowsRepository(mockPayload);
});
it('should return shows from payload', async () => {
const mockShows = [
{ id: '1', title: 'Show 1', date: '2024-12-25' },
{ id: '2', title: 'Show 2', date: '2024-12-26' }
];
mockPayload.find.mockResolvedValue({
docs: mockShows
} as any);
const result = await repository.getNextFewShows(2);
expect(result).toHaveLength(2);
expect(mockPayload.find).toHaveBeenCalledWith({
collection: 'shows',
where: {
date: { greater_than: expect.any(String) }
},
limit: 2,
sort: 'date'
});
});
});🚀 Déploiement et configuration
Variables d'environnement
# Configuration PayloadCMS
PAYLOAD_SECRET=votre-secret-super-securise
PAYLOAD_PUBLIC_SERVER_URL=https://votre-domaine.com
# Base de données
DATABASE_URL=postgresql://user:password@host:port/database
REDIS_URL=redis://host:port
# Configuration admin
PAYLOAD_PUBLIC_ADMIN_ROUTE=/admin
PAYLOAD_PUBLIC_ADMIN_EMAIL=admin@lipaix.com
PAYLOAD_PUBLIC_ADMIN_PASSWORD=password-securiseConfiguration de production
// payload.config.ts
export default buildConfig({
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL,
admin: {
user: 'users',
meta: {
titleSuffix: '- LIPAIX Admin',
ogImage: '/og-image.jpg',
favicon: '/favicon.ico'
}
},
collections: [Show, User, Availability],
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts')
},
graphQL: {
schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql')
},
cors: [
'https://votre-domaine.com',
'https://admin.votre-domaine.com'
],
csrf: [
'https://votre-domaine.com'
]
});📊 Monitoring et observabilité
Health checks
// Endpoint de santé
export async function GET() {
try {
// Vérifier la base de données
await payload.db.connect();
// Vérifier Redis
await redis.ping();
return Response.json({
status: 'healthy',
timestamp: new Date().toISOString(),
services: {
database: 'connected',
redis: 'connected',
payload: 'running'
}
});
} catch (error) {
return Response.json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
error: error.message
}, { status: 503 });
}
}Logs et métriques
// Logger structuré
export const logger = {
info: (message: string, meta?: any) => {
console.log(JSON.stringify({
level: 'info',
message,
timestamp: new Date().toISOString(),
...meta
}));
},
error: (message: string, error?: any, meta?: any) => {
console.error(JSON.stringify({
level: 'error',
message,
error: error?.message,
stack: error?.stack,
timestamp: new Date().toISOString(),
...meta
}));
}
};
// Utilisation
logger.info('Show created', { showId: show.id, userId: user.id });
logger.error('Failed to create show', error, { userId: user.id });🔄 Évolution et maintenance
Migrations de base de données
// Script de migration
export async function migrateShows() {
const shows = await payload.find({
collection: 'shows',
limit: 1000
});
for (const show of shows.docs) {
// Ajouter un nouveau champ
if (!show.headline) {
await payload.update({
collection: 'shows',
id: show.id,
data: {
headline: show.title // Utiliser le titre comme headline par défaut
}
});
}
}
}Backup et restauration
// Script de backup
export async function backupShows() {
const shows = await payload.find({
collection: 'shows',
limit: 10000
});
const backup = {
timestamp: new Date().toISOString(),
collection: 'shows',
count: shows.docs.length,
data: shows.docs
};
// Sauvegarder dans un fichier
await fs.writeFile(
`backup-shows-${Date.now()}.json`,
JSON.stringify(backup, null, 2)
);
}🎯 Bonnes pratiques
Do's
- ✅ Valider les données - Toujours valider les entrées
- ✅ Gérer les erreurs - Logs détaillés et réponses appropriées
- ✅ Utiliser le cache - Améliorer les performances
- ✅ Tester les collections - Tests unitaires et d'intégration
Don'ts
- ❌ Exposer les données sensibles - Filtrer les champs privés
- ❌ Ignorer la validation - Valider côté serveur
- ❌ Oublier la sécurité - Contrôle d'accès strict
- ❌ Négliger les performances - Index et cache appropriés
🚀 Prochaines étapes
- Bot Discord - Discord.js et intégrations
- Frontend - React et Next.js
- Déploiement - Railway et workflows
- Architecture - Structure technique détaillée
Le backend LIPAIX évolue constamment. N'hésitez pas à proposer des améliorations ! 🚀
