Skip to content

Commit b91d5b0

Browse files
committed
feat(profile): add user profile page and api integration
1 parent 44051a0 commit b91d5b0

13 files changed

Lines changed: 970 additions & 1 deletion

File tree

apps/backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ChainsModule } from './modules/chains/chains.module';
1111
import { RiskAnalyzerModule } from './modules/soroban/risk/risk-analyzer.module';
1212
import { NotesModule } from './modules/cases/notes/notes.module';
1313
import { AlertsModule } from './modules/alerts/alerts.module';
14+
import { ProfileModule } from './modules/profile/profile.module';
1415

1516
@Module({
1617
imports: [
@@ -25,6 +26,7 @@ import { AlertsModule } from './modules/alerts/alerts.module';
2526
RiskAnalyzerModule,
2627
NotesModule,
2728
AlertsModule,
29+
ProfileModule,
2830
],
2931
controllers: [AppController],
3032
})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export class UpdateProfileDto {
2+
email?: string;
3+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export interface NotificationPreferencesSummary {
2+
discordEnabled: boolean;
3+
telegramEnabled: boolean;
4+
emailEnabled: boolean;
5+
alertTypes: string[];
6+
}
7+
8+
export interface AccountMetadata {
9+
watchlistCount: number;
10+
openAlertsCount: number;
11+
notificationPreferences: NotificationPreferencesSummary | null;
12+
}
13+
14+
export interface UserProfile {
15+
id: string;
16+
email: string;
17+
createdAt: string;
18+
metadata: AccountMetadata;
19+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Body, Controller, Get, Headers, Patch, UnauthorizedException } from '@nestjs/common';
2+
import { ProfileService } from './profile.service';
3+
import { UpdateProfileDto } from './dto/update-profile.dto';
4+
import { UserProfile } from './interfaces/profile.interface';
5+
6+
/**
7+
* User profile endpoints.
8+
*
9+
* GET /api/profile — read profile and account metadata
10+
* PATCH /api/profile — update editable profile fields
11+
*
12+
* Auth: temporary `X-User-Id` header until login/JWT issues (#78, #80) land.
13+
*/
14+
@Controller('profile')
15+
export class ProfileController {
16+
constructor(private readonly profileService: ProfileService) {}
17+
18+
@Get()
19+
getProfile(@Headers('x-user-id') userId?: string): Promise<UserProfile> {
20+
const resolvedUserId = this.resolveUserId(userId);
21+
return this.profileService.getProfile(resolvedUserId);
22+
}
23+
24+
@Patch()
25+
updateProfile(
26+
@Headers('x-user-id') userId: string | undefined,
27+
@Body() dto: UpdateProfileDto,
28+
): Promise<UserProfile> {
29+
const resolvedUserId = this.resolveUserId(userId);
30+
return this.profileService.updateProfile(resolvedUserId, dto);
31+
}
32+
33+
private resolveUserId(userId?: string): string {
34+
const trimmed = userId?.trim();
35+
if (!trimmed) {
36+
throw new UnauthorizedException('X-User-Id header is required');
37+
}
38+
return trimmed;
39+
}
40+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@nestjs/common';
2+
import { ProfileController } from './profile.controller';
3+
import { ProfileService } from './profile.service';
4+
5+
@Module({
6+
controllers: [ProfileController],
7+
providers: [ProfileService],
8+
exports: [ProfileService],
9+
})
10+
export class ProfileModule {}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
3+
import { ProfileService } from './profile.service';
4+
import { PrismaClient } from '@prisma/client';
5+
6+
jest.mock('@prisma/client', () => {
7+
const mPrismaClient = {
8+
user: {
9+
findUnique: jest.fn(),
10+
update: jest.fn(),
11+
},
12+
watchlist: {
13+
count: jest.fn(),
14+
},
15+
alert: {
16+
count: jest.fn(),
17+
},
18+
};
19+
return { PrismaClient: jest.fn(() => mPrismaClient) };
20+
});
21+
22+
describe('ProfileService', () => {
23+
let service: ProfileService;
24+
let prisma: PrismaClient;
25+
26+
const mockUser = {
27+
id: 'user-1',
28+
email: 'user@example.com',
29+
createdAt: new Date('2026-06-15T10:00:00.000Z'),
30+
notificationPreferences: [
31+
{
32+
discordEnabled: true,
33+
telegramEnabled: false,
34+
emailEnabled: true,
35+
alertTypes: ['critical'],
36+
},
37+
],
38+
};
39+
40+
beforeEach(async () => {
41+
const module: TestingModule = await Test.createTestingModule({
42+
providers: [ProfileService],
43+
}).compile();
44+
45+
service = module.get<ProfileService>(ProfileService);
46+
prisma = new PrismaClient();
47+
});
48+
49+
afterEach(() => {
50+
jest.clearAllMocks();
51+
});
52+
53+
it('should be defined', () => {
54+
expect(service).toBeDefined();
55+
});
56+
57+
describe('getProfile', () => {
58+
it('returns profile with metadata', async () => {
59+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser);
60+
(prisma.watchlist.count as jest.Mock).mockResolvedValue(3);
61+
(prisma.alert.count as jest.Mock).mockResolvedValue(2);
62+
63+
const result = await service.getProfile('user-1');
64+
65+
expect(result).toEqual({
66+
id: 'user-1',
67+
email: 'user@example.com',
68+
createdAt: '2026-06-15T10:00:00.000Z',
69+
metadata: {
70+
watchlistCount: 3,
71+
openAlertsCount: 2,
72+
notificationPreferences: {
73+
discordEnabled: true,
74+
telegramEnabled: false,
75+
emailEnabled: true,
76+
alertTypes: ['critical'],
77+
},
78+
},
79+
});
80+
});
81+
82+
it('throws NotFoundException when user does not exist', async () => {
83+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
84+
85+
await expect(service.getProfile('missing')).rejects.toThrow(NotFoundException);
86+
});
87+
});
88+
89+
describe('updateProfile', () => {
90+
it('updates email and returns profile', async () => {
91+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser);
92+
(prisma.user.update as jest.Mock).mockResolvedValue({
93+
...mockUser,
94+
email: 'new@example.com',
95+
});
96+
(prisma.watchlist.count as jest.Mock).mockResolvedValue(1);
97+
(prisma.alert.count as jest.Mock).mockResolvedValue(0);
98+
99+
const result = await service.updateProfile('user-1', { email: 'new@example.com' });
100+
101+
expect(prisma.user.update).toHaveBeenCalledWith({
102+
where: { id: 'user-1' },
103+
data: { email: 'new@example.com' },
104+
include: {
105+
notificationPreferences: {
106+
take: 1,
107+
orderBy: { createdAt: 'desc' },
108+
},
109+
},
110+
});
111+
expect(result.email).toBe('new@example.com');
112+
});
113+
114+
it('throws BadRequestException for invalid email', async () => {
115+
await expect(service.updateProfile('user-1', { email: 'not-an-email' })).rejects.toThrow(
116+
BadRequestException,
117+
);
118+
});
119+
120+
it('throws BadRequestException when email is missing', async () => {
121+
await expect(service.updateProfile('user-1', {})).rejects.toThrow(BadRequestException);
122+
});
123+
124+
it('throws NotFoundException when user does not exist', async () => {
125+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
126+
127+
await expect(service.updateProfile('missing', { email: 'a@b.com' })).rejects.toThrow(
128+
NotFoundException,
129+
);
130+
});
131+
132+
it('throws ConflictException on duplicate email', async () => {
133+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser);
134+
(prisma.user.update as jest.Mock).mockRejectedValue({ code: 'P2002' });
135+
136+
await expect(service.updateProfile('user-1', { email: 'taken@example.com' })).rejects.toThrow(
137+
ConflictException,
138+
);
139+
});
140+
});
141+
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {
2+
BadRequestException,
3+
ConflictException,
4+
Injectable,
5+
NotFoundException,
6+
} from '@nestjs/common';
7+
import { PrismaClient } from '@prisma/client';
8+
import { UpdateProfileDto } from './dto/update-profile.dto';
9+
import { UserProfile } from './interfaces/profile.interface';
10+
11+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
12+
13+
@Injectable()
14+
export class ProfileService {
15+
private prisma: PrismaClient;
16+
17+
constructor() {
18+
this.prisma = new PrismaClient();
19+
}
20+
21+
async getProfile(userId: string): Promise<UserProfile> {
22+
const user = await this.prisma.user.findUnique({
23+
where: { id: userId },
24+
include: {
25+
notificationPreferences: {
26+
take: 1,
27+
orderBy: { createdAt: 'desc' },
28+
},
29+
},
30+
});
31+
32+
if (!user) {
33+
throw new NotFoundException(`User ${userId} not found`);
34+
}
35+
36+
const [watchlistCount, openAlertsCount] = await Promise.all([
37+
this.prisma.watchlist.count({ where: { userId } }),
38+
this.prisma.alert.count({ where: { userId, status: 'open' } }),
39+
]);
40+
41+
return this.toUserProfile(user, watchlistCount, openAlertsCount);
42+
}
43+
44+
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<UserProfile> {
45+
const email = dto.email?.trim();
46+
47+
if (!email) {
48+
throw new BadRequestException('email is required');
49+
}
50+
51+
if (!EMAIL_REGEX.test(email)) {
52+
throw new BadRequestException('email format is invalid');
53+
}
54+
55+
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
56+
57+
if (!existing) {
58+
throw new NotFoundException(`User ${userId} not found`);
59+
}
60+
61+
try {
62+
const user = await this.prisma.user.update({
63+
where: { id: userId },
64+
data: { email },
65+
include: {
66+
notificationPreferences: {
67+
take: 1,
68+
orderBy: { createdAt: 'desc' },
69+
},
70+
},
71+
});
72+
73+
const [watchlistCount, openAlertsCount] = await Promise.all([
74+
this.prisma.watchlist.count({ where: { userId } }),
75+
this.prisma.alert.count({ where: { userId, status: 'open' } }),
76+
]);
77+
78+
return this.toUserProfile(user, watchlistCount, openAlertsCount);
79+
} catch (error: unknown) {
80+
if (this.isUniqueConstraintError(error)) {
81+
throw new ConflictException('Email is already in use');
82+
}
83+
throw error;
84+
}
85+
}
86+
87+
private toUserProfile(
88+
user: {
89+
id: string;
90+
email: string;
91+
createdAt: Date;
92+
notificationPreferences: Array<{
93+
discordEnabled: boolean;
94+
telegramEnabled: boolean;
95+
emailEnabled: boolean;
96+
alertTypes: string[];
97+
}>;
98+
},
99+
watchlistCount: number,
100+
openAlertsCount: number,
101+
): UserProfile {
102+
const prefs = user.notificationPreferences[0] ?? null;
103+
104+
return {
105+
id: user.id,
106+
email: user.email,
107+
createdAt: user.createdAt.toISOString(),
108+
metadata: {
109+
watchlistCount,
110+
openAlertsCount,
111+
notificationPreferences: prefs
112+
? {
113+
discordEnabled: prefs.discordEnabled,
114+
telegramEnabled: prefs.telegramEnabled,
115+
emailEnabled: prefs.emailEnabled,
116+
alertTypes: prefs.alertTypes,
117+
}
118+
: null,
119+
},
120+
};
121+
}
122+
123+
private isUniqueConstraintError(error: unknown): boolean {
124+
return (
125+
typeof error === 'object' &&
126+
error !== null &&
127+
'code' in error &&
128+
(error as { code: string }).code === 'P2002'
129+
);
130+
}
131+
}

0 commit comments

Comments
 (0)