test, more id fixes, and naming all files
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 48s
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 48s
This commit is contained in:
@@ -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
|
||||
#
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# sql/helper_scripts/generate_rollup.sh
|
||||
# ============================================================================
|
||||
# SQL ROLLUP GENERATION SCRIPT (BASH)
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# sql/helper_scripts/verify_rollup.sh
|
||||
# ============================================================================
|
||||
# SQL ROLLUP VERIFICATION SCRIPT
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
-- sql/initial_data.sql
|
||||
-- ============================================================================
|
||||
-- INITIAL DATA SEEDING SCRIPT
|
||||
-- ============================================================================
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
-- sql/initial_schema.sql
|
||||
-- ============================================================================
|
||||
-- ============================================================================
|
||||
-- PART 2: TABLES
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
-- sql/master_schema_rollup.sql
|
||||
-- ============================================================================
|
||||
-- MASTER SCHEMA SCRIPT
|
||||
-- ============================================================================
|
||||
|
||||
11
src/App.tsx
11
src/App.tsx
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
140
src/components/ActivityLog.test.tsx
Normal file
140
src/components/ActivityLog.test.tsx
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/components/AdminPage.tsx
|
||||
import React from 'react';
|
||||
import { SystemCheck } from './SystemCheck';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/components/AdminRoute.tsx
|
||||
import React from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import type { Profile } from '../types';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/components/AnonymousUserBanner.tsx
|
||||
import React from 'react';
|
||||
import { InformationCircleIcon } from './icons/InformationCircleIcon';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/components/BulkImporter.tsx
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { UploadIcon } from './icons/UploadIcon';
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
// src/components/DarkModeToggle.tsx
|
||||
import React from 'react';
|
||||
import { SunIcon } from './icons/SunIcon';
|
||||
import { MoonIcon } from './icons/MoonIcon';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
// src/components/ErrorDisplay.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
// src/components/FlyerDisplay.tsx
|
||||
import React from 'react';
|
||||
import type { Store } from '../types';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/components/FlyerList.tsx
|
||||
import React from 'react';
|
||||
import type { Flyer } from '../types';
|
||||
import { DocumentTextIcon } from './icons/DocumentTextIcon';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/components/Header.tsx
|
||||
import React from 'react';
|
||||
import { ShoppingCartIcon } from './icons/ShoppingCartIcon';
|
||||
import { UserIcon } from './icons/UserIcon';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
// src/components/LoadingSpinner.tsx
|
||||
import React from 'react';
|
||||
|
||||
export const LoadingSpinner: React.FC = () => (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/components/PasswordInput.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { EyeIcon } from './icons/EyeIcon';
|
||||
import { EyeSlashIcon } from './icons/EyeSlashIcon';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/components/PasswordStrengthIndicator.tsx
|
||||
import React from 'react';
|
||||
import zxcvbn from 'zxcvbn';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/components/PriceChart.tsx
|
||||
import React from 'react';
|
||||
import type { DealItem, User } from '../types';
|
||||
import { TagIcon } from './icons/TagIcon';
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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)}`}>
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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={
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
// src/components/SampleDataButton.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface SampleDataButtonProps {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/components/TopDeals.tsx
|
||||
import React, { useMemo } from 'react';
|
||||
import type { FlyerItem } from '../types';
|
||||
import { TrophyIcon } from './icons/TrophyIcon';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/components/WhatsNewModal.tsx
|
||||
import React from 'react';
|
||||
import { XCircleIcon } from './icons/XCircleIcon';
|
||||
import { GiftIcon } from './icons/GiftIcon';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/db/backup_user.ts
|
||||
/*
|
||||
|
||||
Implement this at some point
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/index.tsx
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
60
src/pages/AdminPage.test.tsx
Normal file
60
src/pages/AdminPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
93
src/pages/AdminStatsPage.test.tsx
Normal file
93
src/pages/AdminStatsPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
122
src/pages/CorrectionsPage.test.tsx
Normal file
122
src/pages/CorrectionsPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/routes/admin.ts
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import passport from './passport';
|
||||
import { isAdmin } from './passport';
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/services/db/connection.ts
|
||||
import { Pool, PoolConfig } from 'pg';
|
||||
import { logger } from '../logger';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/services/db/flyer.ts
|
||||
import { getPool } from './connection';
|
||||
import { logger } from '../logger';
|
||||
import { Flyer, Brand, MasterGroceryItem, FlyerItem } from '../../types';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/services/db/personalization.ts
|
||||
import { getPool } from './connection';
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/services/db/recipe.ts
|
||||
import { getPool } from './connection';
|
||||
import { logger } from '../logger';
|
||||
import { Recipe, FavoriteRecipe, RecipeComment } from '../../types';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/services/db/shopping.ts
|
||||
import { getPool } from './connection';
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/services/emailService.server.ts
|
||||
import nodemailer from 'nodemailer';
|
||||
import { logger } from './logger.server';
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/services/logger.server.ts
|
||||
/**
|
||||
* SERVER-SIDE LOGGER
|
||||
* A logger service that includes server-specific details like process ID.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/services/notificationService.ts
|
||||
import toast, { ToastOptions } from 'react-hot-toast';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/tests/setup/global-setup.ts
|
||||
import { Pool } from 'pg';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/tests/setup/mock-db.ts
|
||||
import { vi } from 'vitest';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/tests/setup/test-db.ts
|
||||
import { Pool } from 'pg';
|
||||
|
||||
// This pool will automatically use the environment variables
|
||||
|
||||
95
src/types.ts
95
src/types.ts
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/utils/checksum.ts
|
||||
/**
|
||||
* Generates a SHA-256 checksum for a file.
|
||||
* @param file The file to hash.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/utils/pdfConverter.ts
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, PageViewport } from 'pdfjs-dist';
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/utils/processingTimer.ts
|
||||
import { logger } from '../services/logger';
|
||||
|
||||
const PROCESSING_TIMES_KEY = 'flyerProcessingTimes';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/utils/timeout.ts
|
||||
/**
|
||||
* Wraps a promise with a timeout.
|
||||
* @param promise The promise to wrap.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/utils/unitConverter.ts
|
||||
import type { UnitPrice } from '../types';
|
||||
|
||||
const METRIC_UNITS = ['g', 'kg', 'ml', 'l'];
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user