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/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-%'");