refactor(structure): promote app to repo root + remove redundant workspace cruft

Standard production layout: the OKR app (was nested under AINative_OKR_CASAN5/) is now
the repository root. No more wrapper directory.

- Promote AINative_OKR_CASAN5/* -> repo root (backend/ frontend/ packages/ apps/
  .specify/ docs/ infra/ nginx/ scripts/ + configs). Merge tool dirs: .gitea (kept the
  active deploy ci.yml, added harness-ci.yml + runbooks), .claude (agents/commands +
  launch.json), .github moved up.
- Remove redundant: 00_SUBMISSION_PACKAGE, scattered root notes (FPT_CASAN_Full.md,
  tu-tuong-casan.md, casan-tu-sinh..., casan_harness_assessment.md, source-review...,
  README_CASAN5_REFINED.md), casan-next-plans/ and optimize-docs/ (competition/planning
  artifacts — roadmap + design history preserved in git log / commit messages).
- Update all references to the old layout:
  - .gitea/workflows/{ci,harness-ci}.yml, .github/workflows/{ci,deploy}.yml:
    working-directory .; drop AINative_OKR_CASAN5/ prefix; .specify/{tests,scripts}
    -> packages/casan-harness/... (.specify/logs state kept)
  - .claude/launch.json, .gitea/*-runbook.md: path prefixes
  - CLAUDE.md, README.md: docs/input -> apps/okr/domain/input
  - policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest re-signed
- secrets-scan.sh: fixture excludes -> new package/domain paths.

Full gate from the new root: PASS=64 FAIL=0 SKIP=3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-08 13:26:36 +09:00
co-authored by Claude Opus 4.8
parent 7101af9fd4
commit 36a4812ef3
925 changed files with 410 additions and 18001 deletions
+698
View File
@@ -0,0 +1,698 @@
# 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 `<AppErrorBoundary>` 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 |
| dotenv / @nestjs/config | — | ~~Not used~~ — values hardcoded (workshop) |
**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.
- **No env files (workshop):** All configuration values (DB credentials, JWT secret, ports) are hardcoded directly in `docker-compose.yml` and application config. No `.env` file or `@nestjs/config` needed.
- **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/
├── 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 # (not used — values hardcoded for workshop)
│ ├── Dockerfile
│ └── package.json
│
├── 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 (values hardcoded)
├── 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: rootpassword
MYSQL_DATABASE: okr_db
MYSQL_USER: okr_user
MYSQL_PASSWORD: okr_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', '-u', 'root', '-prootpassword']
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: mysql://okr_user:okr_password@mysql:3306/okr_db
JWT_SECRET: workshop_jwt_secret_key
JWT_REFRESH_SECRET: workshop_jwt_refresh_secret_key
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 | Hardcoded in 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
// backend/prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
// Always seed — workshop environment, no env gate needed
// --- Users ---
const passwordHash = await bcrypt.hash('Password@123', 10);
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 <repo-url>
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 login credentials (seeded):
| Role | Email | Password |
|------|-------|----------|
| Admin | admin@okr.local | Password@123 |
| Manager | manager@okr.local | Password@123 |
| Employee | employee@okr.local | Password@123 |
### 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
- All values (DB credentials, JWT secrets, ports) are hardcoded in `docker-compose.yml` — no `.env` file required. This is intentional for the workshop environment.
- 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), `DELETE`
- Auth: `Authorization: Bearer <token>` 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 secret | Hardcoded in `docker-compose.yml` (workshop only — never do this in production) |
| 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
<AppErrorBoundary fallback={<ErrorPage />}>
<RouterProvider router={router} />
</AppErrorBoundary>
```
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 `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 | Hardcoded in `docker-compose.yml` | Move to `.env` + `@nestjs/config`, then AWS Parameter Store / Vault |
| 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 |