Compare commits

...

6 Commits

Author SHA1 Message Date
Gitea Actions
c852483e18 ci: Bump version to 0.7.29 [skip ci] 2026-01-03 02:43:54 +05:00
2e01ad5bc9 more test fixin
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m50s
2026-01-02 13:43:20 -08:00
Gitea Actions
26763c7183 ci: Bump version to 0.7.28 [skip ci] 2026-01-03 02:04:26 +05:00
f0c5c2c45b more test fixin
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m40s
2026-01-02 13:03:25 -08:00
Gitea Actions
034bb60fd5 ci: Bump version to 0.7.27 [skip ci] 2026-01-03 01:31:54 +05:00
d4b389cb79 more test fixin
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m39s
2026-01-02 12:31:19 -08:00
9 changed files with 150 additions and 28 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.7.26",
"version": "0.7.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.7.26",
"version": "0.7.29",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.7.26",
"version": "0.7.29",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -2115,6 +2115,61 @@ AS $$
ORDER BY potential_savings_cents DESC;
$$;
-- Function to get a user's spending breakdown by category for a given date range.
DROP FUNCTION IF EXISTS public.get_spending_by_category(UUID, DATE, DATE);
CREATE OR REPLACE FUNCTION public.get_spending_by_category(p_user_id UUID, p_start_date DATE, p_end_date DATE)
RETURNS TABLE (
category_id BIGINT,
category_name TEXT,
total_spent_cents BIGINT
)
LANGUAGE sql
STABLE
SECURITY INVOKER
AS $$
WITH all_purchases AS (
-- CTE 1: Combine purchases from completed shopping trips.
-- We only consider items that have a price paid.
SELECT
sti.master_item_id,
sti.price_paid_cents
FROM public.shopping_trip_items sti
JOIN public.shopping_trips st ON sti.shopping_trip_id = st.shopping_trip_id
WHERE st.user_id = p_user_id
AND st.completed_at::date BETWEEN p_start_date AND p_end_date
AND sti.price_paid_cents IS NOT NULL
UNION ALL
-- CTE 2: Combine purchases from processed receipts.
SELECT
ri.master_item_id,
ri.price_paid_cents
FROM public.receipt_items ri
JOIN public.receipts r ON ri.receipt_id = r.receipt_id
WHERE r.user_id = p_user_id
AND r.transaction_date::date BETWEEN p_start_date AND p_end_date
AND ri.master_item_id IS NOT NULL -- Only include items matched to a master item
)
-- Final Aggregation: Group all combined purchases by category and sum the spending.
SELECT
c.category_id,
c.name AS category_name,
SUM(ap.price_paid_cents)::BIGINT AS total_spent_cents
FROM all_purchases ap
-- Join with master_grocery_items to get the category_id for each purchase.
JOIN public.master_grocery_items mgi ON ap.master_item_id = mgi.master_grocery_item_id
-- Join with categories to get the category name for display.
JOIN public.categories c ON mgi.category_id = c.category_id
GROUP BY
c.category_id, c.name
HAVING
SUM(ap.price_paid_cents) > 0
ORDER BY
total_spent_cents DESC;
$$;
-- Function to approve a suggested correction and apply it.
DROP FUNCTION IF EXISTS public.approve_correction(BIGINT);
@@ -2669,6 +2724,58 @@ CREATE TRIGGER on_new_recipe_collection_share
AFTER INSERT ON public.shared_recipe_collections
FOR EACH ROW EXECUTE FUNCTION public.log_new_recipe_collection_share();
-- 10. Trigger function to geocode a store location's address.
-- This function is designed to be extensible for external geocoding services.
DROP FUNCTION IF EXISTS public.geocode_store_location();
CREATE OR REPLACE FUNCTION public.geocode_store_location()
RETURNS TRIGGER AS $$
DECLARE
full_address TEXT;
BEGIN
-- Only proceed if the address has actually changed.
-- Note: We check against the linked address fields via the NEW.address_id in a real scenario,
-- but for this trigger to work effectively, it usually requires a direct update on the address table
-- or this trigger should be moved to the 'addresses' table.
-- However, based on the provided logic, we are keeping the placeholder structure.
-- Placeholder logic:
IF TG_OP = 'INSERT' THEN
-- Logic to fetch address string based on NEW.address_id and geocode
NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to call the geocoding function.
DROP TRIGGER IF EXISTS on_store_location_address_change ON public.store_locations;
CREATE TRIGGER on_store_location_address_change
BEFORE INSERT OR UPDATE ON public.store_locations
FOR EACH ROW EXECUTE FUNCTION public.geocode_store_location();
-- 11. Trigger function to increment the fork_count on the original recipe.
DROP FUNCTION IF EXISTS public.increment_recipe_fork_count();
CREATE OR REPLACE FUNCTION public.increment_recipe_fork_count()
RETURNS TRIGGER AS $$
BEGIN
-- Only run if the recipe is a fork (original_recipe_id is not null).
IF NEW.original_recipe_id IS NOT NULL THEN
UPDATE public.recipes SET fork_count = fork_count + 1 WHERE recipe_id = NEW.original_recipe_id;
-- Award 'First Fork' achievement.
PERFORM public.award_achievement(NEW.user_id, 'First Fork');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS on_recipe_fork ON public.recipes;
CREATE TRIGGER on_recipe_fork
AFTER INSERT ON public.recipes
FOR EACH ROW EXECUTE FUNCTION public.increment_recipe_fork_count();
-- =================================================================
-- Function: get_best_sale_prices_for_all_users()
-- Description: Retrieves the best sale price for every item on every user's watchlist.

