added address table, super awesome - bunch of gitea workflow options - "Processed Flyers" made better
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 1m1s

This commit is contained in:
2025-12-03 19:34:47 -08:00
parent 893ae6da53
commit e63999694a
25 changed files with 765 additions and 139 deletions

View File

@@ -257,7 +257,7 @@ jobs:
# Ensure the destination directory exists # Ensure the destination directory exists
mkdir -p "$APP_PATH" mkdir -p "$APP_PATH"
mkdir -p "$APP_PATH/flyer-images/icons" # Also ensure our protected image directory exists mkdir -p "$APP_PATH/flyer-images/icons" "$APP_PATH/flyer-images/archive" # Ensure all required subdirectories exist
# 1. Copy the backend source code and project files first. # 1. Copy the backend source code and project files first.
# CRITICAL: We exclude '.env', 'node_modules', '.git', 'dist', and now 'flyer-images' to protect user content. # CRITICAL: We exclude '.env', 'node_modules', '.git', 'dist', and now 'flyer-images' to protect user content.

View File

@@ -0,0 +1,63 @@
# .gitea/workflows/manual-db-backup.yml
#
# This workflow provides a manual trigger to back up the production database.
# It creates a compressed SQL dump and saves it as a downloadable artifact.
name: Manual - Backup Production Database
on:
workflow_dispatch:
inputs:
confirmation:
description: 'Type "backup-production-db" to confirm you want to create a backup.'
required: true
default: 'do-not-run'
jobs:
backup-database:
runs-on: projectium.com # This job runs on your self-hosted Gitea runner.
env:
# Use production database credentials for this entire job.
DB_HOST: ${{ secrets.DB_HOST }}
DB_PORT: ${{ secrets.DB_PORT }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_DATABASE: ${{ secrets.DB_DATABASE_PROD }}
steps:
- name: Validate Secrets
run: |
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_DATABASE" ]; then
echo "ERROR: One or more production database secrets are not set in Gitea repository settings."
exit 1
fi
echo "✅ All required database secrets are present."
- name: Verify Confirmation Phrase
run: |
if [ "${{ gitea.event.inputs.confirmation }}" != "backup-production-db" ]; then
echo "ERROR: Confirmation phrase did not match. Aborting database backup."
exit 1
fi
echo "✅ Confirmation accepted. Proceeding with database backup."
- name: Create Database Backup
id: backup
run: |
# Generate a timestamped filename for the backup.
TIMESTAMP=$(date +'%Y%m%d-%H%M%S')
BACKUP_FILENAME="flyer-crawler-prod-backup-${TIMESTAMP}.sql.gz"
echo "Creating backup file: $BACKUP_FILENAME"
# Use pg_dump to create a plain-text SQL dump, then pipe it to gzip for compression.
# This is more efficient than creating a large uncompressed file first.
PGPASSWORD="$DB_PASSWORD" pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_DATABASE" --clean --if-exists | gzip > "$BACKUP_FILENAME"
echo "✅ Database backup created successfully."
echo "backup_filename=$BACKUP_FILENAME" >> $GITEA_ENV
- name: Upload Backup as Artifact
uses: actions/upload-artifact@v3
with:
name: database-backup
path: ${{ env.backup_filename }}

View File

@@ -135,3 +135,14 @@ jobs:
echo "ERROR: Failed to set schema hash in the database." echo "ERROR: Failed to set schema hash in the database."
exit 1 exit 1
fi fi
- name: Step 6 - Clear Flyer Asset Directories
run: |
APP_PATH="/var/www/flyer-crawler.projectium.com"
echo "Clearing contents of flyer asset directories..."
# Use find to delete files within the directories, but not the directories themselves.
# This is safer than `rm -rf` as it won't fail if a directory doesn't exist.
find "$APP_PATH/flyer-images" -mindepth 1 -maxdepth 1 -type f -delete
find "$APP_PATH/flyer-images/icons" -mindepth 1 -maxdepth 1 -type f -delete
find "$APP_PATH/flyer-images/archive" -mindepth 1 -maxdepth 1 -type f -delete || echo "Archive directory not found, skipping."
echo "✅ Flyer asset directories cleared."

View File

@@ -0,0 +1,96 @@
# .gitea/workflows/manual-db-restore.yml
#
# DANGER: This workflow is DESTRUCTIVE. It restores the production database from a backup file.
# It should be run manually with extreme caution.
name: Manual - Restore Production Database from Backup
on:
workflow_dispatch:
inputs:
backup_filename:
description: 'The exact filename of the backup (.sql.gz) located in /var/www/backups/'
required: true
confirmation:
description: 'DANGER: This will WIPE the production DB. Type "restore-production-db" to confirm.'
required: true
default: 'do-not-run'
jobs:
restore-database:
runs-on: projectium.com # This job runs on your self-hosted Gitea runner.
env:
# Use production database credentials for this entire job.
DB_HOST: ${{ secrets.DB_HOST }}
DB_PORT: ${{ secrets.DB_PORT }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_DATABASE: ${{ secrets.DB_DATABASE_PROD }}
BACKUP_DIR: "/var/www/backups" # Define a dedicated directory for backups
steps:
- name: Validate Secrets and Inputs
run: |
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_DATABASE" ]; then
echo "ERROR: One or more production database secrets are not set in Gitea repository settings."
exit 1
fi
if [ "${{ gitea.event.inputs.confirmation }}" != "restore-production-db" ]; then
echo "ERROR: Confirmation phrase did not match. Aborting database restore."
exit 1
fi
if [ -z "${{ gitea.event.inputs.backup_filename }}" ]; then
echo "ERROR: Backup filename cannot be empty."
exit 1
fi
echo "✅ Confirmation accepted. Proceeding with database restore."
- name: 🚨 FINAL WARNING & PAUSE 🚨
run: |
echo "*********************************************************************"
echo "WARNING: YOU ARE ABOUT TO WIPE AND RESTORE THE PRODUCTION DATABASE."
echo "This action is IRREVERSIBLE. Press Ctrl+C in the runner terminal NOW to cancel."
echo "Restoring from file: ${{ gitea.event.inputs.backup_filename }}"
echo "Sleeping for 10 seconds..."
echo "*********************************************************************"
sleep 10
- name: Step 1 - Stop Application Server
run: |
echo "Stopping all PM2 processes to release database connections..."
pm2 stop all || echo "PM2 processes were not running."
echo "✅ Application server stopped."
- name: Step 2 - Drop and Recreate Database
run: |
echo "Dropping and recreating the production database..."
# Connect as the superuser (postgres) to drop the database.
# First, terminate all active connections to the database.
sudo -u postgres psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${DB_DATABASE}';"
# Now, drop and recreate it.
sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"${DB_DATABASE}\";"
sudo -u postgres psql -c "CREATE DATABASE \"${DB_DATABASE}\" WITH OWNER = ${DB_USER};"
echo "✅ Database dropped and recreated successfully."
- name: Step 3 - Restore Database from Backup
run: |
BACKUP_FILE_PATH="${BACKUP_DIR}/${{ gitea.event.inputs.backup_filename }}"
echo "Restoring database from: $BACKUP_FILE_PATH"
if [ ! -f "$BACKUP_FILE_PATH" ]; then
echo "ERROR: Backup file not found at $BACKUP_FILE_PATH"
exit 1
fi
# Uncompress the gzipped file and pipe the SQL commands directly into psql.
# This is efficient as it doesn't require an intermediate uncompressed file.
gunzip < "$BACKUP_FILE_PATH" | PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_DATABASE"
echo "✅ Database restore completed successfully."
- name: Step 4 - Restart Application Server
run: |
echo "Restarting application server..."
cd /var/www/flyer-crawler.projectium.com
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
echo "✅ Application server restarted."

View File

@@ -67,6 +67,7 @@ DROP TABLE IF EXISTS public.tags CASCADE;
DROP TABLE IF EXISTS public.appliances CASCADE; DROP TABLE IF EXISTS public.appliances CASCADE;
DROP TABLE IF EXISTS public.dietary_restrictions CASCADE; DROP TABLE IF EXISTS public.dietary_restrictions CASCADE;
DROP TABLE IF EXISTS public.categories CASCADE; DROP TABLE IF EXISTS public.categories CASCADE;
DROP TABLE IF EXISTS public.addresses CASCADE;
DROP TABLE IF EXISTS public.achievements CASCADE; DROP TABLE IF EXISTS public.achievements CASCADE;
DROP TABLE IF EXISTS public.budgets CASCADE; DROP TABLE IF EXISTS public.budgets CASCADE;
DROP TABLE IF EXISTS public.profiles CASCADE; DROP TABLE IF EXISTS public.profiles CASCADE;

View File

@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS public.users (
refresh_token TEXT, refresh_token TEXT,
failed_login_attempts INTEGER DEFAULT 0, failed_login_attempts INTEGER DEFAULT 0,
last_failed_login TIMESTAMPTZ, last_failed_login TIMESTAMPTZ,
last_login_ip TEXT,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
); );
@@ -18,6 +19,7 @@ COMMENT ON TABLE public.users IS 'Stores user authentication information.';
COMMENT ON COLUMN public.users.refresh_token IS 'Stores the long-lived refresh token for re-authentication.'; COMMENT ON COLUMN public.users.refresh_token IS 'Stores the long-lived refresh token for re-authentication.';
COMMENT ON COLUMN public.users.failed_login_attempts IS 'Tracks the number of consecutive failed login attempts.'; COMMENT ON COLUMN public.users.failed_login_attempts IS 'Tracks the number of consecutive failed login attempts.';
COMMENT ON COLUMN public.users.last_failed_login IS 'Timestamp of the last failed login attempt.'; COMMENT ON COLUMN public.users.last_failed_login IS 'Timestamp of the last failed login attempt.';
COMMENT ON COLUMN public.users.last_login_ip IS 'The IP address from which the user last successfully logged in.';
-- Add an index on the refresh_token for faster lookups when refreshing tokens. -- Add an index on the refresh_token for faster lookups when refreshing tokens.
CREATE INDEX IF NOT EXISTS idx_users_refresh_token ON public.users(refresh_token); CREATE INDEX IF NOT EXISTS idx_users_refresh_token ON public.users(refresh_token);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON public.users (email); CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON public.users (email);
@@ -40,16 +42,12 @@ CREATE INDEX IF NOT EXISTS idx_activity_log_user_id ON public.activity_log(user_
-- 3. for public user profiles. -- 3. for public user profiles.
-- This table is linked to the users table and stores non-sensitive user data. -- This table is linked to the users table and stores non-sensitive user data.
-- This table now references the new `addresses` table for the user's home address.
CREATE TABLE IF NOT EXISTS public.profiles ( CREATE TABLE IF NOT EXISTS public.profiles (
user_id UUID PRIMARY KEY REFERENCES public.users(user_id) ON DELETE CASCADE, user_id UUID PRIMARY KEY REFERENCES public.users(user_id) ON DELETE CASCADE,
full_name TEXT, full_name TEXT,
avatar_url TEXT, avatar_url TEXT,
address_line_1 TEXT, address_id BIGINT REFERENCES public.addresses(address_id) ON DELETE SET NULL,
address_line_2 TEXT,
city VARCHAR(255),
province_state VARCHAR(255),
postal_code VARCHAR(10),
country VARCHAR(2),
preferences JSONB, preferences JSONB,
role TEXT CHECK (role IN ('admin', 'user')), role TEXT CHECK (role IN ('admin', 'user')),
points INTEGER DEFAULT 0 NOT NULL, points INTEGER DEFAULT 0 NOT NULL,
@@ -59,12 +57,7 @@ CREATE TABLE IF NOT EXISTS public.profiles (
updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
); );
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.'; COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.';
COMMENT ON COLUMN public.profiles.address_line_1 IS 'Optional. The first line of the user''s street address.'; COMMENT ON COLUMN public.profiles.address_id IS 'A foreign key to the user''s primary address in the `addresses` table.';
COMMENT ON COLUMN public.profiles.address_line_2 IS 'Optional. The second line of the user''s street address (e.g., apartment, suite).';
COMMENT ON COLUMN public.profiles.city IS 'Optional. The user''s city for regional content filtering.';
COMMENT ON COLUMN public.profiles.province_state IS 'Optional. The user''s province or state.';
COMMENT ON COLUMN public.profiles.postal_code IS 'Optional. The user''s postal or ZIP code.';
COMMENT ON COLUMN public.profiles.country IS 'Optional. The user''s two-letter ISO 3166-1 alpha-2 country code (e.g., CA, US).';
COMMENT ON COLUMN public.profiles.points IS 'A simple integer column to store a user''s total accumulated points from achievements.'; COMMENT ON COLUMN public.profiles.points IS 'A simple integer column to store a user''s total accumulated points from achievements.';
-- 4. The 'stores' table for normalized store data. -- 4. The 'stores' table for normalized store data.
@@ -217,21 +210,38 @@ CREATE INDEX IF NOT EXISTS idx_notifications_user_id_created_at ON public.notifi
-- 12. Store individual store locations with geographic data. -- 12. Store individual store locations with geographic data.
CREATE TABLE IF NOT EXISTS public.store_locations ( CREATE TABLE IF NOT EXISTS public.store_locations (
store_location_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, store_location_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE, store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
address TEXT NOT NULL, address_id BIGINT NOT NULL REFERENCES public.addresses(address_id) ON DELETE CASCADE,
city TEXT,
province_state TEXT,
postal_code TEXT,
location GEOGRAPHY(Point, 4326),
created_at TIMESTAMPTZ DEFAULT now() NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
); );
COMMENT ON TABLE public.store_locations IS 'Stores physical locations of stores with geographic data for proximity searches.'; COMMENT ON TABLE public.store_locations IS 'Stores physical locations of stores with geographic data for proximity searches.';
COMMENT ON COLUMN public.store_locations.location IS 'Geographic coordinates (longitude, latitude) of the store.';
CREATE INDEX IF NOT EXISTS idx_store_locations_store_id ON public.store_locations(store_id); CREATE INDEX IF NOT EXISTS idx_store_locations_store_id ON public.store_locations(store_id);
-- Add a GIST index for efficient geographic queries. -- Add a GIST index for efficient geographic queries.
-- This requires the postgis extension. -- This requires the postgis extension.
CREATE INDEX IF NOT EXISTS store_locations_geo_idx ON public.store_locations USING GIST (location); -- CREATE INDEX IF NOT EXISTS store_locations_geo_idx ON public.store_locations USING GIST (location);
-- NEW TABLE: A centralized table for storing all physical addresses.
CREATE TABLE IF NOT EXISTS public.addresses (
address_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
address_line_1 TEXT NOT NULL,
address_line_2 TEXT,
city TEXT NOT NULL,
province_state TEXT NOT NULL,
postal_code TEXT NOT NULL,
country TEXT NOT NULL,
latitude NUMERIC(9, 6),
longitude NUMERIC(9, 6),
location GEOGRAPHY(Point, 4326),
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.addresses IS 'A centralized table for storing all physical addresses for users and stores.';
COMMENT ON COLUMN public.addresses.latitude IS 'The geographic latitude.';
COMMENT ON COLUMN public.addresses.longitude IS 'The geographic longitude.';
COMMENT ON COLUMN public.addresses.location IS 'A PostGIS geography type for efficient spatial queries.';
CREATE INDEX IF NOT EXISTS addresses_location_idx ON public.addresses USING GIST (location);
-- 13. For aggregated, historical price data for master items. -- 13. For aggregated, historical price data for master items.
CREATE TABLE IF NOT EXISTS public.item_price_history ( CREATE TABLE IF NOT EXISTS public.item_price_history (

View File

@@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS public.users (
refresh_token TEXT, refresh_token TEXT,
failed_login_attempts INTEGER DEFAULT 0, failed_login_attempts INTEGER DEFAULT 0,
last_failed_login TIMESTAMPTZ, last_failed_login TIMESTAMPTZ,
last_login_ip TEXT,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
); );
@@ -34,6 +35,7 @@ COMMENT ON TABLE public.users IS 'Stores user authentication information.';
COMMENT ON COLUMN public.users.refresh_token IS 'Stores the long-lived refresh token for re-authentication.'; COMMENT ON COLUMN public.users.refresh_token IS 'Stores the long-lived refresh token for re-authentication.';
COMMENT ON COLUMN public.users.failed_login_attempts IS 'Tracks the number of consecutive failed login attempts.'; COMMENT ON COLUMN public.users.failed_login_attempts IS 'Tracks the number of consecutive failed login attempts.';
COMMENT ON COLUMN public.users.last_failed_login IS 'Timestamp of the last failed login attempt.'; COMMENT ON COLUMN public.users.last_failed_login IS 'Timestamp of the last failed login attempt.';
COMMENT ON COLUMN public.users.last_login_ip IS 'The IP address from which the user last successfully logged in.';
-- Add an index on the refresh_token for faster lookups when refreshing tokens. -- Add an index on the refresh_token for faster lookups when refreshing tokens.
CREATE INDEX IF NOT EXISTS idx_users_refresh_token ON public.users(refresh_token); CREATE INDEX IF NOT EXISTS idx_users_refresh_token ON public.users(refresh_token);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON public.users (email); CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON public.users (email);
@@ -56,16 +58,12 @@ CREATE INDEX IF NOT EXISTS idx_activity_log_user_id ON public.activity_log(user_
-- 3. for public user profiles. -- 3. for public user profiles.
-- This table is linked to the users table and stores non-sensitive user data. -- This table is linked to the users table and stores non-sensitive user data.
-- This table now references the new `addresses` table for the user's home address.
CREATE TABLE IF NOT EXISTS public.profiles ( CREATE TABLE IF NOT EXISTS public.profiles (
user_id UUID PRIMARY KEY REFERENCES public.users(user_id) ON DELETE CASCADE, user_id UUID PRIMARY KEY REFERENCES public.users(user_id) ON DELETE CASCADE,
full_name TEXT, full_name TEXT,
avatar_url TEXT, avatar_url TEXT,
address_line_1 TEXT, address_id BIGINT REFERENCES public.addresses(address_id) ON DELETE SET NULL,
address_line_2 TEXT,
city VARCHAR(255),
province_state VARCHAR(255),
postal_code VARCHAR(10),
country VARCHAR(2),
points INTEGER DEFAULT 0 NOT NULL, points INTEGER DEFAULT 0 NOT NULL,
preferences JSONB, preferences JSONB,
role TEXT CHECK (role IN ('admin', 'user')), role TEXT CHECK (role IN ('admin', 'user')),
@@ -75,12 +73,7 @@ CREATE TABLE IF NOT EXISTS public.profiles (
updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL updated_by UUID REFERENCES public.users(user_id) ON DELETE SET NULL
); );
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.'; COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the public.users table.';
COMMENT ON COLUMN public.profiles.address_line_1 IS 'Optional. The first line of the user''s street address.'; COMMENT ON COLUMN public.profiles.address_id IS 'A foreign key to the user''s primary address in the `addresses` table.';
COMMENT ON COLUMN public.profiles.address_line_2 IS 'Optional. The second line of the user''s street address (e.g., apartment, suite).';
COMMENT ON COLUMN public.profiles.city IS 'Optional. The user''s city for regional content filtering.';
COMMENT ON COLUMN public.profiles.province_state IS 'Optional. The user''s province or state.';
COMMENT ON COLUMN public.profiles.postal_code IS 'Optional. The user''s postal or ZIP code.';
COMMENT ON COLUMN public.profiles.country IS 'Optional. The user''s two-letter ISO 3166-1 alpha-2 country code (e.g., CA, US).';
COMMENT ON COLUMN public.profiles.points IS 'A simple integer column to store a user''s total accumulated points from achievements.'; COMMENT ON COLUMN public.profiles.points IS 'A simple integer column to store a user''s total accumulated points from achievements.';
-- 4. The 'stores' table for normalized store data. -- 4. The 'stores' table for normalized store data.
@@ -234,21 +227,37 @@ CREATE INDEX IF NOT EXISTS idx_notifications_user_id_created_at ON public.notifi
-- 12. Store individual store locations with geographic data. -- 12. Store individual store locations with geographic data.
CREATE TABLE IF NOT EXISTS public.store_locations ( CREATE TABLE IF NOT EXISTS public.store_locations (
store_location_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, store_location_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE, store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
address TEXT NOT NULL, address_id BIGINT NOT NULL REFERENCES public.addresses(address_id) ON DELETE CASCADE,
city TEXT,
province_state TEXT,
postal_code TEXT,
location GEOGRAPHY(Point, 4326),
created_at TIMESTAMPTZ DEFAULT now() NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
); );
COMMENT ON TABLE public.store_locations IS 'Stores physical locations of stores with geographic data for proximity searches.'; COMMENT ON TABLE public.store_locations IS 'Stores physical locations of stores with geographic data for proximity searches.';
COMMENT ON COLUMN public.store_locations.location IS 'Geographic coordinates (longitude, latitude) of the store.';
CREATE INDEX IF NOT EXISTS idx_store_locations_store_id ON public.store_locations(store_id); CREATE INDEX IF NOT EXISTS idx_store_locations_store_id ON public.store_locations(store_id);
-- Add a GIST index for efficient geographic queries. -- Add a GIST index for efficient geographic queries.
-- This requires the postgis extension. -- This requires the postgis extension.
CREATE INDEX IF NOT EXISTS store_locations_geo_idx ON public.store_locations USING GIST (location); -- CREATE INDEX IF NOT EXISTS store_locations_geo_idx ON public.store_locations USING GIST (location);
-- NEW TABLE: A centralized table for storing all physical addresses.
CREATE TABLE IF NOT EXISTS public.addresses (
address_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
address_line_1 TEXT NOT NULL,
address_line_2 TEXT,
city TEXT NOT NULL,
province_state TEXT NOT NULL,
postal_code TEXT NOT NULL,
country TEXT NOT NULL,
latitude NUMERIC(9, 6),
longitude NUMERIC(9, 6),
location GEOGRAPHY(Point, 4326),
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.addresses IS 'A centralized table for storing all physical addresses for users and stores.';
COMMENT ON COLUMN public.addresses.latitude IS 'The geographic latitude.';
COMMENT ON COLUMN public.addresses.longitude IS 'The geographic longitude.';
COMMENT ON COLUMN public.addresses.location IS 'A PostGIS geography type for efficient spatial queries.';
CREATE INDEX IF NOT EXISTS addresses_location_idx ON public.addresses USING GIST (location);
-- 13. For aggregated, historical price data for master items. -- 13. For aggregated, historical price data for master items.
CREATE TABLE IF NOT EXISTS public.item_price_history ( CREATE TABLE IF NOT EXISTS public.item_price_history (

View File

@@ -0,0 +1,30 @@
// src/components/MapView.tsx
import React from 'react';
interface MapViewProps {
latitude: number;
longitude: number;
}
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_EMBED_API_KEY;
export const MapView: React.FC<MapViewProps> = ({ latitude, longitude }) => {
if (!apiKey) {
return <div className="text-sm text-red-500">Map view is disabled: API key is not configured.</div>;
}
const mapSrc = `https://www.google.com/maps/embed/v1/view?key=${apiKey}&center=${latitude},${longitude}&zoom=14`;
return (
<div className="w-full h-64 rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
<iframe
width="100%"
height="100%"
style={{ border: 0 }}
loading="lazy"
allowFullScreen
src={mapSrc}
></iframe>
</div>
);
};

View File

@@ -85,13 +85,13 @@ export const FlyerList: React.FC<FlyerListProps> = ({ flyers, onFlyerSelect, sel
<DocumentTextIcon className="w-6 h-6 text-brand-primary shrink-0" /> <DocumentTextIcon className="w-6 h-6 text-brand-primary shrink-0" />
)} )}
<div className="grow min-w-0"> <div className="grow min-w-0">
<div className="flex items-center space-x-1.5"> <div className="flex items-center space-x-2">
<p className="text-sm font-semibold text-gray-900 dark:text-white truncate" title={flyer.store?.name || 'Unknown Store'}> <p className="text-sm font-semibold text-gray-900 dark:text-white truncate">
{flyer.store?.name || 'Unknown Store'} {flyer.store?.name || 'Unknown Store'}
</p> </p>
{flyer.store_address && ( {flyer.store_address && (
<a href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(flyer.store_address)}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} title={`View address: ${flyer.store_address}`}> <a href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(flyer.store_address)}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} title={`View address: ${flyer.store_address}`}>
<MapPinIcon className="w-3.5 h-3.5 text-gray-400 hover:text-brand-primary transition-colors" /> <MapPinIcon className="w-5 h-5 text-gray-400 hover:text-brand-primary transition-colors" />
</a> </a>
)} )}
</div> </div>

21
src/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,21 @@
// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';
/**
* A custom hook that debounces a value.
* It will only update the returned value after the specified delay has passed
* without the input value changing.
* @param value The value to debounce.
* @param delay The debounce delay in milliseconds.
* @returns The debounced value.
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,71 @@
// src/pages/admin/components/AddressForm.tsx
import React from 'react';
import { Address } from '../../../types';
import { MapPinIcon } from 'lucide-react';
import { LoadingSpinner } from '../../../components/LoadingSpinner';
interface AddressFormProps {
address: Partial<Address>;
onAddressChange: (field: keyof Address, value: string) => void;
isGeocoding: boolean;
}
export const AddressForm: React.FC<AddressFormProps> = ({ address, onAddressChange, isGeocoding }) => {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
onAddressChange(name as keyof Address, value);
};
return (
<div className="space-y-4">
<div className="flex items-center space-x-3">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Home Address</h3>
{isGeocoding && (
<div className="w-4 h-4 text-brand-primary"><LoadingSpinner /></div>
)}
</div>
<div>
<label htmlFor="address_line_1" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Address Line 1</label>
<input
type="text"
name="address_line_1"
id="address_line_1"
value={address.address_line_1 || ''}
onChange={handleInputChange}
className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm"
/>
</div>
<div>
<label htmlFor="address_line_2" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Address Line 2</label>
<input
type="text"
name="address_line_2"
id="address_line_2"
value={address.address_line_2 || ''}
onChange={handleInputChange}
className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm"
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700 dark:text-gray-300">City</label>
<input type="text" name="city" id="city" value={address.city || ''} onChange={handleInputChange} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" />
</div>
<div>
<label htmlFor="province_state" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Province / State</label>
<input type="text" name="province_state" id="province_state" value={address.province_state || ''} onChange={handleInputChange} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" />
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="postal_code" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Postal / ZIP Code</label>
<input type="text" name="postal_code" id="postal_code" value={address.postal_code || ''} onChange={handleInputChange} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" />
</div>
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
<input type="text" name="country" id="country" value={address.country || ''} onChange={handleInputChange} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" />
</div>
</div>
</div>
);
};

View File

@@ -1,6 +1,7 @@
// src/pages/admin/components/ProfileManager.tsx // src/pages/admin/components/ProfileManager.tsx
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import type { Profile } from '../../../types'; import toast from 'react-hot-toast';
import type { Profile, Address, User } from '../../../types';
import { useApi } from '../../../hooks/useApi'; import { useApi } from '../../../hooks/useApi';
import * as apiClient from '../../../services/apiClient'; import * as apiClient from '../../../services/apiClient';
import { notifySuccess, notifyError } from '../../../services/notificationService'; import { notifySuccess, notifyError } from '../../../services/notificationService';
@@ -10,8 +11,10 @@ import { XMarkIcon } from '../../../components/icons/XMarkIcon';
import { GoogleIcon } from '../../../components/icons/GoogleIcon'; import { GoogleIcon } from '../../../components/icons/GoogleIcon';
import { GithubIcon } from '../../../components/icons/GithubIcon'; import { GithubIcon } from '../../../components/icons/GithubIcon';
import { ConfirmationModal } from '../../../components/ConfirmationModal'; import { ConfirmationModal } from '../../../components/ConfirmationModal';
import { User } from '../../../types';
import { PasswordInput } from './PasswordInput'; import { PasswordInput } from './PasswordInput';
import { AddressForm } from './AddressForm';
import { MapView } from '../../../components/MapView';
import { useDebounce } from '../../../hooks/useDebounce';
type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED'; type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED';
interface ProfileManagerProps { interface ProfileManagerProps {
@@ -37,6 +40,8 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
const [fullName, setFullName] = useState(profile?.full_name || ''); const [fullName, setFullName] = useState(profile?.full_name || '');
const [avatarUrl, setAvatarUrl] = useState(profile?.avatar_url || ''); const [avatarUrl, setAvatarUrl] = useState(profile?.avatar_url || '');
const { execute: updateProfile, loading: profileLoading } = useApi<Profile, [Partial<Profile>]>(apiClient.updateUserProfile); const { execute: updateProfile, loading: profileLoading } = useApi<Profile, [Partial<Profile>]>(apiClient.updateUserProfile);
const [isGeocoding, setIsGeocoding] = useState(false);
const [address, setAddress] = useState<Partial<Address>>({});
// Password state // Password state
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -73,6 +78,16 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
if (isOpen && profile) { // Ensure profile exists before setting state if (isOpen && profile) { // Ensure profile exists before setting state
setFullName(profile?.full_name || ''); setFullName(profile?.full_name || '');
setAvatarUrl(profile?.avatar_url || ''); setAvatarUrl(profile?.avatar_url || '');
// If the user has an address, fetch its details
if (profile.address_id) {
apiClient.getUserAddress(profile.address_id)
.then((res: Response) => res.json())
.then((data: Address) => setAddress(data))
.catch((err: Error) => toast.error(`Could not load address details: ${err.message}`));
} else {
// Reset address form if user has no address
setAddress({});
}
setActiveTab('profile'); setActiveTab('profile');
setIsConfirmingDelete(false); setIsConfirmingDelete(false);
setPasswordForDelete(''); setPasswordForDelete('');
@@ -83,6 +98,8 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
setIsRegistering(false); setIsRegistering(false);
setIsForgotPassword(false); setIsForgotPassword(false);
setRememberMe(false); // Reset on open setRememberMe(false); // Reset on open
} else {
setAddress({});
} }
}, [isOpen, profile]); // Depend on isOpen and profile }, [isOpen, profile]); // Depend on isOpen and profile
@@ -94,15 +111,23 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
} }
try { try {
const updatedProfile = await updateProfile({ // This now calls the hook's execute function // Update profile and address in parallel
const profileUpdatePromise = updateProfile({
full_name: fullName, full_name: fullName,
avatar_url: avatarUrl, avatar_url: avatarUrl,
}); });
const addressUpdatePromise = apiClient.updateUserAddress(address);
if (updatedProfile) { const [profileResponse] = await Promise.all([
onProfileUpdate(updatedProfile); profileUpdatePromise,
notifySuccess('Profile updated successfully!'); addressUpdatePromise
]);
if (profileResponse) {
onProfileUpdate(profileResponse);
} }
notifySuccess('Profile and address updated successfully!');
onClose();
} catch (error) { } catch (error) {
// Although the useApi hook is designed to handle errors, we log here // Although the useApi hook is designed to handle errors, we log here
// as a safeguard to catch any unexpected issues during profile save. // as a safeguard to catch any unexpected issues during profile save.
@@ -110,6 +135,46 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
} }
}; };
const handleAddressChange = (field: keyof Address, value: string) => {
setAddress(prev => ({ ...prev, [field]: value }));
};
// --- Automatic Geocoding Logic ---
const debouncedAddress = useDebounce(address, 1500); // Debounce address state by 1.5 seconds
useEffect(() => {
// This effect runs when the debouncedAddress value changes.
const handleGeocode = async () => {
// Only trigger if the core address fields are present and have changed.
const addressString = [
debouncedAddress.address_line_1,
debouncedAddress.city,
debouncedAddress.province_state,
debouncedAddress.postal_code,
debouncedAddress.country,
].filter(Boolean).join(', ');
// Don't geocode an empty address or if we already have coordinates for this exact address.
if (!addressString || (debouncedAddress.latitude && debouncedAddress.longitude)) {
return;
}
setIsGeocoding(true);
try {
const response = await apiClient.geocodeAddress(addressString);
const { lat, lng } = await response.json();
setAddress(prev => ({ ...prev, latitude: lat, longitude: lng }));
toast.success('Address geocoded successfully!');
} catch (error) {
toast.error('Failed to geocode address.');
} finally {
setIsGeocoding(false);
}
};
handleGeocode();
}, [debouncedAddress]); // Dependency array ensures this runs only when the debounced value changes.
const handleOAuthLink = async (provider: 'google' | 'github') => { const handleOAuthLink = async (provider: 'google' | 'github') => {
// This will redirect the user to the OAuth provider to link the account. // 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. // TODO: This is a placeholder. Implement OAuth account linking via the Passport.js backend.
@@ -376,6 +441,14 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
<label htmlFor="avatarUrl" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Avatar URL</label> <label htmlFor="avatarUrl" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Avatar URL</label>
<input id="avatarUrl" type="url" value={avatarUrl} onChange={e => setAvatarUrl(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" /> <input id="avatarUrl" type="url" value={avatarUrl} onChange={e => setAvatarUrl(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" />
</div> </div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<AddressForm address={address} onAddressChange={handleAddressChange} isGeocoding={isGeocoding} />
</div>
{address.latitude && address.longitude && (
<div className="pt-4">
<MapView latitude={address.latitude} longitude={address.longitude} />
</div>
)}
<div className="pt-2"> <div className="pt-2">
<button type="submit" disabled={profileLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center"> <button type="submit" disabled={profileLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
{profileLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Save Profile'} {profileLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Save Profile'}

View File

@@ -85,14 +85,14 @@ router.post('/upload-and-process', optionalAuth, uploadToDisk.single('flyerFile'
const user = req.user as UserProfile | undefined; const user = req.user as UserProfile | undefined;
// Construct a user address string from their profile if they are logged in. // Construct a user address string from their profile if they are logged in.
let userProfileAddress: string | undefined = undefined; let userProfileAddress: string | undefined = undefined;
if (user?.address_line_1) { if (user?.address) {
userProfileAddress = [ userProfileAddress = [
user.address_line_1, user.address.address_line_1,
user.address_line_2, user.address.address_line_2,
user.city, user.address.city,
user.province_state, user.address.province_state,
user.postal_code, user.address.postal_code,
user.country user.address.country
].filter(Boolean).join(', '); ].filter(Boolean).join(', ');
} }

View File

@@ -77,7 +77,7 @@ passport.use(new LocalStrategy(
// 3. Success! Return the user object (without password_hash for security). // 3. Success! Return the user object (without password_hash for security).
// Reset failed login attempts upon successful login. // Reset failed login attempts upon successful login.
await db.resetFailedLoginAttempts(user.user_id); await db.resetFailedLoginAttempts(user.user_id, req.ip ?? 'unknown');
// The password_hash is intentionally removed for security before returning the user object. // The password_hash is intentionally removed for security before returning the user object.
const userWithoutHash = omit(user, ['password_hash']); const userWithoutHash = omit(user, ['password_hash']);

View File

@@ -2,6 +2,7 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server';
import { geocodeAddress } from '../services/geocodingService.server';
const router = Router(); const router = Router();
@@ -37,4 +38,24 @@ router.get('/pm2-status', (req: Request, res: Response) => {
}); });
}); });
/**
* POST /api/system/geocode - Geocodes a given address string.
* This acts as a secure proxy to the Google Maps Geocoding API.
*/
router.post('/geocode', async (req: Request, res: Response) => {
const { address } = req.body;
if (!address || typeof address !== 'string') {
return res.status(400).json({ message: 'An address string is required.' });
}
const coordinates = await geocodeAddress(address);
if (!coordinates) {
return res.status(404).json({ message: 'Could not geocode the provided address.' });
}
res.json(coordinates);
});
export default router; export default router;

View File

@@ -8,7 +8,7 @@ import * as bcrypt from 'bcrypt';
import zxcvbn from 'zxcvbn'; import zxcvbn from 'zxcvbn';
import * as db from '../services/db'; import * as db from '../services/db';
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server';
import { User, UserProfile } from '../types'; import { User, UserProfile, Address } from '../types';
const router = express.Router(); const router = express.Router();
@@ -426,4 +426,47 @@ router.put('/me/appliances', async (req: Request, res: Response, next: NextFunct
} }
}); });
/**
* GET /api/users/addresses/:addressId - Get a specific address by its ID.
* This is protected to ensure a user can only fetch their own address details.
*/
router.get('/addresses/:addressId', async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as UserProfile;
const addressId = parseInt(req.params.addressId, 10);
// Security check: Ensure the requested addressId matches the one on the user's profile.
if (user.address_id !== addressId) {
return res.status(403).json({ message: 'Forbidden: You can only access your own address.' });
}
try {
const address = await db.getAddressById(addressId);
if (!address) {
return res.status(404).json({ message: 'Address not found.' });
}
res.json(address);
} catch (error) {
next(error);
}
});
/**
* PUT /api/users/profile/address - Create or update the user's primary address.
*/
router.put('/profile/address', async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as UserProfile;
const addressData = req.body as Partial<Address>;
try {
const addressId = await db.upsertAddress({ ...addressData, address_id: user.address_id ?? undefined });
// If the user didn't have an address_id before, update their profile to link it.
if (!user.address_id) {
await db.updateUserProfile(user.user_id, { address_id: addressId });
}
res.status(200).json({ message: 'Address updated successfully', address_id: addressId });
} catch (error) {
next(error);
}
});
export default router; export default router;

