Add user and employee management enhancements with database schema updates

- Introduced new columns `status`, `created_by`, and `updated_by` in the `users` table to track user status and ownership.
- Updated the `employees` table to include a foreign key reference to the `users` table via `user_id`.
- Created migration script `0012_users_primary.sql` to apply these changes to the database schema.
- Enhanced the `EmployeesService` and `EmployeesRepository` to support user assignments and related data retrieval.
- Updated DTOs and service methods to reflect the new user and employee relationships.
- Added unit tests to validate the new functionality and ensure data integrity.
- Modified existing controllers to accommodate the new fields and relationships in user and employee management.
This commit is contained in:
shancheas
2026-08-26 15:29:18 +07:00
parent f635ebeda0
commit 8a61c94078
50 changed files with 2175 additions and 401 deletions
+4 -2
View File
@@ -17,8 +17,9 @@ describe('AuthController', () => {
beforeEach(async () => {
authService = {
register: jest.fn().mockResolvedValue({
accessToken: 'a',
refreshToken: 'b'.repeat(64),
id: 'user-1',
username: 'alice',
status: 'draft',
}),
login: jest.fn().mockResolvedValue({
accessToken: 'a',
@@ -105,6 +106,7 @@ describe('AuthController', () => {
id: 'priv-1',
name: 'Admin',
code: 'ADMIN',
status: 'active',
});
privilegesService.getPermissionsMap.mockResolvedValue({
PRIVILEGES: {
+3 -2
View File
@@ -24,6 +24,7 @@ import {
MeResponseDto,
RefreshTokenDto,
RegisterDto,
RegisterResponseDto,
TokenPairDto,
} from './dto/auth.dto';
@@ -40,11 +41,11 @@ export class AuthController {
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiCreatedResponse({ type: TokenPairDto })
@ApiCreatedResponse({ type: RegisterResponseDto })
@ApiBadRequestResponse({ description: 'Validation failed' })
@ApiConflictResponse({ description: 'Username already registered' })
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
register(@Body() dto: RegisterDto): Promise<TokenPairDto> {
register(@Body() dto: RegisterDto): Promise<RegisterResponseDto> {
return this.authService.register(dto.username, dto.password);
}
+4 -1
View File
@@ -31,7 +31,10 @@ import { JwtStrategy } from './strategies/jwt.strategy';
},
}),
}),
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
ThrottlerModule.forRoot({
skipIf: () => process.env.NODE_ENV === 'test',
throttlers: [{ ttl: 60_000, limit: 100 }],
}),
],
controllers: [AuthController],
providers: [
+25 -8
View File
@@ -5,6 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import * as bcrypt from 'bcrypt';
import { createHash } from 'node:crypto';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import { Status } from '../../common/value-objects/status/status';
import type { User } from '../users/user';
import { UsersService } from '../users/users.service';
import { AuthService } from './auth.service';
@@ -17,7 +18,10 @@ import { RevokedAccessTokensRepository } from './revoked-access-tokens.repositor
describe('AuthService', () => {
let service: AuthService;
let usersService: jest.Mocked<
Pick<UsersService, 'create' | 'findByUsername' | 'findById'>
Pick<
UsersService,
'create' | 'findByUsername' | 'findById' | 'assertCanAuthenticate'
>
>;
let jwtService: jest.Mocked<Pick<JwtService, 'signAsync'>>;
let config: { getOrThrow: jest.Mock };
@@ -46,14 +50,22 @@ describe('AuthService', () => {
passwordHash: await bcrypt.hash('password123', 4),
privilegeId: null,
isSuperadmin: false,
status: Status.create('active'),
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
privilege: null,
employee: null,
createdByUser: { id: 'user-1', username: 'alice' },
updatedByUser: { id: 'user-1', username: 'alice' },
};
usersService = {
create: jest.fn(),
findByUsername: jest.fn(),
findById: jest.fn(),
assertCanAuthenticate: jest.fn(),
};
jwtService = {
signAsync: jest.fn().mockResolvedValue('access.jwt.token'),
@@ -108,17 +120,22 @@ describe('AuthService', () => {
service = moduleRef.get(AuthService);
});
it('register creates user and returns token pair', async () => {
it('register creates a draft user and does not issue tokens', async () => {
usersService.findByUsername.mockResolvedValue(null);
usersService.create.mockResolvedValue(user);
usersService.create.mockResolvedValue({
...user,
status: Status.create('draft'),
});
const pair = await service.register('Alice', 'password123');
const result = await service.register('Alice', 'password123');
expect(usersService.create).toHaveBeenCalled();
expect(pair.accessToken).toBe('access.jwt.token');
expect(pair.refreshToken).toHaveLength(64);
expect(Object.keys(pair).sort()).toEqual(['accessToken', 'refreshToken']);
expect(refreshTokensRepository.create).toHaveBeenCalled();
expect(result).toEqual({
id: 'user-1',
username: 'alice',
status: 'draft',
});
expect(refreshTokensRepository.create).not.toHaveBeenCalled();
});
it('register throws ConflictException when username exists', async () => {
+11 -3
View File
@@ -33,7 +33,10 @@ export class AuthService {
private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository,
) {}
async register(username: string, password: string): Promise<TokenPair> {
async register(
username: string,
password: string,
): Promise<{ id: string; username: string; status: string }> {
const existing = await this.usersService.findByUsername(username);
if (existing) {
throw new ConflictException('Username already registered');
@@ -41,8 +44,11 @@ export class AuthService {
const saltRounds = this.config.getOrThrow<number>('BCRYPT_SALT_ROUNDS');
const passwordHash = await bcrypt.hash(password, saltRounds);
const user = await this.usersService.create(username, passwordHash);
const { tokens } = await this.issueTokenPair(user);
return tokens;
return {
id: user.id,
username: user.username,
status: user.status.value,
};
}
async login(username: string, password: string): Promise<TokenPair> {
@@ -52,6 +58,7 @@ export class AuthService {
if (!user || !match) {
throw new UnauthorizedException('Invalid credentials');
}
this.usersService.assertCanAuthenticate(user);
const { tokens } = await this.issueTokenPair(user);
return tokens;
}
@@ -78,6 +85,7 @@ export class AuthService {
if (!user) {
throw new UnauthorizedException('Invalid refresh token');
}
this.usersService.assertCanAuthenticate(user);
await this.denylistAccessJti(claimed.accessJti);
const issued = await this.issueTokenPair(user);
+11
View File
@@ -54,6 +54,17 @@ export class RefreshTokenDto {
refreshToken!: string;
}
export class RegisterResponseDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'alice' })
username!: string;
@ApiProperty({ example: 'draft' })
status!: string;
}
export class TokenPairDto implements TokenPair {
@ApiProperty({
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example',
@@ -2,6 +2,7 @@ import { UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import type { User } from '../../users/user';
import { UsersService } from '../../users/users.service';
import { RevokedAccessTokensRepository } from '../revoked-access-tokens.repository';
@@ -9,7 +10,9 @@ import { JwtStrategy } from './jwt.strategy';
describe('JwtStrategy', () => {
let strategy: JwtStrategy;
let usersService: jest.Mocked<Pick<UsersService, 'findById'>>;
let usersService: jest.Mocked<
Pick<UsersService, 'findById' | 'assertCanAuthenticate'>
>;
let revoked: jest.Mocked<Pick<RevokedAccessTokensRepository, 'exists'>>;
const now = DateTime.fromUnixMs(1_700_000_000_000);
@@ -19,12 +22,22 @@ describe('JwtStrategy', () => {
passwordHash: 'hash',
privilegeId: null,
isSuperadmin: false,
status: Status.create('active'),
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
privilege: null,
employee: null,
createdByUser: { id: 'user-1', username: 'alice' },
updatedByUser: { id: 'user-1', username: 'alice' },
};
beforeEach(async () => {
usersService = { findById: jest.fn() };
usersService = {
findById: jest.fn(),
assertCanAuthenticate: jest.fn(),
};
revoked = { exists: jest.fn() };
const moduleRef: TestingModule = await Test.createTestingModule({
@@ -40,6 +40,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
if (!user) {
throw new UnauthorizedException('User not found');
}
this.usersService.assertCanAuthenticate(user);
return {
id: user.id,
@@ -9,8 +9,12 @@ import {
IsUUID,
Matches,
MaxLength,
ValidateIf,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import {
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import {
EMPLOYEE_CODE_MAX_LENGTH,
@@ -55,6 +59,11 @@ export class CreateEmployeeDto {
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
userId?: string;
}
export class UpdateEmployeeDto {
@@ -88,6 +97,11 @@ export class UpdateEmployeeDto {
@IsOptional()
@IsIn([...EMPLOYEE_POSITIONS])
position?: string;
@ApiPropertyOptional({ format: 'uuid', nullable: true })
@ValidateIf((_, value) => value !== undefined)
@IsUUID('4')
userId?: string | null;
}
export class UpdateEmployeeStatusDto {
@@ -142,6 +156,11 @@ export class ListEmployeesQueryDto extends PaginationQueryDto {
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
userId?: string;
@ApiPropertyOptional({
description: 'Case-insensitive match on code or name',
})
@@ -180,4 +199,7 @@ export class EmployeeDto {
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
@ApiPropertyOptional({ type: UserRelationDto, nullable: true })
user!: UserRelationDto | null;
}
@@ -14,6 +14,8 @@ export type Employee = {
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly userId: string | null;
readonly user: { readonly id: string; readonly username: string } | null;
};
export type CreateEmployeeInput = {
@@ -23,6 +25,7 @@ export type CreateEmployeeInput = {
readonly position: EmployeePosition;
readonly status?: Status;
readonly userId: string;
readonly assignedUserId?: string | null;
};
export type UpdateEmployeeInput = {
@@ -31,6 +34,7 @@ export type UpdateEmployeeInput = {
readonly phone?: PhoneNumber;
readonly position?: EmployeePosition;
readonly userId: string;
readonly assignedUserId?: string | null;
};
export type ListEmployeesFilters = {
@@ -39,6 +43,7 @@ export type ListEmployeesFilters = {
readonly phone?: string;
readonly position?: string;
readonly status?: string;
readonly userId?: string;
readonly search?: string;
readonly limit: number;
readonly offset: number;
@@ -37,6 +37,7 @@ describe('EmployeesWriteController', () => {
...createDto,
status: undefined,
userId: 'user-1',
assignedUserId: undefined,
});
});
@@ -130,6 +130,7 @@ export class EmployeesWriteController {
position: dto.position,
status: dto.status,
userId,
assignedUserId: dto.userId,
});
}
@@ -166,6 +167,7 @@ export class EmployeesWriteController {
phone: dto.phone,
position: dto.position,
userId,
assignedUserId: dto.userId,
});
}
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { UsersModule } from '../../users/users.module';
import { EmployeesReadController } from './employees-read.controller';
import { EmployeesWriteController } from './employees-write.controller';
import { EmployeesRepository } from './employees.repository';
import { EmployeesService } from './employees.service';
@Module({
imports: [UsersModule],
controllers: [EmployeesReadController, EmployeesWriteController],
providers: [EmployeesRepository, EmployeesService],
exports: [EmployeesService],
@@ -42,8 +42,11 @@ describe('EmployeesRepository', () => {
updatedAt: 1_700_000_000_000,
createdBy: 'user-1',
updatedBy: 'user-1',
userId: null,
};
const joinedRow = { employee: row, user: null };
const createInput = {
code: 'EMP_01',
name: 'Ada Lovelace',
@@ -114,14 +117,14 @@ describe('EmployeesRepository', () => {
.mockImplementationOnce(() => ({
from: () => ({
$dynamic: () => ({
where: () => ({
orderBy: () => ({
limit: () => ({
offset: () => Promise.resolve([row]),
where: () => ({
orderBy: () => ({
limit: () => ({
offset: () => Promise.resolve([row]),
}),
}),
}),
}),
}),
}),
}));
@@ -10,6 +10,7 @@ import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-nu
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import { employees, type EmployeeRow } from '../../../database/employees-table';
import { users } from '../../../database/schema';
import type { EmployeePosition } from './employee-fields';
import type {
CreateEmployeeInput,
@@ -41,7 +42,11 @@ export class EmployeesRepository {
.offset(filters.offset);
return {
data: rows.map((row) => this.toDomain(row)),
data: await Promise.all(
rows.map(async (row) =>
this.toDomain(row, await this.loadAssignedUser(row.userId)),
),
),
total: Number(totalRow?.total ?? 0),
};
}
@@ -55,13 +60,13 @@ export class EmployeesRepository {
}
async findById(id: string): Promise<Employee | null> {
const rows: EmployeeRow[] = await this.db
const rows = await this.db
.select()
.from(employees)
.where(eq(employees.id, id))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
return row ? this.toDomain(row, await this.loadAssignedUser(row.userId)) : null;
}
async findByCode(code: string): Promise<Employee | null> {
@@ -71,7 +76,7 @@ export class EmployeesRepository {
.where(eq(employees.code, code))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
return row ? this.toDomain(row, await this.loadAssignedUser(row.userId)) : null;
}
async create(input: CreateEmployeeInput): Promise<Employee> {
@@ -83,7 +88,7 @@ export class EmployeesRepository {
.values(this.toInsertValues(input, status, now, input.userId))
.returning();
const row = inserted[0];
return this.toDomain(row);
return this.toDomain(row, await this.loadAssignedUser(row.userId));
} catch (error) {
this.rethrowUniqueViolation(error);
}
@@ -125,11 +130,14 @@ export class EmployeesRepository {
position: input.position ?? existing.position,
updatedAt: now.value,
updatedBy: input.userId,
...(input.assignedUserId !== undefined
? { userId: input.assignedUserId }
: {}),
})
.where(eq(employees.id, id))
.returning();
const row = updated[0];
return this.toDomain(row);
return this.toDomain(row, await this.loadAssignedUser(row.userId));
} catch (error) {
this.rethrowUniqueViolation(error);
}
@@ -154,7 +162,7 @@ export class EmployeesRepository {
if (!row) {
throw new NotFoundException('Employee not found');
}
return this.toDomain(row);
return this.toDomain(row, await this.loadAssignedUser(row.userId));
}
async bulkUpdateStatus(
@@ -216,6 +224,9 @@ export class EmployeesRepository {
if (filters.status) {
parts.push(eq(employees.status, filters.status));
}
if (filters.userId) {
parts.push(eq(employees.userId, filters.userId));
}
if (filters.search) {
const search = or(
ilike(employees.code, `%${filters.search}%`),
@@ -247,10 +258,41 @@ export class EmployeesRepository {
updatedAt: now.value,
createdBy: userId,
updatedBy: userId,
userId: input.assignedUserId ?? null,
};
}
private toDomain(row: EmployeeRow): Employee {
private selectWithUser() {
return this.db
.select({
employee: employees,
user: {
id: users.id,
username: users.username,
},
})
.from(employees)
.leftJoin(users, eq(employees.userId, users.id));
}
private async loadAssignedUser(
userId: string | null,
): Promise<{ id: string; username: string } | null> {
if (!userId) {
return null;
}
const rows = await this.db
.select({ id: users.id, username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return rows[0] ?? null;
}
private toDomain(
row: EmployeeRow,
user: { id: string; username: string } | null,
): Employee {
return {
id: row.id,
code: row.code,
@@ -262,29 +304,39 @@ export class EmployeesRepository {
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
userId: row.userId ?? null,
user: user?.id ? { id: user.id, username: user.username } : null,
};
}
private rethrowUniqueViolation(error: unknown): never {
const err = this.unwrapDbError(error);
if (err.code === '23505') {
const constraint = err.constraint ?? '';
if (constraint.includes('user_id')) {
throw new ConflictException('User is already assigned to an employee');
}
throw new ConflictException('Employee code already exists');
}
throw error;
}
private unwrapDbError(error: unknown): { code?: string } {
private unwrapDbError(error: unknown): { code?: string; constraint?: string } {
let current: unknown = error;
for (let i = 0; i < 5; i++) {
if (!current || typeof current !== 'object') {
break;
}
const obj = current as { code?: string; cause?: unknown };
const obj = current as {
code?: string;
constraint?: string;
cause?: unknown;
};
if (obj.code === '23505' || obj.code === '23503') {
return { code: obj.code };
return { code: obj.code, constraint: obj.constraint };
}
current = obj.cause;
}
return error as { code?: string };
return error as { code?: string; constraint?: string };
}
}
@@ -4,6 +4,7 @@ import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
import type { Employee } from './employee';
import { UsersService } from '../../users/users.service';
import { EmployeesRepository } from './employees.repository';
import { EmployeesService } from './employees.service';
@@ -36,6 +37,8 @@ describe('EmployeesService', () => {
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
userId: null,
user: null,
};
const createInput = {
@@ -63,6 +66,10 @@ describe('EmployeesService', () => {
providers: [
EmployeesService,
{ provide: EmployeesRepository, useValue: repository },
{
provide: UsersService,
useValue: { findById: jest.fn().mockResolvedValue({ id: 'user-2' }) },
},
],
}).compile();
@@ -20,6 +20,7 @@ import {
parseCsvRecord,
type EmployeePosition,
} from './employee-fields';
import { UsersService } from '../../users/users.service';
import { EmployeesRepository } from './employees.repository';
export type ListEmployeesQuery = {
@@ -28,6 +29,7 @@ export type ListEmployeesQuery = {
readonly phone?: string;
readonly position?: string;
readonly status?: string;
readonly userId?: string;
readonly search?: string;
readonly page?: number;
readonly limit?: number;
@@ -45,13 +47,17 @@ const VISIBLE_FIELDS = [
'updatedAt',
'createdBy',
'updatedBy',
'user',
] as const;
const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'position'] as const;
@Injectable()
export class EmployeesService {
constructor(private readonly employeesRepository: EmployeesRepository) {}
constructor(
private readonly employeesRepository: EmployeesRepository,
private readonly usersService: UsersService,
) {}
async list(
query: ListEmployeesQuery,
@@ -63,6 +69,7 @@ export class EmployeesService {
phone: query.phone,
position: query.position,
status: query.status,
userId: query.userId,
search: query.search,
limit: page.limit,
offset: page.offset,
@@ -100,9 +107,10 @@ export class EmployeesService {
position: string;
status?: string;
userId: string;
assignedUserId?: string | null;
}): Promise<ReturnType<EmployeesService['toListItem']>> {
const created = await this.employeesRepository.create(
this.toCreateInput(input),
await this.toCreateInput(input),
);
return this.toListItem(created);
}
@@ -116,6 +124,7 @@ export class EmployeesService {
position?: string;
status?: unknown;
userId: string;
assignedUserId?: string | null;
},
): Promise<ReturnType<EmployeesService['toListItem']>> {
if (input.status !== undefined) {
@@ -131,6 +140,7 @@ export class EmployeesService {
? this.assertPosition(input.position)
: undefined,
userId: input.userId,
assignedUserId: await this.assertAssignedUserId(input.assignedUserId),
};
const updated = await this.employeesRepository.update(id, payload);
return this.toListItem(updated);
@@ -201,14 +211,16 @@ export class EmployeesService {
const rowNum = filled[i].lineNo;
try {
const statusRaw = idx('status') >= 0 ? cols[idx('status')] : '';
const assignedRaw = idx('userid') >= 0 ? cols[idx('userid')] : '';
rows.push(
this.toCreateInput({
await this.toCreateInput({
code: cols[idx('code')] ?? '',
name: cols[idx('name')] ?? '',
phone: cols[idx('phone')] ?? '',
position: cols[idx('position')] ?? '',
status: statusRaw || undefined,
userId,
assignedUserId: assignedRaw || undefined,
}),
);
} catch (error) {
@@ -241,6 +253,7 @@ export class EmployeesService {
updatedAt: employee.updatedAt.value,
createdBy: employee.createdBy,
updatedBy: employee.updatedBy,
user: employee.user,
};
}
@@ -248,14 +261,15 @@ export class EmployeesService {
return VISIBLE_FIELDS;
}
private toCreateInput(input: {
private async toCreateInput(input: {
code: string;
name: string;
phone: string;
position: string;
status?: string;
userId: string;
}): CreateEmployeeInput {
assignedUserId?: string | null;
}): Promise<CreateEmployeeInput> {
return {
code: this.assertCode(input.code),
name: this.assertName(input.name),
@@ -265,9 +279,26 @@ export class EmployeesService {
? Status.create(input.status)
: Status.create(Status.DEFAULT),
userId: input.userId,
assignedUserId: await this.assertAssignedUserId(input.assignedUserId),
};
}
private async assertAssignedUserId(
userId?: string | null,
): Promise<string | null | undefined> {
if (userId === undefined) {
return undefined;
}
if (userId === null || userId === '') {
return null;
}
const user = await this.usersService.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
return userId;
}
private assertName(raw: string): string {
const name = raw.trim();
if (!isValidEmployeeName(name)) {
+182
View File
@@ -0,0 +1,182 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayNotEmpty,
IsArray,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
ValidateIf,
} from 'class-validator';
import {
DefaultRelationDto,
PaginationQueryDto,
UserRelationDto,
} from '../../../common/http/response';
import { CORE_STATUSES } from '../../../common/value-objects/status/status';
import {
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
USERNAME_MAX_LENGTH,
USERNAME_MIN_LENGTH,
USERNAME_PATTERN,
} from '../user-fields';
export class CreateUserDto {
@ApiProperty({ example: 'alice', minLength: USERNAME_MIN_LENGTH })
@IsString()
@IsNotEmpty()
@MinLength(USERNAME_MIN_LENGTH)
@MaxLength(USERNAME_MAX_LENGTH)
@Matches(USERNAME_PATTERN, {
message: 'username must contain only letters, numbers, and underscores',
})
username!: string;
@ApiProperty({
example: 'password123',
format: 'password',
writeOnly: true,
minLength: PASSWORD_MIN_LENGTH,
})
@IsString()
@MinLength(PASSWORD_MIN_LENGTH)
@MaxLength(PASSWORD_MAX_LENGTH)
password!: string;
@ApiPropertyOptional({ format: 'uuid', nullable: true })
@IsOptional()
@IsUUID('4')
privilegeId?: string;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
}
export class UpdateUserDto {
@ApiPropertyOptional({ example: 'alice' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MinLength(USERNAME_MIN_LENGTH)
@MaxLength(USERNAME_MAX_LENGTH)
@Matches(USERNAME_PATTERN, {
message: 'username must contain only letters, numbers, and underscores',
})
username?: string;
@ApiPropertyOptional({
example: 'password123',
format: 'password',
writeOnly: true,
})
@IsOptional()
@IsString()
@MinLength(PASSWORD_MIN_LENGTH)
@MaxLength(PASSWORD_MAX_LENGTH)
password?: string;
@ApiPropertyOptional({ format: 'uuid', nullable: true })
@ValidateIf((_, value) => value !== undefined)
@IsUUID('4')
privilegeId?: string | null;
}
export class UpdateUserStatusDto {
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class BulkIdsDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
}
export class BulkStatusDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class ListUsersQueryDto extends PaginationQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
privilegeId?: string;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional({
description: 'Case-insensitive match on username',
})
@IsOptional()
@IsString()
search?: string;
}
export class UserDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
username!: string;
@ApiProperty()
isSuperadmin!: boolean;
@ApiPropertyOptional({ type: DefaultRelationDto, nullable: true })
privilege!: DefaultRelationDto | null;
@ApiPropertyOptional({ type: DefaultRelationDto, nullable: true })
employee!: DefaultRelationDto | null;
@ApiProperty({ enum: CORE_STATUSES })
status!: string;
@ApiProperty({ description: 'Unix ms' })
createdAt!: number;
@ApiProperty({ description: 'Unix ms' })
updatedAt!: number;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
export class RegisterUserResponseDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
username!: string;
@ApiProperty({ enum: CORE_STATUSES })
status!: string;
}
+39
View File
@@ -0,0 +1,39 @@
import {
isAllowedCsvUpload,
isValidPassword,
isValidUsername,
parseCsvRecord,
} from './user-fields';
describe('user-fields', () => {
it('accepts valid usernames and passwords', () => {
expect(isValidUsername('alice')).toBe(true);
expect(isValidUsername('a_1')).toBe(true);
expect(isValidPassword('password123')).toBe(true);
});
it('rejects invalid usernames and passwords', () => {
expect(isValidUsername('ab')).toBe(false);
expect(isValidUsername('alice bob')).toBe(false);
expect(isValidPassword('short')).toBe(false);
});
it('parses CSV records with quoted commas', () => {
expect(parseCsvRecord('a,"b,c",d')).toEqual(['a', 'b,c', 'd']);
});
it('allows csv uploads by mimetype or extension', () => {
expect(
isAllowedCsvUpload({ mimetype: 'text/csv', originalname: 'users.csv' }),
).toBe(true);
expect(
isAllowedCsvUpload({
mimetype: 'application/octet-stream',
originalname: 'users.CSV',
}),
).toBe(true);
expect(
isAllowedCsvUpload({ mimetype: 'text/plain', originalname: 'users.txt' }),
).toBe(false);
});
});
+64
View File
@@ -0,0 +1,64 @@
export const USERNAME_MIN_LENGTH = 3;
export const USERNAME_MAX_LENGTH = 32;
export const USERNAME_PATTERN = /^[a-zA-Z0-9_]+$/;
export const PASSWORD_MIN_LENGTH = 8;
export const PASSWORD_MAX_LENGTH = 72;
export function isValidUsername(raw: string): boolean {
return (
typeof raw === 'string' &&
raw.length >= USERNAME_MIN_LENGTH &&
raw.length <= USERNAME_MAX_LENGTH &&
USERNAME_PATTERN.test(raw)
);
}
export function isValidPassword(raw: string): boolean {
return (
typeof raw === 'string' &&
raw.length >= PASSWORD_MIN_LENGTH &&
raw.length <= PASSWORD_MAX_LENGTH
);
}
/** RFC 4180-style record split that preserves commas inside quotes. */
export function parseCsvRecord(line: string): string[] {
const cells: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (line[i + 1] === '"') {
current += '"';
i += 1;
} else {
inQuotes = false;
}
} else {
current += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
cells.push(current.trim());
current = '';
} else {
current += ch;
}
}
cells.push(current.trim());
return cells;
}
export function isAllowedCsvUpload(file: {
mimetype: string;
originalname: string;
}): boolean {
return (
file.mimetype.includes('csv') ||
file.originalname.toLowerCase().endsWith('.csv')
);
}
+42
View File
@@ -1,4 +1,20 @@
import { DateTime } from '../../common/value-objects/date-time/date-time';
import { Status } from '../../common/value-objects/status/status';
export type UserRelationRef = {
readonly id: string;
readonly username: string;
};
export type CatalogRelationRef = {
readonly id: string;
readonly code: string;
readonly name: string;
};
export type EmployeeRelationRef = CatalogRelationRef & {
readonly status: Status;
};
export type User = {
readonly id: string;
@@ -6,11 +22,37 @@ export type User = {
readonly passwordHash: string;
readonly privilegeId: string | null;
readonly isSuperadmin: boolean;
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly privilege: CatalogRelationRef | null;
readonly employee: EmployeeRelationRef | null;
readonly createdByUser: UserRelationRef;
readonly updatedByUser: UserRelationRef;
};
export type CreateUserInput = {
readonly username: string;
readonly passwordHash: string;
readonly privilegeId?: string | null;
readonly status?: Status;
readonly actorUserId?: string;
};
export type UpdateUserInput = {
readonly username?: string;
readonly passwordHash?: string;
readonly privilegeId?: string | null;
readonly actorUserId: string;
};
export type ListUsersFilters = {
readonly username?: string;
readonly privilegeId?: string;
readonly status?: string;
readonly search?: string;
readonly limit: number;
readonly offset: number;
};
@@ -0,0 +1,36 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersReadController } from './users-read.controller';
import { UsersService } from './users.service';
describe('UsersReadController', () => {
let controller: UsersReadController;
const service = {
list: jest.fn(),
getById: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [UsersReadController],
providers: [{ provide: UsersService, useValue: service }],
}).compile();
controller = moduleRef.get(UsersReadController);
});
it('list delegates to the service', async () => {
service.list.mockResolvedValue({ data: [], total: 0 });
await expect(controller.list({ page: 1 })).resolves.toEqual({
data: [],
total: 0,
});
expect(service.list).toHaveBeenCalledWith({ page: 1 });
});
it('findOne delegates to the service', async () => {
service.getById.mockResolvedValue({ id: 'user-1' });
await expect(controller.findOne('user-1')).resolves.toEqual({
id: 'user-1',
});
});
});
@@ -0,0 +1,64 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { RequirePrivilege } from '../../common/decorators/require-privilege.decorator';
import {
Pagination,
type PaginationResponse,
PaginationMetaDto,
} from '../../common/http/response';
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
import { ListUsersQueryDto, UserDto } from './dto/user.dto';
import { UsersService } from './users.service';
export const USERS_PRIVILEGE_KEY = 'USERS';
@ApiTags('users')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('users')
export class UsersReadController {
constructor(private readonly usersService: UsersService) {}
@Get()
@Pagination()
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'List users' })
@ApiOkResponse({
schema: {
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/UserDto' },
},
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
},
},
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
list(
@Query() query: ListUsersQueryDto,
): Promise<PaginationResponse<UserDto>> {
return this.usersService.list(query);
}
@Get(':id')
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get user detail' })
@ApiOkResponse({ type: UserDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<UserDto> {
return this.usersService.getById(id);
}
}
void PaginationMetaDto;
@@ -0,0 +1,94 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersWriteController } from './users-write.controller';
import { UsersService } from './users.service';
const createDto = {
username: 'alice',
password: 'password123',
};
describe('UsersWriteController', () => {
let controller: UsersWriteController;
const service = {
createManaged: jest.fn(),
update: jest.fn(),
updateStatus: jest.fn(),
delete: jest.fn(),
bulkDelete: jest.fn(),
bulkUpdateStatus: jest.fn(),
importCsv: jest.fn(),
assignPrivilege: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [UsersWriteController],
providers: [{ provide: UsersService, useValue: service }],
}).compile();
controller = moduleRef.get(UsersWriteController);
});
it('create passes dto fields and actor id', async () => {
service.createManaged.mockResolvedValue({ id: 'user-2' });
await controller.create(createDto, 'user-1');
expect(service.createManaged).toHaveBeenCalledWith({
username: 'alice',
password: 'password123',
privilegeId: undefined,
status: undefined,
actorUserId: 'user-1',
});
});
it('update, updateStatus, and delete delegate', async () => {
service.update.mockResolvedValue({ id: 'user-2' });
service.updateStatus.mockResolvedValue({ id: 'user-2' });
service.delete.mockResolvedValue(undefined);
await controller.update('user-2', { username: 'alice' }, 'user-1');
await controller.updateStatus('user-2', { status: 'active' }, 'user-1');
await controller.delete('user-2');
expect(service.updateStatus).toHaveBeenCalledWith(
'user-2',
'active',
'user-1',
);
expect(service.delete).toHaveBeenCalledWith('user-2');
});
it('assignPrivilege delegates with actor id', async () => {
service.assignPrivilege.mockResolvedValue({ id: 'user-2' });
await controller.assignPrivilege(
'user-2',
{ privilegeId: 'priv-1' },
'user-1',
);
expect(service.assignPrivilege).toHaveBeenCalledWith(
'user-2',
'priv-1',
'user-1',
);
});
it('bulk and import delegate', async () => {
service.bulkDelete.mockResolvedValue({ deleted: 1 });
service.bulkUpdateStatus.mockResolvedValue({ updated: 1 });
service.importCsv.mockResolvedValue({ imported: 1 });
await controller.bulkDelete({ ids: ['user-2'] });
await controller.bulkStatus(
{ ids: ['user-2'], status: 'archived' },
'user-1',
);
await controller.importCsv(
{ buffer: Buffer.from('username,password\nalice,password123') },
'user-1',
);
expect(service.importCsv).toHaveBeenCalled();
});
it('importCsv uses empty string when file is missing', async () => {
service.importCsv.mockResolvedValue({ imported: 0 });
await controller.importCsv(undefined, 'user-1');
expect(service.importCsv).toHaveBeenCalledWith('', 'user-1');
});
});
+198
View File
@@ -0,0 +1,198 @@
import {
BadRequestException,
Body,
Controller,
Delete,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
import { AssignPrivilegeDto } from './dto/assign-privilege.dto';
import {
BulkIdsDto,
BulkStatusDto,
CreateUserDto,
UpdateUserDto,
UpdateUserStatusDto,
UserDto,
} from './dto/user.dto';
import { isAllowedCsvUpload } from './user-fields';
import { USERS_PRIVILEGE_KEY } from './users-read.controller';
import { UsersService } from './users.service';
@ApiTags('users')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('users')
export class UsersWriteController {
constructor(private readonly usersService: UsersService) {}
@Post('import')
@HttpCode(200)
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'import')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 1_048_576 },
fileFilter: (_req, file, cb) => {
if (!isAllowedCsvUpload(file)) {
cb(new BadRequestException('Only CSV files are allowed'), false);
return;
}
cb(null, true);
},
}),
)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
},
required: ['file'],
},
})
@ApiOperation({ summary: 'Import users from CSV' })
@ApiOkResponse({
schema: { properties: { imported: { type: 'number' } } },
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
importCsv(
@UploadedFile() file: { buffer?: Buffer } | undefined,
@CurrentUser('id') userId: string,
): Promise<{ imported: number }> {
const csv = file?.buffer?.toString('utf8') ?? '';
return this.usersService.importCsv(csv, userId);
}
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'delete')
@ApiOperation({ summary: 'Bulk delete users' })
@ApiOkResponse({
schema: { properties: { deleted: { type: 'number' } } },
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
return this.usersService.bulkDelete(dto.ids);
}
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Bulk update user status' })
@ApiOkResponse({
schema: { properties: { updated: { type: 'number' } } },
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
bulkStatus(
@Body() dto: BulkStatusDto,
@CurrentUser('id') userId: string,
): Promise<{ updated: number }> {
return this.usersService.bulkUpdateStatus(dto.ids, dto.status, userId);
}
@Post()
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'create')
@ApiOperation({ summary: 'Create user' })
@ApiCreatedResponse({ type: UserDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
create(
@Body() dto: CreateUserDto,
@CurrentUser('id') userId: string,
): Promise<UserDto> {
return this.usersService.createManaged({
username: dto.username,
password: dto.password,
privilegeId: dto.privilegeId,
status: dto.status,
actorUserId: userId,
});
}
@Patch(':id/status')
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Update user status' })
@ApiOkResponse({ type: UserDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserStatusDto,
@CurrentUser('id') userId: string,
): Promise<UserDto> {
return this.usersService.updateStatus(id, dto.status, userId);
}
@Patch(':id/privilege')
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Assign or clear a user privilege' })
@ApiOkResponse({ type: UserDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
assignPrivilege(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignPrivilegeDto,
@CurrentUser('id') userId: string,
): Promise<UserDto> {
return this.usersService.assignPrivilege(id, dto.privilegeId, userId);
}
@Patch(':id')
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Update user (not status)' })
@ApiOkResponse({ type: UserDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserDto,
@CurrentUser('id') userId: string,
): Promise<UserDto> {
return this.usersService.update(id, {
username: dto.username,
password: dto.password,
privilegeId: dto.privilegeId,
actorUserId: userId,
});
}
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'delete')
@ApiOperation({ summary: 'Delete user' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
await this.usersService.delete(id);
}
}
-43
View File
@@ -1,43 +0,0 @@
import { Body, Controller, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { RequirePrivilege } from '../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
import {
AssignPrivilegeDto,
UserPrivilegeResponseDto,
} from './dto/assign-privilege.dto';
import { UsersService } from './users.service';
@ApiTags('users')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Patch(':id/privilege')
@RequirePrivilege('USERS', 'update')
@ApiOperation({ summary: 'Assign or clear a user privilege' })
@ApiOkResponse({ type: UserPrivilegeResponseDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
async assignPrivilege(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignPrivilegeDto,
): Promise<UserPrivilegeResponseDto> {
const user = await this.usersService.assignPrivilege(id, dto.privilegeId);
return {
id: user.id,
username: user.username,
privilegeId: user.privilegeId,
};
}
}
+3 -2
View File
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { PrivilegesModule } from '../privileges/privileges.module';
import { UsersController } from './users.controller';
import { UsersReadController } from './users-read.controller';
import { UsersWriteController } from './users-write.controller';
import { UsersRepository } from './users.repository';
import { UsersService } from './users.service';
@Module({
imports: [PrivilegesModule],
controllers: [UsersController],
controllers: [UsersReadController, UsersWriteController],
providers: [UsersRepository, UsersService],
exports: [UsersService],
})
+96 -78
View File
@@ -1,107 +1,125 @@
import { ConflictException } from '@nestjs/common';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { DRIZZLE } from '../../database/database.module';
import { UsersRepository } from './users.repository';
describe('UsersRepository', () => {
let repository: UsersRepository;
const limit = jest.fn();
const where = jest.fn(() => ({ limit }));
const from = jest.fn(() => ({ where }));
const select = jest.fn(() => ({ from }));
const returning = jest.fn();
const values = jest.fn(() => ({ returning }));
const insert = jest.fn(() => ({ values }));
const db = { select, insert };
const limit = jest.fn();
const orderBy = jest.fn();
const offset = jest.fn();
const where = jest.fn();
const from = jest.fn();
const select = jest.fn();
const returning = jest.fn();
const values = jest.fn();
const insert = jest.fn();
const set = jest.fn();
const update = jest.fn();
const del = jest.fn();
const $dynamic = jest.fn();
const db = {
select,
insert,
update,
delete: del,
};
const userRow = {
id: 'user-1',
username: 'alice',
passwordHash: 'hash',
privilegeId: null,
isSuperadmin: false,
status: 'draft',
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
createdBy: 'user-1',
updatedBy: 'user-1',
};
const joinedRow = {
user: userRow,
privilege: { id: null, code: null, name: null },
employee: { id: null, code: null, name: null, status: null },
createdByUser: { id: 'user-1', username: 'alice' },
updatedByUser: { id: 'user-1', username: 'alice' },
};
const joinChain = () => {
const chain: {
leftJoin: jest.Mock;
where: typeof where;
$dynamic: typeof $dynamic;
} = {
leftJoin: jest.fn(),
where,
$dynamic,
};
chain.leftJoin.mockReturnValue(chain);
return chain;
};
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy, returning }));
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({
offset,
then: (
resolve: (value: (typeof joinedRow)[]) => unknown,
reject?: (reason: unknown) => unknown,
) => Promise.resolve([joinedRow]).then(resolve, reject),
}));
offset.mockResolvedValue([joinedRow]);
from.mockImplementation(() => joinChain());
$dynamic.mockReturnValue({ where });
select.mockImplementation(() => ({ from }));
values.mockReturnValue({ returning });
insert.mockReturnValue({ values });
set.mockReturnValue({ where });
update.mockReturnValue({ set });
del.mockReturnValue({ where });
returning.mockResolvedValue([{ id: 'user-1' }]);
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [UsersRepository, { provide: DRIZZLE, useValue: db }],
}).compile();
repository = moduleRef.get(UsersRepository);
});
it('findById maps a row to domain User', async () => {
limit.mockResolvedValue([
{
id: 'user-1',
username: 'alice',
passwordHash: 'hash',
privilegeId: null,
isSuperadmin: false,
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
},
]);
const user = await repository.findById('user-1');
expect(user).toMatchObject({
id: 'user-1',
username: 'alice',
privilegeId: null,
isSuperadmin: false,
});
expect(user?.createdAt.value).toBe(1_700_000_000_000);
});
it('findById returns null when missing', async () => {
limit.mockResolvedValue([]);
from.mockImplementationOnce(() => ({
where: () => ({
limit: () => Promise.resolve([]),
}),
}));
await expect(repository.findById('missing')).resolves.toBeNull();
});
it('create inserts lowercase username', async () => {
returning.mockResolvedValue([
{
id: 'user-1',
username: 'alice',
passwordHash: 'hash',
privilegeId: null,
isSuperadmin: false,
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
},
]);
const user = await repository.create({
username: 'Alice',
passwordHash: 'hash',
});
expect(user.username).toBe('alice');
expect(user.privilegeId).toBeNull();
it('create inserts lowercase username and maps unique violations', async () => {
returning.mockRejectedValueOnce({ code: '23505' });
await expect(
repository.create({ username: 'Alice', passwordHash: 'hash' }),
).rejects.toBeInstanceOf(ConflictException);
expect(values).toHaveBeenCalledWith(
expect.objectContaining({ username: 'alice', passwordHash: 'hash' }),
);
});
it('findByUsername maps a row', async () => {
limit.mockResolvedValue([
{
id: 'user-1',
username: 'alice',
passwordHash: 'hash',
privilegeId: null,
isSuperadmin: false,
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
},
]);
const user = await repository.findByUsername('Alice');
expect(user?.username).toBe('alice');
it('delete maps foreign-key violations to ConflictException', async () => {
returning.mockRejectedValueOnce({ code: '23503' });
await expect(repository.delete('user-1')).rejects.toBeInstanceOf(
ConflictException,
);
});
it('create maps unique violations to ConflictException', async () => {
returning.mockRejectedValue({ code: '23505' });
await expect(
repository.create({ username: 'alice', passwordHash: 'hash' }),
).rejects.toBeInstanceOf(ConflictException);
});
it('create rethrows unknown errors', async () => {
returning.mockRejectedValue(new Error('db down'));
await expect(
repository.create({ username: 'alice', passwordHash: 'hash' }),
).rejects.toThrow('db down');
it('delete throws when missing', async () => {
returning.mockResolvedValueOnce([]);
await expect(repository.delete('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
+353 -23
View File
@@ -4,84 +4,414 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { users, type UserRow } from '../../database/schema';
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { randomUUID } from 'node:crypto';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import { Status } from '../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
import type { CreateUserInput, User } from './user';
import {
employees,
privileges,
users,
type UserRow,
} from '../../database/schema';
import type {
CreateUserInput,
ListUsersFilters,
UpdateUserInput,
User,
} from './user';
const createdByUsers = alias(users, 'created_by_users');
const updatedByUsers = alias(users, 'updated_by_users');
type UserJoinedRow = {
user: UserRow;
privilege: { id: string; code: string; name: string } | null;
employee: {
id: string;
code: string;
name: string;
status: string;
} | null;
createdByUser: { id: string; username: string } | null;
updatedByUser: { id: string; username: string } | null;
};
@Injectable()
export class UsersRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async list(
filters: ListUsersFilters,
): Promise<{ data: User[]; total: number }> {
const where = this.buildListWhere(filters);
const totalRows = await this.db
.select({ total: count() })
.from(users)
.where(where);
const totalRow = totalRows[0];
let qb = this.selectWithRelations().$dynamic();
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(users.username))
.limit(filters.limit)
.offset(filters.offset);
return {
data: rows.map((row) => this.toDomain(row)),
total: Number(totalRow?.total ?? 0),
};
}
extendListQuery<T>(qb: T, filters: ListUsersFilters): T {
void filters;
return qb;
}
async findById(id: string): Promise<User | null> {
const [row] = await this.db
const rows = await this.db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
return row ? this.toDomain(row) : null;
const row = rows[0];
return row ? this.hydrate(row) : null;
}
async findByUsername(username: string): Promise<User | null> {
const normalized = username.toLowerCase();
const [row] = await this.db
const rows = await this.db
.select()
.from(users)
.where(eq(users.username, normalized))
.limit(1);
return row ? this.toDomain(row) : null;
const row = rows[0];
return row ? this.hydrate(row) : null;
}
async create(input: CreateUserInput): Promise<User> {
const now = DateTime.fromUnixMs(Date.now());
const id = randomUUID();
const actorId = input.actorUserId ?? id;
const status = input.status ?? Status.create(Status.DEFAULT);
try {
const [row] = await this.db
const inserted = await this.db
.insert(users)
.values({
id,
username: input.username.toLowerCase(),
passwordHash: input.passwordHash,
privilegeId: input.privilegeId ?? null,
status: status.value,
createdAt: now.value,
updatedAt: now.value,
createdBy: actorId,
updatedBy: actorId,
})
.returning();
return this.toDomain(row);
const row = inserted[0];
return this.toDomain({
user: row,
privilege: null,
employee: null,
createdByUser: { id: actorId, username: input.username.toLowerCase() },
updatedByUser: { id: actorId, username: input.username.toLowerCase() },
});
} catch (error) {
if ((error as { code?: string }).code === '23505') {
throw new ConflictException('Username already registered');
this.rethrowConstraintViolation(error);
}
}
async update(id: string, input: UpdateUserInput): Promise<User> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('User not found');
}
const now = DateTime.fromUnixMs(Date.now());
try {
const updated = await this.db
.update(users)
.set({
username: input.username ?? existing.username,
passwordHash: input.passwordHash ?? existing.passwordHash,
privilegeId:
input.privilegeId !== undefined
? input.privilegeId
: existing.privilegeId,
updatedAt: now.value,
updatedBy: input.actorUserId,
})
.where(eq(users.id, id))
.returning({ id: users.id });
const row = updated[0];
if (!row) {
throw new NotFoundException('User not found');
}
throw error;
return this.requireById(row.id);
} catch (error) {
this.rethrowConstraintViolation(error);
}
}
async updatePrivilegeId(
userId: string,
privilegeId: string | null,
actorUserId?: string,
): Promise<User> {
const now = DateTime.fromUnixMs(Date.now());
const [row] = await this.db
const updated = await this.db
.update(users)
.set({
privilegeId,
updatedAt: now.value,
...(actorUserId ? { updatedBy: actorUserId } : {}),
})
.where(eq(users.id, userId))
.returning();
.returning({ id: users.id });
const row = updated[0];
if (!row) {
throw new NotFoundException('User not found');
}
return this.toDomain(row);
return this.requireById(row.id);
}
private toDomain(row: UserRow): User {
async updateStatus(
id: string,
status: Status,
actorUserId: string,
): Promise<User> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(users)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: actorUserId,
})
.where(eq(users.id, id))
.returning({ id: users.id });
const row = updated[0];
if (!row) {
throw new NotFoundException('User not found');
}
return this.requireById(row.id);
}
async bulkUpdateStatus(
ids: string[],
status: Status,
actorUserId: string,
): Promise<number> {
if (ids.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(users)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: actorUserId,
})
.where(inArray(users.id, ids))
.returning({ id: users.id });
return rows.length;
}
async delete(id: string): Promise<void> {
try {
const deleted = await this.db
.delete(users)
.where(eq(users.id, id))
.returning({ id: users.id });
if (deleted.length === 0) {
throw new NotFoundException('User not found');
}
} catch (error) {
this.rethrowConstraintViolation(error);
}
}
async bulkDelete(ids: string[]): Promise<number> {
if (ids.length === 0) {
return 0;
}
try {
const deleted = await this.db
.delete(users)
.where(inArray(users.id, ids))
.returning({ id: users.id });
return deleted.length;
} catch (error) {
this.rethrowConstraintViolation(error);
}
}
private selectWithRelations() {
return this.db
.select({
user: users,
privilege: privileges,
employee: employees,
createdByUser: createdByUsers,
updatedByUser: updatedByUsers,
})
.from(users)
.leftJoin(privileges, eq(users.privilegeId, privileges.id))
.leftJoin(employees, eq(employees.userId, users.id))
.leftJoin(createdByUsers, eq(users.createdBy, createdByUsers.id))
.leftJoin(updatedByUsers, eq(users.updatedBy, updatedByUsers.id));
}
private async hydrate(row: UserRow): Promise<User> {
const [privilege] = row.privilegeId
? await this.db
.select({
id: privileges.id,
code: privileges.code,
name: privileges.name,
})
.from(privileges)
.where(eq(privileges.id, row.privilegeId))
.limit(1)
: [null];
const [employee] = await this.db
.select({
id: employees.id,
code: employees.code,
name: employees.name,
status: employees.status,
})
.from(employees)
.where(eq(employees.userId, row.id))
.limit(1);
const createdByUser =
row.createdBy === row.id
? { id: row.id, username: row.username }
: ((
await this.db
.select({ id: users.id, username: users.username })
.from(users)
.where(eq(users.id, row.createdBy))
.limit(1)
)[0] ?? null);
const updatedByUser =
row.updatedBy === row.id
? { id: row.id, username: row.username }
: ((
await this.db
.select({ id: users.id, username: users.username })
.from(users)
.where(eq(users.id, row.updatedBy))
.limit(1)
)[0] ?? null);
return this.toDomain({
user: row,
privilege: privilege ?? null,
employee: employee ?? null,
createdByUser,
updatedByUser,
});
}
private async requireById(id: string): Promise<User> {
const loaded = await this.findById(id);
if (!loaded) {
throw new NotFoundException('User not found');
}
return loaded;
}
private buildListWhere(filters: ListUsersFilters): SQL | undefined {
const parts: SQL[] = [];
if (filters.username) {
parts.push(ilike(users.username, `%${filters.username}%`));
}
if (filters.privilegeId) {
parts.push(eq(users.privilegeId, filters.privilegeId));
}
if (filters.status) {
parts.push(eq(users.status, filters.status));
}
if (filters.search) {
parts.push(ilike(users.username, `%${filters.search}%`));
}
if (parts.length === 0) {
return undefined;
}
return parts.length === 1 ? parts[0] : and(...parts);
}
private toDomain(row: UserJoinedRow): User {
const user = row.user;
return {
id: row.id,
username: row.username,
passwordHash: row.passwordHash,
privilegeId: row.privilegeId ?? null,
isSuperadmin: row.isSuperadmin,
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
id: user.id,
username: user.username,
passwordHash: user.passwordHash,
privilegeId: user.privilegeId ?? null,
isSuperadmin: user.isSuperadmin,
status: Status.create(user.status),
createdAt: DateTime.fromUnixMs(user.createdAt),
updatedAt: DateTime.fromUnixMs(user.updatedAt),
createdBy: user.createdBy,
updatedBy: user.updatedBy,
privilege: row.privilege?.id
? {
id: row.privilege.id,
code: row.privilege.code,
name: row.privilege.name,
}
: null,
employee: row.employee?.id
? {
id: row.employee.id,
code: row.employee.code,
name: row.employee.name,
status: Status.create(row.employee.status),
}
: null,
createdByUser: this.toUserRelation(row.createdByUser, user.createdBy),
updatedByUser: this.toUserRelation(row.updatedByUser, user.updatedBy),
};
}
private toUserRelation(
row: { id: string; username: string } | null,
fallbackId: string,
): User['createdByUser'] {
if (row?.id) {
return { id: row.id, username: row.username };
}
return { id: fallbackId, username: '' };
}
private rethrowConstraintViolation(error: unknown): never {
const err = this.unwrapDbError(error);
if (err.code === '23505') {
throw new ConflictException('Username already registered');
}
if (err.code === '23503') {
throw new ConflictException('User is still referenced');
}
throw error;
}
private unwrapDbError(error: unknown): { code?: string } {
let current: unknown = error;
for (let i = 0; i < 5; i++) {
if (!current || typeof current !== 'object') {
break;
}
const obj = current as { code?: string; cause?: unknown };
if (obj.code === '23505' || obj.code === '23503') {
return { code: obj.code };
}
current = obj.cause;
}
return error as { code?: string };
}
}
+95 -3
View File
@@ -1,6 +1,12 @@
import { ConflictException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import { Status } from '../../common/value-objects/status/status';
import { PrivilegesService } from '../privileges/privileges.service';
import type { User } from './user';
import { UsersRepository } from './users.repository';
@@ -11,7 +17,16 @@ describe('UsersService', () => {
let repository: jest.Mocked<
Pick<
UsersRepository,
'findById' | 'findByUsername' | 'create' | 'updatePrivilegeId'
| 'findById'
| 'findByUsername'
| 'create'
| 'update'
| 'updatePrivilegeId'
| 'updateStatus'
| 'bulkUpdateStatus'
| 'delete'
| 'bulkDelete'
| 'list'
>
>;
let privilegesService: jest.Mocked<
@@ -25,8 +40,15 @@ describe('UsersService', () => {
passwordHash: 'hashed',
privilegeId: null,
isSuperadmin: false,
status: Status.create('draft'),
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
privilege: null,
employee: null,
createdByUser: { id: 'user-1', username: 'alice' },
updatedByUser: { id: 'user-1', username: 'alice' },
};
beforeEach(async () => {
@@ -34,7 +56,13 @@ describe('UsersService', () => {
findById: jest.fn(),
findByUsername: jest.fn(),
create: jest.fn(),
update: jest.fn(),
updatePrivilegeId: jest.fn(),
updateStatus: jest.fn(),
bulkUpdateStatus: jest.fn(),
delete: jest.fn(),
bulkDelete: jest.fn(),
list: jest.fn(),
};
privilegesService = {
findPrivilegeSummary: jest.fn(),
@@ -45,6 +73,10 @@ describe('UsersService', () => {
UsersService,
{ provide: UsersRepository, useValue: repository },
{ provide: PrivilegesService, useValue: privilegesService },
{
provide: ConfigService,
useValue: { getOrThrow: () => 4 },
},
],
}).compile();
@@ -94,9 +126,69 @@ describe('UsersService', () => {
repository.updatePrivilegeId.mockResolvedValue({
...sampleUser,
privilegeId: 'priv-1',
privilege: { id: 'priv-1', code: 'ADMIN', name: 'Admin' },
});
const result = await service.assignPrivilege('user-1', 'priv-1');
expect(result.privilegeId).toBe('priv-1');
expect(result.privilege).toEqual({
id: 'priv-1',
code: 'ADMIN',
name: 'Admin',
});
expect(result).not.toHaveProperty('passwordHash');
});
it('list maps nested relations and omits password', async () => {
repository.list.mockResolvedValue({ data: [sampleUser], total: 1 });
const result = await service.list({ page: 1, limit: 10 });
expect(result.total).toBe(1);
expect(result.data[0]).toMatchObject({
id: 'user-1',
username: 'alice',
status: 'draft',
privilege: null,
employee: null,
createdBy: { id: 'user-1', username: 'alice' },
});
expect(result.data[0]).not.toHaveProperty('passwordHash');
});
it('update rejects status field', async () => {
await expect(
service.update('user-1', { status: 'active', actorUserId: 'user-1' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('assertCanAuthenticate allows active users without employee', () => {
expect(() =>
service.assertCanAuthenticate({
...sampleUser,
status: Status.create('active'),
}),
).not.toThrow();
});
it('assertCanAuthenticate rejects draft users and inactive employees', () => {
expect(() => service.assertCanAuthenticate(sampleUser)).toThrow(
UnauthorizedException,
);
expect(() =>
service.assertCanAuthenticate({
...sampleUser,
status: Status.create('active'),
employee: {
id: 'emp-1',
code: 'EMP_01',
name: 'Ada',
status: Status.create('draft'),
},
}),
).toThrow(UnauthorizedException);
});
it('importCsv requires username and password headers', async () => {
await expect(service.importCsv('name\nalice', 'user-1')).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
+318 -11
View File
@@ -3,22 +3,93 @@ import {
ConflictException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import type { PaginationResponse } from '../../common/http/response';
import {
DEFAULT_RELATION_FIELDS,
pickRelation,
pickUserRelation,
toListPage,
} from '../../common/http/response';
import { InvalidStatusError } from '../../common/value-objects/status/invalid-status.error';
import { Status } from '../../common/value-objects/status/status';
import { PrivilegesService } from '../privileges/privileges.service';
import type { CreateUserInput, UpdateUserInput, User } from './user';
import {
isValidPassword,
isValidUsername,
parseCsvRecord,
} from './user-fields';
import { UsersRepository } from './users.repository';
import type { User } from './user';
export type ListUsersQuery = {
readonly username?: string;
readonly privilegeId?: string;
readonly status?: string;
readonly search?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
};
const VISIBLE_FIELDS = [
'id',
'username',
'isSuperadmin',
'privilege',
'employee',
'status',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
] as const;
const CSV_REQUIRED_HEADERS = ['username', 'password'] as const;
@Injectable()
export class UsersService {
constructor(
private readonly usersRepository: UsersRepository,
private readonly privilegesService: PrivilegesService,
private readonly config: ConfigService,
) {}
async list(
query: ListUsersQuery,
): Promise<PaginationResponse<ReturnType<UsersService['toListItem']>>> {
const page = toListPage(query);
const { data, total } = await this.usersRepository.list({
username: query.username,
privilegeId: query.privilegeId,
status: query.status,
search: query.search,
limit: page.limit,
offset: page.offset,
});
return {
data: data.map((item) => this.toListItem(item)),
total,
};
}
async findById(id: string): Promise<User | null> {
return this.usersRepository.findById(id);
}
async getById(
id: string,
): Promise<ReturnType<UsersService['toListItem']>> {
const user = await this.usersRepository.findById(id);
if (!user) {
throw new NotFoundException('User not found');
}
return this.toListItem(user);
}
async findByUsername(username: string): Promise<User | null> {
return this.usersRepository.findByUsername(username);
}
@@ -35,24 +106,260 @@ export class UsersService {
});
}
async createManaged(input: {
username: string;
password: string;
privilegeId?: string;
status?: string;
actorUserId: string;
}): Promise<ReturnType<UsersService['toListItem']>> {
const created = await this.usersRepository.create(
await this.toCreateInput(input),
);
return this.toListItem(created);
}
async update(
id: string,
input: {
username?: string;
password?: string;
privilegeId?: string | null;
status?: unknown;
actorUserId: string;
},
): Promise<ReturnType<UsersService['toListItem']>> {
if (input.status !== undefined) {
throw new BadRequestException('status cannot be updated via PATCH');
}
const payload: UpdateUserInput = {
username:
input.username !== undefined
? this.assertUsername(input.username)
: undefined,
passwordHash:
input.password !== undefined
? await this.hashPassword(this.assertPassword(input.password))
: undefined,
privilegeId:
input.privilegeId !== undefined
? await this.assertPrivilegeId(input.privilegeId)
: undefined,
actorUserId: input.actorUserId,
};
const updated = await this.usersRepository.update(id, payload);
return this.toListItem(updated);
}
async updateStatus(
id: string,
statusRaw: string,
actorUserId: string,
): Promise<ReturnType<UsersService['toListItem']>> {
const status = Status.create(statusRaw);
const updated = await this.usersRepository.updateStatus(
id,
status,
actorUserId,
);
return this.toListItem(updated);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
actorUserId: string,
): Promise<{ updated: number }> {
const status = Status.create(statusRaw);
const updated = await this.usersRepository.bulkUpdateStatus(
ids,
status,
actorUserId,
);
return { updated };
}
async delete(id: string): Promise<void> {
await this.usersRepository.delete(id);
}
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
const deleted = await this.usersRepository.bulkDelete(ids);
return { deleted };
}
async assignPrivilege(
userId: string,
privilegeId: string | null,
): Promise<User> {
actorUserId?: string,
): Promise<ReturnType<UsersService['toListItem']>> {
const user = await this.usersRepository.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
if (privilegeId !== null) {
const privilege =
await this.privilegesService.findPrivilegeSummary(privilegeId);
if (!privilege) {
throw new NotFoundException('Privilege not found');
}
if (privilege.status !== 'active') {
throw new BadRequestException('Privilege must be active');
const assigned = await this.assertPrivilegeId(privilegeId);
const updated = await this.usersRepository.updatePrivilegeId(
userId,
assigned,
actorUserId,
);
return this.toListItem(updated);
}
async importCsv(
csv: string,
actorUserId: string,
): Promise<{ imported: number }> {
const rawLines = csv.split(/\r?\n/);
const filled = rawLines
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
.filter((entry) => entry.line.length > 0);
if (filled.length === 0) {
throw new BadRequestException('CSV is empty');
}
if (filled.length > 501) {
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
}
const header = parseCsvRecord(filled[0].line).map((h) =>
h.trim().toLowerCase(),
);
const missing = CSV_REQUIRED_HEADERS.filter((h) => header.indexOf(h) < 0);
if (missing.length > 0) {
throw new BadRequestException('CSV must include required headers');
}
const idx = (key: string) => header.indexOf(key);
const errors: string[] = [];
const rows: CreateUserInput[] = [];
for (let i = 1; i < filled.length; i++) {
const cols = parseCsvRecord(filled[i].line);
const rowNum = filled[i].lineNo;
try {
const privilegeRaw =
idx('privilegeid') >= 0 ? cols[idx('privilegeid')] : '';
const statusRaw = idx('status') >= 0 ? cols[idx('status')] : '';
rows.push(
await this.toCreateInput({
username: cols[idx('username')] ?? '',
password: cols[idx('password')] ?? '',
privilegeId: privilegeRaw || undefined,
status: statusRaw || undefined,
actorUserId,
}),
);
} catch (error) {
const reason =
error instanceof BadRequestException ? error.message : 'invalid data';
errors.push(`row ${rowNum}: ${reason}`);
}
}
return this.usersRepository.updatePrivilegeId(userId, privilegeId);
if (errors.length > 0) {
throw new BadRequestException({
message: 'CSV validation failed',
errors,
});
}
for (const row of rows) {
await this.usersRepository.create(row);
}
return { imported: rows.length };
}
assertCanAuthenticate(user: User): void {
if (user.status.value !== 'active') {
throw new UnauthorizedException('Invalid credentials');
}
if (user.employee && user.employee.status.value !== 'active') {
throw new UnauthorizedException('Invalid credentials');
}
}
toListItem(user: User) {
return {
id: user.id,
username: user.username,
isSuperadmin: user.isSuperadmin,
privilege: pickRelation(user.privilege, DEFAULT_RELATION_FIELDS),
employee: pickRelation(user.employee, DEFAULT_RELATION_FIELDS),
status: user.status.value,
createdAt: user.createdAt.value,
updatedAt: user.updatedAt.value,
createdBy: pickUserRelation(user.createdByUser),
updatedBy: pickUserRelation(user.updatedByUser),
};
}
get visibleFields(): readonly string[] {
return VISIBLE_FIELDS;
}
private async toCreateInput(input: {
username: string;
password: string;
privilegeId?: string;
status?: string;
actorUserId: string;
}): Promise<CreateUserInput> {
let status: Status | undefined;
if (input.status) {
try {
status = Status.create(input.status);
} catch (error) {
if (error instanceof InvalidStatusError) {
throw new BadRequestException('Invalid status');
}
throw error;
}
}
return {
username: this.assertUsername(input.username),
passwordHash: await this.hashPassword(this.assertPassword(input.password)),
privilegeId:
input.privilegeId !== undefined && input.privilegeId !== ''
? await this.assertPrivilegeId(input.privilegeId)
: undefined,
status,
actorUserId: input.actorUserId,
};
}
private assertUsername(raw: string): string {
const username = raw.trim();
if (!isValidUsername(username)) {
throw new BadRequestException('Invalid username');
}
return username.toLowerCase();
}
private assertPassword(raw: string): string {
if (!isValidPassword(raw)) {
throw new BadRequestException('Invalid password');
}
return raw;
}
private async assertPrivilegeId(
privilegeId: string | null,
): Promise<string | null> {
if (privilegeId === null) {
return null;
}
const privilege =
await this.privilegesService.findPrivilegeSummary(privilegeId);
if (!privilege) {
throw new NotFoundException('Privilege not found');
}
if (privilege.status !== 'active') {
throw new BadRequestException('Privilege must be active');
}
return privilegeId;
}
private async hashPassword(password: string): Promise<string> {
const saltRounds = this.config.getOrThrow<number>('BCRYPT_SALT_ROUNDS');
return bcrypt.hash(password, saltRounds);
}
}