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
+21
View File
@@ -0,0 +1,21 @@
ALTER TABLE "users" ADD COLUMN "status" text DEFAULT 'draft' NOT NULL;
--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "created_by" uuid;
--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "updated_by" uuid;
--> statement-breakpoint
UPDATE "users" SET "status" = 'active', "created_by" = "id", "updated_by" = "id";
--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "created_by" SET NOT NULL;
--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "updated_by" SET NOT NULL;
--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "employees" ADD COLUMN "user_id" uuid;
--> statement-breakpoint
ALTER TABLE "employees" ADD CONSTRAINT "employees_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
CREATE UNIQUE INDEX "employees_user_id_unique" ON "employees" USING btree ("user_id");
+7
View File
@@ -85,6 +85,13 @@
"when": 1787559000000, "when": 1787559000000,
"tag": "0011_field", "tag": "0011_field",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1787560000000,
"tag": "0012_users_primary",
"breakpoints": true
} }
] ]
} }
+5 -1
View File
@@ -14,9 +14,13 @@ export const employees = pgTable(
name: varchar('name', { length: 64 }).notNull(), name: varchar('name', { length: 64 }).notNull(),
phone: text('phone').notNull(), phone: text('phone').notNull(),
position: text('position').notNull(), position: text('position').notNull(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
...primaryEntityColumns(users), ...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; export type EmployeeRow = typeof employees.$inferSelect;
+11
View File
@@ -8,13 +8,17 @@ import {
uniqueIndex, uniqueIndex,
uuid, uuid,
varchar, varchar,
type AnyPgColumn,
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
import { Status } from '../common/value-objects/status/status';
import { primaryEntityColumns } from './primary-entity-columns'; import { primaryEntityColumns } from './primary-entity-columns';
/** /**
* Application users. Timestamps are UTC unix milliseconds. * Application users. Timestamps are UTC unix milliseconds.
* privilege_id is nullable until a role is assigned (deny-by-default). * privilege_id is nullable until a role is assigned (deny-by-default).
* FK to privileges.id is enforced in the migration (circular table dependency). * 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( export const users = pgTable(
'users', 'users',
@@ -24,8 +28,15 @@ export const users = pgTable(
passwordHash: text('password_hash').notNull(), passwordHash: text('password_hash').notNull(),
privilegeId: uuid('privilege_id'), privilegeId: uuid('privilege_id'),
isSuperadmin: boolean('is_superadmin').notNull().default(false), isSuperadmin: boolean('is_superadmin').notNull().default(false),
status: text('status').notNull().default(Status.DEFAULT),
createdAt: bigint('created_at', { mode: 'number' }).notNull(), createdAt: bigint('created_at', { mode: 'number' }).notNull(),
updatedAt: bigint('updated_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) => [ (t) => [
uniqueIndex('users_username_unique').on(t.username), uniqueIndex('users_username_unique').on(t.username),
+4 -2
View File
@@ -17,8 +17,9 @@ describe('AuthController', () => {
beforeEach(async () => { beforeEach(async () => {
authService = { authService = {
register: jest.fn().mockResolvedValue({ register: jest.fn().mockResolvedValue({
accessToken: 'a', id: 'user-1',
refreshToken: 'b'.repeat(64), username: 'alice',
status: 'draft',
}), }),
login: jest.fn().mockResolvedValue({ login: jest.fn().mockResolvedValue({
accessToken: 'a', accessToken: 'a',
@@ -105,6 +106,7 @@ describe('AuthController', () => {
id: 'priv-1', id: 'priv-1',
name: 'Admin', name: 'Admin',
code: 'ADMIN', code: 'ADMIN',
status: 'active',
}); });
privilegesService.getPermissionsMap.mockResolvedValue({ privilegesService.getPermissionsMap.mockResolvedValue({
PRIVILEGES: { PRIVILEGES: {
+3 -2
View File
@@ -24,6 +24,7 @@ import {
MeResponseDto, MeResponseDto,
RefreshTokenDto, RefreshTokenDto,
RegisterDto, RegisterDto,
RegisterResponseDto,
TokenPairDto, TokenPairDto,
} from './dto/auth.dto'; } from './dto/auth.dto';
@@ -40,11 +41,11 @@ export class AuthController {
@Throttle({ default: { limit: 5, ttl: 60_000 } }) @Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('register') @Post('register')
@ApiOperation({ summary: 'Register a new user' }) @ApiOperation({ summary: 'Register a new user' })
@ApiCreatedResponse({ type: TokenPairDto }) @ApiCreatedResponse({ type: RegisterResponseDto })
@ApiBadRequestResponse({ description: 'Validation failed' }) @ApiBadRequestResponse({ description: 'Validation failed' })
@ApiConflictResponse({ description: 'Username already registered' }) @ApiConflictResponse({ description: 'Username already registered' })
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' }) @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); 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], controllers: [AuthController],
providers: [ providers: [
+25 -8
View File
@@ -5,6 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { DateTime } from '../../common/value-objects/date-time/date-time'; 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 type { User } from '../users/user';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@@ -17,7 +18,10 @@ import { RevokedAccessTokensRepository } from './revoked-access-tokens.repositor
describe('AuthService', () => { describe('AuthService', () => {
let service: AuthService; let service: AuthService;
let usersService: jest.Mocked< let usersService: jest.Mocked<
Pick<UsersService, 'create' | 'findByUsername' | 'findById'> Pick<
UsersService,
'create' | 'findByUsername' | 'findById' | 'assertCanAuthenticate'
>
>; >;
let jwtService: jest.Mocked<Pick<JwtService, 'signAsync'>>; let jwtService: jest.Mocked<Pick<JwtService, 'signAsync'>>;
let config: { getOrThrow: jest.Mock }; let config: { getOrThrow: jest.Mock };
@@ -46,14 +50,22 @@ describe('AuthService', () => {
passwordHash: await bcrypt.hash('password123', 4), passwordHash: await bcrypt.hash('password123', 4),
privilegeId: null, privilegeId: null,
isSuperadmin: false, isSuperadmin: false,
status: Status.create('active'),
createdAt: now, createdAt: now,
updatedAt: 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 = { usersService = {
create: jest.fn(), create: jest.fn(),
findByUsername: jest.fn(), findByUsername: jest.fn(),
findById: jest.fn(), findById: jest.fn(),
assertCanAuthenticate: jest.fn(),
}; };
jwtService = { jwtService = {
signAsync: jest.fn().mockResolvedValue('access.jwt.token'), signAsync: jest.fn().mockResolvedValue('access.jwt.token'),
@@ -108,17 +120,22 @@ describe('AuthService', () => {
service = moduleRef.get(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.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(usersService.create).toHaveBeenCalled();
expect(pair.accessToken).toBe('access.jwt.token'); expect(result).toEqual({
expect(pair.refreshToken).toHaveLength(64); id: 'user-1',
expect(Object.keys(pair).sort()).toEqual(['accessToken', 'refreshToken']); username: 'alice',
expect(refreshTokensRepository.create).toHaveBeenCalled(); status: 'draft',
});
expect(refreshTokensRepository.create).not.toHaveBeenCalled();
}); });
it('register throws ConflictException when username exists', async () => { it('register throws ConflictException when username exists', async () => {
+11 -3
View File
@@ -33,7 +33,10 @@ export class AuthService {
private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository, 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); const existing = await this.usersService.findByUsername(username);
if (existing) { if (existing) {
throw new ConflictException('Username already registered'); throw new ConflictException('Username already registered');
@@ -41,8 +44,11 @@ export class AuthService {
const saltRounds = this.config.getOrThrow<number>('BCRYPT_SALT_ROUNDS'); const saltRounds = this.config.getOrThrow<number>('BCRYPT_SALT_ROUNDS');
const passwordHash = await bcrypt.hash(password, saltRounds); const passwordHash = await bcrypt.hash(password, saltRounds);
const user = await this.usersService.create(username, passwordHash); const user = await this.usersService.create(username, passwordHash);
const { tokens } = await this.issueTokenPair(user); return {
return tokens; id: user.id,
username: user.username,
status: user.status.value,
};
} }
async login(username: string, password: string): Promise<TokenPair> { async login(username: string, password: string): Promise<TokenPair> {
@@ -52,6 +58,7 @@ export class AuthService {
if (!user || !match) { if (!user || !match) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
this.usersService.assertCanAuthenticate(user);
const { tokens } = await this.issueTokenPair(user); const { tokens } = await this.issueTokenPair(user);
return tokens; return tokens;
} }
@@ -78,6 +85,7 @@ export class AuthService {
if (!user) { if (!user) {
throw new UnauthorizedException('Invalid refresh token'); throw new UnauthorizedException('Invalid refresh token');
} }
this.usersService.assertCanAuthenticate(user);
await this.denylistAccessJti(claimed.accessJti); await this.denylistAccessJti(claimed.accessJti);
const issued = await this.issueTokenPair(user); const issued = await this.issueTokenPair(user);
+11
View File
@@ -54,6 +54,17 @@ export class RefreshTokenDto {
refreshToken!: string; 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 { export class TokenPairDto implements TokenPair {
@ApiProperty({ @ApiProperty({
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example',
@@ -2,6 +2,7 @@ import { UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; 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 type { User } from '../../users/user';
import { UsersService } from '../../users/users.service'; import { UsersService } from '../../users/users.service';
import { RevokedAccessTokensRepository } from '../revoked-access-tokens.repository'; import { RevokedAccessTokensRepository } from '../revoked-access-tokens.repository';
@@ -9,7 +10,9 @@ import { JwtStrategy } from './jwt.strategy';
describe('JwtStrategy', () => { describe('JwtStrategy', () => {
let strategy: 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'>>; let revoked: jest.Mocked<Pick<RevokedAccessTokensRepository, 'exists'>>;
const now = DateTime.fromUnixMs(1_700_000_000_000); const now = DateTime.fromUnixMs(1_700_000_000_000);
@@ -19,12 +22,22 @@ describe('JwtStrategy', () => {
passwordHash: 'hash', passwordHash: 'hash',
privilegeId: null, privilegeId: null,
isSuperadmin: false, isSuperadmin: false,
status: Status.create('active'),
createdAt: now, createdAt: now,
updatedAt: 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 () => { beforeEach(async () => {
usersService = { findById: jest.fn() }; usersService = {
findById: jest.fn(),
assertCanAuthenticate: jest.fn(),
};
revoked = { exists: jest.fn() }; revoked = { exists: jest.fn() };
const moduleRef: TestingModule = await Test.createTestingModule({ const moduleRef: TestingModule = await Test.createTestingModule({
@@ -40,6 +40,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
if (!user) { if (!user) {
throw new UnauthorizedException('User not found'); throw new UnauthorizedException('User not found');
} }
this.usersService.assertCanAuthenticate(user);
return { return {
id: user.id, id: user.id,
@@ -9,8 +9,12 @@ import {
IsUUID, IsUUID,
Matches, Matches,
MaxLength, MaxLength,
ValidateIf,
} from 'class-validator'; } 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 { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { import {
EMPLOYEE_CODE_MAX_LENGTH, EMPLOYEE_CODE_MAX_LENGTH,
@@ -55,6 +59,11 @@ export class CreateEmployeeDto {
@IsOptional() @IsOptional()
@IsIn([...CORE_STATUSES]) @IsIn([...CORE_STATUSES])
status?: string; status?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
userId?: string;
} }
export class UpdateEmployeeDto { export class UpdateEmployeeDto {
@@ -88,6 +97,11 @@ export class UpdateEmployeeDto {
@IsOptional() @IsOptional()
@IsIn([...EMPLOYEE_POSITIONS]) @IsIn([...EMPLOYEE_POSITIONS])
position?: string; position?: string;
@ApiPropertyOptional({ format: 'uuid', nullable: true })
@ValidateIf((_, value) => value !== undefined)
@IsUUID('4')
userId?: string | null;
} }
export class UpdateEmployeeStatusDto { export class UpdateEmployeeStatusDto {
@@ -142,6 +156,11 @@ export class ListEmployeesQueryDto extends PaginationQueryDto {
@IsIn([...CORE_STATUSES]) @IsIn([...CORE_STATUSES])
status?: string; status?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
userId?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Case-insensitive match on code or name', description: 'Case-insensitive match on code or name',
}) })
@@ -180,4 +199,7 @@ export class EmployeeDto {
@ApiProperty({ format: 'uuid' }) @ApiProperty({ format: 'uuid' })
updatedBy!: string; updatedBy!: string;
@ApiPropertyOptional({ type: UserRelationDto, nullable: true })
user!: UserRelationDto | null;
} }
@@ -14,6 +14,8 @@ export type Employee = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: string; readonly updatedBy: string;
readonly userId: string | null;
readonly user: { readonly id: string; readonly username: string } | null;
}; };
export type CreateEmployeeInput = { export type CreateEmployeeInput = {
@@ -23,6 +25,7 @@ export type CreateEmployeeInput = {
readonly position: EmployeePosition; readonly position: EmployeePosition;
readonly status?: Status; readonly status?: Status;
readonly userId: string; readonly userId: string;
readonly assignedUserId?: string | null;
}; };
export type UpdateEmployeeInput = { export type UpdateEmployeeInput = {
@@ -31,6 +34,7 @@ export type UpdateEmployeeInput = {
readonly phone?: PhoneNumber; readonly phone?: PhoneNumber;
readonly position?: EmployeePosition; readonly position?: EmployeePosition;
readonly userId: string; readonly userId: string;
readonly assignedUserId?: string | null;
}; };
export type ListEmployeesFilters = { export type ListEmployeesFilters = {
@@ -39,6 +43,7 @@ export type ListEmployeesFilters = {
readonly phone?: string; readonly phone?: string;
readonly position?: string; readonly position?: string;
readonly status?: string; readonly status?: string;
readonly userId?: string;
readonly search?: string; readonly search?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
@@ -37,6 +37,7 @@ describe('EmployeesWriteController', () => {
...createDto, ...createDto,
status: undefined, status: undefined,
userId: 'user-1', userId: 'user-1',
assignedUserId: undefined,
}); });
}); });
@@ -130,6 +130,7 @@ export class EmployeesWriteController {
position: dto.position, position: dto.position,
status: dto.status, status: dto.status,
userId, userId,
assignedUserId: dto.userId,
}); });
} }
@@ -166,6 +167,7 @@ export class EmployeesWriteController {
phone: dto.phone, phone: dto.phone,
position: dto.position, position: dto.position,
userId, userId,
assignedUserId: dto.userId,
}); });
} }
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { UsersModule } from '../../users/users.module';
import { EmployeesReadController } from './employees-read.controller'; import { EmployeesReadController } from './employees-read.controller';
import { EmployeesWriteController } from './employees-write.controller'; import { EmployeesWriteController } from './employees-write.controller';
import { EmployeesRepository } from './employees.repository'; import { EmployeesRepository } from './employees.repository';
import { EmployeesService } from './employees.service'; import { EmployeesService } from './employees.service';
@Module({ @Module({
imports: [UsersModule],
controllers: [EmployeesReadController, EmployeesWriteController], controllers: [EmployeesReadController, EmployeesWriteController],
providers: [EmployeesRepository, EmployeesService], providers: [EmployeesRepository, EmployeesService],
exports: [EmployeesService], exports: [EmployeesService],
@@ -42,8 +42,11 @@ describe('EmployeesRepository', () => {
updatedAt: 1_700_000_000_000, updatedAt: 1_700_000_000_000,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: 'user-1', updatedBy: 'user-1',
userId: null,
}; };
const joinedRow = { employee: row, user: null };
const createInput = { const createInput = {
code: 'EMP_01', code: 'EMP_01',
name: 'Ada Lovelace', name: 'Ada Lovelace',
@@ -10,6 +10,7 @@ import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-nu
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import { employees, type EmployeeRow } from '../../../database/employees-table'; import { employees, type EmployeeRow } from '../../../database/employees-table';
import { users } from '../../../database/schema';
import type { EmployeePosition } from './employee-fields'; import type { EmployeePosition } from './employee-fields';
import type { import type {
CreateEmployeeInput, CreateEmployeeInput,
@@ -41,7 +42,11 @@ export class EmployeesRepository {
.offset(filters.offset); .offset(filters.offset);
return { 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), total: Number(totalRow?.total ?? 0),
}; };
} }
@@ -55,13 +60,13 @@ export class EmployeesRepository {
} }
async findById(id: string): Promise<Employee | null> { async findById(id: string): Promise<Employee | null> {
const rows: EmployeeRow[] = await this.db const rows = await this.db
.select() .select()
.from(employees) .from(employees)
.where(eq(employees.id, id)) .where(eq(employees.id, id))
.limit(1); .limit(1);
const row = rows[0]; 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> { async findByCode(code: string): Promise<Employee | null> {
@@ -71,7 +76,7 @@ export class EmployeesRepository {
.where(eq(employees.code, code)) .where(eq(employees.code, code))
.limit(1); .limit(1);
const row = rows[0]; 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> { async create(input: CreateEmployeeInput): Promise<Employee> {
@@ -83,7 +88,7 @@ export class EmployeesRepository {
.values(this.toInsertValues(input, status, now, input.userId)) .values(this.toInsertValues(input, status, now, input.userId))
.returning(); .returning();
const row = inserted[0]; const row = inserted[0];
return this.toDomain(row); return this.toDomain(row, await this.loadAssignedUser(row.userId));
} catch (error) { } catch (error) {
this.rethrowUniqueViolation(error); this.rethrowUniqueViolation(error);
} }
@@ -125,11 +130,14 @@ export class EmployeesRepository {
position: input.position ?? existing.position, position: input.position ?? existing.position,
updatedAt: now.value, updatedAt: now.value,
updatedBy: input.userId, updatedBy: input.userId,
...(input.assignedUserId !== undefined
? { userId: input.assignedUserId }
: {}),
}) })
.where(eq(employees.id, id)) .where(eq(employees.id, id))
.returning(); .returning();
const row = updated[0]; const row = updated[0];
return this.toDomain(row); return this.toDomain(row, await this.loadAssignedUser(row.userId));
} catch (error) { } catch (error) {
this.rethrowUniqueViolation(error); this.rethrowUniqueViolation(error);
} }
@@ -154,7 +162,7 @@ export class EmployeesRepository {
if (!row) { if (!row) {
throw new NotFoundException('Employee not found'); throw new NotFoundException('Employee not found');
} }
return this.toDomain(row); return this.toDomain(row, await this.loadAssignedUser(row.userId));
} }
async bulkUpdateStatus( async bulkUpdateStatus(
@@ -216,6 +224,9 @@ export class EmployeesRepository {
if (filters.status) { if (filters.status) {
parts.push(eq(employees.status, filters.status)); parts.push(eq(employees.status, filters.status));
} }
if (filters.userId) {
parts.push(eq(employees.userId, filters.userId));
}
if (filters.search) { if (filters.search) {
const search = or( const search = or(
ilike(employees.code, `%${filters.search}%`), ilike(employees.code, `%${filters.search}%`),
@@ -247,10 +258,41 @@ export class EmployeesRepository {
updatedAt: now.value, updatedAt: now.value,
createdBy: userId, createdBy: userId,
updatedBy: 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 { return {
id: row.id, id: row.id,
code: row.code, code: row.code,
@@ -262,29 +304,39 @@ export class EmployeesRepository {
updatedAt: DateTime.fromUnixMs(row.updatedAt), updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy, createdBy: row.createdBy,
updatedBy: row.updatedBy, updatedBy: row.updatedBy,
userId: row.userId ?? null,
user: user?.id ? { id: user.id, username: user.username } : null,
}; };
} }
private rethrowUniqueViolation(error: unknown): never { private rethrowUniqueViolation(error: unknown): never {
const err = this.unwrapDbError(error); const err = this.unwrapDbError(error);
if (err.code === '23505') { 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 new ConflictException('Employee code already exists');
} }
throw error; throw error;
} }
private unwrapDbError(error: unknown): { code?: string } { private unwrapDbError(error: unknown): { code?: string; constraint?: string } {
let current: unknown = error; let current: unknown = error;
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
if (!current || typeof current !== 'object') { if (!current || typeof current !== 'object') {
break; 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') { if (obj.code === '23505' || obj.code === '23503') {
return { code: obj.code }; return { code: obj.code, constraint: obj.constraint };
} }
current = obj.cause; 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 { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
import type { Employee } from './employee'; import type { Employee } from './employee';
import { UsersService } from '../../users/users.service';
import { EmployeesRepository } from './employees.repository'; import { EmployeesRepository } from './employees.repository';
import { EmployeesService } from './employees.service'; import { EmployeesService } from './employees.service';
@@ -36,6 +37,8 @@ describe('EmployeesService', () => {
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: 'user-1', updatedBy: 'user-1',
userId: null,
user: null,
}; };
const createInput = { const createInput = {
@@ -63,6 +66,10 @@ describe('EmployeesService', () => {
providers: [ providers: [
EmployeesService, EmployeesService,
{ provide: EmployeesRepository, useValue: repository }, { provide: EmployeesRepository, useValue: repository },
{
provide: UsersService,
useValue: { findById: jest.fn().mockResolvedValue({ id: 'user-2' }) },
},
], ],
}).compile(); }).compile();
@@ -20,6 +20,7 @@ import {
parseCsvRecord, parseCsvRecord,
type EmployeePosition, type EmployeePosition,
} from './employee-fields'; } from './employee-fields';
import { UsersService } from '../../users/users.service';
import { EmployeesRepository } from './employees.repository'; import { EmployeesRepository } from './employees.repository';
export type ListEmployeesQuery = { export type ListEmployeesQuery = {
@@ -28,6 +29,7 @@ export type ListEmployeesQuery = {
readonly phone?: string; readonly phone?: string;
readonly position?: string; readonly position?: string;
readonly status?: string; readonly status?: string;
readonly userId?: string;
readonly search?: string; readonly search?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
@@ -45,13 +47,17 @@ const VISIBLE_FIELDS = [
'updatedAt', 'updatedAt',
'createdBy', 'createdBy',
'updatedBy', 'updatedBy',
'user',
] as const; ] as const;
const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'position'] as const; const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'position'] as const;
@Injectable() @Injectable()
export class EmployeesService { export class EmployeesService {
constructor(private readonly employeesRepository: EmployeesRepository) {} constructor(
private readonly employeesRepository: EmployeesRepository,
private readonly usersService: UsersService,
) {}
async list( async list(
query: ListEmployeesQuery, query: ListEmployeesQuery,
@@ -63,6 +69,7 @@ export class EmployeesService {
phone: query.phone, phone: query.phone,
position: query.position, position: query.position,
status: query.status, status: query.status,
userId: query.userId,
search: query.search, search: query.search,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
@@ -100,9 +107,10 @@ export class EmployeesService {
position: string; position: string;
status?: string; status?: string;
userId: string; userId: string;
assignedUserId?: string | null;
}): Promise<ReturnType<EmployeesService['toListItem']>> { }): Promise<ReturnType<EmployeesService['toListItem']>> {
const created = await this.employeesRepository.create( const created = await this.employeesRepository.create(
this.toCreateInput(input), await this.toCreateInput(input),
); );
return this.toListItem(created); return this.toListItem(created);
} }
@@ -116,6 +124,7 @@ export class EmployeesService {
position?: string; position?: string;
status?: unknown; status?: unknown;
userId: string; userId: string;
assignedUserId?: string | null;
}, },
): Promise<ReturnType<EmployeesService['toListItem']>> { ): Promise<ReturnType<EmployeesService['toListItem']>> {
if (input.status !== undefined) { if (input.status !== undefined) {
@@ -131,6 +140,7 @@ export class EmployeesService {
? this.assertPosition(input.position) ? this.assertPosition(input.position)
: undefined, : undefined,
userId: input.userId, userId: input.userId,
assignedUserId: await this.assertAssignedUserId(input.assignedUserId),
}; };
const updated = await this.employeesRepository.update(id, payload); const updated = await this.employeesRepository.update(id, payload);
return this.toListItem(updated); return this.toListItem(updated);
@@ -201,14 +211,16 @@ export class EmployeesService {
const rowNum = filled[i].lineNo; const rowNum = filled[i].lineNo;
try { try {
const statusRaw = idx('status') >= 0 ? cols[idx('status')] : ''; const statusRaw = idx('status') >= 0 ? cols[idx('status')] : '';
const assignedRaw = idx('userid') >= 0 ? cols[idx('userid')] : '';
rows.push( rows.push(
this.toCreateInput({ await this.toCreateInput({
code: cols[idx('code')] ?? '', code: cols[idx('code')] ?? '',
name: cols[idx('name')] ?? '', name: cols[idx('name')] ?? '',
phone: cols[idx('phone')] ?? '', phone: cols[idx('phone')] ?? '',
position: cols[idx('position')] ?? '', position: cols[idx('position')] ?? '',
status: statusRaw || undefined, status: statusRaw || undefined,
userId, userId,
assignedUserId: assignedRaw || undefined,
}), }),
); );
} catch (error) { } catch (error) {
@@ -241,6 +253,7 @@ export class EmployeesService {
updatedAt: employee.updatedAt.value, updatedAt: employee.updatedAt.value,
createdBy: employee.createdBy, createdBy: employee.createdBy,
updatedBy: employee.updatedBy, updatedBy: employee.updatedBy,
user: employee.user,
}; };
} }
@@ -248,14 +261,15 @@ export class EmployeesService {
return VISIBLE_FIELDS; return VISIBLE_FIELDS;
} }
private toCreateInput(input: { private async toCreateInput(input: {
code: string; code: string;
name: string; name: string;
phone: string; phone: string;
position: string; position: string;
status?: string; status?: string;
userId: string; userId: string;
}): CreateEmployeeInput { assignedUserId?: string | null;
}): Promise<CreateEmployeeInput> {
return { return {
code: this.assertCode(input.code), code: this.assertCode(input.code),
name: this.assertName(input.name), name: this.assertName(input.name),
@@ -265,9 +279,26 @@ export class EmployeesService {
? Status.create(input.status) ? Status.create(input.status)
: Status.create(Status.DEFAULT), : Status.create(Status.DEFAULT),
userId: input.userId, 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 { private assertName(raw: string): string {
const name = raw.trim(); const name = raw.trim();
if (!isValidEmployeeName(name)) { 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 { 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 = { export type User = {
readonly id: string; readonly id: string;
@@ -6,11 +22,37 @@ export type User = {
readonly passwordHash: string; readonly passwordHash: string;
readonly privilegeId: string | null; readonly privilegeId: string | null;
readonly isSuperadmin: boolean; readonly isSuperadmin: boolean;
readonly status: Status;
readonly createdAt: DateTime; readonly createdAt: DateTime;
readonly updatedAt: 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 = { export type CreateUserInput = {
readonly username: string; readonly username: string;
readonly passwordHash: 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 { Module } from '@nestjs/common';
import { PrivilegesModule } from '../privileges/privileges.module'; 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 { UsersRepository } from './users.repository';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
@Module({ @Module({
imports: [PrivilegesModule], imports: [PrivilegesModule],
controllers: [UsersController], controllers: [UsersReadController, UsersWriteController],
providers: [UsersRepository, UsersService], providers: [UsersRepository, UsersService],
exports: [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 { Test, TestingModule } from '@nestjs/testing';
import { DRIZZLE } from '../../database/database.module'; import { DRIZZLE } from '../../database/database.module';
import { UsersRepository } from './users.repository'; import { UsersRepository } from './users.repository';
describe('UsersRepository', () => { describe('UsersRepository', () => {
let repository: 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 () => { beforeEach(async () => {
jest.clearAllMocks(); 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({ const moduleRef: TestingModule = await Test.createTestingModule({
providers: [UsersRepository, { provide: DRIZZLE, useValue: db }], providers: [UsersRepository, { provide: DRIZZLE, useValue: db }],
}).compile(); }).compile();
repository = moduleRef.get(UsersRepository); 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 () => { it('findById returns null when missing', async () => {
limit.mockResolvedValue([]); from.mockImplementationOnce(() => ({
where: () => ({
limit: () => Promise.resolve([]),
}),
}));
await expect(repository.findById('missing')).resolves.toBeNull(); await expect(repository.findById('missing')).resolves.toBeNull();
}); });
it('create inserts lowercase username', async () => { it('create inserts lowercase username and maps unique violations', async () => {
returning.mockResolvedValue([ returning.mockRejectedValueOnce({ code: '23505' });
{ await expect(
id: 'user-1', repository.create({ username: 'Alice', passwordHash: 'hash' }),
username: 'alice', ).rejects.toBeInstanceOf(ConflictException);
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();
expect(values).toHaveBeenCalledWith( expect(values).toHaveBeenCalledWith(
expect.objectContaining({ username: 'alice', passwordHash: 'hash' }), expect.objectContaining({ username: 'alice', passwordHash: 'hash' }),
); );
}); });
it('findByUsername maps a row', async () => { it('delete maps foreign-key violations to ConflictException', async () => {
limit.mockResolvedValue([ returning.mockRejectedValueOnce({ code: '23503' });
{ await expect(repository.delete('user-1')).rejects.toBeInstanceOf(
id: 'user-1', ConflictException,
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('create maps unique violations to ConflictException', async () => { it('delete throws when missing', async () => {
returning.mockRejectedValue({ code: '23505' }); returning.mockResolvedValueOnce([]);
await expect( await expect(repository.delete('missing')).rejects.toBeInstanceOf(
repository.create({ username: 'alice', passwordHash: 'hash' }), NotFoundException,
).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');
}); });
}); });
+353 -23
View File
@@ -4,84 +4,414 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { eq } from 'drizzle-orm'; import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { users, type UserRow } from '../../database/schema'; import { alias } from 'drizzle-orm/pg-core';
import { randomUUID } from 'node:crypto';
import { DateTime } from '../../common/value-objects/date-time/date-time'; 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 { 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() @Injectable()
export class UsersRepository { export class UsersRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} 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> { async findById(id: string): Promise<User | null> {
const [row] = await this.db const rows = await this.db
.select() .select()
.from(users) .from(users)
.where(eq(users.id, id)) .where(eq(users.id, id))
.limit(1); .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> { async findByUsername(username: string): Promise<User | null> {
const normalized = username.toLowerCase(); const normalized = username.toLowerCase();
const [row] = await this.db const rows = await this.db
.select() .select()
.from(users) .from(users)
.where(eq(users.username, normalized)) .where(eq(users.username, normalized))
.limit(1); .limit(1);
return row ? this.toDomain(row) : null; const row = rows[0];
return row ? this.hydrate(row) : null;
} }
async create(input: CreateUserInput): Promise<User> { async create(input: CreateUserInput): Promise<User> {
const now = DateTime.fromUnixMs(Date.now()); const now = DateTime.fromUnixMs(Date.now());
const id = randomUUID();
const actorId = input.actorUserId ?? id;
const status = input.status ?? Status.create(Status.DEFAULT);
try { try {
const [row] = await this.db const inserted = await this.db
.insert(users) .insert(users)
.values({ .values({
id,
username: input.username.toLowerCase(), username: input.username.toLowerCase(),
passwordHash: input.passwordHash, passwordHash: input.passwordHash,
privilegeId: input.privilegeId ?? null,
status: status.value,
createdAt: now.value, createdAt: now.value,
updatedAt: now.value, updatedAt: now.value,
createdBy: actorId,
updatedBy: actorId,
}) })
.returning(); .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) { } catch (error) {
if ((error as { code?: string }).code === '23505') { this.rethrowConstraintViolation(error);
throw new ConflictException('Username already registered');
} }
throw 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');
}
return this.requireById(row.id);
} catch (error) {
this.rethrowConstraintViolation(error);
} }
} }
async updatePrivilegeId( async updatePrivilegeId(
userId: string, userId: string,
privilegeId: string | null, privilegeId: string | null,
actorUserId?: string,
): Promise<User> { ): Promise<User> {
const now = DateTime.fromUnixMs(Date.now()); const now = DateTime.fromUnixMs(Date.now());
const [row] = await this.db const updated = await this.db
.update(users) .update(users)
.set({ .set({
privilegeId, privilegeId,
updatedAt: now.value, updatedAt: now.value,
...(actorUserId ? { updatedBy: actorUserId } : {}),
}) })
.where(eq(users.id, userId)) .where(eq(users.id, userId))
.returning(); .returning({ id: users.id });
const row = updated[0];
if (!row) { if (!row) {
throw new NotFoundException('User not found'); 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 { return {
id: row.id, id: user.id,
username: row.username, username: user.username,
passwordHash: row.passwordHash, passwordHash: user.passwordHash,
privilegeId: row.privilegeId ?? null, privilegeId: user.privilegeId ?? null,
isSuperadmin: row.isSuperadmin, isSuperadmin: user.isSuperadmin,
createdAt: DateTime.fromUnixMs(row.createdAt), status: Status.create(user.status),
updatedAt: DateTime.fromUnixMs(row.updatedAt), 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 { Test, TestingModule } from '@nestjs/testing';
import { DateTime } from '../../common/value-objects/date-time/date-time'; 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 { PrivilegesService } from '../privileges/privileges.service';
import type { User } from './user'; import type { User } from './user';
import { UsersRepository } from './users.repository'; import { UsersRepository } from './users.repository';
@@ -11,7 +17,16 @@ describe('UsersService', () => {
let repository: jest.Mocked< let repository: jest.Mocked<
Pick< Pick<
UsersRepository, UsersRepository,
'findById' | 'findByUsername' | 'create' | 'updatePrivilegeId' | 'findById'
| 'findByUsername'
| 'create'
| 'update'
| 'updatePrivilegeId'
| 'updateStatus'
| 'bulkUpdateStatus'
| 'delete'
| 'bulkDelete'
| 'list'
> >
>; >;
let privilegesService: jest.Mocked< let privilegesService: jest.Mocked<
@@ -25,8 +40,15 @@ describe('UsersService', () => {
passwordHash: 'hashed', passwordHash: 'hashed',
privilegeId: null, privilegeId: null,
isSuperadmin: false, isSuperadmin: false,
status: Status.create('draft'),
createdAt: now, createdAt: now,
updatedAt: 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 () => { beforeEach(async () => {
@@ -34,7 +56,13 @@ describe('UsersService', () => {
findById: jest.fn(), findById: jest.fn(),
findByUsername: jest.fn(), findByUsername: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(),
updatePrivilegeId: jest.fn(), updatePrivilegeId: jest.fn(),
updateStatus: jest.fn(),
bulkUpdateStatus: jest.fn(),
delete: jest.fn(),
bulkDelete: jest.fn(),
list: jest.fn(),
}; };
privilegesService = { privilegesService = {
findPrivilegeSummary: jest.fn(), findPrivilegeSummary: jest.fn(),
@@ -45,6 +73,10 @@ describe('UsersService', () => {
UsersService, UsersService,
{ provide: UsersRepository, useValue: repository }, { provide: UsersRepository, useValue: repository },
{ provide: PrivilegesService, useValue: privilegesService }, { provide: PrivilegesService, useValue: privilegesService },
{
provide: ConfigService,
useValue: { getOrThrow: () => 4 },
},
], ],
}).compile(); }).compile();
@@ -94,9 +126,69 @@ describe('UsersService', () => {
repository.updatePrivilegeId.mockResolvedValue({ repository.updatePrivilegeId.mockResolvedValue({
...sampleUser, ...sampleUser,
privilegeId: 'priv-1', privilegeId: 'priv-1',
privilege: { id: 'priv-1', code: 'ADMIN', name: 'Admin' },
}); });
const result = await service.assignPrivilege('user-1', 'priv-1'); 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,
);
}); });
}); });
+311 -4
View File
@@ -3,22 +3,93 @@ import {
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
UnauthorizedException,
} from '@nestjs/common'; } 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 { 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 { 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() @Injectable()
export class UsersService { export class UsersService {
constructor( constructor(
private readonly usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
private readonly privilegesService: PrivilegesService, 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> { async findById(id: string): Promise<User | null> {
return this.usersRepository.findById(id); 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> { async findByUsername(username: string): Promise<User | null> {
return this.usersRepository.findByUsername(username); return this.usersRepository.findByUsername(username);
} }
@@ -35,15 +106,247 @@ 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( async assignPrivilege(
userId: string, userId: string,
privilegeId: string | null, privilegeId: string | null,
): Promise<User> { actorUserId?: string,
): Promise<ReturnType<UsersService['toListItem']>> {
const user = await this.usersRepository.findById(userId); const user = await this.usersRepository.findById(userId);
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
} }
if (privilegeId !== null) { 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}`);
}
}
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 = const privilege =
await this.privilegesService.findPrivilegeSummary(privilegeId); await this.privilegesService.findPrivilegeSummary(privilegeId);
if (!privilege) { if (!privilege) {
@@ -52,7 +355,11 @@ export class UsersService {
if (privilege.status !== 'active') { if (privilege.status !== 'active') {
throw new BadRequestException('Privilege must be active'); throw new BadRequestException('Privilege must be active');
} }
return privilegeId;
} }
return this.usersRepository.updatePrivilegeId(userId, privilegeId);
private async hashPassword(password: string): Promise<string> {
const saltRounds = this.config.getOrThrow<number>('BCRYPT_SALT_ROUNDS');
return bcrypt.hash(password, saltRounds);
} }
} }
+25 -14
View File
@@ -4,9 +4,12 @@ import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app.module';
import { configureApp } from '../src/common/configure-app'; import { configureApp } from '../src/common/configure-app';
import { DRIZZLE, type DrizzleDB } from '../src/database/database.module';
import { registerAndActivate } from './helpers/activate-user';
describe('Auth (e2e)', () => { describe('Auth (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
let db: DrizzleDB;
const username = `user_${Date.now()}`; const username = `user_${Date.now()}`;
const password = 'password123'; const password = 'password123';
@@ -22,6 +25,7 @@ describe('Auth (e2e)', () => {
SWAGGER_ENABLED: 'false', SWAGGER_ENABLED: 'false',
}); });
await app.init(); await app.init();
db = app.get(DRIZZLE);
}); });
afterAll(async () => { afterAll(async () => {
@@ -39,30 +43,37 @@ describe('Auth (e2e)', () => {
await request(app.getHttpServer()).get('/auth/me').expect(401); await request(app.getHttpServer()).get('/auth/me').expect(401);
}); });
it('register → me → refresh → revoke → me 401', async () => { it('register creates a draft user without tokens; login works after activate', async () => {
const register = await request(app.getHttpServer()) const register = await request(app.getHttpServer())
.post('/auth/register') .post('/auth/register')
.send({ username, password }) .send({ username, password })
.expect(201); .expect(201);
const { accessToken, refreshToken } = register.body as { expect(register.body).toMatchObject({
accessToken: string; username: username.toLowerCase(),
refreshToken: string; status: 'draft',
}; });
expect(accessToken).toBeDefined(); expect(register.body).not.toHaveProperty('accessToken');
expect(refreshToken).toHaveLength(64);
expect(Object.keys(register.body).sort()).toEqual([ await request(app.getHttpServer())
'accessToken', .post('/auth/login')
'refreshToken', .send({ username, password })
]); .expect(401);
const activated = await registerAndActivate(
app,
db,
`${username}_active`,
password,
);
const me = await request(app.getHttpServer()) const me = await request(app.getHttpServer())
.get('/auth/me') .get('/auth/me')
.set('Authorization', `Bearer ${accessToken}`) .set('Authorization', `Bearer ${activated.accessToken}`)
.expect(200); .expect(200);
expect(me.body).toMatchObject({ expect(me.body).toMatchObject({
username: username.toLowerCase(), username: `${username}_active`.toLowerCase(),
isSuperadmin: false, isSuperadmin: false,
privilege: null, privilege: null,
permissions: {}, permissions: {},
@@ -70,7 +81,7 @@ describe('Auth (e2e)', () => {
const refreshed = await request(app.getHttpServer()) const refreshed = await request(app.getHttpServer())
.post('/auth/refresh') .post('/auth/refresh')
.send({ refreshToken }) .send({ refreshToken: activated.refreshToken })
.expect(200); .expect(200);
const { accessToken: nextAccess, refreshToken: nextRefresh } = const { accessToken: nextAccess, refreshToken: nextRefresh } =
+6 -16
View File
@@ -13,6 +13,7 @@ import {
users, users,
} from '../src/database/schema'; } from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Branches (e2e)', () => { describe('Branches (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -53,23 +54,12 @@ describe('Branches (e2e)', () => {
await app.init(); await app.init();
db = app.get(DRIZZLE); db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer()) const admin = await registerAndActivate(app, db, adminUsername, password);
.post('/auth/register') adminAccessToken = admin.accessToken;
.send({ username: adminUsername, password }) adminUserId = admin.userId;
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer()) const other = await registerAndActivate(app, db, otherUsername, password);
.get('/auth/me') otherAccessToken = other.accessToken;
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now(); const now = Date.now();
const [priv] = await db const [priv] = await db
+6 -16
View File
@@ -13,6 +13,7 @@ import {
users, users,
} from '../src/database/schema'; } from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Company settings (e2e)', () => { describe('Company settings (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -39,23 +40,12 @@ describe('Company settings (e2e)', () => {
await app.init(); await app.init();
db = app.get(DRIZZLE); db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer()) const admin = await registerAndActivate(app, db, adminUsername, password);
.post('/auth/register') adminAccessToken = admin.accessToken;
.send({ username: adminUsername, password }) adminUserId = admin.userId;
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer()) const other = await registerAndActivate(app, db, otherUsername, password);
.get('/auth/me') otherAccessToken = other.accessToken;
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now(); const now = Date.now();
const [priv] = await db const [priv] = await db
+6 -16
View File
@@ -13,6 +13,7 @@ import {
users, users,
} from '../src/database/schema'; } from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Customers (e2e)', () => { describe('Customers (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -46,23 +47,12 @@ describe('Customers (e2e)', () => {
await app.init(); await app.init();
db = app.get(DRIZZLE); db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer()) const admin = await registerAndActivate(app, db, adminUsername, password);
.post('/auth/register') adminAccessToken = admin.accessToken;
.send({ username: adminUsername, password }) adminUserId = admin.userId;
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer()) const other = await registerAndActivate(app, db, otherUsername, password);
.get('/auth/me') otherAccessToken = other.accessToken;
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now(); const now = Date.now();
const [priv] = await db const [priv] = await db
+6 -16
View File
@@ -13,6 +13,7 @@ import {
users, users,
} from '../src/database/schema'; } from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Cycles (e2e)', () => { describe('Cycles (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -43,23 +44,12 @@ describe('Cycles (e2e)', () => {
await app.init(); await app.init();
db = app.get(DRIZZLE); db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer()) const admin = await registerAndActivate(app, db, adminUsername, password);
.post('/auth/register') adminAccessToken = admin.accessToken;
.send({ username: adminUsername, password }) adminUserId = admin.userId;
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer()) const other = await registerAndActivate(app, db, otherUsername, password);
.get('/auth/me') otherAccessToken = other.accessToken;
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now(); const now = Date.now();
const [priv] = await db const [priv] = await db
+6 -16
View File
@@ -13,6 +13,7 @@ import {
users, users,
} from '../src/database/schema'; } from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Divisions (e2e)', () => { describe('Divisions (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -39,23 +40,12 @@ describe('Divisions (e2e)', () => {
await app.init(); await app.init();
db = app.get(DRIZZLE); db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer()) const admin = await registerAndActivate(app, db, adminUsername, password);
.post('/auth/register') adminAccessToken = admin.accessToken;
.send({ username: adminUsername, password }) adminUserId = admin.userId;
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer()) const other = await registerAndActivate(app, db, otherUsername, password);
.get('/auth/me') otherAccessToken = other.accessToken;
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now(); const now = Date.now();
const [priv] = await db const [priv] = await db
+6 -16
View File
@@ -13,6 +13,7 @@ import {
users, users,
} from '../src/database/schema'; } from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Employees (e2e)', () => { describe('Employees (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -46,23 +47,12 @@ describe('Employees (e2e)', () => {
await app.init(); await app.init();
db = app.get(DRIZZLE); db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer()) const admin = await registerAndActivate(app, db, adminUsername, password);
.post('/auth/register') adminAccessToken = admin.accessToken;
.send({ username: adminUsername, password }) adminUserId = admin.userId;
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer()) const other = await registerAndActivate(app, db, otherUsername, password);
.get('/auth/me') otherAccessToken = other.accessToken;
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now(); const now = Date.now();
const [priv] = await db const [priv] = await db
+40
View File
@@ -0,0 +1,40 @@
import { INestApplication } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import request from 'supertest';
import { users } from '../../src/database/schema';
import type { DrizzleDB } from '../../src/database/database.module';
export async function registerAndActivate(
app: INestApplication,
db: DrizzleDB,
username: string,
password: string,
): Promise<{ accessToken: string; refreshToken: string; userId: string }> {
const register = await request(app.getHttpServer())
.post('/auth/register')
.send({ username, password });
if (register.status !== 201) {
throw new Error(
`register failed ${register.status} ${JSON.stringify(register.body)}`,
);
}
const userId = (register.body as { id: string }).id;
await db
.update(users)
.set({ status: 'active' })
.where(eq(users.id, userId));
const login = await request(app.getHttpServer())
.post('/auth/login')
.send({ username, password });
if (login.status !== 200) {
throw new Error(
`login failed ${login.status} ${JSON.stringify(login.body)}`,
);
}
const tokens = login.body as { accessToken: string; refreshToken: string };
return {
userId,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
}
+16 -15
View File
@@ -13,6 +13,7 @@ import {
users, users,
} from '../src/database/schema'; } from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Packing slips (e2e)', () => { describe('Packing slips (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -34,21 +35,21 @@ describe('Packing slips (e2e)', () => {
await app.init(); await app.init();
db = app.get(DRIZZLE); db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer()) const admin = await registerAndActivate(
.post('/auth/register') app,
.send({ username: `ps_admin_${suffix}`, password }) db,
.expect(201); `ps_admin_${suffix}`,
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; password,
const adminMe = await request(app.getHttpServer()) );
.get('/auth/me') adminAccessToken = admin.accessToken;
.set('Authorization', `Bearer ${adminAccessToken}`) const adminUserId = admin.userId;
.expect(200); const other = await registerAndActivate(
const adminUserId = (adminMe.body as { id: string }).id; app,
const otherReg = await request(app.getHttpServer()) db,
.post('/auth/register') `ps_other_${suffix}`,
.send({ username: `ps_other_${suffix}`, password }) password,
.expect(201); );
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken; otherAccessToken = other.accessToken;
const now = Date.now(); const now = Date.now();
const [priv] = await db const [priv] = await db
+6 -16
View File
@@ -13,6 +13,7 @@ import {
users, users,
} from '../src/database/schema'; } from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Plans (e2e)', () => { describe('Plans (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -45,23 +46,12 @@ describe('Plans (e2e)', () => {
await app.init(); await app.init();
db = app.get(DRIZZLE); db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer()) const admin = await registerAndActivate(app, db, adminUsername, password);
.post('/auth/register') adminAccessToken = admin.accessToken;
.send({ username: adminUsername, password }) adminUserId = admin.userId;
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer()) const other = await registerAndActivate(app, db, otherUsername, password);
.get('/auth/me') otherAccessToken = other.accessToken;
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now(); const now = Date.now();
const [priv] = await db const [priv] = await db
+6 -12
View File
@@ -13,6 +13,7 @@ import {
privileges, privileges,
users, users,
} from '../src/database/schema'; } from '../src/database/schema';
import { registerAndActivate } from './helpers/activate-user';
describe('Privileges (e2e)', () => { describe('Privileges (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -41,27 +42,20 @@ describe('Privileges (e2e)', () => {
await app.init(); await app.init();
db = app.get(DRIZZLE); db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer()) const admin = await registerAndActivate(app, db, adminUsername, password);
.post('/auth/register') adminAccessToken = admin.accessToken;
.send({ username: adminUsername, password }) adminUserId = admin.userId;
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer()) const adminMe = await request(app.getHttpServer())
.get('/auth/me') .get('/auth/me')
.set('Authorization', `Bearer ${adminAccessToken}`) .set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200); .expect(200);
adminUserId = (adminMe.body as { id: string }).id;
expect(adminMe.body).toMatchObject({ expect(adminMe.body).toMatchObject({
privilege: null, privilege: null,
permissions: {}, permissions: {},
}); });
const otherReg = await request(app.getHttpServer()) const other = await registerAndActivate(app, db, otherUsername, password);
.post('/auth/register') otherAccessToken = other.accessToken;
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const otherMe = await request(app.getHttpServer()) const otherMe = await request(app.getHttpServer())
.get('/auth/me') .get('/auth/me')
.set('Authorization', `Bearer ${otherAccessToken}`) .set('Authorization', `Bearer ${otherAccessToken}`)
+6 -16
View File
@@ -13,6 +13,7 @@ import {
users, users,
} from '../src/database/schema'; } from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Products (e2e)', () => { describe('Products (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -47,23 +48,12 @@ describe('Products (e2e)', () => {
await app.init(); await app.init();
db = app.get(DRIZZLE); db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer()) const admin = await registerAndActivate(app, db, adminUsername, password);
.post('/auth/register') adminAccessToken = admin.accessToken;
.send({ username: adminUsername, password }) adminUserId = admin.userId;
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer()) const other = await registerAndActivate(app, db, otherUsername, password);
.get('/auth/me') otherAccessToken = other.accessToken;
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now(); const now = Date.now();
const [priv] = await db const [priv] = await db
+16 -15
View File
@@ -13,6 +13,7 @@ import {
users, users,
} from '../src/database/schema'; } from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Sales invoices (e2e)', () => { describe('Sales invoices (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -37,21 +38,21 @@ describe('Sales invoices (e2e)', () => {
await app.init(); await app.init();
db = app.get(DRIZZLE); db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer()) const admin = await registerAndActivate(
.post('/auth/register') app,
.send({ username: `si_admin_${suffix}`, password }) db,
.expect(201); `si_admin_${suffix}`,
token = (adminReg.body as { accessToken: string }).accessToken; password,
const adminMe = await request(app.getHttpServer()) );
.get('/auth/me') token = admin.accessToken;
.set('Authorization', `Bearer ${token}`) const adminUserId = admin.userId;
.expect(200); const other = await registerAndActivate(
const adminUserId = (adminMe.body as { id: string }).id; app,
const otherReg = await request(app.getHttpServer()) db,
.post('/auth/register') `si_other_${suffix}`,
.send({ username: `si_other_${suffix}`, password }) password,
.expect(201); );
otherToken = (otherReg.body as { accessToken: string }).accessToken; otherToken = other.accessToken;
const now = Date.now(); const now = Date.now();
const [priv] = await db const [priv] = await db
+16 -15
View File
@@ -13,6 +13,7 @@ import {
users, users,
} from '../src/database/schema'; } from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Sales payments (e2e)', () => { describe('Sales payments (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -33,21 +34,21 @@ describe('Sales payments (e2e)', () => {
await app.init(); await app.init();
db = app.get(DRIZZLE); db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer()) const admin = await registerAndActivate(
.post('/auth/register') app,
.send({ username: `sp_admin_${suffix}`, password }) db,
.expect(201); `sp_admin_${suffix}`,
token = (adminReg.body as { accessToken: string }).accessToken; password,
const adminMe = await request(app.getHttpServer()) );
.get('/auth/me') token = admin.accessToken;
.set('Authorization', `Bearer ${token}`) const adminUserId = admin.userId;
.expect(200); const other = await registerAndActivate(
const adminUserId = (adminMe.body as { id: string }).id; app,
const otherReg = await request(app.getHttpServer()) db,
.post('/auth/register') `sp_other_${suffix}`,
.send({ username: `sp_other_${suffix}`, password }) password,
.expect(201); );
otherToken = (otherReg.body as { accessToken: string }).accessToken; otherToken = other.accessToken;
const now = Date.now(); const now = Date.now();
const [priv] = await db const [priv] = await db
+158
View File
@@ -0,0 +1,158 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { eq } from 'drizzle-orm';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/common/configure-app';
import { DRIZZLE, type DrizzleDB } from '../src/database/database.module';
import {
privilegeDetails,
privilegeKeys,
privileges,
users,
} from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
describe('Users (e2e)', () => {
let app: INestApplication<App>;
let db: DrizzleDB;
const password = 'password123';
const adminUsername = `usr_admin_${Date.now()}`;
const otherUsername = `usr_other_${Date.now()}`;
let adminAccessToken: string;
let adminUserId: string;
let otherAccessToken: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
configureApp(app, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
});
await app.init();
db = app.get(DRIZZLE);
const admin = await registerAndActivate(app, db, adminUsername, password);
adminAccessToken = admin.accessToken;
adminUserId = admin.userId;
const other = await registerAndActivate(app, db, otherUsername, password);
otherAccessToken = other.accessToken;
const now = Date.now();
const [priv] = await db
.insert(privileges)
.values({
name: 'User Admin',
code: `USR_ADMIN_${now}`,
status: 'active',
createdAt: now,
updatedAt: now,
createdBy: adminUserId,
updatedBy: adminUserId,
})
.returning();
const keys = await db.select().from(privilegeKeys);
const detailRows = keys.flatMap((key) =>
PRIVILEGE_ACTIONS.map((action) => ({
privilegeId: priv.id,
privilegeKeyId: key.id,
action,
value: true,
})),
);
await db.insert(privilegeDetails).values(detailRows);
await db
.update(users)
.set({ privilegeId: priv.id, updatedAt: Date.now() })
.where(eq(users.id, adminUserId));
});
afterAll(async () => {
await app.close();
});
it('forbids users list without permission', async () => {
await request(app.getHttpServer())
.get('/users')
.set('Authorization', `Bearer ${otherAccessToken}`)
.expect(403);
});
it('rejects unauthenticated access', async () => {
await request(app.getHttpServer()).get('/users').expect(401);
});
it('CRUD users with status, privilege, search, bulk, and import', async () => {
const created = await request(app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
username: `usr_${Date.now().toString().slice(-6)}`,
password: 'password123',
})
.expect(201);
expect(created.body).toMatchObject({
status: 'draft',
privilege: null,
employee: null,
});
expect(created.body).not.toHaveProperty('passwordHash');
const id = (created.body as { id: string }).id;
await request(app.getHttpServer())
.get(`/users/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
const listed = await request(app.getHttpServer())
.get('/users')
.query({ search: 'usr_' })
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
expect(listed.body.data.length).toBeGreaterThan(0);
expect(listed.body.meta).toBeDefined();
await request(app.getHttpServer())
.patch(`/users/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ status: 'active' })
.expect(400);
await request(app.getHttpServer())
.patch(`/users/${id}/status`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ status: 'active' })
.expect(200);
await request(app.getHttpServer())
.post('/users/bulk-status')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ ids: [id], status: 'archived' })
.expect(200);
const csv = `username,password\nusr_imp_${Date.now().toString().slice(-5)},password123\n`;
await request(app.getHttpServer())
.post('/users/import')
.set('Authorization', `Bearer ${adminAccessToken}`)
.attach('file', Buffer.from(csv, 'utf8'), 'users.csv')
.expect(200);
await request(app.getHttpServer())
.post('/users/bulk-delete')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ ids: [id] })
.expect(200);
});
});