From 8a61c94078553243794c5107994f5b01a1b1731e Mon Sep 17 00:00:00 2001 From: shancheas Date: Wed, 26 Aug 2026 15:29:18 +0700 Subject: [PATCH] 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. --- drizzle/migrations/0012_users_primary.sql | 21 + drizzle/migrations/meta/_journal.json | 7 + src/database/employees-table.ts | 6 +- src/database/schema.ts | 11 + src/modules/auth/auth.controller.spec.ts | 6 +- src/modules/auth/auth.controller.ts | 5 +- src/modules/auth/auth.module.ts | 5 +- src/modules/auth/auth.service.spec.ts | 33 +- src/modules/auth/auth.service.ts | 14 +- src/modules/auth/dto/auth.dto.ts | 11 + .../auth/strategies/jwt.strategy.spec.ts | 17 +- src/modules/auth/strategies/jwt.strategy.ts | 1 + .../employees/dto/employee.dto.ts | 24 +- .../configuration/employees/employee.ts | 5 + .../employees-write.controller.spec.ts | 1 + .../employees/employees-write.controller.ts | 2 + .../employees/employees.module.ts | 2 + .../employees/employees.repository.spec.ts | 13 +- .../employees/employees.repository.ts | 76 +++- .../employees/employees.service.spec.ts | 7 + .../employees/employees.service.ts | 41 +- src/modules/users/dto/user.dto.ts | 182 +++++++++ src/modules/users/user-fields.spec.ts | 39 ++ src/modules/users/user-fields.ts | 64 +++ src/modules/users/user.ts | 42 ++ .../users/users-read.controller.spec.ts | 36 ++ src/modules/users/users-read.controller.ts | 64 +++ .../users/users-write.controller.spec.ts | 94 +++++ src/modules/users/users-write.controller.ts | 198 +++++++++ src/modules/users/users.controller.ts | 43 -- src/modules/users/users.module.ts | 5 +- src/modules/users/users.repository.spec.ts | 174 ++++---- src/modules/users/users.repository.ts | 376 ++++++++++++++++-- src/modules/users/users.service.spec.ts | 98 ++++- src/modules/users/users.service.ts | 329 ++++++++++++++- test/auth.e2e-spec.ts | 39 +- test/branches.e2e-spec.ts | 22 +- test/company-settings.e2e-spec.ts | 22 +- test/customers.e2e-spec.ts | 22 +- test/cycles.e2e-spec.ts | 22 +- test/divisions.e2e-spec.ts | 22 +- test/employees.e2e-spec.ts | 22 +- test/helpers/activate-user.ts | 40 ++ test/packing-slips.e2e-spec.ts | 31 +- test/plans.e2e-spec.ts | 22 +- test/privileges.e2e-spec.ts | 18 +- test/products.e2e-spec.ts | 22 +- test/sales-invoices.e2e-spec.ts | 31 +- test/sales-payments.e2e-spec.ts | 31 +- test/users.e2e-spec.ts | 158 ++++++++ 50 files changed, 2175 insertions(+), 401 deletions(-) create mode 100644 drizzle/migrations/0012_users_primary.sql create mode 100644 src/modules/users/dto/user.dto.ts create mode 100644 src/modules/users/user-fields.spec.ts create mode 100644 src/modules/users/user-fields.ts create mode 100644 src/modules/users/users-read.controller.spec.ts create mode 100644 src/modules/users/users-read.controller.ts create mode 100644 src/modules/users/users-write.controller.spec.ts create mode 100644 src/modules/users/users-write.controller.ts delete mode 100644 src/modules/users/users.controller.ts create mode 100644 test/helpers/activate-user.ts create mode 100644 test/users.e2e-spec.ts diff --git a/drizzle/migrations/0012_users_primary.sql b/drizzle/migrations/0012_users_primary.sql new file mode 100644 index 0000000..57280aa --- /dev/null +++ b/drizzle/migrations/0012_users_primary.sql @@ -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"); diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 7f482b2..bf10e59 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1787559000000, "tag": "0011_field", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1787560000000, + "tag": "0012_users_primary", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/database/employees-table.ts b/src/database/employees-table.ts index 3f7daa8..515019f 100644 --- a/src/database/employees-table.ts +++ b/src/database/employees-table.ts @@ -14,9 +14,13 @@ export const employees = pgTable( name: varchar('name', { length: 64 }).notNull(), phone: text('phone').notNull(), position: text('position').notNull(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }), ...primaryEntityColumns(users), }, - (t) => [uniqueIndex('employees_code_unique').on(t.code)], + (t) => [ + uniqueIndex('employees_code_unique').on(t.code), + uniqueIndex('employees_user_id_unique').on(t.userId), + ], ); export type EmployeeRow = typeof employees.$inferSelect; diff --git a/src/database/schema.ts b/src/database/schema.ts index bec2507..15c2a51 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -8,13 +8,17 @@ import { uniqueIndex, uuid, varchar, + type AnyPgColumn, } from 'drizzle-orm/pg-core'; +import { Status } from '../common/value-objects/status/status'; import { primaryEntityColumns } from './primary-entity-columns'; /** * Application users. Timestamps are UTC unix milliseconds. * privilege_id is nullable until a role is assigned (deny-by-default). * FK to privileges.id is enforced in the migration (circular table dependency). + * Status / created_by / updated_by are declared here (not via primaryEntityColumns) + * because this table cannot pass itself the same way other tables pass `users`. */ export const users = pgTable( 'users', @@ -24,8 +28,15 @@ export const users = pgTable( passwordHash: text('password_hash').notNull(), privilegeId: uuid('privilege_id'), isSuperadmin: boolean('is_superadmin').notNull().default(false), + status: text('status').notNull().default(Status.DEFAULT), createdAt: bigint('created_at', { mode: 'number' }).notNull(), updatedAt: bigint('updated_at', { mode: 'number' }).notNull(), + createdBy: uuid('created_by') + .notNull() + .references((): AnyPgColumn => users.id), + updatedBy: uuid('updated_by') + .notNull() + .references((): AnyPgColumn => users.id), }, (t) => [ uniqueIndex('users_username_unique').on(t.username), diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts index 84e944a..60fac9c 100644 --- a/src/modules/auth/auth.controller.spec.ts +++ b/src/modules/auth/auth.controller.spec.ts @@ -17,8 +17,9 @@ describe('AuthController', () => { beforeEach(async () => { authService = { register: jest.fn().mockResolvedValue({ - accessToken: 'a', - refreshToken: 'b'.repeat(64), + id: 'user-1', + username: 'alice', + status: 'draft', }), login: jest.fn().mockResolvedValue({ accessToken: 'a', @@ -105,6 +106,7 @@ describe('AuthController', () => { id: 'priv-1', name: 'Admin', code: 'ADMIN', + status: 'active', }); privilegesService.getPermissionsMap.mockResolvedValue({ PRIVILEGES: { diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 0584a55..bbd0451 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -24,6 +24,7 @@ import { MeResponseDto, RefreshTokenDto, RegisterDto, + RegisterResponseDto, TokenPairDto, } from './dto/auth.dto'; @@ -40,11 +41,11 @@ export class AuthController { @Throttle({ default: { limit: 5, ttl: 60_000 } }) @Post('register') @ApiOperation({ summary: 'Register a new user' }) - @ApiCreatedResponse({ type: TokenPairDto }) + @ApiCreatedResponse({ type: RegisterResponseDto }) @ApiBadRequestResponse({ description: 'Validation failed' }) @ApiConflictResponse({ description: 'Username already registered' }) @ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' }) - register(@Body() dto: RegisterDto): Promise { + register(@Body() dto: RegisterDto): Promise { return this.authService.register(dto.username, dto.password); } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 844f5ea..8b93036 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -31,7 +31,10 @@ import { JwtStrategy } from './strategies/jwt.strategy'; }, }), }), - ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]), + ThrottlerModule.forRoot({ + skipIf: () => process.env.NODE_ENV === 'test', + throttlers: [{ ttl: 60_000, limit: 100 }], + }), ], controllers: [AuthController], providers: [ diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index 30b4cb1..ec0c909 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -5,6 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import * as bcrypt from 'bcrypt'; import { createHash } from 'node:crypto'; import { DateTime } from '../../common/value-objects/date-time/date-time'; +import { Status } from '../../common/value-objects/status/status'; import type { User } from '../users/user'; import { UsersService } from '../users/users.service'; import { AuthService } from './auth.service'; @@ -17,7 +18,10 @@ import { RevokedAccessTokensRepository } from './revoked-access-tokens.repositor describe('AuthService', () => { let service: AuthService; let usersService: jest.Mocked< - Pick + Pick< + UsersService, + 'create' | 'findByUsername' | 'findById' | 'assertCanAuthenticate' + > >; let jwtService: jest.Mocked>; let config: { getOrThrow: jest.Mock }; @@ -46,14 +50,22 @@ describe('AuthService', () => { passwordHash: await bcrypt.hash('password123', 4), privilegeId: null, isSuperadmin: false, + status: Status.create('active'), createdAt: now, updatedAt: now, + createdBy: 'user-1', + updatedBy: 'user-1', + privilege: null, + employee: null, + createdByUser: { id: 'user-1', username: 'alice' }, + updatedByUser: { id: 'user-1', username: 'alice' }, }; usersService = { create: jest.fn(), findByUsername: jest.fn(), findById: jest.fn(), + assertCanAuthenticate: jest.fn(), }; jwtService = { signAsync: jest.fn().mockResolvedValue('access.jwt.token'), @@ -108,17 +120,22 @@ describe('AuthService', () => { service = moduleRef.get(AuthService); }); - it('register creates user and returns token pair', async () => { + it('register creates a draft user and does not issue tokens', async () => { usersService.findByUsername.mockResolvedValue(null); - usersService.create.mockResolvedValue(user); + usersService.create.mockResolvedValue({ + ...user, + status: Status.create('draft'), + }); - const pair = await service.register('Alice', 'password123'); + const result = await service.register('Alice', 'password123'); expect(usersService.create).toHaveBeenCalled(); - expect(pair.accessToken).toBe('access.jwt.token'); - expect(pair.refreshToken).toHaveLength(64); - expect(Object.keys(pair).sort()).toEqual(['accessToken', 'refreshToken']); - expect(refreshTokensRepository.create).toHaveBeenCalled(); + expect(result).toEqual({ + id: 'user-1', + username: 'alice', + status: 'draft', + }); + expect(refreshTokensRepository.create).not.toHaveBeenCalled(); }); it('register throws ConflictException when username exists', async () => { diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index fd38b81..833959c 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -33,7 +33,10 @@ export class AuthService { private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository, ) {} - async register(username: string, password: string): Promise { + async register( + username: string, + password: string, + ): Promise<{ id: string; username: string; status: string }> { const existing = await this.usersService.findByUsername(username); if (existing) { throw new ConflictException('Username already registered'); @@ -41,8 +44,11 @@ export class AuthService { const saltRounds = this.config.getOrThrow('BCRYPT_SALT_ROUNDS'); const passwordHash = await bcrypt.hash(password, saltRounds); const user = await this.usersService.create(username, passwordHash); - const { tokens } = await this.issueTokenPair(user); - return tokens; + return { + id: user.id, + username: user.username, + status: user.status.value, + }; } async login(username: string, password: string): Promise { @@ -52,6 +58,7 @@ export class AuthService { if (!user || !match) { throw new UnauthorizedException('Invalid credentials'); } + this.usersService.assertCanAuthenticate(user); const { tokens } = await this.issueTokenPair(user); return tokens; } @@ -78,6 +85,7 @@ export class AuthService { if (!user) { throw new UnauthorizedException('Invalid refresh token'); } + this.usersService.assertCanAuthenticate(user); await this.denylistAccessJti(claimed.accessJti); const issued = await this.issueTokenPair(user); diff --git a/src/modules/auth/dto/auth.dto.ts b/src/modules/auth/dto/auth.dto.ts index 802afc7..242dfe7 100644 --- a/src/modules/auth/dto/auth.dto.ts +++ b/src/modules/auth/dto/auth.dto.ts @@ -54,6 +54,17 @@ export class RefreshTokenDto { refreshToken!: string; } +export class RegisterResponseDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'alice' }) + username!: string; + + @ApiProperty({ example: 'draft' }) + status!: string; +} + export class TokenPairDto implements TokenPair { @ApiProperty({ example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example', diff --git a/src/modules/auth/strategies/jwt.strategy.spec.ts b/src/modules/auth/strategies/jwt.strategy.spec.ts index ab2943e..2d4de6a 100644 --- a/src/modules/auth/strategies/jwt.strategy.spec.ts +++ b/src/modules/auth/strategies/jwt.strategy.spec.ts @@ -2,6 +2,7 @@ import { UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; +import { Status } from '../../../common/value-objects/status/status'; import type { User } from '../../users/user'; import { UsersService } from '../../users/users.service'; import { RevokedAccessTokensRepository } from '../revoked-access-tokens.repository'; @@ -9,7 +10,9 @@ import { JwtStrategy } from './jwt.strategy'; describe('JwtStrategy', () => { let strategy: JwtStrategy; - let usersService: jest.Mocked>; + let usersService: jest.Mocked< + Pick + >; let revoked: jest.Mocked>; const now = DateTime.fromUnixMs(1_700_000_000_000); @@ -19,12 +22,22 @@ describe('JwtStrategy', () => { passwordHash: 'hash', privilegeId: null, isSuperadmin: false, + status: Status.create('active'), createdAt: now, updatedAt: now, + createdBy: 'user-1', + updatedBy: 'user-1', + privilege: null, + employee: null, + createdByUser: { id: 'user-1', username: 'alice' }, + updatedByUser: { id: 'user-1', username: 'alice' }, }; beforeEach(async () => { - usersService = { findById: jest.fn() }; + usersService = { + findById: jest.fn(), + assertCanAuthenticate: jest.fn(), + }; revoked = { exists: jest.fn() }; const moduleRef: TestingModule = await Test.createTestingModule({ diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts index ae544f9..e2376b1 100644 --- a/src/modules/auth/strategies/jwt.strategy.ts +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -40,6 +40,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { if (!user) { throw new UnauthorizedException('User not found'); } + this.usersService.assertCanAuthenticate(user); return { id: user.id, diff --git a/src/modules/configuration/employees/dto/employee.dto.ts b/src/modules/configuration/employees/dto/employee.dto.ts index 341c201..2fcf0cd 100644 --- a/src/modules/configuration/employees/dto/employee.dto.ts +++ b/src/modules/configuration/employees/dto/employee.dto.ts @@ -9,8 +9,12 @@ import { IsUUID, Matches, MaxLength, + ValidateIf, } from 'class-validator'; -import { PaginationQueryDto } from '../../../../common/http/response'; +import { + PaginationQueryDto, + UserRelationDto, +} from '../../../../common/http/response'; import { CORE_STATUSES } from '../../../../common/value-objects/status/status'; import { EMPLOYEE_CODE_MAX_LENGTH, @@ -55,6 +59,11 @@ export class CreateEmployeeDto { @IsOptional() @IsIn([...CORE_STATUSES]) status?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID('4') + userId?: string; } export class UpdateEmployeeDto { @@ -88,6 +97,11 @@ export class UpdateEmployeeDto { @IsOptional() @IsIn([...EMPLOYEE_POSITIONS]) position?: string; + + @ApiPropertyOptional({ format: 'uuid', nullable: true }) + @ValidateIf((_, value) => value !== undefined) + @IsUUID('4') + userId?: string | null; } export class UpdateEmployeeStatusDto { @@ -142,6 +156,11 @@ export class ListEmployeesQueryDto extends PaginationQueryDto { @IsIn([...CORE_STATUSES]) status?: string; + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID('4') + userId?: string; + @ApiPropertyOptional({ description: 'Case-insensitive match on code or name', }) @@ -180,4 +199,7 @@ export class EmployeeDto { @ApiProperty({ format: 'uuid' }) updatedBy!: string; + + @ApiPropertyOptional({ type: UserRelationDto, nullable: true }) + user!: UserRelationDto | null; } diff --git a/src/modules/configuration/employees/employee.ts b/src/modules/configuration/employees/employee.ts index 0091583..946c910 100644 --- a/src/modules/configuration/employees/employee.ts +++ b/src/modules/configuration/employees/employee.ts @@ -14,6 +14,8 @@ export type Employee = { readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly userId: string | null; + readonly user: { readonly id: string; readonly username: string } | null; }; export type CreateEmployeeInput = { @@ -23,6 +25,7 @@ export type CreateEmployeeInput = { readonly position: EmployeePosition; readonly status?: Status; readonly userId: string; + readonly assignedUserId?: string | null; }; export type UpdateEmployeeInput = { @@ -31,6 +34,7 @@ export type UpdateEmployeeInput = { readonly phone?: PhoneNumber; readonly position?: EmployeePosition; readonly userId: string; + readonly assignedUserId?: string | null; }; export type ListEmployeesFilters = { @@ -39,6 +43,7 @@ export type ListEmployeesFilters = { readonly phone?: string; readonly position?: string; readonly status?: string; + readonly userId?: string; readonly search?: string; readonly limit: number; readonly offset: number; diff --git a/src/modules/configuration/employees/employees-write.controller.spec.ts b/src/modules/configuration/employees/employees-write.controller.spec.ts index 658360b..7afcbf1 100644 --- a/src/modules/configuration/employees/employees-write.controller.spec.ts +++ b/src/modules/configuration/employees/employees-write.controller.spec.ts @@ -37,6 +37,7 @@ describe('EmployeesWriteController', () => { ...createDto, status: undefined, userId: 'user-1', + assignedUserId: undefined, }); }); diff --git a/src/modules/configuration/employees/employees-write.controller.ts b/src/modules/configuration/employees/employees-write.controller.ts index c7039d8..de6b6cf 100644 --- a/src/modules/configuration/employees/employees-write.controller.ts +++ b/src/modules/configuration/employees/employees-write.controller.ts @@ -130,6 +130,7 @@ export class EmployeesWriteController { position: dto.position, status: dto.status, userId, + assignedUserId: dto.userId, }); } @@ -166,6 +167,7 @@ export class EmployeesWriteController { phone: dto.phone, position: dto.position, userId, + assignedUserId: dto.userId, }); } diff --git a/src/modules/configuration/employees/employees.module.ts b/src/modules/configuration/employees/employees.module.ts index ffee11b..1573983 100644 --- a/src/modules/configuration/employees/employees.module.ts +++ b/src/modules/configuration/employees/employees.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; +import { UsersModule } from '../../users/users.module'; import { EmployeesReadController } from './employees-read.controller'; import { EmployeesWriteController } from './employees-write.controller'; import { EmployeesRepository } from './employees.repository'; import { EmployeesService } from './employees.service'; @Module({ + imports: [UsersModule], controllers: [EmployeesReadController, EmployeesWriteController], providers: [EmployeesRepository, EmployeesService], exports: [EmployeesService], diff --git a/src/modules/configuration/employees/employees.repository.spec.ts b/src/modules/configuration/employees/employees.repository.spec.ts index 564d643..903860c 100644 --- a/src/modules/configuration/employees/employees.repository.spec.ts +++ b/src/modules/configuration/employees/employees.repository.spec.ts @@ -42,8 +42,11 @@ describe('EmployeesRepository', () => { updatedAt: 1_700_000_000_000, createdBy: 'user-1', updatedBy: 'user-1', + userId: null, }; + const joinedRow = { employee: row, user: null }; + const createInput = { code: 'EMP_01', name: 'Ada Lovelace', @@ -114,14 +117,14 @@ describe('EmployeesRepository', () => { .mockImplementationOnce(() => ({ from: () => ({ $dynamic: () => ({ - where: () => ({ - orderBy: () => ({ - limit: () => ({ - offset: () => Promise.resolve([row]), + where: () => ({ + orderBy: () => ({ + limit: () => ({ + offset: () => Promise.resolve([row]), + }), }), }), }), - }), }), })); diff --git a/src/modules/configuration/employees/employees.repository.ts b/src/modules/configuration/employees/employees.repository.ts index 385004f..99ccf88 100644 --- a/src/modules/configuration/employees/employees.repository.ts +++ b/src/modules/configuration/employees/employees.repository.ts @@ -10,6 +10,7 @@ import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-nu import { Status } from '../../../common/value-objects/status/status'; import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; import { employees, type EmployeeRow } from '../../../database/employees-table'; +import { users } from '../../../database/schema'; import type { EmployeePosition } from './employee-fields'; import type { CreateEmployeeInput, @@ -41,7 +42,11 @@ export class EmployeesRepository { .offset(filters.offset); return { - data: rows.map((row) => this.toDomain(row)), + data: await Promise.all( + rows.map(async (row) => + this.toDomain(row, await this.loadAssignedUser(row.userId)), + ), + ), total: Number(totalRow?.total ?? 0), }; } @@ -55,13 +60,13 @@ export class EmployeesRepository { } async findById(id: string): Promise { - const rows: EmployeeRow[] = await this.db + const rows = await this.db .select() .from(employees) .where(eq(employees.id, id)) .limit(1); const row = rows[0]; - return row ? this.toDomain(row) : null; + return row ? this.toDomain(row, await this.loadAssignedUser(row.userId)) : null; } async findByCode(code: string): Promise { @@ -71,7 +76,7 @@ export class EmployeesRepository { .where(eq(employees.code, code)) .limit(1); const row = rows[0]; - return row ? this.toDomain(row) : null; + return row ? this.toDomain(row, await this.loadAssignedUser(row.userId)) : null; } async create(input: CreateEmployeeInput): Promise { @@ -83,7 +88,7 @@ export class EmployeesRepository { .values(this.toInsertValues(input, status, now, input.userId)) .returning(); const row = inserted[0]; - return this.toDomain(row); + return this.toDomain(row, await this.loadAssignedUser(row.userId)); } catch (error) { this.rethrowUniqueViolation(error); } @@ -125,11 +130,14 @@ export class EmployeesRepository { position: input.position ?? existing.position, updatedAt: now.value, updatedBy: input.userId, + ...(input.assignedUserId !== undefined + ? { userId: input.assignedUserId } + : {}), }) .where(eq(employees.id, id)) .returning(); const row = updated[0]; - return this.toDomain(row); + return this.toDomain(row, await this.loadAssignedUser(row.userId)); } catch (error) { this.rethrowUniqueViolation(error); } @@ -154,7 +162,7 @@ export class EmployeesRepository { if (!row) { throw new NotFoundException('Employee not found'); } - return this.toDomain(row); + return this.toDomain(row, await this.loadAssignedUser(row.userId)); } async bulkUpdateStatus( @@ -216,6 +224,9 @@ export class EmployeesRepository { if (filters.status) { parts.push(eq(employees.status, filters.status)); } + if (filters.userId) { + parts.push(eq(employees.userId, filters.userId)); + } if (filters.search) { const search = or( ilike(employees.code, `%${filters.search}%`), @@ -247,10 +258,41 @@ export class EmployeesRepository { updatedAt: now.value, createdBy: userId, updatedBy: userId, + userId: input.assignedUserId ?? null, }; } - private toDomain(row: EmployeeRow): Employee { + private selectWithUser() { + return this.db + .select({ + employee: employees, + user: { + id: users.id, + username: users.username, + }, + }) + .from(employees) + .leftJoin(users, eq(employees.userId, users.id)); + } + + private async loadAssignedUser( + userId: string | null, + ): Promise<{ id: string; username: string } | null> { + if (!userId) { + return null; + } + const rows = await this.db + .select({ id: users.id, username: users.username }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + return rows[0] ?? null; + } + + private toDomain( + row: EmployeeRow, + user: { id: string; username: string } | null, + ): Employee { return { id: row.id, code: row.code, @@ -262,29 +304,39 @@ export class EmployeesRepository { updatedAt: DateTime.fromUnixMs(row.updatedAt), createdBy: row.createdBy, updatedBy: row.updatedBy, + userId: row.userId ?? null, + user: user?.id ? { id: user.id, username: user.username } : null, }; } private rethrowUniqueViolation(error: unknown): never { const err = this.unwrapDbError(error); if (err.code === '23505') { + const constraint = err.constraint ?? ''; + if (constraint.includes('user_id')) { + throw new ConflictException('User is already assigned to an employee'); + } throw new ConflictException('Employee code already exists'); } throw error; } - private unwrapDbError(error: unknown): { code?: string } { + private unwrapDbError(error: unknown): { code?: string; constraint?: string } { let current: unknown = error; for (let i = 0; i < 5; i++) { if (!current || typeof current !== 'object') { break; } - const obj = current as { code?: string; cause?: unknown }; + const obj = current as { + code?: string; + constraint?: string; + cause?: unknown; + }; if (obj.code === '23505' || obj.code === '23503') { - return { code: obj.code }; + return { code: obj.code, constraint: obj.constraint }; } current = obj.cause; } - return error as { code?: string }; + return error as { code?: string; constraint?: string }; } } diff --git a/src/modules/configuration/employees/employees.service.spec.ts b/src/modules/configuration/employees/employees.service.spec.ts index 8495cff..735df39 100644 --- a/src/modules/configuration/employees/employees.service.spec.ts +++ b/src/modules/configuration/employees/employees.service.spec.ts @@ -4,6 +4,7 @@ import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { Status } from '../../../common/value-objects/status/status'; import type { Employee } from './employee'; +import { UsersService } from '../../users/users.service'; import { EmployeesRepository } from './employees.repository'; import { EmployeesService } from './employees.service'; @@ -36,6 +37,8 @@ describe('EmployeesService', () => { updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', + userId: null, + user: null, }; const createInput = { @@ -63,6 +66,10 @@ describe('EmployeesService', () => { providers: [ EmployeesService, { provide: EmployeesRepository, useValue: repository }, + { + provide: UsersService, + useValue: { findById: jest.fn().mockResolvedValue({ id: 'user-2' }) }, + }, ], }).compile(); diff --git a/src/modules/configuration/employees/employees.service.ts b/src/modules/configuration/employees/employees.service.ts index 0069da9..c1a3990 100644 --- a/src/modules/configuration/employees/employees.service.ts +++ b/src/modules/configuration/employees/employees.service.ts @@ -20,6 +20,7 @@ import { parseCsvRecord, type EmployeePosition, } from './employee-fields'; +import { UsersService } from '../../users/users.service'; import { EmployeesRepository } from './employees.repository'; export type ListEmployeesQuery = { @@ -28,6 +29,7 @@ export type ListEmployeesQuery = { readonly phone?: string; readonly position?: string; readonly status?: string; + readonly userId?: string; readonly search?: string; readonly page?: number; readonly limit?: number; @@ -45,13 +47,17 @@ const VISIBLE_FIELDS = [ 'updatedAt', 'createdBy', 'updatedBy', + 'user', ] as const; const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'position'] as const; @Injectable() export class EmployeesService { - constructor(private readonly employeesRepository: EmployeesRepository) {} + constructor( + private readonly employeesRepository: EmployeesRepository, + private readonly usersService: UsersService, + ) {} async list( query: ListEmployeesQuery, @@ -63,6 +69,7 @@ export class EmployeesService { phone: query.phone, position: query.position, status: query.status, + userId: query.userId, search: query.search, limit: page.limit, offset: page.offset, @@ -100,9 +107,10 @@ export class EmployeesService { position: string; status?: string; userId: string; + assignedUserId?: string | null; }): Promise> { const created = await this.employeesRepository.create( - this.toCreateInput(input), + await this.toCreateInput(input), ); return this.toListItem(created); } @@ -116,6 +124,7 @@ export class EmployeesService { position?: string; status?: unknown; userId: string; + assignedUserId?: string | null; }, ): Promise> { if (input.status !== undefined) { @@ -131,6 +140,7 @@ export class EmployeesService { ? this.assertPosition(input.position) : undefined, userId: input.userId, + assignedUserId: await this.assertAssignedUserId(input.assignedUserId), }; const updated = await this.employeesRepository.update(id, payload); return this.toListItem(updated); @@ -201,14 +211,16 @@ export class EmployeesService { const rowNum = filled[i].lineNo; try { const statusRaw = idx('status') >= 0 ? cols[idx('status')] : ''; + const assignedRaw = idx('userid') >= 0 ? cols[idx('userid')] : ''; rows.push( - this.toCreateInput({ + await this.toCreateInput({ code: cols[idx('code')] ?? '', name: cols[idx('name')] ?? '', phone: cols[idx('phone')] ?? '', position: cols[idx('position')] ?? '', status: statusRaw || undefined, userId, + assignedUserId: assignedRaw || undefined, }), ); } catch (error) { @@ -241,6 +253,7 @@ export class EmployeesService { updatedAt: employee.updatedAt.value, createdBy: employee.createdBy, updatedBy: employee.updatedBy, + user: employee.user, }; } @@ -248,14 +261,15 @@ export class EmployeesService { return VISIBLE_FIELDS; } - private toCreateInput(input: { + private async toCreateInput(input: { code: string; name: string; phone: string; position: string; status?: string; userId: string; - }): CreateEmployeeInput { + assignedUserId?: string | null; + }): Promise { return { code: this.assertCode(input.code), name: this.assertName(input.name), @@ -265,9 +279,26 @@ export class EmployeesService { ? Status.create(input.status) : Status.create(Status.DEFAULT), userId: input.userId, + assignedUserId: await this.assertAssignedUserId(input.assignedUserId), }; } + private async assertAssignedUserId( + userId?: string | null, + ): Promise { + if (userId === undefined) { + return undefined; + } + if (userId === null || userId === '') { + return null; + } + const user = await this.usersService.findById(userId); + if (!user) { + throw new NotFoundException('User not found'); + } + return userId; + } + private assertName(raw: string): string { const name = raw.trim(); if (!isValidEmployeeName(name)) { diff --git a/src/modules/users/dto/user.dto.ts b/src/modules/users/dto/user.dto.ts new file mode 100644 index 0000000..845c5b0 --- /dev/null +++ b/src/modules/users/dto/user.dto.ts @@ -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; +} diff --git a/src/modules/users/user-fields.spec.ts b/src/modules/users/user-fields.spec.ts new file mode 100644 index 0000000..5a2dc17 --- /dev/null +++ b/src/modules/users/user-fields.spec.ts @@ -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); + }); +}); diff --git a/src/modules/users/user-fields.ts b/src/modules/users/user-fields.ts new file mode 100644 index 0000000..1e13da9 --- /dev/null +++ b/src/modules/users/user-fields.ts @@ -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') + ); +} diff --git a/src/modules/users/user.ts b/src/modules/users/user.ts index 85a8554..6e939ef 100644 --- a/src/modules/users/user.ts +++ b/src/modules/users/user.ts @@ -1,4 +1,20 @@ import { DateTime } from '../../common/value-objects/date-time/date-time'; +import { Status } from '../../common/value-objects/status/status'; + +export type UserRelationRef = { + readonly id: string; + readonly username: string; +}; + +export type CatalogRelationRef = { + readonly id: string; + readonly code: string; + readonly name: string; +}; + +export type EmployeeRelationRef = CatalogRelationRef & { + readonly status: Status; +}; export type User = { readonly id: string; @@ -6,11 +22,37 @@ export type User = { readonly passwordHash: string; readonly privilegeId: string | null; readonly isSuperadmin: boolean; + readonly status: Status; readonly createdAt: DateTime; readonly updatedAt: DateTime; + readonly createdBy: string; + readonly updatedBy: string; + readonly privilege: CatalogRelationRef | null; + readonly employee: EmployeeRelationRef | null; + readonly createdByUser: UserRelationRef; + readonly updatedByUser: UserRelationRef; }; export type CreateUserInput = { readonly username: string; readonly passwordHash: string; + readonly privilegeId?: string | null; + readonly status?: Status; + readonly actorUserId?: string; +}; + +export type UpdateUserInput = { + readonly username?: string; + readonly passwordHash?: string; + readonly privilegeId?: string | null; + readonly actorUserId: string; +}; + +export type ListUsersFilters = { + readonly username?: string; + readonly privilegeId?: string; + readonly status?: string; + readonly search?: string; + readonly limit: number; + readonly offset: number; }; diff --git a/src/modules/users/users-read.controller.spec.ts b/src/modules/users/users-read.controller.spec.ts new file mode 100644 index 0000000..0ebdafe --- /dev/null +++ b/src/modules/users/users-read.controller.spec.ts @@ -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', + }); + }); +}); diff --git a/src/modules/users/users-read.controller.ts b/src/modules/users/users-read.controller.ts new file mode 100644 index 0000000..b59c6b6 --- /dev/null +++ b/src/modules/users/users-read.controller.ts @@ -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> { + 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 { + return this.usersService.getById(id); + } +} + +void PaginationMetaDto; diff --git a/src/modules/users/users-write.controller.spec.ts b/src/modules/users/users-write.controller.spec.ts new file mode 100644 index 0000000..ec5da50 --- /dev/null +++ b/src/modules/users/users-write.controller.spec.ts @@ -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'); + }); +}); diff --git a/src/modules/users/users-write.controller.ts b/src/modules/users/users-write.controller.ts new file mode 100644 index 0000000..a49a7a0 --- /dev/null +++ b/src/modules/users/users-write.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + await this.usersService.delete(id); + } +} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts deleted file mode 100644 index a673183..0000000 --- a/src/modules/users/users.controller.ts +++ /dev/null @@ -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 { - const user = await this.usersService.assignPrivilege(id, dto.privilegeId); - return { - id: user.id, - username: user.username, - privilegeId: user.privilegeId, - }; - } -} diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index d0cbcd6..cca925a 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { PrivilegesModule } from '../privileges/privileges.module'; -import { UsersController } from './users.controller'; +import { UsersReadController } from './users-read.controller'; +import { UsersWriteController } from './users-write.controller'; import { UsersRepository } from './users.repository'; import { UsersService } from './users.service'; @Module({ imports: [PrivilegesModule], - controllers: [UsersController], + controllers: [UsersReadController, UsersWriteController], providers: [UsersRepository, UsersService], exports: [UsersService], }) diff --git a/src/modules/users/users.repository.spec.ts b/src/modules/users/users.repository.spec.ts index 537d428..ad5ed56 100644 --- a/src/modules/users/users.repository.spec.ts +++ b/src/modules/users/users.repository.spec.ts @@ -1,107 +1,125 @@ -import { ConflictException } from '@nestjs/common'; +import { ConflictException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { DRIZZLE } from '../../database/database.module'; import { UsersRepository } from './users.repository'; describe('UsersRepository', () => { let repository: UsersRepository; - const limit = jest.fn(); - const where = jest.fn(() => ({ limit })); - const from = jest.fn(() => ({ where })); - const select = jest.fn(() => ({ from })); - const returning = jest.fn(); - const values = jest.fn(() => ({ returning })); - const insert = jest.fn(() => ({ values })); - const db = { select, insert }; + const limit = jest.fn(); + const orderBy = jest.fn(); + const offset = jest.fn(); + const where = jest.fn(); + const from = jest.fn(); + const select = jest.fn(); + const returning = jest.fn(); + const values = jest.fn(); + const insert = jest.fn(); + const set = jest.fn(); + const update = jest.fn(); + const del = jest.fn(); + const $dynamic = jest.fn(); + + const db = { + select, + insert, + update, + delete: del, + }; + + const userRow = { + id: 'user-1', + username: 'alice', + passwordHash: 'hash', + privilegeId: null, + isSuperadmin: false, + status: 'draft', + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + createdBy: 'user-1', + updatedBy: 'user-1', + }; + + const joinedRow = { + user: userRow, + privilege: { id: null, code: null, name: null }, + employee: { id: null, code: null, name: null, status: null }, + createdByUser: { id: 'user-1', username: 'alice' }, + updatedByUser: { id: 'user-1', username: 'alice' }, + }; + + const joinChain = () => { + const chain: { + leftJoin: jest.Mock; + where: typeof where; + $dynamic: typeof $dynamic; + } = { + leftJoin: jest.fn(), + where, + $dynamic, + }; + chain.leftJoin.mockReturnValue(chain); + return chain; + }; beforeEach(async () => { jest.clearAllMocks(); + where.mockImplementation(() => ({ limit, orderBy, returning })); + orderBy.mockImplementation(() => ({ limit })); + limit.mockImplementation(() => ({ + offset, + then: ( + resolve: (value: (typeof joinedRow)[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve([joinedRow]).then(resolve, reject), + })); + offset.mockResolvedValue([joinedRow]); + from.mockImplementation(() => joinChain()); + $dynamic.mockReturnValue({ where }); + select.mockImplementation(() => ({ from })); + values.mockReturnValue({ returning }); + insert.mockReturnValue({ values }); + set.mockReturnValue({ where }); + update.mockReturnValue({ set }); + del.mockReturnValue({ where }); + returning.mockResolvedValue([{ id: 'user-1' }]); + const moduleRef: TestingModule = await Test.createTestingModule({ providers: [UsersRepository, { provide: DRIZZLE, useValue: db }], }).compile(); repository = moduleRef.get(UsersRepository); }); - it('findById maps a row to domain User', async () => { - limit.mockResolvedValue([ - { - id: 'user-1', - username: 'alice', - passwordHash: 'hash', - privilegeId: null, - isSuperadmin: false, - createdAt: 1_700_000_000_000, - updatedAt: 1_700_000_000_000, - }, - ]); - - const user = await repository.findById('user-1'); - expect(user).toMatchObject({ - id: 'user-1', - username: 'alice', - privilegeId: null, - isSuperadmin: false, - }); - expect(user?.createdAt.value).toBe(1_700_000_000_000); - }); - it('findById returns null when missing', async () => { - limit.mockResolvedValue([]); + from.mockImplementationOnce(() => ({ + where: () => ({ + limit: () => Promise.resolve([]), + }), + })); await expect(repository.findById('missing')).resolves.toBeNull(); }); - it('create inserts lowercase username', async () => { - returning.mockResolvedValue([ - { - id: 'user-1', - username: 'alice', - passwordHash: 'hash', - privilegeId: null, - isSuperadmin: false, - createdAt: 1_700_000_000_000, - updatedAt: 1_700_000_000_000, - }, - ]); - - const user = await repository.create({ - username: 'Alice', - passwordHash: 'hash', - }); - expect(user.username).toBe('alice'); - expect(user.privilegeId).toBeNull(); + it('create inserts lowercase username and maps unique violations', async () => { + returning.mockRejectedValueOnce({ code: '23505' }); + await expect( + repository.create({ username: 'Alice', passwordHash: 'hash' }), + ).rejects.toBeInstanceOf(ConflictException); expect(values).toHaveBeenCalledWith( expect.objectContaining({ username: 'alice', passwordHash: 'hash' }), ); }); - it('findByUsername maps a row', async () => { - limit.mockResolvedValue([ - { - id: 'user-1', - username: 'alice', - passwordHash: 'hash', - privilegeId: null, - isSuperadmin: false, - createdAt: 1_700_000_000_000, - updatedAt: 1_700_000_000_000, - }, - ]); - const user = await repository.findByUsername('Alice'); - expect(user?.username).toBe('alice'); + it('delete maps foreign-key violations to ConflictException', async () => { + returning.mockRejectedValueOnce({ code: '23503' }); + await expect(repository.delete('user-1')).rejects.toBeInstanceOf( + ConflictException, + ); }); - it('create maps unique violations to ConflictException', async () => { - returning.mockRejectedValue({ code: '23505' }); - await expect( - repository.create({ username: 'alice', passwordHash: 'hash' }), - ).rejects.toBeInstanceOf(ConflictException); - }); - - it('create rethrows unknown errors', async () => { - returning.mockRejectedValue(new Error('db down')); - await expect( - repository.create({ username: 'alice', passwordHash: 'hash' }), - ).rejects.toThrow('db down'); + it('delete throws when missing', async () => { + returning.mockResolvedValueOnce([]); + await expect(repository.delete('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); }); }); diff --git a/src/modules/users/users.repository.ts b/src/modules/users/users.repository.ts index 15f04cc..11eb900 100644 --- a/src/modules/users/users.repository.ts +++ b/src/modules/users/users.repository.ts @@ -4,84 +4,414 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { eq } from 'drizzle-orm'; -import { users, type UserRow } from '../../database/schema'; +import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; +import { randomUUID } from 'node:crypto'; import { DateTime } from '../../common/value-objects/date-time/date-time'; +import { Status } from '../../common/value-objects/status/status'; import { DRIZZLE, type DrizzleDB } from '../../database/database.module'; -import type { CreateUserInput, User } from './user'; +import { + employees, + privileges, + users, + type UserRow, +} from '../../database/schema'; +import type { + CreateUserInput, + ListUsersFilters, + UpdateUserInput, + User, +} from './user'; + +const createdByUsers = alias(users, 'created_by_users'); +const updatedByUsers = alias(users, 'updated_by_users'); + +type UserJoinedRow = { + user: UserRow; + privilege: { id: string; code: string; name: string } | null; + employee: { + id: string; + code: string; + name: string; + status: string; + } | null; + createdByUser: { id: string; username: string } | null; + updatedByUser: { id: string; username: string } | null; +}; @Injectable() export class UsersRepository { constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} + async list( + filters: ListUsersFilters, + ): Promise<{ data: User[]; total: number }> { + const where = this.buildListWhere(filters); + const totalRows = await this.db + .select({ total: count() }) + .from(users) + .where(where); + const totalRow = totalRows[0]; + + let qb = this.selectWithRelations().$dynamic(); + qb = this.extendListQuery(qb, filters); + const rows = await qb + .where(where) + .orderBy(asc(users.username)) + .limit(filters.limit) + .offset(filters.offset); + + return { + data: rows.map((row) => this.toDomain(row)), + total: Number(totalRow?.total ?? 0), + }; + } + + extendListQuery(qb: T, filters: ListUsersFilters): T { + void filters; + return qb; + } + async findById(id: string): Promise { - const [row] = await this.db + const rows = await this.db .select() .from(users) .where(eq(users.id, id)) .limit(1); - return row ? this.toDomain(row) : null; + const row = rows[0]; + return row ? this.hydrate(row) : null; } async findByUsername(username: string): Promise { const normalized = username.toLowerCase(); - const [row] = await this.db + const rows = await this.db .select() .from(users) .where(eq(users.username, normalized)) .limit(1); - return row ? this.toDomain(row) : null; + const row = rows[0]; + return row ? this.hydrate(row) : null; } async create(input: CreateUserInput): Promise { const now = DateTime.fromUnixMs(Date.now()); + const id = randomUUID(); + const actorId = input.actorUserId ?? id; + const status = input.status ?? Status.create(Status.DEFAULT); try { - const [row] = await this.db + const inserted = await this.db .insert(users) .values({ + id, username: input.username.toLowerCase(), passwordHash: input.passwordHash, + privilegeId: input.privilegeId ?? null, + status: status.value, createdAt: now.value, updatedAt: now.value, + createdBy: actorId, + updatedBy: actorId, }) .returning(); - return this.toDomain(row); + const row = inserted[0]; + return this.toDomain({ + user: row, + privilege: null, + employee: null, + createdByUser: { id: actorId, username: input.username.toLowerCase() }, + updatedByUser: { id: actorId, username: input.username.toLowerCase() }, + }); } catch (error) { - if ((error as { code?: string }).code === '23505') { - throw new ConflictException('Username already registered'); + this.rethrowConstraintViolation(error); + } + } + + async update(id: string, input: UpdateUserInput): Promise { + const existing = await this.findById(id); + if (!existing) { + throw new NotFoundException('User not found'); + } + const now = DateTime.fromUnixMs(Date.now()); + try { + const updated = await this.db + .update(users) + .set({ + username: input.username ?? existing.username, + passwordHash: input.passwordHash ?? existing.passwordHash, + privilegeId: + input.privilegeId !== undefined + ? input.privilegeId + : existing.privilegeId, + updatedAt: now.value, + updatedBy: input.actorUserId, + }) + .where(eq(users.id, id)) + .returning({ id: users.id }); + const row = updated[0]; + if (!row) { + throw new NotFoundException('User not found'); } - throw error; + return this.requireById(row.id); + } catch (error) { + this.rethrowConstraintViolation(error); } } async updatePrivilegeId( userId: string, privilegeId: string | null, + actorUserId?: string, ): Promise { const now = DateTime.fromUnixMs(Date.now()); - const [row] = await this.db + const updated = await this.db .update(users) .set({ privilegeId, updatedAt: now.value, + ...(actorUserId ? { updatedBy: actorUserId } : {}), }) .where(eq(users.id, userId)) - .returning(); + .returning({ id: users.id }); + const row = updated[0]; if (!row) { throw new NotFoundException('User not found'); } - return this.toDomain(row); + return this.requireById(row.id); } - private toDomain(row: UserRow): User { + async updateStatus( + id: string, + status: Status, + actorUserId: string, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const loaded = await this.findById(id); + if (!loaded) { + throw new NotFoundException('User not found'); + } + return loaded; + } + + private buildListWhere(filters: ListUsersFilters): SQL | undefined { + const parts: SQL[] = []; + if (filters.username) { + parts.push(ilike(users.username, `%${filters.username}%`)); + } + if (filters.privilegeId) { + parts.push(eq(users.privilegeId, filters.privilegeId)); + } + if (filters.status) { + parts.push(eq(users.status, filters.status)); + } + if (filters.search) { + parts.push(ilike(users.username, `%${filters.search}%`)); + } + if (parts.length === 0) { + return undefined; + } + return parts.length === 1 ? parts[0] : and(...parts); + } + + private toDomain(row: UserJoinedRow): User { + const user = row.user; return { - id: row.id, - username: row.username, - passwordHash: row.passwordHash, - privilegeId: row.privilegeId ?? null, - isSuperadmin: row.isSuperadmin, - createdAt: DateTime.fromUnixMs(row.createdAt), - updatedAt: DateTime.fromUnixMs(row.updatedAt), + id: user.id, + username: user.username, + passwordHash: user.passwordHash, + privilegeId: user.privilegeId ?? null, + isSuperadmin: user.isSuperadmin, + status: Status.create(user.status), + createdAt: DateTime.fromUnixMs(user.createdAt), + updatedAt: DateTime.fromUnixMs(user.updatedAt), + createdBy: user.createdBy, + updatedBy: user.updatedBy, + privilege: row.privilege?.id + ? { + id: row.privilege.id, + code: row.privilege.code, + name: row.privilege.name, + } + : null, + employee: row.employee?.id + ? { + id: row.employee.id, + code: row.employee.code, + name: row.employee.name, + status: Status.create(row.employee.status), + } + : null, + createdByUser: this.toUserRelation(row.createdByUser, user.createdBy), + updatedByUser: this.toUserRelation(row.updatedByUser, user.updatedBy), }; } + + private toUserRelation( + row: { id: string; username: string } | null, + fallbackId: string, + ): User['createdByUser'] { + if (row?.id) { + return { id: row.id, username: row.username }; + } + return { id: fallbackId, username: '' }; + } + + private rethrowConstraintViolation(error: unknown): never { + const err = this.unwrapDbError(error); + if (err.code === '23505') { + throw new ConflictException('Username already registered'); + } + if (err.code === '23503') { + throw new ConflictException('User is still referenced'); + } + throw error; + } + + private unwrapDbError(error: unknown): { code?: string } { + let current: unknown = error; + for (let i = 0; i < 5; i++) { + if (!current || typeof current !== 'object') { + break; + } + const obj = current as { code?: string; cause?: unknown }; + if (obj.code === '23505' || obj.code === '23503') { + return { code: obj.code }; + } + current = obj.cause; + } + return error as { code?: string }; + } } diff --git a/src/modules/users/users.service.spec.ts b/src/modules/users/users.service.spec.ts index 266b772..c3277e6 100644 --- a/src/modules/users/users.service.spec.ts +++ b/src/modules/users/users.service.spec.ts @@ -1,6 +1,12 @@ -import { ConflictException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { DateTime } from '../../common/value-objects/date-time/date-time'; +import { Status } from '../../common/value-objects/status/status'; import { PrivilegesService } from '../privileges/privileges.service'; import type { User } from './user'; import { UsersRepository } from './users.repository'; @@ -11,7 +17,16 @@ describe('UsersService', () => { let repository: jest.Mocked< Pick< UsersRepository, - 'findById' | 'findByUsername' | 'create' | 'updatePrivilegeId' + | 'findById' + | 'findByUsername' + | 'create' + | 'update' + | 'updatePrivilegeId' + | 'updateStatus' + | 'bulkUpdateStatus' + | 'delete' + | 'bulkDelete' + | 'list' > >; let privilegesService: jest.Mocked< @@ -25,8 +40,15 @@ describe('UsersService', () => { passwordHash: 'hashed', privilegeId: null, isSuperadmin: false, + status: Status.create('draft'), createdAt: now, updatedAt: now, + createdBy: 'user-1', + updatedBy: 'user-1', + privilege: null, + employee: null, + createdByUser: { id: 'user-1', username: 'alice' }, + updatedByUser: { id: 'user-1', username: 'alice' }, }; beforeEach(async () => { @@ -34,7 +56,13 @@ describe('UsersService', () => { findById: jest.fn(), findByUsername: jest.fn(), create: jest.fn(), + update: jest.fn(), updatePrivilegeId: jest.fn(), + updateStatus: jest.fn(), + bulkUpdateStatus: jest.fn(), + delete: jest.fn(), + bulkDelete: jest.fn(), + list: jest.fn(), }; privilegesService = { findPrivilegeSummary: jest.fn(), @@ -45,6 +73,10 @@ describe('UsersService', () => { UsersService, { provide: UsersRepository, useValue: repository }, { provide: PrivilegesService, useValue: privilegesService }, + { + provide: ConfigService, + useValue: { getOrThrow: () => 4 }, + }, ], }).compile(); @@ -94,9 +126,69 @@ describe('UsersService', () => { repository.updatePrivilegeId.mockResolvedValue({ ...sampleUser, privilegeId: 'priv-1', + privilege: { id: 'priv-1', code: 'ADMIN', name: 'Admin' }, }); const result = await service.assignPrivilege('user-1', 'priv-1'); - expect(result.privilegeId).toBe('priv-1'); + expect(result.privilege).toEqual({ + id: 'priv-1', + code: 'ADMIN', + name: 'Admin', + }); + expect(result).not.toHaveProperty('passwordHash'); + }); + + it('list maps nested relations and omits password', async () => { + repository.list.mockResolvedValue({ data: [sampleUser], total: 1 }); + const result = await service.list({ page: 1, limit: 10 }); + expect(result.total).toBe(1); + expect(result.data[0]).toMatchObject({ + id: 'user-1', + username: 'alice', + status: 'draft', + privilege: null, + employee: null, + createdBy: { id: 'user-1', username: 'alice' }, + }); + expect(result.data[0]).not.toHaveProperty('passwordHash'); + }); + + it('update rejects status field', async () => { + await expect( + service.update('user-1', { status: 'active', actorUserId: 'user-1' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('assertCanAuthenticate allows active users without employee', () => { + expect(() => + service.assertCanAuthenticate({ + ...sampleUser, + status: Status.create('active'), + }), + ).not.toThrow(); + }); + + it('assertCanAuthenticate rejects draft users and inactive employees', () => { + expect(() => service.assertCanAuthenticate(sampleUser)).toThrow( + UnauthorizedException, + ); + expect(() => + service.assertCanAuthenticate({ + ...sampleUser, + status: Status.create('active'), + employee: { + id: 'emp-1', + code: 'EMP_01', + name: 'Ada', + status: Status.create('draft'), + }, + }), + ).toThrow(UnauthorizedException); + }); + + it('importCsv requires username and password headers', async () => { + await expect(service.importCsv('name\nalice', 'user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); }); }); diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 7f08e9f..f1f2522 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -3,22 +3,93 @@ import { ConflictException, Injectable, NotFoundException, + UnauthorizedException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as bcrypt from 'bcrypt'; +import type { PaginationResponse } from '../../common/http/response'; +import { + DEFAULT_RELATION_FIELDS, + pickRelation, + pickUserRelation, + toListPage, +} from '../../common/http/response'; +import { InvalidStatusError } from '../../common/value-objects/status/invalid-status.error'; +import { Status } from '../../common/value-objects/status/status'; import { PrivilegesService } from '../privileges/privileges.service'; +import type { CreateUserInput, UpdateUserInput, User } from './user'; +import { + isValidPassword, + isValidUsername, + parseCsvRecord, +} from './user-fields'; import { UsersRepository } from './users.repository'; -import type { User } from './user'; + +export type ListUsersQuery = { + readonly username?: string; + readonly privilegeId?: string; + readonly status?: string; + readonly search?: string; + readonly page?: number; + readonly limit?: number; + readonly offset?: number; +}; + +const VISIBLE_FIELDS = [ + 'id', + 'username', + 'isSuperadmin', + 'privilege', + 'employee', + 'status', + 'createdAt', + 'updatedAt', + 'createdBy', + 'updatedBy', +] as const; + +const CSV_REQUIRED_HEADERS = ['username', 'password'] as const; @Injectable() export class UsersService { constructor( private readonly usersRepository: UsersRepository, private readonly privilegesService: PrivilegesService, + private readonly config: ConfigService, ) {} + async list( + query: ListUsersQuery, + ): Promise>> { + 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 { return this.usersRepository.findById(id); } + async getById( + id: string, + ): Promise> { + const user = await this.usersRepository.findById(id); + if (!user) { + throw new NotFoundException('User not found'); + } + return this.toListItem(user); + } + async findByUsername(username: string): Promise { 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> { + 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> { + 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> { + 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 { + await this.usersRepository.delete(id); + } + + async bulkDelete(ids: string[]): Promise<{ deleted: number }> { + const deleted = await this.usersRepository.bulkDelete(ids); + return { deleted }; + } + async assignPrivilege( userId: string, privilegeId: string | null, - ): Promise { + actorUserId?: string, + ): Promise> { const user = await this.usersRepository.findById(userId); if (!user) { throw new NotFoundException('User not found'); } - if (privilegeId !== null) { - const privilege = - await this.privilegesService.findPrivilegeSummary(privilegeId); - if (!privilege) { - throw new NotFoundException('Privilege not found'); - } - if (privilege.status !== 'active') { - throw new BadRequestException('Privilege must be active'); + const assigned = await this.assertPrivilegeId(privilegeId); + const updated = await this.usersRepository.updatePrivilegeId( + userId, + assigned, + actorUserId, + ); + return this.toListItem(updated); + } + + async importCsv( + csv: string, + actorUserId: string, + ): Promise<{ imported: number }> { + const rawLines = csv.split(/\r?\n/); + const filled = rawLines + .map((line, index) => ({ line: line.trim(), lineNo: index + 1 })) + .filter((entry) => entry.line.length > 0); + if (filled.length === 0) { + throw new BadRequestException('CSV is empty'); + } + if (filled.length > 501) { + throw new BadRequestException('CSV exceeds maximum of 500 data rows'); + } + + const header = parseCsvRecord(filled[0].line).map((h) => + h.trim().toLowerCase(), + ); + const missing = CSV_REQUIRED_HEADERS.filter((h) => header.indexOf(h) < 0); + if (missing.length > 0) { + throw new BadRequestException('CSV must include required headers'); + } + + const idx = (key: string) => header.indexOf(key); + const errors: string[] = []; + const rows: CreateUserInput[] = []; + for (let i = 1; i < filled.length; i++) { + const cols = parseCsvRecord(filled[i].line); + const rowNum = filled[i].lineNo; + try { + const privilegeRaw = + idx('privilegeid') >= 0 ? cols[idx('privilegeid')] : ''; + const statusRaw = idx('status') >= 0 ? cols[idx('status')] : ''; + rows.push( + await this.toCreateInput({ + username: cols[idx('username')] ?? '', + password: cols[idx('password')] ?? '', + privilegeId: privilegeRaw || undefined, + status: statusRaw || undefined, + actorUserId, + }), + ); + } catch (error) { + const reason = + error instanceof BadRequestException ? error.message : 'invalid data'; + errors.push(`row ${rowNum}: ${reason}`); } } - return this.usersRepository.updatePrivilegeId(userId, privilegeId); + + if (errors.length > 0) { + throw new BadRequestException({ + message: 'CSV validation failed', + errors, + }); + } + + for (const row of rows) { + await this.usersRepository.create(row); + } + return { imported: rows.length }; + } + + assertCanAuthenticate(user: User): void { + if (user.status.value !== 'active') { + throw new UnauthorizedException('Invalid credentials'); + } + if (user.employee && user.employee.status.value !== 'active') { + throw new UnauthorizedException('Invalid credentials'); + } + } + + toListItem(user: User) { + return { + id: user.id, + username: user.username, + isSuperadmin: user.isSuperadmin, + privilege: pickRelation(user.privilege, DEFAULT_RELATION_FIELDS), + employee: pickRelation(user.employee, DEFAULT_RELATION_FIELDS), + status: user.status.value, + createdAt: user.createdAt.value, + updatedAt: user.updatedAt.value, + createdBy: pickUserRelation(user.createdByUser), + updatedBy: pickUserRelation(user.updatedByUser), + }; + } + + get visibleFields(): readonly string[] { + return VISIBLE_FIELDS; + } + + private async toCreateInput(input: { + username: string; + password: string; + privilegeId?: string; + status?: string; + actorUserId: string; + }): Promise { + 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 { + 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 { + const saltRounds = this.config.getOrThrow('BCRYPT_SALT_ROUNDS'); + return bcrypt.hash(password, saltRounds); } } diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts index 35fb904..056f594 100644 --- a/test/auth.e2e-spec.ts +++ b/test/auth.e2e-spec.ts @@ -4,9 +4,12 @@ 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 { registerAndActivate } from './helpers/activate-user'; describe('Auth (e2e)', () => { let app: INestApplication; + let db: DrizzleDB; const username = `user_${Date.now()}`; const password = 'password123'; @@ -22,6 +25,7 @@ describe('Auth (e2e)', () => { SWAGGER_ENABLED: 'false', }); await app.init(); + db = app.get(DRIZZLE); }); afterAll(async () => { @@ -39,30 +43,37 @@ describe('Auth (e2e)', () => { 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()) .post('/auth/register') .send({ username, password }) .expect(201); - const { accessToken, refreshToken } = register.body as { - accessToken: string; - refreshToken: string; - }; - expect(accessToken).toBeDefined(); - expect(refreshToken).toHaveLength(64); - expect(Object.keys(register.body).sort()).toEqual([ - 'accessToken', - 'refreshToken', - ]); + expect(register.body).toMatchObject({ + username: username.toLowerCase(), + status: 'draft', + }); + expect(register.body).not.toHaveProperty('accessToken'); + + await request(app.getHttpServer()) + .post('/auth/login') + .send({ username, password }) + .expect(401); + + const activated = await registerAndActivate( + app, + db, + `${username}_active`, + password, + ); const me = await request(app.getHttpServer()) .get('/auth/me') - .set('Authorization', `Bearer ${accessToken}`) + .set('Authorization', `Bearer ${activated.accessToken}`) .expect(200); expect(me.body).toMatchObject({ - username: username.toLowerCase(), + username: `${username}_active`.toLowerCase(), isSuperadmin: false, privilege: null, permissions: {}, @@ -70,7 +81,7 @@ describe('Auth (e2e)', () => { const refreshed = await request(app.getHttpServer()) .post('/auth/refresh') - .send({ refreshToken }) + .send({ refreshToken: activated.refreshToken }) .expect(200); const { accessToken: nextAccess, refreshToken: nextRefresh } = diff --git a/test/branches.e2e-spec.ts b/test/branches.e2e-spec.ts index 4555fee..39c74b0 100644 --- a/test/branches.e2e-spec.ts +++ b/test/branches.e2e-spec.ts @@ -13,6 +13,7 @@ import { users, } from '../src/database/schema'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; +import { registerAndActivate } from './helpers/activate-user'; describe('Branches (e2e)', () => { let app: INestApplication; @@ -53,23 +54,12 @@ describe('Branches (e2e)', () => { await app.init(); db = app.get(DRIZZLE); - const adminReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: adminUsername, password }) - .expect(201); - adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; + const admin = await registerAndActivate(app, db, adminUsername, password); + adminAccessToken = admin.accessToken; + adminUserId = admin.userId; - const adminMe = await request(app.getHttpServer()) - .get('/auth/me') - .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 other = await registerAndActivate(app, db, otherUsername, password); + otherAccessToken = other.accessToken; const now = Date.now(); const [priv] = await db diff --git a/test/company-settings.e2e-spec.ts b/test/company-settings.e2e-spec.ts index 9e6450f..01b0e9b 100644 --- a/test/company-settings.e2e-spec.ts +++ b/test/company-settings.e2e-spec.ts @@ -13,6 +13,7 @@ import { users, } from '../src/database/schema'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; +import { registerAndActivate } from './helpers/activate-user'; describe('Company settings (e2e)', () => { let app: INestApplication; @@ -39,23 +40,12 @@ describe('Company settings (e2e)', () => { await app.init(); db = app.get(DRIZZLE); - const adminReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: adminUsername, password }) - .expect(201); - adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; + const admin = await registerAndActivate(app, db, adminUsername, password); + adminAccessToken = admin.accessToken; + adminUserId = admin.userId; - const adminMe = await request(app.getHttpServer()) - .get('/auth/me') - .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 other = await registerAndActivate(app, db, otherUsername, password); + otherAccessToken = other.accessToken; const now = Date.now(); const [priv] = await db diff --git a/test/customers.e2e-spec.ts b/test/customers.e2e-spec.ts index 8fbb4dd..7468783 100644 --- a/test/customers.e2e-spec.ts +++ b/test/customers.e2e-spec.ts @@ -13,6 +13,7 @@ import { users, } from '../src/database/schema'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; +import { registerAndActivate } from './helpers/activate-user'; describe('Customers (e2e)', () => { let app: INestApplication; @@ -46,23 +47,12 @@ describe('Customers (e2e)', () => { await app.init(); db = app.get(DRIZZLE); - const adminReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: adminUsername, password }) - .expect(201); - adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; + const admin = await registerAndActivate(app, db, adminUsername, password); + adminAccessToken = admin.accessToken; + adminUserId = admin.userId; - const adminMe = await request(app.getHttpServer()) - .get('/auth/me') - .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 other = await registerAndActivate(app, db, otherUsername, password); + otherAccessToken = other.accessToken; const now = Date.now(); const [priv] = await db diff --git a/test/cycles.e2e-spec.ts b/test/cycles.e2e-spec.ts index b6fe218..eb7bb39 100644 --- a/test/cycles.e2e-spec.ts +++ b/test/cycles.e2e-spec.ts @@ -13,6 +13,7 @@ import { users, } from '../src/database/schema'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; +import { registerAndActivate } from './helpers/activate-user'; describe('Cycles (e2e)', () => { let app: INestApplication; @@ -43,23 +44,12 @@ describe('Cycles (e2e)', () => { await app.init(); db = app.get(DRIZZLE); - const adminReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: adminUsername, password }) - .expect(201); - adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; + const admin = await registerAndActivate(app, db, adminUsername, password); + adminAccessToken = admin.accessToken; + adminUserId = admin.userId; - const adminMe = await request(app.getHttpServer()) - .get('/auth/me') - .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 other = await registerAndActivate(app, db, otherUsername, password); + otherAccessToken = other.accessToken; const now = Date.now(); const [priv] = await db diff --git a/test/divisions.e2e-spec.ts b/test/divisions.e2e-spec.ts index 47b018c..2fce41c 100644 --- a/test/divisions.e2e-spec.ts +++ b/test/divisions.e2e-spec.ts @@ -13,6 +13,7 @@ import { users, } from '../src/database/schema'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; +import { registerAndActivate } from './helpers/activate-user'; describe('Divisions (e2e)', () => { let app: INestApplication; @@ -39,23 +40,12 @@ describe('Divisions (e2e)', () => { await app.init(); db = app.get(DRIZZLE); - const adminReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: adminUsername, password }) - .expect(201); - adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; + const admin = await registerAndActivate(app, db, adminUsername, password); + adminAccessToken = admin.accessToken; + adminUserId = admin.userId; - const adminMe = await request(app.getHttpServer()) - .get('/auth/me') - .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 other = await registerAndActivate(app, db, otherUsername, password); + otherAccessToken = other.accessToken; const now = Date.now(); const [priv] = await db diff --git a/test/employees.e2e-spec.ts b/test/employees.e2e-spec.ts index 467dbee..7a2f4b7 100644 --- a/test/employees.e2e-spec.ts +++ b/test/employees.e2e-spec.ts @@ -13,6 +13,7 @@ import { users, } from '../src/database/schema'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; +import { registerAndActivate } from './helpers/activate-user'; describe('Employees (e2e)', () => { let app: INestApplication; @@ -46,23 +47,12 @@ describe('Employees (e2e)', () => { await app.init(); db = app.get(DRIZZLE); - const adminReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: adminUsername, password }) - .expect(201); - adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; + const admin = await registerAndActivate(app, db, adminUsername, password); + adminAccessToken = admin.accessToken; + adminUserId = admin.userId; - const adminMe = await request(app.getHttpServer()) - .get('/auth/me') - .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 other = await registerAndActivate(app, db, otherUsername, password); + otherAccessToken = other.accessToken; const now = Date.now(); const [priv] = await db diff --git a/test/helpers/activate-user.ts b/test/helpers/activate-user.ts new file mode 100644 index 0000000..9cbbb36 --- /dev/null +++ b/test/helpers/activate-user.ts @@ -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, + }; +} diff --git a/test/packing-slips.e2e-spec.ts b/test/packing-slips.e2e-spec.ts index 2f5b60c..c4aa742 100644 --- a/test/packing-slips.e2e-spec.ts +++ b/test/packing-slips.e2e-spec.ts @@ -13,6 +13,7 @@ import { users, } from '../src/database/schema'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; +import { registerAndActivate } from './helpers/activate-user'; describe('Packing slips (e2e)', () => { let app: INestApplication; @@ -34,21 +35,21 @@ describe('Packing slips (e2e)', () => { await app.init(); db = app.get(DRIZZLE); - const adminReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: `ps_admin_${suffix}`, password }) - .expect(201); - adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; - const adminMe = await request(app.getHttpServer()) - .get('/auth/me') - .set('Authorization', `Bearer ${adminAccessToken}`) - .expect(200); - const adminUserId = (adminMe.body as { id: string }).id; - const otherReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: `ps_other_${suffix}`, password }) - .expect(201); - otherAccessToken = (otherReg.body as { accessToken: string }).accessToken; + const admin = await registerAndActivate( + app, + db, + `ps_admin_${suffix}`, + password, + ); + adminAccessToken = admin.accessToken; + const adminUserId = admin.userId; + const other = await registerAndActivate( + app, + db, + `ps_other_${suffix}`, + password, + ); + otherAccessToken = other.accessToken; const now = Date.now(); const [priv] = await db diff --git a/test/plans.e2e-spec.ts b/test/plans.e2e-spec.ts index 4a310de..3834982 100644 --- a/test/plans.e2e-spec.ts +++ b/test/plans.e2e-spec.ts @@ -13,6 +13,7 @@ import { users, } from '../src/database/schema'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; +import { registerAndActivate } from './helpers/activate-user'; describe('Plans (e2e)', () => { let app: INestApplication; @@ -45,23 +46,12 @@ describe('Plans (e2e)', () => { await app.init(); db = app.get(DRIZZLE); - const adminReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: adminUsername, password }) - .expect(201); - adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; + const admin = await registerAndActivate(app, db, adminUsername, password); + adminAccessToken = admin.accessToken; + adminUserId = admin.userId; - const adminMe = await request(app.getHttpServer()) - .get('/auth/me') - .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 other = await registerAndActivate(app, db, otherUsername, password); + otherAccessToken = other.accessToken; const now = Date.now(); const [priv] = await db diff --git a/test/privileges.e2e-spec.ts b/test/privileges.e2e-spec.ts index 572c69f..95b818e 100644 --- a/test/privileges.e2e-spec.ts +++ b/test/privileges.e2e-spec.ts @@ -13,6 +13,7 @@ import { privileges, users, } from '../src/database/schema'; +import { registerAndActivate } from './helpers/activate-user'; describe('Privileges (e2e)', () => { let app: INestApplication; @@ -41,27 +42,20 @@ describe('Privileges (e2e)', () => { await app.init(); db = app.get(DRIZZLE); - const adminReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: adminUsername, password }) - .expect(201); - adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; - + const admin = await registerAndActivate(app, db, adminUsername, password); + adminAccessToken = admin.accessToken; + adminUserId = admin.userId; const adminMe = await request(app.getHttpServer()) .get('/auth/me') .set('Authorization', `Bearer ${adminAccessToken}`) .expect(200); - adminUserId = (adminMe.body as { id: string }).id; expect(adminMe.body).toMatchObject({ privilege: null, permissions: {}, }); - const otherReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: otherUsername, password }) - .expect(201); - otherAccessToken = (otherReg.body as { accessToken: string }).accessToken; + const other = await registerAndActivate(app, db, otherUsername, password); + otherAccessToken = other.accessToken; const otherMe = await request(app.getHttpServer()) .get('/auth/me') .set('Authorization', `Bearer ${otherAccessToken}`) diff --git a/test/products.e2e-spec.ts b/test/products.e2e-spec.ts index 5fc5f27..b89549a 100644 --- a/test/products.e2e-spec.ts +++ b/test/products.e2e-spec.ts @@ -13,6 +13,7 @@ import { users, } from '../src/database/schema'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; +import { registerAndActivate } from './helpers/activate-user'; describe('Products (e2e)', () => { let app: INestApplication; @@ -47,23 +48,12 @@ describe('Products (e2e)', () => { await app.init(); db = app.get(DRIZZLE); - const adminReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: adminUsername, password }) - .expect(201); - adminAccessToken = (adminReg.body as { accessToken: string }).accessToken; + const admin = await registerAndActivate(app, db, adminUsername, password); + adminAccessToken = admin.accessToken; + adminUserId = admin.userId; - const adminMe = await request(app.getHttpServer()) - .get('/auth/me') - .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 other = await registerAndActivate(app, db, otherUsername, password); + otherAccessToken = other.accessToken; const now = Date.now(); const [priv] = await db diff --git a/test/sales-invoices.e2e-spec.ts b/test/sales-invoices.e2e-spec.ts index 1885371..494ecdf 100644 --- a/test/sales-invoices.e2e-spec.ts +++ b/test/sales-invoices.e2e-spec.ts @@ -13,6 +13,7 @@ import { users, } from '../src/database/schema'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; +import { registerAndActivate } from './helpers/activate-user'; describe('Sales invoices (e2e)', () => { let app: INestApplication; @@ -37,21 +38,21 @@ describe('Sales invoices (e2e)', () => { await app.init(); db = app.get(DRIZZLE); - const adminReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: `si_admin_${suffix}`, password }) - .expect(201); - token = (adminReg.body as { accessToken: string }).accessToken; - const adminMe = await request(app.getHttpServer()) - .get('/auth/me') - .set('Authorization', `Bearer ${token}`) - .expect(200); - const adminUserId = (adminMe.body as { id: string }).id; - const otherReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: `si_other_${suffix}`, password }) - .expect(201); - otherToken = (otherReg.body as { accessToken: string }).accessToken; + const admin = await registerAndActivate( + app, + db, + `si_admin_${suffix}`, + password, + ); + token = admin.accessToken; + const adminUserId = admin.userId; + const other = await registerAndActivate( + app, + db, + `si_other_${suffix}`, + password, + ); + otherToken = other.accessToken; const now = Date.now(); const [priv] = await db diff --git a/test/sales-payments.e2e-spec.ts b/test/sales-payments.e2e-spec.ts index bfc6a1e..80d48ef 100644 --- a/test/sales-payments.e2e-spec.ts +++ b/test/sales-payments.e2e-spec.ts @@ -13,6 +13,7 @@ import { users, } from '../src/database/schema'; import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action'; +import { registerAndActivate } from './helpers/activate-user'; describe('Sales payments (e2e)', () => { let app: INestApplication; @@ -33,21 +34,21 @@ describe('Sales payments (e2e)', () => { await app.init(); db = app.get(DRIZZLE); - const adminReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: `sp_admin_${suffix}`, password }) - .expect(201); - token = (adminReg.body as { accessToken: string }).accessToken; - const adminMe = await request(app.getHttpServer()) - .get('/auth/me') - .set('Authorization', `Bearer ${token}`) - .expect(200); - const adminUserId = (adminMe.body as { id: string }).id; - const otherReg = await request(app.getHttpServer()) - .post('/auth/register') - .send({ username: `sp_other_${suffix}`, password }) - .expect(201); - otherToken = (otherReg.body as { accessToken: string }).accessToken; + const admin = await registerAndActivate( + app, + db, + `sp_admin_${suffix}`, + password, + ); + token = admin.accessToken; + const adminUserId = admin.userId; + const other = await registerAndActivate( + app, + db, + `sp_other_${suffix}`, + password, + ); + otherToken = other.accessToken; const now = Date.now(); const [priv] = await db diff --git a/test/users.e2e-spec.ts b/test/users.e2e-spec.ts new file mode 100644 index 0000000..6a2cf53 --- /dev/null +++ b/test/users.e2e-spec.ts @@ -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; + 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); + }); +});