├── app ├── src │ ├── redux │ │ ├── slices │ │ │ ├── index.ts │ │ │ └── counterSlice │ │ │ │ ├── index.ts │ │ │ │ ├── fetchIdentityCount.ts │ │ │ │ ├── selectors.ts │ │ │ │ ├── thunks.ts │ │ │ │ └── counterSlice.ts │ │ ├── index.ts │ │ ├── rootReducer.ts │ │ ├── createAppAsyncThunk.ts │ │ └── store.ts │ ├── app │ │ ├── fonts │ │ │ ├── GeistVF.woff │ │ │ └── GeistMonoVF.woff │ │ ├── providers.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── utils │ │ └── utils.ts │ ├── i18n │ │ └── request.ts │ ├── components │ │ ├── counter │ │ │ ├── counter.module.css │ │ │ └── Counter.tsx │ │ └── Button.tsx │ └── styles │ │ ├── Home.module.css │ │ ├── globals.css │ │ └── tailwindcss-animate.css ├── messages │ └── en.json ├── postcss.config.js ├── public │ ├── favicon.ico │ └── vercel.svg ├── .vscode │ └── settings.json ├── next-env.d.ts ├── next.config.js ├── components.json ├── .gitignore ├── Dockerfile ├── tsconfig.json └── package.json ├── backoffice └── README.md ├── .gitignore ├── compose_override ├── production.yaml └── development.yaml ├── populate.sh ├── api ├── .gitignore ├── Dockerfile ├── .s │ └── migrations │ │ ├── meta │ │ ├── _journal.json │ │ └── 0000_snapshot.json │ │ └── 0000_mute_mandarin.sql ├── drizzle.config.ts ├── utils │ ├── valkey.ts │ ├── schemas │ │ ├── index.ts │ │ ├── database.ts │ │ ├── schemaConverter.ts │ │ ├── types.ts │ │ └── validation.ts │ ├── validation.ts │ ├── db.ts │ ├── appError.ts │ ├── logger.ts │ └── constants.ts ├── env.d.ts ├── services │ ├── index.ts │ └── userService.ts ├── tsconfig.json ├── package.json ├── app.ts ├── routes │ ├── testRoutes.ts │ ├── userRoutes.ts │ └── betterAuthRoutes.ts ├── middleware │ ├── betterAuth.ts │ └── errorHandler.ts └── Starter.postman_collection.json ├── .vscode └── settings.json ├── .env.placeholder ├── compose.yaml ├── start.sh ├── biome.json └── README.md /app/src/redux/slices/index.ts: -------------------------------------------------------------------------------- 1 | export * from './counterSlice' 2 | -------------------------------------------------------------------------------- /backoffice/README.md: -------------------------------------------------------------------------------- 1 | In thid directory you can put your code for BO -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | private 2 | .env 3 | docker-compose.override.yml 4 | lastpass 5 | -------------------------------------------------------------------------------- /app/src/redux/index.ts: -------------------------------------------------------------------------------- 1 | export * from './slices' 2 | export * from './store' 3 | -------------------------------------------------------------------------------- /compose_override/production.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | api: 3 | restart: always 4 | -------------------------------------------------------------------------------- /app/messages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "HomePage": { 3 | "title": "Welcome to" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /app/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndreaGiulianini/fullstack_basic_starter/HEAD/app/public/favicon.ico -------------------------------------------------------------------------------- /populate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | docker exec api npm run generate 6 | docker exec api npm run migrate 7 | -------------------------------------------------------------------------------- /app/src/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndreaGiulianini/fullstack_basic_starter/HEAD/app/src/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /app/src/redux/slices/counterSlice/index.ts: -------------------------------------------------------------------------------- 1 | export * from './counterSlice' 2 | export * from './selectors' 3 | export * from './thunks' 4 | -------------------------------------------------------------------------------- /app/src/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndreaGiulianini/fullstack_basic_starter/HEAD/app/src/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /app/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # Optional npm cache directory 5 | .npm 6 | 7 | # Optional eslint cache 8 | .eslintcache 9 | -------------------------------------------------------------------------------- /app/src/redux/rootReducer.ts: -------------------------------------------------------------------------------- 1 | /* Instruments */ 2 | import { counterSlice } from './slices' 3 | 4 | export const reducer = { 5 | counter: counterSlice.reducer 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "biome.configurationPath": "biome.json", 3 | "biome.suggestInstallingGlobally": true, 4 | "biome.lsp.bin": "./api/node_modules/.bin/biome", //TODO: fix 5 | } -------------------------------------------------------------------------------- /app/src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:25-slim 2 | 3 | WORKDIR /home/node/api 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install --legacy-peer-deps && \ 8 | npm rebuild esbuild && \ 9 | npm cache clean --force 10 | 11 | COPY ./ ./ 12 | 13 | CMD [ "npm", "run", "dev"] 14 | -------------------------------------------------------------------------------- /app/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import "./.next/types/routes.d.ts"; 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 7 | -------------------------------------------------------------------------------- /api/.s/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1752531948738, 9 | "tag": "0000_mute_mandarin", 10 | "breakpoints": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.env.placeholder: -------------------------------------------------------------------------------- 1 | ENV=development 2 | DB_HOST=postgres_db 3 | DB_USER=your_db_user 4 | DB_PASS=your_db_password 5 | DB_NAME=your_db_name 6 | DB_PORT=5432 7 | VALKEY_HOST=valkey 8 | VALKEY_PASS=your_valkey_password 9 | VALKEY_PORT=6379 10 | ELASTICSEARCH_HOST=elasticsearch 11 | ELASTICSEARCH_PORT=9200 12 | JWT_SECRET=superdupersecret 13 | -------------------------------------------------------------------------------- /app/src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { ReactNode } from 'react' 4 | import { Provider } from 'react-redux' 5 | import { reduxStore } from '../redux/store' 6 | 7 | export default function Providers({ children }: { children: ReactNode }) { 8 | return {children} 9 | } 10 | -------------------------------------------------------------------------------- /app/next.config.js: -------------------------------------------------------------------------------- 1 | import createNextIntlPlugin from 'next-intl/plugin' 2 | 3 | const withNextIntl = createNextIntlPlugin() 4 | 5 | /** @type {import('next').NextConfig} */ 6 | const nextConfig = { 7 | env: { 8 | NEXT_PUBLIC_ENVIRONMENT: process.env.NEXT_PUBLIC_ENVIRONMENT 9 | } 10 | } 11 | 12 | export default withNextIntl(nextConfig) 13 | -------------------------------------------------------------------------------- /api/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit' 2 | 3 | export default defineConfig({ 4 | schema: ['./utils/schemas/database.ts'], 5 | out: '.s/migrations', 6 | dialect: 'postgresql', 7 | dbCredentials: { 8 | url: `postgres://${process.env.DB_USER}:${process.env.DB_PASS}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}` 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /app/src/i18n/request.ts: -------------------------------------------------------------------------------- 1 | import { getRequestConfig } from 'next-intl/server' 2 | 3 | export default getRequestConfig(async () => { 4 | // Provide a static locale, fetch a user setting, 5 | // read from `cookies()`, `headers()`, etc. 6 | const locale = 'en' 7 | 8 | return { 9 | locale, 10 | messages: (await import(`../../messages/${locale}.json`)).default 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /api/utils/valkey.ts: -------------------------------------------------------------------------------- 1 | import Valkey from 'iovalkey' 2 | import { DB_LIMITS, DEFAULT_PORTS } from './constants' 3 | 4 | const valkey = new Valkey({ 5 | port: Number.parseInt(process.env.VALKEY_PORT || DEFAULT_PORTS.VALKEY.toString(), 10), 6 | host: process.env.VALKEY_HOST || 'localhost', 7 | password: process.env.VALKEY_PASS, 8 | db: DB_LIMITS.DEFAULT_DB_INDEX 9 | }) 10 | 11 | export default valkey 12 | -------------------------------------------------------------------------------- /app/src/redux/slices/counterSlice/fetchIdentityCount.ts: -------------------------------------------------------------------------------- 1 | export const fetchIdentityCount = async (amount = 1): Promise<{ amount: number }> => { 2 | const response = await fetch('/api/identity-count', { 3 | method: 'POST', 4 | headers: { 'Content-Type': 'application/json' }, 5 | body: JSON.stringify({ amount }) 6 | }) 7 | const result = await response.json() 8 | 9 | return result 10 | } 11 | -------------------------------------------------------------------------------- /app/src/redux/createAppAsyncThunk.ts: -------------------------------------------------------------------------------- 1 | /* Core */ 2 | import { createAsyncThunk } from '@reduxjs/toolkit' 3 | 4 | /* Instruments */ 5 | import type { ReduxDispatch, ReduxState } from './store' 6 | 7 | /** 8 | * ? A utility function to create a typed Async Thnuk Actions. 9 | */ 10 | export const createAppAsyncThunk = createAsyncThunk.withTypes<{ 11 | state: ReduxState 12 | dispatch: ReduxDispatch 13 | rejectValue: string 14 | }>() 15 | -------------------------------------------------------------------------------- /app/src/redux/slices/counterSlice/selectors.ts: -------------------------------------------------------------------------------- 1 | /* Instruments */ 2 | import type { ReduxState } from '../../../redux' 3 | 4 | // The function below is called a selector and allows us to select a value from 5 | // the state. Selectors can also be defined inline where they're used instead of 6 | // in the slice file. For example: `useSelector((state: RootState) => state.counter.value)` 7 | export const selectCount = (state: ReduxState) => state.counter.value 8 | -------------------------------------------------------------------------------- /api/env.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJs { 3 | interface ProcessEnv { 4 | ENV: string 5 | DB_HOST: string 6 | DB_USER: string 7 | DB_PASS: string 8 | DB_NAME: string 9 | DB_PORT: number 10 | VALKEY_HOST: string 11 | VALKEY_PASS: string 12 | VALKEY_PORT: number 13 | ELASTICSEARCH_HOST: string 14 | ELASTICSEARCH_PORT: number 15 | JWT_SECRET: string 16 | } 17 | } 18 | } 19 | 20 | export {} 21 | -------------------------------------------------------------------------------- /app/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "styles/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/utils/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | /config.ts 38 | -------------------------------------------------------------------------------- /api/services/index.ts: -------------------------------------------------------------------------------- 1 | // ============================================================================= 2 | // SERVICES INDEX - DIRECT ACCESS PATTERN 3 | // Centralized exports for all service functionality 4 | // Simple, direct, and performant services without repository abstraction 5 | // ============================================================================= 6 | 7 | // User service functions 8 | export { 9 | createUser, 10 | deleteUser, 11 | findAllUsers, 12 | findUserByEmail, 13 | findUserById, 14 | updateUser, 15 | userExists 16 | } from './userService' 17 | -------------------------------------------------------------------------------- /app/src/components/counter/counter.module.css: -------------------------------------------------------------------------------- 1 | .row { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | } 6 | 7 | .row > button { 8 | margin-left: 4px; 9 | margin-right: 8px; 10 | } 11 | 12 | .row:not(:last-child) { 13 | margin-bottom: 16px; 14 | } 15 | 16 | .value { 17 | font-size: 78px; 18 | padding-left: 16px; 19 | padding-right: 16px; 20 | margin-top: 2px; 21 | font-family: "Courier New", Courier, monospace; 22 | } 23 | 24 | .textbox { 25 | font-size: 32px; 26 | padding: 2px; 27 | width: 64px; 28 | text-align: center; 29 | margin-right: 4px; 30 | } 31 | -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:25-slim AS base 2 | WORKDIR /home/node 3 | COPY package*.json ./ 4 | ENV PATH=/home/node/node_modules/.bin:$PATH 5 | RUN npm install --force --no-audit --loglevel info && npm cache clean --force 6 | RUN npx next telemetry disable 7 | RUN npx next telemetry status 8 | WORKDIR /home/node/app 9 | COPY ./ ./ 10 | 11 | FROM base AS base-remote 12 | ARG NEXT_PUBLIC_ENVIRONMENT 13 | RUN echo 'NEXT_PUBLIC_ENVIRONMENT=${NEXT_PUBLIC_ENVIRONMENT}' > /home/node/app/.env 14 | RUN npm run build 15 | 16 | FROM base AS image-development 17 | CMD [ "npm", "run", "dev" ] 18 | 19 | FROM base-remote AS image-production 20 | CMD [ "npm", "run", "start" ] 21 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "./dist", 11 | "baseUrl": ".", 12 | "paths": { 13 | "@/*": ["./*"], 14 | "@middleware/*": ["middleware/*"], 15 | "@models/*": ["models/*"], 16 | "@routes/*": ["routes/*"], 17 | "@types/*": ["types/*"], 18 | "@utils/*": ["utils/*"] 19 | } 20 | }, 21 | "watchOptions": { 22 | "watchFile": "dynamicPriorityPolling" 23 | }, 24 | "include": ["**/*.ts", "app.ts", "routes/betterAuthRoutes.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "react-jsx", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /api/utils/schemas/index.ts: -------------------------------------------------------------------------------- 1 | // ============================================================================= 2 | // SCHEMAS INDEX - UNIFIED SCHEMA SYSTEM 3 | // Single point of access for all schemas, types, and validation 4 | // Clean separation of concerns with logical organization 5 | // ============================================================================= 6 | 7 | // Database schemas (Drizzle) 8 | export * from './database' 9 | // Re-export for convenience 10 | export { default as databaseSchemas } from './database' 11 | 12 | // TypeScript types 13 | export * from './types' 14 | // Validation schemas (Zod) 15 | export * from './validation' 16 | 17 | // ============================================================================= 18 | // QUICK ACCESS COLLECTIONS 19 | // ============================================================================= 20 | 21 | /** 22 | * All schemas in one place for easy access 23 | */ 24 | export { validationSchemas } from './validation' 25 | 26 | /** 27 | * All database tables exported above via * from './database' 28 | */ 29 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "check": "npx @biomejs/biome check --write .", 11 | "format": "npx @biomejs/biome format --write .", 12 | "lint": "npx @biomejs/biome lint ." 13 | }, 14 | "dependencies": { 15 | "@radix-ui/react-slot": "^1.2.4", 16 | "@reduxjs/toolkit": "^2.11.1", 17 | "class-variance-authority": "^0.7.1", 18 | "clsx": "^2.1.1", 19 | "lucide-react": "^0.561.0", 20 | "next": "^16.0.10", 21 | "next-intl": "^4.6.0", 22 | "react": "^19.2.3", 23 | "react-dom": "^19.2.3", 24 | "react-redux": "^9.2.0", 25 | "tailwind-merge": "^3.4.0" 26 | }, 27 | "devDependencies": { 28 | "@biomejs/biome": "^2.3.8", 29 | "@tailwindcss/postcss": "^4.1.18", 30 | "@types/node": "^25.0.2", 31 | "@types/react": "^19.2.7", 32 | "@types/react-dom": "^19.2.3", 33 | "tailwindcss": "^4.1.18", 34 | "typescript": "^5.9.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /api/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import type { ZodError, ZodSchema } from 'zod' 2 | import { ValidationError } from './appError' 3 | 4 | // ============================================================================= 5 | // SIMPLIFIED VALIDATION SYSTEM 6 | // Simplified validation system that works natively with Fastify 7 | // ============================================================================= 8 | 9 | /** 10 | * Helper for quick validation - use only when necessary 11 | * Fastify automatically handles validation with schemas in routes 12 | */ 13 | export const validateData = (schema: ZodSchema, data: unknown): T => { 14 | const result = schema.safeParse(data) 15 | if (!result.success) { 16 | throw new ValidationError(`Validation failed: ${result.error.message}`) 17 | } 18 | return result.data 19 | } 20 | 21 | /** 22 | * Zod error extractor for more user-friendly messages 23 | */ 24 | export const formatZodError = (error: ZodError): string => { 25 | if (error?.issues) { 26 | return error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join(', ') 27 | } 28 | return error.message || 'Validation error' 29 | } 30 | -------------------------------------------------------------------------------- /app/src/redux/store.ts: -------------------------------------------------------------------------------- 1 | /* Core */ 2 | import { type Action, type ConfigureStoreOptions, configureStore, type ThunkAction } from '@reduxjs/toolkit' 3 | import { 4 | type TypedUseSelectorHook, 5 | useDispatch as useReduxDispatch, 6 | useSelector as useReduxSelector 7 | } from 'react-redux' 8 | 9 | /* Instruments */ 10 | import { reducer } from './rootReducer' 11 | 12 | const configreStoreDefaultOptions: ConfigureStoreOptions = { reducer } 13 | 14 | export const makeReduxStore = (options: ConfigureStoreOptions = configreStoreDefaultOptions) => { 15 | const store = configureStore(options) 16 | 17 | return store 18 | } 19 | 20 | export const reduxStore = configureStore({ 21 | reducer 22 | }) 23 | export const useDispatch = () => useReduxDispatch() 24 | export const useSelector: TypedUseSelectorHook = useReduxSelector 25 | 26 | /* Types */ 27 | export type ReduxStore = typeof reduxStore 28 | export type ReduxState = ReturnType 29 | export type ReduxDispatch = typeof reduxStore.dispatch 30 | export type ReduxThunkAction = ThunkAction 31 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "main": "app.js", 5 | "license": "ISC", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "tsx watch app.ts", 9 | "generate": "drizzle-kit generate", 10 | "migrate": "drizzle-kit migrate", 11 | "check": "npx @biomejs/biome check . --write", 12 | "format": "npx @biomejs/biome format . --write", 13 | "lint": "npx @biomejs/biome lint ." 14 | }, 15 | "dependencies": { 16 | "@elastic/ecs-pino-format": "^1.5.0", 17 | "@fastify/swagger": "^9.6.1", 18 | "@scalar/fastify-api-reference": "^1.40.5", 19 | "bcrypt": "^6.0.0", 20 | "better-auth": "^1.4.7", 21 | "dayjs": "^1.11.19", 22 | "drizzle-orm": "^0.45.1", 23 | "fastify": "^5.6.2", 24 | "iovalkey": "^0.3.3", 25 | "pg": "^8.16.3", 26 | "pino": "^10.1.0", 27 | "pino-elasticsearch": "^8.1.0", 28 | "zod": "^4.1.13" 29 | }, 30 | "devDependencies": { 31 | "@biomejs/biome": "^2.3.8", 32 | "@types/bcrypt": "^6.0.0", 33 | "@types/node": "^25.0.2", 34 | "@types/pg": "^8.16.0", 35 | "drizzle-kit": "^0.31.8", 36 | "pino-pretty": "^13.1.3", 37 | "tsx": "^4.21.0", 38 | "typescript": "^5.9.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import localFont from 'next/font/local' 3 | import { NextIntlClientProvider } from 'next-intl' 4 | import { getLocale, getMessages } from 'next-intl/server' 5 | import type React from 'react' 6 | import Providers from './providers' 7 | import '../styles/globals.css' 8 | 9 | const geistSans = localFont({ 10 | src: './fonts/GeistVF.woff', 11 | variable: '--font-geist-sans', 12 | weight: '100 900' 13 | }) 14 | 15 | const geistMono = localFont({ 16 | src: './fonts/GeistMonoVF.woff', 17 | variable: '--font-geist-mono', 18 | weight: '100 900' 19 | }) 20 | 21 | export const metadata: Metadata = { 22 | title: 'Create Next App', 23 | description: 'Generated by create next app' 24 | } 25 | 26 | export default async function RootLayout({ children }: { children: React.ReactNode }) { 27 | const locale = await getLocale() 28 | 29 | // Providing all messages to the client 30 | // side is the easiest way to get started 31 | const messages = await getMessages() 32 | 33 | return ( 34 | 35 | 36 | 37 | {children} 38 | 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /api/utils/db.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from 'better-auth' 2 | import { drizzleAdapter } from 'better-auth/adapters/drizzle' 3 | import { bearer } from 'better-auth/plugins' 4 | import { drizzle } from 'drizzle-orm/node-postgres' 5 | import { account, session, user, verification } from './schemas/database' 6 | 7 | // Database connection 8 | const url = `postgres://${process.env.DB_USER}:${process.env.DB_PASS}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}` 9 | const db = drizzle(url, { 10 | schema: { 11 | user, 12 | session, 13 | account, 14 | verification 15 | } 16 | }) 17 | 18 | // Better-Auth configuration 19 | export const auth = betterAuth({ 20 | database: drizzleAdapter(db, { 21 | provider: 'pg', 22 | schema: { 23 | user, 24 | session, 25 | account, 26 | verification 27 | } 28 | }), 29 | plugins: [bearer()], 30 | emailAndPassword: { 31 | enabled: true, 32 | requireEmailVerification: false 33 | }, 34 | session: { 35 | expiresIn: 60 * 60 * 24 * 7, // 7 days (matches current refresh token) 36 | updateAge: 60 * 60 * 24 // 1 day 37 | }, 38 | secret: process.env.JWT_SECRET || 'superdupersecret', 39 | baseURL: process.env.BASE_URL || 'http://localhost', 40 | advanced: { 41 | useSecureCookies: process.env.ENV === 'production', 42 | crossSubDomainCookies: { 43 | enabled: false 44 | } 45 | } 46 | }) 47 | 48 | export default db 49 | -------------------------------------------------------------------------------- /api/utils/appError.ts: -------------------------------------------------------------------------------- 1 | // ============================================================================= 2 | // CUSTOM ERROR CLASSES 3 | // ============================================================================= 4 | 5 | export class AppError extends Error { 6 | public readonly statusCode: number 7 | public readonly isOperational: boolean 8 | 9 | constructor(message: string, statusCode = 500, isOperational = true) { 10 | super(message) 11 | Object.setPrototypeOf(this, new.target.prototype) 12 | 13 | this.statusCode = statusCode 14 | this.isOperational = isOperational 15 | 16 | Error.captureStackTrace(this) 17 | } 18 | } 19 | 20 | export class ValidationError extends AppError { 21 | constructor(message = 'Validation failed') { 22 | super(message, 400) 23 | } 24 | } 25 | 26 | export class AuthenticationError extends AppError { 27 | constructor(message = 'Authentication failed') { 28 | super(message, 401) 29 | } 30 | } 31 | 32 | export class AuthorizationError extends AppError { 33 | constructor(message = 'Insufficient permissions') { 34 | super(message, 403) 35 | } 36 | } 37 | 38 | export class NotFoundError extends AppError { 39 | constructor(message = 'Resource not found') { 40 | super(message, 404) 41 | } 42 | } 43 | 44 | export class ConflictError extends AppError { 45 | constructor(message = 'Conflict error') { 46 | // Conflict (409) - e.g. email already exists 47 | super(message, 409) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/redux/slices/counterSlice/thunks.ts: -------------------------------------------------------------------------------- 1 | /* Instruments */ 2 | import type { ReduxThunkAction } from '../../../redux' 3 | import { createAppAsyncThunk } from '../../../redux/createAppAsyncThunk' 4 | import { counterSlice } from './counterSlice' 5 | import { fetchIdentityCount } from './fetchIdentityCount' 6 | import { selectCount } from './selectors' 7 | 8 | // The function below is called a thunk and allows us to perform async logic. It 9 | // can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This 10 | // will call the thunk with the `dispatch` function as the first argument. Async 11 | // code can then be executed and other actions can be dispatched. Thunks are 12 | // typically used to make async requests. 13 | export const incrementAsync = createAppAsyncThunk('counter/fetchIdentityCount', async (amount: number) => { 14 | const response = await fetchIdentityCount(amount) 15 | // The value we return becomes the `fulfilled` action payload 16 | return response.amount 17 | }) 18 | 19 | // We can also write thunks by hand, which may contain both sync and async logic. 20 | // Here's an example of conditionally dispatching actions based on current state. 21 | export const incrementIfOddAsync = 22 | (amount: number): ReduxThunkAction => 23 | (dispatch, getState) => { 24 | const currentValue = selectCount(getState()) 25 | 26 | if (currentValue % 2 === 1) { 27 | dispatch(counterSlice.actions.incrementByAmount(amount)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/components/counter/Counter.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | import { counterSlice, incrementAsync, incrementIfOddAsync, selectCount } from '../../redux/slices/index' 5 | import { useDispatch, useSelector } from '../../redux/store' 6 | import { Button } from '../Button' 7 | import styles from './counter.module.css' 8 | 9 | function Counter() { 10 | const dispatch = useDispatch() 11 | const count = useSelector(selectCount) 12 | const [incrementAmount, setIncrementAmount] = useState(2) 13 | 14 | return ( 15 |
16 |
17 | 20 | 21 | {count} 22 | 23 | 26 |
27 |
28 | setIncrementAmount(Number(e.target.value ?? 0))} 33 | /> 34 | 35 | 36 | 37 |
38 |
39 | ) 40 | } 41 | 42 | export default Counter 43 | -------------------------------------------------------------------------------- /app/src/redux/slices/counterSlice/counterSlice.ts: -------------------------------------------------------------------------------- 1 | /* Core */ 2 | import { createSlice, type PayloadAction } from '@reduxjs/toolkit' 3 | 4 | /* Instruments */ 5 | import { incrementAsync } from './thunks' 6 | 7 | const initialState: CounterSliceState = { 8 | value: 0, 9 | status: 'idle' 10 | } 11 | 12 | export const counterSlice = createSlice({ 13 | name: 'counter', 14 | initialState, 15 | // The `reducers` field lets us define reducers and generate associated actions 16 | reducers: { 17 | increment: (state) => { 18 | // Redux Toolkit allows us to write "mutating" logic in reducers. It 19 | // doesn't actually mutate the state because it uses the Immer library, 20 | // which detects changes to a "draft state" and produces a brand new 21 | // immutable state based off those changes 22 | state.value += 1 23 | }, 24 | decrement: (state) => { 25 | state.value -= 1 26 | }, 27 | // Use the PayloadAction type to declare the contents of `action.payload` 28 | incrementByAmount: (state, action: PayloadAction) => { 29 | state.value += action.payload 30 | } 31 | }, 32 | // The `extraReducers` field lets the slice handle actions defined elsewhere, 33 | // including actions generated by createAsyncThunk or in other slices. 34 | extraReducers: (builder) => { 35 | builder 36 | .addCase(incrementAsync.pending, (state) => { 37 | state.status = 'loading' 38 | }) 39 | .addCase(incrementAsync.fulfilled, (state, action) => { 40 | state.status = 'idle' 41 | state.value += action.payload 42 | }) 43 | } 44 | }) 45 | 46 | /* Types */ 47 | export interface CounterSliceState { 48 | value: number 49 | status: 'idle' | 'loading' | 'failed' 50 | } 51 | -------------------------------------------------------------------------------- /api/.s/migrations/0000_mute_mandarin.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "account" ( 2 | "id" text PRIMARY KEY NOT NULL, 3 | "account_id" text NOT NULL, 4 | "provider_id" text NOT NULL, 5 | "user_id" text NOT NULL, 6 | "access_token" text, 7 | "refresh_token" text, 8 | "id_token" text, 9 | "access_token_expires_at" timestamp, 10 | "refresh_token_expires_at" timestamp, 11 | "scope" text, 12 | "password" text, 13 | "created_at" timestamp NOT NULL, 14 | "updated_at" timestamp NOT NULL 15 | ); 16 | --> statement-breakpoint 17 | CREATE TABLE "session" ( 18 | "id" text PRIMARY KEY NOT NULL, 19 | "expires_at" timestamp NOT NULL, 20 | "token" text NOT NULL, 21 | "created_at" timestamp NOT NULL, 22 | "updated_at" timestamp NOT NULL, 23 | "ip_address" text, 24 | "user_agent" text, 25 | "user_id" text NOT NULL, 26 | CONSTRAINT "session_token_unique" UNIQUE("token") 27 | ); 28 | --> statement-breakpoint 29 | CREATE TABLE "users" ( 30 | "id" text PRIMARY KEY NOT NULL, 31 | "name" text, 32 | "email" text NOT NULL, 33 | "email_verified" boolean NOT NULL, 34 | "image" text, 35 | "created_at" timestamp NOT NULL, 36 | "updated_at" timestamp NOT NULL, 37 | CONSTRAINT "users_email_unique" UNIQUE("email") 38 | ); 39 | --> statement-breakpoint 40 | CREATE TABLE "verification" ( 41 | "id" text PRIMARY KEY NOT NULL, 42 | "identifier" text NOT NULL, 43 | "value" text NOT NULL, 44 | "expires_at" timestamp NOT NULL, 45 | "created_at" timestamp, 46 | "updated_at" timestamp 47 | ); 48 | --> statement-breakpoint 49 | ALTER TABLE "account" ADD CONSTRAINT "account_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 50 | ALTER TABLE "session" ADD CONSTRAINT "session_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | traefik: 3 | image: "traefik:3.6.2" 4 | container_name: traefik 5 | command: 6 | - "--api.insecure=true" 7 | - "--providers.docker=true" 8 | - "--entrypoints.web.address=:80" 9 | - "--entrypoints.websecure.address=:443" 10 | ports: 11 | - "80:80" 12 | - "443:443" 13 | - "8080:8080" 14 | volumes: 15 | - "/var/run/docker.sock:/var/run/docker.sock" 16 | - "./letsencrypt:/letsencrypt" 17 | depends_on: 18 | - app 19 | - api 20 | 21 | app: 22 | build: 23 | context: ./app 24 | target: "image-${ENV}" 25 | args: 26 | NEXT_PUBLIC_ENVIRONMENT: "${ENV}" 27 | image: app 28 | container_name: app 29 | labels: 30 | - traefik.enable=true 31 | - traefik.http.routers.app.rule=PathPrefix(`/`) 32 | - traefik.http.services.app.loadbalancer.server.port=3000 33 | environment: 34 | TZ: Europe/Rome 35 | NEXT_PUBLIC_ENVIRONMENT: "${ENV}" 36 | restart: always 37 | 38 | api: 39 | build: 40 | context: ./api 41 | image: api 42 | container_name: api 43 | labels: 44 | - traefik.enable=true 45 | - traefik.http.routers.api.rule=PathPrefix(`/api`) 46 | - traefik.http.routers.reference.rule=PathPrefix(`/reference`) 47 | - traefik.http.services.api.loadbalancer.server.port=5000 48 | environment: 49 | ENV: "${ENV}" 50 | DB_HOST: "${DB_HOST}" 51 | DB_USER: "${DB_USER}" 52 | DB_PASS: "${DB_PASS}" 53 | DB_NAME: "${DB_NAME}" 54 | DB_PORT: "${DB_PORT}" 55 | VALKEY_HOST: "${VALKEY_HOST}" 56 | VALKEY_PASS: "${VALKEY_PASS}" 57 | VALKEY_PORT: "${VALKEY_PORT}" 58 | ELASTICSEARCH_HOST: "${ELASTICSEARCH_HOST}" 59 | ELASTICSEARCH_PORT: "${ELASTICSEARCH_PORT}" 60 | JWT_SECRET: "${JWT_SECRET}" 61 | restart: always 62 | -------------------------------------------------------------------------------- /app/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import { getTranslations } from 'next-intl/server' 3 | import Counter from '../components/counter/Counter' 4 | import styles from '../styles/Home.module.css' 5 | 6 | async function fetchServerData() { 7 | try { 8 | const res = await fetch('http://traefik/api/healthcheck/ping', { 9 | cache: 'no-store' 10 | }) // Use traefik for server call, localhost if you find on client side 11 | 12 | if (!res.ok) { 13 | console.error(`Failed to fetch server data: ${res.status} ${res.statusText}`) 14 | return null 15 | } 16 | 17 | const contentType = res.headers.get('content-type') 18 | if (!contentType?.includes('application/json')) { 19 | const text = await res.text() 20 | console.error(`Expected JSON but got: ${text}`) 21 | return null 22 | } 23 | 24 | const data = await res.json() 25 | return data 26 | } catch (error) { 27 | console.error('Failed to fetch server data:', error) 28 | return null 29 | } 30 | } 31 | 32 | export default async function HomePage() { 33 | const t = await getTranslations('HomePage') 34 | const data = await fetchServerData() 35 | if (data) { 36 | console.log('server', data) 37 | } 38 | 39 | return ( 40 |
41 |
42 |

43 | {t('title')} Next.js! 44 |

45 | 46 |
47 | 48 | 60 |
61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /api/app.ts: -------------------------------------------------------------------------------- 1 | import swagger from '@fastify/swagger' 2 | import scalar from '@scalar/fastify-api-reference' 3 | import Fastify from 'fastify' 4 | import errorHandlerPlugin from './middleware/errorHandler' 5 | import betterAuthRoutes from './routes/betterAuthRoutes' 6 | import testRoutes from './routes/testRoutes' 7 | import userRoutes from './routes/userRoutes' 8 | import { API_DOCS, SECURITY_DEFINITIONS, SERVER } from './utils/constants' 9 | import { logShutdown, logStartup } from './utils/logger' 10 | 11 | const app = Fastify({ logger: true }) 12 | 13 | app.register(errorHandlerPlugin) 14 | 15 | app.register(swagger, { 16 | swagger: { 17 | info: { 18 | title: API_DOCS.TITLE, 19 | description: API_DOCS.DESCRIPTION, 20 | version: API_DOCS.VERSION 21 | }, 22 | host: SERVER.LOCALHOST, // Update as needed for your environment 23 | schemes: [...API_DOCS.SCHEMES], 24 | consumes: [API_DOCS.CONSUMES], 25 | produces: [API_DOCS.PRODUCES], 26 | securityDefinitions: { 27 | bearerAuth: { 28 | type: SECURITY_DEFINITIONS.BEARER_AUTH.TYPE, 29 | name: SECURITY_DEFINITIONS.BEARER_AUTH.NAME, 30 | in: SECURITY_DEFINITIONS.BEARER_AUTH.IN, 31 | description: SECURITY_DEFINITIONS.BEARER_AUTH.DESCRIPTION 32 | } 33 | } 34 | } 35 | }) 36 | 37 | app.register(scalar, { 38 | routePrefix: '/reference', 39 | configuration: { 40 | theme: 'fastify' 41 | } 42 | }) 43 | 44 | app.register(testRoutes) 45 | app.register(betterAuthRoutes) 46 | app.register(userRoutes) 47 | 48 | const start = async () => { 49 | try { 50 | await app.listen({ port: SERVER.PORT, host: SERVER.HOST }) 51 | logStartup(SERVER.PORT, SERVER.HOST) 52 | } catch (err) { 53 | app.log.error(err) 54 | logShutdown('Error during startup') 55 | process.exit(1) 56 | } 57 | } 58 | 59 | // Graceful shutdown handling 60 | process.on('SIGTERM', async () => { 61 | logShutdown('SIGTERM received') 62 | await app.close() 63 | process.exit(0) 64 | }) 65 | 66 | process.on('SIGINT', async () => { 67 | logShutdown('SIGINT received') 68 | await app.close() 69 | process.exit(0) 70 | }) 71 | 72 | start() 73 | 74 | export default app 75 | -------------------------------------------------------------------------------- /app/src/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | min-height: 100vh; 7 | padding: 4rem 0; 8 | flex: 1; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .footer { 16 | display: flex; 17 | flex: 1; 18 | padding: 2rem 0; 19 | border-top: 1px solid #eaeaea; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | 24 | .footer a { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | flex-grow: 1; 29 | } 30 | 31 | .title a { 32 | color: #0070f3; 33 | text-decoration: none; 34 | } 35 | 36 | .title a:hover, 37 | .title a:focus, 38 | .title a:active { 39 | text-decoration: underline; 40 | } 41 | 42 | .title { 43 | margin: 0; 44 | line-height: 1.15; 45 | font-size: 4rem; 46 | } 47 | 48 | .title, 49 | .description { 50 | text-align: center; 51 | } 52 | 53 | .description { 54 | margin: 4rem 0; 55 | line-height: 1.5; 56 | font-size: 1.5rem; 57 | } 58 | 59 | .code { 60 | background: #fafafa; 61 | border-radius: 5px; 62 | padding: 0.75rem; 63 | font-size: 1.1rem; 64 | } 65 | 66 | .grid { 67 | display: flex; 68 | align-items: center; 69 | justify-content: center; 70 | flex-wrap: wrap; 71 | max-width: 800px; 72 | } 73 | 74 | .card { 75 | margin: 1rem; 76 | padding: 1.5rem; 77 | text-align: left; 78 | color: inherit; 79 | text-decoration: none; 80 | border: 1px solid #eaeaea; 81 | border-radius: 10px; 82 | transition: 83 | color 0.15s ease, 84 | border-color 0.15s ease; 85 | max-width: 300px; 86 | } 87 | 88 | .card:hover, 89 | .card:focus, 90 | .card:active { 91 | color: #0070f3; 92 | border-color: #0070f3; 93 | } 94 | 95 | .card h2 { 96 | margin: 0 0 1rem 0; 97 | font-size: 1.5rem; 98 | } 99 | 100 | .card p { 101 | margin: 0; 102 | font-size: 1.25rem; 103 | line-height: 1.5; 104 | } 105 | 106 | .logo { 107 | height: 1em; 108 | margin-left: 0.5rem; 109 | } 110 | 111 | @media (max-width: 600px) { 112 | .grid { 113 | width: 100%; 114 | flex-direction: column; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /app/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from '@radix-ui/react-slot' 2 | import { cva, type VariantProps } from 'class-variance-authority' 3 | import type * as React from 'react' 4 | 5 | import { cn } from '@/utils/utils' 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', 13 | destructive: 14 | 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', 15 | outline: 16 | 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', 17 | secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', 18 | ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', 19 | link: 'text-primary underline-offset-4 hover:underline' 20 | }, 21 | size: { 22 | default: 'h-9 px-4 py-2 has-[>svg]:px-3', 23 | sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', 24 | lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', 25 | icon: 'size-9' 26 | } 27 | }, 28 | defaultVariants: { 29 | variant: 'default', 30 | size: 'default' 31 | } 32 | } 33 | ) 34 | 35 | function Button({ 36 | className, 37 | variant, 38 | size, 39 | asChild = false, 40 | ...props 41 | }: React.ComponentProps<'button'> & 42 | VariantProps & { 43 | asChild?: boolean 44 | }) { 45 | const Comp = asChild ? Slot : 'button' 46 | 47 | return 48 | } 49 | 50 | export { Button, buttonVariants } 51 | -------------------------------------------------------------------------------- /compose_override/development.yaml: -------------------------------------------------------------------------------- 1 | volumes: 2 | pgdata: null 3 | valkeydata: null 4 | esdata: null 5 | 6 | services: 7 | app: 8 | develop: 9 | watch: 10 | - action: sync 11 | path: ./app 12 | target: /home/node/app 13 | ignore: 14 | - ./app/node_modules/ 15 | - action: rebuild 16 | path: ./app/package.json 17 | volumes: 18 | - /home/node/app/node_modules 19 | - /home/node/app/.next 20 | restart: unless-stopped 21 | depends_on: 22 | - postgres_db 23 | 24 | api: 25 | develop: 26 | watch: 27 | - action: sync 28 | path: ./api 29 | target: /home/node/api 30 | ignore: 31 | - ./api/node_modules/ 32 | - action: rebuild 33 | path: ./api/package.json 34 | volumes: 35 | - /home/node/api/node_modules 36 | restart: always 37 | depends_on: 38 | - postgres_db 39 | 40 | postgres_db: 41 | image: "postgres:18" 42 | container_name: postgres_db 43 | environment: 44 | POSTGRES_USER: "${DB_USER}" 45 | POSTGRES_PASSWORD: "${DB_PASS}" 46 | POSTGRES_DB: "${DB_NAME}" 47 | ports: 48 | - "5432:5432" 49 | volumes: 50 | - "pgdata:/var/lib/postgresql" 51 | 52 | valkey: 53 | image: "valkey/valkey:9" 54 | container_name: valkey 55 | command: valkey-server --port 6379 --cluster-enabled no --requirepass ${VALKEY_PASS} 56 | restart: unless-stopped 57 | ports: 58 | - "6379:6379" 59 | volumes: 60 | - "valkeydata:/data" 61 | 62 | elasticsearch: 63 | image: "docker.elastic.co/elasticsearch/elasticsearch:9.2.0" 64 | container_name: elasticsearch 65 | environment: 66 | - discovery.type=single-node 67 | - xpack.security.enabled=false 68 | - ES_JAVA_OPTS=-Xms512m -Xmx512m 69 | ports: 70 | - "9200:9200" 71 | volumes: 72 | - "esdata:/usr/share/elasticsearch/data" 73 | 74 | kibana: 75 | image: "docker.elastic.co/kibana/kibana:9.2.0" 76 | container_name: kibana 77 | environment: 78 | - "ELASTICSEARCH_HOSTS=http://${ELASTICSEARCH_HOST}:${ELASTICSEARCH_PORT}" 79 | - xpack.security.enabled=false 80 | ports: 81 | - "5601:5601" 82 | depends_on: 83 | - elasticsearch 84 | -------------------------------------------------------------------------------- /api/routes/testRoutes.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' 2 | import { CACHE_KEYS, ERROR_MESSAGES, TIMEOUTS } from '../utils/constants' 3 | import logger from '../utils/logger' 4 | import { healthcheckResponseSchema, identityCountBodySchema, identityCountResponseSchema } from '../utils/schemas' 5 | import { createRouteSchema } from '../utils/schemas/schemaConverter' 6 | import { validateData } from '../utils/validation' 7 | import valkey from '../utils/valkey' 8 | 9 | // ============================================================================= 10 | // UTILITY FUNCTIONS 11 | // ============================================================================= 12 | 13 | /** 14 | * Sleep utility for simulating delays 15 | */ 16 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 17 | 18 | // ============================================================================= 19 | // TEST ROUTES WITH UNIFIED ZOD SYSTEM 20 | // Zod schemas are the single source of truth for both validation and docs 21 | // ============================================================================= 22 | 23 | async function testRoutes(fastify: FastifyInstance) { 24 | // GET /api/healthcheck/ping - Basic health check with cache test 25 | fastify.get('/api/healthcheck/ping', { 26 | schema: createRouteSchema({ 27 | description: 'Test Fastify server and cache connectivity', 28 | tags: ['Test'], 29 | response: { 30 | 200: healthcheckResponseSchema 31 | } 32 | }), 33 | handler: async (_request: FastifyRequest, reply: FastifyReply) => { 34 | const data = await valkey.get(CACHE_KEYS.TEST_KEY) 35 | if (!data) { 36 | await valkey.set(CACHE_KEYS.TEST_KEY, CACHE_KEYS.TEST_VALUE) 37 | logger.info('ping') 38 | } 39 | const response = { success: true, message: ERROR_MESSAGES.SUCCESS_RESPONSE_MESSAGE } 40 | return reply.send(response) 41 | } 42 | }) 43 | 44 | // POST /api/identity-count - Test Redux with delay simulation 45 | fastify.post('/api/identity-count', { 46 | schema: createRouteSchema({ 47 | description: 'Test Redux state management with simulated delay', 48 | tags: ['Test'], 49 | body: identityCountBodySchema, 50 | response: { 51 | 200: identityCountResponseSchema 52 | } 53 | }), 54 | handler: async (request: FastifyRequest, reply: FastifyReply) => { 55 | // Validate request body - same schema used for docs! 56 | const { amount } = validateData(identityCountBodySchema, request.body) 57 | 58 | // Simulate processing delay 59 | await sleep(TIMEOUTS.IDENTITY_COUNT_DELAY) 60 | 61 | const response = { success: true, amount } 62 | return reply.send(response) 63 | } 64 | }) 65 | } 66 | 67 | export default testRoutes 68 | -------------------------------------------------------------------------------- /api/utils/schemas/database.ts: -------------------------------------------------------------------------------- 1 | import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core' 2 | 3 | // ============================================================================= 4 | // DATABASE SCHEMA DEFINITIONS 5 | // All Drizzle table definitions centralized here 6 | // Single source of truth for database structure 7 | // ============================================================================= 8 | 9 | /** 10 | * Users table - Main user entity 11 | */ 12 | export const user = pgTable('users', { 13 | id: text('id').primaryKey(), 14 | name: text('name'), 15 | email: text('email').notNull().unique(), 16 | emailVerified: boolean('email_verified') 17 | .$defaultFn(() => false) 18 | .notNull(), 19 | image: text('image'), 20 | createdAt: timestamp('created_at') 21 | .$defaultFn(() => new Date()) 22 | .notNull(), 23 | updatedAt: timestamp('updated_at') 24 | .$defaultFn(() => new Date()) 25 | .notNull() 26 | }) 27 | 28 | /** 29 | * Sessions table - User authentication sessions 30 | */ 31 | export const session = pgTable('session', { 32 | id: text('id').primaryKey(), 33 | expiresAt: timestamp('expires_at').notNull(), 34 | token: text('token').notNull().unique(), 35 | createdAt: timestamp('created_at').notNull(), 36 | updatedAt: timestamp('updated_at').notNull(), 37 | ipAddress: text('ip_address'), 38 | userAgent: text('user_agent'), 39 | userId: text('user_id') 40 | .notNull() 41 | .references(() => user.id, { onDelete: 'cascade' }) 42 | }) 43 | 44 | /** 45 | * Accounts table - OAuth provider accounts 46 | */ 47 | export const account = pgTable('account', { 48 | id: text('id').primaryKey(), 49 | accountId: text('account_id').notNull(), 50 | providerId: text('provider_id').notNull(), 51 | userId: text('user_id') 52 | .notNull() 53 | .references(() => user.id, { onDelete: 'cascade' }), 54 | accessToken: text('access_token'), 55 | refreshToken: text('refresh_token'), 56 | idToken: text('id_token'), 57 | accessTokenExpiresAt: timestamp('access_token_expires_at'), 58 | refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), 59 | scope: text('scope'), 60 | password: text('password'), 61 | createdAt: timestamp('created_at').notNull(), 62 | updatedAt: timestamp('updated_at').notNull() 63 | }) 64 | 65 | /** 66 | * Verification table - Email/phone verification codes 67 | */ 68 | export const verification = pgTable('verification', { 69 | id: text('id').primaryKey(), 70 | identifier: text('identifier').notNull(), 71 | value: text('value').notNull(), 72 | expiresAt: timestamp('expires_at').notNull(), 73 | createdAt: timestamp('created_at').$defaultFn(() => new Date()), 74 | updatedAt: timestamp('updated_at').$defaultFn(() => new Date()) 75 | }) 76 | 77 | // ============================================================================= 78 | // SCHEMA COLLECTIONS 79 | // ============================================================================= 80 | 81 | /** 82 | * All database schemas for easy export and Drizzle config 83 | */ 84 | export const databaseSchemas = { 85 | user, 86 | session, 87 | account, 88 | verification 89 | } as const 90 | 91 | /** 92 | * Default export for convenience 93 | */ 94 | export default databaseSchemas 95 | -------------------------------------------------------------------------------- /api/routes/userRoutes.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' 2 | import { betterAuthMiddleware } from '../middleware/betterAuth' 3 | import { createUser, findUserById } from '../services' 4 | import { NotFoundError } from '../utils/appError' 5 | import { ERROR_MESSAGES } from '../utils/constants' 6 | import { 7 | type CreateUserBody, 8 | createUserBodySchema, 9 | createUserResponseSchema, 10 | getUserResponseSchema, 11 | type SafeUserApi, 12 | type User, 13 | type UserParams, 14 | userParamsSchema 15 | } from '../utils/schemas' 16 | import { createRouteSchema } from '../utils/schemas/schemaConverter' 17 | import { validateData } from '../utils/validation' 18 | 19 | // ============================================================================= 20 | // USER ROUTES WITH UNIFIED ZOD SYSTEM 21 | // Zod schemas are the single source of truth for both validation and docs 22 | // ============================================================================= 23 | 24 | async function userRoutes(fastify: FastifyInstance) { 25 | // GET /api/users/:id - Get user by ID 26 | fastify.get('/api/users/:id', { 27 | schema: createRouteSchema({ 28 | description: 'Get a user by ID', 29 | tags: ['User'], 30 | params: userParamsSchema, 31 | response: { 32 | 200: getUserResponseSchema 33 | }, 34 | security: [{ bearerAuth: [] }] 35 | }), 36 | preHandler: [betterAuthMiddleware], 37 | handler: async (request: FastifyRequest, reply: FastifyReply) => { 38 | // Validate request parameters - same schema used for docs! 39 | const { id }: UserParams = validateData(userParamsSchema, request.params) 40 | 41 | const user: User | undefined = await findUserById(id) 42 | if (!user) { 43 | throw new NotFoundError(ERROR_MESSAGES.USER_NOT_FOUND) 44 | } 45 | 46 | // Create safe user object without sensitive data 47 | const safeUser: SafeUserApi = { 48 | id: user.id, 49 | email: user.email, 50 | name: user.name, 51 | image: user.image, 52 | createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : user.createdAt 53 | } 54 | 55 | return reply.send({ success: true, data: safeUser }) 56 | } 57 | }) 58 | 59 | // POST /api/users - Create new user 60 | fastify.post('/api/users', { 61 | schema: createRouteSchema({ 62 | description: 'Create a new user', 63 | tags: ['User'], 64 | body: createUserBodySchema, 65 | response: { 66 | 200: createUserResponseSchema 67 | } 68 | }), 69 | handler: async (request: FastifyRequest, reply: FastifyReply) => { 70 | // Validate request body - same schema used for docs! 71 | const { name, email, password }: CreateUserBody = validateData(createUserBodySchema, request.body) 72 | 73 | const user: User = await createUser({ name, email, password }) 74 | 75 | // Create safe user object without sensitive data 76 | const safeUser: SafeUserApi = { 77 | id: user.id, 78 | email: user.email, 79 | name: user.name, 80 | image: user.image, 81 | createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : user.createdAt 82 | } 83 | 84 | return reply.send({ 85 | success: true, 86 | data: safeUser, 87 | message: 'User created successfully' 88 | }) 89 | } 90 | }) 91 | } 92 | 93 | export default userRoutes 94 | -------------------------------------------------------------------------------- /api/utils/schemas/schemaConverter.ts: -------------------------------------------------------------------------------- 1 | import type { ZodSchema } from 'zod' 2 | import * as z from 'zod' 3 | 4 | // ============================================================================= 5 | // UNIFIED SCHEMA SYSTEM - ZOD V4 NATIVE 6 | // Zod as single source of truth → auto-convert to JSON Schema for OpenAPI 7 | // Using Zod v4 native JSON Schema conversion (no external dependencies!) 8 | // ============================================================================= 9 | 10 | /** 11 | * Convert Zod schema to Fastify-compatible JSON Schema 12 | * This ensures documentation and validation are always in sync 13 | * Uses Zod v4 native z.toJSONSchema() method - zero dependencies! 14 | */ 15 | export const zodToFastifySchema = (schema: ZodSchema): object => { 16 | // Use Zod v4 native JSON Schema conversion 17 | const jsonSchema = z.toJSONSchema(schema) 18 | 19 | // Clean up the schema for Fastify compatibility 20 | const { $schema: _$schema, ...cleanSchema } = jsonSchema as Record 21 | 22 | return cleanSchema 23 | } 24 | 25 | /** 26 | * Create a route schema object from Zod schemas 27 | * This is the main function to use in routes 28 | * Zero external dependencies - pure Zod v4! 29 | */ 30 | export const createRouteSchema = (schemas: { 31 | body?: ZodSchema 32 | params?: ZodSchema 33 | querystring?: ZodSchema 34 | response?: { [statusCode: number]: ZodSchema } 35 | tags?: string[] 36 | description?: string 37 | summary?: string 38 | security?: Array<{ [key: string]: string[] }> 39 | }) => { 40 | const routeSchema: Record = {} 41 | 42 | // Convert each schema type using native Zod conversion 43 | if (schemas.body) { 44 | routeSchema.body = zodToFastifySchema(schemas.body) 45 | } 46 | 47 | if (schemas.params) { 48 | routeSchema.params = zodToFastifySchema(schemas.params) 49 | } 50 | 51 | if (schemas.querystring) { 52 | routeSchema.querystring = zodToFastifySchema(schemas.querystring) 53 | } 54 | 55 | if (schemas.response) { 56 | routeSchema.response = {} 57 | for (const [statusCode, schema] of Object.entries(schemas.response)) { 58 | ;(routeSchema.response as Record)[statusCode] = zodToFastifySchema(schema) 59 | } 60 | } 61 | 62 | // Add OpenAPI metadata 63 | if (schemas.tags) { 64 | routeSchema.tags = schemas.tags 65 | } 66 | if (schemas.description) { 67 | routeSchema.description = schemas.description 68 | } 69 | if (schemas.summary) { 70 | routeSchema.summary = schemas.summary 71 | } 72 | if (schemas.security) { 73 | routeSchema.security = schemas.security 74 | } 75 | 76 | return routeSchema 77 | } 78 | 79 | /** 80 | * Type-safe route schema creation with IntelliSense 81 | * Pure Zod v4 implementation with zero external dependencies 82 | */ 83 | export const createTypedRouteSchema = < 84 | TBody extends ZodSchema | undefined = undefined, 85 | TParams extends ZodSchema | undefined = undefined, 86 | TQuery extends ZodSchema | undefined = undefined, 87 | TResponse extends Record | undefined = undefined 88 | >(schemas: { 89 | body?: TBody 90 | params?: TParams 91 | querystring?: TQuery 92 | response?: TResponse 93 | tags?: string[] 94 | description?: string 95 | summary?: string 96 | security?: Array<{ [key: string]: string[] }> 97 | }) => { 98 | return createRouteSchema(schemas) 99 | } 100 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | # Colors for output 6 | RED='\033[0;31m' 7 | GREEN='\033[0;32m' 8 | YELLOW='\033[1;33m' 9 | BLUE='\033[0;34m' 10 | NC='\033[0m' # No Color 11 | 12 | # Default values 13 | ENVIRONMENT="development" 14 | WATCH_MODE=false 15 | CLEAN_BUILD=false 16 | 17 | # Function to display help 18 | show_help() { 19 | echo -e "${BLUE}Docker Compose Starter Script${NC}" 20 | echo "" 21 | echo "Usage: $0 [OPTIONS]" 22 | echo "" 23 | echo "Options:" 24 | echo " -e, --env ENVIRONMENT Set environment (development|production) [default: development]" 25 | echo " -w, --watch Enable watch mode for development" 26 | echo " -c, --clean Clean build (remove existing containers and images)" 27 | echo " -h, --help Show this help message" 28 | echo "" 29 | echo "Examples:" 30 | echo " $0 # Start in development mode" 31 | echo " $0 -e production # Start in production mode" 32 | echo " $0 -w # Start in development with watch mode" 33 | echo " $0 -e production -c # Clean build in production mode" 34 | echo "" 35 | } 36 | 37 | # Function to validate environment 38 | validate_environment() { 39 | if [[ ! -f "compose_override/${ENVIRONMENT}.yaml" ]]; then 40 | echo -e "${RED}Error: compose_override/${ENVIRONMENT}.yaml not found!${NC}" 41 | echo -e "${YELLOW}Available environments:${NC}" 42 | ls compose_override/*.yaml 2>/dev/null | sed 's/compose_override\///g' | sed 's/\.yaml//g' | sed 's/^/ - /' 43 | exit 1 44 | fi 45 | } 46 | 47 | # Function to clean up existing containers and images 48 | clean_build() { 49 | echo -e "${YELLOW}Cleaning up existing containers and images...${NC}" 50 | docker compose $COMPOSE_FLAGS down --volumes --remove-orphans 51 | docker system prune -f 52 | echo -e "${GREEN}Cleanup completed!${NC}" 53 | } 54 | 55 | # Function to setup compose files 56 | setup_compose_files() { 57 | echo -e "${BLUE}Configuring compose files for ${ENVIRONMENT} environment...${NC}" 58 | 59 | # Set compose files array 60 | COMPOSE_FILES=("compose.yaml" "compose_override/${ENVIRONMENT}.yaml") 61 | 62 | # Build the -f flags 63 | COMPOSE_FLAGS="" 64 | for file in "${COMPOSE_FILES[@]}"; do 65 | COMPOSE_FLAGS="$COMPOSE_FLAGS -f $file" 66 | done 67 | 68 | echo -e "${GREEN}Using compose files: ${COMPOSE_FILES[*]}${NC}" 69 | } 70 | 71 | # Function to start services 72 | start_services() { 73 | echo -e "${BLUE}Starting services...${NC}" 74 | 75 | if [[ "$WATCH_MODE" == true && "$ENVIRONMENT" == "development" ]]; then 76 | echo -e "${YELLOW}Starting in watch mode for development...${NC}" 77 | docker compose $COMPOSE_FLAGS down && docker compose $COMPOSE_FLAGS watch 78 | else 79 | echo -e "${YELLOW}Starting with build...${NC}" 80 | docker compose $COMPOSE_FLAGS down && docker compose $COMPOSE_FLAGS up --build 81 | fi 82 | } 83 | 84 | # Parse command line arguments 85 | while [[ $# -gt 0 ]]; do 86 | case $1 in 87 | -e|--env) 88 | ENVIRONMENT="$2" 89 | shift 2 90 | ;; 91 | -w|--watch) 92 | WATCH_MODE=true 93 | shift 94 | ;; 95 | -c|--clean) 96 | CLEAN_BUILD=true 97 | shift 98 | ;; 99 | -h|--help) 100 | show_help 101 | exit 0 102 | ;; 103 | *) 104 | echo -e "${RED}Unknown option: $1${NC}" 105 | show_help 106 | exit 1 107 | ;; 108 | esac 109 | done 110 | 111 | # Main execution 112 | echo -e "${BLUE}=== Docker Compose Starter ===${NC}" 113 | echo -e "Environment: ${GREEN}${ENVIRONMENT}${NC}" 114 | echo -e "Watch mode: ${GREEN}${WATCH_MODE}${NC}" 115 | echo -e "Clean build: ${GREEN}${CLEAN_BUILD}${NC}" 116 | echo "" 117 | 118 | # Validate environment 119 | validate_environment 120 | 121 | # Setup compose files 122 | setup_compose_files 123 | 124 | # Clean build if requested 125 | if [[ "$CLEAN_BUILD" == true ]]; then 126 | clean_build 127 | fi 128 | 129 | # Start services 130 | start_services 131 | 132 | -------------------------------------------------------------------------------- /app/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "./tailwindcss-animate.css"; 3 | 4 | @variant dark (&:is(.dark *)); 5 | 6 | @theme { 7 | --radius-lg: var(--radius); 8 | --radius-md: calc(var(--radius) - 2px); 9 | --radius-sm: calc(var(--radius) - 4px); 10 | 11 | --color-background: hsl(var(--background)); 12 | --color-foreground: hsl(var(--foreground)); 13 | 14 | --color-card: hsl(var(--card)); 15 | --color-card-foreground: hsl(var(--card-foreground)); 16 | 17 | --color-popover: hsl(var(--popover)); 18 | --color-popover-foreground: hsl(var(--popover-foreground)); 19 | 20 | --color-primary: hsl(var(--primary)); 21 | --color-primary-foreground: hsl(var(--primary-foreground)); 22 | 23 | --color-secondary: hsl(var(--secondary)); 24 | --color-secondary-foreground: hsl(var(--secondary-foreground)); 25 | 26 | --color-muted: hsl(var(--muted)); 27 | --color-muted-foreground: hsl(var(--muted-foreground)); 28 | 29 | --color-accent: hsl(var(--accent)); 30 | --color-accent-foreground: hsl(var(--accent-foreground)); 31 | 32 | --color-destructive: hsl(var(--destructive)); 33 | --color-destructive-foreground: hsl(var(--destructive-foreground)); 34 | 35 | --color-border: hsl(var(--border)); 36 | --color-input: hsl(var(--input)); 37 | --color-ring: hsl(var(--ring)); 38 | 39 | --color-chart-1: hsl(var(--chart-1)); 40 | --color-chart-2: hsl(var(--chart-2)); 41 | --color-chart-3: hsl(var(--chart-3)); 42 | --color-chart-4: hsl(var(--chart-4)); 43 | --color-chart-5: hsl(var(--chart-5)); 44 | } 45 | 46 | /* 47 | The default border color has changed to `currentColor` in Tailwind CSS v4, 48 | so we've added these compatibility styles to make sure everything still 49 | looks the same as it did with Tailwind CSS v3. 50 | 51 | If we ever want to remove these styles, we need to add an explicit border 52 | color utility to any element that depends on these defaults. 53 | */ 54 | @layer base { 55 | *, 56 | ::after, 57 | ::before, 58 | ::backdrop, 59 | ::file-selector-button { 60 | border-color: var(--color-gray-200, currentColor); 61 | } 62 | } 63 | 64 | @layer utilities { 65 | html, 66 | body { 67 | padding: 0; 68 | margin: 0; 69 | } 70 | 71 | a { 72 | color: inherit; 73 | text-decoration: none; 74 | } 75 | 76 | * { 77 | box-sizing: border-box; 78 | } 79 | } 80 | 81 | @layer base { 82 | :root { 83 | --background: 0 0% 100%; 84 | --foreground: 240 10% 3.9%; 85 | --card: 0 0% 100%; 86 | --card-foreground: 240 10% 3.9%; 87 | --popover: 0 0% 100%; 88 | --popover-foreground: 240 10% 3.9%; 89 | --primary: 240 5.9% 10%; 90 | --primary-foreground: 0 0% 98%; 91 | --secondary: 240 4.8% 95.9%; 92 | --secondary-foreground: 240 5.9% 10%; 93 | --muted: 240 4.8% 95.9%; 94 | --muted-foreground: 240 3.8% 46.1%; 95 | --accent: 240 4.8% 95.9%; 96 | --accent-foreground: 240 5.9% 10%; 97 | --destructive: 0 84.2% 60.2%; 98 | --destructive-foreground: 0 0% 98%; 99 | --border: 240 5.9% 90%; 100 | --input: 240 5.9% 90%; 101 | --ring: 240 10% 3.9%; 102 | --chart-1: 12 76% 61%; 103 | --chart-2: 173 58% 39%; 104 | --chart-3: 197 37% 24%; 105 | --chart-4: 43 74% 66%; 106 | --chart-5: 27 87% 67%; 107 | --radius: 0.5rem; 108 | } 109 | .dark { 110 | --background: 240 10% 3.9%; 111 | --foreground: 0 0% 98%; 112 | --card: 240 10% 3.9%; 113 | --card-foreground: 0 0% 98%; 114 | --popover: 240 10% 3.9%; 115 | --popover-foreground: 0 0% 98%; 116 | --primary: 0 0% 98%; 117 | --primary-foreground: 240 5.9% 10%; 118 | --secondary: 240 3.7% 15.9%; 119 | --secondary-foreground: 0 0% 98%; 120 | --muted: 240 3.7% 15.9%; 121 | --muted-foreground: 240 5% 64.9%; 122 | --accent: 240 3.7% 15.9%; 123 | --accent-foreground: 0 0% 98%; 124 | --destructive: 0 62.8% 30.6%; 125 | --destructive-foreground: 0 0% 98%; 126 | --border: 240 3.7% 15.9%; 127 | --input: 240 3.7% 15.9%; 128 | --ring: 240 4.9% 83.9%; 129 | --chart-1: 220 70% 50%; 130 | --chart-2: 160 60% 45%; 131 | --chart-3: 30 80% 55%; 132 | --chart-4: 280 65% 60%; 133 | --chart-5: 340 75% 55%; 134 | } 135 | } 136 | 137 | @layer base { 138 | * { 139 | @apply border-border; 140 | } 141 | body { 142 | @apply bg-background text-foreground; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "includes": [ 10 | "**", 11 | "!!.next/**", 12 | "!!node_modules/**", 13 | "!tailwindcss-animate.css", 14 | "!!dist/**", 15 | "!!build/**", 16 | "!**/*.css", 17 | "!**/*.min.js", 18 | "!**/*.min.css" 19 | ] 20 | }, 21 | "assist": { 22 | "actions": { 23 | "source": { 24 | "organizeImports": "on" 25 | } 26 | } 27 | }, 28 | "linter": { 29 | "enabled": true, 30 | "rules": { 31 | "style": { 32 | "useLiteralEnumMembers": "error", 33 | "useNodejsImportProtocol": "error", 34 | "useAsConstAssertion": "error", 35 | "useEnumInitializers": "error", 36 | "useSelfClosingElements": "error", 37 | "useConst": "error", 38 | "useSingleVarDeclarator": "error", 39 | "noUnusedTemplateLiteral": "error", 40 | "useNumberNamespace": "error", 41 | "noInferrableTypes": "error", 42 | "useExponentiationOperator": "error", 43 | "useTemplate": "error", 44 | "noParameterAssign": "error", 45 | "noNonNullAssertion": "error", 46 | "useDefaultParameterLast": "error", 47 | "useImportType": "error", 48 | "useExportType": "error", 49 | "noUselessElse": "error", 50 | "useShorthandFunctionType": "error", 51 | "useBlockStatements": "error", 52 | "useConsistentArrayType": "error" 53 | }, 54 | "a11y": { 55 | "useKeyWithClickEvents": "off", 56 | "useKeyWithMouseEvents": "off", 57 | "useMediaCaption": "warn", 58 | "useHeadingContent": "warn", 59 | "useAltText": "warn", 60 | "useAriaPropsForRole": "warn", 61 | "useValidAriaProps": "warn", 62 | "useValidAriaValues": "warn", 63 | "useValidAnchor": "warn", 64 | "useValidLang": "warn" 65 | }, 66 | "correctness": { 67 | "noUndeclaredVariables": "error", 68 | "noUnusedImports": "error", 69 | "noUnreachable": "error", 70 | "noUnusedVariables": "error", 71 | "noUnusedPrivateClassMembers": "error", 72 | "noVoidElementsWithChildren": "error", 73 | "noVoidTypeReturn": "error", 74 | "useExhaustiveDependencies": "error", 75 | "useHookAtTopLevel": "error", 76 | "noUnusedLabels": "error" 77 | }, 78 | "nursery": { 79 | "useSortedClasses": "warn" 80 | }, 81 | "complexity": { 82 | "noExcessiveCognitiveComplexity": "warn", 83 | "noExcessiveNestedTestSuites": "warn" 84 | }, 85 | "suspicious": { 86 | "noArrayIndexKey": "warn", 87 | "noAssignInExpressions": "warn", 88 | "noCatchAssign": "warn", 89 | "noClassAssign": "warn", 90 | "noCommentText": "warn", 91 | "noCompareNegZero": "warn", 92 | "noConfusingLabels": "warn", 93 | "noControlCharactersInRegex": "warn", 94 | "noDebugger": "warn", 95 | "noDoubleEquals": "warn", 96 | "noDuplicateCase": "warn", 97 | "noDuplicateClassMembers": "warn", 98 | "noDuplicateJsxProps": "warn", 99 | "noDuplicateObjectKeys": "warn", 100 | "noEmptyInterface": "warn", 101 | "noExplicitAny": "warn", 102 | "noExtraNonNullAssertion": "warn", 103 | "noFunctionAssign": "warn", 104 | "noGlobalAssign": "warn", 105 | "noImportAssign": "warn", 106 | "noIrregularWhitespace": "warn", 107 | "noMisleadingCharacterClass": "warn", 108 | "noMisleadingInstantiator": "warn", 109 | "noMisrefactoredShorthandAssign": "warn", 110 | "noOctalEscape": "warn", 111 | "noRedundantUseStrict": "warn", 112 | "noSelfCompare": "warn", 113 | "noShadowRestrictedNames": "warn", 114 | "noSparseArray": "warn", 115 | "noUnsafeDeclarationMerging": "warn", 116 | "noUnsafeNegation": "warn", 117 | "noVar": "warn", 118 | "noWith": "warn", 119 | "useIsArray": "warn" 120 | }, 121 | "performance": { 122 | "noDelete": "warn" 123 | } 124 | } 125 | }, 126 | "formatter": { 127 | "enabled": true, 128 | "indentStyle": "space", 129 | "indentWidth": 2, 130 | "lineEnding": "lf", 131 | "lineWidth": 120 132 | }, 133 | "javascript": { 134 | "formatter": { 135 | "indentStyle": "space", 136 | "indentWidth": 2, 137 | "lineWidth": 120, 138 | "semicolons": "asNeeded", 139 | "quoteStyle": "single", 140 | "jsxQuoteStyle": "single", 141 | "trailingCommas": "none" 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /app/src/styles/tailwindcss-animate.css: -------------------------------------------------------------------------------- 1 | @theme inline { 2 | --animation-delay-0: 0s; 3 | --animation-delay-75: 75ms; 4 | --animation-delay-100: 0.1s; 5 | --animation-delay-150: 0.15s; 6 | --animation-delay-200: 0.2s; 7 | --animation-delay-300: 0.3s; 8 | --animation-delay-500: 0.5s; 9 | --animation-delay-700: 0.7s; 10 | --animation-delay-1000: 1s; 11 | 12 | --animation-repeat-0: 0; 13 | --animation-repeat-1: 1; 14 | --animation-repeat-infinite: infinite; 15 | 16 | --animation-direction-normal: normal; 17 | --animation-direction-reverse: reverse; 18 | --animation-direction-alternate: alternate; 19 | --animation-direction-alternate-reverse: alternate-reverse; 20 | 21 | --animation-fill-mode-none: none; 22 | --animation-fill-mode-forwards: forwards; 23 | --animation-fill-mode-backwards: backwards; 24 | --animation-fill-mode-both: both; 25 | 26 | --animate-in: var(--tw-duration, 150ms) var(--tw-ease, ease) enter; 27 | --animate-out: var(--tw-duration, 150ms) var(--tw-ease, ease) exit; 28 | 29 | --percentage-0: 0; 30 | --percentage-5: 0.05; 31 | --percentage-10: 0.1; 32 | --percentage-15: 0.15; 33 | --percentage-20: 0.2; 34 | --percentage-25: 0.25; 35 | --percentage-30: 0.3; 36 | --percentage-35: 0.35; 37 | --percentage-40: 0.4; 38 | --percentage-45: 0.45; 39 | --percentage-50: 0.5; 40 | --percentage-55: 0.55; 41 | --percentage-60: 0.6; 42 | --percentage-65: 0.65; 43 | --percentage-70: 0.7; 44 | --percentage-75: 0.75; 45 | --percentage-80: 0.8; 46 | --percentage-85: 0.85; 47 | --percentage-90: 0.9; 48 | --percentage-95: 0.95; 49 | --percentage-100: 1; 50 | 51 | @keyframes enter { 52 | from { 53 | opacity: var(--tw-enter-opacity, 1); 54 | transform: translate3d( 55 | var(--tw-enter-translate-x, 0), 56 | var(--tw-enter-translate-y, 0), 57 | 0 58 | ) 59 | scale3d( 60 | var(--tw-enter-scale, 1), 61 | var(--tw-enter-scale, 1), 62 | var(--tw-enter-scale, 1) 63 | ) 64 | rotate(var(--tw-enter-rotate, 0)); 65 | } 66 | } 67 | 68 | @keyframes exit { 69 | to { 70 | opacity: var(--tw-exit-opacity, 1); 71 | transform: translate3d( 72 | var(--tw-exit-translate-x, 0), 73 | var(--tw-exit-translate-y, 0), 74 | 0 75 | ) 76 | scale3d( 77 | var(--tw-exit-scale, 1), 78 | var(--tw-exit-scale, 1), 79 | var(--tw-exit-scale, 1) 80 | ) 81 | rotate(var(--tw-exit-rotate, 0)); 82 | } 83 | } 84 | } 85 | 86 | /* 87 | * Tailwind's default `duration` utility sets the `--tw-duration` variable, so 88 | * can set `animation-duration` directly in the animation definition in the 89 | * `@theme` section above. Same goes for the `animation-timing-function`, set 90 | * with `--tw-ease`. 91 | */ 92 | 93 | @utility delay-* { 94 | animation-delay: --value([duration]); 95 | animation-delay: calc(--value(integer) * 1ms); 96 | animation-delay: --value(--animation-delay- *); 97 | } 98 | 99 | @utility repeat-* { 100 | animation-iteration-count: --value(--animation-repeat- *, integer); 101 | } 102 | 103 | @utility direction-* { 104 | animation-direction: --value(--animation-direction- *); 105 | } 106 | 107 | @utility fill-mode-* { 108 | animation-fill-mode: --value(--animation-fill-mode- *); 109 | } 110 | 111 | @utility running { 112 | animation-play-state: running; 113 | } 114 | @utility paused { 115 | animation-play-state: paused; 116 | } 117 | 118 | @utility fade-in-* { 119 | --tw-enter-opacity: --value(--percentage- *); 120 | } 121 | @utility fade-out-* { 122 | --tw-exit-opacity: --value(--percentage- *); 123 | } 124 | 125 | @utility zoom-in-* { 126 | --tw-enter-scale: --value(--percentage- *); 127 | } 128 | @utility zoom-out-* { 129 | --tw-exit-scale: --value(--percentage- *); 130 | } 131 | 132 | @utility spin-in-* { 133 | --tw-enter-rotate: calc(--value(integer) * 1deg); 134 | --tw-enter-rotate: --value(--rotate- *, [angle]); 135 | } 136 | @utility spin-out-* { 137 | --tw-exit-rotate: calc(--value(integer) * 1deg); 138 | --tw-exit-rotate: --value(--rotate- *, [angle]); 139 | } 140 | 141 | @utility slide-in-from-top-* { 142 | --tw-enter-translate-y: calc(--value([percentage], [length]) * -1); 143 | } 144 | @utility slide-in-from-bottom-* { 145 | --tw-enter-translate-y: --value([percentage], [length]); 146 | } 147 | @utility slide-in-from-left-* { 148 | --tw-enter-translate-x: calc(--value([percentage], [length]) * -1); 149 | } 150 | @utility slide-in-from-right-* { 151 | --tw-enter-translate-x: --value([percentage], [length]); 152 | } 153 | 154 | @utility slide-out-to-top-* { 155 | --tw-exit-translate-y: calc(--value([percentage], [length]) * -1); 156 | } 157 | @utility slide-out-to-bottom-* { 158 | --tw-exit-translate-y: --value([percentage], [length]); 159 | } 160 | @utility slide-out-to-left-* { 161 | --tw-exit-translate-x: calc(--value([percentage], [length]) * -1); 162 | } 163 | @utility slide-out-to-right-* { 164 | --tw-exit-translate-x: --value([percentage], [length]); 165 | } 166 | -------------------------------------------------------------------------------- /api/services/userService.ts: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm' 2 | import { AppError, ConflictError, NotFoundError } from '../utils/appError' 3 | import { DB_QUERY_LIMITS, ERROR_MESSAGES, HTTP_STATUS } from '../utils/constants' 4 | import db, { auth } from '../utils/db' 5 | import type { CreateUserData, UpdateUserData, User } from '../utils/schemas' 6 | import { createUserBodySchema, emailSchema, user } from '../utils/schemas' 7 | import { validateData } from '../utils/validation' 8 | 9 | // ============================================================================= 10 | // USER SERVICE - DIRECT DRIZZLE ACCESS 11 | // Simplified service layer with direct database access 12 | // No more repository abstraction - cleaner and more performant 13 | // Error handling is done by middleware - services just throw errors 14 | // ============================================================================= 15 | 16 | /** 17 | * Create a new user via Better-Auth 18 | */ 19 | export async function createUser(data: CreateUserData): Promise { 20 | // Validate input data 21 | const validatedData = validateData(createUserBodySchema, data) 22 | 23 | // Check if user already exists 24 | const existingUser = await findUserByEmail(validatedData.email) 25 | if (existingUser) { 26 | throw new ConflictError(ERROR_MESSAGES.USER_ALREADY_EXISTS) 27 | } 28 | 29 | // Create user via Better-Auth API for secure password handling 30 | const result = await auth.api.signUpEmail({ 31 | body: { 32 | name: validatedData.name, 33 | email: validatedData.email, 34 | password: validatedData.password 35 | } 36 | }) 37 | 38 | if (!result.user) { 39 | throw new AppError(ERROR_MESSAGES.DATABASE_OPERATION_FAILED, HTTP_STATUS.INTERNAL_SERVER_ERROR) 40 | } 41 | 42 | // Return standardized User type 43 | return { 44 | id: result.user.id, 45 | name: result.user.name, 46 | email: result.user.email, 47 | emailVerified: result.user.emailVerified, 48 | image: result.user.image || null, 49 | createdAt: result.user.createdAt, 50 | updatedAt: result.user.updatedAt 51 | } 52 | } 53 | 54 | /** 55 | * Find user by ID 56 | */ 57 | export async function findUserById(id: string): Promise { 58 | if (!id?.trim()) { 59 | throw new AppError(ERROR_MESSAGES.INVALID_USER_ID, HTTP_STATUS.BAD_REQUEST) 60 | } 61 | 62 | const [foundUser] = await db.select().from(user).where(eq(user.id, id)).limit(DB_QUERY_LIMITS.SINGLE_RECORD) 63 | return foundUser 64 | } 65 | 66 | /** 67 | * Find user by email 68 | */ 69 | export async function findUserByEmail(email: string): Promise { 70 | // Validate email format 71 | const validatedEmail = validateData(emailSchema, email) 72 | 73 | const [foundUser] = await db 74 | .select() 75 | .from(user) 76 | .where(eq(user.email, validatedEmail)) 77 | .limit(DB_QUERY_LIMITS.SINGLE_RECORD) 78 | return foundUser 79 | } 80 | 81 | /** 82 | * Update user data (excluding password - use Better-Auth for that) 83 | */ 84 | export async function updateUser(id: string, data: Partial): Promise { 85 | if (!id?.trim()) { 86 | throw new AppError(ERROR_MESSAGES.INVALID_USER_ID, HTTP_STATUS.BAD_REQUEST) 87 | } 88 | 89 | // Check if user exists 90 | const existingUser = await findUserById(id) 91 | if (!existingUser) { 92 | throw new NotFoundError(ERROR_MESSAGES.USER_NOT_FOUND) 93 | } 94 | 95 | // Prepare update data (exclude password - handle via Better-Auth) 96 | const updateData: Partial = {} 97 | 98 | if (data.name !== undefined) { 99 | updateData.name = data.name 100 | } 101 | 102 | if (data.email !== undefined) { 103 | // Validate email if provided 104 | const validatedEmail = validateData(emailSchema, data.email) 105 | 106 | // Check email uniqueness 107 | const emailExists = await findUserByEmail(validatedEmail) 108 | if (emailExists && emailExists.id !== id) { 109 | throw new ConflictError(ERROR_MESSAGES.EMAIL_ALREADY_IN_USE) 110 | } 111 | 112 | updateData.email = validatedEmail 113 | } 114 | 115 | // Update user directly in database 116 | const [updatedUser] = await db 117 | .update(user) 118 | .set({ 119 | ...updateData, 120 | updatedAt: new Date() 121 | }) 122 | .where(eq(user.id, id)) 123 | .returning() 124 | 125 | if (!updatedUser) { 126 | throw new AppError(ERROR_MESSAGES.USER_UPDATE_FAILED, HTTP_STATUS.INTERNAL_SERVER_ERROR) 127 | } 128 | 129 | return updatedUser 130 | } 131 | 132 | /** 133 | * Delete user 134 | */ 135 | export async function deleteUser(id: string): Promise { 136 | if (!id?.trim()) { 137 | throw new AppError(ERROR_MESSAGES.INVALID_USER_ID, HTTP_STATUS.BAD_REQUEST) 138 | } 139 | 140 | // Check if user exists 141 | const existingUser = await findUserById(id) 142 | if (!existingUser) { 143 | throw new NotFoundError(ERROR_MESSAGES.USER_NOT_FOUND) 144 | } 145 | 146 | // Delete via database (Better-Auth handles cascade) 147 | const result = await db.delete(user).where(eq(user.id, id)) 148 | 149 | return result.rowCount !== null && result.rowCount > 0 150 | } 151 | 152 | /** 153 | * Get all users (with pagination) 154 | */ 155 | export async function findAllUsers(options: { limit?: number; offset?: number } = {}): Promise { 156 | const { limit = DB_QUERY_LIMITS.DEFAULT_PAGE_SIZE, offset = DB_QUERY_LIMITS.DEFAULT_OFFSET } = options 157 | 158 | const users = await db.select().from(user).limit(limit).offset(offset) 159 | return users 160 | } 161 | 162 | /** 163 | * Check if user exists by ID 164 | */ 165 | export async function userExists(id: string): Promise { 166 | const foundUser = await findUserById(id) 167 | return !!foundUser 168 | } 169 | -------------------------------------------------------------------------------- /api/middleware/betterAuth.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyReply, FastifyRequest } from 'fastify' 2 | import { AuthenticationError } from '../utils/appError' 3 | import { ERROR_MESSAGES } from '../utils/constants' 4 | import { auth } from '../utils/db' 5 | import logUtils from '../utils/logger' 6 | import type { AuthenticatedFastifyRequest, AuthenticatedUser, ExtendedFastifyRequest } from '../utils/schemas' 7 | 8 | // ============================================================================= 9 | // AUTHENTICATION MIDDLEWARE 10 | // ============================================================================= 11 | 12 | export const betterAuthMiddleware = async (request: FastifyRequest, _reply: FastifyReply) => { 13 | const startTime = Date.now() 14 | 15 | try { 16 | // Get session from better-auth with timeout 17 | const sessionPromise = auth.api.getSession({ 18 | headers: request.headers as unknown as Headers 19 | }) 20 | 21 | // Add timeout to prevent hanging requests 22 | const timeoutPromise = new Promise((_, reject) => { 23 | setTimeout(() => reject(new Error('Authentication timeout')), 5000) 24 | }) 25 | 26 | const session = (await Promise.race([sessionPromise, timeoutPromise])) as { 27 | user: { id: string; email: string; name?: string } 28 | } | null 29 | 30 | if (!session) { 31 | // Log failed authentication attempt 32 | logUtils.logAuth({ 33 | event: 'login', 34 | ip: request.ip, 35 | userAgent: request.headers['user-agent'], 36 | success: false, 37 | reason: 'No valid session found' 38 | }) 39 | 40 | throw new AuthenticationError(ERROR_MESSAGES.AUTHENTICATION_REQUIRED) 41 | } 42 | 43 | // Validate session data 44 | if (!session.user || !session.user.id || !session.user.email) { 45 | logUtils.logAuth({ 46 | event: 'login', 47 | ip: request.ip, 48 | userAgent: request.headers['user-agent'], 49 | success: false, 50 | reason: 'Invalid session data' 51 | }) 52 | 53 | throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION_DATA) 54 | } 55 | 56 | // Attach user to request 57 | const authenticatedRequest = request as AuthenticatedFastifyRequest 58 | authenticatedRequest.user = { 59 | id: session.user.id, 60 | email: session.user.email, 61 | name: session.user.name 62 | } as AuthenticatedUser 63 | 64 | // Log successful authentication 65 | logUtils.logAuth({ 66 | event: 'login', 67 | userId: session.user.id, 68 | email: session.user.email, 69 | ip: request.ip, 70 | userAgent: request.headers['user-agent'], 71 | success: true 72 | }) 73 | 74 | // Log performance metrics 75 | const duration = Date.now() - startTime 76 | logUtils.logPerformance({ 77 | operation: 'authentication', 78 | duration, 79 | requestId: (request as ExtendedFastifyRequest).requestId 80 | }) 81 | } catch (error) { 82 | // Log authentication failure 83 | logUtils.logAuth({ 84 | event: 'login', 85 | ip: request.ip, 86 | userAgent: request.headers['user-agent'], 87 | success: false, 88 | reason: error instanceof Error ? error.message : 'Unknown error' 89 | }) 90 | 91 | // Log security event for multiple failed attempts 92 | logUtils.logSecurity({ 93 | event: 'access_denied', 94 | ip: request.ip, 95 | userAgent: request.headers['user-agent'], 96 | details: 'Authentication failed', 97 | severity: 'medium' 98 | }) 99 | 100 | if (error instanceof AuthenticationError) { 101 | throw error 102 | } 103 | 104 | throw new AuthenticationError(ERROR_MESSAGES.AUTHENTICATION_FAILED_GENERIC) 105 | } 106 | } 107 | 108 | // ============================================================================= 109 | // OPTIONAL AUTHENTICATION MIDDLEWARE 110 | // ============================================================================= 111 | 112 | // Middleware for routes that can work with or without authentication 113 | export const optionalAuthMiddleware = async (request: FastifyRequest, reply: FastifyReply) => { 114 | try { 115 | await betterAuthMiddleware(request, reply) 116 | } catch (error) { 117 | // Don't throw error for optional auth, just continue without user 118 | if (error instanceof AuthenticationError) { 119 | // Log that optional auth was attempted but failed 120 | logUtils.logAuth({ 121 | event: 'login', 122 | ip: request.ip, 123 | userAgent: request.headers['user-agent'], 124 | success: false, 125 | reason: 'Optional authentication failed' 126 | }) 127 | } 128 | } 129 | } 130 | 131 | // ============================================================================= 132 | // ROLE-BASED AUTHORIZATION MIDDLEWARE 133 | // ============================================================================= 134 | 135 | export const requireRole = (requiredRole: 'admin' | 'user') => { 136 | return async (request: FastifyRequest, reply: FastifyReply) => { 137 | // First ensure user is authenticated 138 | await betterAuthMiddleware(request, reply) 139 | 140 | const authenticatedRequest = request as AuthenticatedFastifyRequest 141 | const user = authenticatedRequest.user 142 | 143 | // For now, we'll implement a simple role check 144 | // In a real application, you'd fetch user roles from database 145 | const userRole = user.email?.includes('admin') ? 'admin' : 'user' 146 | 147 | if (userRole !== requiredRole && requiredRole === 'admin') { 148 | logUtils.logSecurity({ 149 | event: 'access_denied', 150 | userId: user.id, 151 | ip: request.ip, 152 | userAgent: request.headers['user-agent'], 153 | details: `User attempted to access ${requiredRole} endpoint`, 154 | severity: 'high' 155 | }) 156 | 157 | throw new AuthenticationError(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS) 158 | } 159 | } 160 | } 161 | 162 | // ============================================================================= 163 | // EXPORTS 164 | // ============================================================================= 165 | 166 | export default betterAuthMiddleware 167 | -------------------------------------------------------------------------------- /api/middleware/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' 2 | import { ZodError } from 'zod' 3 | import { AppError } from '../utils/appError' 4 | import { BETTER_AUTH_ERROR_NAMES, ERROR_MESSAGES, HTTP_STATUS } from '../utils/constants' 5 | import type { ErrorResponse, ExtendedFastifyRequest, ValidationErrorDetail } from '../utils/schemas' 6 | 7 | // ============================================================================= 8 | // ERROR INTERFACES 9 | // ============================================================================= 10 | 11 | interface ErrorWithStatusCode extends Error { 12 | statusCode?: number 13 | details?: ValidationErrorDetail[] 14 | } 15 | 16 | // ============================================================================= 17 | // ERROR CONTEXT EXTRACTION 18 | // ============================================================================= 19 | 20 | export const extractErrorContext = (request: FastifyRequest) => { 21 | return { 22 | url: request.url, 23 | method: request.method, 24 | userId: (request as ExtendedFastifyRequest).user?.id || 'anonymous', 25 | ip: request.ip, 26 | userAgent: request.headers['user-agent'], 27 | timestamp: new Date().toISOString() 28 | } 29 | } 30 | 31 | export const logError = (error: Error, context: ReturnType, request: FastifyRequest) => { 32 | const requestInfo = { 33 | requestId: request.id, 34 | userId: (request as ExtendedFastifyRequest).user?.id || 'anonymous', 35 | userAgent: request.headers['user-agent'], 36 | ip: request.ip, 37 | startTime: Date.now() 38 | } 39 | 40 | request.log.error( 41 | { 42 | error: { 43 | name: error.name, 44 | message: error.message, 45 | stack: error.stack 46 | }, 47 | request: requestInfo, 48 | context 49 | }, 50 | 'Request failed with error' 51 | ) 52 | } 53 | 54 | // ============================================================================= 55 | // VALIDATION ERROR FORMATTING 56 | // ============================================================================= 57 | 58 | const formatValidationErrors = (error: ZodError): string => { 59 | return error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join(', ') 60 | } 61 | 62 | // ============================================================================= 63 | // REQUEST ID GENERATION 64 | // ============================================================================= 65 | 66 | const generateRequestId = (): string => { 67 | return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` 68 | } 69 | 70 | // ============================================================================= 71 | // USER INFO EXTRACTION 72 | // ============================================================================= 73 | 74 | // Helper function removed - not used in current implementation 75 | 76 | // ============================================================================= 77 | // MAIN ERROR HANDLER 78 | // ============================================================================= 79 | 80 | export const errorHandler = async (error: Error, request: FastifyRequest, reply: FastifyReply): Promise => { 81 | const context = extractErrorContext(request) 82 | 83 | // Log error with full context 84 | logError(error, context, request) 85 | 86 | let statusCode: number = HTTP_STATUS.INTERNAL_SERVER_ERROR 87 | let message: string = ERROR_MESSAGES.INTERNAL_SERVER_ERROR 88 | let details: ValidationErrorDetail[] | undefined 89 | 90 | // Enhanced error type handling 91 | if (error instanceof AppError) { 92 | statusCode = error.statusCode 93 | message = error.message 94 | } else if (error instanceof ZodError) { 95 | statusCode = HTTP_STATUS.BAD_REQUEST 96 | message = formatValidationErrors(error) 97 | details = error.issues.map((issue) => ({ 98 | field: issue.path.join('.'), 99 | message: issue.message, 100 | code: issue.code 101 | })) 102 | } else if ( 103 | error.name === BETTER_AUTH_ERROR_NAMES.INVALID_SESSION || 104 | error.name === BETTER_AUTH_ERROR_NAMES.EXPIRED_SESSION 105 | ) { 106 | statusCode = HTTP_STATUS.UNAUTHORIZED 107 | message = ERROR_MESSAGES.AUTHENTICATION_FAILED 108 | } else if ('statusCode' in error && typeof (error as ErrorWithStatusCode).statusCode === 'number') { 109 | statusCode = (error as ErrorWithStatusCode).statusCode || HTTP_STATUS.INTERNAL_SERVER_ERROR 110 | message = error.message 111 | details = (error as ErrorWithStatusCode).details as ValidationErrorDetail[] 112 | } else if (process.env.ENV !== 'production') { 113 | // In development, show original error message 114 | message = error.message 115 | } 116 | 117 | // Enhanced error response with context 118 | const response: ErrorResponse = { 119 | success: false, 120 | error: { 121 | message, 122 | code: error.name || 'UNKNOWN_ERROR', 123 | statusCode, 124 | timestamp: context.timestamp, 125 | path: context.url 126 | } 127 | } 128 | 129 | // Add optional properties conditionally 130 | if (details) { 131 | response.error.details = details 132 | } 133 | 134 | // Set status code and send response 135 | reply.code(statusCode).send(response) 136 | } 137 | 138 | // ============================================================================= 139 | // ERROR HANDLER PLUGIN 140 | // ============================================================================= 141 | 142 | const errorHandlerPlugin = async (fastify: FastifyInstance) => { 143 | // Register the main error handler 144 | fastify.setErrorHandler(errorHandler) 145 | 146 | // Add request ID to all requests for tracing 147 | fastify.addHook('onRequest', async (request) => { 148 | ;(request as ExtendedFastifyRequest).requestId = generateRequestId() 149 | }) 150 | 151 | // Add response time tracking 152 | fastify.addHook('onResponse', async (request, reply) => { 153 | const responseTime = Date.now() - ((request as ExtendedFastifyRequest).startTime || 0) 154 | request.log.info( 155 | { 156 | requestId: (request as ExtendedFastifyRequest).requestId, 157 | method: request.method, 158 | url: request.url, 159 | statusCode: reply.statusCode, 160 | responseTime: `${responseTime}ms` 161 | }, 162 | 'Request completed' 163 | ) 164 | }) 165 | 166 | // Track request start time 167 | fastify.addHook('onRequest', async (request) => { 168 | ;(request as ExtendedFastifyRequest).startTime = Date.now() 169 | }) 170 | } 171 | 172 | export default errorHandlerPlugin 173 | -------------------------------------------------------------------------------- /api/utils/schemas/types.ts: -------------------------------------------------------------------------------- 1 | import type { InferSelectModel } from 'drizzle-orm' 2 | import type { NodePgDatabase } from 'drizzle-orm/node-postgres' 3 | import type { FastifyRequest } from 'fastify' 4 | import type { account, session, user, verification } from './database' 5 | 6 | // ============================================================================= 7 | // DATABASE TYPES 8 | // All TypeScript types inferred from database schemas 9 | // ============================================================================= 10 | 11 | /** 12 | * User entity type (inferred from Drizzle schema) 13 | */ 14 | export type User = InferSelectModel 15 | 16 | /** 17 | * Session entity type (inferred from Drizzle schema) 18 | */ 19 | export type Session = InferSelectModel 20 | 21 | /** 22 | * Account entity type (inferred from Drizzle schema) 23 | */ 24 | export type Account = InferSelectModel 25 | 26 | /** 27 | * Verification entity type (inferred from Drizzle schema) 28 | */ 29 | export type Verification = InferSelectModel 30 | 31 | // ============================================================================= 32 | // DATABASE INSTANCE TYPES 33 | // ============================================================================= 34 | 35 | /** 36 | * Complete database schema type for Drizzle instance 37 | */ 38 | export type DatabaseSchema = { 39 | user: typeof user 40 | session: typeof session 41 | account: typeof account 42 | verification: typeof verification 43 | } 44 | 45 | /** 46 | * Database connection instance type 47 | */ 48 | export type DatabaseInstance = NodePgDatabase 49 | 50 | // ============================================================================= 51 | // API RESPONSE TYPES 52 | // Clean types for API responses (without sensitive data) 53 | // ============================================================================= 54 | 55 | /** 56 | * Safe user type for API responses (no emailVerified) 57 | */ 58 | export type SafeUser = Omit 59 | 60 | /** 61 | * API-optimized user type with string dates 62 | */ 63 | export type SafeUserApi = Omit & { 64 | createdAt: string // ISO string format for JSON APIs 65 | } 66 | 67 | // ============================================================================= 68 | // DATA TRANSFER OBJECTS (DTOs) 69 | // Input types for service operations 70 | // ============================================================================= 71 | 72 | /** 73 | * Data required to create a new user 74 | */ 75 | export interface CreateUserData { 76 | name: string 77 | email: string 78 | password: string 79 | } 80 | 81 | /** 82 | * Data for updating user information (all optional) 83 | */ 84 | export interface UpdateUserData { 85 | name?: string 86 | email?: string 87 | image?: string 88 | } 89 | 90 | /** 91 | * Data required to create a new session 92 | */ 93 | export interface CreateSessionData { 94 | token: string 95 | userId: string 96 | expiresAt: Date 97 | ipAddress?: string 98 | userAgent?: string 99 | } 100 | 101 | // ============================================================================= 102 | // AUTHENTICATION TYPES 103 | // Better-Auth related types 104 | // ============================================================================= 105 | 106 | /** 107 | * Better-Auth session structure 108 | */ 109 | export interface BetterAuthSession { 110 | user: { 111 | id: string 112 | email: string 113 | name: string | null 114 | } 115 | session: { 116 | id: string 117 | expiresAt: Date 118 | } 119 | } 120 | 121 | /** 122 | * Authenticated user context 123 | */ 124 | export interface AuthenticatedUser { 125 | id: string 126 | email: string 127 | name: string | null 128 | emailVerified: boolean 129 | } 130 | 131 | /** 132 | * Profile API response structure 133 | */ 134 | export interface ProfileResponse { 135 | success: boolean 136 | data: SafeUserApi 137 | } 138 | 139 | // ============================================================================= 140 | // VALIDATION TYPES 141 | // Error handling and validation types 142 | // ============================================================================= 143 | 144 | /** 145 | * Validation error details 146 | */ 147 | export interface ValidationErrorDetail { 148 | field: string 149 | message: string 150 | code: string 151 | } 152 | 153 | /** 154 | * Standard API error response 155 | */ 156 | export interface ErrorResponse { 157 | success: false 158 | error: { 159 | message: string 160 | code: string 161 | statusCode: number 162 | timestamp: string 163 | path: string 164 | details?: ValidationErrorDetail[] 165 | } 166 | } 167 | 168 | /** 169 | * Standard API success response 170 | */ 171 | export interface SuccessResponse { 172 | success: true 173 | data: T 174 | message?: string 175 | } 176 | 177 | // ============================================================================= 178 | // FASTIFY TYPES 179 | // Framework-specific types 180 | // ============================================================================= 181 | 182 | /** 183 | * Generic JSON Schema object for Fastify 184 | */ 185 | export interface JsonSchemaObject { 186 | type?: string 187 | properties?: Record 188 | required?: string[] 189 | additionalProperties?: boolean 190 | [key: string]: unknown 191 | } 192 | 193 | /** 194 | * Pre-handler function type for Fastify hooks 195 | */ 196 | export type PreHandlerFunction = (request: unknown, reply: unknown) => Promise | void 197 | 198 | /** 199 | * Simple route handler type 200 | */ 201 | export type SimpleRouteHandler = (request: unknown, reply: unknown) => Promise | unknown 202 | 203 | /** 204 | * Extended Fastify request with custom properties 205 | */ 206 | export interface ExtendedFastifyRequest extends FastifyRequest { 207 | user?: AuthenticatedUser 208 | requestId?: string 209 | startTime?: number 210 | } 211 | 212 | /** 213 | * Authenticated Fastify request with user context 214 | */ 215 | export interface AuthenticatedFastifyRequest extends ExtendedFastifyRequest { 216 | user: AuthenticatedUser 217 | } 218 | 219 | /** 220 | * Route options for Fastify with OpenAPI 221 | */ 222 | export interface RouteOptions { 223 | method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' 224 | url: string 225 | schema?: { 226 | body?: JsonSchemaObject 227 | params?: JsonSchemaObject 228 | querystring?: JsonSchemaObject 229 | response?: { [statusCode: number]: JsonSchemaObject } 230 | tags?: string[] 231 | description?: string 232 | summary?: string 233 | security?: Array<{ [key: string]: string[] }> 234 | } 235 | preHandler?: PreHandlerFunction | PreHandlerFunction[] 236 | handler: SimpleRouteHandler 237 | } 238 | -------------------------------------------------------------------------------- /api/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { ecsFormat } from '@elastic/ecs-pino-format' 2 | import pino from 'pino' 3 | import pinoElastic from 'pino-elasticsearch' 4 | import pinoPretty from 'pino-pretty' 5 | import { ENVIRONMENT, TIMEOUTS } from './constants' 6 | 7 | // Determine log level based on environment 8 | const env = process.env.ENV || ENVIRONMENT.DEVELOPMENT 9 | 10 | // Logger configuration 11 | const loggerConfig = { 12 | levels: { 13 | [ENVIRONMENT.PRODUCTION]: 'info', 14 | [ENVIRONMENT.DEVELOPMENT]: 'debug', 15 | default: 'trace' 16 | } as const, 17 | prettyPrint: { 18 | development: { 19 | colorize: true, 20 | translateTime: 'SYS:standard', 21 | ignore: 'pid,hostname', 22 | singleLine: false, 23 | hideObject: false, 24 | messageFormat: '{msg}', 25 | customPrettifiers: { 26 | time: (inputData: string | object) => { 27 | const timestamp = typeof inputData === 'string' ? inputData : String(inputData) 28 | return `🕐 ${timestamp}` 29 | }, 30 | level: (inputData: string | object) => { 31 | const logLevel = typeof inputData === 'string' ? inputData : String(inputData) 32 | return `${logLevel.toUpperCase()}` 33 | } 34 | } 35 | } 36 | } 37 | } as const 38 | 39 | // Configure Elasticsearch stream if available 40 | const createElasticsearchStream = () => { 41 | if (!process.env.ELASTICSEARCH_HOST || !process.env.ELASTICSEARCH_PORT) { 42 | return null 43 | } 44 | 45 | return pinoElastic({ 46 | index: 'api-logs', 47 | node: `http://${process.env.ELASTICSEARCH_HOST}:${process.env.ELASTICSEARCH_PORT}`, 48 | opType: 'index', 49 | esVersion: 8, 50 | flushBytes: TIMEOUTS.ELASTICSEARCH_FLUSH_BYTES, 51 | auth: process.env.ELASTICSEARCH_AUTH 52 | ? { 53 | username: process.env.ELASTICSEARCH_USERNAME || 'elastic', 54 | password: process.env.ELASTICSEARCH_PASSWORD || '' 55 | } 56 | : undefined 57 | }) 58 | } 59 | 60 | const level = (loggerConfig.levels[env as keyof typeof loggerConfig.levels] || 61 | loggerConfig.levels.default) as pino.Level 62 | 63 | const elasticsearchStream = createElasticsearchStream() 64 | 65 | const streams: pino.StreamEntry[] = [ 66 | { 67 | level: level, 68 | stream: env === ENVIRONMENT.DEVELOPMENT ? pinoPretty(loggerConfig.prettyPrint.development) : process.stdout 69 | } 70 | ] 71 | 72 | // Add Elasticsearch stream if configured 73 | if (elasticsearchStream) { 74 | streams.push({ 75 | level: level, 76 | stream: elasticsearchStream 77 | }) 78 | } 79 | 80 | // Create logger instance 81 | const logger = pino( 82 | { 83 | level, 84 | ...ecsFormat, 85 | // Add custom fields 86 | base: { 87 | service: 'api', 88 | version: process.env.npm_package_version || '1.0.0', 89 | environment: env 90 | }, 91 | // Format timestamps 92 | timestamp: pino.stdTimeFunctions.isoTime, 93 | // Add request ID to all logs 94 | formatters: { 95 | level: (label) => ({ level: label }), 96 | log: (object) => { 97 | // Add correlation ID if available 98 | if (object.requestId) { 99 | return { ...object, correlationId: object.requestId } 100 | } 101 | return object 102 | } 103 | } 104 | }, 105 | pino.multistream(streams) 106 | ) 107 | 108 | // ============================================================================= 109 | // ERROR HANDLING FOR ELASTICSEARCH 110 | // ============================================================================= 111 | 112 | if (elasticsearchStream) { 113 | // Create a fallback logger for Elasticsearch errors (avoiding circular logging) 114 | const fallbackLogger = pino({ level: 'error' }, process.stderr) 115 | 116 | // Handle Elasticsearch connection errors 117 | elasticsearchStream.on('error', (error) => { 118 | fallbackLogger.error({ error, component: 'elasticsearch' }, 'Elasticsearch client error') 119 | }) 120 | 121 | // Handle Elasticsearch insertion errors 122 | elasticsearchStream.on('insertError', (error) => { 123 | fallbackLogger.error({ error, component: 'elasticsearch' }, 'Elasticsearch server error') 124 | }) 125 | 126 | // Handle successful connections 127 | elasticsearchStream.on('connect', () => { 128 | fallbackLogger.info({ component: 'elasticsearch' }, 'Connected to Elasticsearch for logging') 129 | }) 130 | } 131 | 132 | // Log application startup 133 | const logStartup = (port: number, host: string) => { 134 | logger.info( 135 | { 136 | eventType: 'app_startup', 137 | port, 138 | host, 139 | nodeVersion: process.version, 140 | platform: process.platform 141 | }, 142 | `Server started on ${host}:${port}` 143 | ) 144 | } 145 | 146 | // Log application shutdown 147 | const logShutdown = (reason?: string) => { 148 | logger.info( 149 | { 150 | eventType: 'app_shutdown', 151 | reason 152 | }, 153 | 'Server shutting down' 154 | ) 155 | } 156 | 157 | // ============================================================================= 158 | // CUSTOM LOGGING METHODS 159 | // ============================================================================= 160 | 161 | type AuthLogData = { 162 | event: string 163 | ip: string 164 | userAgent?: string 165 | success: boolean 166 | reason?: string 167 | userId?: string 168 | email?: string 169 | } 170 | 171 | type PerformanceLogData = { 172 | operation: string 173 | duration: number 174 | metadata?: Record 175 | requestId?: string 176 | [key: string]: unknown 177 | } 178 | 179 | type SecurityLogData = { 180 | event: string 181 | severity: 'low' | 'medium' | 'high' | 'critical' 182 | ip?: string 183 | details?: string | Record 184 | [key: string]: unknown 185 | } 186 | 187 | const logAuth = (data: AuthLogData) => { 188 | logger.info( 189 | { 190 | eventType: 'authentication', 191 | ...data 192 | }, 193 | `Auth event: ${data.event} - ${data.success ? 'success' : 'failed'}` 194 | ) 195 | } 196 | 197 | const logPerformance = (data: PerformanceLogData) => { 198 | logger.info( 199 | { 200 | eventType: 'performance', 201 | ...data 202 | }, 203 | `Performance: ${data.operation} took ${data.duration}ms` 204 | ) 205 | } 206 | 207 | const logSecurity = (data: SecurityLogData) => { 208 | const logLevel = data.severity === 'critical' || data.severity === 'high' ? 'warn' : 'info' 209 | logger[logLevel]( 210 | { 211 | eventType: 'security', 212 | ...data 213 | }, 214 | `Security event: ${data.event} (${data.severity})` 215 | ) 216 | } 217 | 218 | // Create a logger wrapper with custom methods 219 | const logUtils = { 220 | ...logger, 221 | logAuth, 222 | logPerformance, 223 | logSecurity 224 | } 225 | 226 | // ============================================================================= 227 | // EXPORTS 228 | // ============================================================================= 229 | 230 | export default logUtils 231 | export { logStartup, logShutdown } 232 | -------------------------------------------------------------------------------- /api/utils/constants.ts: -------------------------------------------------------------------------------- 1 | // ============================================================================= 2 | // APPLICATION CONSTANTS 3 | // Consolidated constants for the entire application 4 | // ============================================================================= 5 | 6 | // ============================================================================= 7 | // SERVER CONFIGURATION 8 | // ============================================================================= 9 | 10 | export const SERVER = { 11 | PORT: 5000, 12 | HOST: '0.0.0.0', 13 | LOCALHOST: 'localhost' 14 | } as const 15 | 16 | // HTTP Status Codes 17 | export const HTTP_STATUS = { 18 | OK: 200, 19 | CREATED: 201, 20 | NO_CONTENT: 204, 21 | BAD_REQUEST: 400, 22 | UNAUTHORIZED: 401, 23 | FORBIDDEN: 403, 24 | NOT_FOUND: 404, 25 | CONFLICT: 409, 26 | PAYLOAD_TOO_LARGE: 413, 27 | UNSUPPORTED_MEDIA_TYPE: 415, 28 | UNPROCESSABLE_ENTITY: 422, 29 | TOO_MANY_REQUESTS: 429, 30 | INTERNAL_SERVER_ERROR: 500, 31 | SERVICE_UNAVAILABLE: 503 32 | } as const 33 | 34 | // ============================================================================= 35 | // API CONFIGURATION 36 | // ============================================================================= 37 | 38 | export const BASE_URL = process.env.BASE_URL || 'http://localhost' 39 | 40 | export const API_DOCS = { 41 | TITLE: 'Fastify API', 42 | DESCRIPTION: 'API documentation for Fastify project', 43 | VERSION: '1.0.0', 44 | SCHEMES: ['http'] as const, 45 | CONSUMES: 'application/json', 46 | PRODUCES: 'application/json', 47 | REFERENCE_ROUTE: '/reference', 48 | THEME: 'fastify' 49 | } as const 50 | 51 | export const SECURITY_DEFINITIONS = { 52 | BEARER_AUTH: { 53 | TYPE: 'apiKey', 54 | NAME: 'Authorization', 55 | IN: 'header', 56 | DESCRIPTION: 'Enter Bearer token **_only_**' 57 | } 58 | } as const 59 | 60 | // ============================================================================= 61 | // DATABASE CONFIGURATION 62 | // ============================================================================= 63 | 64 | export const DATABASE = { 65 | DIALECT: 'postgresql', 66 | TABLE_NAMES: { 67 | USERS: 'users' 68 | }, 69 | COLUMN_NAMES: { 70 | ID: 'id', 71 | NAME: 'name', 72 | EMAIL: 'email', 73 | PASSWORD: 'password', 74 | CREATED_AT: 'created_at' 75 | }, 76 | CONSTRAINTS: { 77 | EMAIL_MAX_LENGTH: 255, 78 | PASSWORD_MAX_LENGTH: 100, 79 | NAME_MAX_LENGTH: 255 80 | } 81 | } as const 82 | 83 | // ============================================================================= 84 | // SECURITY CONFIGURATION 85 | // ============================================================================= 86 | 87 | export const SECURITY = { 88 | BCRYPT_SALT_ROUNDS: 10, 89 | JWT_EXPIRY: '7d', 90 | SESSION_EXPIRY: 60 * 60 * 24 * 7, // 7 days in seconds 91 | REFRESH_TOKEN_EXPIRY: 60 * 60 * 24 * 30, // 30 days in seconds 92 | PASSWORD_MIN_LENGTH: 8, 93 | PASSWORD_MAX_LENGTH: 100, 94 | MAX_LOGIN_ATTEMPTS: 5, 95 | LOCKOUT_DURATION: 15 * 60 // 15 minutes in seconds 96 | } as const 97 | 98 | // Cache keys for Redis/Valkey 99 | export const CACHE_KEYS = { 100 | TEST_KEY: 'test', 101 | TEST_VALUE: 'ping', 102 | USER_SESSION: (userId: string) => `user:session:${userId}`, 103 | USER_PROFILE: (userId: string) => `user:profile:${userId}`, 104 | LOGIN_ATTEMPTS: (email: string) => `auth:attempts:${email}`, 105 | RATE_LIMIT: (ip: string, endpoint: string) => `rate:${ip}:${endpoint}`, 106 | HEALTH_CHECK: 'app:health' 107 | } as const 108 | 109 | // ============================================================================= 110 | // ERROR MESSAGES 111 | // ============================================================================= 112 | 113 | export const ERROR_MESSAGES = { 114 | INTERNAL_SERVER_ERROR: 'Internal server error', 115 | INVALID_REQUEST_DATA: 'Invalid request data', 116 | AUTHENTICATION_FAILED: 'Authentication failed', 117 | ACCESS_DENIED: 'Access denied', 118 | RESOURCE_NOT_FOUND: 'Resource not found', 119 | RESOURCE_ALREADY_EXISTS: 'Resource already exists', 120 | VALIDATION_FAILED: 'Validation failed', 121 | UNAUTHORIZED: 'Unauthorized', 122 | USER_NOT_FOUND: 'User not found', 123 | USER_ALREADY_EXISTS: 'User already exists', 124 | USER_UPDATE_FAILED: 'Failed to update user', 125 | EMAIL_ALREADY_IN_USE: 'Email already in use', 126 | INVALID_CREDENTIALS: 'Invalid credentials', 127 | DATABASE_OPERATION_FAILED: 'Database operation failed', 128 | REQUEST_BODY_VALIDATION_FAILED: 'Request body validation failed', 129 | REQUEST_PARAMS_VALIDATION_FAILED: 'Request params validation failed', 130 | REQUEST_QUERY_VALIDATION_FAILED: 'Request query validation failed', 131 | INVALID_EMAIL_FORMAT: 'Invalid email format', 132 | SUCCESS_RESPONSE_MESSAGE: 'pong', 133 | AUTHENTICATION_REQUIRED: 'Authentication required', 134 | INVALID_SESSION_DATA: 'Invalid session data', 135 | AUTHENTICATION_FAILED_GENERIC: 'Authentication failed', 136 | INSUFFICIENT_PERMISSIONS: 'Insufficient permissions', 137 | INVALID_USER_ID: 'Invalid user ID' 138 | } as const 139 | 140 | export const BETTER_AUTH_ERROR_NAMES = { 141 | INVALID_SESSION: 'InvalidSession', 142 | EXPIRED_SESSION: 'ExpiredSession' 143 | } as const 144 | 145 | // ============================================================================= 146 | // COMMON UTILITIES 147 | // ============================================================================= 148 | 149 | export const BOOLEAN_STRINGS = ['true', 'false', '1', '0', 'yes', 'no'] as const 150 | 151 | export const ENVIRONMENT = { 152 | DEVELOPMENT: 'development', 153 | PRODUCTION: 'production', 154 | TEST: 'test' 155 | } as const 156 | 157 | export const TIMEOUTS = { 158 | DEFAULT: 5000, 159 | LONG: 30000, 160 | DATABASE: 10000, 161 | IDENTITY_COUNT_DELAY: 1000, 162 | ELASTICSEARCH_FLUSH_BYTES: 100 163 | } as const 164 | 165 | // ============================================================================= 166 | // DATABASE QUERY LIMITS 167 | // ============================================================================= 168 | 169 | export const DB_QUERY_LIMITS = { 170 | SINGLE_RECORD: 1, 171 | DEFAULT_PAGE_SIZE: 10, 172 | DEFAULT_OFFSET: 0, 173 | MAX_PAGE_SIZE: 100 174 | } as const 175 | 176 | // ============================================================================= 177 | // VALIDATION PATTERNS 178 | // ============================================================================= 179 | 180 | export const VALIDATION_PATTERNS = { 181 | EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, 182 | PASSWORD: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 183 | UUID: /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, 184 | SLUG: /^[a-z0-9]+(?:-[a-z0-9]+)*$/ 185 | } as const 186 | 187 | // ============================================================================= 188 | // DATABASE AND CONNECTION LIMITS 189 | // ============================================================================= 190 | 191 | export const DB_LIMITS = { 192 | MAX_CONNECTIONS: 20, 193 | CONNECTION_TIMEOUT: 30000, 194 | QUERY_TIMEOUT: 5000, 195 | DEFAULT_DB_INDEX: 0 196 | } as const 197 | 198 | export const DEFAULT_PORTS = { 199 | POSTGRES: 5432, 200 | REDIS: 6379, 201 | VALKEY: 6379, 202 | ELASTICSEARCH: 9200 203 | } as const 204 | -------------------------------------------------------------------------------- /api/.s/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "9734a472-0b5e-4bb0-be8b-11f61302b2d8", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.account": { 8 | "name": "account", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "text", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "account_id": { 18 | "name": "account_id", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "provider_id": { 24 | "name": "provider_id", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "user_id": { 30 | "name": "user_id", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "access_token": { 36 | "name": "access_token", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": false 40 | }, 41 | "refresh_token": { 42 | "name": "refresh_token", 43 | "type": "text", 44 | "primaryKey": false, 45 | "notNull": false 46 | }, 47 | "id_token": { 48 | "name": "id_token", 49 | "type": "text", 50 | "primaryKey": false, 51 | "notNull": false 52 | }, 53 | "access_token_expires_at": { 54 | "name": "access_token_expires_at", 55 | "type": "timestamp", 56 | "primaryKey": false, 57 | "notNull": false 58 | }, 59 | "refresh_token_expires_at": { 60 | "name": "refresh_token_expires_at", 61 | "type": "timestamp", 62 | "primaryKey": false, 63 | "notNull": false 64 | }, 65 | "scope": { 66 | "name": "scope", 67 | "type": "text", 68 | "primaryKey": false, 69 | "notNull": false 70 | }, 71 | "password": { 72 | "name": "password", 73 | "type": "text", 74 | "primaryKey": false, 75 | "notNull": false 76 | }, 77 | "created_at": { 78 | "name": "created_at", 79 | "type": "timestamp", 80 | "primaryKey": false, 81 | "notNull": true 82 | }, 83 | "updated_at": { 84 | "name": "updated_at", 85 | "type": "timestamp", 86 | "primaryKey": false, 87 | "notNull": true 88 | } 89 | }, 90 | "indexes": {}, 91 | "foreignKeys": { 92 | "account_user_id_users_id_fk": { 93 | "name": "account_user_id_users_id_fk", 94 | "tableFrom": "account", 95 | "tableTo": "users", 96 | "columnsFrom": ["user_id"], 97 | "columnsTo": ["id"], 98 | "onDelete": "cascade", 99 | "onUpdate": "no action" 100 | } 101 | }, 102 | "compositePrimaryKeys": {}, 103 | "uniqueConstraints": {}, 104 | "policies": {}, 105 | "checkConstraints": {}, 106 | "isRLSEnabled": false 107 | }, 108 | "public.session": { 109 | "name": "session", 110 | "schema": "", 111 | "columns": { 112 | "id": { 113 | "name": "id", 114 | "type": "text", 115 | "primaryKey": true, 116 | "notNull": true 117 | }, 118 | "expires_at": { 119 | "name": "expires_at", 120 | "type": "timestamp", 121 | "primaryKey": false, 122 | "notNull": true 123 | }, 124 | "token": { 125 | "name": "token", 126 | "type": "text", 127 | "primaryKey": false, 128 | "notNull": true 129 | }, 130 | "created_at": { 131 | "name": "created_at", 132 | "type": "timestamp", 133 | "primaryKey": false, 134 | "notNull": true 135 | }, 136 | "updated_at": { 137 | "name": "updated_at", 138 | "type": "timestamp", 139 | "primaryKey": false, 140 | "notNull": true 141 | }, 142 | "ip_address": { 143 | "name": "ip_address", 144 | "type": "text", 145 | "primaryKey": false, 146 | "notNull": false 147 | }, 148 | "user_agent": { 149 | "name": "user_agent", 150 | "type": "text", 151 | "primaryKey": false, 152 | "notNull": false 153 | }, 154 | "user_id": { 155 | "name": "user_id", 156 | "type": "text", 157 | "primaryKey": false, 158 | "notNull": true 159 | } 160 | }, 161 | "indexes": {}, 162 | "foreignKeys": { 163 | "session_user_id_users_id_fk": { 164 | "name": "session_user_id_users_id_fk", 165 | "tableFrom": "session", 166 | "tableTo": "users", 167 | "columnsFrom": ["user_id"], 168 | "columnsTo": ["id"], 169 | "onDelete": "cascade", 170 | "onUpdate": "no action" 171 | } 172 | }, 173 | "compositePrimaryKeys": {}, 174 | "uniqueConstraints": { 175 | "session_token_unique": { 176 | "name": "session_token_unique", 177 | "nullsNotDistinct": false, 178 | "columns": ["token"] 179 | } 180 | }, 181 | "policies": {}, 182 | "checkConstraints": {}, 183 | "isRLSEnabled": false 184 | }, 185 | "public.users": { 186 | "name": "users", 187 | "schema": "", 188 | "columns": { 189 | "id": { 190 | "name": "id", 191 | "type": "text", 192 | "primaryKey": true, 193 | "notNull": true 194 | }, 195 | "name": { 196 | "name": "name", 197 | "type": "text", 198 | "primaryKey": false, 199 | "notNull": false 200 | }, 201 | "email": { 202 | "name": "email", 203 | "type": "text", 204 | "primaryKey": false, 205 | "notNull": true 206 | }, 207 | "email_verified": { 208 | "name": "email_verified", 209 | "type": "boolean", 210 | "primaryKey": false, 211 | "notNull": true 212 | }, 213 | "image": { 214 | "name": "image", 215 | "type": "text", 216 | "primaryKey": false, 217 | "notNull": false 218 | }, 219 | "created_at": { 220 | "name": "created_at", 221 | "type": "timestamp", 222 | "primaryKey": false, 223 | "notNull": true 224 | }, 225 | "updated_at": { 226 | "name": "updated_at", 227 | "type": "timestamp", 228 | "primaryKey": false, 229 | "notNull": true 230 | } 231 | }, 232 | "indexes": {}, 233 | "foreignKeys": {}, 234 | "compositePrimaryKeys": {}, 235 | "uniqueConstraints": { 236 | "users_email_unique": { 237 | "name": "users_email_unique", 238 | "nullsNotDistinct": false, 239 | "columns": ["email"] 240 | } 241 | }, 242 | "policies": {}, 243 | "checkConstraints": {}, 244 | "isRLSEnabled": false 245 | }, 246 | "public.verification": { 247 | "name": "verification", 248 | "schema": "", 249 | "columns": { 250 | "id": { 251 | "name": "id", 252 | "type": "text", 253 | "primaryKey": true, 254 | "notNull": true 255 | }, 256 | "identifier": { 257 | "name": "identifier", 258 | "type": "text", 259 | "primaryKey": false, 260 | "notNull": true 261 | }, 262 | "value": { 263 | "name": "value", 264 | "type": "text", 265 | "primaryKey": false, 266 | "notNull": true 267 | }, 268 | "expires_at": { 269 | "name": "expires_at", 270 | "type": "timestamp", 271 | "primaryKey": false, 272 | "notNull": true 273 | }, 274 | "created_at": { 275 | "name": "created_at", 276 | "type": "timestamp", 277 | "primaryKey": false, 278 | "notNull": false 279 | }, 280 | "updated_at": { 281 | "name": "updated_at", 282 | "type": "timestamp", 283 | "primaryKey": false, 284 | "notNull": false 285 | } 286 | }, 287 | "indexes": {}, 288 | "foreignKeys": {}, 289 | "compositePrimaryKeys": {}, 290 | "uniqueConstraints": {}, 291 | "policies": {}, 292 | "checkConstraints": {}, 293 | "isRLSEnabled": false 294 | } 295 | }, 296 | "enums": {}, 297 | "schemas": {}, 298 | "sequences": {}, 299 | "roles": {}, 300 | "policies": {}, 301 | "views": {}, 302 | "_meta": { 303 | "columns": {}, 304 | "schemas": {}, 305 | "tables": {} 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /api/routes/betterAuthRoutes.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from 'fastify' 2 | import * as z from 'zod' 3 | import { auth } from '../utils/db' 4 | import { 5 | betterAuthForgotPasswordSchema, 6 | betterAuthResetPasswordSchema, 7 | betterAuthSessionResponseSchema, 8 | betterAuthSignInSchema, 9 | betterAuthSignUpSchema, 10 | betterAuthSocialSignInSchema, 11 | betterAuthSuccessResponseSchema, 12 | errorResponseSchema 13 | } from '../utils/schemas' 14 | import { createRouteSchema } from '../utils/schemas/schemaConverter' 15 | 16 | // ============================================================================= 17 | // BETTER-AUTH ROUTES WITH UNIFIED ZOD SYSTEM 18 | // Properly documented Better-Auth endpoints with OpenAPI schemas 19 | // ============================================================================= 20 | 21 | export default async function betterAuthRoutes(fastify: FastifyInstance) { 22 | // POST /api/auth/sign-up/email - Sign up with email and password 23 | fastify.post('/api/auth/sign-up/email', { 24 | schema: createRouteSchema({ 25 | description: 'Create a new user account using email and password', 26 | tags: ['Authentication'], 27 | body: betterAuthSignUpSchema, 28 | response: { 29 | 200: betterAuthSessionResponseSchema, 30 | 400: errorResponseSchema, 31 | 409: errorResponseSchema 32 | } 33 | }), 34 | handler: async (request, reply) => { 35 | // Convert Fastify request to standard Request object 36 | const url = new URL(request.url, `http://${request.headers.host}`) 37 | const webRequest = new Request(url, { 38 | method: request.method, 39 | headers: request.headers as HeadersInit, 40 | body: JSON.stringify(request.body) 41 | }) 42 | 43 | // Get response from better-auth 44 | const response = await auth.handler(webRequest) 45 | const responseBody = await response.text() 46 | 47 | // Set headers 48 | response.headers.forEach((value, key) => { 49 | reply.header(key, value) 50 | }) 51 | 52 | return reply.code(response.status).send(responseBody) 53 | } 54 | }) 55 | 56 | // POST /api/auth/sign-in/email - Sign in with email and password 57 | fastify.post('/api/auth/sign-in/email', { 58 | schema: createRouteSchema({ 59 | description: 'Sign in to an existing account using email and password', 60 | tags: ['Authentication'], 61 | body: betterAuthSignInSchema, 62 | response: { 63 | 200: betterAuthSessionResponseSchema, 64 | 400: errorResponseSchema, 65 | 401: errorResponseSchema 66 | } 67 | }), 68 | handler: async (request, reply) => { 69 | const url = new URL(request.url, `http://${request.headers.host}`) 70 | const webRequest = new Request(url, { 71 | method: request.method, 72 | headers: request.headers as HeadersInit, 73 | body: JSON.stringify(request.body) 74 | }) 75 | 76 | const response = await auth.handler(webRequest) 77 | const responseBody = await response.text() 78 | 79 | response.headers.forEach((value, key) => { 80 | reply.header(key, value) 81 | }) 82 | 83 | return reply.code(response.status).send(responseBody) 84 | } 85 | }) 86 | 87 | // POST /api/auth/sign-in/social - Sign in with social provider 88 | fastify.post('/api/auth/sign-in/social', { 89 | schema: createRouteSchema({ 90 | description: 'Sign in using a social authentication provider (Google, GitHub, etc.)', 91 | tags: ['Authentication'], 92 | body: betterAuthSocialSignInSchema, 93 | response: { 94 | 200: betterAuthSessionResponseSchema, 95 | 302: z 96 | .object({ 97 | location: z.string().describe('Redirect URL to social provider') 98 | }) 99 | .describe('Redirect response'), 100 | 400: errorResponseSchema 101 | } 102 | }), 103 | handler: async (request, reply) => { 104 | const url = new URL(request.url, `http://${request.headers.host}`) 105 | const webRequest = new Request(url, { 106 | method: request.method, 107 | headers: request.headers as HeadersInit, 108 | body: JSON.stringify(request.body) 109 | }) 110 | 111 | const response = await auth.handler(webRequest) 112 | const responseBody = await response.text() 113 | 114 | response.headers.forEach((value, key) => { 115 | reply.header(key, value) 116 | }) 117 | 118 | return reply.code(response.status).send(responseBody) 119 | } 120 | }) 121 | 122 | // POST /api/auth/sign-out - Sign out current session 123 | fastify.post('/api/auth/sign-out', { 124 | schema: createRouteSchema({ 125 | description: 'Sign out of the current session and invalidate the session token', 126 | tags: ['Authentication'], 127 | response: { 128 | 200: betterAuthSuccessResponseSchema, 129 | 401: errorResponseSchema 130 | } 131 | }), 132 | handler: async (request, reply) => { 133 | const url = new URL(request.url, `http://${request.headers.host}`) 134 | const webRequest = new Request(url, { 135 | method: request.method, 136 | headers: request.headers as HeadersInit, 137 | body: request.method !== 'GET' && request.method !== 'HEAD' ? JSON.stringify(request.body) : undefined 138 | }) 139 | 140 | const response = await auth.handler(webRequest) 141 | const responseBody = await response.text() 142 | 143 | response.headers.forEach((value, key) => { 144 | reply.header(key, value) 145 | }) 146 | 147 | return reply.code(response.status).send(responseBody) 148 | } 149 | }) 150 | 151 | // GET /api/auth/get-session - Get current session information 152 | fastify.get('/api/auth/get-session', { 153 | schema: createRouteSchema({ 154 | description: 'Retrieve the current user session and user information', 155 | tags: ['Authentication'], 156 | response: { 157 | 200: betterAuthSessionResponseSchema, 158 | 401: errorResponseSchema 159 | } 160 | }), 161 | handler: async (request, reply) => { 162 | const url = new URL(request.url, `http://${request.headers.host}`) 163 | const webRequest = new Request(url, { 164 | method: request.method, 165 | headers: request.headers as HeadersInit 166 | }) 167 | 168 | const response = await auth.handler(webRequest) 169 | const responseBody = await response.text() 170 | 171 | response.headers.forEach((value, key) => { 172 | reply.header(key, value) 173 | }) 174 | 175 | return reply.code(response.status).send(responseBody) 176 | } 177 | }) 178 | 179 | // POST /api/auth/forgot-password - Request password reset 180 | fastify.post('/api/auth/forgot-password', { 181 | schema: createRouteSchema({ 182 | description: 'Request a password reset email for the specified email address', 183 | tags: ['Authentication'], 184 | body: betterAuthForgotPasswordSchema, 185 | response: { 186 | 200: betterAuthSuccessResponseSchema, 187 | 400: errorResponseSchema, 188 | 404: errorResponseSchema 189 | } 190 | }), 191 | handler: async (request, reply) => { 192 | const url = new URL(request.url, `http://${request.headers.host}`) 193 | const webRequest = new Request(url, { 194 | method: request.method, 195 | headers: request.headers as HeadersInit, 196 | body: JSON.stringify(request.body) 197 | }) 198 | 199 | const response = await auth.handler(webRequest) 200 | const responseBody = await response.text() 201 | 202 | response.headers.forEach((value, key) => { 203 | reply.header(key, value) 204 | }) 205 | 206 | return reply.code(response.status).send(responseBody) 207 | } 208 | }) 209 | 210 | // POST /api/auth/reset-password - Reset password with token 211 | fastify.post('/api/auth/reset-password', { 212 | schema: createRouteSchema({ 213 | description: 'Reset user password using a valid reset token', 214 | tags: ['Authentication'], 215 | body: betterAuthResetPasswordSchema, 216 | response: { 217 | 200: betterAuthSuccessResponseSchema, 218 | 400: errorResponseSchema, 219 | 401: errorResponseSchema 220 | } 221 | }), 222 | handler: async (request, reply) => { 223 | const url = new URL(request.url, `http://${request.headers.host}`) 224 | const webRequest = new Request(url, { 225 | method: request.method, 226 | headers: request.headers as HeadersInit, 227 | body: JSON.stringify(request.body) 228 | }) 229 | 230 | const response = await auth.handler(webRequest) 231 | const responseBody = await response.text() 232 | 233 | response.headers.forEach((value, key) => { 234 | reply.header(key, value) 235 | }) 236 | 237 | return reply.code(response.status).send(responseBody) 238 | } 239 | }) 240 | 241 | // Handle all other better-auth routes as catch-all 242 | // This covers OAuth callbacks, email verification, and other dynamic routes 243 | fastify.all('/api/auth/*', { 244 | schema: createRouteSchema({ 245 | description: 'Better-Auth dynamic routes (OAuth callbacks, email verification, etc.)', 246 | tags: ['Authentication'] 247 | }), 248 | handler: async (request, reply) => { 249 | const url = new URL(request.url, `http://${request.headers.host}`) 250 | const webRequest = new Request(url, { 251 | method: request.method, 252 | headers: request.headers as HeadersInit, 253 | body: request.method !== 'GET' && request.method !== 'HEAD' ? JSON.stringify(request.body) : undefined 254 | }) 255 | 256 | const response = await auth.handler(webRequest) 257 | const responseBody = await response.text() 258 | 259 | response.headers.forEach((value, key) => { 260 | reply.header(key, value) 261 | }) 262 | 263 | return reply.code(response.status).send(responseBody) 264 | } 265 | }) 266 | } 267 | -------------------------------------------------------------------------------- /api/utils/schemas/validation.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod' 2 | 3 | // ============================================================================= 4 | // ZOD VALIDATION SCHEMAS 5 | // All validation schemas centralized here 6 | // Single source of truth for data validation and OpenAPI documentation 7 | // ============================================================================= 8 | 9 | // ============================================================================= 10 | // BASIC FIELD SCHEMAS 11 | // Reusable field-level validation schemas 12 | // ============================================================================= 13 | 14 | /** 15 | * Email validation with transformation 16 | */ 17 | export const emailSchema = z.string().trim().email('Invalid email format').describe('User email address') 18 | 19 | /** 20 | * Name validation schema 21 | */ 22 | export const nameSchema = z 23 | .string() 24 | .min(1, 'Name is required') 25 | .max(100, 'Name too long') 26 | .trim() 27 | .describe('User full name') 28 | 29 | /** 30 | * Password validation schema 31 | */ 32 | export const passwordSchema = z 33 | .string() 34 | .min(8, 'Password must be at least 8 characters') 35 | .max(100, 'Password too long') 36 | .describe('User password') 37 | 38 | /** 39 | * Text ID validation (for URLs and references) 40 | */ 41 | export const textIdSchema = z.string().min(1, 'ID is required').describe('Unique identifier') 42 | 43 | // ============================================================================= 44 | // USER VALIDATION SCHEMAS 45 | // ============================================================================= 46 | 47 | /** 48 | * Schema for creating a new user 49 | */ 50 | export const createUserBodySchema = z 51 | .object({ 52 | name: nameSchema, 53 | email: emailSchema, 54 | password: passwordSchema 55 | }) 56 | .describe('User creation data') 57 | 58 | /** 59 | * Schema for user route parameters (ID) 60 | */ 61 | export const userParamsSchema = z 62 | .object({ 63 | id: textIdSchema 64 | }) 65 | .describe('User route parameters') 66 | 67 | /** 68 | * Schema for safe user data (without password) 69 | */ 70 | export const safeUserSchema = z 71 | .object({ 72 | id: textIdSchema, 73 | name: nameSchema.nullable(), 74 | email: emailSchema, 75 | image: z.url().nullable().describe('User profile image URL'), 76 | createdAt: z.string().datetime().describe('Account creation timestamp') 77 | }) 78 | .describe('Safe user data without sensitive information') 79 | 80 | /** 81 | * Schema for user update data 82 | */ 83 | export const updateUserBodySchema = z 84 | .object({ 85 | name: nameSchema.optional(), 86 | email: emailSchema.optional(), 87 | image: z.url().optional().describe('User profile image URL') 88 | }) 89 | .describe('User update data') 90 | 91 | // ============================================================================= 92 | // NOTE: Session management is handled by Better-Auth JWT 93 | // No custom session schemas needed - removed for simplicity 94 | // ============================================================================= 95 | 96 | // ============================================================================= 97 | // RESPONSE SCHEMAS 98 | // ============================================================================= 99 | 100 | /** 101 | * Factory function for success response schemas 102 | */ 103 | export const createSuccessResponseSchema = (dataSchema: T) => 104 | z 105 | .object({ 106 | success: z.literal(true), 107 | data: dataSchema, 108 | message: z.string().optional().describe('Optional success message') 109 | }) 110 | .describe('Successful API response') 111 | 112 | /** 113 | * User creation response schema 114 | */ 115 | export const createUserResponseSchema = createSuccessResponseSchema(safeUserSchema).describe( 116 | 'User creation success response' 117 | ) 118 | 119 | /** 120 | * Get user response schema 121 | */ 122 | export const getUserResponseSchema = createSuccessResponseSchema(safeUserSchema).describe('Get user success response') 123 | 124 | /** 125 | * Error response schema 126 | */ 127 | export const errorResponseSchema = z 128 | .object({ 129 | success: z.literal(false), 130 | error: z.object({ 131 | message: z.string().describe('Error message'), 132 | code: z.string().describe('Error code'), 133 | statusCode: z.number().describe('HTTP status code'), 134 | timestamp: z.string().datetime().describe('Error timestamp'), 135 | path: z.string().describe('Request path'), 136 | details: z 137 | .array( 138 | z.object({ 139 | field: z.string().describe('Field with error'), 140 | message: z.string().describe('Error message for field'), 141 | code: z.string().describe('Error code for field') 142 | }) 143 | ) 144 | .optional() 145 | .describe('Detailed validation errors') 146 | }) 147 | }) 148 | .describe('Error API response') 149 | 150 | // ============================================================================= 151 | // TEST/UTILITY SCHEMAS 152 | // ============================================================================= 153 | 154 | /** 155 | * Healthcheck response schema 156 | */ 157 | export const healthcheckResponseSchema = z 158 | .object({ 159 | success: z.literal(true), 160 | message: z.string().describe('Health status message') 161 | }) 162 | .describe('Health check response') 163 | 164 | /** 165 | * Identity count request schema (for testing) 166 | */ 167 | export const identityCountBodySchema = z 168 | .object({ 169 | amount: z.number().int().min(0).describe('Count amount') 170 | }) 171 | .describe('Identity count request') 172 | 173 | /** 174 | * Identity count response schema (for testing) 175 | */ 176 | export const identityCountResponseSchema = z 177 | .object({ 178 | success: z.literal(true), 179 | amount: z.number().int().describe('Returned count amount') 180 | }) 181 | .describe('Identity count response') 182 | 183 | // ============================================================================= 184 | // BETTER-AUTH SCHEMAS 185 | // ============================================================================= 186 | 187 | /** 188 | * Better-Auth sign up with email schema 189 | */ 190 | export const betterAuthSignUpSchema = z 191 | .object({ 192 | name: nameSchema, 193 | email: emailSchema, 194 | password: passwordSchema, 195 | callbackURL: z.url().optional().describe('URL to redirect after verification') 196 | }) 197 | .describe('Better-Auth email signup data') 198 | 199 | /** 200 | * Better-Auth sign in with email schema 201 | */ 202 | export const betterAuthSignInSchema = z 203 | .object({ 204 | email: emailSchema, 205 | password: passwordSchema, 206 | rememberMe: z.boolean().default(true).describe('Remember user session'), 207 | callbackURL: z.url().optional().describe('URL to redirect after sign in') 208 | }) 209 | .describe('Better-Auth email signin data') 210 | 211 | /** 212 | * Better-Auth social sign in schema 213 | */ 214 | export const betterAuthSocialSignInSchema = z 215 | .object({ 216 | provider: z.enum(['google', 'github', 'discord', 'apple']).describe('Social provider'), 217 | callbackURL: z.url().optional().describe('URL to redirect after sign in'), 218 | errorCallbackURL: z.url().optional().describe('URL to redirect on error'), 219 | newUserCallbackURL: z.url().optional().describe('URL to redirect for new users') 220 | }) 221 | .describe('Better-Auth social signin data') 222 | 223 | /** 224 | * Better-Auth forgot password schema 225 | */ 226 | export const betterAuthForgotPasswordSchema = z 227 | .object({ 228 | email: emailSchema, 229 | redirectTo: z.url().optional().describe('URL to redirect after reset') 230 | }) 231 | .describe('Better-Auth forgot password data') 232 | 233 | /** 234 | * Better-Auth reset password schema 235 | */ 236 | export const betterAuthResetPasswordSchema = z 237 | .object({ 238 | token: z.string().min(1, 'Reset token is required').describe('Password reset token'), 239 | password: passwordSchema 240 | }) 241 | .describe('Better-Auth reset password data') 242 | 243 | /** 244 | * Better-Auth session response schema 245 | */ 246 | export const betterAuthSessionResponseSchema = z 247 | .object({ 248 | user: safeUserSchema.nullable(), 249 | session: z 250 | .object({ 251 | id: textIdSchema, 252 | userId: textIdSchema, 253 | expiresAt: z.string().datetime().describe('Session expiration'), 254 | token: z.string().describe('Session token'), 255 | ipAddress: z.string().optional().describe('Client IP'), 256 | userAgent: z.string().optional().describe('Client user agent') 257 | }) 258 | .nullable() 259 | }) 260 | .describe('Better-Auth session data') 261 | 262 | /** 263 | * Better-Auth success response schema (for operations like signout) 264 | */ 265 | export const betterAuthSuccessResponseSchema = z 266 | .object({ 267 | success: z.literal(true), 268 | message: z.string().optional().describe('Operation success message') 269 | }) 270 | .describe('Better-Auth operation success response') 271 | 272 | // ============================================================================= 273 | // PAGINATION SCHEMAS 274 | // ============================================================================= 275 | 276 | /** 277 | * Pagination query parameters schema 278 | */ 279 | export const paginationQuerySchema = z 280 | .object({ 281 | page: z.number().int().min(1).default(1).describe('Page number'), 282 | limit: z.number().int().min(1).max(100).default(10).describe('Items per page'), 283 | sortBy: z.string().optional().describe('Sort field'), 284 | sortOrder: z.enum(['asc', 'desc']).default('desc').describe('Sort order'), 285 | search: z.string().optional().describe('Search term') 286 | }) 287 | .describe('Pagination query parameters') 288 | 289 | /** 290 | * Paginated response schema factory 291 | */ 292 | export const createPaginatedResponseSchema = (itemSchema: T) => 293 | z 294 | .object({ 295 | success: z.literal(true), 296 | data: z.object({ 297 | items: z.array(itemSchema).describe('Page items'), 298 | pagination: z.object({ 299 | page: z.number().int().describe('Current page'), 300 | limit: z.number().int().describe('Items per page'), 301 | total: z.number().int().describe('Total items'), 302 | totalPages: z.number().int().describe('Total pages'), 303 | hasNext: z.boolean().describe('Has next page'), 304 | hasPrev: z.boolean().describe('Has previous page') 305 | }) 306 | }) 307 | }) 308 | .describe('Paginated API response') 309 | 310 | // ============================================================================= 311 | // VALIDATION UTILITIES 312 | // ============================================================================= 313 | 314 | /** 315 | * Export all schemas for easy access 316 | */ 317 | export const validationSchemas = { 318 | // Field schemas 319 | emailSchema, 320 | nameSchema, 321 | passwordSchema, 322 | textIdSchema, 323 | 324 | // User schemas 325 | createUserBodySchema, 326 | userParamsSchema, 327 | safeUserSchema, 328 | updateUserBodySchema, 329 | 330 | // Note: Custom session schemas removed - using Better-Auth JWT only 331 | 332 | // Response schemas 333 | createUserResponseSchema, 334 | getUserResponseSchema, 335 | errorResponseSchema, 336 | 337 | // Test schemas 338 | healthcheckResponseSchema, 339 | identityCountBodySchema, 340 | identityCountResponseSchema, 341 | 342 | // Pagination schemas 343 | paginationQuerySchema, 344 | 345 | // Better-Auth schemas 346 | betterAuthSignUpSchema, 347 | betterAuthSignInSchema, 348 | betterAuthSocialSignInSchema, 349 | betterAuthForgotPasswordSchema, 350 | betterAuthResetPasswordSchema, 351 | betterAuthSessionResponseSchema, 352 | betterAuthSuccessResponseSchema 353 | } as const 354 | 355 | /** 356 | * Type definitions for schemas 357 | */ 358 | export type CreateUserBody = z.infer 359 | export type UserParams = z.infer 360 | export type SafeUserFromSchema = z.infer 361 | export type UpdateUserBody = z.infer 362 | // Note: Custom session types removed - using Better-Auth JWT only 363 | export type PaginationQuery = z.infer 364 | export type BetterAuthSignUp = z.infer 365 | export type BetterAuthSignIn = z.infer 366 | export type BetterAuthSocialSignIn = z.infer 367 | export type BetterAuthForgotPassword = z.infer 368 | export type BetterAuthResetPassword = z.infer 369 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **Modern Full Stack Architecture Starter** 2 | 3 | A comprehensive, production-ready full-stack starter template designed for modern web development. This project serves as an excellent learning resource and starting point for developers working with cutting-edge technologies. 4 | 5 | ## **🚀 Technology Stack** 6 | 7 | ### **Frontend** 8 | - **[Next.js 16](https://nextjs.org/)** - The React framework for production with App Router 9 | - **[React 19](https://react.dev/)** - A JavaScript library for building user interfaces 10 | - **[TailwindCSS 4](https://tailwindcss.com/)** - Utility-first CSS framework 11 | - **[TypeScript](https://www.typescriptlang.org/)** - Type-safe JavaScript 12 | - **[Redux Toolkit](https://redux-toolkit.js.org/)** - Modern Redux state management 13 | - **[Shadcn/UI](https://ui.shadcn.com/)** - Beautifully designed components built with Radix UI 14 | - **[Radix UI](https://www.radix-ui.com/)** - Accessible UI component primitives 15 | - **[next-intl](https://next-intl-docs.vercel.app/)** - Internationalization for Next.js 16 | - **[Lucide React](https://lucide.dev/)** - Beautiful & consistent icon toolkit 17 | 18 | ### **Backend** 19 | - **[Fastify](https://www.fastify.io/)** - Fast and low overhead web framework 20 | - **[Drizzle ORM](https://orm.drizzle.team/)** - TypeScript ORM with excellent performance 21 | - **[Better-Auth](https://www.better-auth.com/)** - Modern authentication library 22 | - **[Zod](https://zod.dev/)** - TypeScript-first schema validation 23 | - **[Pino](https://getpino.io/#/)** - Super fast JSON logger 24 | - **[OpenAPI](https://swagger.io/)** - API documentation standard 25 | - **[Scalar](https://scalar.com/)** - Beautiful API documentation 26 | 27 | ### **Infrastructure & DevOps** 28 | - **[Docker](https://www.docker.com/)** - Containerization platform 29 | - **[Traefik](https://doc.traefik.io/traefik/)** - Modern reverse proxy and load balancer 30 | - **[Valkey](https://valkey.dev/)** - High-performance in-memory data store 31 | - **[PostgreSQL](https://www.postgresql.org/)** - Robust relational database 32 | - **[ELK Stack](https://www.elastic.co/what-is/elk-stack)** - Elasticsearch, Logstash, Kibana for monitoring 33 | 34 | ### **Code Quality & Development** 35 | - **[Biome](https://biomejs.dev/)** - Ultra-fast formatter and linter written in Rust 36 | - **[TypeScript](https://www.typescriptlang.org/)** - Static type checking 37 | - **[Turbopack](https://turbo.build/pack)** - Next.js Turbo build system 38 | - **Multi-stage Docker builds** - Optimized container images 39 | - **Hot reloading** - Fast development experience 40 | 41 | This setup provides a complete, production-ready foundation for modern web applications with excellent developer experience and performance. 42 | 43 | --- 44 | 45 | ## **🚀 Quick Start** 46 | 47 | ### **Prerequisites** 48 | - Docker and Docker Compose 49 | - Node.js 18+ (for local development) 50 | - Git 51 | 52 | ### **1. Clone and Setup** 53 | ```bash 54 | git clone 55 | cd fullstack_basic_starter 56 | ``` 57 | 58 | ### **2. Environment Configuration** 59 | ```bash 60 | # Copy environment template 61 | cp .env.placeholder .env 62 | 63 | # Edit environment variables 64 | nano .env 65 | ``` 66 | 67 | ### **3. Start the Application** 68 | 69 | The `start.sh` script provides a convenient way to start the application with various options: 70 | 71 | ```bash 72 | # Start in development mode (default) 73 | ./start.sh 74 | 75 | # Start in production mode 76 | ./start.sh -e production 77 | 78 | # Start in development with watch mode (auto-reload on changes) 79 | ./start.sh -w 80 | 81 | # Clean build (removes existing containers and images) 82 | ./start.sh -c 83 | 84 | # Combine options 85 | ./start.sh -e production -c 86 | 87 | # Show all available options 88 | ./start.sh -h 89 | ``` 90 | 91 | **Available Options:** 92 | - `-e, --env` - Set environment (development|production) [default: development] 93 | - `-w, --watch` - Enable watch mode for automatic reload during development 94 | - `-c, --clean` - Clean build (removes existing containers and images before starting) 95 | - `-h, --help` - Display help message with all options 96 | 97 | **Manual Docker Compose:** 98 | ```bash 99 | # Without the start.sh script 100 | docker compose -f compose.yaml -f compose_override/development.yaml up --build 101 | ``` 102 | 103 | ### **4. Initialize Database** 104 | ```bash 105 | # Populate database with initial data 106 | ./populate.sh 107 | ``` 108 | 109 | ### **5. Development Commands** 110 | ```bash 111 | # Frontend development 112 | cd app 113 | npm run dev 114 | 115 | # Backend development 116 | cd api 117 | npm run dev 118 | 119 | # Linting and formatting (using Biome) 120 | cd app # or cd api 121 | npm run check # Check and auto-fix 122 | npm run format # Format code 123 | npm run lint # Lint only 124 | ``` 125 | 126 | --- 127 | 128 | ## **🌐 Available Services** 129 | 130 | Once the application is running, you can access the following services: 131 | 132 | | Service | URL | Description | 133 | |---------|-----|-------------| 134 | | **Frontend** | [http://localhost](http://localhost) | Next.js application with React 19 | 135 | | **API** | [http://localhost/api](http://localhost/api) | Fastify REST API with OpenAPI docs | 136 | | **API Docs** | [http://localhost/reference](http://localhost/reference) | Interactive Scalar API documentation | 137 | | **Traefik Dashboard** | [http://localhost:8080](http://localhost:8080) | Reverse proxy management interface | 138 | | **Elasticsearch** | [http://localhost:9200](http://localhost:9200) | Search and analytics engine | 139 | | **Kibana** | [http://localhost:5601](http://localhost:5601) | Data visualization and monitoring | 140 | 141 | ## **📁 Project Structure** 142 | 143 | ``` 144 | fullstack_basic_starter/ 145 | ├── app/ # Next.js frontend application 146 | │ ├── src/ 147 | │ │ ├── app/ # App Router pages and layouts 148 | │ │ ├── components/ # React components 149 | │ │ ├── redux/ # Redux Toolkit state management 150 | │ │ ├── styles/ # Global styles 151 | │ │ ├── i18n/ # Internationalization setup 152 | │ │ └── utils/ # Utility functions and helpers 153 | │ ├── public/ # Static assets 154 | │ ├── messages/ # Translation files 155 | │ └── Dockerfile # Frontend container configuration 156 | ├── api/ # Fastify backend API 157 | │ ├── routes/ # API route handlers 158 | │ ├── services/ # Business logic services 159 | │ ├── middleware/ # Fastify middleware 160 | │ ├── utils/ # Utility functions 161 | │ └── Dockerfile # Backend container configuration 162 | ├── backoffice/ # Admin dashboard (future implementation) 163 | ├── compose_override/ # Docker Compose environment overrides 164 | │ ├── development.yaml # Development configuration 165 | │ └── production.yaml # Production configuration 166 | ├── compose.yaml # Base Docker Compose configuration 167 | ├── biome.json # Biome formatter/linter configuration 168 | ├── start.sh # Application startup script 169 | └── README.md # This file 170 | ``` 171 | 172 | --- 173 | 174 | ## **✨ Key Features** 175 | 176 | ### **Frontend Features** 177 | - 🎨 **Modern UI** - TailwindCSS 4 with Shadcn/UI components built on Radix UI 178 | - 🌍 **Internationalization** - Multi-language support with next-intl 179 | - 📱 **Responsive Design** - Mobile-first approach 180 | - 🔄 **State Management** - Redux Toolkit for predictable state updates 181 | - 🎯 **Type Safety** - Full TypeScript support 182 | - ⚡ **Performance** - Optimized with Next.js App Router, React Server Components, and Turbopack 183 | 184 | ### **Backend Features** 185 | - 🚀 **High Performance** - Fastify with excellent benchmarks 186 | - 🔐 **Authentication** - Better-Auth with modern security 187 | - 📊 **API Documentation** - Auto-generated OpenAPI/Scalar docs 188 | - 🗄️ **Database ORM** - Drizzle ORM with type safety 189 | - 📝 **Logging** - Structured logging with Pino 190 | - ✅ **Validation** - Zod schema validation 191 | 192 | ### **DevOps & Quality** 193 | - 🐳 **Containerized** - Multi-stage Docker builds 194 | - 🔍 **Code Quality** - Biome for ultra-fast linting and formatting 195 | - 📈 **Monitoring** - ELK stack for observability 196 | - 🔄 **Hot Reload** - Fast development with Turbopack 197 | - 🛡️ **Security** - Production-ready configurations 198 | 199 | ## **🎯 Learning Opportunities** 200 | 201 | This project is perfect for learning modern web development concepts: 202 | 203 | 1. **Database Transactions** - Implement with [Drizzle ORM transactions](https://orm.drizzle.team/docs/transactions) 204 | 2. **Admin Dashboard** - Complete the backoffice implementation with user management 205 | 3. **Real-time Features** - Add WebSocket support with Socket.io 206 | 4. **Testing** - Implement unit and integration tests with Vitest/Jest 207 | 5. **CI/CD** - Set up GitHub Actions workflows 208 | 6. **Advanced Auth** - OAuth providers, 2FA, session management with Better-Auth 209 | 7. **Server Components** - Leverage Next.js Server Components for optimal performance 210 | 211 | ## **🚀 Future Enhancements** 212 | 213 | - [ ] **Testing Suite** - Jest/Vitest integration 214 | - [ ] **CI/CD Pipeline** - GitHub Actions workflows 215 | - [ ] **Real-time Features** - WebSocket support 216 | - [ ] **Advanced Monitoring** - APM and metrics 217 | - [ ] **Microservices** - Service decomposition 218 | - [ ] **Cloud Deployment** - Kubernetes manifests 219 | 220 | --- 221 | 222 | ## **🛠️ Development** 223 | 224 | ### **Code Quality** 225 | ```bash 226 | # Check, lint, and format with Biome 227 | cd app # or cd api 228 | npm run check # Run checks and auto-fix issues 229 | npm run format # Format code only 230 | npm run lint # Lint code only 231 | 232 | # Type check 233 | npx tsc --noEmit 234 | ``` 235 | 236 | ### **Docker Commands** 237 | 238 | **Basic Operations:** 239 | ```bash 240 | # Build images for development environment 241 | docker compose -f compose.yaml -f compose_override/development.yaml build 242 | 243 | # Start services (detached mode) 244 | docker compose -f compose.yaml -f compose_override/development.yaml up -d 245 | 246 | # Start services with build and watch logs 247 | docker compose -f compose.yaml -f compose_override/development.yaml up --build 248 | 249 | # Stop services 250 | docker compose -f compose.yaml -f compose_override/development.yaml down 251 | 252 | # Stop services and remove volumes 253 | docker compose -f compose.yaml -f compose_override/development.yaml down -v 254 | ``` 255 | 256 | **Service Management:** 257 | ```bash 258 | # Start specific service 259 | docker compose -f compose.yaml -f compose_override/development.yaml up app 260 | 261 | # Restart a specific service 262 | docker compose -f compose.yaml -f compose_override/development.yaml restart api 263 | 264 | # Rebuild and restart a specific service 265 | docker compose -f compose.yaml -f compose_override/development.yaml up --build -d app 266 | 267 | # Scale a service (if applicable) 268 | docker compose -f compose.yaml -f compose_override/development.yaml up -d --scale api=3 269 | ``` 270 | 271 | **Logs and Monitoring:** 272 | ```bash 273 | # View all logs (follow mode) 274 | docker compose -f compose.yaml -f compose_override/development.yaml logs -f 275 | 276 | # View logs for specific service 277 | docker compose -f compose.yaml -f compose_override/development.yaml logs -f app 278 | 279 | # View last 100 lines of logs 280 | docker compose -f compose.yaml -f compose_override/development.yaml logs --tail=100 281 | 282 | # View logs with timestamps 283 | docker compose -f compose.yaml -f compose_override/development.yaml logs -f -t 284 | ``` 285 | 286 | **Debugging and Maintenance:** 287 | ```bash 288 | # Execute command in running container 289 | docker compose -f compose.yaml -f compose_override/development.yaml exec api sh 290 | 291 | # List all running containers 292 | docker compose -f compose.yaml -f compose_override/development.yaml ps 293 | 294 | # Check service status 295 | docker compose -f compose.yaml -f compose_override/development.yaml ps -a 296 | 297 | # View container resource usage 298 | docker stats 299 | 300 | # Run commands in a specific service 301 | docker compose -f compose.yaml -f compose_override/development.yaml exec app npm run build 302 | ``` 303 | 304 | **Cleanup:** 305 | ```bash 306 | # Remove stopped containers 307 | docker compose -f compose.yaml -f compose_override/development.yaml rm 308 | 309 | # Remove all containers, networks, and volumes 310 | docker compose -f compose.yaml -f compose_override/development.yaml down -v --remove-orphans 311 | 312 | # Clean up Docker system (use with caution) 313 | docker system prune -f 314 | 315 | # Remove all unused images 316 | docker image prune -a 317 | 318 | # Remove specific image 319 | docker rmi 320 | ``` 321 | 322 | **Production Environment:** 323 | ```bash 324 | # Start in production mode 325 | docker compose -f compose.yaml -f compose_override/production.yaml up --build -d 326 | 327 | # View production logs 328 | docker compose -f compose.yaml -f compose_override/production.yaml logs -f 329 | 330 | # Stop production services 331 | docker compose -f compose.yaml -f compose_override/production.yaml down 332 | ``` 333 | 334 | ## **🤝 Contributing** 335 | 336 | We welcome contributions! Here's how you can help: 337 | 338 | 1. **Fork** the repository 339 | 2. **Create** a feature branch (`git checkout -b feature/amazing-feature`) 340 | 3. **Commit** your changes (`git commit -m 'Add amazing feature'`) 341 | 4. **Push** to the branch (`git push origin feature/amazing-feature`) 342 | 5. **Open** a Pull Request 343 | 344 | ### **Development Guidelines** 345 | - Follow the existing code style 346 | - Add tests for new features 347 | - Update documentation as needed 348 | - Ensure all checks pass 349 | 350 | ## **📄 License** 351 | 352 | This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. 353 | 354 | ## **🙏 Acknowledgments** 355 | 356 | - [Next.js Team](https://nextjs.org/) for the amazing framework 357 | - [React Team](https://react.dev/) for the powerful UI library 358 | - [Fastify Team](https://www.fastify.io/) for the high-performance server 359 | - [Biome Team](https://biomejs.dev/) for the ultra-fast formatter and linter 360 | - All contributors and the open-source community 361 | 362 | --- 363 | 364 | **⭐ Star this repository if you find it helpful!** 365 | -------------------------------------------------------------------------------- /api/Starter.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "894dbd5b-95ff-472d-9cb5-8f05d4d8c734", 4 | "name": "Fullstack Starter API - Better Auth & JWT", 5 | "description": "Comprehensive API testing collection for the Fullstack Starter project with Better-Auth JWT integration. Clean architecture with proper error handling and validation.", 6 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 7 | "_exporter_id": "6610193" 8 | }, 9 | "variable": [ 10 | { 11 | "key": "baseUrl", 12 | "value": "http://localhost", 13 | "type": "string" 14 | }, 15 | { 16 | "key": "testEmail", 17 | "value": "test@example.com", 18 | "type": "string" 19 | }, 20 | { 21 | "key": "testPassword", 22 | "value": "TestPass123!", 23 | "type": "string" 24 | }, 25 | { 26 | "key": "testName", 27 | "value": "Test User", 28 | "type": "string" 29 | }, 30 | { 31 | "key": "testEmail2", 32 | "value": "test2@example.com", 33 | "type": "string" 34 | }, 35 | { 36 | "key": "authToken", 37 | "value": "", 38 | "type": "string", 39 | "description": "JWT token from better-auth" 40 | }, 41 | { 42 | "key": "userId", 43 | "value": "", 44 | "type": "string" 45 | }, 46 | { 47 | "key": "createdUserId", 48 | "value": "", 49 | "type": "string" 50 | } 51 | ], 52 | "item": [ 53 | { 54 | "name": "Health Check", 55 | "item": [ 56 | { 57 | "name": "Ping - Basic Health Check", 58 | "event": [ 59 | { 60 | "listen": "test", 61 | "script": { 62 | "exec": [ 63 | "pm.test('Status code is 200', function () {", 64 | " pm.response.to.have.status(200);", 65 | "});", 66 | "", 67 | "pm.test('Response has success property', function () {", 68 | " const jsonData = pm.response.json();", 69 | " pm.expect(jsonData).to.have.property('success', true);", 70 | "});" 71 | ], 72 | "type": "text/javascript" 73 | } 74 | } 75 | ], 76 | "request": { 77 | "method": "GET", 78 | "header": [], 79 | "url": { 80 | "raw": "{{baseUrl}}/api/healthcheck/ping", 81 | "host": ["{{baseUrl}}"], 82 | "path": ["api", "healthcheck", "ping"] 83 | } 84 | }, 85 | "response": [] 86 | } 87 | ] 88 | }, 89 | { 90 | "name": "Authentication", 91 | "item": [ 92 | { 93 | "name": "Sign Up with Email", 94 | "event": [ 95 | { 96 | "listen": "test", 97 | "script": { 98 | "exec": [ 99 | "pm.test('Status code is 200', function () {", 100 | " pm.response.to.have.status(200);", 101 | "});", 102 | "", 103 | "pm.test('Response contains user data', function () {", 104 | " const jsonData = pm.response.json();", 105 | " pm.expect(jsonData).to.have.property('user');", 106 | " pm.expect(jsonData.user).to.have.property('email', pm.variables.get('testEmail'));", 107 | " ", 108 | " // Store user ID for future requests", 109 | " if (jsonData.user && jsonData.user.id) {", 110 | " pm.collectionVariables.set('userId', jsonData.user.id);", 111 | " }", 112 | "});", 113 | "", 114 | "pm.test('Sets authentication token', function () {", 115 | " const cookies = pm.cookies.toObject();", 116 | " if (cookies['better-auth.session_token']) {", 117 | " pm.collectionVariables.set('authToken', cookies['better-auth.session_token']);", 118 | " }", 119 | "});" 120 | ], 121 | "type": "text/javascript" 122 | } 123 | } 124 | ], 125 | "request": { 126 | "method": "POST", 127 | "header": [ 128 | { 129 | "key": "Content-Type", 130 | "value": "application/json" 131 | } 132 | ], 133 | "body": { 134 | "mode": "raw", 135 | "raw": "{\n \"name\": \"{{testName}}\",\n \"email\": \"{{testEmail}}\",\n \"password\": \"{{testPassword}}\"\n}" 136 | }, 137 | "url": { 138 | "raw": "{{baseUrl}}/api/auth/sign-up/email", 139 | "host": ["{{baseUrl}}"], 140 | "path": ["api", "auth", "sign-up", "email"] 141 | } 142 | }, 143 | "response": [] 144 | }, 145 | { 146 | "name": "Sign In with Email", 147 | "event": [ 148 | { 149 | "listen": "test", 150 | "script": { 151 | "exec": [ 152 | "pm.test('Status code is 200', function () {", 153 | " pm.response.to.have.status(200);", 154 | "});", 155 | "", 156 | "pm.test('Response contains user data', function () {", 157 | " const jsonData = pm.response.json();", 158 | " pm.expect(jsonData).to.have.property('user');", 159 | " pm.expect(jsonData.user).to.have.property('email');", 160 | "});", 161 | "", 162 | "pm.test('Sets authentication token', function () {", 163 | " const cookies = pm.cookies.toObject();", 164 | " if (cookies['better-auth.session_token']) {", 165 | " pm.collectionVariables.set('authToken', cookies['better-auth.session_token']);", 166 | " }", 167 | "});" 168 | ], 169 | "type": "text/javascript" 170 | } 171 | } 172 | ], 173 | "request": { 174 | "method": "POST", 175 | "header": [ 176 | { 177 | "key": "Content-Type", 178 | "value": "application/json" 179 | } 180 | ], 181 | "body": { 182 | "mode": "raw", 183 | "raw": "{\n \"email\": \"{{testEmail}}\",\n \"password\": \"{{testPassword}}\",\n \"rememberMe\": true\n}" 184 | }, 185 | "url": { 186 | "raw": "{{baseUrl}}/api/auth/sign-in/email", 187 | "host": ["{{baseUrl}}"], 188 | "path": ["api", "auth", "sign-in", "email"] 189 | } 190 | }, 191 | "response": [] 192 | }, 193 | { 194 | "name": "Get Current Session", 195 | "event": [ 196 | { 197 | "listen": "test", 198 | "script": { 199 | "exec": [ 200 | "pm.test('Status code is 200', function () {", 201 | " pm.response.to.have.status(200);", 202 | "});", 203 | "", 204 | "pm.test('Response contains session data', function () {", 205 | " const jsonData = pm.response.json();", 206 | " pm.expect(jsonData).to.have.property('user');", 207 | " if (jsonData.user) {", 208 | " pm.expect(jsonData.user).to.have.property('email');", 209 | " }", 210 | "});" 211 | ], 212 | "type": "text/javascript" 213 | } 214 | } 215 | ], 216 | "request": { 217 | "method": "GET", 218 | "header": [ 219 | { 220 | "key": "Cookie", 221 | "value": "better-auth.session_token={{authToken}}", 222 | "type": "text" 223 | } 224 | ], 225 | "url": { 226 | "raw": "{{baseUrl}}/api/auth/get-session", 227 | "host": ["{{baseUrl}}"], 228 | "path": ["api", "auth", "get-session"] 229 | } 230 | }, 231 | "response": [] 232 | }, 233 | { 234 | "name": "Sign Out", 235 | "event": [ 236 | { 237 | "listen": "test", 238 | "script": { 239 | "exec": [ 240 | "pm.test('Status code is 200', function () {", 241 | " pm.response.to.have.status(200);", 242 | "});", 243 | "", 244 | "pm.test('Response indicates success', function () {", 245 | " const jsonData = pm.response.json();", 246 | " pm.expect(jsonData).to.have.property('success', true);", 247 | "});", 248 | "", 249 | "// Clear stored token", 250 | "pm.collectionVariables.set('authToken', '');" 251 | ], 252 | "type": "text/javascript" 253 | } 254 | } 255 | ], 256 | "request": { 257 | "method": "POST", 258 | "header": [ 259 | { 260 | "key": "Cookie", 261 | "value": "better-auth.session_token={{authToken}}", 262 | "type": "text" 263 | } 264 | ], 265 | "url": { 266 | "raw": "{{baseUrl}}/api/auth/sign-out", 267 | "host": ["{{baseUrl}}"], 268 | "path": ["api", "auth", "sign-out"] 269 | } 270 | }, 271 | "response": [] 272 | }, 273 | { 274 | "name": "Forgot Password", 275 | "event": [ 276 | { 277 | "listen": "test", 278 | "script": { 279 | "exec": [ 280 | "pm.test('Status code is 200', function () {", 281 | " pm.response.to.have.status(200);", 282 | "});", 283 | "", 284 | "pm.test('Response indicates success', function () {", 285 | " const jsonData = pm.response.json();", 286 | " pm.expect(jsonData).to.have.property('success', true);", 287 | "});" 288 | ], 289 | "type": "text/javascript" 290 | } 291 | } 292 | ], 293 | "request": { 294 | "method": "POST", 295 | "header": [ 296 | { 297 | "key": "Content-Type", 298 | "value": "application/json" 299 | } 300 | ], 301 | "body": { 302 | "mode": "raw", 303 | "raw": "{\n \"email\": \"{{testEmail}}\"\n}" 304 | }, 305 | "url": { 306 | "raw": "{{baseUrl}}/api/auth/forgot-password", 307 | "host": ["{{baseUrl}}"], 308 | "path": ["api", "auth", "forgot-password"] 309 | } 310 | }, 311 | "response": [] 312 | }, 313 | { 314 | "name": "Sign In - Invalid Credentials", 315 | "event": [ 316 | { 317 | "listen": "test", 318 | "script": { 319 | "exec": [ 320 | "pm.test('Status code is 401', function () {", 321 | " pm.response.to.have.status(401);", 322 | "});", 323 | "", 324 | "pm.test('Response contains error', function () {", 325 | " const jsonData = pm.response.json();", 326 | " pm.expect(jsonData).to.have.property('success', false);", 327 | " pm.expect(jsonData).to.have.property('error');", 328 | "});" 329 | ], 330 | "type": "text/javascript" 331 | } 332 | } 333 | ], 334 | "request": { 335 | "method": "POST", 336 | "header": [ 337 | { 338 | "key": "Content-Type", 339 | "value": "application/json" 340 | } 341 | ], 342 | "body": { 343 | "mode": "raw", 344 | "raw": "{\n \"email\": \"{{testEmail}}\",\n \"password\": \"wrongpassword\"\n}" 345 | }, 346 | "url": { 347 | "raw": "{{baseUrl}}/api/auth/sign-in/email", 348 | "host": ["{{baseUrl}}"], 349 | "path": ["api", "auth", "sign-in", "email"] 350 | } 351 | }, 352 | "response": [] 353 | } 354 | ] 355 | }, 356 | { 357 | "name": "Users", 358 | "item": [ 359 | { 360 | "name": "Create User (Direct API)", 361 | "event": [ 362 | { 363 | "listen": "test", 364 | "script": { 365 | "exec": [ 366 | "pm.test('Status code is 200', function () {", 367 | " pm.response.to.have.status(200);", 368 | "});", 369 | "", 370 | "pm.test('Response contains user data', function () {", 371 | " const jsonData = pm.response.json();", 372 | " pm.expect(jsonData).to.have.property('success', true);", 373 | " pm.expect(jsonData).to.have.property('data');", 374 | " pm.expect(jsonData.data).to.have.property('email', pm.variables.get('testEmail2'));", 375 | " ", 376 | " // Store created user ID", 377 | " if (jsonData.data && jsonData.data.id) {", 378 | " pm.collectionVariables.set('createdUserId', jsonData.data.id);", 379 | " }", 380 | "});" 381 | ], 382 | "type": "text/javascript" 383 | } 384 | } 385 | ], 386 | "request": { 387 | "method": "POST", 388 | "header": [ 389 | { 390 | "key": "Content-Type", 391 | "value": "application/json" 392 | } 393 | ], 394 | "body": { 395 | "mode": "raw", 396 | "raw": "{\n \"name\": \"Another Test User\",\n \"email\": \"{{testEmail2}}\",\n \"password\": \"{{testPassword}}\"\n}" 397 | }, 398 | "url": { 399 | "raw": "{{baseUrl}}/api/users", 400 | "host": ["{{baseUrl}}"], 401 | "path": ["api", "users"] 402 | } 403 | }, 404 | "response": [] 405 | }, 406 | { 407 | "name": "Create User - Invalid Email", 408 | "event": [ 409 | { 410 | "listen": "test", 411 | "script": { 412 | "exec": [ 413 | "pm.test('Status code is 400', function () {", 414 | " pm.response.to.have.status(400);", 415 | "});", 416 | "", 417 | "pm.test('Response contains validation error', function () {", 418 | " const jsonData = pm.response.json();", 419 | " pm.expect(jsonData).to.have.property('success', false);", 420 | " pm.expect(jsonData).to.have.property('error');", 421 | "});" 422 | ], 423 | "type": "text/javascript" 424 | } 425 | } 426 | ], 427 | "request": { 428 | "method": "POST", 429 | "header": [ 430 | { 431 | "key": "Content-Type", 432 | "value": "application/json" 433 | } 434 | ], 435 | "body": { 436 | "mode": "raw", 437 | "raw": "{\n \"name\": \"Test User\",\n \"email\": \"invalid-email\",\n \"password\": \"{{testPassword}}\"\n}" 438 | }, 439 | "url": { 440 | "raw": "{{baseUrl}}/api/users", 441 | "host": ["{{baseUrl}}"], 442 | "path": ["api", "users"] 443 | } 444 | }, 445 | "response": [] 446 | }, 447 | { 448 | "name": "Get User by ID - Authenticated", 449 | "event": [ 450 | { 451 | "listen": "test", 452 | "script": { 453 | "exec": [ 454 | "pm.test('Status code is 200', function () {", 455 | " pm.response.to.have.status(200);", 456 | "});", 457 | "", 458 | "pm.test('Response contains user data', function () {", 459 | " const jsonData = pm.response.json();", 460 | " pm.expect(jsonData).to.have.property('success', true);", 461 | " pm.expect(jsonData).to.have.property('data');", 462 | " pm.expect(jsonData.data).to.have.property('id');", 463 | " pm.expect(jsonData.data).to.have.property('email');", 464 | "});" 465 | ], 466 | "type": "text/javascript" 467 | } 468 | } 469 | ], 470 | "request": { 471 | "method": "GET", 472 | "header": [ 473 | { 474 | "key": "Cookie", 475 | "value": "better-auth.session_token={{authToken}}", 476 | "type": "text" 477 | } 478 | ], 479 | "url": { 480 | "raw": "{{baseUrl}}/api/users/{{userId}}", 481 | "host": ["{{baseUrl}}"], 482 | "path": ["api", "users", "{{userId}}"] 483 | } 484 | }, 485 | "response": [] 486 | }, 487 | { 488 | "name": "Get User by ID - Unauthenticated", 489 | "event": [ 490 | { 491 | "listen": "test", 492 | "script": { 493 | "exec": [ 494 | "pm.test('Status code is 401', function () {", 495 | " pm.response.to.have.status(401);", 496 | "});", 497 | "", 498 | "pm.test('Response contains authentication error', function () {", 499 | " const jsonData = pm.response.json();", 500 | " pm.expect(jsonData).to.have.property('success', false);", 501 | " pm.expect(jsonData).to.have.property('error');", 502 | "});" 503 | ], 504 | "type": "text/javascript" 505 | } 506 | } 507 | ], 508 | "request": { 509 | "method": "GET", 510 | "header": [], 511 | "url": { 512 | "raw": "{{baseUrl}}/api/users/{{userId}}", 513 | "host": ["{{baseUrl}}"], 514 | "path": ["api", "users", "{{userId}}"] 515 | } 516 | }, 517 | "response": [] 518 | } 519 | ] 520 | }, 521 | { 522 | "name": "Testing", 523 | "item": [ 524 | { 525 | "name": "Identity Count - Valid", 526 | "event": [ 527 | { 528 | "listen": "test", 529 | "script": { 530 | "exec": [ 531 | "pm.test('Status code is 200', function () {", 532 | " pm.response.to.have.status(200);", 533 | "});", 534 | "", 535 | "pm.test('Response contains amount', function () {", 536 | " const jsonData = pm.response.json();", 537 | " pm.expect(jsonData).to.have.property('success', true);", 538 | " pm.expect(jsonData).to.have.property('amount', 5);", 539 | "});" 540 | ], 541 | "type": "text/javascript" 542 | } 543 | } 544 | ], 545 | "request": { 546 | "method": "POST", 547 | "header": [ 548 | { 549 | "key": "Content-Type", 550 | "value": "application/json" 551 | } 552 | ], 553 | "body": { 554 | "mode": "raw", 555 | "raw": "{\n \"amount\": 5\n}" 556 | }, 557 | "url": { 558 | "raw": "{{baseUrl}}/api/identity-count", 559 | "host": ["{{baseUrl}}"], 560 | "path": ["api", "identity-count"] 561 | } 562 | }, 563 | "response": [] 564 | }, 565 | { 566 | "name": "Identity Count - Invalid Input", 567 | "event": [ 568 | { 569 | "listen": "test", 570 | "script": { 571 | "exec": [ 572 | "pm.test('Status code is 400', function () {", 573 | " pm.response.to.have.status(400);", 574 | "});", 575 | "", 576 | "pm.test('Response contains validation error', function () {", 577 | " const jsonData = pm.response.json();", 578 | " pm.expect(jsonData).to.have.property('success', false);", 579 | " pm.expect(jsonData).to.have.property('error');", 580 | "});" 581 | ], 582 | "type": "text/javascript" 583 | } 584 | } 585 | ], 586 | "request": { 587 | "method": "POST", 588 | "header": [ 589 | { 590 | "key": "Content-Type", 591 | "value": "application/json" 592 | } 593 | ], 594 | "body": { 595 | "mode": "raw", 596 | "raw": "{\n \"amount\": \"invalid\"\n}" 597 | }, 598 | "url": { 599 | "raw": "{{baseUrl}}/api/identity-count", 600 | "host": ["{{baseUrl}}"], 601 | "path": ["api", "identity-count"] 602 | } 603 | }, 604 | "response": [] 605 | } 606 | ] 607 | } 608 | ], 609 | "event": [ 610 | { 611 | "listen": "prerequest", 612 | "script": { 613 | "type": "text/javascript", 614 | "exec": ["// Global pre-request script", "console.log('Running request to:', pm.request.url.toString());"] 615 | } 616 | }, 617 | { 618 | "listen": "test", 619 | "script": { 620 | "type": "text/javascript", 621 | "exec": [ 622 | "// Global test script", 623 | "pm.test('Response time is reasonable', function () {", 624 | " pm.expect(pm.response.responseTime).to.be.below(5000);", 625 | "});", 626 | "", 627 | "pm.test('Response has proper content type', function () {", 628 | " pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json');", 629 | "});" 630 | ] 631 | } 632 | } 633 | ] 634 | } 635 | --------------------------------------------------------------------------------