diff --git a/.gitattributes b/.gitattributes index 42d5981..e91027b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,3 +10,5 @@ Dockerfile text eol=lf docker-compose*.yaml text eol=lf docker-compose*.yml text eol=lf +# Collapse generated files in GitHub PRs +apps/backend/.adonisjs/** linguist-generated=true \ No newline at end of file diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 3f4259c..e9a805c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -20,7 +20,7 @@ jobs: services: postgres: - image: pgvector/pgvector:0.8.0-pg17 + image: postgres:18 ports: - 5432:5432 env: @@ -28,7 +28,7 @@ jobs: POSTGRES_PASSWORD: root POSTGRES_DB: app_test options: >- - --health-cmd="pg_isready -U postgres" + --health-cmd="pg_isready -U root" --health-interval=10s --health-timeout=5s --health-retries=5 diff --git a/.gitignore b/.gitignore index 2f32c63..2b437ad 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,17 @@ target .claude .vscode .idea +.osgrep +node_modules +.DS_Store +dist +dist-ssr +*.local +count.txt +.env +.nitro +.tanstack +.wrangler +.output +.vinxi +todos.json diff --git a/.oxlintrc.json b/.oxlintrc.json index 2b709b2..55e4926 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -14,7 +14,7 @@ * Web specific overrides */ { - "files": ["./apps/app/**/*"], + "files": ["./apps/frontend/**/*"], "plugins": ["react", "react-perf"] }, @@ -22,7 +22,7 @@ * API specific overrides */ { - "files": ["./apps/api/**/*"], + "files": ["./apps/backend/**/*"], "plugins": ["node"] } ], diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e057d4b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,122 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Full-stack TypeScript monorepo boilerplate: **AdonisJS v7** backend, **TanStack Start** frontend, and a **Radix/Tailwind** design system. Managed with pnpm workspaces and Turborepo. + +**Current branch**: `adonis-v7` (AdonisJS v7 pre-release — many packages use `-next` versions) + +## Commands + +### Root (monorepo) +```bash +pnpm dev # docker compose up -d && turbo dev (starts everything) +pnpm lint # oxlint across all workspaces +pnpm format # oxfmt across all workspaces +pnpm typecheck # turbo typecheck +pnpm test # turbo test +``` + +### Backend (`apps/backend`) +```bash +node ace serve --hmr # Dev server with hot module replacement +node ace test # Run all test suites +node ace test --files "app/**/tests/functional/auth.spec.ts" # Single test file +node ace migration:run # Run pending migrations +node ace db:seed # Seed database +node ace build # Production build +pnpm email # Preview React Email templates (dev server) +``` + +### Frontend (`apps/frontend`) +```bash +pnpm dev # Vite dev server +pnpm build # Production build +pnpm deploy # Deploy to Cloudflare Workers (wrangler) +``` + +### Docker services (started automatically by `pnpm dev`) +- **PostgreSQL 18**: port 5432 (databases: `app`, `app_test`) +- **MinIO** (S3-compatible): port 9000 (API), 9001 (console) +- **Redis**: port 6379 +- **Mailpit**: port 1025 (SMTP), 8025 (web UI) + +## Architecture + +### Monorepo layout +``` +apps/backend # @boilerplate/backend - AdonisJS v7 API +apps/frontend # @boilerplate/frontend - TanStack Start SPA +packages/design-system # @boilerplate/design-system - Radix + Tailwind components +``` + +Shared dependency versions are managed via `pnpm-workspace.yaml` catalog (React 19, TypeScript 5.9, Tailwind 4, Tuyau). + +### Type-safe API contract +**Tuyau** generates TypeScript types from AdonisJS controllers, consumed by the frontend via `@tuyau/react-query`. Changes to backend controllers require running `node ace tuyau:generate` to update the shared types exported from `@boilerplate/backend/registry`. + +### Backend structure (AdonisJS v7) + +Feature-based organization under `app/`: +- `app/auth/` — Auth controllers, middleware, services (session-based auth) +- `app/admin/` — Admin features (user management, impersonation) +- `app/users/` — User model and DTOs +- `app/core/` — Abilities, global middleware, exception handler, policies +- `app/common/` — Shared utilities + +**Path aliases** use `#` prefix (Node.js subpath imports in package.json): +`#auth/*`, `#admin/*`, `#users/*`, `#core/*`, `#common/*`, `#config/*`, `#database/*`, `#start/*`, `#emails/*` + +**Girouette** auto-discovers controllers (`**/*_controller.ts`) and generates routes in `start/routes.girouette.ts`. Manual routes live in `start/routes.ts`. + +**Schema-first models**: `database/schema.ts` defines base schema classes (UserSchema, etc.) that models extend. Models add mixins like `withAuthFinder` and `withUUID`. + +**Middleware stack** (in order): +- Server: container bindings → force JSON → CORS +- Router: Monocle → bodyparser → session → auth init → silent auth → bouncer +- Named: `auth`, `silentAuth`, `requireSecretToken` + +**Testing**: Japa test runner with suites: +- Unit tests: `app/**/tests/unit/**/*.spec.ts` (2s timeout) +- Functional tests: `app/**/tests/functional/**/*.spec.ts` (30s timeout) +- Database is truncated before test runs. Functional/e2e suites start the HTTP server. + +**Important**: Migrations are applied manually — never run `db:push` or auto-apply migrations. + +### Frontend structure (TanStack Start) + +- **Routing**: File-based via TanStack Router under `src/routes/`. Localized routes under `$locale/`. +- **Data fetching**: TanStack Query with Tuyau's React Query integration (`@tuyau/react-query`) +- **i18n**: Intlayer with prefix-all routing mode (English default, French supported). Content files in `src/contents/`. +- **Styling**: Tailwind CSS v4 via `@tailwindcss/vite` +- **Deployment**: Cloudflare Workers via Nitro adapter. SPA mode enabled. +- **Path alias**: `@/*` → `./src/*` + +### Design system + +Radix UI primitives styled with Tailwind and `class-variance-authority`. Follows shadcn/ui conventions (New York style, Zinc base color, CSS variables). 50+ components. Imports: `@boilerplate/design-system`. + +## Code Style + +Enforced by **oxlint** and **oxfmt** (Rust-based tooling): +- No semicolons, single quotes, trailing commas +- 100 char line width, arrow parens always +- Import sort order: descending +- Backend-specific: `node` plugin. Frontend-specific: `react`, `react-perf` plugins. +- Ignore patterns: `.adonisjs/**`, `node_modules/**`, `dist/**`, `build/**` + +## CI + +GitHub Actions (`checks.yml`) runs on PRs to `main`/`dev`: +1. `pnpm install` → `pnpm lint` → `pnpm typecheck` → `pnpm test` (with PostgreSQL + Mailpit services) +2. Trivy security scan on PRs + +## Environment Variables + +Backend env validation is in `apps/backend/start/env.ts`. Key groups: app config, database (PostgreSQL), session, S3 storage (MinIO locally), mail (Resend + SMTP), Stripe, Monocle monitoring. + +## Patched Dependencies + +`@adonisjs/transmit@2.0.2` has a local patch in `patches/`. diff --git a/README.md b/README.md index 04153c6..c820bd2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A modern full-stack monorepo boilerplate featuring AdonisJS backend, TanStack St ### Backend -- **[AdonisJS v6](https://adonisjs.com/)** - Node.js web framework +- **[AdonisJS v7](https://adonisjs.com/)** - Node.js web framework - **PostgreSQL** - Database - **Lucid ORM** - Database toolkit - **Authentication** - Built-in auth system with session management @@ -14,7 +14,7 @@ A modern full-stack monorepo boilerplate featuring AdonisJS backend, TanStack St - **Email** - React Email for beautiful email templates - **Stripe Integration** - Payment processing with Shopkeeper - **Tuyau** - Type-safe API client generation -- **Sentry** - Error tracking and monitoring +- **Monocle** - Error tracking and monitoring ### Frontend @@ -39,6 +39,7 @@ A modern full-stack monorepo boilerplate featuring AdonisJS backend, TanStack St - **[pnpm](https://pnpm.io/)** - Fast, disk space efficient package manager - **[Turbo](https://turbo.build/)** - High-performance build system +- **[Oxc](https://oxc.rs/)** - High-performance linter & formatter - **Docker** - Containerization for development and deployment ## 📁 Project Structure @@ -127,6 +128,12 @@ RESEND_API_KEY=your_resend_key # Stripe STRIPE_KEY=your_stripe_key STRIPE_SECRET=your_stripe_secret + +# Monocle +APP_NAME=adonisjs_app +APP_VERSION=0.0.1 +APP_ENV=development +MONOCLE_API_KEY=mk_test ``` **Frontend** (`apps/frontend/.env`): @@ -167,6 +174,12 @@ pnpm dev # Run linting across all workspaces pnpm lint +# Run format across all workspaces +pnpm format + +# Run typecheck across all workspaces +pnpm typecheck + # Run tests across all workspaces pnpm test @@ -234,11 +247,6 @@ pnpm serve # Run tests pnpm test -# Linting and formatting -pnpm lint -pnpm format -pnpm check # Format and lint fix - # Deploy to Cloudflare pnpm deploy diff --git a/apps/backend/.adonisjs/client/data.d.ts b/apps/backend/.adonisjs/client/data.d.ts new file mode 100644 index 0000000..34f66d4 --- /dev/null +++ b/apps/backend/.adonisjs/client/data.d.ts @@ -0,0 +1,12 @@ +/// +import type { InferData, InferVariants } from '@adonisjs/core/types/transformers' +import type UsersUserTransformer from '#app/users/transformers/user_transformer' + +export namespace Data { + export namespace Users { + export type User = InferData + export namespace User { + export type Variants = InferVariants + } + } +} diff --git a/apps/backend/.adonisjs/client/manifest.d.ts b/apps/backend/.adonisjs/client/manifest.d.ts new file mode 100644 index 0000000..10d9113 --- /dev/null +++ b/apps/backend/.adonisjs/client/manifest.d.ts @@ -0,0 +1,9 @@ +/// +/// +/// +/// +/// +/// +/// +/// +/// diff --git a/apps/backend/.adonisjs/client/registry/index.ts b/apps/backend/.adonisjs/client/registry/index.ts new file mode 100644 index 0000000..83a2f83 --- /dev/null +++ b/apps/backend/.adonisjs/client/registry/index.ts @@ -0,0 +1,119 @@ +/* eslint-disable prettier/prettier */ +import type { AdonisEndpoint } from '@tuyau/core/types' +import type { Registry } from './schema.d.ts' +import type { ApiDefinition } from './tree.d.ts' + +const placeholder: any = {} + +const routes = { + 'event_stream': { + methods: ["GET","HEAD"], + pattern: '/__transmit/events', + tokens: [{"old":"/__transmit/events","type":0,"val":"__transmit","end":""},{"old":"/__transmit/events","type":0,"val":"events","end":""}], + types: placeholder as Registry['event_stream']['types'], + }, + 'subscribe': { + methods: ["POST"], + pattern: '/__transmit/subscribe', + tokens: [{"old":"/__transmit/subscribe","type":0,"val":"__transmit","end":""},{"old":"/__transmit/subscribe","type":0,"val":"subscribe","end":""}], + types: placeholder as Registry['subscribe']['types'], + }, + 'unsubscribe': { + methods: ["POST"], + pattern: '/__transmit/unsubscribe', + tokens: [{"old":"/__transmit/unsubscribe","type":0,"val":"__transmit","end":""},{"old":"/__transmit/unsubscribe","type":0,"val":"unsubscribe","end":""}], + types: placeholder as Registry['unsubscribe']['types'], + }, + 'core': { + methods: ["GET","HEAD"], + pattern: '/health', + tokens: [{"old":"/health","type":0,"val":"health","end":""}], + types: placeholder as Registry['core']['types'], + }, + 'admin impersonation.impersonateUser': { + methods: ["POST"], + pattern: '/admin/impersonate/:user_id/start', + tokens: [{"old":"/admin/impersonate/:user_id/start","type":0,"val":"admin","end":""},{"old":"/admin/impersonate/:user_id/start","type":0,"val":"impersonate","end":""},{"old":"/admin/impersonate/:user_id/start","type":1,"val":"user_id","end":""},{"old":"/admin/impersonate/:user_id/start","type":0,"val":"start","end":""}], + types: placeholder as Registry['admin impersonation.impersonateUser']['types'], + }, + 'admin impersonation.stopImpersonation': { + methods: ["POST"], + pattern: '/admin/impersonate/stop', + tokens: [{"old":"/admin/impersonate/stop","type":0,"val":"admin","end":""},{"old":"/admin/impersonate/stop","type":0,"val":"impersonate","end":""},{"old":"/admin/impersonate/stop","type":0,"val":"stop","end":""}], + types: placeholder as Registry['admin impersonation.stopImpersonation']['types'], + }, + 'admin impersonation.impersonationStatus': { + methods: ["GET"], + pattern: '/admin/impersonate/status', + tokens: [{"old":"/admin/impersonate/status","type":0,"val":"admin","end":""},{"old":"/admin/impersonate/status","type":0,"val":"impersonate","end":""},{"old":"/admin/impersonate/status","type":0,"val":"status","end":""}], + types: placeholder as Registry['admin impersonation.impersonationStatus']['types'], + }, + 'admin users.index': { + methods: ["GET"], + pattern: '/admin/users', + tokens: [{"old":"/admin/users","type":0,"val":"admin","end":""},{"old":"/admin/users","type":0,"val":"users","end":""}], + types: placeholder as Registry['admin users.index']['types'], + }, + 'auth.register': { + methods: ["POST"], + pattern: '/register', + tokens: [{"old":"/register","type":0,"val":"register","end":""}], + types: placeholder as Registry['auth.register']['types'], + }, + 'auth.login': { + methods: ["POST"], + pattern: '/login', + tokens: [{"old":"/login","type":0,"val":"login","end":""}], + types: placeholder as Registry['auth.login']['types'], + }, + 'auth.me': { + methods: ["GET"], + pattern: '/me', + tokens: [{"old":"/me","type":0,"val":"me","end":""}], + types: placeholder as Registry['auth.me']['types'], + }, + 'auth.logout': { + methods: ["POST"], + pattern: '/logout', + tokens: [{"old":"/logout","type":0,"val":"logout","end":""}], + types: placeholder as Registry['auth.logout']['types'], + }, + 'email.verifyEmail': { + methods: ["POST"], + pattern: '/auth/email/verify/:token', + tokens: [{"old":"/auth/email/verify/:token","type":0,"val":"auth","end":""},{"old":"/auth/email/verify/:token","type":0,"val":"email","end":""},{"old":"/auth/email/verify/:token","type":0,"val":"verify","end":""},{"old":"/auth/email/verify/:token","type":1,"val":"token","end":""}], + types: placeholder as Registry['email.verifyEmail']['types'], + }, + 'email.resendVerificationEmail': { + methods: ["POST"], + pattern: '/auth/email/resend', + tokens: [{"old":"/auth/email/resend","type":0,"val":"auth","end":""},{"old":"/auth/email/resend","type":0,"val":"email","end":""},{"old":"/auth/email/resend","type":0,"val":"resend","end":""}], + types: placeholder as Registry['email.resendVerificationEmail']['types'], + }, + 'password.forgotPassword': { + methods: ["POST"], + pattern: '/auth/password/forgot', + tokens: [{"old":"/auth/password/forgot","type":0,"val":"auth","end":""},{"old":"/auth/password/forgot","type":0,"val":"password","end":""},{"old":"/auth/password/forgot","type":0,"val":"forgot","end":""}], + types: placeholder as Registry['password.forgotPassword']['types'], + }, + 'password.resetPassword': { + methods: ["POST"], + pattern: '/auth/password/reset/:token', + tokens: [{"old":"/auth/password/reset/:token","type":0,"val":"auth","end":""},{"old":"/auth/password/reset/:token","type":0,"val":"password","end":""},{"old":"/auth/password/reset/:token","type":0,"val":"reset","end":""},{"old":"/auth/password/reset/:token","type":1,"val":"token","end":""}], + types: placeholder as Registry['password.resetPassword']['types'], + }, +} as const satisfies Record + +export { routes } + +export const registry = { + routes, + $tree: {} as ApiDefinition, +} + +declare module '@tuyau/core/types' { + export interface UserRegistry { + routes: typeof routes + $tree: ApiDefinition + } +} diff --git a/apps/backend/.adonisjs/client/registry/schema.d.ts b/apps/backend/.adonisjs/client/registry/schema.d.ts new file mode 100644 index 0000000..c31cc49 --- /dev/null +++ b/apps/backend/.adonisjs/client/registry/schema.d.ts @@ -0,0 +1,184 @@ +/* eslint-disable prettier/prettier */ +/// + +import type { ExtractBody, ExtractQuery, ExtractQueryForGet } from '@tuyau/core/types' +import type { InferInput } from '@vinejs/vine/types' + +export interface Registry { + 'event_stream': { + methods: ["GET","HEAD"] + pattern: '/__transmit/events' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: unknown + } + } + 'subscribe': { + methods: ["POST"] + pattern: '/__transmit/subscribe' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: unknown + } + } + 'unsubscribe': { + methods: ["POST"] + pattern: '/__transmit/unsubscribe' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: unknown + } + } + 'core': { + methods: ["GET","HEAD"] + pattern: '/health' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: Awaited> + } + } + 'admin impersonation.impersonateUser': { + methods: ["POST"] + pattern: '/admin/impersonate/:user_id/start' + types: { + body: {} + paramsTuple: [string] + params: { user_id: string } + query: {} + response: Awaited> + } + } + 'admin impersonation.stopImpersonation': { + methods: ["POST"] + pattern: '/admin/impersonate/stop' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: Awaited> + } + } + 'admin impersonation.impersonationStatus': { + methods: ["GET"] + pattern: '/admin/impersonate/status' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: Awaited> + } + } + 'admin users.index': { + methods: ["GET"] + pattern: '/admin/users' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: Awaited> + } + } + 'auth.register': { + methods: ["POST"] + pattern: '/register' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: Awaited> + } + } + 'auth.login': { + methods: ["POST"] + pattern: '/login' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: Awaited> + } + } + 'auth.me': { + methods: ["GET"] + pattern: '/me' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: Awaited> + } + } + 'auth.logout': { + methods: ["POST"] + pattern: '/logout' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: Awaited> + } + } + 'email.verifyEmail': { + methods: ["POST"] + pattern: '/auth/email/verify/:token' + types: { + body: {} + paramsTuple: [string] + params: { token: string } + query: {} + response: Awaited> + } + } + 'email.resendVerificationEmail': { + methods: ["POST"] + pattern: '/auth/email/resend' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: Awaited> + } + } + 'password.forgotPassword': { + methods: ["POST"] + pattern: '/auth/password/forgot' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: Awaited> + } + } + 'password.resetPassword': { + methods: ["POST"] + pattern: '/auth/password/reset/:token' + types: { + body: ExtractBody> + paramsTuple: [string] + params: { token: string } + query: ExtractQuery> + response: Awaited> + } + } +} diff --git a/apps/backend/.adonisjs/client/registry/tree.d.ts b/apps/backend/.adonisjs/client/registry/tree.d.ts new file mode 100644 index 0000000..63ca1e3 --- /dev/null +++ b/apps/backend/.adonisjs/client/registry/tree.d.ts @@ -0,0 +1,31 @@ +/* eslint-disable prettier/prettier */ +import type { routes } from './index.ts' + +export interface ApiDefinition { + eventStream: typeof routes['event_stream'] + subscribe: typeof routes['subscribe'] + unsubscribe: typeof routes['unsubscribe'] + core: typeof routes['core'] + adminImpersonation: { + impersonateUser: typeof routes['admin impersonation.impersonateUser'] + stopImpersonation: typeof routes['admin impersonation.stopImpersonation'] + impersonationStatus: typeof routes['admin impersonation.impersonationStatus'] + } + adminUsers: { + index: typeof routes['admin users.index'] + } + auth: { + register: typeof routes['auth.register'] + login: typeof routes['auth.login'] + me: typeof routes['auth.me'] + logout: typeof routes['auth.logout'] + } + email: { + verifyEmail: typeof routes['email.verifyEmail'] + resendVerificationEmail: typeof routes['email.resendVerificationEmail'] + } + password: { + forgotPassword: typeof routes['password.forgotPassword'] + resetPassword: typeof routes['password.resetPassword'] + } +} diff --git a/apps/backend/.adonisjs/server/controllers.ts b/apps/backend/.adonisjs/server/controllers.ts new file mode 100644 index 0000000..9c333ca --- /dev/null +++ b/apps/backend/.adonisjs/server/controllers.ts @@ -0,0 +1,14 @@ +export const controllers = { + admin: { + AdminImpersonation: () => import('#app/admin/controllers/admin_impersonation_controller'), + AdminUsers: () => import('#app/admin/controllers/admin_users_controller'), + }, + auth: { + Auth: () => import('#app/auth/controllers/auth_controller'), + Email: () => import('#app/auth/controllers/email_controller'), + Password: () => import('#app/auth/controllers/password_controller'), + }, + core: { + HealthChecks: () => import('#app/core/controllers/health_checks_controller'), + }, +} diff --git a/apps/backend/.adonisjs/server/events.ts b/apps/backend/.adonisjs/server/events.ts new file mode 100644 index 0000000..a7ab473 --- /dev/null +++ b/apps/backend/.adonisjs/server/events.ts @@ -0,0 +1 @@ +export const events = {} diff --git a/apps/backend/.adonisjs/server/listeners.ts b/apps/backend/.adonisjs/server/listeners.ts new file mode 100644 index 0000000..28cbf66 --- /dev/null +++ b/apps/backend/.adonisjs/server/listeners.ts @@ -0,0 +1 @@ +export const listeners = {} diff --git a/apps/backend/.adonisjs/server/routes.d.ts b/apps/backend/.adonisjs/server/routes.d.ts new file mode 100644 index 0000000..8ea5af2 --- /dev/null +++ b/apps/backend/.adonisjs/server/routes.d.ts @@ -0,0 +1,51 @@ +import '@adonisjs/core/types/http' + +type ParamValue = string | number | bigint | boolean + +export type ScannedRoutes = { + ALL: { + 'event_stream': { paramsTuple?: []; params?: {} } + 'subscribe': { paramsTuple?: []; params?: {} } + 'unsubscribe': { paramsTuple?: []; params?: {} } + 'core': { paramsTuple?: []; params?: {} } + 'admin impersonation.impersonateUser': { paramsTuple: [ParamValue]; params: {'user_id': ParamValue} } + 'admin impersonation.stopImpersonation': { paramsTuple?: []; params?: {} } + 'admin impersonation.impersonationStatus': { paramsTuple?: []; params?: {} } + 'admin users.index': { paramsTuple?: []; params?: {} } + 'auth.register': { paramsTuple?: []; params?: {} } + 'auth.login': { paramsTuple?: []; params?: {} } + 'auth.me': { paramsTuple?: []; params?: {} } + 'auth.logout': { paramsTuple?: []; params?: {} } + 'email.verifyEmail': { paramsTuple: [ParamValue]; params: {'token': ParamValue} } + 'email.resendVerificationEmail': { paramsTuple?: []; params?: {} } + 'password.forgotPassword': { paramsTuple?: []; params?: {} } + 'password.resetPassword': { paramsTuple: [ParamValue]; params: {'token': ParamValue} } + } + GET: { + 'event_stream': { paramsTuple?: []; params?: {} } + 'core': { paramsTuple?: []; params?: {} } + 'admin impersonation.impersonationStatus': { paramsTuple?: []; params?: {} } + 'admin users.index': { paramsTuple?: []; params?: {} } + 'auth.me': { paramsTuple?: []; params?: {} } + } + HEAD: { + 'event_stream': { paramsTuple?: []; params?: {} } + 'core': { paramsTuple?: []; params?: {} } + } + POST: { + 'subscribe': { paramsTuple?: []; params?: {} } + 'unsubscribe': { paramsTuple?: []; params?: {} } + 'admin impersonation.impersonateUser': { paramsTuple: [ParamValue]; params: {'user_id': ParamValue} } + 'admin impersonation.stopImpersonation': { paramsTuple?: []; params?: {} } + 'auth.register': { paramsTuple?: []; params?: {} } + 'auth.login': { paramsTuple?: []; params?: {} } + 'auth.logout': { paramsTuple?: []; params?: {} } + 'email.verifyEmail': { paramsTuple: [ParamValue]; params: {'token': ParamValue} } + 'email.resendVerificationEmail': { paramsTuple?: []; params?: {} } + 'password.forgotPassword': { paramsTuple?: []; params?: {} } + 'password.resetPassword': { paramsTuple: [ParamValue]; params: {'token': ParamValue} } + } +} +declare module '@adonisjs/core/types/http' { + export interface RoutesList extends ScannedRoutes {} +} \ No newline at end of file diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 433726f..d30218a 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,28 +1,43 @@ +# App +NODE_ENV=development +APP_ENV=development +APP_NAME=numelo +APP_VERSION=0.0.1 +APP_KEY= +APP_URL=http://localhost:3333 TZ=UTC -PORT=3333 HOST=localhost +PORT=3333 LOG_LEVEL=info -APP_KEY= -NODE_ENV=development + +# Database DB_HOST=127.0.0.1 DB_PORT=5432 DB_USER=root DB_PASSWORD=root -DB_DATABASE=app +DB_DATABASE=numelo + +# Session SESSION_DRIVER=cookie + +# Storage (S3) DRIVE_DISK=s3 AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_REGION= S3_BUCKET= -RESEND_API_KEY= +S3_ENDPOINT= + +# Mail MAILER=smtp SMTP_HOST=localhost SMTP_PORT=1025 -STRIPE_KEY=your-stripe-key -STRIPE_SECRET=your-stripe-secret -STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret -APP_NAME=adonisjs_app -APP_VERSION=0.0.1 -APP_ENV=development -MONOCLE_API_KEY=mk_test \ No newline at end of file +RESEND_API_KEY= + +# Stripe +STRIPE_KEY= +STRIPE_SECRET= +STRIPE_WEBHOOK_SECRET= + +# Monocle +MONOCLE_API_KEY= diff --git a/apps/backend/.env.test b/apps/backend/.env.test index cc8d9dc..018079b 100644 --- a/apps/backend/.env.test +++ b/apps/backend/.env.test @@ -1,31 +1,43 @@ +# App NODE_ENV=test +APP_ENV=test +APP_NAME=numelo +APP_VERSION=0.0.1 +APP_KEY=XYtCBW_xl6bCUzCiyfdMtDBxWWQ5vcoV +APP_URL=http://localhost:3334 TZ=UTC -PORT=3334 HOST=localhost +PORT=3334 LOG_LEVEL=silent -APP_KEY=XYtCBW_xl6bCUzCiyfdMtDBxWWQ5vcoV -NODE_ENV=development + +# Database DB_HOST=127.0.0.1 DB_PORT=5432 DB_USER=root DB_PASSWORD=root -DB_DATABASE=app_test +DB_DATABASE=numelo_test + +# Session SESSION_DRIVER=memory -APP_URL=http://localhost:3001 + +# Storage (S3) DRIVE_DISK=s3 AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin AWS_REGION=local S3_BUCKET=backend S3_ENDPOINT=http://localhost:9000 -RESEND_API_KEY=test_1234567890abcdefg + +# Mail MAILER=smtp SMTP_HOST=127.0.0.1 SMTP_PORT=1025 -STRIPE_KEY=your-stripe-key -STRIPE_SECRET=your-stripe-secret -STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret -APP_NAME=adonisjs_app -APP_VERSION=0.0.1 -APP_ENV=development -MONOCLE_API_KEY=mk_test \ No newline at end of file +RESEND_API_KEY=test_1234567890abcdefg + +# Stripe +STRIPE_KEY=sk_test_xxx +STRIPE_SECRET=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_test_xxx + +# Monocle +MONOCLE_API_KEY=mk_test diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 32c1deb..bd53f66 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -30,9 +30,13 @@ RUN pnpm turbo build --filter=@boilerplate/backend FROM base AS runner WORKDIR /app +ARG APP_VERSION ENV NODE_ENV=production +ENV APP_VERSION=${APP_VERSION} RUN mkdir /app/tmp COPY --from=installer /app . +COPY apps/backend/docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh EXPOSE 3333 -CMD ["sh", "-c", "cd apps/backend/build && node bin/server.js"] \ No newline at end of file +ENTRYPOINT ["/app/docker-entrypoint.sh"] \ No newline at end of file diff --git a/apps/backend/ace.js b/apps/backend/ace.js index df02fb7..d7647d0 100644 --- a/apps/backend/ace.js +++ b/apps/backend/ace.js @@ -17,11 +17,11 @@ */ /** - * Register hook to process TypeScript files using ts-node + * Register hook to process TypeScript files using @poppinss/ts-exec */ -import 'ts-node-maintained/register/esm' +import "@poppinss/ts-exec"; /** * Import ace console entrypoint */ -await import('./bin/console.js') +await import("./bin/console.js"); diff --git a/apps/backend/adonisrc.ts b/apps/backend/adonisrc.ts index 29a6e5e..f87fc66 100644 --- a/apps/backend/adonisrc.ts +++ b/apps/backend/adonisrc.ts @@ -1,4 +1,7 @@ +import { generateRegistry } from '@tuyau/core/hooks' import { defineConfig } from '@adonisjs/core/app' +import { indexEntities } from '@adonisjs/core' +import { indexControllers } from '@adonisjs-community/girouette' export default defineConfig({ /* @@ -30,10 +33,8 @@ export default defineConfig({ () => import('@adonisjs/lucid/commands'), () => import('@tuyau/core/commands'), () => import('@adonisjs-community/modules/commands'), - () => import('@jrmc/adonis-attachment/commands'), () => import('@adonisjs/mail/commands'), () => import('@adonisjs/bouncer/commands'), - () => import('@foadonis/shopkeeper/commands'), ], /* @@ -57,18 +58,15 @@ export default defineConfig({ () => import('@adonisjs/lucid/database_provider'), () => import('@adonisjs/session/session_provider'), () => import('@adonisjs/auth/auth_provider'), - () => import('@tuyau/core/tuyau_provider'), () => import('@adonisjs-community/girouette/girouette_provider'), - () => import('@jrmc/adonis-attachment/attachment_provider'), () => import('@adonisjs/drive/drive_provider'), () => import('@adonisjs/mail/mail_provider'), () => import('@adonisjs/bouncer/bouncer_provider'), - () => import('@holoyan/adonisjs-permissions/role_permission_provider'), - () => import('@foadonis/shopkeeper/shopkeeper_provider'), - () => import('@facteurjs/adonisjs/facteur_provider'), () => import('@adonisjs/transmit/transmit_provider'), () => import('@monocle.sh/adonisjs-agent/monocle_provider'), - () => import('@adonisjs/i18n/i18n_provider') + () => import('@adonisjs/i18n/i18n_provider'), + () => import('#core/providers/api_provider'), + () => import('#core/providers/vine_provider'), ], /* @@ -79,7 +77,11 @@ export default defineConfig({ | List of modules to import before starting the application. | */ - preloads: [() => import('#start/routes'), () => import('#start/kernel')], + preloads: [ + () => import('#start/routes'), + () => import('#start/routes.girouette'), + () => import('#start/kernel'), + ], /* |-------------------------------------------------------------------------- @@ -111,4 +113,29 @@ export default defineConfig({ reloadServer: false, }, ], + + hooks: { + init: [ + indexEntities({ + transformers: { + enabled: true, + source: './app', + glob: ['**/*_transformer.ts'], + importAlias: '#app', + }, + controllers: { + enabled: true, + source: './app', + glob: ['**/*_controller.ts'], + importAlias: '#app', + }, + }), + generateRegistry(), + indexControllers({ + source: './app', + glob: ['**/*_controller.ts'], + importAlias: '#app', + }), + ], + }, }) diff --git a/apps/backend/app/admin/controllers/admin_impersonation_controller.ts b/apps/backend/app/admin/controllers/admin_impersonation_controller.ts index 4a24f6b..8b6a309 100644 --- a/apps/backend/app/admin/controllers/admin_impersonation_controller.ts +++ b/apps/backend/app/admin/controllers/admin_impersonation_controller.ts @@ -2,17 +2,17 @@ import type { HttpContext } from '@adonisjs/core/http' import { inject } from '@adonisjs/core' import { Get, Group, Middleware, Post } from '@adonisjs-community/girouette' +import UserTransformer from '#users/transformers/user_transformer' import User from '#users/models/user' -import UserDto from '#users/dtos/user' import { middleware } from '#start/kernel' import AdminPolicy from '#admin/policies/admin_policy' @inject() -@Group({ name: 'admin.impersonate', prefix: '/admin/impersonate' }) +@Group({ prefix: '/admin/impersonate' }) export default class AdminImpersonationController { - @Post('/:user_id/start', 'start') + @Post('/:user_id/start') @Middleware(middleware.auth()) - async impersonateUser({ params, session, response, auth, bouncer }: HttpContext) { + async impersonateUser({ params, session, auth, bouncer, serialize }: HttpContext) { const currentUser = auth.getUserOrFail() const { user_id: userId } = params @@ -25,16 +25,16 @@ export default class AdminImpersonationController { await auth.use('web').login(targetUser) - return response.ok({ + return serialize({ message: `Now impersonating ${targetUser.firstName} ${targetUser.lastName}`, - impersonatedUser: new UserDto(targetUser), - originalAdmin: new UserDto(currentUser), + impersonatedUser: UserTransformer.transform(targetUser), + originalAdmin: UserTransformer.transform(currentUser), }) } - @Post('/stop', 'stop') + @Post('/stop') @Middleware(middleware.auth()) - async stopImpersonation({ session, response, auth }: HttpContext) { + async stopImpersonation({ session, response, auth, serialize }: HttpContext) { const originalAdminId = session.get('originalAdminId') const isImpersonating = session.get('isImpersonating') @@ -48,15 +48,15 @@ export default class AdminImpersonationController { session.forget('originalAdminId') session.forget('isImpersonating') - return response.ok({ + return serialize({ message: 'Impersonation stopped', - user: new UserDto(originalAdmin), + user: UserTransformer.transform(originalAdmin), }) } - @Get('/status', 'status') + @Get('/status') @Middleware(middleware.auth()) - async impersonationStatus({ session, auth }: HttpContext) { + async impersonationStatus({ session, auth, serialize }: HttpContext) { const originalAdminId = session.get('originalAdminId') const isImpersonating = session.get('isImpersonating') @@ -71,10 +71,10 @@ export default class AdminImpersonationController { const currentUser = auth.getUserOrFail() const originalAdmin = await User.findOrFail(originalAdminId) - return { + return serialize({ isImpersonating: true, - currentUser: new UserDto(currentUser), - originalAdmin: new UserDto(originalAdmin), - } + currentUser: UserTransformer.transform(currentUser), + originalAdmin: UserTransformer.transform(originalAdmin), + }) } } diff --git a/apps/backend/app/admin/controllers/admin_users_controller.ts b/apps/backend/app/admin/controllers/admin_users_controller.ts index 2239c3b..886fdc7 100644 --- a/apps/backend/app/admin/controllers/admin_users_controller.ts +++ b/apps/backend/app/admin/controllers/admin_users_controller.ts @@ -2,17 +2,17 @@ import type { HttpContext } from '@adonisjs/core/http' import { inject } from '@adonisjs/core' import { Get, Group, Middleware } from '@adonisjs-community/girouette' +import UserTransformer from '#users/transformers/user_transformer' import User from '#users/models/user' -import UserDto from '#users/dtos/user' import { middleware } from '#start/kernel' import AdminPolicy from '#admin/policies/admin_policy' @inject() -@Group({ name: 'admin.users', prefix: '/admin/users' }) +@Group({ prefix: '/admin/users' }) export default class AdminUsersController { - @Get('/', 'index') + @Get('/') @Middleware(middleware.auth()) - async index({ request, bouncer }: HttpContext) { + async index({ request, bouncer, serialize }: HttpContext) { await bouncer.with(AdminPolicy).authorize('accessAdmin') const page = request.input('page', 1) @@ -32,6 +32,9 @@ export default class AdminUsersController { const users = await query.orderBy('createdAt', 'desc').paginate(page, limit) - return UserDto.fromPaginator(users) + const data = users.all() + const meta = users.getMeta() + + return await serialize(UserTransformer.paginate(data, meta)) } } diff --git a/apps/backend/app/admin/policies/admin_policy.ts b/apps/backend/app/admin/policies/admin_policy.ts index 2c83e1c..a29b5c6 100644 --- a/apps/backend/app/admin/policies/admin_policy.ts +++ b/apps/backend/app/admin/policies/admin_policy.ts @@ -1,15 +1,12 @@ import { BasePolicy } from '@adonisjs/bouncer' -import User from '#users/models/user' +import User, { Role } from '#users/models/user' export default class AdminPolicy extends BasePolicy { async impersonate(user: User, targetUser: User) { - return ( - (await user.hasPermission('validate_entities')) && - !(await targetUser.hasPermission('validate_entities')) - ) + return user.role === Role.ADMIN && targetUser.role === Role.USER } async accessAdmin(user: User) { - return await user.hasPermission('admin_access') + return user.role === Role.ADMIN } } diff --git a/apps/backend/app/auth/controllers/auth_controller.ts b/apps/backend/app/auth/controllers/auth_controller.ts index eba6957..b5a3d7f 100644 --- a/apps/backend/app/auth/controllers/auth_controller.ts +++ b/apps/backend/app/auth/controllers/auth_controller.ts @@ -1,20 +1,19 @@ import type { HttpContext } from '@adonisjs/core/http' import { inject } from '@adonisjs/core' -import { Get, Group, Middleware, Post } from '@adonisjs-community/girouette' +import { Get, Middleware, Post } from '@adonisjs-community/girouette' +import UserTransformer from '#users/transformers/user_transformer' import User from '#users/models/user' -import UserDto from '#users/dtos/user' import { middleware } from '#start/kernel' import { registerValidator } from '#auth/validators/register' import { loginValidator } from '#auth/validators/login' import { EmailVerificationService } from '#auth/services/email_verification_service' @inject() -@Group({ name: 'auth' }) export default class AuthController { constructor(private emailVerificationService: EmailVerificationService) {} - @Post('/register', 'register') - async register({ request, response }: HttpContext) { + @Post('/register') + async register({ request, serialize }: HttpContext) { const payload = await request.validateUsing(registerValidator) const user = await User.create({ @@ -26,32 +25,32 @@ export default class AuthController { await this.emailVerificationService.sendVerificationEmail(user) - return response.status(201).json({ + return serialize({ message: 'Registration successful. Please check your email to verify your account.', - user: new UserDto(user), + user: await serialize(UserTransformer.transform(user)), }) } - @Post('/login', 'login') - async login({ auth, request }: HttpContext) { + @Post('/login') + async login({ auth, request, serialize }: HttpContext) { const { email, password } = await request.validateUsing(loginValidator) const user = await User.verifyCredentials(email, password) await auth.use('web').login(user) - return new UserDto(user) + return serialize(UserTransformer.transform(user)) } - @Get('/me', 'me') + @Get('/me') @Middleware([middleware.auth()]) - async me({ auth }: HttpContext) { + async me({ auth, serialize }: HttpContext) { const user = auth.getUserOrFail() - return new UserDto(user) + return serialize(UserTransformer.transform(user)) } - @Post('/logout', 'logout') + @Post('/logout') @Middleware(middleware.auth()) async logout({ auth, response }: HttpContext) { await auth.use('web').logout() diff --git a/apps/backend/app/auth/controllers/email_controller.ts b/apps/backend/app/auth/controllers/email_controller.ts index c36aecb..2bb095b 100644 --- a/apps/backend/app/auth/controllers/email_controller.ts +++ b/apps/backend/app/auth/controllers/email_controller.ts @@ -6,11 +6,11 @@ import { resendVerificationEmailValidator } from '#auth/validators/email_verific import { EmailVerificationService } from '#auth/services/email_verification_service' @inject() -@Group({ name: 'auth.email', prefix: '/auth/email' }) +@Group({ prefix: '/auth/email' }) export default class EmailController { constructor(private emailVerificationService: EmailVerificationService) {} - @Post('/verify/:token', 'verify') + @Post('/verify/:token') async verifyEmail({ response, params }: HttpContext) { const { token } = params const verificationToken = await this.emailVerificationService.getToken(token) @@ -24,7 +24,7 @@ export default class EmailController { return response.status(200).json(result) } - @Post('/resend', 'resend') + @Post('/resend') async resendVerificationEmail({ request, response }: HttpContext) { const { email } = await request.validateUsing(resendVerificationEmailValidator) diff --git a/apps/backend/app/auth/controllers/password_controller.ts b/apps/backend/app/auth/controllers/password_controller.ts index 309d61c..0f30698 100644 --- a/apps/backend/app/auth/controllers/password_controller.ts +++ b/apps/backend/app/auth/controllers/password_controller.ts @@ -6,11 +6,11 @@ import { forgotPasswordValidator, resetPasswordValidator } from '#auth/validator import { PasswordResetService } from '#auth/services/password_reset_service' @inject() -@Group({ name: 'auth.password', prefix: '/auth/password' }) +@Group({ prefix: '/auth/password' }) export default class PasswordController { constructor(private passwordResetService: PasswordResetService) {} - @Post('/forgot', 'forgot') + @Post('/forgot') async forgotPassword({ request, response }: HttpContext) { const { email } = await request.validateUsing(forgotPasswordValidator) @@ -19,7 +19,7 @@ export default class PasswordController { return response.ok(result) } - @Post('/reset/:token', 'reset') + @Post('/reset/:token') async resetPassword({ request, response, params, auth }: HttpContext) { const { token } = params const payload = await request.validateUsing(resetPasswordValidator) diff --git a/apps/backend/app/auth/tests/functional/auth.spec.ts b/apps/backend/app/auth/tests/functional/auth.spec.ts index 979032e..c6f6062 100644 --- a/apps/backend/app/auth/tests/functional/auth.spec.ts +++ b/apps/backend/app/auth/tests/functional/auth.spec.ts @@ -1,7 +1,7 @@ import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' -import { UserFactory } from '#users/factories/user_factory' import User from '#users/models/user' +import { UserFactory } from '#users/factories/user_factory' test.group('Auth', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) @@ -18,7 +18,6 @@ test.group('Auth', (group) => { const response = await client.post('/register').json(payload) console.log(response.body()) - response.assertStatus(201) assert.exists(response.body().user) assert.equal(response.body().user.email, payload.email) assert.equal(response.body().user.firstName, payload.firstName) @@ -111,4 +110,3 @@ test.group('Auth', (group) => { response.assertStatus(401) }) }) - diff --git a/apps/backend/app/core/middleware/acl_middleware.ts b/apps/backend/app/core/middleware/acl_middleware.ts deleted file mode 100644 index 7fb110d..0000000 --- a/apps/backend/app/core/middleware/acl_middleware.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { NextFn } from '@adonisjs/core/types/http' -import type { HttpContext } from '@adonisjs/core/http' - -import { AclManager, Scope } from '@holoyan/adonisjs-permissions' - -declare module '@adonisjs/core/http' { - export interface HttpContext { - acl: AclManager - } -} - -export default class UserScopeMiddleware { - //@ts-ignore - async handle(ctx: HttpContext, next: NextFn) { - const scope = new Scope() - ctx.acl = new AclManager(true).scope(scope) - /** - * Call next method in the pipeline and return its output - * Call next method in the pipeline and return its output - */ - const output = await next() - return output - } -} diff --git a/apps/backend/app/core/providers/api_provider.ts b/apps/backend/app/core/providers/api_provider.ts new file mode 100644 index 0000000..76824e7 --- /dev/null +++ b/apps/backend/app/core/providers/api_provider.ts @@ -0,0 +1,57 @@ +import { type SimplePaginatorMetaKeys } from '@adonisjs/lucid/types/querybuilder' +import { BaseSerializer } from '@adonisjs/core/transformers' +import { HttpContext } from '@adonisjs/core/http' + +/** + * 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: undefined + PaginationMetaData: SimplePaginatorMetaKeys +}> { + /** + * Wraps all serialized data under this key in the response object. + * Example: { data: [...] } instead of returning raw arrays/objects + */ + wrap: undefined = undefined + + /** + * 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 + } +} + +/** + * 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: ApiSerializer['serialize'] + } +} + +/** + * Single instance of ApiSerializer used across the application + */ +const serializer = new ApiSerializer() + +/** + * 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.macro('serialize', function (this: HttpContext, values: any): any { + const ctx = HttpContext.getOrFail() + return serializer.serialize(values, ctx.containerResolver) +}) diff --git a/apps/backend/app/core/providers/vine_provider.ts b/apps/backend/app/core/providers/vine_provider.ts new file mode 100644 index 0000000..0c0cebb --- /dev/null +++ b/apps/backend/app/core/providers/vine_provider.ts @@ -0,0 +1,12 @@ +import { DateTime } from 'luxon' +import { VineDate } from '@vinejs/vine' + +declare module '@vinejs/vine/types' { + interface VineGlobalTransforms { + date: DateTime + } +} + +VineDate.transform((value: Date | string) => { + return DateTime.fromJSDate(new Date(value)) +}) diff --git a/apps/backend/app/users/dtos/user.ts b/apps/backend/app/users/dtos/user.ts deleted file mode 100644 index d17e13c..0000000 --- a/apps/backend/app/users/dtos/user.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BaseModelDto } from '@adocasts.com/dto/base' -import User from '#users/models/user' -import { Address } from '#common/types/address' - -export default class UserDto extends BaseModelDto { - declare id: string - declare email: string - declare firstName: string - declare lastName: string - declare createdAt: string - declare updatedAt: string - declare address: Address - declare fullName: string - - constructor(user: User) { - super() - - if (!user) return - this.id = user.id.toString() - this.email = user.email - this.firstName = user.firstName - this.lastName = user.lastName - this.createdAt = user.createdAt.toISO()! - this.updatedAt = user.updatedAt.toISO()! - this.address = user.address - this.fullName = user.fullName - } -} diff --git a/apps/backend/app/users/models/email_verification_token.ts b/apps/backend/app/users/models/email_verification_token.ts index cdc988d..320b48a 100644 --- a/apps/backend/app/users/models/email_verification_token.ts +++ b/apps/backend/app/users/models/email_verification_token.ts @@ -1,24 +1,10 @@ import type { BelongsTo } from '@adonisjs/lucid/types/relations' -import { DateTime } from 'luxon' -import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' -import { compose } from '@adonisjs/core/helpers' +import { belongsTo } from '@adonisjs/lucid/orm' import User from '#users/models/user' -import { withUUID } from '#common/mixins/with_uuid' - -export default class EmailVerificationToken extends compose(BaseModel, withUUID()) { - @column() - declare userId: string - - @column() - declare token: string - - @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare createdAt: DateTime - - @column.dateTime() - declare expiresAt: DateTime +import { EmailVerificationTokenSchema } from '#database/schema' +export default class EmailVerificationToken extends EmailVerificationTokenSchema { @belongsTo(() => User) declare user: BelongsTo } diff --git a/apps/backend/app/users/models/reset_password_token.ts b/apps/backend/app/users/models/reset_password_token.ts index 10c4144..da2fd2c 100644 --- a/apps/backend/app/users/models/reset_password_token.ts +++ b/apps/backend/app/users/models/reset_password_token.ts @@ -1,24 +1,10 @@ import type { BelongsTo } from '@adonisjs/lucid/types/relations' -import { DateTime } from 'luxon' -import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' -import { compose } from '@adonisjs/core/helpers' +import { belongsTo } from '@adonisjs/lucid/orm' import User from '#users/models/user' -import { withUUID } from '#common/mixins/with_uuid' - -export default class ResetPasswordToken extends compose(BaseModel, withUUID()) { - @column() - declare userId: string - - @column() - declare token: string - - @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare createdAt: DateTime - - @column.dateTime() - declare expiresAt: DateTime +import { ResetPasswordTokenSchema } from '#database/schema' +export default class ResetPasswordToken extends ResetPasswordTokenSchema { @belongsTo(() => User) declare user: BelongsTo } diff --git a/apps/backend/app/users/models/user.ts b/apps/backend/app/users/models/user.ts index 69f4e06..5c1c3f5 100644 --- a/apps/backend/app/users/models/user.ts +++ b/apps/backend/app/users/models/user.ts @@ -1,60 +1,30 @@ -import type { AclModelInterface } from '@holoyan/adonisjs-permissions/types' import type { Address } from '#common/types/address' -import { DateTime } from 'luxon' -import { MorphMap } from '@holoyan/morph-map-js' -import { hasPermissions } from '@holoyan/adonisjs-permissions' -import { Billable } from '@foadonis/shopkeeper/mixins' -import { NotifiableTargets } from '@facteurjs/adonisjs/types' -import { BaseModel, column, computed } from '@adonisjs/lucid/orm' +import { column, computed } from '@adonisjs/lucid/orm' import hash from '@adonisjs/core/services/hash' import { compose } from '@adonisjs/core/helpers' import { withAuthFinder } from '@adonisjs/auth/mixins/lucid' +import { UserSchema } from '#database/schema' import { withUUID } from '#common/mixins/with_uuid' -import { withTimestamps } from '#common/mixins/with_timestamps' const AuthFinder = withAuthFinder(() => hash.use('scrypt'), { uids: ['email'], passwordColumnName: 'password', }) -@MorphMap('users') -export default class User - extends compose(BaseModel, AuthFinder, Billable, withUUID(), withTimestamps(), hasPermissions()) - implements AclModelInterface -{ - @column() - declare firstName: string - - @column() - declare lastName: string - - @column() - declare email: string - - @column({ serializeAs: null }) - declare password: string +export enum Role { + ADMIN = 'ADMIN', + USER = 'USER', +} +export default class User extends compose(UserSchema, AuthFinder, withUUID()) { @column({ prepare: (value: Address) => JSON.stringify(value), }) declare address: Address - @column.dateTime() - declare emailVerifiedAt: DateTime | null - @computed() get fullName(): string { return `${this.firstName} ${this.lastName}` } - - getModelId(): string { - return this.id - } - - notificationTargets(): NotifiableTargets { - return { - transmit: { channel: `users/${this.id}` }, - } - } } diff --git a/apps/backend/app/users/transformers/user_transformer.ts b/apps/backend/app/users/transformers/user_transformer.ts new file mode 100644 index 0000000..b3478ef --- /dev/null +++ b/apps/backend/app/users/transformers/user_transformer.ts @@ -0,0 +1,17 @@ +import { BaseTransformer } from '@adonisjs/core/transformers' +import User from '#users/models/user' + +export default class UserTransformer extends BaseTransformer { + toObject() { + return this.pick(this.resource, [ + 'id', + 'firstName', + 'lastName', + 'email', + 'role', + 'fullName', + 'address', + 'createdAt', + ]) + } +} diff --git a/apps/backend/bin/server.ts b/apps/backend/bin/server.ts index 0fd43cc..8c976d4 100644 --- a/apps/backend/bin/server.ts +++ b/apps/backend/bin/server.ts @@ -13,8 +13,6 @@ * @see https://opentelemetry.io/docs/languages/js/getting-started/nodejs/ */ import '../otel.js' - - import 'reflect-metadata' import { Ignitor, prettyPrintError } from '@adonisjs/core' diff --git a/apps/backend/config/app.ts b/apps/backend/config/app.ts index 9c37ae5..9c58442 100644 --- a/apps/backend/config/app.ts +++ b/apps/backend/config/app.ts @@ -23,7 +23,7 @@ export const http = defineConfig({ * Enabling async local storage will let you access HTTP context * from anywhere inside your application. */ - useAsyncLocalStorage: false, + useAsyncLocalStorage: true, /** * Manage cookies configuration. The settings for the session id cookie are diff --git a/apps/backend/config/attachment.ts b/apps/backend/config/attachment.ts deleted file mode 100644 index a68a0ac..0000000 --- a/apps/backend/config/attachment.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { InferConverters } from '@jrmc/adonis-attachment/types/config' -// import sharp from 'sharp' -import { defineConfig } from '@jrmc/adonis-attachment' - -/** - * Documentation: https://adonis-attachment.jrmc.dev/guide/essentials/configuration - */ - -const attachmentConfig = defineConfig({ - converters: { - thumbnail: { - converter: () => import('@jrmc/adonis-attachment/converters/image_converter'), - options: { - resize: 300, - }, - }, - }, -}) - -export default attachmentConfig - -declare module '@jrmc/adonis-attachment' { - interface AttachmentVariants extends InferConverters {} -} diff --git a/apps/backend/config/encryption.ts b/apps/backend/config/encryption.ts new file mode 100644 index 0000000..f1aa5de --- /dev/null +++ b/apps/backend/config/encryption.ts @@ -0,0 +1,12 @@ +import { defineConfig, drivers } from '@adonisjs/core/encryption' +import env from '#start/env' + +export default defineConfig({ + default: 'app', + list: { + app: drivers.aes256gcm({ + id: 'app', + keys: [env.get('APP_KEY')], + }), + }, +}) diff --git a/apps/backend/config/notifications.ts b/apps/backend/config/notifications.ts deleted file mode 100644 index 5dcad28..0000000 --- a/apps/backend/config/notifications.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { InferChannels } from '@facteurjs/adonisjs/types' - -import { defineConfig, channels } from '@facteurjs/adonisjs' - -const config = defineConfig({ - channels: { - transmit: channels.transmit(), - }, - - preferences: { - global: { - channels: { - transmit: true, - }, - }, - }, -}) - -export default config - -declare module '@facteurjs/adonisjs/types' { - interface NotificationChannels extends InferChannels {} -} diff --git a/apps/backend/config/permissions.ts b/apps/backend/config/permissions.ts deleted file mode 100644 index b951d6f..0000000 --- a/apps/backend/config/permissions.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Permissions } from '@holoyan/adonisjs-permissions/types' - -export const permissionsConfig: Permissions = { - tables: { - roles: 'roles', - modelRoles: 'model_roles', - permissions: 'permissions', - modelPermissions: 'model_permissions', - }, - morphMaps: { - roles: 'roles', - permissions: 'permissions', - }, - uuidSupport: true, -} diff --git a/apps/backend/config/shopkeeper.ts b/apps/backend/config/shopkeeper.ts deleted file mode 100644 index ad8db00..0000000 --- a/apps/backend/config/shopkeeper.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { defineConfig } from '@foadonis/shopkeeper' -import env from '#start/env' - -export default defineConfig({ - key: env.get('STRIPE_KEY'), - secret: env.get('STRIPE_SECRET'), - - currency: env.get('SHOPKEEPER_CURRENCY', 'USD'), - currencyLocale: env.get('SHOPKEEPER_CURRENCY_LOCALE', 'en-US'), - - webhook: { - secret: env.get('STRIPE_WEBHOOK_SECRET'), - tolerance: 300, - }, - - models: { - customerModel: () => import('#users/models/user'), - subscriptionModel: () => import('@foadonis/shopkeeper/models/subscription'), - subscriptionItemModel: () => import('@foadonis/shopkeeper/models/subscription_item'), - }, - - calculateTaxes: false, - - keepIncompleteSubscriptionsActive: false, - keepPastDueSubscriptionsActive: false, - - registerRoutes: true, -}) diff --git a/apps/backend/config/tuyau.ts b/apps/backend/config/tuyau.ts deleted file mode 100644 index dd04438..0000000 --- a/apps/backend/config/tuyau.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from '@tuyau/core' - -const tuyauConfig = defineConfig({ - codegen: { - /** - * Filters the definitions and named routes to be generated - */ - // definitions: { - // only: [], - // } - // routes: { - // only: [], - // } - }, -}) - -export default tuyauConfig diff --git a/apps/backend/database/migrations/01_create_users_table.ts b/apps/backend/database/migrations/01_create_users_table.ts index c9042ea..123eff1 100644 --- a/apps/backend/database/migrations/01_create_users_table.ts +++ b/apps/backend/database/migrations/01_create_users_table.ts @@ -9,7 +9,9 @@ export default class extends BaseSchema { table.string('first_name').nullable() table.string('last_name').nullable() table.string('email', 254).notNullable().unique() + table.jsonb('address').nullable() table.string('password').notNullable() + table.enum('role', ['ADMIN', 'USER']).notNullable().defaultTo('USER') table.timestamp('email_verified_at').nullable() table.timestamp('created_at').notNullable() diff --git a/apps/backend/database/migrations/02_create_reset_password_tokens_table.ts b/apps/backend/database/migrations/02_create_reset_password_tokens_table.ts index 75d096c..5faa2d5 100644 --- a/apps/backend/database/migrations/02_create_reset_password_tokens_table.ts +++ b/apps/backend/database/migrations/02_create_reset_password_tokens_table.ts @@ -5,7 +5,7 @@ export default class extends BaseSchema { async up() { this.schema.createTable(this.tableName, (table) => { - table.uuid('id') + table.uuid('id').primary().notNullable() table.uuid('user_id').references('id').inTable('users').onDelete('CASCADE') table.string('token', 255).notNullable() table.timestamp('expires_at').notNullable() diff --git a/apps/backend/database/migrations/04_create_role_permissions_table.ts b/apps/backend/database/migrations/04_create_role_permissions_table.ts deleted file mode 100644 index eb0ac8e..0000000 --- a/apps/backend/database/migrations/04_create_role_permissions_table.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { BaseSchema } from '@adonisjs/lucid/schema' -import config from '@adonisjs/core/services/config' - -export default class extends BaseSchema { - async up() { - this.schema.createTable( - config.get('permissions.permissionsConfig.tables.permissions'), - (table) => { - this.primaryKey(table, 'id') - - table.string('slug') - table.string('title').nullable() - table.string('entity_type').defaultTo('*') - this.modelId(table, 'entity_id').nullable() - table.string('scope').defaultTo('default') - table.boolean('allowed').defaultTo(true) - - /** - * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL - */ - table.timestamp('created_at', { useTz: true }) - table.timestamp('updated_at', { useTz: true }) - - table.index(['scope', 'slug']) - table.index(['entity_type', 'entity_id']) - }, - ) - - this.schema.createTable(config.get('permissions.permissionsConfig.tables.roles'), (table) => { - this.primaryKey(table, 'id') - - table.string('slug') - table.string('title').nullable() - table.string('entity_type').defaultTo('*') - this.modelId(table, 'entity_id').nullable() - table.string('scope').defaultTo('default') - table.boolean('allowed').defaultTo(true) - - /** - * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL - */ - table.timestamp('created_at', { useTz: true }) - table.timestamp('updated_at', { useTz: true }) - - table.index(['scope', 'slug']) - table.index(['entity_type', 'entity_id']) - }) - - this.schema.createTable( - config.get('permissions.permissionsConfig.tables.modelRoles'), - (table) => { - table.bigIncrements('id') - - table.string('model_type') - this.modelId(table, 'model_id') - this.modelId(table, 'role_id') - - /** - * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL - */ - table.timestamp('created_at', { useTz: true }) - table.timestamp('updated_at', { useTz: true }) - - table.index(['model_type', 'model_id']) - - table - .foreign('role_id') - .references(config.get('permissions.permissionsConfig.tables.roles') + '.id') - .onDelete('CASCADE') - }, - ) - - this.schema.createTable( - config.get('permissions.permissionsConfig.tables.modelPermissions'), - (table) => { - table.bigIncrements('id') - - table.string('model_type') - this.modelId(table, 'model_id') - this.modelId(table, 'permission_id') - - /** - * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL - */ - table.timestamp('created_at', { useTz: true }) - table.timestamp('updated_at', { useTz: true }) - - table.index(['model_type', 'model_id']) - - table - .foreign('permission_id') - .references(config.get('permissions.permissionsConfig.tables.permissions') + '.id') - .onDelete('CASCADE') - }, - ) - } - - async down() { - this.schema.dropTable(config.get('permissions.permissionsConfig.tables.modelRoles')) - this.schema.dropTable(config.get('permissions.permissionsConfig.tables.roles')) - this.schema.dropTable(config.get('permissions.permissionsConfig.tables.modelPermissions')) - this.schema.dropTable(config.get('permissions.permissionsConfig.tables.permissions')) - } - - private primaryKey(table: any, columnName: string) { - return config.get('permissions.permissionsConfig.uuidSupport') - ? table.string(columnName).primary() - : table.bigIncrements(columnName) - } - - private modelId(table: any, columnName: string) { - return config.get('permissions.permissionsConfig.uuidSupport') - ? table.string(columnName) - : table.bigint(columnName).unsigned() - } -} diff --git a/apps/backend/database/migrations/05_create_customer_stripe_columns.ts b/apps/backend/database/migrations/05_create_customer_stripe_columns.ts deleted file mode 100644 index 2082cff..0000000 --- a/apps/backend/database/migrations/05_create_customer_stripe_columns.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseSchema } from '@adonisjs/lucid/schema' - -export default class extends BaseSchema { - protected tableName = 'users' - - async up() { - this.schema.table(this.tableName, (table) => { - table.string('stripe_id').nullable().index() - table.string('pm_type').nullable() - table.string('pm_last_four', 4).nullable() - table.timestamp('trial_ends_at').nullable() - }) - } - - async down() { - this.schema.table(this.tableName, (table) => { - table.dropColumns('stripe_id', 'pm_type', 'pm_last_four', 'trial_ends_at') - }) - } -} diff --git a/apps/backend/database/migrations/06_create_subscriptions_table.ts b/apps/backend/database/migrations/06_create_subscriptions_table.ts deleted file mode 100644 index e41d718..0000000 --- a/apps/backend/database/migrations/06_create_subscriptions_table.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { BaseSchema } from '@adonisjs/lucid/schema' - -export default class extends BaseSchema { - protected tableName = 'subscriptions' - - async up() { - this.schema.createTable(this.tableName, (table) => { - table.uuid('id').primary().notNullable() - - table.uuid('user_id').references('users.id').onDelete('CASCADE') - - table.string('type').notNullable() - table.string('stripe_id').notNullable().unique() - table.string('stripe_status').notNullable() - table.string('stripe_price').nullable() - table.integer('quantity').nullable() - table.timestamp('trial_ends_at').nullable() - table.timestamp('ends_at').nullable() - - table.timestamp('created_at').notNullable() - table.timestamp('updated_at').nullable() - - table.index(['user_id', 'stripe_status']) - }) - } - - async down() { - this.schema.dropTable(this.tableName) - } -} diff --git a/apps/backend/database/migrations/07_create_subscription_items_table.ts b/apps/backend/database/migrations/07_create_subscription_items_table.ts deleted file mode 100644 index 2c5f008..0000000 --- a/apps/backend/database/migrations/07_create_subscription_items_table.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { BaseSchema } from '@adonisjs/lucid/schema' - -export default class extends BaseSchema { - protected tableName = 'subscription_items' - - async up() { - this.schema.createTable(this.tableName, (table) => { - table.uuid('id').primary().notNullable() - - table.uuid('subscription_id').references('subscriptions.id').onDelete('CASCADE') - - table.string('stripe_id').notNullable().unique() - table.string('stripe_product').notNullable() - table.string('stripe_price').notNullable() - table.integer('quantity').nullable() - - table.timestamp('created_at').notNullable() - table.timestamp('updated_at').nullable() - - table.index(['subscription_id', 'stripe_price']) - }) - } - - async down() { - this.schema.dropTable(this.tableName) - } -} diff --git a/apps/backend/database/schema.ts b/apps/backend/database/schema.ts new file mode 100644 index 0000000..863d00e --- /dev/null +++ b/apps/backend/database/schema.ts @@ -0,0 +1,61 @@ +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { DateTime } from 'luxon' + +export class EmailVerificationTokenSchema extends BaseModel { + static $columns = ['id', 'userId', 'token', 'expiresAt', 'createdAt', 'updatedAt'] as const + $columns = EmailVerificationTokenSchema.$columns + @column({ isPrimary: true }) + declare id: string + @column() + declare userId: string | null + @column() + declare token: string + @column.dateTime() + declare expiresAt: DateTime + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime | null + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null +} + +export class ResetPasswordTokenSchema extends BaseModel { + static $columns = ['id', 'userId', 'token', 'expiresAt', 'createdAt', 'updatedAt'] as const + $columns = ResetPasswordTokenSchema.$columns + @column({ isPrimary: true }) + declare id: string + @column() + declare userId: string | null + @column() + declare token: string + @column.dateTime() + declare expiresAt: DateTime + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime | null + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null +} + +export class UserSchema extends BaseModel { + static $columns = ['id', 'firstName', 'lastName', 'email', 'address', 'password', 'role', 'emailVerifiedAt', 'createdAt', 'updatedAt'] as const + $columns = UserSchema.$columns + @column({ isPrimary: true }) + declare id: string + @column() + declare firstName: string | null + @column() + declare lastName: string | null + @column() + declare email: string + @column() + declare address: any | null + @column({ serializeAs: null }) + declare password: string + @column() + declare role: string + @column.dateTime() + declare emailVerifiedAt: DateTime | null + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null +} diff --git a/apps/backend/database/seeders/main/index_seeder.ts b/apps/backend/database/seeders/main/index_seeder.ts index 508d9c9..1d97452 100644 --- a/apps/backend/database/seeders/main/index_seeder.ts +++ b/apps/backend/database/seeders/main/index_seeder.ts @@ -3,7 +3,6 @@ import app from '@adonisjs/core/services/app' export default class IndexSeeder extends BaseSeeder { async run() { - await this.seed(await import('#database/seeders/roles_seeder')) await this.seed(await import('#database/seeders/super_admin_seeder')) } diff --git a/apps/backend/database/seeders/roles_seeder.ts b/apps/backend/database/seeders/roles_seeder.ts deleted file mode 100644 index 42b96c9..0000000 --- a/apps/backend/database/seeders/roles_seeder.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Acl } from '@holoyan/adonisjs-permissions' -import { BaseSeeder } from '@adonisjs/lucid/seeders' - -export default class extends BaseSeeder { - static environment = ['development', 'production'] - - async run() { - const rolesWithPermissions = { - super_admin: ['admin_access'], - } - - for (const [role, permissions] of Object.entries(rolesWithPermissions)) { - await this.createRoleWithPermissions(role, permissions) - } - } - - async createRoleWithPermissions(roleSlug: string, permissions: string[]) { - const role = await Acl.role().create({ slug: roleSlug }) - for (const permission of permissions) { - await Acl.role(role).assign(permission) - } - return role - } -} diff --git a/apps/backend/database/seeders/super_admin_seeder.ts b/apps/backend/database/seeders/super_admin_seeder.ts index b30186e..c8562ea 100644 --- a/apps/backend/database/seeders/super_admin_seeder.ts +++ b/apps/backend/database/seeders/super_admin_seeder.ts @@ -1,5 +1,5 @@ import { BaseSeeder } from '@adonisjs/lucid/seeders' -import User from '#users/models/user' +import User, { Role } from '#users/models/user' export default class extends BaseSeeder { static environment = ['development', 'production'] @@ -9,12 +9,12 @@ export default class extends BaseSeeder { if (existingUser) { return } - const user = await User.create({ + await User.create({ firstName: 'Super', lastName: 'Admin', email: 'admin@repo.com', password: '123', + role: Role.ADMIN, }) - await user.assignRole('super_admin') } } diff --git a/apps/backend/docker-compose.yaml b/apps/backend/docker-compose.yaml index d2ea867..c67cdb1 100644 --- a/apps/backend/docker-compose.yaml +++ b/apps/backend/docker-compose.yaml @@ -1,6 +1,6 @@ services: pgsql: - image: 'pgvector/pgvector:0.8.0-pg17' + image: 'postgres:18' container_name: '${COMPOSE_PROJECT_NAME}_pgsql' ports: - '${DB_PORT:-5432}:5432' @@ -11,7 +11,7 @@ services: POSTGRES_USER: '${DB_USER?:err}' POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' volumes: - - 'sailpgsql:/var/lib/postgresql/data' + - 'sailpgsql:/var/lib/postgresql' - ./docker/pg/scripts:/docker-entrypoint-initdb.d networks: - sail diff --git a/apps/backend/docker-entrypoint.sh b/apps/backend/docker-entrypoint.sh new file mode 100644 index 0000000..13eeadb --- /dev/null +++ b/apps/backend/docker-entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +cd /app/apps/backend/build + +echo "Running database migrations..." +node --no-warnings ace migration:run --force + +echo "Starting the application..." +exec node --no-warnings bin/server.js diff --git a/apps/backend/facteur/service.ts b/apps/backend/facteur/service.ts deleted file mode 100644 index d956591..0000000 --- a/apps/backend/facteur/service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { NotificationChannels } from '@facteurjs/adonisjs/types' -import type { NotificationManager } from '@facteurjs/adonisjs' - -import app from '@adonisjs/core/services/app' - -let facteur: NotificationManager - -await app?.booted(async () => { - facteur = await app.container.make('notifications.manager') -}) - -export { facteur as default } diff --git a/apps/backend/package.json b/apps/backend/package.json index 5257898..f3efcea 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -16,10 +16,12 @@ "#auth/*": "./app/auth/*.js", "#admin/*": "./app/admin/*.js", "#common/*": "./app/common/*.js", - "#emails/*": "./emails/*.js" + "#emails/*": "./emails/*.js", + "#app/*": "./app/*.js" }, "exports": { - "./api": "./.adonisjs/index.ts" + "./registry": "./.adonisjs/client/registry/index.ts", + "./data": "./.adonisjs/client/data.d.ts" }, "scripts": { "start": "node bin/server.js", @@ -28,69 +30,59 @@ "test": "node ace test", "lint": "eslint .", "format": "prettier --write .", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "email": "email dev" }, "dependencies": { - "@adocasts.com/dto": "^0.0.10", - "@adonisjs-community/girouette": "^0.1.5", + "@adonisjs-community/girouette": "0.1.6-next.8", "@adonisjs-community/modules": "^1.1.0", - "@adonisjs/auth": "^9.5.0", - "@adonisjs/bouncer": "^3.1.6", - "@adonisjs/core": "^6.19.0", - "@adonisjs/cors": "^2.2.1", - "@adonisjs/drive": "^3.4.1", - "@adonisjs/i18n": "^2.2.3", - "@adonisjs/lucid": "^21.8.0", - "@adonisjs/mail": "^9.2.2", - "@adonisjs/session": "^7.5.1", - "@adonisjs/transmit": "^2.0.2", - "@aws-sdk/client-s3": "^3.901.0", - "@aws-sdk/s3-request-presigner": "^3.901.0", - "@facteurjs/adonisjs": "1.0.0-beta.4", - "@foadonis/shopkeeper": "^0.1.7", - "@holoyan/adonisjs-permissions": "^1.3.1", - "@holoyan/morph-map-js": "^0.1.1", - "@jrmc/adonis-attachment": "^5.0.1", + "@adonisjs/auth": "10.0.0-next.6", + "@adonisjs/bouncer": "4.0.0-next.2", + "@adonisjs/core": "7.0.0-next.28", + "@adonisjs/cors": "3.0.0-next.0", + "@adonisjs/drive": "4.0.0-next.0", + "@adonisjs/i18n": "2.2.3", + "@adonisjs/lucid": "22.0.0-next.7", + "@adonisjs/mail": "^10.0.0-next.1", + "@adonisjs/session": "8.0.0-next.5", + "@adonisjs/transmit": "^3.0.1-next.0", + "@aws-sdk/client-s3": "^3.985.0", + "@aws-sdk/s3-request-presigner": "^3.985.0", "@monocle.sh/adonisjs-agent": "1.0.0-beta.9", "@react-email/components": "^0.5.7", "@react-email/render": "^1.4.0", - "@tuyau/core": "^0.4.2", - "@vinejs/vine": "^3.0.1", - "edge.js": "^6.3.0", - "locate-path": "^8.0.0", + "@tuyau/core": "catalog:", + "@vinejs/vine": "^4.3.0", + "edge.js": "^6.4.0", "luxon": "^3.7.2", - "pg": "^8.16.3", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "reflect-metadata": "^0.2.2", - "uuid": "^10.0.0" + "pg": "^8.18.0", + "react": "catalog:", + "react-dom": "catalog:", + "reflect-metadata": "^0.2.2" }, "devDependencies": { - "@adonisjs/assembler": "^7.8.2", - "@adonisjs/prettier-config": "^1.4.5", - "@adonisjs/tsconfig": "^1.4.1", - "@japa/api-client": "^3.1.0", - "@japa/assert": "^4.1.1", - "@japa/plugin-adonisjs": "^4.0.0", - "@japa/runner": "^4.4.0", - "@react-email/preview-server": "4.3.1", + "@adonisjs/assembler": "8.0.0-next.32", + "@adonisjs/tsconfig": "2.0.0-next.3", + "@japa/api-client": "^3.2.1", + "@japa/assert": "^4.2.0", + "@japa/plugin-adonisjs": "5.1.0-next.0", + "@japa/runner": "^5.3.0", + "@poppinss/ts-exec": "^1.4.4", + "@react-email/preview-server": "5.2.5", "@swc/core": "1.11.24", "@tuyau/utils": "^0.0.9", "@types/luxon": "^3.7.1", - "@types/node": "^22.18.8", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "@vavite/multibuild": "^5.1.0", - "hot-hook": "^0.4.0", - "pino-pretty": "^13.1.1", - "react-email": "^4.3.1", - "ts-node-maintained": "^10.9.6", - "typescript": "~5.8.3" + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "hot-hook": "0.4.1-next.4", + "pino-pretty": "^13.1.3", + "react-email": "^4.3.2", + "typescript": "catalog:" }, - "prettier": "@adonisjs/prettier-config", "hotHook": { "boundaries": [ - "./app/controllers/**/*.ts", + "./app/**/*_controller.ts", "./app/middleware/*.ts" ] } diff --git a/apps/backend/start/kernel.ts b/apps/backend/start/kernel.ts index 098da4b..339a23d 100644 --- a/apps/backend/start/kernel.ts +++ b/apps/backend/start/kernel.ts @@ -26,7 +26,6 @@ server.use([ () => import('#core/middleware/container_bindings_middleware'), () => import('#core/middleware/force_json_response_middleware'), () => import('@adonisjs/cors/cors_middleware'), - () => import('#core/middleware/acl_middleware'), ]) /** diff --git a/apps/backend/start/routes.girouette.ts b/apps/backend/start/routes.girouette.ts new file mode 100644 index 0000000..fd714b1 --- /dev/null +++ b/apps/backend/start/routes.girouette.ts @@ -0,0 +1,25 @@ +/* +|-------------------------------------------------------------------------- +| Girouette routes loader file +|-------------------------------------------------------------------------- +| +| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD PROCESS +| +| It automatically register your resolvers present in `./app`. +| You can disable this behavior by removing the `indexControllers` from your `adonisrc.ts`. +| +*/ + +import girouette from '@adonisjs-community/girouette/services/main' +import app from '@adonisjs/core/services/app' + +await girouette.controllers([ + () => import('#app/admin/controllers/admin_impersonation_controller'), + () => import('#app/admin/controllers/admin_users_controller'), + () => import('#app/auth/controllers/auth_controller'), + () => import('#app/auth/controllers/email_controller'), + () => import('#app/auth/controllers/password_controller'), + () => import('#app/core/controllers/health_checks_controller'), +]) + +girouette.hmr(app.makePath('./app')) diff --git a/apps/backend/start/routes.ts b/apps/backend/start/routes.ts index dc01aa2..c532463 100644 --- a/apps/backend/start/routes.ts +++ b/apps/backend/start/routes.ts @@ -1,11 +1,8 @@ import transmit from '@adonisjs/transmit/services/main' import router from '@adonisjs/core/services/router' - -import facteur from '../facteur/service.js' import { middleware } from '#start/kernel' transmit.registerRoutes() -router.group(() => facteur.registerRoutes()) const CoreController = () => import('#core/controllers/health_checks_controller') router.get('health', [CoreController, 'handle']).use(middleware.requireSecretToken()) diff --git a/apps/backend/tests/bootstrap.ts b/apps/backend/tests/bootstrap.ts index e1a206d..b5257bb 100644 --- a/apps/backend/tests/bootstrap.ts +++ b/apps/backend/tests/bootstrap.ts @@ -8,9 +8,13 @@ import testUtils from '@adonisjs/core/services/test_utils' import app from '@adonisjs/core/services/app' import { authApiClient } from '@adonisjs/auth/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 {} +} /** * Configure Japa plugins in the plugins array. @@ -32,7 +36,7 @@ export const plugins: Config['plugins'] = [ * The teardown functions are executed after all the tests */ export const runnerHooks: Required> = { - setup: [() => testUtils.db().migrate()], + setup: [() => testUtils.db().truncate()], teardown: [], } diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 024aeac..326667f 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -4,7 +4,13 @@ "rootDir": "./", "outDir": "./build", "jsx": "react-jsx", - "jsxImportSource": "react" + "jsxImportSource": "react", + "paths": { + "~/*": ["./*"], + "~/generated/*": ["../.adonisjs/client/*"], + "~registry": ["../.adonisjs/client/registry.ts"] + } }, + "include": ["./**/*.ts", "./**/*.tsx", "./.adonisjs/**/*.ts"], "exclude": ["node_modules", "build"] } diff --git a/apps/frontend/.cta.json b/apps/frontend/.cta.json deleted file mode 100644 index 887f121..0000000 --- a/apps/frontend/.cta.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "projectName": "frontend", - "mode": "file-router", - "typescript": true, - "tailwind": true, - "packageManager": "pnpm", - "git": true, - "version": 1, - "framework": "react-cra", - "chosenAddOns": ["eslint", "cloudflare", "start", "sentry", "table", "tanstack-query", "form"] -} diff --git a/apps/frontend/README.md b/apps/frontend/README.md deleted file mode 100644 index f556fea..0000000 --- a/apps/frontend/README.md +++ /dev/null @@ -1,297 +0,0 @@ -Welcome to your new TanStack app! - -# Getting Started - -To run this application: - -```bash -pnpm install -pnpm start -``` - -# Building For Production - -To build this application for production: - -```bash -pnpm build -``` - -## Testing - -This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with: - -```bash -pnpm test -``` - -## Styling - -This project uses [Tailwind CSS](https://tailwindcss.com/) for styling. - -## Linting & Formatting - -This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available: - -```bash -pnpm lint -pnpm format -pnpm check -``` - -## Routing - -This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`. - -### Adding A Route - -To add a new route to your application just add another a new file in the `./src/routes` directory. - -TanStack will automatically generate the content of the route file for you. - -Now that you have two routes you can use a `Link` component to navigate between them. - -### Adding Links - -To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`. - -```tsx -import { Link } from "@tanstack/react-router"; -``` - -Then anywhere in your JSX you can use it like so: - -```tsx -About -``` - -This will create a link that will navigate to the `/about` route. - -More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent). - -### Using A Layout - -In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `` component. - -Here is an example layout that includes a header: - -```tsx -import { Outlet, createRootRoute } from '@tanstack/react-router' -import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' - -import { Link } from "@tanstack/react-router"; - -export const Route = createRootRoute({ - component: () => ( - <> -
- -
- - - - ), -}) -``` - -The `` component is not required so you can remove it if you don't want it in your layout. - -More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts). - -## Data Fetching - -There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered. - -For example: - -```tsx -const peopleRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/people", - loader: async () => { - const response = await fetch("https://swapi.dev/api/people"); - return response.json() as Promise<{ - results: { - name: string; - }[]; - }>; - }, - component: () => { - const data = peopleRoute.useLoaderData(); - return ( -
    - {data.results.map((person) => ( -
  • {person.name}
  • - ))} -
