Aller au contenu

API interne Bricks — architecture & décisions

Comment sont construites les routes /internal/* qui exposent les données Bricks aux applications consommatrices internes (hors monorepo — voir le guide d'intégration). Une route = une tranche verticale : un contrat zod partagé, un contrôleur Nest, un repository SQL — authentifiée par clé d'app, validée en deux temps (zod au fil, idtlt à la ligne DB) et documentée automatiquement. Deux tests socle garantissent que toute route est authentifiée et documentée sans qu'une PR ait à les réécrire.

Les couches

Une requête traverse trois couches, toujours dans le même ordre. Le contrôleur ne contient aucune SQL ; le repository ne connaît aucun détail HTTP.

Couche Rôle
Contrat (zod) Forme du request/response, partagée API ↔ consommateurs @bricks-common/api-communication-internal
Contrôleur (Nest) Auth x-api-key → parse zod → appelle le repo → mappe la réponse + décorateur doc internal-<domaine>/controllers/
Repository (SQL) Requête paramétrée $1 + validation idtlt de la ligne brute repo prod du domaine s'il existe (méthode ajoutée au besoin), sinon internal-<domaine>/repositories/

Réutiliser le repo prod existant

Si la table a déjà son repository (SpecialPurposeVehiculeRepository, ProjectOwnerRepository…), la query y vit — les tests d'intégration /internal couvrent alors du code prod. Un repo internal-* n'existe que pour les projections sans foyer naturel (ex. le détail référentiel 24 colonnes de internal-project). Lecture leniente assumée quand le validateur strict du domaine peut échouer sur une ligne legacy (méthode dédiée, ex. getSpvSummaryByPropertyId).

flowchart TD
    Req(["GET /internal/investors/:id/referral<br/>x-api-key: ..."]):::endpoint
    Auth["assertApiKeyInHeadersAnyOf(headers, consumers)"]:::action
    Key{"clé valide ?"}
    Unauth["401 Unauthorized"]:::failure
    Parse["bodySafeParse_zod<br/>params / query / body"]:::action
    Ok{"zod ok ?"}
    E400["400 validation-body"]:::failure
    Repo["queryPgAndValidate<br/>SQL paramétré $1 + validateur idtlt"]:::action
    Row{"firstRowValidator : 0 ligne ?"}
    E404["404 investor-not-found"]:::failure
    Map["mapping vers la forme du contrat"]:::action
    Res(["réponse typée par le zod du contrat"]):::success

    Req --> Auth --> Key
    Key -- non --> Unauth
    Key -- oui --> Parse --> Ok
    Ok -- non --> E400
    Ok -- oui --> Repo --> Row
    Row -- oui --> E404
    Row -- non --> Map --> Res

    classDef endpoint fill:#4f46e5,color:#fff,stroke:#3730a3
    classDef action fill:#0ea5e9,color:#fff,stroke:#0369a1
    classDef success fill:#10b981,color:#fff,stroke:#047857
    classDef failure fill:#ef4444,color:#fff,stroke:#b91c1c

Le 404 est opt-in

L'étape 0 ligne → 404 n'existe que sur les routes déclarées notFound (ex. referral, identity, balances). Les collections renvoient { items: [] } et l'auto-invest { plan: null } — une absence n'est pas une erreur.

Le contrat partagé (zod)

Chaque route est décrite une seule fois dans le package @bricks-common/api-communication-internal, sous forme d'un descripteur : méthode, path (fonction des params), schémas request et response. C'est la source unique consommée des deux côtés.

// internalInvestorReferral.endpoint.ts
export const internalInvestorReferral_zod = z.object({
  referralCodeOwn: z.string().nullable(),
  referrerCodeUsed: z.string().nullable(),
  referrer: z
    .object({ investorId: z.uuid(), email: z.string(), firstName: z.string().nullable(), lastName: z.string().nullable() })
    .nullable(),
})

export const getInternalInvestorReferralEndpoint = {
  request: {
    method: 'GET',
    path: (investorId: string) => `/internal/investors/${investorId}/referral`,
    params: internalInvestorParams_zod,
  },
  response: internalInvestorReferral_zod,
} as const
flowchart LR
    subgraph contract ["@bricks-common/api-communication-internal"]
      EP["*.endpoint.ts<br/>method / path / params / query / body / response"]:::status
    end
    subgraph api ["API Bricks (projects/api)"]
      Ctrl["Contrôleurs<br/>@ApiZodEndpoint + bodySafeParse_zod"]:::action
      Doc["/internal/docs/json<br/>(protégé x-api-key)"]:::success
    end
    subgraph apps ["Apps consommatrices (hors monorepo)"]
      Client["client typé<br/>openapi-typescript"]:::action
    end

    EP -->|importe| Ctrl
    Ctrl -->|SwaggerModule.createDocument| Doc
    Doc -->|genere| Client
    EP -.->|meme contrat de bout en bout| Client

    classDef action fill:#0ea5e9,color:#fff,stroke:#0369a1
    classDef status fill:#fef3c7,color:#78350f,stroke:#d97706
    classDef success fill:#10b981,color:#fff,stroke:#047857

Authentification — une clé par consommateur

ApiAuthService.assertApiKeyInHeadersAnyOf(headers, consumers) accepte la clé de n'importe lequel des consommateurs autorisés sur la route. Comparaison en temps constant (crypto.timingSafeEqual), et la valeur fournie n'est jamais loggée.

assertApiKeyInHeadersAnyOf(headers: unknown, apiKeyTypes: readonly InternalToolApiKeyType[]) {
  const apiKeys = lift(apiKeyTypes).map(getApiKey).compact().value()
  if (apiKeys.length === 0) return // clés non configurées (hors prod) → no-op

  const validation = object({ 'x-api-key': notEmptyString }).map((_) => _['x-api-key']).validate(headers)
  if (!validation.ok) throw new UnauthorizedException()

  // NB: on ne log pas la valeur fournie pour eviter de fuiter des cles dans les logs
  if (!apiKeys.some((apiKey) => isApiKeyEqual(validation.value, apiKey))) {
    throw new UnauthorizedException()
  }
}

getApiKey mappe chaque consommateur vers sa variable d'env via un match(...).exhaustive() — ajouter un consommateur ne compile pas tant que son mapping n'est pas ajouté.

Consommateur (type doc) Variable d'env
ticketing TICKETING_API_KEY_INTERNAL
ia-whatsapp IA_WHATSAPP_API_KEY_INTERNAL
gestion-defauts GESTION_DEFAUTS_API_KEY_INTERNAL
projet-analyse PROJET_ANALYSE_API_KEY_INTERNAL
ai-agent AI_AGENT_API_KEY_INTERNAL
project-owner-invoicing PROJECT_OWNER_INVOICING_API_KEY_INTERNAL

Une const, deux usages → zéro drift auth/doc

Les listes d'apps autorisées sont des constantes uniques (internal-investor-consumers.ts), réutilisées telles quelles par l'assert runtime et par le décorateur OpenAPI. Impossible que la doc dise « ticketing » pendant que le runtime accepte autre chose.

export const ticketingConsumers = [internalToolApiKeyType['customer-ticketing']] as const
export const customer360Consumers = [
  internalToolApiKeyType['customer-ticketing'],
  internalToolApiKeyType['ia-whatsapp'],
] as const

Doc OpenAPI auto-générée

Un seul décorateur, @ApiZodEndpoint(endpoint, { consumers, notFound?, successStatus? }), attache toute la doc d'une route depuis son descripteur : la liste des apps autorisées (dans la description), le 401, le schéma de réponse de succès (converti via z.toJSONSchema), les params/query/body, et le 404 optionnel. successStatus par défaut à 200 ; le passer à 201 pour un POST qui crée une ressource (le code réel Nest par défaut sur @Post).

export const ApiZodEndpoint = (endpoint: EndpointDoc, opts: EndpointDocOpts) =>
  applyDecorators(
    ApiOperation({ description: `Apps autorisées : ${opts.consumers.join(', ')}` }),
    ApiUnauthorizedResponse({ description: 'x-api-key absente ou invalide' }),
    ApiResponse({ status: opts.successStatus ?? 200, schema: toOpenApi(endpoint.response) }),
    ...(endpoint.request.body == null ? [] : [ApiBody({ schema: toOpenApi(endpoint.request.body) })]),
    ...perPropertyDecorators(endpoint.request.query, (name, propSchema, required) =>
      ApiQuery({ name, required, schema: propSchema })),
    ...perPropertyDecorators(endpoint.request.params, (name, propSchema) =>
      ApiParam({ name, schema: propSchema })),
    ...(opts.notFound == null ? [] : [ApiNotFoundResponse({ description: opts.notFound })]),
  )

La spec est assemblée par setup-internal-openapi-docs.ts : buildInternalOpenApiDocument appelle SwaggerModule.createDocument une fois au boot, filtre les paths par préfixe /internal et mémorise le document — aucun couplage à des modules Nest. Il est ensuite servi en JSON sur /internal/docs/json par InternalCoreController (@Get('docs/json') + @ApiExcludeEndpoint() — l'endpoint ne se documente pas lui-même), derrière la même clé x-api-key que les routes — le contrat n'est pas public. Pas de page HTML, pas de dépendance @fastify/static.

Nouveau contrôleur → controllers: [] de AppModule

Un contrôleur /internal/* enregistré dans app.module.ts apparaît automatiquement dans la doc (filtrage par préfixe de path). C'est le seul geste manuel — et le test de parité ci-dessous échoue avec un message explicite si @ApiZodEndpoint manque.

Validation en deux temps

Deux validateurs, deux frontières, deux libs :

  • zod valide le fil (params/query/body entrants, schéma de réponse) — c'est le contrat partagé, et la seule lib qui sait s'exporter en OpenAPI.
  • idtlt valide la ligne SQL brute côté API uniquement — cohérent avec le reste du repo, et fail-fast au boundary DB : une colonne renommée fait échouer la requête au lieu de propager un undefined silencieux.
// queryPgAndValidate : SQL paramétré + validation idtlt, throw si la forme ne colle pas
const pgResult = await getAppDataSource().query(query, queryParams)
const validation = validator.validate(pgResult)
if (!validation.ok) { Logger.error('Query validation failed', { query, queryParams }); throw new Error('query-validation-failed') }
return validation.value

// firstRowValidator : extrait la 1re ligne, ou `undefined` si 0 ligne (→ le contrôleur teste `== null` → 404)
export const firstRowValidator = <T>(validator: Validator<T>) => array(validator).map((_) => _[0] ?? undefined)

Anatomie d'un module

Exemple internal-investor :

internal-investor/
├─ controllers/
│  ├─ internal-investor-consumers.ts        ← consts d'apps autorisées (source unique)
│  ├─ internal-investor.controller.ts       ← resolve, search, identity, balances, referral…
│  ├─ internal-investor-portfolio.controller.ts  ← portfolio, projects, transactions
│  └─ __tests__/                            ← tests d'intégration métier
├─ repositories/
│  ├─ internal-investor.repository.ts       ← SQL paramétré + validateurs idtlt
│  └─ internal-investor-portfolio.repository.ts
└─ model/
   └─ internal-investor.model.ts            ← validateurs idtlt réutilisés

Pourquoi deux contrôleurs / deux repos ?

La limite 300 lignes/fichier : le périmètre investor (12 routes) est scindé en internal-investor + internal-investor-portfolio, contrôleur et repo en miroir. Un validateur idtlt utilisé une seule fois vit inline dans le repo ; réutilisé, il monte dans model/.

Une tranche verticale complète, du contrat à la ligne SQL :

export const getInternalInvestorReferralEndpoint = {
  request: {
    method: 'GET',
    path: (investorId: string) => `/internal/investors/${investorId}/referral`,
    params: internalInvestorParams_zod,
  },
  response: internalInvestorReferral_zod,
} as const
@Get(':investorId/referral')
@ApiZodEndpoint(getInternalInvestorReferralEndpoint, {
  consumers: ticketingConsumers,
  notFound: 'Investisseur introuvable',
})
async referral(@Headers() headers: unknown, @Param() params: unknown): Promise<InternalInvestorReferral> {
  ApiAuthService.assertApiKeyInHeadersAnyOf(headers, ticketingConsumers)

  const parsed = bodySafeParse_zod(getInternalInvestorReferralEndpoint.request.params, params)
  if (!parsed.ok) return throwApiError(parsed)

  const referral = await InternalInvestorRepository.findReferral(parsed.value.investorId)
  if (referral == null) throw new NotFoundException('investor-not-found')

  return {
    referralCodeOwn: referral.referralCodeOwn,
    referrerCodeUsed: referral.referrerCodeUsed,
    referrer:
      referral.parrainId == null || referral.parrainEmail == null
        ? null
        : { investorId: referral.parrainId, email: referral.parrainEmail, firstName: referral.parrainFirstName, lastName: referral.parrainLastName },
  }
}
async findReferral(investorId: UUID) {
  return queryPgAndValidate({
    query: `
      SELECT rl_own.code AS "referralCodeOwn", rl_used.code AS "referrerCodeUsed",
             referrer.id::text AS "referrerId", referrer.email AS "referrerEmail",
             referrer_profile."firstName" AS "referrerFirstName", referrer_profile."lastName" AS "referrerLastName"
      FROM customers c
      LEFT JOIN "referral-link" rl_used ON rl_used.id = c."referrerLinkId"
      LEFT JOIN customers referrer ON referrer.id = rl_used."customerId"
      LEFT JOIN customer_profile referrer_profile ON referrer_profile."customerId" = referrer.id
      LEFT JOIN "referral-link" rl_own ON rl_own."customerId" = c.id
      WHERE c.id = $1 AND c."deletedAt" IS NULL
      LIMIT 1
    `,
    queryParams: [investorId],
    validator: firstRowValidator(
      object({
        referralCodeOwn: string.nullable().map(mapNullableToUndefined),
        referrerCodeUsed: string.nullable().map(mapNullableToUndefined),
        referrerId: uuid.nullable().map(mapNullableToUndefined),
        referrerEmail: string.nullable().map(mapNullableToUndefined),
        referrerFirstName: string.nullable().map(mapNullableToUndefined),
        referrerLastName: string.nullable().map(mapNullableToUndefined),
      }),
    ),
  })
}

Le SQL cible des tables legacy

Le code et le contrat parlent investor/project, mais le schéma DB porte encore les noms historiques (customers, customer_profile, properties, payment_methods…) — d'où le FROM customers ci-dessus. Dette assumée, expliquée dans Décisions & FAQ.

Segments — SQL opérateur

Cas à part du socle : /internal/segments (module internal-segment) exécute du SQL libre écrit par les opérateurs marketing (segments de broadcasts/campagnes du ticketing), pas des requêtes figées. Trois routes consolidées :

Route Remplace (ticketing) Corps Réponse
POST /internal/segments/recipients runSegmentQuery, runEmailSegmentQuery, runCampaignQuery { query, channel: 'phone'\|'email' } { items: [{ investorId, email?, phone?, firstName?, lastName? }] }
POST /internal/segments/count countSegmentRecipients { query, recipientColumn } { count }
POST /internal/segments/preview testQuery { query, sampleLimit?, recipientColumn? } { columns, rows, totalCount, recipientCount? }

Contrat de sortie d'un segment (route /recipients) : les 5 colonnes firstName, lastName, email, phone, customerId (le wrapper les sélectionne explicitement — une colonne manquante échoue côté PG avec un message clair). La réponse parle investorId (mapping depuis customerId, vocabulaire legacy).

Modèle de sécurité — le rôle PG read-only est LA frontière (consensus Metabase/PostgREST, validé en MAR-284) :

  • Rôle PG read-only dédié (DATABASE_USER_RO/DATABASE_PASS_RO, requis au boot) : SELECT-only, non-superuser, default_transaction_read_only=on, grants par table côté DevOps. Aucune écriture ne passe, quelle que soit la requête (WITH purge AS (DELETE …) SELECT …permission denied). Aucun parsing SQL en code.
  • Pool dédié (getReadOnlyPgPool) : cible le premier DATABASE_SLAVES (read replica = isolation de charge) sinon le master ; max: 5 hors budget du pool applicatif ; même statement_timeout.
  • Accès via queryPgAndValidate({ readOnly: true }) ; pour le preview qui a besoin des column names, { readOnly: true, includeColumns: true } renvoie { rows, columns } (les .fields[] de pg, indispensables sur empty result).
  • recipientColumn est contraint par regex au contrat et quoté à l'interpolation.

Contrat d'erreur : les échecs du SQL opérateur sont des résultats attendus, jamais des 500 — invalid-segment-sql (toute DatabaseError pg : syntaxe, colonne inconnue, timeout, permission, violation read-only — message pg transmis pour le debug opérateur), segment-contract-mismatch (lignes hors contrat 5 colonnes). Le SQL opérateur est toujours encapsulé en derived table, parenthèse fermante sur sa propre ligne (un -- commentaire final la commenterait).

Garde-fous transverses

Deux tests d'intégration vivent dans le module socle (internal-core) et balayent toutes les routes /internal/* enregistrées. Conséquence : une PR qui ajoute une route n'écrit aucun test d'auth ni de doc — le socle les impose globalement. (Le helper extractInternalRoutes lit l'arbre printRoutes de Fastify et exclut /internal/docs : cette route est protégée par clé mais ne se documente pas elle-même — son auth et sa forme sont couvertes par le test internal-openapi-docs.)

const routes = extractInternalRoutes(app.printRoutes({ commonPrefix: false }))
expect(routes.length).toBeGreaterThanOrEqual(12) // 12 routes investor (P1–P5)

for (const route of routes) {
  const path = route.path.replace(/:[^/]+/g, randomUUID()) // l'assert clé tourne avant toute validation de param
  const res = await req()[route.method](path)
  expect(res.status).toBe(401)
}
const res = await req().get('/internal/docs/json').set('x-api-key', ticketingKey())
for (const route of routes) {
  const docPath = route.path.replace(/:([^/]+)/g, '{$1}')
  expect(
    res.body.paths?.[docPath]?.[route.method],
    `${route.method.toUpperCase()} ${docPath} must be documented — add @ApiZodEndpoint on the handler`,
  ).toBeDefined()
}
// + vérifie le schéma de sécurité x-api-key et la description « Apps autorisées : … »

Si la parité casse

Message type : « … must be documented — add @ApiZodEndpoint on the handler ». Neuf fois sur dix : le décorateur @ApiZodEndpoint oublié sur le handler.

Ajouter une route

  1. Contrat — un *.endpoint.ts dans api-communication-internal/src/endpoints/ (request + response zod), exporté depuis endpoints/index.ts. Réutiliser les primitives existantes (internalInvestorParams_zod, isoDate_zod, investorContractType_zod…) plutôt qu'en créer.
  2. Repository — une méthode SQL paramétrée $1 passée à queryPgAndValidate, avec un validateur idtlt (inline si usage unique, sinon dans model/).
  3. Contrôleur — un handler : assertApiKeyInHeadersAnyOf(headers, <consumers>)bodySafeParse_zod → repo → mapping, décoré de @ApiZodEndpoint(endpoint, { consumers, notFound? }).
  4. Nouveau contrôleur seulement — l'ajouter au tableau controllers: [] de app.module.ts (la doc OpenAPI le découvre par préfixe de path, rien d'autre à déclarer).
  5. Tests — les deux transverses couvrent déjà auth + doc ; ajouter un test d'intégration pour la logique métier (cf. tests d'intégration).

Décisions & FAQ

Le « pourquoi » des choix propres à ce chantier (les conventions d'équipe déjà établies — fixtures .build/.seed, NestJS, __new… — ne sont pas reprises ici).

Vocabulaire & découpage

Pourquoi investor/project, alors que le SQL dit encore customers/properties ?

investor/project sont le vocabulaire actuel de l'app ; customer/property sont legacy, encore présents dans le schéma DB. On aligne la surface (contrat + code) maintenant ; renommer la DB est une dette assumée, chantier séparé. D'où le FROM customers dans les repos.

Pourquoi un package api-communication-internal séparé (pas dans l'app, ni dans api-communication) ?

Le contrat zod est la source unique partagée API↔consommateurs : il doit être importable côté client, donc hors de l'app. Séparé du package front/BO pour ne pas le charger de schémas internes (il en dépend tout de même pour les primitives uuid/isoDate_zod).

Pourquoi un module internal-<domaine> par domaine, à plat ?

Trop d'endpoints pour un module unique. Découpe par domaine (axe stable), pas par consommateur — un consommateur touche plusieurs domaines et un endpoint sert plusieurs consommateurs. internal, pas marketing : le support et d'autres outils internes consomment aussi.

Validation & accès aux données

Pourquoi deux libs de validation (zod et idtlt) ?

La nouveauté ici, c'est zod au contrat — seule lib qui s'exporte en OpenAPI (z.toJSONSchema). À la ligne SQL, on garde idtlt, le pattern habituel de l'API (fail-fast au boundary DB). Détail : section Validation en deux temps.

Pourquoi du SQL brut + queryPgAndValidate, pas un ORM ni Kysely ?

$1 paramétré (anti-injection), reprise des requêtes legacy telles quelles, et validation idtlt de la forme. Pas Kysely : une dépendance de plus qui aurait élargi le chantier bien au-delà du raisonnable. TypeORM, lui, est en décommissionnement.

Sécurité & documentation

Pourquoi l'autorisation par clé est-elle déclarée par endpoint ?

Chaque route déclare ses consumers autorisés, via une const réutilisée par l'assert runtime et le décorateur doc (zéro drift). Tous les consommateurs n'ont pas accès à tout.

Pourquoi une doc OpenAPI auto-générée ?

Quasi gratuite (un seul @ApiZodEndpoint), et elle laisse les consommateurs générer un client typé → contrats à jour, intégration rapide. Zéro drift code↔doc, garanti par le test de parité.

Pourquoi /internal/docs/json est-il protégé (et le YAML supprimé) ?

Le contrat révèle tous les chemins, schémas et apps autorisées : il n'est pas public. Un seul format (JSON) ; viewer local côté consommateur si besoin.

Tests

Pourquoi des tests d'intégration ?

On suit la reco d'équipe : coût faible, filet de sécurité. Ils valident le contrat de bout en bout (auth + parse + vrai Postgres via testcontainers/Flyway + mapping). Bénéfice annexe : on construit au passage les fixtures (investor/project) que réutiliseront les futurs endpoints.

Pourquoi deux tests « socle » transverses ?

Pour qu'une PR de route n'ait aucun test d'auth ni de doc à écrire : le sweep 401 et la parité route↔doc s'appliquent globalement (voir Garde-fous transverses).

Gotchas

À connaître

  • Nouveau contrôleur → l'ajouter à controllers: [] dans app.module.ts ; @ApiZodEndpoint obligatoire sur chaque handler, sinon parité rouge.
  • 300 lignes/fichier → split contrôleur + repo (cf. internal-investor / internal-investor-portfolio).
  • Montants toujours en cents (int), cast ::int en SQL — jamais de float.
  • firstRowValidator renvoie undefined (pas null) sur 0 ligne → le contrôleur teste == null (attrape les deux) → 404.
  • notFound est opt-in : ne le déclarer que sur les routes qui peuvent vraiment 404 ; une collection vide reste un 200.
  • Jamais logguer la valeur d'une x-api-key.