better flyer icons + archive
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m25s

This commit is contained in:
2025-12-03 14:13:44 -08:00
parent ba778a20d7
commit 383e8e3d25
6 changed files with 211 additions and 15 deletions

View File

@@ -104,7 +104,7 @@ function App() {
const [isDarkMode, setIsDarkMode] = useState(false);
const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial');
const [profile, setProfile] = useState<Profile | null>(null);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [authStatus, setAuthStatus] = useState<AuthStatus>('SIGNED_OUT');
const [isProfileManagerOpen, setIsProfileManagerOpen] = useState(false); // This will now control the login modal as well
const [isWhatsNewOpen, setIsWhatsNewOpen] = useState(false);
@@ -126,6 +126,16 @@ function App() {
setSelectedFlyer(updatedFlyer);
};
const handleProfileUpdate = (updatedProfileData: Profile) => {
// When the profile is updated, the API returns a `Profile` object.
// We need to merge it with the existing `user` object to maintain
// the `UserProfile` type in our state.
if (user) {
const updatedUserProfile: UserProfile = { ...updatedProfileData, user };
setProfile(updatedUserProfile);
}
};
const [estimatedTime, setEstimatedTime] = useState(0);
const [activeListId, setActiveListId] = useState<number | null>(null);
@@ -609,7 +619,7 @@ function App() {
user={user}
authStatus={authStatus}
profile={profile}
onProfileUpdate={setProfile}
onProfileUpdate={handleProfileUpdate}
onLoginSuccess={handleLoginSuccess}
onSignOut={handleSignOut} // Pass the signOut handler
/>
@@ -653,7 +663,7 @@ function App() {
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 items-start">
<div className="lg:col-span-1 flex flex-col space-y-6">
<FlyerList flyers={flyers} onFlyerSelect={handleFlyerSelect} selectedFlyerId={selectedFlyer?.flyer_id || null} />
<FlyerList flyers={flyers} onFlyerSelect={handleFlyerSelect} selectedFlyerId={selectedFlyer?.flyer_id || null} profile={profile} />
<FlyerUploader onProcessingComplete={refetchFlyers} />
</div>

View File

@@ -1,8 +1,11 @@
// src/features/flyer/FlyerList.tsx
import React from 'react';
import type { Flyer } from '../../types';
import toast from 'react-hot-toast';
import type { Flyer, UserProfile } from '../../types';
import { DocumentTextIcon } from '../../components/icons/DocumentTextIcon';
import { parseISO, format } from 'date-fns';
import { parseISO, format, isValid } from 'date-fns';
import { MapPinIcon, Trash2Icon } from 'lucide-react';
import * as apiClient from '../../services/apiClient';
const formatShortDate = (dateString: string | null | undefined): string | null => {
if (!dateString) return null;
@@ -22,9 +25,25 @@ interface FlyerListProps {
flyers: Flyer[];
onFlyerSelect: (flyer: Flyer) => void;
selectedFlyerId: number | null;
profile: UserProfile | null;
}
export const FlyerList: React.FC<FlyerListProps> = ({ flyers, onFlyerSelect, selectedFlyerId }) => {
export const FlyerList: React.FC<FlyerListProps> = ({ flyers, onFlyerSelect, selectedFlyerId, profile }) => {
const isAdmin = profile?.role === 'admin';
const handleCleanupClick = async (e: React.MouseEvent, flyerId: number) => {
e.stopPropagation(); // Prevent the row's onClick from firing
if (!window.confirm(`Are you sure you want to clean up the files for flyer ID ${flyerId}? This action cannot be undone.`)) {
return;
}
try {
await apiClient.cleanupFlyerFiles(flyerId);
toast.success(`Cleanup job for flyer ID ${flyerId} has been enqueued.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to enqueue cleanup job.');
}
};
return (
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-bold text-gray-800 dark:text-white p-4 border-b border-gray-200 dark:border-gray-700">
@@ -36,27 +55,60 @@ export const FlyerList: React.FC<FlyerListProps> = ({ flyers, onFlyerSelect, sel
const from = formatShortDate(flyer.valid_from);
const to = formatShortDate(flyer.valid_to);
const dateRange = from && to ? (from === to ? from : `${from} - ${to}`) : from || to;
const processedDate = format(parseISO(flyer.created_at), 'P');
const tooltipText = `File: ${flyer.file_name}\nProcessed: ${processedDate}`;
// Build a more detailed tooltip string
const processedDate = isValid(parseISO(flyer.created_at)) ? format(parseISO(flyer.created_at), 'PPPP p') : 'N/A';
const validFromFull = flyer.valid_from && isValid(parseISO(flyer.valid_from)) ? format(parseISO(flyer.valid_from), 'PPPP') : 'N/A';
const validToFull = flyer.valid_to && isValid(parseISO(flyer.valid_to)) ? format(parseISO(flyer.valid_to), 'PPPP') : 'N/A';
const tooltipLines = [
`File: ${flyer.file_name}`,
`Store: ${flyer.store?.name || 'Unknown'}`,
`Address: ${flyer.store_address || 'N/A'}`,
`Items: ${flyer.item_count}`,
`Valid: ${validFromFull} to ${validToFull}`,
`Processed: ${processedDate}`
];
const tooltipText = tooltipLines.join('\n');
return (
<li
data-testid={`flyer-list-item-${flyer.flyer_id}`}
key={flyer.flyer_id}
onClick={() => onFlyerSelect(flyer)}
className={`group p-4 flex items-center space-x-3 cursor-pointer transition-colors duration-200 ${selectedFlyerId === flyer.flyer_id ? 'bg-brand-light dark:bg-brand-dark/30' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
className={`p-4 flex items-center space-x-3 cursor-pointer transition-colors duration-200 ${selectedFlyerId === flyer.flyer_id ? 'bg-brand-light dark:bg-brand-dark/30' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
title={tooltipText}
>
<DocumentTextIcon className="w-6 h-6 text-brand-primary shrink-0" />
{flyer.icon_url ? (
<img src={flyer.icon_url} alt="Flyer Icon" className="w-6 h-6 shrink-0 rounded-sm" />
) : (
<DocumentTextIcon className="w-6 h-6 text-brand-primary shrink-0" />
)}
<div className="grow min-w-0">
<p className="text-sm font-semibold text-gray-900 dark:text-white truncate" title={flyer.store?.name || 'Unknown Store'}>
{flyer.store?.name || 'Unknown Store'}
</p>
<div className="flex items-center space-x-1.5">
<p className="text-sm font-semibold text-gray-900 dark:text-white truncate" title={flyer.store?.name || 'Unknown Store'}>
{flyer.store?.name || 'Unknown Store'}
</p>
{flyer.store_address && (
<a href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(flyer.store_address)}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} title={`View address: ${flyer.store_address}`}>
<MapPinIcon className="w-3.5 h-3.5 text-gray-400 hover:text-brand-primary transition-colors" />
</a>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{`${flyer.item_count} items`}
{dateRange && ` • Valid: ${dateRange}`}
</p>
</div>
{isAdmin && (
<button
onClick={(e) => handleCleanupClick(e, flyer.flyer_id)}
className="shrink-0 p-2 rounded-md hover:bg-red-100 dark:hover:bg-red-900/50 text-gray-400 hover:text-red-600 transition-colors"
title={`Clean up files for flyer ID ${flyer.flyer_id}`}
>
<Trash2Icon className="w-4 h-4" />
</button>
)}
</li>
);
})}

View File

@@ -13,7 +13,7 @@ import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { runDailyDealCheck } from '../services/backgroundJobService';
import { flyerQueue, emailQueue, analyticsQueue } from '../services/queueService.server'; // Import your queues
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue } from '../services/queueService.server'; // Import your queues
const router = Router();
@@ -38,7 +38,8 @@ createBullBoard({
queues: [
new BullMQAdapter(flyerQueue),
new BullMQAdapter(emailQueue),
new BullMQAdapter(analyticsQueue) // Add the new analytics queue here
new BullMQAdapter(analyticsQueue),
new BullMQAdapter(cleanupQueue) // Add the new cleanup queue here
],
serverAdapter: serverAdapter,
});
@@ -237,4 +238,23 @@ router.post('/trigger/analytics-report', async (req: Request, res: Response, nex
}
});
/**
* POST /api/admin/flyers/:flyerId/cleanup - Enqueue a job to clean up a flyer's files.
* This is triggered by an admin after they have verified the flyer processing was successful.
*/
router.post('/flyers/:flyerId/cleanup', async (req: Request, res: Response, next: NextFunction) => {
const adminUser = req.user as UserProfile;
const flyerId = parseInt(req.params.flyerId, 10);
if (isNaN(flyerId)) {
return res.status(400).json({ message: 'A valid flyer ID is required.' });
}
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user_id} for flyer ID: ${flyerId}`);
// Enqueue the cleanup job. The worker will handle the file deletion.
await cleanupQueue.add('cleanup-flyer-files', { flyerId });
res.status(202).json({ message: `File cleanup job for flyer ID ${flyerId} has been enqueued.` });
});
export default router;

View File

@@ -670,6 +670,16 @@ export const updateSuggestedCorrection = async (correctionId: number, newSuggest
}, tokenOverride);
};
/**
* Enqueues a job to clean up the files associated with a specific flyer.
* Requires admin privileges.
* @param flyerId The ID of the flyer to clean up.
* @param tokenOverride Optional token for testing.
*/
export const cleanupFlyerFiles = async (flyerId: number, tokenOverride?: string): Promise<Response> => {
return apiFetch(`/admin/flyers/${flyerId}/cleanup`, { method: 'POST' }, tokenOverride);
};
export async function registerUser(
email: string,
password: string,

View File

@@ -121,6 +121,22 @@ export async function findFlyerByChecksum(checksum: string): Promise<Flyer | und
}
}
/**
* Retrieves a single flyer by its ID.
* @param flyerId The ID of the flyer to retrieve.
* @returns A promise that resolves to the Flyer object if found, otherwise undefined.
*/
// prettier-ignore
export async function getFlyerById(flyerId: number): Promise<Flyer | undefined> {
try {
const res = await getPool().query<Flyer>('SELECT * FROM public.flyers WHERE flyer_id = $1', [flyerId]);
return res.rows[0];
} catch (error) {
logger.error('Database error in getFlyerById:', { error, flyerId });
throw new Error('Failed to retrieve flyer by ID.');
}
}
/**
* Creates a new flyer and all its associated items in a single database transaction.
* @param flyerData The metadata for the flyer.

View File

@@ -55,6 +55,17 @@ export const analyticsQueue = new Queue<AnalyticsJobData>('analytics-reporting',
},
});
export const cleanupQueue = new Queue<CleanupJobData>('file-cleanup', {
connection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 30000, // Retry cleanup after 30 seconds
},
removeOnComplete: true, // No need to keep successful cleanup jobs
},
});
// --- Job Data Interfaces ---
interface FlyerJobData {
@@ -78,6 +89,10 @@ interface AnalyticsJobData {
reportDate: string; // e.g., '2024-10-26'
}
interface CleanupJobData {
flyerId: number;
}
/**
* The main worker process for handling flyer jobs.
* This should be run as a separate process.
@@ -86,6 +101,8 @@ export const flyerWorker = new Worker<FlyerJobData>(
'flyer-processing',
async (job: Job<FlyerJobData>) => {
const { filePath, originalFileName, checksum, userId } = job.data;
const createdImagePaths: string[] = [];
let jobSucceeded = false;
logger.info(`[Worker] Processing job ${job.id} for file: ${originalFileName}`);
try {
@@ -115,6 +132,9 @@ export const flyerWorker = new Worker<FlyerJobData>(
for (const img of generatedImages) {
imagePaths.push({ path: path.join(outputDir, img), mimetype: 'image/jpeg' });
const imagePath = path.join(outputDir, img);
imagePaths.push({ path: imagePath, mimetype: 'image/jpeg' });
createdImagePaths.push(imagePath); // Track generated images for cleanup
}
logger.info(`[Worker] Converted PDF to ${imagePaths.length} images.`);
@@ -156,6 +176,7 @@ export const flyerWorker = new Worker<FlyerJobData>(
});
// TODO: Cleanup temporary files (original PDF and generated images)
jobSucceeded = true; // Mark the job as successful before the finally block.
return { flyerId: newFlyer.flyer_id };
} catch (error: unknown) {
@@ -169,6 +190,26 @@ export const flyerWorker = new Worker<FlyerJobData>(
await job.updateProgress({ message: `Error: ${errorMessage}` });
// Re-throw the error to let BullMQ know the job has failed and should be retried or marked as failed.
throw error;
} finally {
// This block will run after the try/catch, regardless of success or failure.
if (jobSucceeded) {
logger.info(`[Worker] Job ${job.id} succeeded. Cleaning up temporary files.`);
try {
// Delete the generated JPEG images from the PDF conversion.
for (const imagePath of createdImagePaths) {
await fs.unlink(imagePath);
logger.debug(`[Worker] Deleted temporary image: ${imagePath}`);
}
// Finally, delete the original uploaded file (PDF or image).
await fs.unlink(filePath);
logger.debug(`[Worker] Deleted original upload: ${filePath}`);
} catch (cleanupError) {
logger.error(`[Worker] Job ${job.id} completed, but failed during file cleanup.`, { error: cleanupError });
// We don't re-throw here because the main job was successful.
}
} else {
logger.warn(`[Worker] Job ${job.id} failed. Temporary files will not be cleaned up to allow for manual inspection.`);
}
}
},
{
@@ -236,4 +277,51 @@ export const analyticsWorker = new Worker<AnalyticsJobData>(
}
);
/**
* A dedicated worker for cleaning up flyer-related files from the filesystem.
* This is triggered manually by an admin after a flyer has been reviewed.
*/
export const cleanupWorker = new Worker<CleanupJobData>(
'file-cleanup',
async (job: Job<CleanupJobData>) => {
const { flyerId } = job.data;
logger.info(`[CleanupWorker] Starting file cleanup for flyer ID: ${flyerId}`);
try {
// 1. Fetch the flyer from the database to get its file paths.
const flyer = await db.getFlyerById(flyerId);
if (!flyer) {
throw new Error(`Flyer with ID ${flyerId} not found. Cannot perform cleanup.`);
}
// 2. Determine the base path for the flyer images.
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
// 3. Delete the main flyer image.
const mainImagePath = path.join(storagePath, path.basename(flyer.image_url));
await fs.unlink(mainImagePath).catch(err => logger.warn(`[CleanupWorker] Could not delete main image (may not exist): ${mainImagePath}`, { error: err.message }));
logger.info(`[CleanupWorker] Deleted main image: ${mainImagePath}`);
// 4. Delete the flyer icon.
if (flyer.icon_url) {
const iconPath = path.join(storagePath, 'icons', path.basename(flyer.icon_url));
await fs.unlink(iconPath).catch(err => logger.warn(`[CleanupWorker] Could not delete icon (may not exist): ${iconPath}`, { error: err.message }));
logger.info(`[CleanupWorker] Deleted icon: ${iconPath}`);
}
// Note: This process does not delete the original PDF, as its path is not stored.
// A more advanced implementation could store the original path in the job data and pass it here.
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'An unknown cleanup error occurred';
logger.error(`[CleanupWorker] Job ${job.id} for flyer ${flyerId} failed.`, { error: errorMessage });
throw error; // Re-throw to let BullMQ handle the failure and retry.
}
},
{
connection,
concurrency: 10, // Cleanup is not very resource-intensive.
}
);
logger.info('All workers started and listening for jobs.');