Files
flyer-crawler.projectium.com/src/routes/ai.ts
Torben Sorensen 80d2b1ffe6
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m28s
Add user database service and unit tests
- Implement user database service with functions for user management (create, find, update, delete).
- Add comprehensive unit tests for user database service using Vitest.
- Mock database interactions to ensure isolated testing.
- Create setup files for unit tests to handle database connections and global mocks.
- Introduce error handling for unique constraints and foreign key violations.
- Enhance logging for better traceability during database operations.
2025-12-04 15:30:27 -08:00

423 lines
18 KiB
TypeScript

// src/routes/ai.ts
import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import passport from './passport';
import { optionalAuth } from './passport';
import * as db from '../services/db/index.db';
import * as aiService from '../services/aiService.server'; // Correctly import server-side AI service
import { generateFlyerIcon } from '../utils/imageProcessor';
import { logger } from '../services/logger.server';
import { UserProfile, ExtractedCoreData } from '../types';
import { flyerQueue } from '../services/queueService.server';
const router = Router();
interface FlyerProcessPayload extends Partial<ExtractedCoreData> {
checksum?: string;
originalFileName?: string;
extractedData?: Partial<ExtractedCoreData>;
data?: FlyerProcessPayload; // For nested data structures
}
// Helper to safely extract an error message from unknown `catch` values.
const errMsg = (e: unknown) => {
if (e instanceof Error) return e.message;
if (typeof e === 'object' && e !== null && 'message' in e) return String((e as { message: unknown }).message);
return String(e || 'An unknown error occurred.');
};
// --- Multer Configuration for File Uploads ---
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
// Ensure the storage path exists at startup so multer can write files there.
try {
fs.mkdirSync(storagePath, { recursive: true });
logger.debug(`AI upload storage path ready: ${storagePath}`);
} catch (err) {
logger.error(`Failed to create storage path (${storagePath}). File uploads may fail.`, { error: err });
}
const diskStorage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
// If in a test environment, use a predictable filename for easy cleanup.
if (process.env.NODE_ENV === 'test') {
cb(null, `${file.fieldname}-test-flyer-image.jpg`);
} else {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
}
}
});
const uploadToDisk = multer({ storage: diskStorage });
// Diagnostic middleware: log incoming AI route requests (headers and sizes)
router.use((req: Request, res: Response, next: NextFunction) => {
try {
const contentType = req.headers['content-type'] || '';
const contentLength = req.headers['content-length'] || 'unknown';
const authPresent = !!req.headers['authorization'];
logger.debug('[API /ai] Incoming request', { method: req.method, url: req.originalUrl, contentType, contentLength, authPresent });
} catch (e) {
logger.error('Failed to log incoming AI request headers', { error: e });
}
next();
});
/**
* NEW ENDPOINT: Accepts a single flyer file (PDF or image), enqueues it for
* background processing, and immediately returns a job ID.
*/
router.post('/upload-and-process', optionalAuth, uploadToDisk.single('flyerFile'), async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' });
}
const { checksum } = req.body;
if (!checksum) {
return res.status(400).json({ message: 'File checksum is required.' });
}
// Check for duplicate flyer using checksum before even creating a job
const existingFlyer = await db.findFlyerByChecksum(checksum);
if (existingFlyer) {
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
// Use 409 Conflict for duplicates
return res.status(409).json({ message: 'This flyer has already been processed.', flyerId: existingFlyer.flyer_id });
}
const user = req.user as UserProfile | undefined;
// Construct a user address string from their profile if they are logged in.
let userProfileAddress: string | undefined = undefined;
if (user?.address) {
userProfileAddress = [
user.address.address_line_1,
user.address.address_line_2,
user.address.city,
user.address.province_state,
user.address.postal_code,
user.address.country
].filter(Boolean).join(', ');
}
// Add job to the queue
const job = await flyerQueue.add('process-flyer', {
filePath: req.file.path,
originalFileName: req.file.originalname,
checksum: checksum,
userId: user?.user_id,
submitterIp: req.ip, // Capture the submitter's IP address
userProfileAddress: userProfileAddress, // Pass the user's profile address
});
logger.info(`Enqueued flyer for processing. File: ${req.file.originalname}, Job ID: ${job.id}`);
// Respond immediately to the client with 202 Accepted
res.status(202).json({
message: 'Flyer accepted for processing.',
jobId: job.id,
});
} catch (error) {
next(error);
}
});
/**
* NEW ENDPOINT: Checks the status of a background job.
*/
router.get('/jobs/:jobId/status', async (req, res) => {
const { jobId } = req.params;
const job = await flyerQueue.getJob(jobId);
if (!job) {
return res.status(404).json({ message: 'Job not found.' });
}
const state = await job.getState();
const progress = job.progress;
const returnValue = job.returnvalue;
const failedReason = job.failedReason;
logger.debug(`[API /ai/jobs] Status check for job ${jobId}: ${state}`);
// When the job is complete, the return value will contain the new flyerId
// which the client can use to navigate to the new flyer's page.
res.json({ id: job.id, state, progress, returnValue, failedReason });
});
/**
* This endpoint saves the processed flyer data to the database. It is the final step
* 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, uploadToDisk.single('flyerImage'), async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Flyer image file is required.' });
}
// Diagnostic & tolerant parsing for flyers/process
logger.debug('[API /ai/flyers/process] req.body keys:', Object.keys(req.body || {}));
logger.debug('[API /ai/flyers/process] file present:', !!req.file);
// Try several ways to obtain the payload so we are tolerant to client variations.
let parsed: FlyerProcessPayload = {};
let extractedData: Partial<ExtractedCoreData> = {};
try {
// If the client sent a top-level `data` field (stringified JSON), parse it.
if (req.body && (req.body.data || req.body.extractedData)) {
const raw = (req.body.data ?? req.body.extractedData);
logger.debug('[API /ai/flyers/process] raw extractedData type:', typeof raw, 'length:', raw && raw.length ? raw.length : 0);
try {
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (err) {
logger.warn('[API /ai/flyers/process] Failed to JSON.parse raw extractedData; falling back to direct assign', { error: errMsg(err) });
parsed = (typeof raw === 'string' ? JSON.parse(String(raw).slice(0, 2000)) : raw) as FlyerProcessPayload;
}
// If parsed itself contains an `extractedData` field, use that, otherwise assume parsed is the extractedData
extractedData = parsed.extractedData ?? (parsed as Partial<ExtractedCoreData>);
} else {
// No explicit `data` field found. Attempt to interpret req.body as an object (Express may have parsed multipart fields differently).
try {
parsed = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
} catch (err) {
logger.warn('[API /ai/flyers/process] Failed to JSON.parse req.body; using empty object', { error: errMsg(err) });
parsed = req.body || {};
}
// extractedData might be nested under `data` or `extractedData`, or the body itself may be the extracted data.
if (parsed.data) {
try {
const inner = typeof parsed.data === 'string' ? JSON.parse(parsed.data) : parsed.data;
extractedData = inner.extractedData ?? inner;
} catch (err) {
logger.warn('[API /ai/flyers/process] Failed to parse parsed.data; falling back', { error: errMsg(err) });
extractedData = parsed.data as Partial<ExtractedCoreData>;
}
} else if (parsed.extractedData) {
extractedData = parsed.extractedData;
} else {
// Assume the body itself is the extracted data if it looks like it (has items or store_name keys)
if ('items' in parsed || 'store_name' in parsed || 'valid_from' in parsed) {
extractedData = parsed as Partial<ExtractedCoreData>;
} else {
extractedData = {};
}
}
}
} catch (err) {
logger.error('[API /ai/flyers/process] Unexpected error while parsing request body', { error: err });
parsed = {};
extractedData = {};
}
// Pull common metadata fields (checksum, originalFileName) from whichever shape we parsed.
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
const originalFileName = parsed.originalFileName ?? parsed?.data?.originalFileName ?? req.file.originalname;
const user = req.user as UserProfile | undefined;
// Validate extractedData to avoid database errors (e.g., null store_name)
if (!extractedData || typeof extractedData !== 'object') {
logger.warn('Missing extractedData in /api/ai/flyers/process payload.', { bodyData: parsed });
// Don't fail hard here; proceed with empty items and fallback store name so the upload can be saved for manual review.
extractedData = {};
}
// Ensure items is an array (DB function handles zero-items case)
const itemsArray = Array.isArray(extractedData.items) ? extractedData.items : [];
if (!Array.isArray(extractedData.items)) {
logger.warn('extractedData.items is missing or not an array; proceeding with empty items array.');
}
// Ensure we have a valid store name; the DB requires a non-null store name.
const storeName = extractedData.store_name && String(extractedData.store_name).trim().length > 0
? String(extractedData.store_name)
: 'Unknown Store (auto)';
if (storeName.startsWith('Unknown')) {
logger.warn('extractedData.store_name missing; using fallback store name to avoid DB constraint error.');
}
// 1. Check for duplicate flyer using checksum
const existingFlyer = await db.findFlyerByChecksum(checksum);
if (existingFlyer) {
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
return res.status(409).json({ message: 'This flyer has already been processed.' });
}
// Generate a 64x64 icon from the uploaded flyer image.
const iconsDir = path.join(path.dirname(req.file.path), 'icons');
const iconFileName = await generateFlyerIcon(req.file.path, iconsDir);
const iconUrl = `/flyer-images/icons/${iconFileName}`;
// 2. Prepare flyer data for insertion
const flyerData = {
file_name: originalFileName,
image_url: req.file.filename, // Store only the filename
icon_url: iconUrl,
checksum: checksum,
// Use normalized store name (fallback applied above).
store_name: storeName,
valid_from: extractedData.valid_from,
valid_to: extractedData.valid_to,
store_address: extractedData.store_address,
item_count: 0, // Set default to 0; the trigger will update it.
uploaded_by: user?.user_id, // Associate with user if logged in
};
// 3. Create flyer and its items in a transaction
const newFlyer = await db.createFlyerAndItems(flyerData, itemsArray);
logger.info(`Successfully processed and saved new flyer: ${newFlyer.file_name} (ID: ${newFlyer.flyer_id})`);
// Log this significant event
await db.logActivity({
userId: user?.user_id,
action: 'flyer_processed',
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name }
});
res.status(201).json({ message: 'Flyer processed and saved successfully.', flyer: newFlyer });
} catch (error) {
next(error);
}
});
/**
* 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, uploadToDisk.single('image'), async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
}
logger.info(`Server-side flyer check for file: ${req.file.originalname}`);
res.status(200).json({ is_flyer: true }); // Stubbed response
} catch (error) {
next(error);
}
});
router.post('/extract-address', optionalAuth, uploadToDisk.single('image'), async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
}
logger.info(`Server-side address extraction for file: ${req.file.originalname}`);
res.status(200).json({ address: "not identified" }); // Updated stubbed response
} catch (error) {
next(error);
}
});
router.post('/extract-logo', optionalAuth, uploadToDisk.array('images'), async (req, res, next: NextFunction) => {
try {
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
return res.status(400).json({ message: 'Image files are required.' });
}
logger.info(`Server-side logo extraction for ${req.files.length} image(s).`);
res.status(200).json({ store_logo_base_64: null }); // Stubbed response
} catch (error) {
next(error);
}
});
router.post('/quick-insights', passport.authenticate('jwt', { session: false }), async (req, res, next: NextFunction) => {
try {
logger.info(`Server-side quick insights requested.`);
res.status(200).json({ text: "This is a server-generated quick insight: buy the cheap stuff!" }); // Stubbed response
} catch (error) {
next(error);
}
});
router.post('/deep-dive', passport.authenticate('jwt', { session: false }), async (req, res, next: NextFunction) => {
try {
logger.info(`Server-side deep dive requested.`);
res.status(200).json({ text: "This is a server-generated deep dive analysis. It is very detailed." }); // Stubbed response
} catch (error) {
next(error);
}
});
router.post('/search-web', passport.authenticate('jwt', { session: false }), async (req, res, next: NextFunction) => {
try {
logger.info(`Server-side web search requested.`);
res.status(200).json({ text: "The web says this is good.", sources: [] }); // Stubbed response
} catch (error) {
next(error);
}
});
router.post('/plan-trip', passport.authenticate('jwt', { session: false }), async (req, res, next: NextFunction) => {
try {
const { items, store, userLocation } = req.body;
logger.info(`Server-side trip planning requested for user.`);
const result = await aiService.planTripWithMaps(items, store, userLocation);
res.status(200).json(result);
} catch (error) {
logger.error('Error in /api/ai/plan-trip endpoint:', { error });
next(error);
}
});
// --- STUBBED AI Routes for Future Features ---
router.post('/generate-image', passport.authenticate('jwt', { session: false }), (req: Request, res: Response) => {
// This endpoint is a placeholder for a future feature.
// Returning 501 Not Implemented is the correct HTTP response for this case.
logger.info('Request received for unimplemented endpoint: /api/ai/generate-image');
res.status(501).json({ message: 'Image generation is not yet implemented.' });
});
router.post('/generate-speech', passport.authenticate('jwt', { session: false }), (req: Request, res: Response) => {
// This endpoint is a placeholder for a future feature.
// Returning 501 Not Implemented is the correct HTTP response for this case.
logger.info('Request received for unimplemented endpoint: /api/ai/generate-speech');
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 }),
uploadToDisk.single('image'),
async (req, res) => {
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 });
res.status(500).json({ message: (error as Error).message || 'An unexpected error occurred during rescan.' });
}
}
);
export default router;