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

This commit is contained in:
2025-11-22 23:26:09 -08:00
parent 9d530ca238
commit dc4ba26315
5 changed files with 202 additions and 40 deletions

View File

@@ -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.

View File

@@ -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) => {

View File

@@ -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();

View 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();
});
});

View File

@@ -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'>[];
}