My App
API

API design

Eden Treaty + TypeBox + Elysia, conventions de routing, auth, validation, erreurs

Une seule source de vérité : l'app Elysia dans packages/api. Le client Eden Treaty infère les types directement depuis export type App. Aucun artefact généré, aucun schéma dupliqué.

Philosophie

  • Pas de REST hybride bricolé : chaque endpoint est déclaratif avec TypeBox
  • Pas de tRPC : Eden Treaty est le client natif Elysia (cf. ADR-07)
  • Pas de validation runtime dispersée : tout passe par TypeBox côté routes
  • Pas de types manuels dupliqués côté client : l'inférence fait le job

Composition de l'app

packages/api/src/index.ts

packages/api/src/index.ts
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { openapi } from "@elysiajs/openapi";
import { errorHandler } from "./plugins/error-handler";
import { betterAuthPlugin } from "./plugins/auth";
import { cardexModule } from "./modules/cardex/cardex.routes";
import { roomsModule } from "./modules/rooms/rooms.routes";
import { bookingModule } from "./modules/booking/booking.routes";
import { chatModule } from "./modules/chat/chat.routes";
import { aiModule } from "./modules/ai/ai.routes";
import { paymentModule } from "./modules/payment/payment.routes";
import { integrationsModule } from "./modules/integrations/integrations.routes";
import { ticketsModule } from "./modules/tickets/tickets.routes";
import { todayModule } from "./modules/today/today.routes";
import { menuModule } from "./modules/menu/menu.routes";
import { usersModule } from "./modules/users/users.routes";
import { analyticsModule } from "./modules/analytics/analytics.routes";

export const app = new Elysia()
  .use(cors({
    origin: (process.env.CORS_ORIGIN ?? "").split(","),
    credentials: true,
  }))
  .use(openapi({
    path: "/openapi",
    documentation: {
      info: {
        title: "Bell API",
        version: "1.0.0",
        description: "Plateforme de conciergerie hôtelière — éditée par HOAIY",
      },
      tags: [
        { name: "cardex", description: "Guest management" },
        { name: "rooms", description: "Inventory" },
        { name: "booking", description: "Services bookings" },
        { name: "chat", description: "Conversations guest ↔ AI ↔ staff" },
        { name: "ai", description: "AI concierge (Vercel AI SDK via @elysiajs/ai-sdk)" },
        { name: "payment", description: "Stripe Payment Intents" },
        { name: "integrations", description: "PMS adapters (Mews, Opera, ...)" },
      ],
    },
  }))
  .use(errorHandler)
  .use(betterAuthPlugin)
  .use(cardexModule)
  .use(roomsModule)
  .use(bookingModule)
  .use(chatModule)
  .use(aiModule)
  .use(paymentModule)
  .use(integrationsModule)
  .use(ticketsModule)
  .use(todayModule)
  .use(menuModule)
  .use(usersModule)
  .use(analyticsModule);

export type App = typeof app;

L'app est un seul objet Elysia composé. Le type App exporté est l'unique source pour le client Eden Treaty.

Plugins réutilisables

plugins/auth.ts

import { Elysia } from "elysia";
import { auth as betterAuth } from "@bell/auth";

// Plugin qui résout la session depuis le cookie + derive { user, auth }
export const betterAuthPlugin = new Elysia({ name: "auth" })
  .derive({ as: "global" }, async ({ request }) => {
    const session = await betterAuth.api.getSession({ headers: request.headers });
    return {
      user: session?.user ?? null,
      auth: session
        ? {
            userId: session.user.id,
            organizationId: (session.session as any).activeOrganizationId ?? null,
            role: null as string | null, // résolu à la demande via member
          }
        : null,
    };
  });

// Macros : requireAuth, requireStaff, requireAdmin
export const requireAuth = (app: Elysia) =>
  app.onBeforeHandle(({ auth, set }) => {
    if (!auth) {
      set.status = 401;
      return { error: "Authentication required" };
    }
  });

