Bladeren bron

feat: add Orgs CRUD and org-scoped RBAC for Projects

- New Orgs module with repository/service/controller/dto
- JWT-protected /v1/orgs endpoints (create/list/get/update/delete)
- ProjectsService now enforces org membership checks via OrgsService
- ProjectsController passes current user context into service methods
- Projects list endpoint returns only org-scoped projects for current user
kingkong 2 maanden geleden
bovenliggende
commit
220d8571bb

+ 28 - 0
apps/api-server/src/modules/orgs/dto/org.dto.ts

@@ -0,0 +1,28 @@
+import { IsEnum, IsOptional, IsString } from 'class-validator'
+
+export class CreateOrgDto {
+  @IsString()
+  name!: string
+
+  @IsOptional()
+  @IsString()
+  planTier?: string
+
+  @IsOptional()
+  @IsEnum(['active', 'suspended', 'trial'])
+  status?: 'active' | 'suspended' | 'trial'
+}
+
+export class UpdateOrgDto {
+  @IsOptional()
+  @IsString()
+  name?: string
+
+  @IsOptional()
+  @IsString()
+  planTier?: string
+
+  @IsOptional()
+  @IsEnum(['active', 'suspended', 'trial'])
+  status?: 'active' | 'suspended' | 'trial'
+}

+ 41 - 0
apps/api-server/src/modules/orgs/orgs.controller.ts

@@ -0,0 +1,41 @@
+import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
+import { CurrentUser } from '../../common/decorators/current-user.decorator'
+import { OrgsService } from './orgs.service'
+import { CreateOrgDto, UpdateOrgDto } from './dto/org.dto'
+
+@Controller('orgs')
+@UseGuards(JwtAuthGuard)
+export class OrgsController {
+  constructor(private readonly orgsService: OrgsService) {}
+
+  @Post()
+  create(@Body() dto: CreateOrgDto) {
+    return this.orgsService.create(dto)
+  }
+
+  @Get()
+  findAll() {
+    return this.orgsService.findAll()
+  }
+
+  @Get(':id')
+  findOne(@Param('id') id: string) {
+    return this.orgsService.findOne(id)
+  }
+
+  @Patch(':id')
+  update(@Param('id') id: string, @Body() dto: UpdateOrgDto) {
+    return this.orgsService.update(id, dto)
+  }
+
+  @Delete(':id')
+  remove(@Param('id') id: string) {
+    return this.orgsService.remove(id)
+  }
+
+  @Get(':id/me-access')
+  myAccess(@Param('id') orgId: string, @CurrentUser() user: { userId: string }) {
+    return this.orgsService.ensureUserInOrg(user.userId, orgId)
+  }
+}

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

@@ -1,8 +1,11 @@
 import { Module } from '@nestjs/common'
+import { OrgsController } from './orgs.controller'
+import { OrgsService } from './orgs.service'
+import { OrgsRepository } from './orgs.repository'
 
 @Module({
-  controllers: [],
-  providers: [],
-  exports: [],
+  controllers: [OrgsController],
+  providers: [OrgsService, OrgsRepository],
+  exports: [OrgsService],
 })
 export class OrgsModule {}

+ 62 - 0
apps/api-server/src/modules/orgs/orgs.repository.ts

