large mock refector hopefully done + no errors?
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 1h17m3s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 1h17m3s
This commit is contained in:
30
src/App.tsx
30
src/App.tsx
@@ -1,6 +1,6 @@
|
||||
// src/App.tsx
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { Routes, Route, useParams, useLocation } from 'react-router-dom';
|
||||
import { Routes, Route, useParams, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { Footer } from './components/Footer'; // Assuming this is where your Footer component will live
|
||||
@@ -38,6 +38,7 @@ function App() {
|
||||
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
|
||||
const { openModal, closeModal, isModalOpen } = useModal();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const params = useParams<{ flyerId?: string }>();
|
||||
|
||||
// Debugging: Log renders to identify infinite loops
|
||||
@@ -71,7 +72,7 @@ function App() {
|
||||
const handleOpenCorrectionTool = useCallback(() => openModal('correctionTool'), [openModal]);
|
||||
const handleCloseCorrectionTool = useCallback(() => closeModal('correctionTool'), [closeModal]);
|
||||
|
||||
const handleDataExtractedFromCorrection = (type: 'store_name' | 'dates', value: string) => {
|
||||
const handleDataExtractedFromCorrection = useCallback((type: 'store_name' | 'dates', value: string) => {
|
||||
if (!selectedFlyer) return;
|
||||
|
||||
// This is a simplified update. A real implementation would involve
|
||||
@@ -84,14 +85,14 @@ function App() {
|
||||
// A more robust solution would parse the date string properly.
|
||||
}
|
||||
setSelectedFlyer(updatedFlyer);
|
||||
};
|
||||
}, [selectedFlyer]);
|
||||
|
||||
const handleProfileUpdate = (updatedProfileData: Profile) => {
|
||||
const handleProfileUpdate = useCallback((updatedProfileData: Profile) => {
|
||||
// When the profile is updated, the API returns a `Profile` object.
|
||||
// We need to merge it with the existing `user` object to maintain
|
||||
// the `UserProfile` type in our state.
|
||||
updateProfile(updatedProfileData);
|
||||
};
|
||||
}, [updateProfile]);
|
||||
|
||||
// --- State Synchronization and Error Handling ---
|
||||
|
||||
@@ -111,7 +112,7 @@ function App() {
|
||||
setIsDarkMode(initialDarkMode);
|
||||
document.documentElement.classList.toggle('dark', initialDarkMode);
|
||||
}
|
||||
}, [userProfile]);
|
||||
}, [userProfile?.preferences?.darkMode, userProfile?.user_id]);
|
||||
|
||||
// Effect to set initial unit system based on user profile or local storage
|
||||
useEffect(() => {
|
||||
@@ -123,10 +124,10 @@ function App() {
|
||||
setUnitSystem(savedSystem);
|
||||
}
|
||||
}
|
||||
}, [userProfile]);
|
||||
}, [userProfile?.preferences?.unitSystem, userProfile?.user_id]);
|
||||
|
||||
// This is the login handler that will be passed to the ProfileManager component.
|
||||
const handleLoginSuccess = async (userProfile: UserProfile, token: string, _rememberMe: boolean) => {
|
||||
const handleLoginSuccess = useCallback(async (userProfile: UserProfile, token: string, _rememberMe: boolean) => {
|
||||
try {
|
||||
await login(token, userProfile);
|
||||
// After successful login, fetch user-specific data
|
||||
@@ -137,7 +138,7 @@ function App() {
|
||||
// and notifications, so we just need to log any unexpected failures here.
|
||||
logger.error({ err: e }, 'An error occurred during the login success handling.');
|
||||
}
|
||||
};
|
||||
}, [login]);
|
||||
|
||||
// Effect to handle the token from Google OAuth redirect
|
||||
useEffect(() => {
|
||||
@@ -151,7 +152,7 @@ function App() {
|
||||
login(googleToken)
|
||||
.catch(err => logger.error('Failed to log in with Google token', { error: err }));
|
||||
// Clean the token from the URL
|
||||
window.history.replaceState({}, document.title, "/");
|
||||
navigate(location.pathname, { replace: true });
|
||||
}
|
||||
|
||||
const githubToken = urlParams.get('githubAuthToken');
|
||||
@@ -165,9 +166,9 @@ function App() {
|
||||
});
|
||||
|
||||
// Clean the token from the URL
|
||||
window.history.replaceState({}, document.title, "/");
|
||||
navigate(location.pathname, { replace: true });
|
||||
}
|
||||
}, [login, location.search]);
|
||||
}, [login, location.search, navigate, location.pathname]);
|
||||
|
||||
|
||||
const handleFlyerSelect = useCallback(async (flyer: Flyer) => {
|
||||
@@ -226,9 +227,8 @@ function App() {
|
||||
<Header
|
||||
isDarkMode={isDarkMode}
|
||||
unitSystem={unitSystem}
|
||||
profile={userProfile}
|
||||
userProfile={userProfile}
|
||||
authStatus={authStatus}
|
||||
user={userProfile?.user ?? null}
|
||||
onOpenProfile={handleOpenProfile}
|
||||
onOpenVoiceAssistant={handleOpenVoiceAssistant}
|
||||
onSignOut={logout}
|
||||
@@ -238,7 +238,7 @@ function App() {
|
||||
isOpen={isModalOpen('profile')}
|
||||
onClose={handleCloseProfile}
|
||||
authStatus={authStatus}
|
||||
profile={userProfile}
|
||||
userProfile={userProfile}
|
||||
onProfileUpdate={handleProfileUpdate}
|
||||
onLoginSuccess={handleLoginSuccess}
|
||||
onSignOut={logout}
|
||||
|
||||
@@ -4,15 +4,22 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Header } from './Header';
|
||||
import type { Profile } from '../types';
|
||||
import { createMockProfile, createMockUser } from '../tests/utils/mockFactories';
|
||||
import type { UserProfile } from '../types';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
|
||||
// Unmock the component to test the real implementation
|
||||
vi.unmock('./Header');
|
||||
|
||||
const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com' });
|
||||
const mockProfile: Profile = createMockProfile({ user_id: 'user-123', role: 'user', points: 0 });
|
||||
const mockAdminProfile: Profile = createMockProfile({ user_id: 'user-123', role: 'admin', points: 0 });
|
||||
const mockUserProfile: UserProfile = createMockUserProfile({
|
||||
user_id: 'user-123',
|
||||
role: 'user',
|
||||
user: { user_id: 'user-123', email: 'test@example.com' },
|
||||
});
|
||||
const mockAdminProfile: UserProfile = createMockUserProfile({
|
||||
user_id: 'admin-123',
|
||||
role: 'admin',
|
||||
user: { user_id: 'admin-123', email: 'admin@example.com' },
|
||||
});
|
||||
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenVoiceAssistant = vi.fn();
|
||||
@@ -21,9 +28,8 @@ const mockOnSignOut = vi.fn();
|
||||
const defaultProps = {
|
||||
isDarkMode: false,
|
||||
unitSystem: 'imperial' as const,
|
||||
user: null,
|
||||
userProfile: null,
|
||||
authStatus: 'SIGNED_OUT' as const,
|
||||
profile: null,
|
||||
onOpenProfile: mockOnOpenProfile,
|
||||
onOpenVoiceAssistant: mockOnOpenVoiceAssistant,
|
||||
onSignOut: mockOnSignOut,
|
||||
@@ -56,18 +62,18 @@ describe('Header', () => {
|
||||
|
||||
describe('When user is logged out', () => {
|
||||
it('should show a Login button', () => {
|
||||
renderWithRouter({ user: null, authStatus: 'SIGNED_OUT' });
|
||||
renderWithRouter({ userProfile: null, authStatus: 'SIGNED_OUT' });
|
||||
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onOpenProfile when Login button is clicked', () => {
|
||||
renderWithRouter({ user: null, authStatus: 'SIGNED_OUT' });
|
||||
renderWithRouter({ userProfile: null, authStatus: 'SIGNED_OUT' });
|
||||
fireEvent.click(screen.getByRole('button', { name: /login/i }));
|
||||
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not show user-specific buttons', () => {
|
||||
renderWithRouter({ user: null, authStatus: 'SIGNED_OUT' });
|
||||
renderWithRouter({ userProfile: null, authStatus: 'SIGNED_OUT' });
|
||||
expect(screen.queryByLabelText(/open voice assistant/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/open my account settings/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /logout/i })).not.toBeInTheDocument();
|
||||
@@ -76,29 +82,29 @@ describe('Header', () => {
|
||||
|
||||
describe('When user is authenticated', () => {
|
||||
it('should display the user email', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'AUTHENTICATED' });
|
||||
expect(screen.getByText(mockUser.email)).toBeInTheDocument();
|
||||
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
||||
expect(screen.getByText(mockUserProfile.user.email)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "Guest" for anonymous users', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'SIGNED_OUT' });
|
||||
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'SIGNED_OUT' });
|
||||
expect(screen.getByText(/guest/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onOpenVoiceAssistant when microphone icon is clicked', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'AUTHENTICATED' });
|
||||
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
||||
fireEvent.click(screen.getByLabelText(/open voice assistant/i));
|
||||
expect(mockOnOpenVoiceAssistant).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onOpenProfile when cog icon is clicked', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'AUTHENTICATED' });
|
||||
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
||||
fireEvent.click(screen.getByLabelText(/open my account settings/i));
|
||||
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onSignOut when Logout button is clicked', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'AUTHENTICATED' });
|
||||
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
||||
fireEvent.click(screen.getByRole('button', { name: /logout/i }));
|
||||
expect(mockOnSignOut).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -106,14 +112,14 @@ describe('Header', () => {
|
||||
|
||||
describe('Admin user', () => {
|
||||
it('should show the Admin Area link for admin users', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'AUTHENTICATED', profile: mockAdminProfile });
|
||||
renderWithRouter({ userProfile: mockAdminProfile, authStatus: 'AUTHENTICATED' });
|
||||
const adminLink = screen.getByTitle(/admin area/i);
|
||||
expect(adminLink).toBeInTheDocument();
|
||||
expect(adminLink.closest('a')).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
|
||||
it('should not show the Admin Area link for non-admin users', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'AUTHENTICATED', profile: mockProfile });
|
||||
renderWithRouter({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED' });
|
||||
expect(screen.queryByTitle(/admin area/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,21 +6,20 @@ import { Cog8ToothIcon } from './icons/Cog8ToothIcon';
|
||||
import { MicrophoneIcon } from './icons/MicrophoneIcon';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ShieldCheckIcon } from './icons/ShieldCheckIcon';
|
||||
import type { Profile, User } from '../types';
|
||||
import type { UserProfile } from '../types';
|
||||
import type { AuthStatus } from '../hooks/useAuth';
|
||||
|
||||
export interface HeaderProps {
|
||||
isDarkMode: boolean;
|
||||
unitSystem: 'metric' | 'imperial';
|
||||
user: User | null;
|
||||
authStatus: AuthStatus;
|
||||
profile: Profile | null;
|
||||
userProfile: UserProfile | null;
|
||||
onOpenProfile: () => void;
|
||||
onOpenVoiceAssistant: () => void;
|
||||
onSignOut: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, user, authStatus, profile, onOpenProfile, onOpenVoiceAssistant, onSignOut }) => {
|
||||
export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, authStatus, userProfile, onOpenProfile, onOpenVoiceAssistant, onSignOut }) => {
|
||||
// The state and handlers for the old AuthModal and SignUpModal have been removed.
|
||||
return (
|
||||
<>
|
||||
@@ -34,7 +33,7 @@ export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, user, au
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 md:space-x-6">
|
||||
{user && (
|
||||
{userProfile && (
|
||||
<button
|
||||
onClick={onOpenVoiceAssistant}
|
||||
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-500 dark:text-gray-400 transition-colors"
|
||||
@@ -52,13 +51,13 @@ export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, user, au
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700 hidden sm:block"></div>
|
||||
{user ? ( // This ternary was missing a 'null' or alternative rendering path for when 'user' is not present.
|
||||
{userProfile ? ( // This ternary was missing a 'null' or alternative rendering path for when 'user' is not present.
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="hidden md:flex items-center space-x-2 text-sm">
|
||||
<UserIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
{authStatus === 'AUTHENTICATED' ? (
|
||||
// Use the user object from the new auth system
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">{user.email}</span>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">{userProfile.user.email}</span>
|
||||
) : (
|
||||
<span className="font-medium text-gray-500 dark:text-gray-400 italic">Guest</span>
|
||||
)}
|
||||
@@ -71,7 +70,7 @@ export const Header: React.FC<HeaderProps> = ({ isDarkMode, unitSystem, user, au
|
||||
>
|
||||
<Cog8ToothIcon className="w-5 h-5" />
|
||||
</button>
|
||||
{profile?.role === 'admin' && (
|
||||
{userProfile?.role === 'admin' && (
|
||||
<Link to="/admin" className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-500 dark:text-gray-400 transition-colors" title="Admin Area">
|
||||
<ShieldCheckIcon className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
@@ -269,7 +269,14 @@ describe('ExtractedDataTable', () => {
|
||||
|
||||
// Assert the order is correct: watched items first, then others.
|
||||
// 'Gala Apples' (101) and 'Apples' (105) both have master_item_id 1, which is watched.
|
||||
expect(itemNamesInOrder).toEqual(['Gala Apples', 'Boneless Chicken', 'Apples', '2% Milk', 'Mystery Soda']);
|
||||
// The implementation sorts watched items to the top, and then sorts alphabetically within each group (watched/unwatched).
|
||||
const expectedOrder = [
|
||||
'Apples', // Watched
|
||||
'Boneless Chicken', // Watched
|
||||
'Gala Apples', // Watched
|
||||
'2% Milk', 'Mystery Soda' // Unwatched
|
||||
];
|
||||
expect(itemNamesInOrder).toEqual(expectedOrder);
|
||||
});
|
||||
|
||||
it('should filter items by category', () => {
|
||||
|
||||
@@ -154,7 +154,7 @@ describe('FlyerList', () => {
|
||||
expect(tooltipText).toContain('File: metro_flyer_oct_1.pdf');
|
||||
expect(tooltipText).toContain('Store: Metro');
|
||||
expect(tooltipText).toContain('Items: 50');
|
||||
expect(tooltipText).toContain('Valid: October 5, 2023 to October 11, 2023');
|
||||
expect(tooltipText).toContain('Deals valid from October 5, 2023 to October 11, 2023');
|
||||
// Use a regex for the processed time to avoid timezone-related flakiness in tests.
|
||||
expect(tooltipText).toMatch(/Processed: October 1, 2023 at \d{1,2}:\d{2}:\d{2} (AM|PM)/);
|
||||
});
|
||||
@@ -169,7 +169,7 @@ describe('FlyerList', () => {
|
||||
|
||||
// Tooltip should show N/A for invalid dates
|
||||
const tooltipText = badDateItem?.getAttribute('title');
|
||||
expect(tooltipText).toContain('Valid: N/A to N/A');
|
||||
expect(tooltipText).toContain('Validity: N/A');
|
||||
expect(tooltipText).toContain('Processed: N/A');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import toast from 'react-hot-toast';
|
||||
import { useProfileAddress } from './useProfileAddress';
|
||||
import { useApi } from './useApi';
|
||||
import { logger } from '../services/logger.client';
|
||||
import { createMockAddress, createMockProfile } from '../tests/utils/mockFactories';
|
||||
import { createMockAddress, createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
@@ -26,14 +26,14 @@ const mockedUseApi = vi.mocked(useApi);
|
||||
const mockedToast = vi.mocked(toast);
|
||||
|
||||
// Mock data
|
||||
const mockProfile = createMockProfile({
|
||||
const mockUserProfile = createMockUserProfile({
|
||||
user_id: 'user-123',
|
||||
address_id: 1,
|
||||
full_name: 'Test User',
|
||||
});
|
||||
|
||||
const mockProfileNoAddress = createMockProfile({
|
||||
...mockProfile,
|
||||
const mockUserProfileNoAddress = createMockUserProfile({
|
||||
...mockUserProfile,
|
||||
address_id: null,
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('useProfileAddress Hook', () => {
|
||||
|
||||
describe('Address Fetching Effect', () => {
|
||||
it('should not fetch address if isOpen is false', () => {
|
||||
renderHook(() => useProfileAddress(mockProfile, false));
|
||||
renderHook(() => useProfileAddress(mockUserProfile, false));
|
||||
expect(mockFetchAddress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -94,17 +94,17 @@ describe('useProfileAddress Hook', () => {
|
||||
|
||||
it('should fetch address when isOpen and a profile with address_id are provided', async () => {
|
||||
mockFetchAddress.mockResolvedValue(mockAddress);
|
||||
const { result } = renderHook(() => useProfileAddress(mockProfile, true));
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAddress).toHaveBeenCalledWith(mockProfile.address_id);
|
||||
expect(mockFetchAddress).toHaveBeenCalledWith(mockUserProfile.address_id);
|
||||
expect(result.current.address).toEqual(mockAddress);
|
||||
expect(result.current.initialAddress).toEqual(mockAddress);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset address if profile has no address_id', () => {
|
||||
const { result } = renderHook(() => useProfileAddress(mockProfileNoAddress, true));
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfileNoAddress, true));
|
||||
expect(mockFetchAddress).not.toHaveBeenCalled();
|
||||
expect(result.current.address).toEqual({});
|
||||
expect(result.current.initialAddress).toEqual({});
|
||||
@@ -113,15 +113,15 @@ describe('useProfileAddress Hook', () => {
|
||||
it('should reset state when modal is closed', async () => {
|
||||
mockFetchAddress.mockResolvedValue(mockAddress);
|
||||
const { result, rerender } = renderHook(
|
||||
({ profile, isOpen }) => useProfileAddress(profile, isOpen),
|
||||
{ initialProps: { profile: mockProfile, isOpen: true } }
|
||||
({ userProfile, isOpen }) => useProfileAddress(userProfile, isOpen),
|
||||
{ initialProps: { userProfile: mockUserProfile, isOpen: true } }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.address).toEqual(mockAddress);
|
||||
});
|
||||
|
||||
rerender({ profile: mockProfile, isOpen: false });
|
||||
rerender({ userProfile: mockUserProfile, isOpen: false });
|
||||
|
||||
expect(result.current.address).toEqual({});
|
||||
expect(result.current.initialAddress).toEqual({});
|
||||
@@ -129,10 +129,10 @@ describe('useProfileAddress Hook', () => {
|
||||
|
||||
it('should handle fetch failure gracefully', async () => {
|
||||
mockFetchAddress.mockResolvedValue(null); // useApi returns null on failure
|
||||
const { result } = renderHook(() => useProfileAddress(mockProfile, true));
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAddress).toHaveBeenCalledWith(mockProfile.address_id);
|
||||
expect(mockFetchAddress).toHaveBeenCalledWith(mockUserProfile.address_id);
|
||||
});
|
||||
|
||||
expect(result.current.address).toEqual({});
|
||||
@@ -211,7 +211,7 @@ describe('useProfileAddress Hook', () => {
|
||||
const newCoords = { lat: 38.89, lng: -77.03 };
|
||||
mockGeocode.mockResolvedValue(newCoords);
|
||||
|
||||
const { result } = renderHook(() => useProfileAddress(mockProfile, true));
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
|
||||
// Wait for initial fetch
|
||||
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
|
||||
@@ -239,7 +239,7 @@ describe('useProfileAddress Hook', () => {
|
||||
|
||||
it('should NOT trigger geocode if address already has coordinates', async () => {
|
||||
mockFetchAddress.mockResolvedValue(mockAddress); // Has coords
|
||||
const { result } = renderHook(() => useProfileAddress(mockProfile, true));
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
|
||||
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
|
||||
|
||||
@@ -257,7 +257,7 @@ describe('useProfileAddress Hook', () => {
|
||||
it('should NOT trigger geocode on initial load, even if address has no coords', async () => {
|
||||
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
||||
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
|
||||
const { result } = renderHook(() => useProfileAddress(mockProfile, true));
|
||||
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
||||
|
||||
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/hooks/useProfileAddress.ts
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { Address, Profile } from '../types';
|
||||
import type { Address, UserProfile } from '../types';
|
||||
import { useApi } from './useApi';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import { logger } from '../services/logger.client';
|
||||
@@ -10,62 +10,70 @@ import { useDebounce } from './useDebounce';
|
||||
const geocodeWrapper = (address: string, signal?: AbortSignal) => apiClient.geocodeAddress(address, { signal });
|
||||
const fetchAddressWrapper = (id: number, signal?: AbortSignal) => apiClient.getUserAddress(id, { signal });
|
||||
|
||||
/**
|
||||
* Helper to generate a consistent address string for geocoding.
|
||||
*/
|
||||
const getAddressString = (address: Partial<Address>): string => {
|
||||
return [
|
||||
address.address_line_1,
|
||||
address.city,
|
||||
address.province_state,
|
||||
address.postal_code,
|
||||
address.country,
|
||||
].filter(Boolean).join(', ');
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom hook to manage a user's profile address, including fetching,
|
||||
* updating, and automatic/manual geocoding.
|
||||
* @param profile The user's profile object.
|
||||
* @param userProfile The user's profile object.
|
||||
* @param isOpen Whether the parent component (e.g., a modal) is open. This is used to reset state.
|
||||
* @returns An object with address state and handler functions.
|
||||
*/
|
||||
export const useProfileAddress = (profile: Profile | null, isOpen: boolean) => {
|
||||
export const useProfileAddress = (userProfile: UserProfile | null, isOpen: boolean) => {
|
||||
const [address, setAddress] = useState<Partial<Address>>({});
|
||||
const [initialAddress, setInitialAddress] = useState<Partial<Address>>({});
|
||||
|
||||
const { execute: geocode, loading: isGeocoding } = useApi<{ lat: number; lng: number }, [string]>(geocodeWrapper);
|
||||
const { execute: fetchAddress } = useApi<Address, [number]>(fetchAddressWrapper);
|
||||
|
||||
const handleAddressFetch = useCallback(async (addressId: number) => {
|
||||
logger.debug(`[useProfileAddress] Starting fetch for addressId: ${addressId}`);
|
||||
const fetchedAddress = await fetchAddress(addressId);
|
||||
if (fetchedAddress) {
|
||||
logger.debug('[useProfileAddress] Successfully fetched address:', fetchedAddress);
|
||||
setAddress(fetchedAddress);
|
||||
setInitialAddress(fetchedAddress);
|
||||
} else {
|
||||
logger.warn(`[useProfileAddress] Fetch returned null or undefined for addressId: ${addressId}.`);
|
||||
}
|
||||
}, [fetchAddress]);
|
||||
|
||||
// Effect to fetch or reset address based on profile and modal state
|
||||
useEffect(() => {
|
||||
if (isOpen && profile) {
|
||||
if (profile.address_id) {
|
||||
logger.debug(`[useProfileAddress] Profile has address_id: ${profile.address_id}. Calling handleAddressFetch.`);
|
||||
handleAddressFetch(profile.address_id);
|
||||
const loadAddress = async () => {
|
||||
if (userProfile?.address_id) {
|
||||
logger.debug(`[useProfileAddress] Profile has address_id: ${userProfile.address_id}. Fetching.`);
|
||||
const fetchedAddress = await fetchAddress(userProfile.address_id);
|
||||
if (fetchedAddress) {
|
||||
logger.debug('[useProfileAddress] Successfully fetched address:', fetchedAddress);
|
||||
setAddress(fetchedAddress);
|
||||
setInitialAddress(fetchedAddress);
|
||||
} else {
|
||||
logger.warn(`[useProfileAddress] Fetch returned null for addressId: ${userProfile.address_id}.`);
|
||||
setAddress({});
|
||||
setInitialAddress({});
|
||||
}
|
||||
} else {
|
||||
logger.debug('[useProfileAddress] Profile has no address_id. Resetting address form.');
|
||||
setAddress({});
|
||||
setInitialAddress({});
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen && userProfile) {
|
||||
loadAddress();
|
||||
} else {
|
||||
logger.debug('[useProfileAddress] Modal is closed or profile is null. Resetting address state.');
|
||||
setAddress({});
|
||||
setInitialAddress({});
|
||||
}
|
||||
}, [isOpen, profile, handleAddressFetch]);
|
||||
}, [isOpen, userProfile, fetchAddress]); // fetchAddress is stable from useApi
|
||||
|
||||
const handleAddressChange = (field: keyof Address, value: string) => {
|
||||
const handleAddressChange = useCallback((field: keyof Address, value: string) => {
|
||||
setAddress(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleManualGeocode = async () => {
|
||||
const addressString = [
|
||||
address.address_line_1,
|
||||
address.city,
|
||||
address.province_state,
|
||||
address.postal_code,
|
||||
address.country,
|
||||
].filter(Boolean).join(', ');
|
||||
const handleManualGeocode = useCallback(async () => {
|
||||
const addressString = getAddressString(address);
|
||||
|
||||
if (!addressString) {
|
||||
toast.error('Please fill in the address fields before geocoding.');
|
||||
@@ -73,14 +81,13 @@ export const useProfileAddress = (profile: Profile | null, isOpen: boolean) => {
|
||||
}
|
||||
|
||||
logger.debug(`[useProfileAddress] Manual geocode triggering for: ${addressString}`);
|
||||
|
||||
const result = await geocode(addressString);
|
||||
if (result) {
|
||||
const { lat, lng } = result;
|
||||
setAddress(prev => ({ ...prev, latitude: lat, longitude: lng }));
|
||||
toast.success('Address re-geocoded successfully!');
|
||||
}
|
||||
};
|
||||
}, [address, geocode]);
|
||||
|
||||
// --- Automatic Geocoding Logic ---
|
||||
const debouncedAddress = useDebounce(address, 1500);
|
||||
@@ -89,29 +96,20 @@ export const useProfileAddress = (profile: Profile | null, isOpen: boolean) => {
|
||||
const handleAutoGeocode = async () => {
|
||||
logger.debug('[useProfileAddress] Auto-geocode effect triggered by debouncedAddress change');
|
||||
|
||||
// Prevent geocoding on initial load if the address hasn't been changed by the user.
|
||||
if (JSON.stringify(debouncedAddress) === JSON.stringify(initialAddress)) {
|
||||
logger.debug('[useProfileAddress] Skipping auto-geocode: address is unchanged from initial load.');
|
||||
return;
|
||||
}
|
||||
|
||||
const addressString = [
|
||||
debouncedAddress.address_line_1,
|
||||
debouncedAddress.city,
|
||||
debouncedAddress.province_state,
|
||||
debouncedAddress.postal_code,
|
||||
debouncedAddress.country,
|
||||
].filter(Boolean).join(', ');
|
||||
|
||||
logger.debug(`[useProfileAddress] addressString generated for auto-geocode: "${addressString}"`);
|
||||
const addressString = getAddressString(debouncedAddress);
|
||||
|
||||
// Don't geocode an empty address or if we already have coordinates.
|
||||
if (!addressString || (debouncedAddress.latitude && debouncedAddress.longitude)) {
|
||||
logger.debug('[useProfileAddress] Skipping auto-geocode: empty string or coordinates already exist');
|
||||
logger.debug('[useProfileAddress] Skipping auto-geocode: empty string or coordinates already exist', { hasString: !!addressString, hasCoords: !!debouncedAddress.latitude });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('[useProfileAddress] Calling auto-geocode API...');
|
||||
logger.debug(`[useProfileAddress] Auto-geocoding: "${addressString}"`);
|
||||
const result = await geocode(addressString);
|
||||
if (result) {
|
||||
logger.debug('[useProfileAddress] Auto-geocode API returned result:', result);
|
||||
|
||||
@@ -4,8 +4,8 @@ import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ActivityLog } from './ActivityLog';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import type { ActivityLogItem, User } from '../../types';
|
||||
import { createMockActivityLogItem, createMockUser } from '../../tests/utils/mockFactories';
|
||||
import type { ActivityLogItem, UserProfile } from '../../types';
|
||||
import { createMockActivityLogItem, createMockUserProfile } from '../../tests/utils/mockFactories';
|
||||
|
||||
// The apiClient and logger are now mocked globally via src/tests/setup/tests-setup-unit.ts.
|
||||
// We can cast it to its mocked type to get type safety and autocompletion.
|
||||
@@ -20,7 +20,7 @@ vi.mock('date-fns', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const mockUser: User = createMockUser({ user_id: 'user-123', email: 'test@example.com' });
|
||||
const mockUserProfile: UserProfile = createMockUserProfile({ user_id: 'user-123', user: { user_id: 'user-123', email: 'test@example.com' } });
|
||||
|
||||
const mockLogs: ActivityLogItem[] = [
|
||||
createMockActivityLogItem({
|
||||
@@ -72,8 +72,8 @@ describe('ActivityLog', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should not render if user is null', () => {
|
||||
const { container } = render(<ActivityLog user={null} onLogClick={vi.fn()} />);
|
||||
it('should not render if userProfile is null', () => {
|
||||
const { container } = render(<ActivityLog userProfile={null} onLogClick={vi.fn()} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('ActivityLog', () => {
|
||||
// Cast to any to bypass strict type checking for the mock return value vs Promise
|
||||
mockedApiClient.fetchActivityLog.mockReturnValue(mockPromise as any);
|
||||
|
||||
render(<ActivityLog user={mockUser} onLogClick={vi.fn()} />);
|
||||
render(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('Loading activity...')).toBeInTheDocument();
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('ActivityLog', () => {
|
||||
|
||||
it('should display an error message if fetching logs fails', async () => {
|
||||
mockedApiClient.fetchActivityLog.mockRejectedValue(new Error('API is down'));
|
||||
render(<ActivityLog user={mockUser} onLogClick={vi.fn()} />);
|
||||
render(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('API is down')).toBeInTheDocument();
|
||||
});
|
||||
@@ -104,7 +104,7 @@ describe('ActivityLog', () => {
|
||||
|
||||
it('should display a message when there are no logs', async () => {
|
||||
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify([])));
|
||||
render(<ActivityLog user={mockUser} onLogClick={vi.fn()} />);
|
||||
render(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No recent activity to show.')).toBeInTheDocument();
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe('ActivityLog', () => {
|
||||
|
||||
it('should render a list of activities successfully covering all types', async () => {
|
||||
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
|
||||
render(<ActivityLog user={mockUser} />);
|
||||
render(<ActivityLog userProfile={mockUserProfile} />);
|
||||
await waitFor(() => {
|
||||
// Check for specific text from different log types
|
||||
expect(screen.getByText('Walmart')).toBeInTheDocument(); // From flyer_processed
|
||||
@@ -146,7 +146,7 @@ describe('ActivityLog', () => {
|
||||
it('should call onLogClick when a clickable log item is clicked', async () => {
|
||||
const onLogClickMock = vi.fn();
|
||||
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
|
||||
render(<ActivityLog user={mockUser} onLogClick={onLogClickMock} />);
|
||||
render(<ActivityLog userProfile={mockUserProfile} onLogClick={onLogClickMock} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Recipe Created
|
||||
@@ -170,7 +170,7 @@ describe('ActivityLog', () => {
|
||||
|
||||
it('should not render clickable styling if onLogClick is undefined', async () => {
|
||||
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
|
||||
render(<ActivityLog user={mockUser} />); // onLogClick is undefined
|
||||
render(<ActivityLog userProfile={mockUserProfile} />); // onLogClick is undefined
|
||||
|
||||
await waitFor(() => {
|
||||
const recipeName = screen.getByText('Pasta Carbonara');
|
||||
@@ -233,7 +233,7 @@ describe('ActivityLog', () => {
|
||||
// Debug: verify structure of logs to ensure defaults are overridden
|
||||
console.log('Testing fallback rendering with logs:', JSON.stringify(logsWithMissingDetails, null, 2));
|
||||
|
||||
render(<ActivityLog user={mockUser} />);
|
||||
render(<ActivityLog userProfile={mockUserProfile} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('a store')[0]).toBeInTheDocument();
|
||||
@@ -253,7 +253,7 @@ describe('ActivityLog', () => {
|
||||
|
||||
it('should display error message from API response when not OK', async () => {
|
||||
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify({ message: 'Server says no' }), { status: 500 }));
|
||||
render(<ActivityLog user={mockUser} />);
|
||||
render(<ActivityLog userProfile={mockUserProfile} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Server says no')).toBeInTheDocument();
|
||||
});
|
||||
@@ -261,7 +261,7 @@ describe('ActivityLog', () => {
|
||||
|
||||
it('should display default error message from API response when not OK and no message provided', async () => {
|
||||
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify({}), { status: 500 }));
|
||||
render(<ActivityLog user={mockUser} />);
|
||||
render(<ActivityLog userProfile={mockUserProfile} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to fetch logs')).toBeInTheDocument();
|
||||
});
|
||||
@@ -269,7 +269,7 @@ describe('ActivityLog', () => {
|
||||
|
||||
it('should display generic error message when fetch throws non-Error object', async () => {
|
||||
mockedApiClient.fetchActivityLog.mockRejectedValue('String error');
|
||||
render(<ActivityLog user={mockUser} />);
|
||||
render(<ActivityLog userProfile={mockUserProfile} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load activity.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { fetchActivityLog } from '../../services/apiClient';
|
||||
import { ActivityLogItem } from '../../types';
|
||||
import { User } from '../../types';
|
||||
import { UserProfile } from '../../types';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
export type ActivityLogClickHandler = (log: ActivityLogItem) => void;
|
||||
|
||||
interface ActivityLogProps {
|
||||
user: User | null;
|
||||
userProfile: UserProfile | null;
|
||||
onLogClick?: ActivityLogClickHandler;
|
||||
}
|
||||
|
||||
@@ -71,13 +71,13 @@ const renderLogDetails = (log: ActivityLogItem, onLogClick?: ActivityLogClickHan
|
||||
}
|
||||
};
|
||||
|
||||
export const ActivityLog: React.FC<ActivityLogProps> = ({ user, onLogClick }) => {
|
||||
export const ActivityLog: React.FC<ActivityLogProps> = ({ userProfile, onLogClick }) => {
|
||||
const [logs, setLogs] = useState<ActivityLogItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
if (!userProfile) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -97,9 +97,9 @@ export const ActivityLog: React.FC<ActivityLogProps> = ({ user, onLogClick }) =>
|
||||
};
|
||||
|
||||
loadLogs();
|
||||
}, [user]);
|
||||
}, [userProfile]);
|
||||
|
||||
if (!user) {
|
||||
if (!userProfile) {
|
||||
return null; // Don't render the component if the user is not logged in
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ const defaultSignedOutProps = {
|
||||
isOpen: true,
|
||||
onClose: mockOnClose,
|
||||
authStatus: 'SIGNED_OUT' as const,
|
||||
profile: null,
|
||||
userProfile: null,
|
||||
onProfileUpdate: mockOnProfileUpdate,
|
||||
onSignOut: mockOnSignOut,
|
||||
onLoginSuccess: mockOnLoginSuccess,
|
||||
@@ -78,7 +78,7 @@ const defaultAuthenticatedProps = {
|
||||
isOpen: true,
|
||||
onClose: mockOnClose,
|
||||
authStatus: 'AUTHENTICATED' as const,
|
||||
profile: authenticatedProfile,
|
||||
userProfile: authenticatedProfile,
|
||||
onProfileUpdate: mockOnProfileUpdate,
|
||||
onSignOut: mockOnSignOut,
|
||||
onLoginSuccess: mockOnLoginSuccess,
|
||||
@@ -203,7 +203,7 @@ describe('ProfileManager', () => {
|
||||
|
||||
it('should show an error if trying to save profile when not logged in', async () => {
|
||||
// This is an edge case, but good to test the safeguard
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} profile={null} />);
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
|
||||
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Updated Name' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
||||
|
||||
@@ -273,7 +273,7 @@ describe('ProfileManager', () => {
|
||||
|
||||
it('should show error if geocoding is attempted with no address string', async () => {
|
||||
mockedApiClient.getUserAddress.mockResolvedValue(new Response(JSON.stringify({})));
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} profile={{ ...authenticatedProfile, address_id: 999 }} />);
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={{ ...authenticatedProfile, address_id: 999 }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Wait for initial render to settle
|
||||
@@ -381,7 +381,7 @@ describe('ProfileManager', () => {
|
||||
|
||||
it('should handle toggling dark mode when profile preferences are initially null', async () => {
|
||||
const profileWithoutPrefs = { ...authenticatedProfile, preferences: null as any };
|
||||
const { rerender } = render(<ProfileManager {...defaultAuthenticatedProps} profile={profileWithoutPrefs} />);
|
||||
const { rerender } = render(<ProfileManager {...defaultAuthenticatedProps} userProfile={profileWithoutPrefs} />);
|
||||
|
||||
console.log('[TEST LOG] Clicking preferences tab...');
|
||||
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
||||
@@ -407,7 +407,7 @@ describe('ProfileManager', () => {
|
||||
});
|
||||
|
||||
// Rerender with the new profile to check the UI update
|
||||
rerender(<ProfileManager {...defaultAuthenticatedProps} profile={updatedProfileWithPrefs} />);
|
||||
rerender(<ProfileManager {...defaultAuthenticatedProps} userProfile={updatedProfileWithPrefs} />);
|
||||
|
||||
// Wait for the preferences tab to be visible again before checking the toggle
|
||||
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
||||
@@ -625,7 +625,7 @@ describe('ProfileManager', () => {
|
||||
|
||||
it('should reset address form if profile has no address_id', async () => {
|
||||
const profileNoAddress = { ...authenticatedProfile, address_id: null };
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} profile={profileNoAddress as any} />);
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={profileNoAddress as any} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Address fields should be empty
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ProfileManagerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
authStatus: AuthStatus;
|
||||
profile: UserProfile | null; // Can be null for login/register
|
||||
userProfile: UserProfile | null; // Can be null for login/register
|
||||
onProfileUpdate: (updatedProfile: Profile) => void;
|
||||
onSignOut: () => void;
|
||||
onLoginSuccess: (user: UserProfile, token: string, rememberMe: boolean) => void; // Add login handler
|
||||
@@ -39,15 +39,15 @@ const deleteAccountWrapper = (password: string, signal?: AbortSignal) => apiClie
|
||||
const updatePreferencesWrapper = (prefs: Partial<Profile['preferences']>, signal?: AbortSignal) => apiClient.updateUserPreferences(prefs, { signal });
|
||||
const updateProfileWrapper = (data: Partial<Profile>, signal?: AbortSignal) => apiClient.updateUserProfile(data, { signal });
|
||||
|
||||
export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose, authStatus, profile, onProfileUpdate, onSignOut, onLoginSuccess }) => {
|
||||
export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose, authStatus, userProfile, onProfileUpdate, onSignOut, onLoginSuccess }) => {
|
||||
const [activeTab, setActiveTab] = useState('profile');
|
||||
|
||||
// Profile state
|
||||
const [fullName, setFullName] = useState(profile?.full_name || '');
|
||||
const [avatarUrl, setAvatarUrl] = useState(profile?.avatar_url || '');
|
||||
const [fullName, setFullName] = useState(userProfile?.full_name || '');
|
||||
const [avatarUrl, setAvatarUrl] = useState(userProfile?.avatar_url || '');
|
||||
|
||||
// Address logic is now encapsulated in this custom hook.
|
||||
const { address, initialAddress, isGeocoding, handleAddressChange, handleManualGeocode } = useProfileAddress(profile, isOpen);
|
||||
const { address, initialAddress, isGeocoding, handleAddressChange, handleManualGeocode } = useProfileAddress(userProfile, isOpen);
|
||||
|
||||
const { execute: updateProfile, loading: profileLoading } = useApi<Profile, [Partial<Profile>]>(updateProfileWrapper);
|
||||
const { execute: updateAddress, loading: addressLoading } = useApi<Address, [Partial<Address>]>(updateAddressWrapper);
|
||||
@@ -71,29 +71,29 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
||||
useEffect(() => {
|
||||
// Only reset state when the modal is opened.
|
||||
// Do not reset on profile changes, which can happen during sign-out.
|
||||
logger.debug('[useEffect] Running effect due to change in isOpen or profile.', { isOpen, profileExists: !!profile });
|
||||
if (isOpen && profile) { // Ensure profile exists before setting state
|
||||
logger.debug('[useEffect] Running effect due to change in isOpen or userProfile.', { isOpen, profileExists: !!userProfile });
|
||||
if (isOpen && userProfile) { // Ensure userProfile exists before setting state
|
||||
logger.debug('[useEffect] Modal is open with a valid profile. Resetting component state.');
|
||||
setFullName(profile.full_name || '');
|
||||
setAvatarUrl(profile.avatar_url || '');
|
||||
setFullName(userProfile.full_name || '');
|
||||
setAvatarUrl(userProfile.avatar_url || '');
|
||||
setActiveTab('profile');
|
||||
setIsConfirmingDelete(false);
|
||||
setPasswordForDelete('');
|
||||
}
|
||||
}, [isOpen, profile]); // Depend on isOpen and profile
|
||||
}, [isOpen, userProfile]); // Depend on isOpen and userProfile
|
||||
|
||||
const handleProfileSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
logger.debug('[handleProfileSave] Save process started.');
|
||||
|
||||
if (!profile) {
|
||||
if (!userProfile) {
|
||||
notifyError("Cannot save profile, no user is logged in.");
|
||||
logger.warn('[handleProfileSave] Aborted: No user is logged in.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if profile or address data has changed
|
||||
const profileDataChanged = fullName !== profile?.full_name || avatarUrl !== profile?.avatar_url;
|
||||
const profileDataChanged = fullName !== userProfile?.full_name || avatarUrl !== userProfile?.avatar_url;
|
||||
const addressDataChanged = JSON.stringify(address) !== JSON.stringify(initialAddress);
|
||||
|
||||
// --- Start Debug Logging ---
|
||||
@@ -101,9 +101,9 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
||||
profileDataChanged,
|
||||
addressDataChanged,
|
||||
currentFullName: fullName,
|
||||
initialFullName: profile?.full_name,
|
||||
initialFullName: userProfile?.full_name,
|
||||
currentAvatarUrl: avatarUrl,
|
||||
initialAvatarUrl: profile?.avatar_url,
|
||||
initialAvatarUrl: userProfile?.avatar_url,
|
||||
currentAddress: JSON.stringify(address),
|
||||
initialAddress: JSON.stringify(initialAddress)
|
||||
});
|
||||
@@ -173,12 +173,12 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
||||
const handleOAuthLink = async (provider: 'google' | 'github') => {
|
||||
// This will redirect the user to the OAuth provider to link the account.
|
||||
// TODO: This is a placeholder. Implement OAuth account linking via the Passport.js backend.
|
||||
if (!profile) {
|
||||
if (!userProfile) {
|
||||
return; // Should not be possible to see this button if not logged in
|
||||
}
|
||||
|
||||
const errorMessage = `Account linking with ${provider} is not yet implemented.`;
|
||||
logger.warn(errorMessage, { userId: profile.user_id });
|
||||
logger.warn(errorMessage, { userId: userProfile.user_id });
|
||||
notifyError(errorMessage);
|
||||
};
|
||||
|
||||
@@ -430,7 +430,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
||||
type="checkbox"
|
||||
id="darkModeToggle"
|
||||
className="sr-only"
|
||||
checked={profile?.preferences?.darkMode ?? false}
|
||||
checked={userProfile?.preferences?.darkMode ?? false}
|
||||
onChange={(e) => handleToggleDarkMode(e.target.checked)}
|
||||
/>
|
||||
<div className="block bg-gray-300 dark:bg-gray-600 w-14 h-8 rounded-full"></div>
|
||||
@@ -448,11 +448,11 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Select your preferred system of measurement.</p>
|
||||
<div className="mt-3 flex space-x-4">
|
||||
<label className="inline-flex items-center">
|
||||
<input type="radio" className="form-radio text-brand-primary" name="unitSystem" value="imperial" checked={profile?.preferences?.unitSystem === 'imperial'} onChange={() => handleToggleUnitSystem('imperial')} />
|
||||
<input type="radio" className="form-radio text-brand-primary" name="unitSystem" value="imperial" checked={userProfile?.preferences?.unitSystem === 'imperial'} onChange={() => handleToggleUnitSystem('imperial')} />
|
||||
<span className="ml-2 text-gray-700 dark:text-gray-300">Imperial (e.g., lbs, oz)</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center">
|
||||
<input type="radio" className="form-radio text-brand-primary" name="unitSystem" value="metric" checked={profile?.preferences?.unitSystem === 'metric'} onChange={() => handleToggleUnitSystem('metric')} />
|
||||
<input type="radio" className="form-radio text-brand-primary" name="unitSystem" value="metric" checked={userProfile?.preferences?.unitSystem === 'metric'} onChange={() => handleToggleUnitSystem('metric')} />
|
||||
<span className="ml-2 text-gray-700 dark:text-gray-300">Metric (e.g., kg, g)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -157,17 +157,17 @@ router.post('/upload-and-process', optionalAuth, uploadToDisk.single('flyerFile'
|
||||
return res.status(409).json({ message: 'This flyer has already been processed.', flyerId: existingFlyer.flyer_id });
|
||||
}
|
||||
|
||||
const user = req.user as UserProfile | undefined;
|
||||
const userProfile = req.user as UserProfile | undefined;
|
||||
// Construct a user address string from their profile if they are logged in.
|
||||
let userProfileAddress: string | undefined = undefined;
|
||||
if (user?.address) {
|
||||
if (userProfile?.address) {
|
||||
userProfileAddress = [
|
||||
user.address.address_line_1,
|
||||
user.address.address_line_2,
|
||||
user.address.city,
|
||||
user.address.province_state,
|
||||
user.address.postal_code,
|
||||
user.address.country
|
||||
userProfile.address.address_line_1,
|
||||
userProfile.address.address_line_2,
|
||||
userProfile.address.city,
|
||||
userProfile.address.province_state,
|
||||
userProfile.address.postal_code,
|
||||
userProfile.address.country
|
||||
].filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ router.post('/upload-and-process', optionalAuth, uploadToDisk.single('flyerFile'
|
||||
filePath: req.file.path,
|
||||
originalFileName: req.file.originalname,
|
||||
checksum: checksum,
|
||||
userId: user?.user_id,
|
||||
userId: userProfile?.user_id,
|
||||
submitterIp: req.ip, // Capture the submitter's IP address
|
||||
userProfileAddress: userProfileAddress, // Pass the user's profile address
|
||||
});
|
||||
@@ -286,7 +286,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
|
||||
// Pull common metadata fields (checksum, originalFileName) from whichever shape we parsed.
|
||||
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
|
||||
const originalFileName = parsed.originalFileName ?? parsed?.data?.originalFileName ?? req.file.originalname;
|
||||
const user = req.user as UserProfile | undefined;
|
||||
const userProfile = req.user as UserProfile | undefined;
|
||||
|
||||
// Validate extractedData to avoid database errors (e.g., null store_name)
|
||||
if (!extractedData || typeof extractedData !== 'object') {
|
||||
@@ -333,7 +333,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
|
||||
valid_to: extractedData.valid_to ?? null,
|
||||
store_address: extractedData.store_address ?? null,
|
||||
item_count: 0, // Set default to 0; the trigger will update it.
|
||||
uploaded_by: user?.user_id, // Associate with user if logged in
|
||||
uploaded_by: userProfile?.user_id, // Associate with user if logged in
|
||||
};
|
||||
|
||||
// 3. Create flyer and its items in a transaction
|
||||
@@ -343,7 +343,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
|
||||
|
||||
// Log this significant event
|
||||
await db.adminRepo.logActivity({
|
||||
userId: user?.user_id,
|
||||
userId: userProfile?.user_id,
|
||||
action: 'flyer_processed',
|
||||
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
|
||||
details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name }
|
||||
|
||||
@@ -285,7 +285,14 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toEqual({ user_id: 'user-123', email: loginCredentials.email });
|
||||
// The API now returns a nested UserProfile object
|
||||
expect(response.body.user).toEqual(expect.objectContaining({
|
||||
user_id: 'user-123',
|
||||
user: expect.objectContaining({
|
||||
user_id: 'user-123',
|
||||
email: loginCredentials.email
|
||||
})
|
||||
}));
|
||||
expect(response.body.token).toBeTypeOf('string');
|
||||
expect(response.headers['set-cookie']).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -167,14 +167,14 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
|
||||
return res.status(401).json({ message: info.message || 'Login failed' });
|
||||
}
|
||||
|
||||
const typedUser = user as UserProfile;
|
||||
const payload = { user_id: typedUser.user_id, email: typedUser.user.email, role: typedUser.role };
|
||||
const userProfile = user as UserProfile;
|
||||
const payload = { user_id: userProfile.user_id, email: userProfile.user.email, role: userProfile.role };
|
||||
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
||||
|
||||
try {
|
||||
const refreshToken = crypto.randomBytes(64).toString('hex'); // This was a duplicate, fixed.
|
||||
await userRepo.saveRefreshToken(typedUser.user_id, refreshToken, req.log);
|
||||
req.log.info(`JWT and refresh token issued for user: ${typedUser.user.email}`);
|
||||
await userRepo.saveRefreshToken(userProfile.user_id, refreshToken, req.log);
|
||||
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
@@ -184,9 +184,9 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
res.cookie('refreshToken', refreshToken, cookieOptions);
|
||||
// Return the full user profile object on login to avoid a second fetch on the client.
|
||||
return res.json({ user: typedUser, token: accessToken });
|
||||
return res.json({ user: userProfile, token: accessToken });
|
||||
} catch (tokenErr) {
|
||||
req.log.error({ error: tokenErr }, `Failed to save refresh token during login for user: ${typedUser.user.email}`);
|
||||
req.log.error({ error: tokenErr }, `Failed to save refresh token during login for user: ${userProfile.user.email}`);
|
||||
return next(tokenErr);
|
||||
}
|
||||
})(req, res, next);
|
||||
|
||||
@@ -125,7 +125,18 @@ describe('Passport Configuration', () => {
|
||||
expect(mockedDb.userRepo.findUserWithProfileByEmail).toHaveBeenCalledWith('test@test.com', logger);
|
||||
expect(bcrypt.compare).toHaveBeenCalledWith('password', 'hashed_password');
|
||||
expect(mockedDb.adminRepo.resetFailedLoginAttempts).toHaveBeenCalledWith('user-123', '127.0.0.1', logger);
|
||||
expect(done).toHaveBeenCalledWith(null, mockUser);
|
||||
// The strategy transforms the flat DB user into a nested UserProfile structure.
|
||||
// We need to construct the expected object based on that transformation logic.
|
||||
const { password_hash, email, ...profileData } = mockUser;
|
||||
const expectedUserProfile = {
|
||||
...profileData,
|
||||
user: {
|
||||
user_id: mockUser.user_id,
|
||||
email: mockUser.email,
|
||||
}
|
||||
};
|
||||
|
||||
expect(done).toHaveBeenCalledWith(null, expectedUserProfile);
|
||||
});
|
||||
|
||||
it('should call done(null, false) if user is not found', async () => {
|
||||
|
||||
@@ -122,9 +122,9 @@ router.post(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try { // The try-catch block was already correct here.
|
||||
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||
const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, { avatar_url: avatarUrl }, req.log);
|
||||
const updatedProfile = await db.userRepo.updateUserProfile(userProfile.user_id, { avatar_url: avatarUrl }, req.log);
|
||||
res.json(updatedProfile);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -141,15 +141,15 @@ router.get(
|
||||
'/notifications',
|
||||
validateRequest(notificationQuerySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
// Fix: Cast to UserProfile and access the nested user property.
|
||||
const user = req.user as UserProfile;
|
||||
// Cast to UserProfile to access user properties safely.
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
try {
|
||||
const { query } = req as unknown as GetNotificationsRequest;
|
||||
// Explicitly convert to numbers to ensure the repo receives correct types
|
||||
const limit = query.limit ? Number(query.limit) : 20;
|
||||
const offset = query.offset ? Number(query.offset) : 0;
|
||||
const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, limit, offset, req.log);
|
||||
const notifications = await db.notificationRepo.getNotificationsForUser(userProfile.user_id, limit, offset, req.log);
|
||||
res.json(notifications);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -164,10 +164,9 @@ router.post(
|
||||
'/notifications/mark-all-read',
|
||||
validateRequest(emptySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try { // The try-catch block was already correct here.
|
||||
// Fix: Cast to UserProfile and access the nested user property.
|
||||
const user = req.user as UserProfile;
|
||||
await db.notificationRepo.markAllNotificationsAsRead(user.user_id, req.log);
|
||||
try {
|
||||
const userProfile = req.user as UserProfile;
|
||||
await db.notificationRepo.markAllNotificationsAsRead(userProfile.user_id, req.log);
|
||||
res.status(204).send(); // No Content
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -183,12 +182,11 @@ type MarkNotificationReadRequest = z.infer<typeof notificationIdSchema>;
|
||||
router.post(
|
||||
'/notifications/:notificationId/mark-read', validateRequest(notificationIdSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try { // The try-catch block was already correct here.
|
||||
// Fix: Cast to UserProfile and access the nested user property.
|
||||
const user = req.user as UserProfile;
|
||||
try {
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as MarkNotificationReadRequest;
|
||||
await db.notificationRepo.markNotificationAsRead(params.notificationId, user.user_id, req.log);
|
||||
await db.notificationRepo.markNotificationAsRead(params.notificationId, userProfile.user_id, req.log);
|
||||
res.status(204).send(); // Success, no content to return
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -201,11 +199,11 @@ router.post(
|
||||
*/
|
||||
router.get('/profile', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] GET /api/users/profile - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
logger.debug(`[ROUTE] Calling db.userRepo.findUserProfileById for user: ${user.user_id}`);
|
||||
const userProfile = await db.userRepo.findUserProfileById(user.user_id, req.log);
|
||||
res.json(userProfile);
|
||||
logger.debug(`[ROUTE] Calling db.userRepo.findUserProfileById for user: ${userProfile.user_id}`);
|
||||
const fullUserProfile = await db.userRepo.findUserProfileById(userProfile.user_id, req.log);
|
||||
res.json(fullUserProfile);
|
||||
} catch (error) {
|
||||
logger.error({ error }, `[ROUTE] GET /api/users/profile - ERROR`);
|
||||
next(error);
|
||||
@@ -218,11 +216,11 @@ router.get('/profile', validateRequest(emptySchema), async (req, res, next: Next
|
||||
type UpdateProfileRequest = z.infer<typeof updateProfileSchema>;
|
||||
router.put('/profile', validateRequest(updateProfileSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/profile - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as UpdateProfileRequest;
|
||||
try {
|
||||
const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, body, req.log);
|
||||
const updatedProfile = await db.userRepo.updateUserProfile(userProfile.user_id, body, req.log);
|
||||
res.json(updatedProfile);
|
||||
} catch (error) {
|
||||
logger.error({ error }, `[ROUTE] PUT /api/users/profile - ERROR`);
|
||||
@@ -236,14 +234,14 @@ router.put('/profile', validateRequest(updateProfileSchema), async (req, res, ne
|
||||
type UpdatePasswordRequest = z.infer<typeof updatePasswordSchema>;
|
||||
router.put('/profile/password', validateRequest(updatePasswordSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as UpdatePasswordRequest;
|
||||
|
||||
try {
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(body.newPassword, saltRounds);
|
||||
await db.userRepo.updateUserPassword(user.user_id, hashedPassword, req.log);
|
||||
await db.userRepo.updateUserPassword(userProfile.user_id, hashedPassword, req.log);
|
||||
res.status(200).json({ message: 'Password updated successfully.' });
|
||||
} catch (error) {
|
||||
logger.error({ error }, `[ROUTE] PUT /api/users/profile/password - ERROR`);
|
||||
@@ -257,12 +255,12 @@ router.put('/profile/password', validateRequest(updatePasswordSchema), async (re
|
||||
type DeleteAccountRequest = z.infer<typeof deleteAccountSchema>;
|
||||
router.delete('/account', validateRequest(deleteAccountSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as DeleteAccountRequest;
|
||||
|
||||
try {
|
||||
const userWithHash = await db.userRepo.findUserWithPasswordHashById(user.user_id, req.log);
|
||||
const userWithHash = await db.userRepo.findUserWithPasswordHashById(userProfile.user_id, req.log);
|
||||
if (!userWithHash || !userWithHash.password_hash) {
|
||||
return res.status(404).json({ message: 'User not found or password not set.' });
|
||||
}
|
||||
@@ -272,7 +270,7 @@ router.delete('/account', validateRequest(deleteAccountSchema), async (req, res,
|
||||
return res.status(403).json({ message: 'Incorrect password.' });
|
||||
}
|
||||
|
||||
await db.userRepo.deleteUserById(user.user_id, req.log);
|
||||
await db.userRepo.deleteUserById(userProfile.user_id, req.log);
|
||||
res.status(200).json({ message: 'Account deleted successfully.' });
|
||||
} catch (error) {
|
||||
logger.error({ error }, `[ROUTE] DELETE /api/users/account - ERROR`);
|
||||
@@ -285,9 +283,9 @@ router.delete('/account', validateRequest(deleteAccountSchema), async (req, res,
|
||||
*/
|
||||
router.get('/watched-items', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] GET /api/users/watched-items - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
const items = await db.personalizationRepo.getWatchedItems(user.user_id, req.log);
|
||||
const items = await db.personalizationRepo.getWatchedItems(userProfile.user_id, req.log);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
logger.error({ error }, `[ROUTE] GET /api/users/watched-items - ERROR`);
|
||||
@@ -301,11 +299,11 @@ router.get('/watched-items', validateRequest(emptySchema), async (req, res, next
|
||||
type AddWatchedItemRequest = z.infer<typeof addWatchedItemSchema>;
|
||||
router.post('/watched-items', validateRequest(addWatchedItemSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as AddWatchedItemRequest;
|
||||
try {
|
||||
const newItem = await db.personalizationRepo.addWatchedItem(user.user_id, body.itemName, body.category, req.log);
|
||||
const newItem = await db.personalizationRepo.addWatchedItem(userProfile.user_id, body.itemName, body.category, req.log);
|
||||
res.status(201).json(newItem);
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) {
|
||||
@@ -327,11 +325,11 @@ const watchedItemIdSchema = numericIdParam('masterItemId');
|
||||
type DeleteWatchedItemRequest = z.infer<typeof watchedItemIdSchema>;
|
||||
router.delete('/watched-items/:masterItemId', validateRequest(watchedItemIdSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as DeleteWatchedItemRequest;
|
||||
try {
|
||||
await db.personalizationRepo.removeWatchedItem(user.user_id, params.masterItemId, req.log);
|
||||
await db.personalizationRepo.removeWatchedItem(userProfile.user_id, params.masterItemId, req.log);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
logger.error({ error }, `[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`);
|
||||
@@ -344,9 +342,9 @@ router.delete('/watched-items/:masterItemId', validateRequest(watchedItemIdSchem
|
||||
*/
|
||||
router.get('/shopping-lists', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] GET /api/users/shopping-lists - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
const lists = await db.shoppingRepo.getShoppingLists(user.user_id, req.log);
|
||||
const lists = await db.shoppingRepo.getShoppingLists(userProfile.user_id, req.log);
|
||||
res.json(lists);
|
||||
} catch (error) {
|
||||
logger.error({ error }, `[ROUTE] GET /api/users/shopping-lists - ERROR`);
|
||||
@@ -361,10 +359,10 @@ const shoppingListIdSchema = numericIdParam('listId');
|
||||
type GetShoppingListRequest = z.infer<typeof shoppingListIdSchema>;
|
||||
router.get('/shopping-lists/:listId', validateRequest(shoppingListIdSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] GET /api/users/shopping-lists/:listId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
const { params } = req as unknown as GetShoppingListRequest;
|
||||
try {
|
||||
const list = await db.shoppingRepo.getShoppingListById(params.listId, user.user_id, req.log);
|
||||
const list = await db.shoppingRepo.getShoppingListById(params.listId, userProfile.user_id, req.log);
|
||||
res.json(list);
|
||||
} catch (error) {
|
||||
logger.error({ error, listId: params.listId }, `[ROUTE] GET /api/users/shopping-lists/:listId - ERROR`);
|
||||
@@ -378,11 +376,11 @@ router.get('/shopping-lists/:listId', validateRequest(shoppingListIdSchema), asy
|
||||
type CreateShoppingListRequest = z.infer<typeof createShoppingListSchema>;
|
||||
router.post('/shopping-lists', validateRequest(createShoppingListSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as CreateShoppingListRequest;
|
||||
try {
|
||||
const newList = await db.shoppingRepo.createShoppingList(user.user_id, body.name, req.log);
|
||||
const newList = await db.shoppingRepo.createShoppingList(userProfile.user_id, body.name, req.log);
|
||||
res.status(201).json(newList);
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) {
|
||||
@@ -402,11 +400,11 @@ router.post('/shopping-lists', validateRequest(createShoppingListSchema), async
|
||||
*/
|
||||
router.delete('/shopping-lists/:listId', validateRequest(shoppingListIdSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as GetShoppingListRequest;
|
||||
try {
|
||||
await db.shoppingRepo.deleteShoppingList(params.listId, user.user_id, req.log);
|
||||
await db.shoppingRepo.deleteShoppingList(params.listId, userProfile.user_id, req.log);
|
||||
res.status(204).send();
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
|
||||
@@ -495,11 +493,11 @@ const updatePreferencesSchema = z.object({
|
||||
type UpdatePreferencesRequest = z.infer<typeof updatePreferencesSchema>;
|
||||
router.put('/profile/preferences', validateRequest(updatePreferencesSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as UpdatePreferencesRequest;
|
||||
try {
|
||||
const updatedProfile = await db.userRepo.updateUserPreferences(user.user_id, body, req.log);
|
||||
const updatedProfile = await db.userRepo.updateUserPreferences(userProfile.user_id, body, req.log);
|
||||
res.json(updatedProfile);
|
||||
} catch (error) {
|
||||
logger.error({ error }, `[ROUTE] PUT /api/users/profile/preferences - ERROR`);
|
||||
@@ -509,9 +507,9 @@ router.put('/profile/preferences', validateRequest(updatePreferencesSchema), asy
|
||||
|
||||
router.get('/me/dietary-restrictions', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] GET /api/users/me/dietary-restrictions - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
const restrictions = await db.personalizationRepo.getUserDietaryRestrictions(user.user_id, req.log);
|
||||
const restrictions = await db.personalizationRepo.getUserDietaryRestrictions(userProfile.user_id, req.log);
|
||||
res.json(restrictions);
|
||||
} catch (error) {
|
||||
logger.error({ error }, `[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`);
|
||||
@@ -525,11 +523,11 @@ const setUserRestrictionsSchema = z.object({
|
||||
type SetUserRestrictionsRequest = z.infer<typeof setUserRestrictionsSchema>;
|
||||
router.put('/me/dietary-restrictions', validateRequest(setUserRestrictionsSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as SetUserRestrictionsRequest;
|
||||
try {
|
||||
await db.personalizationRepo.setUserDietaryRestrictions(user.user_id, body.restrictionIds, req.log);
|
||||
await db.personalizationRepo.setUserDietaryRestrictions(userProfile.user_id, body.restrictionIds, req.log);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) {
|
||||
@@ -546,9 +544,9 @@ router.put('/me/dietary-restrictions', validateRequest(setUserRestrictionsSchema
|
||||
|
||||
router.get('/me/appliances', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] GET /api/users/me/appliances - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
try {
|
||||
const appliances = await db.personalizationRepo.getUserAppliances(user.user_id, req.log);
|
||||
const appliances = await db.personalizationRepo.getUserAppliances(userProfile.user_id, req.log);
|
||||
res.json(appliances);
|
||||
} catch (error) {
|
||||
logger.error({ error }, `[ROUTE] GET /api/users/me/appliances - ERROR`);
|
||||
@@ -562,11 +560,11 @@ const setUserAppliancesSchema = z.object({
|
||||
type SetUserAppliancesRequest = z.infer<typeof setUserAppliancesSchema>;
|
||||
router.put('/me/appliances', validateRequest(setUserAppliancesSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as SetUserAppliancesRequest;
|
||||
try {
|
||||
await db.personalizationRepo.setUserAppliances(user.user_id, body.applianceIds, req.log);
|
||||
await db.personalizationRepo.setUserAppliances(userProfile.user_id, body.applianceIds, req.log);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) {
|
||||
@@ -588,13 +586,13 @@ router.put('/me/appliances', validateRequest(setUserAppliancesSchema), async (re
|
||||
const addressIdSchema = numericIdParam('addressId');
|
||||
type GetAddressRequest = z.infer<typeof addressIdSchema>;
|
||||
router.get('/addresses/:addressId', validateRequest(addressIdSchema), async (req, res, next: NextFunction) => {
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as GetAddressRequest;
|
||||
try {
|
||||
const addressId = params.addressId;
|
||||
// Security check: Ensure the requested addressId matches the one on the user's profile.
|
||||
if (user.address_id !== addressId) {
|
||||
if (userProfile.address_id !== addressId) {
|
||||
return res.status(403).json({ message: 'Forbidden: You can only access your own address.' });
|
||||
}
|
||||
const address = await db.addressRepo.getAddressById(addressId, req.log); // This will throw NotFoundError if not found
|
||||
@@ -619,7 +617,7 @@ const updateUserAddressSchema = z.object({
|
||||
});
|
||||
type UpdateUserAddressRequest = z.infer<typeof updateUserAddressSchema>;
|
||||
router.put('/profile/address', validateRequest(updateUserAddressSchema), async (req, res, next: NextFunction) => {
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body: addressData } = req as unknown as UpdateUserAddressRequest;
|
||||
|
||||
@@ -627,7 +625,7 @@ router.put('/profile/address', validateRequest(updateUserAddressSchema), async (
|
||||
// Per ADR-002, complex operations involving multiple database writes should be
|
||||
// encapsulated in a single service method that manages the transaction.
|
||||
// This ensures both the address upsert and the user profile update are atomic.
|
||||
const addressId = await userService.upsertUserAddress(user, addressData, req.log); // This was a duplicate, fixed.
|
||||
const addressId = await userService.upsertUserAddress(userProfile, addressData, req.log); // This was a duplicate, fixed.
|
||||
res.status(200).json({ message: 'Address updated successfully', address_id: addressId });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -641,11 +639,11 @@ const recipeIdSchema = numericIdParam('recipeId');
|
||||
type DeleteRecipeRequest = z.infer<typeof recipeIdSchema>;
|
||||
router.delete('/recipes/:recipeId', validateRequest(recipeIdSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/recipes/:recipeId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as DeleteRecipeRequest;
|
||||
try {
|
||||
await db.recipeRepo.deleteRecipe(params.recipeId, user.user_id, false, req.log);
|
||||
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user_id, false, req.log);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
logger.error({ error, params: req.params }, `[ROUTE] DELETE /api/users/recipes/:recipeId - ERROR`);
|
||||
@@ -670,12 +668,12 @@ const updateRecipeSchema = recipeIdSchema.extend({
|
||||
type UpdateRecipeRequest = z.infer<typeof updateRecipeSchema>;
|
||||
router.put('/recipes/:recipeId', validateRequest(updateRecipeSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/recipes/:recipeId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const userProfile = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params, body } = req as unknown as UpdateRecipeRequest;
|
||||
|
||||
try {
|
||||
const updatedRecipe = await db.recipeRepo.updateRecipe(params.recipeId, user.user_id, body, req.log);
|
||||
const updatedRecipe = await db.recipeRepo.updateRecipe(params.recipeId, userProfile.user_id, body, req.log);
|
||||
res.json(updatedRecipe);
|
||||
} catch (error) {
|
||||
logger.error({ error, params: req.params, body: req.body }, `[ROUTE] PUT /api/users/recipes/:recipeId - ERROR`);
|
||||
|
||||
Reference in New Issue
Block a user