Aller au contenu

API Conventions

Nommage (verbes par couche, suffixes de type, booléens, lexique de statuts, vocabulaire de domaine, modules) : voir naming-conventions.mdc.

Regle absolue

Ne JAMAIS refactoriser du code existant sauf demande explicite. Les conventions ci-dessous s'appliquent au nouveau code uniquement. Si un refactoring semble benefique, demander confirmation d'abord.

DO NOT

  • DO NOT utiliser les patterns legacy (tout hors __new/) ni dtos/ → utiliser model/
  • DO NOT utiliser de dependency injection ou factory pattern → import direct des service objects (export const X = {})
  • DO NOT assigner un service object a une variable intermediaire (const x = SomeService, this.x = SomeService) → appeler SomeService.method() directement depuis l'import
  • DO NOT passer un service object migre en parametre de fonction, propriete d'objet (services: { ... }) ou champ de controller → import statique et appel direct. Exceptions : services NestJS @Injectable() non migres, instances runtime (ex. lemonwayApi cree avec options par factory), state watchers / factories avec deps explicites documentees
  • DO NOT utiliser de decorateurs hors controllers et entites NestJS
  • DO NOT utiliser momentdayjs
  • DO NOT utiliser varconst par defaut, let si reassignation
  • DO NOT utiliser de primitifs nus pour montants, IDs, durees → types forts (voir section "Types forts")
  • DO NOT utiliser Omit, Pick, Partial, types conditionnels
  • DO NOT muter les objets → produce d'immer ou redeclarer tous les champs
  • DO NOT utiliser produce() d'immer sur des instances de classes (entites TypeORM/ORM) → immer ne peut pas proxifier les class instances sans [immerable] = true. Utiliser l'assignation directe pour muter des entites ORM dans une transaction
  • DO NOT utiliser le spread ... → redeclarer chaque champ
  • DO NOT try/catch.catch() apres promesse ou Result<T, E>
  • DO NOT throw pour erreurs metier → Err('error-code' as const)
  • DO NOT throw d'exceptions NestJS depuis les services pour des erreurs metier → retourner Err(...) et les mapper dans les controllers
  • DO NOT ajouter d'annotations de type explicites quand TypeScript infere deja le type voulu
  • DO NOT construire des helpers metadata-driven autour de prepareParameterizedQuery pour les casts PG simples → declarer cast: 'jsonb' / cast: 'uuid' explicitement dans le mapping
  • DO NOT return await → retourner directement la promesse
  • DO NOT switch ou if/else enchaines → match().exhaustive() de ts-pattern
  • DO NOT mettre de logique metier dans service/ → seulement dans business/
  • DO NOT mettre de validators dans business/ → seulement dans model/
  • DO NOT extraire un validator utilise une seule fois → l'inliner au callsite
  • DO NOT nommer avec mots generiques (data, info, value, details)
  • DO NOT logger de secrets, tokens, cookies de session ou PII (email, telephone, adresse) dans les payloads → logger l'evenement, pas la valeur. Utiliser des summaries sanitisees pour les payloads d'API externes. Ajouter les headers sensibles (cookie, set-cookie, authorization, x-api-key) aux redact.paths de pino. Les secrets passes en query param (webhook apiKey) sont masques par le serializer req (src/__new/lib/log/redact-req-secrets.ts) — un nouveau secret en query = ajouter sa cle a SENSITIVE_QUERY_KEYS, pas un nouveau mecanisme. Si tu logges une URL brute hors du champ req (interceptor, filter, etc.), passe-la par redactUrlSecrets — le serializer et redact.paths ne couvrent que les logs clés req
  • DO NOT swallow des erreurs dans les cron tasks batch (graphile-worker) → accumuler les IDs en echec pendant la boucle, puis throw a la fin si des items ont echoue. Graphile-worker ne retry que si le job throw. Les items deja commites sont naturellement idempotents (filtres par check de statut au prochain run)
  • DO NOT laisser passer une mutation d'achat quand funding.blocked est set → fige PG pour la réconciliation Redis ; détail : domain-map §Investissement primaire + doc module
  • DO NOT mettre de DEFAULT en DB sur les colonnes applicatives (migrations) → comportement explicite dans le code, l'INSERT fournit toujours la valeur. Exceptions : (1) colonnes techniques (createdAt DEFAULT now(), generated columns) ; (2) DEFAULT temporaire de backfill ADD COLUMN NOT NULL DEFAULT … ; ALTER … DROP DEFAULT → rétro-remplit les lignes existantes en metadata-only (pas de réécriture de table) puis le code reprend la main sur les nouvelles lignes (cf. V038, V202606181612)

