From 94a2eda576eea16ce7a90967bda011fd8cf3a1ba Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Mon, 24 Nov 2025 13:35:05 -0800 Subject: [PATCH] db to user_id --- .gitea/workflows/deploy.yml | 7 +- src/App.tsx | 49 ++++++++--- src/components/FlyerList.tsx | 4 +- src/components/SystemCheck.tsx | 82 +++++++++++++------ src/components/auth.integration.test.ts | 4 +- src/pages/AdminStatPages.tsx | 2 +- src/services/apiClient.ts | 17 +++- .../flyer-processing.integration.test.ts | 9 +- 8 files changed, 124 insertions(+), 50 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c6d3ce93..2655360c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -107,7 +107,12 @@ jobs: - name: Build React Application # We set the environment variable directly in the command line for this step. # This maps the Gitea secret to the environment variable the application expects. - run: VITE_API_BASE_URL=/api VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} npm run build + # We also generate and inject the application version and a direct link to the commit. + run: | + GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL + VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \ + VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \ + VITE_API_BASE_URL=/api VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} npm run build - name: Deploy Application to Server run: | diff --git a/src/App.tsx b/src/App.tsx index 2570e9e7..498e505b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -654,7 +654,9 @@ function App() { try { const updatedOrNewItem = await apiAddWatchedItem(itemName, category); setWatchedItems(prevItems => { - const itemExists = prevItems.some(item => item.item_id === updatedOrNewItem.item_id); + // The API returns an object with `id`, but our state uses `item_id`. + // We compare the existing item's `item_id` with the new item's `id`. + const itemExists = prevItems.some(item => item.item_id === (updatedOrNewItem as any).id); if (!itemExists) { const newItems = [...prevItems, updatedOrNewItem]; // This was correct, but the check above was wrong. return newItems.sort((a,b) => a.name.localeCompare(b.name)); @@ -672,7 +674,7 @@ function App() { if (!user) return; try { await apiRemoveWatchedItem(masterItemId); - setWatchedItems(prevItems => prevItems.filter(item => item.id !== masterItemId)); + setWatchedItems(prevItems => prevItems.filter(item => item.item_id !== masterItemId)); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not remove watched item: ${errorMessage}`); @@ -695,10 +697,10 @@ function App() { if (!user) return; try { await apiDeleteShoppingList(listId); - const newLists = shoppingLists.filter(l => l.id !== listId); + const newLists = shoppingLists.filter(l => l.shopping_list_id !== listId); setShoppingLists(newLists); if (activeListId === listId) { - setActiveListId(newLists.length > 0 ? newLists[0].id : null); + setActiveListId(newLists.length > 0 ? newLists[0].shopping_list_id : null); } } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); @@ -711,9 +713,11 @@ function App() { try { const newItem = await apiAddShoppingListItem(listId, item); setShoppingLists(prevLists => prevLists.map(list => { - if (list.id === listId) { + if (list.shopping_list_id === listId) { // Avoid adding duplicates to the state if it's already there - const itemExists = list.items.some(i => i.id === newItem.shopping_list_id); + // The API returns an object with `id`, but our state uses `item_id`. + // We compare the existing item's `item_id` with the new item's `id`. + const itemExists = list.items.some(i => i.item_id === (newItem as any).id); if (itemExists) return list; return { ...list, items: [...list.items, newItem] }; } @@ -730,8 +734,8 @@ function App() { try { const updatedItem = await apiUpdateShoppingListItem(itemId, updates); setShoppingLists(prevLists => prevLists.map(list => { - if (list.id === activeListId) { - return { ...list, items: list.items.map(i => i.id === itemId ? updatedItem : i) }; + if (list.shopping_list_id === activeListId) { + return { ...list, items: list.items.map(i => i.item_id === itemId ? updatedItem : i) }; } return list; })); @@ -746,8 +750,8 @@ function App() { try { await apiRemoveShoppingListItem(itemId); setShoppingLists(prevLists => prevLists.map(list => { - if (list.id === activeListId) { - return { ...list, items: list.items.filter(i => i.id !== itemId) }; + if (list.shopping_list_id === activeListId) { + return { ...list, items: list.items.filter(i => i.item_id !== itemId) }; } return list; })); @@ -767,8 +771,8 @@ function App() { const handleActivityLogClick: ActivityLogClickHandler = (log) => { if (log.activity_type === 'share_shopping_list' && log.entity_id) { const listId = parseInt(log.entity_id, 10); - // Check if the list exists before setting it as active. - if (shoppingLists.some(list => list.id === listId)) { + // Check if the list exists before setting it as active. This was correct. + if (shoppingLists.some(list => list.shopping_list_id === listId)) { setActiveListId(listId); } } @@ -779,6 +783,14 @@ function App() { const hasData = flyerItems.length > 0; + // Read the application version injected at build time. + // This will only be available in the production build, not during local development. + const appVersion = import.meta.env.VITE_APP_VERSION; + const commitUrl = import.meta.env.VITE_APP_COMMIT_URL; + useEffect(() => { + if (appVersion) logger.info(`Application version: ${appVersion}`); + }, [appVersion]); + return (
{/* Toaster component for displaying notifications. It's placed at the top level. */} @@ -938,6 +950,19 @@ function App() { } /> + + {/* Display the build version number at the bottom-left of the screen */} + {appVersion && ( + + Version: {appVersion} + + )}
); } diff --git a/src/components/FlyerList.tsx b/src/components/FlyerList.tsx index a6c9638c..d2ec72f5 100644 --- a/src/components/FlyerList.tsx +++ b/src/components/FlyerList.tsx @@ -39,8 +39,8 @@ export const FlyerList: React.FC = ({ flyers, onFlyerSelect, sel onClick={() => onFlyerSelect(flyer)} 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'}`} > - -
+ +

{flyer.store?.name || 'Unknown Store'}

diff --git a/src/components/SystemCheck.tsx b/src/components/SystemCheck.tsx index 14e8b941..cd311dd8 100644 --- a/src/components/SystemCheck.tsx +++ b/src/components/SystemCheck.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { loginUser, pingBackend, checkDbSchema, checkStorage, checkDbPoolHealth } from '../services/apiClient'; +import { loginUser, pingBackend, checkDbSchema, checkStorage, checkDbPoolHealth, checkPm2Status } from '../services/apiClient'; import { ShieldCheckIcon } from './icons/ShieldCheckIcon'; import { LoadingSpinner } from './LoadingSpinner'; import { CheckCircleIcon } from './icons/CheckCircleIcon'; @@ -40,6 +40,7 @@ export const SystemCheck: React.FC = () => { const [checks, setChecks] = useState(initialChecks); const [isRunning, setIsRunning] = useState(false); const [hasRunAutoTest, setHasRunAutoTest] = useState(false); + const [elapsedTime, setElapsedTime] = useState(null); const updateCheckStatus = useCallback((id: CheckID, status: TestStatus, message: string) => { setChecks(prev => prev.map(c => c.id === id ? { ...c, status, message } : c)); @@ -135,19 +136,43 @@ export const SystemCheck: React.FC = () => { }, [updateCheckStatus]); const runChecks = useCallback(async () => { + const startTime = performance.now(); + setElapsedTime(null); // Reset timer on new run setIsRunning(true); setChecks(prev => prev.map(c => ({ ...c, status: 'running', message: 'Checking...' }))); - - if (!checkApiKey()) { setIsRunning(false); return; } - if (!await checkBackendConnection()) { setIsRunning(false); return; } - await checkPm2Process(); // This is not a blocking check for others - if (!await checkDatabasePool()) { setIsRunning(false); return; } - if (!await checkDatabaseSchema()) { setIsRunning(false); return; } - if (!await checkStorageDirectory()) { setIsRunning(false); return; } - await checkSeededUsers(); - - setIsRunning(false); - }, [checkApiKey, checkBackendConnection, checkPm2Process, checkDatabasePool, checkDatabaseSchema, checkStorageDirectory, checkSeededUsers]); + + try { + // --- Step 1: Synchronous local check --- + if (!checkApiKey()) { + return; // Exit early + } + + // --- Step 2: Critical backend connection check --- + const backendOk = await checkBackendConnection(); + if (!backendOk) { + // If backend is down, fail all dependent checks to provide immediate feedback. + const dependentChecks = [CheckID.DB_POOL, CheckID.SCHEMA, CheckID.SEED, CheckID.STORAGE, CheckID.PM2_STATUS]; + dependentChecks.forEach(id => { + updateCheckStatus(id, 'fail', 'Skipped: Backend server is not reachable.'); + }); + return; // Exit early + } + + // --- Step 3: Run remaining checks in parallel for performance --- + await Promise.all([ + checkPm2Process(), + checkDatabasePool(), + checkDatabaseSchema(), + checkStorageDirectory(), + checkSeededUsers(), + ]); + } finally { + // This block will run regardless of whether the checks succeeded or failed. + setIsRunning(false); + const endTime = performance.now(); + setElapsedTime((endTime - startTime) / 1000); // Set elapsed time in seconds + } + }, [checkApiKey, checkBackendConnection, checkPm2Process, checkDatabasePool, checkDatabaseSchema, checkStorageDirectory, checkSeededUsers, updateCheckStatus]); useEffect(() => { if (!hasRunAutoTest) { @@ -190,20 +215,27 @@ export const SystemCheck: React.FC = () => { ))} - + +
); }; diff --git a/src/components/auth.integration.test.ts b/src/components/auth.integration.test.ts index ad366ccc..29bacb04 100644 --- a/src/components/auth.integration.test.ts +++ b/src/components/auth.integration.test.ts @@ -16,7 +16,7 @@ import { getPool } from '../services/db/connection'; describe('Authentication API Integration', () => { // --- START DEBUG LOGGING --- // Query the DB from within the test file to see its state. - getPool().query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.id').then(res => { + getPool().query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id').then(res => { console.log('\n--- [auth.integration.test.ts] Users found in DB from TEST perspective: ---'); console.table(res.rows); console.log('--------------------------------------------------------------------------\n'); @@ -47,7 +47,7 @@ describe('Authentication API Integration', () => { expect(response).toBeDefined(); expect(response.user).toBeDefined(); expect(response.user.email).toBe(adminEmail); - expect(response.user.id).toBeTypeOf('string'); + expect(response.user.user_id).toBeTypeOf('string'); expect(response.token).toBeTypeOf('string'); }); diff --git a/src/pages/AdminStatPages.tsx b/src/pages/AdminStatPages.tsx index 72cce210..cc009a11 100644 --- a/src/pages/AdminStatPages.tsx +++ b/src/pages/AdminStatPages.tsx @@ -46,7 +46,7 @@ export const AdminStatsPage: React.FC = () => { }, []); return ( -
+
← Back to Admin Dashboard

Application Statistics

diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 1f8fa43c..658f56c1 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -3,7 +3,7 @@ import { Profile, UserProfile, Flyer, MasterGroceryItem, ShoppingList, ShoppingL import { logger } from './logger'; interface AuthResponse { - user: { id: string; email: string }; + user: { user_id: string; email: string }; token: string; } @@ -233,6 +233,21 @@ export const checkDbPoolHealth = async (): Promise<{ success: boolean; message: return data; }; +/** + * Checks the status of the application process managed by PM2. + * This is intended for development and diagnostic purposes. + */ +export const checkPm2Status = async (): Promise<{ success: boolean; message: string }> => { + // This is a public health check, so we can use standard fetch. + const response = await fetch(`${API_BASE_URL}/system/pm2-status`); + const data = await response.json(); + if (!response.ok) { + // Use the message from the backend error response + throw new Error(data.message || 'Failed to check PM2 status.'); + } + return data; +}; + /** * Fetches all flyers from the backend. * @returns A promise that resolves to an array of Flyer objects. diff --git a/src/services/flyer-processing.integration.test.ts b/src/services/flyer-processing.integration.test.ts index a9d67e54..d08ec7b5 100644 --- a/src/services/flyer-processing.integration.test.ts +++ b/src/services/flyer-processing.integration.test.ts @@ -31,11 +31,8 @@ describe('Flyer Processing End-to-End Integration Tests', () => { beforeAll(async () => { // 1. Create an authenticated user for testing protected uploads const email = `flyer-user-${Date.now()}@example.com`; - const { user: loggedInUser, token } = await createAndLoginUser(email); - // The loginUser function returns a user object with `id`. We need to map it - // to the `User` type which expects `user_id`. - // We'll construct a partial User object sufficient for this test's needs. - testUser = { ...loggedInUser, user_id: loggedInUser.id } as User; + const { user, token } = await createAndLoginUser(email); + testUser = user as User; authToken = token; // 2. Fetch master items, which are needed for AI processing @@ -46,7 +43,7 @@ describe('Flyer Processing End-to-End Integration Tests', () => { afterAll(async () => { // Clean up the created user if (testUser) { - await getPool().query('DELETE FROM public.users WHERE id = $1', [testUser.user_id]); + await getPool().query('DELETE FROM public.users WHERE user_id = $1', [testUser.user_id]); } // Clean up any flyers created during the test await getPool().query("DELETE FROM public.flyers WHERE file_name LIKE 'test-flyer-%'");