diff --git a/CLAUDE.md b/CLAUDE.md index b859c97..8dbbfc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/apps/api/app/transformers/organization_transformer.ts b/apps/api/app/transformers/organization_transformer.ts index efefa51..3dc4d3c 100644 --- a/apps/api/app/transformers/organization_transformer.ts +++ b/apps/api/app/transformers/organization_transformer.ts @@ -5,9 +5,8 @@ export default class OrganizationTransformer extends BaseTransformer { 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()!, diff --git a/apps/api/database/migrations/1761885935168_create_users_table.ts b/apps/api/database/migrations/1761885935168_create_users_table.ts index dbca083..ce72f45 100644 --- a/apps/api/database/migrations/1761885935168_create_users_table.ts +++ b/apps/api/database/migrations/1761885935168_create_users_table.ts @@ -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() diff --git a/apps/api/database/migrations/1768620764696_create_access_tokens_table.ts b/apps/api/database/migrations/1768620764696_create_access_tokens_table.ts index a3ce197..d8f16aa 100644 --- a/apps/api/database/migrations/1768620764696_create_access_tokens_table.ts +++ b/apps/api/database/migrations/1768620764696_create_access_tokens_table.ts @@ -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') diff --git a/apps/api/database/migrations/1778080000000_create_organizations_table.ts b/apps/api/database/migrations/1778080000000_create_organizations_table.ts index eda1109..a0fd7b7 100644 --- a/apps/api/database/migrations/1778080000000_create_organizations_table.ts +++ b/apps/api/database/migrations/1778080000000_create_organizations_table.ts @@ -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() diff --git a/apps/api/database/migrations/1778080000100_add_organization_to_users_table.ts b/apps/api/database/migrations/1778080000100_add_organization_to_users_table.ts index a545a89..f8b9506 100644 --- a/apps/api/database/migrations/1778080000100_add_organization_to_users_table.ts +++ b/apps/api/database/migrations/1778080000100_add_organization_to_users_table.ts @@ -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') diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index f0718fe..eb3487e 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -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() diff --git a/docs/tech/backend.md b/docs/tech/backend.md index a3486fb..a35ed66 100644 --- a/docs/tech/backend.md +++ b/docs/tech/backend.md @@ -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