chore(api): UUID partout pour les PK et FK

Convention dure : tous les identifiants applicatifs sont des UUID v4 générés par PG (default gen_random_uuid()), aucun increments/serial même pour les tables techniques.

- CLAUDE.md → "Conventions techniques" : règle énoncée explicitement (anti-énumération, multi-tenant, génération côté client, dumps propres).
- docs/tech/backend.md §4.0 : exemple de migration + raisons.
- 4 migrations existantes réécrites en uuid (users, auth_access_tokens, organizations, alter users.organization_id). Les access tokens d'Adonis acceptent un tokenable_id uuid sans changement côté provider.
- Transformers nettoyés : plus de String(id), les UUID sont déjà des string.
- DB régénérée from scratch (migrations sont éditées avant tout déploiement, pas un cas où un autre dev a une DB en prod).
This commit is contained in:
ordinarthur 2026-05-06 13:58:11 +02:00
parent eeb4ce25b8
commit 1d3b6a3f8f
9 changed files with 49 additions and 19 deletions

View File

@ -124,6 +124,10 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
**Décisions cadres** : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG Proxmox existant), ADR-017 (Bearer tokens), ADR-018 (MinIO existant).
### Conventions techniques (cross-cutting)
- **Identifiants : UUID partout.** Toutes les PK et FK applicatives sont des UUID v4 (PG `uuid` avec default `gen_random_uuid()`). **Jamais d'`increments`/`serial`**, même pour les tables internes (auth tokens, sessions, refresh tokens, etc.). Les UUID protègent de l'énumération, simplifient la fédération multi-tenant et évitent les fuites de volumes par incrément. Les transformers exposent les UUID directement en string — pas de cast nécessaire.
## Documents associés
| Fichier | Rôle |

View File

@ -5,9 +5,8 @@ export default class OrganizationTransformer extends BaseTransformer<Organizatio
toObject() {
const o = this.resource
return {
// IDs sérialisés en string pour rester aligné sur le contrat SPA
// (cf. packages/shared/src/types/user.ts).
id: String(o.id),
// UUID natif (cf. CLAUDE.md → Conventions techniques).
id: o.id,
name: o.name,
siret: o.siret,
monthlyVolumeBucket: o.monthlyVolumeBucket,

View File

@ -5,11 +5,11 @@ export default class UserTransformer extends BaseTransformer<User> {
toObject() {
const u = this.resource
return {
// IDs sérialisés en string — cf. packages/shared/src/types/user.ts
id: String(u.id),
// id et organizationId sont des UUID (cf. CLAUDE.md → Conventions techniques).
id: u.id,
email: u.email,
fullName: u.fullName,
organizationId: u.organizationId !== null ? String(u.organizationId) : null,
organizationId: u.organizationId,
signature: u.signature,
initials: u.initials,
createdAt: u.createdAt.toISO()!,

View File

@ -5,7 +5,9 @@ export default class extends BaseSchema {
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
// UUID v4 généré par PG (gen_random_uuid() est built-in depuis PG 13).
// Convention projet : tous les ID sont des UUID, jamais des increments.
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table.string('full_name').nullable()
table.string('email', 254).notNullable().unique()
table.string('password').notNullable()

View File

@ -5,11 +5,12 @@ export default class extends BaseSchema {
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
// PK uuid (cf. convention projet : pas d'increments).
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
// FK vers users — type aligné sur users.id (uuid).
table
.integer('tokenable_id')
.uuid('tokenable_id')
.notNullable()
.unsigned()
.references('id')
.inTable('users')
.onDelete('CASCADE')

View File

@ -5,7 +5,8 @@ export default class extends BaseSchema {
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
// UUID v4 (cf. convention projet).
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table.string('name', 120).notNullable().defaultTo('')
table.string('siret', 14).nullable()
table.string('monthly_volume_bucket', 20).nullable()

View File

@ -5,12 +5,11 @@ export default class extends BaseSchema {
async up() {
this.schema.alterTable(this.tableName, (table) => {
// FK vers organizations. Nullable pour permettre la création d'un
// FK uuid vers organizations. Nullable pour permettre la création d'un
// utilisateur sans org (cas très limite, surtout V1) — en pratique
// le signup crée toujours une org puis l'user dans la même tx.
table
.integer('organization_id')
.unsigned()
.uuid('organization_id')
.nullable()
.references('id')
.inTable('organizations')

View File

@ -19,13 +19,13 @@ export class AuthAccessTokenSchema extends BaseModel {
@column()
declare hash: string
@column({ isPrimary: true })
declare id: number
declare id: string
@column.dateTime()
declare lastUsedAt: DateTime | null
@column()
declare name: string | null
@column()
declare tokenableId: number
declare tokenableId: string
@column()
declare type: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
@ -38,7 +38,7 @@ export class OrganizationSchema extends BaseModel {
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: number
declare id: string
@column()
declare monthlyVolumeBucket: string | null
@column()
@ -63,9 +63,9 @@ export class UserSchema extends BaseModel {
@column()
declare fullName: string | null
@column({ isPrimary: true })
declare id: number
declare id: string
@column()
declare organizationId: number | null
declare organizationId: string | null
@column({ serializeAs: null })
declare password: string
@column()

View File

@ -203,6 +203,30 @@ apps/api/
Le modèle est **scopé par `organization_id` partout** — V1 mono-user mais multi-tenant ready dès le schéma. Tous les Lucid queries doivent filtrer sur `organization_id` (en pratique : un middleware ou un scope global qui injecte ce filtre).
### 4.0 Identifiants — UUID partout
**Convention dure** : toutes les PK et FK applicatives sont des UUID (PG `uuid` avec default `gen_random_uuid()`). **Pas d'`increments`/`serial`**, y compris pour les tables techniques (`auth_access_tokens`, `refresh_tokens`, `import_batches`, etc.).
```ts
// migration
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table.uuid('organization_id').notNullable().references('id').inTable('organizations').onDelete('CASCADE')
```
```ts
// model — id est typé string, le schema generator le déduit du type uuid
@column({ isPrimary: true })
declare id: string
```
Côté transformers, on expose l'UUID tel quel (pas de cast `String(id)`).
**Pourquoi** :
1. **Anti-énumération** : `/invoices/42` révèle qu'on en a au moins 42, ce qui peut leak des volumes business.
2. **Fédération multi-tenant** : pas besoin d'un namespace par tenant pour éviter les collisions, l'UUID est globalement unique.
3. **Génération côté client** : permet d'optimiste-créer un objet (UUID généré côté SPA) avant le round-trip API.
4. **Pas de surprise sur les exports** : un dump CSV qui sort des `id=1,2,3` est un bug latent.
### 4.1 User
```ts