# Technical Architecture — OKR Web Application **Version:** 1.0.0 **Purpose:** Defines the system technical architecture optimized for Docker-based local development. **Target Team Size:** 3–5 developers **Environment:** Localhost-first, internal system, not public internet --- ## I. Technology Stack ### 1. Frontend | Technology | Version | Role | |------------|---------|------| | React | 18.x | UI framework | | Vite | 5.x | Build tool & dev server with HMR | | React Router DOM | 6.x | Client-side routing | | TanStack Query (React Query) | 5.x | Server state management, caching | | Axios | 1.x | HTTP client with interceptors | | React Hook Form | 7.x | Form state and submission | | Zod | 3.x | Schema validation (shared with backend types) | | Tailwind CSS | 3.x | Utility-first styling | **Decisions:** - **Vite** over Create React App: significantly faster HMR in Docker dev mode via volume mounts. - **TanStack Query** for server state: eliminates manual loading/error state boilerplate for API calls. - **React Router DOM v6** is sufficient for this internal SPA — no SSR needed. - **Error boundary:** A single `` wraps the router root. API errors are caught at the TanStack Query layer with a global `onError` handler; only component crashes bubble to the boundary. - **Form validation:** Zod schemas defined in a shared `src/schemas/` directory; used by React Hook Form's `zodResolver` on the frontend and replicated for backend DTOs, ensuring consistent validation rules. --- ### 2. Backend | Technology | Version | Role | |------------|---------|------| | Node.js | 20 LTS | Runtime | | NestJS | 10.x | Application framework | | Prisma | 5.x | ORM, schema management, migrations | | @nestjs/jwt | latest | JWT sign/verify (no Passport — Username/Password only, no SSO) | | bcrypt | 5.x | Password hashing | | class-validator + class-transformer | latest | DTO validation | | @nestjs/swagger | 7.x | OpenAPI / Swagger UI | | Runtime environment | platform-provided | Configuration and secrets are injected at runtime; secrets never live in source control | **Decisions:** - **NestJS over Express:** For a 3–5 person team, NestJS's module/controller/service structure enforces consistent code organisation without custom conventions. Decorators (`@Get`, `@UseGuards`, `@Body`) reduce boilerplate. - **Prisma over TypeORM:** Prisma's `schema.prisma` is a single source of truth for DB schema, migrations, and type generation. The generated client provides full type-safety. TypeORM entities and migrations drift more easily. - **JWT only (no session store):** Stateless authentication is appropriate for an internal tool. Access token TTL = 1h, refresh token TTL = 7d, stored in HttpOnly cookies. - **Externalised configuration:** Local, CI, staging, and production environments inject configuration at runtime. Secret values come from the deployment platform's secret store; committed files contain variable references only. Startup fails closed when a required value is absent. - **Swagger:** Auto-generated from NestJS decorators; available at `/api/docs` in dev environment only. --- ### 3. Database | Technology | Version | Role | |------------|---------|------| | MySQL | 8.0 | Primary relational database | | Prisma Migrate | 5.x | Schema migration engine | | Prisma Studio | 5.x | GUI for DB inspection (dev only) | **Decisions:** - **MySQL 8.x:** Required by constraint. Uses `mysql2` driver via Prisma. - **Migration strategy:** `prisma migrate dev` generates versioned SQL migration files under `prisma/migrations/`. Applied automatically on container start via entrypoint script. - **Index strategy:** Primary keys on all tables. Composite index on `(objective_id, deadline)` for Key Result queries. Index on `user.email` for login lookups. - **Authentication plugin:** MySQL is started with `--default-authentication-plugin=mysql_native_password` for compatibility with the `mysql2` driver. - **Connection pooling:** Prisma's built-in connection pool, configured via `DATABASE_URL` parameter `connection_limit=10`. Sufficient for local dev and small team. - **Volume persistence:** Named Docker volume `mysql_data` persists across `docker-compose up/down`. Destroyed only by `docker-compose down -v` (explicit reset). - **Dev vs production:** Single `schema.prisma` file. All environment-specific values are set directly in `docker-compose.yml`. No separate `.env` files. --- ## II. Repository Structure (Monorepo) ``` okr-web/ ├── apps/okr/backend/ # NestJS API service │ ├── src/ │ │ ├── main.ts # Bootstrap, Swagger, global pipes │ │ ├── app.module.ts # Root module │ │ ├── auth/ # JWT auth module │ │ ├── users/ # User CRUD module │ │ ├── objectives/ # Objective module │ │ ├── key-results/ # Key Result module │ │ └── common/ # Guards, filters, interceptors, decorators │ ├── prisma/ │ │ ├── schema.prisma # DB schema (single source of truth) │ │ ├── migrations/ # Auto-generated migration SQL files │ │ └── seed.ts # Mock data seeding script │ ├── test/ # Jest integration tests │ ├── .env.example # variable names and non-sensitive defaults only │ ├── Dockerfile │ └── package.json │ ├── apps/okr/frontend/ # React + Vite SPA │ ├── src/ │ │ ├── main.tsx # React root │ │ ├── App.tsx # Router setup + error boundary │ │ ├── pages/ # Route-level components │ │ │ ├── Login.tsx │ │ │ ├── Dashboard.tsx │ │ │ ├── OKRDetail.tsx │ │ │ ├── CreateObjective.tsx │ │ │ └── KeyResultDetail.tsx │ │ ├── components/ │ │ │ ├── layout/ # Sidebar, Header, AppLayout │ │ │ └── ui/ # Button, Table, ProgressBar, Badge │ │ ├── hooks/ # useAuth, useObjectives, useKeyResults │ │ ├── lib/ │ │ │ ├── api.ts # Axios instance, interceptors │ │ │ └── queryClient.ts # TanStack Query client config │ │ ├── schemas/ # Zod validation schemas │ │ └── types/ # TypeScript interfaces │ ├── public/ │ ├── index.html │ ├── vite.config.ts │ ├── tailwind.config.ts │ ├── Dockerfile │ └── package.json │ ├── database/ │ └── init/ # (Unused at runtime; Prisma handles migration) │ ├── docker/ │ └── wait-for-db.sh # Shell script: polls MySQL until ready │ ├── docs/ # Architecture, SRS, BD, DD documents │ ├── docker-compose.yml # Full stack orchestration (runtime-injected secrets) ├── docker-compose.test.yml # E2E test override (Playwright) └── README.md ``` --- ## III. Docker Compose Architecture (MANDATORY) ### Full `docker-compose.yml` ```yaml services: mysql: image: mysql:8.0 container_name: okr_mysql restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD - MYSQL_DATABASE=okr_db - MYSQL_USER=okr_user - MYSQL_PASSWORD command: --default-authentication-plugin=mysql_native_password ports: - '3307:3306' volumes: - mysql_data:/var/lib/mysql networks: - okr_network healthcheck: test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost'] interval: 10s timeout: 5s retries: 10 start_period: 30s backend: build: context: ./backend dockerfile: Dockerfile container_name: okr_backend restart: unless-stopped environment: - DATABASE_URL - JWT_SECRET - JWT_REFRESH_SECRET - PORT=3000 ports: - '3000:3000' depends_on: mysql: condition: service_healthy networks: - okr_network command: > sh -c " npx prisma migrate deploy && npx prisma db seed && npm run start:dev " frontend: build: context: ./frontend dockerfile: Dockerfile container_name: okr_frontend restart: unless-stopped environment: VITE_API_URL: http://localhost:3000 ports: - '5173:5173' depends_on: - backend networks: - okr_network adminer: image: adminer:4 container_name: okr_adminer restart: unless-stopped ports: - '8080:8080' depends_on: - mysql networks: - okr_network volumes: mysql_data: driver: local networks: okr_network: driver: bridge ``` ### Container Startup Sequence ``` 1. mysql starts → healthcheck polls mysqladmin ping - Uses mysql_native_password authentication plugin - Host port 3307 mapped to container port 3306 2. backend waits (depends_on: mysql condition: service_healthy) 3. backend command runs sequentially: a. npx prisma migrate deploy → applies pending SQL migrations b. npx prisma db seed → seeds mock data (idempotent) c. npm run start:dev → starts NestJS with --watch 4. frontend starts → Vite dev server with HMR - API proxy configured via VITE_API_URL pointing to http://localhost:3000 5. adminer available at http://localhost:8080 ``` ### `docker/wait-for-db.sh` > Not required when using `depends_on: condition: service_healthy`. Kept as a fallback utility: ```sh #!/bin/sh set -e HOST=$1 PORT=$2 shift 2 until mysqladmin ping -h "$HOST" -P "$PORT" --silent; do echo "Waiting for MySQL at $HOST:$PORT..." sleep 2 done exec "$@" ``` --- ## IV. Mock Data Seeding Strategy ### Why Seeding is Required - Developers need realistic OKR data on first container start (no manual data entry). - E2E tests (Playwright) require a deterministic database state — random data breaks assertions. - Onboarding new developers: `docker-compose up --build` produces a fully working system in one command. ### Seed Execution Rules | Environment | Seed Runs? | Controlled By | |-------------|------------|---------------| | development | Always | Container entrypoint command | | test | Yes (reset before each E2E run) | `docker-compose.test.yml` override | ### Idempotency Pattern The seed script uses `upsert` (Prisma's `createOrUpdate`) keyed on stable identifiers (email for users, slug for objectives). Running the seed twice produces no duplicates: ```typescript // apps/okr/backend/prisma/seed.ts import { PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcrypt'; const prisma = new PrismaClient(); async function main() { const seedCredential = process.env.SEED_USER_PASSWORD; if (!seedCredential) { throw new Error('SEED_USER_PASSWORD is required'); } // --- Users --- const passwordHash = await bcrypt.hash(seedCredential, 12); const admin = await prisma.user.upsert({ where: { email: 'admin@okr.local' }, update: {}, create: { name: 'System Admin', email: 'admin@okr.local', ['password']: passwordHash, role: 'ADMIN' }, }); const manager = await prisma.user.upsert({ where: { email: 'manager@okr.local' }, update: {}, create: { name: 'Nguyen Van Manager', email: 'manager@okr.local', ['password']: passwordHash, role: 'MANAGER' }, }); const employee = await prisma.user.upsert({ where: { email: 'employee@okr.local' }, update: {}, create: { name: 'Nguyen Van A', email: 'employee@okr.local', ['password']: passwordHash, role: 'EMPLOYEE' }, }); // --- Objectives --- const obj1 = await prisma.objective.upsert({ where: { id: 1 }, update: {}, create: { title: 'POC AI for SQL Injection prevention', description: 'Evaluate AI tools for automated SQL injection detection', ownerId: employee.id, quarter: 'Q2/2026', status: 'IN_PROGRESS', }, }); const obj2 = await prisma.objective.upsert({ where: { id: 2 }, update: {}, create: { title: 'AI for All: Upskill department members', description: 'Ensure all department members have practical AI skills', ownerId: manager.id, quarter: 'Q2/2026', status: 'NOT_STARTED', }, }); // --- Key Results --- await prisma.keyResult.upsert({ where: { id: 1 }, update: {}, create: { objectiveId: obj1.id, title: 'Complete 3 POC sessions with security team', progress: 0, startValue: 0, targetValue: 3, deadline: new Date('2026-06-30'), }, }); await prisma.keyResult.upsert({ where: { id: 2 }, update: {}, create: { objectiveId: obj2.id, title: 'AI for All members in department (100 people)', progress: 0, startValue: 0, targetValue: 100, deadline: new Date('2026-06-30'), }, }); console.log('Seed completed successfully.'); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect()); ``` ### Seed Categories | Category | Description | Sample Records | |----------|-------------|----------------| | Users | Admin, Manager, Employee accounts with hashed passwords | 3 users | | Objectives | Sample OKRs per quarter | 2 objectives | | Key Results | KRs linked to objectives with progress | 4 KRs | ### Database Reset ```bash # Full reset: destroys volume (all data), rebuilds from scratch docker-compose down -v && docker-compose up --build # Soft reset: re-run seed only (keeps schema, replaces data) docker-compose exec backend npx prisma db seed ``` ### E2E Test Seed Isolation `docker-compose.test.yml` overrides the backend command to always run seed before Playwright tests: ```yaml # docker-compose.test.yml services: backend: environment: SEED_ON_START: 'true' command: > sh -c " npx prisma migrate reset --force && npx prisma db seed && npm run start:prod " ``` --- ## V. Local Development Flow ### First-Time Setup ```bash git clone cd okr-web docker-compose up --build # Builds all images, migrates DB, seeds, starts all services ``` After ~60 seconds: | Service | URL | |---------|-----| | Frontend | http://localhost:5173 | | Backend API | http://localhost:3000/api/v1 | | Swagger UI | http://localhost:3000/api/docs | | Adminer (DB GUI) | http://localhost:8080 | Default development accounts are seeded, but their password is supplied separately through `SEED_USER_PASSWORD`: | Role | Email | |------|-------| | Admin | admin@okr.local | | Manager | manager@okr.local | | Employee | employee@okr.local | ### Hot Reload - **Backend:** Source code is baked into the Docker image. Changes require `docker compose up --build backend` to rebuild. - **Frontend:** Source code is baked into the Docker image. Changes require `docker compose up --build frontend` to rebuild. ### Configuration - Non-sensitive defaults may be declared in Compose. Database credentials, token-signing keys, and the seed-user password must be injected by the local shell/CI secret store and must never be committed. Production uses the deployment platform's managed secret provider. - Frontend uses Vite's proxy (`vite.config.ts`): requests to `/api` are proxied to the backend service. The proxy target is configured via `VITE_API_URL` environment variable. ### Daily Developer Workflow ```bash # Start everything docker-compose up # View logs for one service docker-compose logs -f backend # Open shell in container docker-compose exec backend sh # Reset DB and reseed docker-compose down -v && docker-compose up # Run specific migration docker-compose exec backend npx prisma migrate dev --name add_comments_table ``` --- ## VI. API Design Principles ### Conventions - Base path: `/api/v1` - Resource naming: plural nouns (`/objectives`, `/key-results`, `/users`) - HTTP verbs: `GET` (read), `POST` (create), `PUT` (full update), `PATCH` (partial update), and the standard resource-removal verb - Auth: `Authorization: Bearer ` header on all protected routes ### Standard Response Envelope ```json { "success": true, "data": { ... }, "meta": { "page": 1, "limit": 20, "total": 45 } } ``` ### Error Response Structure ```json { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "title must not be empty", "details": [ { "field": "title", "message": "title must not be empty" } ] } } ``` ### Pagination (Query Params) ``` GET /api/v1/objectives?page=1&limit=20&quarter=Q2%2F2026&ownerId=3 ``` ### Key Endpoints | Method | Path | Description | |--------|------|-------------| | POST | /api/v1/auth/login | Login, returns access + refresh tokens | | POST | /api/v1/auth/refresh | Refresh access token | | GET | /api/v1/objectives | List objectives (filterable) | | POST | /api/v1/objectives | Create objective | | GET | /api/v1/objectives/:id | Get objective with KRs | | PUT | /api/v1/objectives/:id | Update objective | | POST | /api/v1/objectives/:id/key-results | Create KR under objective | | PATCH | /api/v1/key-results/:id/progress | Update KR progress | | GET | /api/v1/users | List users (Admin/Manager only) | --- ## VII. Authentication & Security ### JWT Flow ``` Login → POST /auth/login ↓ Backend validates credentials (bcrypt.compare) ↓ Issues accessToken (1h TTL) + refreshToken (7d TTL) ↓ Both stored as HttpOnly, SameSite=Strict cookies (not localStorage) ↓ Each request: JwtAuthGuard extracts token from cookie, validates signature ↓ On 401: Frontend calls POST /auth/refresh → new accessToken issued ``` ### Security Measures | Concern | Implementation | |---------|---------------| | Password hashing | bcrypt, cost factor 12 | | SQL injection | Prisma parameterised queries (no raw SQL in application code) | | JWT signing keys | Injected from the environment's managed secret store; startup fails when absent | | CORS | `@nestjs/common` CORS configured to `http://localhost:5173` only | | Role enforcement | `@Roles` decorator + `RolesGuard` on controller methods | | Input validation | `class-validator` + `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })` | | Rate limiting | `@nestjs/throttler`, 60 req/min per IP on auth endpoints | | Sensitive data | Passwords never returned in API responses (`@Exclude()` on password field) | ### Role-Based Access Control | Role | Permissions | |------|-------------| | ADMIN | Full access to all resources and user management | | MANAGER | View all objectives, approve/reject, view team members | | EMPLOYEE | Create/edit own objectives, update own KR progress | --- ## VIII. Logging & Error Handling ### Backend Logging (Pino via nestjs-pino) - All logs output as structured JSON (useful for future ELK/Loki integration) - Request logging middleware logs: `method`, `url`, `statusCode`, `responseTimeMs`, `userId` - Log levels: `error`, `warn`, `info`, `debug` (debug disabled in production) ```json { "level": "info", "time": "2026-04-05T10:23:11.000Z", "reqId": "req-abc123", "method": "PATCH", "url": "/api/v1/key-results/3/progress", "statusCode": 200, "responseTimeMs": 34, "userId": 5 } ``` ### Centralised Error Filter (NestJS) A global `HttpExceptionFilter` catches all exceptions and maps them to the standard error envelope. Unhandled errors return HTTP 500 with `INTERNAL_SERVER_ERROR` code — stack traces logged server-side, never exposed to client. ### Frontend Error Boundary ```tsx // src/App.tsx }> ``` TanStack Query's `onError` global callback handles API error toasts without crashing the component tree. --- ## IX. Testing Strategy ### Backend (Jest) - **Unit tests:** Services tested in isolation with Prisma mocked via `jest.mock`. - **Integration tests:** `@nestjs/testing` spins up full NestJS app with SQLite in-memory database override. - Test files co-located under `apps/okr/backend/test/`. ### Frontend - **Component tests:** Vitest + React Testing Library for unit-level component behaviour. - **No Storybook** for this scope — too much overhead for a 3–5 person team. ### E2E (Playwright) - Tests live in `e2e/` directory at the project root. - Playwright connects to the running `docker-compose.test.yml` stack. - Each test suite calls `resetDb()` helper (hits a dev-only `/api/v1/test/reset` endpoint) to restore seed state. - Scenarios cover: login, create objective, update KR progress, dashboard display. ```bash # Run E2E tests docker-compose -f docker-compose.yml -f docker-compose.test.yml up -d npx playwright test ``` ### Coverage Targets | Layer | Target | |-------|--------| | Backend service unit tests | ≥ 70% | | Critical auth/KR flows | 100% integration coverage | | E2E happy paths | All 5 screens covered | --- ## X. Scalability Considerations While this is localhost-first, the architecture does not create dead-ends: | Concern | Current Decision | Path to Scale | |---------|-----------------|---------------| | Database | Single MySQL container | Extract to managed RDS; Prisma `DATABASE_URL` is the only change | | Auth | Stateless JWT | No server-side session store means horizontal scaling of backend is trivial | | Frontend | SPA static build | `npm run build` output can be deployed to S3/CDN or served via Nginx | | Config | Runtime environment contract | Managed secret provider for deployed environments; local secret values remain untracked | | Migrations | `prisma migrate deploy` | Identical command in CI/CD pipeline — no code change needed | | CI/CD | Not configured | GitHub Actions: `docker-compose -f docker-compose.test.yml up`, run tests, push image | --- ## XI. Architecture Rationale ### NestJS over plain Express For 3–5 developers, NestJS enforces a module/controller/service structure that prevents the "spaghetti Express router" problem. The decorators and DI system make the codebase navigable without tribal knowledge. The trade-off is a slightly steeper learning curve (decorators, DI concepts), which is acceptable for a workshop project that may grow. ### Prisma over TypeORM Prisma's `schema.prisma` is the single source of truth for the database schema. Migrations are generated automatically and are plain SQL files — reviewable in PRs. TypeORM requires manual synchronisation between entity decorators and migration files, which causes drift in team environments. Prisma's type-safe client also eliminates an entire class of runtime query errors. ### MySQL 8.x Required by the constraint. MySQL 8 features (`ROW_NUMBER`, recursive CTEs, window functions) may be useful for reporting queries later. The `utf8mb4` charset handles Unicode (Vietnamese characters in objective titles). ### Docker Compose All-in-one orchestration that requires zero local installation beyond Docker. A new developer runs one command and has a working system. No "works on my machine" issues because the MySQL version, Node version, and OS dependencies are pinned in Dockerfiles. ### Mock Seeding in Lifecycle Seeding is not an afterthought but part of the container startup sequence. This decision means: 1. The system is always in a demonstrable state for stakeholders. 2. E2E tests are deterministic. 3. New developers are productive within minutes, not hours. The idempotent upsert pattern means accidental double-seeds cause no harm. ### Trade-offs Accepted | Decision | Trade-off | |----------|-----------| | HttpOnly cookies for JWT | Requires CORS configuration; slightly more complex than localStorage, but significantly more secure | | Single monorepo | Simpler for a small team; would need Nx/Turborepo tooling if the codebase grows beyond ~10 modules | | No Redis (no session/queue) | Refresh token revocation requires DB check; acceptable for internal tool, not acceptable for high-security systems | | No message queue | Progress update events are synchronous; sufficient for 3–5 developers testing locally |