Loading Leaderboard...
;
+ }
+
+ if (error) {
+ return (
+
+
+
+ Top Users
+
+ {leaderboard.length === 0 ? (
+
The leaderboard is currently empty. Be the first to earn points!
+ ) : (
+
+ {leaderboard.map((user) => (
+ -
+
+ {getRankIcon(user.rank)}
+
+
+
+
{user.full_name || 'Anonymous User'}
+
+
+ {user.points} pts
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+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