Compare commits

..

10 Commits

Author SHA1 Message Date
Gitea Actions
2379f3a878 ci: Bump version to 0.11.19 [skip ci] 2026-01-20 23:40:50 +05:00
0232b9de7a Enhance logging and error handling in PostgreSQL functions; update API endpoints in E2E tests; add Logstash troubleshooting documentation
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m25s
- Added tiered logging and error handling in various PostgreSQL functions to improve observability and error tracking.
- Updated E2E tests to reflect changes in API endpoints for fetching best watched prices.
- Introduced a comprehensive troubleshooting runbook for Logstash to assist in diagnosing common issues in the PostgreSQL observability pipeline.
2026-01-20 10:39:33 -08:00
Gitea Actions
2e98bc3fc7 ci: Bump version to 0.11.18 [skip ci] 2026-01-20 14:18:32 +05:00
ec2f143218 logging postgres + test fixin
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m18s
2026-01-20 01:16:27 -08:00
Gitea Actions
f3e233bf38 ci: Bump version to 0.11.17 [skip ci] 2026-01-20 10:30:14 +05:00
1696aeb54f minor fixin
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m42s
2026-01-19 21:28:44 -08:00
Gitea Actions
e45804776d ci: Bump version to 0.11.16 [skip ci] 2026-01-20 08:14:50 +05:00
5879328b67 fixing categories 3rd normal form
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m34s
2026-01-19 19:13:30 -08:00
Gitea Actions
4618d11849 ci: Bump version to 0.11.15 [skip ci] 2026-01-20 02:49:48 +05:00
4022768c03 set up local e2e tests, and some e2e test fixes + docs on more db fixin - ugh
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m39s
2026-01-19 13:45:21 -08:00
38 changed files with 4577 additions and 431 deletions

View File

@@ -1 +1 @@
FORCE_COLOR=0 npx lint-staged
FORCE_COLOR=0 npx lint-staged --quiet

View File

@@ -517,3 +517,90 @@ ssh root@projectium.com "tail -50 /var/www/flyer-crawler.projectium.com/logs/app
- Checking service status
**Important:** SSH access requires the host machine to have SSH keys configured for `root@projectium.com`.
---
## Logstash Configuration (ADR-050)
The production server uses **Logstash** to aggregate logs from multiple sources and forward errors to Bugsink for centralized error tracking.
**Log Sources:**
- **PostgreSQL function logs** - Structured JSON logs from `fn_log()` helper function
- **PM2 worker logs** - Service logs from BullMQ job workers (stdout)
- **Redis logs** - Operational logs (INFO level) and errors
- **NGINX logs** - Access logs (all requests) and error logs
### Configuration Location
**Primary configuration file:**
- `/etc/logstash/conf.d/bugsink.conf` - Complete Logstash pipeline configuration
**Related files:**
- `/etc/postgresql/14/main/conf.d/observability.conf` - PostgreSQL logging configuration
- `/var/log/postgresql/*.log` - PostgreSQL log files
- `/home/gitea-runner/.pm2/logs/*.log` - PM2 worker logs
- `/var/log/redis/redis-server.log` - Redis logs
- `/var/log/nginx/access.log` - NGINX access logs
- `/var/log/nginx/error.log` - NGINX error logs
- `/var/log/logstash/*.log` - Logstash file outputs (operational logs)
- `/var/lib/logstash/sincedb_*` - Logstash position tracking files
### Key Features
1. **Multi-source aggregation**: Collects logs from PostgreSQL, PM2 workers, Redis, and NGINX
2. **Environment-based routing**: Automatically detects production vs test environments and routes errors to the correct Bugsink project
3. **Structured JSON parsing**: Extracts `fn_log()` function output from PostgreSQL logs and Pino JSON from PM2 workers
4. **Sentry-compatible format**: Transforms events to Sentry format with `event_id`, `timestamp`, `level`, `message`, and `extra` context
5. **Error filtering**: Only forwards WARNING and ERROR level messages to Bugsink
6. **Operational log storage**: Stores non-error logs (Redis INFO, NGINX access, PM2 operational) to `/var/log/logstash/` for analysis
7. **Request monitoring**: Categorizes NGINX requests by status code (2xx, 3xx, 4xx, 5xx) and identifies slow requests
### Common Maintenance Commands
```bash
# Check Logstash status
systemctl status logstash
# Restart Logstash after configuration changes
systemctl restart logstash
# Test configuration syntax
/usr/share/logstash/bin/logstash --config.test_and_exit -f /etc/logstash/conf.d/bugsink.conf
# View Logstash logs
journalctl -u logstash -f
# Check Logstash stats (events processed, failures)
curl -XGET 'localhost:9600/_node/stats/pipelines?pretty' | jq '.pipelines.main.plugins.filters'
# Monitor PostgreSQL logs being processed
tail -f /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log
# View operational log outputs
tail -f /var/log/logstash/pm2-workers-$(date +%Y-%m-%d).log
tail -f /var/log/logstash/redis-operational-$(date +%Y-%m-%d).log
tail -f /var/log/logstash/nginx-access-$(date +%Y-%m-%d).log
# Check disk usage of log files
du -sh /var/log/logstash/
```
### Troubleshooting
| Issue | Check | Solution |
| ------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------- |
| Errors not appearing in Bugsink | Check Logstash is running | `systemctl status logstash` |
| Configuration syntax errors | Test config file | `/usr/share/logstash/bin/logstash --config.test_and_exit -f /etc/logstash/conf.d/bugsink.conf` |
| Grok pattern failures | Check Logstash stats | `curl localhost:9600/_node/stats/pipelines?pretty \| jq '.pipelines.main.plugins.filters'` |
| Wrong Bugsink project | Verify environment detection | Check tags in logs match expected environment (production/test) |
| Permission denied reading logs | Check Logstash permissions | `groups logstash` should include `postgres`, `adm` groups |
| PM2 logs not captured | Check file paths exist | `ls /home/gitea-runner/.pm2/logs/flyer-crawler-worker-*.log` |
| NGINX access logs not showing | Check file output directory | `ls -lh /var/log/logstash/nginx-access-*.log` |
| High disk usage | Check log rotation | Verify `/etc/logrotate.d/logstash` is configured and running daily |
**Full setup guide**: See [docs/BARE-METAL-SETUP.md](docs/BARE-METAL-SETUP.md) section "PostgreSQL Function Observability (ADR-050)"
**Architecture details**: See [docs/adr/0050-postgresql-function-observability.md](docs/adr/0050-postgresql-function-observability.md)

View File

