Browse Source

feat: Phase 2 — Auth real + Seed data

Auth:
- auth.repository.ts: user CRUD, session management, bcrypt password
- auth.service.ts: register/login/refresh/logout với JWT + refresh token
- auth.controller.ts: POST /auth/register, /login, /refresh, /logout, GET /me
- JwtAuthGuard, CurrentUser decorator
- Drizzle schema: users, sessions tables sẵn dùng

Seed:
- src/db/seed.ts: tạo org + admin user + project + 3 devices mẫu
  Login: admin@k9tech.space / admin1234
  Device API key: dev-api-key-demo-001|002|003
kingkong 2 tháng trước cách đây
mục cha
commit
79958ed029

+ 14 - 0
apps/api-server/src/common/decorators/current-user.decorator.ts

@@ -0,0 +1,14 @@
+import { createParamDecorator, ExecutionContext } from '@nestjs/common'
+
+interface JwtPayload {
+  userId: string
+  email?: string
+}
+
+export const CurrentUser = createParamDecorator(
+  (data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
+    const request = ctx.switchToHttp().getRequest()
+    const user = request.user as JwtPayload
+    return data ? user?.[data] : user
+  },
+)

+ 16 - 0
apps/api-server/src/common/guards/jwt-auth.guard.ts

@@ -0,0 +1,16 @@
+import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common'
+import { AuthGuard } from '@nestjs/passport'
+
+@Injectable()
+export class JwtAuthGuard extends AuthGuard('jwt') {
+  canActivate(context: ExecutionContext): boolean | Promise<boolean> {
+    return super.canActivate(context) as boolean | Promise<boolean>
+  }
+
+  handleRequest<TUser = any>(err: any, user: TUser, info: any): TUser {
+    if (err || !user) {
+      throw err || new UnauthorizedException('Invalid or expired token')
+    }
+    return user
+  }
+}

+ 95 - 0
apps/api-server/src/db/seed.ts

@@ -0,0 +1,95 @@
+/**
+ * Seed script — chạy sau khi drizzle-kit push để tạo dữ liệu mẫu.
+ * Usage: npm run seed --workspace=apps/api-server
+ * hoặc: npx tsx src/db/seed.ts
+ */
+import { db } from './database.module'
+import { users, organizations, projects, memberships, devices } from './schema'
+import { nanoid } from 'nanoid'
+import * as bcrypt from 'bcrypt'
+
+async function seed() {
+  console.log('🌱 Seeding database...')
+
+  // Tạo organization mặc định
+  const orgId = nanoid()
+  await db.insert(organizations).values({
+    id: orgId,
+    name: 'K9 Tech Demo',
+    status: 'active',
+    planTier: 'trial',
+  })
+  console.log(`  ✓ Organization: K9 Tech Demo (${orgId})`)
+
+  // Tạo user admin
+  const adminId = nanoid()
+  const passwordHash = await bcrypt.hash('admin1234', 12)
+  await db.insert(users).values({
+    id: adminId,
+    email: 'admin@k9tech.space',
+    name: 'Admin User',
+    passwordHash,
+    provider: 'email',
+    emailVerified: true,
+  })
+  console.log(`  ✓ User: admin@k9tech.space / admin1234`)
+
+  // Gán admin vào org
+  await db.insert(memberships).values({
+    userId: adminId,
+    orgId,
+    role: 'org_admin',
+  })
+  console.log(`  ✓ Membership: admin → K9 Tech Demo (org_admin)`)
+
+  // Tạo project mẫu
+  const projectId = nanoid()
+  await db.insert(projects).values({
+    id: projectId,
+    orgId,
+    name: 'Công trình Demo',
+    description: 'Dự án timelapse demo cho testing',
+    status: 'active',
+    captureInterval: 60,
+    resolution: '1920x1080',
+    timezone: 'Asia/Ho_Chi_Minh',
+  })
+  console.log(`  ✓ Project: Công trình Demo (${projectId})`)
+
+  // Tạo 3 device mẫu
+  const deviceNames = [
+    { name: 'Camera Chính', serial: 'DEMO-001' },
+    { name: 'Camera Góc 2', serial: 'DEMO-002' },
+    { name: 'Camera Góc 3', serial: 'DEMO-003' },
+  ]
+
+  for (const d of deviceNames) {
+    const deviceId = nanoid()
+    const apiKeyHash = `dev-api-key-${d.serial.toLowerCase()}`
+    await db.insert(devices).values({
+      id: deviceId,
+      orgId,
+      projectId,
+      name: d.name,
+      serialNo: d.serial,
+      apiKeyHash,
+      status: 'online',
+      firmwareVersion: '1.0.0',
+      lastSeenAt: new Date(),
+    })
+    console.log(`  ✓ Device: ${d.name} (${d.serial}) — API key: ${apiKeyHash}`)
+  }
+
+  console.log('\n✅ Seed complete!')
+  console.log('\nTest login:')
+  console.log('  curl -X POST http://localhost:3001/v1/auth/login \\')
+  console.log('    -H "Content-Type: application/json" \\')
+  console.log('    -d \'{"email":"admin@k9tech.space","password":"admin1234"}\'')
+
+  process.exit(0)
+}
+
+seed().catch((err) => {
+  console.error('❌ Seed failed:', err)
+  process.exit(1)
+})

