feat: scaffold frontend monorepo + first /login screen
Monorepo Turborepo (pnpm workspaces) avec 3 packages :
- apps/web : SPA React 19 + Vite 8 + Tailwind v4 (CSS-first)
• TanStack Router (file-based, auto code-splitting), Query, Form
• Radix primitives bruts + CVA + clsx + tailwind-merge
• MSW pour mocker l'API tant qu'Adonis n'est pas branché
• Polices Bricolage Grotesque + Inter self-hostées via fontsource
• Tokens marque (rubis, cream, ink) exposés via @theme
• Primitives maison : Gem, Brand, Eyebrow, Button, Input, Field
• Route /login full flow : TanStack Form + Zod + mutation Query
- apps/api : Adonis 7 (kit api, scaffold via create-adonisjs)
• Auth access tokens (Bearer) — cf. ADR-017
• Tuyau core déjà câblé pour la génération de types
• Routes /api/v1/auth/{signup,login} + /api/v1/account/{profile,logout}
• Minimal — uniquement le pont front ↔ back
- packages/shared : types TS + schemas Zod + constantes
• Source unique de vérité partagée api ↔ web
• Domaines : User, Org, Auth, Client, Invoice, Plan
Tooling racine : Turbo, ESLint v9 flat, Prettier, husky, lint-staged.
CLAUDE.md et docs/decisions.md mis à jour avec ADR-014 à ADR-018
(stack, monorepo, PG existant, Bearer tokens, MinIO existant)
et le pointeur vers docs/tech/architecture.md.
Logo Rubis déplacé de landing/assets/ vers /assets/ (source unique
réutilisée par la landing et l'app).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
7c80c391f1
commit
8d3bab6a89
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@ -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
|
||||
28
.gitignore
vendored
28
.gitignore
vendored
@ -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
|
||||
|
||||
4
.lintstagedrc.json
Normal file
4
.lintstagedrc.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"*.{ts,tsx,js,jsx}": ["prettier --write", "eslint --fix"],
|
||||
"*.{json,md,css,yml,yaml}": ["prettier --write"]
|
||||
}
|
||||
10
.prettierignore
Normal file
10
.prettierignore
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.turbo
|
||||
.adonisjs
|
||||
coverage
|
||||
pnpm-lock.yaml
|
||||
landing/index.html
|
||||
**/routeTree.gen.ts
|
||||
**/*.gen.ts
|
||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
16
CLAUDE.md
16
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) |
|
||||
|
||||
22
apps/api/.editorconfig
Normal file
22
apps/api/.editorconfig
Normal file
@ -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
|
||||
18
apps/api/.env.example
Normal file
18
apps/api/.env.example
Normal file
@ -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
|
||||
1
apps/api/.env.test
Normal file
1
apps/api/.env.test
Normal file
@ -0,0 +1 @@
|
||||
SESSION_DRIVER=memory
|
||||
26
apps/api/.gitignore
vendored
Normal file
26
apps/api/.gitignore
vendored
Normal file
@ -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
|
||||
3
apps/api/.prettierignore
Normal file
3
apps/api/.prettierignore
Normal file
@ -0,0 +1,3 @@
|
||||
.adonisjs
|
||||
node_modules
|
||||
build
|
||||
27
apps/api/ace.js
Normal file
27
apps/api/ace.js
Normal file
@ -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')
|
||||
116
apps/api/adonisrc.ts
Normal file
116
apps/api/adonisrc.ts
Normal file
@ -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(),
|
||||
],
|
||||
},
|
||||
})
|
||||
29
apps/api/app/controllers/access_tokens_controller.ts
Normal file
29
apps/api/app/controllers/access_tokens_controller.ts
Normal file
@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/api/app/controllers/new_account_controller.ts
Normal file
18
apps/api/app/controllers/new_account_controller.ts
Normal file
@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
8
apps/api/app/controllers/profile_controller.ts
Normal file
8
apps/api/app/controllers/profile_controller.ts
Normal file
@ -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()))
|
||||
}
|
||||
}
|
||||
28
apps/api/app/exceptions/handler.ts
Normal file
28
apps/api/app/exceptions/handler.ts
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
20
apps/api/app/middleware/auth_middleware.ts
Normal file
20
apps/api/app/middleware/auth_middleware.ts
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
19
apps/api/app/middleware/container_bindings_middleware.ts
Normal file
19
apps/api/app/middleware/container_bindings_middleware.ts
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
16
apps/api/app/middleware/silent_auth_middleware.ts
Normal file
16
apps/api/app/middleware/silent_auth_middleware.ts
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
18
apps/api/app/models/user.ts
Normal file
18
apps/api/app/models/user.ts
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
15
apps/api/app/transformers/user_transformer.ts
Normal file
15
apps/api/app/transformers/user_transformer.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type User from '#models/user'
|
||||
import { BaseTransformer } from '@adonisjs/core/transformers'
|
||||
|
||||
export default class UserTransformer extends BaseTransformer<User> {
|
||||
toObject() {
|
||||
return this.pick(this.resource, [
|
||||
'id',
|
||||
'fullName',
|
||||
'email',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'initials',
|
||||
])
|
||||
}
|
||||
}
|
||||
26
apps/api/app/validators/user.ts
Normal file
26
apps/api/app/validators/user.ts
Normal file
@ -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(),
|
||||
})
|
||||
47
apps/api/bin/console.ts
Normal file
47
apps/api/bin/console.ts
Normal file
@ -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)
|
||||
})
|
||||
45
apps/api/bin/server.ts
Normal file
45
apps/api/bin/server.ts
Normal file
@ -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)
|
||||
})
|
||||
62
apps/api/bin/test.ts
Normal file
62
apps/api/bin/test.ts
Normal file
@ -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)
|
||||
})
|
||||
93
apps/api/config/app.ts
Normal file
93
apps/api/config/app.ts
Normal file
@ -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',
|
||||
},
|
||||
})
|
||||
50
apps/api/config/auth.ts
Normal file
50
apps/api/config/auth.ts
Normal file
@ -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<typeof authConfig> {}
|
||||
}
|
||||
declare module '@adonisjs/core/types' {
|
||||
interface EventsList extends InferAuthEvents<Authenticators> {}
|
||||
}
|
||||
78
apps/api/config/bodyparser.ts
Normal file
78
apps/api/config/bodyparser.ts
Normal file
@ -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
|
||||
50
apps/api/config/cors.ts
Normal file
50
apps/api/config/cors.ts
Normal file
@ -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
|
||||
131
apps/api/config/database.ts
Normal file
131
apps/api/config/database.ts
Normal file
@ -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
|
||||
34
apps/api/config/encryption.ts
Normal file
34
apps/api/config/encryption.ts
Normal file
@ -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<typeof encryptionConfig> {}
|
||||
}
|
||||
75
apps/api/config/hash.ts
Normal file
75
apps/api/config/hash.ts
Normal file
@ -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<typeof hashConfig> {}
|
||||
}
|
||||
51
apps/api/config/logger.ts
Normal file
51
apps/api/config/logger.ts
Normal file
@ -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<typeof loggerConfig> {}
|
||||
}
|
||||
78
apps/api/config/session.ts
Normal file
78
apps/api/config/session.ts
Normal file
@ -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
|
||||
95
apps/api/config/shield.ts
Normal file
95
apps/api/config/shield.ts
Normal file
@ -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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
61
apps/api/database/schema.ts
Normal file
61
apps/api/database/schema.ts
Normal file
@ -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
|
||||
}
|
||||
3
apps/api/database/schema_rules.ts
Normal file
3
apps/api/database/schema_rules.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { type SchemaRules } from '@adonisjs/lucid/types/schema_generator'
|
||||
|
||||
export default {} satisfies SchemaRules
|
||||
2
apps/api/eslint.config.js
Normal file
2
apps/api/eslint.config.js
Normal file
@ -0,0 +1,2 @@
|
||||
import { configApp } from '@adonisjs/eslint-config'
|
||||
export default configApp()
|
||||
79
apps/api/package.json
Normal file
79
apps/api/package.json
Normal file
@ -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"
|
||||
}
|
||||
69
apps/api/providers/api_provider.ts
Normal file
69
apps/api/providers/api_provider.ts
Normal file
@ -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<ApiSerializer['serialize']>) {
|
||||
return serializer.serialize(data, resolver ?? this.containerResolver)
|
||||
},
|
||||
{
|
||||
withoutWrapping(
|
||||
this: HttpContext,
|
||||
...[data, resolver]: Parameters<ApiSerializer['serializeWithoutWrapping']>
|
||||
) {
|
||||
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
|
||||
}
|
||||
}
|
||||
27
apps/api/start/env.ts
Normal file
27
apps/api/start/env.ts
Normal file
@ -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),
|
||||
})
|
||||
49
apps/api/start/kernel.ts
Normal file
49
apps/api/start/kernel.ts
Normal file
@ -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'),
|
||||
})
|
||||
37
apps/api/start/routes.ts
Normal file
37
apps/api/start/routes.ts
Normal file
@ -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')
|
||||
23
apps/api/start/validator.ts
Normal file
23
apps/api/start/validator.ts
Normal file
@ -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))
|
||||
56
apps/api/tests/bootstrap.ts
Normal file
56
apps/api/tests/bootstrap.ts
Normal file
@ -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<Pick<Config, 'setup' | 'teardown'>> = {
|
||||
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())
|
||||
}
|
||||
}
|
||||
0
apps/api/tmp/.gitkeep
Normal file
0
apps/api/tmp/.gitkeep
Normal file
8
apps/api/tsconfig.json
Normal file
8
apps/api/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@adonisjs/tsconfig/tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./",
|
||||
"jsx": "react",
|
||||
"outDir": "./build"
|
||||
}
|
||||
}
|
||||
3
apps/web/.env.development
Normal file
3
apps/web/.env.development
Normal file
@ -0,0 +1,3 @@
|
||||
VITE_API_URL=http://localhost:3333
|
||||
VITE_PUBLIC_LANDING_URL=http://localhost:8080
|
||||
VITE_USE_MOCKS=true
|
||||
10
apps/web/.env.example
Normal file
10
apps/web/.env.example
Normal file
@ -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
|
||||
24
apps/web/.gitignore
vendored
Normal file
24
apps/web/.gitignore
vendored
Normal file
@ -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?
|
||||
73
apps/web/README.md
Normal file
73
apps/web/README.md
Normal file
@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
22
apps/web/eslint.config.js
Normal file
22
apps/web/eslint.config.js
Normal file
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
18
apps/web/index.html
Normal file
18
apps/web/index.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#9F1239" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Rubis Sur l'Ongle — Vos factures relancées toutes seules pendant que vous travaillez."
|
||||
/>
|
||||
<title>Rubis Sur l'Ongle</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
71
apps/web/package.json
Normal file
71
apps/web/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
3
apps/web/public/favicon.svg
Normal file
3
apps/web/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.1 MiB |
43
apps/web/src/components/brand/Brand.tsx
Normal file
43
apps/web/src/components/brand/Brand.tsx
Normal file
@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2.5 font-display text-ink",
|
||||
"text-[19px] font-extrabold tracking-[-0.02em]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Gem size={gemSize} aria-label="Rubis Sur l'Ongle" />
|
||||
<span className="leading-none">
|
||||
Rubis
|
||||
{withSuffix && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 font-display italic font-medium text-ink-3",
|
||||
"text-[12.5px] tracking-[-0.005em]",
|
||||
)}
|
||||
>
|
||||
sur l'ongle
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
45
apps/web/src/components/brand/Gem.tsx
Normal file
45
apps/web/src/components/brand/Gem.tsx
Normal file
@ -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 (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 200 200"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
"text-rubis shrink-0",
|
||||
glow && "[filter:drop-shadow(0_4px_8px_rgba(159,18,57,0.3))]",
|
||||
className,
|
||||
)}
|
||||
role={props["aria-label"] ? "img" : "presentation"}
|
||||
aria-hidden={props["aria-label"] ? undefined : "true"}
|
||||
{...props}
|
||||
>
|
||||
<polygon points="100,10 190,100 100,190 10,100" fill="currentColor" />
|
||||
{/* Table du gem */}
|
||||
<line x1="10" y1="100" x2="190" y2="100" stroke="rgba(255,255,255,0.55)" strokeWidth="3" />
|
||||
{/* Facettes hautes */}
|
||||
<line x1="55" y1="55" x2="100" y2="100" stroke="rgba(255,255,255,0.4)" strokeWidth="2" />
|
||||
<line x1="145" y1="55" x2="100" y2="100" stroke="rgba(255,255,255,0.4)" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
103
apps/web/src/components/ui/Button.tsx
Normal file
103
apps/web/src/components/ui/Button.tsx
Normal file
@ -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 : `<Button asChild><Link to=…>` propage les styles
|
||||
* sans wrapper supplémentaire.
|
||||
*/
|
||||
const buttonVariants = cva(
|
||||
cn(
|
||||
// Base
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-default",
|
||||
"font-sans font-semibold transition-[transform,background,box-shadow,color] duration-150",
|
||||
// États génériques
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
// Focus ring discret rubis-glow — pas de blue ring browser default
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||
"focus-visible:ring-offset-0",
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: cn(
|
||||
"bg-rubis text-white shadow-rubis",
|
||||
"hover:bg-rubis-deep hover:-translate-y-px hover:shadow-rubis-hover",
|
||||
"active:translate-y-0 active:shadow-rubis",
|
||||
),
|
||||
secondary: cn(
|
||||
"bg-transparent text-ink border border-ink",
|
||||
"hover:bg-ink hover:text-cream",
|
||||
),
|
||||
ghost: cn(
|
||||
"bg-transparent text-ink",
|
||||
"hover:bg-cream-2",
|
||||
),
|
||||
link: cn(
|
||||
"bg-transparent text-rubis underline-offset-4 hover:underline px-0 py-0 h-auto",
|
||||
"shadow-none",
|
||||
),
|
||||
danger: cn(
|
||||
"bg-rubis-deep text-white",
|
||||
"hover:bg-rubis-deep/90",
|
||||
),
|
||||
},
|
||||
size: {
|
||||
sm: "h-9 px-3 text-[13px]",
|
||||
md: "h-11 px-[22px] py-[13px] text-[15px]",
|
||||
lg: "h-12 px-7 text-base",
|
||||
icon: "size-10 px-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "primary",
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
/** Si true, rend l'enfant en propageant les styles (cf. Radix Slot). */
|
||||
asChild?: boolean;
|
||||
/** État chargement : remplace le contenu par un spinner discret. */
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, loading = false, children, disabled, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <ButtonSpinner /> : children}
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
function ButtonSpinner() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block size-4 animate-spin rounded-full border-2 border-current border-r-transparent"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { buttonVariants };
|
||||
37
apps/web/src/components/ui/Eyebrow.tsx
Normal file
37
apps/web/src/components/ui/Eyebrow.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Eyebrow — petit label majuscule rubis avec ◆ géométrique en préfixe.
|
||||
* Marqueur de section, signature visible partout. Cf. /docs/marque.md §4.
|
||||
*
|
||||
* On préfère ça à un sous-titre classique : moins corporate, plus identifiable.
|
||||
*/
|
||||
type EyebrowProps = {
|
||||
children: React.ReactNode;
|
||||
/** Désactive le ◆ préfixe (rare — utile dans des contextes inline). */
|
||||
withoutMark?: boolean;
|
||||
/** Couleur custom (default rubis). Doit rester dans la palette marque. */
|
||||
tone?: "rubis" | "ink";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Eyebrow({ children, withoutMark = false, tone = "rubis", className }: EyebrowProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 font-sans text-[11px] font-semibold uppercase leading-tight",
|
||||
"tracking-[0.14em]",
|
||||
tone === "rubis" ? "text-rubis" : "text-ink-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!withoutMark && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block size-[7px] rotate-45 bg-current"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
98
apps/web/src/components/ui/Field.tsx
Normal file
98
apps/web/src/components/ui/Field.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { useId } from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Field — groupe label + input + description + erreur.
|
||||
* Compose des primitives Radix Label pour l'a11y propre.
|
||||
*
|
||||
* Usage :
|
||||
* <Field label="Email" hint="Pro de préférence" error={...}>
|
||||
* <Input type="email" />
|
||||
* </Field>
|
||||
*
|
||||
* Le children reçoit automatiquement `id` et `aria-invalid` via cloneElement-free
|
||||
* pattern : le `htmlFor` matche le `id` injecté dans children, mais on laisse
|
||||
* l'utilisateur passer son input pour rester explicite.
|
||||
*/
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
/** Description courte sous le label (≠ erreur). */
|
||||
hint?: string;
|
||||
/** Message d'erreur — affiché en rubis-deep. */
|
||||
error?: string;
|
||||
/** ID custom (sinon généré). À passer aussi sur l'input enfant. */
|
||||
htmlFor?: string;
|
||||
/** Si true, label visuellement masqué (mais lu par les screen readers). */
|
||||
srOnlyLabel?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
htmlFor,
|
||||
srOnlyLabel = false,
|
||||
children,
|
||||
className,
|
||||
}: FieldProps) {
|
||||
const generatedId = useId();
|
||||
const id = htmlFor ?? generatedId;
|
||||
const hintId = hint ? `${id}-hint` : undefined;
|
||||
const errorId = error ? `${id}-error` : undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<LabelPrimitive.Root
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"font-sans text-[13px] font-semibold text-ink",
|
||||
srOnlyLabel && "sr-only",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</LabelPrimitive.Root>
|
||||
{hint && !error && (
|
||||
<p id={hintId} className="text-[12.5px] text-ink-3 leading-snug">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
<FieldContext.Provider value={{ id, describedBy: errorId ?? hintId, invalid: !!error }}>
|
||||
{children}
|
||||
</FieldContext.Provider>
|
||||
{error && (
|
||||
<p
|
||||
id={errorId}
|
||||
role="alert"
|
||||
className="text-[12.5px] font-medium text-rubis-deep leading-snug"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
type FieldContextValue = {
|
||||
id: string;
|
||||
describedBy: string | undefined;
|
||||
invalid: boolean;
|
||||
} | null;
|
||||
|
||||
const FieldContext = createContext<FieldContextValue>(null);
|
||||
|
||||
/**
|
||||
* Hook pour les inputs enfants : récupère id, aria-describedby, aria-invalid
|
||||
* depuis le Field parent. Permet d'éviter les props redondantes.
|
||||
*/
|
||||
export function useFieldContext(): NonNullable<FieldContextValue> {
|
||||
const ctx = useContext(FieldContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useFieldContext doit être utilisé dans un <Field>");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
36
apps/web/src/components/ui/Input.tsx
Normal file
36
apps/web/src/components/ui/Input.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Input texte — primitive minimale.
|
||||
* - Pas de shadow, juste 1px line.
|
||||
* - Focus = ring rubis-glow + border rubis (pas de blue browser default).
|
||||
* - aria-invalid = état d'erreur visible sans dépendre de la lib de form.
|
||||
*/
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type = "text", ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
// Base
|
||||
"block w-full rounded-default border border-line bg-white px-3.5 py-3",
|
||||
"font-sans text-[15px] text-ink placeholder:text-ink-3",
|
||||
// Transitions
|
||||
"transition-[border-color,box-shadow] duration-150",
|
||||
// Focus
|
||||
"focus:outline-none focus:border-rubis focus:ring-4 focus:ring-rubis-glow",
|
||||
// États
|
||||
"disabled:cursor-not-allowed disabled:bg-cream-2 disabled:text-ink-3",
|
||||
"aria-[invalid=true]:border-rubis-deep aria-[invalid=true]:bg-rubis-glow/30",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
102
apps/web/src/lib/api.ts
Normal file
102
apps/web/src/lib/api.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { env } from "./env";
|
||||
import { authStore } from "./auth";
|
||||
|
||||
/**
|
||||
* Client HTTP minimal — placeholder en attendant que le client Tuyau
|
||||
* soit branché contre le code Adonis (cf. /docs/tech/frontend.md §6).
|
||||
*
|
||||
* Tant que MSW intercepte ou que l'API n'est pas prête, on tape via fetch
|
||||
* sur baseUrl/api/v1/... et on sérialise/désérialise nous-mêmes.
|
||||
*
|
||||
* Une fois Tuyau opérationnel :
|
||||
* import { createTuyau } from "@tuyau/client"
|
||||
* import { api } from "@rubis/api/registry"
|
||||
* export const tuyau = createTuyau({ api, baseUrl: env.VITE_API_URL, ... })
|
||||
*
|
||||
* On gardera ce fichier comme façade pour pouvoir centraliser :
|
||||
* - l'auth header
|
||||
* - le retry sur 401 (silent refresh)
|
||||
* - la gestion d'erreur uniforme
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly fieldErrors?: Record<string, string[]>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
/** Si true, n'inclut pas le header Authorization (utile pour /auth/login). */
|
||||
anonymous?: boolean;
|
||||
};
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = "GET", body, signal, anonymous = false } = options;
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
if (!anonymous && authStore.token) {
|
||||
headers.Authorization = `Bearer ${authStore.token}`;
|
||||
}
|
||||
|
||||
const url = path.startsWith("http") ? path : `${env.VITE_API_URL}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const json = (await response.json().catch(() => null)) as
|
||||
| { data?: T; errors?: Array<{ code: string; message: string; field?: string }> }
|
||||
| null;
|
||||
|
||||
if (!response.ok) {
|
||||
const firstError = json?.errors?.[0];
|
||||
const fieldErrors: Record<string, string[]> | undefined = json?.errors?.reduce(
|
||||
(acc, err) => {
|
||||
if (err.field) {
|
||||
acc[err.field] = [...(acc[err.field] ?? []), err.message];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string[]>,
|
||||
);
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
firstError?.code ?? "unknown",
|
||||
firstError?.message ?? `Requête échouée (${response.status})`,
|
||||
fieldErrors,
|
||||
);
|
||||
}
|
||||
|
||||
// Convention de réponse Adonis : { data: ..., meta?: ... }
|
||||
return (json?.data ?? json) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
||||
request<T>(path, { ...options, method: "GET" }),
|
||||
post: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
||||
request<T>(path, { ...options, method: "POST", body }),
|
||||
patch: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
||||
request<T>(path, { ...options, method: "PATCH", body }),
|
||||
delete: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
||||
request<T>(path, { ...options, method: "DELETE" }),
|
||||
};
|
||||
60
apps/web/src/lib/auth.ts
Normal file
60
apps/web/src/lib/auth.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { User } from "@rubis/shared";
|
||||
|
||||
/**
|
||||
* Auth store — token en mémoire seulement (pas localStorage).
|
||||
* Le refresh token vit en cookie httpOnly côté API, invisible ici.
|
||||
* Cf. ADR-017 et /docs/tech/frontend.md §7.
|
||||
*/
|
||||
|
||||
type AuthState = {
|
||||
accessToken: string | null;
|
||||
user: User | null;
|
||||
};
|
||||
|
||||
class AuthStore {
|
||||
private state: AuthState = { accessToken: null, user: null };
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
getSnapshot = (): AuthState => this.state;
|
||||
|
||||
get token(): string | null {
|
||||
return this.state.accessToken;
|
||||
}
|
||||
|
||||
get user(): User | null {
|
||||
return this.state.user;
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return this.state.accessToken !== null;
|
||||
}
|
||||
|
||||
setSession(accessToken: string, user: User): void {
|
||||
this.state = { accessToken, user };
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.state = { accessToken: null, user: null };
|
||||
this.notify();
|
||||
}
|
||||
|
||||
subscribe = (fn: () => void): (() => void) => {
|
||||
this.listeners.add(fn);
|
||||
return () => {
|
||||
this.listeners.delete(fn);
|
||||
};
|
||||
};
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
}
|
||||
|
||||
export const authStore = new AuthStore();
|
||||
|
||||
/** Hook React pour s'abonner à l'état d'auth. */
|
||||
export function useAuth(): AuthState {
|
||||
return useSyncExternalStore(authStore.subscribe, authStore.getSnapshot, authStore.getSnapshot);
|
||||
}
|
||||
24
apps/web/src/lib/env.ts
Normal file
24
apps/web/src/lib/env.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Variables d'environnement exposées au client (préfixées VITE_).
|
||||
* Validées au boot pour planter tôt si une variable manque.
|
||||
*/
|
||||
const envSchema = z.object({
|
||||
VITE_API_URL: z.string().url(),
|
||||
VITE_PUBLIC_LANDING_URL: z.string().url(),
|
||||
VITE_USE_MOCKS: z
|
||||
.enum(["true", "false"])
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(import.meta.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Variables d'environnement invalides :", parsed.error.flatten().fieldErrors);
|
||||
throw new Error("Configuration invalide. Voir .env.example");
|
||||
}
|
||||
|
||||
export const env = parsed.data;
|
||||
45
apps/web/src/lib/format.ts
Normal file
45
apps/web/src/lib/format.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { format, formatDistanceToNowStrict, parseISO } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { MINUTES_PER_RUBIS } from "@rubis/shared";
|
||||
|
||||
/**
|
||||
* Formateurs métier — centralisés pour cohérence d'affichage.
|
||||
* Cf. /docs/tech/frontend.md §9.
|
||||
*/
|
||||
|
||||
/** "1 240,00 €" depuis un montant en centimes. */
|
||||
export function formatEuros(cents: number): string {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un nombre de rubis en libellé "X h Y" (1 rubis = 10 min).
|
||||
* Ex : 124 rubis → "20 h 40".
|
||||
*/
|
||||
export function formatRubisToHours(rubis: number): string {
|
||||
const totalMinutes = rubis * MINUTES_PER_RUBIS;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (hours === 0) return `${minutes} min`;
|
||||
if (minutes === 0) return `${hours} h`;
|
||||
return `${hours} h ${minutes.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** "5 mai 2026" depuis une date ISO. */
|
||||
export function formatDate(iso: string): string {
|
||||
return format(parseISO(iso), "d MMMM yyyy", { locale: fr });
|
||||
}
|
||||
|
||||
/** "dans 3 jours" / "il y a 2 jours" depuis maintenant. */
|
||||
export function formatRelativeDate(iso: string): string {
|
||||
return formatDistanceToNowStrict(parseISO(iso), { locale: fr, addSuffix: true });
|
||||
}
|
||||
|
||||
/** "5 mai" version courte. */
|
||||
export function formatDateShort(iso: string): string {
|
||||
return format(parseISO(iso), "d MMM", { locale: fr });
|
||||
}
|
||||
26
apps/web/src/lib/queryKeys.ts
Normal file
26
apps/web/src/lib/queryKeys.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { InvoiceListFilters } from "@rubis/shared";
|
||||
|
||||
/**
|
||||
* Convention queryKeys — toutes les clés TanStack Query passent ici.
|
||||
* Permet d'invalider précisément après une mutation.
|
||||
*/
|
||||
export const queryKeys = {
|
||||
me: () => ["me"] as const,
|
||||
invoices: {
|
||||
all: () => ["invoices"] as const,
|
||||
list: (filters: InvoiceListFilters) => ["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,
|
||||
detail: (id: string) => ["clients", "detail", id] as const,
|
||||
},
|
||||
dashboard: {
|
||||
kpis: () => ["dashboard", "kpis"] as const,
|
||||
activity: () => ["dashboard", "activity"] as const,
|
||||
},
|
||||
} as const;
|
||||
10
apps/web/src/lib/utils.ts
Normal file
10
apps/web/src/lib/utils.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* Combine et résout les conflits de classes Tailwind.
|
||||
* Utilisation : `<div className={cn("p-4", maybeBig && "p-8")} />`
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
69
apps/web/src/main.tsx
Normal file
69
apps/web/src/main.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { env } from "./lib/env";
|
||||
|
||||
import "./styles/app.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient },
|
||||
defaultPreload: "intent",
|
||||
defaultPreloadStaleTime: 0,
|
||||
});
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableMocking(): Promise<void> {
|
||||
// import.meta.env.DEV est un booléen statique : Vite tree-shake la branche
|
||||
// entière (et le chunk MSW avec) quand on build en mode production.
|
||||
if (!import.meta.env.DEV || !env.VITE_USE_MOCKS) return;
|
||||
const { worker } = await import("./mocks/browser");
|
||||
await worker.start({
|
||||
onUnhandledRequest: "bypass",
|
||||
serviceWorker: {
|
||||
url: "/mockServiceWorker.js",
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
"%c[MSW]%c Mocks API actifs — VITE_USE_MOCKS=true",
|
||||
"background:#9F1239;color:white;padding:2px 6px;border-radius:3px;font-weight:600",
|
||||
"color:#8A7F76",
|
||||
);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("#root introuvable dans index.html");
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
void enableMocking().then(render);
|
||||
5
apps/web/src/mocks/browser.ts
Normal file
5
apps/web/src/mocks/browser.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { setupWorker } from "msw/browser";
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
/** Service worker MSW — démarré conditionnellement depuis main.tsx. */
|
||||
export const worker = setupWorker(...handlers);
|
||||
84
apps/web/src/mocks/db.ts
Normal file
84
apps/web/src/mocks/db.ts
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Petite base in-memory pour les mocks MSW.
|
||||
* Persiste dans sessionStorage pour survivre aux reload pendant le dev,
|
||||
* mais reste isolée par onglet (pas d'interférence entre devs).
|
||||
*/
|
||||
import type { User } from "@rubis/shared";
|
||||
|
||||
const STORAGE_KEY = "rubis.mocks.db";
|
||||
|
||||
type Db = {
|
||||
users: Array<User & { passwordHash: string }>;
|
||||
};
|
||||
|
||||
const seedDb = (): Db => ({
|
||||
users: [
|
||||
{
|
||||
id: "usr_demo",
|
||||
email: "demo@rubis.fr",
|
||||
fullName: "Arthur Démo",
|
||||
organizationId: "org_demo",
|
||||
signature: "Cordialement,\nArthur — Rubis Démo",
|
||||
createdAt: new Date("2026-01-01").toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
// mot de passe : "demo1234"
|
||||
passwordHash: "demo1234",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function load(): Db {
|
||||
if (typeof sessionStorage === "undefined") return seedDb();
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
const fresh = seedDb();
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(fresh));
|
||||
return fresh;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as Db;
|
||||
} catch {
|
||||
const fresh = seedDb();
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(fresh));
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
function save(db: Db): void {
|
||||
if (typeof sessionStorage !== "undefined") {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(db));
|
||||
}
|
||||
}
|
||||
|
||||
export const mockDb = {
|
||||
findUserByEmail(email: string): (User & { passwordHash: string }) | undefined {
|
||||
const db = load();
|
||||
return db.users.find((u) => u.email.toLowerCase() === email.toLowerCase());
|
||||
},
|
||||
|
||||
createUser(input: { email: string; password: string; fullName: string }): User {
|
||||
const db = load();
|
||||
const orgId = `org_${crypto.randomUUID()}`;
|
||||
const user: User & { passwordHash: string } = {
|
||||
id: `usr_${crypto.randomUUID()}`,
|
||||
email: input.email,
|
||||
fullName: input.fullName,
|
||||
organizationId: orgId,
|
||||
signature: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
passwordHash: input.password,
|
||||
};
|
||||
db.users.push(user);
|
||||
save(db);
|
||||
// Renvoie sans le hash
|
||||
const { passwordHash: _ph, ...publicUser } = user;
|
||||
return publicUser;
|
||||
},
|
||||
|
||||
reset(): void {
|
||||
if (typeof sessionStorage !== "undefined") {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
},
|
||||
};
|
||||
137
apps/web/src/mocks/handlers/auth.ts
Normal file
137
apps/web/src/mocks/handlers/auth.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { loginSchema, registerSchema } from "@rubis/shared";
|
||||
import { mockDb } from "../db";
|
||||
|
||||
const apiBase = "*/api/v1";
|
||||
|
||||
/** Génère un faux access token signé "à la main" — pas de vraie crypto. */
|
||||
function fakeToken(userId: string): string {
|
||||
return `mock.${userId}.${Date.now()}`;
|
||||
}
|
||||
|
||||
function expiresInMinutes(min: number): string {
|
||||
return new Date(Date.now() + min * 60_000).toISOString();
|
||||
}
|
||||
|
||||
export const authHandlers = [
|
||||
// POST /api/v1/auth/login
|
||||
http.post(`${apiBase}/auth/login`, async ({ request }) => {
|
||||
const json = await request.json();
|
||||
const parsed = loginSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: parsed.error.issues.map((i) => ({
|
||||
code: "validation_failed",
|
||||
message: i.message,
|
||||
field: i.path.join("."),
|
||||
})),
|
||||
},
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
const user = mockDb.findUserByEmail(parsed.data.email);
|
||||
if (!user || user.passwordHash !== parsed.data.password) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{ code: "invalid_credentials", message: "Email ou mot de passe incorrect" },
|
||||
],
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
const { passwordHash: _ph, ...publicUser } = user;
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
accessToken: fakeToken(user.id),
|
||||
expiresAt: expiresInMinutes(30),
|
||||
user: publicUser,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// POST /api/v1/auth/signup
|
||||
http.post(`${apiBase}/auth/signup`, async ({ request }) => {
|
||||
const json = await request.json();
|
||||
const parsed = registerSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: parsed.error.issues.map((i) => ({
|
||||
code: "validation_failed",
|
||||
message: i.message,
|
||||
field: i.path.join("."),
|
||||
})),
|
||||
},
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
if (mockDb.findUserByEmail(parsed.data.email)) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
code: "email_taken",
|
||||
message: "Cet email est déjà utilisé",
|
||||
field: "email",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
const user = mockDb.createUser(parsed.data);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
data: {
|
||||
accessToken: fakeToken(user.id),
|
||||
expiresAt: expiresInMinutes(30),
|
||||
user,
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
}),
|
||||
|
||||
// POST /api/v1/auth/refresh — pour l'instant, pas de refresh token côté mocks.
|
||||
http.post(`${apiBase}/auth/refresh`, () => {
|
||||
return HttpResponse.json(
|
||||
{ errors: [{ code: "no_session", message: "Pas de session active" }] },
|
||||
{ status: 401 },
|
||||
);
|
||||
}),
|
||||
|
||||
// POST /api/v1/account/logout
|
||||
http.post(`${apiBase}/account/logout`, () => {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
|
||||
// GET /api/v1/account/profile
|
||||
http.get(`${apiBase}/account/profile`, ({ request }) => {
|
||||
const auth = request.headers.get("authorization");
|
||||
if (!auth?.startsWith("Bearer mock.")) {
|
||||
return HttpResponse.json(
|
||||
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
const userId = auth.split(".")[1];
|
||||
if (!userId) {
|
||||
return HttpResponse.json(
|
||||
{ errors: [{ code: "invalid_token", message: "Token invalide" }] },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
// On retrouve l'utilisateur par id
|
||||
const seed = mockDb.findUserByEmail("demo@rubis.fr");
|
||||
if (!seed) {
|
||||
return HttpResponse.json(
|
||||
{ errors: [{ code: "not_found", message: "Utilisateur introuvable" }] },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const { passwordHash: _ph, ...publicUser } = seed;
|
||||
return HttpResponse.json({ data: publicUser });
|
||||
}),
|
||||
];
|
||||
4
apps/web/src/mocks/handlers/index.ts
Normal file
4
apps/web/src/mocks/handlers/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { authHandlers } from "./auth";
|
||||
|
||||
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
|
||||
export const handlers = [...authHandlers];
|
||||
74
apps/web/src/routes/__root.tsx
Normal file
74
apps/web/src/routes/__root.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { Toaster } from "sonner";
|
||||
import { lazy, Suspense } from "react";
|
||||
|
||||
/**
|
||||
* Devtools — chargés uniquement en dev pour ne pas alourdir le bundle prod.
|
||||
* Cf. /docs/tech/frontend.md §4.
|
||||
*/
|
||||
const TanStackRouterDevtools = import.meta.env.DEV
|
||||
? lazy(() =>
|
||||
import("@tanstack/react-router-devtools").then((m) => ({
|
||||
default: m.TanStackRouterDevtools,
|
||||
})),
|
||||
)
|
||||
: () => null;
|
||||
|
||||
const ReactQueryDevtools = import.meta.env.DEV
|
||||
? lazy(() =>
|
||||
import("@tanstack/react-query-devtools").then((m) => ({
|
||||
default: m.ReactQueryDevtools,
|
||||
})),
|
||||
)
|
||||
: () => null;
|
||||
|
||||
export interface RouterContext {
|
||||
queryClient: QueryClient;
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootLayout,
|
||||
notFoundComponent: NotFound,
|
||||
});
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "var(--color-cream)",
|
||||
color: "var(--color-ink)",
|
||||
border: "1px solid var(--color-line)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{import.meta.env.DEV && (
|
||||
<Suspense fallback={null}>
|
||||
<TanStackRouterDevtools position="bottom-left" />
|
||||
<ReactQueryDevtools buttonPosition="bottom-right" />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-cream px-6 text-center">
|
||||
<p className="font-display text-[80px] font-extrabold leading-none tracking-tight text-rubis">
|
||||
404
|
||||
</p>
|
||||
<h1 className="mt-4 font-display text-3xl font-bold text-ink">
|
||||
Cette page <em>s'est perdue</em>.
|
||||
</h1>
|
||||
<p className="mt-3 max-w-sm text-ink-2">
|
||||
Rien de grave. Retournez à l'accueil et on repart de zéro.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
apps/web/src/routes/index.tsx
Normal file
15
apps/web/src/routes/index.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { authStore } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Route racine "/" — redirige selon l'état d'auth.
|
||||
* Pas de UI propre : c'est juste un router.
|
||||
*/
|
||||
export const Route = createFileRoute("/")({
|
||||
beforeLoad: () => {
|
||||
if (authStore.isAuthenticated()) {
|
||||
throw redirect({ to: "/login" }); // Sera /_app/ une fois le layout app prêt
|
||||
}
|
||||
throw redirect({ to: "/login" });
|
||||
},
|
||||
});
|
||||
194
apps/web/src/routes/login.tsx
Normal file
194
apps/web/src/routes/login.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { loginSchema, type AuthSession, type LoginInput } from "@rubis/shared";
|
||||
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { authStore } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { Brand } from "@/components/brand/Brand";
|
||||
import { Gem } from "@/components/brand/Gem";
|
||||
|
||||
const searchSchema = z.object({
|
||||
redirect: z.string().optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
validateSearch: searchSchema,
|
||||
component: LoginPage,
|
||||
});
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch();
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (input: LoginInput) =>
|
||||
api.post<AuthSession>("/api/v1/auth/login", input, { anonymous: true }),
|
||||
onSuccess: (session) => {
|
||||
authStore.setSession(session.accessToken, session.user);
|
||||
toast.success(`Bonjour ${session.user.fullName.split(" ")[0]}.`);
|
||||
void navigate({ to: search.redirect ?? "/login" });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
toast.error("Email ou mot de passe incorrect.");
|
||||
return;
|
||||
}
|
||||
toast.error("Connexion impossible. Réessayez dans un instant.");
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: { email: "", password: "" } satisfies LoginInput,
|
||||
validators: {
|
||||
onChange: loginSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await loginMutation.mutateAsync(value);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-cream relative overflow-hidden">
|
||||
{/* Glow rubis discret en haut-droite — signature visuelle (cohérent landing). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-[-180px] right-[-220px] size-[680px] rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(251,228,234,0.55), transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 mx-auto grid min-h-screen w-full max-w-[1180px] grid-cols-1 gap-16 px-6 py-12 lg:grid-cols-[1.1fr_1fr] lg:items-center lg:px-8">
|
||||
{/* Colonne gauche — message marketing.
|
||||
Décalée, dense, du caractère. Pas une carte centrée et fade. */}
|
||||
<section className="order-2 lg:order-1 max-w-[520px]">
|
||||
<Link to="/login" className="inline-block">
|
||||
<Brand withSuffix />
|
||||
</Link>
|
||||
|
||||
<Eyebrow className="mt-12">Bon retour</Eyebrow>
|
||||
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-[-0.025em] text-ink lg:text-[52px]">
|
||||
Vos factures vous <em>attendent</em>.
|
||||
<br className="hidden sm:block" />
|
||||
On reprend où vous en étiez.
|
||||
</h1>
|
||||
<p className="mt-5 max-w-md text-[16px] leading-[1.6] text-ink-2">
|
||||
Connectez-vous pour voir où en sont vos relances, qui a payé,
|
||||
et combien de temps Rubis vous a fait gagner cette semaine.
|
||||
</p>
|
||||
|
||||
<ul className="mt-10 flex flex-wrap gap-x-6 gap-y-3 text-[12.5px] text-ink-3">
|
||||
<li className="inline-flex items-center gap-2">
|
||||
<Gem size={10} /> Hébergement souverain
|
||||
</li>
|
||||
<li className="inline-flex items-center gap-2">
|
||||
<span className="size-1 rounded-full bg-ink-3" aria-hidden="true" />
|
||||
Made in France
|
||||
</li>
|
||||
<li className="inline-flex items-center gap-2">
|
||||
<span className="size-1 rounded-full bg-ink-3" aria-hidden="true" />
|
||||
RGPD-friendly
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Colonne droite — formulaire de connexion */}
|
||||
<section className="order-1 lg:order-2">
|
||||
<div className="mx-auto w-full max-w-[420px] rounded-card border border-line bg-white p-8 shadow-card">
|
||||
<h2 className="font-display text-2xl font-semibold tracking-[-0.018em] text-ink">
|
||||
Se connecter
|
||||
</h2>
|
||||
<p className="mt-1.5 text-[14px] text-ink-3">
|
||||
Pas encore de compte ?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-rubis underline-offset-4 hover:underline"
|
||||
>
|
||||
Créer un compte
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
className="mt-7 flex flex-col gap-5"
|
||||
>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<Field
|
||||
label="Email"
|
||||
htmlFor={field.name}
|
||||
error={field.state.meta.errors[0]?.message}
|
||||
>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
placeholder="vous@entreprise.fr"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
aria-invalid={field.state.meta.errors.length > 0}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<Field
|
||||
label="Mot de passe"
|
||||
htmlFor={field.name}
|
||||
error={field.state.meta.errors[0]?.message}
|
||||
>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
aria-invalid={field.state.meta.errors.length > 0}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="md"
|
||||
loading={loginMutation.isPending}
|
||||
className="mt-1 w-full"
|
||||
>
|
||||
Continuer <ArrowRight size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-[12.5px] text-ink-3">
|
||||
<Link to="/login" className="hover:text-rubis hover:underline">
|
||||
Mot de passe oublié ?
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
142
apps/web/src/styles/app.css
Normal file
142
apps/web/src/styles/app.css
Normal file
@ -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);
|
||||
}
|
||||
7
apps/web/src/test/setup.ts
Normal file
7
apps/web/src/test/setup.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
22
apps/web/tsconfig.app.json
Normal file
22
apps/web/tsconfig.app.json
Normal file
@ -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"]
|
||||
}
|
||||
7
apps/web/tsconfig.json
Normal file
7
apps/web/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
15
apps/web/tsconfig.node.json
Normal file
15
apps/web/tsconfig.node.json
Normal file
@ -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"]
|
||||
}
|
||||
28
apps/web/vite.config.ts
Normal file
28
apps/web/vite.config.ts
Normal file
@ -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,
|
||||
},
|
||||
});
|
||||
21
apps/web/vitest.config.ts
Normal file
21
apps/web/vitest.config.ts
Normal file
@ -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}"],
|
||||
},
|
||||
});
|
||||
|
Before Width: | Height: | Size: 846 KiB After Width: | Height: | Size: 846 KiB |
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
456
docs/tech/architecture.md
Normal file
456
docs/tech/architecture.md
Normal file
@ -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<typeof createInvoiceSchema>
|
||||
```
|
||||
|
||||
**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 <accessToken>
|
||||
▼
|
||||
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:<sha>
|
||||
- git.arthurbarre.fr/ordinarthur/rubis-web:<sha>
|
||||
↓
|
||||
kubectl rollout (api + web)
|
||||
↓
|
||||
healthchecks readinessProbe → service public
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Conventions de code
|
||||
|
||||
| Domaine | Convention |
|
||||
|---|---|
|
||||
| Branches | `feat/<short-desc>`, `fix/<short-desc>`, `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/<date>.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`.*
|
||||
763
docs/tech/frontend.md
Normal file
763
docs/tech/frontend.md
Normal file
@ -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
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700;12..96,800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
```
|
||||
|
||||
### Usage typique
|
||||
|
||||
```tsx
|
||||
<button className="bg-rubis text-white hover:bg-rubis-deep px-4 py-2 rounded-md font-medium">
|
||||
Démarrer
|
||||
</button>
|
||||
|
||||
<h1 className="font-display text-4xl tracking-tight">
|
||||
Bonjour Arthur
|
||||
</h1>
|
||||
```
|
||||
|
||||
### 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 : `<em className="italic text-rubis">`
|
||||
- **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 (
|
||||
<>
|
||||
<Outlet />
|
||||
<Toaster position="bottom-right" />
|
||||
{import.meta.env.DEV && (
|
||||
<>
|
||||
<TanStackRouterDevtools />
|
||||
<ReactQueryDevtools />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NotFound() {
|
||||
return <div className="p-8">Page introuvable.</div>
|
||||
}
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<AppLayout>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 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(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
```
|
||||
|
||||
### 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** :
|
||||
|
||||
- `<Brand>` — gem ◆ + wordmark "Rubis" / "Rubis Sur l'Ongle"
|
||||
- `<Button variant="primary"|"secondary"|"ghost">` — cohérent avec la landing
|
||||
- `<Input>`, `<Textarea>`, `<Select>` — avec label + erreur
|
||||
- `<Card>`, `<Panel>` — surfaces standardisées
|
||||
- `<Modal>` — pour mise en demeure (validation manuelle obligatoire), saisie manuelle
|
||||
- `<Chip>` — filtres et tags de volume/tonalité
|
||||
- `<RubisCounter>` — composant héros gamification
|
||||
- `<Stepper>` — wizard onboarding 3 étapes
|
||||
- `<Timeline>` — pour le détail facture
|
||||
- `<Dropzone>` — drag & drop multi-fichiers PDF
|
||||
|
||||
---
|
||||
|
||||
## 9. Conventions
|
||||
|
||||
### Structure de dossiers
|
||||
|
||||
```
|
||||
apps/web/src/
|
||||
├── routes/ # TanStack Router file-based
|
||||
├── components/
|
||||
│ ├── ui/ # Primitives (Button, Input, Card…)
|
||||
│ ├── layout/ # AppLayout, OnboardingLayout
|
||||
│ ├── factures/ # Composants spécifiques au domaine factures
|
||||
│ ├── plans/ # Composants spécifiques aux plans
|
||||
│ └── shared/ # Brand, RubisCounter, Stepper…
|
||||
├── lib/
|
||||
│ ├── api.ts # Client Tuyau
|
||||
│ ├── auth.ts # Auth store
|
||||
│ ├── queryKeys.ts # Convention queryKeys
|
||||
│ ├── format.ts # Formateurs (€, dates, durées en rubis…)
|
||||
│ └── utils.ts # cn() helper, divers
|
||||
├── hooks/
|
||||
│ ├── useInvoices.ts # Wrapper TanStack Query par domaine
|
||||
│ ├── usePlans.ts
|
||||
│ └── …
|
||||
├── styles/
|
||||
│ └── app.css # Imports Tailwind + tokens
|
||||
└── main.tsx # Bootstrap + providers
|
||||
```
|
||||
|
||||
### Naming
|
||||
|
||||
- Composants : `PascalCase.tsx`
|
||||
- Hooks : `useCamelCase.ts`
|
||||
- Helpers : `camelCase.ts`
|
||||
- Routes : `kebab-case.tsx` (TanStack Router file-based)
|
||||
- Tests : `*.test.tsx` colocalisé avec le composant
|
||||
|
||||
### Style code
|
||||
|
||||
- TypeScript `strict: true`, pas de `any` non justifié
|
||||
- Imports absolus via alias `@/*` mappé sur `src/*` (`tsconfig.json` + `vite.config.ts`)
|
||||
- Préférer destructuring + early returns plutôt qu'imbriquer
|
||||
- Composants fonctionnels uniquement, hooks pour la logique
|
||||
- Une responsabilité par composant — refactor en plusieurs fichiers dès qu'un composant dépasse ~200 lignes
|
||||
|
||||
### Formateurs métier
|
||||
|
||||
Centraliser dans `src/lib/format.ts` les conversions récurrentes :
|
||||
|
||||
```ts
|
||||
export const formatEuros = (cents: number) => /* "1 240,00 €" */
|
||||
export const formatRubisToHours = (rubis: number) => /* "20 h 40" */
|
||||
export const formatDate = (iso: string) => /* "5 mai 2026" */
|
||||
export const formatRelativeDate = (iso: string) => /* "dans 3 jours" */
|
||||
```
|
||||
|
||||
→ Cohérence de l'affichage partout, et on évite que chaque composant fasse son `Intl.NumberFormat` à la main.
|
||||
|
||||
---
|
||||
|
||||
## 10. Variables d'environnement
|
||||
|
||||
`apps/web/.env.local` (git-ignoré) :
|
||||
|
||||
```bash
|
||||
VITE_API_URL=http://localhost:3333
|
||||
VITE_PUBLIC_LANDING_URL=https://rubis-sur-l-ongle.fr
|
||||
```
|
||||
|
||||
Production via secret K3s injecté dans le build Vite :
|
||||
|
||||
```bash
|
||||
VITE_API_URL=https://api.rubis-sur-l-ongle.fr
|
||||
VITE_PUBLIC_LANDING_URL=https://rubis-sur-l-ongle.fr
|
||||
```
|
||||
|
||||
Toutes les vars accessibles côté SPA **doivent être préfixées `VITE_`** (sinon Vite ne les expose pas au bundle).
|
||||
|
||||
---
|
||||
|
||||
## 11. Build & déploiement
|
||||
|
||||
### Build local
|
||||
|
||||
```bash
|
||||
pnpm -F web build # produit apps/web/dist/
|
||||
```
|
||||
|
||||
### Image Docker
|
||||
|
||||
`Dockerfile.web` à la racine du monorepo :
|
||||
|
||||
```dockerfile
|
||||
# Stage 1 : build
|
||||
FROM node:22-alpine AS builder
|
||||
RUN npm install -g pnpm
|
||||
WORKDIR /app
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY apps/web ./apps/web
|
||||
COPY packages/shared ./packages/shared
|
||||
RUN pnpm install --frozen-lockfile
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
RUN pnpm -F web build
|
||||
|
||||
# Stage 2 : nginx
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
||||
# Réutilise le même nginx.conf que la landing (try_files / SPA)
|
||||
COPY infra/nginx-spa.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
```
|
||||
|
||||
Ce Dockerfile sera buildé en parallèle du `Dockerfile.api` par le CI Gitea, puis poussé en `git.arthurbarre.fr/ordinarthur/rubis-web:<sha>`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Pointeurs vers l'existant
|
||||
|
||||
Quand tu reconstruis l'app, **rien ne se réinvente sans consulter** :
|
||||
|
||||
- **Wireframes** : `/docs/wireframes-mvp.html` — 13 écrans avec annotations UX par écran
|
||||
- **Brand visuel** : `/docs/brand-identity.html` — logo direction A, palette en application
|
||||
- **Brand écrit** : `/docs/marque.md` — règles do/don't, voix
|
||||
- **Voix & microcopy** : `/docs/marque.md` section 7 — messages de notif, empty states, erreurs
|
||||
- **Landing déployée** : `/landing/index.html` — référence vivante de couleurs/spacing/typo en HTML/Tailwind-compatible
|
||||
|
||||
→ Avant de styler un composant, **regarde la landing déployée** ou le wireframe correspondant. Cohérence visuelle = signature de marque tenue.
|
||||
|
||||
---
|
||||
|
||||
## 13. Points d'attention
|
||||
|
||||
- **Auth Bearer en mémoire** : si l'utilisateur reload, l'access token est perdu — toujours appeler `/auth/refresh` au boot avant le render initial (cf. section 7)
|
||||
- **Tuyau import path** : le client SPA importe les types depuis `apps/api/.adonisjs/api.ts` — bien configurer les `paths` du `tsconfig.json` pour que ça fonctionne en monorepo
|
||||
- **Polices** : Bricolage Grotesque doit être préchargée (preconnect Google Fonts) sinon FOUT/FOIT visible sur les titres
|
||||
- **Le ◆** : c'est un SVG inline, pas une icône Lucide. Composant `<Gem />` à coder à part — réutilisé partout (sidebar, dashboard hero, badge mobile, CTA gamification)
|
||||
- **Mobile-first sur les actions critiques** : la photo de facture depuis le tel est un usage clé (cf. wireframe 4.3) — ne pas la traiter comme une feature secondaire
|
||||
- **3 clics maximum** : règle de design (cf. ADR-011) — chaque parcours doit être contesté à l'aune de cette règle
|
||||
|
||||
---
|
||||
|
||||
## 14. Décisions encore à prendre côté frontend
|
||||
|
||||
| Sujet | Quand trancher |
|
||||
|---|---|
|
||||
| Lib de formulaires (TanStack Form vs react-hook-form vs natif) | Avant le 1er formulaire complexe (signup ou plan editor) |
|
||||
| State client local (Zustand vs context vs Jotai) | Probablement pas nécessaire en V1 — TanStack Query gère 95 % du state |
|
||||
| Composants accessibility primitives (Radix vs Headless UI vs natif) | Avant le 1er Modal/Select complexe |
|
||||
| Tests E2E (Playwright vs Cypress) | Phase polish |
|
||||
|
||||
---
|
||||
|
||||
*Maintenu par Arthur + Claude. Les décisions structurelles passent par un ADR dans `/docs/decisions.md`.*
|
||||
58
eslint.config.js
Normal file
58
eslint.config.js
Normal file
@ -0,0 +1,58 @@
|
||||
// @ts-check
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/.turbo/**",
|
||||
"**/node_modules/**",
|
||||
"**/.adonisjs/**",
|
||||
"**/routeTree.gen.ts",
|
||||
"**/coverage/**",
|
||||
"landing/**",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["apps/web/**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
34
package.json
Normal file
34
package.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "rubis",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Rubis Sur l'Ongle — SaaS de relance de factures impayées pour TPE-PME françaises",
|
||||
"packageManager": "pnpm@10.0.0",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "turbo run dev --parallel",
|
||||
"dev:web": "turbo run dev --filter=@rubis/web",
|
||||
"dev:api": "turbo run dev --filter=@rubis/api",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,css}\" --ignore-path .prettierignore",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,css}\" --ignore-path .prettierignore",
|
||||
"prepare": "husky || true"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"eslint": "^9.18.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.3.0",
|
||||
"prettier": "^3.4.2",
|
||||
"turbo": "^2.3.3",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["esbuild", "msw", "better-sqlite3"]
|
||||
}
|
||||
}
|
||||
35
packages/shared/package.json
Normal file
35
packages/shared/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@rubis/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Types, Zod schemas et constantes partagés api ↔ web",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./types/*": {
|
||||
"types": "./src/types/*.ts",
|
||||
"default": "./src/types/*.ts"
|
||||
},
|
||||
"./schemas/*": {
|
||||
"types": "./src/schemas/*.ts",
|
||||
"default": "./src/schemas/*.ts"
|
||||
},
|
||||
"./constants": {
|
||||
"types": "./src/constants/index.ts",
|
||||
"default": "./src/constants/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
46
packages/shared/src/constants/index.ts
Normal file
46
packages/shared/src/constants/index.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Constantes métier partagées entre l'API et le SPA.
|
||||
* Une seule source de vérité — pas de duplication.
|
||||
*/
|
||||
|
||||
/** 1 rubis = 10 minutes libérées (cf. CLAUDE.md, glossaire) */
|
||||
export const MINUTES_PER_RUBIS = 10;
|
||||
|
||||
/** Statuts possibles d'une facture (cf. architecture.md §5) */
|
||||
export const INVOICE_STATUSES = [
|
||||
"pending",
|
||||
"awaiting_user_confirmation",
|
||||
"in_relance",
|
||||
"paid",
|
||||
"litigation",
|
||||
"cancelled",
|
||||
] as const;
|
||||
|
||||
/** Tonalité d'un email de relance — du plus doux au plus ferme. */
|
||||
export const RELANCE_TONES = ["amical", "courtois", "ferme", "mise_en_demeure"] as const;
|
||||
|
||||
/** Plans pré-fournis par défaut (cf. produit.md) */
|
||||
export const DEFAULT_PLAN_SLUGS = [
|
||||
"standard-30j",
|
||||
"rapide-15j",
|
||||
"patient-60j",
|
||||
"ferme-7j",
|
||||
] as const;
|
||||
|
||||
/** Volumes mensuels de facturation pour l'onboarding (chips) */
|
||||
export const MONTHLY_VOLUME_BUCKETS = [
|
||||
"moins-10",
|
||||
"10-50",
|
||||
"50-100",
|
||||
"100-200",
|
||||
"plus-200",
|
||||
] as const;
|
||||
|
||||
/** Formats de fichier acceptés à l'import */
|
||||
export const ACCEPTED_INVOICE_MIME_TYPES = [
|
||||
"application/pdf",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
] as const;
|
||||
|
||||
export const MAX_INVOICE_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 Mo
|
||||
15
packages/shared/src/index.ts
Normal file
15
packages/shared/src/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// Types
|
||||
export * from "./types/auth.js";
|
||||
export * from "./types/user.js";
|
||||
export * from "./types/client.js";
|
||||
export * from "./types/invoice.js";
|
||||
export * from "./types/plan.js";
|
||||
|
||||
// Schemas
|
||||
export * from "./schemas/auth.js";
|
||||
export * from "./schemas/client.js";
|
||||
export * from "./schemas/invoice.js";
|
||||
export * from "./schemas/plan.js";
|
||||
|
||||
// Constants
|
||||
export * from "./constants/index.js";
|
||||
31
packages/shared/src/schemas/auth.ts
Normal file
31
packages/shared/src/schemas/auth.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Schemas de validation côté client ET côté API.
|
||||
* Les messages d'erreur sont en français pour qu'ils puissent
|
||||
* être affichés directement dans l'UI sans traduction.
|
||||
*/
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z
|
||||
.string({ required_error: "Votre email est requis" })
|
||||
.email("Format d'email invalide"),
|
||||
password: z
|
||||
.string({ required_error: "Mot de passe requis" })
|
||||
.min(1, "Mot de passe requis"),
|
||||
});
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z
|
||||
.string({ required_error: "Votre email est requis" })
|
||||
.email("Format d'email invalide"),
|
||||
password: z
|
||||
.string({ required_error: "Mot de passe requis" })
|
||||
.min(8, "Au moins 8 caractères, on est sérieux"),
|
||||
fullName: z
|
||||
.string({ required_error: "Votre prénom et nom" })
|
||||
.min(2, "Au moins 2 caractères"),
|
||||
});
|
||||
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
14
packages/shared/src/schemas/client.ts
Normal file
14
packages/shared/src/schemas/client.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createClientSchema = z.object({
|
||||
name: z.string().min(1, "Le nom du client est requis").max(120),
|
||||
email: z.string().email("Email invalide").nullable().optional(),
|
||||
phone: z.string().max(40).nullable().optional(),
|
||||
address: z.string().max(500).nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
export const updateClientSchema = createClientSchema.partial();
|
||||
|
||||
export type CreateClientInput = z.infer<typeof createClientSchema>;
|
||||
export type UpdateClientInput = z.infer<typeof updateClientSchema>;
|
||||
34
packages/shared/src/schemas/invoice.ts
Normal file
34
packages/shared/src/schemas/invoice.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
import { INVOICE_STATUSES } from "../constants/index.js";
|
||||
|
||||
export const invoiceStatusSchema = z.enum(INVOICE_STATUSES);
|
||||
|
||||
export const createInvoiceSchema = z.object({
|
||||
clientId: z.string().uuid("Client invalide"),
|
||||
numero: z
|
||||
.string()
|
||||
.min(1, "Numéro requis")
|
||||
.max(50, "50 caractères maximum"),
|
||||
amountTtcCents: z
|
||||
.number({ invalid_type_error: "Montant invalide" })
|
||||
.int("Montant en centimes (entier)")
|
||||
.positive("Le montant doit être positif"),
|
||||
issueDate: z.string().datetime({ message: "Date d'émission invalide" }),
|
||||
dueDate: z.string().datetime({ message: "Date d'échéance invalide" }),
|
||||
planId: z.string().uuid().nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
export const updateInvoiceSchema = createInvoiceSchema.partial();
|
||||
|
||||
/** Schéma des filtres URL — utilisé par TanStack Router validateSearch. */
|
||||
export const invoiceListFiltersSchema = z.object({
|
||||
status: invoiceStatusSchema.or(z.literal("all")).optional(),
|
||||
q: z.string().optional(),
|
||||
clientId: z.string().uuid().optional(),
|
||||
page: z.number().int().min(1).optional().default(1),
|
||||
});
|
||||
|
||||
export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>;
|
||||
export type UpdateInvoiceInput = z.infer<typeof updateInvoiceSchema>;
|
||||
export type InvoiceListFiltersInput = z.infer<typeof invoiceListFiltersSchema>;
|
||||
32
packages/shared/src/schemas/plan.ts
Normal file
32
packages/shared/src/schemas/plan.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { z } from "zod";
|
||||
import { RELANCE_TONES } from "../constants/index.js";
|
||||
|
||||
export const relanceToneSchema = z.enum(RELANCE_TONES);
|
||||
|
||||
export const planStepSchema = z.object({
|
||||
order: z.number().int().min(0),
|
||||
offsetDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(-30, "Pas plus de 30j avant échéance")
|
||||
.max(180, "Pas plus de 180j après échéance"),
|
||||
tone: relanceToneSchema,
|
||||
subject: z.string().min(1, "Sujet requis").max(200),
|
||||
body: z.string().min(1, "Corps requis").max(5000),
|
||||
requiresManualValidation: z.boolean(),
|
||||
});
|
||||
|
||||
export const createPlanSchema = z.object({
|
||||
name: z.string().min(1, "Nom du plan requis").max(80),
|
||||
description: z.string().max(500).default(""),
|
||||
steps: z
|
||||
.array(planStepSchema)
|
||||
.min(1, "Au moins une étape")
|
||||
.max(10, "Pas plus de 10 étapes — on reste raisonnable"),
|
||||
});
|
||||
|
||||
export const updatePlanSchema = createPlanSchema.partial();
|
||||
|
||||
export type CreatePlanInput = z.infer<typeof createPlanSchema>;
|
||||
export type UpdatePlanInput = z.infer<typeof updatePlanSchema>;
|
||||
export type PlanStepInput = z.infer<typeof planStepSchema>;
|
||||
16
packages/shared/src/types/auth.ts
Normal file
16
packages/shared/src/types/auth.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { User } from "./user.js";
|
||||
|
||||
/**
|
||||
* Réponse au login / register / refresh.
|
||||
* Le `accessToken` est stocké en mémoire côté SPA (cf. ADR-017).
|
||||
* Le `refreshToken` est en cookie httpOnly et n'est jamais visible ici.
|
||||
*
|
||||
* Les inputs (`LoginInput`, `RegisterInput`) sont définis depuis les schemas Zod
|
||||
* dans `schemas/auth.ts` (source unique de vérité pour valider et typer).
|
||||
*/
|
||||
export type AuthSession = {
|
||||
accessToken: string;
|
||||
/** ISO 8601, expiration du access token. */
|
||||
expiresAt: string;
|
||||
user: User;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user