export const requireStaff = (app: Elysia) =>
  app.use(requireAuth).onBeforeHandle(async ({ auth, set }) => {
    // ... lookup member role, 403 si pas staff+
  });

Chaque module utilise le macro approprié via .use(requireStaff) ou .use(requireAuth).

plugins/openapi.ts

Utilise @elysiajs/openapi. La doc Swagger est exposée sur /openapi en dev, protégée par role admin en prod (flag OPENAPI_ENABLED).

plugins/error-handler.ts

Centralise le mapping exception → HTTP status :

export const errorHandler = new Elysia({ name: "error-handler" })
  .error({
    NotFoundError,
    InvalidTransitionError,
    ValidationError,
    ForbiddenError,
  })
  .onError(({ code, error, set }) => {
    if (error instanceof NotFoundError) {
      set.status = 404;
      return { error: error.message };
    }
    if (error instanceof InvalidTransitionError) {
      set.status = 400;
      return { error: error.message, code: "INVALID_TRANSITION" };
    }
    if (error instanceof ForbiddenError) {
      set.status = 403;
      return { error: error.message };
    }
    if (code === "VALIDATION") {
      set.status = 422;
      return { error: "Validation failed", details: error.message };
    }
    // Fallback
    logger.error(error);
    set.status = 500;
    return { error: "Internal server error" };
  });

plugins/pubsub.ts

Wrapper Redis pour publier/souscrire aux events (SSE).

export const pubsub = {
  publish(channel: string, payload: unknown) {
    return redis.publish(channel, JSON.stringify(payload));
  },
  subscribe(channel: string): AsyncIterable<unknown> {
    // Retourne un async iterable que les routes SSE consomment
  },
};

Convention des routes

Préfixes par module

// packages/api/src/modules/cardex/cardex.routes.ts
export const cardexModule = new Elysia({ prefix: "/cardex" })
  .use(requireStaff)
  // tous les endpoints préfixés /cardex/*
  ;

Verbes et paths

ActionPattern
ListeGET /resource
DétailGET /resource/:id
CréationPOST /resource
Update completPUT /resource/:id
Update partielPATCH /resource/:id
SuppressionDELETE /resource/:id
Action métierPOST /resource/action-name (kebab-case)

Exemples d'actions métier :

  • POST /cardex/confirm-arrival
  • POST /cardex/send-check-in-email
  • POST /booking/room-service/update-status
  • POST /integrations/full-sync

Règle : un verbe qui ne mappe pas proprement à un CRUD est une action (POST /resource/action-name). On ne bricole pas en faisant un PUT /resource/:id avec 12 champs qui modifient des trucs différents.

TypeBox schemas

Chaque route déclare son body, ses params, son query, et sa response :

packages/api/src/modules/cardex/cardex.routes.ts
import { Elysia, t } from "elysia";
import { confirmArrivalBody, confirmArrivalResponse } from "./cardex.schemas";

export const cardexModule = new Elysia({ prefix: "/cardex" })
  .use(requireStaff)
  .get("/guests", ({ auth }) =>
    service.getAllGuests({ organizationId: auth.organizationId }),
    {
      query: t.Object({
        search: t.Optional(t.String()),
        vipOnly: t.Optional(t.Boolean()),
        staying: t.Optional(t.Boolean()),
      }),
      response: t.Array(guestSummarySchema),
      detail: { tags: ["cardex"], summary: "List guests of org (with filters)" },
    },
  )
  .post("/confirm-arrival", ({ body, auth }) =>
    service.confirmGuestArrival({
      organizationId: auth.organizationId,
      guestId: body.guestId,
    }),
    {
      body: confirmArrivalBody,
      response: confirmArrivalResponse,
      detail: { tags: ["cardex"], summary: "Mark guest arrived, trigger PMS check-in" },
    },
  );

Macros d'auth par route

Les 4 niveaux d'auth :

