86 KiB
86 KiB
Dofus Manager - Architecture Document
Table of Contents
- Introduction
- High-Level Architecture
- Technology Stack
- Data Models
- API Specification
- Components Architecture
- External APIs
- Core Workflows
- Database Schema
- Frontend Architecture
- Backend Architecture
- Project Structure
- Development Workflow
- Deployment Architecture
- Security & Performance
- Testing Strategy
- Coding Standards
- Error Handling
- Monitoring & Observability
- Architecture Checklist
1. Introduction
Project Overview
Dofus Manager est une application web personnelle conçue pour gérer efficacement plus de 60 personnages du MMORPG Dofus, répartis sur plusieurs comptes. L'application centralise le suivi des progressions (quêtes, donjons, succès) et permet une gestion optimisée via des équipes.
Goals
- Centralisation : Un point unique pour gérer tous les personnages et comptes
- Suivi des progressions : Tracker l'avancement des quêtes, donjons et succès
- Gestion par équipes : Organiser les personnages en équipes pour faciliter le suivi
- Efficacité : Interface rapide avec opérations bulk et filtres avancés
Scope
| In Scope | Out of Scope |
|---|---|
| Gestion des personnages (CRUD) | Intégration directe avec le jeu |
| Gestion des comptes | Multi-utilisateurs |
| Suivi des progressions | Application mobile native |
| Gestion des équipes | Synchronisation automatique |
| Import données DofusDB | Fonctionnalités sociales |
| Interface responsive | Mode hors-ligne |
Non-Functional Requirements
| Requirement | Target |
|---|---|
| Performance | < 200ms pour les opérations courantes |
| Disponibilité | 99% uptime (usage personnel) |
| Sécurité | Authentification requise, données protégées |
| Scalabilité | Support de 100+ personnages |
| Maintenabilité | Code typé, tests automatisés |
2. High-Level Architecture
System Architecture Diagram
┌─────────────────────────────────────────────────────────────────────────────┐
│ DOFUS MANAGER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CLIENT (Browser) │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │
│ │ │ React │ │ TanStack │ │ TanStack │ │ Zustand │ │ │
│ │ │ Components│ │ Router │ │ Query │ │ (UI State)│ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ Server Functions (RPC) │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ SERVER (TanStack Start) │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │
│ │ │ Server │ │ Zod │ │ Prisma │ │ node-cache│ │ │
│ │ │ Functions │ │ Validation │ │ ORM │ │ (Cache) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ DATABASE (PostgreSQL) │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │
│ │ │ Characters │ │ Accounts │ │ Teams │ │Progressions│ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ EXTERNAL SERVICES │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ DofusDB API │ │ │
│ │ │ (Classes, Quêtes, Donjons, Succès) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Data Flow
User Action → React Component → TanStack Query → Server Function → Prisma → PostgreSQL
↑ │
└───────────────────────── Response ────────────────────────────────────────┘
Key Architectural Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Full-stack Framework | TanStack Start | Type-safe, modern React, server functions |
| Database | PostgreSQL | Robust, JSON support, full-text search |
| ORM | Prisma | Type-safe queries, excellent DX |
| State Management | TanStack Query + Zustand | Server state vs UI state separation |
| Styling | Tailwind + shadcn/ui | Rapid development, consistent design |
| Deployment | Docker + Traefik | Easy SSL, reverse proxy, scalable |
3. Technology Stack
Frontend
| Technology | Version | Purpose |
|---|---|---|
| React | 19.x | UI library |
| TanStack Router | 1.x | Type-safe routing |
| TanStack Query | 5.x | Server state management |
| Zustand | 5.x | Client state management |
| Tailwind CSS | 4.x | Utility-first styling |
| shadcn/ui | latest | Component library |
| Lucide React | latest | Icons |
| React Hook Form | 7.x | Form management |
| Zod | 3.x | Schema validation |
Backend
| Technology | Version | Purpose |
|---|---|---|
| TanStack Start | 1.x | Full-stack framework |
| Prisma | 6.x | Database ORM |
| Zod | 3.x | Input validation |
| node-cache | 5.x | In-memory caching |
| Pino | 9.x | Structured logging |
| bcryptjs | 2.x | Password hashing |
Database
| Technology | Version | Purpose |
|---|---|---|
| PostgreSQL | 16.x | Primary database |
DevOps
| Technology | Purpose |
|---|---|
| Docker | Containerization |
| Docker Compose | Local development & deployment |
| Traefik | Reverse proxy, SSL termination |
| GitLab CI | CI/CD pipeline |
Development Tools
| Tool | Purpose |
|---|---|
| TypeScript | Type safety |
| Biome | Linting & formatting |
| Vitest | Unit testing |
| Playwright | E2E testing |
4. Data Models
Entity Relationship Diagram
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ User │ │ Account │ │ Character │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ id │ │ id │ │ id │
│ email │──┐ │ name │──┐ │ name │
│ password │ │ │ userId ◄─┘ │ │ level │
│ createdAt │ └───►│ createdAt │ └───►│ classId │
│ updatedAt │ │ updatedAt │ │ serverId │
└─────────────┘ └─────────────┘ │ accountId ◄─┘
│ createdAt │
│ updatedAt │
└──────┬──────┘
│
┌────────────────────────────┼────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Team │ │TeamMember │ │ CharProgress│
├─────────────┤ ├─────────────┤ ├─────────────┤
│ id │◄─────────────│ teamId │ │ id │
│ name │ │ characterId │◄─────────────│ characterId │
│ type │ │ joinedAt │ │ progressId │
│ userId │ └─────────────┘ │ completed │
│ createdAt │ │ completedAt │
│ updatedAt │ └─────────────┘
└─────────────┘ │
│
▼
┌─────────────┐
│ Progression │
├─────────────┤
│ id │
│ name │
│ type │
│ category │
│ dofusDbId │
└─────────────┘
Core Entities
User
interface User {
id: string;
email: string;
passwordHash: string;
createdAt: Date;
updatedAt: Date;
}
Account
interface Account {
id: string;
name: string;
userId: string;
createdAt: Date;
updatedAt: Date;
}
Character
interface Character {
id: string;
name: string;
level: number;
classId: number;
className: string;
serverId: number;
serverName: string;
accountId: string;
createdAt: Date;
updatedAt: Date;
}
Team
interface Team {
id: string;
name: string;
type: 'MAIN' | 'SECONDARY' | 'CUSTOM';
userId: string;
createdAt: Date;
updatedAt: Date;
}
Progression
interface Progression {
id: string;
name: string;
type: 'QUEST' | 'DUNGEON' | 'ACHIEVEMENT' | 'DOFUS';
category: string;
dofusDbId: number | null;
}
CharacterProgression
interface CharacterProgression {
id: string;
characterId: string;
progressionId: string;
completed: boolean;
completedAt: Date | null;
}
5. API Specification
Server Functions Pattern
TanStack Start utilise des "Server Functions" - des fonctions RPC type-safe qui s'exécutent côté serveur.
// Définition
import { createServerFn } from '@tanstack/react-start/server';
import { z } from 'zod';
const inputSchema = z.object({
name: z.string().min(1),
});
export const createCharacter = createServerFn({ method: 'POST' })
.validator((data: unknown) => inputSchema.parse(data))
.handler(async ({ data }) => {
// Logique serveur avec accès DB
return await db.character.create({ data });
});
// Utilisation côté client
const result = await createCharacter({ data: { name: 'MyChar' } });
API Endpoints (Server Functions)
Characters
| Function | Method | Description |
|---|---|---|
getCharacters |
GET | Liste tous les personnages avec filtres |
getCharacter |
GET | Récupère un personnage par ID |
createCharacter |
POST | Crée un nouveau personnage |
updateCharacter |
POST | Met à jour un personnage |
deleteCharacter |
POST | Supprime un personnage |
bulkUpdateCharacters |
POST | Met à jour plusieurs personnages |
bulkDeleteCharacters |
POST | Supprime plusieurs personnages |
Accounts
| Function | Method | Description |
|---|---|---|
getAccounts |
GET | Liste tous les comptes |
getAccount |
GET | Récupère un compte par ID |
createAccount |
POST | Crée un nouveau compte |
updateAccount |
POST | Met à jour un compte |
deleteAccount |
POST | Supprime un compte |
Teams
| Function | Method | Description |
|---|---|---|
getTeams |
GET | Liste toutes les équipes |
getTeam |
GET | Récupère une équipe avec ses membres |
createTeam |
POST | Crée une nouvelle équipe |
updateTeam |
POST | Met à jour une équipe |
deleteTeam |
POST | Supprime une équipe |
addTeamMembers |
POST | Ajoute des personnages à une équipe |
removeTeamMembers |
POST | Retire des personnages d'une équipe |
Progressions
| Function | Method | Description |
|---|---|---|
getProgressions |
GET | Liste toutes les progressions |
getCharacterProgressions |
GET | Progressions d'un personnage |
updateCharacterProgression |
POST | Met à jour une progression |
bulkUpdateProgressions |
POST | Met à jour plusieurs progressions |
syncFromDofusDb |
POST | Synchronise depuis DofusDB |
Auth
| Function | Method | Description |
|---|---|---|
login |
POST | Authentification |
logout |
POST | Déconnexion |
getSession |
GET | Récupère la session courante |
Validation Schemas
// src/lib/schemas/character.ts
import { z } from 'zod';
export const createCharacterSchema = z.object({
name: z.string().min(2).max(50),
level: z.number().int().min(1).max(200),
classId: z.number().int().positive(),
serverId: z.number().int().positive(),
accountId: z.string().uuid(),
});
export const updateCharacterSchema = createCharacterSchema.partial();
export const characterFiltersSchema = z.object({
search: z.string().optional(),
classIds: z.array(z.number()).optional(),
serverIds: z.array(z.number()).optional(),
accountIds: z.array(z.string()).optional(),
minLevel: z.number().optional(),
maxLevel: z.number().optional(),
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(20),
sortBy: z.enum(['name', 'level', 'class', 'server']).default('name'),
sortOrder: z.enum(['asc', 'desc']).default('asc'),
});
6. Components Architecture
Component Hierarchy
src/components/
├── ui/ # shadcn/ui primitives
│ ├── button.tsx
│ ├── input.tsx
│ ├── dialog.tsx
│ ├── table.tsx
│ ├── select.tsx
│ ├── checkbox.tsx
│ ├── tabs.tsx
│ ├── card.tsx
│ ├── badge.tsx
│ ├── progress.tsx
│ └── ...
│
├── layout/ # Layout components
│ ├── app-shell.tsx # Main layout wrapper
│ ├── app-sidebar.tsx # Navigation sidebar
│ └── app-header.tsx # Top header with breadcrumbs
│
├── characters/ # Character feature components
│ ├── character-list.tsx
│ ├── character-card.tsx
│ ├── character-form.tsx
│ ├── character-filters.tsx
│ └── character-bulk-actions.tsx
│
├── accounts/ # Account feature components
│ ├── account-list.tsx
│ ├── account-card.tsx
│ └── account-form.tsx
│
├── teams/ # Team feature components
│ ├── team-list.tsx
│ ├── team-detail.tsx
│ ├── team-form.tsx
│ └── team-member-selector.tsx
│
├── progressions/ # Progression feature components
│ ├── progression-section.tsx
│ ├── progression-tracker.tsx
│ └── progression-filters.tsx
│
└── shared/ # Shared components
├── data-table.tsx
├── search-input.tsx
├── pagination.tsx
├── confirmation-modal.tsx
├── loading-spinner.tsx
└── error-boundary.tsx
Component Design Principles
- Composition over inheritance - Prefer composing smaller components
- Props interface first - Define TypeScript interfaces for all props
- Controlled components - Parent manages state when needed
- Accessible by default - Use semantic HTML, ARIA attributes
- Responsive design - Mobile-first approach with Tailwind
7. External APIs
DofusDB API Integration
Base URL: https://api.dofusdb.fr
Endpoints Used
| Endpoint | Purpose |
|---|---|
GET /classes |
Liste des classes de personnages |
GET /servers |
Liste des serveurs |
GET /quests |
Liste des quêtes |
GET /dungeons |
Liste des donjons |
GET /achievements |
Liste des succès |
Integration Service
// src/lib/server/dofusdb.ts
import NodeCache from 'node-cache';
const cache = new NodeCache({ stdTTL: 3600 }); // 1 hour cache
const BASE_URL = 'https://api.dofusdb.fr';
interface DofusDbOptions {
lang?: 'fr' | 'en';
limit?: number;
}
export async function fetchFromDofusDb<T>(
endpoint: string,
options: DofusDbOptions = {}
): Promise<T> {
const { lang = 'fr', limit = 100 } = options;
const cacheKey = `dofusdb:${endpoint}:${lang}:${limit}`;
// Check cache
const cached = cache.get<T>(cacheKey);
if (cached) return cached;
// Fetch from API
const url = new URL(endpoint, BASE_URL);
url.searchParams.set('$lang', lang);
url.searchParams.set('$limit', limit.toString());
const response = await fetch(url.toString(), {
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
throw new Error(`DofusDB API error: ${response.status}`);
}
const data = await response.json();
cache.set(cacheKey, data);
return data as T;
}
// Specific fetchers
export const getClasses = () => fetchFromDofusDb<DofusClass[]>('/classes');
export const getServers = () => fetchFromDofusDb<DofusServer[]>('/servers');
export const getQuests = () => fetchFromDofusDb<DofusQuest[]>('/quests');
export const getDungeons = () => fetchFromDofusDb<DofusDungeon[]>('/dungeons');
8. Core Workflows
Character Creation Flow
┌─────────────────────────────────────────────────────────────────┐
│ CHARACTER CREATION FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Click │───▶│ Open │───▶│ Fill │───▶│ Validate │ │
│ │ "Add" │ │ Modal │ │ Form │ │ (Zod) │ │
│ └──────────┘ └──────────┘ └──────────┘ └────┬─────┘ │
│ │ │
│ ┌──────────────┴───┐ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Valid │ │ Invalid │ │
│ └────┬─────┘ └────┬─────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ │
│ │ Server │ │ Show │ │
│ │ Function │ │ Errors │ │
│ └─────┬──────┘ └────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────┐ │
│ │ Prisma │ │
│ │ Create │ │
│ └─────┬──────┘ │
│ │ │
│ ▼ │
│ ┌────────────┐ │
│ │ Invalidate │ │
│ │ Query │ │
│ └─────┬──────┘ │
│ │ │
│ ▼ │
│ ┌────────────┐ │
│ │ Toast │ │
│ │ Success │ │
│ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Progression Update Flow
┌─────────────────────────────────────────────────────────────────┐
│ PROGRESSION UPDATE FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ Toggle Check │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Optimistic │────▶│ Server │────▶│ Database │ │
│ │ Update │ │ Function │ │ Update │ │
│ └──────────────┘ └──────────────┘ └──────┬───────┘ │
│ │ │ │
│ │ ┌──────────┴─────┐ │
│ │ ▼ ▼ │
│ │ ┌──────────┐ ┌──────────┐ │
│ │ │ Success │ │ Error │ │
│ │ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ │ ▼ ▼ │
│ │ ┌──────────┐ ┌──────────┐ │
│ └────────────────────▶│ Confirm │ │ Rollback │ │
│ │ State │ │ + Toast │ │
│ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Bulk Operations Flow
┌─────────────────────────────────────────────────────────────────┐
│ BULK OPERATIONS FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Select │───▶│ Choose │───▶│ Confirm │───▶│ Process │ │
│ │ Items │ │ Action │ │ Modal │ │ Batch │ │
│ └──────────┘ └──────────┘ └──────────┘ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌────────────┐ │
│ │ Loading │ │
│ │ State │ │
│ └─────┬──────┘ │
│ │ │
│ ┌─────────────┴────────┐ │
│ ▼ ▼ │
│ ┌────────────┐ ┌────────┐│
│ │ Success │ │ Partial││
│ │ Toast │ │ Failure││
│ └─────┬──────┘ └───┬────┘│
│ │ │ │
│ ▼ ▼ │
│ ┌────────────┐ ┌──────────┐ │
│ │ Invalidate │ │ Show │ │
│ │ Queries │ │ Errors │ │
│ └────────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
9. Database Schema
Prisma Schema
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================
// USER & AUTHENTICATION
// ============================================
model User {
id String @id @default(cuid())
email String @unique
passwordHash String @map("password_hash")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
accounts Account[]
teams Team[]
sessions Session[]
@@map("users")
}
model Session {
id String @id @default(cuid())
userId String @map("user_id")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("sessions")
}
// ============================================
// CORE ENTITIES
// ============================================
model Account {
id String @id @default(cuid())
name String
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
characters Character[]
@@unique([userId, name])
@@index([userId])
@@map("accounts")
}
model Character {
id String @id @default(cuid())
name String
level Int @default(1)
classId Int @map("class_id")
className String @map("class_name")
serverId Int @map("server_id")
serverName String @map("server_name")
accountId String @map("account_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
teamMembers TeamMember[]
progressions CharacterProgression[]
@@unique([accountId, name])
@@index([accountId])
@@index([classId])
@@index([serverId])
@@index([level])
@@map("characters")
}
// ============================================
// TEAMS
// ============================================
enum TeamType {
MAIN
SECONDARY
CUSTOM
}
model Team {
id String @id @default(cuid())
name String
type TeamType @default(CUSTOM)
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
members TeamMember[]
@@unique([userId, name])
@@index([userId])
@@map("teams")
}
model TeamMember {
id String @id @default(cuid())
teamId String @map("team_id")
characterId String @map("character_id")
joinedAt DateTime @default(now()) @map("joined_at")
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@unique([teamId, characterId])
@@index([teamId])
@@index([characterId])
@@map("team_members")
}
// ============================================
// PROGRESSIONS
// ============================================
enum ProgressionType {
QUEST
DUNGEON
ACHIEVEMENT
DOFUS
}
model Progression {
id String @id @default(cuid())
name String
type ProgressionType
category String
dofusDbId Int? @unique @map("dofusdb_id")
characterProgressions CharacterProgression[]
@@index([type])
@@index([category])
@@map("progressions")
}
model CharacterProgression {
id String @id @default(cuid())
characterId String @map("character_id")
progressionId String @map("progression_id")
completed Boolean @default(false)
completedAt DateTime? @map("completed_at")
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
progression Progression @relation(fields: [progressionId], references: [id], onDelete: Cascade)
@@unique([characterId, progressionId])
@@index([characterId])
@@index([progressionId])
@@index([completed])
@@map("character_progressions")
}
10. Frontend Architecture
State Management Strategy
┌─────────────────────────────────────────────────────────────────┐
│ STATE MANAGEMENT │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ TanStack Query │ │
│ │ (Server State / Cache) │ │
│ │ │ │
│ │ • Characters list │ │
│ │ • Accounts data │ │
│ │ • Teams & members │ │
│ │ • Progressions │ │
│ │ • DofusDB reference data │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Zustand │ │
│ │ (Client/UI State) │ │
│ │ │ │
│ │ • Selected items (multi-select) │ │
│ │ • UI preferences (sidebar collapsed, etc.) │ │
│ │ • Filter states │ │
│ │ • Modal open/close states │ │
│ │ • Theme preference │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ React State │ │
│ │ (Component-local State) │ │
│ │ │ │
│ │ • Form inputs │ │
│ │ • Hover/focus states │ │
│ │ • Animation states │ │
│ │ • Temporary UI states │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
TanStack Query Setup
// src/lib/client/query-client.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
onError: (error) => {
console.error('Mutation error:', error);
},
},
},
});
// Query keys factory
export const queryKeys = {
characters: {
all: ['characters'] as const,
list: (filters: CharacterFilters) => [...queryKeys.characters.all, 'list', filters] as const,
detail: (id: string) => [...queryKeys.characters.all, 'detail', id] as const,
},
accounts: {
all: ['accounts'] as const,
list: () => [...queryKeys.accounts.all, 'list'] as const,
detail: (id: string) => [...queryKeys.accounts.all, 'detail', id] as const,
},
teams: {
all: ['teams'] as const,
list: () => [...queryKeys.teams.all, 'list'] as const,
detail: (id: string) => [...queryKeys.teams.all, 'detail', id] as const,
},
progressions: {
all: ['progressions'] as const,
list: (type?: ProgressionType) => [...queryKeys.progressions.all, 'list', type] as const,
byCharacter: (characterId: string) => [...queryKeys.progressions.all, 'character', characterId] as const,
},
} as const;
Zustand Store
// src/lib/client/stores/ui-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UIState {
// Sidebar
sidebarCollapsed: boolean;
toggleSidebar: () => void;
// Theme
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
// Selection (for bulk actions)
selectedCharacterIds: string[];
selectCharacter: (id: string) => void;
deselectCharacter: (id: string) => void;
selectAllCharacters: (ids: string[]) => void;
clearSelection: () => void;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
// Sidebar
sidebarCollapsed: false,
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
// Theme
theme: 'dark',
setTheme: (theme) => set({ theme }),
// Selection
selectedCharacterIds: [],
selectCharacter: (id) =>
set((state) => ({
selectedCharacterIds: [...state.selectedCharacterIds, id]
})),
deselectCharacter: (id) =>
set((state) => ({
selectedCharacterIds: state.selectedCharacterIds.filter((i) => i !== id)
})),
selectAllCharacters: (ids) => set({ selectedCharacterIds: ids }),
clearSelection: () => set({ selectedCharacterIds: [] }),
}),
{
name: 'dofus-manager-ui',
partialize: (state) => ({
sidebarCollapsed: state.sidebarCollapsed,
theme: state.theme,
}),
}
)
);
Routing Structure
// src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router';
import { AppShell } from '@/components/layout/app-shell';
export const Route = createRootRoute({
component: () => (
<AppShell>
<Outlet />
</AppShell>
),
});
// Route tree
/*
src/routes/
├── __root.tsx # Root layout with AppShell
├── index.tsx # / → Dashboard
├── characters/
│ ├── index.tsx # /characters → List
│ └── $id.tsx # /characters/:id → Detail
├── accounts/
│ ├── index.tsx # /accounts → List
│ └── $id.tsx # /accounts/:id → Detail
├── teams/
│ ├── index.tsx # /teams → List
│ └── $id.tsx # /teams/:id → Detail
├── progressions/
│ └── index.tsx # /progressions → Tracker
└── settings/
└── index.tsx # /settings → Settings
*/
11. Backend Architecture
Server Functions Organization
src/server/
├── functions/
│ ├── auth.ts # Authentication functions
│ ├── characters.ts # Character CRUD
│ ├── accounts.ts # Account CRUD
│ ├── teams.ts # Team management
│ ├── progressions.ts # Progression tracking
│ └── dofusdb.ts # DofusDB sync
│
├── middleware/
│ └── auth.ts # Authentication middleware
│
└── index.ts # Export all functions
Server Function Example
// src/server/functions/characters.ts
import { createServerFn } from '@tanstack/react-start/server';
import { z } from 'zod';
import { db } from '@/lib/server/db';
import { requireAuth } from '@/server/middleware/auth';
// Schemas
const createCharacterSchema = z.object({
name: z.string().min(2).max(50),
level: z.number().int().min(1).max(200),
classId: z.number().int().positive(),
className: z.string(),
serverId: z.number().int().positive(),
serverName: z.string(),
accountId: z.string().cuid(),
});
const characterFiltersSchema = z.object({
search: z.string().optional(),
classIds: z.array(z.number()).optional(),
serverIds: z.array(z.number()).optional(),
accountIds: z.array(z.string()).optional(),
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(20),
});
// Functions
export const getCharacters = createServerFn({ method: 'GET' })
.validator((data: unknown) => characterFiltersSchema.parse(data))
.handler(async ({ data }) => {
const session = await requireAuth();
const { search, classIds, serverIds, accountIds, page, limit } = data;
const where = {
account: { userId: session.userId },
...(search && {
name: { contains: search, mode: 'insensitive' as const },
}),
...(classIds?.length && { classId: { in: classIds } }),
...(serverIds?.length && { serverId: { in: serverIds } }),
...(accountIds?.length && { accountId: { in: accountIds } }),
};
const [characters, total] = await Promise.all([
db.character.findMany({
where,
include: { account: { select: { id: true, name: true } } },
skip: (page - 1) * limit,
take: limit,
orderBy: { name: 'asc' },
}),
db.character.count({ where }),
]);
return {
characters,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
});
export const createCharacter = createServerFn({ method: 'POST' })
.validator((data: unknown) => createCharacterSchema.parse(data))
.handler(async ({ data }) => {
const session = await requireAuth();
// Verify account belongs to user
const account = await db.account.findFirst({
where: { id: data.accountId, userId: session.userId },
});
if (!account) {
throw new Error('Account not found');
}
return db.character.create({ data });
});
export const bulkDeleteCharacters = createServerFn({ method: 'POST' })
.validator((data: unknown) => z.object({ ids: z.array(z.string().cuid()) }).parse(data))
.handler(async ({ data }) => {
const session = await requireAuth();
const result = await db.character.deleteMany({
where: {
id: { in: data.ids },
account: { userId: session.userId },
},
});
return { deleted: result.count };
});
Authentication Middleware
// src/server/middleware/auth.ts
import { getWebRequest } from '@tanstack/react-start/server';
import { db } from '@/lib/server/db';
interface Session {
userId: string;
sessionId: string;
}
export async function requireAuth(): Promise<Session> {
const request = getWebRequest();
const sessionId = request.headers.get('cookie')
?.split(';')
.find(c => c.trim().startsWith('session='))
?.split('=')[1];
if (!sessionId) {
throw new Error('Unauthorized');
}
const session = await db.session.findUnique({
where: { id: sessionId },
include: { user: true },
});
if (!session || session.expiresAt < new Date()) {
throw new Error('Session expired');
}
return {
userId: session.userId,
sessionId: session.id,
};
}
export async function getOptionalAuth(): Promise<Session | null> {
try {
return await requireAuth();
} catch {
return null;
}
}
Caching Strategy
// src/lib/server/cache.ts
import NodeCache from 'node-cache';
// Different TTLs for different data types
const caches = {
// Reference data (rarely changes)
reference: new NodeCache({ stdTTL: 3600, checkperiod: 600 }), // 1 hour
// User data (changes more frequently)
user: new NodeCache({ stdTTL: 300, checkperiod: 60 }), // 5 minutes
};
export const referenceCache = {
get: <T>(key: string): T | undefined => caches.reference.get(key),
set: <T>(key: string, value: T): boolean => caches.reference.set(key, value),
del: (key: string): number => caches.reference.del(key),
flush: (): void => caches.reference.flushAll(),
};
export const userCache = {
get: <T>(key: string): T | undefined => caches.user.get(key),
set: <T>(key: string, value: T): boolean => caches.user.set(key, value),
del: (key: string | string[]): number => caches.user.del(key),
flush: (): void => caches.user.flushAll(),
// Helper to invalidate all cache for a user
invalidateUser: (userId: string): void => {
const keys = caches.user.keys().filter(k => k.startsWith(`user:${userId}:`));
caches.user.del(keys);
},
};
12. Project Structure
dofus-manager/
├── .github/
│ └── workflows/
│ └── ci.yml
│
├── docker/
│ ├── Dockerfile
│ └── docker-compose.yml
│
├── docs/
│ ├── prd.md
│ ├── front-end-spec.md
│ └── architecture.md
│
├── prisma/
│ ├── schema.prisma
│ └── migrations/
│
├── public/
│ └── favicon.ico
│
├── src/
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ ├── layout/ # Layout components
│ │ ├── characters/ # Character feature
│ │ ├── accounts/ # Account feature
│ │ ├── teams/ # Team feature
│ │ ├── progressions/ # Progression feature
│ │ └── shared/ # Shared components
│ │
│ ├── lib/
│ │ ├── utils.ts # Utility functions (cn, etc.)
│ │ ├── errors.ts # Error types
│ │ ├── schemas/ # Zod schemas
│ │ │ ├── character.ts
│ │ │ ├── account.ts
│ │ │ ├── team.ts
│ │ │ └── progression.ts
│ │ ├── client/ # Client-only code
│ │ │ ├── query-client.ts
│ │ │ └── stores/
│ │ │ └── ui-store.ts
│ │ └── server/ # Server-only code
│ │ ├── db.ts
│ │ ├── cache.ts
│ │ ├── logger.ts
│ │ └── dofusdb.ts
│ │
│ ├── routes/
│ │ ├── __root.tsx
│ │ ├── index.tsx
│ │ ├── characters/
│ │ ├── accounts/
│ │ ├── teams/
│ │ ├── progressions/
│ │ └── settings/
│ │
│ ├── server/
│ │ ├── functions/
│ │ │ ├── auth.ts
│ │ │ ├── characters.ts
│ │ │ ├── accounts.ts
│ │ │ ├── teams.ts
│ │ │ ├── progressions.ts
│ │ │ └── dofusdb.ts
│ │ ├── middleware/
│ │ │ └── auth.ts
│ │ └── index.ts
│ │
│ ├── styles/
│ │ └── globals.css
│ │
│ └── app.tsx # App entry point
│
├── tests/
│ ├── unit/
│ ├── integration/
│ └── e2e/
│
├── .env.example
├── .gitignore
├── biome.json
├── package.json
├── tsconfig.json
├── app.config.ts # TanStack Start config
└── README.md
13. Development Workflow
Local Development Setup
# 1. Clone repository
git clone <repo-url>
cd dofus-manager
# 2. Install dependencies
pnpm install
# 3. Setup environment
cp .env.example .env
# Edit .env with your values
# 4. Start database
docker compose up -d postgres
# 5. Run migrations
pnpm prisma migrate dev
# 6. Seed database (optional)
pnpm prisma db seed
# 7. Start development server
pnpm dev
Environment Variables
# .env.example
# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dofus_manager?schema=public"
# App
APP_URL="http://localhost:3000"
NODE_ENV="development"
# Session
SESSION_SECRET="your-secret-key-min-32-chars"
# Optional: DofusDB
DOFUSDB_CACHE_TTL="3600"
Git Workflow
main (production)
│
└── develop (staging)
│
├── feature/add-character-filters
├── feature/team-management
└── fix/progression-update-bug
Branch Naming
feature/*- New featuresfix/*- Bug fixesrefactor/*- Code refactoringdocs/*- Documentation updateschore/*- Maintenance tasks
Commit Convention
<type>(<scope>): <description>
Types: feat, fix, docs, style, refactor, test, chore
Scope: characters, accounts, teams, progressions, auth, ui
Examples:
feat(characters): add bulk delete functionality
fix(progressions): correct date formatting
docs(readme): update setup instructions
14. Deployment Architecture
Docker Configuration
# docker/Dockerfile
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy package files
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Copy source and build
COPY . .
RUN pnpm prisma generate
RUN pnpm build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 app
# Copy built application
COPY --from=builder --chown=app:nodejs /app/.output ./.output
COPY --from=builder --chown=app:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=app:nodejs /app/package.json ./package.json
COPY --from=builder --chown=app:nodejs /app/prisma ./prisma
USER app
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
CMD ["node", ".output/server/index.mjs"]
Docker Compose (Production)
# docker/docker-compose.yml
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile
restart: unless-stopped
environment:
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD}@postgres:5432/dofus_manager
- SESSION_SECRET=${SESSION_SECRET}
- NODE_ENV=production
depends_on:
postgres:
condition: service_healthy
networks:
- internal
- traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.dofus.rule=Host(`dofus.example.com`)"
- "traefik.http.routers.dofus.entrypoints=websecure"
- "traefik.http.routers.dofus.tls.certresolver=letsencrypt"
- "traefik.http.services.dofus.loadbalancer.server.port=3000"
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=dofus_manager
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
networks:
- internal
traefik:
image: traefik:v3.0
restart: unless-stopped
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks:
- traefik
volumes:
postgres_data:
letsencrypt:
networks:
internal:
traefik:
external: true
GitLab CI/CD Pipeline
# .gitlab-ci.yml
stages:
- test
- build
- deploy
variables:
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# Test stage
test:
stage: test
image: node:20-alpine
before_script:
- corepack enable
- pnpm install --frozen-lockfile
script:
- pnpm lint
- pnpm typecheck
- pnpm test
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
# Build stage
build:
stage: build
image: docker:24
services:
- docker:24-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker build -t $DOCKER_IMAGE -f docker/Dockerfile .
- docker push $DOCKER_IMAGE
- docker tag $DOCKER_IMAGE $CI_REGISTRY_IMAGE:latest
- docker push $CI_REGISTRY_IMAGE:latest
only:
- main
- develop
# Deploy staging
deploy_staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
script:
- ssh $STAGING_USER@$STAGING_HOST "cd /opt/dofus-manager && docker compose pull && docker compose up -d"
environment:
name: staging
url: https://staging.dofus.example.com
only:
- develop
# Deploy production
deploy_production:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
script:
- ssh $PROD_USER@$PROD_HOST "cd /opt/dofus-manager && docker compose pull && docker compose up -d && docker compose exec app pnpm prisma migrate deploy"
environment:
name: production
url: https://dofus.example.com
only:
- main
when: manual
15. Security & Performance
Security Measures
Authentication
- Session-based authentication with secure cookies
- Password hashing with bcrypt (cost factor 12)
- Session expiration and rotation
Input Validation
- All inputs validated with Zod schemas
- Server-side validation mandatory
- Prisma parameterized queries (SQL injection prevention)
Headers (via Traefik)
# Security headers middleware
http:
middlewares:
security-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
contentTypeNosniff: true
frameDeny: true
browserXssFilter: true
referrerPolicy: "strict-origin-when-cross-origin"
contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
Performance Optimizations
Database
- Indexes on foreign keys and search fields
- Pagination for all list queries
- Connection pooling via Prisma
Caching
- node-cache for server-side caching
- TanStack Query for client-side caching
- DofusDB data cached for 1 hour
Frontend
- Code splitting via TanStack Router
- Lazy loading for routes
- Optimistic updates for better UX
Bundle Optimization
// app.config.ts
export default defineConfig({
vite: {
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-tanstack': ['@tanstack/react-router', '@tanstack/react-query'],
'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-select'],
},
},
},
},
},
});
16. Testing Strategy
Testing Pyramid
┌───────┐
│ E2E │ ← Few, critical paths
│ Tests │
┌┴───────┴┐
│Integration│ ← API & DB tests
│ Tests │
┌┴─────────┴┐
│ Unit │ ← Many, fast
│ Tests │
└───────────┘
Unit Tests (Vitest)
// tests/unit/schemas/character.test.ts
import { describe, it, expect } from 'vitest';
import { createCharacterSchema } from '@/lib/schemas/character';
describe('createCharacterSchema', () => {
it('validates correct input', () => {
const input = {
name: 'TestChar',
level: 200,
classId: 1,
className: 'Cra',
serverId: 1,
serverName: 'Imagiro',
accountId: 'clx123abc',
};
expect(() => createCharacterSchema.parse(input)).not.toThrow();
});
it('rejects invalid level', () => {
const input = {
name: 'TestChar',
level: 250, // Invalid: max is 200
classId: 1,
className: 'Cra',
serverId: 1,
serverName: 'Imagiro',
accountId: 'clx123abc',
};
expect(() => createCharacterSchema.parse(input)).toThrow();
});
});
Integration Tests
// tests/integration/characters.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { db } from '@/lib/server/db';
describe('Character API', () => {
let testUserId: string;
let testAccountId: string;
beforeAll(async () => {
// Setup test data
const user = await db.user.create({
data: {
email: 'test@test.com',
passwordHash: 'hash',
},
});
testUserId = user.id;
const account = await db.account.create({
data: {
name: 'TestAccount',
userId: testUserId,
},
});
testAccountId = account.id;
});
afterAll(async () => {
// Cleanup
await db.user.delete({ where: { id: testUserId } });
});
it('creates a character', async () => {
const character = await db.character.create({
data: {
name: 'TestChar',
level: 200,
classId: 1,
className: 'Cra',
serverId: 1,
serverName: 'Imagiro',
accountId: testAccountId,
},
});
expect(character.name).toBe('TestChar');
expect(character.level).toBe(200);
});
});
E2E Tests (Playwright)
// tests/e2e/characters.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Characters Page', () => {
test.beforeEach(async ({ page }) => {
// Login
await page.goto('/login');
await page.fill('[name="email"]', 'test@test.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/');
});
test('displays character list', async ({ page }) => {
await page.goto('/characters');
await expect(page.locator('h1')).toContainText('Personnages');
await expect(page.locator('table')).toBeVisible();
});
test('can create a new character', async ({ page }) => {
await page.goto('/characters');
await page.click('button:has-text("Ajouter")');
await page.fill('[name="name"]', 'NewChar');
await page.fill('[name="level"]', '100');
await page.click('button:has-text("Créer")');
await expect(page.locator('text=NewChar')).toBeVisible();
});
});
17. Coding Standards
Cette section définit les standards de code minimaux mais critiques pour le développement. Ces règles sont conçues pour les agents IA et les développeurs.
Critical Fullstack Rules
| Rule | Description |
|---|---|
| Type Sharing | Toujours définir les types partagés dans src/types/ et les importer dans les composants et server functions. Ne jamais dupliquer les interfaces. |
| Server Functions Only | Ne jamais faire d'appels HTTP directs (fetch, axios). Toujours utiliser les server functions importées depuis @/server/*. |
| Zod Validation | Toute entrée utilisateur doit être validée avec un schéma Zod. Le schéma doit être colocalisé avec la server function. |
| Prisma Transactions | Les opérations multi-tables doivent utiliser prisma.$transaction() pour garantir l'atomicité. |
| Cache Invalidation | Après chaque mutation, invalider les caches affectés via cacheService.invalidate() et queryClient.invalidateQueries(). |
| Error Boundaries | Chaque page doit être wrappée dans un ErrorBoundary. Les erreurs serveur doivent utiliser les classes de @/lib/errors.ts. |
| No Direct State Mutation | Ne jamais muter l'état directement. Utiliser les setters de useState ou les mutations TanStack Query. |
| URL State for Filters | Les filtres et la pagination doivent être stockés dans l'URL via TanStack Router, pas dans le state local. |
| Optimistic Updates Pattern | Pour les actions fréquentes (toggle, update), utiliser les optimistic updates de TanStack Query. |
| Component Colocation | Les hooks, types et helpers spécifiques à un composant doivent être dans le même dossier. |
Naming Conventions
| Element | Frontend | Backend | Example |
|---|---|---|---|
| Components | PascalCase | — | CharacterCard.tsx |
| Component files | kebab-case ou PascalCase | — | character-card.tsx |
| Hooks | camelCase + use prefix | — | useCharacters.ts |
| Server Functions | camelCase | camelCase | getCharacters, createTeam |
| Server Function files | kebab-case | kebab-case | characters.ts |
| Types/Interfaces | PascalCase | PascalCase | Character, CreateCharacterInput |
| Enums | PascalCase | PascalCase | CharacterClass, TeamType |
| Constants | SCREAMING_SNAKE | SCREAMING_SNAKE | MAX_TEAM_SIZE |
| Database tables | — | snake_case | character_progressions |
| Database columns | — | snake_case | created_at, account_id |
| URL routes | kebab-case | kebab-case | /characters, /bulk-progressions |
| Query keys | camelCase arrays | — | ['characters', 'list', filters] |
| CSS classes | Tailwind utilities | — | bg-card text-foreground |
File Structure Conventions
Component File Structure
// 1. Imports (grouped)
import { useState } from 'react'; // React
import { useQuery } from '@tanstack/react-query'; // External libs
import { Button } from '@/components/ui/button'; // Internal UI
import { getCharacters } from '@/server/characters'; // Server functions
import type { Character } from '@/types'; // Types (last)
// 2. Types (if not in separate file)
interface CharacterListProps {
serverId?: string;
}
// 3. Constants (if any)
const PAGE_SIZE = 20;
// 4. Component
export function CharacterList({ serverId }: CharacterListProps) {
// 4a. Hooks (in order: router, query, state, effects, callbacks)
const navigate = useNavigate();
const { data, isLoading } = useQuery({ ... });
const [selected, setSelected] = useState<string[]>([]);
// 4b. Early returns
if (isLoading) return <Loading />;
if (!data) return <EmptyState />;
// 4c. Render
return (
<div>
{/* JSX */}
</div>
);
}
// 5. Sub-components (if small and only used here)
function CharacterRow({ character }: { character: Character }) {
return <tr>...</tr>;
}
Server Function File Structure
// 1. Imports
import { createServerFn } from '@tanstack/start';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
import { cacheService } from '@/lib/cache';
import { NotFoundError, ValidationError } from '@/lib/errors';
// 2. Schemas (colocated with functions)
const createCharacterSchema = z.object({
name: z.string().min(1).max(50),
class: z.nativeEnum(CharacterClass),
level: z.number().int().min(1).max(200),
serverId: z.string().uuid(),
accountId: z.string().uuid(),
});
// 3. Server Functions (exported)
export const getCharacters = createServerFn('GET', async (filters) => {
// Implementation
});
export const createCharacter = createServerFn('POST', async (input) => {
const data = createCharacterSchema.parse(input);
// Implementation
});
// 4. Helper functions (private, at bottom)
function buildWhereClause(filters: Filters): Prisma.CharacterWhereInput {
// Implementation
}
Code Quality Rules
TypeScript
// ✅ DO: Explicit return types for exported functions
export function calculateTotal(items: Item[]): number {
return items.reduce((sum, item) => sum + item.amount, 0);
}
// ❌ DON'T: Implicit any
function processData(data) { ... }
// ✅ DO: Use type imports
import type { Character } from '@/types';
// ❌ DON'T: Import types as values
import { Character } from '@/types';
// ✅ DO: Discriminated unions for state
type State =
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: Character[] };
// ❌ DON'T: Optional properties for state
type State = {
loading?: boolean;
error?: Error;
data?: Character[];
};
React
// ✅ DO: Named exports for components
export function CharacterCard({ character }: Props) { ... }
// ❌ DON'T: Default exports (except for routes)
export default function CharacterCard() { ... }
// ✅ DO: Destructure props
function CharacterCard({ character, onSelect }: Props) { ... }
// ❌ DON'T: Props object
function CharacterCard(props: Props) {
const character = props.character;
}
// ✅ DO: useCallback for handlers passed to children
const handleSelect = useCallback((id: string) => {
setSelected(prev => [...prev, id]);
}, []);
// ✅ DO: useMemo for expensive computations
const filteredCharacters = useMemo(
() => characters.filter(c => c.level >= 180),
[characters]
);
// ❌ DON'T: Inline objects/arrays in props (causes re-renders)
<Table columns={[col1, col2]} /> // Creates new array each render
Prisma
// ✅ DO: Select only needed fields
const characters = await prisma.character.findMany({
select: {
id: true,
name: true,
level: true,
server: { select: { name: true } },
},
});
// ❌ DON'T: Fetch everything
const characters = await prisma.character.findMany({
include: {
server: true,
account: true,
progressions: true,
currencies: true,
teamMemberships: true,
},
});
// ✅ DO: Use transactions for related operations
await prisma.$transaction([
prisma.teamMember.deleteMany({ where: { teamId } }),
prisma.team.delete({ where: { id: teamId } }),
]);
// ✅ DO: Count separately from data
const [data, total] = await Promise.all([
prisma.character.findMany({ take: 20 }),
prisma.character.count(),
]);
Import Order
ESLint avec eslint-plugin-import applique cet ordre :
// 1. React
import { useState, useCallback } from 'react';
// 2. External packages (alphabetical)
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { z } from 'zod';
// 3. Internal absolute imports (alphabetical by path)
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/data-table/data-table';
import { useCharacters } from '@/hooks/use-characters';
import { prisma } from '@/lib/prisma';
import { getCharacters } from '@/server/characters';
// 4. Relative imports
import { CharacterRow } from './character-row';
// 5. Type imports (at the end)
import type { Character, CharacterFilters } from '@/types';
ESLint & Prettier Config
.eslintrc.cjs
module.exports = {
root: true,
env: { browser: true, es2022: true, node: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: './tsconfig.json',
},
plugins: ['@typescript-eslint', 'import'],
rules: {
// TypeScript
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
// Import order
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'index',
'type',
],
'newlines-between': 'always',
alphabetize: { order: 'asc' },
},
],
// React
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// General
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
ignorePatterns: ['node_modules', '.output', 'dist'],
};
.prettierrc
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"]
}
Git Commit Standards
# Format
<type>(<scope>): <subject>
# Types
feat # New feature
fix # Bug fix
docs # Documentation
style # Formatting (no code change)
refactor # Refactoring
test # Adding tests
chore # Build, deps, tooling
# Scopes (optional)
characters, teams, accounts, progressions, currencies
ui, api, db, auth, dofusdb
# Examples
feat(characters): add bulk progression update
fix(teams): validate account constraint on member add
refactor(api): extract prisma helpers
test(teams): add integration tests for constraints
chore(deps): upgrade TanStack Query to v5.17
18. Error Handling
Unified Error Types
// src/lib/errors.ts
export enum ErrorCode {
// Validation (400)
VALIDATION_ERROR = 'VALIDATION_ERROR',
INVALID_INPUT = 'INVALID_INPUT',
// Authentication (401)
UNAUTHORIZED = 'UNAUTHORIZED',
SESSION_EXPIRED = 'SESSION_EXPIRED',
// Authorization (403)
FORBIDDEN = 'FORBIDDEN',
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
// Not Found (404)
NOT_FOUND = 'NOT_FOUND',
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
// Conflict (409)
CONFLICT = 'CONFLICT',
DUPLICATE_ENTRY = 'DUPLICATE_ENTRY',
// External Service (502)
EXTERNAL_API_ERROR = 'EXTERNAL_API_ERROR',
DOFUSDB_ERROR = 'DOFUSDB_ERROR',
// Server (500)
INTERNAL_ERROR = 'INTERNAL_ERROR',
DATABASE_ERROR = 'DATABASE_ERROR',
}
export interface AppErrorData {
code: ErrorCode;
message: string;
details?: Record<string, unknown>;
field?: string;
}
export class AppError extends Error {
public readonly code: ErrorCode;
public readonly statusCode: number;
public readonly details?: Record<string, unknown>;
public readonly field?: string;
public readonly isOperational: boolean;
constructor(data: AppErrorData & { statusCode?: number }) {
super(data.message);
this.name = 'AppError';
this.code = data.code;
this.statusCode = data.statusCode ?? this.getDefaultStatusCode(data.code);
this.details = data.details;
this.field = data.field;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
private getDefaultStatusCode(code: ErrorCode): number {
const statusMap: Record<ErrorCode, number> = {
[ErrorCode.VALIDATION_ERROR]: 400,
[ErrorCode.INVALID_INPUT]: 400,
[ErrorCode.UNAUTHORIZED]: 401,
[ErrorCode.SESSION_EXPIRED]: 401,
[ErrorCode.FORBIDDEN]: 403,
[ErrorCode.INSUFFICIENT_PERMISSIONS]: 403,
[ErrorCode.NOT_FOUND]: 404,
[ErrorCode.RESOURCE_NOT_FOUND]: 404,
[ErrorCode.CONFLICT]: 409,
[ErrorCode.DUPLICATE_ENTRY]: 409,
[ErrorCode.EXTERNAL_API_ERROR]: 502,
[ErrorCode.DOFUSDB_ERROR]: 502,
[ErrorCode.INTERNAL_ERROR]: 500,
[ErrorCode.DATABASE_ERROR]: 500,
};
return statusMap[code] ?? 500;
}
toJSON(): AppErrorData & { statusCode: number } {
return {
code: this.code,
message: this.message,
statusCode: this.statusCode,
details: this.details,
field: this.field,
};
}
}
Backend Error Handler
// src/lib/server/error-handler.ts
import { Prisma } from '@prisma/client';
import { AppError, ErrorCode } from '@/lib/errors';
export function handlePrismaError(error: unknown): AppError {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
switch (error.code) {
case 'P2002':
return new AppError({
code: ErrorCode.DUPLICATE_ENTRY,
message: 'Cette entrée existe déjà',
details: { fields: error.meta?.target },
});
case 'P2025':
return new AppError({
code: ErrorCode.NOT_FOUND,
message: 'Ressource non trouvée',
});
default:
return new AppError({
code: ErrorCode.DATABASE_ERROR,
message: 'Erreur de base de données',
});
}
}
return new AppError({
code: ErrorCode.INTERNAL_ERROR,
message: 'Une erreur inattendue est survenue',
});
}
Frontend Error Handler
// src/lib/client/error-handler.ts
import { toast } from 'sonner';
import { AppError, ErrorCode } from '@/lib/errors';
const errorMessages: Record<ErrorCode, string> = {
[ErrorCode.VALIDATION_ERROR]: 'Données invalides',
[ErrorCode.UNAUTHORIZED]: 'Veuillez vous connecter',
[ErrorCode.NOT_FOUND]: 'Ressource non trouvée',
[ErrorCode.DUPLICATE_ENTRY]: 'Cette entrée existe déjà',
[ErrorCode.INTERNAL_ERROR]: 'Erreur serveur',
// ... other codes
};
export function handleError(error: unknown): void {
if (error instanceof AppError) {
const message = error.message || errorMessages[error.code];
if (error.statusCode >= 500) {
toast.error('Erreur serveur', { description: message });
} else {
toast.warning(message);
}
return;
}
console.error('Unexpected error:', error);
toast.error('Une erreur inattendue est survenue');
}
19. Monitoring & Observability
Structured Logging (Pino)
// src/lib/server/logger.ts
import pino from 'pino';
const isDev = process.env.NODE_ENV === 'development';
export const logger = pino({
level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info'),
transport: isDev
? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss',
ignore: 'pid,hostname',
},
}
: undefined,
formatters: {
level: (label) => ({ level: label }),
},
base: {
env: process.env.NODE_ENV,
version: process.env.APP_VERSION ?? '0.0.0',
},
redact: {
paths: ['password', 'token', 'authorization'],
censor: '[REDACTED]',
},
});
export const dbLogger = logger.child({ module: 'database' });
export const apiLogger = logger.child({ module: 'api' });
Health Check Endpoint
// src/server/functions/health.ts
import { createServerFn } from '@tanstack/react-start/server';
import { db } from '@/lib/server/db';
interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
timestamp: string;
version: string;
checks: {
database: boolean;
cache: boolean;
};
}
export const getHealth = createServerFn({ method: 'GET' }).handler(
async (): Promise<HealthStatus> => {
const checks = {
database: false,
cache: true,
};
try {
await db.$queryRaw`SELECT 1`;
checks.database = true;
} catch {
// Database check failed
}
const allHealthy = Object.values(checks).every(Boolean);
return {
status: allHealthy ? 'healthy' : 'degraded',
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? '0.0.0',
checks,
};
}
);
Application Metrics
// src/lib/server/metrics.ts
class MetricsCollector {
private startTime = Date.now();
private data = {
requests: { total: 0, errors: 0 },
database: { queries: 0, slowQueries: 0 },
cache: { hits: 0, misses: 0 },
};
incrementRequest(isError = false): void {
this.data.requests.total++;
if (isError) this.data.requests.errors++;
}
incrementDbQuery(isSlow = false): void {
this.data.database.queries++;
if (isSlow) this.data.database.slowQueries++;
}
incrementCacheHit(): void {
this.data.cache.hits++;
}
incrementCacheMiss(): void {
this.data.cache.misses++;
}
getMetrics() {
return {
...this.data,
uptime: Math.floor((Date.now() - this.startTime) / 1000),
};
}
}
export const metrics = new MetricsCollector();
20. Architecture Checklist
| Category | Item | Status |
|---|---|---|
| Core Architecture | ||
| High-level architecture diagram | ✅ | |
| Technology stack justified | ✅ | |
| Data flow documented | ✅ | |
| Scalability considered | ✅ | |
| Data Layer | ||
| Database schema defined | ✅ | |
| Relationships documented | ✅ | |
| Indexes identified | ✅ | |
| Migration strategy | ✅ | |
| API Design | ||
| API contracts defined | ✅ | |
| Input validation | ✅ | |
| Error responses standardized | ✅ | |
| Authentication/Authorization | ✅ | |
| Frontend | ||
| Component architecture | ✅ | |
| State management | ✅ | |
| Routing strategy | ✅ | |
| Error boundaries | ✅ | |
| Security | ||
| Authentication flow | ✅ | |
| Input sanitization | ✅ | |
| CORS/CSP headers | ✅ | |
| Secrets management | ✅ | |
| Performance | ||
| Caching strategy | ✅ | |
| Database optimization | ✅ | |
| Bundle optimization | ✅ | |
| Loading states | ✅ | |
| DevOps | ||
| CI/CD pipeline | ✅ | |
| Docker configuration | ✅ | |
| Environment separation | ✅ | |
| Deployment strategy | ✅ | |
| Observability | ||
| Logging strategy | ✅ | |
| Health checks | ✅ | |
| Metrics collection | ✅ | |
| Error tracking | ✅ |
Architecture Summary
┌─────────────────────────────────────────────────────────────────────────┐
│ DOFUS MANAGER - FINAL ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Frontend (React + TanStack) Backend (TanStack Start) │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ • TanStack Router │ │ • Server Functions │ │
│ │ • TanStack Query │◀────────▶│ • Prisma ORM │ │
│ │ • Zustand (UI state) │ RPC │ • Zod Validation │ │
│ │ • shadcn/ui + Tailwind │ │ • node-cache │ │
│ └─────────────────────────┘ └───────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ PostgreSQL 16 │ │
│ │ • 7 tables │ │
│ │ • Full-text search │ │
│ └─────────────────────────┘ │
│ │
│ Infrastructure │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Docker Compose + Traefik (reverse proxy + SSL) │ │
│ │ GitLab CI/CD → VPS Deployment │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Document généré avec BMAD Framework Version: 1.0.0 Date: 2026-01-18