From f7edb946f73b2df47b0009cc1f00f557255279ab Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Sun, 30 Nov 2025 00:27:21 -0800 Subject: [PATCH] more TS fixes + tests --- server.ts | 18 ++--- src/App.tsx | 2 + src/components/Leaderboard.test.tsx | 101 ++++++++++++++++++++++++ src/components/Leaderboard.tsx | 97 +++++++++++++++++++++++ src/routes/ai.ts | 20 +++-- src/routes/gamification.ts | 19 ++++- src/services/apiClient.ts | 10 +++ src/services/db/budget.test.ts | 112 +++++++++++++++++++++++++++ src/services/db/gamification.test.ts | 87 +++++++++++++++++++++ src/services/db/gamification.ts | 28 ++++++- src/types.ts | 12 +++ 11 files changed, 487 insertions(+), 19 deletions(-) create mode 100644 src/components/Leaderboard.test.tsx create mode 100644 src/components/Leaderboard.tsx create mode 100644 src/services/db/budget.test.ts create mode 100644 src/services/db/gamification.test.ts diff --git a/server.ts b/server.ts index 0dfdd9a9..85a1e2b1 100644 --- a/server.ts +++ b/server.ts @@ -107,22 +107,22 @@ if ((process.env.JWT_SECRET || 'your_super_secret_jwt_key_change_this') === 'you // The order of route registration is critical. // More specific routes should be registered before more general ones. -// 1. Public routes that require no authentication. -app.use('/api', publicRouter); -// 2. Authentication routes for login, registration, etc. +// 1. Authentication routes for login, registration, etc. app.use('/api/auth', authRouter); -// System routes for health checks, etc. +// 2. System routes for health checks, etc. app.use('/api/system', systemRouter); -// 3. AI routes, some of which use optional authentication. -app.use('/api/ai', aiRouter); -// 4. Admin routes, which are all protected by admin-level checks. -app.use('/api/admin', adminRouter); // This seems to be missing from the original file list, but is required. -// 5. General authenticated user routes. Mount this on a specific path to avoid ambiguity. +// 3. General authenticated user routes. app.use('/api/users', userRouter); +// 4. AI routes, some of which use optional authentication. +app.use('/api/ai', aiRouter); +// 5. Admin routes, which are all protected by admin-level checks. +app.use('/api/admin', adminRouter); // This seems to be missing from the original file list, but is required. // 6. Budgeting and spending analysis routes. app.use('/api/budgets', budgetRouter); // 7. Gamification routes for achievements. app.use('/api/achievements', gamificationRouter); +// 8. Public routes that require no authentication. This should be last among the API routes. +app.use('/api', publicRouter); // --- Error Handling and Server Startup --- diff --git a/src/App.tsx b/src/App.tsx index e92eb423..c902f8a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,7 @@ import { VoiceLabPage } from './pages/VoiceLabPage'; import { WhatsNewModal } from './components/WhatsNewModal'; import { FlyerCorrectionTool } from './components/FlyerCorrectionTool'; import { QuestionMarkCircleIcon } from './components/icons/QuestionMarkCircleIcon'; +import Leaderboard from './components/Leaderboard'; /** * Defines the possible authentication states for a user session. @@ -957,6 +958,7 @@ function App() { user={user} /> + )} diff --git a/src/components/Leaderboard.test.tsx b/src/components/Leaderboard.test.tsx new file mode 100644 index 00000000..d3b53321 --- /dev/null +++ b/src/components/Leaderboard.test.tsx @@ -0,0 +1,101 @@ +// src/components/Leaderboard.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; +import Leaderboard from './Leaderboard'; +import * as apiClient from '../services/apiClient'; +import { LeaderboardUser } from '../types'; + +// Mock the apiClient +vi.mock('../services/apiClient'); +const mockedApiClient = apiClient as Mocked; + +// Mock the logger +vi.mock('../services/logger', () => ({ + logger: { + error: vi.fn(), + }, +})); + +// Mock lucide-react icons to prevent rendering errors in the test environment +vi.mock('lucide-react', () => ({ + Award: () =>
, + Crown: () =>
, + ShieldAlert: () =>
, +})); + +const mockLeaderboardData: LeaderboardUser[] = [ + { user_id: 'user-1', full_name: 'Alice', avatar_url: null, points: 1000, rank: '1' }, + { user_id: 'user-2', full_name: 'Bob', avatar_url: 'http://example.com/bob.jpg', points: 950, rank: '2' }, + { user_id: 'user-3', full_name: 'Charlie', avatar_url: null, points: 900, rank: '3' }, + { user_id: 'user-4', full_name: 'Diana', avatar_url: null, points: 850, rank: '4' }, +]; + +describe('Leaderboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should display a loading message initially', () => { + // Mock a pending promise that never resolves to keep it in the loading state + mockedApiClient.fetchLeaderboard.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText('Loading Leaderboard...')).toBeInTheDocument(); + }); + + it('should display an error message if the API call fails', async () => { + mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(null, { status: 500 })); + render(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Error: Failed to fetch leaderboard data.')).toBeInTheDocument(); + }); + }); + + it('should display a message when the leaderboard is empty', async () => { + mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify([]))); + render(); + + await waitFor(() => { + expect(screen.getByText('The leaderboard is currently empty. Be the first to earn points!')).toBeInTheDocument(); + }); + }); + + it('should render the leaderboard with user data on successful fetch', async () => { + mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify(mockLeaderboardData))); + render(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Top Users' })).toBeInTheDocument(); + + // Check for user details + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('1000 pts')).toBeInTheDocument(); + + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('950 pts')).toBeInTheDocument(); + + // Check for correct avatar URLs + const bobAvatar = screen.getByAltText('Bob') as HTMLImageElement; + expect(bobAvatar.src).toBe('http://example.com/bob.jpg'); + + const aliceAvatar = screen.getByAltText('Alice') as HTMLImageElement; + expect(aliceAvatar.src).toContain('api.dicebear.com'); // Check for fallback avatar + }); + }); + + it('should render the correct rank icons', async () => { + mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify(mockLeaderboardData))); + render(); + + await waitFor(() => { + // Rank 1, 2, and 3 should have a crown icon + const crownIcons = screen.getAllByTestId('crown-icon'); + expect(crownIcons).toHaveLength(3); + + // Rank 4 should display the number + expect(screen.getByText('4')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/src/components/Leaderboard.tsx b/src/components/Leaderboard.tsx new file mode 100644 index 00000000..a1d47cbc --- /dev/null +++ b/src/components/Leaderboard.tsx @@ -0,0 +1,97 @@ +// src/components/Leaderboard.tsx +import React, { useState, useEffect } from 'react'; +import * as apiClient from '../services/apiClient'; +import { LeaderboardUser } from '../types'; +import { logger } from '../services/logger'; +import { Award, Crown, ShieldAlert } from 'lucide-react'; + +export const Leaderboard: React.FC = () => { + const [leaderboard, setLeaderboard] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadLeaderboard = async () => { + setIsLoading(true); + try { + const response = await apiClient.fetchLeaderboard(10); // Fetch top 10 users + if (!response.ok) { + throw new Error('Failed to fetch leaderboard data.'); + } + const data: LeaderboardUser[] = await response.json(); + setLeaderboard(data); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.'; + logger.error('Error fetching leaderboard:', { error: err }); + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + loadLeaderboard(); + }, []); + + const getRankIcon = (rank: string) => { + switch (rank) { + case '1': + return ; + case '2': + return ; + case '3': + return ; + default: + return {rank}; + } + }; + + if (isLoading) { + return
Loading Leaderboard...
; + } + + if (error) { + return ( +
+
+ +

Error: {error}

+
+
+ ); + } + + return ( +
+

+ + Top Users +

+ {leaderboard.length === 0 ? ( +

The leaderboard is currently empty. Be the first to earn points!

+ ) : ( +
    + {leaderboard.map((user) => ( +
  1. +
    + {getRankIcon(user.rank)} +
    + {user.full_name +
    +

    {user.full_name || 'Anonymous User'}

    +
    +
    + {user.points} pts +
    +
  2. + ))} +
+ )} +
+ ); +}; + +export default Leaderboard; \ No newline at end of file diff --git a/src/routes/ai.ts b/src/routes/ai.ts index 923f0bc5..37628ef1 100644 --- a/src/routes/ai.ts +++ b/src/routes/ai.ts @@ -12,7 +12,7 @@ const router = Router(); // --- Multer Configuration for File Uploads --- const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets'; -const storage = multer.diskStorage({ +const diskStorage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, storagePath); }, @@ -21,13 +21,17 @@ const storage = multer.diskStorage({ cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname); } }); -const upload = multer({ storage: storage }); +// 2. Memory storage for endpoints that only need to analyze the file in memory without saving it. +const memoryStorage = multer.memoryStorage(); + +const uploadToDisk = multer({ storage: diskStorage }); +const uploadToMemory = multer({ storage: memoryStorage }); /** * This endpoint processes a flyer using AI. It uses `optionalAuth` middleware to allow * both authenticated and anonymous users to upload flyers. */ -router.post('/process-flyer', optionalAuth, upload.array('flyerImages'), async (req: Request, res: Response, next: NextFunction) => { +router.post('/process-flyer', optionalAuth, uploadToMemory.array('flyerImages'), async (req: Request, res: Response, next: NextFunction) => { try { const files = req.files as Express.Multer.File[]; const totalSize = files ? files.reduce((acc, file) => acc + file.size, 0) : 0; @@ -70,7 +74,7 @@ router.post('/process-flyer', optionalAuth, upload.array('flyerImages'), async ( * in the flyer upload workflow after the AI has extracted the data. * It uses `optionalAuth` to handle submissions from both anonymous and authenticated users. */ -router.post('/flyers/process', optionalAuth, upload.single('flyerImage'), async (req: Request, res: Response, next: NextFunction) => { +router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'), async (req: Request, res: Response, next: NextFunction) => { try { if (!req.file) { return res.status(400).json({ message: 'Flyer image file is required.' }); @@ -125,7 +129,7 @@ router.post('/flyers/process', optionalAuth, upload.single('flyerImage'), async * This endpoint checks if an image is a flyer. It uses `optionalAuth` to allow * both authenticated and anonymous users to perform this check. */ -router.post('/check-flyer', optionalAuth, upload.single('image'), async (req, res, next) => { +router.post('/check-flyer', optionalAuth, uploadToMemory.single('image'), async (req, res, next) => { try { if (!req.file) { return res.status(400).json({ message: 'Image file is required.' }); @@ -137,7 +141,7 @@ router.post('/check-flyer', optionalAuth, upload.single('image'), async (req, re } }); -router.post('/extract-address', optionalAuth, upload.single('image'), async (req, res, next) => { +router.post('/extract-address', optionalAuth, uploadToMemory.single('image'), async (req, res, next) => { try { if (!req.file) { return res.status(400).json({ message: 'Image file is required.' }); @@ -149,7 +153,7 @@ router.post('/extract-address', optionalAuth, upload.single('image'), async (req } }); -router.post('/extract-logo', optionalAuth, upload.array('images'), async (req, res, next) => { +router.post('/extract-logo', optionalAuth, uploadToMemory.array('images'), async (req, res, next) => { try { if (!req.files || !Array.isArray(req.files) || req.files.length === 0) { return res.status(400).json({ message: 'Image files are required.' }); @@ -223,7 +227,7 @@ router.post('/generate-speech', passport.authenticate('jwt', { session: false }) router.post( '/rescan-area', passport.authenticate('jwt', { session: false }), - upload.single('image'), + uploadToMemory.single('image'), async (req: Request, res: Response, next: NextFunction) => { try { if (!req.file) { diff --git a/src/routes/gamification.ts b/src/routes/gamification.ts index 5f641f6f..e203f2d4 100644 --- a/src/routes/gamification.ts +++ b/src/routes/gamification.ts @@ -1,7 +1,7 @@ // src/routes/gamification.ts import express, { Request, Response } from 'express'; import passport, { isAdmin } from './passport'; -import { getAllAchievements, getUserAchievements, awardAchievement } from '../services/db'; +import { getAllAchievements, getUserAchievements, awardAchievement, getLeaderboard } from '../services/db'; import { logger } from '../services/logger'; import { User } from '../types'; @@ -21,6 +21,23 @@ router.get('/', async (req: Request, res: Response) => { } }); +/** + * GET /api/achievements/leaderboard - Get the top users by points. + * This is a public endpoint. + */ +router.get('/leaderboard', async (req: Request, res: Response) => { + // Allow client to specify a limit, but default to 10 and cap it at 50. + const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50); + + try { + const leaderboard = await getLeaderboard(limit); + res.json(leaderboard); + } catch (error) { + logger.error('Error fetching leaderboard:', { error }); + res.status(500).json({ message: 'Failed to fetch leaderboard.' }); + } +}); + /** * GET /api/achievements/me - Get all achievements for the authenticated user. * This is a protected endpoint. diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 23d0221c..1c8b1d28 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -915,6 +915,16 @@ export const getUserAchievements = async (tokenOverride?: string): Promise => { + // This is a public endpoint, so we can use standard fetch. + return fetch(`${API_BASE_URL}/achievements/leaderboard?limit=${limit}`); +}; + /** * Uploads a new avatar image for the authenticated user. * @param avatarFile The image file to upload. diff --git a/src/services/db/budget.test.ts b/src/services/db/budget.test.ts new file mode 100644 index 00000000..7a828a40 --- /dev/null +++ b/src/services/db/budget.test.ts @@ -0,0 +1,112 @@ +// src/services/db/budget.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getPool } from './connection'; +import { + getBudgetsForUser, + createBudget, + updateBudget, + deleteBudget, + getSpendingByCategory, +} from './budget'; +import type { Budget, SpendingByCategory } from '../../types'; + +// Mock the getPool function to return a mocked pool object. +const mockQuery = vi.fn(); +vi.mock('./connection', () => ({ + getPool: () => ({ + query: mockQuery, + }), +})); + +// Mock the logger to prevent console output during tests +vi.mock('../logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('Budget DB Service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getBudgetsForUser', () => { + it('should execute the correct SELECT query and return budgets', async () => { + const mockBudgets: Budget[] = [{ budget_id: 1, user_id: 'user-123', name: 'Groceries', amount_cents: 50000, period: 'monthly', start_date: '2024-01-01' }]; + mockQuery.mockResolvedValue({ rows: mockBudgets }); + + const result = await getBudgetsForUser('user-123'); + + expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.budgets WHERE user_id = $1 ORDER BY start_date DESC', ['user-123']); + expect(result).toEqual(mockBudgets); + }); + }); + + describe('createBudget', () => { + it('should execute an INSERT query and return the new budget', async () => { + const budgetData = { name: 'Groceries', amount_cents: 50000, period: 'monthly' as const, start_date: '2024-01-01' }; + const mockCreatedBudget: Budget = { budget_id: 1, user_id: 'user-123', ...budgetData }; + // Mock the INSERT...RETURNING * call and the subsequent award_achievement call + mockQuery.mockResolvedValueOnce({ rows: [mockCreatedBudget] }).mockResolvedValueOnce({ rows: [] }); + + const result = await createBudget('user-123', budgetData); + + expect(getPool().query).toHaveBeenCalledWith( + 'INSERT INTO public.budgets (user_id, name, amount_cents, period, start_date) VALUES ($1, $2, $3, $4, $5) RETURNING *', + ['user-123', budgetData.name, budgetData.amount_cents, budgetData.period, budgetData.start_date] + ); + // Verify that the achievement function was called + expect(getPool().query).toHaveBeenCalledWith("SELECT public.award_achievement($1, 'First Budget Created')", ['user-123']); + expect(result).toEqual(mockCreatedBudget); + }); + }); + + describe('updateBudget', () => { + it('should execute an UPDATE query with COALESCE and return the updated budget', async () => { + const budgetUpdates = { name: 'Updated Groceries', amount_cents: 55000 }; + const mockUpdatedBudget: Budget = { budget_id: 1, user_id: 'user-123', name: 'Updated Groceries', amount_cents: 55000, period: 'monthly', start_date: '2024-01-01' }; + mockQuery.mockResolvedValue({ rows: [mockUpdatedBudget], rowCount: 1 }); + + const result = await updateBudget(1, 'user-123', budgetUpdates); + + expect(getPool().query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE public.budgets SET'), + [budgetUpdates.name, budgetUpdates.amount_cents, undefined, undefined, 1, 'user-123'] + ); + expect(result).toEqual(mockUpdatedBudget); + }); + + it('should throw an error if no rows are updated', async () => { + mockQuery.mockResolvedValue({ rowCount: 0 }); + await expect(updateBudget(999, 'user-123', { name: 'Fail' })).rejects.toThrow('Budget not found or user does not have permission to update.'); + }); + }); + + describe('deleteBudget', () => { + it('should execute a DELETE query with user ownership check', async () => { + mockQuery.mockResolvedValue({ rowCount: 1 }); + await deleteBudget(1, 'user-123'); + expect(getPool().query).toHaveBeenCalledWith('DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2', [1, 'user-123']); + }); + + it('should throw an error if no rows are deleted', async () => { + mockQuery.mockResolvedValue({ rowCount: 0 }); + await expect(deleteBudget(999, 'user-123')).rejects.toThrow('Budget not found or user does not have permission to delete.'); + }); + }); + + describe('getSpendingByCategory', () => { + it('should call the correct database function and return spending data', async () => { + const mockSpendingData: SpendingByCategory[] = [{ category_id: 1, category_name: 'Produce', total_spent_cents: 12345 }]; + mockQuery.mockResolvedValue({ rows: mockSpendingData }); + + const result = await getSpendingByCategory('user-123', '2024-01-01', '2024-01-31'); + + expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.get_spending_by_category($1, $2, $3)', ['user-123', '2024-01-01', '2024-01-31']); + expect(result).toEqual(mockSpendingData); + }); + }); +}); \ No newline at end of file diff --git a/src/services/db/gamification.test.ts b/src/services/db/gamification.test.ts new file mode 100644 index 00000000..17eef53d --- /dev/null +++ b/src/services/db/gamification.test.ts @@ -0,0 +1,87 @@ +// src/services/db/gamification.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getPool } from './connection'; +import { + getAllAchievements, + getUserAchievements, + awardAchievement, + getLeaderboard, +} from './gamification'; +import type { Achievement, UserAchievement, LeaderboardUser } from '../../types'; + +// Mock the getPool function to return a mocked pool object. +const mockQuery = vi.fn(); +vi.mock('./connection', () => ({ + getPool: () => ({ + query: mockQuery, + }), +})); + +// Mock the logger to prevent console output during tests +vi.mock('../logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('Gamification DB Service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getAllAchievements', () => { + it('should execute the correct SELECT query and return achievements', async () => { + const mockAchievements: Achievement[] = [ + { achievement_id: 1, name: 'First Steps', description: '...', icon: 'footprints', points_value: 10 }, + ]; + mockQuery.mockResolvedValue({ rows: mockAchievements }); + + const result = await getAllAchievements(); + + expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC'); + expect(result).toEqual(mockAchievements); + }); + }); + + describe('getUserAchievements', () => { + it('should execute the correct SELECT query with a JOIN and return user achievements', async () => { + const mockUserAchievements: (UserAchievement & Achievement)[] = [ + { achievement_id: 1, user_id: 'user-123', achieved_at: '2024-01-01', name: 'First Steps', description: '...', icon: 'footprints', points_value: 10 }, + ]; + mockQuery.mockResolvedValue({ rows: mockUserAchievements }); + + const result = await getUserAchievements('user-123'); + + expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.user_achievements ua'), ['user-123']); + expect(result).toEqual(mockUserAchievements); + }); + }); + + describe('awardAchievement', () => { + it('should call the award_achievement database function with the correct parameters', async () => { + mockQuery.mockResolvedValue({ rows: [] }); // The function returns void + await awardAchievement('user-123', 'Test Achievement'); + + expect(getPool().query).toHaveBeenCalledWith("SELECT public.award_achievement($1, $2)", ['user-123', 'Test Achievement']); + }); + }); + + describe('getLeaderboard', () => { + it('should execute the correct SELECT query with a LIMIT and return leaderboard users', async () => { + const mockLeaderboard: LeaderboardUser[] = [ + { user_id: 'user-1', full_name: 'User One', avatar_url: null, points: 500, rank: '1' }, + { user_id: 'user-2', full_name: 'User Two', avatar_url: null, points: 450, rank: '2' }, + ]; + mockQuery.mockResolvedValue({ rows: mockLeaderboard }); + + const result = await getLeaderboard(10); + + expect(getPool().query).toHaveBeenCalledTimes(1); + expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('RANK() OVER (ORDER BY points DESC)'), [10]); + expect(result).toEqual(mockLeaderboard); + }); + }); +}); \ No newline at end of file diff --git a/src/services/db/gamification.ts b/src/services/db/gamification.ts index 70f5a084..803d2123 100644 --- a/src/services/db/gamification.ts +++ b/src/services/db/gamification.ts @@ -1,7 +1,7 @@ // src/services/db/gamification.ts import { getPool } from './connection'; import { logger } from '../logger'; -import { Achievement, UserAchievement } from '../../types'; +import { Achievement, UserAchievement, LeaderboardUser } from '../../types'; /** * Retrieves the master list of all available achievements. @@ -62,4 +62,30 @@ export async function awardAchievement(userId: string, achievementName: string): logger.error('Database error in awardAchievement:', { error, userId, achievementName }); throw new Error('Failed to award achievement.'); } +} + +/** + * Retrieves a leaderboard of users with the most points. + * @param limit The number of users to return. + * @returns A promise that resolves to an array of leaderboard user objects. + */ +export async function getLeaderboard(limit: number): Promise { + try { + const query = ` + SELECT + user_id, + full_name, + avatar_url, + points, + RANK() OVER (ORDER BY points DESC) as rank + FROM public.profiles + ORDER BY points DESC, full_name ASC + LIMIT $1; + `; + const res = await getPool().query(query, [limit]); + return res.rows; + } catch (error) { + logger.error('Database error in getLeaderboard:', { error, limit }); + throw new Error('Failed to retrieve leaderboard.'); + } } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 729e3570..3fb01c8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -772,4 +772,16 @@ export interface UserAchievement { user_id: string; // UUID achievement_id: number; achieved_at: string; // TIMESTAMPTZ +} + +/** + * Represents a user's entry on the leaderboard. + * Returned by the `getLeaderboard` database function. + */ +export interface LeaderboardUser { + user_id: string; + full_name: string | null; + avatar_url: string | null; + points: number; + rank: string; // RANK() returns a bigint, which the pg driver returns as a string. } \ No newline at end of file