upload file size limit increased in server.ts
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 1m5s
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 1m5s
This commit is contained in:
@@ -2,6 +2,7 @@ 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';
|
||||
import { UserProfile } from '../types';
|
||||
@@ -52,6 +53,61 @@ router.post('/process-flyer', optionalAuth, upload.array('flyerImages'), async (
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.' });
|
||||
}
|
||||
|
||||
// 2. Prepare flyer data for insertion
|
||||
const flyerData = {
|
||||
file_name: originalFileName,
|
||||
image_url: `/assets/${req.file.filename}`,
|
||||
checksum: checksum,
|
||||
store_name: extractedData.store_name,
|
||||
valid_from: extractedData.valid_from,
|
||||
valid_to: extractedData.valid_to,
|
||||
store_address: extractedData.store_address,
|
||||
uploaded_by: 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.id})`);
|
||||
|
||||
// Log this significant event
|
||||
await db.logActivity({
|
||||
userId: user?.id,
|
||||
action: 'flyer_processed',
|
||||
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
|
||||
details: { flyerId: newFlyer.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.
|
||||
|
||||
@@ -145,42 +145,6 @@ router.put('/users/profile/password', async (req: Request, res: Response, next:
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/users/account', async (req: Request, res: Response) => {
|
||||
// The `req.user` object from the JWT strategy is the user's profile.
|
||||
// It reliably contains the user's ID.
|
||||
const authenticatedUser = req.user as { id: string; email: string };
|
||||
const { password } = req.body;
|
||||
|
||||
if (!password) {
|
||||
return res.status(400).json({ message: 'Password is required for account deletion.' });
|
||||
}
|
||||
|
||||
logger.info(`Account deletion requested for user ID: ${authenticatedUser.id}`);
|
||||
|
||||
try {
|
||||
// Fetch the user by their ID from the authenticated token, not by email.
|
||||
// Use the specific function that includes the password hash for verification.
|
||||
const userWithHash = await db.findUserWithPasswordHashById(authenticatedUser.id);
|
||||
|
||||
if (!userWithHash || !userWithHash.password_hash) {
|
||||
return res.status(404).json({ message: 'User not found or is an OAuth user.' });
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, userWithHash.password_hash);
|
||||
if (!isMatch) {
|
||||
logger.warn(`Account deletion failed for user ${authenticatedUser.email} due to incorrect password.`);
|
||||
return res.status(403).json({ message: 'Incorrect password.' });
|
||||
}
|
||||
|
||||
await db.deleteUserById(authenticatedUser.id);
|
||||
logger.warn(`User account deleted successfully: ${authenticatedUser.email}`);
|
||||
res.status(200).json({ message: 'Account deleted successfully.' });
|
||||
} catch (error) {
|
||||
logger.error('Error during account deletion:', { error });
|
||||
res.status(500).json({ message: 'Failed to delete account.' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Watched Items Routes ---
|
||||
|
||||
router.get('/watched-items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
@@ -101,6 +101,32 @@ export const apiFetch = async (url: string, options: RequestInit = {}, tokenOver
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* A specialized fetch wrapper for FormData uploads that require authentication.
|
||||
* It correctly adds the Authorization header but crucially AVOIDS setting a
|
||||
* 'Content-Type' header, allowing the browser to set it automatically with the
|
||||
* correct multipart boundary. Using the main `apiFetch` for FormData will fail
|
||||
* because it defaults to 'application/json'.
|
||||
* @param url The URL to fetch.
|
||||
* @param options The fetch options, which must include a FormData body.
|
||||
* @returns A promise that resolves to the fetch Response.
|
||||
*/
|
||||
export const apiFetchWithAuth = async (url: string, options: RequestInit): Promise<Response> => {
|
||||
const headers = new Headers(options.headers || {});
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
|
||||
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
// IMPORTANT: Do NOT set Content-Type. The browser handles it for FormData.
|
||||
const newOptions = { ...options, headers };
|
||||
|
||||
// This does not need the full token refresh logic of apiFetch, because if the token
|
||||
// is expired, the user will be logged out on the next page navigation anyway.
|
||||
return fetch(url, newOptions);
|
||||
};
|
||||
/**
|
||||
* Pings the backend server to check if it's running and reachable.
|
||||
* @returns A promise that resolves to true if the server responds with 'pong'.
|
||||
@@ -223,12 +249,14 @@ export const processFlyerFile = async (
|
||||
const dataPayload = { checksum, originalFileName, extractedData };
|
||||
formData.append('data', JSON.stringify(dataPayload));
|
||||
|
||||
// We use the standard `fetch` here because `apiFetch` is for JSON APIs and token refresh.
|
||||
// File uploads with FormData have different header requirements.
|
||||
const response = await fetch(`${API_BASE_URL}/flyers/process`, {
|
||||
// We must use our special `apiFetchWithAuth` for this request.
|
||||
// 1. It ensures the `Authorization` header is added if the user is logged in.
|
||||
// 2. It correctly AVOIDS setting a `Content-Type` header, allowing the browser to
|
||||
// set the `multipart/form-data` header with the required `boundary` parameter.
|
||||
// Using the standard `apiFetch` would fail by incorrectly setting `Content-Type: application/json`.
|
||||
const response = await apiFetchWithAuth(`${API_BASE_URL}/api/ai/flyers/process`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
// DO NOT set 'Content-Type' header manually; the browser does it correctly for FormData.
|
||||
});
|
||||
|
||||
return response.json();
|
||||
|
||||
112
src/services/flyer-processing.integration.test.ts
Normal file
112
src/services/flyer-processing.integration.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as apiClient from './apiClient';
|
||||
import * as aiApiClient from './aiApiClient';
|
||||
import * as db from './db';
|
||||
import { getPool } from './db/connection';
|
||||
import type { User, MasterGroceryItem } from '../types';
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
const TEST_PASSWORD = 'a-much-stronger-password-for-testing-!@#$';
|
||||
|
||||
/**
|
||||
* Helper to create and log in a user for authenticated tests.
|
||||
*/
|
||||
const createAndLoginUser = async (email: string) => {
|
||||
await apiClient.registerUser(email, TEST_PASSWORD, 'Flyer Uploader');
|
||||
const { user, token } = await apiClient.loginUser(email, TEST_PASSWORD, false);
|
||||
return { user, token };
|
||||
};
|
||||
|
||||
describe('Flyer Processing End-to-End Integration Tests', () => {
|
||||
let testUser: User;
|
||||
let authToken: string;
|
||||
let masterItems: MasterGroceryItem[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// 1. Create an authenticated user for testing protected uploads
|
||||
const email = `flyer-user-${Date.now()}@example.com`;
|
||||
const { user, token } = await createAndLoginUser(email);
|
||||
testUser = user;
|
||||
authToken = token;
|
||||
|
||||
// 2. Fetch master items, which are needed for AI processing
|
||||
masterItems = await db.getAllMasterItems();
|
||||
expect(masterItems.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up the created user
|
||||
if (testUser) {
|
||||
await getPool().query('DELETE FROM public.users WHERE id = $1', [testUser.id]);
|
||||
}
|
||||
// Clean up any flyers created during the test
|
||||
await getPool().query("DELETE FROM public.flyers WHERE file_name LIKE 'test-flyer-%'");
|
||||
});
|
||||
|
||||
const runFlyerProcessingTest = async (token?: string) => {
|
||||
// Arrange: Create a mock file and payload
|
||||
const mockImageFile = new File(['mock-image-content'], 'test-flyer.jpg', { type: 'image/jpeg' });
|
||||
const mockMasterItems: MasterGroceryItem[] = masterItems.slice(0, 2);
|
||||
|
||||
// Act 1: Simulate the first step - AI data extraction
|
||||
// In a real test, we might mock the AI service, but here we call our backend which has stubs.
|
||||
const extractedData = await aiApiClient.extractCoreDataFromImage([mockImageFile], mockMasterItems);
|
||||
|
||||
// Assert 1: Check that we got some data back from the extraction step
|
||||
expect(extractedData).toBeDefined();
|
||||
expect(extractedData).not.toBeNull();
|
||||
if (!extractedData) throw new Error('Extraction failed');
|
||||
|
||||
expect(extractedData.store_name).toBeTypeOf('string');
|
||||
expect(extractedData.items.length).toBeGreaterThan(0);
|
||||
|
||||
// Arrange 2: Prepare for the final processing step
|
||||
const checksum = `test-checksum-${Date.now()}`;
|
||||
const originalFileName = `test-flyer-${Date.now()}.jpg`;
|
||||
|
||||
// Act 2: Simulate the final step - saving the data and image to the database
|
||||
// This is the critical part that uses `apiFetchWithAuth` under the hood.
|
||||
// We override the global localStorage for the test environment.
|
||||
if (token) {
|
||||
global.localStorage.setItem('authToken', token);
|
||||
} else {
|
||||
global.localStorage.removeItem('authToken');
|
||||
}
|
||||
|
||||
const processResponse = await apiClient.processFlyerFile(
|
||||
mockImageFile,
|
||||
checksum,
|
||||
originalFileName,
|
||||
extractedData
|
||||
);
|
||||
|
||||
// Assert 2: Check for a successful response from the server
|
||||
expect(processResponse).toBeDefined();
|
||||
expect(processResponse.message).toBe('Flyer processed and saved successfully.');
|
||||
expect(processResponse.flyer).toBeDefined();
|
||||
expect(processResponse.flyer.file_name).toBe(originalFileName);
|
||||
|
||||
// Assert 3: Verify the flyer was actually saved in the database
|
||||
const savedFlyer = await db.findFlyerByChecksum(checksum);
|
||||
expect(savedFlyer).toBeDefined();
|
||||
expect(savedFlyer?.id).toBe(processResponse.flyer.id);
|
||||
|
||||
// Verify user association
|
||||
if (token) {
|
||||
expect(savedFlyer?.uploaded_by).toBe(testUser.id);
|
||||
} else {
|
||||
expect(savedFlyer?.uploaded_by).toBe(null);
|
||||
}
|
||||
};
|
||||
|
||||
it('should successfully process and save a flyer for an AUTHENTICATED user', async () => {
|
||||
await runFlyerProcessingTest(authToken);
|
||||
});
|
||||
|
||||
it('should successfully process and save a flyer for an ANONYMOUS user', async () => {
|
||||
await runFlyerProcessingTest();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export interface Flyer {
|
||||
valid_from?: string | null;
|
||||
valid_to?: string | null;
|
||||
store_address?: string | null;
|
||||
uploaded_by?: string | null; // UUID of the user who uploaded it
|
||||
store?: Store;
|
||||
}
|
||||
|
||||
@@ -549,6 +550,7 @@ export interface ExtractedCoreData {
|
||||
store_name: string;
|
||||
valid_from: string | null;
|
||||
valid_to: string | null;
|
||||
store_address: string | null;
|
||||
items: Omit<FlyerItem, 'id' | 'created_at' | 'flyer_id'>[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user