@@ -0,0 +1,62 @@
+import { Injectable } from '@nestjs/common'
+import { db } from '../../db/database.module'
+import { organizations, memberships } from '../../db/schema'
+import { and, desc, eq } from 'drizzle-orm'
+import { nanoid } from 'nanoid'
+
+@Injectable()
+export class OrgsRepository {
+  async create(data: { name: string; planTier?: string; status?: 'active' | 'suspended' | 'trial' }) {
+    const id = nanoid()
+    await db.insert(organizations).values({
+      id,
+      name: data.name,
+      planTier: data.planTier ?? 'free',
+      status: data.status ?? 'trial',
+    })
+    return this.findById(id)
+  }
+
+  async findById(id: string) {
+    const result = await db.select().from(organizations).where(eq(organizations.id, id)).limit(1)
+    return result[0] ?? null
+  }
+
+  async findAll() {
+    return db.select().from(organizations).orderBy(desc(organizations.updatedAt))
+  }
+
+  async update(
+    id: string,
+    data: Partial<{ name: string; planTier: string; status: 'active' | 'suspended' | 'trial' }>,
+  ) {
+    await db
+      .update(organizations)
+      .set({
+        ...(data.name !== undefined && { name: data.name }),
+        ...(data.planTier !== undefined && { planTier: data.planTier }),
+        ...(data.status !== undefined && { status: data.status }),
+        updatedAt: new Date(),
+      })
+      .where(eq(organizations.id, id))
+
+    return this.findById(id)
+  }
+
+  async remove(id: string) {
+    await db.delete(organizations).where(eq(organizations.id, id))
+  }
+
+  async isUserMemberOfOrg(userId: string, orgId: string) {
+    const result = await db
+      .select()
+      .from(memberships)
+      .where(and(eq(memberships.userId, userId), eq(memberships.orgId, orgId)))
+      .limit(1)
+    return result[0] ?? null
+  }
+
+  async getUserMemberships(userId: string) {
+    return db.select().from(memberships).where(eq(memberships.userId, userId))
+  }
+}

+ 48 - 0
apps/api-server/src/modules/orgs/orgs.service.ts

@@ -0,0 +1,48 @@
+import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
+import { OrgsRepository } from './orgs.repository'
+import { CreateOrgDto, UpdateOrgDto } from './dto/org.dto'
+
+@Injectable()
+export class OrgsService {
+  constructor(private readonly repo: OrgsRepository) {}
+
+  create(dto: CreateOrgDto) {
+    return this.repo.create(dto)
+  }
+
+  findAll() {
+    return this.repo.findAll()
+  }
+
+  async findOne(id: string) {
+    const org = await this.repo.findById(id)
+    if (!org) throw new NotFoundException(`Organization ${id} not found`)
+    return org
+  }
+
+  async update(id: string, dto: UpdateOrgDto) {
+    const org = await this.repo.findById(id)
+    if (!org) throw new NotFoundException(`Organization ${id} not found`)
+    return this.repo.update(id, dto)
+  }
+
+  async remove(id: string) {
+    const org = await this.repo.findById(id)
+    if (!org) throw new NotFoundException(`Organization ${id} not found`)
+    await this.repo.remove(id)
+    return { success: true }
+  }
+
+  async ensureUserInOrg(userId: string, orgId: string) {
+    const membership = await this.repo.isUserMemberOfOrg(userId, orgId)
+    if (!membership) {
+      throw new ForbiddenException('You are not a member of this organization')
+    }
+    return membership
+  }
+
+  async getUserOrgIds(userId: string): Promise<string[]> {
+    const memberships = await this.repo.getUserMemberships(userId)
+    return memberships.map((m) => m.orgId)
+  }
+}

+ 11 - 10
apps/api-server/src/modules/projects/projects.controller.ts

@@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } f
 import { ProjectsService } from './projects.service'
 import { CreateProjectDto, UpdateProjectDto } from './dto/project.dto'
 import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
+import { CurrentUser } from '../../common/decorators/current-user.decorator'
 
 @Controller('projects')
 @UseGuards(JwtAuthGuard)
