Farm Owner Assignment Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Admin có thể gán Owner vào trang trại (Area); Owner tự động có quyền truy cập tất cả ao trong trang trại đó.

Architecture: Thêm junction table area_users (many-to-many Area ↔ User). Khi tạo JWT, query area_users → expand sang pond IDs → merge vào assignedResources. Zero thay đổi với AuthorizationPolicyService.

Tech Stack: NestJS, TypeORM, PostgreSQL, JWT (passport-jwt), Bun, Biome


File Map

File Action Mô tả
backend/src/migrations/1745600000000-AddAreaUsers.ts Create Migration tạo bảng area_users
backend/src/farm/entities/area.entity.ts Modify Thêm assignedOwners: User[] ManyToMany
backend/src/farm/dto/assign-area.dto.ts Create AssignOwnerDto { userId }
backend/src/farm/dto/index.ts Modify Export assign-area.dto.ts
backend/src/farm/areas.service.ts Modify Thêm assignOwner, unassignOwner, getOwners
backend/src/farm/areas.service.spec.ts Modify Tests cho 3 methods mới
backend/src/farm/controllers/areas.controller.ts Modify Thêm 3 endpoints mới
backend/src/auth/decorators/current-user.decorator.ts Modify Thêm assignedAreaIds?: string[]
backend/src/auth/auth.service.ts Modify Query area_users trong generateTokensvalidateUser
backend/src/auth/strategies/jwt.strategy.ts Modify Map assignedAreaIds từ JWT payload
backend/src/auth/auth.module.ts Modify Import AreaPond entities

Task 1: Migration — Tạo bảng area_users

Files:

  • Create: backend/src/migrations/1745600000000-AddAreaUsers.ts

  • Step 1: Tạo migration file

