diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4039ff1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index 8f2e64f..aa7de9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,32 @@ .DS_Store node_modules/ + +# Env files (never commit secrets) .env .env.local +.env.*.local + +# Build artefacts +dist/ +build/ +*.tsbuildinfo + +# Tooling caches +.turbo/ +.cache/ +coverage/ +.eslintcache + +# Adonis generated types (regenerated from API source) +apps/api/.adonisjs/ + +# Generated by TanStack Router +apps/web/src/routeTree.gen.ts + +# Generated by MSW (vendored worker) +apps/web/public/mockServiceWorker.js + +# Editor +.vscode/ +.idea/ +*.swp diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 0000000..7969e62 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,4 @@ +{ + "*.{ts,tsx,js,jsx}": ["prettier --write", "eslint --fix"], + "*.{json,md,css,yml,yaml}": ["prettier --write"] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9ef086f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +node_modules +dist +build +.turbo +.adonisjs +coverage +pnpm-lock.yaml +landing/index.html +**/routeTree.gen.ts +**/*.gen.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..064f847 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "bracketSpacing": true, + "endOfLine": "lf" +} diff --git a/CLAUDE.md b/CLAUDE.md index 26267f9..b859c97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,9 +109,20 @@ Voir `/docs/decisions.md` pour le log complet avec rationale. ## Stack technique -À confirmer avec Arthur. Stack choisie mais pas encore documentée. *À remplir lors de la prochaine session technique.* +| Couche | Choix | Source | +|---|---|---| +| Backend | **AdonisJS v7** (TS, MVC, Lucid ORM, auth & jobs intégrés) | ADR-014 | +| Frontend | **React + Vite** | ADR-014 | +| Routing client | **TanStack Router** | ADR-014 | +| State serveur | **TanStack Query** | ADR-014 | +| Base de données | **PostgreSQL** | ADR-014 | +| Hosting | **Proxmox + K3s** (perso) | ADR-014 | +| OCR provider | à benchmarker | ADR-020 (en attente) | +| Email outbound | à benchmarker | ADR-021 (en attente) | -Ce qu'on sait : TypeScript, le reste à formaliser (framework, DB, OCR provider, email provider, hosting, jobs). +**Architecture** : monorepo (`apps/api` + `apps/web` + `packages/shared`), API REST AdonisJS Bearer-auth, SPA React/Vite séparé, PG et MinIO existants sur LXC Proxmox. Détails dans `/docs/tech/architecture.md`. + +**Décisions cadres** : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG Proxmox existant), ADR-017 (Bearer tokens), ADR-018 (MinIO existant). ## Documents associés @@ -128,6 +139,7 @@ Ce qu'on sait : TypeScript, le reste à formaliser (framework, DB, OCR provider, | `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP | | `/docs/brand-identity.html` | Présentation visuelle de la marque (4 logos, applications) | | `/docs/munitions-marketing.md` | Stats marché, concurrents, positionnement, copy | +| `/docs/tech/architecture.md` | Architecture technique : composants, flux, topologie, conventions | | `/Dockerfile` | Build nginx-alpine servant `/landing/` sur port 80 | | `/k3s/` | Manifests Kubernetes (namespace, deployment, service) | | `/.claude/deploy-memory.md` | Procédure de déploiement (Gitea CI ou manuel) | diff --git a/apps/api/.editorconfig b/apps/api/.editorconfig new file mode 100644 index 0000000..f830f40 --- /dev/null +++ b/apps/api/.editorconfig @@ -0,0 +1,22 @@ +# http://editorconfig.org + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.json] +insert_final_newline = unset + +[**.min.js] +indent_style = unset +insert_final_newline = unset + +[MakeFile] +indent_style = space + +[*.md] +trim_trailing_whitespace = false diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..79a6cf8 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,18 @@ +# Node +TZ=UTC +PORT=3333 +HOST=localhost +NODE_ENV=development + +# App +LOG_LEVEL=info +APP_KEY= +APP_URL=http://${HOST}:${PORT} + +# Session +SESSION_DRIVER=cookie + +#-------------------------------------------------------------------- +# CORS (configure allowed origins for API access) +#-------------------------------------------------------------------- +# CORS_ORIGIN=http://localhost:5173,http://localhost:3000 \ No newline at end of file diff --git a/apps/api/.env.test b/apps/api/.env.test new file mode 100644 index 0000000..28d2da0 --- /dev/null +++ b/apps/api/.env.test @@ -0,0 +1 @@ +SESSION_DRIVER=memory diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 0000000..dd7579a --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,26 @@ +# Dependencies and AdonisJS build +node_modules +build +tmp/* +!tmp/.gitkeep + +# Secrets +.env +.env.local +.env.production.local +.env.development.local + +# Frontend assets compiled code +public/assets + +# Build tools specific +npm-debug.log +yarn-error.log + +# Editors specific +.fleet +.idea +.vscode + +# Platform specific +.DS_Store diff --git a/apps/api/.prettierignore b/apps/api/.prettierignore new file mode 100644 index 0000000..8e91c7d --- /dev/null +++ b/apps/api/.prettierignore @@ -0,0 +1,3 @@ +.adonisjs +node_modules +build diff --git a/apps/api/ace.js b/apps/api/ace.js new file mode 100644 index 0000000..0eefd20 --- /dev/null +++ b/apps/api/ace.js @@ -0,0 +1,27 @@ +/* +|-------------------------------------------------------------------------- +| JavaScript entrypoint for running ace commands +|-------------------------------------------------------------------------- +| +| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD +| PROCESS. +| +| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build +| +| Since, we cannot run TypeScript source code using "node" binary, we need +| a JavaScript entrypoint to run ace commands. +| +| This file registers the "ts-node/esm" hook with the Node.js module system +| and then imports the "bin/console.ts" file. +| +*/ + +/** + * Register hook to process TypeScript files using @poppinss/ts-exec + */ +import '@poppinss/ts-exec' + +/** + * Import ace console entrypoint + */ +await import('./bin/console.js') diff --git a/apps/api/adonisrc.ts b/apps/api/adonisrc.ts new file mode 100644 index 0000000..a96390d --- /dev/null +++ b/apps/api/adonisrc.ts @@ -0,0 +1,116 @@ +import { indexEntities } from '@adonisjs/core' +import { defineConfig } from '@adonisjs/core/app' +import { generateRegistry } from '@tuyau/core/hooks' + +export default defineConfig({ + /* + |-------------------------------------------------------------------------- + | Experimental flags + |-------------------------------------------------------------------------- + | + | The following features will be enabled by default in the next major release + | of AdonisJS. You can opt into them today to avoid any breaking changes + | during upgrade. + | + */ + experimental: {}, + + /* + |-------------------------------------------------------------------------- + | Commands + |-------------------------------------------------------------------------- + | + | List of ace commands to register from packages. The application commands + | will be scanned automatically from the "./commands" directory. + | + */ + commands: [ + () => import('@adonisjs/core/commands'), + () => import('@adonisjs/lucid/commands'), + () => import('@adonisjs/session/commands'), + ], + + /* + |-------------------------------------------------------------------------- + | Service providers + |-------------------------------------------------------------------------- + | + | List of service providers to import and register when booting the + | application + | + */ + providers: [ + () => import('@adonisjs/core/providers/app_provider'), + () => import('@adonisjs/core/providers/hash_provider'), + { + file: () => import('@adonisjs/core/providers/repl_provider'), + environment: ['repl', 'test'], + }, + () => import('@adonisjs/core/providers/vinejs_provider'), + () => import('@adonisjs/session/session_provider'), + () => import('@adonisjs/shield/shield_provider'), + () => import('@adonisjs/lucid/database_provider'), + () => import('@adonisjs/cors/cors_provider'), + () => import('@adonisjs/auth/auth_provider'), + () => import('#providers/api_provider'), + ], + + /* + |-------------------------------------------------------------------------- + | Preloads + |-------------------------------------------------------------------------- + | + | List of modules to import before starting the application. + | + */ + preloads: [ + () => import('#start/routes'), + () => import('#start/kernel'), + () => import('#start/validator'), + ], + + /* + |-------------------------------------------------------------------------- + | Tests + |-------------------------------------------------------------------------- + | + | List of test suites to organize tests by their type. Feel free to remove + | and add additional suites. + | + */ + tests: { + suites: [ + { + files: ['tests/unit/**/*.spec.{ts,js}'], + name: 'unit', + timeout: 2000, + }, + { + files: ['tests/functional/**/*.spec.{ts,js}'], + name: 'functional', + timeout: 30000, + }, + ], + forceExit: false, + }, + + /* + |-------------------------------------------------------------------------- + | Metafiles + |-------------------------------------------------------------------------- + | + | A collection of files you want to copy to the build folder when creating + | the production build. + | + */ + metaFiles: [], + + hooks: { + init: [ + indexEntities({ + transformers: { enabled: true }, + }), + generateRegistry(), + ], + }, +}) diff --git a/apps/api/app/controllers/access_tokens_controller.ts b/apps/api/app/controllers/access_tokens_controller.ts new file mode 100644 index 0000000..153267c --- /dev/null +++ b/apps/api/app/controllers/access_tokens_controller.ts @@ -0,0 +1,29 @@ +import User from '#models/user' +import { loginValidator } from '#validators/user' +import type { HttpContext } from '@adonisjs/core/http' +import UserTransformer from '#transformers/user_transformer' + +export default class AccessTokensController { + 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) + + return serialize({ + user: UserTransformer.transform(user), + token: token.value!.release(), + }) + } + + async destroy({ auth }: HttpContext) { + const user = auth.getUserOrFail() + if (user.currentAccessToken) { + await User.accessTokens.delete(user, user.currentAccessToken.identifier) + } + + return { + message: 'Logged out successfully', + } + } +} diff --git a/apps/api/app/controllers/new_account_controller.ts b/apps/api/app/controllers/new_account_controller.ts new file mode 100644 index 0000000..1251fb7 --- /dev/null +++ b/apps/api/app/controllers/new_account_controller.ts @@ -0,0 +1,18 @@ +import User from '#models/user' +import { signupValidator } from '#validators/user' +import type { HttpContext } from '@adonisjs/core/http' +import UserTransformer from '#transformers/user_transformer' + +export default class NewAccountController { + async store({ request, 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) + + return serialize({ + user: UserTransformer.transform(user), + token: token.value!.release(), + }) + } +} diff --git a/apps/api/app/controllers/profile_controller.ts b/apps/api/app/controllers/profile_controller.ts new file mode 100644 index 0000000..1fe82f2 --- /dev/null +++ b/apps/api/app/controllers/profile_controller.ts @@ -0,0 +1,8 @@ +import UserTransformer from '#transformers/user_transformer' +import type { HttpContext } from '@adonisjs/core/http' + +export default class ProfileController { + async show({ auth, serialize }: HttpContext) { + return serialize(UserTransformer.transform(auth.getUserOrFail())) + } +} diff --git a/apps/api/app/exceptions/handler.ts b/apps/api/app/exceptions/handler.ts new file mode 100644 index 0000000..30ddc51 --- /dev/null +++ b/apps/api/app/exceptions/handler.ts @@ -0,0 +1,28 @@ +import app from '@adonisjs/core/services/app' +import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http' + +export default class HttpExceptionHandler extends ExceptionHandler { + /** + * In debug mode, the exception handler will display verbose errors + * with pretty printed stack traces. + */ + protected debug = !app.inProduction + + /** + * The method is used for handling errors and returning + * response to the client + */ + async handle(error: unknown, ctx: HttpContext) { + return super.handle(error, ctx) + } + + /** + * The method is used to report error to the logging service or + * the a third party error monitoring service. + * + * @note You should not attempt to send a response from this method. + */ + async report(error: unknown, ctx: HttpContext) { + return super.report(error, ctx) + } +} diff --git a/apps/api/app/middleware/auth_middleware.ts b/apps/api/app/middleware/auth_middleware.ts new file mode 100644 index 0000000..8c86950 --- /dev/null +++ b/apps/api/app/middleware/auth_middleware.ts @@ -0,0 +1,20 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' +import type { Authenticators } from '@adonisjs/auth/types' + +/** + * Auth middleware is used authenticate HTTP requests and deny + * access to unauthenticated users. + */ +export default class AuthMiddleware { + async handle( + ctx: HttpContext, + next: NextFn, + options: { + guards?: (keyof Authenticators)[] + } = {} + ) { + await ctx.auth.authenticateUsing(options.guards) + return next() + } +} diff --git a/apps/api/app/middleware/container_bindings_middleware.ts b/apps/api/app/middleware/container_bindings_middleware.ts new file mode 100644 index 0000000..8f5322b --- /dev/null +++ b/apps/api/app/middleware/container_bindings_middleware.ts @@ -0,0 +1,19 @@ +import { Logger } from '@adonisjs/core/logger' +import { HttpContext } from '@adonisjs/core/http' +import { type NextFn } from '@adonisjs/core/types/http' + +/** + * The container bindings middleware binds classes to their request + * specific value using the container resolver. + * + * - We bind "HttpContext" class to the "ctx" object + * - And bind "Logger" class to the "ctx.logger" object + */ +export default class ContainerBindingsMiddleware { + handle(ctx: HttpContext, next: NextFn) { + ctx.containerResolver.bindValue(HttpContext, ctx) + ctx.containerResolver.bindValue(Logger, ctx.logger) + + return next() + } +} diff --git a/apps/api/app/middleware/force_json_response_middleware.ts b/apps/api/app/middleware/force_json_response_middleware.ts new file mode 100644 index 0000000..f46ec9c --- /dev/null +++ b/apps/api/app/middleware/force_json_response_middleware.ts @@ -0,0 +1,9 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class ForceJsonResponseMiddleware { + handle(ctx: HttpContext, next: NextFn) { + ctx.request.request.headers.accept = 'application/json' + return next() + } +} diff --git a/apps/api/app/middleware/silent_auth_middleware.ts b/apps/api/app/middleware/silent_auth_middleware.ts new file mode 100644 index 0000000..3e46f40 --- /dev/null +++ b/apps/api/app/middleware/silent_auth_middleware.ts @@ -0,0 +1,16 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +/** + * Silent auth middleware can be used as a global middleware to silent check + * if the user is logged-in or not. + * + * The request continues as usual, even when the user is not logged-in. + */ +export default class SilentAuthMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + await ctx.auth.check() + + return next() + } +} diff --git a/apps/api/app/models/user.ts b/apps/api/app/models/user.ts new file mode 100644 index 0000000..7bc5a08 --- /dev/null +++ b/apps/api/app/models/user.ts @@ -0,0 +1,18 @@ +import { UserSchema } from '#database/schema' +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' + +export default class User extends compose(UserSchema, withAuthFinder(hash)) { + static accessTokens = DbAccessTokensProvider.forModel(User) + declare currentAccessToken?: AccessToken + + get initials() { + const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@') + if (first && last) { + return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase() + } + return `${first.slice(0, 2)}`.toUpperCase() + } +} diff --git a/apps/api/app/transformers/user_transformer.ts b/apps/api/app/transformers/user_transformer.ts new file mode 100644 index 0000000..c772ff7 --- /dev/null +++ b/apps/api/app/transformers/user_transformer.ts @@ -0,0 +1,15 @@ +import type User from '#models/user' +import { BaseTransformer } from '@adonisjs/core/transformers' + +export default class UserTransformer extends BaseTransformer { + toObject() { + return this.pick(this.resource, [ + 'id', + 'fullName', + 'email', + 'createdAt', + 'updatedAt', + 'initials', + ]) + } +} diff --git a/apps/api/app/validators/user.ts b/apps/api/app/validators/user.ts new file mode 100644 index 0000000..5c4ff9a --- /dev/null +++ b/apps/api/app/validators/user.ts @@ -0,0 +1,26 @@ +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) + +/** + * Validator to use when performing self-signup + */ +export const signupValidator = vine.create({ + fullName: vine.string().nullable(), + email: email().unique({ table: 'users', column: 'email' }), + password: password(), + passwordConfirmation: password().sameAs('password'), +}) + +/** + * Validator to use before validating user credentials + * during login + */ +export const loginValidator = vine.create({ + email: email(), + password: vine.string(), +}) diff --git a/apps/api/bin/console.ts b/apps/api/bin/console.ts new file mode 100644 index 0000000..86bf4d2 --- /dev/null +++ b/apps/api/bin/console.ts @@ -0,0 +1,47 @@ +/* +|-------------------------------------------------------------------------- +| Ace entry point +|-------------------------------------------------------------------------- +| +| The "console.ts" file is the entrypoint for booting the AdonisJS +| command-line framework and executing commands. +| +| Commands do not boot the application, unless the currently running command +| has "options.startApp" flag set to true. +| +*/ + +await import('reflect-metadata') +const { Ignitor, prettyPrintError } = await import('@adonisjs/core') + +/** + * URL to the application root. AdonisJS need it to resolve + * paths to file and directories for scaffolding commands + */ +const APP_ROOT = new URL('../', import.meta.url) + +/** + * The importer is used to import files in context of the + * application. + */ +const IMPORTER = (filePath: string) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, APP_ROOT).href) + } + return import(filePath) +} + +new Ignitor(APP_ROOT, { importer: IMPORTER }) + .tap((app) => { + app.booting(async () => { + await import('#start/env') + }) + app.listen('SIGTERM', () => app.terminate()) + app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate()) + }) + .ace() + .handle(process.argv.splice(2)) + .catch((error) => { + process.exitCode = 1 + prettyPrintError(error) + }) diff --git a/apps/api/bin/server.ts b/apps/api/bin/server.ts new file mode 100644 index 0000000..297b371 --- /dev/null +++ b/apps/api/bin/server.ts @@ -0,0 +1,45 @@ +/* +|-------------------------------------------------------------------------- +| HTTP server entrypoint +|-------------------------------------------------------------------------- +| +| The "server.ts" file is the entrypoint for starting the AdonisJS HTTP +| server. Either you can run this file directly or use the "serve" +| command to run this file and monitor file changes +| +*/ + +await import('reflect-metadata') +const { Ignitor, prettyPrintError } = await import('@adonisjs/core') + +/** + * URL to the application root. AdonisJS need it to resolve + * paths to file and directories for scaffolding commands + */ +const APP_ROOT = new URL('../', import.meta.url) + +/** + * The importer is used to import files in context of the + * application. + */ +const IMPORTER = (filePath: string) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, APP_ROOT).href) + } + return import(filePath) +} + +new Ignitor(APP_ROOT, { importer: IMPORTER }) + .tap((app) => { + app.booting(async () => { + await import('#start/env') + }) + app.listen('SIGTERM', () => app.terminate()) + app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate()) + }) + .httpServer() + .start() + .catch((error) => { + process.exitCode = 1 + prettyPrintError(error) + }) diff --git a/apps/api/bin/test.ts b/apps/api/bin/test.ts new file mode 100644 index 0000000..d759efe --- /dev/null +++ b/apps/api/bin/test.ts @@ -0,0 +1,62 @@ +/* +|-------------------------------------------------------------------------- +| Test runner entrypoint +|-------------------------------------------------------------------------- +| +| The "test.ts" file is the entrypoint for running tests using Japa. +| +| Either you can run this file directly or use the "test" +| command to run this file and monitor file changes. +| +*/ + +process.env.NODE_ENV = 'test' + +import 'reflect-metadata' +import { Ignitor, prettyPrintError } from '@adonisjs/core' +import { configure, processCLIArgs, run } from '@japa/runner' + +/** + * URL to the application root. AdonisJS need it to resolve + * paths to file and directories for scaffolding commands + */ +const APP_ROOT = new URL('../', import.meta.url) + +/** + * The importer is used to import files in context of the + * application. + */ +const IMPORTER = (filePath: string) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, APP_ROOT).href) + } + return import(filePath) +} + +new Ignitor(APP_ROOT, { importer: IMPORTER }) + .tap((app) => { + app.booting(async () => { + await import('#start/env') + }) + app.listen('SIGTERM', () => app.terminate()) + app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate()) + }) + .testRunner() + .configure(async (app) => { + const { runnerHooks, ...config } = await import('../tests/bootstrap.js') + + processCLIArgs(process.argv.splice(2)) + configure({ + ...app.rcFile.tests, + ...config, + ...{ + setup: runnerHooks.setup, + teardown: runnerHooks.teardown.concat([() => app.terminate()]), + }, + }) + }) + .run(() => run()) + .catch((error) => { + process.exitCode = 1 + prettyPrintError(error) + }) diff --git a/apps/api/config/app.ts b/apps/api/config/app.ts new file mode 100644 index 0000000..116f717 --- /dev/null +++ b/apps/api/config/app.ts @@ -0,0 +1,93 @@ +import env from '#start/env' +import app from '@adonisjs/core/services/app' +import { defineConfig } from '@adonisjs/core/http' + +/** + * The app key is used for encrypting cookies, generating signed URLs, + * and by the "encryption" module. + * + * The encryption module will fail to decrypt data if the key is lost or + * changed. Therefore it is recommended to keep the app key secure. + */ +export const appKey = env.get('APP_KEY') + +/** + * The app URL can be used in various places where you want to create absolute + * URLs to your application. For example, when sending emails, images should + * use absolute URLs. + */ +export const appUrl = env.get('APP_URL') + +/** + * The configuration settings used by the HTTP server + */ +export const http = defineConfig({ + /** + * Generate a unique request id for each incoming request. + * Useful to correlate logs and debug a request flow. + */ + generateRequestId: true, + + /** + * Allow HTTP method spoofing via the "_method" form/query parameter. + * This lets HTML forms target PUT/PATCH/DELETE routes while still + * submitting with POST. + */ + allowMethodSpoofing: false, + + /** + * Enabling async local storage will let you access HTTP context + * from anywhere inside your application. + */ + useAsyncLocalStorage: false, + + /** + * Redirect configuration controls the behavior of + * response.redirect().back() and query string forwarding. + */ + redirect: { + /** + * When enabled, all redirects automatically carry over the current + * request's query string parameters to the redirect destination. + * Use withQs(false) to opt out for a specific redirect. + */ + forwardQueryString: true, + }, + + /** + * Manage cookies configuration. The settings for the session id cookie are + * defined inside the "config/session.ts" file. + */ + cookie: { + /** + * Restrict the cookie to a specific domain. + * Keep empty to use the current host. + */ + domain: '', + + /** + * Restrict the cookie to a URL path. '/' means all routes. + */ + path: '/', + + /** + * Default lifetime for cookies managed by the HTTP layer. + */ + maxAge: '2h', + + /** + * Prevent JavaScript access to the cookie in the browser. + */ + httpOnly: true, + + /** + * Send cookies only over HTTPS in production. + */ + secure: app.inProduction, + + /** + * Cross-site policy for cookie sending. + */ + sameSite: 'lax', + }, +}) diff --git a/apps/api/config/auth.ts b/apps/api/config/auth.ts new file mode 100644 index 0000000..686e0ae --- /dev/null +++ b/apps/api/config/auth.ts @@ -0,0 +1,50 @@ +import { defineConfig } from '@adonisjs/auth' +import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session' +import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens' +import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types' + +const authConfig = defineConfig({ + /** + * Default guard used when no guard is explicitly specified. + */ + default: 'api', + + guards: { + /** + * Token-based guard for stateless API authentication. + */ + api: tokensGuard({ + provider: tokensUserProvider({ + tokens: 'accessTokens', + model: () => import('#models/user'), + }), + }), + + /** + * Session-based guard for browser authentication. + */ + web: sessionGuard({ + /** + * Enable persistent login using remember-me tokens. + */ + useRememberMeTokens: false, + + provider: sessionUserProvider({ + model: () => import('#models/user'), + }), + }), + }, +}) + +export default authConfig + +/** + * Inferring types from the configured auth + * guards. + */ +declare module '@adonisjs/auth/types' { + export interface Authenticators extends InferAuthenticators {} +} +declare module '@adonisjs/core/types' { + interface EventsList extends InferAuthEvents {} +} diff --git a/apps/api/config/bodyparser.ts b/apps/api/config/bodyparser.ts new file mode 100644 index 0000000..7e836e6 --- /dev/null +++ b/apps/api/config/bodyparser.ts @@ -0,0 +1,78 @@ +import { defineConfig } from '@adonisjs/core/bodyparser' + +const bodyParserConfig = defineConfig({ + /** + * Parse request bodies for these HTTP methods. + * Keep this aligned with methods that receive payloads in your routes. + */ + allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'], + + /** + * Config for the "application/x-www-form-urlencoded" + * content-type parser. + */ + form: { + /** + * Normalize empty string values to null. + */ + convertEmptyStringsToNull: true, + + /** + * Content types handled by the form parser. + */ + types: ['application/x-www-form-urlencoded'], + }, + + /** + * Config for the JSON parser. + */ + json: { + /** + * Normalize empty string values to null. + */ + convertEmptyStringsToNull: true, + + /** + * Content types handled by the JSON parser. + */ + types: [ + 'application/json', + 'application/json-patch+json', + 'application/vnd.api+json', + 'application/csp-report', + ], + }, + + /** + * Config for the "multipart/form-data" content-type parser. + * File uploads are handled by the multipart parser. + */ + multipart: { + /** + * Automatically process uploaded files into the system tmp directory. + */ + autoProcess: true, + + /** + * Normalize empty string values to null. + */ + convertEmptyStringsToNull: true, + + /** + * Routes where multipart processing is handled manually. + */ + processManually: [], + + /** + * Maximum accepted payload size for multipart requests. + */ + limit: '20mb', + + /** + * Content types handled by the multipart parser. + */ + types: ['multipart/form-data'], + }, +}) + +export default bodyParserConfig diff --git a/apps/api/config/cors.ts b/apps/api/config/cors.ts new file mode 100644 index 0000000..56f9d5c --- /dev/null +++ b/apps/api/config/cors.ts @@ -0,0 +1,50 @@ +import app from '@adonisjs/core/services/app' +import { defineConfig } from '@adonisjs/cors' + +/** + * Configuration options to tweak the CORS policy. The following + * options are documented on the official documentation website. + * + * https://docs.adonisjs.com/guides/security/cors + */ +const corsConfig = defineConfig({ + /** + * Enable or disable CORS handling globally. + */ + enabled: true, + + /** + * In development, allow every origin to simplify local front/backend setup. + * In production, keep an explicit allowlist (empty by default, so no + * cross-origin browser access is allowed until configured). + */ + origin: app.inDev ? true : [], + + /** + * HTTP methods accepted for cross-origin requests. + */ + methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'], + + /** + * Reflect request headers by default. Use a string array to restrict + * allowed headers. + */ + headers: true, + + /** + * Response headers exposed to the browser. + */ + exposeHeaders: [], + + /** + * Allow cookies/authorization headers on cross-origin requests. + */ + credentials: true, + + /** + * Cache CORS preflight response for N seconds. + */ + maxAge: 90, +}) + +export default corsConfig diff --git a/apps/api/config/database.ts b/apps/api/config/database.ts new file mode 100644 index 0000000..e36cef1 --- /dev/null +++ b/apps/api/config/database.ts @@ -0,0 +1,131 @@ +import app from '@adonisjs/core/services/app' +import { defineConfig } from '@adonisjs/lucid' + +const dbConfig = defineConfig({ + /** + * Default connection used for all queries. + */ + connection: 'sqlite', + + connections: { + /** + * SQLite connection (default). + */ + sqlite: { + client: 'better-sqlite3', + + connection: { + filename: app.tmpPath('db.sqlite3'), + }, + + /** + * Required by Knex for SQLite defaults. + */ + useNullAsDefault: true, + + migrations: { + /** + * Sort migration files naturally by filename. + */ + naturalSort: true, + + /** + * Paths containing migration files. + */ + paths: ['database/migrations'], + }, + + schemaGeneration: { + /** + * Enable schema generation from Lucid models. + */ + enabled: true, + + /** + * Custom schema rules file paths. + */ + rulesPaths: ['./database/schema_rules.js'], + }, + }, + + /** + * PostgreSQL connection. + * Install package to switch: npm install pg + */ + // pg: { + // client: 'pg', + // connection: { + // host: env.get('DB_HOST'), + // port: env.get('DB_PORT'), + // user: env.get('DB_USER'), + // password: env.get('DB_PASSWORD'), + // database: env.get('DB_DATABASE'), + // }, + // migrations: { + // naturalSort: true, + // paths: ['database/migrations'], + // }, + // debug: app.inDev, + // }, + + /** + * MySQL / MariaDB connection. + * Install package to switch: npm install mysql2 + */ + // mysql: { + // client: 'mysql2', + // connection: { + // host: env.get('DB_HOST'), + // port: env.get('DB_PORT'), + // user: env.get('DB_USER'), + // password: env.get('DB_PASSWORD'), + // database: env.get('DB_DATABASE'), + // }, + // migrations: { + // naturalSort: true, + // paths: ['database/migrations'], + // }, + // debug: app.inDev, + // }, + + /** + * Microsoft SQL Server connection. + * Install package to switch: npm install tedious + */ + // mssql: { + // client: 'mssql', + // connection: { + // server: env.get('DB_HOST'), + // port: env.get('DB_PORT'), + // user: env.get('DB_USER'), + // password: env.get('DB_PASSWORD'), + // database: env.get('DB_DATABASE'), + // }, + // migrations: { + // naturalSort: true, + // paths: ['database/migrations'], + // }, + // debug: app.inDev, + // }, + + /** + * libSQL (Turso) connection. + * Install package to switch: npm install @libsql/client + */ + // libsql: { + // client: 'libsql', + // connection: { + // url: env.get('LIBSQL_URL'), + // authToken: env.get('LIBSQL_AUTH_TOKEN'), + // }, + // useNullAsDefault: true, + // migrations: { + // naturalSort: true, + // paths: ['database/migrations'], + // }, + // debug: app.inDev, + // }, + }, +}) + +export default dbConfig diff --git a/apps/api/config/encryption.ts b/apps/api/config/encryption.ts new file mode 100644 index 0000000..1bb3dc5 --- /dev/null +++ b/apps/api/config/encryption.ts @@ -0,0 +1,34 @@ +import env from '#start/env' +import { defineConfig, drivers } from '@adonisjs/core/encryption' + +const encryptionConfig = defineConfig({ + /** + * Default encryption driver used by the application. + */ + default: 'gcm', + + list: { + gcm: drivers.aes256gcm({ + /** + * Keys used for encryption/decryption. + * First key encrypts, all keys are tried for decryption. + */ + keys: [env.get('APP_KEY')], + + /** + * Stable identifier for this driver. + */ + id: 'gcm', + }), + }, +}) + +export default encryptionConfig + +/** + * Inferring types for the list of encryptors you have configured + * in your application. + */ +declare module '@adonisjs/core/types' { + export interface EncryptorsList extends InferEncryptors {} +} diff --git a/apps/api/config/hash.ts b/apps/api/config/hash.ts new file mode 100644 index 0000000..a54c086 --- /dev/null +++ b/apps/api/config/hash.ts @@ -0,0 +1,75 @@ +import { defineConfig, drivers } from '@adonisjs/core/hash' + +/** + * Hashing configuration. + * + * This starter uses Node.js scrypt under the hood. + * Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback + */ +const hashConfig = defineConfig({ + /** + * Default hasher used by the application. + */ + default: 'scrypt', + + list: { + /** + * Scrypt is memory-hard, which makes brute-force attacks more expensive. + */ + scrypt: drivers.scrypt({ + /** + * Work factor (Node alias: N / cost). + * Higher values increase security and CPU+memory usage. + * + * Tuning guideline: + * - Start with 16384. + * - Increase gradually (for example 32768) and benchmark login/signup latency. + * - Keep values practical for your slowest production machine. + * + * Node constraint: value must be a power of two greater than 1. + */ + cost: 16384, + + /** + * Block size (Node alias: r / blockSize). + * Increases memory and CPU linearly. + * + * Tuning guideline: + * - Keep 8 unless you have a measured reason to change it. + * - Raise only with benchmark data, because memory usage grows quickly. + */ + blockSize: 8, + + /** + * Parallelization (Node alias: p / parallelization). + * Controls how many independent computations are performed. + * + * Tuning guideline: + * - Keep 1 for most applications. + * - Increase only after load testing if your infrastructure benefits from it. + */ + parallelization: 1, + + /** + * Maximum memory limit in bytes (Node alias: maxmem / maxMemory). + * Hashing throws if the estimated memory usage is above this limit. + * Node documents the check as approximately: 128 * N * r > maxmem. + * + * Tuning guideline: + * - Keep this aligned with your cost/blockSize choices. + * - Increase carefully on memory-constrained environments. + */ + maxMemory: 33554432, + }), + }, +}) + +export default hashConfig + +/** + * Inferring types for the list of hashers you have configured + * in your application. + */ +declare module '@adonisjs/core/types' { + export interface HashersList extends InferHashers {} +} diff --git a/apps/api/config/logger.ts b/apps/api/config/logger.ts new file mode 100644 index 0000000..ba8c96a --- /dev/null +++ b/apps/api/config/logger.ts @@ -0,0 +1,51 @@ +import env from '#start/env' +import app from '@adonisjs/core/services/app' +import { defineConfig, syncDestination, targets } from '@adonisjs/core/logger' + +const loggerConfig = defineConfig({ + /** + * Default logger name used by ctx.logger and app logger calls. + */ + default: 'app', + + loggers: { + app: { + /** + * Toggle this logger on/off. + */ + enabled: true, + + /** + * Logger name shown in log records. + */ + name: env.get('APP_NAME'), + + /** + * Minimum level to output (trace, debug, info, warn, error, fatal). + */ + level: env.get('LOG_LEVEL'), + + /** + * Use sync destination in non-production for immediate flush. + */ + destination: !app.inProduction ? await syncDestination() : undefined, + + /** + * Configure where logs are written. + */ + transport: { + targets: [targets.file({ destination: 1 })], + }, + }, + }, +}) + +export default loggerConfig + +/** + * Inferring types for the list of loggers you have configured + * in your application. + */ +declare module '@adonisjs/core/types' { + export interface LoggersList extends InferLoggers {} +} diff --git a/apps/api/config/session.ts b/apps/api/config/session.ts new file mode 100644 index 0000000..cc34262 --- /dev/null +++ b/apps/api/config/session.ts @@ -0,0 +1,78 @@ +import env from '#start/env' +import app from '@adonisjs/core/services/app' +import { defineConfig, stores } from '@adonisjs/session' + +const sessionConfig = defineConfig({ + /** + * Enable or disable session support globally. + */ + enabled: true, + + /** + * Cookie name storing the session identifier. + */ + cookieName: 'adonis-session', + + /** + * When set to true, the session id cookie will be deleted + * once the user closes the browser. + */ + clearWithBrowser: false, + + /** + * Define how long to keep the session data alive without + * any activity. + */ + age: '2h', + + /** + * Configuration for session cookie and the + * cookie store. + */ + cookie: { + /** + * Restrict the cookie to a URL path. '/' means all routes. + */ + path: '/', + + /** + * Prevent JavaScript access to the cookie in the browser. + */ + httpOnly: true, + + /** + * Send cookies only over HTTPS in production. + */ + secure: app.inProduction, + + /** + * Cross-site policy for cookie sending. + */ + sameSite: 'lax', + }, + + /** + * The store to use. Make sure to validate the environment + * variable in order to infer the store name without any + * errors. + */ + store: env.get('SESSION_DRIVER'), + + /** + * List of configured stores. Refer documentation to see + * list of available stores and their config. + */ + stores: { + /** + * Store session data inside encrypted cookies. + */ + cookie: stores.cookie(), + + /** + * Store session data inside the configured database. + */ + database: stores.database(), + }, +}) + +export default sessionConfig diff --git a/apps/api/config/shield.ts b/apps/api/config/shield.ts new file mode 100644 index 0000000..ad1e618 --- /dev/null +++ b/apps/api/config/shield.ts @@ -0,0 +1,95 @@ +import { defineConfig } from '@adonisjs/shield' + +const shieldConfig = defineConfig({ + /** + * Configure CSP policies for your app. Refer documentation + * to learn more. + */ + csp: { + /** + * Enable the Content-Security-Policy header. + */ + enabled: false, + + /** + * Per-resource CSP directives. + */ + directives: {}, + + /** + * Report violations without blocking resources. + */ + reportOnly: false, + }, + + /** + * Configure CSRF protection options. Refer documentation + * to learn more. + */ + csrf: { + /** + * Enable CSRF token verification for state-changing requests. + */ + enabled: false, + + /** + * Route patterns to exclude from CSRF checks. + * Useful for external webhooks or API endpoints. + */ + exceptRoutes: [], + + /** + * Expose an encrypted XSRF-TOKEN cookie for frontend HTTP clients. + */ + enableXsrfCookie: true, + + /** + * HTTP methods protected by CSRF validation. + */ + methods: ['POST', 'PUT', 'PATCH', 'DELETE'], + }, + + /** + * Control how your website should be embedded inside + * iframes. + */ + xFrame: { + /** + * Enable the X-Frame-Options header. + */ + enabled: true, + + /** + * Block all framing attempts. Default value is DENY. + */ + action: 'DENY', + }, + + /** + * Force browser to always use HTTPS. + */ + hsts: { + /** + * Enable the Strict-Transport-Security header. + */ + enabled: true, + + /** + * HSTS policy duration remembered by browsers. + */ + maxAge: '180 days', + }, + + /** + * Disable browsers from sniffing content types and rely only + * on the response content-type header. + */ + contentTypeSniffing: { + /** + * Enable X-Content-Type-Options: nosniff. + */ + enabled: true, + }, +}) + +export default shieldConfig diff --git a/apps/api/database/migrations/1761885935168_create_users_table.ts b/apps/api/database/migrations/1761885935168_create_users_table.ts new file mode 100644 index 0000000..dbca083 --- /dev/null +++ b/apps/api/database/migrations/1761885935168_create_users_table.ts @@ -0,0 +1,21 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'users' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').notNullable() + table.string('full_name').nullable() + table.string('email', 254).notNullable().unique() + table.string('password').notNullable() + + table.timestamp('created_at').notNullable() + table.timestamp('updated_at').nullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/api/database/migrations/1768620764696_create_access_tokens_table.ts b/apps/api/database/migrations/1768620764696_create_access_tokens_table.ts new file mode 100644 index 0000000..a3ce197 --- /dev/null +++ b/apps/api/database/migrations/1768620764696_create_access_tokens_table.ts @@ -0,0 +1,31 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'auth_access_tokens' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table + .integer('tokenable_id') + .notNullable() + .unsigned() + .references('id') + .inTable('users') + .onDelete('CASCADE') + + table.string('type').notNullable() + table.string('name').nullable() + table.string('hash').notNullable() + table.text('abilities').notNullable() + table.timestamp('created_at') + table.timestamp('updated_at') + table.timestamp('last_used_at').nullable() + table.timestamp('expires_at').nullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts new file mode 100644 index 0000000..521fa3a --- /dev/null +++ b/apps/api/database/schema.ts @@ -0,0 +1,61 @@ +/** + * This file is automatically generated + * DO NOT EDIT manually + * Run "node ace migration:run" command to re-generate this file + */ + +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 + $columns = AuthAccessTokenSchema.$columns + @column() + declare abilities: string + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime | null + @column.dateTime() + declare expiresAt: DateTime | null + @column() + declare hash: string + @column({ isPrimary: true }) + declare id: number + @column.dateTime() + declare lastUsedAt: DateTime | null + @column() + declare name: string | null + @column() + declare tokenableId: number + @column() + declare type: string + @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 + $columns = UserSchema.$columns + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column() + declare email: string + @column() + declare fullName: string | null + @column({ isPrimary: true }) + declare id: number + @column({ serializeAs: null }) + declare password: string + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null +} diff --git a/apps/api/database/schema_rules.ts b/apps/api/database/schema_rules.ts new file mode 100644 index 0000000..1153c92 --- /dev/null +++ b/apps/api/database/schema_rules.ts @@ -0,0 +1,3 @@ +import { type SchemaRules } from '@adonisjs/lucid/types/schema_generator' + +export default {} satisfies SchemaRules diff --git a/apps/api/eslint.config.js b/apps/api/eslint.config.js new file mode 100644 index 0000000..9be1be3 --- /dev/null +++ b/apps/api/eslint.config.js @@ -0,0 +1,2 @@ +import { configApp } from '@adonisjs/eslint-config' +export default configApp() diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..554466f --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,79 @@ +{ + "name": "@rubis/api", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + "./data": "./.adonisjs/client/data.d.ts", + "./registry": "./.adonisjs/client/registry/index.ts" + }, + "scripts": { + "start": "node bin/server.js", + "build": "node ace build", + "dev": "node ace serve --hmr", + "test": "node ace test", + "lint": "eslint .", + "format": "prettier --write .", + "typecheck": "tsc --noEmit" + }, + "imports": { + "#controllers/*": "./app/controllers/*.js", + "#exceptions/*": "./app/exceptions/*.js", + "#models/*": "./app/models/*.js", + "#mails/*": "./app/mails/*.js", + "#services/*": "./app/services/*.js", + "#listeners/*": "./app/listeners/*.js", + "#events/*": "./app/events/*.js", + "#generated/*": "./.adonisjs/server/*.js", + "#middleware/*": "./app/middleware/*.js", + "#transformers/*": "./app/transformers/*.js", + "#validators/*": "./app/validators/*.js", + "#providers/*": "./providers/*.js", + "#policies/*": "./app/policies/*.js", + "#abilities/*": "./app/abilities/*.js", + "#database/*": "./database/*.js", + "#tests/*": "./tests/*.js", + "#start/*": "./start/*.js", + "#config/*": "./config/*.js" + }, + "devDependencies": { + "@adonisjs/assembler": "^8.4.0", + "@adonisjs/eslint-config": "^3.0.0", + "@adonisjs/prettier-config": "^1.4.5", + "@adonisjs/tsconfig": "^2.0.0", + "@japa/assert": "^4.2.0", + "@japa/plugin-adonisjs": "^5.2.0", + "@japa/runner": "^5.3.0", + "@poppinss/ts-exec": "^1.4.4", + "@types/luxon": "^3.7.1", + "@types/node": "~25.6.0", + "eslint": "^10.2.0", + "hot-hook": "^1.0.0", + "pino-pretty": "^13.1.3", + "prettier": "^3.8.2", + "typescript": "~6.0.2", + "youch": "^4.1.1" + }, + "dependencies": { + "@adonisjs/auth": "^10.1.0", + "@adonisjs/core": "^7.3.1", + "@adonisjs/cors": "^3.0.0", + "@adonisjs/lucid": "^22.4.2", + "@adonisjs/session": "^8.1.0", + "@adonisjs/shield": "^9.0.0", + "@japa/api-client": "^3.2.1", + "@tuyau/core": "^1.2.2", + "@vinejs/vine": "^4.3.1", + "better-sqlite3": "^12.9.0", + "luxon": "^3.7.2", + "reflect-metadata": "^0.2.2" + }, + "hotHook": { + "boundaries": [ + "./app/controllers/**/*.ts", + "./app/middleware/*.ts" + ] + }, + "prettier": "@adonisjs/prettier-config" +} \ No newline at end of file diff --git a/apps/api/providers/api_provider.ts b/apps/api/providers/api_provider.ts new file mode 100644 index 0000000..8e4e00c --- /dev/null +++ b/apps/api/providers/api_provider.ts @@ -0,0 +1,69 @@ +import { HttpContext } from '@adonisjs/core/http' +import { BaseSerializer } from '@adonisjs/core/transformers' +import { type SimplePaginatorMetaKeys } from '@adonisjs/lucid/types/querybuilder' + +/** + * Custom serializer for API responses that ensures consistent JSON structure + * across all API endpoints. Wraps response data in a 'data' property and handles + * pagination metadata for Lucid ORM query results. + */ +class ApiSerializer extends BaseSerializer<{ + Wrap: 'data' + PaginationMetaData: SimplePaginatorMetaKeys +}> { + /** + * Wraps all serialized data under this key in the response object. + * Example: { data: [...] } instead of returning raw arrays/objects + */ + wrap: 'data' = 'data' + + /** + * Validates and defines pagination metadata structure for paginated responses. + * Ensures that pagination info from Lucid queries is properly formatted. + * + * @throws Error if metadata doesn't match Lucid's pagination structure + */ + definePaginationMetaData(metaData: unknown): SimplePaginatorMetaKeys { + if (!this.isLucidPaginatorMetaData(metaData)) { + throw new Error( + 'Invalid pagination metadata. Expected metadata to contain Lucid pagination keys' + ) + } + return metaData + } +} + +/** + * Single instance of ApiSerializer used across the application + */ +const serializer = new ApiSerializer() +const serialize = Object.assign( + function (this: HttpContext, ...[data, resolver]: Parameters) { + return serializer.serialize(data, resolver ?? this.containerResolver) + }, + { + withoutWrapping( + this: HttpContext, + ...[data, resolver]: Parameters + ) { + return serializer.serializeWithoutWrapping(data, resolver ?? this.containerResolver) + }, + } +) as ApiSerializer['serialize'] & { withoutWrapping: ApiSerializer['serializeWithoutWrapping'] } + +/** + * Adds the serialize method to all HttpContext instances. + * Usage in controllers: return ctx.serialize(data) + * This ensures all API responses follow the same structure with data wrapping. + */ +HttpContext.instanceProperty('serialize', serialize) + +/** + * Module augmentation to add the serialize method to HttpContext. + * This allows controllers to use ctx.serialize() for consistent API responses. + */ +declare module '@adonisjs/core/http' { + export interface HttpContext { + serialize: typeof serialize + } +} diff --git a/apps/api/start/env.ts b/apps/api/start/env.ts new file mode 100644 index 0000000..b9252b0 --- /dev/null +++ b/apps/api/start/env.ts @@ -0,0 +1,27 @@ +/* +|-------------------------------------------------------------------------- +| Environment variables service +|-------------------------------------------------------------------------- +| +| The `Env.create` method creates an instance of the Env service. The +| service validates the environment variables and also cast values +| to JavaScript data types. +| +*/ + +import { Env } from '@adonisjs/core/env' + +export default await Env.create(new URL('../', import.meta.url), { + // Node + NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const), + PORT: Env.schema.number(), + HOST: Env.schema.string({ format: 'host' }), + LOG_LEVEL: Env.schema.string(), + + // App + APP_KEY: Env.schema.secret(), + APP_URL: Env.schema.string({ format: 'url', tld: false }), + + // Session + SESSION_DRIVER: Env.schema.enum(['cookie', 'memory', 'database'] as const), +}) diff --git a/apps/api/start/kernel.ts b/apps/api/start/kernel.ts new file mode 100644 index 0000000..d3c18be --- /dev/null +++ b/apps/api/start/kernel.ts @@ -0,0 +1,49 @@ +/* +|-------------------------------------------------------------------------- +| HTTP kernel file +|-------------------------------------------------------------------------- +| +| The HTTP kernel file is used to register the middleware with the server +| or the router. +| +*/ + +import router from '@adonisjs/core/services/router' +import server from '@adonisjs/core/services/server' + +/** + * The error handler is used to convert an exception + * to a HTTP response. + */ +server.errorHandler(() => import('#exceptions/handler')) + +/** + * The server middleware stack runs middleware on all the HTTP + * requests, even if there is no route registered for + * the request URL. + */ +server.use([ + () => import('#middleware/force_json_response_middleware'), + () => import('#middleware/container_bindings_middleware'), + () => import('@adonisjs/cors/cors_middleware'), +]) + +/** + * The router middleware stack runs middleware on all the HTTP + * requests with a registered route. + */ +router.use([ + () => import('@adonisjs/core/bodyparser_middleware'), + () => import('@adonisjs/session/session_middleware'), + () => import('@adonisjs/shield/shield_middleware'), + () => import('@adonisjs/auth/initialize_auth_middleware'), + () => import('#middleware/silent_auth_middleware'), +]) + +/** + * Named middleware collection must be explicitly assigned to + * the routes or the routes group. + */ +export const middleware = router.named({ + auth: () => import('#middleware/auth_middleware'), +}) diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts new file mode 100644 index 0000000..29fc17f --- /dev/null +++ b/apps/api/start/routes.ts @@ -0,0 +1,37 @@ +/* +|-------------------------------------------------------------------------- +| Routes file +|-------------------------------------------------------------------------- +| +| The routes file is used for defining the HTTP routes. +| +*/ + +import { middleware } from '#start/kernel' +import router from '@adonisjs/core/services/router' +import { controllers } from '#generated/controllers' + +router.get('/', () => { + return { hello: 'world' } +}) + +router + .group(() => { + router + .group(() => { + router.post('signup', [controllers.NewAccount, 'store']) + router.post('login', [controllers.AccessTokens, 'store']) + }) + .prefix('auth') + .as('auth') + + router + .group(() => { + router.get('profile', [controllers.Profile, 'show']) + router.post('logout', [controllers.AccessTokens, 'destroy']) + }) + .prefix('account') + .as('profile') + .use(middleware.auth()) + }) + .prefix('/api/v1') diff --git a/apps/api/start/validator.ts b/apps/api/start/validator.ts new file mode 100644 index 0000000..6a2ca7d --- /dev/null +++ b/apps/api/start/validator.ts @@ -0,0 +1,23 @@ +/* +|-------------------------------------------------------------------------- +| Validator file +|-------------------------------------------------------------------------- +| +| The validator file is used for configuring global transforms for VineJS. +| The transform below converts all VineJS date outputs from JavaScript +| Date objects to Luxon DateTime instances, so that validated dates are +| ready to use with Lucid models and other parts of the app that expect +| Luxon DateTime. +| +*/ + +import { DateTime } from 'luxon' +import { VineDate } from '@vinejs/vine' + +declare module '@vinejs/vine/types' { + interface VineGlobalTransforms { + date: DateTime + } +} + +VineDate.transform((value) => DateTime.fromJSDate(value)) diff --git a/apps/api/tests/bootstrap.ts b/apps/api/tests/bootstrap.ts new file mode 100644 index 0000000..bee79df --- /dev/null +++ b/apps/api/tests/bootstrap.ts @@ -0,0 +1,56 @@ +import { assert } from '@japa/assert' +import { apiClient } from '@japa/api-client' +import app from '@adonisjs/core/services/app' +import type { Config } from '@japa/runner/types' +import { pluginAdonisJS } from '@japa/plugin-adonisjs' +import { dbAssertions } from '@adonisjs/lucid/plugins/db' +import testUtils from '@adonisjs/core/services/test_utils' +import { authApiClient } from '@adonisjs/auth/plugins/api_client' +import { sessionApiClient } from '@adonisjs/session/plugins/api_client' +import type { Registry } from '../.adonisjs/client/registry/schema.d.ts' + +/** + * This file is imported by the "bin/test.ts" entrypoint file + */ +declare module '@japa/api-client/types' { + interface RoutesRegistry extends Registry {} +} + +/** + * This file is imported by the "bin/test.ts" entrypoint file + */ + +/** + * Configure Japa plugins in the plugins array. + * Learn more - https://japa.dev/docs/runner-config#plugins-optional + */ +export const plugins: Config['plugins'] = [ + assert(), + pluginAdonisJS(app), + dbAssertions(app), + apiClient(), + sessionApiClient(app), + authApiClient(app), +] + +/** + * Configure lifecycle function to run before and after all the + * tests. + * + * The setup functions are executed before all the tests + * The teardown functions are executed after all the tests + */ +export const runnerHooks: Required> = { + setup: [], + teardown: [], +} + +/** + * Configure suites by tapping into the test suite instance. + * Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks + */ +export const configureSuite: Config['configureSuite'] = (suite) => { + if (['browser', 'functional', 'e2e'].includes(suite.name)) { + return suite.setup(() => testUtils.httpServer().start()) + } +} diff --git a/apps/api/tmp/.gitkeep b/apps/api/tmp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..8c9803b --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@adonisjs/tsconfig/tsconfig.app.json", + "compilerOptions": { + "rootDir": "./", + "jsx": "react", + "outDir": "./build" + } +} diff --git a/apps/web/.env.development b/apps/web/.env.development new file mode 100644 index 0000000..e809d3c --- /dev/null +++ b/apps/web/.env.development @@ -0,0 +1,3 @@ +VITE_API_URL=http://localhost:3333 +VITE_PUBLIC_LANDING_URL=http://localhost:8080 +VITE_USE_MOCKS=true diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..8d4a902 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,10 @@ +# URL de l'API AdonisJS. En dev local, MSW intercepte les requêtes — +# cette URL n'est utilisée que comme base path symbolique tant que le backend +# n'est pas branché. +VITE_API_URL=http://localhost:3333 + +# URL de la landing publique (lien retour depuis l'app) +VITE_PUBLIC_LANDING_URL=https://rubis.arthurbarre.fr + +# Active MSW pour mocker l'API. Mettre à "false" pour taper le vrai backend. +VITE_USE_MOCKS=true diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..a4a4648 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Rubis Sur l'Ongle + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..bddfc5b --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,71 @@ +{ + "name": "@rubis/web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "routes:generate": "tsr generate", + "prebuild": "tsr generate", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "pretypecheck": "tsr generate", + "typecheck": "tsc -b --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "msw:init": "msw init public --save" + }, + "dependencies": { + "@fontsource-variable/bricolage-grotesque": "^5.2.5", + "@fontsource-variable/inter": "^5.2.5", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@rubis/shared": "workspace:*", + "@tanstack/react-form": "^1.0.0", + "@tanstack/react-query": "^5.66.0", + "@tanstack/react-query-devtools": "^5.66.0", + "@tanstack/react-router": "^1.114.3", + "@tanstack/react-router-devtools": "^1.114.3", + "@tuyau/client": "^0.2.10", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.475.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "sonner": "^1.7.4", + "tailwind-merge": "^3.0.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.1.0", + "@tanstack/router-cli": "^1.114.3", + "@tanstack/router-plugin": "^1.114.3", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "jsdom": "^26.0.0", + "msw": "^2.7.3", + "tailwindcss": "^4.1.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10", + "vitest": "^3.0.5" + }, + "msw": { + "workerDirectory": "public" + } +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..162a2cd --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,3 @@ +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/apps/web/src/components/brand/Brand.tsx b/apps/web/src/components/brand/Brand.tsx new file mode 100644 index 0000000..730df33 --- /dev/null +++ b/apps/web/src/components/brand/Brand.tsx @@ -0,0 +1,43 @@ +import { Gem } from "./Gem"; +import { cn } from "@/lib/utils"; + +/** + * Lockup horizontal : ◆ + "Rubis" (+ optionnel "sur l'ongle" en suffixe italique muted). + * À utiliser dans les headers, le sidebar, les emails. + * + * Cf. /docs/marque.md §2 et le pattern de la landing. + */ +type BrandProps = { + /** Affiche le suffixe "sur l'ongle" en italique muted. */ + withSuffix?: boolean; + /** Taille du gem (le wordmark s'aligne dessus). */ + gemSize?: number; + className?: string; +}; + +export function Brand({ withSuffix = false, gemSize = 22, className }: BrandProps) { + return ( + + + + Rubis + {withSuffix && ( + + sur l'ongle + + )} + + + ); +} diff --git a/apps/web/src/components/brand/Gem.tsx b/apps/web/src/components/brand/Gem.tsx new file mode 100644 index 0000000..565a3fd --- /dev/null +++ b/apps/web/src/components/brand/Gem.tsx @@ -0,0 +1,45 @@ +import { cn } from "@/lib/utils"; + +/** + * Le ◆ — gem facetté, signature de la marque. + * SVG inline (pas une icône Lucide, jamais). + * + * 4 facettes suggérées + ligne médiane "table" du gem. + * Couleur : `currentColor` — hérite du contexte. Default text-rubis. + * + * Cf. /docs/marque.md §2 (logo direction A) et §5 (icônes spéciales). + */ +type GemProps = { + /** Taille en pixels (carré). Default 22. */ + size?: number; + /** Si true, applique un drop-shadow doux rubis pour les héros. */ + glow?: boolean; + className?: string; + "aria-label"?: string; +}; + +export function Gem({ size = 22, glow = false, className, ...props }: GemProps) { + return ( + + + {/* Table du gem */} + + {/* Facettes hautes */} + + + + ); +} diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..d4ad61d --- /dev/null +++ b/apps/web/src/components/ui/Button.tsx @@ -0,0 +1,103 @@ +import { forwardRef } from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +/** + * Bouton — primitive maison. + * + * Personnalité : + * - Border-radius 6px (sharper que la default Tailwind, cohérent landing) + * - Shadow rubis-teintée sur primary (pas de shadow plate générique) + * - Micro-translateY au hover (le bouton "soulève" légèrement) + * - Pas de focus ring bleu — anneau rubis-glow discret + * - Variants explicites : primary / secondary / ghost / link / danger + * + * Composition via Radix Slot : ` + +

