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:
@@ -14,9 +14,13 @@ export const employees = pgTable(
|
||||
name: varchar('name', { length: 64 }).notNull(),
|
||||
phone: text('phone').notNull(),
|
||||
position: text('position').notNull(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [uniqueIndex('employees_code_unique').on(t.code)],
|
||||
(t) => [
|
||||
uniqueIndex('employees_code_unique').on(t.code),
|
||||
uniqueIndex('employees_user_id_unique').on(t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export type EmployeeRow = typeof employees.$inferSelect;
|
||||
|
||||
@@ -8,13 +8,17 @@ import {
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
type AnyPgColumn,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { Status } from '../common/value-objects/status/status';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
|
||||
/**
|
||||
* Application users. Timestamps are UTC unix milliseconds.
|
||||
* privilege_id is nullable until a role is assigned (deny-by-default).
|
||||
* FK to privileges.id is enforced in the migration (circular table dependency).
|
||||
* Status / created_by / updated_by are declared here (not via primaryEntityColumns)
|
||||
* because this table cannot pass itself the same way other tables pass `users`.
|
||||
*/
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
@@ -24,8 +28,15 @@ export const users = pgTable(
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
privilegeId: uuid('privilege_id'),
|
||||
isSuperadmin: boolean('is_superadmin').notNull().default(false),
|
||||
status: text('status').notNull().default(Status.DEFAULT),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
|
||||
createdBy: uuid('created_by')
|
||||
.notNull()
|
||||
.references((): AnyPgColumn => users.id),
|
||||
updatedBy: uuid('updated_by')
|
||||
.notNull()
|
||||
.references((): AnyPgColumn => users.id),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('users_username_unique').on(t.username),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user