My App
Intégrations

PMS adapter pattern

Pattern adapter qui permet à Bell d'être branché à n'importe quel PMS (Mews, Opera, Cloudbeds, ...) via une interface commune

Pattern Adapter : un seul code de sync, branché à N PMS via des adapters qui implémentent une interface commune. C'est le différenciateur technique central de Bell.

Architecture

L'interface IntegrationAdapter

packages/api/src/services/integrations/types.ts
export type AdapterCapability =
  | "rooms"
  | "guests"
  | "reservations"
  | "webhooks"
  | "checkin"        // peut déclencher un check-in PMS
  | "checkout"       // peut déclencher un check-out PMS
  | "charges"        // peut poster des charges
  | "room-status";   // peut updater le status room

export interface IntegrationAdapter {
  readonly provider: string;
  readonly capabilities: Set<AdapterCapability>;

  // Santé / connexion
  testConnection(): Promise<{ success: boolean; propertyName: string; propertyId: string }>;

  // Lecture (sync)
  getRooms?(): Promise<NormalizedRoom[]>;
  getGuests?(ids?: string[]): Promise<NormalizedGuest[]>;
  getReservations?(opts: { states?: ReservationState[] }): Promise<NormalizedReservation[]>;
  getGuestById?(id: string): Promise<NormalizedGuest | null>;
  getReservationById?(id: string): Promise<NormalizedReservation | null>;

  // Webhooks entrants (parser des events)
  parseWebhookEvent?(body: unknown, headers?: Record<string, string>): NormalizedWebhookEvent[];
  verifyWebhookSignature?(body: string, signature: string, secret: string): boolean;

  // Écriture (bridges)
  startReservation?(reservationId: string): Promise<void>;       // check-in PMS
  processReservation?(reservationId: string): Promise<void>;     // check-out PMS
  addOrder?(params: { guestId: string; items: NormalizedOrderItem[]; reservationId?: string }): Promise<{ orderId?: string }>;
  updateRoomStatus?(params: { roomId: string; status: RoomStatus }): Promise<void>;
}

Les types normalisés

export type RoomStatus = "available" | "occupied" | "cleaning" | "maintenance" | "reserved";
export type ReservationState = "inquiry" | "confirmed" | "checked_in" | "checked_out" | "canceled";
export type BookingChannel =
  | "booking" | "expedia" | "airbnb" | "hrs" | "hotelbeds" | "agoda"
  | "direct" | "mews" | "other";

export interface NormalizedGuest {
  externalId: string;
  firstName: string;
  lastName: string;
  email?: string;
  phone?: string;
  isVip?: boolean;
}

export interface NormalizedRoom {
  externalId: string;
  number: string;
  floor?: string;
  type?: string;
  status: RoomStatus;
}

export interface NormalizedReservation {
  externalId: string;
  guestExternalId: string;
  roomExternalId?: string;
  state: ReservationState;
  checkIn: string;                  // ISO date
  checkOut: string;
  source?: string;
  channel?: BookingChannel;
  confirmationNumber?: string;
}

export interface NormalizedOrderItem {
  name: string;
  unitCount: number;
  unitAmount: number;
  currency: string;
  notes?: string;
}

export interface NormalizedWebhookEvent {
  entityType: "guest" | "reservation" | "room" | "order";
  action: "created" | "updated" | "deleted";
  externalId: string;
  data?: unknown;
}

Ces types sont la lingua franca : tous les adapters normalisent vers ces formes, tout le code Bell consomme ces formes. Aucun adapter ne fuit ses types propriétaires vers le core.

Factory

packages/api/src/services/integrations/index.ts
import { MewsAdapter } from "./adapters/mews";
import { OperaAdapter } from "./adapters/opera";

export function createAdapter(integration: Integration): IntegrationAdapter {
  switch (integration.provider) {
    case "mews":
      return new MewsAdapter(integration.credentials, integration.platformUrl);
    case "opera":
      return new OperaAdapter(integration.credentials, integration.platformUrl);
    case "cloudbeds":
      return new CloudbedsAdapter(integration.credentials, integration.platformUrl);
    default:
      throw new UnsupportedProviderError(integration.provider);
  }
}

Pas de dynamic import ou magic — chaque provider est une entry explicite. Le skill bell-pms-adapter automatise l'ajout.

Sync service

IntegrationSyncService est le code commun qui utilise n'importe quel adapter :

packages/api/src/services/integrations/sync.ts
export class IntegrationSyncService {
  constructor(
    private adapter: IntegrationAdapter,
    private organizationId: string,
    private integrationId: string,
  ) {}

  async fullSync() {
    if (this.adapter.capabilities.has("rooms") && this.adapter.getRooms) {
      const rooms = await this.adapter.getRooms();
      for (const r of rooms) await this.syncRoom(r);
    }

    if (this.adapter.capabilities.has("reservations") && this.adapter.getReservations) {
      const reservations = await this.adapter.getReservations({ states: ["confirmed", "checked_in"] });
      // Batch guests pour éviter rate limits
      const uniqueGuestIds = [...new Set(reservations.map((r) => r.guestExternalId))];
      const guestMap = new Map<string, NormalizedGuest>();
      if (this.adapter.getGuests) {
        for (let i = 0; i < uniqueGuestIds.length; i += 1000) {
          const chunk = uniqueGuestIds.slice(i, i + 1000);
          const guests = await this.adapter.getGuests(chunk);
          for (const g of guests) guestMap.set(g.externalId, g);
        }
      }
      for (const res of reservations) {
        await this.syncReservationWithGuest(res, guestMap.get(res.guestExternalId) ?? null);
      }
    }
  }

