From b91d5b05570117d34349cbc9aaba597e58b3e411 Mon Sep 17 00:00:00 2001 From: Nekolandd Date: Sat, 27 Jun 2026 22:51:03 -0600 Subject: [PATCH] feat(profile): add user profile page and api integration --- apps/backend/src/app.module.ts | 2 + .../modules/profile/dto/update-profile.dto.ts | 3 + .../profile/interfaces/profile.interface.ts | 19 ++ .../src/modules/profile/profile.controller.ts | 40 +++ .../src/modules/profile/profile.module.ts | 10 + .../modules/profile/profile.service.spec.ts | 141 ++++++++++ .../src/modules/profile/profile.service.ts | 131 +++++++++ apps/web/app/profile/page.spec.tsx | 108 ++++++++ apps/web/app/profile/page.tsx | 248 ++++++++++++++++++ apps/web/app/profile/profile.css | 189 +++++++++++++ apps/web/lib/api/profile.ts | 45 ++++ apps/web/lib/types/profile.ts | 33 +++ apps/web/tsconfig.json | 2 +- 13 files changed, 970 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/modules/profile/dto/update-profile.dto.ts create mode 100644 apps/backend/src/modules/profile/interfaces/profile.interface.ts create mode 100644 apps/backend/src/modules/profile/profile.controller.ts create mode 100644 apps/backend/src/modules/profile/profile.module.ts create mode 100644 apps/backend/src/modules/profile/profile.service.spec.ts create mode 100644 apps/backend/src/modules/profile/profile.service.ts create mode 100644 apps/web/app/profile/page.spec.tsx create mode 100644 apps/web/app/profile/page.tsx create mode 100644 apps/web/app/profile/profile.css create mode 100644 apps/web/lib/api/profile.ts create mode 100644 apps/web/lib/types/profile.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index e2c6964..bc285a7 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { ChainsModule } from './modules/chains/chains.module'; import { RiskAnalyzerModule } from './modules/soroban/risk/risk-analyzer.module'; import { NotesModule } from './modules/cases/notes/notes.module'; import { AlertsModule } from './modules/alerts/alerts.module'; +import { ProfileModule } from './modules/profile/profile.module'; @Module({ imports: [ @@ -25,6 +26,7 @@ import { AlertsModule } from './modules/alerts/alerts.module'; RiskAnalyzerModule, NotesModule, AlertsModule, + ProfileModule, ], controllers: [AppController], }) diff --git a/apps/backend/src/modules/profile/dto/update-profile.dto.ts b/apps/backend/src/modules/profile/dto/update-profile.dto.ts new file mode 100644 index 0000000..92c3427 --- /dev/null +++ b/apps/backend/src/modules/profile/dto/update-profile.dto.ts @@ -0,0 +1,3 @@ +export class UpdateProfileDto { + email?: string; +} diff --git a/apps/backend/src/modules/profile/interfaces/profile.interface.ts b/apps/backend/src/modules/profile/interfaces/profile.interface.ts new file mode 100644 index 0000000..cef5fee --- /dev/null +++ b/apps/backend/src/modules/profile/interfaces/profile.interface.ts @@ -0,0 +1,19 @@ +export interface NotificationPreferencesSummary { + discordEnabled: boolean; + telegramEnabled: boolean; + emailEnabled: boolean; + alertTypes: string[]; +} + +export interface AccountMetadata { + watchlistCount: number; + openAlertsCount: number; + notificationPreferences: NotificationPreferencesSummary | null; +} + +export interface UserProfile { + id: string; + email: string; + createdAt: string; + metadata: AccountMetadata; +} diff --git a/apps/backend/src/modules/profile/profile.controller.ts b/apps/backend/src/modules/profile/profile.controller.ts new file mode 100644 index 0000000..7d859b1 --- /dev/null +++ b/apps/backend/src/modules/profile/profile.controller.ts @@ -0,0 +1,40 @@ +import { Body, Controller, Get, Headers, Patch, UnauthorizedException } from '@nestjs/common'; +import { ProfileService } from './profile.service'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { UserProfile } from './interfaces/profile.interface'; + +/** + * User profile endpoints. + * + * GET /api/profile — read profile and account metadata + * PATCH /api/profile — update editable profile fields + * + * Auth: temporary `X-User-Id` header until login/JWT issues (#78, #80) land. + */ +@Controller('profile') +export class ProfileController { + constructor(private readonly profileService: ProfileService) {} + + @Get() + getProfile(@Headers('x-user-id') userId?: string): Promise { + const resolvedUserId = this.resolveUserId(userId); + return this.profileService.getProfile(resolvedUserId); + } + + @Patch() + updateProfile( + @Headers('x-user-id') userId: string | undefined, + @Body() dto: UpdateProfileDto, + ): Promise { + const resolvedUserId = this.resolveUserId(userId); + return this.profileService.updateProfile(resolvedUserId, dto); + } + + private resolveUserId(userId?: string): string { + const trimmed = userId?.trim(); + if (!trimmed) { + throw new UnauthorizedException('X-User-Id header is required'); + } + return trimmed; + } +} diff --git a/apps/backend/src/modules/profile/profile.module.ts b/apps/backend/src/modules/profile/profile.module.ts new file mode 100644 index 0000000..4300ce7 --- /dev/null +++ b/apps/backend/src/modules/profile/profile.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ProfileController } from './profile.controller'; +import { ProfileService } from './profile.service'; + +@Module({ + controllers: [ProfileController], + providers: [ProfileService], + exports: [ProfileService], +}) +export class ProfileModule {} diff --git a/apps/backend/src/modules/profile/profile.service.spec.ts b/apps/backend/src/modules/profile/profile.service.spec.ts new file mode 100644 index 0000000..4925de3 --- /dev/null +++ b/apps/backend/src/modules/profile/profile.service.spec.ts @@ -0,0 +1,141 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { ProfileService } from './profile.service'; +import { PrismaClient } from '@prisma/client'; + +jest.mock('@prisma/client', () => { + const mPrismaClient = { + user: { + findUnique: jest.fn(), + update: jest.fn(), + }, + watchlist: { + count: jest.fn(), + }, + alert: { + count: jest.fn(), + }, + }; + return { PrismaClient: jest.fn(() => mPrismaClient) }; +}); + +describe('ProfileService', () => { + let service: ProfileService; + let prisma: PrismaClient; + + const mockUser = { + id: 'user-1', + email: 'user@example.com', + createdAt: new Date('2026-06-15T10:00:00.000Z'), + notificationPreferences: [ + { + discordEnabled: true, + telegramEnabled: false, + emailEnabled: true, + alertTypes: ['critical'], + }, + ], + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ProfileService], + }).compile(); + + service = module.get(ProfileService); + prisma = new PrismaClient(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getProfile', () => { + it('returns profile with metadata', async () => { + (prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser); + (prisma.watchlist.count as jest.Mock).mockResolvedValue(3); + (prisma.alert.count as jest.Mock).mockResolvedValue(2); + + const result = await service.getProfile('user-1'); + + expect(result).toEqual({ + id: 'user-1', + email: 'user@example.com', + createdAt: '2026-06-15T10:00:00.000Z', + metadata: { + watchlistCount: 3, + openAlertsCount: 2, + notificationPreferences: { + discordEnabled: true, + telegramEnabled: false, + emailEnabled: true, + alertTypes: ['critical'], + }, + }, + }); + }); + + it('throws NotFoundException when user does not exist', async () => { + (prisma.user.findUnique as jest.Mock).mockResolvedValue(null); + + await expect(service.getProfile('missing')).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateProfile', () => { + it('updates email and returns profile', async () => { + (prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser); + (prisma.user.update as jest.Mock).mockResolvedValue({ + ...mockUser, + email: 'new@example.com', + }); + (prisma.watchlist.count as jest.Mock).mockResolvedValue(1); + (prisma.alert.count as jest.Mock).mockResolvedValue(0); + + const result = await service.updateProfile('user-1', { email: 'new@example.com' }); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { email: 'new@example.com' }, + include: { + notificationPreferences: { + take: 1, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + expect(result.email).toBe('new@example.com'); + }); + + it('throws BadRequestException for invalid email', async () => { + await expect(service.updateProfile('user-1', { email: 'not-an-email' })).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws BadRequestException when email is missing', async () => { + await expect(service.updateProfile('user-1', {})).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when user does not exist', async () => { + (prisma.user.findUnique as jest.Mock).mockResolvedValue(null); + + await expect(service.updateProfile('missing', { email: 'a@b.com' })).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws ConflictException on duplicate email', async () => { + (prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser); + (prisma.user.update as jest.Mock).mockRejectedValue({ code: 'P2002' }); + + await expect(service.updateProfile('user-1', { email: 'taken@example.com' })).rejects.toThrow( + ConflictException, + ); + }); + }); +}); diff --git a/apps/backend/src/modules/profile/profile.service.ts b/apps/backend/src/modules/profile/profile.service.ts new file mode 100644 index 0000000..88fd6ac --- /dev/null +++ b/apps/backend/src/modules/profile/profile.service.ts @@ -0,0 +1,131 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { UserProfile } from './interfaces/profile.interface'; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +@Injectable() +export class ProfileService { + private prisma: PrismaClient; + + constructor() { + this.prisma = new PrismaClient(); + } + + async getProfile(userId: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + notificationPreferences: { + take: 1, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!user) { + throw new NotFoundException(`User ${userId} not found`); + } + + const [watchlistCount, openAlertsCount] = await Promise.all([ + this.prisma.watchlist.count({ where: { userId } }), + this.prisma.alert.count({ where: { userId, status: 'open' } }), + ]); + + return this.toUserProfile(user, watchlistCount, openAlertsCount); + } + + async updateProfile(userId: string, dto: UpdateProfileDto): Promise { + const email = dto.email?.trim(); + + if (!email) { + throw new BadRequestException('email is required'); + } + + if (!EMAIL_REGEX.test(email)) { + throw new BadRequestException('email format is invalid'); + } + + const existing = await this.prisma.user.findUnique({ where: { id: userId } }); + + if (!existing) { + throw new NotFoundException(`User ${userId} not found`); + } + + try { + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { email }, + include: { + notificationPreferences: { + take: 1, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + const [watchlistCount, openAlertsCount] = await Promise.all([ + this.prisma.watchlist.count({ where: { userId } }), + this.prisma.alert.count({ where: { userId, status: 'open' } }), + ]); + + return this.toUserProfile(user, watchlistCount, openAlertsCount); + } catch (error: unknown) { + if (this.isUniqueConstraintError(error)) { + throw new ConflictException('Email is already in use'); + } + throw error; + } + } + + private toUserProfile( + user: { + id: string; + email: string; + createdAt: Date; + notificationPreferences: Array<{ + discordEnabled: boolean; + telegramEnabled: boolean; + emailEnabled: boolean; + alertTypes: string[]; + }>; + }, + watchlistCount: number, + openAlertsCount: number, + ): UserProfile { + const prefs = user.notificationPreferences[0] ?? null; + + return { + id: user.id, + email: user.email, + createdAt: user.createdAt.toISOString(), + metadata: { + watchlistCount, + openAlertsCount, + notificationPreferences: prefs + ? { + discordEnabled: prefs.discordEnabled, + telegramEnabled: prefs.telegramEnabled, + emailEnabled: prefs.emailEnabled, + alertTypes: prefs.alertTypes, + } + : null, + }, + }; + } + + private isUniqueConstraintError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code: string }).code === 'P2002' + ); + } +} diff --git a/apps/web/app/profile/page.spec.tsx b/apps/web/app/profile/page.spec.tsx new file mode 100644 index 0000000..1419386 --- /dev/null +++ b/apps/web/app/profile/page.spec.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UserProfilePage } from './page'; +import { UserProfile } from '../../lib/types/profile'; + +const mockProfile: UserProfile = { + id: 'user-1', + email: 'user@example.com', + createdAt: '2026-06-15T10:00:00.000Z', + metadata: { + watchlistCount: 2, + openAlertsCount: 1, + notificationPreferences: { + discordEnabled: true, + telegramEnabled: false, + emailEnabled: true, + alertTypes: ['critical'], + }, + }, +}; + +describe('UserProfilePage', () => { + beforeEach(() => { + window.localStorage.clear(); + jest.restoreAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders the profile page heading', () => { + render(); + expect(screen.getByRole('heading', { name: /user profile/i })).toBeInTheDocument(); + }); + + it('loads and displays profile data', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockProfile, + } as Response); + + window.localStorage.setItem('sentinel_user_id', 'user-1'); + render(); + + await waitFor(() => { + expect(screen.getByLabelText('Email')).toHaveValue('user@example.com'); + }); + + expect(screen.getByLabelText('Account ID')).toHaveValue('user-1'); + expect(screen.getByText(/watchlist entries/i)).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('submits profile updates', async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => mockProfile, + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ ...mockProfile, email: 'updated@example.com' }), + } as Response); + global.fetch = fetchMock; + + window.localStorage.setItem('sentinel_user_id', 'user-1'); + render(); + + await waitFor(() => { + expect(screen.getByLabelText('Email')).toHaveValue('user@example.com'); + }); + + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: 'updated@example.com' }, + }); + fireEvent.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(screen.getByText(/profile updated successfully/i)).toBeInTheDocument(); + }); + + expect(fetchMock).toHaveBeenLastCalledWith( + expect.stringContaining('/profile'), + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ email: 'updated@example.com' }), + }), + ); + }); + + it('shows an error when profile loading fails', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ message: 'User missing not found' }), + } as Response); + + window.localStorage.setItem('sentinel_user_id', 'missing'); + render(); + + await waitFor(() => { + expect(screen.getByText(/user missing not found/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx new file mode 100644 index 0000000..b67995c --- /dev/null +++ b/apps/web/app/profile/page.tsx @@ -0,0 +1,248 @@ +'use client'; + +import React, { useCallback, useEffect, useState } from 'react'; +import { getProfile, updateProfile } from '../../lib/api/profile'; +import { ProfileApiError, UserProfile } from '../../lib/types/profile'; +import './profile.css'; + +const USER_ID_STORAGE_KEY = 'sentinel_user_id'; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(); +} + +function resolveInitialUserId(): string { + if (typeof window === 'undefined') { + return process.env.NEXT_PUBLIC_USER_ID ?? ''; + } + + return window.localStorage.getItem(USER_ID_STORAGE_KEY) ?? process.env.NEXT_PUBLIC_USER_ID ?? ''; +} + +export const UserProfilePage: React.FC = () => { + const [userId, setUserId] = useState(''); + const [userIdInput, setUserIdInput] = useState(''); + const [profile, setProfile] = useState(null); + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + const [statusType, setStatusType] = useState<'info' | 'success' | 'error'>('info'); + + const loadProfile = useCallback(async (resolvedUserId: string) => { + if (!resolvedUserId.trim()) { + setProfile(null); + setStatusMessage('Enter your user ID to load the profile.'); + setStatusType('info'); + return; + } + + setLoading(true); + setStatusMessage(null); + + try { + const data = await getProfile(resolvedUserId.trim()); + setProfile(data); + setEmail(data.email); + setStatusMessage(null); + } catch (error) { + setProfile(null); + const message = error instanceof ProfileApiError ? error.message : 'Failed to load profile.'; + setStatusMessage(message); + setStatusType('error'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + const initialUserId = resolveInitialUserId(); + setUserId(initialUserId); + setUserIdInput(initialUserId); + void loadProfile(initialUserId); + }, [loadProfile]); + + const handleConnect = () => { + const trimmed = userIdInput.trim(); + setUserId(trimmed); + + if (typeof window !== 'undefined') { + if (trimmed) { + window.localStorage.setItem(USER_ID_STORAGE_KEY, trimmed); + } else { + window.localStorage.removeItem(USER_ID_STORAGE_KEY); + } + } + + void loadProfile(trimmed); + }; + + const handleSave = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!userId.trim()) { + setStatusMessage('User ID is required before saving.'); + setStatusType('error'); + return; + } + + setSaving(true); + setStatusMessage(null); + + try { + const updated = await updateProfile(userId.trim(), { email: email.trim() }); + setProfile(updated); + setEmail(updated.email); + setStatusMessage('Profile updated successfully.'); + setStatusType('success'); + } catch (error) { + const message = + error instanceof ProfileApiError ? error.message : 'Failed to update profile.'; + setStatusMessage(message); + setStatusType('error'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

User Profile

+

Manage your Sentinel account information and metadata.

+
+ +
+
+

Account Connection

+
+ + setUserIdInput(event.target.value)} + placeholder="Paste your Sentinel user ID" + aria-label="User ID" + /> +
+
+ + {loading && Loading...} +
+

+ Temporary auth via X-User-Id until login is implemented (#80). +

+
+ + {profile ? ( + <> +
+

Profile Information

+
+
+ + +
+
+ + setEmail(event.target.value)} + required + aria-label="Email" + /> +
+
+ + {statusMessage && ( + + {statusMessage} + + )} +
+
+
+ +
+

Account Metadata

+
    +
  • + Member since + {formatDate(profile.createdAt)} +
  • +
  • + Watchlist entries + {profile.metadata.watchlistCount} +
  • +
  • + Open alerts + {profile.metadata.openAlertsCount} +
  • +
+ + {profile.metadata.notificationPreferences ? ( +
+

Notification preferences

+
+ Discord + + {profile.metadata.notificationPreferences.discordEnabled + ? 'Enabled' + : 'Disabled'} + +
+
+ Telegram + + {profile.metadata.notificationPreferences.telegramEnabled + ? 'Enabled' + : 'Disabled'} + +
+
+ Email + + {profile.metadata.notificationPreferences.emailEnabled + ? 'Enabled' + : 'Disabled'} + +
+
+ ) : ( +

No notification preferences configured yet.

+ )} +
+ + ) : ( + !loading && ( +
+ {statusMessage ?? 'Connect with a user ID to view your profile.'} +
+ ) + )} +
+
+ ); +}; + +export default UserProfilePage; diff --git a/apps/web/app/profile/profile.css b/apps/web/app/profile/profile.css new file mode 100644 index 0000000..f12a0d5 --- /dev/null +++ b/apps/web/app/profile/profile.css @@ -0,0 +1,189 @@ +:root { + --color-bg-main: #0b0e14; + --color-bg-card: rgba(17, 22, 32, 0.75); + --color-border-card: rgba(255, 255, 255, 0.08); + --color-text-primary: #f8fafc; + --color-text-secondary: #94a3b8; + --color-accent: #8b5cf6; + --color-success: #10b981; + --color-error: #ef4444; +} + +.profile-container { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1.5rem; + min-height: 100vh; + background-color: var(--color-bg-main); + color: var(--color-text-primary); + font-family: + 'Outfit', + 'Inter', + -apple-system, + sans-serif; +} + +.profile-header { + margin-bottom: 2rem; +} + +.profile-title { + font-size: 2.25rem; + font-weight: 700; + letter-spacing: -0.025em; + margin: 0; + background: linear-gradient(135deg, #ffffff 0%, #94a3b8 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.profile-subtitle { + color: var(--color-text-secondary); + margin-top: 0.5rem; + font-size: 0.95rem; +} + +.profile-grid { + display: grid; + gap: 1.5rem; +} + +.profile-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border-card); + border-radius: 12px; + padding: 1.5rem; + backdrop-filter: blur(8px); +} + +.profile-card-title { + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.profile-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.profile-label { + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +.profile-input { + background: rgba(15, 23, 42, 0.8); + border: 1px solid var(--color-border-card); + border-radius: 8px; + color: var(--color-text-primary); + padding: 0.75rem 1rem; + font-size: 0.95rem; +} + +.profile-input:focus { + outline: 2px solid rgba(139, 92, 246, 0.5); + border-color: var(--color-accent); +} + +.profile-input:disabled, +.profile-input[readonly] { + opacity: 0.7; + cursor: not-allowed; +} + +.profile-meta-list { + display: grid; + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; +} + +.profile-meta-item { + display: flex; + justify-content: space-between; + gap: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--color-border-card); +} + +.profile-meta-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.profile-meta-label { + color: var(--color-text-secondary); + font-size: 0.875rem; +} + +.profile-meta-value { + font-size: 0.875rem; + text-align: right; +} + +.profile-actions { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 0.5rem; +} + +.profile-button { + background: var(--color-accent); + color: white; + border: none; + border-radius: 8px; + padding: 0.75rem 1.25rem; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; +} + +.profile-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.profile-status { + font-size: 0.875rem; +} + +.profile-status--success { + color: var(--color-success); +} + +.profile-status--error { + color: var(--color-error); +} + +.profile-status--info { + color: var(--color-text-secondary); +} + +.profile-dev-note { + margin-top: 1rem; + font-size: 0.8rem; + color: var(--color-text-secondary); +} + +.profile-prefs { + display: grid; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.profile-pref-row { + display: flex; + justify-content: space-between; + font-size: 0.875rem; +} + +.profile-empty { + text-align: center; + padding: 2rem 1rem; + color: var(--color-text-secondary); +} diff --git a/apps/web/lib/api/profile.ts b/apps/web/lib/api/profile.ts new file mode 100644 index 0000000..77d9f09 --- /dev/null +++ b/apps/web/lib/api/profile.ts @@ -0,0 +1,45 @@ +import { ProfileApiError, UpdateProfileRequest, UserProfile } from '../types/profile'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000/api'; + +function buildHeaders(userId: string): HeadersInit { + return { + 'Content-Type': 'application/json', + 'X-User-Id': userId, + }; +} + +async function parseResponse(response: Response): Promise { + const body = (await response.json().catch(() => ({}))) as { message?: string | string[] }; + + if (!response.ok) { + const message = Array.isArray(body.message) + ? body.message.join(', ') + : (body.message ?? `Request failed with status ${response.status}`); + throw new ProfileApiError(message, response.status); + } + + return body as T; +} + +export async function getProfile(userId: string): Promise { + const response = await fetch(`${API_BASE}/profile`, { + method: 'GET', + headers: buildHeaders(userId), + }); + + return parseResponse(response); +} + +export async function updateProfile( + userId: string, + payload: UpdateProfileRequest, +): Promise { + const response = await fetch(`${API_BASE}/profile`, { + method: 'PATCH', + headers: buildHeaders(userId), + body: JSON.stringify(payload), + }); + + return parseResponse(response); +} diff --git a/apps/web/lib/types/profile.ts b/apps/web/lib/types/profile.ts new file mode 100644 index 0000000..1c5a412 --- /dev/null +++ b/apps/web/lib/types/profile.ts @@ -0,0 +1,33 @@ +export interface NotificationPreferencesSummary { + discordEnabled: boolean; + telegramEnabled: boolean; + emailEnabled: boolean; + alertTypes: string[]; +} + +export interface AccountMetadata { + watchlistCount: number; + openAlertsCount: number; + notificationPreferences: NotificationPreferencesSummary | null; +} + +export interface UserProfile { + id: string; + email: string; + createdAt: string; + metadata: AccountMetadata; +} + +export interface UpdateProfileRequest { + email: string; +} + +export class ProfileApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = 'ProfileApiError'; + } +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index a3043a0..e13c3fa 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -12,6 +12,6 @@ "noEmit": true, "types": ["node", "react", "react-dom"] }, - "include": ["app/**/*", "src/**/*"], + "include": ["app/**/*", "lib/**/*", "src/**/*"], "exclude": ["node_modules", "dist"] }