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 generateTokens và validateUser |
backend/src/auth/strategies/jwt.strategy.ts |
Modify | Map assignedAreaIds từ JWT payload |
backend/src/auth/auth.module.ts |
Modify | Import Area và Pond entities |
Task 1: Migration — Tạo bảng area_users
Files:
Create:
backend/src/migrations/1745600000000-AddAreaUsers.tsStep 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.tsCreate:
backend/src/farm/dto/assign-area.dto.tsModify:
backend/src/farm/dto/index.tsStep 1: Thêm
assignedOwnersvà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.tsvà thêm mock user, mock userRepository, và 3 test suites
Tìm phần describe('AreasService', () => { và thêm:
- Khai báo
userRepositorymock cùng với các mocks hiện có:
let userRepository: jest.Mocked<Repository<User>>;
- Thêm import
Uservào đầu file:
import { User } from '@/auth/entities/user.entity';
- Thêm
mockUserconstant saumockArea:
const mockUser: Partial<User> = {
id: 'user-owner-1',
email: 'owner@test.com',
organizationId: 'org-id-1',
};
- Trong
beforeEach, thêmuserRepositoryvàoproviders:
{
provide: getRepositoryToken(User),
useValue: {
findOne: jest.fn(),
},
},
- Sau
areaRepository = module.get(...), thêm:
userRepository = module.get(getRepositoryToken(User));
Trong
AreasServiceproviders, thêmUservàoTypeOrmModule.forFeature— thực ra trong test ta mock trực tiếp nên chỉ cần thêm vàoprovidersarray.Thêm 3
describeblocks 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
userRepositoryvàoAreasServiceconstructor
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ướcprivate 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.tsStep 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.tsModify:
backend/src/auth/auth.module.tsModify:
backend/src/auth/auth.service.tsModify:
backend/src/auth/strategies/jwt.strategy.tsStep 1: Thêm
assignedAreaIdsvàoCurrentUserData
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
AreavàPondvàoAuthModule
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để mapassignedAreaIds
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-ownerDELETE /areas/{id}/assign-owner/{userId}GET /areas/{id}/ownersStep 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"