feat(api): install + configure bouncer, mail, limiter, drive, bullmq

Stack backend complète selon docs/tech/backend.md §2 :

- @adonisjs/bouncer : configure standard, middleware initialize_bouncer simplifié (API JSON-only, pas d'Edge views).
- @adonisjs/limiter : store Redis par défaut, throttler global défini dans start/limiter.ts.
- @adonisjs/mail : transports SMTP (Mailpit en dev) + Resend (prod).
- @adonisjs/drive : services fs (fallback) + S3 (MinIO en dev, prod plus tard).
- bullmq + ioredis : config queue.ts définit la connection Redis et la liste des queues (ocr, relances, checkins, kpis). Worker à câbler dans le commit suivant.
- @aws-sdk/client-s3 + s3-request-presigner pour le driver flydrive S3.

Pas de @rlanz/bull-queue : peer Adonis 6.5, plus maintenu — on consomme BullMQ directement.
This commit is contained in:
ordinarthur 2026-05-06 13:25:00 +02:00
parent a8c7ab539a
commit 274f2a8270
13 changed files with 1862 additions and 0 deletions

View File

@ -68,3 +68,4 @@ ACCESS_TOKEN_TTL_MINUTES=30
REFRESH_TOKEN_TTL_DAYS=30
COOKIE_DOMAIN=
COOKIE_SECURE=false
LIMITER_STORE=redis

View File

@ -1,6 +1,7 @@
import { indexEntities } from '@adonisjs/core'
import { defineConfig } from '@adonisjs/core/app'
import { generateRegistry } from '@tuyau/core/hooks'
import { indexPolicies } from '@adonisjs/bouncer'
export default defineConfig({
/*
@ -28,6 +29,7 @@ export default defineConfig({
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands'),
() => import('@adonisjs/session/commands'),
() => import('@adonisjs/bouncer/commands')
],
/*
@ -53,6 +55,10 @@ export default defineConfig({
() => import('@adonisjs/cors/cors_provider'),
() => import('@adonisjs/auth/auth_provider'),
() => import('#providers/api_provider'),
() => import('@adonisjs/bouncer/bouncer_provider'),
() => import('@adonisjs/limiter/limiter_provider'),
() => import('@adonisjs/mail/mail_provider'),
() => import('@adonisjs/drive/drive_provider'),
],
/*
@ -111,6 +117,7 @@ export default defineConfig({
transformers: { enabled: true },
}),
generateRegistry(),
indexPolicies()
],
},
})

View File

@ -0,0 +1,23 @@
/*
|--------------------------------------------------------------------------
| Bouncer abilities
|--------------------------------------------------------------------------
|
| You may export multiple abilities from this file and pre-register them
| when creating the Bouncer instance.
|
| Pre-registered policies and abilities can be referenced as a string by their
| name. Also they are must if want to perform authorization inside Edge
| templates.
|
*/
import { Bouncer } from '@adonisjs/bouncer'
/**
* Delete the following ability to start from
* scratch
*/
export const editUser = Bouncer.ability(() => {
return true
})

View File

@ -0,0 +1,37 @@
import * as abilities from '#abilities/main'
import { policies } from '#generated/policies'
import { Bouncer } from '@adonisjs/bouncer'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* Init bouncer middleware is used to create a bouncer instance
* during an HTTP request
*/
export default class InitializeBouncerMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
/**
* Create bouncer instance for the ongoing HTTP request.
* We will pull the user from the HTTP context.
*/
ctx.bouncer = new Bouncer(
() => ctx.auth.user || null,
abilities,
policies
).setContainerResolver(ctx.containerResolver)
// API JSON-only : pas d'intégration Edge views à partager.
return next()
}
}
declare module '@adonisjs/core/http' {
export interface HttpContext {
bouncer: Bouncer<
Exclude<HttpContext['auth']['user'], undefined>,
typeof abilities,
typeof policies
>
}
}

39
apps/api/config/drive.ts Normal file
View File

@ -0,0 +1,39 @@
import { defineConfig, services } from '@adonisjs/drive'
import type { InferDriveDisks } from '@adonisjs/drive/types'
import env from '#start/env'
const driveConfig = defineConfig({
default: env.get('DRIVE_DISK', 's3'),
/**
* Stockage local (filesystem) utilisé en fallback si MinIO indisponible.
* Bucket par défaut : storage/uploads (ignoré par git).
*/
services: {
fs: services.fs({
location: 'storage/uploads',
visibility: 'private',
}),
/**
* MinIO via le driver S3 (S3-compatible).
*/
s3: services.s3({
credentials: {
accessKeyId: env.get('S3_ACCESS_KEY', ''),
secretAccessKey: env.get('S3_SECRET_KEY', ''),
},
endpoint: env.get('S3_ENDPOINT'),
region: env.get('S3_REGION', 'fr-par'),
bucket: env.get('S3_BUCKET', 'rubis-invoices'),
forcePathStyle: env.get('S3_FORCE_PATH_STYLE', true),
visibility: 'private',
}),
},
})
export default driveConfig
declare module '@adonisjs/drive/types' {
export interface DriveDisks extends InferDriveDisks<typeof driveConfig> {}
}

View File

@ -0,0 +1,31 @@
import env from '#start/env'
import { defineConfig, stores } from '@adonisjs/limiter'
import type { InferLimiters } from '@adonisjs/limiter/types'
const limiterConfig = defineConfig({
default: env.get('LIMITER_STORE'),
stores: {
/**
* Redis store to save rate limiting data inside a
* redis database.
*
* It is recommended to use a separate database for
* the limiter connection.
*/
redis: stores.redis({}),
/**
* Memory store could be used during
* testing
*/
memory: stores.memory({})
},
})
export default limiterConfig
declare module '@adonisjs/limiter/types' {
export interface LimitersList extends InferLimiters<typeof limiterConfig> {}
}

46
apps/api/config/mail.ts Normal file
View File

@ -0,0 +1,46 @@
import env from '#start/env'
import { defineConfig, transports } from '@adonisjs/mail'
import type { InferMailers } from '@adonisjs/mail/types'
const mailConfig = defineConfig({
default: env.get('MAIL_DRIVER', 'smtp'),
from: {
address: env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'),
name: env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"),
},
/**
* Variables partagées par tous les templates Edge (logo, URL de base).
*/
globals: {
brandName: "Rubis Sur l'Ongle",
appUrl: env.get('APP_URL'),
},
mailers: {
/**
* SMTP Mailpit en dev (catch-all sur localhost:1025), n'importe quel
* relais SMTP en prod si on ne veut pas de provider tiers.
*/
smtp: transports.smtp({
host: env.get('SMTP_HOST', 'localhost'),
port: env.get('SMTP_PORT', 1025),
// Auth optionnelle — pas requise pour Mailpit
}),
/**
* Resend provider transactionnel par défaut en prod (cf. ADR-021).
*/
resend: transports.resend({
key: env.get('RESEND_API_KEY', ''),
baseUrl: 'https://api.resend.com',
}),
},
})
export default mailConfig
declare module '@adonisjs/mail/types' {
export interface MailersList extends InferMailers<typeof mailConfig> {}
}

29
apps/api/config/queue.ts Normal file
View File

@ -0,0 +1,29 @@
import env from '#start/env'
import { type RedisOptions } from 'ioredis'
/**
* Connexion Redis partagée pour BullMQ. On garde un objet d'options
* (et pas une instance) parce que BullMQ instancie ses propres clients
* pour chaque queue/worker.
*/
export const redisConnection: RedisOptions = {
host: env.get('REDIS_HOST', 'localhost'),
port: env.get('REDIS_PORT', 6379),
password: env.get('REDIS_PASSWORD') || undefined,
// Requis par BullMQ pour les blocking commands.
maxRetriesPerRequest: null,
}
/**
* Liste des queues. La concurrence est appliquée côté worker.
* Ajouter une queue ici ajouter un Worker correspondant dans #start/queue.ts.
*/
export const queueNames = ['ocr', 'relances', 'checkins', 'kpis'] as const
export type QueueName = (typeof queueNames)[number]
export const queueConcurrency: Record<QueueName, number> = {
ocr: 2,
relances: 5,
checkins: 5,
kpis: 1,
}

View File

@ -58,15 +58,23 @@
},
"dependencies": {
"@adonisjs/auth": "^10.1.0",
"@adonisjs/bouncer": "^4.0.0",
"@adonisjs/core": "^7.3.1",
"@adonisjs/cors": "^3.0.0",
"@adonisjs/drive": "^4.0.0",
"@adonisjs/limiter": "^3.0.1",
"@adonisjs/lucid": "^22.4.2",
"@adonisjs/mail": "^10.2.0",
"@adonisjs/session": "^8.1.0",
"@adonisjs/shield": "^9.0.0",
"@aws-sdk/client-s3": "^3.1043.0",
"@aws-sdk/s3-request-presigner": "^3.1043.0",
"@japa/api-client": "^3.2.1",
"@tuyau/core": "^1.2.2",
"@vinejs/vine": "^4.3.1",
"better-sqlite3": "^12.9.0",
"bullmq": "^5.76.5",
"ioredis": "^5.10.1",
"luxon": "^3.7.2",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2"

View File

@ -64,4 +64,11 @@ export default await Env.create(new URL('../', import.meta.url), {
REFRESH_TOKEN_TTL_DAYS: Env.schema.number.optional(),
COOKIE_DOMAIN: Env.schema.string.optional(),
COOKIE_SECURE: Env.schema.boolean.optional(),
/*
|----------------------------------------------------------
| Variables for configuring the limiter package
|----------------------------------------------------------
*/
LIMITER_STORE: Env.schema.enum(['redis', 'memory'] as const)
})

View File

@ -38,6 +38,7 @@ router.use([
() => import('@adonisjs/shield/shield_middleware'),
() => import('@adonisjs/auth/initialize_auth_middleware'),
() => import('#middleware/silent_auth_middleware'),
() => import('#middleware/initialize_bouncer_middleware')
])
/**

16
apps/api/start/limiter.ts Normal file
View File

@ -0,0 +1,16 @@
/*
|--------------------------------------------------------------------------
| Define HTTP limiters
|--------------------------------------------------------------------------
|
| The "limiter.define" method creates an HTTP middleware to apply rate
| limits on a route or a group of routes. Feel free to define as many
| throttle middleware as needed.
|
*/
import limiter from '@adonisjs/limiter/services/main'
export const throttle = limiter.define('global', () => {
return limiter.allowRequests(10).every('1 minute')
})

1617
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff