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:
@@ -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");
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -114,14 +117,14 @@ describe('EmployeesRepository', () => {
|
|||||||
.mockImplementationOnce(() => ({
|
.mockImplementationOnce(() => ({
|
||||||
from: () => ({
|
from: () => ({
|
||||||
$dynamic: () => ({
|
$dynamic: () => ({
|
||||||
where: () => ({
|
where: () => ({
|
||||||
orderBy: () => ({
|
orderBy: () => ({
|
||||||
limit: () => ({
|
limit: () => ({
|
||||||
offset: () => Promise.resolve([row]),
|
offset: () => Promise.resolve([row]),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-nu
|
|||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { 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)) {
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayNotEmpty,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
import {
|
||||||
|
DefaultRelationDto,
|
||||||
|
PaginationQueryDto,
|
||||||
|
UserRelationDto,
|
||||||
|
} from '../../../common/http/response';
|
||||||
|
import { CORE_STATUSES } from '../../../common/value-objects/status/status';
|
||||||
|
import {
|
||||||
|
PASSWORD_MAX_LENGTH,
|
||||||
|
PASSWORD_MIN_LENGTH,
|
||||||
|
USERNAME_MAX_LENGTH,
|
||||||
|
USERNAME_MIN_LENGTH,
|
||||||
|
USERNAME_PATTERN,
|
||||||
|
} from '../user-fields';
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@ApiProperty({ example: 'alice', minLength: USERNAME_MIN_LENGTH })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(USERNAME_MIN_LENGTH)
|
||||||
|
@MaxLength(USERNAME_MAX_LENGTH)
|
||||||
|
@Matches(USERNAME_PATTERN, {
|
||||||
|
message: 'username must contain only letters, numbers, and underscores',
|
||||||
|
})
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'password123',
|
||||||
|
format: 'password',
|
||||||
|
writeOnly: true,
|
||||||
|
minLength: PASSWORD_MIN_LENGTH,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(PASSWORD_MIN_LENGTH)
|
||||||
|
@MaxLength(PASSWORD_MAX_LENGTH)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
privilegeId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateUserDto {
|
||||||
|
@ApiPropertyOptional({ example: 'alice' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(USERNAME_MIN_LENGTH)
|
||||||
|
@MaxLength(USERNAME_MAX_LENGTH)
|
||||||
|
@Matches(USERNAME_PATTERN, {
|
||||||
|
message: 'username must contain only letters, numbers, and underscores',
|
||||||
|
})
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'password123',
|
||||||
|
format: 'password',
|
||||||
|
writeOnly: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(PASSWORD_MIN_LENGTH)
|
||||||
|
@MaxLength(PASSWORD_MAX_LENGTH)
|
||||||
|
password?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', nullable: true })
|
||||||
|
@ValidateIf((_, value) => value !== undefined)
|
||||||
|
@IsUUID('4')
|
||||||
|
privilegeId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateUserStatusDto {
|
||||||
|
@ApiProperty({ enum: CORE_STATUSES })
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BulkIdsDto {
|
||||||
|
@ApiProperty({ type: [String], format: 'uuid' })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
ids!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BulkStatusDto {
|
||||||
|
@ApiProperty({ type: [String], format: 'uuid' })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
ids!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CORE_STATUSES })
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListUsersQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
privilegeId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Case-insensitive match on username',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
isSuperadmin!: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: DefaultRelationDto, nullable: true })
|
||||||
|
privilege!: DefaultRelationDto | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: DefaultRelationDto, nullable: true })
|
||||||
|
employee!: DefaultRelationDto | null;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CORE_STATUSES })
|
||||||
|
status!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Unix ms' })
|
||||||
|
createdAt!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Unix ms' })
|
||||||
|
updatedAt!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: UserRelationDto })
|
||||||
|
createdBy!: UserRelationDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: UserRelationDto })
|
||||||
|
updatedBy!: UserRelationDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegisterUserResponseDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CORE_STATUSES })
|
||||||
|
status!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
isAllowedCsvUpload,
|
||||||
|
isValidPassword,
|
||||||
|
isValidUsername,
|
||||||
|
parseCsvRecord,
|
||||||
|
} from './user-fields';
|
||||||
|
|
||||||
|
describe('user-fields', () => {
|
||||||
|
it('accepts valid usernames and passwords', () => {
|
||||||
|
expect(isValidUsername('alice')).toBe(true);
|
||||||
|
expect(isValidUsername('a_1')).toBe(true);
|
||||||
|
expect(isValidPassword('password123')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid usernames and passwords', () => {
|
||||||
|
expect(isValidUsername('ab')).toBe(false);
|
||||||
|
expect(isValidUsername('alice bob')).toBe(false);
|
||||||
|
expect(isValidPassword('short')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses CSV records with quoted commas', () => {
|
||||||
|
expect(parseCsvRecord('a,"b,c",d')).toEqual(['a', 'b,c', 'd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows csv uploads by mimetype or extension', () => {
|
||||||
|
expect(
|
||||||
|
isAllowedCsvUpload({ mimetype: 'text/csv', originalname: 'users.csv' }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isAllowedCsvUpload({
|
||||||
|
mimetype: 'application/octet-stream',
|
||||||
|
originalname: 'users.CSV',
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isAllowedCsvUpload({ mimetype: 'text/plain', originalname: 'users.txt' }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export const USERNAME_MIN_LENGTH = 3;
|
||||||
|
export const USERNAME_MAX_LENGTH = 32;
|
||||||
|
export const USERNAME_PATTERN = /^[a-zA-Z0-9_]+$/;
|
||||||
|
|
||||||
|
export const PASSWORD_MIN_LENGTH = 8;
|
||||||
|
export const PASSWORD_MAX_LENGTH = 72;
|
||||||
|
|
||||||
|
export function isValidUsername(raw: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof raw === 'string' &&
|
||||||
|
raw.length >= USERNAME_MIN_LENGTH &&
|
||||||
|
raw.length <= USERNAME_MAX_LENGTH &&
|
||||||
|
USERNAME_PATTERN.test(raw)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidPassword(raw: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof raw === 'string' &&
|
||||||
|
raw.length >= PASSWORD_MIN_LENGTH &&
|
||||||
|
raw.length <= PASSWORD_MAX_LENGTH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 4180-style record split that preserves commas inside quotes. */
|
||||||
|
export function parseCsvRecord(line: string): string[] {
|
||||||
|
const cells: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (line[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (ch === ',') {
|
||||||
|
cells.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push(current.trim());
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllowedCsvUpload(file: {
|
||||||
|
mimetype: string;
|
||||||
|
originalname: string;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
file.mimetype.includes('csv') ||
|
||||||
|
file.originalname.toLowerCase().endsWith('.csv')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,20 @@
|
|||||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
import { 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiForbiddenResponse,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiNotFoundResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { RequirePrivilege } from '../../common/decorators/require-privilege.decorator';
|
||||||
|
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
||||||
|
import { AssignPrivilegeDto } from './dto/assign-privilege.dto';
|
||||||
|
import {
|
||||||
|
BulkIdsDto,
|
||||||
|
BulkStatusDto,
|
||||||
|
CreateUserDto,
|
||||||
|
UpdateUserDto,
|
||||||
|
UpdateUserStatusDto,
|
||||||
|
UserDto,
|
||||||
|
} from './dto/user.dto';
|
||||||
|
import { isAllowedCsvUpload } from './user-fields';
|
||||||
|
import { USERS_PRIVILEGE_KEY } from './users-read.controller';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@ApiTags('users')
|
||||||
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
@Controller('users')
|
||||||
|
export class UsersWriteController {
|
||||||
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'import')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
limits: { fileSize: 1_048_576 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (!isAllowedCsvUpload(file)) {
|
||||||
|
cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file: { type: 'string', format: 'binary' },
|
||||||
|
},
|
||||||
|
required: ['file'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiOperation({ summary: 'Import users from CSV' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: { properties: { imported: { type: 'number' } } },
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
importCsv(
|
||||||
|
@UploadedFile() file: { buffer?: Buffer } | undefined,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<{ imported: number }> {
|
||||||
|
const csv = file?.buffer?.toString('utf8') ?? '';
|
||||||
|
return this.usersService.importCsv(csv, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'delete')
|
||||||
|
@ApiOperation({ summary: 'Bulk delete users' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: { properties: { deleted: { type: 'number' } } },
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||||
|
return this.usersService.bulkDelete(dto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-status')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'update')
|
||||||
|
@ApiOperation({ summary: 'Bulk update user status' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: { properties: { updated: { type: 'number' } } },
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
bulkStatus(
|
||||||
|
@Body() dto: BulkStatusDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<{ updated: number }> {
|
||||||
|
return this.usersService.bulkUpdateStatus(dto.ids, dto.status, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'create')
|
||||||
|
@ApiOperation({ summary: 'Create user' })
|
||||||
|
@ApiCreatedResponse({ type: UserDto })
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateUserDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<UserDto> {
|
||||||
|
return this.usersService.createManaged({
|
||||||
|
username: dto.username,
|
||||||
|
password: dto.password,
|
||||||
|
privilegeId: dto.privilegeId,
|
||||||
|
status: dto.status,
|
||||||
|
actorUserId: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'update')
|
||||||
|
@ApiOperation({ summary: 'Update user status' })
|
||||||
|
@ApiOkResponse({ type: UserDto })
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
updateStatus(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateUserStatusDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<UserDto> {
|
||||||
|
return this.usersService.updateStatus(id, dto.status, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/privilege')
|
||||||
|
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'update')
|
||||||
|
@ApiOperation({ summary: 'Assign or clear a user privilege' })
|
||||||
|
@ApiOkResponse({ type: UserDto })
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
assignPrivilege(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AssignPrivilegeDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<UserDto> {
|
||||||
|
return this.usersService.assignPrivilege(id, dto.privilegeId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'update')
|
||||||
|
@ApiOperation({ summary: 'Update user (not status)' })
|
||||||
|
@ApiOkResponse({ type: UserDto })
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateUserDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<UserDto> {
|
||||||
|
return this.usersService.update(id, {
|
||||||
|
username: dto.username,
|
||||||
|
password: dto.password,
|
||||||
|
privilegeId: dto.privilegeId,
|
||||||
|
actorUserId: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@RequirePrivilege(USERS_PRIVILEGE_KEY, 'delete')
|
||||||
|
@ApiOperation({ summary: 'Delete user' })
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||||
|
await this.usersService.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { Body, Controller, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiForbiddenResponse,
|
|
||||||
ApiNotFoundResponse,
|
|
||||||
ApiOkResponse,
|
|
||||||
ApiOperation,
|
|
||||||
ApiTags,
|
|
||||||
ApiUnauthorizedResponse,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { RequirePrivilege } from '../../common/decorators/require-privilege.decorator';
|
|
||||||
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
|
||||||
import {
|
|
||||||
AssignPrivilegeDto,
|
|
||||||
UserPrivilegeResponseDto,
|
|
||||||
} from './dto/assign-privilege.dto';
|
|
||||||
import { UsersService } from './users.service';
|
|
||||||
|
|
||||||
@ApiTags('users')
|
|
||||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
|
||||||
@Controller('users')
|
|
||||||
export class UsersController {
|
|
||||||
constructor(private readonly usersService: UsersService) {}
|
|
||||||
|
|
||||||
@Patch(':id/privilege')
|
|
||||||
@RequirePrivilege('USERS', 'update')
|
|
||||||
@ApiOperation({ summary: 'Assign or clear a user privilege' })
|
|
||||||
@ApiOkResponse({ type: UserPrivilegeResponseDto })
|
|
||||||
@ApiNotFoundResponse()
|
|
||||||
@ApiUnauthorizedResponse()
|
|
||||||
@ApiForbiddenResponse()
|
|
||||||
async assignPrivilege(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@Body() dto: AssignPrivilegeDto,
|
|
||||||
): Promise<UserPrivilegeResponseDto> {
|
|
||||||
const user = await this.usersService.assignPrivilege(id, dto.privilegeId);
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
privilegeId: user.privilegeId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { 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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, input: UpdateUserInput): Promise<User> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
try {
|
||||||
|
const updated = await this.db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
username: input.username ?? existing.username,
|
||||||
|
passwordHash: input.passwordHash ?? existing.passwordHash,
|
||||||
|
privilegeId:
|
||||||
|
input.privilegeId !== undefined
|
||||||
|
? input.privilegeId
|
||||||
|
: existing.privilegeId,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: input.actorUserId,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, id))
|
||||||
|
.returning({ id: users.id });
|
||||||
|
const row = updated[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
throw error;
|
return this.requireById(row.id);
|
||||||
|
} catch (error) {
|
||||||
|
this.rethrowConstraintViolation(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePrivilegeId(
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,24 +106,260 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createManaged(input: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
privilegeId?: string;
|
||||||
|
status?: string;
|
||||||
|
actorUserId: string;
|
||||||
|
}): Promise<ReturnType<UsersService['toListItem']>> {
|
||||||
|
const created = await this.usersRepository.create(
|
||||||
|
await this.toCreateInput(input),
|
||||||
|
);
|
||||||
|
return this.toListItem(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
privilegeId?: string | null;
|
||||||
|
status?: unknown;
|
||||||
|
actorUserId: string;
|
||||||
|
},
|
||||||
|
): Promise<ReturnType<UsersService['toListItem']>> {
|
||||||
|
if (input.status !== undefined) {
|
||||||
|
throw new BadRequestException('status cannot be updated via PATCH');
|
||||||
|
}
|
||||||
|
const payload: UpdateUserInput = {
|
||||||
|
username:
|
||||||
|
input.username !== undefined
|
||||||
|
? this.assertUsername(input.username)
|
||||||
|
: undefined,
|
||||||
|
passwordHash:
|
||||||
|
input.password !== undefined
|
||||||
|
? await this.hashPassword(this.assertPassword(input.password))
|
||||||
|
: undefined,
|
||||||
|
privilegeId:
|
||||||
|
input.privilegeId !== undefined
|
||||||
|
? await this.assertPrivilegeId(input.privilegeId)
|
||||||
|
: undefined,
|
||||||
|
actorUserId: input.actorUserId,
|
||||||
|
};
|
||||||
|
const updated = await this.usersRepository.update(id, payload);
|
||||||
|
return this.toListItem(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
statusRaw: string,
|
||||||
|
actorUserId: string,
|
||||||
|
): Promise<ReturnType<UsersService['toListItem']>> {
|
||||||
|
const status = Status.create(statusRaw);
|
||||||
|
const updated = await this.usersRepository.updateStatus(
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
actorUserId,
|
||||||
|
);
|
||||||
|
return this.toListItem(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkUpdateStatus(
|
||||||
|
ids: string[],
|
||||||
|
statusRaw: string,
|
||||||
|
actorUserId: string,
|
||||||
|
): Promise<{ updated: number }> {
|
||||||
|
const status = Status.create(statusRaw);
|
||||||
|
const updated = await this.usersRepository.bulkUpdateStatus(
|
||||||
|
ids,
|
||||||
|
status,
|
||||||
|
actorUserId,
|
||||||
|
);
|
||||||
|
return { updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.usersRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||||
|
const deleted = await this.usersRepository.bulkDelete(ids);
|
||||||
|
return { deleted };
|
||||||
|
}
|
||||||
|
|
||||||
async assignPrivilege(
|
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 privilege =
|
const updated = await this.usersRepository.updatePrivilegeId(
|
||||||
await this.privilegesService.findPrivilegeSummary(privilegeId);
|
userId,
|
||||||
if (!privilege) {
|
assigned,
|
||||||
throw new NotFoundException('Privilege not found');
|
actorUserId,
|
||||||
}
|
);
|
||||||
if (privilege.status !== 'active') {
|
return this.toListItem(updated);
|
||||||
throw new BadRequestException('Privilege must be active');
|
}
|
||||||
|
|
||||||
|
async importCsv(
|
||||||
|
csv: string,
|
||||||
|
actorUserId: string,
|
||||||
|
): Promise<{ imported: number }> {
|
||||||
|
const rawLines = csv.split(/\r?\n/);
|
||||||
|
const filled = rawLines
|
||||||
|
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
|
||||||
|
.filter((entry) => entry.line.length > 0);
|
||||||
|
if (filled.length === 0) {
|
||||||
|
throw new BadRequestException('CSV is empty');
|
||||||
|
}
|
||||||
|
if (filled.length > 501) {
|
||||||
|
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = parseCsvRecord(filled[0].line).map((h) =>
|
||||||
|
h.trim().toLowerCase(),
|
||||||
|
);
|
||||||
|
const missing = CSV_REQUIRED_HEADERS.filter((h) => header.indexOf(h) < 0);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new BadRequestException('CSV must include required headers');
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = (key: string) => header.indexOf(key);
|
||||||
|
const errors: string[] = [];
|
||||||
|
const rows: CreateUserInput[] = [];
|
||||||
|
for (let i = 1; i < filled.length; i++) {
|
||||||
|
const cols = parseCsvRecord(filled[i].line);
|
||||||
|
const rowNum = filled[i].lineNo;
|
||||||
|
try {
|
||||||
|
const privilegeRaw =
|
||||||
|
idx('privilegeid') >= 0 ? cols[idx('privilegeid')] : '';
|
||||||
|
const statusRaw = idx('status') >= 0 ? cols[idx('status')] : '';
|
||||||
|
rows.push(
|
||||||
|
await this.toCreateInput({
|
||||||
|
username: cols[idx('username')] ?? '',
|
||||||
|
password: cols[idx('password')] ?? '',
|
||||||
|
privilegeId: privilegeRaw || undefined,
|
||||||
|
status: statusRaw || undefined,
|
||||||
|
actorUserId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const reason =
|
||||||
|
error instanceof BadRequestException ? error.message : 'invalid data';
|
||||||
|
errors.push(`row ${rowNum}: ${reason}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.usersRepository.updatePrivilegeId(userId, privilegeId);
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'CSV validation failed',
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
await this.usersRepository.create(row);
|
||||||
|
}
|
||||||
|
return { imported: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCanAuthenticate(user: User): void {
|
||||||
|
if (user.status.value !== 'active') {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
if (user.employee && user.employee.status.value !== 'active') {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toListItem(user: User) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
isSuperadmin: user.isSuperadmin,
|
||||||
|
privilege: pickRelation(user.privilege, DEFAULT_RELATION_FIELDS),
|
||||||
|
employee: pickRelation(user.employee, DEFAULT_RELATION_FIELDS),
|
||||||
|
status: user.status.value,
|
||||||
|
createdAt: user.createdAt.value,
|
||||||
|
updatedAt: user.updatedAt.value,
|
||||||
|
createdBy: pickUserRelation(user.createdByUser),
|
||||||
|
updatedBy: pickUserRelation(user.updatedByUser),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get visibleFields(): readonly string[] {
|
||||||
|
return VISIBLE_FIELDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toCreateInput(input: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
privilegeId?: string;
|
||||||
|
status?: string;
|
||||||
|
actorUserId: string;
|
||||||
|
}): Promise<CreateUserInput> {
|
||||||
|
let status: Status | undefined;
|
||||||
|
if (input.status) {
|
||||||
|
try {
|
||||||
|
status = Status.create(input.status);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof InvalidStatusError) {
|
||||||
|
throw new BadRequestException('Invalid status');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
username: this.assertUsername(input.username),
|
||||||
|
passwordHash: await this.hashPassword(this.assertPassword(input.password)),
|
||||||
|
privilegeId:
|
||||||
|
input.privilegeId !== undefined && input.privilegeId !== ''
|
||||||
|
? await this.assertPrivilegeId(input.privilegeId)
|
||||||
|
: undefined,
|
||||||
|
status,
|
||||||
|
actorUserId: input.actorUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertUsername(raw: string): string {
|
||||||
|
const username = raw.trim();
|
||||||
|
if (!isValidUsername(username)) {
|
||||||
|
throw new BadRequestException('Invalid username');
|
||||||
|
}
|
||||||
|
return username.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertPassword(raw: string): string {
|
||||||
|
if (!isValidPassword(raw)) {
|
||||||
|
throw new BadRequestException('Invalid password');
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPrivilegeId(
|
||||||
|
privilegeId: string | null,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (privilegeId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const privilege =
|
||||||
|
await this.privilegesService.findPrivilegeSummary(privilegeId);
|
||||||
|
if (!privilege) {
|
||||||
|
throw new NotFoundException('Privilege not found');
|
||||||
|
}
|
||||||
|
if (privilege.status !== 'active') {
|
||||||
|
throw new BadRequestException('Privilege must be active');
|
||||||
|
}
|
||||||
|
return privilegeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hashPassword(password: string): Promise<string> {
|
||||||
|
const saltRounds = this.config.getOrThrow<number>('BCRYPT_SALT_ROUNDS');
|
||||||
|
return bcrypt.hash(password, saltRounds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-14
@@ -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 } =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user