🔧 Backend & Admin
This guide covers everything you need to know about the LIPAIX backend, built with PayloadCMS - a modern, TypeScript-first headless CMS that provides both an admin interface and powerful APIs.
🎯 Overview
What is the Backend?
The LIPAIX Backend is built with PayloadCMS, a self-hosted headless CMS that provides:
- 📊 Admin Interface - Visual content management system
- 🔌 REST & GraphQL APIs - Programmatic access to data
- 🗄️ Database Management - Automatic schema generation and migrations
- 🔐 Access Control - Role-based permissions and authentication
- 🌐 Internationalization - Multi-language content support
- 📝 Content Types - Flexible field definitions and relationships
Why PayloadCMS?
- 🚀 TypeScript-First - Full type safety and IntelliSense
- 🎨 Self-Hosted - Complete control over your data and infrastructure
- 🔌 API-First - REST and GraphQL APIs out of the box
- 📱 Admin Interface - Beautiful, customizable admin panel
- 🔄 Real-time - Live preview and collaborative editing
- 📊 Rich Content - Rich text, media, and relationship fields
🏗️ Architecture Overview
Backend Architecture
graph TB
subgraph "PayloadCMS Core"
CONFIG[Payload Config]
COLLECTIONS[Collections]
FIELDS[Field Types]
HOOKS[Hooks & Lifecycles]
end
subgraph "Admin Interface"
ADMIN_UI[Admin UI]
FORMS[Forms & Fields]
MEDIA[Media Library]
USERS[User Management]
end
subgraph "API Layer"
REST_API[REST API]
GRAPHQL[GraphQL API]
AUTH[Authentication]
VALIDATION[Validation]
end
subgraph "Database"
POSTGRES[(PostgreSQL)]
MIGRATIONS[Migrations]
INDEXES[Indexes]
end
CONFIG --> COLLECTIONS
COLLECTIONS --> FIELDS
FIELDS --> HOOKS
COLLECTIONS --> ADMIN_UI
FIELDS --> FORMS
HOOKS --> MEDIA
COLLECTIONS --> REST_API
COLLECTIONS --> GRAPHQL
HOOKS --> AUTH
FIELDS --> VALIDATION
COLLECTIONS --> POSTGRES
HOOKS --> MIGRATIONS
VALIDATION --> INDEXESData Flow
sequenceDiagram
participant A as Admin User
participant UI as Admin UI
participant API as PayloadCMS API
participant DB as PostgreSQL
A->>UI: Create/Edit Content
UI->>API: Submit Form Data
API->>API: Validate & Transform
API->>DB: Save to Database
DB-->>API: Confirmation
API-->>UI: Success Response
UI-->>A: Content Updated📊 Database Collections
What are Collections?
Collections in PayloadCMS are like database tables, but with much more power. They define the structure of your data and automatically create database tables, admin forms, and API endpoints.
LIPAIX Collections
Show Collection (Events)
export const Show: CollectionConfig = {
slug: 'shows',
admin: {
useAsTitle: 'title',
group: 'Événements',
defaultColumns: ['title', 'date', 'venue', 'status'],
},
access: {
read: () => true, // Public read access
create: ({ req: { user } }) => Boolean(user?.role === 'admin' || user?.role === 'editor'),
update: ({ req: { user } }) => Boolean(user?.role === 'admin' || user?.role === 'editor'),
delete: ({ req: { user } }) => Boolean(user?.role === 'admin'),
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true, // French and English versions
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
description: 'URL slug for the event page',
},
},
{
name: 'date',
type: 'date',
required: true,
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'eventType',
type: 'select',
required: true,
options: [
{ label: 'Apéro Impro', value: 'apero-impro' },
{ label: 'Match d\'Impro', value: 'match-home' },
{ label: 'Match Extérieur', value: 'match-away' },
{ label: 'New Impro Show', value: 'new-impro-show' },
{ label: 'Toute(s) une Histoire', value: 'histoire' },
{ label: 'La Main dans le Sac', value: 'main-dans-le-sac' },
{ label: 'Public Investigation', value: 'public-investigation' },
{ label: 'Battle d\'Impro', value: 'battle' },
{ label: 'Festival', value: 'festival' },
{ label: 'Autre', value: 'other' },
],
},
{
name: 'descriptions',
type: 'group',
fields: [
{
name: 'headline',
type: 'text',
localized: true,
admin: {
description: 'Titre accrocheur pour l\'affichage',
},
},
{
name: 'short',
type: 'textarea',
localized: true,
admin: {
description: 'Description courte (max 200 caractères)',
},
},
{
name: 'detailed',
type: 'richText',
localized: true,
admin: {
description: 'Description détaillée avec mise en forme',
},
},
],
},
{
name: 'venue',
type: 'relationship',
relationTo: 'venues',
required: true,
},
{
name: 'media',
type: 'group',
fields: [
{
name: 'poster',
type: 'upload',
relationTo: 'media',
admin: {
description: 'Affiche de l\'événement',
},
},
{
name: 'thumbnail',
type: 'upload',
relationTo: 'media',
admin: {
description: 'Miniature de l\'événement',
},
},
{
name: 'gallery',
type: 'array',
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
],
admin: {
description: 'Galerie photos de l\'événement',
},
},
],
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'draft',
options: [
{ label: 'Brouillon', value: 'draft' },
{ label: 'Publié', value: 'published' },
{ label: 'Archivé', value: 'archived' },
],
},
],
hooks: {
beforeChange: [
({ data }) => {
// Auto-generate slug if not provided
if (!data.slug && data.title) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
return data
},
],
},
}Availability Collection
export const Availability: CollectionConfig = {
slug: 'availabilities',
admin: {
useAsTitle: 'playerName',
group: 'Disponibilités',
defaultColumns: ['playerName', 'event', 'status', 'updatedAt'],
},
access: {
read: ({ req: { user } }) => {
// Users can read their own availabilities
if (user) return true
return false
},
create: ({ req: { user } }) => Boolean(user),
update: ({ req: { user }, data }) => {
// Users can update their own availabilities
if (user?.id === data.player) return true
// Admins can update any
if (user?.role === 'admin') return true
return false
},
delete: ({ req: { user } }) => Boolean(user?.role === 'admin'),
},
fields: [
{
name: 'player',
type: 'relationship',
relationTo: 'users',
required: true,
admin: {
description: 'Joueur',
},
},
{
name: 'event',
type: 'relationship',
relationTo: 'shows',
required: true,
admin: {
description: 'Événement',
},
},
{
name: 'status',
type: 'select',
required: true,
options: [
{ label: 'Disponible', value: 'available' },
{ label: 'Indisponible', value: 'unavailable' },
{ label: 'Peut-être', value: 'maybe' },
{ label: 'Non répondu', value: 'unanswered' },
],
defaultValue: 'unanswered',
},
{
name: 'notes',
type: 'textarea',
admin: {
description: 'Notes additionnelles (optionnel)',
},
},
{
name: 'updatedAt',
type: 'date',
admin: {
readOnly: true,
position: 'sidebar',
},
},
],
}🎨 Field Types
Core Field Types
PayloadCMS provides a rich set of field types for building complex content structures:
graph TB
subgraph "Basic Fields"
TEXT[Text]
TEXTAREA[Textarea]
NUMBER[Number]
EMAIL[Email]
URL[URL]
DATE[Date]
CHECKBOX[Checkbox]
SELECT[Select]
end
subgraph "Rich Content"
RICHTEXT[Rich Text]
UPLOAD[Upload]
ARRAY[Array]
GROUP[Group]
TAB[Tab]
ROW[Row]
end
subgraph "Relationships"
RELATIONSHIP[Relationship]
BLOCKS[Blocks]
COLLAPSIBLE[Collapsible]
TABS[Tabs]
end
TEXT --> RICHTEXT
TEXTAREA --> UPLOAD
NUMBER --> ARRAY
SELECT --> GROUP
CHECKBOX --> TAB
DATE --> ROW
RELATIONSHIP --> BLOCKS
BLOCKS --> COLLAPSIBLE
COLLAPSIBLE --> TABSField Configuration Examples
import { Field } from 'payload/types'
export const descriptionsField: Field = {
name: 'descriptions',
type: 'group',
fields: [
{
name: 'headline',
type: 'text',
localized: true,
admin: {
description: 'Titre accrocheur pour l\'affichage',
placeholder: 'Ex: Un spectacle d\'improvisation unique',
},
validate: (val) => {
if (val && val.length > 100) {
return 'Le titre ne peut pas dépasser 100 caractères'
}
return true
},
},
{
name: 'short',
type: 'textarea',
localized: true,
admin: {
description: 'Description courte (max 200 caractères)',
rows: 3,
},
validate: (val) => {
if (val && val.length > 200) {
return 'La description courte ne peut pas dépasser 200 caractères'
}
return true
},
},
{
name: 'detailed',
type: 'richText',
localized: true,
admin: {
description: 'Description détaillée avec mise en forme',
elements: ['h2', 'h3', 'link', 'ol', 'ul'],
leaves: ['bold', 'italic', 'underline'],
},
},
],
}🎭 Custom Admin Components
Component Architecture
PayloadCMS allows you to create custom admin components for enhanced user experience:
graph LR
subgraph "Custom Components"
FIELD_COMPONENT[Field Component]
CELL_COMPONENT[Cell Component]
VIEW_COMPONENT[View Component]
HOOK_COMPONENT[Hook Component]
end
subgraph "Integration Points"
FIELD_OVERRIDE[Field Override]
CELL_OVERRIDE[Cell Override]
VIEW_OVERRIDE[View Override]
HOOK_OVERRIDE[Hook Override]
end
subgraph "Benefits"
BETTER_UX[Better UX]
CUSTOM_VALIDATION[Custom Validation]
ENHANCED_FUNCTIONALITY[Enhanced Functionality]
BRANDING[Branding]
end
FIELD_COMPONENT --> FIELD_OVERRIDE
CELL_COMPONENT --> CELL_OVERRIDE
VIEW_COMPONENT --> VIEW_OVERRIDE
HOOK_COMPONENT --> HOOK_OVERRIDE
FIELD_OVERRIDE --> BETTER_UX
CELL_OVERRIDE --> CUSTOM_VALIDATION
VIEW_OVERRIDE --> ENHANCED_FUNCTIONALITY
HOOK_OVERRIDE --> BRANDINGCustom Field Component Example
import React from 'react'
import { useField } from 'payload/components/forms'
import { Label } from 'payload/components/forms'
interface HeadlineFieldProps {
path: string
label?: string
required?: boolean
}
export const HeadlineField: React.FC<HeadlineFieldProps> = ({
path,
label = 'Titre accrocheur',
required = false
}) => {
const { value, setValue } = useField<string>({ path })
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
setValue(newValue)
}
const getCharacterCount = () => {
return value ? value.length : 0
}
const getCharacterCountColor = () => {
const count = getCharacterCount()
if (count > 80) return 'text-red-500'
if (count > 60) return 'text-yellow-500'
return 'text-gray-500'
}
return (
<div className="field-type headline-field">
<Label
htmlFor={path}
label={label}
required={required}
/>
<input
id={path}
type="text"
value={value || ''}
onChange={handleChange}
className="input"
placeholder="Ex: Un spectacle d'improvisation unique"
maxLength={100}
/>
<div className={`character-count ${getCharacterCountColor()}`}>
{getCharacterCount()}/100 caractères
</div>
{value && value.length > 80 && (
<div className="warning text-yellow-600 text-sm mt-1">
⚠️ Le titre est assez long, envisagez de le raccourcir
</div>
)}
</div>
)
}🔌 API Development
API Architecture
PayloadCMS automatically generates REST and GraphQL APIs for all your collections:
graph TB
subgraph "API Endpoints"
REST[REST API]
GRAPHQL[GraphQL API]
WEBHOOKS[Webhooks]
end
subgraph "Authentication"
JWT[JWT Tokens]
API_KEYS[API Keys]
SESSIONS[Sessions]
end
subgraph "Features"
FILTERING[Filtering]
SORTING[Sorting]
PAGINATION[Pagination]
RELATIONS[Relations]
end
REST --> JWT
GRAPHQL --> API_KEYS
WEBHOOKS --> SESSIONS
JWT --> FILTERING
API_KEYS --> SORTING
SESSIONS --> PAGINATION
FILTERING --> RELATIONSREST API Examples
import { payload } from 'payload'
// Get all published events
export async function getPublishedEvents() {
const events = await payload.find({
collection: 'shows',
where: {
status: { equals: 'published' },
},
sort: 'date',
limit: 10,
})
return events
}
// Get event by slug with related data
export async function getEventBySlug(slug: string) {
const event = await payload.find({
collection: 'shows',
where: {
slug: { equals: slug },
status: { equals: 'published' },
},
depth: 2, // Include related data
populate: ['venue', 'media.poster', 'media.thumbnail'],
})
return event.docs[0] || null
}
// Get upcoming events
export async function getUpcomingEvents(limit = 5) {
const now = new Date()
const events = await payload.find({
collection: 'shows',
where: {
and: [
{ status: { equals: 'published' } },
{ date: { greater_than: now.toISOString() } },
],
},
sort: 'date',
limit,
})
return events
}GraphQL API Examples
import { gql } from 'graphql-request'
const GET_EVENTS_QUERY = gql`
query GetEvents($status: Shows_Status_Input, $limit: Int) {
Shows(where: { status: { equals: $status } }, limit: $limit) {
docs {
id
title
slug
date
eventType
descriptions {
headline
short
}
venue {
name
address {
city
}
}
media {
poster {
url
alt
}
thumbnail {
url
alt
}
}
status
}
totalDocs
totalPages
page
hasNextPage
hasPrevPage
}
}
`
const GET_EVENT_BY_SLUG_QUERY = gql`
query GetEventBySlug($slug: String!) {
Shows(where: { slug: { equals: $slug } }) {
docs {
id
title
slug
date
eventType
descriptions {
headline
short
detailed
}
venue {
name
address {
street
city
postalCode
}
}
media {
poster {
url
alt
}
thumbnail {
url
alt
}
gallery {
image {
url
alt
}
}
}
status
}
}
}
`
export async function getEventsGraphQL(status = 'published', limit = 10) {
const variables = { status, limit }
const data = await graphqlClient.request(GET_EVENTS_QUERY, variables)
return data.Shows
}
export async function getEventBySlugGraphQL(slug: string) {
const variables = { slug }
const data = await graphqlClient.request(GET_EVENT_BY_SLUG_QUERY, variables)
return data.Shows.docs[0] || null
}🔐 Access Control
Access Control System
PayloadCMS provides a powerful access control system based on user roles and permissions:
graph TB
subgraph "Access Control"
ROLES[User Roles]
PERMISSIONS[Permissions]
POLICIES[Access Policies]
end
subgraph "Collection Access"
READ[Read Access]
CREATE[Create Access]
UPDATE[Update Access]
DELETE[Delete Access]
end
subgraph "Field Access"
FIELD_READ[Field Read]
FIELD_UPDATE[Field Update]
FIELD_CREATE[Field Create]
end
ROLES --> PERMISSIONS
PERMISSIONS --> POLICIES
POLICIES --> READ
POLICIES --> CREATE
POLICIES --> UPDATE
POLICIES --> DELETE
READ --> FIELD_READ
UPDATE --> FIELD_UPDATE
CREATE --> FIELD_CREATEAccess Control Examples
import { Access, FieldAccess } from 'payload/types'
import { User } from '../payload-types'
// Collection-level access control
export const isAdmin: Access = ({ req: { user } }) => {
return Boolean(user?.role === 'admin')
}
export const isAdminOrEditor: Access = ({ req: { user } }) => {
return Boolean(user?.role === 'admin' || user?.role === 'editor')
}
export const isAdminOrSelf: Access = ({ req: { user }, data }) => {
if (user?.role === 'admin') return true
if (user?.id === data?.user) return true
return false
}
// Field-level access control
export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) => {
return Boolean(user?.role === 'admin')
}
export const isAdminOrEditorFieldLevel: FieldAccess = ({ req: { user } }) => {
return Boolean(user?.role === 'admin' || user?.role === 'editor')
}
// Conditional access based on data
export const canEditEvent: Access = ({ req: { user }, data }) => {
if (user?.role === 'admin') return true
if (user?.role === 'editor') return true
// Users can edit their own events
if (data?.createdBy === user?.id) return true
return false
}
// Time-based access control
export const canEditPublishedEvent: Access = ({ req: { user }, data }) => {
if (user?.role === 'admin') return true
if (user?.role === 'editor') return true
// Published events can only be edited by admins/editors
if (data?.status === 'published') {
return Boolean(user?.role === 'admin' || user?.role === 'editor')
}
// Draft events can be edited by creators
if (data?.createdBy === user?.id) return true
return false
}🌐 Internationalization
i18n Architecture
LIPAIX supports multiple languages through PayloadCMS's built-in internationalization:
graph LR
subgraph "Languages"
FR[Français]
EN[English]
ES[Español]
end
subgraph "Content Types"
LOCALIZED[Localized Fields]
FALLBACK[Fallback Language]
TRANSLATIONS[Translation Management]
end
subgraph "Admin Interface"
LANGUAGE_SWITCHER[Language Switcher]
TRANSLATION_UI[Translation UI]
CONTENT_PREVIEW[Content Preview]
end
FR --> LOCALIZED
EN --> FALLBACK
ES --> TRANSLATIONS
LOCALIZED --> LANGUAGE_SWITCHER
FALLBACK --> TRANSLATION_UI
TRANSLATIONS --> CONTENT_PREVIEWi18n Configuration
import { buildConfig } from 'payload/config'
export default buildConfig({
// ... other config
localization: {
locales: ['fr', 'en', 'es'],
defaultLocale: 'fr',
fallback: true, // Use fallback locale if translation missing
},
collections: [
{
slug: 'shows',
fields: [
{
name: 'title',
type: 'text',
localized: true, // This field will be translated
required: true,
},
{
name: 'descriptions',
type: 'group',
fields: [
{
name: 'headline',
type: 'text',
localized: true,
},
{
name: 'short',
type: 'textarea',
localized: true,
},
{
name: 'detailed',
type: 'richText',
localized: true,
},
],
},
{
name: 'venue',
type: 'relationship',
relationTo: 'venues',
// Not localized - same for all languages
},
],
},
],
})🌱 Database Seeding
Seeding Strategy
Database seeding provides initial data for development and testing:
graph TD
A[Seed Scripts] --> B[Initial Data]
B --> C[Development Environment]
B --> D[Testing Environment]
B --> E[Staging Environment]
subgraph "Seed Types"
USERS[Users & Roles]
CONTENT[Content Types]
MEDIA[Media Files]
RELATIONS[Relationships]
end
C --> USERS
D --> CONTENT
E --> MEDIA
USERS --> RELATIONSSeeding Implementation
import { payload } from 'payload'
import { seedRoles } from './seed-roles'
import { seedUsers } from './seed-users'
import { seedVenues } from './seed-venues'
import { seedEventTypes } from './seed-event-types'
export async function runInitialSeed() {
try {
console.log('🌱 Starting initial seed...')
// Seed in order due to dependencies
await seedRoles()
await seedUsers()
await seedVenues()
await seedEventTypes()
console.log('✅ Initial seed completed successfully!')
} catch (error) {
console.error('❌ Seed failed:', error)
throw error
}
}
// Seed event types
async function seedEventTypes() {
const eventTypes = [
{ label: 'Apéro Impro', value: 'apero-impro' },
{ label: 'Match d\'Impro', value: 'match-home' },
{ label: 'Match Extérieur', value: 'match-away' },
{ label: 'New Impro Show', value: 'new-impro-show' },
{ label: 'Toute(s) une Histoire', value: 'histoire' },
{ label: 'La Main dans le Sac', value: 'main-dans-le-sac' },
{ label: 'Public Investigation', value: 'public-investigation' },
{ label: 'Battle d\'Impro', value: 'battle' },
{ label: 'Festival', value: 'festival' },
{ label: 'Autre', value: 'other' },
]
for (const eventType of eventTypes) {
const existing = await payload.find({
collection: 'event-types',
where: { value: { equals: eventType.value } },
})
if (existing.docs.length === 0) {
await payload.create({
collection: 'event-types',
data: eventType,
})
console.log(`✅ Created event type: ${eventType.label}`)
}
}
}🛠️ Development Tools
Development Workflow
graph LR
subgraph "Development"
CODE[Code Changes]
HOT_RELOAD[Hot Reload]
AUTO_REBUILD[Auto Rebuild]
end
subgraph "Testing"
UNIT_TESTS[Unit Tests]
INTEGRATION[Integration Tests]
E2E_TESTS[E2E Tests]
end
subgraph "Deployment"
DEV[Development]
STAGING[Staging]
PRODUCTION[Production]
end
CODE --> HOT_RELOAD
HOT_RELOAD --> AUTO_REBUILD
AUTO_REBUILD --> UNIT_TESTS
UNIT_TESTS --> INTEGRATION
INTEGRATION --> E2E_TESTS
E2E_TESTS --> DEV
DEV --> STAGING
STAGING --> PRODUCTIONDevelopment Scripts
{
"scripts": {
"dev": "payload dev",
"build": "payload build",
"serve": "payload serve",
"seed": "tsx src/admin/seeds/initialSeed.ts",
"seed:reset": "tsx src/admin/seeds/resetDatabase.ts",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"type-check": "tsc --noEmit"
}
}🔧 Backend & Admin Summary
The LIPAIX backend is built with PayloadCMS, a modern TypeScript-first headless CMS that provides both an admin interface and powerful APIs. The system includes comprehensive access control, internationalization, and automated database management.
Key features:
- Collections - Flexible data structures with automatic API generation
- Admin Interface - Beautiful, customizable content management system
- API Layer - REST and GraphQL APIs with authentication and validation
- Access Control - Role-based permissions and field-level security
- Internationalization - Multi-language content support
- Development Tools - Hot reload, seeding, and comprehensive testing
