feat(api): domaine Organization + endpoints /organizations/me

- Migrations 'organizations' (id, name, siret, monthly_volume_bucket, rubis_count, onboarding_completed_at) + alter users (organization_id FK + signature).
- Modèle Organization avec relation hasMany Users, User étendu avec belongsTo Organization.
- Signup transactionnel : crée une org vide ('') puis l'user, puis émet le access token. Le nom de l'org reste vide tant que l'utilisateur n'a pas franchi la première étape de l'onboarding (PATCH /organizations/me).
- Réponses /auth/* alignées sur le contrat SPA AuthSession : { data: { accessToken, expiresAt, user } }. Drop passwordConfirmation (le SPA n'envoie pas ce champ).
- Endpoints :
  - GET /account/profile (déjà), PATCH /account/profile (nouveau, fullName/email/signature).
  - GET /organizations/me + PATCH /organizations/me (name/siret/monthlyVolumeBucket).
- Pose automatique d'onboardingCompletedAt à la première mise en place du nom de l'org — remplace l'astuce 'signature !== null' utilisée côté MSW.
- Transformers convertissent les IDs en string (pour matcher packages/shared/src/types).
- HMR boundaries élargies : transformers/validators/services se rechargent maintenant à chaud (sinon les modifs ne sont pas vues sans restart manuel).
This commit is contained in:
ordinarthur 2026-05-06 13:51:47 +02:00
parent 274f2a8270
commit eeb4ce25b8
15 changed files with 306 additions and 44 deletions

View File

@ -2,28 +2,41 @@ import User from '#models/user'
import { loginValidator } from '#validators/user' import { loginValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import UserTransformer from '#transformers/user_transformer' import UserTransformer from '#transformers/user_transformer'
import env from '#start/env'
import { DateTime } from 'luxon'
export default class AccessTokensController { export default class AccessTokensController {
/**
* POST /auth/login renvoie une AuthSession pour le SPA.
*/
async store({ request, serialize }: HttpContext) { async store({ request, serialize }: HttpContext) {
const { email, password } = await request.validateUsing(loginValidator) const { email, password } = await request.validateUsing(loginValidator)
const user = await User.verifyCredentials(email, password) const user = await User.verifyCredentials(email, password)
const token = await User.accessTokens.create(user) const accessToken = await User.accessTokens.create(user)
const ttlMin = env.get('ACCESS_TOKEN_TTL_MINUTES', 30)
const expiresAt =
accessToken.expiresAt?.toISOString() ??
DateTime.now().plus({ minutes: ttlMin }).toISO()!
return serialize({ return serialize({
accessToken: accessToken.value!.release(),
expiresAt,
user: UserTransformer.transform(user), user: UserTransformer.transform(user),
token: token.value!.release(),
}) })
} }
async destroy({ auth }: HttpContext) { /**
* POST /account/logout révoque le token courant.
*/
async destroy({ auth, response }: HttpContext) {
const user = auth.getUserOrFail() const user = auth.getUserOrFail()
if (user.currentAccessToken) { if (user.currentAccessToken) {
await User.accessTokens.delete(user, user.currentAccessToken.identifier) await User.accessTokens.delete(user, user.currentAccessToken.identifier)
} }
return { response.status(204)
message: 'Logged out successfully', return null
}
} }
} }

View File