  private async syncGuest(n: NormalizedGuest) { /* upsert par (externalId, source) ou fallback email */ }
  private async syncRoom(n: NormalizedRoom) { /* upsert par externalRoomId ou fallback roomNumber */ }
  private async syncReservationWithGuest(r: NormalizedReservation, g: NormalizedGuest | null) { /* ... */ }
}

Idempotent : on peut le relancer N fois, il upsert sans duper.

Bridge functions

Le chemin inverse : Bell → PMS. Fire-and-forget via BullMQ pour résilience.

packages/api/src/services/integrations/bridge.ts
import { queues } from "../../jobs/queues";

export async function bridgeCheckIn(organizationId: string, guestId: string) {
  await queues.pmsBridge.add("check-in", { type: "check-in", organizationId, guestId }, {
    attempts: 5,
    backoff: { type: "exponential", delay: 5000 },
  });
}

export async function bridgeCheckOut(organizationId: string, guestId: string) {
  await queues.pmsBridge.add("check-out", { type: "check-out", organizationId, guestId });
}

export async function bridgePostCharges(
  organizationId: string,
  guestId: string,
  items: NormalizedOrderItem[],
) {
  await queues.pmsBridge.add("post-charges", { type: "post-charges", organizationId, guestId, items });
}

export async function bridgeUpdateRoomStatus(
  organizationId: string,
  roomId: string,
  status: RoomStatus,
) {
  await queues.pmsBridge.add("update-room-status", {
    type: "update-room-status",
    organizationId,
    roomId,
    status,
  });
}

Le worker apps/worker consomme queues.pmsBridge et dispatche vers les méthodes correspondantes de l'adapter actif de l'org.

Webhooks entrants

Pour chaque provider qui supporte les webhooks :

apps/server/src/routes/webhooks.ts
.post("/webhooks/:provider", async ({ params, body, headers, set }) => {
  const { provider } = params;

  // 1. Identifier l'intégration (provider + propertyId extrait du body)
  const integration = await findIntegrationByWebhookPayload(provider, body);
  if (!integration) {
    set.status = 404;
    return { error: "Integration not found" };
  }

  // 2. Créer l'adapter
  const adapter = createAdapter(integration);

  // 3. Verify HMAC
  const signature = headers["x-signature"] ?? headers["webhook-signature"] ?? "";
  if (!adapter.verifyWebhookSignature?.(JSON.stringify(body), signature, integration.webhookSecret)) {
    set.status = 401;
    return { error: "Invalid signature" };
  }

  // 4. Parser les events
  const events = adapter.parseWebhookEvent?.(body, headers) ?? [];

  // 5. Enqueue sync pour chaque event (async, response 200 immédiat)
  for (const ev of events) {
    await queues.pmsSync.add("webhook-entity", {
      integrationId: integration.id,
      event: ev,
    });
  }

  return { ok: true, processed: events.length };
});

Ajouter un nouveau PMS

Workflow standardisé (cf. skill bell-pms-adapter) :

  1. Créer packages/api/src/services/integrations/adapters/<pms>.ts avec classe <Pms>Adapter implements IntegrationAdapter
  2. Déclarer les capabilities qu'on supporte (pas obligé de tout implémenter)
  3. Mapper l'API du provider vers les types normalisés (inferChannel() si OTA tracking pertinent)
  4. Ajouter le cas à la factory createAdapter()
  5. Écrire le contract test qui vérifie l'interface :
// packages/api/src/services/integrations/adapters/opera.test.ts
import { runAdapterContractTests } from "../testing/contract";
import { OperaAdapter } from "./opera";

runAdapterContractTests({
  name: "OperaAdapter",
  build: () => new OperaAdapter(mockedCredentials),
  expectedCapabilities: ["rooms", "guests", "reservations", "checkin", "checkout"],
  mocks: operaFixtures,
});

Le helper runAdapterContractTests est partagé : il teste que chaque capability déclarée est bien implémentée, que les normalized returns matchent les types, etc.

Aucune capability = hôtel sans PMS

Un hôtel sans PMS fonctionne : pas d'intégration active, pas de sync, les guests sont créés manuellement dans le cardex Bell. Les bridges bridge* sont no-op si aucun adapter actif pour l'org.

Pourquoi ce pattern, pas autre chose

  • Plugin system (découverte dynamique d'adapters via filesystem) — rejeté, trop magique, types cassés
  • Composition fonctionnelle (pas de classes, juste des objets de méthodes) — acceptable mais les classes sont plus lisibles ici
  • Config-driven (adapter entièrement décrit dans un JSON) — impossible, chaque API PMS a sa spécificité runtime

Classes + interface + factory = simple, typé, extensible. Jamais un PMS n'a forcé à casser le pattern jusqu'ici.

Lien

On this page