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
55 changes: 55 additions & 0 deletions backend/src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { AuthRole } from '../auth/enums/auth-role.enum';
import { JwtPayload } from '../auth/interfaces/jwt-payload.interface';
import { AdminService } from './admin.service';

@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(AuthRole.ADMIN)
export class AdminController {
constructor(private readonly adminService: AdminService) {}

@Post('mentors/:id/verify')
verifyMentor(
@Param('id') id: string,
@Body() body: { notes?: string },
@Req() req: Request & { user?: JwtPayload },
) {
return this.adminService.verifyMentor(id, req.user!.sub, body.notes);
}

@Delete('mentors/:id/verify')
revokeVerification(
@Param('id') id: string,
@Req() req: Request & { user?: JwtPayload },
) {
return this.adminService.revokeVerification(id, req.user!.sub);
}

@Get('users/:userId/profile-history')
getProfileHistory(
@Param('userId') userId: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.adminService.getProfileHistory(
userId,
limit ? Math.min(parseInt(limit, 10) || 50, 500) : 50,
offset ? parseInt(offset, 10) || 0 : 0,
);
}
}
15 changes: 15 additions & 0 deletions backend/src/admin/admin.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../users/entities/user.entity';
import { ProfileHistory } from '../users/entities/profile-history.entity';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { ProfileHistorySubscriber } from './profile-history.subscriber';
import { AuthModule } from '../auth/auth.module';

@Module({
imports: [TypeOrmModule.forFeature([User, ProfileHistory]), AuthModule],
controllers: [AdminController],
providers: [AdminService, ProfileHistorySubscriber],
})
export class AdminModule {}
101 changes: 101 additions & 0 deletions backend/src/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { AdminService } from './admin.service';
import { User } from '../users/entities/user.entity';
import { ProfileHistory } from '../users/entities/profile-history.entity';

const mockUser = (): User =>
({
id: 'user-1',
walletAddress: 'GABC',
isVerified: false,
verifiedAt: null,
verifiedBy: null,
verificationNotes: null,
tokenVersion: 0,
roles: [],
}) as unknown as User;

describe('AdminService', () => {
let service: AdminService;

const userRepo = {
findOne: jest.fn(),
save: jest.fn((u: User) => Promise.resolve(u)),
};

const historyRepo = {
findAndCount: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{ provide: getRepositoryToken(User), useValue: userRepo },
{ provide: getRepositoryToken(ProfileHistory), useValue: historyRepo },
],
}).compile();

service = module.get(AdminService);
jest.clearAllMocks();
});

describe('verifyMentor', () => {
it('sets isVerified=true and saves', async () => {
const user = mockUser();
userRepo.findOne.mockResolvedValue(user);

const result = await service.verifyMentor('user-1', 'admin-1', 'Verified credentials');

expect(result.isVerified).toBe(true);
expect(result.verifiedBy).toBe('admin-1');
expect(result.verificationNotes).toBe('Verified credentials');
expect(result.verifiedAt).toBeInstanceOf(Date);
expect(userRepo.save).toHaveBeenCalledWith(user);
});

it('throws NotFoundException when user not found', async () => {
userRepo.findOne.mockResolvedValue(null);
await expect(service.verifyMentor('missing', 'admin-1')).rejects.toThrow(NotFoundException);
});
});

describe('revokeVerification', () => {
it('sets isVerified=false and clears fields', async () => {
const user = { ...mockUser(), isVerified: true, verifiedAt: new Date(), verifiedBy: 'admin-1' } as User;
userRepo.findOne.mockResolvedValue(user);

const result = await service.revokeVerification('user-1', 'admin-2');

expect(result.isVerified).toBe(false);
expect(result.verifiedAt).toBeNull();
expect(result.verifiedBy).toBe('admin-2');
expect(result.verificationNotes).toBeNull();
});

it('throws NotFoundException when user not found', async () => {
userRepo.findOne.mockResolvedValue(null);
await expect(service.revokeVerification('missing', 'admin-1')).rejects.toThrow(NotFoundException);
});
});

describe('getProfileHistory', () => {
it('returns paginated history for a user', async () => {
const items = [{ id: 'h1', userId: 'user-1', fieldName: 'isVerified' }];
historyRepo.findAndCount.mockResolvedValue([items, 1]);

const result = await service.getProfileHistory('user-1', 10, 0);

expect(result.items).toEqual(items);
expect(result.total).toBe(1);
expect(historyRepo.findAndCount).toHaveBeenCalledWith({
where: { userId: 'user-1' },
order: { changedAt: 'DESC' },
take: 10,
skip: 0,
});
});
});
});
53 changes: 53 additions & 0 deletions backend/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../users/entities/user.entity';
import { ProfileHistory } from '../users/entities/profile-history.entity';

@Injectable()
export class AdminService {
constructor(
@InjectRepository(User) private readonly userRepo: Repository<User>,
@InjectRepository(ProfileHistory) private readonly historyRepo: Repository<ProfileHistory>,
) {}

async verifyMentor(
mentorId: string,
adminId: string,
notes?: string,
): Promise<User> {
const user = await this.userRepo.findOne({ where: { id: mentorId } });
if (!user) throw new NotFoundException('Mentor not found');

user.isVerified = true;
user.verifiedAt = new Date();
user.verifiedBy = adminId;
user.verificationNotes = notes ?? null;
return this.userRepo.save(user);
}

async revokeVerification(mentorId: string, adminId: string): Promise<User> {
const user = await this.userRepo.findOne({ where: { id: mentorId } });
if (!user) throw new NotFoundException('Mentor not found');

user.isVerified = false;
user.verifiedAt = null;
user.verifiedBy = adminId;
user.verificationNotes = null;
return this.userRepo.save(user);
}

async getProfileHistory(
userId: string,
limit = 50,
offset = 0,
): Promise<{ items: ProfileHistory[]; total: number }> {
const [items, total] = await this.historyRepo.findAndCount({
where: { userId },
order: { changedAt: 'DESC' },
take: limit,
skip: offset,
});
return { items, total };
}
}
67 changes: 67 additions & 0 deletions backend/src/admin/profile-history.subscriber.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ProfileHistorySubscriber } from './profile-history.subscriber';
import { ChangeReason, ProfileHistory } from '../users/entities/profile-history.entity';
import { User } from '../users/entities/user.entity';
import { UpdateEvent } from 'typeorm';