DO

  • DO poser CreateAdminActionInterceptor sur toute mutation admin (POST/PUT/PATCH/DELETE) qui change un état — exemptés : simulations compute-only et uploads S3 bruts. M2M (routes x-api-key sans req.user) : source + ApiKeyAuthGuard sur la route, adminId NULL. Routes BO session : adminId via req.user + source optionnel, AdminAuthGuard + même interceptor. Invariant DB : au moins l'un des deux non NULL. Body sensible (PII identité/bancaire) : pickBodyKeys pour ne persister que les clés utiles à l'audit. Service : __new/modules/admin-action/service/admin-action.service.ts
  • DO prefer function name() {} for module-level helpers and named pure functions — reserve const fn = () => for inline callbacks passed as arguments
  • DO factoriser un validator reutilise plusieurs fois dans le meme fichier
  • DO colocate un validator factorise au plus pres de la fonction qui l'utilise

Architecture modulaire

Modules par feature dans src/__new/modules/, flat (pas de sous-modules). Reference : project-financing-request/. Le pourquoi des choix : doc MkDocs projects/api/docs/architecture/.

Couche Role
controllers/ Couche HTTP : auth (guards) + validation payload, rien d'autre. Appel service, throwApiError. Service reduit a un proxy → appel direct du repository autorise
services/ Plomberie : ordre des appels, transactions, assemblage d'entites, checks de flow. Les side effects vivent ici (ou repository). Integration externe propre au module = service du module (ex. XxxS3Service), pas un provider dans __new/lib/
repositories/ Seul acces DB. ~1 table par repository. Types du domaine valides a la lecture. Pas d'interpretation metier de l'absence (get* vs find*, cf naming)
business/ Contraintes logiques/metier pures uniquement : zero side effect, pas d'horloge (now en param). Un check d'une ligne reste inline dans le service ; remapping/parsing utilitaire → lib/. Tests 100% couverture — seule couche où les unit tests sont exigés (cf. §Tests)
mappers/ Projection pure vers la shape Response. Recoit ses sources en parametres ; side effects dans service/repository
guards/ Authn/authz NestJS, enrichit request typee. Seule couche qui throw, avec les controllers
lib/ Utilitaires techniques purs specifiques au module. Ni metier ni plomberie
tasks/, state-watcher/ Entry points additionnels au meme niveau que controllers/ (pas de wrapper entrypoints/)

Modules de l'ere idtlt : dossiers au singulier (repository/, service/) + model/ (validators idtlt) — ne pas renommer.

CRUD simple = controller + repository suffit, pas besoin de service.

__new/lib/ = utilitaires generiques cross-modules (postgres, cache, kysely, providers partages, etc.)

Nommage fichiers : *.repository.ts, *.service.ts, *.model.ts, *.controller.ts, *.mapper.ts, *.guard.ts

Types forts

Pas de primitifs nus → utiliser les branded types de @bricks-common/api-communication. Consulter projects/common/both/api-communication/src/common/ pour la liste complete (nombres, dates, UUIDs, etc.). IDs : Brand<string, 'EntityId'> avec baseUUID.tagged<Id>() (jamais string). Discriminated unions OK pour narrowing.

defined() de space-lift/commonjs/is pour verifier null/undefined. array.push() acceptable dans scope locale.

Patterns de reference

Service object / Repository

Nommage : PascalCase sans suffixe Factory (CustomerioEmailB2bApi, pas customerioEmailB2bApiFactory).

Methodes publiques definies directement dans l'objet exporte (LSP + rename). Helpers prives et deps module-level (logger, axios client) en dessous de l'objet exporte. Ne JAMAIS declarer les methodes publiques comme fonctions separees puis les referencer dans l'objet. Exporter le type : export type MonService = typeof MonService.

