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:
parent
274f2a8270
commit
eeb4ce25b8
@ -2,28 +2,41 @@ import User from '#models/user'
|
||||
import { loginValidator } from '#validators/user'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import UserTransformer from '#transformers/user_transformer'
|
||||
import env from '#start/env'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export default class AccessTokensController {
|
||||
/**
|
||||
* POST /auth/login — renvoie une AuthSession pour le SPA.
|
||||
*/
|
||||
async store({ request, serialize }: HttpContext) {
|
||||
const { email, password } = await request.validateUsing(loginValidator)
|
||||
|
||||
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({
|
||||
accessToken: accessToken.value!.release(),
|
||||
expiresAt,
|
||||
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()
|
||||
if (user.currentAccessToken) {
|
||||
await User.accessTokens.delete(user, user.currentAccessToken.identifier)
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Logged out successfully',
|
||||
}
|
||||
response.status(204)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,48 @@
|
||||
import User from '#models/user'
|
||||
import Organization from '#models/organization'
|
||||
import { signupValidator } from '#validators/user'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
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 {
|
||||
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 user = await User.create({ fullName, email, password })
|
||||
const token = await User.accessTokens.create(user)
|
||||
// org + user créés atomiquement, puis le token (qui passe par un client
|
||||
// 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({
|
||||
accessToken: accessToken.value!.release(),
|
||||
expiresAt,
|
||||
user: UserTransformer.transform(user),
|
||||
token: token.value!.release(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
47
apps/api/app/controllers/organizations_controller.ts
Normal file
47
apps/api/app/controllers/organizations_controller.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,25 @@
|
||||
import UserTransformer from '#transformers/user_transformer'
|
||||
import { updateProfileValidator } from '#validators/user'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
export default class ProfileController {
|
||||
/**
|
||||
* GET /account/profile
|
||||
*/
|
||||
async show({ auth, serialize }: HttpContext) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
9
apps/api/app/models/organization.ts
Normal file
9
apps/api/app/models/organization.ts
Normal 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>
|
||||
}
|
||||
@ -3,11 +3,17 @@ import hash from '@adonisjs/core/services/hash'
|
||||
import { compose } from '@adonisjs/core/helpers'
|
||||
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
|
||||
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)) {
|
||||
static accessTokens = DbAccessTokensProvider.forModel(User)
|
||||
declare currentAccessToken?: AccessToken
|
||||
|
||||
@belongsTo(() => Organization)
|
||||
declare organization: BelongsTo<typeof Organization>
|
||||
|
||||
get initials() {
|
||||
const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
|
||||
if (first && last) {
|
||||
|
||||
20
apps/api/app/transformers/organization_transformer.ts
Normal file
20
apps/api/app/transformers/organization_transformer.ts
Normal 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()!,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,13 +3,17 @@ import { BaseTransformer } from '@adonisjs/core/transformers'
|
||||
|
||||
export default class UserTransformer extends BaseTransformer<User> {
|
||||
toObject() {
|
||||
return this.pick(this.resource, [
|
||||
'id',
|
||||
'fullName',
|
||||
'email',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'initials',
|
||||
])
|
||||
const u = this.resource
|
||||
return {
|
||||
// IDs sérialisés en string — cf. packages/shared/src/types/user.ts
|
||||
id: String(u.id),
|
||||
email: u.email,
|
||||
fullName: u.fullName,
|
||||
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()!,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
apps/api/app/validators/organization.ts
Normal file
14
apps/api/app/validators/organization.ts
Normal 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(),
|
||||
})
|
||||
@ -4,23 +4,32 @@ import vine from '@vinejs/vine'
|
||||
* Shared rules for email and password.
|
||||
*/
|
||||
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({
|
||||
fullName: vine.string().nullable(),
|
||||
email: email().unique({ table: 'users', column: 'email' }),
|
||||
password: password(),
|
||||
passwordConfirmation: password().sameAs('password'),
|
||||
fullName: vine.string().minLength(2).maxLength(120),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validator to use before validating user credentials
|
||||
* during login
|
||||
* Validator pour /auth/login.
|
||||
*/
|
||||
export const loginValidator = vine.create({
|
||||
email: email(),
|
||||
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(),
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -8,18 +8,7 @@ import { BaseModel, column } from '@adonisjs/lucid/orm'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export class AuthAccessTokenSchema extends BaseModel {
|
||||
static $columns = [
|
||||
'abilities',
|
||||
'createdAt',
|
||||
'expiresAt',
|
||||
'hash',
|
||||
'id',
|
||||
'lastUsedAt',
|
||||
'name',
|
||||
'tokenableId',
|
||||
'type',
|
||||
'updatedAt',
|
||||
] as const
|
||||
static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
|
||||
$columns = AuthAccessTokenSchema.$columns
|
||||
@column()
|
||||
declare abilities: string
|
||||
@ -43,8 +32,29 @@ export class AuthAccessTokenSchema extends BaseModel {
|
||||
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 {
|
||||
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
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime
|
||||
@ -54,8 +64,12 @@ export class UserSchema extends BaseModel {
|
||||
declare fullName: string | null
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
@column()
|
||||
declare organizationId: number | null
|
||||
@column({ serializeAs: null })
|
||||
declare password: string
|
||||
@column()
|
||||
declare signature: string | null
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime | null
|
||||
}
|
||||
|
||||
@ -82,7 +82,10 @@
|
||||
"hotHook": {
|
||||
"boundaries": [
|
||||
"./app/controllers/**/*.ts",
|
||||
"./app/middleware/*.ts"
|
||||
"./app/middleware/*.ts",
|
||||
"./app/transformers/**/*.ts",
|
||||
"./app/validators/**/*.ts",
|
||||
"./app/services/**/*.ts"
|
||||
]
|
||||
},
|
||||
"prettier": "@adonisjs/prettier-config"
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
| 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
|
||||
.group(() => {
|
||||
/**
|
||||
* Auth — public.
|
||||
*/
|
||||
router
|
||||
.group(() => {
|
||||
router.post('signup', [controllers.NewAccount, 'store'])
|
||||
router.post('login', [controllers.AccessTokens, 'store'])
|
||||
router.post('signup', [controllers.NewAccount, 'store']).as('signup')
|
||||
router.post('login', [controllers.AccessTokens, 'store']).as('login')
|
||||
})
|
||||
.prefix('auth')
|
||||
.as('auth')
|
||||
|
||||
/**
|
||||
* Compte courant — auth requise.
|
||||
*/
|
||||
router
|
||||
.group(() => {
|
||||
router.get('profile', [controllers.Profile, 'show'])
|
||||
router.post('logout', [controllers.AccessTokens, 'destroy'])
|
||||
router.get('profile', [controllers.Profile, 'show']).as('profile.show')
|
||||
router.patch('profile', [controllers.Profile, 'update']).as('profile.update')
|
||||
router.post('logout', [controllers.AccessTokens, 'destroy']).as('logout')
|
||||
})
|
||||
.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())
|
||||
})
|
||||
.prefix('/api/v1')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user