@@ -225,14 +225,20 @@ filter {\n\
mutate { add_tag => ["error"] }\n\
}\n\
\n\
# Redis error detection\n\
# Redis log parsing\n\
if [type] == "redis" {\n\
grok {\n\
match => { "message" => "%%{POSINT:pid}:%%{WORD:role} %%{MONTHDAY} %%{MONTH} %%{TIME} %%{WORD:loglevel} %%{GREEDYDATA:redis_message}" }\n\
}\n\
\n\
# Tag errors (WARNING/ERROR) for Bugsink forwarding\n\
if [loglevel] in ["WARNING", "ERROR"] {\n\
mutate { add_tag => ["error"] }\n\
}\n\
# Tag INFO-level operational events (startup, config, persistence)\n\
else if [loglevel] == "INFO" {\n\
mutate { add_tag => ["redis_operational"] }\n\
}\n\
}\n\
\n\
# PostgreSQL function log parsing (ADR-050)\n\
@@ -265,6 +271,7 @@ filter {\n\
}\n\
\n\
output {\n\
# Forward errors to Bugsink\n\
if "error" in [tags] {\n\
http {\n\
url => "http://localhost:8000/api/store/"\n\
@@ -272,14 +279,23 @@ output {\n\
format => "json"\n\
}\n\
}\n\
\n\
# Store Redis operational logs (INFO level) to file\n\
if "redis_operational" in [tags] {\n\
file {\n\
path => "/var/log/logstash/redis-operational-%%{+YYYY-MM-dd}.log"\n\
codec => json_lines\n\
}\n\
}\n\
\n\
# Debug output (comment out in production)\n\
stdout { codec => rubydebug }\n\
}\n\
' > /etc/logstash/conf.d/bugsink.conf
# Create Logstash sincedb directory
# Create Logstash directories
RUN mkdir -p /var/lib/logstash && chown -R logstash:logstash /var/lib/logstash
RUN mkdir -p /var/log/logstash && chown -R logstash:logstash /var/log/logstash
# ============================================================================
# Set Working Directory

View File

@@ -128,6 +128,25 @@ services:
- ./docker/postgres/postgresql.conf.override:/etc/postgresql/postgresql.conf.d/custom.conf:ro
# Create log volume for Logstash access (ADR-050)
- postgres_logs:/var/log/postgresql
# Override postgres command to include custom config (ADR-050)
command: >
postgres
-c config_file=/var/lib/postgresql/data/postgresql.conf
-c hba_file=/var/lib/postgresql/data/pg_hba.conf
-c log_min_messages=notice
-c client_min_messages=notice
-c logging_collector=on
-c log_destination=stderr
-c log_directory=/var/log/postgresql
-c log_filename=postgresql-%Y-%m-%d.log
-c log_rotation_age=1d
-c log_rotation_size=100MB
-c log_truncate_on_rotation=on
-c log_line_prefix='%t [%p] %u@%d '
-c log_min_duration_statement=1000
-c log_statement=none
-c log_connections=on
-c log_disconnections=on
# Healthcheck ensures postgres is ready before app starts
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d flyer_crawler_dev']

View File

@@ -24,6 +24,6 @@ log_min_duration_statement = 1000
# Log statement types (off for production, 'all' for debugging)
log_statement = 'none'
# Connection logging
# Connection logging (useful for dev, can be disabled in production)
log_connections = on
log_disconnections = on

View File

@@ -1244,6 +1244,620 @@ If you only need application error tracking, the Sentry SDK integration is suffi
---
## PostgreSQL Function Observability (ADR-050)
PostgreSQL function observability provides structured logging and error tracking for database functions, preventing silent failures. This setup forwards database errors to Bugsink for centralized monitoring.
See [ADR-050](adr/0050-postgresql-function-observability.md) for the full architecture decision.
### Prerequisites
- PostgreSQL 14+ installed and running
- Logstash installed and configured (see [Logstash section](#logstash-log-aggregation) above)
- Bugsink running at `https://bugsink.projectium.com`
### Step 1: Configure PostgreSQL Logging
Create the observability configuration file:
```bash
sudo nano /etc/postgresql/14/main/conf.d/observability.conf
```
Add the following content:
```ini
# PostgreSQL Logging Configuration for Database Function Observability (ADR-050)
# Enable logging to files for Logstash pickup
logging_collector = on
log_destination = 'stderr'
log_directory = '/var/log/postgresql'
log_filename = 'postgresql-%Y-%m-%d.log'
log_rotation_age = 1d
log_rotation_size = 100MB
log_truncate_on_rotation = on
# Log level - capture NOTICE and above (includes fn_log WARNING/ERROR)
log_min_messages = notice
client_min_messages = notice
# Include useful context in log prefix
log_line_prefix = '%t [%p] %u@%d '
# Capture slow queries from functions (1 second threshold)
log_min_duration_statement = 1000
# Log statement types (off for production)
log_statement = 'none'
# Connection logging (off for production to reduce noise)
log_connections = off
log_disconnections = off
```
Set up the log directory:
```bash
# Create log directory
sudo mkdir -p /var/log/postgresql
# Set ownership to postgres user
sudo chown postgres:postgres /var/log/postgresql
sudo chmod 750 /var/log/postgresql
```
Restart PostgreSQL:
```bash
sudo systemctl restart postgresql
```
Verify logging is working:
```bash
# Check that log files are being created
ls -la /var/log/postgresql/
# Should see files like: postgresql-2026-01-20.log
```
### Step 2: Configure Logstash for PostgreSQL Logs
The Logstash configuration is located at `/etc/logstash/conf.d/bugsink.conf`.
**Key features:**
- Parses PostgreSQL log format with grok patterns
- Extracts JSON from `fn_log()` function calls
- Tags WARNING/ERROR level logs
- Routes production database errors to Bugsink project 1
- Routes test database errors to Bugsink project 3
- Transforms events to Sentry-compatible format
**Configuration file:** `/etc/logstash/conf.d/bugsink.conf`
See the [Logstash Configuration Reference](#logstash-configuration-reference) below for the complete configuration.
**Grant Logstash access to PostgreSQL logs:**
```bash
# Add logstash user to postgres group
sudo usermod -aG postgres logstash
# Verify group membership
groups logstash
# Restart Logstash to apply changes
sudo systemctl restart logstash
```
### Step 3: Test the Pipeline
Test structured logging from PostgreSQL:
```bash
# Production database (routes to Bugsink project 1)
sudo -u postgres psql -d flyer-crawler-prod -c "SELECT fn_log('WARNING', 'test_observability', 'Testing PostgreSQL observability pipeline', '{\"environment\": \"production\"}'::jsonb);"
# Test database (routes to Bugsink project 3)
sudo -u postgres psql -d flyer-crawler-test -c "SELECT fn_log('WARNING', 'test_observability', 'Testing PostgreSQL observability pipeline', '{\"environment\": \"test\"}'::jsonb);"
```
Check Bugsink UI:
- Production errors: <https://bugsink.projectium.com> → Project 1 (flyer-crawler-backend)
- Test errors: <https://bugsink.projectium.com> → Project 3 (flyer-crawler-backend-test)
### Step 4: Verify Database Functions
The following critical functions use `fn_log()` for observability:
| Function | What it logs |
| -------------------------- | ---------------------------------------- |
| `award_achievement()` | Missing achievements, duplicate awards |
| `fork_recipe()` | Missing original recipes |
| `handle_new_user()` | User creation events |
| `approve_correction()` | Permission denied, corrections not found |
| `complete_shopping_list()` | Permission checks, list not found |
Test error logging with a database function:
```bash
# Try to award a non-existent achievement (should fail and log to Bugsink)
sudo -u postgres psql -d flyer-crawler-test -c "SELECT award_achievement('00000000-0000-0000-0000-000000000000'::uuid, 'NonexistentBadge');"
# Check Bugsink project 3 - should see an ERROR with full context
```
### Logstash Configuration Reference
Complete configuration for PostgreSQL observability (`/etc/logstash/conf.d/bugsink.conf`):
```conf
input {
# PostgreSQL function logs (ADR-050)
# Both production and test databases write to the same log files
file {
path => "/var/log/postgresql/*.log"
type => "postgres"
tags => ["postgres", "database"]
start_position => "beginning"
sincedb_path => "/var/lib/logstash/sincedb_postgres"
}
}
filter {
# PostgreSQL function log parsing (ADR-050)
if [type] == "postgres" {
# Extract timestamp, timezone, process ID, user, database, level, and message
grok {
match => { "message" => "%{TIMESTAMP_ISO8601:pg_timestamp} [+-]%{INT:pg_timezone} \[%{POSINT:pg_pid}\] %{DATA:pg_user}@%{DATA:pg_database} %{WORD:pg_level}: %{GREEDYDATA:pg_message}" }
}
# Try to parse pg_message as JSON (from fn_log())
if [pg_message] =~ /^\{/ {
json {
source => "pg_message"
target => "fn_log"
skip_on_invalid_json => true
}
# Mark as error if level is WARNING or ERROR
if [fn_log][level] in ["WARNING", "ERROR"] {
mutate { add_tag => ["error", "db_function"] }
}
}
# Also catch native PostgreSQL errors
if [pg_level] in ["ERROR", "FATAL"] {
mutate { add_tag => ["error", "postgres_native"] }
}
# Detect environment from database name
if [pg_database] == "flyer-crawler-prod" {
mutate {
add_tag => ["production"]
}
} else if [pg_database] == "flyer-crawler-test" {
mutate {
add_tag => ["test"]
}
}
# Generate event_id for Sentry
if "error" in [tags] {
uuid {
target => "[@metadata][event_id]"
overwrite => true
}
}
}
}
output {
# Production database errors -> project 1 (flyer-crawler-backend)
if "error" in [tags] and "production" in [tags] {
http {
url => "https://bugsink.projectium.com/api/1/store/"
http_method => "post"
format => "json"
headers => {
"X-Sentry-Auth" => "Sentry sentry_version=7, sentry_client=logstash/1.0, sentry_key=911aef02b9a548fa8fabb8a3c81abfe5"
"Content-Type" => "application/json"
}
mapping => {
"event_id" => "%{[@metadata][event_id]}"
"timestamp" => "%{@timestamp}"
"platform" => "other"
"level" => "error"
"logger" => "postgresql"
"message" => "%{[fn_log][message]}"
"environment" => "production"
"extra" => {
"pg_user" => "%{[pg_user]}"
"pg_database" => "%{[pg_database]}"
"pg_function" => "%{[fn_log][function]}"
"pg_level" => "%{[pg_level]}"
"context" => "%{[fn_log][context]}"
}
}
}
}
# Test database errors -> project 3 (flyer-crawler-backend-test)
if "error" in [tags] and "test" in [tags] {
http {
url => "https://bugsink.projectium.com/api/3/store/"
http_method => "post"
format => "json"
headers => {
"X-Sentry-Auth" => "Sentry sentry_version=7, sentry_client=logstash/1.0, sentry_key=cdb99c314589431e83d4cc38a809449b"
"Content-Type" => "application/json"
}
mapping => {
"event_id" => "%{[@metadata][event_id]}"
"timestamp" => "%{@timestamp}"
"platform" => "other"
"level" => "error"
"logger" => "postgresql"
"message" => "%{[fn_log][message]}"
"environment" => "test"
"extra" => {
"pg_user" => "%{[pg_user]}"
"pg_database" => "%{[pg_database]}"
"pg_function" => "%{[fn_log][function]}"
"pg_level" => "%{[pg_level]}"
"context" => "%{[fn_log][context]}"
}
}
}
}
}
```
### Extended Logstash Configuration (PM2, Redis, NGINX)
The complete production Logstash configuration includes additional log sources beyond PostgreSQL:
**Input Sources:**
```conf
input {
# PostgreSQL function logs (shown above)
# PM2 Worker stdout logs (production)
file {
path => "/home/gitea-runner/.pm2/logs/flyer-crawler-worker-*.log"
type => "pm2_stdout"
tags => ["infra", "pm2", "worker", "production"]
start_position => "end"
sincedb_path => "/var/lib/logstash/sincedb_pm2_worker_prod"
exclude => "*-test-*.log"
}
# PM2 Analytics Worker stdout (production)
file {
path => "/home/gitea-runner/.pm2/logs/flyer-crawler-analytics-worker-*.log"
type => "pm2_stdout"
tags => ["infra", "pm2", "analytics", "production"]
start_position => "end"
sincedb_path => "/var/lib/logstash/sincedb_pm2_analytics_prod"
exclude => "*-test-*.log"
}
# PM2 Worker stdout (test environment)
file {
path => "/home/gitea-runner/.pm2/logs/flyer-crawler-worker-test-*.log"
type => "pm2_stdout"
tags => ["infra", "pm2", "worker", "test"]
start_position => "end"
sincedb_path => "/var/lib/logstash/sincedb_pm2_worker_test"
}
# PM2 Analytics Worker stdout (test environment)
file {
path => "/home/gitea-runner/.pm2/logs/flyer-crawler-analytics-worker-test-*.log"
type => "pm2_stdout"
tags => ["infra", "pm2", "analytics", "test"]
start_position => "end"
sincedb_path => "/var/lib/logstash/sincedb_pm2_analytics_test"
}
# Redis logs (already configured)
file {
path => "/var/log/redis/redis-server.log"
type => "redis"
tags => ["infra", "redis"]
start_position => "end"
sincedb_path => "/var/lib/logstash/sincedb_redis"
}
# NGINX access logs
file {
path => "/var/log/nginx/access.log"
type => "nginx_access"
tags => ["infra", "nginx", "access"]
start_position => "end"
sincedb_path => "/var/lib/logstash/sincedb_nginx_access"
}
# NGINX error logs
file {
path => "/var/log/nginx/error.log"
type => "nginx_error"
tags => ["infra", "nginx", "error"]
start_position => "end"
sincedb_path => "/var/lib/logstash/sincedb_nginx_error"
}
}
```
**Filter Rules:**
```conf
filter {
# PostgreSQL filters (shown above)
# PM2 Worker log parsing
if [type] == "pm2_stdout" {
# Try to parse as JSON first (if worker uses Pino)
json {
source => "message"
target => "pm2_json"
skip_on_invalid_json => true
}
# If JSON parsing succeeded, extract level and tag errors
if [pm2_json][level] {
if [pm2_json][level] >= 50 {
mutate { add_tag => ["error"] }
}
}
# If not JSON, check for error keywords in plain text
else if [message] =~ /(Error|ERROR|Exception|EXCEPTION|Fatal|FATAL|failed|FAILED)/ {
mutate { add_tag => ["error"] }
}
# Generate event_id for errors
if "error" in [tags] {
uuid {
target => "[@metadata][event_id]"
overwrite => true
}
}
}
# Redis log parsing
if [type] == "redis" {
grok {
match => { "message" => "%{POSINT:pid}:%{WORD:role} %{MONTHDAY} %{MONTH} %{TIME} %{WORD:loglevel} %{GREEDYDATA:redis_message}" }
}
# Tag errors (WARNING/ERROR) for Bugsink forwarding
if [loglevel] in ["WARNING", "ERROR"] {
mutate { add_tag => ["error"] }
uuid {
target => "[@metadata][event_id]"
overwrite => true
}
}
# Tag INFO-level operational events (startup, config, persistence)
else if [loglevel] == "INFO" {
mutate { add_tag => ["redis_operational"] }
}
}
# NGINX access log parsing
if [type] == "nginx_access" {
grok {
match => { "message" => "%{COMBINEDAPACHELOG}" }
}
# Parse response time if available (requires NGINX log format with request_time)
if [message] =~ /request_time:(\d+\.\d+)/ {
grok {
match => { "message" => "request_time:(?<request_time_seconds>\d+\.\d+)" }
}
}
# Categorize by status code
if [response] =~ /^5\d{2}$/ {
mutate { add_tag => ["error", "http_5xx"] }
uuid {
target => "[@metadata][event_id]"
overwrite => true
}
}
else if [response] =~ /^4\d{2}$/ {
mutate { add_tag => ["client_error", "http_4xx"] }
}
else if [response] =~ /^2\d{2}$/ {
mutate { add_tag => ["success", "http_2xx"] }
}
else if [response] =~ /^3\d{2}$/ {
mutate { add_tag => ["redirect", "http_3xx"] }
}
# Tag slow requests (>1 second response time)
if [request_time_seconds] and [request_time_seconds] > 1.0 {
mutate { add_tag => ["slow_request"] }
}
# Always tag for monitoring
mutate { add_tag => ["access_log"] }
}
# NGINX error log parsing
if [type] == "nginx_error" {
mutate { add_tag => ["error"] }
uuid {
target => "[@metadata][event_id]"
overwrite => true
}
}
}
```
**Output Rules:**
```conf
output {
# Production errors -> Bugsink infrastructure project (5)
# Includes: PM2 worker errors, Redis errors, NGINX 5xx, PostgreSQL errors
if "error" in [tags] and "infra" in [tags] and "production" in [tags] {
http {
url => "https://bugsink.projectium.com/api/5/store/"
http_method => "post"
format => "json"
headers => {
"X-Sentry-Auth" => "Sentry sentry_version=7, sentry_client=logstash/1.0, sentry_key=b083076f94fb461b889d5dffcbef43bf"
"Content-Type" => "application/json"
}
mapping => {
"event_id" => "%{[@metadata][event_id]}"
"timestamp" => "%{@timestamp}"
"platform" => "other"
"level" => "error"
"logger" => "%{type}"
"message" => "%{message}"
"environment" => "production"
}
}
}
# Test errors -> Bugsink test infrastructure project (6)
if "error" in [tags] and "infra" in [tags] and "test" in [tags] {
http {
url => "https://bugsink.projectium.com/api/6/store/"
http_method => "post"
format => "json"
headers => {
"X-Sentry-Auth" => "Sentry sentry_version=7, sentry_client=logstash/1.0, sentry_key=25020dd6c2b74ad78463ec90e90fadab"
"Content-Type" => "application/json"
}
mapping => {
"event_id" => "%{[@metadata][event_id]}"
"timestamp" => "%{@timestamp}"
"platform" => "other"
"level" => "error"
"logger" => "%{type}"
"message" => "%{message}"
"environment" => "test"
}
}
}
# PM2 worker operational logs (non-errors) -> file
if [type] == "pm2_stdout" and "error" not in [tags] {
file {
path => "/var/log/logstash/pm2-workers-%{+YYYY-MM-dd}.log"
codec => json_lines
}
}
# Redis INFO logs (operational events) -> file
if "redis_operational" in [tags] {
file {
path => "/var/log/logstash/redis-operational-%{+YYYY-MM-dd}.log"
codec => json_lines
}
}
# NGINX access logs (all requests) -> file
if "access_log" in [tags] {
file {
path => "/var/log/logstash/nginx-access-%{+YYYY-MM-dd}.log"
codec => json_lines
}
}
}
```
**Setup Instructions:**
1. Create log output directory:
```bash
sudo mkdir -p /var/log/logstash
sudo chown logstash:logstash /var/log/logstash
```
2. Configure logrotate for Logstash file outputs:
```bash
sudo tee /etc/logrotate.d/logstash <<EOF
/var/log/logstash/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0644 logstash logstash
}
EOF
```
3. Verify Logstash can read PM2 logs:
```bash
# Add logstash to required groups
sudo usermod -a -G postgres logstash
sudo usermod -a -G adm logstash
# Test permissions
sudo -u logstash cat /home/gitea-runner/.pm2/logs/flyer-crawler-worker-*.log | head -5
sudo -u logstash cat /var/log/redis/redis-server.log | head -5
sudo -u logstash cat /var/log/nginx/access.log | head -5
```
4. Restart Logstash:
```bash
sudo systemctl restart logstash
```
**Verification:**
```bash
# Check Logstash is processing new log sources
curl -s http://localhost:9600/_node/stats/pipelines?pretty | jq '.pipelines.main.events'
# Check file outputs
ls -lh /var/log/logstash/
tail -f /var/log/logstash/pm2-workers-$(date +%Y-%m-%d).log
tail -f /var/log/logstash/redis-operational-$(date +%Y-%m-%d).log
tail -f /var/log/logstash/nginx-access-$(date +%Y-%m-d).log
```
### Troubleshooting
| Issue | Solution |
| ------------------------------ | --------------------------------------------------------------------------------------------------- |
| No logs appearing in Bugsink | Check Logstash status: `sudo journalctl -u logstash -f` |
| Permission denied errors | Verify logstash is in postgres group: `groups logstash` |
| Grok parse failures | Check Logstash stats: `curl -s http://localhost:9600/_node/stats/pipelines?pretty \| grep failures` |
| Wrong Bugsink project | Verify database name detection in filter (flyer-crawler-prod vs flyer-crawler-test) |
| PostgreSQL logs not created | Check `logging_collector = on` and restart PostgreSQL |
| Events not formatted correctly | Check mapping in output section matches Sentry event schema |
| Test config before restarting | Run: `/usr/share/logstash/bin/logstash --config.test_and_exit -f /etc/logstash/conf.d/bugsink.conf` |
### Maintenance Commands
| Task | Command |
| ----------------------------- | ---------------------------------------------------------------------------------------------- |
| View Logstash status | `sudo systemctl status logstash` |
| View Logstash logs | `sudo journalctl -u logstash -f` |
| View PostgreSQL logs | `tail -f /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log` |
| Test Logstash config | `/usr/share/logstash/bin/logstash --config.test_and_exit -f /etc/logstash/conf.d/bugsink.conf` |
| Restart Logstash | `sudo systemctl restart logstash` |
| Check Logstash pipeline stats | `curl -s http://localhost:9600/_node/stats/pipelines?pretty` |
| Clear sincedb (re-read logs) | `sudo rm /var/lib/logstash/sincedb_postgres && sudo systemctl restart logstash` |
---
## SSL/TLS with Let's Encrypt
### Install Certbot

View File

@@ -0,0 +1,460 @@
# Logstash Troubleshooting Runbook
This runbook provides step-by-step diagnostics and solutions for common Logstash issues in the PostgreSQL observability pipeline (ADR-050).
## Quick Reference
| Symptom | Most Likely Cause | Quick Check |
| ------------------------ | ---------------------------- | ------------------------------------- |
| No errors in Bugsink | Logstash not running | `systemctl status logstash` |
| Events not processed | Grok pattern mismatch | Check filter failures in stats |
| Wrong Bugsink project | Environment detection failed | Verify `pg_database` field extraction |
| 403 authentication error | Missing/wrong DSN key | Check `X-Sentry-Auth` header |
| 500 error from Bugsink | Invalid event format | Verify `event_id` and required fields |
---
## Diagnostic Steps
### 1. Verify Logstash is Running
```bash
# Check service status
systemctl status logstash
# If stopped, start it
systemctl start logstash
# View recent logs
journalctl -u logstash -n 50 --no-pager
```
**Expected output:**
- Status: `active (running)`
- No error messages in recent logs
---
### 2. Check Configuration Syntax
```bash
# Test configuration file
/usr/share/logstash/bin/logstash --config.test_and_exit -f /etc/logstash/conf.d/bugsink.conf
```
**Expected output:**
```
Configuration OK
```
**If syntax errors:**
1. Review error message for line number
2. Check for missing braces, quotes, or commas
3. Verify plugin names are correct (e.g., `json`, `grok`, `uuid`, `http`)
---
### 3. Verify PostgreSQL Logs Are Being Read
```bash
# Check if log file exists and has content
ls -lh /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log
# Check Logstash can read the file
sudo -u logstash cat /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log | head -10
```
**Expected output:**
- Log file exists and is not empty
- Logstash user can read the file without permission errors
**If permission denied:**
```bash
# Check Logstash is in postgres group
groups logstash
# Should show: logstash : logstash adm postgres
# If not, add to group
usermod -a -G postgres logstash
systemctl restart logstash
```
---
### 4. Check Logstash Pipeline Stats
```bash
# Get pipeline statistics
curl -XGET 'localhost:9600/_node/stats/pipelines?pretty' | jq '.pipelines.main.plugins.filters'
```
**Key metrics to check:**
1. **Grok filter events:**
- `"events.in"` - Total events received
- `"events.out"` - Events successfully parsed
- `"failures"` - Events that failed to parse
**If failures > 0:** Grok pattern doesn't match log format. Check PostgreSQL log format.
2. **JSON filter events:**
- `"events.in"` - Events received by JSON parser
- `"events.out"` - Successfully parsed JSON
**If events.in = 0:** Regex check `pg_message =~ /^\{/` is not matching. Verify fn_log() output format.
3. **UUID filter events:**
- Should match number of errors being forwarded
---
### 5. Test Grok Pattern Manually
```bash
# Get a sample log line
tail -1 /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log
# Example expected format:
# 2026-01-20 10:30:00 +05 [12345] flyer_crawler_prod@flyer-crawler-prod WARNING: {"level":"WARNING","source":"postgresql",...}
```
**Pattern breakdown:**
```
%{TIMESTAMP_ISO8601:pg_timestamp} # 2026-01-20 10:30:00
[+-]%{INT:pg_timezone} # +05
\[%{POSINT:pg_pid}\] # [12345]
%{DATA:pg_user}@%{DATA:pg_database} # flyer_crawler_prod@flyer-crawler-prod
%{WORD:pg_level}: # WARNING:
%{GREEDYDATA:pg_message} # (rest of line)
```
**If pattern doesn't match:**
1. Check PostgreSQL `log_line_prefix` setting in `/etc/postgresql/14/main/conf.d/observability.conf`
2. Should be: `log_line_prefix = '%t [%p] %u@%d '`
3. Restart PostgreSQL if changed: `systemctl restart postgresql`
---
### 6. Verify Environment Detection
```bash
# Check recent PostgreSQL logs for database field
tail -20 /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log | grep -E "flyer-crawler-(prod|test)"
```
**Expected:**
- Production database: `flyer_crawler_prod@flyer-crawler-prod`
- Test database: `flyer_crawler_test@flyer-crawler-test`
**If database name doesn't match:**
- Check database connection string in application
- Verify `DB_DATABASE_PROD` and `DB_DATABASE_TEST` Gitea secrets
---
### 7. Test Bugsink API Connection
```bash
# Test production endpoint
curl -X POST https://bugsink.projectium.com/api/1/store/ \
-H "X-Sentry-Auth: Sentry sentry_version=7, sentry_client=test/1.0, sentry_key=911aef02b9a548fa8fabb8a3c81abfe5" \
-H "Content-Type: application/json" \
-d '{
"event_id": "12345678901234567890123456789012",
"timestamp": "2026-01-20T10:30:00Z",
"platform": "other",
"level": "error",
"logger": "test",
"message": "Test error from troubleshooting"
}'
```
**Expected response:**
- HTTP 200 OK
- Response body: `{"id": "..."}`
**If 403 Forbidden:**
- DSN key is wrong in `/etc/logstash/conf.d/bugsink.conf`
- Get correct key from Bugsink UI: Settings → Projects → DSN
**If 500 Internal Server Error:**
- Missing required fields (event_id, timestamp, level)
- Check `mapping` section in Logstash config
---
### 8. Monitor Logstash Output in Real-Time
```bash
# Watch Logstash processing logs
journalctl -u logstash -f
```
**What to look for:**
- `"response code => 200"` - Successful forwarding to Bugsink
- `"response code => 403"` - Authentication failure
- `"response code => 500"` - Invalid event format
- Grok parse failures
---
## Common Issues and Solutions
### Issue 1: Grok Pattern Parse Failures
**Symptoms:**
- Logstash stats show increasing `"failures"` count
- No events reaching Bugsink
**Diagnosis:**
```bash
curl -XGET 'localhost:9600/_node/stats/pipelines?pretty' | jq '.pipelines.main.plugins.filters[] | select(.name == "grok") | .failures'
```
**Solution:**
1. Check PostgreSQL log format matches expected pattern
2. Verify `log_line_prefix` in PostgreSQL config
3. Test with sample log line using Grok Debugger (Kibana Dev Tools)
---
### Issue 2: JSON Filter Not Parsing fn_log() Output
**Symptoms:**
- Grok parses successfully but JSON filter shows 0 events
- `[fn_log]` fields missing in Logstash output
**Diagnosis:**
```bash
# Check if pg_message field contains JSON
tail -20 /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log | grep "WARNING:" | grep "{"
```
**Solution:**
1. Verify `fn_log()` function exists in database:
```sql
\df fn_log
```
2. Test `fn_log()` output format:
```sql
SELECT fn_log('WARNING', 'test', 'Test message', '{"key":"value"}'::jsonb);
```
3. Check logs show JSON output starting with `{`
---
### Issue 3: Events Going to Wrong Bugsink Project
**Symptoms:**
- Production errors appear in test project (or vice versa)
**Diagnosis:**
```bash
# Check database name detection in recent logs
tail -50 /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log | grep -E "(flyer-crawler-prod|flyer-crawler-test)"
```
**Solution:**
1. Verify database names in filter section match actual database names
2. Check `pg_database` field is correctly extracted by grok pattern:
```bash
# Enable debug output in Logstash config temporarily
stdout { codec => rubydebug { metadata => true } }
```
3. Verify environment tagging in filter:
- `pg_database == "flyer-crawler-prod"` → adds "production" tag → routes to project 1
- `pg_database == "flyer-crawler-test"` → adds "test" tag → routes to project 3
---
### Issue 4: 403 Authentication Errors from Bugsink
**Symptoms:**
- Logstash logs show `response code => 403`
- Events not appearing in Bugsink
**Diagnosis:**
```bash
# Check Logstash output logs for authentication errors
journalctl -u logstash -n 100 | grep "403"
```
**Solution:**
1. Verify DSN key in `/etc/logstash/conf.d/bugsink.conf` matches Bugsink project
2. Get correct DSN from Bugsink UI:
- Navigate to Settings → Projects → Click project
- Copy "DSN" value
- Extract key: `http://KEY@host/PROJECT_ID` → use KEY
3. Update `X-Sentry-Auth` header in Logstash config:
```conf
"X-Sentry-Auth" => "Sentry sentry_version=7, sentry_client=logstash/1.0, sentry_key=YOUR_KEY_HERE"
```
4. Restart Logstash: `systemctl restart logstash`
---
### Issue 5: 500 Errors from Bugsink
**Symptoms:**
- Logstash logs show `response code => 500`
- Bugsink logs show validation errors
**Diagnosis:**
```bash
# Check Bugsink logs for details
docker logs bugsink-web 2>&1 | tail -50
```
**Common causes:**
1. Missing `event_id` field
2. Invalid timestamp format
3. Missing required Sentry fields
**Solution:**
1. Verify `uuid` filter is generating `event_id`:
```conf
uuid {
target => "[@metadata][event_id]"
overwrite => true
}
```
2. Check `mapping` section includes all required fields:
- `event_id` (UUID)
- `timestamp` (ISO 8601)
- `platform` (string)
- `level` (error/warning/info)
- `logger` (string)
- `message` (string)
---
### Issue 6: High Memory Usage by Logstash
**Symptoms:**
- Server running out of memory
- Logstash OOM killed
**Diagnosis:**
```bash
# Check Logstash memory usage
ps aux | grep logstash
systemctl status logstash
```
**Solution:**
1. Limit Logstash heap size in `/etc/logstash/jvm.options`:
```
-Xms1g
-Xmx1g
```
2. Restart Logstash: `systemctl restart logstash`
3. Monitor with: `top -p $(pgrep -f logstash)`
---
### Issue 7: Log File Rotation Issues
**Symptoms:**
- Logstash stops processing after log file rotates
- Sincedb file pointing to old inode
**Diagnosis:**
```bash
# Check sincedb file
cat /var/lib/logstash/sincedb_postgres
# Check current log file inode
ls -li /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log
```
**Solution:**
1. Logstash should automatically detect rotation
2. If stuck, delete sincedb file (will reprocess recent logs):
```bash
systemctl stop logstash
rm /var/lib/logstash/sincedb_postgres
systemctl start logstash
```
---
## Verification Checklist
After making any changes, verify the pipeline is working:
- [ ] Logstash is running: `systemctl status logstash`
- [ ] Configuration is valid: `/usr/share/logstash/bin/logstash --config.test_and_exit -f /etc/logstash/conf.d/bugsink.conf`
- [ ] No grok failures: `curl localhost:9600/_node/stats/pipelines?pretty | jq '.pipelines.main.plugins.filters[] | select(.name == "grok") | .failures'`
- [ ] Events being processed: `curl localhost:9600/_node/stats/pipelines?pretty | jq '.pipelines.main.events'`
- [ ] Test error appears in Bugsink: Trigger a database function error and check Bugsink UI
---
## Test Database Function Error
To generate a test error for verification:
```bash
# Connect to production database
sudo -u postgres psql -d flyer-crawler-prod
# Trigger an error (achievement not found)
SELECT award_achievement('00000000-0000-0000-0000-000000000001'::uuid, 'Nonexistent Badge');
\q
```
**Expected flow:**
1. PostgreSQL logs the error to `/var/log/postgresql/postgresql-YYYY-MM-DD.log`
2. Logstash reads and parses the log (within ~30 seconds)
3. Error appears in Bugsink project 1 (production)
**If error doesn't appear:**
- Check each diagnostic step above
- Review Logstash logs: `journalctl -u logstash -f`
---
## Related Documentation
- **Setup Guide**: [docs/BARE-METAL-SETUP.md](BARE-METAL-SETUP.md) - PostgreSQL Function Observability section
- **Architecture**: [docs/adr/0050-postgresql-function-observability.md](adr/0050-postgresql-function-observability.md)
- **Configuration Reference**: [CLAUDE.md](../CLAUDE.md) - Logstash Configuration section
- **Bugsink MCP Server**: [CLAUDE.md](../CLAUDE.md) - Sentry/Bugsink MCP Server Setup section

View File

@@ -0,0 +1,352 @@
# ADR-023: Database Normalization and Referential Integrity
**Date:** 2026-01-19
**Status:** Accepted
**Context:** API design violates database normalization principles
## Problem Statement
The application's API layer currently accepts string-based references (category names) instead of numerical IDs when creating relationships between entities. This violates database normalization principles and creates a brittle, error-prone API contract.
**Example of Current Problem:**
```typescript
// API accepts string:
POST /api/users/watched-items
{ "itemName": "Milk", "category": "Dairy & Eggs" } // ❌ String reference
// But database uses normalized foreign keys:
CREATE TABLE master_grocery_items (
category_id BIGINT REFERENCES categories(category_id) -- Proper FK
)
```
This mismatch forces the service layer to perform string lookups on every request:
```typescript
// Service must do string matching:
const categoryRes = await client.query(
'SELECT category_id FROM categories WHERE name = $1',
[categoryName], // ❌ Error-prone string matching
);
```
## Database Normal Forms (In Order of Importance)
### 1. First Normal Form (1NF) ✅ Currently Satisfied
**Rule:** Each column contains atomic values; no repeating groups.
**Status:****Compliant**
- All columns contain single values
- No arrays or delimited strings in columns
- Each row is uniquely identifiable
**Example:**
```sql
-- ✅ Good: Atomic values
CREATE TABLE master_grocery_items (
master_grocery_item_id BIGINT PRIMARY KEY,
name TEXT,
category_id BIGINT
);
-- ❌ Bad: Non-atomic values (violates 1NF)
CREATE TABLE items (
id BIGINT,
categories TEXT -- "Dairy,Frozen,Snacks" (comma-delimited)
);
```
### 2. Second Normal Form (2NF) ✅ Currently Satisfied
**Rule:** No partial dependencies; all non-key columns depend on the entire primary key.
**Status:****Compliant**
- All tables use single-column primary keys (no composite keys)
- All non-key columns depend on the entire primary key
**Example:**
```sql
-- ✅ Good: All columns depend on full primary key
CREATE TABLE flyer_items (
flyer_item_id BIGINT PRIMARY KEY,
flyer_id BIGINT, -- Depends on flyer_item_id
master_item_id BIGINT, -- Depends on flyer_item_id
price_in_cents INT -- Depends on flyer_item_id
);
-- ❌ Bad: Partial dependency (violates 2NF)
CREATE TABLE flyer_items (
flyer_id BIGINT,
item_id BIGINT,
store_name TEXT, -- Depends only on flyer_id, not (flyer_id, item_id)
PRIMARY KEY (flyer_id, item_id)
);
```
### 3. Third Normal Form (3NF) ⚠️ VIOLATED IN API LAYER
**Rule:** No transitive dependencies; non-key columns depend only on the primary key, not on other non-key columns.
**Status:** ⚠️ **Database is compliant, but API layer violates this principle**
**Database Schema (Correct):**
```sql
-- ✅ Categories are normalized
CREATE TABLE categories (
category_id BIGINT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE master_grocery_items (
master_grocery_item_id BIGINT PRIMARY KEY,
name TEXT,
category_id BIGINT REFERENCES categories(category_id) -- Direct reference
);
```
**API Layer (Violates 3NF Principle):**
```typescript
// ❌ API accepts category name instead of ID
POST /api/users/watched-items
{
"itemName": "Milk",
"category": "Dairy & Eggs" // String! Should be category_id
}
// Service layer must denormalize by doing lookup:
SELECT category_id FROM categories WHERE name = $1
```
This creates a **transitive dependency** in the application layer:
- `watched_item``category_name``category_id`
- Instead of direct: `watched_item``category_id`
### 4. Boyce-Codd Normal Form (BCNF) ✅ Currently Satisfied
**Rule:** Every determinant is a candidate key (stricter version of 3NF).
**Status:****Compliant**
- All foreign key references use primary keys
- No non-trivial functional dependencies where determinant is not a superkey
### 5. Fourth Normal Form (4NF) ✅ Currently Satisfied
**Rule:** No multi-valued dependencies; a record should not contain independent multi-valued facts.
**Status:****Compliant**
- Junction tables properly separate many-to-many relationships
- Examples: `user_watched_items`, `shopping_list_items`, `recipe_ingredients`
### 6. Fifth Normal Form (5NF) ✅ Currently Satisfied
**Rule:** No join dependencies; tables cannot be decomposed further without loss of information.
**Status:****Compliant** (as far as schema design goes)
## Impact of API Violation
### 1. Brittleness
```typescript
// Test fails because of exact string matching:
addWatchedItem('Milk', 'Dairy'); // ❌ Fails - not exact match
addWatchedItem('Milk', 'Dairy & Eggs'); // ✅ Works - exact match
addWatchedItem('Milk', 'dairy & eggs'); // ❌ Fails - case sensitive
```
### 2. No Discovery Mechanism
- No API endpoint to list available categories
- Frontend cannot dynamically populate dropdowns
- Clients must hardcode category names
### 3. Performance Penalty
```sql
-- Current: String lookup on every request
SELECT category_id FROM categories WHERE name = $1; -- Full table scan or index scan
-- Should be: Direct ID reference (no lookup needed)
INSERT INTO master_grocery_items (name, category_id) VALUES ($1, $2);
```
### 4. Impossible Localization
- Cannot translate category names without breaking API
- Category names are hardcoded in English
### 5. Maintenance Burden
- Renaming a category breaks all API clients
- Must coordinate name changes across frontend, tests, and documentation
## Decision
**We adopt the following principles for all API design:**
### 1. Use Numerical IDs for All Foreign Key References
**Rule:** APIs MUST accept numerical IDs when creating relationships between entities.
```typescript
// ✅ CORRECT: Use IDs
POST /api/users/watched-items
{
"itemName": "Milk",
"category_id": 3 // Numerical ID
}
// ❌ INCORRECT: Use strings
POST /api/users/watched-items
{
"itemName": "Milk",
"category": "Dairy & Eggs" // String name
}
```
### 2. Provide Discovery Endpoints
**Rule:** For any entity referenced by ID, provide a GET endpoint to list available options.
```typescript
// Required: Category discovery endpoint
GET / api / categories;
Response: [
{ category_id: 1, name: 'Fruits & Vegetables' },
{ category_id: 2, name: 'Meat & Seafood' },
{ category_id: 3, name: 'Dairy & Eggs' },
];
```
### 3. Support Lookup by Name (Optional)
**Rule:** If convenient, provide query parameters for name-based lookup, but use IDs internally.
```typescript
// Optional: Convenience endpoint
GET /api/categories?name=Dairy%20%26%20Eggs
Response: { "category_id": 3, "name": "Dairy & Eggs" }
```
### 4. Return Full Objects in Responses
**Rule:** API responses SHOULD include denormalized data for convenience, but inputs MUST use IDs.
```typescript
// ✅ Response includes category details
GET / api / users / watched - items;
Response: [
{
master_grocery_item_id: 42,
name: 'Milk',
category_id: 3,
category: {
// ✅ Include full object in response
category_id: 3,
name: 'Dairy & Eggs',
},
},
];
```
## Affected Areas
### Immediate Violations (Must Fix)
1. **User Watched Items** ([src/routes/user.routes.ts:76](../../src/routes/user.routes.ts))
- Currently: `category: string`
- Should be: `category_id: number`
2. **Service Layer** ([src/services/db/personalization.db.ts:175](../../src/services/db/personalization.db.ts))
- Currently: `categoryName: string`
- Should be: `categoryId: number`
3. **API Client** ([src/services/apiClient.ts:436](../../src/services/apiClient.ts))
- Currently: `category: string`
- Should be: `category_id: number`
4. **Frontend Hooks** ([src/hooks/mutations/useAddWatchedItemMutation.ts:9](../../src/hooks/mutations/useAddWatchedItemMutation.ts))
- Currently: `category?: string`
- Should be: `category_id: number`
### Potential Violations (Review Required)
1. **UPC/Barcode System** ([src/types/upc.ts:85](../../src/types/upc.ts))
- Uses `category: string | null`
- May be appropriate if category is free-form user input
2. **AI Extraction** ([src/types/ai.ts:21](../../src/types/ai.ts))
- Uses `category_name: z.string()`
- AI extracts category names, needs mapping to IDs
3. **Flyer Data Transformer** ([src/services/flyerDataTransformer.ts:40](../../src/services/flyerDataTransformer.ts))
- Uses `category_name: string`
- May need category matching/creation logic
## Migration Strategy
See [research-category-id-migration.md](../research-category-id-migration.md) for detailed migration plan.
**High-level approach:**
1. **Phase 1: Add category discovery endpoint** (non-breaking)
- `GET /api/categories`
- No API changes yet
2. **Phase 2: Support both formats** (non-breaking)
- Accept both `category` (string) and `category_id` (number)
- Deprecate string format with warning logs
3. **Phase 3: Remove string support** (breaking change, major version bump)
- Only accept `category_id`
- Update all clients and tests
## Consequences
### Positive
- ✅ API matches database schema design
- ✅ More robust (no typo-based failures)
- ✅ Better performance (no string lookups)
- ✅ Enables localization
- ✅ Discoverable via REST API
- ✅ Follows REST best practices
### Negative
- ⚠️ Breaking change for existing API consumers
- ⚠️ Requires client updates
- ⚠️ More complex migration path
### Neutral
- Frontend must fetch categories before displaying form
- Slightly more initial API calls (one-time category fetch)
## References
- [Database Normalization (Wikipedia)](https://en.wikipedia.org/wiki/Database_normalization)
- [REST API Design Best Practices](https://stackoverflow.blog/2020/03/02/best-practices-for-rest-api-design/)
- [PostgreSQL Foreign Keys](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK)
## Related Decisions
- [ADR-001: Database Schema Design](./0001-database-schema-design.md) (if exists)
- [ADR-014: Containerization and Deployment Strategy](./0014-containerization-and-deployment-strategy.md)
## Approval
- **Proposed by:** Claude Code (via user observation)
- **Date:** 2026-01-19
- **Status:** Accepted (pending implementation)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,232 @@
# Research: Separating E2E Tests from Integration Tests
**Date:** 2026-01-19
**Status:** In Progress
**Context:** E2E tests exist with their own config but are not being run separately
## Current State
### Test Structure
- **Unit tests**: `src/tests/unit/` (but most are co-located with source files)
- **Integration tests**: `src/tests/integration/` (28 test files)
- **E2E tests**: `src/tests/e2e/` (11 test files) **← NOT CURRENTLY RUNNING**
### Configurations
| Config File | Project Name | Environment | Port | Include Pattern |
| ------------------------------ | ------------- | ----------- | ---- | ------------------------------------------ |
| `vite.config.ts` | `unit` | jsdom | N/A | Component/hook tests |
| `vitest.config.integration.ts` | `integration` | node | 3099 | `src/tests/integration/**/*.test.{ts,tsx}` |
| `vitest.config.e2e.ts` | `e2e` | node | 3098 | `src/tests/e2e/**/*.e2e.test.ts` |
### Workspace Configuration
**`vitest.workspace.ts` currently includes:**
```typescript
export default [
'vite.config.ts', // Unit tests
'vitest.config.integration.ts', // Integration tests
// ❌ vitest.config.e2e.ts is NOT included!
];
```
### NPM Scripts
```json
{
"test": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx ./node_modules/vitest/vitest.mjs run",
"test:unit": "... --project unit ...",
"test:integration": "... --project integration ..."
// ❌ NO test:e2e script exists!
}
```
### CI/CD Status
**`.gitea/workflows/deploy-to-test.yml` runs:**
-`npm run test:unit -- --coverage`
-`npm run test:integration -- --coverage`
- ❌ E2E tests are NOT run in CI
## Key Findings
### 1. E2E Tests Are Orphaned
- 11 E2E test files exist but are never executed
- E2E config file exists (`vitest.config.e2e.ts`) but is not referenced anywhere
- No npm script to run E2E tests
- Not included in vitest workspace
- Not run in CI/CD pipeline
### 2. When Were E2E Tests Created?
Git history shows E2E config was added in commit `e66027d` ("fix e2e and deploy to prod"), but:
- It was never added to the workspace
- It was never added to CI
- No test:e2e script was created
This suggests the E2E separation was **started but never completed**.
### 3. How Are Tests Currently Run?
**Locally:**
- `npm test` → runs workspace (unit + integration only)
- `npm run test:unit` → runs only unit tests
- `npm run test:integration` → runs only integration tests
- E2E tests: **Not accessible via any command**
**In CI:**
- Only `test:unit` and `test:integration` are run
- E2E tests are never executed
### 4. Port Allocation
- Integration tests: Port 3099
- E2E tests: Port 3098 (configured but never used)
- No conflicts if both run sequentially
## E2E Test Files (11 total)
1. `admin-authorization.e2e.test.ts`
2. `admin-dashboard.e2e.test.ts`
3. `auth.e2e.test.ts`
4. `budget-journey.e2e.test.ts`
5. `deals-journey.e2e.test.ts` ← Just fixed URL constraint issue
6. `error-reporting.e2e.test.ts`
7. `flyer-upload.e2e.test.ts`
8. `inventory-journey.e2e.test.ts`
9. `receipt-journey.e2e.test.ts`
10. `upc-journey.e2e.test.ts`
11. `user-journey.e2e.test.ts`
## Problems to Solve
### Immediate Issues
1. **E2E tests are not running** - Code exists but is never executed
2. **No way to run E2E tests** - No npm script or CI job
3. **Coverage gaps** - E2E scenarios are untested in practice
4. **False sense of security** - Team may think E2E tests are running
### Implementation Challenges
#### 1. Adding E2E to Workspace
**Option A: Add to workspace**
```typescript
// vitest.workspace.ts
export default [
'vite.config.ts',
'vitest.config.integration.ts',
'vitest.config.e2e.ts', // ← Add this
];
```
**Impact:** E2E tests would run with `npm test`, increasing test time significantly
**Option B: Keep separate**
- E2E remains outside workspace
- Requires explicit `npm run test:e2e` command
- CI would need separate step for E2E tests
#### 2. Adding NPM Script
```json
{
"test:e2e": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project e2e -c vitest.config.e2e.ts"
}
```
**Dependencies:**
- Uses same global setup pattern as integration tests
- Requires server to be stopped first (like integration tests)
- Port 3098 must be available
#### 3. CI/CD Integration
**Add to `.gitea/workflows/deploy-to-test.yml`:**
```yaml
- name: Run E2E Tests
run: |
npm run test:e2e -- --coverage \
--reporter=verbose \
--includeTaskLocation \
--testTimeout=120000 \
--silent=passed-only
```
**Questions:**
- Should E2E run before or after integration tests?
- Should E2E failures block deployment?
- Should E2E have separate coverage reports?
#### 4. Test Organization Questions
- Are current "integration" tests actually E2E tests?
- Should some E2E tests be moved to integration?
- What's the distinction between integration and E2E in this project?
#### 5. Coverage Implications
- E2E tests have separate coverage directory: `.coverage/e2e`
- Integration tests: `.coverage/integration`
- How to merge coverage from all test types?
- Do we need combined coverage reports?
## Recommended Approach
### Phase 1: Quick Fix (Enable E2E Tests)
1. ✅ Fix any failing E2E tests (like URL constraints)
2. Add `test:e2e` npm script
3. Document how to run E2E tests manually
4. Do NOT add to workspace yet (keep separate)
### Phase 2: CI Integration
1. Add E2E test step to `.gitea/workflows/deploy-to-test.yml`
2. Run after integration tests pass
3. Allow failures initially (monitor results)
4. Make blocking once stable
### Phase 3: Optimize
1. Review test categorization (integration vs E2E)
2. Consider adding to workspace if test time is acceptable
3. Merge coverage reports if needed
4. Document test strategy in testing docs
## Next Steps
1. **Create `test:e2e` script** in package.json
2. **Run E2E tests manually** to verify they work
3. **Fix any failing E2E tests**
4. **Document E2E testing** in TESTING.md
5. **Add to CI** once stable
6. **Consider workspace integration** after CI is stable
## Questions for Team
1. Why were E2E tests never fully integrated?
2. Should E2E tests run on every commit or separately?
3. What's the acceptable test time for local development?
4. Should we run E2E tests in parallel or sequentially with integration?
## Related Files
- `vitest.workspace.ts` - Workspace configuration
- `vitest.config.e2e.ts` - E2E test configuration
- `src/tests/setup/e2e-global-setup.ts` - E2E global setup
- `.gitea/workflows/deploy-to-test.yml` - CI pipeline
- `package.json` - NPM scripts

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.11.14",
"version": "0.11.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.11.14",
"version": "0.11.19",
"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.11.14",
"version": "0.11.19",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",
@@ -14,6 +14,7 @@
"test:coverage": "npm run clean && npm run test:unit -- --coverage && npm run test:integration -- --coverage",
"test:unit": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project unit -c vite.config.ts",
"test:integration": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --project integration -c vitest.config.integration.ts",
"test:e2e": "node scripts/check-linux.js && cross-env NODE_ENV=test tsx --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --config vitest.config.e2e.ts",
"format": "prettier --write .",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit",

View File

@@ -38,6 +38,7 @@ import receiptRouter from './src/routes/receipt.routes';
import dealsRouter from './src/routes/deals.routes';
import reactionsRouter from './src/routes/reactions.routes';
import storeRouter from './src/routes/store.routes';
import categoryRouter from './src/routes/category.routes';
import { errorHandler } from './src/middleware/errorHandler';
import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService';
import { websocketService } from './src/services/websocketService.server';
@@ -288,6 +289,8 @@ app.use('/api/deals', dealsRouter);
app.use('/api/reactions', reactionsRouter);
// 16. Store management routes.
app.use('/api/stores', storeRouter);
// 17. Category discovery routes (ADR-023: Database Normalization)
app.use('/api/categories', categoryRouter);
// --- Error Handling and Server Startup ---

View File

@@ -73,7 +73,25 @@ RETURNS TABLE (
LANGUAGE plpgsql
SECURITY INVOKER -- Runs with the privileges of the calling user.
AS $$
DECLARE
v_watched_items_count INTEGER;
v_result_count INTEGER;
v_context JSONB;
BEGIN
v_context := jsonb_build_object('user_id', p_user_id);
-- Tier 2 logging: Check if user has any watched items
SELECT COUNT(*) INTO v_watched_items_count
FROM public.user_watched_items
WHERE user_id = p_user_id;
IF v_watched_items_count = 0 THEN
PERFORM fn_log('NOTICE', 'get_best_sale_prices_for_user',
'User has no watched items',
v_context);
RETURN; -- Return empty result set
END IF;
RETURN QUERY
WITH UserWatchedSales AS (
-- This CTE gathers all sales from active flyers that match the user's watched items.
@@ -104,6 +122,20 @@ BEGIN
SELECT uws.master_item_id, uws.item_name, uws.price_in_cents, uws.store_name, uws.flyer_id, uws.flyer_icon_url, uws.flyer_image_url, uws.flyer_valid_from, uws.flyer_valid_to
FROM UserWatchedSales uws
WHERE uws.rn = 1;
-- Tier 2 logging: Check if any sales were found
GET DIAGNOSTICS v_result_count = ROW_COUNT;
IF v_result_count = 0 THEN
PERFORM fn_log('NOTICE', 'get_best_sale_prices_for_user',
'No sales found for watched items',
v_context || jsonb_build_object('watched_items_count', v_watched_items_count));
END IF;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'get_best_sale_prices_for_user',
'Unexpected error getting best sale prices: ' || SQLERRM,
v_context);
RAISE;
END;
$$;
@@ -125,7 +157,42 @@ RETURNS TABLE (
LANGUAGE plpgsql
SECURITY INVOKER -- Runs with the privileges of the calling user.
AS $$
DECLARE
v_menu_plan_exists BOOLEAN;
v_planned_meals_count INTEGER;
v_result_count INTEGER;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'menu_plan_id', p_menu_plan_id,
'user_id', p_user_id
);
-- Tier 2 logging: Check if menu plan exists and belongs to user
SELECT EXISTS(
SELECT 1 FROM public.menu_plans
WHERE menu_plan_id = p_menu_plan_id AND user_id = p_user_id
) INTO v_menu_plan_exists;
IF NOT v_menu_plan_exists THEN
PERFORM fn_log('NOTICE', 'generate_shopping_list_for_menu_plan',
'Menu plan not found or does not belong to user',
v_context);
RETURN; -- Return empty result set
END IF;
-- Tier 2 logging: Check if menu plan has any recipes
SELECT COUNT(*) INTO v_planned_meals_count
FROM public.planned_meals
WHERE menu_plan_id = p_menu_plan_id;
IF v_planned_meals_count = 0 THEN
PERFORM fn_log('NOTICE', 'generate_shopping_list_for_menu_plan',
'Menu plan has no recipes',
v_context);
RETURN; -- Return empty result set
END IF;
RETURN QUERY
WITH RequiredIngredients AS (
-- This CTE calculates the total quantity of each ingredient needed for the menu plan.
@@ -163,6 +230,20 @@ BEGIN
WHERE
-- Only include items that actually need to be purchased.
GREATEST(0, req.total_required - COALESCE(pi.quantity, 0)) > 0;
-- Tier 2 logging: Check if any items need to be purchased
GET DIAGNOSTICS v_result_count = ROW_COUNT;
IF v_result_count = 0 THEN
PERFORM fn_log('NOTICE', 'generate_shopping_list_for_menu_plan',
'All ingredients already in pantry (no shopping needed)',
v_context || jsonb_build_object('planned_meals_count', v_planned_meals_count));
END IF;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'generate_shopping_list_for_menu_plan',
'Unexpected error generating shopping list: ' || SQLERRM,
v_context);
RAISE;
END;
$$;
@@ -458,10 +539,14 @@ STABLE -- This function does not modify the database.
AS $$
DECLARE
suggested_id BIGINT;
best_score REAL;
-- A similarity score between 0 and 1. A higher value means a better match.
-- This threshold can be adjusted based on observed performance. 0.4 is a reasonable starting point.
similarity_threshold REAL := 0.4;
v_context JSONB;
BEGIN
v_context := jsonb_build_object('flyer_item_name', p_flyer_item_name, 'similarity_threshold', similarity_threshold);
WITH candidates AS (
-- Search for matches in the primary master_grocery_items table
SELECT
@@ -480,7 +565,14 @@ BEGIN
WHERE alias % p_flyer_item_name
)
-- Select the master_item_id with the highest similarity score, provided it's above our threshold.
SELECT master_item_id INTO suggested_id FROM candidates WHERE score >= similarity_threshold ORDER BY score DESC, master_item_id LIMIT 1;
SELECT master_item_id, score INTO suggested_id, best_score FROM candidates WHERE score >= similarity_threshold ORDER BY score DESC, master_item_id LIMIT 1;
-- Tier 2 logging: Log when no match found (anomaly detection)
IF suggested_id IS NULL THEN
PERFORM fn_log('INFO', 'suggest_master_item_for_flyer_item',
'No master item match found for flyer item',
v_context || jsonb_build_object('best_score', best_score));
END IF;
RETURN suggested_id;
END;
@@ -500,10 +592,18 @@ RETURNS TABLE (
recommendation_score NUMERIC,
recommendation_reason TEXT
)
LANGUAGE sql
LANGUAGE plpgsql
STABLE
SECURITY INVOKER
AS $$
DECLARE
v_count INTEGER;
v_context JSONB;
BEGIN
v_context := jsonb_build_object('user_id', p_user_id, 'limit', p_limit);
-- Execute the recommendation query
RETURN QUERY
WITH UserHighRatedRecipes AS (
-- CTE 1: Get recipes the user has rated 4 stars or higher.
SELECT rr.recipe_id, rr.rating
@@ -581,6 +681,15 @@ ORDER BY
r.rating_count DESC,
r.name ASC
LIMIT p_limit;
-- Tier 2 logging: Log when no recommendations generated (anomaly detection)
GET DIAGNOSTICS v_count = ROW_COUNT;
IF v_count = 0 THEN
PERFORM fn_log('INFO', 'recommend_recipes_for_user',
'No recipe recommendations generated for user',
v_context);
END IF;
END;
$$;
-- Function to approve a suggested correction and apply it.
@@ -743,49 +852,85 @@ RETURNS TABLE(
avg_rating NUMERIC,
missing_ingredients_count BIGINT
)
LANGUAGE sql
LANGUAGE plpgsql
STABLE
SECURITY INVOKER
AS $$
WITH UserPantryItems AS (
-- CTE 1: Get a distinct set of master item IDs from the user's pantry.
SELECT master_item_id, quantity, unit
DECLARE
v_pantry_item_count INTEGER;
v_result_count INTEGER;
v_context JSONB;
BEGIN
v_context := jsonb_build_object('user_id', p_user_id);
-- Tier 2 logging: Check if user has any pantry items
SELECT COUNT(*) INTO v_pantry_item_count
FROM public.pantry_items
WHERE user_id = p_user_id AND quantity > 0
),
RecipeIngredientStats AS (
-- CTE 2: For each recipe, count its total ingredients and how many of those are in the user's pantry.
WHERE user_id = p_user_id AND quantity > 0;
IF v_pantry_item_count = 0 THEN
PERFORM fn_log('NOTICE', 'find_recipes_from_pantry',
'User has empty pantry',
v_context);
RETURN; -- Return empty result set
END IF;
-- Execute the main query and return results
RETURN QUERY
WITH UserPantryItems AS (
-- CTE 1: Get a distinct set of master item IDs from the user's pantry.
SELECT pi.master_item_id, pi.quantity, pi.unit
FROM public.pantry_items pi
WHERE pi.user_id = p_user_id AND pi.quantity > 0
),
RecipeIngredientStats AS (
-- CTE 2: For each recipe, count its total ingredients and how many of those are in the user's pantry.
SELECT
ri.recipe_id,
-- Count how many ingredients DO NOT meet the pantry requirements.
-- An ingredient is missing if it's not in the pantry OR if the quantity is insufficient.
-- The filter condition handles this logic.
COUNT(*) FILTER (
WHERE upi.master_item_id IS NULL -- The item is not in the pantry at all
OR upi.quantity < ri.quantity -- The user has the item, but not enough of it
) AS missing_ingredients_count
FROM public.recipe_ingredients ri
-- LEFT JOIN to the user's pantry on both item and unit.
-- We only compare quantities if the units match (e.g., 'g' vs 'g').
LEFT JOIN UserPantryItems upi
ON ri.master_item_id = upi.master_item_id
AND ri.unit = upi.unit
GROUP BY ri.recipe_id
)
-- Final Step: Select recipes where the total ingredient count matches the pantry ingredient count.
SELECT
ri.recipe_id,
-- Count how many ingredients DO NOT meet the pantry requirements.
-- An ingredient is missing if it's not in the pantry OR if the quantity is insufficient.
-- The filter condition handles this logic.
COUNT(*) FILTER (
WHERE upi.master_item_id IS NULL -- The item is not in the pantry at all
OR upi.quantity < ri.quantity -- The user has the item, but not enough of it
) AS missing_ingredients_count
FROM public.recipe_ingredients ri
-- LEFT JOIN to the user's pantry on both item and unit.
-- We only compare quantities if the units match (e.g., 'g' vs 'g').
LEFT JOIN UserPantryItems upi
ON ri.master_item_id = upi.master_item_id
AND ri.unit = upi.unit
GROUP BY ri.recipe_id
)
-- Final Step: Select recipes where the total ingredient count matches the pantry ingredient count.
SELECT
r.recipe_id,
r.name,
r.description,
r.prep_time_minutes,
r.cook_time_minutes,
r.avg_rating,
ris.missing_ingredients_count
FROM public.recipes r
JOIN RecipeIngredientStats ris ON r.recipe_id = ris.recipe_id
-- Order by recipes with the fewest missing ingredients first, then by rating.
-- Recipes with 0 missing ingredients are the ones that can be made.
ORDER BY ris.missing_ingredients_count ASC, r.avg_rating DESC, r.name ASC;
r.recipe_id,
r.name,
r.description,
r.prep_time_minutes,
r.cook_time_minutes,
r.avg_rating,
ris.missing_ingredients_count
FROM public.recipes r
JOIN RecipeIngredientStats ris ON r.recipe_id = ris.recipe_id
-- Order by recipes with the fewest missing ingredients first, then by rating.
-- Recipes with 0 missing ingredients are the ones that can be made.
ORDER BY ris.missing_ingredients_count ASC, r.avg_rating DESC, r.name ASC;
-- Tier 2 logging: Check if any recipes were found
GET DIAGNOSTICS v_result_count = ROW_COUNT;
IF v_result_count = 0 THEN
PERFORM fn_log('NOTICE', 'find_recipes_from_pantry',
'No recipes found matching pantry items',
v_context || jsonb_build_object('pantry_item_count', v_pantry_item_count));
END IF;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'find_recipes_from_pantry',
'Unexpected error finding recipes from pantry: ' || SQLERRM,
v_context);
RAISE;
END;
$$;
-- Function to suggest alternative units for a given pantry item.
@@ -1409,7 +1554,15 @@ DECLARE
flyer_valid_to DATE;
current_summary_date DATE;
flyer_location_id BIGINT;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'flyer_item_id', NEW.flyer_item_id,
'flyer_id', NEW.flyer_id,
'master_item_id', NEW.master_item_id,
'price_in_cents', NEW.price_in_cents
);
-- If the item could not be matched, add it to the unmatched queue for review.
IF NEW.master_item_id IS NULL THEN
INSERT INTO public.unmatched_flyer_items (flyer_item_id)
@@ -1427,6 +1580,14 @@ BEGIN
FROM public.flyers
WHERE flyer_id = NEW.flyer_id;
-- Tier 3 logging: Log when flyer lookup fails
IF flyer_valid_from IS NULL OR flyer_valid_to IS NULL THEN
PERFORM fn_log('ERROR', 'update_price_history_on_flyer_item_insert',
'Flyer not found or missing validity dates',
v_context);
RETURN NEW;
END IF;
-- This single, set-based query is much more performant than looping.
-- It generates all date/location pairs and inserts/updates them in one operation.
INSERT INTO public.item_price_history (master_item_id, summary_date, store_location_id, min_price_in_cents, max_price_in_cents, avg_price_in_cents, data_points_count)
@@ -1449,6 +1610,14 @@ BEGIN
data_points_count = item_price_history.data_points_count + 1;
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
-- Tier 3 logging: Log unexpected errors in trigger
PERFORM fn_log('ERROR', 'update_price_history_on_flyer_item_insert',
'Unexpected error in price history update: ' || SQLERRM,
v_context);
-- Re-raise the exception to ensure trigger failure is visible
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -1511,6 +1680,30 @@ BEGIN
AND iph.store_location_id = na.store_location_id;
-- 4. Delete any history records that no longer have any data points.
-- We need to recreate the CTE since CTEs are scoped to a single statement.
WITH affected_days_and_locations AS (
SELECT DISTINCT
generate_series(f.valid_from, f.valid_to, '1 day'::interval)::date AS summary_date,
fl.store_location_id
FROM public.flyers f
JOIN public.flyer_locations fl ON f.flyer_id = fl.flyer_id
WHERE f.flyer_id = OLD.flyer_id
),
new_aggregates AS (
SELECT
adl.summary_date,
adl.store_location_id,
MIN(fi.price_in_cents) AS min_price,
MAX(fi.price_in_cents) AS max_price,
ROUND(AVG(fi.price_in_cents))::int AS avg_price,
COUNT(fi.flyer_item_id)::int AS data_points
FROM affected_days_and_locations adl
LEFT JOIN public.flyer_items fi ON fi.master_item_id = OLD.master_item_id AND fi.price_in_cents IS NOT NULL
LEFT JOIN public.flyers f ON fi.flyer_id = f.flyer_id AND adl.summary_date BETWEEN f.valid_from AND f.valid_to
LEFT JOIN public.flyer_locations fl ON fi.flyer_id = fl.flyer_id AND adl.store_location_id = fl.store_location_id
WHERE fl.flyer_id IS NOT NULL
GROUP BY adl.summary_date, adl.store_location_id
)
DELETE FROM public.item_price_history iph
WHERE iph.master_item_id = OLD.master_item_id
AND NOT EXISTS (
@@ -1533,22 +1726,45 @@ DROP FUNCTION IF EXISTS public.update_recipe_rating_aggregates();
CREATE OR REPLACE FUNCTION public.update_recipe_rating_aggregates()
RETURNS TRIGGER AS $$
DECLARE
v_recipe_id BIGINT;
v_rows_updated INTEGER;
v_context JSONB;
BEGIN
v_recipe_id := COALESCE(NEW.recipe_id, OLD.recipe_id);
v_context := jsonb_build_object('recipe_id', v_recipe_id);
UPDATE public.recipes
SET
avg_rating = (
SELECT AVG(rating)
FROM public.recipe_ratings
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed
WHERE recipe_id = v_recipe_id
),
rating_count = (
SELECT COUNT(*)
FROM public.recipe_ratings
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed
WHERE recipe_id = v_recipe_id
)
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id);
WHERE recipe_id = v_recipe_id;
-- Tier 3 logging: Log when recipe update fails
GET DIAGNOSTICS v_rows_updated = ROW_COUNT;
IF v_rows_updated = 0 THEN
PERFORM fn_log('ERROR', 'update_recipe_rating_aggregates',
'Recipe not found for rating aggregate update',
v_context);
END IF;
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
EXCEPTION
WHEN OTHERS THEN
-- Tier 3 logging: Log unexpected errors in trigger
PERFORM fn_log('ERROR', 'update_recipe_rating_aggregates',
'Unexpected error in rating aggregate update: ' || SQLERRM,
v_context);
-- Re-raise the exception to ensure trigger failure is visible
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -1563,12 +1779,30 @@ DROP FUNCTION IF EXISTS public.log_new_recipe();
CREATE OR REPLACE FUNCTION public.log_new_recipe()
RETURNS TRIGGER AS $$
DECLARE
v_full_name TEXT;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'user_id', NEW.user_id,
'recipe_id', NEW.recipe_id,
'recipe_name', NEW.name
);
-- Get user's full name (Tier 3 logging: Log if profile lookup fails)
SELECT full_name INTO v_full_name FROM public.profiles WHERE user_id = NEW.user_id;
IF v_full_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_recipe',
'Profile not found for user creating recipe',
v_context);
v_full_name := 'Unknown User';
END IF;
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.user_id,
'recipe_created',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' created a new recipe: ' || NEW.name,
v_full_name || ' created a new recipe: ' || NEW.name,
'chef-hat',
jsonb_build_object('recipe_id', NEW.recipe_id, 'recipe_name', NEW.name)
);
@@ -1577,6 +1811,14 @@ BEGIN
PERFORM public.award_achievement(NEW.user_id, 'First Recipe');
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
-- Tier 3 logging: Log unexpected errors in trigger
PERFORM fn_log('ERROR', 'log_new_recipe',
'Unexpected error in recipe activity logging: ' || SQLERRM,
v_context);
-- Re-raise the exception to ensure trigger failure is visible
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -1593,13 +1835,39 @@ DROP FUNCTION IF EXISTS public.update_flyer_item_count();
CREATE OR REPLACE FUNCTION public.update_flyer_item_count()
RETURNS TRIGGER AS $$
DECLARE
v_rows_updated INTEGER;
v_context JSONB;
v_flyer_id BIGINT;
BEGIN
-- Determine which flyer_id to use based on operation
IF (TG_OP = 'INSERT') THEN
v_flyer_id := NEW.flyer_id;
v_context := jsonb_build_object('flyer_id', NEW.flyer_id, 'operation', 'INSERT');
UPDATE public.flyers SET item_count = item_count + 1 WHERE flyer_id = NEW.flyer_id;
ELSIF (TG_OP = 'DELETE') THEN
v_flyer_id := OLD.flyer_id;
v_context := jsonb_build_object('flyer_id', OLD.flyer_id, 'operation', 'DELETE');
UPDATE public.flyers SET item_count = item_count - 1 WHERE flyer_id = OLD.flyer_id;
END IF;
-- Tier 3 logging: Log if flyer not found
GET DIAGNOSTICS v_rows_updated = ROW_COUNT;
IF v_rows_updated = 0 THEN
PERFORM fn_log('ERROR', 'update_flyer_item_count',
'Flyer not found for item count update',
v_context);
END IF;
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'update_flyer_item_count',
'Unexpected error updating flyer item count: ' || SQLERRM,
v_context);
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -1615,27 +1883,55 @@ DROP FUNCTION IF EXISTS public.log_new_flyer();
CREATE OR REPLACE FUNCTION public.log_new_flyer()
RETURNS TRIGGER AS $$
DECLARE
v_store_name TEXT;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'flyer_id', NEW.flyer_id,
'store_id', NEW.store_id,
'uploaded_by', NEW.uploaded_by,
'valid_from', NEW.valid_from,
'valid_to', NEW.valid_to
);
-- If the flyer was uploaded by a registered user, award the 'First-Upload' achievement.
-- The award_achievement function handles checking if the user already has it.
IF NEW.uploaded_by IS NOT NULL THEN
PERFORM public.award_achievement(NEW.uploaded_by, 'First-Upload');
END IF;
-- Get store name (Tier 3 logging: Log if store lookup fails)
SELECT name INTO v_store_name FROM public.stores WHERE store_id = NEW.store_id;
IF v_store_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_flyer',
'Store not found for flyer',
v_context);
v_store_name := 'Unknown Store';
END IF;
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.uploaded_by, -- Log the user who uploaded it
'flyer_uploaded',
'A new flyer for ' || (SELECT name FROM public.stores WHERE store_id = NEW.store_id) || ' has been uploaded.',
'A new flyer for ' || v_store_name || ' has been uploaded.',
'file-text',
jsonb_build_object(
'flyer_id', NEW.flyer_id,
'store_name', (SELECT name FROM public.stores WHERE store_id = NEW.store_id),
'store_name', v_store_name,
'valid_from', to_char(NEW.valid_from, 'YYYY-MM-DD'),
'valid_to', to_char(NEW.valid_to, 'YYYY-MM-DD')
)
);
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
-- Tier 3 logging: Log unexpected errors in trigger
PERFORM fn_log('ERROR', 'log_new_flyer',
'Unexpected error in flyer activity logging: ' || SQLERRM,
v_context);
-- Re-raise the exception to ensure trigger failure is visible
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -1650,14 +1946,41 @@ DROP FUNCTION IF EXISTS public.log_new_favorite_recipe();
CREATE OR REPLACE FUNCTION public.log_new_favorite_recipe()
RETURNS TRIGGER AS $$
DECLARE
v_user_name TEXT;
v_recipe_name TEXT;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'user_id', NEW.user_id,
'recipe_id', NEW.recipe_id
);
-- Get user name (Tier 3 logging: Log if profile lookup fails)
SELECT full_name INTO v_user_name FROM public.profiles WHERE user_id = NEW.user_id;
IF v_user_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_favorite_recipe',
'Profile not found for user',
v_context);
v_user_name := 'Unknown User';
END IF;
-- Get recipe name (Tier 3 logging: Log if recipe lookup fails)
SELECT name INTO v_recipe_name FROM public.recipes WHERE recipe_id = NEW.recipe_id;
IF v_recipe_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_favorite_recipe',
'Recipe not found',
v_context);
v_recipe_name := 'Unknown Recipe';
END IF;
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.user_id,
'recipe_favorited',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' favorited the recipe: ' || (SELECT name FROM public.recipes WHERE recipe_id = NEW.recipe_id),
v_user_name || ' favorited the recipe: ' || v_recipe_name,
'heart',
jsonb_build_object(
jsonb_build_object(
'recipe_id', NEW.recipe_id
)
);
@@ -1665,6 +1988,12 @@ BEGIN
-- Award 'First Favorite' achievement.
PERFORM public.award_achievement(NEW.user_id, 'First Favorite');
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'log_new_favorite_recipe',
'Unexpected error in favorite recipe activity logging: ' || SQLERRM,
v_context);
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -1679,16 +2008,44 @@ DROP FUNCTION IF EXISTS public.log_new_list_share();
CREATE OR REPLACE FUNCTION public.log_new_list_share()
RETURNS TRIGGER AS $$
DECLARE
v_user_name TEXT;
v_list_name TEXT;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'shared_by_user_id', NEW.shared_by_user_id,
'shopping_list_id', NEW.shopping_list_id,
'shared_with_user_id', NEW.shared_with_user_id
);
-- Get user name (Tier 3 logging: Log if profile lookup fails)
SELECT full_name INTO v_user_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id;
IF v_user_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_list_share',
'Profile not found for sharing user',
v_context);
v_user_name := 'Unknown User';
END IF;
-- Get list name (Tier 3 logging: Log if list lookup fails)
SELECT name INTO v_list_name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id;
IF v_list_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_list_share',
'Shopping list not found',
v_context);
v_list_name := 'Unknown List';
END IF;
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.shared_by_user_id,
'list_shared',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a shopping list.',
v_user_name || ' shared a shopping list.',
'share-2',
jsonb_build_object(
'shopping_list_id', NEW.shopping_list_id,
'list_name', (SELECT name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id),
'list_name', v_list_name,
'shared_with_user_id', NEW.shared_with_user_id
)
);
@@ -1696,6 +2053,12 @@ BEGIN
-- Award 'List Sharer' achievement.
PERFORM public.award_achievement(NEW.shared_by_user_id, 'List Sharer');
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'log_new_list_share',
'Unexpected error in list share activity logging: ' || SQLERRM,
v_context);
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -1710,12 +2073,30 @@ DROP FUNCTION IF EXISTS public.log_new_recipe_collection_share();
CREATE OR REPLACE FUNCTION public.log_new_recipe_collection_share()
RETURNS TRIGGER AS $$
DECLARE
v_user_name TEXT;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'shared_by_user_id', NEW.shared_by_user_id,
'recipe_collection_id', NEW.recipe_collection_id,
'shared_with_user_id', NEW.shared_with_user_id
);
-- Get user name (Tier 3 logging: Log if profile lookup fails)
SELECT full_name INTO v_user_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id;
IF v_user_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_recipe_collection_share',
'Profile not found for sharing user',
v_context);
v_user_name := 'Unknown User';
END IF;
-- Log the activity
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.shared_by_user_id, 'recipe_collection_shared',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a recipe collection.',
v_user_name || ' shared a recipe collection.',
'book',
jsonb_build_object('collection_id', NEW.recipe_collection_id, 'shared_with_user_id', NEW.shared_with_user_id)
);
@@ -1723,6 +2104,12 @@ BEGIN
-- Award 'Recipe Sharer' achievement.
PERFORM public.award_achievement(NEW.shared_by_user_id, 'Recipe Sharer');
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'log_new_recipe_collection_share',
'Unexpected error in recipe collection share activity logging: ' || SQLERRM,
v_context);
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -1775,14 +2162,38 @@ DROP FUNCTION IF EXISTS public.increment_recipe_fork_count();
CREATE OR REPLACE FUNCTION public.increment_recipe_fork_count()
RETURNS TRIGGER AS $$
DECLARE
v_rows_updated INTEGER;
v_context JSONB;
BEGIN
-- Only run if the recipe is a fork (original_recipe_id is not null).
IF NEW.original_recipe_id IS NOT NULL THEN
v_context := jsonb_build_object(
'recipe_id', NEW.recipe_id,
'original_recipe_id', NEW.original_recipe_id,
'user_id', NEW.user_id
);
-- Tier 3 logging: Log if original recipe not found
UPDATE public.recipes SET fork_count = fork_count + 1 WHERE recipe_id = NEW.original_recipe_id;
GET DIAGNOSTICS v_rows_updated = ROW_COUNT;
IF v_rows_updated = 0 THEN
PERFORM fn_log('ERROR', 'increment_recipe_fork_count',
'Original recipe not found for fork count increment',
v_context);
END IF;
-- Award 'First Fork' achievement.
PERFORM public.award_achievement(NEW.user_id, 'First Fork');
END IF;
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'increment_recipe_fork_count',
'Unexpected error incrementing fork count: ' || SQLERRM,
v_context);
RAISE;
END;
$$ LANGUAGE plpgsql;