View File

@@ -1,5 +1,5 @@
// src/services/apiClient.ts // src/services/apiClient.ts
import { Profile, ShoppingListItem, FlyerItem, SearchQuery, Budget } from '../types'; import { Profile, ShoppingListItem, FlyerItem, SearchQuery, Budget, Address } from '../types';
import { logger } from './logger'; import { logger } from './logger';
// This constant should point to your backend API. // This constant should point to your backend API.
@@ -790,6 +790,40 @@ export const setUserAppliances = async (applianceIds: number[], tokenOverride?:
}, tokenOverride); }, tokenOverride);
}; };
/**
* Sends an address string to the backend to be geocoded.
* @param address The full address string.
* @param tokenOverride Optional token for testing.
*/
export const geocodeAddress = async (address: string, tokenOverride?: string): Promise<Response> => {
return apiFetch(`/system/geocode`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address }),
}, tokenOverride);
};
/**
* Fetches a specific address by its ID.
* @param addressId The ID of the address to fetch.
* @param tokenOverride Optional token for testing.
*/
export const getUserAddress = async (addressId: number, tokenOverride?: string): Promise<Response> => {
return apiFetch(`/users/addresses/${addressId}`, {}, tokenOverride);
};
/**
* Creates or updates the authenticated user's primary address.
* @param addressData The full address object.
* @param tokenOverride Optional token for testing.
*/
export const updateUserAddress = async (addressData: Partial<Address>, tokenOverride?: string): Promise<Response> => {
return apiFetch(`/users/profile/address`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressData),
}, tokenOverride);
};
/** /**
* Sends a new password to the backend to be updated. * Sends a new password to the backend to be updated.

View File

@@ -0,0 +1,51 @@
// src/services/db/address.ts
import { getPool } from './connection';
import { logger } from '../logger.server';
import { Address } from '../../types';
/**
* Retrieves a single address by its ID.
* @param addressId The ID of the address to retrieve.
* @returns A promise that resolves to the Address object or undefined.
*/
export async function getAddressById(addressId: number): Promise<Address | undefined> {
try {
const res = await getPool().query<Address>('SELECT * FROM public.addresses WHERE address_id = $1', [addressId]);
return res.rows[0];
} catch (error) {
logger.error('Database error in getAddressById:', { error, addressId });
throw new Error('Failed to retrieve address.');
}
}
/**
* Creates or updates an address and returns its ID.
* This function uses an "upsert" pattern.
* @param address The address data.
* @returns The ID of the created or updated address.
*/
export async function upsertAddress(address: Partial<Address>): Promise<number> {
const { address_id, address_line_1, address_line_2, city, province_state, postal_code, country, latitude, longitude } = address;
const locationPoint = latitude && longitude ? `ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326)` : null;
// If an ID is provided, it's an update. Otherwise, it's an insert.
if (address_id) {
const query = `
UPDATE public.addresses
SET address_line_1 = $1, address_line_2 = $2, city = $3, province_state = $4, postal_code = $5, country = $6,
latitude = $7, longitude = $8, location = ${locationPoint}, updated_at = now()
WHERE address_id = $9
RETURNING address_id;
`;
const res = await getPool().query(query, [address_line_1, address_line_2, city, province_state, postal_code, country, latitude, longitude, address_id]);
return res.rows[0].address_id;
} else {
const query = `
INSERT INTO public.addresses (address_line_1, address_line_2, city, province_state, postal_code, country, latitude, longitude, location)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, ${locationPoint})
RETURNING address_id;
`;
const res = await getPool().query(query, [address_line_1, address_line_2, city, province_state, postal_code, country, latitude, longitude]);
return res.rows[0].address_id;
}
}

