diff --git a/server.ts b/server.ts index f9f14f6..0dfdd9a 100644 --- a/server.ts +++ b/server.ts @@ -17,6 +17,7 @@ import aiRouter from './src/routes/ai'; import budgetRouter from './src/routes/budget'; import gamificationRouter from './src/routes/gamification'; import systemRouter from './src/routes/system'; +import { startBackgroundJobs } from './src/services/backgroundJobService'; // Environment variables are now loaded by the `tsx` command in package.json scripts. // This ensures the correct .env file is used for development vs. testing. @@ -150,6 +151,9 @@ if (process.env.NODE_ENV !== 'test') { console.table(listEndpoints(app)); console.log('-----------------------------'); }); + + // Start the scheduled background jobs + startBackgroundJobs(); } diff --git a/sql/initial_data.sql b/sql/initial_data.sql index dd0d106..c77732b 100644 --- a/sql/initial_data.sql +++ b/sql/initial_data.sql @@ -260,5 +260,6 @@ INSERT INTO public.achievements (name, description, icon, points_value) VALUES ('Recipe Sharer', 'Share a recipe with another user for the first time.', 'share-2', 15), ('List Sharer', 'Share a shopping list with another user for the first time.', 'list', 20), ('First Favorite', 'Mark a recipe as one of your favorites.', 'heart', 5), -('First Fork', 'Make a personal copy of a public recipe.', 'git-fork', 10) +('First Fork', 'Make a personal copy of a public recipe.', 'git-fork', 10), +('First Budget Created', 'Create your first budget to track spending.', 'piggy-bank', 15) ON CONFLICT (name) DO NOTHING; diff --git a/src/App.tsx b/src/App.tsx index 9205da7..2696d1c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ import { ResetPasswordPage } from './pages/ResetPasswordPage'; import { AnonymousUserBanner } from './pages/admin/components/AnonymousUserBanner'; import { VoiceLabPage } from './pages/VoiceLabPage'; import { WhatsNewModal } from './components/WhatsNewModal'; +import { FlyerCorrectionTool } from './components/FlyerCorrectionTool'; // This path is now correct after moving the file import { QuestionMarkCircleIcon } from './components/icons/QuestionMarkCircleIcon'; /** @@ -113,6 +114,22 @@ function App() { const [isProfileManagerOpen, setIsProfileManagerOpen] = useState(false); // This will now control the login modal as well const [isWhatsNewOpen, setIsWhatsNewOpen] = useState(false); const [isVoiceAssistantOpen, setIsVoiceAssistantOpen] = useState(false); + const [isCorrectionToolOpen, setIsCorrectionToolOpen] = useState(false); + + const handleDataExtractedFromCorrection = (type: 'store_name' | 'dates', value: string) => { + if (!selectedFlyer) return; + + // This is a simplified update. A real implementation would involve + // making another API call to update the flyer record in the database. + // For now, we just update the local state for immediate visual feedback. + const updatedFlyer = { ...selectedFlyer }; + if (type === 'store_name') { + updatedFlyer.store = { ...updatedFlyer.store!, name: value }; + } else if (type === 'dates') { + // A more robust solution would parse the date string properly. + } + setSelectedFlyer(updatedFlyer); + }; const [processingStages, setProcessingStages] = useState([]); const [estimatedTime, setEstimatedTime] = useState(0); @@ -831,6 +848,16 @@ function App() { commitMessage={commitMessage} /> )} + + {selectedFlyer && ( + setIsCorrectionToolOpen(false)} + imageUrl={selectedFlyer.image_url} + onDataExtracted={handleDataExtractedFromCorrection} + /> + )} + @@ -872,6 +899,7 @@ function App() { validFrom={selectedFlyer.valid_from} validTo={selectedFlyer.valid_to} storeAddress={selectedFlyer.store_address} + onOpenCorrectionTool={() => setIsCorrectionToolOpen(true)} /> {hasData && ( <> diff --git a/src/RefreshCwIcon.tsx b/src/RefreshCwIcon.tsx new file mode 100644 index 0000000..49475ce --- /dev/null +++ b/src/RefreshCwIcon.tsx @@ -0,0 +1,6 @@ +// src/components/icons/RefreshCwIcon.tsx +import React from 'react'; + +export const RefreshCwIcon: React.FC> = (props) => ( + +); \ No newline at end of file diff --git a/src/ScanIcon.tsx b/src/ScanIcon.tsx new file mode 100644 index 0000000..cfbdd84 --- /dev/null +++ b/src/ScanIcon.tsx @@ -0,0 +1,6 @@ +// src/components/icons/ScanIcon.tsx +import React from 'react'; + +export const ScanIcon: React.FC> = (props) => ( + +); \ No newline at end of file diff --git a/src/components/AchievementsList.tsx b/src/components/AchievementsList.tsx new file mode 100644 index 0000000..cb8c204 --- /dev/null +++ b/src/components/AchievementsList.tsx @@ -0,0 +1,52 @@ +// src/components/AchievementsList.tsx +import React from 'react'; +import { Achievement, UserAchievement } from '../types'; + +/** + * A simple component to render an icon based on its name. + * In a real app, this would map to an icon library like Feather or FontAwesome. + */ +const Icon: React.FC<{ name: string | null | undefined }> = ({ name }) => { + if (!name) return 🏆; + // For now, just return the name as text or a default emoji. + // Example: if name is 'chef-hat', you could return a chef hat icon. + const iconMap: { [key: string]: string } = { + 'chef-hat': '🧑‍🍳', + 'share-2': '🤝', + 'list': '📋', + 'heart': '❤️', + 'git-fork': '🍴', + 'piggy-bank': '🐷', + }; + return {iconMap[name] || '🏆'}; +}; + +interface AchievementsListProps { + achievements: (UserAchievement & Achievement)[]; +} + +export const AchievementsList: React.FC = ({ achievements }) => { + return ( +
+

