Skip to content

🔧 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énement
  • description - Description détaillée
  • date - Date et heure
  • maxPlayers - Nombre maximum de joueurs
  • status - Statut (draft, published, cancelled)
  • format - Format de l'événement

Configuration d'accès :

typescript
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'utilisateur
  • show - Référence vers l'événement
  • status - Statut de disponibilité
  • notes - Notes additionnelles

Configuration :

typescript
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 complet
  • role - Rôle dans le système
  • discordId - ID Discord (optionnel)

Configuration :

typescript
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 standard
  • admin - Administrateur avec tous les droits

Permissions par rôles :

typescript
// 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 :

typescript
// 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 :

typescript
// 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 :

typescript
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 :

typescript
// 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 :

typescript
// 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 :

typescript
// 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 :

typescript
// 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

typescript
// 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

typescript
// 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

bash
# 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-securise

Configuration de production

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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


Le backend LIPAIX évolue constamment. N'hésitez pas à proposer des améliorations ! 🚀

Released under the MIT License.