const makeEvent = (entity: Partial<User>, databaseEntity: Partial<User>) => {
const saved: ProfileHistory[] = [];
return {
entity,
databaseEntity,
manager: {
save: jest.fn((_cls: unknown, entries: ProfileHistory[]) => {
saved.push(...entries);
return Promise.resolve(entries);
}),
},
_saved: saved,
} as unknown as UpdateEvent<User> & { _saved: ProfileHistory[] };
};

describe('ProfileHistorySubscriber', () => {
let subscriber: ProfileHistorySubscriber;

beforeEach(() => {
const fakeDataSource = { subscribers: [] } as never;
subscriber = new ProfileHistorySubscriber(fakeDataSource);
});

it('listens to User entity', () => {
expect(subscriber.listenTo()).toBe(User);
});

it('captures changed tracked fields', async () => {
const event = makeEvent(
{ id: 'u1', isVerified: true, verifiedBy: 'admin-1', verifiedAt: new Date(), verificationNotes: 'ok' },
{ id: 'u1', isVerified: false, verifiedBy: null, verifiedAt: null, verificationNotes: null },
);

await subscriber.afterUpdate(event);

expect(event.manager.save).toHaveBeenCalled();
const entries = event._saved;
expect(entries.length).toBeGreaterThan(0);
const isVerifiedEntry = entries.find((e) => e.fieldName === 'isVerified');
expect(isVerifiedEntry).toBeDefined();
expect(isVerifiedEntry!.oldValue).toBe(false);
expect(isVerifiedEntry!.newValue).toBe(true);
expect(isVerifiedEntry!.changeReason).toBe(ChangeReason.ADMIN_EDIT);
});

it('does nothing when no tracked fields changed', async () => {
const event = makeEvent(
{ id: 'u1', isVerified: false },
{ id: 'u1', isVerified: false },
);

await subscriber.afterUpdate(event);

expect(event.manager.save).not.toHaveBeenCalled();
});

it('skips when entity is undefined', async () => {
const event = makeEvent(undefined as never, { id: 'u1' });
await subscriber.afterUpdate(event);
expect(event.manager.save).not.toHaveBeenCalled();
});
});
52 changes: 52 additions & 0 deletions backend/src/admin/profile-history.subscriber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { DataSource, EntitySubscriberInterface, EventSubscriber, UpdateEvent } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { Injectable } from '@nestjs/common';
import { User } from '../users/entities/user.entity';
import { ChangeReason, ProfileHistory } from '../users/entities/profile-history.entity';

const TRACKED_FIELDS: (keyof User)[] = ['isVerified', 'verifiedAt', 'verifiedBy', 'verificationNotes'];

@Injectable()
@EventSubscriber()
export class ProfileHistorySubscriber implements EntitySubscriberInterface<User> {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {
dataSource.subscribers.push(this);
}

listenTo() {
return User;
}

async afterUpdate(event: UpdateEvent<User>): Promise<void> {
const entity = event.entity as Partial<User> | undefined;
const databaseEntity = event.databaseEntity as Partial<User> | undefined;
if (!entity || !databaseEntity) return;

const userId = (entity.id ?? databaseEntity.id) as string | undefined;
if (!userId) return;

const changedBy = (entity.verifiedBy ?? databaseEntity.verifiedBy ?? 'system') as string;
const changeReason = entity.verifiedBy ? ChangeReason.ADMIN_EDIT : ChangeReason.SYSTEM;

const entries: ProfileHistory[] = [];
for (const field of TRACKED_FIELDS) {
const oldVal = databaseEntity[field];
const newVal = entity[field];
if (newVal !== undefined && oldVal !== newVal) {
const entry = new ProfileHistory();
entry.userId = userId;
entry.fieldName = field;
entry.oldValue = oldVal ?? null;
entry.newValue = newVal ?? null;
entry.changedBy = changedBy;
entry.changeReason = changeReason;
entry.changedAt = new Date();
entries.push(entry);
}
}

if (entries.length > 0) {
await event.manager.save(ProfileHistory, entries);
}
}
}
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AuthModule } from './auth/auth.module';
import { RedisModule } from './redis/redis.module';
import { HealthModule } from './health/health.module';
import { UsersModule } from './users/users.module';
import { AdminModule } from './admin/admin.module';

@Module({
imports: [
Expand All @@ -19,6 +20,7 @@ import { UsersModule } from './users/users.module';
}),
AuthModule,
UsersModule,
AdminModule,
RedisModule,
HealthModule,
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';

export class AddVerificationFieldsToUsers1763000000000 implements MigrationInterface {
name = 'AddVerificationFieldsToUsers1763000000000';

async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumns('users', [
new TableColumn({ name: 'is_verified', type: 'boolean', default: false, isNullable: false }),
new TableColumn({ name: 'verified_at', type: 'timestamptz', isNullable: true }),
new TableColumn({ name: 'verified_by', type: 'varchar', length: '128', isNullable: true }),
new TableColumn({ name: 'verification_notes', type: 'text', isNullable: true }),
]);
}

async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumns('users', [
'is_verified',
'verified_at',
'verified_by',
'verification_notes',
]);
}
}
Loading