Bläddra i källkod

feat: add Projects CRUD API (JWT-protected)

- projects.repository/service/controller/module
- DTOs for create/update project validation
- Endpoints: create/list/get/update/delete projects
- Protect all /projects routes with JwtAuthGuard
- Update memory.md with latest progress
kingkong 2 månader sedan
förälder
incheckning
5277b135ea

+ 75 - 0
apps/api-server/src/modules/projects/dto/project.dto.ts

@@ -0,0 +1,75 @@
+import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'
+
+export class CreateProjectDto {
+  @IsString()
+  orgId!: string
+
+  @IsString()
+  name!: string
+
+  @IsOptional()
+  @IsString()
+  description?: string
+
+  @IsOptional()
+  @IsString()
+  timezone?: string
+
+  @IsOptional()
+  @IsString()
+  startDate?: string
+
+  @IsOptional()
+  @IsString()
+  endDate?: string
+
+  @IsOptional()
+  @IsEnum(['planning', 'active', 'paused', 'completed', 'archived'])
+  status?: 'planning' | 'active' | 'paused' | 'completed' | 'archived'
+
+  @IsOptional()
+  @IsInt()
+  @Min(1)
+  @Max(24 * 60)
+  captureInterval?: number
+
+  @IsOptional()
+  @IsString()
+  resolution?: string
+}
+
+export class UpdateProjectDto {
+  @IsOptional()
+  @IsString()
+  name?: string
+
+  @IsOptional()
+  @IsString()
+  description?: string
+
+  @IsOptional()
+  @IsString()
+  timezone?: string
+
+  @IsOptional()
+  @IsString()
+  startDate?: string
+
+  @IsOptional()
+  @IsString()
+  endDate?: string
+
+  @IsOptional()
+  @IsEnum(['planning', 'active', 'paused', 'completed', 'archived'])
+  status?: 'planning' | 'active' | 'paused' | 'completed' | 'archived'
+
+  @IsOptional()
+  @IsInt()
+  @Min(1)
+  @Max(24 * 60)
+  captureInterval?: number
+
+  @IsOptional()
+  @IsString()
+  resolution?: string
+}

+ 35 - 0
apps/api-server/src/modules/projects/projects.controller.ts

@@ -0,0 +1,35 @@
+import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'
+import { ProjectsService } from './projects.service'
+import { CreateProjectDto, UpdateProjectDto } from './dto/project.dto'
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
+
+@Controller('projects')
+@UseGuards(JwtAuthGuard)
+export class ProjectsController {
+  constructor(private readonly projectsService: ProjectsService) {}
+
+  @Post()
+  create(@Body() dto: CreateProjectDto) {
+    return this.projectsService.create(dto)
+  }
+
+  @Get()
+  findAll(@Query('orgId') orgId?: string) {
+    return this.projectsService.findAll(orgId)
+  }
+
+  @Get(':id')
+  findOne(@Param('id') id: string) {
+    return this.projectsService.findOne(id)
+  }
+
+  @Patch(':id')
+  update(@Param('id') id: string, @Body() dto: UpdateProjectDto) {
+    return this.projectsService.update(id, dto)
+  }
+
+  @Delete(':id')
+  remove(@Param('id') id: string) {
+    return this.projectsService.remove(id)
+  }
+}

+ 6 - 3
apps/api-server/src/modules/projects/projects.module.ts

@@ -1,8 +1,11 @@
 import { Module } from '@nestjs/common'
+import { ProjectsController } from './projects.controller'
+import { ProjectsService } from './projects.service'
+import { ProjectsRepository } from './projects.repository'
 
 @Module({
-  controllers: [],
-  providers: [],
-  exports: [],
+  controllers: [ProjectsController],
+  providers: [ProjectsService, ProjectsRepository],
+  exports: [ProjectsService],
 })
 export class ProjectsModule {}

+ 87 - 0
apps/api-server/src/modules/projects/projects.repository.ts