@@ -9,27 +10,27 @@ export class ProjectsController {
   constructor(private readonly projectsService: ProjectsService) {}
 
   @Post()
-  create(@Body() dto: CreateProjectDto) {
-    return this.projectsService.create(dto)
+  create(@Body() dto: CreateProjectDto, @CurrentUser() user: { userId: string }) {
+    return this.projectsService.create(dto, user.userId)
   }
 
   @Get()
-  findAll(@Query('orgId') orgId?: string) {
-    return this.projectsService.findAll(orgId)
+  findAll(@CurrentUser() user: { userId: string }, @Query('orgId') orgId?: string) {
+    return this.projectsService.findAll(user.userId, orgId)
   }
 
   @Get(':id')
-  findOne(@Param('id') id: string) {
-    return this.projectsService.findOne(id)
+  findOne(@Param('id') id: string, @CurrentUser() user: { userId: string }) {
+    return this.projectsService.findOne(id, user.userId)
   }
 
   @Patch(':id')
-  update(@Param('id') id: string, @Body() dto: UpdateProjectDto) {
-    return this.projectsService.update(id, dto)
+  update(@Param('id') id: string, @Body() dto: UpdateProjectDto, @CurrentUser() user: { userId: string }) {
+    return this.projectsService.update(id, dto, user.userId)
   }
 
   @Delete(':id')
-  remove(@Param('id') id: string) {
-    return this.projectsService.remove(id)
+  remove(@Param('id') id: string, @CurrentUser() user: { userId: string }) {
+    return this.projectsService.remove(id, user.userId)
   }
 }

+ 2 - 0
apps/api-server/src/modules/projects/projects.module.ts

@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'
 import { ProjectsController } from './projects.controller'
 import { ProjectsService } from './projects.service'
 import { ProjectsRepository } from './projects.repository'
+import { OrgsModule } from '../orgs/orgs.module'
 
 @Module({
+  imports: [OrgsModule],
   controllers: [ProjectsController],
   providers: [ProjectsService, ProjectsRepository],
   exports: [ProjectsService],

+ 24 - 7
apps/api-server/src/modules/projects/projects.service.ts

@@ -1,34 +1,51 @@
 import { Injectable, NotFoundException } from '@nestjs/common'
 import { ProjectsRepository } from './projects.repository'
 import { CreateProjectDto, UpdateProjectDto } from './dto/project.dto'
+import { OrgsService } from '../orgs/orgs.service'
 
 @Injectable()
 export class ProjectsService {
-  constructor(private readonly repo: ProjectsRepository) {}
+  constructor(
+    private readonly repo: ProjectsRepository,
+    private readonly orgsService: OrgsService,
+  ) {}
 
-  create(dto: CreateProjectDto) {
+  async create(dto: CreateProjectDto, userId: string) {
+    await this.orgsService.ensureUserInOrg(userId, dto.orgId)
     return this.repo.create(dto)
   }
 
-  findAll(orgId?: string) {
-    return this.repo.findAll(orgId)
+  async findAll(userId: string, orgId?: string) {
+    if (orgId) {
+      await this.orgsService.ensureUserInOrg(userId, orgId)
+      return this.repo.findAll(orgId)
+    }
+
+    const allowedOrgIds = await this.orgsService.getUserOrgIds(userId)
+    if (allowedOrgIds.length === 0) return []
+
+    const all = await this.repo.findAll()
+    return all.filter((p) => allowedOrgIds.includes(p.orgId))
   }
 
-  async findOne(id: string) {
+  async findOne(id: string, userId: string) {
     const project = await this.repo.findById(id)
     if (!project) throw new NotFoundException(`Project ${id} not found`)
+    await this.orgsService.ensureUserInOrg(userId, project.orgId)
     return project
   }
 
-  async update(id: string, dto: UpdateProjectDto) {
+  async update(id: string, dto: UpdateProjectDto, userId: string) {
     const exists = await this.repo.findById(id)
     if (!exists) throw new NotFoundException(`Project ${id} not found`)
+    await this.orgsService.ensureUserInOrg(userId, exists.orgId)
     return this.repo.update(id, dto)
   }
 
-  async remove(id: string) {
+  async remove(id: string, userId: string) {
     const exists = await this.repo.findById(id)
     if (!exists) throw new NotFoundException(`Project ${id} not found`)
+    await this.orgsService.ensureUserInOrg(userId, exists.orgId)
     await this.repo.remove(id)
     return { success: true }
   }

+ 4 - 3
memory.md

@@ -119,16 +119,17 @@ docker compose up -d --build api-server web-dashboard worker
 
 ## TODO — Phase tiếp theo
 1. **DB migration**: drizzle-kit push + seed data (trên Pi)
-2. **Orgs CRUD endpoints**
+2. **Role-based access control nâng cao** (org_admin/project_manager/viewer)
 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)
+6. **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
+- Orgs CRUD API (`/v1/orgs`) có JWT guard
+- RBAC cơ bản cho Projects: chỉ user thuộc org mới được create/list/get/update/delete project
 - Projects CRUD API:
   - `POST /v1/projects`
   - `GET /v1/projects?orgId=`