test, more id fixes, and naming all files
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 48s

This commit is contained in:
2025-11-25 05:59:56 -08:00
parent 2a48b0a041
commit 1d0bd630b2
82 changed files with 708 additions and 116 deletions

View File

@@ -1,4 +1,4 @@
# FILE: .gitea/workflows/deploy.yml
# .gitea/workflows/deploy.yml
#
# deploy to production which is an ubuntu co-lo server with nginx + postgres
#

View File

@@ -1,4 +1,4 @@
# FILE: .gitea/workflows/manual-db-reset.yml
# .gitea/workflows/manual-db-reset.yml
#
# DANGER: This workflow is DESTRUCTIVE and intended for manual execution only.
# It will completely WIPE and RESET the PRODUCTION database.

View File

@@ -1,3 +1,4 @@
// global-setup.ts
/**
* This function is executed once before all tests.
* It assumes the database is already running and connection details are

11
package-lock.json generated
View File

@@ -54,6 +54,7 @@
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.15.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/zxcvbn": "^4.4.5",
"@typescript-eslint/eslint-plugin": "^8.47.0",
"@typescript-eslint/parser": "^8.47.0",
@@ -4439,6 +4440,16 @@
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",

View File

@@ -66,6 +66,7 @@
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.15.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/zxcvbn": "^4.4.5",
"@typescript-eslint/eslint-plugin": "^8.47.0",
"@typescript-eslint/parser": "^8.47.0",

View File

@@ -1,3 +1,4 @@
-- sql/Initial_triggers_and_functions.sql
-- This file contains all trigger functions and trigger definitions for the database.
-- 1. Set up the trigger to automatically create a profile when a new user signs up.

View File

@@ -1,3 +1,4 @@
-- sql/drop_tables.sql
/*
-- This script is used to completely reset the public schema by deleting all tables.
-- It should be run before re-running the schema.sql.txt script to ensure a clean state.

View File

@@ -1,3 +1,4 @@
# sql/helper_scripts/generate_rollup.ps1
<#
.SYNOPSIS
SQL ROLLUP GENERATION SCRIPT (POWERSHELL)
@@ -29,4 +30,3 @@ $SourceFiles = @(
Write-Host "Generating '$MasterFile' from source files..."
Get-Content -Path $SourceFiles | Set-Content -Path $MasterFile -Encoding UTF8
Write-Host "✅ Success: '$MasterFile' has been generated." -ForegroundColor Green

View File

@@ -1,5 +1,5 @@
#!/bin/bash
# sql/helper_scripts/generate_rollup.sh
# ============================================================================
# SQL ROLLUP GENERATION SCRIPT (BASH)
# ============================================================================

View File

@@ -1,5 +1,5 @@
#!/bin/bash
# sql/helper_scripts/verify_rollup.sh
# ============================================================================
# SQL ROLLUP VERIFICATION SCRIPT
# ============================================================================

View File

@@ -1,3 +1,4 @@
-- sql/initial_data.sql
-- ============================================================================
-- INITIAL DATA SEEDING SCRIPT
-- ============================================================================

View File

@@ -1,3 +1,4 @@
-- sql/initial_schema.sql
-- ============================================================================
-- ============================================================================
-- PART 2: TABLES

View File

@@ -1,3 +1,4 @@
-- sql/master_schema_rollup.sql
-- ============================================================================
-- MASTER SCHEMA SCRIPT
-- ============================================================================

View File

@@ -1,3 +1,4 @@
// src/App.tsx
import React, { useState, useCallback, useEffect } from 'react';
import { Routes, Route } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
@@ -29,7 +30,7 @@ import { AdminRoute } from './components/AdminRoute';
import { CorrectionsPage } from './pages/CorrectionsPage';
import { ActivityLog, ActivityLogClickHandler } from './components/ActivityLog';
import { WatchedItemsList } from './components/WatchedItemsList';
import { AdminStatsPage } from './pages/AdminStatPages';
import { AdminStatsPage } from './pages/AdminStatsPage';
import { ResetPasswordPage } from './pages/ResetPasswordPage';
import { AnonymousUserBanner } from './components/AnonymousUserBanner';
import { VoiceLabPage } from './pages/VoiceLabPage'; // Import the new page
@@ -775,10 +776,10 @@ function App() {
};
const handleActivityLogClick: ActivityLogClickHandler = (log) => {
if (log.action === 'list_shared' && log.details?.shopping_list_id) {
const listId = parseInt(String(log.details.shopping_list_id), 10);
// Check if the list exists before setting it as active. This was correct.
if (shoppingLists.some(list => list.shopping_list_id === listId) && typeof listId === 'number') {
// Thanks to the discriminated union, if the action is 'list_shared', TypeScript knows 'details.shopping_list_id' is a number.
if (log.action === 'list_shared') {
const listId = log.details.shopping_list_id;
if (shoppingLists.some(list => list.shopping_list_id === listId)) {
setActiveListId(listId);
}
}

View File

@@ -0,0 +1,140 @@
// src/components/ActivityLog.test.tsx
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { ActivityLog } from './ActivityLog';
import * as apiClient from '../services/apiClient';
import type { ActivityLogItem, User } from '../types';
// Mock the apiClient module
vi.mock('../services/apiClient', async (importOriginal) => {
const actual = await importOriginal<typeof apiClient>();
return {
...actual,
fetchActivityLog: vi.fn(),
};
});
// Mock the logger
vi.mock('../services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock date-fns to return a consistent value for snapshots
vi.mock('date-fns', async (importOriginal) => {
const actual = await importOriginal<typeof import('date-fns')>();
return {
...actual,
formatDistanceToNow: vi.fn(() => 'about 5 hours ago'),
};
});
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
const mockLogs: ActivityLogItem[] = [
{
activity_log_id: 1,
user_id: 'user-123',
action: 'flyer_processed',
display_text: 'Processed a new flyer for Walmart.',
details: { flyerId: 1, store_name: 'Walmart', user_avatar_url: 'http://example.com/avatar.png', user_full_name: 'Test User' },
created_at: new Date().toISOString(),
},
{
activity_log_id: 2,
user_id: 'user-456',
action: 'recipe_created',
display_text: 'Jane Doe added a new recipe: Pasta Carbonara',
details: { recipe_name: 'Pasta Carbonara', user_full_name: 'Jane Doe' },
created_at: new Date().toISOString(),
},
{
activity_log_id: 3,
user_id: 'user-789',
action: 'list_shared',
display_text: 'John Smith shared a list.',
details: { list_name: 'Weekly Groceries', shopping_list_id: 10, user_full_name: 'John Smith', shared_with_name: 'Test User' },
created_at: new Date().toISOString(),
},
];
describe('ActivityLog', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should not render if user is null', () => {
const { container } = render(<ActivityLog user={null} />);
expect(container).toBeEmptyDOMElement();
});
it('should show a loading state initially', () => {
(apiClient.fetchActivityLog as Mock).mockReturnValue(new Promise(() => {}));
render(<ActivityLog user={mockUser} />);
expect(screen.getByText('Loading activity...')).toBeInTheDocument();
});
it('should display an error message if fetching logs fails', async () => {
(apiClient.fetchActivityLog as Mock).mockRejectedValue(new Error('API is down'));
render(<ActivityLog user={mockUser} />);
await waitFor(() => {
expect(screen.getByText('API is down')).toBeInTheDocument();
});
});
it('should display a message when there are no logs', async () => {
(apiClient.fetchActivityLog as Mock).mockResolvedValue([]);
render(<ActivityLog user={mockUser} />);
await waitFor(() => {
expect(screen.getByText('No recent activity to show.')).toBeInTheDocument();
});
});
it('should render a list of activities successfully', async () => {
(apiClient.fetchActivityLog as Mock).mockResolvedValue(mockLogs);
render(<ActivityLog user={mockUser} />);
await waitFor(() => {
// Check for specific text from different log types
expect(screen.getByText('Walmart')).toBeInTheDocument(); // From flyer_processed
expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument(); // From recipe_created
expect(screen.getByText('Weekly Groceries')).toBeInTheDocument(); // From list_shared
// Check for user names
expect(screen.getByText('Jane Doe', { exact: false })).toBeInTheDocument();
// Check for avatar
const avatar = screen.getByAltText('Test User');
expect(avatar).toBeInTheDocument();
expect(avatar).toHaveAttribute('src', 'http://example.com/avatar.png');
// Check for the mocked date
expect(screen.getAllByText('about 5 hours ago')).toHaveLength(mockLogs.length);
});
});
it('should call onLogClick when a clickable log item is clicked', async () => {
const onLogClickMock = vi.fn();
(apiClient.fetchActivityLog as Mock).mockResolvedValue(mockLogs);
render(<ActivityLog user={mockUser} onLogClick={onLogClickMock} />);
await waitFor(() => {
// Find the clickable element (the recipe name in this case)
const clickableRecipe = screen.getByText('Pasta Carbonara');
fireEvent.click(clickableRecipe);
expect(onLogClickMock).toHaveBeenCalledTimes(1);
// The second log item has the recipe
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[1]);
const clickableList = screen.getByText('Weekly Groceries');
fireEvent.click(clickableList);
expect(onLogClickMock).toHaveBeenCalledTimes(2);
// The third log item has the list
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[2]);
});
});
});

View File

@@ -1,3 +1,4 @@
// src/components/ActivityLog.tsx
import React, { useState, useEffect } from 'react';
import { fetchActivityLog } from '../services/apiClient';
import { ActivityLogItem } from '../types';
@@ -12,16 +13,17 @@ interface ActivityLogProps {
}
const renderLogDetails = (log: ActivityLogItem, onLogClick?: ActivityLogClickHandler) => {
const userName = log.details.user_full_name || 'A user';
// With discriminated unions, we can safely access properties based on the 'action' type.
const userName = 'user_full_name' in log.details ? log.details.user_full_name : 'A user';
const isClickable = onLogClick !== undefined;
switch (log.activity_type) {
case 'new_flyer':
switch (log.action) {
case 'flyer_processed':
return (
<span>
A new flyer for <strong>{log.details.store_name || 'a store'}</strong> was added.
</span>
);
case 'new_recipe':
case 'recipe_created':
return (
<span>
{userName} added a new recipe:{" "}
@@ -33,13 +35,13 @@ const renderLogDetails = (log: ActivityLogItem, onLogClick?: ActivityLogClickHan
</strong>.
</span>
);
case 'new_user':
case 'user_registered':
return (
<span>
<strong>{log.details.full_name || 'A new user'}</strong> just joined!
</span>
);
case 'favorite_recipe':
case 'recipe_favorited':
return (
<span>
{userName} favorited the recipe:{" "}
@@ -51,7 +53,7 @@ const renderLogDetails = (log: ActivityLogItem, onLogClick?: ActivityLogClickHan
</strong>.
</span>
);
case 'share_shopping_list':
case 'list_shared':
return (
<span>
{userName} shared the list "
@@ -110,10 +112,10 @@ export const ActivityLog: React.FC<ActivityLogProps> = ({ user, onLogClick }) =>
)}
<ul className="space-y-4">
{logs.map((log) => (
<li key={log.log_id} className="flex items-start space-x-3">
<li key={log.activity_log_id} className="flex items-start space-x-3">
<div className="shrink-0">
{log.user_avatar_url ? (
<img className="h-8 w-8 rounded-full" src={log.user_avatar_url} alt={log.user_full_name || ''} />
{log.details?.user_avatar_url ? (
<img className="h-8 w-8 rounded-full" src={log.details.user_avatar_url} alt={log.details.user_full_name || ''} />
) : (
<span className="h-8 w-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
<svg className="h-5 w-5 text-gray-500 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20">

View File

@@ -1,3 +1,4 @@
// src/components/AdminBrandManager.tsx
import React, { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import { fetchAllBrands, uploadBrandLogo } from '../services/apiClient';
@@ -97,7 +98,7 @@ export const AdminBrandManager: React.FC = () => {
<input
type="file"
accept="image/png, image/jpeg, image/webp, image/svg+xml"
onChange={(e) => e.target.files && handleLogoUpload(brand.id, e.target.files[0])}
onChange={(e) => e.target.files && handleLogoUpload(brand.brand_id, e.target.files[0])}
className="text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-light file:text-brand-dark hover:file:bg-brand-primary/20"
/>
</td>

View File

@@ -1,3 +1,4 @@
// src/components/AdminPage.tsx
import React from 'react';
import { SystemCheck } from './SystemCheck';
import { Link } from 'react-router-dom';

View File

@@ -1,3 +1,4 @@
// src/components/AdminRoute.tsx
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import type { Profile } from '../types';

View File

@@ -1,4 +1,4 @@
// src/components/AnalysisPanel.tsx
import React, { useState, useCallback } from 'react';
import { AnalysisType, FlyerItem, Store } from '../types';
import type { GroundingChunk } from '@google/genai';

View File

@@ -1,3 +1,4 @@
// src/components/AnonymousUserBanner.tsx
import React from 'react';
import { InformationCircleIcon } from './icons/InformationCircleIcon';

View File

@@ -1,3 +1,4 @@
// src/components/BulkImportSummary.tsx
import React from 'react';
import { CheckCircleIcon } from './icons/CheckCircleIcon';
import { ExclamationTriangleIcon } from './icons/ExclamationTriangleIcon';
@@ -34,7 +35,7 @@ export const BulkImportSummary: React.FC<BulkImportSummaryProps> = ({ summary, o
</div>
{hasContent ? (
<div className="space-y-4 flex-grow overflow-y-auto">
<div className="space-y-4 grow overflow-y-auto">
{summary.processed.length > 0 && (
<div>
<h4 className="text-md font-semibold flex items-center mb-2 text-green-700 dark:text-green-400">
@@ -74,7 +75,7 @@ export const BulkImportSummary: React.FC<BulkImportSummaryProps> = ({ summary, o
)}
</div>
) : (
<div className="flex-grow flex flex-col justify-center items-center text-center">
<div className="grow flex flex-col justify-center items-center text-center">
<InformationCircleIcon className="w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-600 dark:text-gray-400">No new files were found to process.</p>
</div>

View File

@@ -1,3 +1,4 @@
// src/components/BulkImporter.tsx
import React, { useCallback, useState } from 'react';
import { UploadIcon } from './icons/UploadIcon';

View File

@@ -1,3 +1,4 @@
// src/components/ConfirmationModal.tsx
import React from 'react';
import { XMarkIcon } from './icons/XMarkIcon';
import { ExclamationTriangleIcon } from './icons/ExclamationTriangleIcon';
@@ -45,7 +46,7 @@ export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
</button>
<div className="p-6">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/30 sm:mx-0 sm:h-10 sm:w-10">
<div className="mx-auto shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/30 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600 dark:text-red-400" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">

View File

@@ -1,3 +1,4 @@
// src/components/CorrectionRow.tsx
import React, { useState } from 'react';
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../types';
import { approveCorrection, rejectCorrection, updateSuggestedCorrection } from '../services/apiClient'; // Ensure we are using apiClient
@@ -35,7 +36,7 @@ export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction: initia
}
if (correction_type === 'INCORRECT_ITEM_LINK') {
const masterItemId = parseInt(suggested_value, 10);
const item = masterItems.find(mi => mi.master_item_id === masterItemId);
const item = masterItems.find(mi => mi.master_grocery_item_id === masterItemId);
return item ? `${item.name} (ID: ${masterItemId})` : `Unknown Item (ID: ${masterItemId})`;
}
if (correction_type === 'ITEM_IS_MISCATEGORIZED') {
@@ -54,18 +55,18 @@ export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction: initia
setError(null);
try {
if (actionToConfirm === 'approve') {
await approveCorrection(currentCorrection.correction_id);
logger.info(`Correction ${currentCorrection.correction_id} approved.`);
await approveCorrection(currentCorrection.suggested_correction_id);
logger.info(`Correction ${currentCorrection.suggested_correction_id} approved.`);
} else if (actionToConfirm === 'reject') {
await rejectCorrection(currentCorrection.correction_id);
logger.info(`Correction ${currentCorrection.correction_id} rejected.`);
await rejectCorrection(currentCorrection.suggested_correction_id);
logger.info(`Correction ${currentCorrection.suggested_correction_id} rejected.`);
}
onProcessed(initialCorrection.correction_id);
onProcessed(initialCorrection.suggested_correction_id);
} catch (err) {
// This is a type-safe way to handle errors. We check if the caught
// object is an instance of Error before accessing its message property.
const errorMessage = err instanceof Error ? err.message : `An unknown error occurred while trying to ${actionToConfirm} the correction.`;
logger.error(`Failed to ${actionToConfirm} correction ${currentCorrection.correction_id}`, { error: errorMessage });
logger.error(`Failed to ${actionToConfirm} correction ${currentCorrection.suggested_correction_id}`, { error: errorMessage });
setError(errorMessage); // Show error on the row
setIsProcessing(false);
}
@@ -76,12 +77,12 @@ export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction: initia
setIsProcessing(true);
setError(null);
try {
const updatedCorrection = await updateSuggestedCorrection(currentCorrection.correction_id, editableValue);
const updatedCorrection = await updateSuggestedCorrection(currentCorrection.suggested_correction_id, editableValue);
setCurrentCorrection(updatedCorrection); // Update local state with the saved version
setIsEditing(false);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to save changes.';
logger.error(`Failed to update correction ${currentCorrection.correction_id}`, { error: errorMessage });
logger.error(`Failed to update correction ${currentCorrection.suggested_correction_id}`, { error: errorMessage });
setError(errorMessage);
} finally {
setIsProcessing(false);
@@ -107,7 +108,7 @@ export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction: initia
onChange={(e) => setEditableValue(e.target.value)}
className="form-select w-full text-sm dark:bg-gray-700 dark:border-gray-600"
>
{masterItems.map(item => <option key={item.item_id} value={item.item_id}>{item.name}</option>)}
{masterItems.map(item => <option key={item.master_grocery_item_id} value={item.master_grocery_item_id}>{item.name}</option>)}
</select>
);
case 'ITEM_IS_MISCATEGORIZED':
@@ -117,7 +118,7 @@ export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction: initia
onChange={(e) => setEditableValue(e.target.value)}
className="form-select w-full text-sm dark:bg-gray-700 dark:border-gray-600"
>
{categories.map(cat => <option key={cat.id} value={cat.id}>{cat.name}</option>)}
{categories.map(cat => <option key={cat.category_id} value={cat.category_id}>{cat.name}</option>)}
</select>
);
default:

View File

@@ -1,4 +1,4 @@
// src/components/DarkModeToggle.tsx
import React from 'react';
import { SunIcon } from './icons/SunIcon';
import { MoonIcon } from './icons/MoonIcon';

View File

@@ -1,4 +1,4 @@
// src/components/ErrorDisplay.tsx
import React from 'react';
interface ErrorDisplayProps {

View File

@@ -1,3 +1,4 @@
// src/components/ExtractedDataTable.tsx
import React, { useMemo, useState } from 'react';
import type { FlyerItem, MasterGroceryItem, ShoppingList } from '../types';
import { formatUnitPrice } from '../utils/unitConverter';
@@ -20,12 +21,12 @@ interface ExtractedDataTableProps {
export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, totalActiveItems, watchedItems = [], masterItems, unitSystem, user, onAddItem, shoppingLists, activeListId, onAddItemToList }) => {
const [categoryFilter, setCategoryFilter] = useState('all');
const watchedItemIds = useMemo(() => new Set(watchedItems.map(item => item.item_id)), [watchedItems]);
const masterItemsMap = useMemo(() => new Map(masterItems.map(item => [item.item_id, item.name])), [masterItems]);
const watchedItemIds = useMemo(() => new Set(watchedItems.map(item => item.master_grocery_item_id)), [watchedItems]);
const masterItemsMap = useMemo(() => new Map(masterItems.map(item => [item.master_grocery_item_id, item.name])), [masterItems]);
const activeShoppingListItems = useMemo(() => {
if (!activeListId) return new Set();
const activeList = shoppingLists.find(list => list.list_id === activeListId);
const activeList = shoppingLists.find(list => list.shopping_list_id === activeListId);
return new Set(activeList?.items.map(item => item.master_item_id));
}, [shoppingLists, activeListId]);
@@ -115,7 +116,7 @@ export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, t
const formattedUnitPrice = formatUnitPrice(item.unit_price, unitSystem);
return (
<tr key={item.item_id || `${item.item}-${index}`} className="group hover:bg-gray-50 dark:hover:bg-gray-800/50">
<tr key={item.flyer_item_id || `${item.item}-${index}`} className="group hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td className="px-6 py-4 whitespace-normal">
<div className="flex justify-between items-center mb-2">
<div className={itemNameClass}>{item.item}</div>

View File

@@ -1,4 +1,4 @@
// src/components/FlyerDisplay.tsx
import React from 'react';
import type { Store } from '../types';

View File

@@ -1,3 +1,4 @@
// src/components/FlyerList.tsx
import React from 'react';
import type { Flyer } from '../types';
import { DocumentTextIcon } from './icons/DocumentTextIcon';

View File

@@ -1,3 +1,4 @@
// src/components/Header.tsx
import React from 'react';
import { ShoppingCartIcon } from './icons/ShoppingCartIcon';
import { UserIcon } from './icons/UserIcon';

View File

@@ -1,4 +1,4 @@
// src/components/LoadingSpinner.tsx
import React from 'react';
export const LoadingSpinner: React.FC = () => (

View File

@@ -1,3 +1,4 @@
// src/components/PasswordInput.tsx
import React, { useState } from 'react';
import { EyeIcon } from './icons/EyeIcon';
import { EyeSlashIcon } from './icons/EyeSlashIcon';

View File

@@ -1,3 +1,4 @@
// src/components/PasswordStrengthIndicator.tsx
import React from 'react';
import zxcvbn from 'zxcvbn';

View File

@@ -1,3 +1,4 @@
// src/components/PriceChart.tsx
import React from 'react';
import type { DealItem, User } from '../types';
import { TagIcon } from './icons/TagIcon';

View File

@@ -1,3 +1,4 @@
// src/components/PriceHistoryChart.tsx
import React, { useState, useEffect, useMemo } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { fetchHistoricalPriceData } from '../services/apiClient';
@@ -18,7 +19,7 @@ export const PriceHistoryChart: React.FC<PriceHistoryChartProps> = ({ watchedIte
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const watchedItemsMap = useMemo(() => new Map(watchedItems.map(item => [item.item_id, item.name])), [watchedItems]);
const watchedItemsMap = useMemo(() => new Map(watchedItems.map(item => [item.master_grocery_item_id, item.name])), [watchedItems]);
useEffect(() => {
if (watchedItems.length === 0) {
@@ -31,7 +32,7 @@ export const PriceHistoryChart: React.FC<PriceHistoryChartProps> = ({ watchedIte
setIsLoading(true);
setError(null);
try {
const watchedItemIds = watchedItems.map(item => item.item_id);
const watchedItemIds = watchedItems.map(item => item.master_grocery_item_id);
const rawData = await fetchHistoricalPriceData(watchedItemIds);
if (rawData.length === 0) {
setHistoricalData({});

View File

@@ -1,3 +1,4 @@
// src/components/ProcessingStatus.tsx
import React, { useState, useEffect } from 'react';
import { LoadingSpinner } from './LoadingSpinner';
import { CheckCircleIcon } from './icons/CheckCircleIcon';
@@ -77,7 +78,7 @@ export const ProcessingStatus: React.FC<ProcessingStatusProps> = ({ stages, esti
return (
<li key={index}>
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="shrink-0">
<StageIcon status={stage.status} isCritical={isCritical} />
</div>
<span className={`text-sm ${getStatusTextColor(stage.status, isCritical)}`}>
@@ -194,7 +195,7 @@ export const ProcessingStatus: React.FC<ProcessingStatusProps> = ({ stages, esti
return (
<li key={index}>
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="shrink-0">
<StageIcon status={stage.status} isCritical={isCritical} />
</div>
<span className={`text-sm ${getStatusTextColor(stage.status, isCritical)}`}>

View File

@@ -1,3 +1,4 @@
// src/components/ProfileManager.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
@@ -308,9 +309,9 @@ describe('ProfileManager Authentication Flows', () => {
render(
<ProfileManager
{...defaultProps}
user={{ id: '123', email: 'authenticated@example.com' }}
user={{ user_id: '123', email: 'authenticated@example.com' }}
authStatus="AUTHENTICATED"
profile={{ id: '123', full_name: 'Test User', role: 'user' }}
profile={{ user_id: '123', full_name: 'Test User', role: 'user' }}
/>
);

View File

@@ -1,3 +1,4 @@
// src/components/ProfileManager.tsx
import React, { useState, useEffect } from 'react';
import type { Profile } from '../types';
import { updateUserPreferences, updateUserPassword, deleteUserAccount, loginUser, registerUser, requestPasswordReset, updateUserProfile, exportUserData } from '../services/apiClient';
@@ -76,10 +77,12 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
const handleProfileSave = async (e: React.FormEvent) => {
e.preventDefault();
setProfileLoading(true);
if (!user) {
notifyError("Cannot save profile, no user is logged in.");
setProfileLoading(false);
return;
}
try {
if (!user) {
throw new Error("Cannot save profile, no user is logged in.");
}
const updatedProfile = await updateUserProfile({ // Use the new apiClient function
full_name: fullName,
avatar_url: avatarUrl
@@ -119,10 +122,12 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
return;
}
setPasswordLoading(true);
if (!user) {
notifyError("Cannot update password, no user is logged in.");
setPasswordLoading(false);
return;
}
try {
if (!user) {
throw new Error("Cannot update password, no user is logged in.");
}
await updateUserPassword(password); // This now uses the new apiClient function
logger.info('User password updated successfully.', { userId: user.user_id });
notifySuccess("Password updated successfully!");
@@ -139,10 +144,12 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
const handleExportData = async () => {
setExportLoading(true);
if (!user) {
notifyError("Cannot export data, no user is logged in.");
setExportLoading(false);
return;
}
try {
if (!user) {
throw new Error("Cannot export data, no user is logged in.");
}
logger.info('User initiated data export.', { userId: user.user_id });
const userData = await exportUserData(); // Call the new apiClient function
const jsonString = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(userData, null, 2))}`;
@@ -159,8 +166,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
}
};
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
const handleDeleteAccount = async () => {
setIsDeleteModalOpen(false); // Close the confirmation modal
setDeleteLoading(true);
@@ -192,10 +198,11 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
};
const handleToggleDarkMode = async (newMode: boolean) => {
if (!user) {
notifyError("Cannot update preferences, no user is logged in.");
return;
}
try {
if (!user) {
throw new Error("Cannot update preferences, no user is logged in.");
}
// Call the API client function to update preferences
const updatedProfile = await updateUserPreferences({ darkMode: newMode });
// Notify parent component (App.tsx) to update its profile state
@@ -209,10 +216,11 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
};
const handleToggleUnitSystem = async (newSystem: 'metric' | 'imperial') => {
if (!user) {
notifyError("Cannot update preferences, no user is logged in.");
return;
}
try {
if (!user) {
throw new Error("Cannot update preferences, no user is logged in.");
}
// Call the API client function to update preferences
const updatedProfile = await updateUserPreferences({ unitSystem: newSystem });
// Notify parent component (App.tsx) to update its profile state
@@ -289,7 +297,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
<ConfirmationModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={handleDeleteAccount}
onConfirm={handleDeleteAccount} // Pass the handler directly
title="Confirm Account Deletion"
message={
<>

View File

@@ -1,4 +1,4 @@
// src/components/SampleDataButton.tsx
import React from 'react';
interface SampleDataButtonProps {

View File

@@ -1,3 +1,4 @@
// src/components/ShoppingList.tsx
import React, { useState, useMemo, useCallback } from 'react';
import type { ShoppingList, ShoppingListItem, User } from '../types';
import { UserIcon } from './icons/UserIcon';
@@ -27,7 +28,7 @@ export const ShoppingListComponent: React.FC<ShoppingListComponentProps> = ({ us
const [isAddingCustom, setIsAddingCustom] = useState(false);
const [isReadingAloud, setIsReadingAloud] = useState(false);
const activeList = useMemo(() => lists.find(list => list.list_id === activeListId), [lists, activeListId]);
const activeList = useMemo(() => lists.find(list => list.shopping_list_id === activeListId), [lists, activeListId]);
const { neededItems, purchasedItems } = useMemo(() => {
if (!activeList) return { neededItems: [], purchasedItems: [] };
const neededItems: ShoppingListItem[] = [];
@@ -53,7 +54,7 @@ export const ShoppingListComponent: React.FC<ShoppingListComponentProps> = ({ us
const handleDeleteList = async () => {
if (activeList && window.confirm(`Are you sure you want to delete the "${activeList.name}" list? This cannot be undone.`)) {
await onDeleteList(activeList.active_list_id);
await onDeleteList(activeList.shopping_list_id);
}
};
@@ -130,7 +131,7 @@ export const ShoppingListComponent: React.FC<ShoppingListComponentProps> = ({ us
onChange={(e) => onSelectList(Number(e.target.value))}
className="block w-full pl-3 pr-8 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary"
>
{lists.map(list => <option key={list.list_id} value={list.list_id}>{list.name}</option>)}
{lists.map(list => <option key={list.shopping_list_id} value={list.shopping_list_id}>{list.name}</option>)}
</select>
)}
<div className="flex space-x-2">
@@ -161,15 +162,15 @@ export const ShoppingListComponent: React.FC<ShoppingListComponentProps> = ({ us
<div className="space-y-2 max-h-80 overflow-y-auto">
{neededItems.length > 0 ? neededItems.map(item => (
<div key={item.item_id} className="group flex items-center space-x-2 text-sm">
<div key={item.shopping_list_item_id} className="group flex items-center space-x-2 text-sm">
<input
type="checkbox"
checked={item.is_purchased}
onChange={() => onUpdateItem(item.item_id, { is_purchased: !item.is_purchased })}
onChange={() => onUpdateItem(item.shopping_list_item_id, { is_purchased: !item.is_purchased })}
className="h-4 w-4 rounded border-gray-300 text-brand-primary focus:ring-brand-secondary"
/>
<span className="grow text-gray-800 dark:text-gray-200">{item.custom_item_name || item.master_item?.name}</span>
<button onClick={() => onRemoveItem(item.item_id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 p-1">
<button onClick={() => onRemoveItem(item.shopping_list_item_id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 p-1">
<TrashIcon className="w-4 h-4"/>
</button>
</div>
@@ -181,15 +182,15 @@ export const ShoppingListComponent: React.FC<ShoppingListComponentProps> = ({ us
<div className="pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Purchased</h4>
{purchasedItems.map(item => (
<div key={item.item_id} className="group flex items-center space-x-2 text-sm">
<div key={item.shopping_list_item_id} className="group flex items-center space-x-2 text-sm">
<input
type="checkbox"
checked={item.is_purchased}
onChange={() => onUpdateItem(item.item_id, { is_purchased: !item.is_purchased })}
onChange={() => onUpdateItem(item.shopping_list_item_id, { is_purchased: !item.is_purchased })}
className="h-4 w-4 rounded border-gray-300 text-brand-primary focus:ring-brand-secondary"
/>
<span className="grow text-gray-500 dark:text-gray-400 line-through">{item.custom_item_name || item.master_item?.name}</span>
<button onClick={() => onRemoveItem(item.item_id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 p-1">
<button onClick={() => onRemoveItem(item.shopping_list_item_id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 p-1">
<TrashIcon className="w-4 h-4"/>
</button>
</div>

View File

@@ -1,3 +1,4 @@
// src/components/SystemCheck.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { loginUser, pingBackend, checkDbSchema, checkStorage, checkDbPoolHealth, checkPm2Status } from '../services/apiClient';
import { ShieldCheckIcon } from './icons/ShieldCheckIcon';

View File

@@ -1,3 +1,4 @@
// src/components/TopDeals.tsx
import React, { useMemo } from 'react';
import type { FlyerItem } from '../types';
import { TrophyIcon } from './icons/TrophyIcon';

View File

@@ -1,3 +1,4 @@
// src/components/UnitSystemToggle.tsx
import React from 'react';
interface UnitSystemToggleProps {
@@ -21,7 +22,7 @@ export const UnitSystemToggle: React.FC<UnitSystemToggleProps> = ({ currentSyste
checked={isImperial}
onChange={onToggle}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-brand-primary/50 dark:peer-focus:ring-brand-secondary rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-primary"></div>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-brand-primary/50 dark:peer-focus:ring-brand-secondary rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5] after:left-0.52px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-primary"></div>
</label>
<span className={`text-sm font-medium ${isImperial ? 'text-gray-700 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500'}`}>
Imperial

View File

@@ -1,3 +1,4 @@
// src/components/VoiceAssistant.tsx
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { startVoiceSession } from '../services/aiApiClient';
import { MicrophoneIcon } from './icons/MicrophoneIcon';
@@ -14,15 +15,20 @@ interface VoiceAssistantProps {
type VoiceStatus = 'idle' | 'connecting' | 'listening' | 'speaking' | 'error';
// Define a local interface for the session object to provide type safety.
interface LiveSession {
close: () => void;
sendRealtimeInput: (input: { media: Blob }) => void;
}
export const VoiceAssistant: React.FC<VoiceAssistantProps> = ({ isOpen, onClose }) => {
const [status, setStatus] = useState<VoiceStatus>('idle');
const [userTranscript, setUserTranscript] = useState('');
const [modelTranscript, setModelTranscript] = useState('');
const [history, setHistory] = useState<{speaker: 'user' | 'model', text: string}[]>([]);
// FIX: Infer the session promise type from the return type of `startVoiceSession`
// to avoid needing to import the `LiveSession` type directly.
const sessionPromiseRef = useRef<ReturnType<typeof startVoiceSession> | null>(null); // This correctly infers the Promise<LiveSession> type
// Use the local LiveSession interface for the ref type.
const sessionPromiseRef = useRef<Promise<LiveSession> | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const scriptProcessorRef = useRef<ScriptProcessorNode | null>(null);
@@ -44,7 +50,7 @@ export const VoiceAssistant: React.FC<VoiceAssistantProps> = ({ isOpen, onClose
const handleClose = useCallback(() => {
if (sessionPromiseRef.current) {
sessionPromiseRef.current.then(session => session.close());
sessionPromiseRef.current.then((session: LiveSession) => session.close());
sessionPromiseRef.current = null;
}
stopRecording();
@@ -84,7 +90,7 @@ export const VoiceAssistant: React.FC<VoiceAssistantProps> = ({ isOpen, onClose
data: encode(new Uint8Array(new Int16Array(inputData.map(x => x * 32768)).buffer)),
mimeType: 'audio/pcm;rate=16000',
};
sessionPromiseRef.current?.then((session) => {
sessionPromiseRef.current?.then((session: LiveSession) => {
session.sendRealtimeInput({ media: pcmBlob });
});
};
@@ -94,14 +100,19 @@ export const VoiceAssistant: React.FC<VoiceAssistantProps> = ({ isOpen, onClose
onmessage: (message: LiveServerMessage) => {
// NOTE: This stub doesn't play audio, just displays transcripts.
// A full implementation would use the audioUtils to decode and play audio.
if (message.serverContent?.inputTranscription) {
setUserTranscript(prev => prev + message.serverContent.inputTranscription.text);
const serverContent = message.serverContent;
if (!serverContent) {
return; // Exit if there's no content to process
}
if (message.serverContent?.outputTranscription) {
setModelTranscript(prev => prev + message.serverContent.outputTranscription.text);
// Safely access nested properties with optional chaining.
if (serverContent.inputTranscription?.text) {
setUserTranscript(prev => prev + serverContent.inputTranscription!.text);
}
if (message.serverContent?.turnComplete) {
if (serverContent.outputTranscription?.text) {
setModelTranscript(prev => prev + serverContent.outputTranscription!.text);
}
if (serverContent.turnComplete) {
setHistory(prev => [...prev,
{ speaker: 'user', text: userTranscript },
{ speaker: 'model', text: modelTranscript }

View File

@@ -1,3 +1,4 @@
// src/components/WatchedItemsList.tsx
import React, { useState, useMemo } from 'react';
import type { MasterGroceryItem, User } from '../types';
import { EyeIcon } from './icons/EyeIcon';
@@ -36,7 +37,7 @@ export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({ items, onAdd
setNewCategory('');
} catch (error) {
// Error is handled in the parent component
logger.error(error);
logger.error('Failed to add watched item from WatchedItemsList', { error });
} finally {
setIsAdding(false);
}
@@ -147,14 +148,14 @@ export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({ items, onAdd
{sortedAndFilteredItems.length > 0 ? (
<ul className="space-y-2 max-h-60 overflow-y-auto">
{sortedAndFilteredItems.map(item => (
<li key={item.item_id} className="group text-sm bg-gray-50 dark:bg-gray-800 p-2 rounded text-gray-700 dark:text-gray-300 flex justify-between items-center">
<li key={item.master_grocery_item_id} className="group text-sm bg-gray-50 dark:bg-gray-800 p-2 rounded text-gray-700 dark:text-gray-300 flex justify-between items-center">
<div className="grow">
<span>{item.name}</span>
<span className="text-xs text-gray-500 dark:text-gray-400 italic ml-2">{item.category_name}</span>
</div>
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => onAddItemToList(item.item_id)}
onClick={() => onAddItemToList(item.master_grocery_item_id)}
disabled={!activeListId}
className="p-1 text-gray-400 hover:text-brand-primary disabled:text-gray-300 disabled:cursor-not-allowed"
title={activeListId ? `Add ${item.name} to list` : 'Select a shopping list first'}
@@ -162,7 +163,7 @@ export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({ items, onAdd
<PlusCircleIcon className="w-4 h-4" />
</button>
<button
onClick={() => onRemoveItem(item.item_id)}
onClick={() => onRemoveItem(item.master_grocery_item_id)}
className="text-red-500 hover:text-red-700 dark:hover:text-red-400 p-1"
aria-label={`Remove ${item.name}`}
title={`Remove ${item.name}`}

View File

@@ -1,3 +1,4 @@
// src/components/WhatsNewModal.tsx
import React from 'react';
import { XCircleIcon } from './icons/XCircleIcon';
import { GiftIcon } from './icons/GiftIcon';

View File

@@ -1,3 +1,4 @@
// src/db/backup_user.ts
/*
Implement this at some point

View File

@@ -1,3 +1,4 @@
// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

View File

@@ -0,0 +1,60 @@
// src/pages/AdminPage.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { AdminPage } from './AdminPage';
// Mock the child SystemCheck component to isolate the test
vi.mock('../components/SystemCheck', () => ({
SystemCheck: () => <div data-testid="system-check-mock">System Health Checks</div>,
}));
// Mock the logger to prevent console output during tests
vi.mock('../services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Helper function to render the component within a router context
const renderWithRouter = () => {
return render(
<MemoryRouter>
<AdminPage />
</MemoryRouter>
);
};
describe('AdminPage', () => {
it('should render the main heading and description', () => {
renderWithRouter();
expect(screen.getByRole('heading', { name: /admin dashboard/i })).toBeInTheDocument();
expect(screen.getByText('Tools and system health checks.')).toBeInTheDocument();
});
it('should render navigation links to other admin sections', () => {
renderWithRouter();
const correctionsLink = screen.getByRole('link', { name: /review corrections/i });
expect(correctionsLink).toBeInTheDocument();
expect(correctionsLink).toHaveAttribute('href', '/admin/corrections');
const statsLink = screen.getByRole('link', { name: /view statistics/i });
expect(statsLink).toBeInTheDocument();
expect(statsLink).toHaveAttribute('href', '/admin/stats');
const backLink = screen.getByRole('link', { name: /back to main app/i });
expect(backLink).toBeInTheDocument();
expect(backLink).toHaveAttribute('href', '/');
});
it('should render the SystemCheck component', () => {
renderWithRouter();
expect(screen.getByTestId('system-check-mock')).toBeInTheDocument();
expect(screen.getByText('System Health Checks')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,93 @@
// src/pages/AdminStatsPage.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { AdminStatsPage } from './AdminStatsPage';
import * as apiClient from '../services/apiClient';
import type { AppStats } from '../services/apiClient';
// Mock the apiClient module to control the getApplicationStats function
vi.mock('../services/apiClient', async (importOriginal) => {
const actual = await importOriginal<typeof apiClient>();
return {
...actual,
getApplicationStats: vi.fn(),
};
});
// Mock the logger to prevent console output during tests
vi.mock('../services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Helper function to render the component within a router context, as it contains a <Link>
const renderWithRouter = () => {
return render(
<MemoryRouter>
<AdminStatsPage />
</MemoryRouter>
);
};
describe('AdminStatsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render a loading spinner while fetching stats', () => {
// Mock a promise that never resolves to keep the component in a loading state
(apiClient.getApplicationStats as Mock).mockReturnValue(new Promise(() => {}));
renderWithRouter();
// The LoadingSpinner component is expected to be present
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /application statistics/i })).toBeInTheDocument();
});
it('should display stats cards when data is fetched successfully', async () => {
const mockStats: AppStats = {
userCount: 123,
flyerCount: 456,
flyerItemCount: 7890,
storeCount: 42,
pendingCorrectionCount: 5,
};
(apiClient.getApplicationStats as Mock).mockResolvedValue(mockStats);
renderWithRouter();
// Wait for the stats to be displayed
await waitFor(() => {
expect(screen.getByText('Total Users')).toBeInTheDocument();
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('Flyers Processed')).toBeInTheDocument();
expect(screen.getByText('456')).toBeInTheDocument();
expect(screen.getByText('Total Flyer Items')).toBeInTheDocument();
expect(screen.getByText('7,890')).toBeInTheDocument(); // Note: toLocaleString() adds a comma
expect(screen.getByText('Stores Tracked')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
expect(screen.getByText('Pending Corrections')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
});
it('should display an error message if fetching stats fails', async () => {
const errorMessage = 'Failed to connect to the database.';
(apiClient.getApplicationStats as Mock).mockRejectedValue(new Error(errorMessage));
renderWithRouter();
// Wait for the error message to appear
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
});

View File

@@ -1,3 +1,4 @@
// src/pages/AdminStatsPage.tsx
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { getApplicationStats, AppStats } from '../services/apiClient';

View File

@@ -0,0 +1,122 @@
// src/pages/CorrectionsPage.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { CorrectionsPage } from './CorrectionsPage';
import * as apiClient from '../services/apiClient';
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../types';
// Mock the apiClient module
vi.mock('../services/apiClient', async (importOriginal) => {
const actual = await importOriginal<typeof apiClient>();
return {
...actual,
getSuggestedCorrections: vi.fn(),
fetchMasterItems: vi.fn(),
fetchCategories: vi.fn(),
};
});
// Mock the logger
vi.mock('../services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock the child CorrectionRow component to isolate the test to the page itself
vi.mock('../components/CorrectionRow', () => ({
CorrectionRow: (props: { correction: SuggestedCorrection & { flyer_item: { item: string } } }) => (
<tr data-testid={`correction-row-${props.correction.suggested_correction_id}`}>
<td>{props.correction.flyer_item_name}</td>
</tr>
),
}));
// Helper to render the component within a router context
const renderWithRouter = () => {
return render(
<MemoryRouter>
<CorrectionsPage />
</MemoryRouter>
);
};
describe('CorrectionsPage', () => {
const mockCorrections: SuggestedCorrection[] = [
{ suggested_correction_id: 1, flyer_item_id: 101, user_id: 'user-1', correction_type: 'item_name', suggested_value: 'Organic Bananas', status: 'pending', created_at: new Date().toISOString(), flyer_item_name: 'Bananas', user_email: 'test@example.com' },
{ suggested_correction_id: 2, flyer_item_id: 102, user_id: 'user-2', correction_type: 'price_in_cents', suggested_value: '199', status: 'pending', created_at: new Date().toISOString(), flyer_item_name: 'Apples', user_email: 'test2@example.com' },
];
const mockMasterItems: MasterGroceryItem[] = [{ master_grocery_item_id: 1, name: 'Organic Bananas', category_id: 1, category_name: 'Produce', created_at: new Date().toISOString() }];
const mockCategories: Category[] = [{ category_id: 1, name: 'Produce' }];
beforeEach(() => {
vi.clearAllMocks();
});
it('should render a loading spinner while fetching data', () => {
// Mock a promise that never resolves to keep the component in a loading state
(apiClient.getSuggestedCorrections as Mock).mockReturnValue(new Promise(() => {}));
(apiClient.fetchMasterItems as Mock).mockReturnValue(new Promise(() => {}));
(apiClient.fetchCategories as Mock).mockReturnValue(new Promise(() => {}));
renderWithRouter();
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /user-submitted corrections/i })).toBeInTheDocument();
});
it('should display corrections when data is fetched successfully', async () => {
(apiClient.getSuggestedCorrections as Mock).mockResolvedValue(mockCorrections);
(apiClient.fetchMasterItems as Mock).mockResolvedValue(mockMasterItems);
(apiClient.fetchCategories as Mock).mockResolvedValue(mockCategories);
renderWithRouter();
await waitFor(() => {
// Check for the mocked CorrectionRow components
expect(screen.getByTestId('correction-row-1')).toBeInTheDocument(); // This will now use suggested_correction_id
expect(screen.getByTestId('correction-row-2')).toBeInTheDocument(); // This will now use suggested_correction_id
// Check for the text content within the mocked rows
expect(screen.getByText('Bananas')).toBeInTheDocument();
expect(screen.getByText('Apples')).toBeInTheDocument();
});
});
it('should display a message when there are no pending corrections', async () => {
(apiClient.getSuggestedCorrections as Mock).mockResolvedValue([]);
(apiClient.fetchMasterItems as Mock).mockResolvedValue(mockMasterItems);
(apiClient.fetchCategories as Mock).mockResolvedValue(mockCategories);
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(/no pending corrections. great job!/i)).toBeInTheDocument();
});
});
it('should display an error message if fetching corrections fails', async () => {
const errorMessage = 'Network Error: Failed to fetch';
(apiClient.getSuggestedCorrections as Mock).mockRejectedValue(new Error(errorMessage));
(apiClient.fetchMasterItems as Mock).mockResolvedValue(mockMasterItems);
(apiClient.fetchCategories as Mock).mockResolvedValue(mockCategories);
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
it('should display an error message if fetching master items fails', async () => {
const errorMessage = 'Could not retrieve master items list.';
(apiClient.getSuggestedCorrections as Mock).mockResolvedValue(mockCorrections);
(apiClient.fetchMasterItems as Mock).mockRejectedValue(new Error(errorMessage));
(apiClient.fetchCategories as Mock).mockResolvedValue(mockCategories);
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
});

View File

@@ -1,3 +1,4 @@
// src/routes/admin.ts
import { Router, Request, Response, NextFunction } from 'express';
import passport from './passport';
import { isAdmin } from './passport';

View File

@@ -110,13 +110,15 @@ export const extractCoreDataFromFlyerImage = async (
Second, extract each individual sale item. For each item, provide:
- "item": The name of the product (e.g., "Coca-Cola Classic").
- "price_display": The sale price as a string (e.g., "$2.99", "2 for $5.00").
- "price_display": The sale price as a string (e.g., "$2.99", "2 for $5.00"). If no price is explicitly displayed, use an empty string "".
- "price_in_cents": The primary numeric price converted to cents (e.g., for "$2.99", use 299). If a price is "2 for $5.00", use 500. If no price, use null.
- "quantity": A string describing the quantity or weight for the price (e.g., "12x355mL", "500g", "each").
- "quantity": A string describing the quantity or weight for the price (e.g., "12x355mL", "500g", "each"). If no quantity is explicitly displayed, use an empty string "".
- "master_item_id": From the provided master list, find the best matching item and return its ID. If no good match is found, use null.
- "category_name": The most appropriate category for the item (e.g., "Beverages", "Meat & Seafood").
- "category_name": The most appropriate category for the item (e.g., "Beverages", "Meat & Seafood"). If no clear category can be determined, use "Other/Miscellaneous".
Here is the master list of grocery items to help with matching:
// The AI uses this list to match extracted items to known master items.
// This helps standardize item names and categories.
${JSON.stringify(masterItems)}
Return a single, valid JSON object with the keys "store_name", "valid_from", "valid_to", and "items". The "items" key should contain an array of the extracted item objects.
@@ -151,9 +153,26 @@ export const extractCoreDataFromFlyerImage = async (
// 4. Clean and parse the AI's response.
const jsonMatch = text?.match(/\{[\s\S]*\}/);
logger.debug(`[aiService.server] Raw Gemini response text (first 500 chars): ${text?.substring(0, 500)}`);
if (!jsonMatch) {
logger.error("AI response for flyer processing did not contain a valid JSON object.", { responseText: text });
throw new Error('AI response did not contain a valid JSON object.');
}
try {
return JSON.parse(jsonMatch[0]);
const extractedData = JSON.parse(jsonMatch[0]);
// Post-process items to ensure 'price_display', 'quantity', and 'category_name' are always strings
// This prevents database 'not-null' constraint violations if the AI returns null for these fields.
if (extractedData && Array.isArray(extractedData.items)) {
extractedData.items = extractedData.items.map((item: any) => ({
...item,
price_display: item.price_display === null || item.price_display === undefined ? "" : String(item.price_display),
quantity: item.quantity === null || item.quantity === undefined ? "" : String(item.quantity),
category_name: item.category_name === null || item.category_name === undefined ? "Other/Miscellaneous" : String(item.category_name),
}));
}
return extractedData;
} catch (e) {
logger.error("Failed to parse JSON from AI response in extractCoreDataFromFlyerImage", { responseText: text, error: e });
throw new Error('Failed to parse structured data from the AI response.');

View File

@@ -1,3 +1,4 @@
// src/services/db.integration.test.ts
import { describe, it, expect } from 'vitest';
import * as db from './db';
import * as bcrypt from 'bcrypt';

View File

@@ -1,6 +1,7 @@
// src/services/db/admin.ts
import { getPool } from './connection';
import { logger } from '../logger';
import { SuggestedCorrection, MostFrequentSaleItem, Recipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, ActivityLogDetails, Receipt } from '../../types';
import { SuggestedCorrection, MostFrequentSaleItem, Recipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, Receipt } from '../../types';
/**
* Retrieves all pending suggested corrections from the database.
@@ -307,7 +308,7 @@ export async function logActivity(logData: {
action: string;
displayText: string;
icon?: string | null;
details?: ActivityLogDetails | null;
details?: Record<string, any> | null;
}): Promise<void> {
const { userId, action, displayText, icon, details } = logData;
try {

View File

@@ -1,3 +1,4 @@
// src/services/db/connection.ts
import { Pool, PoolConfig } from 'pg';
import { logger } from '../logger';

View File

@@ -1,3 +1,4 @@
// src/services/db/flyer.ts
import { getPool } from './connection';
import { logger } from '../logger';
import { Flyer, Brand, MasterGroceryItem, FlyerItem } from '../../types';

View File

@@ -1,3 +1,4 @@
// src/services/db/personalization.ts
import { getPool } from './connection';
import { logger } from '../logger';
import {

View File

@@ -1,3 +1,4 @@
// src/services/db/recipe.ts
import { getPool } from './connection';
import { logger } from '../logger';
import { Recipe, FavoriteRecipe, RecipeComment } from '../../types';

View File

@@ -1,3 +1,4 @@
// src/services/db/shopping.ts
import { getPool } from './connection';
import { logger } from '../logger';
import {

View File

@@ -1,3 +1,4 @@
// src/services/emailService.server.ts
import nodemailer from 'nodemailer';
import { logger } from './logger.server';

View File

@@ -1,3 +1,4 @@
// src/services/geminiService.ts
// This file is intended for client-side Gemini helper functions.
// Currently, most AI logic is handled on the server via `aiService.server.ts`

View File

@@ -1,3 +1,4 @@
// src/services/logger.client.ts
/**
* A simple, client-side logger service that wraps the console.
* This version is guaranteed to be safe for browser environments as it

View File

@@ -1,3 +1,4 @@
// src/services/logger.server.ts
/**
* SERVER-SIDE LOGGER
* A logger service that includes server-specific details like process ID.

View File

@@ -1,3 +1,4 @@
// src/services/logger.ts
/**
* A simple logger service that wraps the console.
* This provides a centralized place to manage logging behavior,

View File

@@ -1,3 +1,4 @@
// src/services/notificationService.ts
import toast, { ToastOptions } from 'react-hot-toast';
/**

View File

@@ -1,3 +1,4 @@
// src/services/shopping-list.integration.test.ts
import { describe, it, expect } from 'vitest';
import * as db from './db'; // This was missing
import * as bcrypt from 'bcrypt';

View File

@@ -1,3 +1,4 @@
// src/tests/setup/global-setup.ts
import { Pool } from 'pg';
import fs from 'fs/promises';
import path from 'path';

View File

@@ -1,3 +1,4 @@
// src/tests/setup/integration-global-setup.ts
import { exec, ChildProcess } from 'child_process';
import { logger } from '../../services/logger';
import { getPool } from '../../services/db/connection'; // Import getPool

View File

@@ -1,3 +1,4 @@
// src/tests/setup/mock-db.ts
import { vi } from 'vitest';
import dotenv from 'dotenv';

View File

@@ -1,3 +1,4 @@
// src/tests/setup/test-db.ts
import { Pool } from 'pg';
// This pool will automatically use the environment variables

View File

@@ -1,3 +1,4 @@
// src/types.ts
export interface Store {
store_id: number;
created_at: string;
@@ -387,28 +388,92 @@ export interface UserFollow {
following_id: string; // UUID
created_at: string;
}
/**
* The list of possible actions for an activity log.
* Using a specific type union instead of a generic 'string' allows for better type checking.
*/
export type ActivityLogAction =
| 'flyer_processed'
| 'recipe_created'
| 'user_registered'
| 'recipe_favorited'
| 'list_shared'
| 'login_failed_password'
| 'password_reset';
/**
* Defines a flexible but type-safe structure for the `details` object in an activity log.
* It allows for arbitrary string keys but restricts values to primitives,
* preventing the use of `any` while remaining easy to use.
* Base interface for all log items, containing common properties.
*/
export type ActivityLogDetails = Record<string, string | number | boolean | null>;
/**
* Represents a single entry in the application's activity log.
* This structure is designed to be both maintainable and type-safe.
*/
export interface ActivityLogItem {
interface ActivityLogItemBase {
activity_log_id: number;
user_id?: string | null; // UUID
action: string; // A key for the event type, e.g., 'user_registered'
display_text: string; // A pre-formatted, user-facing message for direct display in the UI
icon?: string | null; // An optional icon name for the UI
details?: ActivityLogDetails | null; // Structured data for analytics, i18n, etc.
user_id: string | null;
action: ActivityLogAction;
display_text: string;
created_at: string;
icon?: string | null;
}
// --- Discriminated Union for Activity Log Details ---
interface FlyerProcessedLog extends ActivityLogItemBase {
action: 'flyer_processed';
details: {
flyerId: number;
store_name: string;
user_avatar_url?: string;
user_full_name?: string;
};
}
interface RecipeCreatedLog extends ActivityLogItemBase {
action: 'recipe_created';
details: {
recipe_name: string;
user_avatar_url?: string;
user_full_name?: string;
};
}
interface UserRegisteredLog extends ActivityLogItemBase {
action: 'user_registered';
details: {
full_name: string;
user_avatar_url?: string;
user_full_name?: string;
};
}
interface RecipeFavoritedLog extends ActivityLogItemBase {
action: 'recipe_favorited';
details: {
recipe_name: string;
user_avatar_url?: string;
user_full_name?: string;
};
}
interface ListSharedLog extends ActivityLogItemBase {
action: 'list_shared';
details: {
list_name: string;
shopping_list_id: number;
shared_with_name: string;
user_avatar_url?: string;
user_full_name?: string;
};
}
/**
* The final ActivityLogItem type is a union of all specific log types.
* TypeScript will now correctly infer the shape of 'details' based on the 'action' property.
*/
export type ActivityLogItem =
| FlyerProcessedLog
| RecipeCreatedLog
| UserRegisteredLog
| RecipeFavoritedLog
| ListSharedLog;
export interface PantryLocation {
pantry_location_id: number;
user_id: string; // UUID

View File

@@ -1,3 +1,4 @@
// src/utils/audioUtils.ts
/**
* Encodes a Uint8Array into a base64 string.
* This is a required utility for handling audio data for the Gemini API.

View File

@@ -1,3 +1,4 @@
// src/utils/checksum.ts
/**
* Generates a SHA-256 checksum for a file.
* @param file The file to hash.

View File

@@ -1,3 +1,4 @@
// src/utils/pdfConverter.ts
import * as pdfjsLib from 'pdfjs-dist';
import type { PDFDocumentProxy, PDFPageProxy, PageViewport } from 'pdfjs-dist';

View File

@@ -1,3 +1,4 @@
// src/utils/priceParser.ts
/**
* Parses a price string into an integer number of cents.
* Handles formats like "$10.99", "99¢", "250".

View File

@@ -1,3 +1,4 @@
// src/utils/processingTimer.ts
import { logger } from '../services/logger';
const PROCESSING_TIMES_KEY = 'flyerProcessingTimes';

View File

@@ -1,3 +1,4 @@
// src/utils/timeout.ts
/**
* Wraps a promise with a timeout.
* @param promise The promise to wrap.

View File

@@ -1,3 +1,4 @@
// src/utils/unitConverter.ts
import type { UnitPrice } from '../types';
const METRIC_UNITS = ['g', 'kg', 'ml', 'l'];

View File

@@ -1,4 +1,5 @@
// vitest.setup.ts
// src/vitest.setup.ts
// This file can be used for global setup logic that applies to ALL test projects
// defined in the workspace. Since our unit and integration tests have distinct
// setup requirements, this file is currently empty.