View File

@@ -9,7 +9,6 @@ import {
getDailyStatsForLast30Days, getDailyStatsForLast30Days,
logActivity, logActivity,
incrementFailedLoginAttempts, incrementFailedLoginAttempts,
resetFailedLoginAttempts,
updateBrandLogo, updateBrandLogo,
getMostFrequentSaleItems, getMostFrequentSaleItems,
updateRecipeCommentStatus, updateRecipeCommentStatus,
@@ -195,17 +194,6 @@ describe('Admin DB Service', () => {
}); });
}); });
describe('resetFailedLoginAttempts', () => {
it('should execute an UPDATE query to reset failed attempts', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await resetFailedLoginAttempts('user-123');
expect(getPool().query).toHaveBeenCalledWith(
expect.stringMatching(/UPDATE\s+public\.users\s+SET\s+failed_login_attempts\s*=\s*0/),
['user-123']
);
});
});
describe('updateBrandLogo', () => { describe('updateBrandLogo', () => {
it('should execute an UPDATE query for the brand logo', async () => { it('should execute an UPDATE query for the brand logo', async () => {
mockQuery.mockResolvedValue({ rows: [] }); mockQuery.mockResolvedValue({ rows: [] });

View File

@@ -374,21 +374,6 @@ export async function incrementFailedLoginAttempts(userId: string): Promise<void
} }
} }
/**
* Resets the failed login attempt counter for a user upon successful login.
* @param userId The ID of the user.
*/
export async function resetFailedLoginAttempts(userId: string): Promise<void> {
try {
await getPool().query(
`UPDATE public.users SET failed_login_attempts = 0, last_failed_login = NULL WHERE user_id = $1`,
[userId]
);
} catch (error) {
logger.error('Database error in resetFailedLoginAttempts:', { error, userId });
}
}
/** /**
* Updates the logo URL for a specific brand. * Updates the logo URL for a specific brand.
* @param brandId The ID of the brand to update. * @param brandId The ID of the brand to update.

View File

@@ -1,8 +1,9 @@
// src/services/db/flyer.ts // src/services/db/flyer.ts
import { getPool } from './connection'; import { getPool } from './connection';
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors'; import { UniqueConstraintError, ForeignKeyConstraintError } from './errors';
import { logger } from '../logger'; import { logger } from '../logger.server';
import { Flyer, Brand, MasterGroceryItem, FlyerItem } from '../../types'; import { geocodeAddress } from '../geocodingService.server';
import { Flyer, Brand, MasterGroceryItem, FlyerItem, Address } from '../../types';
/** /**
* Retrieves all flyers from the database, joining with store information. * Retrieves all flyers from the database, joining with store information.
@@ -17,6 +18,7 @@ export async function getFlyers(): Promise<Flyer[]> {
f.created_at, f.created_at,
f.file_name, f.file_name,
f.image_url, f.image_url,
f.icon_url,
f.checksum, f.checksum,
f.store_id, f.store_id,
f.valid_from, f.valid_from,
@@ -151,19 +153,11 @@ export async function createFlyerAndItems(
const client = await getPool().connect(); const client = await getPool().connect();
const safeItems = items ?? []; const safeItems = items ?? [];
logger.debug('[DB createFlyerAndItems] Starting transaction to create flyer.', { flyerData: { name: flyerData.file_name, store_name: flyerData.store_name }, itemCount: safeItems.length }); logger.debug('[DB createFlyerAndItems] Starting transaction to create flyer.', { flyerData: { name: flyerData.file_name, store_name: flyerData.store_name }, itemCount: safeItems.length });
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
logger.debug('[DB createFlyerAndItems] BEGIN transaction successful.'); logger.debug('[DB createFlyerAndItems] BEGIN transaction successful.');
// This function is now inside the transaction block.
// It was previously outside, which could lead to race conditions.
const findOrCreateStore = async (storeName: string): Promise<number> => {
const storeRes = await client.query<{ store_id: number }>('SELECT store_id FROM public.stores WHERE name = $1', [storeName]);
if (storeRes.rows.length > 0) return storeRes.rows[0].store_id;
const newStoreRes = await client.query<{ store_id: number }>('INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id', [storeName]);
return newStoreRes.rows[0].store_id;
};
// Find or create the store to get its ID. This logic is now self-contained. // Find or create the store to get its ID. This logic is now self-contained.
let storeId: number; let storeId: number;
const storeRes = await client.query<{ store_id: number }>('SELECT store_id FROM public.stores WHERE name = $1', [flyerData.store_name]); const storeRes = await client.query<{ store_id: number }>('SELECT store_id FROM public.stores WHERE name = $1', [flyerData.store_name]);
@@ -174,6 +168,38 @@ export async function createFlyerAndItems(
storeId = newStoreRes.rows[0].store_id; storeId = newStoreRes.rows[0].store_id;
} }
// --- New Address Handling Logic ---
let storeLocationId: number | null = null;
if (flyerData.store_address) {
// 1. Geocode the address string from the AI.
const coords = await geocodeAddress(flyerData.store_address);
// 2. Upsert the address into the `addresses` table.
// For simplicity, we'll assume the AI gives a full address string.
// A more complex implementation would parse this into structured fields.
const addressData: Partial<Address> = {
address_line_1: flyerData.store_address,
city: '', province_state: '', postal_code: '', country: '', // These would be parsed in a real scenario
latitude: coords?.lat,
longitude: coords?.lng,
};
const addressRes = await client.query<{ address_id: number }>(
`INSERT INTO public.addresses (address_line_1, city, province_state, postal_code, country, latitude, longitude, location)
VALUES ($1, $2, $3, $4, $5, $6, $7, ${coords ? `ST_SetSRID(ST_MakePoint(${coords.lng}, ${coords.lat}), 4326)` : null})
ON CONFLICT (address_line_1) DO UPDATE SET address_line_1 = EXCLUDED.address_line_1 RETURNING address_id`,
[addressData.address_line_1, addressData.city, addressData.province_state, addressData.postal_code, addressData.country, addressData.latitude, addressData.longitude]
);
const addressId = addressRes.rows[0].address_id;
// 3. Upsert the `store_locations` link.
const storeLocationRes = await client.query<{ store_location_id: number }>(
`INSERT INTO public.store_locations (store_id, address_id) VALUES ($1, $2)
ON CONFLICT (store_id, address_id) DO UPDATE SET store_id = EXCLUDED.store_id RETURNING store_location_id`,
[storeId, addressId]
);
storeLocationId = storeLocationRes.rows[0].store_location_id;
}
// Create the flyer record // Create the flyer record
const flyerQuery = ` const flyerQuery = `
INSERT INTO public.flyers (file_name, image_url, checksum, store_id, valid_from, valid_to, store_address, uploaded_by) INSERT INTO public.flyers (file_name, image_url, checksum, store_id, valid_from, valid_to, store_address, uploaded_by)
@@ -184,6 +210,12 @@ export async function createFlyerAndItems(
const newFlyerRes = await client.query<Flyer>(flyerQuery, flyerValues); const newFlyerRes = await client.query<Flyer>(flyerQuery, flyerValues);
const newFlyer = newFlyerRes.rows[0]; const newFlyer = newFlyerRes.rows[0];
// 4. If a store location was created, link it to the new flyer.
if (storeLocationId) {
await client.query('INSERT INTO public.flyer_locations (flyer_id, store_location_id) VALUES ($1, $2)', [newFlyer.flyer_id, storeLocationId]);
}
// --- End New Address Handling Logic ---
// Prepare and insert all flyer items // Prepare and insert all flyer items
if (safeItems.length > 0) { if (safeItems.length > 0) {
const itemInsertQuery = ` const itemInsertQuery = `

View File

@@ -1,11 +1,13 @@
// src/services/db/index.ts // src/services/db/index.ts
export * from './connection'; export * from './connection';
export * from './errors';
export * from './user'; export * from './user';
export * from './flyer'; export * from './flyer';
export * from './recipe';
export * from './personalization';
export * from './shopping'; export * from './shopping';
export * from './personalization';
export * from './recipe';
export * from './admin'; export * from './admin';
export * from './budget';
export * from './gamification';
export * from './notification'; export * from './notification';
export * from './gamification';
export * from './budget';
export * from './address'; // Add the new address service exports

View File

@@ -167,8 +167,25 @@ export async function findUserWithPasswordHashById(userId: string): Promise<{ us
export async function findUserProfileById(userId: string): Promise<Profile | undefined> { export async function findUserProfileById(userId: string): Promise<Profile | undefined> {
try { try {
// This query assumes your 'profiles' table has a foreign key 'id' referencing 'users.user_id' // This query assumes your 'profiles' table has a foreign key 'id' referencing 'users.user_id'
// It now joins with the addresses table to fetch the user's address as a nested object.
const res = await getPool().query<Profile>( const res = await getPool().query<Profile>(
'SELECT user_id, full_name, avatar_url, address_line_1, address_line_2, city, province_state, postal_code, country, preferences, role FROM public.profiles WHERE user_id = $1', `SELECT
p.user_id, p.full_name, p.avatar_url, p.address_id, p.preferences, p.role, p.points,
CASE
WHEN a.address_id IS NOT NULL THEN json_build_object(
'address_id', a.address_id,
'address_line_1', a.address_line_1,
'address_line_2', a.address_line_2,
'city', a.city,
'province_state', a.province_state,
'postal_code', a.postal_code,
'country', a.country
)
ELSE NULL
END as address
FROM public.profiles p
LEFT JOIN public.addresses a ON p.address_id = a.address_id
WHERE p.user_id = $1`,
[userId] [userId]
); );
return res.rows[0]; return res.rows[0];
@@ -185,28 +202,32 @@ export async function findUserProfileById(userId: string): Promise<Profile | und
* @returns A promise that resolves to the updated profile object. * @returns A promise that resolves to the updated profile object.
*/ */
// prettier-ignore // prettier-ignore
export async function updateUserProfile(userId: string, profileData: Partial<Pick<Profile, 'full_name' | 'avatar_url' | 'address_line_1' | 'address_line_2' | 'city' | 'province_state' | 'postal_code' | 'country'>>): Promise<Profile> { export async function updateUserProfile(userId: string, profileData: Partial<Pick<Profile, 'full_name' | 'avatar_url' | 'address_id'>>): Promise<Profile> {
try { try {
const { full_name, avatar_url, address_id } = profileData;
const fieldsToUpdate = [];
const values = [];
let paramIndex = 1;
if (full_name !== undefined) { fieldsToUpdate.push(`full_name = $${paramIndex++}`); values.push(full_name); }
if (avatar_url !== undefined) { fieldsToUpdate.push(`avatar_url = $${paramIndex++}`); values.push(avatar_url); }
if (address_id !== undefined) { fieldsToUpdate.push(`address_id = $${paramIndex++}`); values.push(address_id); }
if (fieldsToUpdate.length === 0) {
// If no fields are being updated, just fetch and return the current profile.
return findUserProfileById(userId) as Promise<Profile>;
}
values.push(userId);
const query = `
UPDATE public.profiles
SET ${fieldsToUpdate.join(', ')}, updated_at = now()
WHERE user_id = $${paramIndex}
RETURNING *;
`;
const res = await getPool().query<Profile>( const res = await getPool().query<Profile>(
`UPDATE public.profiles query, values
SET full_name = COALESCE($1, full_name),
avatar_url = COALESCE($2, avatar_url),
address_line_1 = COALESCE($3, address_line_1),
address_line_2 = COALESCE($4, address_line_2),
city = COALESCE($5, city),
province_state = COALESCE($6, province_state),
postal_code = COALESCE($7, postal_code),
country = COALESCE($8, country),
updated_by = $9
WHERE user_id = $9 -- Use the same parameter for updated_by and the WHERE clause
RETURNING user_id, full_name, avatar_url, address_line_1, address_line_2, city, province_state, postal_code, country, preferences, role`,
[
profileData.full_name, profileData.avatar_url,
profileData.address_line_1, profileData.address_line_2,
profileData.city, profileData.province_state,
profileData.postal_code, profileData.country,
userId
]
); );
return res.rows[0]; return res.rows[0];
} catch (error) { } catch (error) {
@@ -474,3 +495,19 @@ export async function logSearchQuery(query: { userId?: string, queryText: string
// Also a non-critical operation. // Also a non-critical operation.
} }
} }
/**
* Resets the failed login attempt counter for a user upon successful login.
* @param userId The ID of the user.
*/
// prettier-ignore
export async function resetFailedLoginAttempts(userId: string, loginIp: string): Promise<void> {
try {
await getPool().query(
`UPDATE public.users SET failed_login_attempts = 0, last_failed_login = NULL, last_login_ip = $2 WHERE user_id = $1`,
[userId, loginIp]
);
} catch (error) {
logger.error('Database error in resetFailedLoginAttempts:', { error, userId });
}
}

View File

@@ -0,0 +1,39 @@
// src/services/geocodingService.server.ts
import { logger } from './logger.server';
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
/**
* Geocodes a physical address into latitude and longitude coordinates using the Google Maps API.
* @param address The address string to geocode.
* @returns A promise that resolves to an object with latitude and longitude, or null if not found.
*/
export async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
if (!apiKey) {
logger.warn('[GeocodingService] GOOGLE_MAPS_API_KEY is not set. Geocoding is disabled.');
return null;
}
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`;
try {
const response = await fetch(url);
const data = await response.json();
if (data.status !== 'OK' || !data.results || data.results.length === 0) {
logger.warn('[GeocodingService] Geocoding failed or returned no results.', { status: data.status, address });
return null;
}
const location = data.results[0].geometry.location;
logger.info(`[GeocodingService] Successfully geocoded address: "${address}"`, { location });
return {
lat: location.lat,
lng: location.lng,
};
} catch (error) {
logger.error('[GeocodingService] An error occurred while calling the Google Maps Geocoding API.', { error });
// We return null instead of throwing to prevent a single geocoding failure from blocking a user profile save.
return null;
}
}

View File

@@ -99,12 +99,7 @@ export interface Profile {
updated_at?: string; updated_at?: string;
full_name?: string | null; full_name?: string | null;
avatar_url?: string | null; avatar_url?: string | null;
address_line_1?: string | null; address_id?: number | null;
address_line_2?: string | null;
city?: string | null;
province_state?: string | null;
postal_code?: string | null;
country?: string | null;
points: number; points: number;
role: 'admin' | 'user'; role: 'admin' | 'user';
preferences?: { preferences?: {
@@ -113,11 +108,16 @@ export interface Profile {
} | null; } | null;
} }
/** /**
* Represents the combined user and profile data object returned by the backend's /users/profile endpoint. * Represents the combined user and profile data object returned by the backend's /users/profile endpoint.
* It embeds the User object within the Profile object. * It embeds the User object within the Profile object.
* It also includes the full Address object if one is associated with the profile.
*/ */
export type UserProfile = Profile & { user: User }; export type UserProfile = Profile & {
user: User;
address?: Address | null;
};
export interface SuggestedCorrection { export interface SuggestedCorrection {
suggested_correction_id: number; suggested_correction_id: number;
@@ -574,12 +574,21 @@ export interface GeoJSONPoint {
export interface StoreLocation { export interface StoreLocation {
store_location_id: number; store_location_id: number;
store_id: number; store_id?: number | null;
address: string; address_id: number;
city?: string | null; }
province_state?: string | null;
postal_code?: string | null; export interface Address {
location?: GeoJSONPoint | null; // Represents PostGIS GEOGRAPHY(Point, 4326) address_id: number;
address_line_1: string;
address_line_2?: string | null;
city: string;
province_state: string;
postal_code: string;
country: string;
latitude?: number | null;
longitude?: number | null;
location?: GeoJSONPoint | null;
} }
export interface FlyerLocation { export interface FlyerLocation {