Achievements

+ {achievements.length > 0 ? ( +
+ {achievements.map((ach) => ( +
+
+ +
+
+

{ach.name}

+

{ach.description}

+

+{ach.points_value} Points

+
+
+ ))} +
+ ) : ( +

No achievements earned yet. Keep exploring to unlock them!

+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/UserProfilePage.tsx b/src/components/UserProfilePage.tsx index b692616..ee2334c 100644 --- a/src/components/UserProfilePage.tsx +++ b/src/components/UserProfilePage.tsx @@ -3,23 +3,7 @@ import * as apiClient from '../services/apiClient'; import { UserProfile, Achievement, UserAchievement } from '../types'; import { logger } from '../services/logger'; import { notifySuccess, notifyError } from '../services/notificationService'; - -// A simple component to render an icon based on its name. -// In a real app, this would map to an icon library like Feather or FontAwesome. -const Icon: React.FC<{ name: string | null | undefined }> = ({ name }) => { - if (!name) return 🏆; - // For now, just return the name as text or a default emoji. - // Example: if name is 'chef-hat', you could return a chef hat icon. - const iconMap: { [key: string]: string } = { - 'chef-hat': '🧑‍🍳', - 'share-2': '🤝', - 'list': '📋', - 'heart': '❤️', - 'git-fork': '🍴', - 'piggy-bank': '🐷', - }; - return {iconMap[name] || '🏆'}; -}; +import { AchievementsList } from './AchievementsList'; const UserProfilePage: React.FC = () => { const [profile, setProfile] = useState(null); @@ -173,27 +157,7 @@ const UserProfilePage: React.FC = () => { {/* Achievements Section */} -
-

Achievements

- {achievements.length > 0 ? ( -
- {achievements.map((ach) => ( -
-
- -
-
-

{ach.name}

-

{ach.description}

-

+{ach.points_value} Points

-
-
- ))} -
- ) : ( -

No achievements earned yet. Keep exploring to unlock them!

- )} -
+ ); }; diff --git a/src/features/flyer/DocumentPlusIcon.tsx b/src/features/flyer/DocumentPlusIcon.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/features/flyer/FlyerDisplay.tsx b/src/features/flyer/FlyerDisplay.tsx index be5da3d..d5fa5be 100644 --- a/src/features/flyer/FlyerDisplay.tsx +++ b/src/features/flyer/FlyerDisplay.tsx @@ -1,5 +1,6 @@ // src/features/flyer/FlyerDisplay.tsx import React from 'react'; +import { ScanIcon } from '../../components/icons/ScanIcon'; import type { Store } from '../../types'; const formatDateRange = (from: string | null | undefined, to: string | null | undefined): string | null => { @@ -21,15 +22,16 @@ interface FlyerDisplayProps { validFrom?: string | null; validTo?: string | null; storeAddress?: string | null; + onOpenCorrectionTool: () => void; } -export const FlyerDisplay: React.FC = ({ imageUrl, store, validFrom, validTo, storeAddress }) => { +export const FlyerDisplay: React.FC = ({ imageUrl, store, validFrom, validTo, storeAddress, onOpenCorrectionTool }) => { const dateRange = formatDateRange(validFrom, validTo); return (
{(store || dateRange) && ( -
+
{store?.logo_url && ( = ({ imageUrl, store, val className="h-12 w-12 object-contain rounded-md" /> )} -
+
{store?.name &&

{store.name}

} {dateRange &&

{dateRange}

} {storeAddress &&

{storeAddress}

}
+
)}
diff --git a/src/notification.ts b/src/notification.ts new file mode 100644 index 0000000..7ab90ee --- /dev/null +++ b/src/notification.ts @@ -0,0 +1,46 @@ +// src/services/db/notification.ts +import { getPool } from './connection'; +import { logger } from '../logger'; +import { Notification } from '../../types'; + +/** + * Inserts a single notification into the database. + * @param userId The ID of the user to notify. + * @param content The text content of the notification. + * @param linkUrl An optional URL for the notification to link to. + * @returns A promise that resolves to the newly created Notification object. + */ +export async function createNotification(userId: string, content: string, linkUrl?: string): Promise { + try { + const res = await getPool().query( + `INSERT INTO public.notifications (user_id, content, link_url) VALUES ($1, $2, $3) RETURNING *`, + [userId, content, linkUrl || null] + ); + return res.rows[0]; + } catch (error) { + logger.error('Database error in createNotification:', { error }); + throw new Error('Failed to create notification.'); + } +} + +/** + * Inserts multiple notifications into the database in a single query. + * This is more efficient than inserting one by one. + * @param notifications An array of notification objects to be inserted. + */ +export async function createBulkNotifications(notifications: Omit[]): Promise { + if (notifications.length === 0) { + return; + } + const client = await getPool().connect(); + try { + const values = notifications.map(n => `('${n.user_id}', '${n.content.replace(/'/g, "''")}', ${n.link_url ? `'${n.link_url.replace(/'/g, "''")}'` : 'NULL'})`).join(','); + const query = `INSERT INTO public.notifications (user_id, content, link_url) VALUES ${values}`; + await client.query(query); + } catch (error) { + logger.error('Database error in createBulkNotifications:', { error }); + throw new Error('Failed to create bulk notifications.'); + } finally { + client.release(); + } +} \ No newline at end of file diff --git a/src/routes/ai.ts b/src/routes/ai.ts index b47e5ed..923f0bc 100644 --- a/src/routes/ai.ts +++ b/src/routes/ai.ts @@ -216,5 +216,40 @@ router.post('/generate-speech', passport.authenticate('jwt', { session: false }) res.status(501).json({ message: 'Speech generation is not yet implemented.' }); }); +/** + * POST /api/ai/rescan-area - Performs a targeted AI scan on a specific area of an image. + * Requires authentication. + */ +router.post( + '/rescan-area', + passport.authenticate('jwt', { session: false }), + upload.single('image'), + async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.file) { + return res.status(400).json({ message: 'Image file is required.' }); + } + if (!req.body.cropArea || !req.body.extractionType) { + return res.status(400).json({ message: 'cropArea and extractionType are required.' }); + } + + const cropArea = JSON.parse(req.body.cropArea); + const { extractionType } = req.body; + const { path, mimetype } = req.file; + + const result = await aiService.extractTextFromImageArea( + path, + mimetype, + cropArea, + extractionType + ); + + res.status(200).json(result); + } catch (error) { + logger.error('Error in /api/ai/rescan-area endpoint:', { error }); + next(error); + } + } +); export default router; \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index eaace2a..87cd829 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -4,7 +4,7 @@ import passport from 'passport'; import multer from 'multer'; import path from 'path'; import fs from 'fs/promises'; -import { updateUserProfile } from '../services/db'; +import * as db from '../services/db'; import { logger } from '../services/logger'; import { User, UserProfile } from '../types'; @@ -60,7 +60,7 @@ router.post( const avatarUrl = `/uploads/avatars/${req.file.filename}`; // Update the user's profile in the database with the new URL - const updatedProfile = await updateUserProfile(user.user_id, { avatar_url: avatarUrl }); + const updatedProfile = await db.updateUserProfile(user.user_id, { avatar_url: avatarUrl }); res.json(updatedProfile); } catch (error) { logger.error('Error uploading avatar:', { error, userId: user.user_id }); @@ -69,4 +69,44 @@ router.post( } ); +/** + * GET /api/users/notifications - Get notifications for the authenticated user. + * Supports pagination with `limit` and `offset` query parameters. + */ +router.get( + '/notifications', + passport.authenticate('jwt', { session: false }), + async (req: Request, res: Response) => { + const user = req.user as User; + const limit = parseInt(req.query.limit as string, 10) || 20; + const offset = parseInt(req.query.offset as string, 10) || 0; + + try { + const notifications = await db.getNotificationsForUser(user.user_id, limit, offset); + res.json(notifications); + } catch (error) { + logger.error('Error fetching notifications:', { error, userId: user.user_id }); + res.status(500).json({ message: 'Failed to fetch notifications.' }); + } + } +); + +/** + * POST /api/users/notifications/mark-all-read - Mark all of the user's notifications as read. + */ +router.post( + '/notifications/mark-all-read', + passport.authenticate('jwt', { session: false }), + async (req: Request, res: Response) => { + const user = req.user as User; + try { + await db.markAllNotificationsAsRead(user.user_id); + res.status(204).send(); // No Content + } catch (error) { + logger.error('Error marking notifications as read:', { error, userId: user.user_id }); + res.status(500).json({ message: 'Failed to mark notifications as read.' }); + } + } +); + export default router; \ No newline at end of file diff --git a/src/services/FlyerCorrectionTool.tsx b/src/services/FlyerCorrectionTool.tsx new file mode 100644 index 0000000..31898f4 --- /dev/null +++ b/src/services/FlyerCorrectionTool.tsx @@ -0,0 +1,195 @@ +// src/components/FlyerCorrectionTool.tsx +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { XCircleIcon } from './icons/XCircleIcon'; +import { ScissorsIcon } from './icons/ScissorsIcon'; +import { RefreshCwIcon } from './icons/RefreshCwIcon'; +import * as aiApiClient from '../services/aiApiClient'; +import { notifyError, notifySuccess } from '../services/notificationService'; +import { logger } from '../services/logger'; + +interface FlyerCorrectionToolProps { + isOpen: boolean; + onClose: () => void; + imageUrl: string; + onDataExtracted: (type: 'store_name' | 'dates', value: string) => void; +} + +type Rect = { x: number; y: number; width: number; height: number }; +type ExtractionType = 'store_name' | 'dates'; + +export const FlyerCorrectionTool: React.FC = ({ isOpen, onClose, imageUrl, onDataExtracted }) => { + const canvasRef = useRef(null); + const imageRef = useRef(null); + const [isDrawing, setIsDrawing] = useState(false); + const [selectionRect, setSelectionRect] = useState(null); + const [startPoint, setStartPoint] = useState<{ x: number; y: number } | null>(null); + const [isProcessing, setIsProcessing] = useState(false); + const [imageFile, setImageFile] = useState(null); + + // Fetch the image and store it as a File object for API submission + useEffect(() => { + if (isOpen && imageUrl) { + fetch(imageUrl) + .then(res => res.blob()) + .then(blob => { + const file = new File([blob], 'flyer-image.jpg', { type: blob.type }); + setImageFile(file); + }) + .catch(err => { + logger.error('Failed to fetch image for correction tool', { err }); + notifyError('Could not load the image for correction.'); + }); + } + }, [isOpen, imageUrl]); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + const image = imageRef.current; + if (!canvas || !image) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Set canvas size to match image display size + canvas.width = image.clientWidth; + canvas.height = image.clientHeight; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + if (selectionRect) { + ctx.strokeStyle = '#f59e0b'; // amber-500 + ctx.lineWidth = 2; + ctx.setLineDash([6, 3]); + ctx.strokeRect(selectionRect.x, selectionRect.y, selectionRect.width, selectionRect.height); + } + }, [selectionRect]); + + useEffect(() => { + draw(); + const handleResize = () => draw(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [draw]); + + const getCanvasCoordinates = (e: React.MouseEvent): { x: number; y: number } => { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + const rect = canvas.getBoundingClientRect(); + return { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }; + }; + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDrawing(true); + setStartPoint(getCanvasCoordinates(e)); + setSelectionRect(null); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDrawing || !startPoint) return; + const currentPoint = getCanvasCoordinates(e); + const rect = { + x: Math.min(startPoint.x, currentPoint.x), + y: Math.min(startPoint.y, currentPoint.y), + width: Math.abs(startPoint.x - currentPoint.x), + height: Math.abs(startPoint.y - currentPoint.y), + }; + setSelectionRect(rect); + }; + + const handleMouseUp = () => { + setIsDrawing(false); + setStartPoint(null); + }; + + const handleRescan = async (type: ExtractionType) => { + if (!selectionRect || !imageRef.current || !imageFile) { + notifyError('Please select an area on the image first.'); + return; + } + + setIsProcessing(true); + try { + // Scale selection coordinates to the original image dimensions + const image = imageRef.current; + const scaleX = image.naturalWidth / image.clientWidth; + const scaleY = image.naturalHeight / image.clientHeight; + + const cropArea = { + x: selectionRect.x * scaleX, + y: selectionRect.y * scaleY, + width: selectionRect.width * scaleX, + height: selectionRect.height * scaleY, + }; + + const response = await aiApiClient.rescanImageArea(imageFile, cropArea, type); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to rescan area.'); + } + + const { text } = await response.json(); + notifySuccess(`Extracted: ${text}`); + onDataExtracted(type, text); + onClose(); // Close modal on success + } catch (err) { + const msg = err instanceof Error ? err.message : 'An unknown error occurred.'; + notifyError(msg); + logger.error('Error during rescan:', { err }); + } finally { + setIsProcessing(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

Flyer Correction Tool

+ +
+
+ Flyer for correction + +
+ +
+ {isProcessing ? ( +
+ + Processing... +
+ ) : ( + <> + + + + )} +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/services/MyDealsPage.tsx b/src/services/MyDealsPage.tsx new file mode 100644 index 0000000..4fb54a4 --- /dev/null +++ b/src/services/MyDealsPage.tsx @@ -0,0 +1,102 @@ +// src/components/MyDealsPage.tsx +import React, { useState, useEffect } from 'react'; +import { WatchedItemDeal } from '../types'; +import { getBestSalePricesForUser } from '../services/db'; // We need a client-side API function for this +import { apiFetch } from '../services/apiClient'; +import { logger } from '../services/logger'; +import { AlertCircle, Tag, Store, Calendar } from 'lucide-react'; + +/** + * API client function to fetch best sale prices for the logged-in user. + * This function should exist in `apiClient.ts`, but is defined here for clarity. + */ +const fetchBestSalePrices = async (tokenOverride?: string): Promise => { + // This endpoint needs to be created in the backend, likely in `user.ts` or a new `deals.ts` router. + // For now, we assume it exists at `/api/users/deals`. + return apiFetch(`/users/deals/best-watched-prices`, {}, tokenOverride); +}; + +const MyDealsPage: React.FC = () => { + const [deals, setDeals] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadDeals = async () => { + setIsLoading(true); + setError(null); + try { + const response = await fetchBestSalePrices(); + if (!response.ok) { + throw new Error('Failed to fetch deals. Please try again later.'); + } + const data: WatchedItemDeal[] = await response.json(); + setDeals(data); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.'; + logger.error('Error fetching watched item deals:', errorMessage); + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + loadDeals(); + }, []); + + if (isLoading) { + return
Loading your deals...
; + } + + if (error) { + return ( +
+
+ +
+

Error

+

{error}

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

My Watched Item Deals

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

No deals found for your watched items right now.

+

Try adding more items to your watchlist or check back after new flyers are uploaded!

+
+ ) : ( +
    + {deals.map((deal) => ( +
  • +
    +

    {deal.item_name}

    +
    + + ${(deal.best_price_in_cents / 100).toFixed(2)} +
    +
    +
    +
    + + {deal.store_name} +
    +
    + + Valid until: {new Date(deal.valid_to).toLocaleDateString()} +
    +
    +
  • + ))} +
+ )} +
+ ); +}; + +export default MyDealsPage; \ No newline at end of file diff --git a/src/services/ScissorsIcon.tsx b/src/services/ScissorsIcon.tsx new file mode 100644 index 0000000..dbf0620 --- /dev/null +++ b/src/services/ScissorsIcon.tsx @@ -0,0 +1,6 @@ +// src/components/icons/ScissorsIcon.tsx +import React from 'react'; + +export const ScissorsIcon: React.FC> = (props) => ( + +); \ No newline at end of file diff --git a/src/services/aiApiClient.ts b/src/services/aiApiClient.ts index 28e7056..56be139 100644 --- a/src/services/aiApiClient.ts +++ b/src/services/aiApiClient.ts @@ -162,3 +162,25 @@ export const startVoiceSession = (callbacks: { - extractItemsFromReceiptImage - extractCoreDataFromFlyerImage */ + +/** + * Sends a cropped area of an image to the backend for targeted text extraction. + * @param imageFile The original image file. + * @param cropArea The { x, y, width, height } of the area to scan. + * @param extractionType The type of data to look for ('store_name', 'dates', etc.). + * @param tokenOverride Optional token for testing. + * @returns A promise that resolves to the API response containing the extracted text. + */ +export const rescanImageArea = async ( + imageFile: File, + cropArea: { x: number; y: number; width: number; height: number }, + extractionType: 'store_name' | 'dates' | 'item_details', + tokenOverride?: string +): Promise => { + const formData = new FormData(); + formData.append('image', imageFile); + formData.append('cropArea', JSON.stringify(cropArea)); + formData.append('extractionType', extractionType); + + return apiFetchWithAuth('/ai/rescan-area', { method: 'POST', body: formData }, tokenOverride); +}; diff --git a/src/services/aiService.server.ts b/src/services/aiService.server.ts index eb528fe..9edcaf9 100644 --- a/src/services/aiService.server.ts +++ b/src/services/aiService.server.ts @@ -196,6 +196,65 @@ export const extractCoreDataFromFlyerImage = async ( } }; +/** + * SERVER-SIDE FUNCTION + * Extracts a specific piece of text from a cropped area of an image. + * @param imagePath The path to the original image file on the server. + * @param cropArea The coordinates and dimensions { x, y, width, height } to crop. + * @param extractionType The type of data to extract, which determines the AI prompt. + * @returns A promise that resolves to the extracted text. + */ +export const extractTextFromImageArea = async ( + imagePath: string, + imageMimeType: string, + cropArea: { x: number; y: number; width: number; height: number }, + extractionType: 'store_name' | 'dates' | 'item_details' +): Promise<{ text: string }> => { + // 1. Define prompts based on the extraction type + const prompts = { + store_name: 'What is the store name in this image? Respond with only the name.', + dates: 'What are the sale dates in this image? Respond with the date range as text (e.g., "Jan 1 - Jan 7").', + item_details: 'Extract the item name, price, and quantity from this image. Respond with the text as seen.', + }; + + const prompt = prompts[extractionType] || 'Extract the text from this image.'; + + // 2. Crop the image using sharp + const sharp = (await import('sharp')).default; + const croppedImageBuffer = await sharp(imagePath) + .extract({ + left: Math.round(cropArea.x), + top: Math.round(cropArea.y), + width: Math.round(cropArea.width), + height: Math.round(cropArea.height), + }) + .toBuffer(); + + // 3. Convert cropped buffer to GenerativePart + const imagePart = { + inlineData: { + data: croppedImageBuffer.toString('base64'), + mimeType: imageMimeType, + }, + }; + + // 4. Call the AI model + try { + logger.info(`[aiService.server] Calling Gemini for targeted rescan of type: ${extractionType}`); + const response = await model.generateContent({ + model: 'gemini-2.5-flash', + contents: [{ parts: [{ text: prompt }, imagePart] }] + }); + + const text = response.text?.trim() ?? ''; + logger.info(`[aiService.server] Gemini rescan completed. Extracted text: "${text}"`); + return { text }; + } catch (apiError) { + logger.error(`Google GenAI API call failed in extractTextFromImageArea for type ${extractionType}:`, { error: apiError }); + throw apiError; + } +}; + /** * SERVER-SIDE FUNCTION * Uses Google Maps grounding to find nearby stores and plan a shopping trip. diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index cf73b8c..5e28353 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -809,6 +809,26 @@ export async function deleteUserAccount(password: string, tokenOverride?: string }, tokenOverride); } +// --- Notification API Functions --- + +/** + * Fetches notifications for the authenticated user. + * @param limit The number of notifications to fetch. + * @param offset The number of notifications to skip for pagination. + * @returns A promise that resolves to the API response. + */ +export const getNotifications = async (limit: number = 20, offset: number = 0, tokenOverride?: string): Promise => { + return apiFetch(`/users/notifications?limit=${limit}&offset=${offset}`, {}, tokenOverride); +}; + +/** + * Marks all of the user's unread notifications as read. + * @returns A promise that resolves to the API response. + */ +export const markAllNotificationsAsRead = async (tokenOverride?: string): Promise => { + return apiFetch(`/users/notifications/mark-all-read`, { method: 'POST' }, tokenOverride); +}; + // --- Budgeting and Spending Analysis API Functions --- /** diff --git a/src/services/backgroundJobService.ts b/src/services/backgroundJobService.ts new file mode 100644 index 0000000..1e090b6 --- /dev/null +++ b/src/services/backgroundJobService.ts @@ -0,0 +1,72 @@ +// src/services/backgroundJobService.ts +import cron from 'node-cron'; +import * as db from './db'; +import { logger } from './logger.server'; +import { sendDealNotificationEmail } from './emailService.server'; +import { Notification } from '../types'; + +/** + * Checks for new deals on watched items for all users and sends notifications. + * This function is designed to be run periodically (e.g., daily). + */ +export async function runDailyDealCheck(): Promise { + logger.info('[BackgroundJob] Starting daily deal check for all users...'); + + try { + // 1. Get all users + const users = await db.getAllUsers(); + if (users.length === 0) { + logger.info('[BackgroundJob] No users found. Skipping deal check.'); + return; + } + + logger.info(`[BackgroundJob] Found ${users.length} users to process.`); + + const allNotifications: Omit[] = []; + + // 2. For each user, find the best deals on their watched items + for (const user of users) { + try { + const deals = await db.getBestSalePricesForUser(user.user_id); + + if (deals.length > 0) { + logger.info(`[BackgroundJob] Found ${deals.length} deals for user ${user.user_id}.`); + + // 3. Create in-app notifications + const notificationContent = `You have ${deals.length} new deal(s) on your watched items!`; + allNotifications.push({ + user_id: user.user_id, + content: notificationContent, + link_url: '/dashboard/deals', // A link to the future "My Deals" page + }); + + // 4. Send email notification + await sendDealNotificationEmail(user.email, user.full_name, deals); + } + } catch (userError) { + logger.error(`[BackgroundJob] Failed to process deals for user ${user.user_id}:`, { error: userError }); + // Continue to the next user + } + } + + // 5. Bulk insert all in-app notifications + if (allNotifications.length > 0) { + await db.createBulkNotifications(allNotifications); + logger.info(`[BackgroundJob] Successfully created ${allNotifications.length} in-app notifications.`); + } + + logger.info('[BackgroundJob] Daily deal check completed successfully.'); + } catch (error) { + logger.error('[BackgroundJob] A critical error occurred during the daily deal check:', { error }); + } +} + +/** + * Initializes and starts the cron job for daily deal checks. + * This should be called once when the server starts. + */ +export function startBackgroundJobs(): void { + // Schedule the job to run once every day at 2:00 AM server time. + cron.schedule('0 2 * * *', runDailyDealCheck); + logger.info('[BackgroundJob] Cron job for daily deal checks has been scheduled.'); +} \ No newline at end of file diff --git a/src/services/db/budget.ts b/src/services/db/budget.ts index 89d7638..6d70782 100644 --- a/src/services/db/budget.ts +++ b/src/services/db/budget.ts @@ -34,6 +34,10 @@ export async function createBudget(userId: string, budgetData: Omit => { + const userName = name || 'there'; + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; + + // Format the deals into a simple list for the email body + const dealsListHtml = deals + .map(deal => `
  • ${deal.item_name} is on sale for $${(deal.best_price_in_cents / 100).toFixed(2)} at ${deal.store_name}
  • `) + .join(''); + + const mailOptions = { + from: `"Flyer Crawler" <${fromAddress}>`, + to: to, + subject: 'Your Weekly Grocery Deals Are Here!', + text: ` +Hello ${userName}, + +We've found some great deals on items from your watchlist! +${deals.map(d => `- ${d.item_name} is on sale for $${(d.best_price_in_cents / 100).toFixed(2)} at ${d.store_name}`).join('\n')} + +Visit your dashboard to see more. + +Happy shopping! +The Flyer Crawler Team + `, + html: ` +
    +

    Your Weekly Deals Summary

    +

    Hello ${userName},

    +

    We've found some great deals on items from your watchlist!

    +
      ${dealsListHtml}
    +

    + View All Deals +

    +

    Happy shopping!

    +
    + `, + }; + + try { + await transporter.sendMail(mailOptions); + logger.info(`Deal notification email sent to ${to}.`); + } catch (error) { + logger.error(`Failed to send deal notification email to ${to}`, { error }); + throw new Error('Failed to send deal notification email.'); + } }; \ No newline at end of file