View File

@@ -967,6 +967,13 @@ describe('ProfileManager', () => {
it('should show error notification when auto-geocoding fails', async () => {
vi.useFakeTimers();
// FIX: Mock getUserAddress to return an address *without* coordinates.
// This is the condition required to trigger the auto-geocoding logic.
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
mockedApiClient.getUserAddress.mockResolvedValue(
new Response(JSON.stringify(addressWithoutCoords)),
);
render(<ProfileManager {...defaultAuthenticatedProps} />);
// Wait for initial load

View File

@@ -94,8 +94,8 @@ export class AIService {
// The fallback list is ordered by preference (speed/cost vs. power).
// We try the fastest models first, then the more powerful 'pro' model as a high-quality fallback,
// and finally the 'lite' model as a last resort.
private readonly models = [ 'gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemma-3-27b', 'gemma-3-12b'];
private readonly models_lite = ["gemma-3-4b", "gemma-3-2b", "gemma-3-1b"];
private readonly models = [ 'gemini-3-flash-preview','gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite','gemini-2.0-flash-001','gemini-2.0-flash','gemini-2.0-flash-exp','gemini-2.0-flash-lite-001','gemini-2.0-flash-lite', 'gemma-3-27b-it', 'gemma-3-12b-it'];
private readonly models_lite = ["gemma-3-4b-it", "gemma-3-2b-it", "gemma-3-1b-it"];
constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) {
this.logger = logger;

View File

@@ -103,17 +103,22 @@ export class FlyerRepository {
const result = await this.db.query<Flyer>(query, values);
return result.rows[0];
} catch (error) {
const isChecksumError =
error instanceof Error && error.message.includes('flyers_checksum_check');
const errorMessage = error instanceof Error ? error.message : '';
let checkMsg = 'A database check constraint failed.';
if (errorMessage.includes('flyers_checksum_check')) {
checkMsg =
'The provided checksum is invalid or does not meet format requirements (e.g., must be a 64-character SHA-256 hash).';
} else if (errorMessage.includes('flyers_status_check')) {
checkMsg = 'Invalid status provided for flyer.';
} else if (errorMessage.includes('url_check')) {
checkMsg = 'Invalid URL format provided for image or icon.';
}
handleDbError(error, logger, 'Database error in insertFlyer', { flyerData }, {
uniqueMessage: 'A flyer with this checksum already exists.',
fkMessage: 'The specified user or store for this flyer does not exist.',
// Provide a more specific message for the checksum constraint violation,
// which is a common issue during seeding or testing with placeholder data.
checkMessage: isChecksumError
? 'The provided checksum is invalid or does not meet format requirements (e.g., must be a 64-character SHA-256 hash).'
: 'Invalid status provided for flyer.',
checkMessage: checkMsg,
defaultMessage: 'Failed to insert flyer into database.',
});
}

View File

@@ -83,8 +83,8 @@ describe('FlyerDataTransformer', () => {
// 1. Check flyer data
expect(flyerData).toEqual({
file_name: originalFileName,
image_url: '/flyer-images/flyer-page-1.jpg',
icon_url: '/flyer-images/icons/icon-flyer-page-1.webp',
image_url: `http://localhost:3000/flyer-images/flyer-page-1.jpg`,
icon_url: `http://localhost:3000/flyer-images/icons/icon-flyer-page-1.webp`,
checksum,
store_name: 'Test Store',
valid_from: '2024-01-01',
@@ -167,8 +167,8 @@ describe('FlyerDataTransformer', () => {
expect(itemsForDb).toHaveLength(0);
expect(flyerData).toEqual({
file_name: originalFileName,
image_url: '/flyer-images/another.png',
icon_url: '/flyer-images/icons/icon-another.webp',
image_url: `http://localhost:3000/flyer-images/another.png`,
icon_url: `http://localhost:3000/flyer-images/icons/icon-another.webp`,
checksum,
store_name: 'Unknown Store (auto)', // Should use fallback
valid_from: null,

View File

@@ -23,14 +23,14 @@ export class FlyerDataTransformer {
): FlyerItemInsert {
return {
...item,
// Use logical OR to default falsy values (null, undefined, '') to a fallback.
// The trim is important for cases where the AI returns only whitespace.
item: String(item.item || '').trim() || 'Unknown Item',
// Use nullish coalescing to default only null/undefined to an empty string.
price_display: String(item.price_display ?? ''),
quantity: String(item.quantity ?? ''),
// Use logical OR to default falsy category names (null, undefined, '') to a fallback.
category_name: String(item.category_name || 'Other/Miscellaneous'),
// Use nullish coalescing and trim for robustness.
// An empty or whitespace-only name falls back to 'Unknown Item'.
item: (item.item ?? '').trim() || 'Unknown Item',
// Default null/undefined to an empty string and trim.
price_display: (item.price_display ?? '').trim(),
quantity: (item.quantity ?? '').trim(),
// An empty or whitespace-only category falls back to 'Other/Miscellaneous'.
category_name: (item.category_name ?? '').trim() || 'Other/Miscellaneous',
// Use nullish coalescing to convert null to undefined for the database.
master_item_id: item.master_item_id ?? undefined,
view_count: 0,
@@ -75,10 +75,13 @@ export class FlyerDataTransformer {
logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".');
}
// Construct proper URLs including protocol and host to satisfy DB constraints
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
const flyerData: FlyerInsert = {
file_name: originalFileName,
image_url: `/flyer-images/${path.basename(firstImage)}`,
icon_url: `/flyer-images/icons/${iconFileName}`,
image_url: new URL(`/flyer-images/${path.basename(firstImage)}`, baseUrl).href,
icon_url: new URL(`/flyer-images/icons/${iconFileName}`, baseUrl).href,
checksum,
store_name: storeName,
valid_from: extractedData.valid_from,

View File

@@ -240,7 +240,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
console.error('[DEBUG] EXIF test job failed:', jobStatus.failedReason);
}
expect(jobStatus?.state).toBe('completed');
const flyerId = jobStatus?.data?.flyerId;
const flyerId = jobStatus?.returnValue?.flyerId;
expect(flyerId).toBeTypeOf('number');
createdFlyerIds.push(flyerId);
@@ -326,7 +326,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
console.error('[DEBUG] PNG metadata test job failed:', jobStatus.failedReason);
}
expect(jobStatus?.state).toBe('completed');
const flyerId = jobStatus?.data?.flyerId;
const flyerId = jobStatus?.returnValue?.flyerId;
expect(flyerId).toBeTypeOf('number');
createdFlyerIds.push(flyerId);