View File

@@ -1624,7 +1624,25 @@ RETURNS TABLE (
LANGUAGE plpgsql
SECURITY INVOKER -- Runs with the privileges of the calling user.
AS $$
DECLARE
v_watched_items_count INTEGER;
v_result_count INTEGER;
v_context JSONB;
BEGIN
v_context := jsonb_build_object('user_id', p_user_id);
-- Tier 2 logging: Check if user has any watched items
SELECT COUNT(*) INTO v_watched_items_count
FROM public.user_watched_items
WHERE user_id = p_user_id;
IF v_watched_items_count = 0 THEN
PERFORM fn_log('NOTICE', 'get_best_sale_prices_for_user',
'User has no watched items',
v_context);
RETURN; -- Return empty result set
END IF;
RETURN QUERY
WITH UserWatchedSales AS (
-- This CTE gathers all sales from active flyers that match the user's watched items.
@@ -1633,7 +1651,7 @@ BEGIN
mgi.name AS item_name,
fi.price_in_cents,
s.name AS store_name,
f.flyer_id AS flyer_id,
f.flyer_id AS flyer_id,
f.image_url AS flyer_image_url,
f.icon_url AS flyer_icon_url,
f.valid_from AS flyer_valid_from,
@@ -1642,10 +1660,10 @@ BEGIN
ROW_NUMBER() OVER (PARTITION BY uwi.master_item_id ORDER BY fi.price_in_cents ASC, f.valid_to DESC, s.name ASC) as rn
FROM
public.user_watched_items uwi
JOIN public.master_grocery_items mgi ON uwi.master_item_id = mgi.master_grocery_item_id
JOIN public.master_grocery_items mgi ON uwi.master_item_id = mgi.master_grocery_item_id
JOIN public.flyer_items fi ON uwi.master_item_id = fi.master_item_id
JOIN public.flyers f ON fi.flyer_id = f.flyer_id
JOIN public.stores s ON f.store_id = s.store_id
JOIN public.flyers f ON fi.flyer_id = f.flyer_id
JOIN public.stores s ON f.store_id = s.store_id
WHERE uwi.user_id = p_user_id
AND f.valid_from <= CURRENT_DATE
AND f.valid_to >= CURRENT_DATE
@@ -1655,6 +1673,20 @@ BEGIN
SELECT uws.master_item_id, uws.item_name, uws.price_in_cents, uws.store_name, uws.flyer_id, uws.flyer_icon_url, uws.flyer_image_url, uws.flyer_valid_from, uws.flyer_valid_to
FROM UserWatchedSales uws
WHERE uws.rn = 1;
-- Tier 2 logging: Check if any sales were found
GET DIAGNOSTICS v_result_count = ROW_COUNT;
IF v_result_count = 0 THEN
PERFORM fn_log('NOTICE', 'get_best_sale_prices_for_user',
'No sales found for watched items',
v_context || jsonb_build_object('watched_items_count', v_watched_items_count));
END IF;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'get_best_sale_prices_for_user',
'Unexpected error getting best sale prices: ' || SQLERRM,
v_context);
RAISE;
END;
$$;
@@ -1676,7 +1708,42 @@ RETURNS TABLE (
LANGUAGE plpgsql
SECURITY INVOKER -- Runs with the privileges of the calling user.
AS $$
DECLARE
v_menu_plan_exists BOOLEAN;
v_planned_meals_count INTEGER;
v_result_count INTEGER;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'menu_plan_id', p_menu_plan_id,
'user_id', p_user_id
);
-- Tier 2 logging: Check if menu plan exists and belongs to user
SELECT EXISTS(
SELECT 1 FROM public.menu_plans
WHERE menu_plan_id = p_menu_plan_id AND user_id = p_user_id
) INTO v_menu_plan_exists;
IF NOT v_menu_plan_exists THEN
PERFORM fn_log('NOTICE', 'generate_shopping_list_for_menu_plan',
'Menu plan not found or does not belong to user',
v_context);
RETURN; -- Return empty result set
END IF;
-- Tier 2 logging: Check if menu plan has any recipes
SELECT COUNT(*) INTO v_planned_meals_count
FROM public.planned_meals
WHERE menu_plan_id = p_menu_plan_id;
IF v_planned_meals_count = 0 THEN
PERFORM fn_log('NOTICE', 'generate_shopping_list_for_menu_plan',
'Menu plan has no recipes',
v_context);
RETURN; -- Return empty result set
END IF;
RETURN QUERY
WITH RequiredIngredients AS (
-- This CTE calculates the total quantity of each ingredient needed for the menu plan.
@@ -1714,6 +1781,20 @@ BEGIN
WHERE
-- Only include items that actually need to be purchased.
GREATEST(0, req.total_required - COALESCE(pi.quantity, 0)) > 0;
-- Tier 2 logging: Check if any items need to be purchased
GET DIAGNOSTICS v_result_count = ROW_COUNT;
IF v_result_count = 0 THEN
PERFORM fn_log('NOTICE', 'generate_shopping_list_for_menu_plan',
'All ingredients already in pantry (no shopping needed)',
v_context || jsonb_build_object('planned_meals_count', v_planned_meals_count));
END IF;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'generate_shopping_list_for_menu_plan',
'Unexpected error generating shopping list: ' || SQLERRM,
v_context);
RAISE;
END;
$$;
@@ -2006,10 +2087,14 @@ STABLE -- This function does not modify the database.
AS $$
DECLARE
suggested_id BIGINT;
best_score REAL;
-- A similarity score between 0 and 1. A higher value means a better match.
-- This threshold can be adjusted based on observed performance. 0.4 is a reasonable starting point.
similarity_threshold REAL := 0.4;
v_context JSONB;
BEGIN
v_context := jsonb_build_object('flyer_item_name', p_flyer_item_name, 'similarity_threshold', similarity_threshold);
WITH candidates AS (
-- Search for matches in the primary master_grocery_items table
SELECT
@@ -2028,7 +2113,14 @@ BEGIN
WHERE alias % p_flyer_item_name
)
-- Select the master_item_id with the highest similarity score, provided it's above our threshold.
SELECT master_item_id INTO suggested_id FROM candidates WHERE score >= similarity_threshold ORDER BY score DESC, master_item_id LIMIT 1;
SELECT master_item_id, score INTO suggested_id, best_score FROM candidates WHERE score >= similarity_threshold ORDER BY score DESC, master_item_id LIMIT 1;
-- Tier 2 logging: Log when no match found (anomaly detection)
IF suggested_id IS NULL THEN
PERFORM fn_log('INFO', 'suggest_master_item_for_flyer_item',
'No master item match found for flyer item',
v_context || jsonb_build_object('best_score', best_score));
END IF;
RETURN suggested_id;
END;
@@ -2049,49 +2141,85 @@ RETURNS TABLE(
avg_rating NUMERIC,
missing_ingredients_count BIGINT
)
LANGUAGE sql
LANGUAGE plpgsql
STABLE
SECURITY INVOKER
AS $$
WITH UserPantryItems AS (
-- CTE 1: Get a distinct set of master item IDs from the user's pantry.
SELECT master_item_id, quantity, unit
DECLARE
v_pantry_item_count INTEGER;
v_result_count INTEGER;
v_context JSONB;
BEGIN
v_context := jsonb_build_object('user_id', p_user_id);
-- Tier 2 logging: Check if user has any pantry items
SELECT COUNT(*) INTO v_pantry_item_count
FROM public.pantry_items
WHERE user_id = p_user_id AND quantity > 0
),
RecipeIngredientStats AS (
-- CTE 2: For each recipe, count its total ingredients and how many of those are in the user's pantry.
WHERE user_id = p_user_id AND quantity > 0;
IF v_pantry_item_count = 0 THEN
PERFORM fn_log('NOTICE', 'find_recipes_from_pantry',
'User has empty pantry',
v_context);
RETURN; -- Return empty result set
END IF;
-- Execute the main query and return results
RETURN QUERY
WITH UserPantryItems AS (
-- CTE 1: Get a distinct set of master item IDs from the user's pantry.
SELECT pi.master_item_id, pi.quantity, pi.unit
FROM public.pantry_items pi
WHERE pi.user_id = p_user_id AND pi.quantity > 0
),
RecipeIngredientStats AS (
-- CTE 2: For each recipe, count its total ingredients and how many of those are in the user's pantry.
SELECT
ri.recipe_id,
-- Count how many ingredients DO NOT meet the pantry requirements.
-- An ingredient is missing if it's not in the pantry OR if the quantity is insufficient.
-- The filter condition handles this logic.
COUNT(*) FILTER (
WHERE upi.master_item_id IS NULL -- The item is not in the pantry at all
OR upi.quantity < ri.quantity -- The user has the item, but not enough of it
) AS missing_ingredients_count
FROM public.recipe_ingredients ri
-- LEFT JOIN to the user's pantry on both item and unit.
-- We only compare quantities if the units match (e.g., 'g' vs 'g').
LEFT JOIN UserPantryItems upi
ON ri.master_item_id = upi.master_item_id
AND ri.unit = upi.unit
GROUP BY ri.recipe_id
)
-- Final Step: Select recipes where the total ingredient count matches the pantry ingredient count.
SELECT
ri.recipe_id,
-- Count how many ingredients DO NOT meet the pantry requirements.
-- An ingredient is missing if it's not in the pantry OR if the quantity is insufficient.
-- The filter condition handles this logic.
COUNT(*) FILTER (
WHERE upi.master_item_id IS NULL -- The item is not in the pantry at all
OR upi.quantity < ri.quantity -- The user has the item, but not enough of it
) AS missing_ingredients_count
FROM public.recipe_ingredients ri
-- LEFT JOIN to the user's pantry on both item and unit.
-- We only compare quantities if the units match (e.g., 'g' vs 'g').
LEFT JOIN UserPantryItems upi
ON ri.master_item_id = upi.master_item_id
AND ri.unit = upi.unit
GROUP BY ri.recipe_id
)
-- Final Step: Select recipes where the total ingredient count matches the pantry ingredient count.
SELECT
r.recipe_id,
r.name,
r.description,
r.prep_time_minutes,
r.cook_time_minutes,
r.avg_rating,
ris.missing_ingredients_count
FROM public.recipes r
JOIN RecipeIngredientStats ris ON r.recipe_id = ris.recipe_id
-- Order by recipes with the fewest missing ingredients first, then by rating.
-- Recipes with 0 missing ingredients are the ones that can be made.
ORDER BY ris.missing_ingredients_count ASC, r.avg_rating DESC, r.name ASC;
r.recipe_id,
r.name,
r.description,
r.prep_time_minutes,
r.cook_time_minutes,
r.avg_rating,
ris.missing_ingredients_count
FROM public.recipes r
JOIN RecipeIngredientStats ris ON r.recipe_id = ris.recipe_id
-- Order by recipes with the fewest missing ingredients first, then by rating.
-- Recipes with 0 missing ingredients are the ones that can be made.
ORDER BY ris.missing_ingredients_count ASC, r.avg_rating DESC, r.name ASC;
-- Tier 2 logging: Check if any recipes were found
GET DIAGNOSTICS v_result_count = ROW_COUNT;
IF v_result_count = 0 THEN
PERFORM fn_log('NOTICE', 'find_recipes_from_pantry',
'No recipes found matching pantry items',
v_context || jsonb_build_object('pantry_item_count', v_pantry_item_count));
END IF;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'find_recipes_from_pantry',
'Unexpected error finding recipes from pantry: ' || SQLERRM,
v_context);
RAISE;
END;
$$;
-- Function to suggest alternative units for a given pantry item.
@@ -2137,10 +2265,18 @@ RETURNS TABLE (
recommendation_score NUMERIC,
recommendation_reason TEXT
)
LANGUAGE sql
LANGUAGE plpgsql
STABLE
SECURITY INVOKER
AS $$
DECLARE
v_count INTEGER;
v_context JSONB;
BEGIN
v_context := jsonb_build_object('user_id', p_user_id, 'limit', p_limit);
-- Execute the recommendation query
RETURN QUERY
WITH UserHighRatedRecipes AS (
-- CTE 1: Get recipes the user has rated 4 stars or higher.
SELECT rr.recipe_id, rr.rating
@@ -2218,6 +2354,15 @@ ORDER BY
r.rating_count DESC,
r.name ASC
LIMIT p_limit;
-- Tier 2 logging: Log when no recommendations generated (anomaly detection)
GET DIAGNOSTICS v_count = ROW_COUNT;
IF v_count = 0 THEN
PERFORM fn_log('INFO', 'recommend_recipes_for_user',
'No recipe recommendations generated for user',
v_context);
END IF;
END;
$$;
-- Function to get a user's favorite recipes.
@@ -2879,7 +3024,15 @@ DECLARE
flyer_valid_to DATE;
current_summary_date DATE;
flyer_location_id BIGINT;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'flyer_item_id', NEW.flyer_item_id,
'flyer_id', NEW.flyer_id,
'master_item_id', NEW.master_item_id,
'price_in_cents', NEW.price_in_cents
);
-- If the item could not be matched, add it to the unmatched queue for review.
IF NEW.master_item_id IS NULL THEN
INSERT INTO public.unmatched_flyer_items (flyer_item_id)
@@ -2897,6 +3050,14 @@ BEGIN
FROM public.flyers
WHERE flyer_id = NEW.flyer_id;
-- Tier 3 logging: Log when flyer lookup fails
IF flyer_valid_from IS NULL OR flyer_valid_to IS NULL THEN
PERFORM fn_log('ERROR', 'update_price_history_on_flyer_item_insert',
'Flyer not found or missing validity dates',
v_context);
RETURN NEW;
END IF;
-- This single, set-based query is much more performant than looping.
-- It generates all date/location pairs and inserts/updates them in one operation.
INSERT INTO public.item_price_history (master_item_id, summary_date, store_location_id, min_price_in_cents, max_price_in_cents, avg_price_in_cents, data_points_count)
@@ -2919,6 +3080,14 @@ BEGIN
data_points_count = item_price_history.data_points_count + 1;
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
-- Tier 3 logging: Log unexpected errors in trigger
PERFORM fn_log('ERROR', 'update_price_history_on_flyer_item_insert',
'Unexpected error in price history update: ' || SQLERRM,
v_context);
-- Re-raise the exception to ensure trigger failure is visible
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -2981,6 +3150,30 @@ BEGIN
AND iph.store_location_id = na.store_location_id;
-- 4. Delete any history records that no longer have any data points.
-- We need to recreate the CTE since CTEs are scoped to a single statement.
WITH affected_days_and_locations AS (
SELECT DISTINCT
generate_series(f.valid_from, f.valid_to, '1 day'::interval)::date AS summary_date,
fl.store_location_id
FROM public.flyers f
JOIN public.flyer_locations fl ON f.flyer_id = fl.flyer_id
WHERE f.flyer_id = OLD.flyer_id
),
new_aggregates AS (
SELECT
adl.summary_date,
adl.store_location_id,
MIN(fi.price_in_cents) AS min_price,
MAX(fi.price_in_cents) AS max_price,
ROUND(AVG(fi.price_in_cents))::int AS avg_price,
COUNT(fi.flyer_item_id)::int AS data_points
FROM affected_days_and_locations adl
LEFT JOIN public.flyer_items fi ON fi.master_item_id = OLD.master_item_id AND fi.price_in_cents IS NOT NULL
LEFT JOIN public.flyers f ON fi.flyer_id = f.flyer_id AND adl.summary_date BETWEEN f.valid_from AND f.valid_to
LEFT JOIN public.flyer_locations fl ON fi.flyer_id = fl.flyer_id AND adl.store_location_id = fl.store_location_id
WHERE fl.flyer_id IS NOT NULL
GROUP BY adl.summary_date, adl.store_location_id
)
DELETE FROM public.item_price_history iph
WHERE iph.master_item_id = OLD.master_item_id
AND NOT EXISTS (
@@ -3003,22 +3196,45 @@ DROP FUNCTION IF EXISTS public.update_recipe_rating_aggregates();
CREATE OR REPLACE FUNCTION public.update_recipe_rating_aggregates()
RETURNS TRIGGER AS $$
DECLARE
v_recipe_id BIGINT;
v_rows_updated INTEGER;
v_context JSONB;
BEGIN
v_recipe_id := COALESCE(NEW.recipe_id, OLD.recipe_id);
v_context := jsonb_build_object('recipe_id', v_recipe_id);
UPDATE public.recipes
SET
avg_rating = (
SELECT AVG(rating)
FROM public.recipe_ratings
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed
WHERE recipe_id = v_recipe_id
),
rating_count = (
SELECT COUNT(*)
FROM public.recipe_ratings
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id) -- This is correct, no change needed
WHERE recipe_id = v_recipe_id
)
WHERE recipe_id = COALESCE(NEW.recipe_id, OLD.recipe_id);
WHERE recipe_id = v_recipe_id;
-- Tier 3 logging: Log when recipe update fails
GET DIAGNOSTICS v_rows_updated = ROW_COUNT;
IF v_rows_updated = 0 THEN
PERFORM fn_log('ERROR', 'update_recipe_rating_aggregates',
'Recipe not found for rating aggregate update',
v_context);
END IF;
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
EXCEPTION
WHEN OTHERS THEN
-- Tier 3 logging: Log unexpected errors in trigger
PERFORM fn_log('ERROR', 'update_recipe_rating_aggregates',
'Unexpected error in rating aggregate update: ' || SQLERRM,
v_context);
-- Re-raise the exception to ensure trigger failure is visible
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -3033,12 +3249,30 @@ DROP FUNCTION IF EXISTS public.log_new_recipe();
CREATE OR REPLACE FUNCTION public.log_new_recipe()
RETURNS TRIGGER AS $$
DECLARE
v_full_name TEXT;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'user_id', NEW.user_id,
'recipe_id', NEW.recipe_id,
'recipe_name', NEW.name
);
-- Get user's full name (Tier 3 logging: Log if profile lookup fails)
SELECT full_name INTO v_full_name FROM public.profiles WHERE user_id = NEW.user_id;
IF v_full_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_recipe',
'Profile not found for user creating recipe',
v_context);
v_full_name := 'Unknown User';
END IF;
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.user_id,
'recipe_created',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' created a new recipe: ' || NEW.name,
v_full_name || ' created a new recipe: ' || NEW.name,
'chef-hat',
jsonb_build_object('recipe_id', NEW.recipe_id, 'recipe_name', NEW.name)
);
@@ -3047,6 +3281,14 @@ BEGIN
PERFORM public.award_achievement(NEW.user_id, 'First Recipe');
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
-- Tier 3 logging: Log unexpected errors in trigger
PERFORM fn_log('ERROR', 'log_new_recipe',
'Unexpected error in recipe activity logging: ' || SQLERRM,
v_context);
-- Re-raise the exception to ensure trigger failure is visible
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -3063,13 +3305,39 @@ DROP FUNCTION IF EXISTS public.update_flyer_item_count();
CREATE OR REPLACE FUNCTION public.update_flyer_item_count()
RETURNS TRIGGER AS $$
DECLARE
v_rows_updated INTEGER;
v_context JSONB;
v_flyer_id BIGINT;
BEGIN
-- Determine which flyer_id to use based on operation
IF (TG_OP = 'INSERT') THEN
v_flyer_id := NEW.flyer_id;
v_context := jsonb_build_object('flyer_id', NEW.flyer_id, 'operation', 'INSERT');
UPDATE public.flyers SET item_count = item_count + 1 WHERE flyer_id = NEW.flyer_id;
ELSIF (TG_OP = 'DELETE') THEN
v_flyer_id := OLD.flyer_id;
v_context := jsonb_build_object('flyer_id', OLD.flyer_id, 'operation', 'DELETE');
UPDATE public.flyers SET item_count = item_count - 1 WHERE flyer_id = OLD.flyer_id;
END IF;
-- Tier 3 logging: Log if flyer not found
GET DIAGNOSTICS v_rows_updated = ROW_COUNT;
IF v_rows_updated = 0 THEN
PERFORM fn_log('ERROR', 'update_flyer_item_count',
'Flyer not found for item count update',
v_context);
END IF;
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'update_flyer_item_count',
'Unexpected error updating flyer item count: ' || SQLERRM,
v_context);
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -3085,27 +3353,55 @@ DROP FUNCTION IF EXISTS public.log_new_flyer();
CREATE OR REPLACE FUNCTION public.log_new_flyer()
RETURNS TRIGGER AS $$
DECLARE
v_store_name TEXT;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'flyer_id', NEW.flyer_id,
'store_id', NEW.store_id,
'uploaded_by', NEW.uploaded_by,
'valid_from', NEW.valid_from,
'valid_to', NEW.valid_to
);
-- If the flyer was uploaded by a registered user, award the 'First-Upload' achievement.
-- The award_achievement function handles checking if the user already has it.
IF NEW.uploaded_by IS NOT NULL THEN
PERFORM public.award_achievement(NEW.uploaded_by, 'First-Upload');
END IF;
-- Get store name (Tier 3 logging: Log if store lookup fails)
SELECT name INTO v_store_name FROM public.stores WHERE store_id = NEW.store_id;
IF v_store_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_flyer',
'Store not found for flyer',
v_context);
v_store_name := 'Unknown Store';
END IF;
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.uploaded_by, -- Log the user who uploaded it
'flyer_uploaded',
'A new flyer for ' || (SELECT name FROM public.stores WHERE store_id = NEW.store_id) || ' has been uploaded.',
'A new flyer for ' || v_store_name || ' has been uploaded.',
'file-text',
jsonb_build_object(
'flyer_id', NEW.flyer_id,
'store_name', (SELECT name FROM public.stores WHERE store_id = NEW.store_id),
'store_name', v_store_name,
'valid_from', to_char(NEW.valid_from, 'YYYY-MM-DD'),
'valid_to', to_char(NEW.valid_to, 'YYYY-MM-DD')
)
);
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
-- Tier 3 logging: Log unexpected errors in trigger
PERFORM fn_log('ERROR', 'log_new_flyer',
'Unexpected error in flyer activity logging: ' || SQLERRM,
v_context);
-- Re-raise the exception to ensure trigger failure is visible
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -3120,12 +3416,39 @@ DROP FUNCTION IF EXISTS public.log_new_favorite_recipe();
CREATE OR REPLACE FUNCTION public.log_new_favorite_recipe()
RETURNS TRIGGER AS $$
DECLARE
v_user_name TEXT;
v_recipe_name TEXT;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'user_id', NEW.user_id,
'recipe_id', NEW.recipe_id
);
-- Get user name (Tier 3 logging: Log if profile lookup fails)
SELECT full_name INTO v_user_name FROM public.profiles WHERE user_id = NEW.user_id;
IF v_user_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_favorite_recipe',
'Profile not found for user',
v_context);
v_user_name := 'Unknown User';
END IF;
-- Get recipe name (Tier 3 logging: Log if recipe lookup fails)
SELECT name INTO v_recipe_name FROM public.recipes WHERE recipe_id = NEW.recipe_id;
IF v_recipe_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_favorite_recipe',
'Recipe not found',
v_context);
v_recipe_name := 'Unknown Recipe';
END IF;
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.user_id,
'recipe_favorited',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.user_id) || ' favorited the recipe: ' || (SELECT name FROM public.recipes WHERE recipe_id = NEW.recipe_id),
v_user_name || ' favorited the recipe: ' || v_recipe_name,
'heart',
jsonb_build_object(
'recipe_id', NEW.recipe_id
@@ -3135,6 +3458,12 @@ BEGIN
-- Award 'First Favorite' achievement.
PERFORM public.award_achievement(NEW.user_id, 'First Favorite');
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'log_new_favorite_recipe',
'Unexpected error in favorite recipe activity logging: ' || SQLERRM,
v_context);
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -3144,16 +3473,44 @@ DROP FUNCTION IF EXISTS public.log_new_list_share();
CREATE OR REPLACE FUNCTION public.log_new_list_share()
RETURNS TRIGGER AS $$
DECLARE
v_user_name TEXT;
v_list_name TEXT;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'shared_by_user_id', NEW.shared_by_user_id,
'shopping_list_id', NEW.shopping_list_id,
'shared_with_user_id', NEW.shared_with_user_id
);
-- Get user name (Tier 3 logging: Log if profile lookup fails)
SELECT full_name INTO v_user_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id;
IF v_user_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_list_share',
'Profile not found for sharing user',
v_context);
v_user_name := 'Unknown User';
END IF;
-- Get list name (Tier 3 logging: Log if list lookup fails)
SELECT name INTO v_list_name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id;
IF v_list_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_list_share',
'Shopping list not found',
v_context);
v_list_name := 'Unknown List';
END IF;
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.shared_by_user_id,
'list_shared',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a shopping list.',
v_user_name || ' shared a shopping list.',
'share-2',
jsonb_build_object(
'shopping_list_id', NEW.shopping_list_id,
'list_name', (SELECT name FROM public.shopping_lists WHERE shopping_list_id = NEW.shopping_list_id),
'list_name', v_list_name,
'shared_with_user_id', NEW.shared_with_user_id
)
);
@@ -3161,6 +3518,12 @@ BEGIN
-- Award 'List Sharer' achievement.
PERFORM public.award_achievement(NEW.shared_by_user_id, 'List Sharer');
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'log_new_list_share',
'Unexpected error in list share activity logging: ' || SQLERRM,
v_context);
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -3169,12 +3532,30 @@ DROP FUNCTION IF EXISTS public.log_new_recipe_collection_share();
CREATE OR REPLACE FUNCTION public.log_new_recipe_collection_share()
RETURNS TRIGGER AS $$
DECLARE
v_user_name TEXT;
v_context JSONB;
BEGIN
v_context := jsonb_build_object(
'shared_by_user_id', NEW.shared_by_user_id,
'recipe_collection_id', NEW.recipe_collection_id,
'shared_with_user_id', NEW.shared_with_user_id
);
-- Get user name (Tier 3 logging: Log if profile lookup fails)
SELECT full_name INTO v_user_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id;
IF v_user_name IS NULL THEN
PERFORM fn_log('ERROR', 'log_new_recipe_collection_share',
'Profile not found for sharing user',
v_context);
v_user_name := 'Unknown User';
END IF;
-- Log the activity
INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES (
NEW.shared_by_user_id, 'recipe_collection_shared',
(SELECT full_name FROM public.profiles WHERE user_id = NEW.shared_by_user_id) || ' shared a recipe collection.',
v_user_name || ' shared a recipe collection.',
'book',
jsonb_build_object('collection_id', NEW.recipe_collection_id, 'shared_with_user_id', NEW.shared_with_user_id)
);
@@ -3182,6 +3563,12 @@ BEGIN
-- Award 'Recipe Sharer' achievement.
PERFORM public.award_achievement(NEW.shared_by_user_id, 'Recipe Sharer');
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'log_new_recipe_collection_share',
'Unexpected error in recipe collection share activity logging: ' || SQLERRM,
v_context);
RAISE;
END;
$$ LANGUAGE plpgsql;
@@ -3244,14 +3631,38 @@ DROP FUNCTION IF EXISTS public.increment_recipe_fork_count();
CREATE OR REPLACE FUNCTION public.increment_recipe_fork_count()
RETURNS TRIGGER AS $$
DECLARE
v_rows_updated INTEGER;
v_context JSONB;
BEGIN
-- Only run if the recipe is a fork (original_recipe_id is not null).
IF NEW.original_recipe_id IS NOT NULL THEN
v_context := jsonb_build_object(
'recipe_id', NEW.recipe_id,
'original_recipe_id', NEW.original_recipe_id,
'user_id', NEW.user_id
);
-- Tier 3 logging: Log if original recipe not found
UPDATE public.recipes SET fork_count = fork_count + 1 WHERE recipe_id = NEW.original_recipe_id;
GET DIAGNOSTICS v_rows_updated = ROW_COUNT;
IF v_rows_updated = 0 THEN
PERFORM fn_log('ERROR', 'increment_recipe_fork_count',
'Original recipe not found for fork count increment',
v_context);
END IF;
-- Award 'First Fork' achievement.
PERFORM public.award_achievement(NEW.user_id, 'First Fork');
END IF;
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
PERFORM fn_log('ERROR', 'increment_recipe_fork_count',
'Unexpected error incrementing fork count: ' || SQLERRM,
v_context);
RAISE;
END;
$$ LANGUAGE plpgsql;

