Files
flyer-crawler.projectium.com/src/routes/ai.ts
Torben Sorensen a4d5e95937
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 1m1s
some more re-org + fixes
2025-11-24 14:46:17 -08:00

209 lines
8.8 KiB
TypeScript

// src/routes/ai.ts
import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import passport from './passport';
import { optionalAuth } from './passport';
import * as db from '../services/db';
import * as aiService from '../services/aiService.server'; // Correctly import server-side AI service
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
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({
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
}
});
const upload = multer({ storage: storage });
/**
* 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) => {
// --- AI ROUTE DEBUG LOGGING ---
logger.debug('[API /ai/process-flyer] Request received.');
logger.debug(`[API /ai/process-flyer] Files received: ${req.files ? (req.files as Express.Multer.File[]).length : 0}`);
logger.debug(`[API /ai/process-flyer] Body masterItems (first 50 chars): ${req.body.masterItems?.substring(0, 50)}...`);
// --- END DEBUG LOGGING ---
try {
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
return res.status(400).json({ message: 'Flyer image files are required.' });
}
const masterItems = JSON.parse(req.body.masterItems);
const imagePaths = (req.files as Express.Multer.File[]).map(file => ({
path: file.path,
mimetype: file.mimetype
}));
logger.debug(`[API /ai/process-flyer] Processing image paths:`, { imagePaths });
const user = req.user as UserProfile | undefined;
const logIdentifier = user ? `user ID: ${user.user_id}` : 'anonymous user';
logger.info(`Starting AI flyer data extraction for ${imagePaths.length} image(s) for ${logIdentifier}.`);
const extractedData = await aiService.extractCoreDataFromFlyerImage(imagePaths, masterItems);
logger.info(`Completed AI flyer data extraction. Found ${extractedData.items.length} items.`);
res.status(200).json({ data: extractedData });
} catch (error) {
logger.error('Error in /api/ai/process-flyer endpoint:', { error });
next(error);
}
});
/**
* 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, upload.single('flyerImage'), async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Flyer image file is required.' });
}
if (!req.body.data) {
return res.status(400).json({ message: 'Data payload is required.' });
}
const { checksum, originalFileName, extractedData } = JSON.parse(req.body.data);
const user = req.user as UserProfile | undefined;
// 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.' });
}
// Find or create the store to get its ID, which is required by `createFlyerAndItems`.
let storeId: number;
const storeRes = await db.getPool().query<{ store_id: number }>('SELECT store_id FROM public.stores WHERE name = $1', [extractedData.store_name]);
if (storeRes.rows.length > 0) {
storeId = storeRes.rows[0].store_id;
} else {
const newStoreRes = await db.getPool().query<{ store_id: number }>('INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id', [extractedData.store_name]);
storeId = newStoreRes.rows[0].store_id;
}
// 2. Prepare flyer data for insertion
const flyerData = {
file_name: originalFileName,
image_url: `/assets/${req.file.filename}`,
checksum: checksum,
store_name: extractedData.store_name,
store_id: storeId, // Use the resolved store_id
valid_from: extractedData.valid_from,
valid_to: extractedData.valid_to,
store_address: extractedData.store_address,
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, extractedData.items);
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, upload.single('image'), async (req, res, next) => {
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, upload.single('image'), async (req, res, next) => {
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: "123 AI Street, Server City" }); // Stubbed response
} catch (error) {
next(error);
}
});
router.post('/extract-logo', optionalAuth, upload.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.' });
}
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) => {
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) => {
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) => {
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) => {
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);
}
});
export default router;