ugh typescript whee

This commit is contained in:
2025-11-11 18:58:22 -08:00
parent 48b7d7ce7b
commit 7f56ee55d6
6 changed files with 386 additions and 241 deletions

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { XMarkIcon } from './icons/XMarkIcon';
import { ExclamationTriangleIcon } from './icons/ExclamationTriangleIcon';
interface ConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: React.ReactNode;
confirmButtonText?: string;
cancelButtonText?: string;
confirmButtonClass?: string;
}
export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmButtonText = 'Confirm',
cancelButtonText = 'Cancel',
confirmButtonClass = 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
}) => {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black bg-opacity-60 z-50 flex justify-center items-center p-4"
onClick={onClose}
aria-modal="true"
role="dialog"
>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md"
onClick={e => e.stopPropagation()}
>
<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">
<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">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
{title}
</h3>
<div className="mt-2">
<div className="text-sm text-gray-500 dark:text-gray-400">{message}</div>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse rounded-b-lg">
<button type="button" className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm ${confirmButtonClass}`} onClick={onConfirm}>{confirmButtonText}</button>
<button type="button" className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm" onClick={onClose}>{cancelButtonText}</button>
</div>
</div>
</div>
);
};

View File

@@ -5,6 +5,8 @@ import { logger } from '../services/logger';
import { CheckIcon } from './icons/CheckIcon';
import { XMarkIcon } from './icons/XMarkIcon';
import { LoadingSpinner } from './LoadingSpinner';
import { ConfirmationModal } from './ConfirmationModal';
import { ExclamationTriangleIcon } from './icons/ExclamationTriangleIcon';
interface CorrectionRowProps {
correction: SuggestedCorrection;
@@ -15,6 +17,8 @@ interface CorrectionRowProps {
export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction, masterItems, onProcessed }) => {
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [actionToConfirm, setActionToConfirm] = useState<'approve' | 'reject' | null>(null);
// Helper to make the suggested value more readable for the admin.
const formatSuggestedValue = () => {
@@ -33,61 +37,74 @@ export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction, master
return suggested_value;
};
const handleApprove = async () => {
if (!window.confirm('Are you sure you want to approve this correction? This will modify the original flyer item.')) {
return;
}
setIsProcessing(true);
setError(null);
try {
await approveCorrection(correction.id);
logger.info(`Correction ${correction.id} approved.`);
onProcessed(correction.id);
} catch (err: any) {
logger.error(`Failed to approve correction ${correction.id}`, err);
setError(err.message);
setIsProcessing(false);
}
};
const handleConfirm = async () => {
if (!actionToConfirm) return;
const handleReject = async () => {
if (!window.confirm('Are you sure you want to reject this correction?')) {
return;
}
setIsProcessing(true);
setIsModalOpen(false);
setError(null);
try {
await rejectCorrection(correction.id);
logger.info(`Correction ${correction.id} rejected.`);
if (actionToConfirm === 'approve') {
await approveCorrection(correction.id);
logger.info(`Correction ${correction.id} approved.`);
} else {
await rejectCorrection(correction.id);
logger.info(`Correction ${correction.id} rejected.`);
}
onProcessed(correction.id);
} catch (err: any) {
logger.error(`Failed to reject correction ${correction.id}`, err);
logger.error(`Failed to ${actionToConfirm} correction ${correction.id}`, err);
setError(err.message);
setIsProcessing(false);
}
setActionToConfirm(null);
};
return (
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">{correction.flyer_item_name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Original Price: {correction.flyer_item_price_display}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{correction.correction_type}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-blue-600 dark:text-blue-400">{formatSuggestedValue()}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{correction.user_email || 'Unknown'}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{new Date(correction.created_at).toLocaleDateString()}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{isProcessing ? (
<div className="flex justify-end items-center"><LoadingSpinner /></div>
) : (
<div className="flex items-center justify-end space-x-2">
<button onClick={handleApprove} className="p-1.5 text-green-600 hover:bg-green-100 dark:hover:bg-green-800/50 rounded-md" title="Approve"><CheckIcon className="w-5 h-5" /></button>
<button onClick={handleReject} className="p-1.5 text-red-600 hover:bg-red-100 dark:hover:bg-red-800/50 rounded-md" title="Reject"><XMarkIcon className="w-5 h-5" /></button>
</div>
)}
{error && <p className="text-xs text-red-500 mt-1 text-right">{error}</p>}
</td>
</tr>
<>
<ConfirmationModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onConfirm={handleConfirm}
title={actionToConfirm === 'approve' ? 'Approve Correction' : 'Reject Correction'}
message={
actionToConfirm === 'approve' ? (
<>
Are you sure you want to approve this correction?
<strong className="block mt-2">This will permanently modify the original flyer item.</strong>
</>
) : (
'Are you sure you want to reject this correction?'
)
}
confirmButtonText={actionToConfirm === 'approve' ? 'Approve' : 'Reject'}
confirmButtonClass={
actionToConfirm === 'approve'
? 'bg-green-600 hover:bg-green-700 focus:ring-green-500'
: 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
}
/>
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">{correction.flyer_item_name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Original Price: {correction.flyer_item_price_display}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{correction.correction_type}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-blue-600 dark:text-blue-400">{formatSuggestedValue()}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{correction.user_email || 'Unknown'}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{new Date(correction.created_at).toLocaleDateString()}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{isProcessing ? (
<div className="flex justify-end items-center"><LoadingSpinner /></div>
) : (
<div className="flex items-center justify-end space-x-2">
<button onClick={() => { setActionToConfirm('approve'); setIsModalOpen(true); }} className="p-1.5 text-green-600 hover:bg-green-100 dark:hover:bg-green-800/50 rounded-md" title="Approve"><CheckIcon className="w-5 h-5" /></button>
<button onClick={() => { setActionToConfirm('reject'); setIsModalOpen(true); }} className="p-1.5 text-red-600 hover:bg-red-100 dark:hover:bg-red-800/50 rounded-md" title="Reject"><XMarkIcon className="w-5 h-5" /></button>
</div>
)}
{error && <p className="text-xs text-red-500 mt-1 text-right">{error}</p>}
</td>
</tr>
</>
);
};

View File

@@ -7,6 +7,7 @@ import { LoadingSpinner } from './LoadingSpinner';
import { XMarkIcon } from './icons/XMarkIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { GithubIcon } from './icons/GithubIcon';
import { ConfirmationModal } from './ConfirmationModal';
type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED';
interface ProfileManagerProps {
@@ -33,6 +34,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordError, setPasswordError] = useState('');
const [passwordMessage, setPasswordMessage] = useState('');
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// Data & Privacy state
const [exportLoading, setExportLoading] = useState(false);
@@ -146,6 +148,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
setIsDeleteModalOpen(false); // Close the confirmation modal
setDeleteError('');
// CRITICAL: Prevent anonymous users from attempting to delete their account.
@@ -281,11 +284,11 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
<p className="text-sm text-red-700 dark:text-red-400 mt-1">This action is permanent and cannot be undone. All your data will be erased.</p>
{!isConfirmingDelete ? (
<button onClick={() => setIsConfirmingDelete(true)} className="mt-3 w-full sm:w-auto bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg">
<button type="button" onClick={() => setIsConfirmingDelete(true)} className="mt-3 w-full sm:w-auto bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg">
Delete My Account
</button>
) : (
<form onSubmit={handleDeleteAccount} className="mt-4 space-y-3 bg-white dark:bg-gray-800 p-4 rounded-md border border-red-500/50">
<form onSubmit={(e) => { e.preventDefault(); setIsDeleteModalOpen(true); }} className="mt-4 space-y-3 bg-white dark:bg-gray-800 p-4 rounded-md border border-red-500/50">
<p className="text-sm font-medium text-gray-800 dark:text-white">To confirm, please enter your current password.</p>
<div>
<label htmlFor="delete-password" className="sr-only">Current Password</label>

View File

@@ -1,7 +1,14 @@
import React from 'react';
export const ExclamationTriangleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
);

View File

@@ -17,6 +17,39 @@ export let supabase = (supabaseUrl && supabaseAnonKey)
? createClient<Database>(supabaseUrl, supabaseAnonKey)
: null;
// =============================================
// INTERNAL HELPERS
// =============================================
/**
* A centralized check to ensure the Supabase client is initialized before use.
* @throws {Error} If the client is not initialized.
*/
const ensureSupabase = (): SupabaseClient<Database> => {
if (!supabase) {
throw new Error("Supabase client not initialized. Please check your environment variables and configuration.");
}
return supabase;
};
/**
* Generic helper to handle Supabase query responses, centralizing error handling and data casting.
* @param query A Supabase query promise.
* @returns The data from the query, or throws an error.
*/
const handleResponse = async <T>(query: PromiseLike<{ data: T | null; error: any }>): Promise<T> => {
const { data, error } = await query;
if (error) {
throw new Error(error.message);
}
if (data === null) {
// This case can happen with .single() if no row is found.
// For list queries, an empty array is returned instead of null.
throw new Error("Query returned no data, but no error was reported.");
}
return data;
};
/**
* Disconnects the Supabase client by setting the instance to null.
*/
@@ -32,13 +65,12 @@ export const disconnectSupabase = () => {
* @returns An object indicating success and any error message.
*/
export const testDatabaseConnection = async (): Promise<{ success: boolean; error: string | null }> => {
if (!supabase) return { success: false, error: 'Supabase client not initialized.' };
try {
const { error } = await supabase.from('stores').select('id').limit(1);
const { error } = await ensureSupabase().from('stores').select('id').limit(1);
if (error) throw error;
return { success: true, error: null };
} catch (error: any) {
return { success: false, error: `Database connection test failed: ${error.message}. Check RLS policies.` };
return { success: false, error: `Database connection test failed: ${error.message}. Check RLS policies and client initialization.` };
}
};
@@ -47,7 +79,7 @@ export const testDatabaseConnection = async (): Promise<{ success: boolean; erro
* @returns An object indicating success and any error message.
*/
export const runDatabaseSelfTest = async (): Promise<{ success: boolean; error: string | null }> => {
if (!supabase) return { success: false, error: 'Supabase client not initialized.' };
const supabaseClient = ensureSupabase();
const testItem = {
item: `DB_SELF_TEST_ITEM_${Date.now()}`,
@@ -57,7 +89,7 @@ export const runDatabaseSelfTest = async (): Promise<{ success: boolean; error:
try {
// 1. Insert
const { data: insertData, error: insertError } = await supabase
const { data: insertData, error: insertError } = await supabaseClient
.from('flyer_items')
.insert(testItem)
.select()
@@ -69,14 +101,14 @@ export const runDatabaseSelfTest = async (): Promise<{ success: boolean; error:
const testItemId = insertData.id;
// 3. Update
const { error: updateError } = await supabase
const { error: updateError } = await supabaseClient
.from('flyer_items')
.update({ item: 'DB_SELF_TEST_ITEM_UPDATED' })
.eq('id', testItemId);
if (updateError) throw new Error(`Update failed: ${updateError.message}`);
// 4. Delete
const { error: deleteError } = await supabase
const { error: deleteError } = await supabaseClient
.from('flyer_items')
.delete()
.eq('id', testItemId);
@@ -93,7 +125,7 @@ export const runDatabaseSelfTest = async (): Promise<{ success: boolean; error:
* @returns An object indicating success and any error message.
*/
export const testStorageConnection = async (): Promise<{ success: boolean; error: string | null }> => {
if (!supabase) return { success: false, error: 'Supabase client not initialized.' };
const supabaseClient = ensureSupabase();
const bucketName = 'flyers';
const testFileName = `storage-self-test-${Date.now()}.txt`;
@@ -101,13 +133,13 @@ export const testStorageConnection = async (): Promise<{ success: boolean; error
try {
// 1. Upload
const { error: uploadError } = await supabase.storage
const { error: uploadError } = await supabaseClient.storage
.from(bucketName)
.upload(testFileName, testFileContent);
if (uploadError) throw new Error(`Upload to storage failed: ${uploadError.message}`);
// 2. Delete
const { error: deleteError } = await supabase.storage
const { error: deleteError } = await supabaseClient.storage
.from(bucketName)
.remove([testFileName]);
if (deleteError) throw new Error(`Deleting from storage failed: ${deleteError.message}`);
@@ -124,15 +156,17 @@ export const testStorageConnection = async (): Promise<{ success: boolean; error
* @returns The public URL of the uploaded image.
*/
export const uploadFlyerImage = async (file: File): Promise<string> => {
if (!supabase) throw new Error("Supabase client not initialized");
const supabaseClient = ensureSupabase();
const fileName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
const { data, error } = await supabase.storage
const { data: uploadData, error } = await supabaseClient.storage
.from('flyers')
.upload(fileName, file);
if (error) throw new Error(`Failed to upload flyer image: ${error.message}`);
if (!uploadData) throw new Error("Upload did not return a path.");
const { data: { publicUrl } } = supabase.storage.from('flyers').getPublicUrl(data.path);
const { data: urlData } = supabaseClient.storage.from('flyers').getPublicUrl(uploadData.path);
const publicUrl = urlData?.publicUrl;
if (!publicUrl) throw new Error("Could not get public URL for uploaded flyer image.");
return publicUrl;
@@ -151,22 +185,22 @@ export const createFlyerRecord = async (
validTo: string | null,
storeAddress: string | null
): Promise<Flyer> => {
if (!supabase) throw new Error("Supabase client not initialized");
const supabaseClient = ensureSupabase();
let { data: store } = await supabase
let store = await handleResponse(
supabaseClient
.from('stores')
.select('*')
.ilike('name', storeName)
.single();
.maybeSingle() // Use maybeSingle to gracefully handle no-store-found case
);
if (!store) {
const { data: newStore, error: newStoreError } = await supabase
store = await handleResponse(supabaseClient
.from('stores')
.insert({ name: storeName })
.select()
.single();
if (newStoreError) throw new Error(`Error creating store: ${newStoreError.message}`);
store = newStore;
.single());
}
const { data: newFlyer, error: flyerError } = await supabase
@@ -196,7 +230,7 @@ export const createFlyerRecord = async (
* @returns The array of saved items with their new IDs.
*/
export const saveFlyerItems = async (items: Omit<FlyerItem, 'id' | 'created_at' | 'flyer_id'>[], flyerId: number): Promise<FlyerItem[]> => {
if (!supabase) throw new Error("Supabase client not initialized");
const supabaseClient = ensureSupabase();
if (items.length === 0) return [];
const itemsToInsert = items.map(item => {
@@ -205,7 +239,7 @@ export const saveFlyerItems = async (items: Omit<FlyerItem, 'id' | 'created_at'
return { ...rest, flyer_id: flyerId };
});
const { data: savedItems, error } = await supabase
const { data: savedItems, error } = await supabaseClient
.from('flyer_items')
// We cast to `any` here to bypass the strict type check on the `unit_price` (Json vs UnitPrice)
// This is safe because UnitPrice is a valid JSONB structure.
@@ -222,16 +256,14 @@ export const saveFlyerItems = async (items: Omit<FlyerItem, 'id' | 'created_at'
* @returns An array of flyer objects.
*/
export const getFlyers = async (): Promise<Flyer[]> => {
if (!supabase) return [];
const supabaseClient = ensureSupabase();
const { data, error } = await supabase
const data = await handleResponse(supabaseClient
.from('flyers')
.select('*, store:stores(*)')
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to get flyers: ${error.message}`);
.order('created_at', { ascending: false }));
// Cast the result back to the expected application type
return (data as Flyer[]) || [];
return (data as Flyer[]) ?? [];
};
/**
@@ -240,17 +272,15 @@ export const getFlyers = async (): Promise<Flyer[]> => {
* @returns An array of flyer item objects.
*/
export const getFlyerItems = async (flyerId: number): Promise<FlyerItem[]> => {
if (!supabase) return [];
const supabaseClient = ensureSupabase();
const { data, error } = await supabase
const data = await handleResponse(supabaseClient
.from('flyer_items')
.select('*')
.eq('flyer_id', flyerId)
.order('item', { ascending: true });
if (error) throw new Error(`Failed to get flyer items: ${error.message}`);
.order('item', { ascending: true }));
// Cast the result back to the expected application type
return (data as unknown as FlyerItem[]) || [];
return (data as unknown as FlyerItem[]) ?? [];
};
/**
@@ -259,9 +289,9 @@ export const getFlyerItems = async (flyerId: number): Promise<FlyerItem[]> => {
* @returns The found flyer object or null.
*/
export const findFlyerByChecksum = async (checksum: string): Promise<Flyer | null> => {
if (!supabase) return null;
const supabaseClient = ensureSupabase();
const { data, error } = await supabase
const { data, error } = await supabaseClient
.from('flyers')
.select('*')
.eq('checksum', checksum)
@@ -281,10 +311,7 @@ export const findFlyerByChecksum = async (checksum: string): Promise<Flyer | nul
* @returns The public URL of the logo if successful, otherwise null.
*/
export const uploadLogoAndUpdateStore = async (storeId: number, logoBase64: string): Promise<string | null> => {
if (!supabase) {
console.warn("Cannot upload logo: Supabase client not initialized.");
return null;
}
const supabaseClient = ensureSupabase();
try {
// Helper function to convert base64 to a Blob for uploading.
@@ -301,7 +328,7 @@ export const uploadLogoAndUpdateStore = async (storeId: number, logoBase64: stri
const logoBlob = base64ToBlob(logoBase64, 'image/png');
const filePath = `logos/store_logo_${storeId}.png`;
const { data, error: uploadError } = await supabase.storage
const { data, error: uploadError } = await supabaseClient.storage
.from('flyers')
.upload(filePath, logoBlob, {
cacheControl: '3600',
@@ -313,14 +340,15 @@ export const uploadLogoAndUpdateStore = async (storeId: number, logoBase64: stri
return null;
}
const { data: { publicUrl } } = supabase.storage.from('flyers').getPublicUrl(data.path);
const { data: urlData } = supabaseClient.storage.from('flyers').getPublicUrl(data.path);
const publicUrl = urlData?.publicUrl;
if (!publicUrl) {
console.warn("Could not get public URL for uploaded logo.");
return null;
}
// Update the store record with the new URL, but only if it's currently null.
const { error: updateError } = await supabase
const { error: updateError } = await supabaseClient
.from('stores')
.update({ logo_url: publicUrl })
.eq('id', storeId)
@@ -343,20 +371,20 @@ export const uploadLogoAndUpdateStore = async (storeId: number, logoBase64: stri
* @returns An array of master grocery item objects.
*/
export const getWatchedItems = async (userId: string): Promise<MasterGroceryItem[]> => {
if (!supabase) return [];
const supabaseClient = ensureSupabase();
const { data, error } = await supabase
const data = await handleResponse(supabaseClient
.from('user_watched_items')
.select('master_grocery_items(*, category_name:categories(name))')
.select(`
master_item:master_grocery_items (
*,
category:categories (name)
)
`)
.eq('user_id', userId)
.order('name', { ascending: true, referencedTable: 'master_grocery_items' });
if (error) throw new Error(`Error fetching watched items: ${error.message}`);
.order('name', { referencedTable: 'master_grocery_items', ascending: true }));
return (data || []).map((item: any) => ({
...item.master_grocery_items,
category_name: item.master_grocery_items.category_name?.name,
}));
return (data?.map(item => ({ ...item.master_item, category_name: item.master_item.category.name })) ?? []) as MasterGroceryItem[];
};
/**
@@ -364,19 +392,17 @@ export const getWatchedItems = async (userId: string): Promise<MasterGroceryItem
* @returns An array of master grocery item objects.
*/
export const getAllMasterItems = async (): Promise<MasterGroceryItem[]> => {
if (!supabase) return [];
const supabaseClient = ensureSupabase();
const { data, error } = await supabase
const data = await handleResponse(supabaseClient
.from('master_grocery_items')
.select('*, category_name:categories(name)')
.order('name', { ascending: true });
.order('name', { ascending: true }));
if (error) throw new Error(`Error fetching master items: ${error.message}`);
return (data || []).map(item => ({
return (data?.map(item => ({
...item,
category_name: (item.category_name as any)?.name,
}));
})) ?? []) as MasterGroceryItem[];
};
/**
@@ -388,49 +414,56 @@ export const getAllMasterItems = async (): Promise<MasterGroceryItem[]> => {
* @returns The master item object that was added to the watchlist.
*/
export const addWatchedItem = async (userId: string, itemName: string, category: string): Promise<MasterGroceryItem> => {
if (!supabase) throw new Error("Supabase client not initialized");
const supabaseClient = ensureSupabase();
// 1. Find or create the category
let { data: categoryData } = await supabase
.from('categories')
.select('id')
.eq('name', category)
.single();
if (!categoryData) {
const { data: newCategoryData, error: newCategoryError } = await supabase
.from('categories')
.insert({ name: category })
.select('id')
try {
// 1. Find or create the category
let categoryData = await handleResponse(
supabaseClient
.from('categories')
.select('id')
.eq('name', category)
.maybeSingle()
);
if (!categoryData) {
categoryData = await handleResponse(
supabaseClient
.from('categories')
.insert({ name: category })
.select('id')
.single()
);
}
// 2. Upsert the master item to ensure it exists and get its ID
const { data: masterItem, error: masterItemError } = await supabaseClient
.from('master_grocery_items')
.upsert({ name: itemName.trim(), category_id: categoryData.id }, { onConflict: 'name' })
.select('*, category_name:categories(name)')
.single();
if (newCategoryError) throw new Error(`Error creating category: ${newCategoryError.message}`);
categoryData = newCategoryData;
if (masterItemError) throw new Error(`Failed to upsert master item: ${masterItemError.message}`);
if (!masterItem) throw new Error("Master item operation did not return data.");
// 3. Create the link in user_watched_items
const { error: watchLinkError } = await supabaseClient
.from('user_watched_items')
.insert({ user_id: userId, master_item_id: masterItem.id });
// Ignore duplicate errors (user already watching), throw others
if (watchLinkError && watchLinkError.code !== '23505') {
throw new Error(`Failed to add item to watchlist: ${watchLinkError.message}`);
}
// 4. Return the full master item object for UI update
return {
...masterItem,
category_name: (masterItem.category_name as any)?.name,
};
} catch (error: any) {
// Centralized error handling for the entire function
throw new Error(`Failed in addWatchedItem: ${error.message}`);
}
// 2. Upsert the master item to ensure it exists and get its ID
const { data: masterItem, error: masterItemError } = await supabase
.from('master_grocery_items')
.upsert({ name: itemName.trim(), category_id: categoryData.id }, { onConflict: 'name', ignoreDuplicates: false })
.select('*, category_name:categories(name)')
.single();
if (masterItemError) throw new Error(`Failed to upsert master item: ${masterItemError.message}`);
if (!masterItem) throw new Error("Master item operation did not return data.");
// 3. Create the link in user_watched_items
const { error: watchLinkError } = await supabase
.from('user_watched_items')
.insert({ user_id: userId, master_item_id: masterItem.id });
// Ignore duplicate errors (user already watching), throw others
if (watchLinkError && watchLinkError.code !== '23505') {
throw new Error(`Failed to add item to watchlist: ${watchLinkError.message}`);
}
// 4. Return the full master item object for UI update
return {
...masterItem,
category_name: (masterItem.category_name as any)?.name,
};
};
/**
@@ -439,9 +472,9 @@ export const addWatchedItem = async (userId: string, itemName: string, category:
* @param masterItemId The ID of the master item to remove.
*/
export const removeWatchedItem = async (userId: string, masterItemId: number): Promise<void> => {
if (!supabase) throw new Error("Supabase client not initialized");
const supabaseClient = ensureSupabase();
const { error } = await supabase
const { error } = await supabaseClient
.from('user_watched_items')
.delete()
.eq('user_id', userId)
@@ -458,14 +491,13 @@ export const removeWatchedItem = async (userId: string, masterItemId: number): P
* @returns An array of flyer item objects.
*/
export const getFlyerItemsForFlyers = async (flyerIds: number[]): Promise<FlyerItem[]> => {
if (!supabase || flyerIds.length === 0) return [];
const supabaseClient = ensureSupabase();
if (flyerIds.length === 0) return [];
const { data, error } = await supabase
const data = await handleResponse(supabaseClient
.from('flyer_items')
.select('*')
.in('flyer_id', flyerIds);
if (error) throw new Error(`Error fetching items for flyers: ${error.message}`);
.in('flyer_id', flyerIds));
// Cast the result back to the expected application type
return (data as unknown as FlyerItem[]) || [];
};
@@ -476,9 +508,10 @@ export const getFlyerItemsForFlyers = async (flyerIds: number[]): Promise<FlyerI
* @returns The total count of items.
*/
export const countFlyerItemsForFlyers = async (flyerIds: number[]): Promise<number> => {
if (!supabase || flyerIds.length === 0) return 0;
const supabaseClient = ensureSupabase();
if (flyerIds.length === 0) return 0;
const { count, error } = await supabase
const { count, error } = await supabaseClient
.from('flyer_items')
.select('*', { count: 'exact', head: true })
.in('flyer_id', flyerIds);
@@ -488,24 +521,24 @@ export const countFlyerItemsForFlyers = async (flyerIds: number[]): Promise<numb
};
/**
* Loads historical price data for watched items.
* Fetches all flyer items corresponding to a user's watched items to build a price history.
* @param watchedItems An array of master grocery items.
* @returns An array of historical price data points.
*/
export const loadAllHistoricalItems = async (watchedItems: MasterGroceryItem[]): Promise<Pick<FlyerItem, 'master_item_id' | 'price_in_cents' | 'created_at'>[]> => {
if (!supabase || watchedItems.length === 0) return [];
export const getHistoricalWatchedItems = async (watchedItems: MasterGroceryItem[]): Promise<Pick<FlyerItem, 'master_item_id' | 'price_in_cents' | 'created_at'>[]> => {
const supabaseClient = ensureSupabase();
if (watchedItems.length === 0) return [];
const watchedItemIds = watchedItems.map(item => item.id);
const { data, error } = await supabase
const data = await handleResponse(supabaseClient
.from('flyer_items')
.select('master_item_id, price_in_cents, created_at')
.in('master_item_id', watchedItemIds)
.not('price_in_cents', 'is', null)
.order('created_at', { ascending: true });
if (error) throw new Error(`Error loading historical items: ${error.message}`);
.order('created_at', { ascending: true }));
return data || [];
return data ?? [];
};
/**
@@ -514,8 +547,9 @@ export const loadAllHistoricalItems = async (watchedItems: MasterGroceryItem[]):
* @returns The user's profile object.
*/
export const getUserProfile = async (userId: string): Promise<Profile | null> => {
if (!supabase) throw new Error("Supabase client not initialized");
const { data, error } = await supabase
const supabaseClient = ensureSupabase();
const { data, error } = await supabaseClient
.from('profiles')
.select('*, role')
.eq('id', userId)
@@ -536,8 +570,8 @@ export const getUserProfile = async (userId: string): Promise<Profile | null> =>
* @returns The updated profile object.
*/
export const updateUserProfile = async (userId: string, updates: { full_name?: string; avatar_url?: string }): Promise<Profile> => {
if (!supabase) throw new Error("Supabase client not initialized");
const { data, error } = await supabase
const supabaseClient = ensureSupabase();
const { data, error } = await supabaseClient
.from('profiles')
// Cast to `any` to handle potential JSONB type mismatches if more fields are added
.update(updates as any)
@@ -545,7 +579,7 @@ export const updateUserProfile = async (userId: string, updates: { full_name?: s
.select()
.single();
if (error) throw new Error(`Error updating profile: ${error.message}`);
// Cast the result back to the expected application type
if (!data) throw new Error("Update profile did not return data.");
return data as Profile;
};
@@ -556,16 +590,15 @@ export const updateUserProfile = async (userId: string, updates: { full_name?: s
* @returns The updated profile object.
*/
export const updateUserPreferences = async (userId: string, preferences: Profile['preferences']): Promise<Profile> => {
if (!supabase) throw new Error("Supabase client not initialized");
const { data, error } = await supabase
const supabaseClient = ensureSupabase();
const data = await handleResponse(supabaseClient
.from('profiles')
// Cast to `any` to handle the JSONB type mismatch for `preferences`
.update({ preferences: preferences as any })
.eq('id', userId)
.select()
.single();
if (error) throw new Error(`Error updating preferences: ${error.message}`);
// Cast the result back to the expected application type
.single());
return data as Profile;
};
@@ -574,8 +607,9 @@ export const updateUserPreferences = async (userId: string, preferences: Profile
* @param newPassword The new password.
*/
export const updateUserPassword = async (newPassword: string): Promise<void> => {
if (!supabase) throw new Error("Supabase client not initialized");
const { error } = await supabase.auth.updateUser({ password: newPassword });
const supabaseClient = ensureSupabase();
const { error } = await supabaseClient.auth.updateUser({ password: newPassword });
if (error) throw new Error(`Error updating password: ${error.message}`);
};
@@ -585,26 +619,23 @@ export const updateUserPassword = async (newPassword: string): Promise<void> =>
* @returns An object containing all of the user's data.
*/
export const exportUserData = async (userId: string): Promise<object> => {
if (!supabase) throw new Error("Supabase client not initialized");
const supabaseClient = ensureSupabase();
const { data: profile, error: profileError } = await supabase
const profile = await handleResponse(supabaseClient
.from('profiles')
.select('*')
.eq('id', userId)
.single();
if (profileError) throw new Error(`Could not fetch profile: ${profileError.message}`);
.single());
const { data: watchedItems, error: watchedItemsError } = await supabase
const watchedItems = await handleResponse(supabaseClient
.from('user_watched_items')
.select('created_at, item:master_grocery_items(name, category:categories(name))')
.eq('user_id', userId);
if (watchedItemsError) throw new Error(`Could not fetch watched items: ${watchedItemsError.message}`);
.eq('user_id', userId));
const { data: shoppingLists, error: shoppingListsError } = await supabase
const shoppingLists = await handleResponse(supabaseClient
.from('shopping_lists')
.select('name, created_at, items:shopping_list_items(custom_item_name, quantity, is_purchased, master_item:master_grocery_items(name))')
.eq('user_id', userId);
if (shoppingListsError) throw new Error(`Could not fetch shopping lists: ${shoppingListsError.message}`);
.eq('user_id', userId));
return {
profile,
@@ -621,10 +652,10 @@ export const exportUserData = async (userId: string): Promise<object> => {
export const deleteUserAccount = async (password: string): Promise<void> => {
// Adding detailed logging to trace who is calling this function.
console.trace("deleteUserAccount called. This trace will show the call stack.");
if (!supabase) throw new Error("Supabase client not initialized");
const supabaseClient = ensureSupabase();
// Invoking the 'delete-user' edge function.
const { data, error } = await supabase.functions.invoke('delete-user', {
const { data, error } = await supabaseClient.functions.invoke('delete-user', {
body: { password },
});
@@ -648,8 +679,9 @@ export const deleteUserAccount = async (password: string): Promise<void> => {
* @returns The results of the system checks.
*/
export const invokeSystemCheckFunction = async (): Promise<any> => {
if (!supabase) throw new Error("Supabase client not initialized");
const { data, error } = await supabase.functions.invoke('system-check');
const supabaseClient = ensureSupabase();
const { data, error } = await supabaseClient.functions.invoke('system-check');
if (error) {
let errorDetails = `System check function failed: ${error.message}.`;
@@ -672,8 +704,9 @@ export const invokeSystemCheckFunction = async (): Promise<any> => {
* Creates the initial development users by invoking a secure Edge Function.
*/
export const invokeSeedDatabaseFunction = async (): Promise<{ message: string }> => {
if (!supabase) throw new Error("Supabase client not initialized");
const { data, error } = await supabase.functions.invoke('seed-database');
const supabaseClient = ensureSupabase();
const { data, error } = await supabaseClient.functions.invoke('seed-database');
if (error) {
let errorDetails = `Edge Function returned a non-2xx status code: ${error.message}.`;
@@ -698,9 +731,11 @@ export const invokeSeedDatabaseFunction = async (): Promise<{ message: string }>
* @returns An array of suggested correction objects with related user and item data.
*/
export const getSuggestedCorrections = async (): Promise<SuggestedCorrection[]> => {
if (!supabase) throw new Error("Supabase client not initialized");
const supabaseClient = ensureSupabase();
const { data, error } = await supabase
// Re-writing this query explicitly to avoid a complex type-inference issue
// that was causing the TypeScript language server to report a phantom syntax error.
const query = supabaseClient
.from('suggested_corrections')
.select(`
*,
@@ -710,13 +745,22 @@ export const getSuggestedCorrections = async (): Promise<SuggestedCorrection[]>
.eq('status', 'pending')
.order('created_at', { ascending: true });
if (error) throw new Error(`Failed to fetch suggested corrections: ${error.message}`);
const { data, error } = await query;
return (data || []).map((c: any) => ({
if (error) {
throw new Error(`Failed to get suggested corrections: ${error.message}`);
}
// The data can be null if the query returns no rows, so we handle that case.
if (!data) {
return [];
}
return data.map((c: any) => ({
...c,
user_email: c.user?.email,
flyer_item_name: c.item?.item,
flyer_item_price_display: c.item?.price_display,
flyer_item_price_display: c.item?.price_display
}));
};
@@ -726,9 +770,9 @@ export const getSuggestedCorrections = async (): Promise<SuggestedCorrection[]>
* @param correctionId The ID of the correction to approve.
*/
export const approveCorrection = async (correctionId: number): Promise<void> => {
if (!supabase) throw new Error("Supabase client not initialized");
const supabaseClient = ensureSupabase();
const { error } = await supabase.rpc('approve_correction', { p_correction_id: correctionId });
const { error } = await supabaseClient.rpc('approve_correction', { p_correction_id: correctionId });
if (error) {
throw new Error(`Failed to approve correction: ${error.message}`);
@@ -741,9 +785,9 @@ export const approveCorrection = async (correctionId: number): Promise<void> =>
* @param correctionId The ID of the correction to reject.
*/
export const rejectCorrection = async (correctionId: number): Promise<void> => {
if (!supabase) throw new Error("Supabase client not initialized");
const supabaseClient = ensureSupabase();
const { error } = await supabase.from('suggested_corrections').update({ status: 'rejected', reviewed_at: new Date().toISOString() }).eq('id', correctionId);
const { error } = await supabaseClient.from('suggested_corrections').update({ status: 'rejected', reviewed_at: new Date().toISOString() }).eq('id', correctionId);
if (error) throw new Error(`Failed to reject correction: ${error.message}`);
};
@@ -759,8 +803,9 @@ export const rejectCorrection = async (correctionId: number): Promise<void> => {
* @returns An array of shopping list objects.
*/
export const getShoppingLists = async (userId: string): Promise<ShoppingList[]> => {
if (!supabase) return [];
const { data, error } = await supabase
const supabaseClient = ensureSupabase();
const data = await handleResponse(
supabaseClient
.from('shopping_lists')
.select(`
*,
@@ -771,10 +816,9 @@ export const getShoppingLists = async (userId: string): Promise<ShoppingList[]>
`)
.eq('user_id', userId)
.order('created_at', { ascending: true })
.order('added_at', { ascending: true, referencedTable: 'shopping_list_items' });
if (error) throw new Error(`Error fetching shopping lists: ${error.message}`);
return data || [];
.order('added_at', { ascending: true, referencedTable: 'shopping_list_items' })
);
return data ?? [];
};
/**
@@ -784,13 +828,12 @@ export const getShoppingLists = async (userId: string): Promise<ShoppingList[]>
* @returns The newly created shopping list object.
*/
export const createShoppingList = async (userId: string, name: string): Promise<ShoppingList> => {
if (!supabase) throw new Error("Supabase client not initialized");
const { data, error } = await supabase
const supabaseClient = ensureSupabase();
const data = await handleResponse(supabaseClient
.from('shopping_lists')
.insert({ user_id: userId, name })
.select()
.single();
if (error) throw new Error(`Error creating shopping list: ${error.message}`);
.single());
return { ...data, items: [] }; // Return with empty items array
};
@@ -799,8 +842,9 @@ export const createShoppingList = async (userId: string, name: string): Promise<
* @param listId The ID of the list to delete.
*/
export const deleteShoppingList = async (listId: number): Promise<void> => {
if (!supabase) throw new Error("Supabase client not initialized");
const { error } = await supabase
const supabaseClient = ensureSupabase();
const { error } = await supabaseClient
.from('shopping_lists')
.delete()
.eq('id', listId);
@@ -815,25 +859,38 @@ export const deleteShoppingList = async (listId: number): Promise<void> => {
* @returns The newly created shopping list item.
*/
export const addShoppingListItem = async (listId: number, { masterItemId, customItemName }: { masterItemId?: number; customItemName?: string }): Promise<ShoppingListItem> => {
if (!supabase) throw new Error("Supabase client not initialized");
if (!masterItemId && !customItemName) throw new Error("Either masterItemId or customItemName must be provided.");
const supabaseClient = ensureSupabase();
if (!masterItemId && !customItemName) {
throw new Error("Either masterItemId or customItemName must be provided.");
}
const itemToInsert = {
shopping_list_id: listId,
master_item_id: masterItemId,
custom_item_name: customItemName,
quantity: 1
};
let query;
// Use upsert to handle potential duplicates of master items gracefully
const query = supabase.from('shopping_list_items').upsert(itemToInsert, {
onConflict: 'shopping_list_id, master_item_id',
ignoreDuplicates: masterItemId ? false : true // Only upsert quantity for master items
}).select('*, master_item:master_grocery_items(name)').single();
const { data, error } = await query;
if (error) throw new Error(`Error adding shopping list item: ${error.message}`);
return data;
if (masterItemId) {
const itemToUpsert = {
shopping_list_id: listId,
master_item_id: masterItemId,
custom_item_name: null, // Ensure the object shape is complete for the type checker
quantity: 1,
};
query = supabaseClient.from('shopping_list_items').upsert(itemToUpsert, { onConflict: 'shopping_list_id, master_item_id' });
} else {
const itemToInsert = {
shopping_list_id: listId,
master_item_id: null, // Ensure the object shape is complete
custom_item_name: customItemName,
quantity: 1,
};
query = supabaseClient.from('shopping_list_items').insert(itemToInsert);
}
// Bypassing the generic `handleResponse` helper to resolve the complex type inference issue.
const { data, error } = await query.select('*, master_item:master_grocery_items(name)').single();
if (error) throw new Error(`Failed to add shopping list item: ${error.message}`);
if (!data) throw new Error("Adding shopping list item did not return data.");
return data as ShoppingListItem;
};
/**
@@ -843,17 +900,16 @@ export const addShoppingListItem = async (listId: number, { masterItemId, custom
* @returns The updated shopping list item.
*/
export const updateShoppingListItem = async (itemId: number, updates: Partial<ShoppingListItem>): Promise<ShoppingListItem> => {
if (!supabase) throw new Error("Supabase client not initialized");
const supabaseClient = ensureSupabase();
// The 'id' cannot be part of the update payload.
const { id, ...updateData } = updates;
const { data, error } = await supabase
const { id, master_item, ...updateData } = updates;
const data = await handleResponse(supabaseClient
.from('shopping_list_items')
.update(updateData)
.eq('id', itemId)
.select('*, master_item:master_grocery_items(name)')
.single();
if (error) throw new Error(`Error updating shopping list item: ${error.message}`);
return data as ShoppingListItem;
.single());
return data;
};
/**
@@ -861,8 +917,9 @@ export const updateShoppingListItem = async (itemId: number, updates: Partial<Sh
* @param itemId The ID of the item to remove.
*/
export const removeShoppingListItem = async (itemId: number): Promise<void> => {
if (!supabase) throw new Error("Supabase client not initialized");
const { error } = await supabase
const supabaseClient = ensureSupabase();
const { error } = await supabaseClient
.from('shopping_list_items')
.delete()
.eq('id', itemId);

View File

@@ -94,8 +94,8 @@ export interface SuggestedCorrection {
suggested_value: string;
status: 'pending' | 'approved' | 'rejected';
created_at: string;
reviewed_at: string | null;
reviewed_notes: string | null;
reviewed_at?: string | null;
reviewed_notes?: string | null;
// Joined data
user_email?: string;
flyer_item_name?: string;