View File

@@ -58,6 +58,7 @@ const mockFlyerItems: FlyerItem[] = [
quantity: 'per lb',
unit_price: { value: 1.99, unit: 'lb' },
master_item_id: 1,
category_id: 1,
category_name: 'Produce',
flyer_id: 1,
}),
@@ -69,6 +70,7 @@ const mockFlyerItems: FlyerItem[] = [
quantity: '4L',
unit_price: { value: 1.125, unit: 'L' },
master_item_id: 2,
category_id: 2,
category_name: 'Dairy',
flyer_id: 1,
}),
@@ -80,6 +82,7 @@ const mockFlyerItems: FlyerItem[] = [
quantity: 'per kg',
unit_price: { value: 8.0, unit: 'kg' },
master_item_id: 3,
category_id: 3,
category_name: 'Meat',
flyer_id: 1,
}),
@@ -241,7 +244,7 @@ describe('ExtractedDataTable', () => {
expect(watchButton).toBeInTheDocument();
fireEvent.click(watchButton);
expect(mockAddWatchedItem).toHaveBeenCalledWith('Chicken Breast', 'Meat');
expect(mockAddWatchedItem).toHaveBeenCalledWith('Chicken Breast', 3);
});
it('should not show watch or add to list buttons for unmatched items', () => {
@@ -589,7 +592,7 @@ describe('ExtractedDataTable', () => {
const watchButton = within(itemRow).getByTitle("Add 'Canonical Mystery' to your watchlist");
fireEvent.click(watchButton);
expect(mockAddWatchedItem).toHaveBeenCalledWith('Canonical Mystery', 'Other/Miscellaneous');
expect(mockAddWatchedItem).toHaveBeenCalledWith('Canonical Mystery', 19);
});
it('should not call addItemToList when activeListId is null and button is clicked', () => {

View File

@@ -25,7 +25,7 @@ interface ExtractedDataTableRowProps {
isAuthenticated: boolean;
activeListId: number | null;
onAddItemToList: (masterItemId: number) => void;
onAddWatchedItem: (itemName: string, category: string) => void;
onAddWatchedItem: (itemName: string, category_id: number) => void;
}
/**
@@ -72,9 +72,7 @@ const ExtractedDataTableRow: React.FC<ExtractedDataTableRowProps> = memo(
)}
{isAuthenticated && !isWatched && canonicalName && (
<button
onClick={() =>
onAddWatchedItem(canonicalName, item.category_name || 'Other/Miscellaneous')
}
onClick={() => onAddWatchedItem(canonicalName, item.category_id || 19)}
className="text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-brand-primary dark:text-brand-light font-semibold py-1 px-2.5 rounded-md transition-colors duration-200"
title={`Add '${canonicalName}' to your watchlist`}
>
@@ -159,8 +157,8 @@ export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, u
);
const handleAddWatchedItem = useCallback(
(itemName: string, category: string) => {
addWatchedItem(itemName, category);
(itemName: string, category_id: number) => {
addWatchedItem(itemName, category_id);
},
[addWatchedItem],
);

View File

@@ -1,15 +1,28 @@
// src/features/shopping/WatchedItemsList.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WatchedItemsList } from './WatchedItemsList';
import type { MasterGroceryItem } from '../../types';
import { logger } from '../../services/logger.client';
import type { MasterGroceryItem, Category } from '../../types';
import { createMockMasterGroceryItem, createMockUser } from '../../tests/utils/mockFactories';
// Mock the logger to spy on error calls
vi.mock('../../services/logger.client');
// Mock the categories query hook
vi.mock('../../hooks/queries/useCategoriesQuery', () => ({
useCategoriesQuery: () => ({
data: [
{ category_id: 1, name: 'Produce', created_at: '2024-01-01', updated_at: '2024-01-01' },
{ category_id: 2, name: 'Dairy', created_at: '2024-01-01', updated_at: '2024-01-01' },
{ category_id: 3, name: 'Bakery', created_at: '2024-01-01', updated_at: '2024-01-01' },
] as Category[],
isLoading: false,
error: null,
}),
}));
const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com' });
const mockItems: MasterGroceryItem[] = [
@@ -52,6 +65,16 @@ const defaultProps = {
onAddItemToList: mockOnAddItemToList,
};
// Helper function to wrap component with QueryClientProvider
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
describe('WatchedItemsList (in shopping feature)', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -60,7 +83,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
});
it('should render a login message when user is not authenticated', () => {
render(<WatchedItemsList {...defaultProps} user={null} />);
renderWithQueryClient(<WatchedItemsList {...defaultProps} user={null} />);
expect(
screen.getByText(/please log in to create and manage your personal watchlist/i),
).toBeInTheDocument();
@@ -68,7 +91,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
});
it('should render the form and item list when user is authenticated', () => {
render(<WatchedItemsList {...defaultProps} />);
renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
expect(screen.getByPlaceholderText(/add item/i)).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /filter by category/i })).toBeInTheDocument();
expect(screen.getByText('Apples')).toBeInTheDocument();
@@ -76,57 +99,8 @@ describe('WatchedItemsList (in shopping feature)', () => {
expect(screen.getByText('Bread')).toBeInTheDocument();
});
it('should allow adding a new item', async () => {
render(<WatchedItemsList {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } });
// Use getByDisplayValue to reliably select the category dropdown, which has no label.
// Also, use the correct category name from the CATEGORIES constant.
const categorySelect = screen.getByDisplayValue('Select a category');
fireEvent.change(categorySelect, { target: { value: 'Dairy & Eggs' } });
fireEvent.submit(screen.getByRole('button', { name: 'Add' }));
await waitFor(() => {
expect(mockOnAddItem).toHaveBeenCalledWith('Cheese', 'Dairy & Eggs');
});
// Check if form resets
expect(screen.getByPlaceholderText(/add item/i)).toHaveValue('');
});
it('should show a loading spinner while adding an item', async () => {
// Create a promise that we can resolve manually to control the loading state
let resolvePromise: (value: void | PromiseLike<void>) => void;
const mockPromise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
mockOnAddItem.mockImplementation(() => mockPromise);
render(<WatchedItemsList {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } });
fireEvent.change(screen.getByDisplayValue('Select a category'), {
target: { value: 'Dairy & Eggs' },
});
const addButton = screen.getByRole('button', { name: 'Add' });
fireEvent.click(addButton);
// The button text is replaced by the spinner, so we use the captured reference
await waitFor(() => {
expect(addButton).toBeDisabled();
});
expect(addButton.querySelector('.animate-spin')).toBeInTheDocument();
// Resolve the promise to complete the async operation and allow the test to finish
await act(async () => {
resolvePromise();
await mockPromise;
});
});
it('should allow removing an item', async () => {
render(<WatchedItemsList {...defaultProps} />);
renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const removeButton = screen.getByRole('button', { name: /remove apples/i });
fireEvent.click(removeButton);
@@ -136,7 +110,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
});
it('should filter items by category', () => {
render(<WatchedItemsList {...defaultProps} />);
renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const categoryFilter = screen.getByRole('combobox', { name: /filter by category/i });
fireEvent.change(categoryFilter, { target: { value: 'Dairy' } });
@@ -147,7 +121,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
});
it('should sort items ascending and descending', () => {
render(<WatchedItemsList {...defaultProps} />);
renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const sortButton = screen.getByRole('button', { name: /sort items descending/i });
const itemsAsc = screen.getAllByRole('listitem');
@@ -176,14 +150,14 @@ describe('WatchedItemsList (in shopping feature)', () => {
});
it('should call onAddItemToList when plus icon is clicked', () => {
render(<WatchedItemsList {...defaultProps} />);
renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const addToListButton = screen.getByTitle('Add Apples to list');
fireEvent.click(addToListButton);
expect(mockOnAddItemToList).toHaveBeenCalledWith(1); // ID for Apples
});
it('should disable the add to list button if activeListId is null', () => {
render(<WatchedItemsList {...defaultProps} activeListId={null} />);
renderWithQueryClient(<WatchedItemsList {...defaultProps} activeListId={null} />);
// Multiple buttons will have this title, so we must use `getAllByTitle`.
const addToListButtons = screen.getAllByTitle('Select a shopping list first');
// Assert that at least one such button exists and that they are all disabled.
@@ -192,85 +166,10 @@ describe('WatchedItemsList (in shopping feature)', () => {
});
it('should display a message when the list is empty', () => {
render(<WatchedItemsList {...defaultProps} items={[]} />);
renderWithQueryClient(<WatchedItemsList {...defaultProps} items={[]} />);
expect(screen.getByText(/your watchlist is empty/i)).toBeInTheDocument();
});
describe('Form Validation and Disabled States', () => {
it('should disable the "Add" button if item name is empty or whitespace', () => {
render(<WatchedItemsList {...defaultProps} />);
const nameInput = screen.getByPlaceholderText(/add item/i);
const categorySelect = screen.getByDisplayValue('Select a category');
const addButton = screen.getByRole('button', { name: 'Add' });
// Initially disabled
expect(addButton).toBeDisabled();
// With category but no name
fireEvent.change(categorySelect, { target: { value: 'Fruits & Vegetables' } });
expect(addButton).toBeDisabled();
// With whitespace name
fireEvent.change(nameInput, { target: { value: ' ' } });
expect(addButton).toBeDisabled();
// With valid name
fireEvent.change(nameInput, { target: { value: 'Grapes' } });
expect(addButton).toBeEnabled();
});
it('should disable the "Add" button if category is not selected', () => {
render(<WatchedItemsList {...defaultProps} />);
const nameInput = screen.getByPlaceholderText(/add item/i);
const addButton = screen.getByRole('button', { name: 'Add' });
// Initially disabled
expect(addButton).toBeDisabled();
// With name but no category
fireEvent.change(nameInput, { target: { value: 'Grapes' } });
expect(addButton).toBeDisabled();
});
it('should not submit if form is submitted with invalid data', () => {
render(<WatchedItemsList {...defaultProps} />);
const nameInput = screen.getByPlaceholderText(/add item/i);
const form = nameInput.closest('form')!;
const categorySelect = screen.getByDisplayValue('Select a category');
fireEvent.change(categorySelect, { target: { value: 'Dairy & Eggs' } });
fireEvent.change(nameInput, { target: { value: ' ' } });
fireEvent.submit(form);
expect(mockOnAddItem).not.toHaveBeenCalled();
});
});
describe('Error Handling', () => {
it('should reset loading state and log an error if onAddItem rejects', async () => {
const apiError = new Error('Item already exists');
mockOnAddItem.mockRejectedValue(apiError);
const loggerSpy = vi.spyOn(logger, 'error');
render(<WatchedItemsList {...defaultProps} />);
const nameInput = screen.getByPlaceholderText(/add item/i);
const categorySelect = screen.getByDisplayValue('Select a category');
const addButton = screen.getByRole('button', { name: 'Add' });
fireEvent.change(nameInput, { target: { value: 'Duplicate Item' } });
fireEvent.change(categorySelect, { target: { value: 'Fruits & Vegetables' } });
fireEvent.click(addButton);
// After the promise rejects, the button should be enabled again
await waitFor(() => expect(addButton).toBeEnabled());
// And the error should be logged
expect(loggerSpy).toHaveBeenCalledWith('Failed to add watched item from WatchedItemsList', {
error: apiError,
});
});
});
describe('UI Edge Cases', () => {
it('should display a specific message when a filter results in no items', () => {
const { rerender } = render(<WatchedItemsList {...defaultProps} />);
@@ -289,7 +188,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
});
it('should hide the sort button if there is only one item', () => {
render(<WatchedItemsList {...defaultProps} items={[mockItems[0]]} />);
renderWithQueryClient(<WatchedItemsList {...defaultProps} items={[mockItems[0]]} />);
expect(screen.queryByRole('button', { name: /sort items/i })).not.toBeInTheDocument();
});
});

View File

@@ -5,14 +5,15 @@ import { EyeIcon } from '../../components/icons/EyeIcon';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { SortAscIcon } from '../../components/icons/SortAscIcon';
import { SortDescIcon } from '../../components/icons/SortDescIcon';
import { CATEGORIES } from '../../types';
import { TrashIcon } from '../../components/icons/TrashIcon';
import { UserIcon } from '../../components/icons/UserIcon';
import { PlusCircleIcon } from '../../components/icons/PlusCircleIcon';
import { logger } from '../../services/logger.client';
import { useCategoriesQuery } from '../../hooks/queries/useCategoriesQuery';
interface WatchedItemsListProps {
items: MasterGroceryItem[];
onAddItem: (itemName: string, category: string) => Promise<void>;
onAddItem: (itemName: string, category_id: number) => Promise<void>;
onRemoveItem: (masterItemId: number) => Promise<void>;
user: User | null;
activeListId: number | null;
@@ -28,20 +29,21 @@ export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({
onAddItemToList,
}) => {
const [newItemName, setNewItemName] = useState('');
const [newCategory, setNewCategory] = useState('');
const [newCategoryId, setNewCategoryId] = useState<number | ''>('');
const [isAdding, setIsAdding] = useState(false);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [categoryFilter, setCategoryFilter] = useState('all');
const { data: categories = [] } = useCategoriesQuery();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newItemName.trim() || !newCategory) return;
if (!newItemName.trim() || !newCategoryId) return;
setIsAdding(true);
try {
await onAddItem(newItemName, newCategory);
await onAddItem(newItemName, newCategoryId as number);
setNewItemName('');
setNewCategory('');
setNewCategoryId('');
} catch (error) {
// Error is handled in the parent component
logger.error('Failed to add watched item from WatchedItemsList', { error });
@@ -139,8 +141,8 @@ export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({
/>
<div className="grid grid-cols-3 gap-2">
<select
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
value={newCategoryId}
onChange={(e) => setNewCategoryId(Number(e.target.value))}
required
className="col-span-2 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"
disabled={isAdding}
@@ -148,15 +150,15 @@ export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({
<option value="" disabled>
Select a category
</option>
{CATEGORIES.map((cat) => (
<option key={cat} value={cat}>
{cat}
{categories.map((cat) => (
<option key={cat.category_id} value={cat.category_id}>
{cat.name}
</option>
))}
</select>
<button
type="submit"
disabled={isAdding || !newItemName.trim() || !newCategory}
disabled={isAdding || !newItemName.trim() || !newCategoryId}
className="col-span-1 bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-bold py-2 px-3 rounded-lg transition-colors duration-300 flex items-center justify-center"
>
{isAdding ? (

View File

@@ -30,8 +30,8 @@ describe('useAddWatchedItemMutation', () => {
});
});
it('should add a watched item successfully with category', async () => {
const mockResponse = { id: 1, item_name: 'Milk', category: 'Dairy' };
it('should add a watched item successfully with category_id', async () => {
const mockResponse = { id: 1, item_name: 'Milk', category_id: 3 };
mockedApiClient.addWatchedItem.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
@@ -39,15 +39,15 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Milk', category: 'Dairy' });
result.current.mutate({ itemName: 'Milk', category_id: 3 });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.addWatchedItem).toHaveBeenCalledWith('Milk', 'Dairy');
expect(mockedApiClient.addWatchedItem).toHaveBeenCalledWith('Milk', 3);
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith('Item added to watched list');
});
it('should add a watched item without category', async () => {
it('should add a watched item with category_id', async () => {
const mockResponse = { id: 1, item_name: 'Bread' };
mockedApiClient.addWatchedItem.mockResolvedValue({
ok: true,
@@ -56,11 +56,11 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Bread' });
result.current.mutate({ itemName: 'Bread', category_id: 4 });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.addWatchedItem).toHaveBeenCalledWith('Bread', '');
expect(mockedApiClient.addWatchedItem).toHaveBeenCalledWith('Bread', 4);
});
it('should invalidate watched-items query on success', async () => {
@@ -73,7 +73,7 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Eggs' });
result.current.mutate({ itemName: 'Eggs', category_id: 3 });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -89,7 +89,7 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Milk' });
result.current.mutate({ itemName: 'Milk', category_id: 3 });
await waitFor(() => expect(result.current.isError).toBe(true));
@@ -106,7 +106,7 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Cheese' });
result.current.mutate({ itemName: 'Cheese', category_id: 3 });
await waitFor(() => expect(result.current.isError).toBe(true));
@@ -122,7 +122,7 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Butter' });
result.current.mutate({ itemName: 'Butter', category_id: 3 });
await waitFor(() => expect(result.current.isError).toBe(true));
@@ -134,7 +134,7 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Yogurt' });
result.current.mutate({ itemName: 'Yogurt', category_id: 3 });
await waitFor(() => expect(result.current.isError).toBe(true));

View File

@@ -6,7 +6,7 @@ import { queryKeyBases } from '../../config/queryKeys';
interface AddWatchedItemParams {
itemName: string;
category?: string;
category_id: number;
}
/**
@@ -24,7 +24,7 @@ interface AddWatchedItemParams {
*
* const handleAdd = () => {
* addWatchedItem.mutate(
* { itemName: 'Milk', category: 'Dairy' },
* { itemName: 'Milk', category_id: 3 },
* {
* onSuccess: () => console.log('Added!'),
* onError: (error) => console.error(error),
@@ -37,8 +37,8 @@ export const useAddWatchedItemMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ itemName, category }: AddWatchedItemParams) => {
const response = await apiClient.addWatchedItem(itemName, category ?? '');
mutationFn: async ({ itemName, category_id }: AddWatchedItemParams) => {
const response = await apiClient.addWatchedItem(itemName, category_id);
if (!response.ok) {
const error = await response.json().catch(() => ({

View File

@@ -100,13 +100,13 @@ describe('useWatchedItems Hook', () => {
const { result } = renderHook(() => useWatchedItems());
await act(async () => {
await result.current.addWatchedItem('Cheese', 'Dairy');
await result.current.addWatchedItem('Cheese', 3);
});
// Verify mutation was called with correct parameters
expect(mockMutateAsync).toHaveBeenCalledWith({
itemName: 'Cheese',
category: 'Dairy',
category_id: 3,
});
});
@@ -128,7 +128,7 @@ describe('useWatchedItems Hook', () => {
const { result } = renderHook(() => useWatchedItems());
await act(async () => {
await result.current.addWatchedItem('Failing Item', 'Error');
await result.current.addWatchedItem('Failing Item', 1);
});
// Should not throw - error is caught and logged
@@ -191,7 +191,7 @@ describe('useWatchedItems Hook', () => {
const { result } = renderHook(() => useWatchedItems());
await act(async () => {
await result.current.addWatchedItem('Test', 'Category');
await result.current.addWatchedItem('Test', 1);
await result.current.removeWatchedItem(1);
});

View File

@@ -36,11 +36,11 @@ const useWatchedItemsHook = () => {
* Uses TanStack Query mutation which automatically invalidates the cache.
*/
const addWatchedItem = useCallback(
async (itemName: string, category: string) => {
async (itemName: string, category_id: number) => {
if (!userProfile) return;
try {
await addWatchedItemMutation.mutateAsync({ itemName, category });
await addWatchedItemMutation.mutateAsync({ itemName, category_id });
} catch (error) {
// Error is already handled by the mutation hook (notification shown)
// Just log for debugging

View File

@@ -0,0 +1,195 @@
// src/routes/category.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { CategoryDbService } from '../services/db/category.db';
const router = Router();
/**
* @swagger
* /api/categories:
* get:
* summary: List all available grocery categories
* description: Returns a list of all predefined grocery categories. Use this endpoint to populate category dropdowns in the UI.
* tags: [Categories]
* responses:
* 200:
* description: List of categories ordered alphabetically by name
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: array
* items:
* type: object
* properties:
* category_id:
* type: integer
* example: 3
* name:
* type: string
* example: "Dairy & Eggs"
* created_at:
* type: string
* format: date-time
* updated_at:
* type: string
* format: date-time
* 500:
* description: Server error
*/
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const categories = await CategoryDbService.getAllCategories(req.log);
res.json({
success: true,
data: categories,
});
} catch (error) {
next(error);
}
});
/**
* @swagger
* /api/categories/lookup:
* get:
* summary: Lookup category by name
* description: Find a category by its name (case-insensitive). This endpoint is provided for migration support to help clients transition from using category names to category IDs.
* tags: [Categories]
* parameters:
* - in: query
* name: name
* required: true
* schema:
* type: string
* description: The category name to search for (case-insensitive)
* example: "Dairy & Eggs"
* responses:
* 200:
* description: Category found
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* category_id:
* type: integer
* name:
* type: string
* 404:
* description: Category not found
* 400:
* description: Missing or invalid query parameter
*/
router.get('/lookup', async (req: Request, res: Response, next: NextFunction) => {
try {
const name = req.query.name as string;
if (!name || typeof name !== 'string' || name.trim() === '') {
return res.status(400).json({
success: false,
error: 'Query parameter "name" is required and must be a non-empty string',
});
}
const category = await CategoryDbService.getCategoryByName(name, req.log);
if (!category) {
return res.status(404).json({
success: false,
error: `Category '${name}' not found`,
});
}
res.json({
success: true,
data: category,
});
} catch (error) {
next(error);
}
});
/**
* @swagger
* /api/categories/{id}:
* get:
* summary: Get a specific category by ID
* description: Retrieve detailed information about a single category
* tags: [Categories]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: The category ID
* responses:
* 200:
* description: Category details
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* category_id:
* type: integer
* name:
* type: string
* created_at:
* type: string
* format: date-time
* updated_at:
* type: string
* format: date-time
* 404:
* description: Category not found
* 400:
* description: Invalid category ID
*/
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const categoryId = parseInt(req.params.id, 10);
if (isNaN(categoryId) || categoryId <= 0) {
return res.status(400).json({
success: false,
error: 'Invalid category ID. Must be a positive integer.',
});
}
const category = await CategoryDbService.getCategoryById(categoryId, req.log);
if (!category) {
return res.status(404).json({
success: false,
error: `Category with ID ${categoryId} not found`,
});
}
res.json({
success: true,
data: category,
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -204,7 +204,7 @@ describe('User Routes (/api/users)', () => {
describe('POST /watched-items', () => {
it('should add an item to the watchlist and return the new item', async () => {
const newItem = { itemName: 'Organic Bananas', category: 'Produce' };
const newItem = { itemName: 'Organic Bananas', category_id: 5 };
const mockAddedItem = createMockMasterGroceryItem({
master_grocery_item_id: 99,
name: 'Organic Bananas',
@@ -221,7 +221,7 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.personalizationRepo.addWatchedItem).mockRejectedValue(dbError);
const response = await supertest(app)
.post('/api/users/watched-items')
.send({ itemName: 'Test', category: 'Produce' });
.send({ itemName: 'Test', category_id: 5 });
expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalled();
});
@@ -231,19 +231,19 @@ describe('User Routes (/api/users)', () => {
it('should return 400 if itemName is missing', async () => {
const response = await supertest(app)
.post('/api/users/watched-items')
.send({ category: 'Produce' });
.send({ category_id: 5 });
expect(response.status).toBe(400);
// Check the 'error.details' array for the specific validation message.
expect(response.body.error.details[0].message).toBe("Field 'itemName' is required.");
});
it('should return 400 if category is missing', async () => {
it('should return 400 if category_id is missing', async () => {
const response = await supertest(app)
.post('/api/users/watched-items')
.send({ itemName: 'Apples' });
expect(response.status).toBe(400);
// Check the 'error.details' array for the specific validation message.
expect(response.body.error.details[0].message).toBe("Field 'category' is required.");
expect(response.body.error.details[0].message).toContain('expected number');
});
});
@@ -253,7 +253,7 @@ describe('User Routes (/api/users)', () => {
);
const response = await supertest(app)
.post('/api/users/watched-items')
.send({ itemName: 'Test', category: 'Invalid' });
.send({ itemName: 'Test', category_id: 999 });
expect(response.status).toBe(400);
});

View File

@@ -73,7 +73,7 @@ const deleteAccountSchema = z.object({
const addWatchedItemSchema = z.object({
body: z.object({
itemName: requiredString("Field 'itemName' is required."),
category: requiredString("Field 'category' is required."),
category_id: z.number().int().positive("Field 'category_id' must be a positive integer."),
}),
});
@@ -690,7 +690,7 @@ router.post(
const newItem = await db.personalizationRepo.addWatchedItem(
userProfile.user.user_id,
body.itemName,
body.category,
body.category_id,
req.log,
);
sendSuccess(res, newItem, 201);

View File

@@ -16,7 +16,6 @@ import {
createMockRegisterUserPayload,
createMockSearchQueryPayload,
createMockShoppingListItemPayload,
createMockWatchedItemPayload,
} from '../tests/utils/mockFactories';
// Mock the logger to keep test output clean and verifiable.
@@ -319,11 +318,8 @@ describe('API Client', () => {
});
it('addWatchedItem should send a POST request with the correct body', async () => {
const watchedItemData = createMockWatchedItemPayload({
itemName: 'Apples',
category: 'Produce',
});
await apiClient.addWatchedItem(watchedItemData.itemName, watchedItemData.category);
const watchedItemData = { itemName: 'Apples', category_id: 5 };
await apiClient.addWatchedItem(watchedItemData.itemName, watchedItemData.category_id);
expect(capturedUrl?.pathname).toBe('/api/users/watched-items');
expect(capturedBody).toEqual(watchedItemData);

View File

@@ -433,10 +433,10 @@ export const fetchWatchedItems = (tokenOverride?: string): Promise<Response> =>
export const addWatchedItem = (
itemName: string,
category: string,
category_id: number,
tokenOverride?: string,
): Promise<Response> =>
authedPost('/users/watched-items', { itemName, category }, { tokenOverride });
authedPost('/users/watched-items', { itemName, category_id }, { tokenOverride });
export const removeWatchedItem = (
masterItemId: number,

View File

@@ -224,11 +224,11 @@ describe('AuthService', () => {
expect(result).toEqual({
newUserProfile: mockUserProfile,
accessToken: 'access-token',
refreshToken: 'mocked_random_id',
refreshToken: expect.any(String),
});
expect(userRepo.saveRefreshToken).toHaveBeenCalledWith(
'user-123',
'mocked_random_id',
expect.any(String),
reqLog,
);
});
@@ -254,7 +254,7 @@ describe('AuthService', () => {
);
expect(result).toEqual({
accessToken: 'access-token',
refreshToken: 'mocked_random_id',
refreshToken: expect.any(String),
});
});
});
@@ -293,10 +293,10 @@ describe('AuthService', () => {
);
expect(sendPasswordResetEmail).toHaveBeenCalledWith(
'test@example.com',
expect.stringContaining('/reset-password/mocked_random_id'),
expect.stringMatching(/\/reset-password\/[a-f0-9]+/),
reqLog,
);
expect(result).toBe('mocked_random_id');
expect(result).toEqual(expect.any(String));
});
it('should log warning and return undefined for non-existent user', async () => {
@@ -333,7 +333,7 @@ describe('AuthService', () => {
{ emailError },
`Email send failure during password reset for user`,
);
expect(result).toBe('mocked_random_id');
expect(result).toEqual(expect.any(String));
});
it('should re-throw RepositoryError', async () => {

View File

@@ -0,0 +1,92 @@
// src/services/db/category.db.ts
import { Logger } from 'pino';
import { getPool } from './connection.db';
import { handleDbError } from './errors.db';
export interface Category {
category_id: number;
name: string;
created_at: Date;
updated_at: Date;
}
/**
* Database service for category operations.
* Categories are predefined grocery item categories (e.g., "Dairy & Eggs", "Fruits & Vegetables").
*/
export class CategoryDbService {
/**
* Get all categories ordered by name.
* This endpoint is used for populating category dropdowns in the UI.
*
* @param logger - Pino logger instance
* @returns Promise resolving to array of categories
*/
static async getAllCategories(logger: Logger): Promise<Category[]> {
const pool = getPool();
try {
const result = await pool.query<Category>(
`SELECT category_id, name, created_at, updated_at
FROM public.categories
ORDER BY name ASC`,
);
return result.rows;
} catch (error) {
handleDbError(error, logger, 'Error fetching all categories', {});
throw error;
}
}
/**
* Get a specific category by its ID.
*
* @param categoryId - The category ID to retrieve
* @param logger - Pino logger instance
* @returns Promise resolving to category or null if not found
*/
static async getCategoryById(categoryId: number, logger: Logger): Promise<Category | null> {
const pool = getPool();
try {
const result = await pool.query<Category>(
`SELECT category_id, name, created_at, updated_at
FROM public.categories
WHERE category_id = $1`,
[categoryId],
);
return result.rows[0] || null;
} catch (error) {
handleDbError(error, logger, 'Error fetching category by ID', { categoryId });
throw error;
}
}
/**
* Get a category by its name (case-insensitive).
* This is primarily used for migration support to allow clients to lookup category IDs by name.
*
* @param name - The category name to search for
* @param logger - Pino logger instance
* @returns Promise resolving to category or null if not found
*/
static async getCategoryByName(name: string, logger: Logger): Promise<Category | null> {
const pool = getPool();
try {
const result = await pool.query<Category>(
`SELECT category_id, name, created_at, updated_at
FROM public.categories
WHERE LOWER(name) = LOWER($1)`,
[name],
);
return result.rows[0] || null;
} catch (error) {
handleDbError(error, logger, 'Error fetching category by name', { name });
throw error;
}
}
}

View File

@@ -138,18 +138,18 @@ describe('Personalization DB Service', () => {
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: mockClientQuery };
mockClientQuery
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Verify category exists
.mockResolvedValueOnce({ rows: [mockItem] }) // Find master item
.mockResolvedValueOnce({ rows: [] }); // Insert into watchlist
return callback(mockClient as unknown as PoolClient);
});
await personalizationRepo.addWatchedItem('user-123', 'New Item', 'Produce', mockLogger);
await personalizationRepo.addWatchedItem('user-123', 'New Item', 1, mockLogger);
expect(withTransaction).toHaveBeenCalledTimes(1);
expect(mockClientQuery).toHaveBeenCalledWith(
expect.stringContaining('SELECT category_id FROM public.categories'),
['Produce'],
expect.stringContaining('SELECT category_id FROM public.categories WHERE category_id'),
[1],
);
expect(mockClientQuery).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM public.master_grocery_items'),
@@ -170,7 +170,7 @@ describe('Personalization DB Service', () => {
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: mockClientQuery };
mockClientQuery
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Verify category exists
.mockResolvedValueOnce({ rows: [] }) // Find master item (not found)
.mockResolvedValueOnce({ rows: [mockNewItem] }) // INSERT new master item
.mockResolvedValueOnce({ rows: [] }); // Insert into watchlist
@@ -180,7 +180,7 @@ describe('Personalization DB Service', () => {
const result = await personalizationRepo.addWatchedItem(
'user-123',
'Brand New Item',
'Produce',
1,
mockLogger,
);
@@ -200,7 +200,7 @@ describe('Personalization DB Service', () => {
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: mockClientQuery };
mockClientQuery
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Verify category exists
.mockResolvedValueOnce({ rows: [mockExistingItem] }) // Find master item
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // INSERT...ON CONFLICT DO NOTHING
return callback(mockClient as unknown as PoolClient);
@@ -208,7 +208,7 @@ describe('Personalization DB Service', () => {
// The function should resolve successfully without throwing an error.
await expect(
personalizationRepo.addWatchedItem('user-123', 'Existing Item', 'Produce', mockLogger),
personalizationRepo.addWatchedItem('user-123', 'Existing Item', 1, mockLogger),
).resolves.toEqual(mockExistingItem);
expect(mockClientQuery).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.user_watched_items'),
@@ -220,20 +220,20 @@ describe('Personalization DB Service', () => {
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) };
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(
"Category 'Fake Category' not found.",
'Category with ID 999 not found.',
);
throw new Error("Category 'Fake Category' not found.");
throw new Error('Category with ID 999 not found.');
});
await expect(
personalizationRepo.addWatchedItem('user-123', 'Some Item', 'Fake Category', mockLogger),
personalizationRepo.addWatchedItem('user-123', 'Some Item', 999, mockLogger),
).rejects.toThrow('Failed to add item to watchlist.');
expect(mockLogger.error).toHaveBeenCalledWith(
{
err: expect.any(Error),
userId: 'user-123',
itemName: 'Some Item',
categoryName: 'Fake Category',
categoryId: 999,
},
'Transaction error in addWatchedItem',
);
@@ -251,10 +251,10 @@ describe('Personalization DB Service', () => {
});
await expect(
personalizationRepo.addWatchedItem('user-123', 'Failing Item', 'Produce', mockLogger),
personalizationRepo.addWatchedItem('user-123', 'Failing Item', 1, mockLogger),
).rejects.toThrow('Failed to add item to watchlist.');
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, userId: 'user-123', itemName: 'Failing Item', categoryName: 'Produce' },
{ err: dbError, userId: 'user-123', itemName: 'Failing Item', categoryId: 1 },
'Transaction error in addWatchedItem',
);
});
@@ -265,7 +265,7 @@ describe('Personalization DB Service', () => {
vi.mocked(withTransaction).mockRejectedValue(dbError);
await expect(
personalizationRepo.addWatchedItem('non-existent-user', 'Some Item', 'Produce', mockLogger),
personalizationRepo.addWatchedItem('non-existent-user', 'Some Item', 1, mockLogger),
).rejects.toThrow('The specified user or category does not exist.');
});
});

View File

@@ -166,25 +166,24 @@ export class PersonalizationRepository {
* This method should be wrapped in a transaction by the calling service if other operations depend on it.
* @param userId The UUID of the user.
* @param itemName The name of the item to watch.
* @param categoryName The category of the item.
* @param categoryId The category ID of the item.
* @returns A promise that resolves to the MasterGroceryItem that was added to the watchlist.
*/
async addWatchedItem(
userId: string,
itemName: string,
categoryName: string,
categoryId: number,
logger: Logger,
): Promise<MasterGroceryItem> {
try {
return await withTransaction(async (client) => {
// Find category ID
// Verify category exists
const categoryRes = await client.query<{ category_id: number }>(
'SELECT category_id FROM public.categories WHERE name = $1',
[categoryName],
'SELECT category_id FROM public.categories WHERE category_id = $1',
[categoryId],
);
const categoryId = categoryRes.rows[0]?.category_id;
if (!categoryId) {
throw new Error(`Category '${categoryName}' not found.`);
if (categoryRes.rows.length === 0) {
throw new Error(`Category with ID ${categoryId} not found.`);
}
// Find or create master item
@@ -216,7 +215,7 @@ export class PersonalizationRepository {
error,
logger,
'Transaction error in addWatchedItem',
{ userId, itemName, categoryName },
{ userId, itemName, categoryId },
{
fkMessage: 'The specified user or category does not exist.',
uniqueMessage: 'A master grocery item with this name was created by another process.',

View File

@@ -78,7 +78,7 @@ describe('ReceiptRepository', () => {
const receiptRow = {
receipt_id: 2,
user_id: 'user-1',
store_id: null,
store_location_id: null,
receipt_image_url: '/uploads/receipts/receipt-2.jpg',
transaction_date: null,
total_amount_cents: null,

View File

@@ -60,7 +60,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
await pool.query('DELETE FROM public.user_watched_items WHERE user_id = $1', [userId]);
}
// Clean up flyer items
// Clean up flyer items (master_item_id has ON DELETE SET NULL constraint, so no trigger disable needed)
if (createdFlyerIds.length > 0) {
await pool.query('DELETE FROM public.flyer_items WHERE flyer_id = ANY($1::bigint[])', [
createdFlyerIds,
@@ -92,6 +92,77 @@ describe('E2E Deals and Price Tracking Journey', () => {
});
it('should complete deals journey: Register -> Watch Items -> View Prices -> Check Deals', async () => {
// Step 0: Demonstrate Category Discovery API (Phase 1 of ADR-023 migration)
// The new category endpoints allow clients to discover and validate category IDs
// before using them in other API calls. This is preparation for Phase 2, which
// will support both category names and IDs in the watched items API.
// Get all available categories
const categoriesResponse = await authedFetch('/categories', {
method: 'GET',
});
expect(categoriesResponse.status).toBe(200);
const categoriesData = await categoriesResponse.json();
expect(categoriesData.success).toBe(true);
expect(categoriesData.data.length).toBeGreaterThan(0);
// Find "Dairy & Eggs" category by name using the lookup endpoint
const categoryLookupResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Dairy & Eggs'),
{
method: 'GET',
},
);
expect(categoryLookupResponse.status).toBe(200);
const categoryLookupData = await categoryLookupResponse.json();
expect(categoryLookupData.success).toBe(true);
expect(categoryLookupData.data.name).toBe('Dairy & Eggs');
const dairyEggsCategoryId = categoryLookupData.data.category_id;
expect(dairyEggsCategoryId).toBeGreaterThan(0);
// Verify we can retrieve the category by ID
const categoryByIdResponse = await authedFetch(`/categories/${dairyEggsCategoryId}`, {
method: 'GET',
});
expect(categoryByIdResponse.status).toBe(200);
const categoryByIdData = await categoryByIdResponse.json();
expect(categoryByIdData.success).toBe(true);
expect(categoryByIdData.data.category_id).toBe(dairyEggsCategoryId);
expect(categoryByIdData.data.name).toBe('Dairy & Eggs');
// Look up other category IDs we'll need
const bakeryResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Bakery & Bread'),
{ method: 'GET' },
);
const bakeryData = await bakeryResponse.json();
const bakeryCategoryId = bakeryData.data.category_id;
const beveragesResponse = await authedFetch('/categories/lookup?name=Beverages', {
method: 'GET',
});
const beveragesData = await beveragesResponse.json();
const beveragesCategoryId = beveragesData.data.category_id;
const produceResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Fruits & Vegetables'),
{ method: 'GET' },
);
const produceData = await produceResponse.json();
const produceCategoryId = produceData.data.category_id;
const meatResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Meat & Seafood'),
{ method: 'GET' },
);
const meatData = await meatResponse.json();
const meatCategoryId = meatData.data.category_id;
// NOTE: The watched items API now uses category_id (number) as of Phase 3.
// Category names are no longer accepted. Use the category discovery endpoints
// to look up category IDs before creating watched items.
// Step 1: Register a new user
const registerResponse = await apiClient.registerUser(
userEmail,
@@ -140,21 +211,21 @@ describe('E2E Deals and Price Tracking Journey', () => {
createdStoreLocations.push(store2);
const store2Id = store2.storeId;
// Create master grocery items
// Create master grocery items with categories
const items = [
'E2E Milk 2%',
'E2E Bread White',
'E2E Coffee Beans',
'E2E Bananas',
'E2E Chicken Breast',
{ name: 'E2E Milk 2%', category_id: dairyEggsCategoryId },
{ name: 'E2E Bread White', category_id: bakeryCategoryId },
{ name: 'E2E Coffee Beans', category_id: beveragesCategoryId },
{ name: 'E2E Bananas', category_id: produceCategoryId },
{ name: 'E2E Chicken Breast', category_id: meatCategoryId },
];
for (const itemName of items) {
for (const item of items) {
const result = await pool.query(
`INSERT INTO public.master_grocery_items (name)
VALUES ($1)
`INSERT INTO public.master_grocery_items (name, category_id)
VALUES ($1, $2)
RETURNING master_grocery_item_id`,
[itemName],
[item.name, item.category_id],
);
createdMasterItemIds.push(result.rows[0].master_grocery_item_id);
}
@@ -166,7 +237,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
const flyer1Result = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, valid_from, valid_to, status)
VALUES ($1, 'e2e-flyer-1.jpg', '/uploads/flyers/e2e-flyer-1.jpg', '/uploads/flyers/e2e-flyer-1-icon.jpg', $2, $3, 'processed')
VALUES ($1, 'e2e-flyer-1.jpg', 'http://localhost:3000/uploads/flyers/e2e-flyer-1.jpg', 'http://localhost:3000/uploads/flyers/e2e-flyer-1-icon.jpg', $2, $3, 'processed')
RETURNING flyer_id`,
[store1Id, validFrom, validTo],
);
@@ -175,7 +246,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
const flyer2Result = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, valid_from, valid_to, status)
VALUES ($1, 'e2e-flyer-2.jpg', '/uploads/flyers/e2e-flyer-2.jpg', '/uploads/flyers/e2e-flyer-2-icon.jpg', $2, $3, 'processed')
VALUES ($1, 'e2e-flyer-2.jpg', 'http://localhost:3000/uploads/flyers/e2e-flyer-2.jpg', 'http://localhost:3000/uploads/flyers/e2e-flyer-2-icon.jpg', $2, $3, 'processed')
RETURNING flyer_id`,
[store2Id, validFrom, validTo],
);
@@ -184,48 +255,48 @@ describe('E2E Deals and Price Tracking Journey', () => {
// Add items to flyers with prices (Store 1 - higher prices)
await pool.query(
`INSERT INTO public.flyer_items (flyer_id, master_item_id, sale_price_cents, page_number)
`INSERT INTO public.flyer_items (flyer_id, master_item_id, price_in_cents, item, price_display, quantity)
VALUES
($1, $2, 599, 1), -- Milk at $5.99
($1, $3, 349, 1), -- Bread at $3.49
($1, $4, 1299, 2), -- Coffee at $12.99
($1, $5, 299, 2), -- Bananas at $2.99
($1, $6, 899, 3) -- Chicken at $8.99
($1, $2, 599, 'Milk', '$5.99', 'each'), -- Milk at $5.99
($1, $3, 349, 'Bread', '$3.49', 'each'), -- Bread at $3.49
($1, $4, 1299, 'Coffee', '$12.99', 'each'), -- Coffee at $12.99
($1, $5, 299, 'Bananas', '$2.99', 'lb'), -- Bananas at $2.99
($1, $6, 899, 'Chicken', '$8.99', 'lb') -- Chicken at $8.99
`,
[flyer1Id, ...createdMasterItemIds],
);
// Add items to flyers with prices (Store 2 - better prices)
await pool.query(
`INSERT INTO public.flyer_items (flyer_id, master_item_id, sale_price_cents, page_number)
`INSERT INTO public.flyer_items (flyer_id, master_item_id, price_in_cents, item, price_display, quantity)
VALUES
($1, $2, 499, 1), -- Milk at $4.99 (BEST PRICE)
($1, $3, 299, 1), -- Bread at $2.99 (BEST PRICE)
($1, $4, 1099, 2), -- Coffee at $10.99 (BEST PRICE)
($1, $5, 249, 2), -- Bananas at $2.49 (BEST PRICE)
($1, $6, 799, 3) -- Chicken at $7.99 (BEST PRICE)
($1, $2, 499, 'Milk', '$4.99', 'each'), -- Milk at $4.99 (BEST PRICE)
($1, $3, 299, 'Bread', '$2.99', 'each'), -- Bread at $2.99 (BEST PRICE)
($1, $4, 1099, 'Coffee', '$10.99', 'each'), -- Coffee at $10.99 (BEST PRICE)
($1, $5, 249, 'Bananas', '$2.49', 'lb'), -- Bananas at $2.49 (BEST PRICE)
($1, $6, 799, 'Chicken', '$7.99', 'lb') -- Chicken at $7.99 (BEST PRICE)
`,
[flyer2Id, ...createdMasterItemIds],
);
// Step 4: Add items to watch list
// Step 4: Add items to watch list (using category_id from lookups above)
const watchItem1Response = await authedFetch('/users/watched-items', {
method: 'POST',
token: authToken,
body: JSON.stringify({
itemName: 'E2E Milk 2%',
category: 'Dairy',
category_id: dairyEggsCategoryId,
}),
});
expect(watchItem1Response.status).toBe(201);
const watchItem1Data = await watchItem1Response.json();
expect(watchItem1Data.data.item_name).toBe('E2E Milk 2%');
expect(watchItem1Data.data.name).toBe('E2E Milk 2%');
// Add more items to watch list
const itemsToWatch = [
{ itemName: 'E2E Bread White', category: 'Bakery' },
{ itemName: 'E2E Coffee Beans', category: 'Beverages' },
{ itemName: 'E2E Bread White', category_id: bakeryCategoryId },
{ itemName: 'E2E Coffee Beans', category_id: beveragesCategoryId },
];
for (const item of itemsToWatch) {
@@ -249,13 +320,13 @@ describe('E2E Deals and Price Tracking Journey', () => {
// Find our watched items
const watchedMilk = watchedListData.data.find(
(item: { item_name: string }) => item.item_name === 'E2E Milk 2%',
(item: { name: string }) => item.name === 'E2E Milk 2%',
);
expect(watchedMilk).toBeDefined();
expect(watchedMilk.category).toBe('Dairy');
expect(watchedMilk.category_id).toBe(dairyEggsCategoryId);
// Step 6: Get best prices for watched items
const bestPricesResponse = await authedFetch('/users/deals/best-watched-prices', {
const bestPricesResponse = await authedFetch('/deals/best-watched-prices', {
method: 'GET',
token: authToken,
});
@@ -334,7 +405,7 @@ describe('E2E Deals and Price Tracking Journey', () => {
expect(otherWatchedData.data.length).toBe(0);
// Other user's deals should be empty
const otherDealsResponse = await authedFetch('/users/deals/best-watched-prices', {
const otherDealsResponse = await authedFetch('/deals/best-watched-prices', {
method: 'GET',
token: otherToken,
});

View File

@@ -0,0 +1,174 @@
// src/tests/integration/category.routes.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import supertest from 'supertest';
/**
* @vitest-environment node
*/
describe('Category API Routes (Integration)', () => {
let request: ReturnType<typeof supertest>;
beforeAll(async () => {
const app = (await import('../../../server')).default;
request = supertest(app);
});
describe('GET /api/categories', () => {
it('should return list of all categories', async () => {
const response = await request.get('/api/categories');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
// Verify category structure
const firstCategory = response.body.data[0];
expect(firstCategory).toHaveProperty('category_id');
expect(firstCategory).toHaveProperty('name');
expect(firstCategory).toHaveProperty('created_at');
expect(firstCategory).toHaveProperty('updated_at');
expect(typeof firstCategory.category_id).toBe('number');
expect(typeof firstCategory.name).toBe('string');
});
it('should return categories in alphabetical order', async () => {
const response = await request.get('/api/categories');
const categories = response.body.data;
// Verify alphabetical ordering
for (let i = 1; i < categories.length; i++) {
const prevName = categories[i - 1].name.toLowerCase();
const currName = categories[i].name.toLowerCase();
expect(currName >= prevName).toBe(true);
}
});
it('should include expected categories', async () => {
const response = await request.get('/api/categories');
const categories = response.body.data;
const categoryNames = categories.map((c: { name: string }) => c.name);
// Verify some expected categories exist
expect(categoryNames).toContain('Dairy & Eggs');
expect(categoryNames).toContain('Fruits & Vegetables');
expect(categoryNames).toContain('Meat & Seafood');
expect(categoryNames).toContain('Bakery & Bread');
});
});
describe('GET /api/categories/:id', () => {
it('should return specific category by valid ID', async () => {
// First get all categories to find a valid ID
const listResponse = await request.get('/api/categories');
const firstCategory = listResponse.body.data[0];
const response = await request.get(`/api/categories/${firstCategory.category_id}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.category_id).toBe(firstCategory.category_id);
expect(response.body.data.name).toBe(firstCategory.name);
});
it('should return 404 for non-existent category ID', async () => {
const response = await request.get('/api/categories/999999');
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('not found');
});
it('should return 400 for invalid category ID (not a number)', async () => {
const response = await request.get('/api/categories/invalid');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('Invalid category ID');
});
it('should return 400 for negative category ID', async () => {
const response = await request.get('/api/categories/-1');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('Invalid category ID');
});
it('should return 400 for zero category ID', async () => {
const response = await request.get('/api/categories/0');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('Invalid category ID');
});
});
describe('GET /api/categories/lookup', () => {
it('should find category by exact name', async () => {
const response = await request.get('/api/categories/lookup?name=Dairy%20%26%20Eggs');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Dairy & Eggs');
expect(response.body.data.category_id).toBeGreaterThan(0);
});
it('should find category by case-insensitive name', async () => {
const response = await request.get('/api/categories/lookup?name=dairy%20%26%20eggs');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Dairy & Eggs');
});
it('should find category with mixed case', async () => {
const response = await request.get('/api/categories/lookup?name=DaIrY%20%26%20eGgS');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Dairy & Eggs');
});
it('should return 404 for non-existent category name', async () => {
const response = await request.get('/api/categories/lookup?name=NonExistentCategory');
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('not found');
});
it('should return 400 if name parameter is missing', async () => {
const response = await request.get('/api/categories/lookup');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('required');
});
it('should return 400 for empty name parameter', async () => {
const response = await request.get('/api/categories/lookup?name=');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('required');
});
it('should return 400 for whitespace-only name parameter', async () => {
const response = await request.get('/api/categories/lookup?name= ');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('required');
});
it('should handle URL-encoded category names', async () => {
const response = await request.get('/api/categories/lookup?name=Dairy%20%26%20Eggs');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Dairy & Eggs');
});
});
});

View File

@@ -3,8 +3,9 @@
* Integration tests for Receipt processing workflow.
* Tests the complete flow from receipt upload to item extraction and inventory addition.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import path from 'path';
import type { UserProfile } from '../../types';
import { createAndLoginUser } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
@@ -14,50 +15,76 @@ import {
cleanupStoreLocations,
type CreatedStoreLocation,
} from '../utils/storeHelpers';
import { cleanupFiles } from '../utils/cleanupFiles';
/**
* @vitest-environment node
*/
// Mock Bull Board to prevent BullMQAdapter from validating queue instances
vi.mock('@bull-board/api', () => ({
createBullBoard: vi.fn(),
}));
vi.mock('@bull-board/api/bullMQAdapter', () => ({
BullMQAdapter: vi.fn(),
}));
// Storage path for test files
const testStoragePath =
process.env.STORAGE_PATH || path.resolve(__dirname, '../../../uploads/receipts');
// Mock the queues to prevent actual background processing
// IMPORTANT: Must include all queue exports that are imported by workers.server.ts
vi.mock('../../services/queues.server', () => ({
receiptQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-job-id' }),
},
cleanupQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-cleanup-job-id' }),
},
flyerQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-flyer-job-id' }),
},
emailQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-email-job-id' }),
},
analyticsQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-analytics-job-id' }),
},
weeklyAnalyticsQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-weekly-analytics-job-id' }),
},
tokenCleanupQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-token-cleanup-job-id' }),
},
expiryAlertQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-expiry-alert-job-id' }),
},
barcodeDetectionQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-barcode-job-id' }),
},
}));
// Mock storage service to write files to disk AND return URLs (like flyer-processing)
vi.mock('../../services/storage/storageService', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fsModule = require('node:fs/promises');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pathModule = require('path');
return {
storageService: {
upload: vi
.fn()
.mockImplementation(
async (
fileData: Buffer | string | { name?: string; path?: string },
fileName?: string,
) => {
const name =
fileName ||
(fileData && typeof fileData === 'object' && 'name' in fileData && fileData.name) ||
(typeof fileData === 'string'
? pathModule.basename(fileData)
: `upload-${Date.now()}.jpg`);
// Use the STORAGE_PATH from the environment (set by global setup to temp directory)
const uploadDir =
process.env.STORAGE_PATH || pathModule.join(process.cwd(), 'uploads', 'receipts');
await fsModule.mkdir(uploadDir, { recursive: true });
const destPath = pathModule.join(uploadDir, name);
let content: Buffer = Buffer.from('');
if (Buffer.isBuffer(fileData)) {
content = Buffer.from(fileData);
} else if (typeof fileData === 'string') {
try {
content = await fsModule.readFile(fileData);
} catch {
/* ignore */
}
} else if (
fileData &&
typeof fileData === 'object' &&
'path' in fileData &&
fileData.path
) {
try {
content = await fsModule.readFile(fileData.path);
} catch {
/* ignore */
}
}
await fsModule.writeFile(destPath, content);
// Return a valid URL to satisfy the 'url_check' DB constraint
return `https://example.com/uploads/receipts/${name}`;
},
),
delete: vi.fn().mockResolvedValue(undefined),
},
};
});
describe('Receipt Processing Integration Tests (/api/receipts)', () => {
let request: ReturnType<typeof supertest>;
@@ -67,10 +94,18 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
const createdReceiptIds: number[] = [];
const createdInventoryIds: number[] = [];
const createdStoreLocations: CreatedStoreLocation[] = [];
const createdFilePaths: string[] = [];
const originalFrontendUrl = process.env.FRONTEND_URL;
beforeAll(async () => {
// Stub FRONTEND_URL to ensure valid absolute URLs
vi.stubEnv('FRONTEND_URL', 'https://example.com');
const app = (await import('../../../server')).default;
vi.stubEnv('STORAGE_PATH', testStoragePath);
process.env.FRONTEND_URL = 'https://example.com';
const appModule = await import('../../../server');
const app = appModule.default;
request = supertest(app);
// Create a user for receipt tests
@@ -84,14 +119,39 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
createdUserIds.push(user.user.user_id);
});
// Reset mocks before each test to ensure isolation
beforeEach(async () => {
console.error('[TEST SETUP] Resetting mocks before test execution');
// Add any mock resets here if needed for receipt processing
});
afterAll(async () => {
// Restore original value
process.env.FRONTEND_URL = originalFrontendUrl;
vi.unstubAllEnvs();
vi.restoreAllMocks();
// CRITICAL: Close workers FIRST before any cleanup to ensure no pending jobs
try {
console.error('[TEST TEARDOWN] Closing in-process workers...');
const { closeWorkers } = await import('../../services/workers.server');
await closeWorkers();
// Give workers a moment to fully release resources
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (error) {
console.error('[TEST TEARDOWN] Error closing workers:', error);
}
// Close the shared redis connection used by the workers/queues
const { connection } = await import('../../services/redis.server');
await connection.quit();
const pool = getPool();
// Clean up inventory items
if (createdInventoryIds.length > 0) {
await pool.query('DELETE FROM public.user_inventory WHERE inventory_id = ANY($1::int[])', [
await pool.query('DELETE FROM public.pantry_items WHERE pantry_item_id = ANY($1::int[])', [
createdInventoryIds,
]);
}
@@ -112,9 +172,31 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
await cleanupDb({ userIds: createdUserIds });
await cleanupStoreLocations(pool, createdStoreLocations);
// Clean up test files
await cleanupFiles(createdFilePaths);
// Final delay to let any remaining async operations settle
await new Promise((resolve) => setTimeout(resolve, 50));
});
describe('POST /api/receipts - Upload Receipt', () => {
let testStoreLocationId: number;
beforeAll(async () => {
// Create a test store for receipt upload tests
const pool = getPool();
const store = await createStoreWithLocation(pool, {
name: `Receipt Upload Test Store - ${Date.now()}`,
address: '123 Receipt St',
city: 'Toronto',
province: 'ON',
postalCode: 'M5V 1A1',
});
createdStoreLocations.push(store);
testStoreLocationId = store.storeLocationId;
});
it('should upload a receipt image successfully', async () => {
// Create a simple test image buffer
const testImageBuffer = Buffer.from(
@@ -126,15 +208,18 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
.post('/api/receipts')
.set('Authorization', `Bearer ${authToken}`)
.attach('receipt', testImageBuffer, 'test-receipt.png')
.field('store_location_id', '1')
.field('store_location_id', testStoreLocationId.toString())
.field('transaction_date', '2024-01-15');
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.data.receipt_id).toBeDefined();
expect(response.body.data.job_id).toBe('mock-job-id');
expect(response.body.data.job_id).toBeDefined(); // Real queue job ID
createdReceiptIds.push(response.body.data.receipt_id);
// Track the uploaded file for cleanup
createdFilePaths.push(path.join(testStoragePath, 'test-receipt.png'));
});
it('should upload receipt without optional fields', async () => {
@@ -152,6 +237,9 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
expect(response.body.data.receipt_id).toBeDefined();
createdReceiptIds.push(response.body.data.receipt_id);
// Track the uploaded file for cleanup
createdFilePaths.push(path.join(testStoragePath, 'test-receipt-2.png'));
});
it('should reject request without file', async () => {
@@ -370,7 +458,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toContain('reprocessing');
expect(response.body.data.job_id).toBe('mock-job-id');
expect(response.body.data.job_id).toBeDefined(); // Real queue job ID
});
it('should return 404 for non-existent receipt', async () => {

View File

@@ -242,11 +242,18 @@ describe('User API Routes Integration Tests', () => {
describe('User Data Routes (Watched Items & Shopping Lists)', () => {
it('should allow a user to add and remove a watched item', async () => {
// First, look up the category ID for "Other/Miscellaneous"
const categoryResponse = await request.get(
'/api/categories/lookup?name=' + encodeURIComponent('Other/Miscellaneous'),
);
expect(categoryResponse.status).toBe(200);
const categoryId = categoryResponse.body.data.category_id;
// Act 1: Add a new watched item. The API returns the created master item.
const addResponse = await request
.post('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`)
.send({ itemName: 'Integration Test Item', category: 'Other/Miscellaneous' });
.send({ itemName: 'Integration Test Item', category_id: categoryId });
const newItem = addResponse.body.data;
if (newItem?.master_grocery_item_id)

View File

@@ -164,21 +164,8 @@ vi.mock('jsonwebtoken', () => ({
// Mock 'bcrypt'. The service uses `import * as bcrypt from 'bcrypt'`.
vi.mock('bcrypt');
// Mock 'crypto'. The service uses `import crypto from 'crypto'`.
vi.mock('crypto', () => ({
default: {
randomBytes: vi.fn().mockReturnValue({
toString: vi.fn().mockImplementation((encoding) => {
const id = 'mocked_random_id';
console.log(
`[DEBUG] tests-setup-unit.ts: crypto.randomBytes mock returning "${id}" for encoding "${encoding}"`,
);
return id;
}),
}),
randomUUID: vi.fn().mockReturnValue('mocked_random_id'),
},
}));
// NOTE: We do NOT mock the 'crypto' module anymore. It works correctly without mocking in tests.
// The previous attempt to mock it caused issues because vi.importActual returned an empty object.
// --- Global Mocks ---