Usage : toujours via l'import statique — jamais d'alias, jamais de re-passage en param.

const logger = new Logger('MonService')

export const MonService = {
  async maMethode(id: UUID) {
    return helperPrivee(id)
  },
}

function helperPrivee(id: UUID) {
  logger.log(id)
}

export type MonService = typeof MonService

// appelant (controller, cron, script, helper du meme fichier)
await MonService.maMethode(id)
// MAUVAIS — alias inutile
const monService = MonService
await monService.maMethode(id)

// MAUVAIS — DI d'un service object
async function syncSomething(params: { monService: MonService }) {
  await params.monService.maMethode(id)
}
await syncSomething({ monService: MonService })

// MAUVAIS — champ controller
private readonly monService = MonService
await this.monService.maMethode(id)

// BON
await MonService.maMethode(id)

Model (namespace pattern)

export namespace MonEntite {
  export type Id = Brand<string, 'MonEntiteId'>
  export const id = baseUUID.tagged<Id>()
  export function Id(): Id { return uuidv4() as Id }
  export const validator = object({ id, /* ... */ })
  export type T = typeof validator.T
}

Toujours deriver le type du validator. IDs forts obligatoires pour toutes les entites.

Pas de .default() dans le schema Kysely : un default Zod est invisible au callsite et masque en base une valeur qu'on croit fournie. Le champ reste requis (z.boolean(), pas z.boolean().default(false)) et chaque construction pose la valeur explicitement — le compilateur force alors a ne l'oublier nulle part.

La couche DB duplique les brands/value-objects du contrat, jamais la logique metier. Un schema Kysely ne doit pas dependre d'@bricks-common/api-communication-* pour ses types : un id/value-object qui traverse un contrat se redeclare localement (export const id = z.uuid().brand<'XxxId'>(), jamais import { xxxIdSchema } puis = xxxIdSchema ; idem cle S3, coordonnees, enum de MIME). Deux brands/objets de meme forme sont structurellement compatibles (Zod brande sur la string du tag), donc la copie DB matche la copie contrat aux frontieres view/controller sans aucun cast ; as string as UUID reste interdit. Le contrat garde sa propre copie pour le front — independante par design. Ne brander que les ids/VO au format fiable (UUID persiste ou client-generated via crypto.randomUUID(), cle S3, enum) ; un id au format douteux (rep-${Date.now()}) se fiabilise d'abord. Un contrat ne porte que des schemas de forme, jamais de comportement. Une fonction (defaut, construction, regle metier) n'a rien a faire dans api-communication-* : le contrat decrit la forme des donnees echangees, point. Une valeur par defaut de colonne appartient au back, declaree DANS le schema Kysely a cote de la colonne qu'elle initialise (pendant de Id() : export function createDefaultDocumentsStep(): …).

Controller

@Post('endpoint')
@UserRoles(CustomerRole.CUSTOMER)
@UseGuards(JwtAuthGuard, UserRoleGuard)
async monEndpoint(@IAM() customer: Customer, @Body() body: unknown) {
  const payload = httpValidate(body, validator)
  const result = await MonService.methode(payload)
  return match(result)
    .with({ ok: true }, ({ value }) => value)
    .with({ ok: false, error: 'not-found' }, () => { throw new NotFoundException() })
    .exhaustive()
}

Logger : controllers → private readonly logger = new Logger(this.constructor.name) ; services, scripts et bootstrap pre-app → module-level const logger = new Logger('Name').

Error handling