+ + Mot de passe oublié ? + +

+ + + + + + ); +} diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css new file mode 100644 index 0000000..9e1ad97 --- /dev/null +++ b/apps/web/src/styles/app.css @@ -0,0 +1,142 @@ +/* ============================================================================ + * Rubis Sur l'Ongle — feuille de styles racine + * Tailwind v4 (CSS-first) + tokens de marque (cf. /docs/marque.md) + * ========================================================================== */ + +@import "tailwindcss"; + +/* Polices self-hostées via fontsource (cf. /docs/tech/frontend.md §10) */ +@import "@fontsource-variable/bricolage-grotesque"; +@import "@fontsource-variable/inter"; + +/* ---------------------------------------------------------------------------- + * Tokens de marque exposés en utilitaires Tailwind v4 via @theme. + * Source : /docs/marque.md §3, §4 + * -------------------------------------------------------------------------- */ +@theme { + /* === Couleurs rubis === */ + --color-rubis: #9f1239; + --color-rubis-deep: #771328; + --color-rubis-light: #c9415c; + --color-rubis-glow: #fbe4ea; + + /* === Neutres chauds (jamais de blanc/noir purs) === */ + --color-cream: #faf7f2; + --color-cream-2: #f5efe7; + --color-line: #e8e0d6; + --color-ink: #1a1410; + --color-ink-2: #4f4640; + --color-ink-3: #8a7f76; + + /* === Typographies === */ + --font-display: "Bricolage Grotesque Variable", "Bricolage Grotesque", -apple-system, + BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + + /* === Border radius — un peu plus tranchés que la default Tailwind === + 6px sur les éléments interactifs (cohérent avec la landing) */ + --radius-sharp: 4px; + --radius-default: 6px; + --radius-soft: 10px; + --radius-card: 14px; + + /* === Ombres rubis-teintées === */ + --shadow-rubis: 0 2px 8px rgba(159, 18, 57, 0.25); + --shadow-rubis-hover: 0 6px 16px rgba(159, 18, 57, 0.35); + --shadow-card: + 0 16px 40px -16px rgba(26, 20, 16, 0.18), 0 4px 8px -2px rgba(26, 20, 16, 0.06); + --shadow-soft: 0 4px 16px rgba(26, 20, 16, 0.04); +} + +/* ---------------------------------------------------------------------------- + * Globals + * -------------------------------------------------------------------------- */ +@layer base { + html { + -webkit-text-size-adjust: 100%; + text-rendering: optimizeLegibility; + } + + body { + background: var(--color-cream); + color: var(--color-ink); + font-family: var(--font-sans); + font-feature-settings: "ss01", "cv11"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.55; + } + + /* Sélection rubis — petite signature partout */ + ::selection { + background: var(--color-rubis); + color: white; + } + + /* Reset hr/fieldset minimal */ + fieldset { + border: 0; + padding: 0; + margin: 0; + } + + /* Chiffres alignés en colonnes — toujours préférable pour montants/dates */ + .tabular-nums { + font-variant-numeric: tabular-nums; + } + + /* Italique rubis : convention typographique sur le mot-clé d'un titre */ + em { + font-style: italic; + color: var(--color-rubis); + } + + /* Anti-overflow sur écrans étroits */ + body, + #root { + min-height: 100vh; + } +} + +/* ---------------------------------------------------------------------------- + * Utilitaires custom — touche maison. + * Pas de helpers Tailwind par-dessus, juste de petites primitives marque. + * -------------------------------------------------------------------------- */ +@utility eyebrow { + font-family: var(--font-sans); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.14em; + color: var(--color-rubis); + text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 8px; + line-height: 1.4; +} + +@utility eyebrow-mark { + /* Le ◆ géométrique, en pseudo-élément carré tourné. Cohérent avec la landing. */ + width: 7px; + height: 7px; + background: currentColor; + display: inline-block; + transform: rotate(45deg); +} + +@utility shadow-rubis { + box-shadow: var(--shadow-rubis); +} + +@utility shadow-rubis-hover { + box-shadow: var(--shadow-rubis-hover); +} + +@utility shadow-card { + box-shadow: var(--shadow-card); +} + +@utility shadow-soft { + box-shadow: var(--shadow-soft); +} diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts new file mode 100644 index 0000000..2ba240b --- /dev/null +++ b/apps/web/src/test/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); +}); diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json new file mode 100644 index 0000000..e46476d --- /dev/null +++ b/apps/web/tsconfig.app.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "jsx": "react-jsx", + "moduleDetection": "force", + "useDefineForClassFields": true, + "types": ["vite/client"], + "paths": { + "@/*": ["./src/*"], + "@rubis/shared": ["../../packages/shared/src/index.ts"], + "@rubis/shared/*": ["../../packages/shared/src/*"] + } + }, + "include": ["src"] +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json new file mode 100644 index 0000000..f3698a2 --- /dev/null +++ b/apps/web/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "moduleDetection": "force", + "types": ["node"] + }, + "include": ["vite.config.ts", "vitest.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..7a04032 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + // Doit être déclaré AVANT react() (cf. doc TanStack Router). + TanStackRouterVite({ + routesDirectory: "./src/routes", + generatedRouteTree: "./src/routeTree.gen.ts", + autoCodeSplitting: true, + }), + react(), + tailwindcss(), + ], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + port: 5173, + strictPort: true, + }, +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..f09eaad --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,21 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +/** + * Config Vitest séparée — vitest@3 n'aligne pas encore ses types Vite avec + * vite@8 (rolldown). On déclare ici tout ce qui est test-only et on garde + * `vite.config.ts` propre pour le bundler. + */ +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + }, +}); diff --git a/landing/assets/logo.png b/assets/logo.png similarity index 100% rename from landing/assets/logo.png rename to assets/logo.png diff --git a/docs/decisions.md b/docs/decisions.md index 48383d8..f863871 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -184,8 +184,6 @@ --- -## Décisions à venir (en attente) - ## ADR-013 · Tagline V1 « Vos factures relancées toutes seules pendant que vous travaillez » - **Date** : 2026-05-05 @@ -206,15 +204,118 @@ --- +## ADR-014 · Stack technique + +- **Date** : 2026-05-05 +- **Statut** : ✅ Validée +- **Contexte** : choix du stack initial pour démarrer le développement de Rubis. Bloquant pour les ADR suivants (domain model, repo layout, etc.). +- **Décision** : + - **Backend** : AdonisJS v7 (TypeScript, MVC, Lucid ORM, auth & jobs intégrés) + - **Frontend** : React + Vite + TanStack Router + TanStack Query + - **Base de données** : PostgreSQL + - **Hosting** : cluster Proxmox personnel + K3s (déjà en place pour la landing) +- **Rationale** : + - **AdonisJS v7** : "batteries included" en TS first — Lucid pour le SQL relationnel, Auth pour les sessions/tokens, Bouncer pour les permissions, Bull pour les jobs, Mailer pour l'email. Évite l'assemblage Express/Fastify + 12 libs. + - **React + Vite** : DX moderne, build rapide, écosystème massif. Maturité éprouvée pour un SaaS B2B. + - **TanStack Router** : routing client-side type-safe avec search params réactifs (idéal pour les filtres de la liste de factures). Pas couplé à un framework SSR. + - **TanStack Query** : cache + invalidation + retry + optimistic updates pour le state serveur. Évite Redux pour gérer du data API. + - **PostgreSQL** : transactions ACID indispensables pour la facturation, support JSON pour les payloads OCR, full-text search natif si besoin. + - **Proxmox + K3s** : maîtrise totale du runtime, coût marginal nul (infrastructure existante), pas de vendor lock-in. Le pipeline Gitea CI → registry → K3s rollout est déjà rodé sur la landing. +- **Conséquences architecturales** : + - **API REST séparée du SPA** (pas Inertia.js) — TanStack Router signifie que le client gère son propre routing. CORS et type-sharing entre back et front à organiser proprement. + - **TypeScript end-to-end** rend possible un dossier de types partagés (ex. `packages/shared/`) ou la génération auto via OpenAPI/Zod. + - Le build front (Vite) produit des assets statiques — soit servis par AdonisJS (`/public/build/`), soit par nginx en sidecar du pod, soit déployés en parallèle. +- **Alternatives écartées** : + - **Next.js / Remix** : trop SSR-centric pour un SaaS transactionnel. Le backend des frameworks meta-React reste plus faible que ce qu'offre Adonis. + - **Express ou Fastify + ORM (Prisma/Drizzle)** : assemblage de 12 libs pour atteindre ce qu'Adonis livre out-of-the-box. + - **MongoDB / NoSQL** : pas adapté aux relations facture-client-relance ni aux transactions financières. + - **Vercel / Render / Fly.io** : coûts récurrents évitables — Proxmox déjà en place et payé. +- **Décisions à formaliser dans la foulée** : repo layout (mono vs split), hébergement Postgres, auth flow (cookie vs token), file storage des PDF, domain model. + +--- + +## ADR-015 · Repo layout : monorepo (apps/api + apps/web) + +- **Date** : 2026-05-05 +- **Statut** : ✅ Validée +- **Décision** : un seul repo Git, deux applications dans `apps/api/` (AdonisJS) et `apps/web/` (React/Vite), un dossier `packages/shared/` pour les types TS partagés. Workspaces gérés en pnpm. +- **Rationale** : + - Le type-sharing entre API et SPA est gratuit (un import depuis `@rubis/shared`) + - Une seule release coordonnée → pas de problèmes de version drift entre API et SPA + - CI/CD unique, scripts npm racine + - Solo dev TS = monorepo natural fit +- **Alternatives écartées** : + - **Deux repos séparés** : friction sur le type-sharing, releases à coordonner manuellement + - **Adonis monolithe avec front intégré (Inertia)** : aurait écarté TanStack Router (couplage Adonis routing). Adopté seulement si on retire TanStack Router de la stack. + +--- + +## ADR-016 · PostgreSQL : LXC Proxmox existant + +- **Date** : 2026-05-05 +- **Statut** : ✅ Validée +- **Décision** : utiliser le serveur PostgreSQL déjà provisionné dans le LXC Proxmox d'Arthur. Créer une base `rubis` dédiée + un user `rubis_user` avec les permissions nécessaires. +- **Rationale** : + - Infrastructure existante, zéro coût d'infra additionnel + - Backups Proxmox existants (snapshots LXC) couvrent la base + - Performance native (pas la couche K3s), latence faible avec le cluster K3s sur le même réseau + - Hors cluster K3s = isolement des changements applicatifs (rollouts ne touchent jamais la DB) +- **À mettre en place** : + - Créer une `database rubis` + user dédié + grants minimum (CREATE/SELECT/INSERT/UPDATE/DELETE sur les tables de la base) + - Service K3s ou ExternalName pour exposer la connexion PG aux pods API + - Sauvegarde dump PG quotidienne dans MinIO (script cron côté LXC) + +--- + +## ADR-017 · Auth : access tokens (Bearer) + +- **Date** : 2026-05-05 +- **Statut** : ✅ Validée +- **Décision** : authentification via **access tokens stateless** (Bearer header) via `@adonisjs/auth` v7. Pas de session cookie côté API. +- **Rationale** : + - Architecture API propre, agnostique du client (web V1, mobile V2, intégrations partenaires V3+ supportées sans refactoring) + - Tokens stockés en base (`auth_access_tokens` Adonis) → révocation possible côté admin + - Permet des **abilities/scopes par token** (utile en V2 pour donner un token "lecture seule" au comptable) +- **Implémentation** : + - **API** : middleware `auth` Adonis sur les routes protégées, sortie `Bearer token` à l'inscription/login + - **SPA** : token stocké en mémoire (variable de module/closure) + refresh token en cookie httpOnly pour persistance après reload, pour limiter le risque XSS + - **TTL** : access token 30 min, refresh token 30 jours (à valider au moment de l'implémentation) +- **Alternatives écartées** : + - **Session cookies @adonisjs/auth** : plus simple en V1 mais oblige à tout refaire au moment d'ajouter une API mobile/tiers + - **Tokens en localStorage seul** : exposé XSS (lecture par n'importe quel script tiers), pas safe pour des données financières + +--- + +## ADR-018 · File storage : MinIO Proxmox existant + +- **Date** : 2026-05-05 +- **Statut** : ✅ Validée +- **Décision** : utiliser le MinIO déjà provisionné sur l'infra Proxmox d'Arthur pour stocker les PDF de factures et les pièces jointes. +- **Rationale** : + - Infrastructure existante, zéro coût additionnel + - API S3-compatible → utilise n'importe quelle lib AWS SDK + - Migration future vers cloud S3 (R2/B2) triviale (mêmes appels) + - Pre-signed URLs natives MinIO pour les téléchargements client-side sécurisés +- **À mettre en place** : + - Bucket `rubis-invoices` (PDF + images de factures) + - Bucket `rubis-attachments` (pièces jointes utilisateurs : signatures, logos) + - Credentials Access Key/Secret dédiés avec permissions limitées à ces 2 buckets + - Politique de retention : pas de purge auto en V1 (les factures sont des documents légaux) +- **Alternatives écartées** : + - **Local pod + PVC** : ne scale pas si plusieurs replicas, backup non trivial + - **Cloudflare R2 / Backblaze B2** : sortie de l'infra perso pour rien — MinIO existe déjà + +--- + ## Décisions à venir (en attente) | # | Sujet | Pourquoi en attente | |---|---|---| -| 014 | Stack technique (framework, DB, OCR provider, email provider, hosting) | À formaliser avec Arthur | -| 015 | Structure DB / domain model | Dépend de 014 | -| 016 | Pricing exact (Free 5 factures ? Pro 19 € ?) | À tester avant figer | -| 017 | Provider OCR (Mindee, Document AI, Textract, open-source) | Dépend de coût/qualité — à benchmarker | -| 018 | Endpoint waitlist (Resend / Formspree / Tally / API perso) | Choix au déploiement de la landing | +| 019 | Domain model / DB schema (entités, relations, index) | À écrire — débloqué par 014-018, prochaine étape technique | +| 020 | Provider OCR (Mindee, Document AI, Textract, Tesseract self-hosted) | À benchmarker (coût + qualité sur factures FR) | +| 021 | Provider email outbound (Resend, Postmark, SendGrid, AWS SES) | À benchmarker (deliverability FR + prix au volume) | +| 022 | Pricing exact (Free 5 factures ? Pro 19 € ? Business 49 €) | À tester avant figer | +| 023 | Endpoint waitlist (Resend / Formspree / Tally / API Adonis perso) | Choix simple au moment du push de la landing | --- diff --git a/docs/tech/architecture.md b/docs/tech/architecture.md new file mode 100644 index 0000000..f00aa3e --- /dev/null +++ b/docs/tech/architecture.md @@ -0,0 +1,456 @@ +# Architecture technique — Rubis Sur l'Ongle + +> Version : 0.1 · Dernière maj : 2026-05-05 +> Décisions de référence : ADR-014 (stack), ADR-015 (repo), ADR-016 (PG), ADR-017 (auth), ADR-018 (storage). Voir `/docs/decisions.md`. + +Ce document est la source de vérité technique. Quand le code et ce fichier divergent, on tranche en discussion et on met à jour ici. + +--- + +## 1. Vue d'ensemble + +``` + ┌────────────────────────────┐ + │ Internet (HTTPS) │ + └─────────────┬──────────────┘ + │ + ┌─────────────▼──────────────┐ + │ Traefik (Proxmox gateway) │ + │ rubis.arthurbarre.fr │ + │ app.rubis-sur-l-ongle.fr │ + └─────────────┬──────────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ┌────────▼────────┐ ┌──────▼──────┐ ┌────────▼────────┐ + │ Pod: web-static │ │ Pod: api │ │ Pod: landing │ + │ nginx + Vite │ │ AdonisJS │ │ (déjà déployé) │ + │ build │ │ Node │ │ │ + └──────────────────┘ └──────┬─────┘ └─────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ + ┌────────▼─────────┐ ┌────────▼────────┐ ┌─────────▼────────┐ + │ LXC: PostgreSQL │ │ LXC: MinIO │ │ Provider OCR │ + │ (existant, pool │ │ (existant, │ │ (à benchmarker) │ + │ Proxmox) │ │ S3-compatible) │ │ │ + └──────────────────┘ └─────────────────┘ └──────────────────┘ + │ + ┌────────▼─────────┐ + │ Provider Email │ + │ (à benchmarker) │ + └──────────────────┘ +``` + +**Composants** : + +| Composant | Rôle | Hosting | Status | +|---|---|---|---| +| `apps/web` | SPA React Vite — interface utilisateur | nginx pod K3s (build statique) | À écrire | +| `apps/api` | API REST AdonisJS — logique métier, jobs, email | Pod Node K3s | À écrire | +| `packages/shared` | Types TS, schemas Zod, constantes communes | npm workspace local | À écrire | +| `landing` | Landing publique waitlist | nginx pod K3s | ✅ Déployé | +| PostgreSQL | Base de données métier | LXC Proxmox existant | ✅ En place | +| MinIO | Stockage PDF + pièces jointes (S3-compat) | LXC Proxmox existant | ✅ En place | +| Provider OCR | Extraction texte des factures | Externe (HTTPS) | ADR-020 à venir | +| Provider Email | Envoi outbound (relances + check-in) | Externe (HTTPS) | ADR-021 à venir | + +--- + +## 2. Repo layout (monorepo) + +``` +rubis/ +├── apps/ +│ ├── api/ # AdonisJS v7 backend +│ │ ├── app/ # Controllers, models, services +│ │ ├── config/ # Auth, database, mail, queue +│ │ ├── database/ +│ │ │ ├── migrations/ +│ │ │ └── seeders/ +│ │ ├── start/ # Routes, kernel +│ │ ├── tests/ +│ │ ├── ace.js # CLI Adonis +│ │ ├── package.json +│ │ └── tsconfig.json +│ └── web/ # React + Vite SPA +│ ├── src/ +│ │ ├── routes/ # TanStack Router (file-based) +│ │ ├── components/ +│ │ ├── lib/ # api client, query keys, utils +│ │ └── main.tsx +│ ├── public/ +│ ├── index.html +│ ├── vite.config.ts +│ ├── package.json +│ └── tsconfig.json +├── packages/ +│ └── shared/ # Code partagé api ↔ web +│ ├── src/ +│ │ ├── types/ # Types TS (DTOs API) +│ │ ├── schemas/ # Schemas Zod (validation) +│ │ └── constants/ # Énums, règles métier +│ ├── package.json +│ └── tsconfig.json +├── landing/ # Landing publique (déjà déployée) +├── docs/ # Documentation +│ ├── produit.md +│ ├── marque.md +│ ├── decisions.md +│ └── tech/ # Doc technique +│ └── architecture.md # (ce fichier) +├── k3s/ # Manifests Kubernetes +├── Dockerfile.api # Build image api +├── Dockerfile.web # Build image web (nginx + bundle) +├── pnpm-workspace.yaml +├── package.json # Scripts root, devDependencies communes +├── tsconfig.base.json # Config TS partagée +└── CLAUDE.md +``` + +**Outils monorepo** : +- **pnpm workspaces** — léger, rapide, gestion native des liens symboliques entre packages +- **TypeScript project references** — résout les imports cross-package sans build préalable +- **Turborepo** *(optionnel, à voir au volume)* — cache + parallélisation des scripts + +**Commandes racine** typiques : +```bash +pnpm install # installe tout +pnpm -F api dev # dev API +pnpm -F web dev # dev SPA +pnpm -F api migration:run # migrations DB +pnpm -F api test # tests API +pnpm build # build api + web pour prod +``` + +--- + +## 3. apps/api — AdonisJS v7 + +### Stack interne +- **AdonisJS v7** + **Lucid ORM** (PG) +- **`@adonisjs/auth`** — access tokens Bearer (stateless) +- **`@adonisjs/bouncer`** — autorisations par policy (admin/lecture/édition pour V2 multi-users) +- **`@adonisjs/mail`** — emails outbound (provider à choisir ADR-021) +- **`@adonisjs/queue`** ou **BullMQ** — jobs différés (relances programmées, OCR, check-ins) +- **`@adonisjs/limiter`** — rate limiting sur les routes publiques (login, signup) +- **Vine** (validateur natif Adonis 7) ou **Zod** côté API pour validation des payloads + +### Conventions de routes +Toutes les routes API sous `/api/v1/`. Versioning explicite — V2 vivra côté `/api/v2/` sans casser V1. + +``` +POST /api/v1/auth/register +POST /api/v1/auth/login +POST /api/v1/auth/logout +POST /api/v1/auth/refresh + +GET /api/v1/me +PATCH /api/v1/me +GET /api/v1/organizations/:id + +GET /api/v1/invoices +POST /api/v1/invoices # create manual +POST /api/v1/invoices/upload # OCR pipeline +GET /api/v1/invoices/:id +PATCH /api/v1/invoices/:id +DELETE /api/v1/invoices/:id +POST /api/v1/invoices/:id/relance # relance manuelle +POST /api/v1/invoices/:id/mark-paid + +GET /api/v1/plans +POST /api/v1/plans +PATCH /api/v1/plans/:id +DELETE /api/v1/plans/:id + +GET /api/v1/clients +POST /api/v1/clients +PATCH /api/v1/clients/:id + +GET /api/v1/dashboard/kpis +GET /api/v1/dashboard/activity +``` + +### Conventions de réponse +- JSON systématique +- Format succès : `{ data: ..., meta?: { ... } }` +- Format erreur : `{ errors: [{ code, message, field? }] }` +- Codes HTTP standards (200, 201, 204, 400, 401, 403, 404, 422, 500) +- Pagination cursor-based pour les listes (préférable à offset pour les flux modifiés en temps réel) + +--- + +## 4. apps/web — React + Vite + +### Stack interne +- **React 19** + **Vite 6** +- **TanStack Router** — routing file-based (à privilégier), search params type-safe pour les filtres facture +- **TanStack Query** — cache + invalidation + optimistic updates pour le state serveur +- **TailwindCSS** *(à confirmer)* — utility-first, cohérent avec les couleurs de marque +- **Lucide React** pour les icônes +- **Bricolage Grotesque + Inter** via Google Fonts (cohérent landing) + +### Auth côté SPA (cf. ADR-017) +- **Access token** stocké en mémoire (variable de module / state Query) — pas localStorage pour éviter XSS +- **Refresh token** en cookie httpOnly + SameSite=Strict +- Au boot du SPA : appel `/auth/refresh` pour obtenir un nouvel access token (silent reauth) +- Si refresh échoue → redirect `/login` + +### Organisation des routes +File-based via TanStack Router : +``` +src/routes/ +├── __root.tsx # Layout global + AuthGate +├── login.tsx +├── signup.tsx +├── _app/ # Routes protégées +│ ├── _app.tsx # Layout app (sidebar, header, brand) +│ ├── index.tsx # Dashboard +│ ├── factures.tsx # Liste factures +│ ├── factures.$id.tsx # Détail facture +│ ├── plans.tsx # Bibliothèque plans +│ ├── plans.$id.tsx # Éditeur plan +│ ├── clients.tsx +│ └── parametres.tsx +└── onboarding/ + ├── compte.tsx + ├── entreprise.tsx + └── signature.tsx +``` + +--- + +## 5. packages/shared — types et schémas partagés + +Le but : un client API typé fortement, sans duplication de définitions. + +```ts +// packages/shared/src/types/invoice.ts +export type InvoiceStatus = 'pending' | 'awaiting_user_confirmation' | 'paid' | 'in_relance' | 'litigation' | 'cancelled' + +export type Invoice = { + id: string + numero: string + clientId: string + amountTtc: number + dueDate: string // ISO + status: InvoiceStatus + planId: string | null + // ... +} +``` + +```ts +// packages/shared/src/schemas/invoice.ts +import { z } from 'zod' + +export const createInvoiceSchema = z.object({ + numero: z.string().min(1), + clientId: z.string().uuid(), + amountTtc: z.number().positive(), + dueDate: z.string().datetime(), + // ... +}) + +export type CreateInvoiceInput = z.infer +``` + +**Avantage** : Adonis valide avec ce schéma, le SPA valide avec le même, le type est inféré une seule fois. + +--- + +## 6. Flux de données critiques + +### 6.1 Upload + OCR + création facture + +``` +SPA (drag & drop PDF) + │ + │ POST /api/v1/invoices/upload (multipart) + ▼ +api: stocke le PDF dans MinIO (bucket rubis-invoices) + │ + │ retourne { uploadId, status: 'processing' } + ▼ +api: enqueue job ProcessOcr(uploadId) + │ + ▼ +worker: récupère le PDF depuis MinIO + │ + │ appel HTTP vers OCR provider + ▼ +worker: parse les champs extraits, crée l'Invoice en DB (status: pending) + │ + ▼ +SPA: poll ou WebSocket → reçoit l'Invoice prête à valider +``` + +**Points d'attention** : le job OCR doit être idempotent (même uploadId rejoué = pas de duplicate). Le SPA peut afficher un spinner pendant les 3-10 secondes d'OCR. + +### 6.2 Programmation des relances + +``` +SPA: utilisateur clique "Valider" sur l'Invoice + │ + │ PATCH /api/v1/invoices/:id (status: scheduled, planId: …) + ▼ +api: créé N RelanceTasks (une par étape du plan) + chaque RelanceTask a un sendAt (calculé d'après dueDate + offset étape) + │ + ▼ +queue: tâches en attente + │ + │ ┌─ avant chaque relance, créer aussi un CheckinTask (T-2j) ─┐ + ▼ ▼ ▼ +worker @ sendAt: vérifie l'état de l'Invoice (toujours pending ?) + │ + │ si invoice.status === 'pending' → envoie l'email + │ sinon → no-op (l'invoice a été marquée payée entre-temps) +``` + +### 6.3 Check-in email à l'utilisateur + +``` +worker @ checkinTask.sendAt: + │ + │ génère un token signé (avec invoice.id + reply_action: 'paid' | 'not_paid') + ▼ +api: envoie un email à l'utilisateur (pas au client) avec 2 boutons + │ + │ chaque bouton = lien GET /api/v1/checkin/:token (action embeddée) + ▼ +utilisateur clique "Oui, j'ai été payé" + │ + ▼ +api: GET /checkin/:token → vérifie token, marque invoice.status = 'paid' + → annule les RelanceTasks futures de cette invoice + → redirect SPA avec confirmation +``` + +**Sécurité** : le token doit être signé (HMAC ou JWT court) et avoir une durée limitée (24h après émission). Pas d'auth Bearer requise pour ce endpoint car c'est un click depuis email. + +### 6.4 Authentification Bearer + +``` +SPA: POST /api/v1/auth/login { email, password } + │ + │ api valide credentials, crée AccessToken (TTL 30 min) + RefreshToken (TTL 30j httpOnly cookie) + ▼ +SPA reçoit { accessToken, user } — accessToken stocké en mémoire + │ + │ chaque requête API : Authorization: Bearer + ▼ +30 min plus tard : 401 sur appel API + │ + ▼ +SPA: POST /api/v1/auth/refresh (cookie httpOnly envoyé auto) + │ + │ api valide refresh, émet nouvel accessToken + ▼ +SPA retry l'appel original avec nouveau token +``` + +--- + +## 7. Topologie de déploiement + +### Réseau Proxmox + +| Resource | Type | Rôle | +|---|---|---| +| Cluster K3s | Pool VMs Proxmox | Orchestration des pods app | +| LXC `postgres` | LXC dédié | PostgreSQL — accessible aux pods K3s via réseau interne | +| LXC `minio` | LXC dédié | MinIO — accessible aux pods K3s via réseau interne | +| Traefik | Reverse proxy | TLS termination + routing par hostname | + +### Pods K3s + +```yaml +# Namespace: rubis +- Deployment: rubis-api # AdonisJS Node, port 3333 +- Deployment: rubis-web # nginx, sert le bundle Vite, port 80 +- Deployment: rubis-landing # déjà existant +- Service: rubis-api-svc # ClusterIP +- Service: rubis-web-svc # ClusterIP +- Service: postgres-external # ExternalName → IP du LXC postgres +- Service: minio-external # ExternalName → IP du LXC minio +- Secret: rubis-config # DB credentials, MinIO credentials, OCR API key, mail API key +- IngressRoute (Traefik) : + api.rubis-sur-l-ongle.fr → rubis-api-svc:3333 + app.rubis-sur-l-ongle.fr → rubis-web-svc:80 + rubis-sur-l-ongle.fr → rubis-landing-svc:80 +``` + +### Pipeline CI Gitea + +``` +git push gitea main + ↓ +.gitea/workflows/build.yml + ↓ +build & push images : + - git.arthurbarre.fr/ordinarthur/rubis-api: + - git.arthurbarre.fr/ordinarthur/rubis-web: + ↓ +kubectl rollout (api + web) + ↓ +healthchecks readinessProbe → service public +``` + +--- + +## 8. Conventions de code + +| Domaine | Convention | +|---|---| +| Branches | `feat/`, `fix/`, `chore/<…>` | +| Commits | [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`) | +| TypeScript | `strict: true`, pas de `any` (sauf justifié + commenté), `noUncheckedIndexedAccess: true` | +| Linting | ESLint + Prettier, config partagée via `tsconfig.base.json` + `.eslintrc.cjs` racine | +| Tests | Japa (Adonis) pour API, Vitest pour shared/, Playwright pour E2E utilisateur (V2) | +| Migrations | Versionnées, jamais éditées rétroactivement (cf. principe ADR du log de décisions) | +| Secrets | Jamais en clair dans le repo. `.env.local` git-ignoré, secrets K3s pour la prod | + +--- + +## 9. Sécurité & RGPD + +- **Hébergement français** (Proxmox France) — conforme RGPD pour la cible TPE-PME +- **Chiffrement at-rest** : disque Proxmox chiffré (LUKS) — à confirmer côté infra +- **Chiffrement in-transit** : TLS partout (Traefik), connexions PG et MinIO en SSL interne +- **Rate limiting** : `@adonisjs/limiter` sur `/auth/*` (5 req/min par IP), routes OCR (10/h par utilisateur) +- **CORS** : whitelist stricte (`app.rubis-sur-l-ongle.fr` uniquement) — refus des origines tierces +- **CSRF** : non-applicable car auth via Bearer header (pas cookie session). Les endpoints email check-in utilisent des tokens signés à TTL court. +- **Backups** : + - PG : dump quotidien dans MinIO (`rubis-backups/pg/.dump`) + - MinIO : snapshot Proxmox du LXC quotidien + - Retention : 30 jours mini (à confirmer) +- **Suppression** : RGPD Article 17 — endpoint `DELETE /api/v1/me` qui purge data utilisateur + factures + pièces jointes + +--- + +## 10. Décisions encore en attente + +À trancher avant fin V1, par ordre de priorité : + +| # | Sujet | Échéance suggérée | +|---|---|---| +| 019 | **Domain model** (entités, relations, index) | Avant la 1ère migration | +| 020 | **Provider OCR** (Mindee, Document AI, Textract, Tesseract) | Avant l'implémentation du job ProcessOcr | +| 021 | **Provider email** (Resend, Postmark, SendGrid, AWS SES) | Avant l'implémentation des relances | +| 022 | **Pricing exact** (Free 5 factures ? Pro 19 €/mois ?) | Avant le payment flow | +| 023 | **Endpoint waitlist** (Resend / Formspree / API Adonis) | Au push de la landing en prod | + +--- + +## 11. Évolutions V2+ anticipées + +- **Multi-utilisateurs** : tables `organizations` et `memberships` à prévoir dès la V1 (même si UI mono-user) +- **SMS** : provider Twilio/OVH abstrait derrière un service `MessageDispatcher` qui route email/sms selon plan + cadence +- **Intégration banking** : webhook entrant sur `/api/v1/banking/payment-confirmed` qui marque les invoices payées automatiquement (le check-in email V1 devient fallback) +- **Intégrations comptables** (Pennylane/Sage) : modèle d'événement abstrait `invoice.created` exportable en webhook sortant +- **API publique** : sous `/api/v1/public/*` avec abilities/scopes par token (lecture seule, écriture limitée) + +--- + +*Maintenu par Arthur + Claude. Ce document est versionné — les changements significatifs passent par un ADR dans `/docs/decisions.md`.* diff --git a/docs/tech/frontend.md b/docs/tech/frontend.md new file mode 100644 index 0000000..e8bbdb7 --- /dev/null +++ b/docs/tech/frontend.md @@ -0,0 +1,763 @@ +# Guide d'implémentation — Frontend + +> Version : 0.1 · Dernière maj : 2026-05-05 +> Décisions de référence : ADR-014 (stack), ADR-015 (monorepo), ADR-017 (auth). + +Ce document est le **guide pratique d'implémentation du SPA**. Il complète `architecture.md` (qui décrit le **quoi**) en expliquant le **comment** : commandes exactes, snippets de config, conventions de dossier. + +**À lire avant** : +- `/CLAUDE.md` — contexte top-level +- `/docs/produit.md` — flows utilisateur, IN/OUT V1 +- `/docs/marque.md` — palette, typo, voix, do/don't +- `/docs/wireframes-mvp.html` — les 13 écrans MVP avec annotations +- `/docs/tech/architecture.md` — vue d'ensemble du système + +--- + +## 1. Vue d'ensemble + +L'app web (`apps/web/`) est un SPA React 19 buildé par Vite, qui consomme l'API AdonisJS `apps/api/` via un client HTTP type-safe (Tuyau). Le routing client est géré par **TanStack Router** (file-based, type-safe), le state serveur par **TanStack Query**, le styling par **Tailwind CSS v4** avec les tokens de marque issus de `marque.md`. + +**Périmètre V1** : 13 écrans listés dans `wireframes-mvp.html`. Auth Bearer (cf. ADR-017) avec refresh token httpOnly cookie. Mobile responsive, pas d'app native. + +**Hors scope V1** : SSR (pas nécessaire pour un SaaS B2B authentifié), i18n (FR uniquement), PWA offline (nice-to-have V2). + +--- + +## 2. Dépendances + +### Bootstrap du workspace + +À exécuter à la racine du monorepo (après avoir créé `pnpm-workspace.yaml`) : + +```bash +mkdir -p apps/web && cd apps/web +pnpm create vite@latest . --template react-ts +``` + +Choix Vite : `react-ts` (TypeScript natif). + +### Dépendances runtime + +```bash +# Routing & state serveur +pnpm add @tanstack/react-router @tanstack/react-query @tanstack/react-query-devtools + +# Tooling Vite pour TanStack Router (file-based) +pnpm add -D @tanstack/router-plugin + +# Client HTTP typé pour AdonisJS +pnpm add @tuyau/client + +# UI primitives & icônes +pnpm add lucide-react +pnpm add clsx tailwind-merge + +# Validation côté client (réutilise schemas Zod de packages/shared) +pnpm add zod + +# Notifications/toasts +pnpm add sonner + +# Dates (formatage français) +pnpm add date-fns +``` + +### Dépendances dev + +```bash +# Tailwind v4 +pnpm add -D tailwindcss @tailwindcss/vite + +# TS strict + lint +pnpm add -D typescript@latest @types/react @types/react-dom +pnpm add -D eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser +pnpm add -D eslint-plugin-react-hooks eslint-plugin-react-refresh + +# Tests +pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom + +# Type-checking strict +pnpm add -D tsc-files +``` + +### Référence au package shared + +Dans `apps/web/package.json` : + +```json +{ + "dependencies": { + "@rubis/shared": "workspace:*" + } +} +``` + +Permet d'importer les types et schemas Zod depuis `packages/shared/` sans publication npm. + +--- + +## 3. Tailwind CSS v4 + tokens de marque + +Tailwind v4 utilise une configuration CSS-first (plus de `tailwind.config.js` requis). Les tokens de marque issus de `marque.md` deviennent des CSS variables. + +### Installation + +`vite.config.ts` : + +```ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { TanStackRouterVite } from '@tanstack/router-plugin/vite' + +export default defineConfig({ + plugins: [ + TanStackRouterVite({ routesDirectory: 'src/routes' }), + react(), + tailwindcss(), + ], +}) +``` + +### Tokens de marque dans `src/styles/app.css` + +```css +@import "tailwindcss"; + +@theme { + /* Couleurs rubis */ + --color-rubis: #9F1239; + --color-rubis-deep: #771328; + --color-rubis-light: #C9415C; + --color-rubis-glow: #FBE4EA; + + /* Neutres chauds */ + --color-cream: #FAF7F2; + --color-cream-2: #F5EFE7; + --color-line: #E8E0D6; + --color-ink: #1A1410; + --color-ink-2: #4F4640; + --color-ink-3: #8A7F76; + + /* Typographies */ + --font-display: "Bricolage Grotesque", -apple-system, BlinkMacSystemFont, sans-serif; + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; +} + +/* Globals */ +body { + font-family: var(--font-sans); + background: var(--color-cream); + color: var(--color-ink); + font-feature-settings: "ss01", "cv11"; + -webkit-font-smoothing: antialiased; +} + +/* Selection */ +::selection { + background: var(--color-rubis); + color: white; +} +``` + +Importé dans `main.tsx` : `import './styles/app.css'`. + +### Polices Google Fonts + +`index.html` : + +```html + + + +``` + +### Usage typique + +```tsx + + +

+ Bonjour Arthur +

+``` + +### Règles de marque appliquées + +- **Pas de `bg-white` en pleine page** → toujours `bg-cream` (#FAF7F2) +- **Pas de `bg-black` ni `text-black`** → utiliser `bg-ink` et `text-ink` (#1A1410) +- **Le rubis est rare** → un seul aplat fort par écran maximum +- **Italique rubis** sur le mot-clé d'un titre : `` +- **Le ◆** est un SVG custom, jamais une icône Lucide (cf. `marque.md`) + +--- + +## 4. TanStack Router — routing file-based + +### Pourquoi file-based + +Le routing file-based est **type-safe nativement** (les params, search params, et loaders sont inférés depuis les fichiers), il évite la déclaration manuelle d'un router central, et il s'aligne avec la structure d'écrans du wireframe. + +### Structure des routes + +Référence : les 13 écrans dans `wireframes-mvp.html`. + +``` +apps/web/src/routes/ +├── __root.tsx # Layout global, providers, AuthGate +├── login.tsx # 1.2 Connexion +├── signup.tsx # 1.1 Inscription +├── _onboarding/ # Layout onboarding (sans sidebar) +│ ├── _onboarding.tsx +│ ├── compte.tsx # 1.3 step 1 +│ ├── entreprise.tsx # 1.3 step 2 +│ └── signature.tsx # 1.3 step 3 +└── _app/ # Layout app authentifiée + ├── _app.tsx # Layout : sidebar + topbar + tab bar mobile + ├── index.tsx # 4.1 Dashboard + ├── factures.tsx # 2.4 Liste filtrable + ├── factures.$id.tsx # 4.2 Détail facture (timeline) + ├── factures.import.$batchId.tsx # 2.2 Vérification OCR + ├── plans.tsx # 3.1 Bibliothèque + ├── plans.$slug.tsx # 3.2 Éditeur (cadence + templates) + ├── clients.tsx # liste clients + └── parametres.tsx # paramètres compte +``` + +Les routes commençant par `_` sont des **layout routes** (n'ajoutent pas de segment URL). + +### Configuration root + +`src/routes/__root.tsx` : + +```tsx +import { createRootRoute, Outlet } from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { Toaster } from 'sonner' + +export const Route = createRootRoute({ + component: RootLayout, + notFoundComponent: NotFound, +}) + +function RootLayout() { + return ( + <> + + + {import.meta.env.DEV && ( + <> + + + + )} + + ) +} + +function NotFound() { + return
Page introuvable.
+} +``` + +### Auth guard sur le layout `_app` + +`src/routes/_app/_app.tsx` : + +```tsx +import { createFileRoute, Outlet, redirect } from '@tanstack/react-router' +import { AppLayout } from '@/components/layout/AppLayout' +import { authStore } from '@/lib/auth' + +export const Route = createFileRoute('/_app')({ + beforeLoad: async ({ location }) => { + if (!authStore.isAuthenticated()) { + throw redirect({ + to: '/login', + search: { redirect: location.href }, + }) + } + }, + component: AppLayoutComponent, +}) + +function AppLayoutComponent() { + return ( + + + + ) +} +``` + +### Search params type-safe (filtres factures) + +`src/routes/_app/factures.tsx` : + +```tsx +import { z } from 'zod' +import { createFileRoute } from '@tanstack/react-router' + +const filterSchema = z.object({ + statut: z.enum(['toutes', 'a_relancer', 'en_relance', 'encaissees', 'litige']).optional(), + q: z.string().optional(), + page: z.number().int().min(1).optional().default(1), +}) + +export const Route = createFileRoute('/_app/factures')({ + validateSearch: filterSchema, + component: FacturesPage, +}) +``` + +→ Les filtres sont dans l'URL, partageables par lien, persistés au reload, type-safe à l'usage : `Route.useSearch()` retourne le bon type. + +--- + +## 5. TanStack Query — state serveur + +### Provider racine + +`src/main.tsx` : + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, // 30s par défaut, ajusté par query + gcTime: 5 * 60_000, // 5 min en cache + retry: 1, + refetchOnWindowFocus: false, + }, + mutations: { + retry: 0, + }, + }, +}) + +const router = createRouter({ + routeTree, + context: { queryClient }, + defaultPreload: 'intent', +}) + +declare module '@tanstack/react-router' { + interface Register { router: typeof router } +} + +createRoot(document.getElementById('root')!).render( + + + + + +) +``` + +### Convention queryKeys + +Dans `src/lib/queryKeys.ts` : + +```ts +export const queryKeys = { + me: () => ['me'] as const, + invoices: { + all: () => ['invoices'] as const, + list: (filters: InvoiceFilters) => ['invoices', 'list', filters] as const, + detail: (id: string) => ['invoices', 'detail', id] as const, + }, + plans: { + all: () => ['plans'] as const, + detail: (slug: string) => ['plans', 'detail', slug] as const, + }, + clients: { + all: () => ['clients'] as const, + }, + dashboard: { + kpis: () => ['dashboard', 'kpis'] as const, + activity: () => ['dashboard', 'activity'] as const, + }, +} as const +``` + +→ Permet d'invalider précisément après une mutation : `queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() })`. + +### Patterns d'invalidation après mutation + +```ts +const markPaidMutation = useMutation({ + mutationFn: (id: string) => api.invoices({ id }).markPaid.$post(), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() }) + queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.kpis() }) + toast.success('Facture marquée encaissée. + 1 rubis.') + }, +}) +``` + +--- + +## 6. Tuyau — client HTTP typé pour AdonisJS + +[Tuyau](https://github.com/Julien-R44/tuyau) est l'équivalent tRPC pour AdonisJS. Il génère un client TS qui connaît toutes les routes API, leurs payloads, et leurs réponses — depuis le code Adonis lui-même. + +### Côté API (à faire dans `apps/api/`) + +```bash +cd apps/api +pnpm add @tuyau/core +node ace add @tuyau/core +``` + +Configuration : annoter les routes Adonis avec un nom (utilisé pour l'autocomplete TS) : + +```ts +// apps/api/start/routes.ts +router.group(() => { + router.post('/auth/login', '#controllers/auth_controller.login').as('auth.login') + router.get('/me', '#controllers/me_controller.show').as('me.show').use(middleware.auth()) + + router.get('/invoices', '#controllers/invoices_controller.index') + .as('invoices.index').use(middleware.auth()) + router.post('/invoices', '#controllers/invoices_controller.store') + .as('invoices.store').use(middleware.auth()) + router.get('/invoices/:id', '#controllers/invoices_controller.show') + .as('invoices.show').use(middleware.auth()) + // ... +}).prefix('/api/v1') +``` + +Génération du client typé : + +```bash +node ace tuyau:generate +``` + +Crée `.adonisjs/api.ts` qui décrit toutes les routes en types TS. Ce fichier est versionné OU regénéré au build (à choisir). + +### Côté SPA (`apps/web/`) + +```bash +cd apps/web +pnpm add @tuyau/client +``` + +`src/lib/api.ts` : + +```ts +import { createTuyau } from '@tuyau/client' +import { api } from '../../../api/.adonisjs/api' // import des types depuis l'API + +export const tuyau = createTuyau({ + api, + baseUrl: import.meta.env.VITE_API_URL, // ex. https://api.rubis-sur-l-ongle.fr + credentials: 'include', // envoie les cookies (refresh token) + headers: () => ({ + Authorization: authStore.token ? `Bearer ${authStore.token}` : '', + }), +}) +``` + +### Intégration avec TanStack Query + +```tsx +import { useQuery } from '@tanstack/react-query' +import { tuyau } from '@/lib/api' +import { queryKeys } from '@/lib/queryKeys' + +export function useInvoices(filters: InvoiceFilters) { + return useQuery({ + queryKey: queryKeys.invoices.list(filters), + queryFn: async () => { + const { data, error } = await tuyau.api.v1.invoices.$get({ query: filters }) + if (error) throw error + return data // typé depuis le controller Adonis + }, + }) +} +``` + +→ **Zéro DTO manuel**, **autocomplete partout**, **erreur de compilation si l'API change** sans MAJ du SPA. Le contrat API ↔ web est verrouillé par TS. + +### Génération automatique au dev + +Ajouter un script `apps/api/package.json` : + +```json +{ + "scripts": { + "tuyau:watch": "node ace tuyau:generate --watch" + } +} +``` + +→ Pendant le dev, modifier une route API régénère les types instantanément côté SPA. + +--- + +## 7. Auth Bearer + refresh token (cf. ADR-017) + +### Auth store (en mémoire, pas localStorage) + +`src/lib/auth.ts` : + +```ts +type AuthState = { + token: string | null + user: User | null +} + +class AuthStore { + private state: AuthState = { token: null, user: null } + private listeners = new Set<() => void>() + + get token() { return this.state.token } + get user() { return this.state.user } + isAuthenticated() { return this.state.token !== null } + + setSession(token: string, user: User) { + this.state = { token, user } + this.notify() + } + + clear() { + this.state = { token: null, user: null } + this.notify() + } + + subscribe(fn: () => void) { + this.listeners.add(fn) + return () => this.listeners.delete(fn) + } + + private notify() { this.listeners.forEach(fn => fn()) } +} + +export const authStore = new AuthStore() +``` + +→ Le token vit en mémoire. Au refresh de la page, il est perdu mais récupérable via `/auth/refresh` (qui lit le cookie httpOnly). + +### Bootstrap session au boot du SPA + +`src/main.tsx` (avant le render) : + +```ts +async function bootstrap() { + try { + const { data } = await tuyau.api.v1.auth.refresh.$post() + if (data) authStore.setSession(data.accessToken, data.user) + } catch { + // pas de refresh valide, rester anonyme + } +} + +bootstrap().then(() => render()) +``` + +→ Si l'utilisateur a déjà une session valide (cookie refresh non expiré), le SPA récupère un access token avant le 1er render. Pas d'écran flash de login. + +### Auto-refresh sur 401 + +Intercepteur dans `tuyau` (à customiser) ou dans un wrapper `api()` qui retry sur 401 après un refresh silent. + +--- + +## 8. Pages à construire + +Référence visuelle : `/docs/wireframes-mvp.html`. Référence brand : `/docs/marque.md`. + +| # | Route | Wireframe | Priorité | Notes | +|---|---|---|---|---| +| 1 | `/login` | 1.2 | P0 | Email + password + Google SSO. | +| 2 | `/signup` | 1.1 | P0 | 2 champs + Google SSO. | +| 3 | `/onboarding/compte` | 1.3 step 1 | P0 | Préfilled email après signup. | +| 4 | `/onboarding/entreprise` | 1.3 step 2 | P0 | Wizard avec chips volume mensuel. | +| 5 | `/onboarding/signature` | 1.3 step 3 | P0 | Signature email pour les relances. | +| 6 | `/_app/` (Dashboard) | 4.1 | P0 | Hero rubis, KPIs, activité du jour. | +| 7 | `/_app/factures` | 2.4 | P0 | Liste + chips de filtre + actions en lot. | +| 8 | `/_app/factures` (empty) | 2.1 | P0 | Dropzone + drag&drop. | +| 9 | `/_app/factures/import/$batchId` | 2.2 | P0 | Split PDF / formulaire OCR. | +| 10 | (modal) | 2.3 | P1 | Saisie manuelle. | +| 11 | `/_app/factures/$id` | 4.2 | P0 | Timeline + sidepanel client + notes. | +| 12 | `/_app/plans` | 3.1 | P0 | Cards 4 plans pré-fournis. | +| 13 | `/_app/plans/$slug` | 3.2 | P0 | Éditeur cadence + email avec variables chips. | +| 14 | (storyboard 3 clics) | 3.3 | — | Concept landing, pas un écran applicatif. | +| 15 | Mobile dashboard | 4.3 | P0 | Responsive, pas une route séparée. | + +**Composants UI partagés à factoriser tôt** : + +- `` — gem ◆ + wordmark "Rubis" / "Rubis Sur l'Ongle" +- `