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:
parent
eeb4ce25b8
commit
1d3b6a3f8f
@ -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).
|
**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
|
## Documents associés
|
||||||
|
|
||||||
| Fichier | Rôle |
|
| Fichier | Rôle |
|
||||||
|
|||||||
@ -5,9 +5,8 @@ export default class OrganizationTransformer extends BaseTransformer<Organizatio
|
|||||||
toObject() {
|
toObject() {
|
||||||
const o = this.resource
|
const o = this.resource
|
||||||
return {
|
return {
|
||||||
// IDs sérialisés en string pour rester aligné sur le contrat SPA
|
// UUID natif (cf. CLAUDE.md → Conventions techniques).
|
||||||
// (cf. packages/shared/src/types/user.ts).
|
id: o.id,
|
||||||
id: String(o.id),
|
|
||||||
name: o.name,
|
name: o.name,
|
||||||
siret: o.siret,
|
siret: o.siret,
|
||||||
monthlyVolumeBucket: o.monthlyVolumeBucket,
|
monthlyVolumeBucket: o.monthlyVolumeBucket,
|
||||||
|
|||||||
@ -5,11 +5,11 @@ export default class UserTransformer extends BaseTransformer<User> {
|
|||||||
toObject() {
|
toObject() {
|
||||||
const u = this.resource
|
const u = this.resource
|
||||||
return {
|
return {
|
||||||
// IDs sérialisés en string — cf. packages/shared/src/types/user.ts
|
// id et organizationId sont des UUID (cf. CLAUDE.md → Conventions techniques).
|
||||||
id: String(u.id),
|
id: u.id,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
fullName: u.fullName,
|
fullName: u.fullName,
|
||||||
organizationId: u.organizationId !== null ? String(u.organizationId) : null,
|
organizationId: u.organizationId,
|
||||||
signature: u.signature,
|
signature: u.signature,
|
||||||
initials: u.initials,
|
initials: u.initials,
|
||||||
createdAt: u.createdAt.toISO()!,
|
createdAt: u.createdAt.toISO()!,
|
||||||
|
|||||||
@ -5,7 +5,9 @@ export default class extends BaseSchema {
|
|||||||
|
|
||||||
async up() {
|
async up() {
|
||||||
this.schema.createTable(this.tableName, (table) => {
|
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('full_name').nullable()
|
||||||
table.string('email', 254).notNullable().unique()
|
table.string('email', 254).notNullable().unique()
|
||||||
table.string('password').notNullable()
|
table.string('password').notNullable()
|
||||||
|
|||||||
@ -5,11 +5,12 @@ export default class extends BaseSchema {
|
|||||||
|
|
||||||
async up() {
|
async up() {
|
||||||
this.schema.createTable(this.tableName, (table) => {
|
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
|
table
|
||||||
.integer('tokenable_id')
|
.uuid('tokenable_id')
|
||||||
.notNullable()
|
.notNullable()
|
||||||
.unsigned()
|
|
||||||
.references('id')
|
.references('id')
|
||||||
.inTable('users')
|
.inTable('users')
|
||||||
.onDelete('CASCADE')
|
.onDelete('CASCADE')
|
||||||
|
|||||||
@ -5,7 +5,8 @@ export default class extends BaseSchema {
|
|||||||
|
|
||||||
async up() {
|
async up() {
|
||||||
this.schema.createTable(this.tableName, (table) => {
|
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('name', 120).notNullable().defaultTo('')
|
||||||
table.string('siret', 14).nullable()
|
table.string('siret', 14).nullable()
|
||||||
table.string('monthly_volume_bucket', 20).nullable()
|
table.string('monthly_volume_bucket', 20).nullable()
|
||||||
|
|||||||
@ -5,12 +5,11 @@ export default class extends BaseSchema {
|
|||||||
|
|
||||||
async up() {
|
async up() {
|
||||||
this.schema.alterTable(this.tableName, (table) => {
|
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
|
// 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.
|
// le signup crée toujours une org puis l'user dans la même tx.
|
||||||
table
|
table
|
||||||
.integer('organization_id')
|
.uuid('organization_id')
|
||||||
.unsigned()
|
|
||||||
.nullable()
|
.nullable()
|
||||||
.references('id')
|
.references('id')
|
||||||
.inTable('organizations')
|
.inTable('organizations')
|
||||||
|
|||||||
@ -19,13 +19,13 @@ export class AuthAccessTokenSchema extends BaseModel {
|
|||||||
@column()
|
@column()
|
||||||
declare hash: string
|
declare hash: string
|
||||||
@column({ isPrimary: true })
|
@column({ isPrimary: true })
|
||||||
declare id: number
|
declare id: string
|
||||||
@column.dateTime()
|
@column.dateTime()
|
||||||
declare lastUsedAt: DateTime | null
|
declare lastUsedAt: DateTime | null
|
||||||
@column()
|
@column()
|
||||||
declare name: string | null
|
declare name: string | null
|
||||||
@column()
|
@column()
|
||||||
declare tokenableId: number
|
declare tokenableId: string
|
||||||
@column()
|
@column()
|
||||||
declare type: string
|
declare type: string
|
||||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||||
@ -38,7 +38,7 @@ export class OrganizationSchema extends BaseModel {
|
|||||||
@column.dateTime({ autoCreate: true })
|
@column.dateTime({ autoCreate: true })
|
||||||
declare createdAt: DateTime
|
declare createdAt: DateTime
|
||||||
@column({ isPrimary: true })
|
@column({ isPrimary: true })
|
||||||
declare id: number
|
declare id: string
|
||||||
@column()
|
@column()
|
||||||
declare monthlyVolumeBucket: string | null
|
declare monthlyVolumeBucket: string | null
|
||||||
@column()
|
@column()
|
||||||
@ -63,9 +63,9 @@ export class UserSchema extends BaseModel {
|
|||||||
@column()
|
@column()
|
||||||
declare fullName: string | null
|
declare fullName: string | null
|
||||||
@column({ isPrimary: true })
|
@column({ isPrimary: true })
|
||||||
declare id: number
|
declare id: string
|
||||||
@column()
|
@column()
|
||||||
declare organizationId: number | null
|
declare organizationId: string | null
|
||||||
@column({ serializeAs: null })
|
@column({ serializeAs: null })
|
||||||
declare password: string
|
declare password: string
|
||||||
@column()
|
@column()
|
||||||
|
|||||||
@ -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).
|
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
|
### 4.1 User
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user