Critere de decision : l'echec fait-il partie de la reponse qu'on veut exposer au client ?

  • Oui → erreur contractuelle : apiErr({ error, message, type }) dans le service, remontee en Result, traduite par throwApiError(result) au controller (seul point HTTP qui throw). Types exposes : not-found (404), validation-body / validation-params (400), conflict (409) — le payload part dans la reponse.
  • DO : if (!row) return apiErr({ error: 'project-not-found' as const, type: 'not-found' }) → controller : if (!result.ok) return throwApiError(result)
  • DON'T : throw new NotFoundException() depuis un service
  • Non → erreur interne (« ne devrait pas arriver » : invariant casse, S3, provider, driver PG) : throw a la source → 500 opaque (detail loggue, jamais expose). Ne pas la transporter en Result/apiErr : validation-pg / provider-error finissent en InternalServerErrorException() vide, le Result n'a aucun consommateur. Variante : un echec interne degradable peut etre absorbe (champ absent, le reste servi) — decision produit explicite.
  • DO : lib frontiere qui retourne deja un Result apiErr (s3-client) → return throwApiError(result) des le premier consommateur ; sinon throw a la source
  • DON'T : re-emballer un Result en throw new Error(...) ni propager l'apiErr sur N couches
  • Routes admin/BO (contrat bricksoffice) : les toasts BO traduisent le code kebab lu dans message → erreur contractuelle = exception Nest portant le code (throw new NotFoundException(result.error.error)), pas throwApiError (message humain). Erreur interne : regle ci-dessus.
  • business/ (fonctions pures) : Err('error-code' as const) nu — le service remappe en apiErr si ca remonte au controller.
  • DO : return Err('insufficient-balance' as const)
  • DON'T : apiErr(...) dans business/ (couple le calcul pur au format HTTP + logger)
  • Validation runtime DB (Kysely + Zod) : ky_parseRows / ky_parseFirstRow / ky_parseOneRow + pgParse_zod throw sur echec schema → 500 (detail loggue, jamais expose au client). ky_parseFirstRow = firstRowValidator : 0 ligne → undefined, le service mappe en apiErr metier si besoin. ky_parseOneRow = oneRowValidator : exactement 1 ligne sinon throw (0 ou >1)
  • .catch() apres promesse pour convertir en Result
  • jsonFromStringValidator au lieu de try/catch pour JSON

Validation

Tout ce qui entre dans le systeme = valide avec idonttrustlikethat : - HTTP : httpValidate(body, validator) dans controllers - DB : queryPgAndValidate avec validator + transactionHandler - APIs externes : valider la reponse - Nullable : .nullable().map(mapNullableToUndefined) - Regles metier complexes : .and() sur le validator - Validators communs depuis @bricks-common/api-communication - Payloads des endpoints back-office (domaine Projects) : importer le validator depuis @bricks-common/api-communication-bricksoffice, ne pas le re-declarer dans le controller (BRI-782, source unique partagee avec bricksoffice-projects). IDs branded : minter au bord via le creator de l'entite (Model.Id(payload.x), meme idiome que Cents(n)/Percentage(n)), jamais as unknown as au callsite. Si le validator partage doit etre plus strict (ex. positiveInteger.then(cents)), renforcer le validator partage (meme type de sortie), pas une copie locale. Etat API↔package : projects/common/both/api-communication-bricksoffice/src/VALIDATOR-MUTUALIZATION.md.

Transactions

