Skip to content

Commit da2571d

Browse files
authored
Merge pull request #1039 from No-bodyq/feat/issue-986-user-module-structure
Complete User module: account CRUD, email field, admin listing
2 parents 2d3bf65 + 14981d0 commit da2571d

11 files changed

Lines changed: 442 additions & 5 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddEmailToUsers1722000000000 implements MigrationInterface {
4+
name = 'AddEmailToUsers1722000000000';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`
8+
ALTER TABLE "users" ADD COLUMN "email" varchar(255)
9+
`);
10+
11+
await queryRunner.query(`
12+
CREATE UNIQUE INDEX "UQ_users_email" ON "users" ("email") WHERE "email" IS NOT NULL
13+
`);
14+
}
15+
16+
public async down(queryRunner: QueryRunner): Promise<void> {
17+
await queryRunner.query(`DROP INDEX IF EXISTS "UQ_users_email"`);
18+
await queryRunner.query(
19+
`ALTER TABLE "users" DROP COLUMN IF EXISTS "email"`,
20+
);
21+
}
22+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
2+
3+
export class UpdateUserDto {
4+
@IsOptional()
5+
@IsString()
6+
@MaxLength(100)
7+
username?: string;
8+
9+
@IsOptional()
10+
@IsString()
11+
@MaxLength(150)
12+
displayName?: string;
13+
14+
@IsOptional()
15+
@IsEmail()
16+
@MaxLength(255)
17+
email?: string;
18+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Type } from 'class-transformer';
2+
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
3+
import { UserStatus } from '../enums/user-status.enum.js';
4+
5+
export class UserQueryDto {
6+
@IsOptional()
7+
@IsEnum(UserStatus)
8+
status?: UserStatus;
9+
10+
@IsOptional()
11+
@Type(() => Number)
12+
@IsInt()
13+
@Min(1)
14+
page?: number = 1;
15+
16+
@IsOptional()
17+
@Type(() => Number)
18+
@IsInt()
19+
@Min(1)
20+
@Max(100)
21+
limit?: number = 20;
22+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { User } from '../entities/user.entity.js';
2+
import { UserStatus } from '../enums/user-status.enum.js';
3+
import { AuthRole } from '../../common/enums/auth-role.enum.js';
4+
5+
export class UserResponseDto {
6+
id: string;
7+
walletAddress: string;
8+
username: string | null;
9+
displayName: string | null;
10+
email: string | null;
11+
status: UserStatus;
12+
roles: AuthRole[];
13+
createdAt: Date;
14+
updatedAt: Date;
15+
16+
static fromEntity(user: User): UserResponseDto {
17+
const dto = new UserResponseDto();
18+
dto.id = user.id;
19+
dto.walletAddress = user.walletAddress;
20+
dto.username = user.username ?? null;
21+
dto.displayName = user.displayName ?? null;
22+
dto.email = user.email ?? null;
23+
dto.status = user.status;
24+
dto.roles = (user.roles ?? []).map((role) => role.name);
25+
dto.createdAt = user.createdAt;
26+
dto.updatedAt = user.updatedAt;
27+
return dto;
28+
}
29+
}

backend/src/users/entities/user.entity.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export class User {
2323
@Column({ type: 'varchar', nullable: true })
2424
username: string;
2525

26+
@Column({ type: 'varchar', length: 255, nullable: true, unique: true })
27+
email: string;
28+
2629
@Column({ type: 'varchar', nullable: true })
2730
displayName: string;
2831

backend/src/users/users.controller.spec.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@ import { Test, TestingModule } from '@nestjs/testing';
22
import { UsersController } from './users.controller.js';
33
import { UsersService } from './users.service.js';
44
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard.js';
5+
import { RolesGuard } from '../auth/guards/roles.guard.js';
56
import { ThrottlerGuard } from '@nestjs/throttler';
7+
import { UserStatus } from './enums/user-status.enum.js';
8+
import { AuthRole } from '../common/enums/auth-role.enum.js';
69

710
describe('UsersController', () => {
811
let controller: UsersController;
912

1013
const mockUsersService = {
14+
findById: jest.fn(),
15+
updateUser: jest.fn(),
16+
deactivateUser: jest.fn(),
17+
findAll: jest.fn(),
1118
createMentorProfile: jest.fn(),
1219
createMenteeProfile: jest.fn(),
1320
updateMentorProfile: jest.fn(),
@@ -34,6 +41,8 @@ describe('UsersController', () => {
3441
})
3542
.overrideGuard(JwtAuthGuard)
3643
.useValue({ canActivate: () => true })
44+
.overrideGuard(RolesGuard)
45+
.useValue({ canActivate: () => true })
3746
.overrideGuard(ThrottlerGuard)
3847
.useValue({ canActivate: () => true })
3948
.compile();
@@ -45,6 +54,87 @@ describe('UsersController', () => {
4554
jest.clearAllMocks();
4655
});
4756

57+
const mockUserEntity = {
58+
id: 'user-1',
59+
walletAddress: 'test-wallet',
60+
username: 'skillsync-user',
61+
displayName: 'Skillsync User',
62+
email: 'user@example.com',
63+
status: UserStatus.ACTIVE,
64+
roles: [{ name: AuthRole.MENTOR }],
65+
tokenVersion: 0,
66+
createdAt: new Date('2024-01-01'),
67+
updatedAt: new Date('2024-01-02'),
68+
};
69+
70+
describe('getMe', () => {
71+
it('should return the requesting user', async () => {
72+
mockUsersService.findById.mockResolvedValue(mockUserEntity);
73+
74+
const result = await controller.getMe(mockRequest('user-1') as any);
75+
76+
expect(mockUsersService.findById).toHaveBeenCalledWith('user-1');
77+
expect(result).toMatchObject({
78+
id: 'user-1',
79+
walletAddress: 'test-wallet',
80+
email: 'user@example.com',
81+
roles: [AuthRole.MENTOR],
82+
});
83+
});
84+
});
85+
86+
describe('updateMe', () => {
87+
it('should update and return the requesting user', async () => {
88+
const dto = { displayName: 'New Name' };
89+
mockUsersService.updateUser.mockResolvedValue({
90+
...mockUserEntity,
91+
displayName: 'New Name',
92+
});
93+
94+
const result = await controller.updateMe(
95+
dto,
96+
mockRequest('user-1') as any,
97+
);
98+
99+
expect(mockUsersService.updateUser).toHaveBeenCalledWith('user-1', dto);
100+
expect(result.displayName).toBe('New Name');
101+
});
102+
});
103+
104+
describe('deleteMe', () => {
105+
it('should deactivate the requesting user', async () => {
106+
mockUsersService.deactivateUser.mockResolvedValue({
107+
...mockUserEntity,
108+
status: UserStatus.DELETED,
109+
});
110+
111+
const result = await controller.deleteMe(mockRequest('user-1') as any);
112+
113+
expect(mockUsersService.deactivateUser).toHaveBeenCalledWith('user-1');
114+
expect(result.status).toBe(UserStatus.DELETED);
115+
});
116+
});
117+
118+
describe('listUsers', () => {
119+
it('should return a paginated, mapped list of users', async () => {
120+
mockUsersService.findAll.mockResolvedValue({
121+
data: [mockUserEntity],
122+
total: 1,
123+
page: 1,
124+
limit: 20,
125+
});
126+
127+
const result = await controller.listUsers({ page: 1, limit: 20 });
128+
129+
expect(mockUsersService.findAll).toHaveBeenCalledWith({
130+
page: 1,
131+
limit: 20,
132+
});
133+
expect(result.total).toBe(1);
134+
expect(result.data[0]).toMatchObject({ id: 'user-1' });
135+
});
136+
});
137+
48138
describe('createProfile', () => {
49139
it('should create a mentor profile', async () => {
50140
const dto = {

backend/src/users/users.controller.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import {
22
Body,
33
Controller,
4+
Delete,
45
Get,
56
HttpCode,
67
HttpStatus,
78
Param,
89
Patch,
910
Post,
11+
Query,
1012
Req,
1113
UseGuards,
1214
} from '@nestjs/common';
@@ -15,15 +17,58 @@ import { UsersService } from './users.service.js';
1517
import { CreateProfileDto } from './dto/create-profile.dto.js';
1618
import { UpdateMentorProfileDto } from './dto/update-mentor-profile.dto.js';
1719
import { UpdateMenteeProfileDto } from './dto/update-mentee-profile.dto.js';
20+
import { UpdateUserDto } from './dto/update-user.dto.js';
21+
import { UserQueryDto } from './dto/user-query.dto.js';
22+
import { UserResponseDto } from './dto/user-response.dto.js';
1823
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard.js';
24+
import { RolesGuard } from '../auth/guards/roles.guard.js';
25+
import { Roles } from '../auth/decorators/roles.decorator.js';
1926
import { JwtAccessTokenPayload } from '../auth/interfaces/jwt-payload.interface.js';
27+
import { AuthRole } from '../common/enums/auth-role.enum.js';
2028

21-
@Controller('user/profile')
29+
@Controller('user')
2230
@UseGuards(JwtAuthGuard)
2331
export class UsersController {
2432
constructor(private readonly usersService: UsersService) {}
2533

26-
@Post()
34+
@Get()
35+
async getMe(
36+
@Req() req: Request & { user: JwtAccessTokenPayload },
37+
): Promise<UserResponseDto> {
38+
const user = await this.usersService.findById(req.user.sub);
39+
return UserResponseDto.fromEntity(user);
40+
}
41+
42+
@Patch()
43+
async updateMe(
44+
@Body() dto: UpdateUserDto,
45+
@Req() req: Request & { user: JwtAccessTokenPayload },
46+
): Promise<UserResponseDto> {
47+
const user = await this.usersService.updateUser(req.user.sub, dto);
48+
return UserResponseDto.fromEntity(user);
49+
}
50+
51+
@Delete()
52+
@HttpCode(HttpStatus.OK)
53+
async deleteMe(
54+
@Req() req: Request & { user: JwtAccessTokenPayload },
55+
): Promise<UserResponseDto> {
56+
const user = await this.usersService.deactivateUser(req.user.sub);
57+
return UserResponseDto.fromEntity(user);
58+
}
59+
60+
@Get('admin')
61+
@UseGuards(RolesGuard)
62+
@Roles(AuthRole.ADMIN)
63+
async listUsers(@Query() query: UserQueryDto) {
64+
const result = await this.usersService.findAll(query);
65+
return {
66+
...result,
67+
data: result.data.map((user) => UserResponseDto.fromEntity(user)),
68+
};
69+
}
70+
71+
@Post('profile')
2772
@HttpCode(HttpStatus.CREATED)
2873
async createProfile(
2974
@Body() dto: CreateProfileDto,
@@ -40,7 +85,7 @@ export class UsersController {
4085
return this.usersService.createMenteeProfile(userId, dto.menteeData ?? {});
4186
}
4287

43-
@Patch(':type')
88+
@Patch('profile/:type')
4489
@Throttle({ default: { limit: 30, ttl: 3600000 } })
4590
async updateProfile(
4691
@Param('type') type: string,
@@ -69,7 +114,7 @@ export class UsersController {
69114
return { message: 'Invalid profile type. Must be "mentor" or "mentee".' };
70115
}
71116

72-
@Get(':type')
117+
@Get('profile/:type')
73118
async getProfile(
74119
@Param('type') type: string,
75120
@Req() req: Request & { user: JwtAccessTokenPayload },

backend/src/users/users.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Role } from './entities/role.entity.js';
99
import { MentorProfile } from './entities/mentor-profile.entity.js';
1010
import { MenteeProfile } from './entities/mentee-profile.entity.js';
1111
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard.js';
12+
import { RolesGuard } from '../auth/guards/roles.guard.js';
1213

1314
@Module({
1415
imports: [
@@ -24,7 +25,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard.js';
2425
ConfigModule,
2526
],
2627
controllers: [UsersController],
27-
providers: [UsersService, JwtAuthGuard],
28+
providers: [UsersService, JwtAuthGuard, RolesGuard],
2829
exports: [UsersService],
2930
})
3031
export class UsersModule {}

0 commit comments

Comments
 (0)