Design d'API
Elysia + Eden Treaty, best practices, contrats, organisation
L'API expose le domaine. C'est aussi la surface utilisée par l'IA, donc tout ce qui peut être fait par l'utilisateur doit être un endpoint propre, sans logique métier dans le frontend ni dans les tools IA.
Stack
- Serveur : Elysia (Bun-native, le plus rapide du marché)
- Client typé : Eden Treaty, type inference end-to-end sans codegen
- Validation :
t(TypeBox) côté Elysia, partagé via Eden au client - Auth équipe : Better Auth (handler monté sur Elysia)
- Auth membres/organisateurs : magic tokens custom (voir
operations/auth)
Pourquoi Eden Treaty plutôt que tRPC : voir l'ADR-04. TL;DR : Bun-first, type inference plus directe, pas de couche client/server intermédiaire, intégration native avec lifecycle Elysia.
Principe directeur
L'IA est un client de l'API comme un autre. Elle appelle les mêmes routes que le frontend, avec les mêmes schémas TypeBox, les mêmes guards, les mêmes vérifications. Cela garantit :
- Une seule source de vérité pour la logique métier
- L'IA ne peut faire que ce que l'utilisateur peut faire
- Tout ce qui passe par l'IA est traçable comme une action utilisateur
Best practices Elysia (à respecter)
Ces règles ne sont pas optionnelles, elles conditionnent le bon fonctionnement du type inference Eden et la maintenabilité.
1. Method chaining obligatoire
Elysia infère les types du retour de chaque méthode. Sans chaining, le client perd le typage.
// ✅ Bon
const app = new Elysia()
.state('version', 1)
.decorate('db', db)
.get('/health', () => 'ok');
// ❌ Mauvais, les types se perdent
const app = new Elysia();
app.state('version', 1);
app.decorate('db', db);
app.get('/health', () => 'ok');2. Une instance Elysia par module métier
Chaque module est une new Elysia({ prefix, name }). On compose ensuite avec .use().
// modules/members/members.routes.ts
export const membersRoutes = new Elysia({ prefix: '/members', name: 'members' })
.use(authMacro)
.get('/', ({ query }) => listMembers(query), { query: ListMembersQuery })
.get('/:id', ({ params }) => getMember(params.id))
.post('/', ({ body }) => createMember(body), { body: CreateMemberBody });Le name permet le plugin deduplication d'Elysia : si le module est mounté plusieurs fois (pour les tests par ex), il n'est instancié qu'une seule fois.
3. Service Locator via derive / decorate
Au lieu d'importer manuellement les services dans chaque handler, on les injecte via le contexte. Pattern recommandé par la doc Elysia.
// lib/context.ts
export const appContext = new Elysia({ name: 'app-context' })
.decorate('db', db) // services stables (singleton)
.decorate('claude', claudeClient)
.decorate('mailer', resendClient)
.derive(({ request }) => ({ // dérivés par requête
requestId: crypto.randomUUID(),
startedAt: Date.now(),
}));Utilisé dans n'importe quel module :
export const requestsRoutes = new Elysia({ prefix: '/requests', name: 'requests' })
.use(appContext)
.post('/', async ({ db, body, requestId }) => {
return db.insert(request).values({ ...body, traceId: requestId });
});4. Macros pour les guards transverses
Les macro permettent d'attacher des comportements réutilisables aux routes (auth, permissions, rate limiting). Plus propre qu'un beforeHandle répété.
// lib/auth-macro.ts
export const authMacro = new Elysia({ name: 'auth-macro' })
.use(appContext)
.macro({
auth: (mode: 'team' | 'member' | 'organizer' | 'public') => ({
resolve: async ({ headers, cookie, error }) => {
if (mode === 'public') return {};
if (mode === 'team') {
const session = await betterAuth.api.getSession({ headers });
if (!session) return error(401, 'Unauthenticated');
return { user: session.user, session };
}
if (mode === 'member') {
const token = headers['x-magic-token'];
const member = await verifyMemberToken(token);
if (!member) return error(401, 'Invalid member token');
return { member };
}
if (mode === 'organizer') {
const token = headers['x-magic-token'];
const organizer = await verifyOrganizerToken(token);
if (!organizer) return error(401, 'Invalid organizer token');
return { organizer };
}
},
}),
});Usage :
.get('/me', ({ user }) => user, {
auth: 'team',
})Le resolve du macro typiquement injecte des valeurs dans le contexte, donc user est typé en sortie. Eden propage ce typage côté client.
5. Lifecycle hooks dans l'ordre
Elysia applique : onRequest → parse → transform → beforeHandle → handler → afterHandle → mapResponse → onAfterResponse → onError.
Notre usage :
| Hook | Usage Reciprok |
|---|---|
onRequest | Trace ID, log d'entrée (Logixlysia) |
transform | Coercion / normalisation des inputs |
beforeHandle | Permissions fines (déjà couvertes par auth macro) |
afterHandle | Append TimelineEvent pour les mutations métier |
onError | Log GlitchTip, format d'erreur unifié, masquage des erreurs internes |
onAfterResponse | Métriques Prometheus (durée, status), audit log |
6. Schémas TypeBox réutilisables via Elysia.model
Les schémas sont déclarés une fois avec t.Object(...) et référencés par nom. Ça réduit la duplication dans la doc OpenAPI générée et améliore le caching de validation.
export const models = new Elysia({ name: 'models' })
.model({
'member.create': t.Object({
name: t.String({ minLength: 2 }),
postalCode: t.String({ pattern: '^\\d{5}$' }),
email: t.String({ format: 'email' }),
}),
'member.public': t.Object({
id: t.String({ format: 'uuid' }),
code: t.String(),
name: t.String(),
}),
});.post('/', ({ body }) => createMember(body), {
body: 'member.create',
response: 'member.public',
})7. Pas de logique métier dans les handlers
Le handler route → service → repository. Le service contient la règle métier, le repository touche Drizzle. Cohérent avec le standard interne (cf. CLAUDE.md).
Organisation du code
apps/server/src/
├── app.ts # Composition racine de l'Elysia
├── index.ts # Entry point (app.listen)
├── lib/
│ ├── context.ts # appContext
│ ├── auth-macro.ts # requireTeam() + resolveUser()
│ ├── error-format.ts # onError unifié (pino logger)
│ ├── logger.ts # Logger pino partagé
│ ├── rate-limit.ts # Global + createScopedLimiter(scope, cfg)
│ ├── rate-limit-presets.ts # authLimit, webhookLimit, sendLimit, aiLimit
│ └── s3.ts # Client RustFS/S3 partagé
├── modules/
│ ├── auth/ # Handler Better Auth monté sur Elysia
│ ├── members/ # Lieux, photos, tags, groupes
│ ├── rooms/ # Salles + capacités Kactus
│ ├── organizers/ # Organisateurs d'événements
│ ├── requests/ # Demandes + pipeline + results
│ ├── catalog/ # Compose/send + accès public magic token
│ ├── timeline/ # Events append-only + emit()
│ ├── notifications/ # Notifications équipe
│ ├── availability/ # Disponibilités des membres
│ ├── commissions/ # Commissions + règles de calcul
│ ├── magic-tokens/ # Tokens d'accès membres/organisateurs
│ ├── communications/ # Emails Resend + webhook signature Svix
│ ├── whatsapp/ # Meta Cloud API + webhook verify/status
│ ├── ai/ # Chat Claude, tools, knowledge, training
│ ├── search/ # Pipeline filtrage SQL + pgvector
│ ├── uploads/ # RustFS/S3 photos + audios
│ ├── tags/ # Tags globaux + member tags
│ ├── stats/ # Overview, funnel, revenue, top, etc.
│ └── embeddings/ # Pipeline refresh pgvector
├── scripts/
│ └── embeddings-refresh.ts # CLI exécuté en cron
└── __tests__/ # Tests d'intégration (vraie Postgres)Chaque module suit le même pattern strict :
[feature].routes.ts, HTTP uniquement, jamais de logique métier[feature].service.ts, règles métier, jamais de Drizzle direct[feature].repository.ts, accès DB via Drizzle uniquement[feature].service.test.ts, unit tests (repo mocké)index.ts, barrel export uniquement
Composition de l'app
L'app monte les routes dans trois surfaces :
- Hors-groupe :
/healthet webhooks publics (/webhooks/resend,/webhooks/whatsapp) /api: toutes les routes métier protégées parrequireTeam()/api/public: endpoints magic tokens (catalog, qualification, etc.)
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
import logixlysia from "logixlysia";
import { appContext } from "./lib/context";
import { errorFormat } from "./lib/error-format";
import { rateLimit } from "./lib/rate-limit";
import { authRoutes } from "./modules/auth";
import { membersRoutes } from "./modules/members";
// ... 19 modules au total
export const app = new Elysia()
.use(logixlysia({ /* access logs */ }))
.use(cors({ origin: env.CORS_ORIGIN, credentials: true }))
.use(swagger({ path: "/docs", /* scalar UI */ }))
.use(appContext)
.use(errorFormat)
.use(rateLimit({ maxRequests: 100, windowMs: 60_000 })) // Global
.get("/health", () => ({ status: "ok", timestamp: new Date().toISOString() }))
.use(resendWebhookRoutes) // POST /webhooks/resend
.use(whatsappWebhookRoutes) // GET/POST /webhooks/whatsapp
.group("/api", (api) =>
api
.use(authRoutes) // /api/auth/*
.use(membersRoutes) // /api/members/*
.use(roomsRoutes)
.use(organizersRoutes)
.use(requestsRoutes)
.use(timelineRoutes)
.use(commissionsRoutes)
.use(availabilityRoutes)
.use(notificationsRoutes)
.use(magicTokensRoutes)
.use(communicationsRoutes)
.use(aiRoutes)
.use(searchRoutes)
.use(uploadsRoutes)
.use(catalogRoutes)
.use(tagsRoutes)
.use(statsRoutes)
.use(embeddingsRoutes)
.use(whatsappRoutes)
.use(publicCatalogRoutes), // /api/public/catalogs/:token
);
export type App = typeof app;Le export type App = typeof app est ce qui alimente Eden Treaty côté client. Il est exposé via apps/server/package.json avec un exports map :
{
"exports": {
"./app": { "types": "./src/app.ts", "default": "./src/app.ts" }
}
}Côté web, apps/web/package.json dépend de "server": "workspace:*" et l'import devient import type { App } from "server/app".
Client Eden Treaty
import { treaty } from "@elysiajs/eden";
import type { App } from "server/app";
export const api = treaty<App>(
process.env.NEXT_PUBLIC_SERVER_URL ?? "http://localhost:3000",
{
fetch: { credentials: "include" },
},
);
export type Api = typeof api;Important : tous les imports dans apps/server/src/ doivent être relatifs (pas de @/). Le web résout @/ vers son propre src/, donc un import @/lib/context dans le serveur casse l'inférence Eden côté client. Les tests sous __tests__/ peuvent garder @/ puisqu'ils ne sont jamais traversés par le web.
Usage côté composant React (React Query) :
import { useQuery, useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api';
export function MemberCard({ id }: { id: string }) {
const { data, isLoading } = useQuery({
queryKey: ['member', id],
queryFn: async () => {
const { data, error } = await api.members({ id }).get();
if (error) throw error;
return data;
},
});
// …
}
export function useCreateMember() {
return useMutation({
mutationFn: async (body: CreateMemberInput) => {
const { data, error } = await api.members.post(body);
if (error) throw error;
return data;
},
});
}Type inference end-to-end : body est inféré depuis le t.Object côté serveur, data est inféré depuis le retour du handler. Aucun codegen.
Conventions
Nommage des routes
REST-ish, pluriels, verbes HTTP :
| Action | Méthode | Path |
|---|---|---|
| Lister | GET | /members |
| Détail | GET | /members/:id |
| Détail par code | GET | /members/by-code/:code |
| Créer | POST | /members |
| Mettre à jour (partial) | PATCH | /members/:id |
| Supprimer | DELETE | /members/:id |
| Sous-ressource | GET | /members/:id/rooms |
| Action métier | POST | /requests/:id/advance |
Pour les actions qui ne sont pas du CRUD (ex: advance, compose-catalog), on garde un verbe explicite dans le path. C'est plus lisible que de tordre une sémantique REST.
Format d'erreur unifié
// lib/error-format.ts
export const errorFormat = new Elysia({ name: 'error-format' })
.onError(({ code, error, set, request }) => {
captureException(error, { request });
if (code === 'VALIDATION') {
set.status = 400;
return { code: 'VALIDATION_ERROR', message: error.message, details: error.all };
}
if (code === 'NOT_FOUND') {
set.status = 404;
return { code: 'NOT_FOUND', message: error.message };
}
if (code === 'INTERNAL_SERVER_ERROR') {
set.status = 500;
return { code: 'INTERNAL_ERROR', message: 'Une erreur interne est survenue' };
}
set.status = error.status ?? 500;
return { code: error.code ?? 'UNKNOWN', message: error.message };
});Pour les erreurs métier, on lance des erreurs typées :
import { error } from 'elysia';
if (request.status === 'lost') {
return error(409, {
code: 'INVALID_STATUS_TRANSITION',
message: "Impossible d'avancer une demande perdue",
});
}Pagination
Convention systématique :
t.Object({
items: t.Array(t.Ref('member.public')),
total: t.Number(),
page: t.Number(),
pageSize: t.Number(),
})Réponses des mutations
Une mutation retourne l'entité complète mise à jour, pas un { ok: true }. Permet au frontend de mettre à jour son cache React Query directement.
Surfaces principales
Toutes les routes ci-dessous vivent sous /api/* sauf mention contraire. Les webhooks publics sont hors du groupe /api pour des URLs stables.
Members, Rooms, Tags
GET /api/members ?search&status&limit&offset
GET /api/members/:id
GET /api/members/by-code/:code
POST /api/members
PATCH /api/members/:id
DELETE /api/members/:id
POST /api/members/:id/tags { tagId }
DELETE /api/members/:id/tags/:tagId
GET /api/rooms/by-member/:memberId
GET /api/rooms/:id
POST /api/rooms
PATCH /api/rooms/:id
DELETE /api/rooms/:id
POST /api/rooms/:id/capacities { configuration, maxCapacity, notes? }
DELETE /api/rooms/:id/capacities/:configuration
GET /api/tags ?category
GET /api/tags/by-member/:memberId
GET /api/tags/:id
POST /api/tags { name, category }
DELETE /api/tags/:idOrganizers, Requests, Pipeline
GET /api/organizers ?search&limit&offset
GET /api/organizers/:id
POST /api/organizers
PATCH /api/organizers/:id
DELETE /api/organizers/:id
GET /api/requests ?status&limit&offset
GET /api/requests/:id
GET /api/requests/by-code/:code
POST /api/requests
PATCH /api/requests/:id
POST /api/requests/:id/results { memberId }
DELETE /api/requests/:id/results/:memberId
POST /api/requests/:id/results/:memberId/advance { toStage }Catalog, Commissions, Availability
GET /api/catalogs/by-request/:requestId
POST /api/catalogs/:requestId/compose
POST /api/catalogs/:requestId/send
GET /api/commissions/:id
POST /api/commissions { requestId, memberId, amount, ... }
POST /api/commissions/:id/invoice
POST /api/commissions/:id/mark-paid
GET /api/availability/by-member/:memberId?from&to
POST /api/availability { memberId, roomId?, date, slot?, status, ... }
DELETE /api/availability/:idSearch + Embeddings
POST /api/search/members { semanticQuery?, postalCodes?, minCapacity?,
configurations?, dates?, center? }
GET /api/embeddings/status → counts staleMembers + staleKnowledge
POST /api/embeddings/refresh { batch? } → force refreshLe pipeline de search combine hard filter SQL (géo via Haversine, capacité, config, dispos), ranking pgvector sur member.embedding, et à terme un LLM ranking. Voir intelligence/search-strategy pour les détails.
AI (Claude chat + knowledge + training)
POST /api/ai/chat/messages { requestId, content }
GET /api/ai/chat/messages ?requestId
GET /api/ai/knowledge/by-member/:memberId
POST /api/ai/knowledge/entries { memberId, type, content, source }
DELETE /api/ai/knowledge/entries/:id
POST /api/ai/training/correction { requestId?, originalSuggestion, correction, notes? }Le chat passe par @anthropic-ai/sdk avec le beta.messages.toolRunner et 3 business tools déclarés en Zod : search_members, advance_pipeline, draft_email. Quand ANTHROPIC_API_KEY est vide, un mock déterministe prend le relais pour que les tests et le dev local restent fonctionnels. Voir intelligence/ai-architecture.
Communications (email + threads + WhatsApp + notifications)
GET /api/communications/threads/by-request/:requestId
GET /api/communications/threads/:id
POST /api/communications/email/send { requestId, to, subject, bodyHtml, ... }
POST /api/communications/email/inbound ← endpoint manuel (test)
GET /api/notifications ?unreadOnly
POST /api/notifications/:id/read
POST /api/notifications/read-all
GET /api/whatsapp/by-request/:requestId
POST /api/whatsapp/send { requestId?, to, body }Envoi Resend : communications.service.sendEmail() appelle @resend.com/emails, persiste le providerId, émet EMAIL_SENT. Fallback mock si RESEND_API_KEY absent.
Envoi WhatsApp : whatsapp.service.sendText() appelle graph.facebook.com/{version}/{phone_id}/messages, persiste le wamid, émet WHATSAPP_SENT. Fallback mock si credentials absents, prêt pour le jour où Meta Business valide le compte.
Timeline + Stats (dashboard)
GET /api/timeline/by-request/:requestId ?limit&offset
GET /api/stats/overview snapshot complet du dashboard
GET /api/stats/activity ?limit → events récents
GET /api/stats/funnel ?from&to → funnel cumulatif
GET /api/stats/revenue ?months → revenue mensuel paid
GET /api/stats/top-members ?limit&from&to
GET /api/stats/top-organizers ?limit&from&to
GET /api/stats/response-time ?from&to → avg/median/sampleSize
GET /api/stats/win-rate ?from&to → won/lost/rateUploads (RustFS / Cloudflare R2)
POST /api/uploads/member-photos/:memberId → multipart, image/jpeg|png|webp, 10MB
DELETE /api/uploads/member-photos/photo/:photoId
POST /api/uploads/member-photos/:memberId/reorder { photoIds: uuid[] }
POST /api/uploads/audio/:entityId → multipart, audio/*, 50MBMagic tokens (accès public membres/orgas)
POST /api/magic-tokens { scope, scopeId, action?, expiresInSeconds? }
POST /api/magic-tokens/:id/revoke
GET /api/magic-tokens/by-scope/:scopeId
GET /api/public/catalogs/:token → accès anonyme au catalogue envoyéWebhooks publics (hors /api)
POST /webhooks/resend ← events Resend (email.sent, delivered, opened, bounced...)
Signature Svix vérifiée si RESEND_WEBHOOK_SECRET est set
GET /webhooks/whatsapp ← verification challenge Meta (hub.verify_token)
POST /webhooks/whatsapp ← messages inbound + status updates (delivered/read/failed)Health probes (hors /api)
GET /health ← { status: "ok", timestamp }
GET /health/db ← SELECT 1 + latencyMs, 503 si erreur
GET /health/ai ← provider: "anthropic" | "mock", degraded flagConsommés par Uptime Kuma pour le monitoring externe (voir operations/monitoring). /health/ai ne fait pas d'appel à l'API Claude, il vérifie juste que ANTHROPIC_API_KEY est présent et retourne degraded: true quand on tourne en mode mock (dev/CI). Zéro coût par probe.
Rate limiting
Deux couches complémentaires :
Global, rateLimit({ maxRequests: 100, windowMs: 60_000 }) mounted à la racine. 100 req/min par IP sur toute l'API.
Scoped, createScopedLimiter(scope, config) dans lib/rate-limit.ts, attaché par route sensible via beforeHandle. Les scopes actuels sont dans lib/rate-limit-presets.ts :
| Scope | Limite | Attaché à |
|---|---|---|
auth | 10/min | /api/auth/* (brute force login) |
webhook | 60/min | /webhooks/resend, /webhooks/whatsapp (retries Meta/Resend) |
send | 20/min | /api/communications/email/send, /api/whatsapp/send |
ai | 30/min | /api/ai/chat/messages (cap le LLM spend par IP) |
L'état est stocké en mémoire par process, quand on scalera horizontalement, on swappera pour Redis.
Logger structuré
pino centralisé dans lib/logger.ts. Trois modes :
- dev : pino-pretty colorisé, level debug
- test : silent
- prod : JSON sur stdout, level info (prêt pour Loki/Grafana scraping)
Convention d'utilisation :
import { logger } from "../../lib/logger";
logger.info({ userId, requestId }, "ai: message sent");
logger.warn({ code }, "webhook: signature missing");
logger.error({ err, context }, "provider: call failed");Jamais de console.* dans apps/server/src/. Les logs HTTP (access log) restent gérés par logixlysia qui compose au-dessus.
Streaming (chat IA)
Pas encore wire, le chat retourne la réponse finale en one-shot, pas en SSE. Le SDK @anthropic-ai/sdk supporte stream: true + beta.messages.toolRunner avec streaming, qu'on branchera quand le front aura son UI de chat prête.
OpenAPI / Swagger
Auto-généré via @elysiajs/swagger (UI Scalar). Disponible à /docs avec toutes les routes groupées par tag module (Members, Rooms, Stats, WhatsApp, Embeddings, etc.). Sert aussi de documentation pour l'IA quand on lui décrit l'outillage disponible.
Tests
Elysia s'instancie sans serveur HTTP, on appelle directement app.handle(new Request(...)). Pas besoin de supertest.
import { app } from "@/app";
test("GET /api/stats/overview returns a snapshot", async () => {
const res = await app.handle(
new Request("http://localhost/api/stats/overview"),
);
expect(res.status).toBe(200);
});La vraie stratégie de test actuelle :
- Tests unitaires dans chaque module (
*.service.test.ts) qui mockent le repository local avecmock.module("./xxx.repository", ...). Jamais demock.module("@reciprok/db")car le mock persiste globalement dans Bun et contamine les autres fichiers de test. - Tests d'intégration dans
src/__tests__/*.integration.test.tsqui tapent dans une vraie Postgres de test (DATABASE_URL=postgres://postgres:password@localhost:5432/reciprok_test), avec les providers externes (Claude, Resend, WhatsApp, embeddings) en mode mock automatique. - 228 tests au total, tous verts, aucun
.skipnon tracké.
Voir aussi
- ADR-04 : Eden Treaty au lieu de tRPC
intelligence/domain-events: codes d'action déterministes utilisés par les tools IAoperations/auth: modes d'authentificationoperations/concurrency: optimistic locking sur les mutations critiques