Mutations = safePgTransaction obligatoire. Locking = FOR UPDATE avant tout UPDATE (sauf increments/decrements atomiques). Err(...) dans transaction = rollback automatique, Ok(...) = commit. Transactions imbriquees via { parent }. setOnRollback pour cleanup en cas de rollback. FOR UPDATE SKIP LOCKED uniquement pour workers/queues. Un FOR UPDATE nu sur un SELECT dont le FROM est un CTE ne verrouille rien : Postgres ignore silencieusement les RTE non verrouillables (FOR UPDATE OF <cte> erreur, mais personne ne l'ecrit). Relire la ligne par id dans une requete dediee sur la vraie table — FOR UPDATE est aussi interdit dans un bras d'UNION (erreur de syntaxe). Colonne json/jsonb ecrite par plusieurs flux : ne jamais ecrire depuis un snapshot lu avant l'acquisition du verrou — sinon lost update sur les cles des autres flux. Soit la lecture est deja sous verrou dans la meme transaction, soit on relit la ligne apres l'avoir verrouillee. Le document ecrit reste typé (GiftCard.T, InvestorFinancialDocument…) : pas de patch jsonb construit a la main.

Business

Fonctions pures : params strictement necessaires, pas de DB/Logger/API, memes inputs = memes outputs. Tests 100% couverture dans *.test.ts a cote — seule couche où les unit tests sont exigés. Un unit test qui a besoin de mocks internes (DB, services, providers) = mauvaise cible (cf. §Tests).

Tests

  • Ne JAMAIS créer de tests spontanément pendant une implémentation — le dev est l'initiateur, pas de bruit dans les diffs
  • En fin d'implem touchant un controller / cron task / worker / webhook, poser la question : proposer d'ajouter les tests d'intégration via le skill upsert-integration-tests
  • Unit tests exigés uniquement dans business/ (100% couverture) — skill upsert-unit-tests ; besoin de mocks internes → test d'intégration ou rien
  • Review du diff de tests : skill review-integration-tests

Stack cible Kysely + Zod

Stack par defaut de tout nouveau module (reference : project-financing-request), a privilegier des que le perimetre est raisonnablement independant — l'interop avec TypeORM/idtlt n'est pas optimale. Nee sur l'Espace Financement :

Outil Remplace Usage
Kysely queryPgAndValidate Query builder type-safe. getKysely() pour l'instance, ky_safePgTransaction pour les transactions
Zod idonttrustlikethat Validation + source unique des types. Schemas dans __new/lib/kysely/schemas/
apiErr Err('code') nu Erreurs metier structurees : apiErr({ error, message, type })
throwApiError match(result) dans controller Map type metier -> exception NestJS (seul point HTTP)
space-lift - Ok/Err/Result (partage avec modules existants)

Conventions de nommage

  • Modules : nommage par feature, cf naming-conventions.mdc §8. Le nommage espace-fi est abandonne — ne jamais creer de nouveau nom espace-fi. Tables EF : project_financing_request_* + project_owner_company (ex-espace_fi_spv)
  • Utilitaires Kysely dans __new/lib/kysely/ : prefix ky_ (ex: ky_parseRows, ky_safePgTransaction)
  • Utilitaires generiques dans __new/lib/ : pas de prefix module

Isolation des donnees

Les modules du domaine financement (project-financing-request...) ont leurs propres tables (project_financing_request_*, project_owner_company). Ne JAMAIS reutiliser les tables ou entites de la partie investisseur. Si une donnee existe deja cote investisseur (ex: project_owner, properties, special_purpose_vehicule), creer une table dediee au domaine. Les deux domaines sont completement isoles.

Migrations

Restent sur Flyway (fichiers SQL dans migration/flyway/). Pas d'utilisation des outils de migration Kysely.

  • DO NOT merger une migration Flyway dont le timestamp de version est ≤ au max deja present dans flyway_schema_history sur develop/prod — merge out-of-order → Flyway rejette le deploy (Detected resolved migration not applied) ou marque la version Ignored (prod saute migrate/validate et deploie le code sans le schema). Re-stamper strictement apres le max courant (rename seul, contenu inchange) ; verifier que l'ancienne version n'a jamais ete appliquee avant de renommer.
  • DO NOT dual-read / dual-write des clés JSON legacy « pour la race de deploy » après un rename/migrate Flyway → les migrations passent avant le traffic ; détail : migrations-flyway.md §Où et quand Flyway s'exécute

Patterns

  • Zod = source unique des types : z.infer<typeof schema> pour deriver les types, pas de duplication
  • Branded types : definis dans Zod (.brand<'TypeId'>()), propages dans Kysely automatiquement
  • Validation runtime DB : ky_parseFirstRowT | undefined (firstRowValidator) ; ky_parseOneRowT, throw si ≠1 ligne (oneRowValidator, via z.tuple([schema])) ; ky_parseRowsT[] ; schema invalide → throw
  • Lectures repository : retour direct T | undefined / T[] — pas de Ok/Err/apiErr au repository
  • Mutations = toujours dans une transaction. Les methodes de mutation du repository prennent trx: Transaction<Database> obligatoire (pas optionnel). Le service appelle ky_safePgTransaction et passe trx au repository. Err(...) = rollback, Ok(...) = commit. Les lectures n'ont pas besoin de transaction
  • Erreurs : apiErr({ error: 'code', message: '...', type: 'not-found' }) dans service, throwApiError(err) dans controller. Types exposes : not-found (404), validation-body / validation-params (400), conflict (409). validation-pg / provider-error (500, payload jete) = erreurs internes → preferer throw a la source (cf. Error handling)

DO NOT (stack Kysely + Zod)

  • DO NOT retourner Ok/Err/apiErr depuis un repository Kysely — lectures = T | undefined / T[] uniquement
  • DO NOT utiliser idonttrustlikethat dans les modules sur cette stack
  • DO NOT utiliser queryPgAndValidate dans les modules sur cette stack
  • DO NOT utiliser safePgTransaction (existant) -> utiliser ky_safePgTransaction
  • DO NOT reutiliser des tables/entites de la partie investisseur
  • DO NOT creer de dossier par module dans __new/lib/ (ex. /lib/espace-fi/) — les utilitaires generiques y vivent par theme

Stack Kysely investisseur

Migration progressive des entites TypeORM legacy vers Kysely + Zod. Tables investisseur plates (referral-link, admin_action, …), une entite par PR.

Outil Usage
Kysely getKysely(), ky_safePgTransaction pour les mutations
Zod Schemas dans __new/lib/kysely/schemas/ — namespace XxxPgSchema, validateurs explicites (row, insertRow, …), z.infer — pas de Pick/Omit TS
queryPgAndValidate Ecritures SQL legacy dans une safePgTransaction TypeORM existante (EntityManager)

Enregistrement des tables

Chaque entite migree ajoute une entree dans Database (database.ts), section commentee investisseur. Si KyselyDb (Kysely<Database> | Transaction<Database>) n'est pas encore exporte depuis database.ts, l'ajouter une fois — les repos l'importent, pas d'alias local par fichier.

Repos (__new/modules/<module>/repository/)

  • Import KyselyDb depuis database.ts — pas de type KyselyDb local par repo
  • Lectures hors tx : getKysely()params: { … } pour les filtres
  • Mutations dans ky_safePgTransaction : insertOneWithKysely(params: { trx: KyselyDb; row })insertInto + ky_parseOneRow. Updates : updateOneWithKysely(params: { trx, … })
  • Appelant dans safePgTransaction TypeORM : insertOneWithTypeorm(params: { transac: EntityManager; row })INSERT … RETURNING via transac.query. Lectures/updates en tx : …WithTypeorm(params: { transac, … })
  • Nommage tx : trx cote Kysely, transac cote TypeORM (EntityManager)
  • Le service appelle directement la methode correspondant a sa transaction parente — pas de wrapper de dispatch
  • Ajouter la branche TypeORM seulement si un grep des call sites montre un appelant encore dans safePgTransaction
  • DO NOT wrapper Kysely autour d'un EntityManager — deux stacks, une transaction a la fois

Validation runtime

ky_parseFirstRow / ky_parseOneRow / pgParse_zod : firstRowValidator vs oneRowValidator (cf. pg-return.ts). Schema invalide ou oneRow avec ≠1 ligne → throw (500 + log). find* / get* nullable → ky_parseFirstRow (T | undefined). Reference : project-financing-request/.

Tables legacy partagees — lock idtlt ↔ zod

Regle simple : create/update via idtlt, read via Kysely. Sur une table partagee, les ecritures restent sur le write-side typeorm+idtlt ; un XxxPgSchema est par defaut une slice de lecture.

La DB est la seule source de verite ; idtlt et zod n'en sont que des projections, et la shape interne des colonnes jsonb echappe a toute validation SQL. Chaque XxxPgSchema d'une table ayant un modele idtlt write-side porte donc un lock compile-time (__new/lib/kysely/ky-schema-lock.ts) : la divergence casse tsc au lieu d'attendre la prod.

Le code zod/Kysely… Schema zod Lock
lit la table (le defaut) slice minimale (champs lus uniquement) Assert<Extends<IdtltModel, Slice>>
ecrit encore une colonne aussi ecrite par idtlt (exception transitoire) miroir complet Assert<Exact<IdtltModel, Mirror>>

Brands d'ids d'entite : un seul brand canonique cote zod (package contrat s'il existe, sinon le PgSchema de la table), qui raffine le brand UUID partage (uuid_zod.brand<'XxxId'>() — reste assignable la ou les contrats attendent uuid_zod) ; le modele idtlt l'aliasse (pattern Cents). Jamais deux brands homonymes : les encodages Brand<> helpers et z.$brand sont inassignables entre eux. Reference : property-construction-budget-request.schema.ts (miroir), project-payment-schedule.schema.ts (slice).

Debug d'un lock casse — tsc echoue avec Type 'false' does not satisfy the constraint 'true', sans nommer le champ : decouper l'assertion champ par champ avec le meme helper (Assert<Exact<L['champ'], Z['champ']>> par ligne) dans un fichier temporaire — le champ fautif ressort immediatement.

DO NOT (investisseur Kysely)

  • DO NOT migrer plusieurs entites dans une seule PR infra — poser la foundation, puis une entite par PR
  • DO NOT ajouter une methode TypeORM dans un repo si tous les appelants sont deja dans ky_safePgTransaction
  • DO NOT ajouter un XxxPgSchema sur une table qui a une entite TypeORM/idtlt sans poser son lock (Extends en lecture, Exact en ecriture)
  • DO NOT introduire de nouvelle ecriture Kysely sur une table legacy partagee — create/update passent par idtlt

Routes internes (modules internal-*)

Routes server-to-server pour les apps internes hors monorepo (ticketing care, ia-whatsapp, gestion-des-defauts, ai-agent) qui remplacent leurs requetes SQL directes sur la DB Bricks.

  • Modules : src/__new/modules/internal-*/, routes prefixees /internal/<domain>
  • Contrats : descriptors zod dans @bricks-common/api-communication-internal (meme stack que api-communication-project-owner), bodies via bodySafeParse_zod. Consommateurs hors monorepo → la doc d'endpoints auto-generee (MkDocs) est la reference du contrat
  • Auth : 1 cle API dediee par app consommatrice via ApiAuthService ; routes partagees → assertApiKeyInHeadersAnyOf. Exception : une cle par surface quand plusieurs clients du meme chantier consomment les memes routes (project-owner-invoicing = middleware invoice-axonaut + Bubble). Cles requises en production (refinement env) car cle absente = auth desactivee
  • Donnees : lecture seule sur les tables legacy via queryPgAndValidate + idtlt — PAS Kysely, pas d'isolation de domaine : ces routes lisent les tables investisseur existantes
  • Repos : la query vit dans le repo prod du domaine s'il existe (methode ajoutee au besoin — les tests /internal couvrent alors du code prod) ; repo internal-* seulement pour les projections sans foyer naturel. Lecture leniente via methode dediee quand le validateur strict du domaine peut echouer sur du legacy
  • Reponses : valeurs brutes (cents, dates ISO), formatage FR cote consommateur ; parite 1:1 avec la requete SQL remplacee, pas de pagination. Shaping row → contrat dans views/ des que ce n'est plus un passe-plat (nullable DB → optional contrat, renommage) : le controleur ne construit pas d'objet de reponse
  • Tests : happy path uniquement ; l'auth est couverte une fois par le guard test de /internal/ping, pas re-testee par route
  • SQL operateur (/internal/segments) : execute derriere le role PG read-only dedie (DATABASE_USER_RO/DATABASE_PASS_RO, premier DATABASE_SLAVES sinon master) via queryPgAndValidate({ readOnly: true }) (et { includeColumns: true } quand on a besoin de result.fields[]) — la frontiere secu est le role PG (grants DevOps), pas de parsing SQL en code ; erreurs pg (DatabaseError) → 400 invalid-segment-sql avec le message pg (debug operateur), jamais 500 ; SQL operateur toujours encapsule en derived table, parenthese fermante sur sa propre ligne (un -- commentaire final la commenterait)

Domain documentation maintenance

When modifying business logic in __new/modules/*/business/ or */service/ : - If domain-map.mdc needs updating (new bounded context, changed business rules, new workflow), update it - If an AGENTS.md exists in the module, update it if business rules change - If no AGENTS.md exists and the change is significant, propose creating one