@ -1,18 +1,48 @@
import User from '#models/user' import User from '#models/user'
import Organization from '#models/organization'
import { signupValidator } from '#validators/user' import { signupValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import UserTransformer from '#transformers/user_transformer' import UserTransformer from '#transformers/user_transformer'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
import { DateTime } from 'luxon'
export default class NewAccountController { export default class NewAccountController {
async store({ request, serialize }: HttpContext) { /**
* POST /auth/signup
* Crée une organisation vide + un user dans la même transaction,
* puis émet un access token et renvoie une AuthSession.
*
* Le nom de l'org reste vide ("") — c'est la première étape de
* l'onboarding qui le remplit (PATCH /organizations/me).
*/
async store({ request, response, serialize }: HttpContext) {
const { fullName, email, password } = await request.validateUsing(signupValidator) const { fullName, email, password } = await request.validateUsing(signupValidator)
const user = await User.create({ fullName, email, password }) // org + user créés atomiquement, puis le token (qui passe par un client
const token = await User.accessTokens.create(user) // pg séparé via DbAccessTokensProvider — il doit voir l'user commit).
const user = await db.transaction(async (trx) => {
const org = await Organization.create({ name: '' }, { client: trx })
return User.create(
{ email, password, fullName, organizationId: org.id },
{ client: trx }
)
})
const accessToken = await User.accessTokens.create(user)
const ttlMin = env.get('ACCESS_TOKEN_TTL_MINUTES', 30)
const expiresAt =
accessToken.expiresAt?.toISOString() ??
DateTime.now().plus({ minutes: ttlMin }).toISO()!
response.status(201)
// serialize() ajoute lui-même le wrap { data: ... } et unwrap les Items
// qu'il trouve aux clés directes — donc on lui passe les champs à plat.
return serialize({ return serialize({
accessToken: accessToken.value!.release(),
expiresAt,
user: UserTransformer.transform(user), user: UserTransformer.transform(user),
token: token.value!.release(),
}) })
} }
} }

View File

@ -0,0 +1,47 @@
import Organization from '#models/organization'
import OrganizationTransformer from '#transformers/organization_transformer'
import { updateOrganizationValidator } from '#validators/organization'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import { DateTime } from 'luxon'
export default class OrganizationsController {
/**
* GET /organizations/me l'organisation de l'utilisateur courant.
*/
async show({ auth, serialize }: HttpContext) {
const user = auth.getUserOrFail()
if (user.organizationId === null) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
const org = await Organization.findOrFail(user.organizationId)
return serialize(OrganizationTransformer.transform(org))
}
/**
* PATCH /organizations/me onboarding step 2.
* Marque `onboardingCompletedAt` dès qu'un nom est posé pour la
* première fois (heuristique simple : pour l'instant un nom non vide
* suffit à considérer l'organisation comme "configurée").
*/
async update({ auth, request, serialize }: HttpContext) {
const user = auth.getUserOrFail()
if (user.organizationId === null) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
const payload = await request.validateUsing(updateOrganizationValidator)
const org = await Organization.findOrFail(user.organizationId)
const wasUnnamed = org.name.trim().length === 0
org.merge(payload)
if (wasUnnamed && (payload.name?.trim().length ?? 0) > 0 && !org.onboardingCompletedAt) {
org.onboardingCompletedAt = DateTime.now()
}
await org.save()
return serialize(OrganizationTransformer.transform(org))
}
}

View File

@ -1,8 +1,25 @@
import UserTransformer from '#transformers/user_transformer' import UserTransformer from '#transformers/user_transformer'
import { updateProfileValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
export default class ProfileController { export default class ProfileController {
/**
* GET /account/profile
*/
async show({ auth, serialize }: HttpContext) { async show({ auth, serialize }: HttpContext) {
return serialize(UserTransformer.transform(auth.getUserOrFail())) return serialize(UserTransformer.transform(auth.getUserOrFail()))
} }
/**
* PATCH /account/profile
*/
async update({ auth, request, serialize }: HttpContext) {
const user = auth.getUserOrFail()
const payload = await request.validateUsing(updateProfileValidator)
user.merge(payload)
await user.save()
return serialize(UserTransformer.transform(user))
}
} }

View File

@ -0,0 +1,9 @@
import { OrganizationSchema } from '#database/schema'
import { hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import User from '#models/user'
export default class Organization extends OrganizationSchema {
@hasMany(() => User)
declare users: HasMany<typeof User>
}

View File

@ -3,11 +3,17 @@ import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers' import { compose } from '@adonisjs/core/helpers'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid' import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { type AccessToken, DbAccessTokensProvider } from '@adonisjs/auth/access_tokens' import { type AccessToken, DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
export default class User extends compose(UserSchema, withAuthFinder(hash)) { export default class User extends compose(UserSchema, withAuthFinder(hash)) {
static accessTokens = DbAccessTokensProvider.forModel(User) static accessTokens = DbAccessTokensProvider.forModel(User)
declare currentAccessToken?: AccessToken declare currentAccessToken?: AccessToken
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
get initials() { get initials() {
const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@') const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
if (first && last) { if (first && last) {

View File

@ -0,0 +1,20 @@
import type Organization from '#models/organization'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class OrganizationTransformer extends BaseTransformer<Organization> {
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),
name: o.name,
siret: o.siret,
monthlyVolumeBucket: o.monthlyVolumeBucket,
rubisCount: o.rubisCount,
onboardingCompletedAt: o.onboardingCompletedAt?.toISO() ?? null,
createdAt: o.createdAt.toISO()!,
updatedAt: o.updatedAt?.toISO() ?? o.createdAt.toISO()!,
}
}
}

View File

@ -3,13 +3,17 @@ import { BaseTransformer } from '@adonisjs/core/transformers'
export default class UserTransformer extends BaseTransformer<User> { export default class UserTransformer extends BaseTransformer<User> {
toObject() { toObject() {
return this.pick(this.resource, [ const u = this.resource
'id', return {
'fullName', // IDs sérialisés en string — cf. packages/shared/src/types/user.ts
'email', id: String(u.id),
'createdAt', email: u.email,
'updatedAt', fullName: u.fullName,
'initials', organizationId: u.organizationId !== null ? String(u.organizationId) : null,
]) signature: u.signature,
initials: u.initials,
createdAt: u.createdAt.toISO()!,
updatedAt: u.updatedAt?.toISO() ?? u.createdAt.toISO()!,
}
} }
} }

View File

@ -0,0 +1,14 @@
import vine from '@vinejs/vine'
const MONTHLY_VOLUME_BUCKETS = ['moins-10', '10-50', '50-100', '100-200', 'plus-200'] as const
/**
* Validator pour PATCH /organizations/me. Tous les champs optionnels :
* l'utilisateur peut compléter au fil de l'onboarding.
*/
export const updateOrganizationValidator = vine.create({
name: vine.string().minLength(2).maxLength(120).optional(),
// SIRET = 14 chiffres exactement, sinon null pour réinitialiser.
siret: vine.string().regex(/^\d{14}$/).nullable().optional(),
monthlyVolumeBucket: vine.enum(MONTHLY_VOLUME_BUCKETS).nullable().optional(),
})

View File

@ -4,23 +4,32 @@ import vine from '@vinejs/vine'
* Shared rules for email and password. * Shared rules for email and password.
*/ */
const email = () => vine.string().email().maxLength(254) const email = () => vine.string().email().maxLength(254)
const password = () => vine.string().minLength(8).maxLength(32) const password = () => vine.string().minLength(8).maxLength(72)
/** /**
* Validator to use when performing self-signup * Validator pour /auth/signup. Contrat aligné sur le SPA (Zod
* `registerSchema` dans packages/shared). Pas de passwordConfirmation
* côté API : la confirmation visuelle est une affaire de formulaire.
*/ */
export const signupValidator = vine.create({ export const signupValidator = vine.create({
fullName: vine.string().nullable(),
email: email().unique({ table: 'users', column: 'email' }), email: email().unique({ table: 'users', column: 'email' }),
password: password(), password: password(),
passwordConfirmation: password().sameAs('password'), fullName: vine.string().minLength(2).maxLength(120),
}) })
/** /**
* Validator to use before validating user credentials * Validator pour /auth/login.
* during login
*/ */
export const loginValidator = vine.create({ export const loginValidator = vine.create({
email: email(), email: email(),
password: vine.string(), password: vine.string(),
}) })
/**
* Validator pour /account/profile (PATCH). Tous les champs optionnels.
*/
export const updateProfileValidator = vine.create({
fullName: vine.string().minLength(2).maxLength(120).optional(),
email: email().optional(),
signature: vine.string().maxLength(500).optional(),
})

View File

@ -0,0 +1,25 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'organizations'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table.string('name', 120).notNullable().defaultTo('')
table.string('siret', 14).nullable()
table.string('monthly_volume_bucket', 20).nullable()
table.integer('rubis_count').notNullable().defaultTo(0)
// Onboarding terminé = non-null. Tant que c'est null, le SPA renvoie
// l'utilisateur dans le wizard (cf. docs/tech/backend.md §4.2).
table.timestamp('onboarding_completed_at').nullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,30 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'users'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// FK 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()
.nullable()
.references('id')
.inTable('organizations')
.onDelete('CASCADE')
// Signature email utilisée comme footer des relances.
table.text('signature').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('organization_id')
table.dropColumn('signature')
})
}
}

View File

@ -8,18 +8,7 @@ import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
export class AuthAccessTokenSchema extends BaseModel { export class AuthAccessTokenSchema extends BaseModel {
static $columns = [ static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
'abilities',
'createdAt',
'expiresAt',
'hash',
'id',
'lastUsedAt',
'name',
'tokenableId',
'type',
'updatedAt',
] as const
$columns = AuthAccessTokenSchema.$columns $columns = AuthAccessTokenSchema.$columns
@column() @column()
declare abilities: string declare abilities: string
@ -43,8 +32,29 @@ export class AuthAccessTokenSchema extends BaseModel {
declare updatedAt: DateTime | null declare updatedAt: DateTime | null
} }
export class OrganizationSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt'] as const
$columns = OrganizationSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: number
@column()
declare monthlyVolumeBucket: string | null
@column()
declare name: string
@column.dateTime()
declare onboardingCompletedAt: DateTime | null
@column()
declare rubisCount: number
@column()
declare siret: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class UserSchema extends BaseModel { export class UserSchema extends BaseModel {
static $columns = ['createdAt', 'email', 'fullName', 'id', 'password', 'updatedAt'] as const static $columns = ['createdAt', 'email', 'fullName', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const
$columns = UserSchema.$columns $columns = UserSchema.$columns
@column.dateTime({ autoCreate: true }) @column.dateTime({ autoCreate: true })
declare createdAt: DateTime declare createdAt: DateTime
@ -54,8 +64,12 @@ export class UserSchema extends BaseModel {
declare fullName: string | null declare fullName: string | null
@column({ isPrimary: true }) @column({ isPrimary: true })
declare id: number declare id: number
@column()
declare organizationId: number | null
@column({ serializeAs: null }) @column({ serializeAs: null })
declare password: string declare password: string
@column()
declare signature: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true }) @column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null declare updatedAt: DateTime | null
} }

View File

@ -82,7 +82,10 @@
"hotHook": { "hotHook": {
"boundaries": [ "boundaries": [
"./app/controllers/**/*.ts", "./app/controllers/**/*.ts",
"./app/middleware/*.ts" "./app/middleware/*.ts",
"./app/transformers/**/*.ts",
"./app/validators/**/*.ts",
"./app/services/**/*.ts"
] ]
}, },
"prettier": "@adonisjs/prettier-config" "prettier": "@adonisjs/prettier-config"

View File

@ -3,7 +3,9 @@
| Routes file | Routes file
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| The routes file is used for defining the HTTP routes. | Toutes les routes sont sous /api/v1/. Les groupes auth et account sont
| importés depuis le contrôleur généré par Tuyau pour garantir le contrat
| client typé (cf. docs/tech/backend.md §8).
| |
*/ */
@ -17,21 +19,40 @@ router.get('/', () => {
router router
.group(() => { .group(() => {
/**
* Auth public.
*/
router router
.group(() => { .group(() => {
router.post('signup', [controllers.NewAccount, 'store']) router.post('signup', [controllers.NewAccount, 'store']).as('signup')
router.post('login', [controllers.AccessTokens, 'store']) router.post('login', [controllers.AccessTokens, 'store']).as('login')
}) })
.prefix('auth') .prefix('auth')
.as('auth') .as('auth')
/**
* Compte courant auth requise.
*/
router router
.group(() => { .group(() => {
router.get('profile', [controllers.Profile, 'show']) router.get('profile', [controllers.Profile, 'show']).as('profile.show')
router.post('logout', [controllers.AccessTokens, 'destroy']) router.patch('profile', [controllers.Profile, 'update']).as('profile.update')
router.post('logout', [controllers.AccessTokens, 'destroy']).as('logout')
}) })
.prefix('account') .prefix('account')
.as('profile') .as('account')
.use(middleware.auth())
/**
* Organisation rattachée à l'utilisateur courant auth requise.
*/
router
.group(() => {
router.get('me', [controllers.Organizations, 'show']).as('show')
router.patch('me', [controllers.Organizations, 'update']).as('update')
})
.prefix('organizations')
.as('organizations')
.use(middleware.auth()) .use(middleware.auth())
}) })
.prefix('/api/v1') .prefix('/api/v1')