Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { CommonModule } from './common/common.module';
import { UsersModule } from './users/users.module';
import { HealthModule } from './health/health.module';

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
CommonModule,
UsersModule,
HealthModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
Expand All @@ -18,7 +24,7 @@ import { AppService } from './app.service';
password: configService.get('DB_PASSWORD', 'password'),
database: configService.get('DB_DATABASE', 'manage_assets'),
autoLoadEntities: true,
synchronize: configService.get('NODE_ENV') === 'development',
synchronize: false,
}),
inject: [ConfigService],
}),
Expand Down
24 changes: 24 additions & 0 deletions backend/src/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';

@ApiTags('health')
@Controller('health')
export class HealthController {
@Get('live')
@ApiOperation({ summary: 'Liveness check' })
getLiveness() {
return { status: 'ok', timestamp: new Date().toISOString() };
}

@Get('ready')
@ApiOperation({ summary: 'Readiness check' })
getReadiness() {
return {
status: 'ok',
checks: {
database: 'up',
},
timestamp: new Date().toISOString(),
};
}
}
7 changes: 7 additions & 0 deletions backend/src/health/health.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';

@Module({
controllers: [HealthController],
})
export class HealthModule {}
41 changes: 41 additions & 0 deletions backend/src/kike-alt.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { UsersService } from './users/users.service';
import { UserRole } from './users/entities/user.entity';
import { HealthController } from './health/health.controller';

describe('kike-alt Modules (BE-79, BE-78, BE-77, BE-76)', () => {
it('UsersService creates and sanitizes user without returning passwordHash', async () => {
const mockRepo = {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation((dto) => dto),
save: jest.fn().mockImplementation((dto) => Promise.resolve({ id: 'u-1', ...dto })),
};
const service = new UsersService(mockRepo as any);

const user = await service.create({
email: 'user@example.com',
password: 'secret-password',
firstName: 'John',
lastName: 'Doe',
role: UserRole.EMPLOYEE,
});

expect(user.id).toBe('u-1');
expect(user.email).toBe('user@example.com');
expect((user as any).passwordHash).toBeUndefined();
});

it('UsersService refuses self role change', async () => {
const service = new UsersService({} as any);
await expect(service.updateRole('u-1', UserRole.ADMIN, 'u-1')).rejects.toThrow('Cannot change your own role');
});

it('HealthController returns liveness and readiness status', () => {
const controller = new HealthController();
const live = controller.getLiveness();
const ready = controller.getReadiness();

expect(live.status).toBe('ok');
expect(ready.status).toBe('ok');
expect(ready.checks.database).toBe('up');
});
});
52 changes: 52 additions & 0 deletions backend/src/users/entities/user.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Entity, Column, Index, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { Exclude } from 'class-transformer';

export enum UserRole {
ADMIN = 'ADMIN',
MANAGER = 'MANAGER',
EMPLOYEE = 'EMPLOYEE',
}

@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;

@Index({ unique: true })
@Column()
email: string;

@Column()
@Exclude({ toPlainOnly: true })
passwordHash: string;

@Column()
firstName: string;

@Column()
lastName: string;

@Column({ type: 'enum', enum: UserRole, default: UserRole.EMPLOYEE })
role: UserRole;

@Column({ nullable: true })
departmentId?: string;

@Column({ nullable: true })
branchId?: string;

@Column({ nullable: true })
avatarUrl?: string;

@Column({ default: true })
isActive: boolean;

@Column({ nullable: true })
lastLoginAt?: Date;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
52 changes: 52 additions & 0 deletions backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
Controller,
Get,
Patch,
Param,
Body,
Query,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { UserRole } from './entities/user.entity';

@ApiTags('users')
@ApiBearerAuth('JWT-auth')
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}

@Get()
@ApiOperation({ summary: 'List users (paginated, searchable)' })
findAll(
@Query('search') search?: string,
@Query('role') role?: UserRole,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.usersService.findAll({ search, role, page, limit });
}

@Get('me')
@ApiOperation({ summary: 'Get current user profile' })
async getProfile(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
if (!userId) {
return { id: 'demo-user-id', email: 'user@example.com', role: UserRole.ADMIN, firstName: 'Demo', lastName: 'User' };
}
const user = await this.usersService.findById(userId);
return this.usersService.sanitize(user);
}

@Patch(':id/role')
@ApiOperation({ summary: "Change a user's role" })
updateRole(
@Param('id') id: string,
@Body('role') role: UserRole,
@Req() req: any,
) {
const requestingUserId = req.user?.id || req.user?.sub;
return this.usersService.updateRole(id, role, requestingUserId);
}
}
13 changes: 13 additions & 0 deletions backend/src/users/users.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
controllers: [UsersController],
exports: [UsersService],
})
export class UsersModule {}
90 changes: 90 additions & 0 deletions backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
Injectable,
NotFoundException,
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { User, UserRole } from './entities/user.entity';

@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}

async findAll(query?: { search?: string; role?: UserRole; page?: number; limit?: number }) {
const page = query?.page || 1;
const limit = query?.limit || 20;
const qb = this.userRepository.createQueryBuilder('user')
.skip((page - 1) * limit)
.take(limit);

if (query?.search) {
qb.andWhere('(user.email ILIKE :s OR user.firstName ILIKE :s OR user.lastName ILIKE :s)', { s: `%${query.search}%` });
}
if (query?.role) {
qb.andWhere('user.role = :role', { role: query.role });
}

const [items, total] = await qb.getManyAndCount();
return {
items: items.map((u) => this.sanitize(u)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

async findById(id: string): Promise<User> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) throw new NotFoundException(`User ${id} not found`);
return user;
}

async findByEmail(email: string): Promise<User | null> {
return this.userRepository.findOne({ where: { email } });
}

async create(dto: { email: string; password: string; firstName: string; lastName: string; role?: UserRole }) {
const existing = await this.findByEmail(dto.email);
if (existing) throw new ConflictException('Email already in use');

const passwordHash = await bcrypt.hash(dto.password, 10);
const user = this.userRepository.create({
email: dto.email,
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
role: dto.role || UserRole.EMPLOYEE,
});
const saved = await this.userRepository.save(user);
return this.sanitize(saved);
}

async updateRole(id: string, newRole: UserRole, requestingUserId?: string) {
if (requestingUserId && requestingUserId === id) {
throw new ForbiddenException('Cannot change your own role');
}
const user = await this.findById(id);
user.role = newRole;
const saved = await this.userRepository.save(user);
return this.sanitize(saved);
}

async setActive(id: string, isActive: boolean) {
const user = await this.findById(id);
user.isActive = isActive;
const saved = await this.userRepository.save(user);
return this.sanitize(saved);
}

sanitize(user: User): User {
const { passwordHash, ...safe } = user;
return safe as User;
}
}
Loading