@@ -0,0 +1,87 @@
+import { Injectable } from '@nestjs/common'
+import { db } from '../../db/database.module'
+import { projects } from '../../db/schema'
+import { and, desc, eq } from 'drizzle-orm'
+import { nanoid } from 'nanoid'
+
+@Injectable()
+export class ProjectsRepository {
+  async create(data: {
+    orgId: string
+    name: string
+    description?: string
+    timezone?: string
+    startDate?: string
+    endDate?: string
+    status?: 'planning' | 'active' | 'paused' | 'completed' | 'archived'
+    captureInterval?: number
+    resolution?: string
+  }) {
+    const id = nanoid()
+    await db.insert(projects).values({
+      id,
+      orgId: data.orgId,
+      name: data.name,
+      description: data.description ?? null,
+      timezone: data.timezone ?? 'Asia/Ho_Chi_Minh',
+      startDate: data.startDate ? new Date(data.startDate) : null,
+      endDate: data.endDate ? new Date(data.endDate) : null,
+      status: data.status ?? 'planning',
+      captureInterval: data.captureInterval ?? 60,
+      resolution: data.resolution ?? '1920x1080',
+    })
+    return this.findById(id)
+  }
+
+  async findById(id: string) {
+    const result = await db.select().from(projects).where(eq(projects.id, id)).limit(1)
+    return result[0] ?? null
+  }
+
+  async findAll(orgId?: string) {
+    if (orgId) {
+      return db
+        .select()
+        .from(projects)
+        .where(eq(projects.orgId, orgId))
+        .orderBy(desc(projects.updatedAt))
+    }
+
+    return db.select().from(projects).orderBy(desc(projects.updatedAt))
+  }
+
+  async update(
+    id: string,
+    data: Partial<{
+      name: string
+      description: string
+      timezone: string
+      startDate: string
+      endDate: string
+      status: 'planning' | 'active' | 'paused' | 'completed' | 'archived'
+      captureInterval: number
+      resolution: string
+    }>,
+  ) {
+    await db
+      .update(projects)
+      .set({
+        ...(data.name !== undefined && { name: data.name }),
+        ...(data.description !== undefined && { description: data.description }),
+        ...(data.timezone !== undefined && { timezone: data.timezone }),
+        ...(data.startDate !== undefined && { startDate: data.startDate ? new Date(data.startDate) : null }),
+        ...(data.endDate !== undefined && { endDate: data.endDate ? new Date(data.endDate) : null }),
+        ...(data.status !== undefined && { status: data.status }),
+        ...(data.captureInterval !== undefined && { captureInterval: data.captureInterval }),
+        ...(data.resolution !== undefined && { resolution: data.resolution }),
+        updatedAt: new Date(),
+      })
+      .where(eq(projects.id, id))
+
+    return this.findById(id)
+  }
+
+  async remove(id: string) {
+    await db.delete(projects).where(eq(projects.id, id))
+  }
+}

+ 35 - 0
apps/api-server/src/modules/projects/projects.service.ts

@@ -0,0 +1,35 @@
+import { Injectable, NotFoundException } from '@nestjs/common'
+import { ProjectsRepository } from './projects.repository'
+import { CreateProjectDto, UpdateProjectDto } from './dto/project.dto'
+
+@Injectable()
+export class ProjectsService {
+  constructor(private readonly repo: ProjectsRepository) {}
+
+  create(dto: CreateProjectDto) {
+    return this.repo.create(dto)
+  }
+
+  findAll(orgId?: string) {
+    return this.repo.findAll(orgId)
+  }
+
+  async findOne(id: string) {
+    const project = await this.repo.findById(id)
+    if (!project) throw new NotFoundException(`Project ${id} not found`)
+    return project
+  }
+
+  async update(id: string, dto: UpdateProjectDto) {
+    const exists = await this.repo.findById(id)
+    if (!exists) throw new NotFoundException(`Project ${id} not found`)
+    return this.repo.update(id, dto)
+  }
+
+  async remove(id: string) {
+    const exists = await this.repo.findById(id)
+    if (!exists) throw new NotFoundException(`Project ${id} not found`)
+    await this.repo.remove(id)
+    return { success: true }
+  }
+}

+ 18 - 6
memory.md

@@ -118,12 +118,24 @@ docker compose up -d --build api-server web-dashboard worker
 ---
 
 ## TODO — Phase tiếp theo
-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**
+1. **DB migration**: drizzle-kit push + seed data (trên Pi)
+2. **Orgs CRUD endpoints**
+3. **Device-agent** (Python) heartbeat sender
+4. **Realtime WebSocket** cho status update
+5. **Google OAuth thật** (redirect + callback)
+6. **Role-based access control** (org_admin/project_manager/viewer)
+7. **Worker thực tế** (hiện vẫn restart vì stub)
+
+## Đã hoàn thành gần đây
+- Auth thực sự: register/login/refresh/logout + JWT guard + /auth/me
+- Dashboard auth flow: /login, ProtectedRoute, logout, auto attach Bearer token
+- Projects CRUD API:
+  - `POST /v1/projects`
+  - `GET /v1/projects?orgId=`
+  - `GET /v1/projects/:id`
+  - `PATCH /v1/projects/:id`
+  - `DELETE /v1/projects/:id`
+  (đều có JwtAuthGuard)
 
 ---