diff --git a/apps/api/app/controllers/access_tokens_controller.ts b/apps/api/app/controllers/access_tokens_controller.ts index 153267c..3414769 100644 --- a/apps/api/app/controllers/access_tokens_controller.ts +++ b/apps/api/app/controllers/access_tokens_controller.ts @@ -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 } } diff --git a/apps/api/app/controllers/new_account_controller.ts b/apps/api/app/controllers/new_account_controller.ts index 1251fb7..5c8cc62 100644 --- a/apps/api/app/controllers/new_account_controller.ts +++ b/apps/api/app/controllers/new_account_controller.ts @@ -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(), }) } } diff --git a/apps/api/app/controllers/organizations_controller.ts b/apps/api/app/controllers/organizations_controller.ts new file mode 100644 index 0000000..fa37e7d --- /dev/null +++ b/apps/api/app/controllers/organizations_controller.ts @@ -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)) + } +} diff --git a/apps/api/app/controllers/profile_controller.ts b/apps/api/app/controllers/profile_controller.ts index 1fe82f2..2201de9 100644 --- a/apps/api/app/controllers/profile_controller.ts +++ b/apps/api/app/controllers/profile_controller.ts @@ -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)) + } } diff --git a/apps/api/app/models/organization.ts b/apps/api/app/models/organization.ts new file mode 100644 index 0000000..b63f80b --- /dev/null +++ b/apps/api/app/models/organization.ts @@ -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 +} diff --git a/apps/api/app/models/user.ts b/apps/api/app/models/user.ts index 7bc5a08..e2316f7 100644 --- a/apps/api/app/models/user.ts +++ b/apps/api/app/models/user.ts @@ -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 + get initials() { const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@') if (first && last) { diff --git a/apps/api/app/transformers/organization_transformer.ts b/apps/api/app/transformers/organization_transformer.ts new file mode 100644 index 0000000..efefa51 --- /dev/null +++ b/apps/api/app/transformers/organization_transformer.ts @@ -0,0 +1,20 @@ +import type Organization from '#models/organization' +import { BaseTransformer } from '@adonisjs/core/transformers' + +export default class OrganizationTransformer extends BaseTransformer { + 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()!, + } + } +} diff --git a/apps/api/app/transformers/user_transformer.ts b/apps/api/app/transformers/user_transformer.ts index c772ff7..67b7725 100644 --- a/apps/api/app/transformers/user_transformer.ts +++ b/apps/api/app/transformers/user_transformer.ts @@ -3,13 +3,17 @@ import { BaseTransformer } from '@adonisjs/core/transformers' export default class UserTransformer extends BaseTransformer { 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()!, + } } } diff --git a/apps/api/app/validators/organization.ts b/apps/api/app/validators/organization.ts new file mode 100644 index 0000000..6099baf --- /dev/null +++ b/apps/api/app/validators/organization.ts @@ -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(), +}) diff --git a/apps/api/app/validators/user.ts b/apps/api/app/validators/user.ts index 5c4ff9a..f13d9dd 100644 --- a/apps/api/app/validators/user.ts +++ b/apps/api/app/validators/user.ts @@ -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(), +}) diff --git a/apps/api/database/migrations/1778080000000_create_organizations_table.ts b/apps/api/database/migrations/1778080000000_create_organizations_table.ts new file mode 100644 index 0000000..eda1109 --- /dev/null +++ b/apps/api/database/migrations/1778080000000_create_organizations_table.ts @@ -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) + } +} 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 new file mode 100644 index 0000000..a545a89 --- /dev/null +++ b/apps/api/database/migrations/1778080000100_add_organization_to_users_table.ts @@ -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') + }) + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index 521fa3a..f0718fe 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -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 } diff --git a/apps/api/package.json b/apps/api/package.json index 9cc1235..341d561 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index 29fc17f..5d9564b 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -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')