- ); - }, -}); -``` - -Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters). - -### React-Query - -React-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze. - -First add your dependencies: - -```bash -pnpm add @tanstack/react-query @tanstack/react-query-devtools -``` - -Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`. - -```tsx -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -// ... - -const queryClient = new QueryClient(); - -// ... - -if (!rootElement.innerHTML) { - const root = ReactDOM.createRoot(rootElement); - - root.render( - - - - ); -} -``` - -You can also add TanStack Query Devtools to the root route (optional). - -```tsx -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; - -const rootRoute = createRootRoute({ - component: () => ( - <> - - - - - ), -}); -``` - -Now you can use `useQuery` to fetch your data. - -```tsx -import { useQuery } from "@tanstack/react-query"; - -import "./App.css"; - -function App() { - const { data } = useQuery({ - queryKey: ["people"], - queryFn: () => - fetch("https://swapi.dev/api/people") - .then((res) => res.json()) - .then((data) => data.results as { name: string }[]), - initialData: [], - }); - - return ( -
-
    - {data.map((person) => ( -
  • {person.name}
  • - ))} -
-
- ); -} - -export default App; -``` - -You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview). - -## State Management - -Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project. - -First you need to add TanStack Store as a dependency: - -```bash -pnpm add @tanstack/store -``` - -Now let's create a simple counter in the `src/App.tsx` file as a demonstration. - -```tsx -import { useStore } from "@tanstack/react-store"; -import { Store } from "@tanstack/store"; -import "./App.css"; - -const countStore = new Store(0); - -function App() { - const count = useStore(countStore); - return ( -
- -
- ); -} - -export default App; -``` - -One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates. - -Let's check this out by doubling the count using derived state. - -```tsx -import { useStore } from "@tanstack/react-store"; -import { Store, Derived } from "@tanstack/store"; -import "./App.css"; - -const countStore = new Store(0); - -const doubledStore = new Derived({ - fn: () => countStore.state * 2, - deps: [countStore], -}); -doubledStore.mount(); - -function App() { - const count = useStore(countStore); - const doubledCount = useStore(doubledStore); - - return ( -
- -
Doubled - {doubledCount}
-
- ); -} - -export default App; -``` - -We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating. - -Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook. - -You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest). - -# Demo files - -Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed. - -# Learn More - -You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com). diff --git a/apps/frontend/components.json b/apps/frontend/components.json new file mode 100644 index 0000000..bf7c533 --- /dev/null +++ b/apps/frontend/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 2b33d7b..c71b0ad 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,61 +6,71 @@ "dev": "vite dev", "build": "vite build", "serve": "vite preview", - "lint": "eslint", - "format": "prettier", - "check": "prettier --write . && eslint --fix", "deploy": "wrangler deploy" }, "dependencies": { + "@base-ui/react": "^1.1.0", "@boilerplate/backend": "workspace:*", - "@boilerplate/design-system": "workspace:*", - "@cloudflare/vite-plugin": "^1.13.10", + "@cloudflare/vite-plugin": "^1.23.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@facteurjs/react": "1.0.0-beta.4", - "@faker-js/faker": "^10.0.0", - "@sentry/tanstackstart-react": "^10.17.0", - "@tailwindcss/vite": "^4.1.14", - "@tanstack/match-sorter-utils": "^8.19.4", - "@tanstack/react-devtools": "^0.7.2", - "@tanstack/react-form": "^1.23.5", - "@tanstack/react-query": "^5.90.2", - "@tanstack/react-query-devtools": "^5.90.2", - "@tanstack/react-router": "^1.132.33", - "@tanstack/react-router-devtools": "^1.132.33", - "@tanstack/react-router-ssr-query": "^1.132.33", - "@tanstack/react-start": "^1.132.36", - "@tanstack/react-store": "^0.8.0", + "@faker-js/faker": "^10.3.0", + "@sentry/tanstackstart-react": "^10.38.0", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-devtools": "^0.9.5", + "@tanstack/react-form": "^1.28.0", + "@tanstack/react-query": "^5.90.20", + "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-router": "^1.159.5", + "@tanstack/react-router-devtools": "^1.159.5", + "@tanstack/react-router-ssr-query": "^1.159.5", + "@tanstack/react-start": "^1.159.5", "@tanstack/react-table": "^8.21.3", - "@tanstack/router-plugin": "^1.132.33", - "@tuyau/client": "0.2.11-next.2", - "@tuyau/react-query": "0.0.1-next.2", - "intlayer": "^7.1.2", - "lucide-react": "^0.544.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-intlayer": "^7.1.2", - "recharts": "^3.2.1", - "sonner": "^1.7.4", - "tailwindcss": "^4.1.14", - "zod": "^4.1.11" + "@tanstack/router-plugin": "^1.159.5", + "@tuyau/core": "catalog:", + "@tuyau/react-query": "1.0.0-beta.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "intlayer": "^7.5.14", + "lucide-react": "^0.562.0", + "next-themes": "^0.4.6", + "nitro": "latest", + "radix-ui": "^1.4.3", + "react": "catalog:", + "react-day-picker": "^9.13.1", + "react-dom": "catalog:", + "react-intlayer": "^7.5.14", + "react-resizable-panels": "^4.6.2", + "recharts": "^2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss": "catalog:", + "vaul": "^1.1.2", + "zod": "^4.3.6" }, "devDependencies": { + "@tanstack/devtools-vite": "^0.4.1", "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.0", - "@types/node": "^22.18.8", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^5.0.4", - "jsdom": "^27.0.0", - "typescript": "^5.9.3", - "vite": "^7.1.9", - "vite-intlayer": "^7.1.2", - "vite-tsconfig-paths": "^5.1.4", + "@testing-library/react": "^16.3.2", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "^5.1.3", + "jsdom": "^27.4.0", + "shadcn": "^3.8.4", + "tw-animate-css": "^1.4.0", + "typescript": "catalog:", + "vite": "^7.3.1", + "vite-intlayer": "^7.5.14", "vitest": "^3.2.4", "web-vitals": "^5.1.0", - "wrangler": "^4.42.0" + "wrangler": "^4.63.0" } } diff --git a/apps/frontend/src/components/admin/impersonation-indicator.tsx b/apps/frontend/src/components/admin/impersonation-indicator.tsx index 1e3f0f2..f6bded2 100644 --- a/apps/frontend/src/components/admin/impersonation-indicator.tsx +++ b/apps/frontend/src/components/admin/impersonation-indicator.tsx @@ -1,8 +1,8 @@ import { useIntlayer } from 'react-intlayer' import { LogOutIcon } from 'lucide-react' -import { Button } from '@boilerplate/design-system/components/ui/button' import { useImpersonation } from '@/hooks/use-impersonation' +import { Button } from '@/components/ui/button' export function ImpersonationIndicator() { const content = useIntlayer('admin') @@ -17,9 +17,7 @@ export function ImpersonationIndicator() {
{content.loggedInAs} - - {currentUser.firstName} {currentUser.lastName} - + {currentUser.fullName}
) diff --git a/apps/frontend/src/components/admin/users-list.tsx b/apps/frontend/src/components/admin/users-list.tsx index f32fd86..e822be7 100644 --- a/apps/frontend/src/components/admin/users-list.tsx +++ b/apps/frontend/src/components/admin/users-list.tsx @@ -2,11 +2,11 @@ import { useIntlayer } from 'react-intlayer' import { useState } from 'react' import { UserIcon } from 'lucide-react' import { useQuery } from '@tanstack/react-query' -import { Input } from '@boilerplate/design-system/components/ui/input' -import { Button } from '@boilerplate/design-system/components/ui/button' import { getUsersListQueryOptions } from '@/lib/queries/admin' import { useImpersonation } from '@/hooks/use-impersonation' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' import Loader from '@/components/common/loader' export function UsersList() { @@ -53,11 +53,9 @@ export function UsersList() { {data?.data.map((user) => ( - - {user.firstName} {user.lastName} - + {user.fullName} {user.email} - {new Date(user.createdAt).toLocaleDateString()} + {new Date(user.createdAt ?? '').toLocaleDateString()} @@ -92,7 +90,7 @@ export function UsersList() { size="sm" variant="outline" onClick={() => setPage((p) => p + 1)} - disabled={data.meta.currentPage === data.meta.lastPage} + disabled={data.metadata.currentPage === data.metadata.lastPage} > {content.next} diff --git a/apps/frontend/src/components/auth/forgot-password-form.tsx b/apps/frontend/src/components/auth/forgot-password-form.tsx index bd97121..cc1112f 100644 --- a/apps/frontend/src/components/auth/forgot-password-form.tsx +++ b/apps/frontend/src/components/auth/forgot-password-form.tsx @@ -1,14 +1,14 @@ import { useIntlayer } from 'react-intlayer' import { useMutation } from '@tanstack/react-query' -import { cn } from '@boilerplate/design-system/lib/utils' -import { Input } from '@boilerplate/design-system/components/ui/input' -import { Form } from '@boilerplate/design-system/components/ui/form' -import { Field, FieldDescription, FieldLabel } from '@boilerplate/design-system/components/ui/field' -import { Button } from '@boilerplate/design-system/components/ui/button' +import { cn } from '@/lib/utils' import { forgotPasswordFormSchema } from '@/lib/schemas/auth' import { forgotPasswordMutationOptions } from '@/lib/queries/auth' import { useAppForm } from '@/hooks/form-hook' +import { Input } from '@/components/ui/input' +import { Form } from '@/components/ui/form' +import { Field, FieldDescription, FieldLabel } from '@/components/ui/field' +import { Button } from '@/components/ui/button' import { LocalizedLink } from '@/components/common/localized-link' export function ForgotPasswordForm({ className, ...props }: React.ComponentProps<'form'>) { @@ -23,7 +23,7 @@ export function ForgotPasswordForm({ className, ...props }: React.ComponentProps }, onSubmit: (data) => { forgotPasswordMutation.mutateAsync({ - payload: { email: data.value.email }, + body: { email: data.value.email }, }) }, }) diff --git a/apps/frontend/src/components/auth/login-form.tsx b/apps/frontend/src/components/auth/login-form.tsx index 051f21d..e41e14b 100644 --- a/apps/frontend/src/components/auth/login-form.tsx +++ b/apps/frontend/src/components/auth/login-form.tsx @@ -1,19 +1,14 @@ import { useIntlayer } from 'react-intlayer' -import { cn } from '@boilerplate/design-system/lib/utils' -import { PasswordField } from '@boilerplate/design-system/components/ui/password_field' -import { Input } from '@boilerplate/design-system/components/ui/input' -import { Form } from '@boilerplate/design-system/components/ui/form' -import { - Field, - FieldDescription, - FieldLabel, - FieldSeparator, -} from '@boilerplate/design-system/components/ui/field' -import { Button } from '@boilerplate/design-system/components/ui/button' +import { cn } from '@/lib/utils' import { loginFormSchema } from '@/lib/schemas/auth' import { useAuth } from '@/hooks/use-auth' import { useAppForm } from '@/hooks/form-hook' +import { PasswordField } from '@/components/ui/password-field' +import { Input } from '@/components/ui/input' +import { Form } from '@/components/ui/form' +import { Field, FieldDescription, FieldLabel, FieldSeparator } from '@/components/ui/field' +import { Button } from '@/components/ui/button' import { LocalizedLink } from '@/components/common/localized-link' export function LoginForm({ className, ...props }: React.ComponentProps<'form'>) { diff --git a/apps/frontend/src/components/auth/register-form.tsx b/apps/frontend/src/components/auth/register-form.tsx index b816d29..14191f6 100644 --- a/apps/frontend/src/components/auth/register-form.tsx +++ b/apps/frontend/src/components/auth/register-form.tsx @@ -1,26 +1,17 @@ import { useIntlayer } from 'react-intlayer' import { useMutation } from '@tanstack/react-query' -import { cn } from '@boilerplate/design-system/lib/utils' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@boilerplate/design-system/components/ui/tooltip' -import { PasswordField } from '@boilerplate/design-system/components/ui/password_field' -import { PasswordStrength } from '@boilerplate/design-system/components/ui/password-strength' -import { Input } from '@boilerplate/design-system/components/ui/input' -import { Form } from '@boilerplate/design-system/components/ui/form' -import { - Field, - FieldDescription, - FieldLabel, - FieldSeparator, -} from '@boilerplate/design-system/components/ui/field' -import { Button } from '@boilerplate/design-system/components/ui/button' +import { cn } from '@/lib/utils' import { registerFormSchema } from '@/lib/schemas/auth' import { registerMutationOptions } from '@/lib/queries/auth' import { useAppForm } from '@/hooks/form-hook' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { PasswordStrength } from '@/components/ui/password-strength' +import { PasswordField } from '@/components/ui/password-field' +import { Input } from '@/components/ui/input' +import { Form } from '@/components/ui/form' +import { Field, FieldDescription, FieldLabel, FieldSeparator } from '@/components/ui/field' +import { Button } from '@/components/ui/button' import { LocalizedLink } from '@/components/common/localized-link' export function RegisterForm({ className, ...props }: React.ComponentProps<'form'>) { @@ -39,7 +30,7 @@ export function RegisterForm({ className, ...props }: React.ComponentProps<'form }, onSubmit: (data) => { registerMutation.mutateAsync({ - payload: { + body: { email: data.value.email, firstName: data.value.firstName, lastName: data.value.lastName, @@ -111,7 +102,7 @@ export function RegisterForm({ className, ...props }: React.ComponentProps<'form
{content.fields.password} - +
) { @@ -23,7 +23,7 @@ export function ResendVerificationForm({ className, ...props }: React.ComponentP }, onSubmit: (data) => { resendVerificationMutation.mutateAsync({ - payload: { email: data.value.email }, + body: { email: data.value.email }, }) }, }) diff --git a/apps/frontend/src/components/auth/reset-password-form.tsx b/apps/frontend/src/components/auth/reset-password-form.tsx index b31a8f8..2997927 100644 --- a/apps/frontend/src/components/auth/reset-password-form.tsx +++ b/apps/frontend/src/components/auth/reset-password-form.tsx @@ -1,21 +1,17 @@ import { useIntlayer } from 'react-intlayer' import { useMutation } from '@tanstack/react-query' -import { cn } from '@boilerplate/design-system/lib/utils' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@boilerplate/design-system/components/ui/tooltip' -import { PasswordField } from '@boilerplate/design-system/components/ui/password_field' -import { PasswordStrength } from '@boilerplate/design-system/components/ui/password-strength' -import { Input } from '@boilerplate/design-system/components/ui/input' -import { Form } from '@boilerplate/design-system/components/ui/form' -import { Field, FieldDescription, FieldLabel } from '@boilerplate/design-system/components/ui/field' -import { Button } from '@boilerplate/design-system/components/ui/button' +import { cn } from '@/lib/utils' import { resetPasswordFormSchema } from '@/lib/schemas/auth' import { resetPasswordMutationOptions } from '@/lib/queries/auth' import { useAppForm } from '@/hooks/form-hook' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { PasswordStrength } from '@/components/ui/password-strength' +import { PasswordField } from '@/components/ui/password-field' +import { Input } from '@/components/ui/input' +import { Form } from '@/components/ui/form' +import { Field, FieldDescription, FieldLabel } from '@/components/ui/field' +import { Button } from '@/components/ui/button' import { LocalizedLink } from '@/components/common/localized-link' interface ResetPasswordFormProps extends React.ComponentProps<'form'> { @@ -24,7 +20,7 @@ interface ResetPasswordFormProps extends React.ComponentProps<'form'> { export function ResetPasswordForm({ token, className, ...props }: ResetPasswordFormProps) { const content = useIntlayer('auth') - const resetPasswordMutation = useMutation(resetPasswordMutationOptions(token)) + const resetPasswordMutation = useMutation(resetPasswordMutationOptions()) const form = useAppForm({ defaultValues: { password: '', @@ -35,7 +31,8 @@ export function ResetPasswordForm({ token, className, ...props }: ResetPasswordF }, onSubmit: (data) => { resetPasswordMutation.mutateAsync({ - payload: { password: data.value.password }, + params: { token }, + body: { password: data.value.password }, }) }, }) @@ -54,7 +51,7 @@ export function ResetPasswordForm({ token, className, ...props }: ResetPasswordF
{content.fields.password} - +
) { - + - + Acme Inc. diff --git a/apps/frontend/src/components/common/nav-documents.tsx b/apps/frontend/src/components/common/nav-documents.tsx index 243ffb5..89bc775 100644 --- a/apps/frontend/src/components/common/nav-documents.tsx +++ b/apps/frontend/src/components/common/nav-documents.tsx @@ -1,6 +1,7 @@ import type { LucideIcon } from 'lucide-react' import { EllipsisVerticalIcon, FolderIcon, ShareIcon, TrashIcon } from 'lucide-react' + import { SidebarGroup, SidebarGroupLabel, @@ -9,14 +10,14 @@ import { SidebarMenuButton, SidebarMenuItem, useSidebar, -} from '@boilerplate/design-system/components/ui/sidebar' +} from '@/components/ui/sidebar' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@boilerplate/design-system/components/ui/dropdown-menu' +} from '@/components/ui/dropdown-menu' export function NavDocuments({ items, @@ -35,14 +36,14 @@ export function NavDocuments({ {items.map((item) => ( - + {item.name} - + More diff --git a/apps/frontend/src/components/common/nav-main.tsx b/apps/frontend/src/components/common/nav-main.tsx index efc9eb6..cc77977 100644 --- a/apps/frontend/src/components/common/nav-main.tsx +++ b/apps/frontend/src/components/common/nav-main.tsx @@ -1,14 +1,15 @@ import type { LucideIcon } from 'lucide-react' import { CirclePlusIcon, MailIcon } from 'lucide-react' + import { SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem, -} from '@boilerplate/design-system/components/ui/sidebar' -import { Button } from '@boilerplate/design-system/components/ui/button' +} from '@/components/ui/sidebar' +import { Button } from '@/components/ui/button' export function NavMain({ items, diff --git a/apps/frontend/src/components/common/nav-secondary.tsx b/apps/frontend/src/components/common/nav-secondary.tsx index c53f7a7..ad6250b 100644 --- a/apps/frontend/src/components/common/nav-secondary.tsx +++ b/apps/frontend/src/components/common/nav-secondary.tsx @@ -1,13 +1,14 @@ import type { LucideIcon } from 'lucide-react' import * as React from 'react' + import { SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem, -} from '@boilerplate/design-system/components/ui/sidebar' +} from '@/components/ui/sidebar' export function NavSecondary({ items, @@ -25,7 +26,7 @@ export function NavSecondary({ {items.map((item) => ( - + {item.title} diff --git a/apps/frontend/src/components/common/nav-user.tsx b/apps/frontend/src/components/common/nav-user.tsx index bd3d4a0..aa23016 100644 --- a/apps/frontend/src/components/common/nav-user.tsx +++ b/apps/frontend/src/components/common/nav-user.tsx @@ -5,12 +5,14 @@ import { LogOutIcon, UserCircleIcon, } from 'lucide-react' + +import { useAuth } from '@/hooks/use-auth' import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, -} from '@boilerplate/design-system/components/ui/sidebar' +} from '@/components/ui/sidebar' import { DropdownMenu, DropdownMenuContent, @@ -19,14 +21,8 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@boilerplate/design-system/components/ui/dropdown-menu' -import { - Avatar, - AvatarFallback, - AvatarImage, -} from '@boilerplate/design-system/components/ui/avatar' - -import { useAuth } from '@/hooks/use-auth' +} from '@/components/ui/dropdown-menu' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' export function NavUser({ user, @@ -44,7 +40,7 @@ export function NavUser({ - + Documents
- + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +} diff --git a/packages/design-system/components/ui/chart.tsx b/apps/frontend/src/components/ui/chart.tsx similarity index 64% rename from packages/design-system/components/ui/chart.tsx rename to apps/frontend/src/components/ui/chart.tsx index 12630be..5f31f72 100644 --- a/packages/design-system/components/ui/chart.tsx +++ b/apps/frontend/src/components/ui/chart.tsx @@ -1,12 +1,12 @@ -'use client' +"use client" -import * as RechartsPrimitive from 'recharts' -import * as React from 'react' +import * as React from "react" +import * as RechartsPrimitive from "recharts" -import { cn } from '../../lib/utils' +import { cn } from "@/lib/utils" // Format: { THEME_NAME: CSS_SELECTOR } -const THEMES = { light: '', dark: '.dark' } as const +const THEMES = { light: "", dark: ".dark" } as const export type ChartConfig = { [k in string]: { @@ -28,7 +28,7 @@ function useChart() { const context = React.useContext(ChartContext) if (!context) { - throw new Error('useChart must be used within a ') + throw new Error("useChart must be used within a ") } return context @@ -40,12 +40,14 @@ function ChartContainer({ children, config, ...props -}: React.ComponentProps<'div'> & { +}: React.ComponentProps<"div"> & { config: ChartConfig - children: React.ComponentProps['children'] + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] }) { const uniqueId = React.useId() - const chartId = `chart-${id || uniqueId.replace(/:/g, '')}` + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` return ( @@ -54,19 +56,23 @@ function ChartContainer({ data-chart={chartId} className={cn( "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", - className, + className )} {...props} > - {children} + + {children} +
) } const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color) + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) if (!colorConfig.length) { return null @@ -81,14 +87,16 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { ${prefix} [data-chart=${id}] { ${colorConfig .map(([key, itemConfig]) => { - const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color + const color = + itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || + itemConfig.color return color ? ` --color-${key}: ${color};` : null }) - .join('\n')} + .join("\n")} } -`, +` ) - .join('\n'), + .join("\n"), }} /> ) @@ -100,7 +108,7 @@ function ChartTooltipContent({ active, payload, className, - indicator = 'dot', + indicator = "dot", hideLabel = false, hideIndicator = false, label, @@ -111,10 +119,10 @@ function ChartTooltipContent({ nameKey, labelKey, }: React.ComponentProps & - React.ComponentProps<'div'> & { + React.ComponentProps<"div"> & { hideLabel?: boolean hideIndicator?: boolean - indicator?: 'line' | 'dot' | 'dashed' + indicator?: "line" | "dot" | "dashed" nameKey?: string labelKey?: string }) { @@ -126,16 +134,18 @@ function ChartTooltipContent({ } const [item] = payload - const key = `${labelKey || item?.dataKey || item?.name || 'value'}` + const key = `${labelKey || item?.dataKey || item?.name || "value"}` const itemConfig = getPayloadConfigFromPayload(config, item, key) const value = - !labelKey && typeof label === 'string' + !labelKey && typeof label === "string" ? config[label as keyof typeof config]?.label || label : itemConfig?.label if (labelFormatter) { return ( -
{labelFormatter(value, payload)}
+
+ {labelFormatter(value, payload)} +
) } @@ -143,28 +153,33 @@ function ChartTooltipContent({ return null } - return
{value}
- }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]) + return
{value}
+ }, [ + label, + labelFormatter, + payload, + hideLabel, + labelClassName, + config, + labelKey, + ]) if (!active || !payload?.length) { return null } - const nestLabel = payload.length === 1 && indicator !== 'dot' + const nestLabel = payload.length === 1 && indicator !== "dot" return (
{!nestLabel ? tooltipLabel : null}
{payload - .filter((item) => item.type !== 'none') + .filter((item) => item.type !== "none") .map((item, index) => { - const key = `${nameKey || item.name || item.dataKey || 'value'}` + const key = `${nameKey || item.name || item.dataKey || "value"}` const itemConfig = getPayloadConfigFromPayload(config, item, key) const indicatorColor = color || item.payload.fill || item.color @@ -172,8 +187,8 @@ function ChartTooltipContent({
svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5', - indicator === 'dot' && 'items-center', + "[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5", + indicator === "dot" && "items-center" )} > {formatter && item?.value !== undefined && item.name ? ( @@ -186,19 +201,19 @@ function ChartTooltipContent({ !hideIndicator && (
@@ -206,8 +221,8 @@ function ChartTooltipContent({ )}
@@ -238,10 +253,10 @@ function ChartLegendContent({ className, hideIcon = false, payload, - verticalAlign = 'bottom', + verticalAlign = "bottom", nameKey, -}: React.ComponentProps<'div'> & - Pick & { +}: React.ComponentProps<"div"> & + Pick & { hideIcon?: boolean nameKey?: string }) { @@ -254,22 +269,22 @@ function ChartLegendContent({ return (
{payload - .filter((item) => item.type !== 'none') + .filter((item) => item.type !== "none") .map((item) => { - const key = `${nameKey || item.dataKey || 'value'}` + const key = `${nameKey || item.dataKey || "value"}` const itemConfig = getPayloadConfigFromPayload(config, item, key) return (
svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3', + "[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3" )} > {itemConfig?.icon && !hideIcon ? ( @@ -290,30 +305,42 @@ function ChartLegendContent({ ) } -// Helper to extract item config from a payload. -function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) { - if (typeof payload !== 'object' || payload === null) { +function getPayloadConfigFromPayload( + config: ChartConfig, + payload: unknown, + key: string +) { + if (typeof payload !== "object" || payload === null) { return undefined } const payloadPayload = - 'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null + "payload" in payload && + typeof payload.payload === "object" && + payload.payload !== null ? payload.payload : undefined let configLabelKey: string = key - if (key in payload && typeof payload[key as keyof typeof payload] === 'string') { + if ( + key in payload && + typeof payload[key as keyof typeof payload] === "string" + ) { configLabelKey = payload[key as keyof typeof payload] as string } else if ( payloadPayload && key in payloadPayload && - typeof payloadPayload[key as keyof typeof payloadPayload] === 'string' + typeof payloadPayload[key as keyof typeof payloadPayload] === "string" ) { - configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string + configLabelKey = payloadPayload[ + key as keyof typeof payloadPayload + ] as string } - return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config] + return configLabelKey in config + ? config[configLabelKey] + : config[key as keyof typeof config] } export { diff --git a/apps/frontend/src/components/ui/checkbox.tsx b/apps/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..b9f1297 --- /dev/null +++ b/apps/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,29 @@ +"use client" + +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox" + +import { cn } from "@/lib/utils" +import { CheckIcon } from "lucide-react" + +function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/apps/frontend/src/components/ui/collapsible.tsx b/apps/frontend/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..4b242f7 --- /dev/null +++ b/apps/frontend/src/components/ui/collapsible.tsx @@ -0,0 +1,19 @@ +import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible" + +function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { + return +} + +function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { + return ( + + ) +} + +function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/apps/frontend/src/components/ui/combobox.tsx b/apps/frontend/src/components/ui/combobox.tsx new file mode 100644 index 0000000..0bcf5b2 --- /dev/null +++ b/apps/frontend/src/components/ui/combobox.tsx @@ -0,0 +1,292 @@ +"use client" + +import * as React from "react" +import { Combobox as ComboboxPrimitive } from "@base-ui/react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group" +import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react" + +const Combobox = ComboboxPrimitive.Root + +function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { + return +} + +function ComboboxTrigger({ + className, + children, + ...props +}: ComboboxPrimitive.Trigger.Props) { + return ( + + {children} + + + ) +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + } + className={cn(className)} + {...props} + > + + + ) +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean + showClear?: boolean +}) { + return ( + + } + {...props} + /> + + {showTrigger && ( + } + data-slot="input-group-button" + className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent" + disabled={disabled} + /> + )} + {showClear && } + + {children} + + ) +} + +function ComboboxContent({ + className, + side = "bottom", + sideOffset = 6, + align = "start", + alignOffset = 0, + anchor, + ...props +}: ComboboxPrimitive.Popup.Props & + Pick< + ComboboxPrimitive.Positioner.Props, + "side" | "align" | "sideOffset" | "alignOffset" | "anchor" + >) { + return ( + + + + + + ) +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ) +} + +function ComboboxItem({ + className, + children, + ...props +}: ComboboxPrimitive.Item.Props) { + return ( + + {children} + } + > + + + + ) +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + + ) +} + +function ComboboxLabel({ + className, + ...props +}: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ( + + ) +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( +