MacroVérifieHTTP
.use(requireAuth)Session valide401 sinon
.use(requireAuth).use(requireRole(["guest"]))Guest logged in403 si autre role
.use(requireStaff)Role ∈ staff/manager/admin/owner403 sinon
.use(requireAdmin)Role ∈ admin/owner403 sinon

Utilisé comme :

const readOnly = new Elysia().use(requireAuth);
const staffOnly = new Elysia().use(requireStaff);
const adminOnly = new Elysia().use(requireAdmin);

Client Eden Treaty

Setup

apps/pwa/src/lib/eden.ts
import { treaty } from "@elysiajs/eden";
import type { App } from "@bell/api";

export const eden = treaty<App>(
  typeof window === "undefined"
    ? process.env.SERVER_URL!
    : "/api/eden",
  { fetch: { credentials: "include" } },
);

En dev, /api/eden est proxifié vers http://localhost:3000 via Next.js rewrites (voir ADR-02). En prod, on pointe directement sur bell-api.hoaiy.com.

Appels typiques

// GET
const { data, error } = await eden.cardex.guests.get({
  query: { vipOnly: true },
});

// POST avec body
const { data, error } = await eden.cardex["confirm-arrival"].post({
  guestId: "...",
});

// GET dynamique
const { data, error } = await eden.guests({ id: "abc" }).get();

Intégration TanStack Query

Hook helper dans apps/<app>/src/lib/use-eden-query.ts :

import { useQuery, useMutation } from "@tanstack/react-query";
import { eden } from "./eden";

export function useGuests(opts: { vipOnly?: boolean } = {}) {
  return useQuery({
    queryKey: ["cardex", "guests", opts],
    queryFn: async () => {
      const { data, error } = await eden.cardex.guests.get({ query: opts });
      if (error) throw new Error(String(error.value));
      return data;
    },
  });
}

export function useConfirmArrival() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (guestId: string) => {
      const { data, error } = await eden.cardex["confirm-arrival"].post({ guestId });
      if (error) throw new Error(String(error.value));
      return data;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ["cardex", "guests"] }),
  });
}

Gestion des erreurs

Eden retourne { data, error } sans throw. On normalise au niveau du hook helper :

  • error.status === 401 → refresh session ou redirect login
  • error.status === 403 → toast "action non autorisée"
  • error.status === 422 → affiche les erreurs de validation (par champ via error.value.details)
  • error.status >= 500 → toast "erreur serveur, réessayez"

SSE (Server-Sent Events)

Les endpoints streaming utilisent async function* d'Elysia :

.get("/check-in/stream", async function* ({ auth }) {
  yield sse({ event: "ready", data: { connected: true } });

  for await (const update of pubsub.subscribe(`guest:${auth.userId}:status`)) {
    yield sse({ event: "status", data: update });
  }
}, {
  detail: { tags: ["check-in"], summary: "SSE stream for check-in status updates" },
});

Côté client Eden :

// EventSource natif, le type inféré est correct pour les events
const es = new EventSource("/api/eden/check-in/stream");
es.addEventListener("status", (e) => {
  const update = JSON.parse(e.data);
  if (update.status === "arrived") window.location.replace("/");
});

Voir ADR-03 pour le raisonnement.

OpenAPI / Swagger

  • Dev : ouvert sur /openapi
  • Prod : protégé par role admin + flag OPENAPI_ENABLED=true

Utile pour :

  • Debug incidents (voir exactement quel payload une route attend)
  • Documentation pour partenaires (quand on ouvrira une API key system)
  • Génération d'un SDK tiers (auto-gen via openapi-generator)

Versioning

Pas de versioning d'API au MVP. Eden Treaty garde les types sync entre client et serveur — si on change la signature d'une route, le client explose au build.

Si on ouvre l'API à des partenaires externes plus tard, on introduira un /v1/* prefix et un @elysiajs/versioning plugin.

Lien avec les autres pages

On this page