+ 27 - 19
apps/api-server/src/modules/auth/auth.controller.ts

@@ -1,37 +1,45 @@
-import { Controller, Get, Post } from '@nestjs/common'
+import { Controller, Get, Post, Body, UseGuards, HttpCode } from '@nestjs/common'
 import { AuthService } from './auth.service'
+import { LoginDto, RegisterDto, RefreshDto } from './dto/login.dto'
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
+import { CurrentUser } from '../../common/decorators/current-user.decorator'
 
 @Controller('auth')
 export class AuthController {
   constructor(private readonly authService: AuthService) {}
 
-  @Get('health')
-  health() {
-    return this.authService.getHealth()
+  @Post('register')
+  register(@Body() dto: RegisterDto) {
+    return this.authService.register(dto)
   }
 
-  @Get('me')
-  me() {
-    return this.authService.getCurrentUser()
+  @Post('login')
+  @HttpCode(200)
+  login(@Body() dto: LoginDto) {
+    return this.authService.login(dto)
   }
 
-  @Get('google')
-  googleAuth() {
-    return { message: 'Google OAuth stub endpoint (Phase 1)' }
+  @Post('refresh')
+  @HttpCode(200)
+  refresh(@Body() dto: RefreshDto) {
+    return this.authService.refresh(dto.refreshToken)
   }
 
-  @Get('google/callback')
-  googleCallback() {
-    return { message: 'Google OAuth callback stub endpoint (Phase 1)' }
+  @Post('logout')
+  @HttpCode(200)
+  @UseGuards(JwtAuthGuard)
+  logout(@Body() dto: RefreshDto) {
+    return this.authService.logout(dto.refreshToken)
   }
 
-  @Post('refresh')
-  refresh() {
-    return { message: 'Refresh token stub endpoint (Phase 1)' }
+  @Get('me')
+  @UseGuards(JwtAuthGuard)
+  me(@CurrentUser() user: { userId: string }) {
+    return this.authService.getCurrentUser(user.userId)
   }
 
-  @Post('logout')
-  logout() {
-    return { message: 'Logout stub endpoint (Phase 1)' }
+  @Get('health')
+  health() {
+    return { status: 'ok', phase: 'phase-2-auth' }
   }
 }

+ 2 - 1
apps/api-server/src/modules/auth/auth.module.ts

@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt'
 import { PassportModule } from '@nestjs/passport'
 import { AuthController } from './auth.controller'
 import { AuthService } from './auth.service'
+import { AuthRepository } from './auth.repository'
 import { JwtStrategy } from './strategies/jwt.strategy'
 import { GoogleStrategy } from './strategies/google.strategy'
 import { OrgsModule } from '../orgs/orgs.module'
@@ -17,7 +18,7 @@ import { OrgsModule } from '../orgs/orgs.module'
     OrgsModule,
   ],
   controllers: [AuthController],
-  providers: [AuthService, JwtStrategy, GoogleStrategy],
+  providers: [AuthService, AuthRepository, JwtStrategy, GoogleStrategy],
   exports: [AuthService, JwtModule],
 })
 export class AuthModule {}

+ 76 - 0
apps/api-server/src/modules/auth/auth.repository.ts

@@ -0,0 +1,76 @@
+import { Injectable } from '@nestjs/common'
+import { eq, and, gt } from 'drizzle-orm'
+import { db } from '../../db/database.module'
+import { users, sessions } from '../../db/schema'
+import { nanoid } from 'nanoid'
+import * as bcrypt from 'bcrypt'
+
+@Injectable()
+export class AuthRepository {
+  async findUserByEmail(email: string) {
+    const result = await db.select().from(users).where(eq(users.email, email.toLowerCase())).limit(1)
+    return result[0] ?? null
+  }
+
+  async findUserById(id: string) {
+    const result = await db.select().from(users).where(eq(users.id, id)).limit(1)
+    return result[0] ?? null
+  }
+
+  async createUser(data: { email: string; name: string; passwordHash: string; provider: string }) {
+    const id = nanoid()
+    await db.insert(users).values({
+      id,
+      email: data.email.toLowerCase(),
+      name: data.name,
+      passwordHash: data.passwordHash,
+      provider: data.provider,
+      emailVerified: false,
+    })
+    return this.findUserById(id)
+  }
+
+  async createSession(userId: string, refreshTokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string) {
+    const id = nanoid(32)
+    await db.insert(sessions).values({
+      id,
+      userId,
+      refreshTokenHash,
+      expiresAt,
+      userAgent,
+      ipAddress,
+    })
+    return id
+  }
+
+  async findSession(id: string) {
+    const result = await db.select().from(sessions).where(eq(sessions.id, id)).limit(1)
+    return result[0] ?? null
+  }
+
+  async findValidSession(userId: string) {
+    const now = new Date()
+    const result = await db
+      .select()
+      .from(sessions)
+      .where(and(eq(sessions.userId, userId), gt(sessions.expiresAt, now)))
+      .limit(1)
+    return result[0] ?? null
+  }
+
+  async deleteSession(id: string) {
+    await db.delete(sessions).where(eq(sessions.id, id))
+  }
+
+  async deleteAllUserSessions(userId: string) {
+    await db.delete(sessions).where(eq(sessions.userId, userId))
+  }
+
+  async verifyPassword(password: string, hash: string): Promise<boolean> {
+    return bcrypt.compare(password, hash)
+  }
+
+  async hashPassword(password: string): Promise<string> {
+    return bcrypt.hash(password, 12)
+  }
+}

+ 115 - 10
apps/api-server/src/modules/auth/auth.service.ts

@@ -1,21 +1,126 @@
-import { Injectable } from '@nestjs/common'
+import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common'
+import { JwtService } from '@nestjs/jwt'
+import * as bcrypt from 'bcrypt'
+import { nanoid } from 'nanoid'
+import { AuthRepository } from './auth.repository'
+import { RegisterDto, LoginDto, AuthResponseDto } from './dto/login.dto'
 
 @Injectable()
 export class AuthService {
-  getHealth() {
+  constructor(
+    private readonly repo: AuthRepository,
+    private readonly jwt: JwtService,
+  ) {}
+
+  private readonly REFRESH_TOKEN_EXPIRY_DAYS = 30
+
+  async register(dto: RegisterDto): Promise<AuthResponseDto> {
+    const existing = await this.repo.findUserByEmail(dto.email)
+    if (existing) {
+      throw new ConflictException('Email already registered')
+    }
+
+    const passwordHash = await this.repo.hashPassword(dto.password)
+    const user = await this.repo.createUser({
+      email: dto.email,
+      name: dto.name,
+      passwordHash,
+      provider: 'email',
+    })
+
+    return this.issueTokens(user!.id, user!.email)
+  }
+
+  async login(dto: LoginDto): Promise<AuthResponseDto> {
+    const user = await this.repo.findUserByEmail(dto.email)
+    if (!user || !user.passwordHash) {
+      throw new UnauthorizedException('Invalid credentials')
+    }
+
+    const valid = await this.repo.verifyPassword(dto.password, user.passwordHash)
+    if (!valid) {
+      throw new UnauthorizedException('Invalid credentials')
+    }
+
+    return this.issueTokens(user.id, user.email)
+  }
+
+  async refresh(refreshToken: string): Promise<AuthResponseDto> {
+    let payload: { sub: string; email: string; jti: string }
+    try {
+      payload = this.jwt.verify(refreshToken, {
+        secret: process.env['REFRESH_TOKEN_SECRET']!,
+      })
+    } catch {
+      throw new UnauthorizedException('Invalid refresh token')
+    }
+
+    const session = await this.repo.findSession(payload.jti)
+    if (!session || session.expiresAt < new Date()) {
+      throw new UnauthorizedException('Session expired or revoked')
+    }
+
+    return this.issueTokens(session.userId, payload.email)
+  }
+
+  async logout(refreshToken: string): Promise<void> {
+    try {
+      const payload = this.jwt.verify(refreshToken, {
+        secret: process.env['REFRESH_TOKEN_SECRET']!,
+      })
+      await this.repo.deleteSession(payload.jti)
+    } catch {
+      // Token invalid/expired — already logged out from client's perspective
+    }
+  }
+
+  async getCurrentUser(userId: string) {
+    const user = await this.repo.findUserById(userId)
+    if (!user) throw new UnauthorizedException()
     return {
-      module: 'auth',
-      status: 'ok',
-      phase: 'phase-1-stub',
+      id: user.id,
+      email: user.email,
+      name: user.name,
+      avatarUrl: user.avatarUrl,
+      provider: user.provider,
     }
   }
 
-  getCurrentUser() {
+  private async issueTokens(userId: string, email: string): Promise<AuthResponseDto> {
+    const jti = nanoid(32)
+    const refreshExpiresAt = new Date()
+    refreshExpiresAt.setDate(refreshExpiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS)
+
+    const refreshTokenHash = await bcrypt.hash(
+      this.jwt.sign({ sub: userId, email, jti }, {
+        secret: process.env['REFRESH_TOKEN_SECRET']!,
+        expiresIn: `${this.REFRESH_TOKEN_EXPIRY_DAYS}d`,
+      }),
+      10,
+    )
+
+    await this.repo.createSession(userId, refreshTokenHash, refreshExpiresAt)
+
+    const accessToken = this.jwt.sign(
+      { sub: userId, email },
+      { expiresIn: '15m' },
+    )
+
+    const user = await this.repo.findUserById(userId)
+
     return {
-      id: 'stub-user',
-      email: 'stub@example.com',
-      name: 'Stub User',
-      roles: ['viewer'],
+      accessToken,
+      refreshToken: this.jwt.sign(
+        { sub: userId, email, jti },
+        { secret: process.env['REFRESH_TOKEN_SECRET']!, expiresIn: `${this.REFRESH_TOKEN_EXPIRY_DAYS}d` },
+      ),
+      expiresIn: 15 * 60,
+      user: {
+        id: userId,
+        email,
+        name: user?.name ?? '',
+        avatarUrl: user?.avatarUrl ?? null,
+      },
     }
   }
 }

+ 40 - 0
apps/api-server/src/modules/auth/dto/login.dto.ts

@@ -0,0 +1,40 @@
+import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator'
+
+export class RegisterDto {
+  @IsEmail()
+  email!: string
+
+  @IsString()
+  @MinLength(8)
+  password!: string
+
+  @IsString()
+  @IsNotEmpty()
+  name!: string
+}
+
+export class LoginDto {
+  @IsEmail()
+  email!: string
+
+  @IsString()
+  password!: string
+}
+
+export class RefreshDto {
+  @IsString()
+  @IsNotEmpty()
+  refreshToken!: string
+}
+
+export class AuthResponseDto {
+  accessToken!: string
+  refreshToken!: string
+  expiresIn!: number
+  user!: {
+    id: string
+    email: string
+    name: string
+    avatarUrl: string | null
+  }
+}

+ 3 - 2
apps/api-server/tsconfig.json

@@ -4,9 +4,10 @@
     "outDir": "./dist",
     "baseUrl": ".",
     "paths": {
-      "@/*": ["./src/*"]
+      "@/*": ["./src/*"],
+      "@shared/types": ["../../packages/shared-types/src"]
     }
   },
-  "include": ["src/**/*"],
+  "include": ["src/**/*", "../../packages/shared-types/src/**/*"],
   "exclude": ["node_modules", "dist"]
 }

+ 95 - 72
memory.md

@@ -2,124 +2,147 @@
 
 ## Tổng quan dự án
 Hệ thống timelapse construction monitoring.
-- **Repo**: `/Users/kingkong/Documents/code/timelaspe-2`
-- **Stack**: Next.js + NestJS + PostgreSQL (Drizzle) + Python (device-agent) + Redis
-- **Package manager**: Bun (KHÔNG dùng npm vì workspace protocol không hoạt động với nexus registry của môi trường)
-- **Registry**: Luôn thêm `--registry https://registry.npmjs.org/` khi cài package từ public npm
+- **Repo Gogs**: `https://git.k9tech.space/kingkong/timelapse-2`
+- **Token**: `a6224640fb6f576f6cba46f0e1646500c87226b6` (Personal Access Token)
+- **Stack**: Next.js 14 + NestJS + PostgreSQL (Drizzle) + Redis + Python (device-agent)
+- **Package manager**: Bun (KHÔNG dùng npm vì workspace protocol không hoạt động với nexus registry)
+- **Registry**: Luôn thêm `--registry https://registry.npmjs.org/` khi cài package
 
 ---
 
 ## Kiến trúc hiện tại
 
 ```
-construction-timelapse/
-├── apps/
-│   ├── api-server/          # NestJS — API backend
-│   ├── web-dashboard/       # Next.js 14 — Dashboard UI
-│   ├── worker/              # (stub)
-│   └── device-agent/        # Python — Pi agent (chưa implement)
-├── packages/
-│   ├── shared-types/        # Shared TypeScript types/enums
-│   └── config/             # tsconfig.base
-└── docs/
-    ├── api/                 # (trống)
-    └── firmware/            # (trống)
+MacOS (code) → Gogs push → webhook → Pi 4 (deploy)
+                                           ↓
+                              ┌─ postgres (docker)
+                              ├─ redis (docker)
+                              ├─ api-server (NestJS)
+                              ├─ web-dashboard (Next.js)
+                              └─ worker (Node)
 ```
 
 ---
 
-## Workspace config (quan trọng!)
+## Workspace config
 - **KHÔNG** dùng `apps/*` trong `workspaces` — `device-agent` là Python
 - Workspace config đúng:
   ```json
   "workspaces": ["apps/api-server", "apps/web-dashboard", "apps/worker", "packages/*"]
   ```
-- `@shared-types` **KHÔNG hợp lệ** — đổi thành `@shared/types` ở mọi nơi (package.json + tsconfig.json)
+- `@shared-types` **KHÔNG hợp lệ** — dùng `@shared/types`
 - Luôn `cd` vào workspace trước khi `bun add`
 
 ---
 
+## Gogs API
+```bash
+# List repos
+curl -H "Authorization: token <TOKEN>" https://git.k9tech.space/api/v1/user/repos
+
+# Create repo
+curl -X POST -H "Authorization: token <TOKEN>" \
+  -H "Content-Type: application/json" \
+  -d '{"name":"repo-name","private":false}' \
+  https://git.k9tech.space/api/v1/user/repos
+
+# Create webhook
+curl -X POST -H "Authorization: token <TOKEN>" \
+  -H "Content-Type: application/json" \
+  -d '{"type":"gogs","active":true,"config":{"url":"http://<pi>:9000/webhook","content_type":"json"},"events":["push"]}' \
+  https://git.k9tech.space/api/v1/repos/<user>/<repo>/hooks
+
+# Push với token
+git remote set-url origin "https://<TOKEN>@git.k9tech.space/kingkong/timelapse-2.git"
+git push -u origin main
+```
+
+---
+
+## Pi 4 Deploy Setup (TODO)
+Trên Pi 4 cần:
+1. Clone repo: `git clone https://git.k9tech.space/kingkong/timelapse-2.git /opt/timelapse`
+2. Tạo `/opt/timelapse/.env` với:
+   ```
+   GOGS_TOKEN=<deploy_token>
+   GOGS_REPO_URL=https://git.k9tech.space/kingkong/timelapse-2.git
+   DATABASE_URL=postgres://user:pass@postgres:5432/timelapse
+   REDIS_URL=redis://redis:6379
+   JWT_SECRET=...
+   ```
+3. Chạy webhook server: `node scripts/webhook-server.js`
+4. Update Gogs webhook URL với IP thật của Pi 4
+5. Deploy script: `bash scripts/deploy.sh`
+
+---
+
+## Docker Compose (in repo)
+```bash
+docker compose up -d postgres redis
+docker compose up -d --build api-server web-dashboard worker
+```
+
+---
+
 ## Phase 1 — Trạng thái: ✅ HOÀN THÀNH
 
 ### Backend (apps/api-server)
-**Chạy dev**: `cd apps/api-server && bun run dev`
-**Build**: `cd apps/api-server && bun run build` ✅
-
-**Auth module** (`src/modules/auth/`):
-- `auth.module.ts`, `auth.controller.ts`, `auth.service.ts` — stub endpoints
-- `strategies/jwt.strategy.ts`, `strategies/google.strategy.ts` — OAuth stubs
-
-**Devices module** (`src/modules/devices/`):
-- `devices.controller.ts` — 4 endpoints:
-  - `POST /v1/devices/:deviceId/heartbeat` — X-API-Key auth
-  - `GET /v1/devices` — list
-  - `GET /v1/devices/:id` — detail
-  - `GET /v1/devices/:id/heartbeats` — history
-- `devices.service.ts` — heartbeat logic + API key verify
-- `devices.repository.ts` — Drizzle queries
-- `dto/heartbeat.dto.ts` — class-validator DTO
-
-**Common guards/decorators** (`src/common/`):
-- `guards/api-key.guard.ts` — X-API-Key header guard
-- `decorators/api-key.decorator.ts` — `@ApiKey()` decorator
-
-**Các module stub** (đều rỗng, chỉ để compile):
-- `modules/orgs/`, `modules/projects/`, `modules/captures/`,
-  `modules/videos/`, `modules/alerts/`, `realtime/`
+- `src/modules/auth/` — AuthController/Service/Strategies stubs
+- `src/modules/devices/` — heartbeat endpoint + API key guard + repo/service/controller
+- `src/common/` — guards, decorators
+- `src/modules/orgs|projects|captures|videos|alerts/` — stubs
+- `src/realtime/` — stub
 
 ### Dashboard (apps/web-dashboard)
-**Chạy dev**: `cd apps/web-dashboard && bun run dev`
-**Build**: `cd apps/web-dashboard && bun run build` ✅
-
-- `pages/index.tsx` — Dashboard home (stats cards + recent devices)
-- `pages/devices/index.tsx` — Device list
-- `pages/devices/[id].tsx` — Device detail
-- `components/StatsCard.tsx`, `DeviceCard.tsx`
-- `hooks/useDashboardStats.ts`, `useDevices.ts`
-- Tailwind CSS v3 + PostCSS đã setup
+- Dashboard home, device list, device detail pages
+- Tailwind CSS v3 + React Query
 
 ---
 
 ## Database (Drizzle Schema)
-File: `apps/api-server/src/db/schema.ts`
-- Enum: org_status, project_status, device_status, capture_status, video_status, alert_severity, alert_type, alert_state, user_role, command_result_status
+- File: `apps/api-server/src/db/schema.ts`
+- Enum: org_status, project_status, device_status, capture_status, video_status, alert_severity, alert_type, user_role, command_result_status
 - Tables: organizations, projects, users, memberships, sessions, magic_links, devices, device_heartbeats, captures, videos, video_jobs, commands, alert_rules, alerts, audit_logs, activity_logs
-- Lưu ý: table names trong Drizzle schema là **lowercase** (`devices`, `deviceHeartbeats`) nhưng khi truy vấn phải dùng `devices`, `deviceHeartbeats` đúng tên export
 
 ---
 
 ## Các lỗi đã gặp và cách fix
-1. `@shared-types` → đổi package name thành `@shared/types`
+1. `@shared-types` → `@shared/types`
 2. `device-agent` (Python) trong npm workspaces → loại khỏi workspaces
-3. `npm install` không hoạt động → dùng `bun install --registry https://registry.npmjs.org/`
-4. `bun add` với nexus registry → luôn thêm `--registry https://registry.npmjs.org/`
+3. `npm install` không hoạt động → `bun install --registry https://registry.npmjs.org/`
+4. `bun add` với nexus registry → luôn `--registry https://registry.npmjs.org/`
 5. `db` không export được → import từ `database.module.ts` riêng
 6. `experimentalDecorators` thiếu → thêm vào `packages/config/tsconfig.node.json`
-7. `rootDir` conflict → bỏ `rootDir` khỏi tsconfig base
+7. TSC output vào `src/` → cleanup *.js/*.d.ts/*.js.map + fix outDir
 
 ---
 
 ## TODO — Phase tiếp theo
-1. **Phase 2**: Auth thực sự (JWT login, refresh token, Google OAuth)
-2. **Phase 2**: Database migration + seed data (drizzle-kit push)
-3. **Phase 2**: Projects/Orgs CRUD endpoints
-4. **Phase 2**: Device-agent (Python) heartbeat sender
-5. **Phase 2**: Realtime WebSocket (Socket.io) cho device status
-6. **Phase 2**: Dashboard auth flow (login/logout)
+1. **Auth thực sự**: JWT login, refresh token, Google OAuth
+2. **DB migration**: drizzle-kit push + seed data
+3. **Pi 4 setup**: webhook server + deploy script + Gogs token
+4. **Device-agent** (Python) heartbeat sender
+5. **Projects/Orgs CRUD endpoints**
+6. **Dashboard login/logout**
 
 ---
 
-## Lệnh dev nhanh
+## Lệnh nhanh (MacOS dev)
 ```bash
-# Backend
-cd apps/api-server && bun run dev  # http://localhost:3001
+# Cài deps
+bun install --registry https://registry.npmjs.org/
+
+# Dev
+cd apps/api-server && bun run dev
+cd apps/web-dashboard && bun run dev
 
-# Dashboard
-cd apps/web-dashboard && bun run dev  # http://localhost:3000
+# Build (verify)
+cd apps/api-server && bun run build
+cd apps/web-dashboard && bun run build
 
-# API test (heartbeat stub)
-curl -X POST http://localhost:3001/v1/devices/test-device-1/heartbeat \
+# Test heartbeat
+curl -X POST http://localhost:3001/v1/devices/test-device/heartbeat \
   -H "Content-Type: application/json" \
   -H "X-API-Key: dev-key-123" \
-  -d '{"deviceId":"test-device-1","apiKey":"dev-key-123","status":"online","storageFreeGb":50,"capturesToday":12,"firmwareVersion":"1.0.0"}'
+  -d '{"deviceId":"test-device","apiKey":"dev-key-123","status":"online","storageFreeGb":50,"capturesToday":12,"firmwareVersion":"1.0.0"}'
 ```