// backend/src/migrations/1745600000000-AddAreaUsers.ts
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddAreaUsers1745600000000 implements MigrationInterface {
  name = 'AddAreaUsers1745600000000';

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      CREATE TABLE "area_users" (
        "area_id" uuid NOT NULL,
        "user_id" uuid NOT NULL,
        CONSTRAINT "PK_area_users" PRIMARY KEY ("area_id", "user_id"),
        CONSTRAINT "FK_area_users_area" FOREIGN KEY ("area_id")
          REFERENCES "areas"("id") ON DELETE CASCADE,
        CONSTRAINT "FK_area_users_user" FOREIGN KEY ("user_id")
          REFERENCES "users"("id") ON DELETE CASCADE
      )
    `);

    await queryRunner.query(`
      CREATE INDEX "IDX_area_users_area_id" ON "area_users" ("area_id")
    `);

    await queryRunner.query(`
      CREATE INDEX "IDX_area_users_user_id" ON "area_users" ("user_id")
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP INDEX "IDX_area_users_user_id"`);
    await queryRunner.query(`DROP INDEX "IDX_area_users_area_id"`);
    await queryRunner.query(`DROP TABLE "area_users"`);
  }
}
  • Step 2: Chạy migration
cd backend && bun run migration:run

Expected: query: CREATE TABLE "area_users" ... — không có lỗi.

  • Step 3: Commit
git add backend/src/migrations/1745600000000-AddAreaUsers.ts
git commit -m "feat: add area_users migration"

Task 2: Area Entity + DTO

Files:

  • Modify: backend/src/farm/entities/area.entity.ts

  • Create: backend/src/farm/dto/assign-area.dto.ts

  • Modify: backend/src/farm/dto/index.ts

  • Step 1: Thêm assignedOwners vào Area entity

Mở backend/src/farm/entities/area.entity.ts. Thêm import User và relation assignedOwners:

import {
  Column,
  CreateDateColumn,
  Entity,
  JoinColumn,
  JoinTable,
  ManyToMany,
  ManyToOne,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { User } from '@/auth/entities/user.entity';
import { Organization } from '@/organizations/entities/organization.entity';
import { Pond } from './pond.entity';

@Entity('areas')
export class Area {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'organization_id' })
  organizationId: string;

  @Column({ length: 255 })
  name: string;

  @Column({ name: 'code', length: 50, type: 'varchar', nullable: true })
  code: string;

  @Column({ type: 'text', nullable: true })
  address: string;

  @Column({ type: 'jsonb', nullable: true })
  coordinates: { lat: number; lng: number };

  @Column({ name: 'is_active', type: 'boolean', default: true })
  isActive: boolean;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
  updatedAt: Date;

  @ManyToOne(() => Organization)
  @JoinColumn({ name: 'organization_id' })
  organization: Organization;

  @OneToMany(
    () => Pond,
    (pond) => pond.area,
  )
  ponds: Pond[];

  @ManyToMany(() => User)
  @JoinTable({
    name: 'area_users',
    joinColumn: { name: 'area_id', referencedColumnName: 'id' },
    inverseJoinColumn: { name: 'user_id', referencedColumnName: 'id' },
  })
  assignedOwners: User[];
}
  • Step 2: Tạo assign-area.dto.ts
// backend/src/farm/dto/assign-area.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsUUID } from 'class-validator';

export class AssignOwnerDto {
  @ApiProperty({ example: 'uuid', description: 'User ID to assign as owner' })
  @IsUUID()
  @IsNotEmpty({ message: 'User ID is required' })
  userId: string;
}
  • Step 3: Export từ dto/index.ts

Thêm dòng sau vào backend/src/farm/dto/index.ts:

export * from './area-response.dto';
export * from './assign-area.dto';
export * from './assign-pond.dto';
export * from './create-area.dto';
export * from './create-pond.dto';
export * from './pond-response.dto';
export * from './query-area.dto';
export * from './query-pond.dto';
export * from './update-area.dto';
export * from './update-pond.dto';
  • Step 4: Lint
cd backend && bun run lint:fix

Expected: No errors.

  • Step 5: Commit
git add backend/src/farm/entities/area.entity.ts \
        backend/src/farm/dto/assign-area.dto.ts \
        backend/src/farm/dto/index.ts
git commit -m "feat: add area assignedOwners relation and AssignOwnerDto"

Task 3: Areas Service — assignOwner, unassignOwner, getOwners (TDD)

Files:

  • Modify: backend/src/farm/areas.service.spec.ts
  • Modify: backend/src/farm/areas.service.ts

3a: Viết failing tests

  • Step 1: Mở areas.service.spec.ts và thêm mock user, mock userRepository, và 3 test suites

Tìm phần describe('AreasService', () => { và thêm:

  1. Khai báo userRepository mock cùng với các mocks hiện có:
let userRepository: jest.Mocked<Repository<User>>;
  1. Thêm import User vào đầu file:
import { User } from '@/auth/entities/user.entity';
  1. Thêm mockUser constant sau mockArea:
const mockUser: Partial<User> = {
  id: 'user-owner-1',
  email: 'owner@test.com',
  organizationId: 'org-id-1',
};
  1. Trong beforeEach, thêm userRepository vào providers:
{
  provide: getRepositoryToken(User),
  useValue: {
    findOne: jest.fn(),
  },
},
  1. Sau areaRepository = module.get(...), thêm:
userRepository = module.get(getRepositoryToken(User));
  1. Trong AreasService providers, thêm User vào TypeOrmModule.forFeature — thực ra trong test ta mock trực tiếp nên chỉ cần thêm vào providers array.

  2. Thêm 3 describe blocks vào cuối file trước closing });:

describe('assignOwner', () => {
  it('should assign a user as owner of an area', async () => {
    const area = { ...mockArea, assignedOwners: [] } as Area;
    areaRepository.findOne = jest.fn().mockResolvedValue(area);
    userRepository.findOne = jest.fn().mockResolvedValue(mockUser);
    authorizationPolicyService.isTenantResourceAccessible = jest.fn().mockReturnValue(true);
    areaRepository.save = jest.fn().mockResolvedValue({ ...area, assignedOwners: [mockUser] });

    await service.assignOwner('area-id-1', { userId: 'user-owner-1' }, mockCurrentUser);

    expect(areaRepository.save).toHaveBeenCalledWith(
      expect.objectContaining({
        assignedOwners: expect.arrayContaining([expect.objectContaining({ id: 'user-owner-1' })]),
      }),
    );
  });

  it('should throw NotFoundException when area not found', async () => {
    areaRepository.findOne = jest.fn().mockResolvedValue(null);

    await expect(
      service.assignOwner('non-existent', { userId: 'user-owner-1' }, mockCurrentUser),
    ).rejects.toThrow(NotFoundException);
  });

  it('should throw NotFoundException when area not accessible', async () => {
    areaRepository.findOne = jest.fn().mockResolvedValue({ ...mockArea, assignedOwners: [] });
    authorizationPolicyService.isTenantResourceAccessible = jest.fn().mockReturnValue(false);

    await expect(
      service.assignOwner('area-id-1', { userId: 'user-owner-1' }, mockCurrentUser),
    ).rejects.toThrow(NotFoundException);
  });

  it('should throw NotFoundException when user not found', async () => {
    areaRepository.findOne = jest.fn().mockResolvedValue({ ...mockArea, assignedOwners: [] });
    authorizationPolicyService.isTenantResourceAccessible = jest.fn().mockReturnValue(true);
    userRepository.findOne = jest.fn().mockResolvedValue(null);

    await expect(
      service.assignOwner('area-id-1', { userId: 'non-existent' }, mockCurrentUser),
    ).rejects.toThrow(NotFoundException);
  });

  it('should throw BadRequestException when user is from different org', async () => {
    areaRepository.findOne = jest.fn().mockResolvedValue({ ...mockArea, assignedOwners: [] });
    authorizationPolicyService.isTenantResourceAccessible = jest.fn().mockReturnValue(true);
    userRepository.findOne = jest.fn().mockResolvedValue({ ...mockUser, organizationId: 'other-org' });

    await expect(
      service.assignOwner('area-id-1', { userId: 'user-owner-1' }, mockCurrentUser),
    ).rejects.toThrow(BadRequestException);
  });

  it('should throw BadRequestException when user is already assigned', async () => {
    const area = { ...mockArea, assignedOwners: [mockUser as User] } as Area;
    areaRepository.findOne = jest.fn().mockResolvedValue(area);
    authorizationPolicyService.isTenantResourceAccessible = jest.fn().mockReturnValue(true);
    userRepository.findOne = jest.fn().mockResolvedValue(mockUser);

    await expect(
      service.assignOwner('area-id-1', { userId: 'user-owner-1' }, mockCurrentUser),
    ).rejects.toThrow(BadRequestException);
  });
});

describe('unassignOwner', () => {
  it('should remove an owner from an area', async () => {
    const area = { ...mockArea, assignedOwners: [mockUser as User] } as Area;
    areaRepository.findOne = jest.fn().mockResolvedValue(area);
    authorizationPolicyService.isTenantResourceAccessible = jest.fn().mockReturnValue(true);
    areaRepository.save = jest.fn().mockResolvedValue({ ...area, assignedOwners: [] });

    await service.unassignOwner('area-id-1', 'user-owner-1', mockCurrentUser);

    expect(areaRepository.save).toHaveBeenCalledWith(
      expect.objectContaining({ assignedOwners: [] }),
    );
  });

  it('should throw NotFoundException when area not found', async () => {
    areaRepository.findOne = jest.fn().mockResolvedValue(null);

    await expect(
      service.unassignOwner('non-existent', 'user-owner-1', mockCurrentUser),
    ).rejects.toThrow(NotFoundException);
  });

  it('should throw NotFoundException when area not accessible', async () => {
    areaRepository.findOne = jest.fn().mockResolvedValue({ ...mockArea, assignedOwners: [] });
    authorizationPolicyService.isTenantResourceAccessible = jest.fn().mockReturnValue(false);

    await expect(
      service.unassignOwner('area-id-1', 'user-owner-1', mockCurrentUser),
    ).rejects.toThrow(NotFoundException);
  });

  it('should throw NotFoundException when user is not assigned', async () => {
    areaRepository.findOne = jest.fn().mockResolvedValue({ ...mockArea, assignedOwners: [] } as Area);
    authorizationPolicyService.isTenantResourceAccessible = jest.fn().mockReturnValue(true);

    await expect(
      service.unassignOwner('area-id-1', 'user-owner-1', mockCurrentUser),
    ).rejects.toThrow(NotFoundException);
  });
});

describe('getOwners', () => {
  it('should return list of owners for an area', async () => {
    const area = { ...mockArea, assignedOwners: [mockUser as User] } as Area;
    areaRepository.findOne = jest.fn().mockResolvedValue(area);
    authorizationPolicyService.isTenantResourceAccessible = jest.fn().mockReturnValue(true);

    const result = await service.getOwners('area-id-1', mockCurrentUser);

    expect(result).toEqual([
      expect.objectContaining({ id: 'user-owner-1', email: 'owner@test.com' }),
    ]);
  });

  it('should throw NotFoundException when area not found', async () => {
    areaRepository.findOne = jest.fn().mockResolvedValue(null);

    await expect(service.getOwners('non-existent', mockCurrentUser)).rejects.toThrow(
      NotFoundException,
    );
  });
});
  • Step 2: Chạy tests — xác nhận FAIL
cd backend && bun test -- backend/src/farm/areas.service.spec.ts --testNamePattern="assignOwner|unassignOwner|getOwners"

Expected: Tests fail với TypeError: service.assignOwner is not a function.

3b: Implement

  • Step 3: Thêm userRepository vào AreasService constructor

Mở backend/src/farm/areas.service.ts. Thêm imports:

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, type Repository } from 'typeorm';

import { FarmingCycle } from '@/aquaculture/entities/farming-cycle.entity';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { User } from '@/auth/entities/user.entity';
import type { AreaResponseDto } from '@/farm/dto/area-response.dto';
import { AssignOwnerDto } from '@/farm/dto/assign-area.dto';
import { CreateAreaDto } from '@/farm/dto/create-area.dto';
import { QueryAreaDto } from '@/farm/dto/query-area.dto';
import { UpdateAreaDto } from '@/farm/dto/update-area.dto';
import { Area } from '@/farm/entities/area.entity';
import { AuthorizationPolicyService } from '@/rbac/authorization-policy.service';

Thêm userRepository vào constructor (sau private areaRepository):

constructor(
  @InjectRepository(Area)
  private areaRepository: Repository<Area>,
  @InjectRepository(User)
  private userRepository: Repository<User>,
  private dataSource: DataSource,
  private authorizationPolicyService: AuthorizationPolicyService,
) {}
  • Step 4: Thêm 3 methods vào AreasService — đặt trước private async toAreaResponse
async assignOwner(
  areaId: string,
  assignOwnerDto: AssignOwnerDto,
  currentUser: CurrentUserData,
): Promise<void> {
  const area = await this.areaRepository.findOne({
    where: { id: areaId },
    relations: ['assignedOwners'],
  });

  if (!area) {
    throw new NotFoundException('Area not found');
  }

  const isAccessible = this.authorizationPolicyService.isTenantResourceAccessible(currentUser, {
    resource: 'area',
    action: 'update',
    organizationId: area.organizationId,
    resourceId: area.id,
  });

  if (!isAccessible) {
    throw new NotFoundException('Area not found');
  }

  const user = await this.userRepository.findOne({ where: { id: assignOwnerDto.userId } });

  if (!user) {
    throw new NotFoundException('User not found');
  }

  if (user.organizationId !== currentUser.organizationId) {
    throw new BadRequestException('User does not belong to your organization');
  }

  if (area.assignedOwners.some((u) => u.id === user.id)) {
    throw new BadRequestException('User is already assigned as owner of this area');
  }

  area.assignedOwners.push(user);
  await this.areaRepository.save(area);
}

async unassignOwner(
  areaId: string,
  userId: string,
  currentUser: CurrentUserData,
): Promise<void> {
  const area = await this.areaRepository.findOne({
    where: { id: areaId },
    relations: ['assignedOwners'],
  });

  if (!area) {
    throw new NotFoundException('Area not found');
  }

  const isAccessible = this.authorizationPolicyService.isTenantResourceAccessible(currentUser, {
    resource: 'area',
    action: 'update',
    organizationId: area.organizationId,
    resourceId: area.id,
  });

  if (!isAccessible) {
    throw new NotFoundException('Area not found');
  }

  const isAssigned = area.assignedOwners.some((u) => u.id === userId);
  if (!isAssigned) {
    throw new NotFoundException('User is not assigned as owner of this area');
  }

  area.assignedOwners = area.assignedOwners.filter((u) => u.id !== userId);
  await this.areaRepository.save(area);
}

async getOwners(
  areaId: string,
  currentUser: CurrentUserData,
): Promise<{ id: string; fullName: string; email: string }[]> {
  const area = await this.areaRepository.findOne({
    where: { id: areaId },
    relations: ['assignedOwners', 'ponds'],
  });

  if (!area) {
    throw new NotFoundException('Area not found');
  }

  const isAccessible = this.authorizationPolicyService.isTenantResourceAccessible(currentUser, {
    resource: 'area',
    action: 'read',
    organizationId: area.organizationId,
    assignedResourceIds: area.ponds?.map((p) => p.id) ?? [],
  });

  if (!isAccessible) {
    throw new NotFoundException('Area not found');
  }

  return area.assignedOwners.map((u) => ({
    id: u.id,
    fullName: u.fullName ?? '',
    email: u.email,
  }));
}
  • Step 5: Chạy tests — xác nhận PASS
cd backend && bun test -- backend/src/farm/areas.service.spec.ts --testNamePattern="assignOwner|unassignOwner|getOwners"

Expected: All tests PASS.

  • Step 6: Chạy toàn bộ test suite của areas
cd backend && bun test -- backend/src/farm/areas.service.spec.ts

Expected: All tests PASS (không có regression).

  • Step 7: Lint
cd backend && bun run lint:fix

Expected: No errors.

  • Step 8: Commit
git add backend/src/farm/areas.service.ts \
        backend/src/farm/areas.service.spec.ts
git commit -m "feat: add assignOwner/unassignOwner/getOwners to AreasService"

Task 4: Areas Controller — 3 Endpoints Mới

Files:

  • Modify: backend/src/farm/controllers/areas.controller.ts

  • Step 1: Thêm 3 endpoints vào AreasController

Thêm import AssignOwnerDto vào đầu file:

import { AssignOwnerDto } from '@/farm/dto/assign-area.dto';

Thêm 3 endpoints sau @Delete(':id') block:

@Post(':id/assign-owner')
@ApiOperation({ summary: 'Assign owner to area' })
@ApiParam({ name: 'id', description: 'Area ID' })
@ApiResponse({ status: 200, description: 'Owner assigned' })
@ApiResponse({ status: 400, description: 'User already assigned or from different org' })
@ApiResponse({ status: 404, description: 'Area or user not found' })
@HttpCode(HttpStatus.OK)
@RequirePermissions('area:update:all')
async assignOwner(
  @CurrentUser() currentUser: CurrentUserData,
  @Param('id') id: string,
  @Body() assignOwnerDto: AssignOwnerDto,
) {
  await this.areasService.assignOwner(id, assignOwnerDto, currentUser);
  return { message: 'Owner assigned successfully' };
}

@Delete(':id/assign-owner/:userId')
@ApiOperation({ summary: 'Remove owner from area' })
@ApiParam({ name: 'id', description: 'Area ID' })
@ApiParam({ name: 'userId', description: 'User ID to remove' })
@ApiResponse({ status: 204, description: 'Owner removed' })
@ApiResponse({ status: 404, description: 'Area not found or user not assigned' })
@HttpCode(HttpStatus.NO_CONTENT)
@RequirePermissions('area:update:all')
async unassignOwner(
  @CurrentUser() currentUser: CurrentUserData,
  @Param('id') id: string,
  @Param('userId') userId: string,
) {
  await this.areasService.unassignOwner(id, userId, currentUser);
}

@Get(':id/owners')
@ApiOperation({ summary: 'List owners of an area' })
@ApiParam({ name: 'id', description: 'Area ID' })
@ApiResponse({ status: 200, description: 'Owners list' })
@ApiResponse({ status: 404, description: 'Area not found' })
@RequirePermissions('area:read:all', 'area:read:own', 'area:read:assigned')
async getOwners(@CurrentUser() currentUser: CurrentUserData, @Param('id') id: string) {
  const data = await this.areasService.getOwners(id, currentUser);
  return { data };
}
  • Step 2: Lint
cd backend && bun run lint:fix

Expected: No errors.

  • Step 3: Commit
git add backend/src/farm/controllers/areas.controller.ts
git commit -m "feat: add assign-owner endpoints to AreasController"

Task 5: Auth — JWT với assignedAreaIds

Files:

  • Modify: backend/src/auth/decorators/current-user.decorator.ts

  • Modify: backend/src/auth/auth.module.ts

  • Modify: backend/src/auth/auth.service.ts

  • Modify: backend/src/auth/strategies/jwt.strategy.ts

  • Step 1: Thêm assignedAreaIds vào CurrentUserData

Sửa backend/src/auth/decorators/current-user.decorator.ts:

import { createParamDecorator, type ExecutionContext } from '@nestjs/common';

export interface CurrentUserData {
  userId: string;
  email: string;
  organizationId: string | null;
  roles: string[];
  permissions?: string[];
  assignedResources?: string[];
  assignedAreaIds?: string[];
}

export const CurrentUser = createParamDecorator(
  (_data: unknown, ctx: ExecutionContext): CurrentUserData => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;
  },
);
  • Step 2: Thêm AreaPond vào AuthModule

Sửa backend/src/auth/auth.module.ts — thêm imports và cập nhật TypeOrmModule.forFeature:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import type { JwtModuleOptions } from '@nestjs/jwt';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ThrottlerModule } from '@nestjs/throttler';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Area } from '@/farm/entities/area.entity';
import { Pond } from '@/farm/entities/pond.entity';
import { UserRole } from '@/user-roles/entities/user-role.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { PasswordResetToken } from './entities/password-reset-token.entity';
import { RefreshToken } from './entities/refresh-token.entity';
import { User } from './entities/user.entity';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RefreshJwtGuard } from './guards/refresh-jwt.guard';
import { JwtStrategy } from './strategies/jwt.strategy';
import { RefreshJwtStrategy } from './strategies/refresh-jwt.strategy';

@Module({
  imports: [
    TypeOrmModule.forFeature([User, RefreshToken, PasswordResetToken, UserRole, Area, Pond]),
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService): Promise<JwtModuleOptions> => ({
        secret: configService.get<string>('JWT_SECRET'),
        signOptions: {
          expiresIn: configService.get<string>('JWT_ACCESS_EXPIRY', '15m') as never,
        },
      }),
      inject: [ConfigService],
    }),
    ThrottlerModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => [
        {
          ttl: config.get<number>('LOGIN_RATE_WINDOW', 900),
          limit: config.get<number>('LOGIN_RATE_LIMIT', 5),
        },
      ],
      inject: [ConfigService],
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy, RefreshJwtStrategy, JwtAuthGuard, RefreshJwtGuard],
  exports: [AuthService, JwtAuthGuard, RefreshJwtGuard, JwtStrategy, RefreshJwtStrategy],
})
export class AuthModule {}
  • Step 3: Cập nhật AuthService — thêm repo + helper method

Mở backend/src/auth/auth.service.ts. Thêm imports:

import { In } from 'typeorm';
import { Area } from '@/farm/entities/area.entity';
import { Pond } from '@/farm/entities/pond.entity';

Thêm 2 repo vào constructor (sau private userRoleRepository):

@InjectRepository(Area)
private areaRepository: Repository<Area>,
@InjectRepository(Pond)
private pondRepository: Repository<Pond>,

Thêm private helper method sau private parseTimeToMs:

private async getAreaAssignments(userId: string): Promise<{ assignedAreaIds: string[]; pondIdsFromAreas: string[] }> {
  const assignedAreas = await this.areaRepository
    .createQueryBuilder('area')
    .innerJoin('area.assignedOwners', 'user', 'user.id = :userId', { userId })
    .select(['area.id'])
    .getMany();

  const assignedAreaIds = assignedAreas.map((a) => a.id);

  if (assignedAreaIds.length === 0) {
    return { assignedAreaIds: [], pondIdsFromAreas: [] };
  }

  const ponds = await this.pondRepository.find({
    where: { areaId: In(assignedAreaIds) },
    select: { id: true },
  });

  return { assignedAreaIds, pondIdsFromAreas: ponds.map((p) => p.id) };
}
  • Step 4: Cập nhật generateTokens để include area assignments

Tìm method async generateTokens(user: User). Thay toàn bộ method:

async generateTokens(user: User) {
  const userRoles = await this.userRoleRepository.find({
    where: { userId: user.id },
    relations: ['role'],
  });

  const roles = userRoles.map((ur) => ur.role.name);
  const permissions: string[] = [];
  const assignedResources: string[] = [];

  for (const userRole of userRoles) {
    const rolePermissions = userRole.role.permissions || [];
    permissions.push(...rolePermissions.map((p) => `${p.resource}:${p.action}:${p.scope}`));
    const resources = (userRole.assignedResources as string[]) || [];
    assignedResources.push(...resources);
  }

  const { assignedAreaIds, pondIdsFromAreas } = await this.getAreaAssignments(user.id);
  assignedResources.push(...pondIdsFromAreas);

  const payload = {
    sub: user.id,
    email: user.email,
    organizationId: user.organizationId,
    roles,
    permissions,
    assignedResources: [...new Set(assignedResources)],
    assignedAreaIds,
  };

  const accessTokenExpiry = this.configService.get<string>('JWT_ACCESS_EXPIRY', '15m');
  const refreshTokenExpiry = this.configService.get<string>('JWT_REFRESH_EXPIRY', '7d');

  const accessToken = this.jwtService.sign(payload, {
    expiresIn: accessTokenExpiry as never,
  });

  const refreshToken = uuidv4();
  const refreshTokenExpiresAt = new Date(Date.now() + this.parseTimeToMs(refreshTokenExpiry));

  const savedRefreshToken = await this.refreshTokenRepository.save({
    userId: user.id,
    token: refreshToken,
    expiresAt: refreshTokenExpiresAt,
  });

  return {
    accessToken,
    refreshToken,
    refreshTokenId: savedRefreshToken.id,
  };
}
  • Step 5: Cập nhật validateUser để include area assignments

Tìm method async validateUser(userId: string). Thay phần return value để thêm assignedAreaIds:

async validateUser(userId: string): Promise<CurrentUserData> {
  const user = await this.userRepository.findOne({
    where: { id: userId },
    relations: ['userRoles', 'userRoles.role'],
  });

  if (!user) {
    throw new UnauthorizedException('User not found');
  }

  const roles = user.userRoles?.map((ur) => ur.role.name) || [];
  const permissions: string[] = [];
  const assignedResources: string[] = [];

  for (const userRole of user.userRoles || []) {
    const rolePermissions = userRole.role.permissions || [];
    permissions.push(...rolePermissions.map((p) => `${p.resource}:${p.action}:${p.scope}`));
    const resources = (userRole.assignedResources as string[]) || [];
    assignedResources.push(...resources);
  }

  const { assignedAreaIds, pondIdsFromAreas } = await this.getAreaAssignments(user.id);
  assignedResources.push(...pondIdsFromAreas);

  return {
    userId: user.id,
    email: user.email,
    organizationId: user.organizationId,
    roles,
    permissions,
    assignedResources: [...new Set(assignedResources)],
    assignedAreaIds,
  };
}
  • Step 6: Cập nhật JwtStrategy.validate để map assignedAreaIds

Sửa backend/src/auth/strategies/jwt.strategy.ts:

async validate(payload: Record<string, unknown>) {
  return {
    userId: payload.sub as string,
    email: payload.email as string,
    organizationId: payload.organizationId as string | null,
    roles: payload.roles as string[],
    permissions: payload.permissions as string[],
    assignedResources: (payload.assignedResources as string[]) || [],
    assignedAreaIds: (payload.assignedAreaIds as string[]) || [],
  };
}
  • Step 7: Lint
cd backend && bun run lint:fix

Expected: No errors.

  • Step 8: Chạy auth tests để kiểm tra regression
cd backend && bun test -- backend/src/auth/

Expected: All tests PASS. (Auth tests hiện có mock generateTokens nên không bị ảnh hưởng bởi new repos.)

  • Step 9: Commit
git add backend/src/auth/decorators/current-user.decorator.ts \
        backend/src/auth/auth.module.ts \
        backend/src/auth/auth.service.ts \
        backend/src/auth/strategies/jwt.strategy.ts
git commit -m "feat: include assignedAreaIds in JWT and expand area assignments to pond IDs"

Task 6: Smoke Test — End-to-End Manual Verification

  • Step 1: Khởi động services và backend
# Terminal 1: Docker services
docker compose up -d

# Terminal 2: Backend
cd backend && bun run start:dev
  • Step 2: Kiểm tra Swagger

Mở http://localhost:3000/api. Kiểm tra 3 endpoints mới trong nhóm Areas:

  • POST /areas/{id}/assign-owner

  • DELETE /areas/{id}/assign-owner/{userId}

  • GET /areas/{id}/owners

  • Step 3: Chạy toàn bộ test suite

cd backend && bun test

Expected: All tests PASS.

  • Step 4: Final commit
git add .
git commit -m "chore: farm owner assignment feature complete"