better flyer icons + archive
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m25s
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m25s
This commit is contained in:
16
src/App.tsx
16
src/App.tsx
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.');
|
||||
Reference in New Issue
Block a user