ADR-022 - websocket notificaitons - also more test fixes with stores
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m47s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m47s
This commit is contained in:
@@ -1 +1 @@
|
|||||||
npx lint-staged
|
FORCE_COLOR=0 npx lint-staged
|
||||||
|
|||||||
311
docs/SCHEMA_RELATIONSHIP_ANALYSIS.md
Normal file
311
docs/SCHEMA_RELATIONSHIP_ANALYSIS.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
# Database Schema Relationship Analysis
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document analyzes the database schema to identify missing table relationships and JOINs that aren't properly implemented in the codebase. This analysis was triggered by discovering that `WatchedItemDeal` was using a `store_name` string instead of a proper `store` object with nested locations.
|
||||||
|
|
||||||
|
## Key Findings
|
||||||
|
|
||||||
|
### ✅ CORRECTLY IMPLEMENTED
|
||||||
|
|
||||||
|
#### 1. Store → Store Locations → Addresses (3-table normalization)
|
||||||
|
|
||||||
|
**Schema:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
stores (store_id) → store_locations (store_location_id) → addresses (address_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
- [src/services/db/storeLocation.db.ts](src/services/db/storeLocation.db.ts) properly JOINs all three tables
|
||||||
|
- [src/types.ts](src/types.ts) defines `StoreWithLocations` interface with nested address objects
|
||||||
|
- Recent fixes corrected `WatchedItemDeal` to use `store` object instead of `store_name` string
|
||||||
|
|
||||||
|
**Queries:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// From storeLocation.db.ts
|
||||||
|
FROM public.stores s
|
||||||
|
LEFT JOIN public.store_locations sl ON s.store_id = sl.store_id
|
||||||
|
LEFT JOIN public.addresses a ON sl.address_id = a.address_id
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Shopping Trips → Shopping Trip Items
|
||||||
|
|
||||||
|
**Schema:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
shopping_trips (shopping_trip_id) → shopping_trip_items (shopping_trip_item_id) → master_grocery_items
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
- [src/services/db/shopping.db.ts:513-518](src/services/db/shopping.db.ts#L513-L518) properly JOINs shopping_trips → shopping_trip_items → master_grocery_items
|
||||||
|
- Uses `json_agg` to nest items array within trip object
|
||||||
|
- [src/types.ts:639-647](src/types.ts#L639-L647) `ShoppingTrip` interface includes nested `items: ShoppingTripItem[]`
|
||||||
|
|
||||||
|
**Queries:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
FROM public.shopping_trips st
|
||||||
|
LEFT JOIN public.shopping_trip_items sti ON st.shopping_trip_id = sti.shopping_trip_id
|
||||||
|
LEFT JOIN public.master_grocery_items mgi ON sti.master_item_id = mgi.master_grocery_item_id
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Receipts → Receipt Items
|
||||||
|
|
||||||
|
**Schema:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
receipts (receipt_id) → receipt_items (receipt_item_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
- [src/types.ts:649-662](src/types.ts#L649-L662) `Receipt` interface includes optional `items?: ReceiptItem[]`
|
||||||
|
- Receipt items are fetched separately via repository methods
|
||||||
|
- Proper foreign key relationship maintained
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ❌ MISSING / INCORRECT IMPLEMENTATIONS
|
||||||
|
|
||||||
|
#### 1. **CRITICAL: Flyers → Flyer Locations → Store Locations (Many-to-Many)**
|
||||||
|
|
||||||
|
**Schema:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS public.flyer_locations (
|
||||||
|
flyer_id BIGINT NOT NULL REFERENCES public.flyers(flyer_id) ON DELETE CASCADE,
|
||||||
|
store_location_id BIGINT NOT NULL REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (flyer_id, store_location_id),
|
||||||
|
...
|
||||||
|
);
|
||||||
|
COMMENT: 'A linking table associating a single flyer with multiple store locations where its deals are valid.'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
|
||||||
|
- The schema defines a **many-to-many relationship** - a flyer can be valid at multiple store locations
|
||||||
|
- Current implementation in [src/services/db/flyer.db.ts](src/services/db/flyer.db.ts) **IGNORES** the `flyer_locations` table entirely
|
||||||
|
- Queries JOIN `flyers` directly to `stores` via `store_id` foreign key
|
||||||
|
- This means flyers can only be associated with ONE store, not multiple locations
|
||||||
|
|
||||||
|
**Current (Incorrect) Queries:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// From flyer.db.ts:315-362
|
||||||
|
FROM public.flyers f
|
||||||
|
JOIN public.stores s ON f.store_id = s.store_id // ❌ Wrong - ignores flyer_locations
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected (Correct) Queries:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Should be:
|
||||||
|
FROM public.flyers f
|
||||||
|
JOIN public.flyer_locations fl ON f.flyer_id = fl.flyer_id
|
||||||
|
JOIN public.store_locations sl ON fl.store_location_id = sl.store_location_id
|
||||||
|
JOIN public.stores s ON sl.store_id = s.store_id
|
||||||
|
JOIN public.addresses a ON sl.address_id = a.address_id
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript Type Issues:**
|
||||||
|
|
||||||
|
- [src/types.ts](src/types.ts) `Flyer` interface has `store` object, but it should have `locations: StoreLocation[]` array
|
||||||
|
- Current structure assumes one store per flyer, not multiple locations
|
||||||
|
|
||||||
|
**Files Affected:**
|
||||||
|
|
||||||
|
- [src/services/db/flyer.db.ts](src/services/db/flyer.db.ts) - All flyer queries
|
||||||
|
- [src/types.ts](src/types.ts) - `Flyer` interface definition
|
||||||
|
- Any component displaying flyer locations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. **User Submitted Prices → Store Locations (MIGRATED)**
|
||||||
|
|
||||||
|
**Status**: ✅ **FIXED** - Migration created
|
||||||
|
|
||||||
|
**Schema:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
||||||
|
...
|
||||||
|
store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
|
...
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution Implemented:**
|
||||||
|
|
||||||
|
- Created migration [sql/migrations/005_add_store_location_to_user_submitted_prices.sql](sql/migrations/005_add_store_location_to_user_submitted_prices.sql)
|
||||||
|
- Added `store_location_id` column to table (NOT NULL after migration)
|
||||||
|
- Migrated existing data: linked each price to first location of its store
|
||||||
|
- Updated TypeScript interface [src/types.ts:270-282](src/types.ts#L270-L282) to include both fields
|
||||||
|
- Kept `store_id` for backward compatibility during transition
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
|
||||||
|
- Prices are now specific to individual store locations
|
||||||
|
- "Walmart Toronto" and "Walmart Vancouver" prices are tracked separately
|
||||||
|
- Improves geographic specificity for price comparisons
|
||||||
|
- Enables proximity-based price recommendations
|
||||||
|
|
||||||
|
**Next Steps:**
|
||||||
|
|
||||||
|
- Application code needs to be updated to use `store_location_id` when creating new prices
|
||||||
|
- Once all code is migrated, can drop the legacy `store_id` column
|
||||||
|
- User-submitted prices feature is not yet implemented in the UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. **Receipts → Store Locations (MIGRATED)**
|
||||||
|
|
||||||
|
**Status**: ✅ **FIXED** - Migration created
|
||||||
|
|
||||||
|
**Schema:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS public.receipts (
|
||||||
|
...
|
||||||
|
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
||||||
|
store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE SET NULL,
|
||||||
|
...
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution Implemented:**
|
||||||
|
|
||||||
|
- Created migration [sql/migrations/006_add_store_location_to_receipts.sql](sql/migrations/006_add_store_location_to_receipts.sql)
|
||||||
|
- Added `store_location_id` column to table (nullable - receipts may not have matched store)
|
||||||
|
- Migrated existing data: linked each receipt to first location of its store
|
||||||
|
- Updated TypeScript interface [src/types.ts:661-675](src/types.ts#L661-L675) to include both fields
|
||||||
|
- Kept `store_id` for backward compatibility during transition
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
|
||||||
|
- Receipts can now be tied to specific store locations
|
||||||
|
- "Loblaws Queen St" and "Loblaws Bloor St" are tracked separately
|
||||||
|
- Enables location-specific shopping pattern analysis
|
||||||
|
- Improves receipt matching accuracy with address data
|
||||||
|
|
||||||
|
**Next Steps:**
|
||||||
|
|
||||||
|
- Receipt scanning code needs to determine specific store_location_id from OCR text
|
||||||
|
- May require address parsing/matching logic in receipt processing
|
||||||
|
- Once all code is migrated, can drop the legacy `store_id` column
|
||||||
|
- OCR confidence and pattern matching should prefer location-specific data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. Item Price History → Store Locations (Already Correct!)
|
||||||
|
|
||||||
|
**Schema:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS public.item_price_history (
|
||||||
|
...
|
||||||
|
store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE,
|
||||||
|
...
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status:**
|
||||||
|
|
||||||
|
- ✅ **CORRECTLY IMPLEMENTED** - This table already uses `store_location_id`
|
||||||
|
- Properly tracks price history per location
|
||||||
|
- Good example of how other tables should be structured
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Table
|
||||||
|
|
||||||
|
| Table | Foreign Key | Should Use | Status | Priority |
|
||||||
|
| --------------------- | --------------------------- | ------------------------------------- | --------------- | -------- |
|
||||||
|
| **flyer_locations** | flyer_id, store_location_id | Many-to-many link | ✅ **FIXED** | ✅ Done |
|
||||||
|
| flyers | store_id | ~~store_id~~ Now uses flyer_locations | ✅ **FIXED** | ✅ Done |
|
||||||
|
| user_submitted_prices | store_id | store_location_id | ✅ **MIGRATED** | ✅ Done |
|
||||||
|
| receipts | store_id | store_location_id | ✅ **MIGRATED** | ✅ Done |
|
||||||
|
| item_price_history | store_location_id | ✅ Already correct | ✅ Correct | ✅ Good |
|
||||||
|
| shopping_trips | (no store ref) | N/A | ✅ Correct | ✅ Good |
|
||||||
|
| store_locations | store_id, address_id | ✅ Already correct | ✅ Correct | ✅ Good |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Impact Assessment
|
||||||
|
|
||||||
|
### Critical (Must Fix)
|
||||||
|
|
||||||
|
1. **Flyer Locations Many-to-Many**
|
||||||
|
- **Impact:** Flyers can't be associated with multiple store locations
|
||||||
|
- **User Impact:** Users can't see which specific store locations have deals
|
||||||
|
- **Business Logic:** Breaks core assumption that one flyer can be valid at multiple stores
|
||||||
|
- **Fix Complexity:** High - requires schema migration, type changes, query rewrites
|
||||||
|
|
||||||
|
### Medium (Should Consider)
|
||||||
|
|
||||||
|
2. **User Submitted Prices & Receipts**
|
||||||
|
- **Impact:** Loss of location-specific data
|
||||||
|
- **User Impact:** Can't distinguish between different locations of same store chain
|
||||||
|
- **Business Logic:** Reduces accuracy of proximity-based recommendations
|
||||||
|
- **Fix Complexity:** Medium - requires migration and query updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Actions
|
||||||
|
|
||||||
|
### Phase 1: Fix Flyer Locations (Critical)
|
||||||
|
|
||||||
|
1. Create migration to properly use `flyer_locations` table
|
||||||
|
2. Update `Flyer` TypeScript interface to support multiple locations
|
||||||
|
3. Rewrite all flyer queries in [src/services/db/flyer.db.ts](src/services/db/flyer.db.ts)
|
||||||
|
4. Update flyer creation/update endpoints to manage `flyer_locations` entries
|
||||||
|
5. Update frontend components to display multiple locations per flyer
|
||||||
|
6. Update tests to use new structure
|
||||||
|
|
||||||
|
### Phase 2: Consider Store Location Specificity (Optional)
|
||||||
|
|
||||||
|
1. Evaluate if location-specific receipts and prices provide value
|
||||||
|
2. If yes, create migrations to change `store_id` → `store_location_id`
|
||||||
|
3. Update repository queries
|
||||||
|
4. Update TypeScript interfaces
|
||||||
|
5. Update tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- [ADR-013: Store Address Normalization](../docs/adr/0013-store-address-normalization.md)
|
||||||
|
- [STORE_ADDRESS_IMPLEMENTATION_PLAN.md](../STORE_ADDRESS_IMPLEMENTATION_PLAN.md)
|
||||||
|
- [TESTING.md](../docs/TESTING.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analysis Methodology
|
||||||
|
|
||||||
|
This analysis was conducted by:
|
||||||
|
|
||||||
|
1. Extracting all foreign key relationships from [sql/master_schema_rollup.sql](sql/master_schema_rollup.sql)
|
||||||
|
2. Comparing schema relationships against TypeScript interfaces in [src/types.ts](src/types.ts)
|
||||||
|
3. Auditing database queries in [src/services/db/](src/services/db/) for proper JOIN usage
|
||||||
|
4. Identifying gaps where schema relationships exist but aren't used in queries
|
||||||
|
|
||||||
|
Commands used:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Extract all foreign keys
|
||||||
|
podman exec -it flyer-crawler-dev bash -c "grep -n 'REFERENCES' sql/master_schema_rollup.sql"
|
||||||
|
|
||||||
|
# Check specific table structures
|
||||||
|
podman exec -it flyer-crawler-dev bash -c "grep -A 15 'CREATE TABLE.*table_name' sql/master_schema_rollup.sql"
|
||||||
|
|
||||||
|
# Verify query patterns
|
||||||
|
podman exec -it flyer-crawler-dev bash -c "grep -n 'JOIN.*table_name' src/services/db/*.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2026-01-19
|
||||||
|
**Analyzed By:** Claude Code (via user request after discovering store_name → store bug)
|
||||||
411
docs/WEBSOCKET_USAGE.md
Normal file
411
docs/WEBSOCKET_USAGE.md
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
# WebSocket Real-Time Notifications - Usage Guide
|
||||||
|
|
||||||
|
This guide shows you how to use the WebSocket real-time notification system in your React components.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Enable Global Notifications
|
||||||
|
|
||||||
|
Add the `NotificationToastHandler` to your root `App.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/App.tsx
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
import { NotificationToastHandler } from './components/NotificationToastHandler';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* React Hot Toast container */}
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
|
||||||
|
{/* WebSocket notification handler (renders nothing, handles side effects) */}
|
||||||
|
<NotificationToastHandler
|
||||||
|
enabled={true}
|
||||||
|
playSound={false} // Set to true to play notification sounds
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Your app routes and components */}
|
||||||
|
<YourAppContent />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add Notification Bell to Header
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/components/Header.tsx
|
||||||
|
import { NotificationBell } from './components/NotificationBell';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex items-center justify-between p-4">
|
||||||
|
<h1>Flyer Crawler</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Notification bell with unread count */}
|
||||||
|
<NotificationBell onClick={() => navigate('/notifications')} showConnectionStatus={true} />
|
||||||
|
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Listen for Notifications in Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/pages/DealsPage.tsx
|
||||||
|
import { useEventBus } from '../hooks/useEventBus';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { DealNotificationData } from '../types/websocket';
|
||||||
|
|
||||||
|
function DealsPage() {
|
||||||
|
const [deals, setDeals] = useState([]);
|
||||||
|
|
||||||
|
// Listen for new deal notifications
|
||||||
|
const handleDealNotification = useCallback((data: DealNotificationData) => {
|
||||||
|
console.log('New deals received:', data.deals);
|
||||||
|
|
||||||
|
// Update your deals list
|
||||||
|
setDeals((prev) => [...data.deals, ...prev]);
|
||||||
|
|
||||||
|
// Or refetch from API
|
||||||
|
// refetchDeals();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEventBus('notification:deal', handleDealNotification);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Deals</h1>
|
||||||
|
{/* Render deals */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Components
|
||||||
|
|
||||||
|
### `NotificationBell`
|
||||||
|
|
||||||
|
A notification bell icon with unread count and connection status indicator.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
|
||||||
|
- `onClick?: () => void` - Callback when bell is clicked
|
||||||
|
- `showConnectionStatus?: boolean` - Show green/red/yellow connection dot (default: `true`)
|
||||||
|
- `className?: string` - Custom CSS classes
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NotificationBell
|
||||||
|
onClick={() => navigate('/notifications')}
|
||||||
|
showConnectionStatus={true}
|
||||||
|
className="mr-4"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ConnectionStatus`
|
||||||
|
|
||||||
|
A simple status indicator showing if WebSocket is connected (no bell icon).
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ConnectionStatus />
|
||||||
|
```
|
||||||
|
|
||||||
|
### `NotificationToastHandler`
|
||||||
|
|
||||||
|
Global handler that listens for WebSocket events and displays toasts. Should be rendered once at app root.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
|
||||||
|
- `enabled?: boolean` - Enable/disable toast notifications (default: `true`)
|
||||||
|
- `playSound?: boolean` - Play sound on notifications (default: `false`)
|
||||||
|
- `soundUrl?: string` - Custom notification sound URL
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NotificationToastHandler enabled={true} playSound={true} soundUrl="/custom-sound.mp3" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Hooks
|
||||||
|
|
||||||
|
### `useWebSocket`
|
||||||
|
|
||||||
|
Connect to the WebSocket server and manage connection state.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
- `autoConnect?: boolean` - Auto-connect on mount (default: `true`)
|
||||||
|
- `maxReconnectAttempts?: number` - Max reconnect attempts (default: `5`)
|
||||||
|
- `reconnectDelay?: number` - Base reconnect delay in ms (default: `1000`)
|
||||||
|
- `onConnect?: () => void` - Callback on connection
|
||||||
|
- `onDisconnect?: () => void` - Callback on disconnect
|
||||||
|
- `onError?: (error: Event) => void` - Callback on error
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
|
||||||
|
- `isConnected: boolean` - Connection status
|
||||||
|
- `isConnecting: boolean` - Connecting state
|
||||||
|
- `error: string | null` - Error message if any
|
||||||
|
- `connect: () => void` - Manual connect function
|
||||||
|
- `disconnect: () => void` - Manual disconnect function
|
||||||
|
- `send: (message: WebSocketMessage) => void` - Send message to server
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { isConnected, error, connect, disconnect } = useWebSocket({
|
||||||
|
autoConnect: true,
|
||||||
|
maxReconnectAttempts: 3,
|
||||||
|
onConnect: () => console.log('Connected!'),
|
||||||
|
onDisconnect: () => console.log('Disconnected!'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
|
||||||
|
{error && <p>Error: {error}</p>}
|
||||||
|
<button onClick={connect}>Reconnect</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `useEventBus`
|
||||||
|
|
||||||
|
Subscribe to event bus events (used with WebSocket integration).
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
- `event: string` - Event name to listen for
|
||||||
|
- `callback: (data?: T) => void` - Callback function
|
||||||
|
|
||||||
|
**Available Events:**
|
||||||
|
|
||||||
|
- `'notification:deal'` - Deal notifications (`DealNotificationData`)
|
||||||
|
- `'notification:system'` - System messages (`SystemMessageData`)
|
||||||
|
- `'notification:error'` - Error messages (`{ message: string; code?: string }`)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEventBus } from '../hooks/useEventBus';
|
||||||
|
import type { DealNotificationData } from '../types/websocket';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
useEventBus<DealNotificationData>('notification:deal', (data) => {
|
||||||
|
console.log('Received deal:', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div>Listening for deals...</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message Types
|
||||||
|
|
||||||
|
### Deal Notification
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DealNotificationData {
|
||||||
|
notification_id?: string;
|
||||||
|
deals: Array<{
|
||||||
|
item_name: string;
|
||||||
|
best_price_in_cents: number;
|
||||||
|
store_name: string;
|
||||||
|
store_id: string;
|
||||||
|
}>;
|
||||||
|
user_id: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### System Message
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SystemMessageData {
|
||||||
|
message: string;
|
||||||
|
severity: 'info' | 'warning' | 'error';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Custom Notification Handling
|
||||||
|
|
||||||
|
If you don't want to use the default `NotificationToastHandler`, you can create your own:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
import { useEventBus } from '../hooks/useEventBus';
|
||||||
|
import type { DealNotificationData } from '../types/websocket';
|
||||||
|
|
||||||
|
function CustomNotificationHandler() {
|
||||||
|
const { isConnected } = useWebSocket({ autoConnect: true });
|
||||||
|
|
||||||
|
useEventBus<DealNotificationData>('notification:deal', (data) => {
|
||||||
|
// Custom handling - e.g., update Redux store
|
||||||
|
dispatch(addDeals(data.deals));
|
||||||
|
|
||||||
|
// Show custom UI
|
||||||
|
showCustomNotification(data.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return null; // Or return your custom UI
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional WebSocket Connection
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
function ConditionalWebSocket() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Only connect if user is logged in
|
||||||
|
useWebSocket({
|
||||||
|
autoConnect: !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send Messages to Server
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
|
||||||
|
function PingComponent() {
|
||||||
|
const { send, isConnected } = useWebSocket();
|
||||||
|
|
||||||
|
const sendPing = () => {
|
||||||
|
send({
|
||||||
|
type: 'ping',
|
||||||
|
data: {},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={sendPing} disabled={!isConnected}>
|
||||||
|
Send Ping
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Monitoring
|
||||||
|
|
||||||
|
### Get WebSocket Stats
|
||||||
|
|
||||||
|
Admin users can check WebSocket connection statistics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get connection stats
|
||||||
|
curl -H "Authorization: Bearer <admin-token>" \
|
||||||
|
http://localhost:3001/api/admin/websocket/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"totalUsers": 42,
|
||||||
|
"totalConnections": 67
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Dashboard Integration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function AdminWebSocketStats() {
|
||||||
|
const [stats, setStats] = useState({ totalUsers: 0, totalConnections: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStats = async () => {
|
||||||
|
const response = await fetch('/api/admin/websocket/stats', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
setStats(data.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStats();
|
||||||
|
const interval = setInterval(fetchStats, 5000); // Poll every 5s
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 border rounded">
|
||||||
|
<h3>WebSocket Stats</h3>
|
||||||
|
<p>Connected Users: {stats.totalUsers}</p>
|
||||||
|
<p>Total Connections: {stats.totalConnections}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Connection Issues
|
||||||
|
|
||||||
|
1. **Check JWT Token**: WebSocket requires a valid JWT token in cookies or query string
|
||||||
|
2. **Check Server Logs**: Look for WebSocket connection errors in server logs
|
||||||
|
3. **Check Browser Console**: WebSocket errors are logged to console
|
||||||
|
4. **Verify Path**: WebSocket server is at `ws://localhost:3001/ws` (or `wss://` for HTTPS)
|
||||||
|
|
||||||
|
### Not Receiving Notifications
|
||||||
|
|
||||||
|
1. **Check Connection Status**: Use `<ConnectionStatus />` to verify connection
|
||||||
|
2. **Verify Event Name**: Ensure you're listening to the correct event (`notification:deal`, etc.)
|
||||||
|
3. **Check User ID**: Notifications are sent to specific users - verify JWT user_id matches
|
||||||
|
|
||||||
|
### High Memory Usage
|
||||||
|
|
||||||
|
1. **Connection Leaks**: Ensure components using `useWebSocket` are properly unmounting
|
||||||
|
2. **Event Listeners**: `useEventBus` automatically cleans up, but verify no manual listeners remain
|
||||||
|
3. **Check Stats**: Use `/api/admin/websocket/stats` to monitor connection count
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
|
||||||
|
describe('useWebSocket', () => {
|
||||||
|
it('should connect automatically', () => {
|
||||||
|
const { result } = renderHook(() => useWebSocket({ autoConnect: true }));
|
||||||
|
expect(result.current.isConnecting).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
See [src/tests/integration/websocket.integration.test.ts](../src/tests/integration/websocket.integration.test.ts) for comprehensive integration tests.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [ADR-022: Real-time Notification System](./adr/0022-real-time-notification-system.md)
|
||||||
|
- [ADR-036: Event Bus and Pub/Sub Pattern](./adr/0036-event-bus-and-pub-sub-pattern.md)
|
||||||
|
- [ADR-042: Email and Notification Architecture](./adr/0042-email-and-notification-architecture.md)
|
||||||
@@ -2,17 +2,374 @@
|
|||||||
|
|
||||||
**Date**: 2025-12-12
|
**Date**: 2025-12-12
|
||||||
|
|
||||||
**Status**: Proposed
|
**Status**: Accepted
|
||||||
|
|
||||||
|
**Implemented**: 2026-01-19
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
A core feature is providing "Active Deal Alerts" to users. The current HTTP-based architecture is not suitable for pushing real-time updates to clients efficiently. Relying on traditional polling would be inefficient and slow.
|
A core feature is providing "Active Deal Alerts" to users. The current HTTP-based architecture is not suitable for pushing real-time updates to clients efficiently. Relying on traditional polling would be inefficient and slow.
|
||||||
|
|
||||||
|
Users need to be notified immediately when:
|
||||||
|
|
||||||
|
1. **New deals are found** on their watched items
|
||||||
|
2. **System announcements** need to be broadcast
|
||||||
|
3. **Background jobs complete** that affect their data
|
||||||
|
|
||||||
|
Traditional approaches:
|
||||||
|
|
||||||
|
- **HTTP Polling**: Inefficient, creates unnecessary load, delays up to polling interval
|
||||||
|
- **Server-Sent Events (SSE)**: One-way only, no client-to-server messaging
|
||||||
|
- **WebSockets**: Bi-directional, real-time, efficient
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
We will implement a real-time communication system using **WebSockets** (e.g., with the `ws` library or Socket.IO). This will involve an architecture for a notification service that listens for backend events (like a new deal from a background job) and pushes live updates to connected clients.
|
We will implement a real-time communication system using **WebSockets** with the `ws` library. This will involve:
|
||||||
|
|
||||||
|
1. **WebSocket Server**: Manages connections, authentication, and message routing
|
||||||
|
2. **React Hook**: Provides easy integration for React components
|
||||||
|
3. **Event Bus Integration**: Bridges WebSocket messages to in-app events
|
||||||
|
4. **Background Job Integration**: Emits WebSocket notifications when deals are found
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
- **JWT Authentication**: WebSocket connections authenticated via JWT tokens
|
||||||
|
- **Type-Safe Messages**: Strongly-typed message formats prevent errors
|
||||||
|
- **Auto-Reconnect**: Client automatically reconnects with exponential backoff
|
||||||
|
- **Graceful Degradation**: Email + DB notifications remain for offline users
|
||||||
|
- **Heartbeat Ping/Pong**: Detect and cleanup dead connections
|
||||||
|
- **Singleton Service**: Single WebSocket service instance shared across app
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### WebSocket Message Types
|
||||||
|
|
||||||
|
Located in `src/types/websocket.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface WebSocketMessage<T = unknown> {
|
||||||
|
type: WebSocketMessageType;
|
||||||
|
data: T;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebSocketMessageType =
|
||||||
|
| 'deal-notification'
|
||||||
|
| 'system-message'
|
||||||
|
| 'ping'
|
||||||
|
| 'pong'
|
||||||
|
| 'error'
|
||||||
|
| 'connection-established';
|
||||||
|
|
||||||
|
// Deal notification payload
|
||||||
|
export interface DealNotificationData {
|
||||||
|
notification_id?: string;
|
||||||
|
deals: DealInfo[];
|
||||||
|
user_id: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-safe message creators
|
||||||
|
export const createWebSocketMessage = {
|
||||||
|
dealNotification: (data: DealNotificationData) => ({ ... }),
|
||||||
|
systemMessage: (data: SystemMessageData) => ({ ... }),
|
||||||
|
error: (data: ErrorMessageData) => ({ ... }),
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket Server Service
|
||||||
|
|
||||||
|
Located in `src/services/websocketService.server.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class WebSocketService {
|
||||||
|
private wss: WebSocketServer | null = null;
|
||||||
|
private clients: Map<string, Set<AuthenticatedWebSocket>> = new Map();
|
||||||
|
private pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
initialize(server: HTTPServer): void {
|
||||||
|
this.wss = new WebSocketServer({
|
||||||
|
server,
|
||||||
|
path: '/ws',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.wss.on('connection', (ws, request) => {
|
||||||
|
this.handleConnection(ws, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.startHeartbeat(); // Ping every 30s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication via JWT from query string or cookie
|
||||||
|
private extractToken(request: IncomingMessage): string | null {
|
||||||
|
// Extract from ?token=xxx or Cookie: accessToken=xxx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast to specific user
|
||||||
|
broadcastDealNotification(userId: string, data: DealNotificationData): void {
|
||||||
|
const message = createWebSocketMessage.dealNotification(data);
|
||||||
|
this.broadcastToUser(userId, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast to all users
|
||||||
|
broadcastToAll(data: SystemMessageData): void {
|
||||||
|
// Send to all connected clients
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown(): void {
|
||||||
|
// Gracefully close all connections
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const websocketService = new WebSocketService(globalLogger);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Integration
|
||||||
|
|
||||||
|
Located in `server.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { websocketService } from './src/services/websocketService.server';
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
const server = app.listen(PORT, () => {
|
||||||
|
logger.info(`Authentication server started on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize WebSocket server (ADR-022)
|
||||||
|
websocketService.initialize(server);
|
||||||
|
logger.info('WebSocket server initialized for real-time notifications');
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const handleShutdown = (signal: string) => {
|
||||||
|
websocketService.shutdown();
|
||||||
|
gracefulShutdown(signal);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => handleShutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### React Client Hook
|
||||||
|
|
||||||
|
Located in `src/hooks/useWebSocket.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||||
|
const [state, setState] = useState<WebSocketState>({
|
||||||
|
isConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
const url = getWebSocketUrl(); // wss://host/ws?token=xxx
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data) as WebSocketMessage;
|
||||||
|
|
||||||
|
// Emit to event bus for cross-component communication
|
||||||
|
switch (message.type) {
|
||||||
|
case 'deal-notification':
|
||||||
|
eventBus.dispatch('notification:deal', message.data);
|
||||||
|
break;
|
||||||
|
case 'system-message':
|
||||||
|
eventBus.dispatch('notification:system', message.data);
|
||||||
|
break;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
// Auto-reconnect with exponential backoff
|
||||||
|
if (reconnectAttempts < maxReconnectAttempts) {
|
||||||
|
setTimeout(connect, reconnectDelay * Math.pow(2, reconnectAttempts));
|
||||||
|
reconnectAttempts++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoConnect) connect();
|
||||||
|
return () => disconnect();
|
||||||
|
}, [autoConnect, connect, disconnect]);
|
||||||
|
|
||||||
|
return { ...state, connect, disconnect, send };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Background Job Integration
|
||||||
|
|
||||||
|
Located in `src/services/backgroundJobService.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private async _processDealsForUser({ userProfile, deals }: UserDealGroup) {
|
||||||
|
// ... existing email notification logic ...
|
||||||
|
|
||||||
|
// Send real-time WebSocket notification (ADR-022)
|
||||||
|
const { websocketService } = await import('./websocketService.server');
|
||||||
|
websocketService.broadcastDealNotification(userProfile.user_id, {
|
||||||
|
user_id: userProfile.user_id,
|
||||||
|
deals: deals.map((deal) => ({
|
||||||
|
item_name: deal.item_name,
|
||||||
|
best_price_in_cents: deal.best_price_in_cents,
|
||||||
|
store_name: deal.store.name,
|
||||||
|
store_id: deal.store.store_id,
|
||||||
|
})),
|
||||||
|
message: `You have ${deals.length} new deal(s) on your watched items!`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage in React Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
import { useEventBus } from '../hooks/useEventBus';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
function NotificationComponent() {
|
||||||
|
// Connect to WebSocket
|
||||||
|
const { isConnected, error } = useWebSocket({ autoConnect: true });
|
||||||
|
|
||||||
|
// Listen for deal notifications via event bus
|
||||||
|
const handleDealNotification = useCallback((data: DealNotificationData) => {
|
||||||
|
toast.success(`${data.deals.length} new deals found!`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEventBus('notification:deal', handleDealNotification);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isConnected ? '🟢 Live' : '🔴 Offline'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ WebSocket Architecture │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Server Side:
|
||||||
|
┌──────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||||
|
│ Background Job │─────▶│ WebSocket │─────▶│ Connected │
|
||||||
|
│ (Deal Checker) │ │ Service │ │ Clients │
|
||||||
|
└──────────────────┘ └──────────────────┘ └─────────────────┘
|
||||||
|
│ ▲
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────┐ │
|
||||||
|
│ Email Queue │ │
|
||||||
|
│ (BullMQ) │ │
|
||||||
|
└──────────────────┘ │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ DB Notification │ │ Express Server │
|
||||||
|
│ Storage │ │ + WS Upgrade │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
|
||||||
|
Client Side:
|
||||||
|
┌──────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||||
|
│ useWebSocket │◀────▶│ WebSocket │◀────▶│ Event Bus │
|
||||||
|
│ Hook │ │ Connection │ │ Integration │
|
||||||
|
└──────────────────┘ └──────────────────┘ └─────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ UI Components │
|
||||||
|
│ (Notifications) │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Authentication**: JWT tokens required for WebSocket connections
|
||||||
|
2. **User Isolation**: Messages routed only to authenticated user's connections
|
||||||
|
3. **Rate Limiting**: Heartbeat ping/pong prevents connection flooding
|
||||||
|
4. **Graceful Shutdown**: Notifies clients before server shutdown
|
||||||
|
5. **Error Handling**: Failed WebSocket sends don't crash the server
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
**Positive**: Enables a core, user-facing feature in a scalable and efficient manner. Significantly improves user engagement and experience.
|
### Positive
|
||||||
**Negative**: Introduces a new dependency (e.g., WebSocket library) and adds complexity to the backend and frontend architecture. Requires careful handling of connection management and scaling.
|
|
||||||
|
- **Real-time Updates**: Users see deals immediately when found
|
||||||
|
- **Better UX**: No page refresh needed, instant notifications
|
||||||
|
- **Efficient**: Single persistent connection vs polling every N seconds
|
||||||
|
- **Scalable**: Connection pooling per user, heartbeat cleanup
|
||||||
|
- **Type-Safe**: TypeScript types prevent message format errors
|
||||||
|
- **Resilient**: Auto-reconnect with exponential backoff
|
||||||
|
- **Observable**: Connection stats available via `getConnectionStats()`
|
||||||
|
- **Testable**: Comprehensive unit tests for message types and service
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- **Complexity**: WebSocket server adds new infrastructure component
|
||||||
|
- **Memory**: Each connection consumes server memory
|
||||||
|
- **Scaling**: Single-server implementation (multi-server requires Redis pub/sub)
|
||||||
|
- **Browser Support**: Requires WebSocket-capable browsers (all modern browsers)
|
||||||
|
- **Network**: Persistent connections require stable network
|
||||||
|
|
||||||
|
### Mitigation
|
||||||
|
|
||||||
|
- **Graceful Degradation**: Email + DB notifications remain for offline users
|
||||||
|
- **Connection Limits**: Can add max connections per user if needed
|
||||||
|
- **Monitoring**: Connection stats exposed for observability
|
||||||
|
- **Future Scaling**: Can add Redis pub/sub for multi-instance deployments
|
||||||
|
- **Heartbeat**: 30s ping/pong detects and cleans up dead connections
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
Located in `src/services/websocketService.server.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('WebSocketService', () => {
|
||||||
|
it('should initialize without errors', () => { ... });
|
||||||
|
it('should handle broadcasting with no active connections', () => { ... });
|
||||||
|
it('should shutdown gracefully', () => { ... });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Located in `src/types/websocket.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('WebSocket Message Creators', () => {
|
||||||
|
it('should create valid deal notification messages', () => { ... });
|
||||||
|
it('should generate valid ISO timestamps', () => { ... });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
Future work: Add integration tests that:
|
||||||
|
|
||||||
|
- Connect WebSocket clients to test server
|
||||||
|
- Verify authentication and message routing
|
||||||
|
- Test reconnection logic
|
||||||
|
- Validate message delivery
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
- `src/types/websocket.ts` - WebSocket message types and creators
|
||||||
|
- `src/services/websocketService.server.ts` - WebSocket server service
|
||||||
|
- `src/hooks/useWebSocket.ts` - React hook for WebSocket connections
|
||||||
|
- `src/services/backgroundJobService.ts` - Integration point for deal notifications
|
||||||
|
- `server.ts` - Express + WebSocket server initialization
|
||||||
|
- `src/services/websocketService.server.test.ts` - Unit tests
|
||||||
|
- `src/types/websocket.test.ts` - Message type tests
|
||||||
|
|
||||||
|
## Related ADRs
|
||||||
|
|
||||||
|
- [ADR-036](./0036-event-bus-and-pub-sub-pattern.md) - Event Bus Pattern (used by client hook)
|
||||||
|
- [ADR-042](./0042-email-and-notification-architecture.md) - Email Notifications (fallback mechanism)
|
||||||
|
- [ADR-006](./0006-background-job-processing-and-task-queues.md) - Background Jobs (triggers WebSocket notifications)
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -84,6 +84,7 @@
|
|||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
"@types/swagger-jsdoc": "^6.0.4",
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.47.0",
|
"@typescript-eslint/eslint-plugin": "^8.47.0",
|
||||||
"@typescript-eslint/parser": "^8.47.0",
|
"@typescript-eslint/parser": "^8.47.0",
|
||||||
@@ -6741,6 +6742,16 @@
|
|||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/zxcvbn": {
|
"node_modules/@types/zxcvbn": {
|
||||||
"version": "4.4.5",
|
"version": "4.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.5.tgz",
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
"@types/swagger-jsdoc": "^6.0.4",
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.47.0",
|
"@typescript-eslint/eslint-plugin": "^8.47.0",
|
||||||
"@typescript-eslint/parser": "^8.47.0",
|
"@typescript-eslint/parser": "^8.47.0",
|
||||||
|
|||||||
21
server.ts
21
server.ts
@@ -40,6 +40,7 @@ import reactionsRouter from './src/routes/reactions.routes';
|
|||||||
import storeRouter from './src/routes/store.routes';
|
import storeRouter from './src/routes/store.routes';
|
||||||
import { errorHandler } from './src/middleware/errorHandler';
|
import { errorHandler } from './src/middleware/errorHandler';
|
||||||
import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService';
|
import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService';
|
||||||
|
import { websocketService } from './src/services/websocketService.server';
|
||||||
import type { UserProfile } from './src/types';
|
import type { UserProfile } from './src/types';
|
||||||
|
|
||||||
// API Documentation (ADR-018)
|
// API Documentation (ADR-018)
|
||||||
@@ -315,13 +316,17 @@ app.use(errorHandler);
|
|||||||
// This prevents the server from trying to listen on a port during tests.
|
// This prevents the server from trying to listen on a port during tests.
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
logger.info(`Authentication server started on port ${PORT}`);
|
logger.info(`Authentication server started on port ${PORT}`);
|
||||||
console.log('--- REGISTERED API ROUTES ---');
|
console.log('--- REGISTERED API ROUTES ---');
|
||||||
console.table(listEndpoints(app));
|
console.table(listEndpoints(app));
|
||||||
console.log('-----------------------------');
|
console.log('-----------------------------');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize WebSocket server (ADR-022)
|
||||||
|
websocketService.initialize(server);
|
||||||
|
logger.info('WebSocket server initialized for real-time notifications');
|
||||||
|
|
||||||
// Start the scheduled background jobs
|
// Start the scheduled background jobs
|
||||||
startBackgroundJobs(
|
startBackgroundJobs(
|
||||||
backgroundJobService,
|
backgroundJobService,
|
||||||
@@ -332,8 +337,18 @@ if (process.env.NODE_ENV !== 'test') {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// --- Graceful Shutdown Handling ---
|
// --- Graceful Shutdown Handling ---
|
||||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
const handleShutdown = (signal: string) => {
|
||||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
logger.info(`${signal} received, starting graceful shutdown...`);
|
||||||
|
|
||||||
|
// Shutdown WebSocket server
|
||||||
|
websocketService.shutdown();
|
||||||
|
|
||||||
|
// Shutdown queues and workers
|
||||||
|
gracefulShutdown(signal);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => handleShutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export the app for integration testing
|
// Export the app for integration testing
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
|||||||
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
store_location_id BIGINT NOT NULL REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE,
|
||||||
price_in_cents INTEGER NOT NULL CHECK (price_in_cents > 0),
|
price_in_cents INTEGER NOT NULL CHECK (price_in_cents > 0),
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
upvotes INTEGER DEFAULT 0 NOT NULL CHECK (upvotes >= 0),
|
upvotes INTEGER DEFAULT 0 NOT NULL CHECK (upvotes >= 0),
|
||||||
@@ -472,6 +472,7 @@ COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitt
|
|||||||
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
|
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_user_id ON public.user_submitted_prices(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_user_id ON public.user_submitted_prices(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_master_item_id ON public.user_submitted_prices(master_item_id);
|
CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_master_item_id ON public.user_submitted_prices(master_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_store_location_id ON public.user_submitted_prices(store_location_id);
|
||||||
|
|
||||||
-- 22. Log flyer items that could not be automatically matched to a master item.
|
-- 22. Log flyer items that could not be automatically matched to a master item.
|
||||||
CREATE TABLE IF NOT EXISTS public.unmatched_flyer_items (
|
CREATE TABLE IF NOT EXISTS public.unmatched_flyer_items (
|
||||||
@@ -936,7 +937,7 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(
|
|||||||
CREATE TABLE IF NOT EXISTS public.receipts (
|
CREATE TABLE IF NOT EXISTS public.receipts (
|
||||||
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE SET NULL,
|
||||||
receipt_image_url TEXT NOT NULL,
|
receipt_image_url TEXT NOT NULL,
|
||||||
transaction_date TIMESTAMPTZ,
|
transaction_date TIMESTAMPTZ,
|
||||||
total_amount_cents INTEGER CHECK (total_amount_cents IS NULL OR total_amount_cents >= 0),
|
total_amount_cents INTEGER CHECK (total_amount_cents IS NULL OR total_amount_cents >= 0),
|
||||||
@@ -956,7 +957,7 @@ CREATE TABLE IF NOT EXISTS public.receipts (
|
|||||||
-- CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https://?.*')
|
-- CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https://?.*')
|
||||||
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
|
CREATE INDEX IF NOT EXISTS idx_receipts_store_location_id ON public.receipts(store_location_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_status_retry ON public.receipts(status, retry_count) WHERE status IN ('pending', 'failed') AND retry_count < 3;
|
CREATE INDEX IF NOT EXISTS idx_receipts_status_retry ON public.receipts(status, retry_count) WHERE status IN ('pending', 'failed') AND retry_count < 3;
|
||||||
|
|
||||||
-- 53. Store individual line items extracted from a user receipt.
|
-- 53. Store individual line items extracted from a user receipt.
|
||||||
|
|||||||
@@ -475,7 +475,7 @@ CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
|
|||||||
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
store_location_id BIGINT NOT NULL REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE,
|
||||||
price_in_cents INTEGER NOT NULL CHECK (price_in_cents > 0),
|
price_in_cents INTEGER NOT NULL CHECK (price_in_cents > 0),
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
upvotes INTEGER DEFAULT 0 NOT NULL CHECK (upvotes >= 0),
|
upvotes INTEGER DEFAULT 0 NOT NULL CHECK (upvotes >= 0),
|
||||||
@@ -489,6 +489,7 @@ COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitt
|
|||||||
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
|
COMMENT ON COLUMN public.user_submitted_prices.upvotes IS 'Community validation score indicating accuracy.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_user_id ON public.user_submitted_prices(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_user_id ON public.user_submitted_prices(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_master_item_id ON public.user_submitted_prices(master_item_id);
|
CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_master_item_id ON public.user_submitted_prices(master_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_store_location_id ON public.user_submitted_prices(store_location_id);
|
||||||
|
|
||||||
-- 22. Log flyer items that could not be automatically matched to a master item.
|
-- 22. Log flyer items that could not be automatically matched to a master item.
|
||||||
CREATE TABLE IF NOT EXISTS public.unmatched_flyer_items (
|
CREATE TABLE IF NOT EXISTS public.unmatched_flyer_items (
|
||||||
@@ -955,7 +956,7 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows(
|
|||||||
CREATE TABLE IF NOT EXISTS public.receipts (
|
CREATE TABLE IF NOT EXISTS public.receipts (
|
||||||
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE,
|
||||||
store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE,
|
store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE SET NULL,
|
||||||
receipt_image_url TEXT NOT NULL,
|
receipt_image_url TEXT NOT NULL,
|
||||||
transaction_date TIMESTAMPTZ,
|
transaction_date TIMESTAMPTZ,
|
||||||
total_amount_cents INTEGER CHECK (total_amount_cents IS NULL OR total_amount_cents >= 0),
|
total_amount_cents INTEGER CHECK (total_amount_cents IS NULL OR total_amount_cents >= 0),
|
||||||
@@ -975,7 +976,7 @@ CREATE TABLE IF NOT EXISTS public.receipts (
|
|||||||
-- CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https?://.*'),
|
-- CONSTRAINT receipts_receipt_image_url_check CHECK (receipt_image_url ~* '^https?://.*'),
|
||||||
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
COMMENT ON TABLE public.receipts IS 'Stores uploaded user receipts for purchase tracking and analysis.';
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
CREATE INDEX IF NOT EXISTS idx_receipts_user_id ON public.receipts(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_store_id ON public.receipts(store_id);
|
CREATE INDEX IF NOT EXISTS idx_receipts_store_location_id ON public.receipts(store_location_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_status_retry ON public.receipts(status, retry_count) WHERE status IN ('pending', 'failed') AND retry_count < 3;
|
CREATE INDEX IF NOT EXISTS idx_receipts_status_retry ON public.receipts(status, retry_count) WHERE status IN ('pending', 'failed') AND retry_count < 3;
|
||||||
|
|
||||||
-- 53. Store individual line items extracted from a user receipt.
|
-- 53. Store individual line items extracted from a user receipt.
|
||||||
|
|||||||
44
sql/migrations/004_populate_flyer_locations.sql
Normal file
44
sql/migrations/004_populate_flyer_locations.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- Migration: Populate flyer_locations table with existing flyer→store relationships
|
||||||
|
-- Purpose: The flyer_locations table was created in the initial schema but never populated.
|
||||||
|
-- This migration populates it with data from the legacy flyer.store_id relationship.
|
||||||
|
--
|
||||||
|
-- Background: The schema correctly defines a many-to-many relationship between flyers
|
||||||
|
-- and store_locations via the flyer_locations table, but all code was using
|
||||||
|
-- the legacy flyer.store_id foreign key directly.
|
||||||
|
|
||||||
|
-- Step 1: For each flyer with a store_id, link it to all locations of that store
|
||||||
|
-- This assumes that if a flyer is associated with a store, it's valid at ALL locations of that store
|
||||||
|
INSERT INTO public.flyer_locations (flyer_id, store_location_id)
|
||||||
|
SELECT DISTINCT
|
||||||
|
f.flyer_id,
|
||||||
|
sl.store_location_id
|
||||||
|
FROM public.flyers f
|
||||||
|
JOIN public.store_locations sl ON f.store_id = sl.store_id
|
||||||
|
WHERE f.store_id IS NOT NULL
|
||||||
|
ON CONFLICT (flyer_id, store_location_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Step 2: Add a comment documenting this migration
|
||||||
|
COMMENT ON TABLE public.flyer_locations IS
|
||||||
|
'A linking table associating a single flyer with multiple store locations where its deals are valid. Populated from legacy flyer.store_id relationships via migration 004.';
|
||||||
|
|
||||||
|
-- Step 3: Verify the migration worked
|
||||||
|
-- This should return the number of flyer_location entries created
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
flyer_location_count INTEGER;
|
||||||
|
flyer_with_store_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO flyer_location_count FROM public.flyer_locations;
|
||||||
|
SELECT COUNT(*) INTO flyer_with_store_count FROM public.flyers WHERE store_id IS NOT NULL;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Migration 004 complete:';
|
||||||
|
RAISE NOTICE ' - Created % flyer_location entries', flyer_location_count;
|
||||||
|
RAISE NOTICE ' - Based on % flyers with store_id', flyer_with_store_count;
|
||||||
|
|
||||||
|
IF flyer_location_count = 0 AND flyer_with_store_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'Migration 004 failed: No flyer_locations created but flyers with store_id exist';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Note: The flyer.store_id column is kept for backward compatibility but should eventually be deprecated
|
||||||
|
-- Future work: Add a migration to remove flyer.store_id once all code uses flyer_locations
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- Migration: Add store_location_id to user_submitted_prices table
|
||||||
|
-- Purpose: Replace store_id with store_location_id for better geographic specificity.
|
||||||
|
-- This allows prices to be specific to individual store locations rather than
|
||||||
|
-- all locations of a store chain.
|
||||||
|
|
||||||
|
-- Step 1: Add the new column (nullable initially for backward compatibility)
|
||||||
|
ALTER TABLE public.user_submitted_prices
|
||||||
|
ADD COLUMN store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Step 2: Create index on the new column
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_submitted_prices_store_location_id
|
||||||
|
ON public.user_submitted_prices(store_location_id);
|
||||||
|
|
||||||
|
-- Step 3: Migrate existing data
|
||||||
|
-- For each existing price with a store_id, link it to the first location of that store
|
||||||
|
-- (or a random location if multiple exist)
|
||||||
|
UPDATE public.user_submitted_prices usp
|
||||||
|
SET store_location_id = sl.store_location_id
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (store_id)
|
||||||
|
store_id,
|
||||||
|
store_location_id
|
||||||
|
FROM public.store_locations
|
||||||
|
ORDER BY store_id, store_location_id ASC
|
||||||
|
) sl
|
||||||
|
WHERE usp.store_id = sl.store_id
|
||||||
|
AND usp.store_location_id IS NULL;
|
||||||
|
|
||||||
|
-- Step 4: Make store_location_id NOT NULL (all existing data should now have values)
|
||||||
|
ALTER TABLE public.user_submitted_prices
|
||||||
|
ALTER COLUMN store_location_id SET NOT NULL;
|
||||||
|
|
||||||
|
-- Step 5: Drop the old store_id column (no longer needed - store_location_id provides better specificity)
|
||||||
|
ALTER TABLE public.user_submitted_prices DROP COLUMN store_id;
|
||||||
|
|
||||||
|
-- Step 6: Update table comment
|
||||||
|
COMMENT ON TABLE public.user_submitted_prices IS
|
||||||
|
'Stores item prices submitted by users directly from physical stores. Uses store_location_id for geographic specificity (added in migration 005).';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.user_submitted_prices.store_location_id IS
|
||||||
|
'The specific store location where this price was observed. Provides geographic specificity for price comparisons.';
|
||||||
|
|
||||||
|
-- Step 7: Verify the migration
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
rows_with_location INTEGER;
|
||||||
|
total_rows INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO rows_with_location FROM public.user_submitted_prices WHERE store_location_id IS NOT NULL;
|
||||||
|
SELECT COUNT(*) INTO total_rows FROM public.user_submitted_prices;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Migration 005 complete:';
|
||||||
|
RAISE NOTICE ' - % of % user_submitted_prices now have store_location_id', rows_with_location, total_rows;
|
||||||
|
RAISE NOTICE ' - store_id column has been removed - all prices use store_location_id';
|
||||||
|
|
||||||
|
IF total_rows > 0 AND rows_with_location != total_rows THEN
|
||||||
|
RAISE EXCEPTION 'Migration 005 failed: Not all prices have store_location_id';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
54
sql/migrations/006_add_store_location_to_receipts.sql
Normal file
54
sql/migrations/006_add_store_location_to_receipts.sql
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
-- Migration: Add store_location_id to receipts table
|
||||||
|
-- Purpose: Replace store_id with store_location_id for better geographic specificity.
|
||||||
|
-- This allows receipts to be tied to specific store locations, enabling
|
||||||
|
-- location-based shopping pattern analysis and better receipt matching.
|
||||||
|
|
||||||
|
-- Step 1: Add the new column (nullable initially for backward compatibility)
|
||||||
|
ALTER TABLE public.receipts
|
||||||
|
ADD COLUMN store_location_id BIGINT REFERENCES public.store_locations(store_location_id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Step 2: Create index on the new column
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_receipts_store_location_id
|
||||||
|
ON public.receipts(store_location_id);
|
||||||
|
|
||||||
|
-- Step 3: Migrate existing data
|
||||||
|
-- For each existing receipt with a store_id, link it to the first location of that store
|
||||||
|
UPDATE public.receipts r
|
||||||
|
SET store_location_id = sl.store_location_id
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (store_id)
|
||||||
|
store_id,
|
||||||
|
store_location_id
|
||||||
|
FROM public.store_locations
|
||||||
|
ORDER BY store_id, store_location_id ASC
|
||||||
|
) sl
|
||||||
|
WHERE r.store_id = sl.store_id
|
||||||
|
AND r.store_location_id IS NULL;
|
||||||
|
|
||||||
|
-- Step 4: Drop the old store_id column (no longer needed - store_location_id provides better specificity)
|
||||||
|
ALTER TABLE public.receipts DROP COLUMN store_id;
|
||||||
|
|
||||||
|
-- Step 5: Update table comment
|
||||||
|
COMMENT ON TABLE public.receipts IS
|
||||||
|
'Stores uploaded user receipts for purchase tracking and analysis. Uses store_location_id for geographic specificity (added in migration 006).';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.receipts.store_location_id IS
|
||||||
|
'The specific store location where this purchase was made. Provides geographic specificity for shopping pattern analysis.';
|
||||||
|
|
||||||
|
-- Step 6: Verify the migration
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
rows_with_location INTEGER;
|
||||||
|
total_rows INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO rows_with_location FROM public.receipts WHERE store_location_id IS NOT NULL;
|
||||||
|
SELECT COUNT(*) INTO total_rows FROM public.receipts;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Migration 006 complete:';
|
||||||
|
RAISE NOTICE ' - Total receipts: %', total_rows;
|
||||||
|
RAISE NOTICE ' - Receipts with store_location_id: %', rows_with_location;
|
||||||
|
RAISE NOTICE ' - store_id column has been removed - all receipts use store_location_id';
|
||||||
|
RAISE NOTICE ' - Note: store_location_id may be NULL if receipt not yet matched to a store';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Note: store_location_id is nullable because receipts may not have a matched store yet during processing.
|
||||||
131
src/components/NotificationBell.tsx
Normal file
131
src/components/NotificationBell.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// src/components/NotificationBell.tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-time notification bell component
|
||||||
|
* Displays WebSocket connection status and unread notification count
|
||||||
|
* Integrates with useWebSocket hook for real-time updates
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Bell, Wifi, WifiOff } from 'lucide-react';
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
import { useEventBus } from '../hooks/useEventBus';
|
||||||
|
import type { DealNotificationData } from '../types/websocket';
|
||||||
|
|
||||||
|
interface NotificationBellProps {
|
||||||
|
/**
|
||||||
|
* Callback when bell is clicked
|
||||||
|
*/
|
||||||
|
onClick?: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the connection status indicator
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showConnectionStatus?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom CSS classes for the bell container
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationBell({
|
||||||
|
onClick,
|
||||||
|
showConnectionStatus = true,
|
||||||
|
className = '',
|
||||||
|
}: NotificationBellProps) {
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const { isConnected, error } = useWebSocket({ autoConnect: true });
|
||||||
|
|
||||||
|
// Handle incoming deal notifications
|
||||||
|
const handleDealNotification = useCallback((data?: DealNotificationData) => {
|
||||||
|
if (data) {
|
||||||
|
setUnreadCount((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen for deal notifications via event bus
|
||||||
|
useEventBus('notification:deal', handleDealNotification);
|
||||||
|
|
||||||
|
// Reset count when clicked
|
||||||
|
const handleClick = () => {
|
||||||
|
setUnreadCount(0);
|
||||||
|
onClick?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative inline-block ${className}`}>
|
||||||
|
{/* Notification Bell Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="relative p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
|
||||||
|
title={
|
||||||
|
error
|
||||||
|
? `WebSocket error: ${error}`
|
||||||
|
: isConnected
|
||||||
|
? 'Connected to live notifications'
|
||||||
|
: 'Connecting...'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Bell
|
||||||
|
className={`w-6 h-6 ${unreadCount > 0 ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Unread Badge */}
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute top-0 right-0 inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-600 rounded-full transform translate-x-1 -translate-y-1">
|
||||||
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connection Status Indicator */}
|
||||||
|
{showConnectionStatus && (
|
||||||
|
<span
|
||||||
|
className="absolute bottom-0 right-0 inline-block w-3 h-3 rounded-full border-2 border-white dark:border-gray-900 transform translate-x-1 translate-y-1"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isConnected ? '#10b981' : error ? '#ef4444' : '#f59e0b',
|
||||||
|
}}
|
||||||
|
title={isConnected ? 'Connected' : error ? 'Disconnected' : 'Connecting'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Connection Status Tooltip (shown on hover when disconnected) */}
|
||||||
|
{!isConnected && error && (
|
||||||
|
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-gray-900 text-white text-sm rounded-lg shadow-lg whitespace-nowrap z-50 opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WifiOff className="w-4 h-4 text-red-400" />
|
||||||
|
<span>Live notifications unavailable</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple connection status indicator (no bell, just status)
|
||||||
|
*/
|
||||||
|
export function ConnectionStatus() {
|
||||||
|
const { isConnected, error } = useWebSocket({ autoConnect: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-gray-100 dark:bg-gray-800 text-sm">
|
||||||
|
{isConnected ? (
|
||||||
|
<>
|
||||||
|
<Wifi className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">Live</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<WifiOff className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">
|
||||||
|
{error ? 'Offline' : 'Connecting...'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
src/components/NotificationToastHandler.tsx
Normal file
177
src/components/NotificationToastHandler.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
// src/components/NotificationToastHandler.tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global notification toast handler
|
||||||
|
* Listens for WebSocket notifications and displays them as toasts
|
||||||
|
* Should be rendered once at the app root level
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
import { useEventBus } from '../hooks/useEventBus';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import type { DealNotificationData, SystemMessageData } from '../types/websocket';
|
||||||
|
import { formatCurrency } from '../utils/formatUtils';
|
||||||
|
|
||||||
|
interface NotificationToastHandlerProps {
|
||||||
|
/**
|
||||||
|
* Whether to enable toast notifications
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
enabled?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to play a sound when notifications arrive
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
playSound?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom sound URL (if playSound is true)
|
||||||
|
*/
|
||||||
|
soundUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationToastHandler({
|
||||||
|
enabled = true,
|
||||||
|
playSound = false,
|
||||||
|
soundUrl = '/notification-sound.mp3',
|
||||||
|
}: NotificationToastHandlerProps) {
|
||||||
|
// Connect to WebSocket
|
||||||
|
const { isConnected, error } = useWebSocket({
|
||||||
|
autoConnect: true,
|
||||||
|
onConnect: () => {
|
||||||
|
if (enabled) {
|
||||||
|
toast.success('Connected to live notifications', {
|
||||||
|
duration: 2000,
|
||||||
|
icon: '🟢',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDisconnect: () => {
|
||||||
|
if (enabled && error) {
|
||||||
|
toast.error('Disconnected from live notifications', {
|
||||||
|
duration: 3000,
|
||||||
|
icon: '🔴',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play notification sound
|
||||||
|
const playNotificationSound = useCallback(() => {
|
||||||
|
if (!playSound) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audio = new Audio(soundUrl);
|
||||||
|
audio.volume = 0.3;
|
||||||
|
audio.play().catch((error) => {
|
||||||
|
console.warn('Failed to play notification sound:', error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to play notification sound:', error);
|
||||||
|
}
|
||||||
|
}, [playSound, soundUrl]);
|
||||||
|
|
||||||
|
// Handle deal notifications
|
||||||
|
const handleDealNotification = useCallback(
|
||||||
|
(data?: DealNotificationData) => {
|
||||||
|
if (!enabled || !data) return;
|
||||||
|
|
||||||
|
playNotificationSound();
|
||||||
|
|
||||||
|
const dealsCount = data.deals.length;
|
||||||
|
const firstDeal = data.deals[0];
|
||||||
|
|
||||||
|
// Show toast with deal information
|
||||||
|
toast.success(
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="font-semibold">
|
||||||
|
{dealsCount === 1 ? 'New Deal Found!' : `${dealsCount} New Deals Found!`}
|
||||||
|
</div>
|
||||||
|
{dealsCount === 1 && firstDeal && (
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{firstDeal.item_name} for {formatCurrency(firstDeal.best_price_in_cents)} at{' '}
|
||||||
|
{firstDeal.store_name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dealsCount > 1 && (
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Check your deals page to see all offers
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
duration: 5000,
|
||||||
|
icon: '🎉',
|
||||||
|
position: 'top-right',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[enabled, playNotificationSound],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle system messages
|
||||||
|
const handleSystemMessage = useCallback(
|
||||||
|
(data?: SystemMessageData) => {
|
||||||
|
if (!enabled || !data) return;
|
||||||
|
|
||||||
|
const toastOptions = {
|
||||||
|
duration: data.severity === 'error' ? 6000 : 4000,
|
||||||
|
position: 'top-center' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (data.severity) {
|
||||||
|
case 'error':
|
||||||
|
toast.error(data.message, { ...toastOptions, icon: '❌' });
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
toast(data.message, { ...toastOptions, icon: '⚠️' });
|
||||||
|
break;
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
toast(data.message, { ...toastOptions, icon: 'ℹ️' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
const handleError = useCallback(
|
||||||
|
(data?: { message: string; code?: string }) => {
|
||||||
|
if (!enabled || !data) return;
|
||||||
|
|
||||||
|
toast.error(`Error: ${data.message}`, {
|
||||||
|
duration: 5000,
|
||||||
|
icon: '🚨',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[enabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe to event bus
|
||||||
|
useEventBus('notification:deal', handleDealNotification);
|
||||||
|
useEventBus('notification:system', handleSystemMessage);
|
||||||
|
useEventBus('notification:error', handleError);
|
||||||
|
|
||||||
|
// Show connection error if persistent
|
||||||
|
useEffect(() => {
|
||||||
|
if (error && !isConnected) {
|
||||||
|
// Only show after a delay to avoid showing on initial connection
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (error && !isConnected && enabled) {
|
||||||
|
toast.error('Unable to connect to live notifications. Some features may be limited.', {
|
||||||
|
duration: 5000,
|
||||||
|
icon: '⚠️',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [error, isConnected, enabled]);
|
||||||
|
|
||||||
|
// This component doesn't render anything - it just handles side effects
|
||||||
|
return null;
|
||||||
|
}
|
||||||
41
src/hooks/useEventBus.ts
Normal file
41
src/hooks/useEventBus.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// src/hooks/useEventBus.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook for subscribing to event bus events
|
||||||
|
* Automatically handles cleanup on unmount
|
||||||
|
*
|
||||||
|
* Based on ADR-036: Event Bus and Pub/Sub Pattern
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { eventBus } from '../services/eventBus';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to subscribe to event bus events
|
||||||
|
* @param event The event name to listen for
|
||||||
|
* @param callback The callback function to execute when the event is dispatched
|
||||||
|
*/
|
||||||
|
export function useEventBus<T = unknown>(event: string, callback: (data?: T) => void): void {
|
||||||
|
// Use a ref to store the latest callback to avoid unnecessary re-subscriptions
|
||||||
|
const callbackRef = useRef(callback);
|
||||||
|
|
||||||
|
// Update the ref when callback changes
|
||||||
|
useEffect(() => {
|
||||||
|
callbackRef.current = callback;
|
||||||
|
}, [callback]);
|
||||||
|
|
||||||
|
// Stable callback that calls the latest version
|
||||||
|
const stableCallback = useCallback((data?: unknown) => {
|
||||||
|
callbackRef.current(data as T);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Subscribe to the event
|
||||||
|
eventBus.on(event, stableCallback);
|
||||||
|
|
||||||
|
// Cleanup: unsubscribe on unmount
|
||||||
|
return () => {
|
||||||
|
eventBus.off(event, stableCallback);
|
||||||
|
};
|
||||||
|
}, [event, stableCallback]);
|
||||||
|
}
|
||||||
284
src/hooks/useWebSocket.ts
Normal file
284
src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
// src/hooks/useWebSocket.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook for WebSocket connections with automatic reconnection
|
||||||
|
* and integration with the event bus for cross-component notifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
|
import { eventBus } from '../services/eventBus';
|
||||||
|
import type { WebSocketMessage, DealNotificationData, SystemMessageData } from '../types/websocket';
|
||||||
|
|
||||||
|
interface UseWebSocketOptions {
|
||||||
|
/**
|
||||||
|
* Whether to automatically connect on mount
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
autoConnect?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of reconnection attempts
|
||||||
|
* @default 5
|
||||||
|
*/
|
||||||
|
maxReconnectAttempts?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base delay for exponential backoff (in ms)
|
||||||
|
* @default 1000
|
||||||
|
*/
|
||||||
|
reconnectDelay?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when connection is established
|
||||||
|
*/
|
||||||
|
onConnect?: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when connection is closed
|
||||||
|
*/
|
||||||
|
onDisconnect?: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when an error occurs
|
||||||
|
*/
|
||||||
|
onError?: (error: Event) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebSocketState {
|
||||||
|
isConnected: boolean;
|
||||||
|
isConnecting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing WebSocket connections to receive real-time notifications
|
||||||
|
*/
|
||||||
|
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||||
|
const {
|
||||||
|
autoConnect = true,
|
||||||
|
maxReconnectAttempts = 5,
|
||||||
|
reconnectDelay = 1000,
|
||||||
|
onConnect,
|
||||||
|
onDisconnect,
|
||||||
|
onError,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const shouldReconnectRef = useRef(true);
|
||||||
|
|
||||||
|
const [state, setState] = useState<WebSocketState>({
|
||||||
|
isConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the WebSocket URL based on current location
|
||||||
|
*/
|
||||||
|
const getWebSocketUrl = useCallback((): string => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
|
||||||
|
// Get access token from cookie
|
||||||
|
const token = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find((row) => row.startsWith('accessToken='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('No access token found. Please log in.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${protocol}//${host}/ws?token=${encodeURIComponent(token)}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming WebSocket messages
|
||||||
|
*/
|
||||||
|
const handleMessage = useCallback((event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data) as WebSocketMessage;
|
||||||
|
|
||||||
|
// Handle different message types
|
||||||
|
switch (message.type) {
|
||||||
|
case 'connection-established':
|
||||||
|
console.log('[WebSocket] Connection established:', message.data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'deal-notification':
|
||||||
|
// Emit to event bus for components to listen
|
||||||
|
eventBus.dispatch('notification:deal', message.data as DealNotificationData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'system-message':
|
||||||
|
// Emit to event bus for system-wide notifications
|
||||||
|
eventBus.dispatch('notification:system', message.data as SystemMessageData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
console.error('[WebSocket] Server error:', message.data);
|
||||||
|
eventBus.dispatch('notification:error', message.data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ping':
|
||||||
|
// Respond to ping with pong
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(
|
||||||
|
JSON.stringify({ type: 'pong', data: {}, timestamp: new Date().toISOString() }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'pong':
|
||||||
|
// Server acknowledged our ping
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('[WebSocket] Unknown message type:', message.type);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebSocket] Failed to parse message:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the WebSocket server
|
||||||
|
*/
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (
|
||||||
|
wsRef.current?.readyState === WebSocket.OPEN ||
|
||||||
|
wsRef.current?.readyState === WebSocket.CONNECTING
|
||||||
|
) {
|
||||||
|
console.warn('[WebSocket] Already connected or connecting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState((prev) => ({ ...prev, isConnecting: true, error: null }));
|
||||||
|
|
||||||
|
const url = getWebSocketUrl();
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('[WebSocket] Connected');
|
||||||
|
reconnectAttemptsRef.current = 0; // Reset reconnect attempts on successful connection
|
||||||
|
setState({ isConnected: true, isConnecting: false, error: null });
|
||||||
|
onConnect?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = handleMessage;
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('[WebSocket] Error:', error);
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
error: 'WebSocket connection error',
|
||||||
|
}));
|
||||||
|
onError?.(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
console.log('[WebSocket] Disconnected:', event.code, event.reason);
|
||||||
|
setState({
|
||||||
|
isConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
error: event.reason || 'Connection closed',
|
||||||
|
});
|
||||||
|
onDisconnect?.();
|
||||||
|
|
||||||
|
// Attempt to reconnect with exponential backoff
|
||||||
|
if (shouldReconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||||
|
const delay = reconnectDelay * Math.pow(2, reconnectAttemptsRef.current);
|
||||||
|
console.log(
|
||||||
|
`[WebSocket] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1}/${maxReconnectAttempts})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
reconnectAttemptsRef.current += 1;
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
|
} else if (reconnectAttemptsRef.current >= maxReconnectAttempts) {
|
||||||
|
console.error('[WebSocket] Max reconnection attempts reached');
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
error: 'Failed to reconnect after multiple attempts',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current = ws;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebSocket] Failed to connect:', error);
|
||||||
|
setState({
|
||||||
|
isConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to connect',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
getWebSocketUrl,
|
||||||
|
handleMessage,
|
||||||
|
maxReconnectAttempts,
|
||||||
|
reconnectDelay,
|
||||||
|
onConnect,
|
||||||
|
onDisconnect,
|
||||||
|
onError,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from the WebSocket server
|
||||||
|
*/
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
shouldReconnectRef.current = false;
|
||||||
|
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
reconnectTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close(1000, 'Client disconnecting');
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to the server
|
||||||
|
*/
|
||||||
|
const send = useCallback((message: WebSocketMessage) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify(message));
|
||||||
|
} else {
|
||||||
|
console.warn('[WebSocket] Cannot send message: not connected');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-connect on mount if enabled
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoConnect) {
|
||||||
|
shouldReconnectRef.current = true;
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
}, [autoConnect, connect, disconnect]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
send,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1229,6 +1229,54 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /admin/websocket/stats:
|
||||||
|
* get:
|
||||||
|
* tags: [Admin]
|
||||||
|
* summary: Get WebSocket connection statistics
|
||||||
|
* description: Get real-time WebSocket connection stats including total users and connections. Requires admin role. (ADR-022)
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: WebSocket connection statistics
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* success:
|
||||||
|
* type: boolean
|
||||||
|
* data:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* totalUsers:
|
||||||
|
* type: number
|
||||||
|
* description: Number of unique users with active connections
|
||||||
|
* totalConnections:
|
||||||
|
* type: number
|
||||||
|
* description: Total number of active WebSocket connections
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Forbidden - admin role required
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/websocket/stats',
|
||||||
|
validateRequest(emptySchema),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { websocketService } = await import('../services/websocketService.server');
|
||||||
|
const stats = websocketService.getConnectionStats();
|
||||||
|
sendSuccess(res, stats);
|
||||||
|
} catch (error) {
|
||||||
|
req.log.error({ error }, 'Error fetching WebSocket stats');
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /admin/jobs/{queueName}/{jobId}/retry:
|
* /admin/jobs/{queueName}/{jobId}/retry:
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const _receiptItemIdParamSchema = numericIdParam(
|
|||||||
*/
|
*/
|
||||||
const uploadReceiptSchema = z.object({
|
const uploadReceiptSchema = z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
store_id: z
|
store_location_id: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((val) => (val ? parseInt(val, 10) : undefined))
|
.transform((val) => (val ? parseInt(val, 10) : undefined))
|
||||||
@@ -80,7 +80,7 @@ const receiptQuerySchema = z.object({
|
|||||||
limit: optionalNumeric({ default: 50, min: 1, max: 100, integer: true }),
|
limit: optionalNumeric({ default: 50, min: 1, max: 100, integer: true }),
|
||||||
offset: optionalNumeric({ default: 0, min: 0, integer: true }),
|
offset: optionalNumeric({ default: 0, min: 0, integer: true }),
|
||||||
status: receiptStatusSchema.optional(),
|
status: receiptStatusSchema.optional(),
|
||||||
store_id: z
|
store_location_id: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((val) => (val ? parseInt(val, 10) : undefined))
|
.transform((val) => (val ? parseInt(val, 10) : undefined))
|
||||||
@@ -167,7 +167,7 @@ router.use(passport.authenticate('jwt', { session: false }));
|
|||||||
* type: string
|
* type: string
|
||||||
* enum: [pending, processing, completed, failed]
|
* enum: [pending, processing, completed, failed]
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: store_id
|
* name: store_location_id
|
||||||
* schema:
|
* schema:
|
||||||
* type: integer
|
* type: integer
|
||||||
* - in: query
|
* - in: query
|
||||||
@@ -199,7 +199,7 @@ router.get(
|
|||||||
{
|
{
|
||||||
user_id: userProfile.user.user_id,
|
user_id: userProfile.user.user_id,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
store_id: query.store_id,
|
store_location_id: query.store_location_id,
|
||||||
from_date: query.from_date,
|
from_date: query.from_date,
|
||||||
to_date: query.to_date,
|
to_date: query.to_date,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
@@ -237,9 +237,9 @@ router.get(
|
|||||||
* type: string
|
* type: string
|
||||||
* format: binary
|
* format: binary
|
||||||
* description: Receipt image file
|
* description: Receipt image file
|
||||||
* store_id:
|
* store_location_id:
|
||||||
* type: integer
|
* type: integer
|
||||||
* description: Store ID if known
|
* description: Store location ID if known
|
||||||
* transaction_date:
|
* transaction_date:
|
||||||
* type: string
|
* type: string
|
||||||
* format: date
|
* format: date
|
||||||
@@ -275,7 +275,7 @@ router.post(
|
|||||||
file.path, // Use the actual file path from multer
|
file.path, // Use the actual file path from multer
|
||||||
req.log,
|
req.log,
|
||||||
{
|
{
|
||||||
storeId: body.store_id,
|
storeLocationId: body.store_location_id,
|
||||||
transactionDate: body.transaction_date,
|
transactionDate: body.transaction_date,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,30 +5,70 @@ import { NotFoundError } from '../services/db/errors.db';
|
|||||||
import { createTestApp } from '../tests/utils/createTestApp';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
import type { Store, StoreWithLocations } from '../types';
|
import type { Store, StoreWithLocations } from '../types';
|
||||||
|
|
||||||
// Mock the Store repositories
|
// Create mock implementations
|
||||||
|
const mockStoreRepoMethods = {
|
||||||
|
getAllStores: vi.fn(),
|
||||||
|
getStoreById: vi.fn(),
|
||||||
|
createStore: vi.fn(),
|
||||||
|
updateStore: vi.fn(),
|
||||||
|
deleteStore: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockStoreLocationRepoMethods = {
|
||||||
|
getAllStoresWithLocations: vi.fn(),
|
||||||
|
getStoreWithLocations: vi.fn(),
|
||||||
|
createStoreLocation: vi.fn(),
|
||||||
|
deleteStoreLocation: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAddressRepoMethods = {
|
||||||
|
upsertAddress: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the Store repositories - Use methods instead of field initializers to avoid hoisting issues
|
||||||
vi.mock('../services/db/store.db', () => ({
|
vi.mock('../services/db/store.db', () => ({
|
||||||
StoreRepository: vi.fn().mockImplementation(() => ({
|
StoreRepository: class MockStoreRepository {
|
||||||
getAllStores: vi.fn(),
|
getAllStores(...args: any[]) {
|
||||||
getStoreById: vi.fn(),
|
return mockStoreRepoMethods.getAllStores(...args);
|
||||||
createStore: vi.fn(),
|
}
|
||||||
updateStore: vi.fn(),
|
getStoreById(...args: any[]) {
|
||||||
deleteStore: vi.fn(),
|
return mockStoreRepoMethods.getStoreById(...args);
|
||||||
})),
|
}
|
||||||
|
createStore(...args: any[]) {
|
||||||
|
return mockStoreRepoMethods.createStore(...args);
|
||||||
|
}
|
||||||
|
updateStore(...args: any[]) {
|
||||||
|
return mockStoreRepoMethods.updateStore(...args);
|
||||||
|
}
|
||||||
|
deleteStore(...args: any[]) {
|
||||||
|
return mockStoreRepoMethods.deleteStore(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../services/db/storeLocation.db', () => ({
|
vi.mock('../services/db/storeLocation.db', () => ({
|
||||||
StoreLocationRepository: vi.fn().mockImplementation(() => ({
|
StoreLocationRepository: class MockStoreLocationRepository {
|
||||||
getAllStoresWithLocations: vi.fn(),
|
getAllStoresWithLocations(...args: any[]) {
|
||||||
getStoreWithLocations: vi.fn(),
|
return mockStoreLocationRepoMethods.getAllStoresWithLocations(...args);
|
||||||
createStoreLocation: vi.fn(),
|
}
|
||||||
deleteStoreLocation: vi.fn(),
|
getStoreWithLocations(...args: any[]) {
|
||||||
})),
|
return mockStoreLocationRepoMethods.getStoreWithLocations(...args);
|
||||||
|
}
|
||||||
|
createStoreLocation(...args: any[]) {
|
||||||
|
return mockStoreLocationRepoMethods.createStoreLocation(...args);
|
||||||
|
}
|
||||||
|
deleteStoreLocation(...args: any[]) {
|
||||||
|
return mockStoreLocationRepoMethods.deleteStoreLocation(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../services/db/address.db', () => ({
|
vi.mock('../services/db/address.db', () => ({
|
||||||
AddressRepository: vi.fn().mockImplementation(() => ({
|
AddressRepository: class MockAddressRepository {
|
||||||
upsertAddress: vi.fn(),
|
upsertAddress(...args: any[]) {
|
||||||
})),
|
return mockAddressRepoMethods.upsertAddress(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock connection pool
|
// Mock connection pool
|
||||||
@@ -43,9 +83,6 @@ vi.mock('../services/db/connection.db', () => ({
|
|||||||
|
|
||||||
// Import after mocks
|
// Import after mocks
|
||||||
import storeRouter from './store.routes';
|
import storeRouter from './store.routes';
|
||||||
import { StoreRepository } from '../services/db/store.db';
|
|
||||||
import { StoreLocationRepository } from '../services/db/storeLocation.db';
|
|
||||||
import { AddressRepository } from '../services/db/address.db';
|
|
||||||
import { getPool } from '../services/db/connection.db';
|
import { getPool } from '../services/db/connection.db';
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
@@ -53,11 +90,17 @@ vi.mock('../services/logger.server', async () => ({
|
|||||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock authentication
|
// Mock authentication - UserProfile has nested user object
|
||||||
vi.mock('../config/passport', () => ({
|
vi.mock('../config/passport', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: any, res: any, next: any) => {
|
authenticate: vi.fn(() => (req: any, res: any, next: any) => {
|
||||||
req.user = { user_id: 'test-user-id', role: 'admin' };
|
req.user = {
|
||||||
|
user: {
|
||||||
|
user_id: 'test-user-id',
|
||||||
|
email: 'test@example.com',
|
||||||
|
role: 'admin',
|
||||||
|
},
|
||||||
|
};
|
||||||
next();
|
next();
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -70,15 +113,8 @@ const expectLogger = expect.objectContaining({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Store Routes (/api/stores)', () => {
|
describe('Store Routes (/api/stores)', () => {
|
||||||
let mockStoreRepo: any;
|
|
||||||
let mockStoreLocationRepo: any;
|
|
||||||
let mockAddressRepo: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockStoreRepo = new (StoreRepository as any)();
|
|
||||||
mockStoreLocationRepo = new (StoreLocationRepository as any)();
|
|
||||||
mockAddressRepo = new (AddressRepository as any)();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = createTestApp({ router: storeRouter, basePath: '/api/stores' });
|
const app = createTestApp({ router: storeRouter, basePath: '/api/stores' });
|
||||||
@@ -104,14 +140,14 @@ describe('Store Routes (/api/stores)', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
mockStoreRepo.getAllStores.mockResolvedValue(mockStores);
|
mockStoreRepoMethods.getAllStores.mockResolvedValue(mockStores);
|
||||||
|
|
||||||
const response = await supertest(app).get('/api/stores');
|
const response = await supertest(app).get('/api/stores');
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.data).toEqual(mockStores);
|
expect(response.body.data).toEqual(mockStores);
|
||||||
expect(mockStoreRepo.getAllStores).toHaveBeenCalledWith(expectLogger);
|
expect(mockStoreRepoMethods.getAllStores).toHaveBeenCalledWith(expectLogger);
|
||||||
expect(mockStoreLocationRepo.getAllStoresWithLocations).not.toHaveBeenCalled();
|
expect(mockStoreLocationRepoMethods.getAllStoresWithLocations).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return stores with locations when includeLocations=true', async () => {
|
it('should return stores with locations when includeLocations=true', async () => {
|
||||||
@@ -127,19 +163,23 @@ describe('Store Routes (/api/stores)', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
mockStoreLocationRepo.getAllStoresWithLocations.mockResolvedValue(mockStoresWithLocations);
|
mockStoreLocationRepoMethods.getAllStoresWithLocations.mockResolvedValue(
|
||||||
|
mockStoresWithLocations,
|
||||||
|
);
|
||||||
|
|
||||||
const response = await supertest(app).get('/api/stores?includeLocations=true');
|
const response = await supertest(app).get('/api/stores?includeLocations=true');
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.data).toEqual(mockStoresWithLocations);
|
expect(response.body.data).toEqual(mockStoresWithLocations);
|
||||||
expect(mockStoreLocationRepo.getAllStoresWithLocations).toHaveBeenCalledWith(expectLogger);
|
expect(mockStoreLocationRepoMethods.getAllStoresWithLocations).toHaveBeenCalledWith(
|
||||||
expect(mockStoreRepo.getAllStores).not.toHaveBeenCalled();
|
expectLogger,
|
||||||
|
);
|
||||||
|
expect(mockStoreRepoMethods.getAllStores).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 if database call fails', async () => {
|
it('should return 500 if database call fails', async () => {
|
||||||
const dbError = new Error('DB Error');
|
const dbError = new Error('DB Error');
|
||||||
mockStoreRepo.getAllStores.mockRejectedValue(dbError);
|
mockStoreRepoMethods.getAllStores.mockRejectedValue(dbError);
|
||||||
|
|
||||||
const response = await supertest(app).get('/api/stores');
|
const response = await supertest(app).get('/api/stores');
|
||||||
|
|
||||||
@@ -181,17 +221,20 @@ describe('Store Routes (/api/stores)', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
mockStoreLocationRepo.getStoreWithLocations.mockResolvedValue(mockStore);
|
mockStoreLocationRepoMethods.getStoreWithLocations.mockResolvedValue(mockStore);
|
||||||
|
|
||||||
const response = await supertest(app).get('/api/stores/1');
|
const response = await supertest(app).get('/api/stores/1');
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.data).toEqual(mockStore);
|
expect(response.body.data).toEqual(mockStore);
|
||||||
expect(mockStoreLocationRepo.getStoreWithLocations).toHaveBeenCalledWith(1, expectLogger);
|
expect(mockStoreLocationRepoMethods.getStoreWithLocations).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
expectLogger,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 if store not found', async () => {
|
it('should return 404 if store not found', async () => {
|
||||||
mockStoreLocationRepo.getStoreWithLocations.mockRejectedValue(
|
mockStoreLocationRepoMethods.getStoreWithLocations.mockRejectedValue(
|
||||||
new NotFoundError('Store with ID 999 not found.'),
|
new NotFoundError('Store with ID 999 not found.'),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -217,7 +260,7 @@ describe('Store Routes (/api/stores)', () => {
|
|||||||
connect: vi.fn().mockResolvedValue(mockClient),
|
connect: vi.fn().mockResolvedValue(mockClient),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
mockStoreRepo.createStore.mockResolvedValue(1);
|
mockStoreRepoMethods.createStore.mockResolvedValue(1);
|
||||||
|
|
||||||
const response = await supertest(app).post('/api/stores').send({
|
const response = await supertest(app).post('/api/stores').send({
|
||||||
name: 'New Store',
|
name: 'New Store',
|
||||||
@@ -240,9 +283,9 @@ describe('Store Routes (/api/stores)', () => {
|
|||||||
connect: vi.fn().mockResolvedValue(mockClient),
|
connect: vi.fn().mockResolvedValue(mockClient),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
mockStoreRepo.createStore.mockResolvedValue(1);
|
mockStoreRepoMethods.createStore.mockResolvedValue(1);
|
||||||
mockAddressRepo.upsertAddress.mockResolvedValue(1);
|
mockAddressRepoMethods.upsertAddress.mockResolvedValue(1);
|
||||||
mockStoreLocationRepo.createStoreLocation.mockResolvedValue(1);
|
mockStoreLocationRepoMethods.createStoreLocation.mockResolvedValue(1);
|
||||||
|
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.post('/api/stores')
|
.post('/api/stores')
|
||||||
@@ -271,7 +314,7 @@ describe('Store Routes (/api/stores)', () => {
|
|||||||
connect: vi.fn().mockResolvedValue(mockClient),
|
connect: vi.fn().mockResolvedValue(mockClient),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
mockStoreRepo.createStore.mockRejectedValue(new Error('DB Error'));
|
mockStoreRepoMethods.createStore.mockRejectedValue(new Error('DB Error'));
|
||||||
|
|
||||||
const response = await supertest(app).post('/api/stores').send({
|
const response = await supertest(app).post('/api/stores').send({
|
||||||
name: 'New Store',
|
name: 'New Store',
|
||||||
@@ -291,14 +334,14 @@ describe('Store Routes (/api/stores)', () => {
|
|||||||
|
|
||||||
describe('PUT /:id', () => {
|
describe('PUT /:id', () => {
|
||||||
it('should update a store', async () => {
|
it('should update a store', async () => {
|
||||||
mockStoreRepo.updateStore.mockResolvedValue(undefined);
|
mockStoreRepoMethods.updateStore.mockResolvedValue(undefined);
|
||||||
|
|
||||||
const response = await supertest(app).put('/api/stores/1').send({
|
const response = await supertest(app).put('/api/stores/1').send({
|
||||||
name: 'Updated Store Name',
|
name: 'Updated Store Name',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
expect(mockStoreRepo.updateStore).toHaveBeenCalledWith(
|
expect(mockStoreRepoMethods.updateStore).toHaveBeenCalledWith(
|
||||||
1,
|
1,
|
||||||
{ name: 'Updated Store Name' },
|
{ name: 'Updated Store Name' },
|
||||||
expectLogger,
|
expectLogger,
|
||||||
@@ -306,7 +349,7 @@ describe('Store Routes (/api/stores)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 if store not found', async () => {
|
it('should return 404 if store not found', async () => {
|
||||||
mockStoreRepo.updateStore.mockRejectedValue(
|
mockStoreRepoMethods.updateStore.mockRejectedValue(
|
||||||
new NotFoundError('Store with ID 999 not found.'),
|
new NotFoundError('Store with ID 999 not found.'),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -318,7 +361,10 @@ describe('Store Routes (/api/stores)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 400 for invalid request body', async () => {
|
it('should return 400 for invalid request body', async () => {
|
||||||
const response = await supertest(app).put('/api/stores/1').send({});
|
// Send invalid data: logo_url must be a valid URL
|
||||||
|
const response = await supertest(app).put('/api/stores/1').send({
|
||||||
|
logo_url: 'not-a-valid-url',
|
||||||
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
});
|
});
|
||||||
@@ -326,16 +372,16 @@ describe('Store Routes (/api/stores)', () => {
|
|||||||
|
|
||||||
describe('DELETE /:id', () => {
|
describe('DELETE /:id', () => {
|
||||||
it('should delete a store', async () => {
|
it('should delete a store', async () => {
|
||||||
mockStoreRepo.deleteStore.mockResolvedValue(undefined);
|
mockStoreRepoMethods.deleteStore.mockResolvedValue(undefined);
|
||||||
|
|
||||||
const response = await supertest(app).delete('/api/stores/1');
|
const response = await supertest(app).delete('/api/stores/1');
|
||||||
|
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
expect(mockStoreRepo.deleteStore).toHaveBeenCalledWith(1, expectLogger);
|
expect(mockStoreRepoMethods.deleteStore).toHaveBeenCalledWith(1, expectLogger);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 if store not found', async () => {
|
it('should return 404 if store not found', async () => {
|
||||||
mockStoreRepo.deleteStore.mockRejectedValue(
|
mockStoreRepoMethods.deleteStore.mockRejectedValue(
|
||||||
new NotFoundError('Store with ID 999 not found.'),
|
new NotFoundError('Store with ID 999 not found.'),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -355,8 +401,8 @@ describe('Store Routes (/api/stores)', () => {
|
|||||||
connect: vi.fn().mockResolvedValue(mockClient),
|
connect: vi.fn().mockResolvedValue(mockClient),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
mockAddressRepo.upsertAddress.mockResolvedValue(1);
|
mockAddressRepoMethods.upsertAddress.mockResolvedValue(1);
|
||||||
mockStoreLocationRepo.createStoreLocation.mockResolvedValue(1);
|
mockStoreLocationRepoMethods.createStoreLocation.mockResolvedValue(1);
|
||||||
|
|
||||||
const response = await supertest(app).post('/api/stores/1/locations').send({
|
const response = await supertest(app).post('/api/stores/1/locations').send({
|
||||||
address_line_1: '456 New St',
|
address_line_1: '456 New St',
|
||||||
@@ -379,16 +425,19 @@ describe('Store Routes (/api/stores)', () => {
|
|||||||
|
|
||||||
describe('DELETE /:id/locations/:locationId', () => {
|
describe('DELETE /:id/locations/:locationId', () => {
|
||||||
it('should delete a store location', async () => {
|
it('should delete a store location', async () => {
|
||||||
mockStoreLocationRepo.deleteStoreLocation.mockResolvedValue(undefined);
|
mockStoreLocationRepoMethods.deleteStoreLocation.mockResolvedValue(undefined);
|
||||||
|
|
||||||
const response = await supertest(app).delete('/api/stores/1/locations/1');
|
const response = await supertest(app).delete('/api/stores/1/locations/1');
|
||||||
|
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
expect(mockStoreLocationRepo.deleteStoreLocation).toHaveBeenCalledWith(1, expectLogger);
|
expect(mockStoreLocationRepoMethods.deleteStoreLocation).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
expectLogger,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 if location not found', async () => {
|
it('should return 404 if location not found', async () => {
|
||||||
mockStoreLocationRepo.deleteStoreLocation.mockRejectedValue(
|
mockStoreLocationRepoMethods.deleteStoreLocation.mockRejectedValue(
|
||||||
new NotFoundError('Store location with ID 999 not found.'),
|
new NotFoundError('Store location with ID 999 not found.'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,22 @@ export class BackgroundJobService {
|
|||||||
// Enqueue an email notification job.
|
// Enqueue an email notification job.
|
||||||
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
|
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
|
||||||
|
|
||||||
|
// Send real-time WebSocket notification (ADR-022)
|
||||||
|
const { websocketService } = await import('./websocketService.server');
|
||||||
|
websocketService.broadcastDealNotification(userProfile.user_id, {
|
||||||
|
user_id: userProfile.user_id,
|
||||||
|
deals: deals.map((deal) => ({
|
||||||
|
item_name: deal.item_name,
|
||||||
|
best_price_in_cents: deal.best_price_in_cents,
|
||||||
|
store_name: deal.store.name,
|
||||||
|
store_id: deal.store.store_id,
|
||||||
|
})),
|
||||||
|
message: `You have ${deals.length} new deal(s) on your watched items!`,
|
||||||
|
});
|
||||||
|
this.logger.info(
|
||||||
|
`[BackgroundJob] Sent WebSocket notification to user ${userProfile.user_id}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Return the notification to be collected for bulk insertion.
|
// Return the notification to be collected for bulk insertion.
|
||||||
return notification;
|
return notification;
|
||||||
} catch (userError) {
|
} catch (userError) {
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ vi.mock('../cacheService.server', () => ({
|
|||||||
CACHE_PREFIX: { BRANDS: 'brands', FLYERS: 'flyers', FLYER_ITEMS: 'flyer_items' },
|
CACHE_PREFIX: { BRANDS: 'brands', FLYERS: 'flyers', FLYER_ITEMS: 'flyer_items' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock flyerLocation.db to avoid real database calls during insertFlyer auto-linking
|
||||||
|
vi.mock('./flyerLocation.db', () => ({
|
||||||
|
FlyerLocationRepository: class MockFlyerLocationRepository {
|
||||||
|
constructor(private db: any) {}
|
||||||
|
|
||||||
|
async linkFlyerToAllStoreLocations(flyerId: number, storeId: number, _logger: any) {
|
||||||
|
// Delegate to the mock client's query method
|
||||||
|
const result = await this.db.query(
|
||||||
|
'INSERT INTO public.flyer_locations (flyer_id, store_location_id) SELECT $1, store_location_id FROM public.store_locations WHERE store_id = $2 ON CONFLICT (flyer_id, store_location_id) DO NOTHING RETURNING store_location_id',
|
||||||
|
[flyerId, storeId],
|
||||||
|
);
|
||||||
|
return result.rowCount || 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the withTransaction helper
|
// Mock the withTransaction helper
|
||||||
vi.mock('./connection.db', async (importOriginal) => {
|
vi.mock('./connection.db', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('./connection.db')>();
|
const actual = await importOriginal<typeof import('./connection.db')>();
|
||||||
@@ -161,7 +177,8 @@ describe('Flyer DB Service', () => {
|
|||||||
const result = await flyerRepo.insertFlyer(flyerData, mockLogger);
|
const result = await flyerRepo.insertFlyer(flyerData, mockLogger);
|
||||||
|
|
||||||
expect(result).toEqual(mockFlyer);
|
expect(result).toEqual(mockFlyer);
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
|
// Expect 2 queries: 1 for INSERT INTO flyers, 1 for linking to store_locations
|
||||||
|
expect(mockPoolInstance.query).toHaveBeenCalledTimes(2);
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('INSERT INTO flyers'),
|
expect.stringContaining('INSERT INTO flyers'),
|
||||||
[
|
[
|
||||||
@@ -509,7 +526,7 @@ describe('Flyer DB Service', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mock the sequence of 4 calls on the client
|
// Mock the sequence of 5 calls on the client (added linkFlyerToAllStoreLocations)
|
||||||
const mockClient = { query: vi.fn() };
|
const mockClient = { query: vi.fn() };
|
||||||
mockClient.query
|
mockClient.query
|
||||||
// 1. findOrCreateStore: INSERT ... ON CONFLICT
|
// 1. findOrCreateStore: INSERT ... ON CONFLICT
|
||||||
@@ -518,7 +535,9 @@ describe('Flyer DB Service', () => {
|
|||||||
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
|
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
|
||||||
// 3. insertFlyer
|
// 3. insertFlyer
|
||||||
.mockResolvedValueOnce({ rows: [mockFlyer] })
|
.mockResolvedValueOnce({ rows: [mockFlyer] })
|
||||||
// 4. insertFlyerItems
|
// 4. linkFlyerToAllStoreLocations (auto-link to store locations)
|
||||||
|
.mockResolvedValueOnce({ rows: [{ store_location_id: 1 }], rowCount: 1 })
|
||||||
|
// 5. insertFlyerItems
|
||||||
.mockResolvedValueOnce({ rows: mockItems });
|
.mockResolvedValueOnce({ rows: mockItems });
|
||||||
|
|
||||||
const result = await createFlyerAndItems(
|
const result = await createFlyerAndItems(
|
||||||
@@ -567,7 +586,8 @@ describe('Flyer DB Service', () => {
|
|||||||
mockClient.query
|
mockClient.query
|
||||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // findOrCreateStore (insert)
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // findOrCreateStore (insert)
|
||||||
.mockResolvedValueOnce({ rows: [{ store_id: 2 }] }) // findOrCreateStore (select)
|
.mockResolvedValueOnce({ rows: [{ store_id: 2 }] }) // findOrCreateStore (select)
|
||||||
.mockResolvedValueOnce({ rows: [mockFlyer] }); // insertFlyer
|
.mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer
|
||||||
|
.mockResolvedValueOnce({ rows: [{ store_location_id: 1 }], rowCount: 1 }); // linkFlyerToAllStoreLocations
|
||||||
|
|
||||||
const result = await createFlyerAndItems(
|
const result = await createFlyerAndItems(
|
||||||
flyerData,
|
flyerData,
|
||||||
@@ -580,7 +600,8 @@ describe('Flyer DB Service', () => {
|
|||||||
flyer: mockFlyer,
|
flyer: mockFlyer,
|
||||||
items: [],
|
items: [],
|
||||||
});
|
});
|
||||||
expect(mockClient.query).toHaveBeenCalledTimes(3);
|
// Expect 4 queries: 2 for findOrCreateStore, 1 for insertFlyer, 1 for linkFlyerToAllStoreLocations
|
||||||
|
expect(mockClient.query).toHaveBeenCalledTimes(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should propagate an error if any step fails', async () => {
|
it('should propagate an error if any step fails', async () => {
|
||||||
@@ -641,8 +662,9 @@ describe('Flyer DB Service', () => {
|
|||||||
const result = await flyerRepo.getFlyerById(123);
|
const result = await flyerRepo.getFlyerById(123);
|
||||||
|
|
||||||
expect(result).toEqual(mockFlyer);
|
expect(result).toEqual(mockFlyer);
|
||||||
|
// The query now includes JOINs through flyer_locations for many-to-many relationship
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||||
'SELECT * FROM public.flyers WHERE flyer_id = $1',
|
expect.stringContaining('FROM public.flyers f'),
|
||||||
[123],
|
[123],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -132,7 +132,30 @@ export class FlyerRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await this.db.query<Flyer>(query, values);
|
const result = await this.db.query<Flyer>(query, values);
|
||||||
return result.rows[0];
|
const newFlyer = result.rows[0];
|
||||||
|
|
||||||
|
// Automatically populate flyer_locations if store_id is provided
|
||||||
|
if (flyerData.store_id) {
|
||||||
|
const { FlyerLocationRepository } = await import('./flyerLocation.db');
|
||||||
|
const { Pool } = await import('pg');
|
||||||
|
|
||||||
|
// Only pass the client if this.db is a PoolClient, not a Pool
|
||||||
|
const clientToPass = this.db instanceof Pool ? undefined : (this.db as PoolClient);
|
||||||
|
const flyerLocationRepo = new FlyerLocationRepository(clientToPass);
|
||||||
|
|
||||||
|
await flyerLocationRepo.linkFlyerToAllStoreLocations(
|
||||||
|
newFlyer.flyer_id,
|
||||||
|
flyerData.store_id,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ flyerId: newFlyer.flyer_id, storeId: flyerData.store_id },
|
||||||
|
'Auto-linked flyer to all store locations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFlyer;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[DB DEBUG] insertFlyer caught error:', error);
|
console.error('[DB DEBUG] insertFlyer caught error:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : '';
|
const errorMessage = error instanceof Error ? error.message : '';
|
||||||
@@ -293,6 +316,7 @@ export class FlyerRepository {
|
|||||||
const query = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
f.*,
|
f.*,
|
||||||
|
-- Legacy store relationship (for backward compatibility)
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'store_id', s.store_id,
|
'store_id', s.store_id,
|
||||||
'name', s.name,
|
'name', s.name,
|
||||||
@@ -311,7 +335,35 @@ export class FlyerRepository {
|
|||||||
WHERE sl.store_id = s.store_id),
|
WHERE sl.store_id = s.store_id),
|
||||||
'[]'::json
|
'[]'::json
|
||||||
)
|
)
|
||||||
) as store
|
) as store,
|
||||||
|
-- Correct many-to-many relationship via flyer_locations
|
||||||
|
COALESCE(
|
||||||
|
(SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'store_location_id', fl_sl.store_location_id,
|
||||||
|
'store', json_build_object(
|
||||||
|
'store_id', fl_s.store_id,
|
||||||
|
'name', fl_s.name,
|
||||||
|
'logo_url', fl_s.logo_url
|
||||||
|
),
|
||||||
|
'address', json_build_object(
|
||||||
|
'address_id', fl_a.address_id,
|
||||||
|
'address_line_1', fl_a.address_line_1,
|
||||||
|
'address_line_2', fl_a.address_line_2,
|
||||||
|
'city', fl_a.city,
|
||||||
|
'province_state', fl_a.province_state,
|
||||||
|
'postal_code', fl_a.postal_code,
|
||||||
|
'country', fl_a.country
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM public.flyer_locations fl
|
||||||
|
JOIN public.store_locations fl_sl ON fl.store_location_id = fl_sl.store_location_id
|
||||||
|
JOIN public.stores fl_s ON fl_sl.store_id = fl_s.store_id
|
||||||
|
JOIN public.addresses fl_a ON fl_sl.address_id = fl_a.address_id
|
||||||
|
WHERE fl.flyer_id = f.flyer_id),
|
||||||
|
'[]'::json
|
||||||
|
) as locations
|
||||||
FROM public.flyers f
|
FROM public.flyers f
|
||||||
LEFT JOIN public.stores s ON f.store_id = s.store_id
|
LEFT JOIN public.stores s ON f.store_id = s.store_id
|
||||||
WHERE f.flyer_id = $1
|
WHERE f.flyer_id = $1
|
||||||
@@ -338,6 +390,7 @@ export class FlyerRepository {
|
|||||||
const query = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
f.*,
|
f.*,
|
||||||
|
-- Legacy store relationship (for backward compatibility)
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'store_id', s.store_id,
|
'store_id', s.store_id,
|
||||||
'name', s.name,
|
'name', s.name,
|
||||||
@@ -356,7 +409,35 @@ export class FlyerRepository {
|
|||||||
WHERE sl.store_id = s.store_id),
|
WHERE sl.store_id = s.store_id),
|
||||||
'[]'::json
|
'[]'::json
|
||||||
)
|
)
|
||||||
) as store
|
) as store,
|
||||||
|
-- Correct many-to-many relationship via flyer_locations
|
||||||
|
COALESCE(
|
||||||
|
(SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'store_location_id', fl_sl.store_location_id,
|
||||||
|
'store', json_build_object(
|
||||||
|
'store_id', fl_s.store_id,
|
||||||
|
'name', fl_s.name,
|
||||||
|
'logo_url', fl_s.logo_url
|
||||||
|
),
|
||||||
|
'address', json_build_object(
|
||||||
|
'address_id', fl_a.address_id,
|
||||||
|
'address_line_1', fl_a.address_line_1,
|
||||||
|
'address_line_2', fl_a.address_line_2,
|
||||||
|
'city', fl_a.city,
|
||||||
|
'province_state', fl_a.province_state,
|
||||||
|
'postal_code', fl_a.postal_code,
|
||||||
|
'country', fl_a.country
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM public.flyer_locations fl
|
||||||
|
JOIN public.store_locations fl_sl ON fl.store_location_id = fl_sl.store_location_id
|
||||||
|
JOIN public.stores fl_s ON fl_sl.store_id = fl_s.store_id
|
||||||
|
JOIN public.addresses fl_a ON fl_sl.address_id = fl_a.address_id
|
||||||
|
WHERE fl.flyer_id = f.flyer_id),
|
||||||
|
'[]'::json
|
||||||
|
) as locations
|
||||||
FROM public.flyers f
|
FROM public.flyers f
|
||||||
JOIN public.stores s ON f.store_id = s.store_id
|
JOIN public.stores s ON f.store_id = s.store_id
|
||||||
ORDER BY f.created_at DESC LIMIT $1 OFFSET $2`;
|
ORDER BY f.created_at DESC LIMIT $1 OFFSET $2`;
|
||||||
|
|||||||
209
src/services/db/flyerLocation.db.ts
Normal file
209
src/services/db/flyerLocation.db.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
// src/services/db/flyerLocation.db.ts
|
||||||
|
/**
|
||||||
|
* Repository for managing flyer_locations (many-to-many relationship between flyers and store_locations).
|
||||||
|
*/
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
import type { PoolClient, Pool } from 'pg';
|
||||||
|
import { handleDbError } from './errors.db';
|
||||||
|
import type { FlyerLocation } from '../../types';
|
||||||
|
import { getPool } from './connection.db';
|
||||||
|
|
||||||
|
export class FlyerLocationRepository {
|
||||||
|
private db: Pool | PoolClient;
|
||||||
|
|
||||||
|
constructor(dbClient?: PoolClient) {
|
||||||
|
this.db = dbClient || getPool();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links a flyer to one or more store locations.
|
||||||
|
* @param flyerId The ID of the flyer
|
||||||
|
* @param storeLocationIds Array of store_location_ids to associate with this flyer
|
||||||
|
* @param logger Logger instance
|
||||||
|
* @returns Promise that resolves when all links are created
|
||||||
|
*/
|
||||||
|
async linkFlyerToLocations(
|
||||||
|
flyerId: number,
|
||||||
|
storeLocationIds: number[],
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (storeLocationIds.length === 0) {
|
||||||
|
logger.warn({ flyerId }, 'No store locations provided for flyer linkage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use VALUES with multiple rows for efficient bulk insert
|
||||||
|
const values = storeLocationIds.map((_, index) => `($1, $${index + 2})`).join(', ');
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO public.flyer_locations (flyer_id, store_location_id)
|
||||||
|
VALUES ${values}
|
||||||
|
ON CONFLICT (flyer_id, store_location_id) DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.db.query(query, [flyerId, ...storeLocationIds]);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ flyerId, locationCount: storeLocationIds.length },
|
||||||
|
'Linked flyer to store locations',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError(
|
||||||
|
error,
|
||||||
|
logger,
|
||||||
|
'Database error in linkFlyerToLocations',
|
||||||
|
{ flyerId, storeLocationIds },
|
||||||
|
{
|
||||||
|
defaultMessage: 'Failed to link flyer to store locations.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links a flyer to all locations of a given store.
|
||||||
|
* This is a convenience method for the common case where a flyer is valid at all store locations.
|
||||||
|
* @param flyerId The ID of the flyer
|
||||||
|
* @param storeId The ID of the store
|
||||||
|
* @param logger Logger instance
|
||||||
|
* @returns Promise that resolves to the number of locations linked
|
||||||
|
*/
|
||||||
|
async linkFlyerToAllStoreLocations(
|
||||||
|
flyerId: number,
|
||||||
|
storeId: number,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO public.flyer_locations (flyer_id, store_location_id)
|
||||||
|
SELECT $1, store_location_id
|
||||||
|
FROM public.store_locations
|
||||||
|
WHERE store_id = $2
|
||||||
|
ON CONFLICT (flyer_id, store_location_id) DO NOTHING
|
||||||
|
RETURNING store_location_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const res = await this.db.query(query, [flyerId, storeId]);
|
||||||
|
const linkedCount = res.rowCount || 0;
|
||||||
|
|
||||||
|
logger.info({ flyerId, storeId, linkedCount }, 'Linked flyer to all store locations');
|
||||||
|
|
||||||
|
return linkedCount;
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError(
|
||||||
|
error,
|
||||||
|
logger,
|
||||||
|
'Database error in linkFlyerToAllStoreLocations',
|
||||||
|
{ flyerId, storeId },
|
||||||
|
{
|
||||||
|
defaultMessage: 'Failed to link flyer to all store locations.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all location links for a flyer.
|
||||||
|
* @param flyerId The ID of the flyer
|
||||||
|
* @param logger Logger instance
|
||||||
|
*/
|
||||||
|
async unlinkAllLocations(flyerId: number, logger: Logger): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db.query('DELETE FROM public.flyer_locations WHERE flyer_id = $1', [flyerId]);
|
||||||
|
|
||||||
|
logger.info({ flyerId }, 'Unlinked all locations from flyer');
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError(
|
||||||
|
error,
|
||||||
|
logger,
|
||||||
|
'Database error in unlinkAllLocations',
|
||||||
|
{ flyerId },
|
||||||
|
{
|
||||||
|
defaultMessage: 'Failed to unlink locations from flyer.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a specific location link from a flyer.
|
||||||
|
* @param flyerId The ID of the flyer
|
||||||
|
* @param storeLocationId The ID of the store location to unlink
|
||||||
|
* @param logger Logger instance
|
||||||
|
*/
|
||||||
|
async unlinkLocation(flyerId: number, storeLocationId: number, logger: Logger): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db.query(
|
||||||
|
'DELETE FROM public.flyer_locations WHERE flyer_id = $1 AND store_location_id = $2',
|
||||||
|
[flyerId, storeLocationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info({ flyerId, storeLocationId }, 'Unlinked location from flyer');
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError(
|
||||||
|
error,
|
||||||
|
logger,
|
||||||
|
'Database error in unlinkLocation',
|
||||||
|
{ flyerId, storeLocationId },
|
||||||
|
{
|
||||||
|
defaultMessage: 'Failed to unlink location from flyer.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all location IDs associated with a flyer.
|
||||||
|
* @param flyerId The ID of the flyer
|
||||||
|
* @param logger Logger instance
|
||||||
|
* @returns Promise that resolves to an array of store_location_ids
|
||||||
|
*/
|
||||||
|
async getLocationIdsByFlyerId(flyerId: number, logger: Logger): Promise<number[]> {
|
||||||
|
try {
|
||||||
|
const res = await this.db.query<{ store_location_id: number }>(
|
||||||
|
'SELECT store_location_id FROM public.flyer_locations WHERE flyer_id = $1',
|
||||||
|
[flyerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.rows.map((row) => row.store_location_id);
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError(
|
||||||
|
error,
|
||||||
|
logger,
|
||||||
|
'Database error in getLocationIdsByFlyerId',
|
||||||
|
{ flyerId },
|
||||||
|
{
|
||||||
|
defaultMessage: 'Failed to get location IDs for flyer.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all flyer_location records for a flyer.
|
||||||
|
* @param flyerId The ID of the flyer
|
||||||
|
* @param logger Logger instance
|
||||||
|
* @returns Promise that resolves to an array of FlyerLocation objects
|
||||||
|
*/
|
||||||
|
async getFlyerLocationsByFlyerId(flyerId: number, logger: Logger): Promise<FlyerLocation[]> {
|
||||||
|
try {
|
||||||
|
const res = await this.db.query<FlyerLocation>(
|
||||||
|
'SELECT * FROM public.flyer_locations WHERE flyer_id = $1',
|
||||||
|
[flyerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.rows;
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError(
|
||||||
|
error,
|
||||||
|
logger,
|
||||||
|
'Database error in getFlyerLocationsByFlyerId',
|
||||||
|
{ flyerId },
|
||||||
|
{
|
||||||
|
defaultMessage: 'Failed to get flyer locations.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ describe('ReceiptRepository', () => {
|
|||||||
{
|
{
|
||||||
user_id: 'user-1',
|
user_id: 'user-1',
|
||||||
receipt_image_url: '/uploads/receipts/receipt-1.jpg',
|
receipt_image_url: '/uploads/receipts/receipt-1.jpg',
|
||||||
store_id: 5,
|
store_location_id: 5,
|
||||||
transaction_date: '2024-01-15',
|
transaction_date: '2024-01-15',
|
||||||
},
|
},
|
||||||
mockLogger,
|
mockLogger,
|
||||||
@@ -237,10 +237,10 @@ describe('ReceiptRepository', () => {
|
|||||||
mockQuery.mockResolvedValueOnce({ rows: [{ count: '3' }] });
|
mockQuery.mockResolvedValueOnce({ rows: [{ count: '3' }] });
|
||||||
mockQuery.mockResolvedValueOnce({ rows: [] });
|
mockQuery.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
await repo.getReceipts({ user_id: 'user-1', store_id: 5 }, mockLogger);
|
await repo.getReceipts({ user_id: 'user-1', store_location_id: 5 }, mockLogger);
|
||||||
|
|
||||||
expect(mockQuery).toHaveBeenCalledWith(
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('store_id = $2'),
|
expect.stringContaining('store_location_id = $2'),
|
||||||
expect.any(Array),
|
expect.any(Array),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ interface StoreReceiptPatternRow {
|
|||||||
export interface CreateReceiptRequest {
|
export interface CreateReceiptRequest {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
receipt_image_url: string;
|
receipt_image_url: string;
|
||||||
store_id?: number;
|
store_location_id?: number;
|
||||||
transaction_date?: string;
|
transaction_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ export interface UpdateReceiptItemRequest {
|
|||||||
export interface ReceiptQueryOptions {
|
export interface ReceiptQueryOptions {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
status?: ReceiptStatus;
|
status?: ReceiptStatus;
|
||||||
store_id?: number;
|
store_location_id?: number;
|
||||||
from_date?: string;
|
from_date?: string;
|
||||||
to_date?: string;
|
to_date?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -166,13 +166,13 @@ export class ReceiptRepository {
|
|||||||
|
|
||||||
const res = await this.db.query<ReceiptRow>(
|
const res = await this.db.query<ReceiptRow>(
|
||||||
`INSERT INTO public.receipts
|
`INSERT INTO public.receipts
|
||||||
(user_id, receipt_image_url, store_id, transaction_date, status)
|
(user_id, receipt_image_url, store_location_id, transaction_date, status)
|
||||||
VALUES ($1, $2, $3, $4, 'pending')
|
VALUES ($1, $2, $3, $4, 'pending')
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
request.user_id,
|
request.user_id,
|
||||||
request.receipt_image_url,
|
request.receipt_image_url,
|
||||||
request.store_id || null,
|
request.store_location_id || null,
|
||||||
request.transaction_date || null,
|
request.transaction_date || null,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -228,7 +228,15 @@ export class ReceiptRepository {
|
|||||||
options: ReceiptQueryOptions,
|
options: ReceiptQueryOptions,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
): Promise<{ receipts: ReceiptScan[]; total: number }> {
|
): Promise<{ receipts: ReceiptScan[]; total: number }> {
|
||||||
const { user_id, status, store_id, from_date, to_date, limit = 50, offset = 0 } = options;
|
const {
|
||||||
|
user_id,
|
||||||
|
status,
|
||||||
|
store_location_id,
|
||||||
|
from_date,
|
||||||
|
to_date,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
} = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build dynamic WHERE clause
|
// Build dynamic WHERE clause
|
||||||
@@ -241,9 +249,9 @@ export class ReceiptRepository {
|
|||||||
params.push(status);
|
params.push(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (store_id) {
|
if (store_location_id) {
|
||||||
conditions.push(`store_id = $${paramIndex++}`);
|
conditions.push(`store_location_id = $${paramIndex++}`);
|
||||||
params.push(store_id);
|
params.push(store_location_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (from_date) {
|
if (from_date) {
|
||||||
|
|||||||
@@ -181,16 +181,14 @@ describe('Email Service (Server)', () => {
|
|||||||
// FIX: Use `stringContaining` to check for key parts of the HTML without being brittle about whitespace.
|
// FIX: Use `stringContaining` to check for key parts of the HTML without being brittle about whitespace.
|
||||||
// The actual HTML is a multi-line template string with tags like <h1>, <ul>, and <li>.
|
// The actual HTML is a multi-line template string with tags like <h1>, <ul>, and <li>.
|
||||||
expect(mailOptions.html).toEqual(expect.stringContaining('<h1>Hi Deal Hunter,</h1>'));
|
expect(mailOptions.html).toEqual(expect.stringContaining('<h1>Hi Deal Hunter,</h1>'));
|
||||||
expect(mailOptions.html).toEqual(
|
// Check for key content without being brittle about exact whitespace/newlines
|
||||||
expect.stringContaining(
|
expect(mailOptions.html).toContain('<strong>Apples</strong>');
|
||||||
'<li>\n <strong>Apples</strong> is on sale for \n <strong>$1.99</strong> \n at Green Grocer!\n </li>',
|
expect(mailOptions.html).toContain('is on sale for');
|
||||||
),
|
expect(mailOptions.html).toContain('<strong>$1.99</strong>');
|
||||||
);
|
expect(mailOptions.html).toContain('Green Grocer');
|
||||||
expect(mailOptions.html).toEqual(
|
expect(mailOptions.html).toContain('<strong>Milk</strong>');
|
||||||
expect.stringContaining(
|
expect(mailOptions.html).toContain('<strong>$3.50</strong>');
|
||||||
'<li>\n <strong>Milk</strong> is on sale for \n <strong>$3.50</strong> \n at Dairy Farm!\n </li>',
|
expect(mailOptions.html).toContain('Dairy Farm');
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(mailOptions.html).toEqual(
|
expect(mailOptions.html).toEqual(
|
||||||
expect.stringContaining('<p>Check them out on the deals page!</p>'),
|
expect.stringContaining('<p>Check them out on the deals page!</p>'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ describe('receiptService.server', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await createReceipt('user-1', '/uploads/receipt2.jpg', mockLogger, {
|
const result = await createReceipt('user-1', '/uploads/receipt2.jpg', mockLogger, {
|
||||||
storeId: 5,
|
storeLocationId: 5,
|
||||||
transactionDate: '2024-01-15',
|
transactionDate: '2024-01-15',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const createReceipt = async (
|
|||||||
userId: string,
|
userId: string,
|
||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
options: { storeId?: number; transactionDate?: string } = {},
|
options: { storeLocationId?: number; transactionDate?: string } = {},
|
||||||
): Promise<ReceiptScan> => {
|
): Promise<ReceiptScan> => {
|
||||||
logger.info({ userId, imageUrl }, 'Creating new receipt for processing');
|
logger.info({ userId, imageUrl }, 'Creating new receipt for processing');
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export const createReceipt = async (
|
|||||||
{
|
{
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
receipt_image_url: imageUrl,
|
receipt_image_url: imageUrl,
|
||||||
store_id: options.storeId,
|
store_location_id: options.storeLocationId,
|
||||||
transaction_date: options.transactionDate,
|
transaction_date: options.transactionDate,
|
||||||
},
|
},
|
||||||
logger,
|
logger,
|
||||||
|
|||||||
123
src/services/websocketService.server.test.ts
Normal file
123
src/services/websocketService.server.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// src/services/websocketService.server.test.ts
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { WebSocketService } from './websocketService.server';
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
import type { Server as HTTPServer } from 'http';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('jsonwebtoken', () => ({
|
||||||
|
default: {
|
||||||
|
verify: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('WebSocketService', () => {
|
||||||
|
let service: WebSocketService;
|
||||||
|
let mockLogger: Logger;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockLogger = {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
child: vi.fn(() => mockLogger),
|
||||||
|
} as unknown as Logger;
|
||||||
|
|
||||||
|
service = new WebSocketService(mockLogger);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
service.shutdown();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('should initialize without errors', () => {
|
||||||
|
const mockServer = {} as HTTPServer;
|
||||||
|
expect(() => service.initialize(mockServer)).not.toThrow();
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith('WebSocket server initialized on path /ws');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('connection stats', () => {
|
||||||
|
it('should return zero stats initially', () => {
|
||||||
|
const stats = service.getConnectionStats();
|
||||||
|
expect(stats).toEqual({
|
||||||
|
totalUsers: 0,
|
||||||
|
totalConnections: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('broadcasting', () => {
|
||||||
|
it('should handle deal notification broadcast without active connections', () => {
|
||||||
|
// Should not throw when no clients are connected
|
||||||
|
expect(() =>
|
||||||
|
service.broadcastDealNotification('user-123', {
|
||||||
|
user_id: 'user-123',
|
||||||
|
deals: [
|
||||||
|
{
|
||||||
|
item_name: 'Milk',
|
||||||
|
best_price_in_cents: 299,
|
||||||
|
store_name: 'Test Store',
|
||||||
|
store_id: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: 'You have 1 new deal!',
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||||
|
{ userId: 'user-123' },
|
||||||
|
'No active WebSocket connections for user',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle system message broadcast without active connections', () => {
|
||||||
|
expect(() =>
|
||||||
|
service.broadcastSystemMessage('user-123', {
|
||||||
|
message: 'Test system message',
|
||||||
|
severity: 'info',
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||||
|
{ userId: 'user-123' },
|
||||||
|
'No active WebSocket connections for user',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle broadcast to all without active connections', () => {
|
||||||
|
expect(() =>
|
||||||
|
service.broadcastToAll({
|
||||||
|
message: 'Test broadcast',
|
||||||
|
severity: 'info',
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
sentCount: 0,
|
||||||
|
totalUsers: 0,
|
||||||
|
}),
|
||||||
|
'Broadcast message to all users',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shutdown', () => {
|
||||||
|
it('should shutdown gracefully', () => {
|
||||||
|
const mockServer = {} as HTTPServer;
|
||||||
|
service.initialize(mockServer);
|
||||||
|
|
||||||
|
expect(() => service.shutdown()).not.toThrow();
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith('Shutting down WebSocket server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle shutdown when not initialized', () => {
|
||||||
|
expect(() => service.shutdown()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
358
src/services/websocketService.server.ts
Normal file
358
src/services/websocketService.server.ts
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
// src/services/websocketService.server.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket service for real-time notifications
|
||||||
|
* Manages WebSocket connections and broadcasts messages to connected clients
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import type { Server as HTTPServer } from 'http';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
import { logger as globalLogger } from './logger.server';
|
||||||
|
import {
|
||||||
|
createWebSocketMessage,
|
||||||
|
type WebSocketMessage,
|
||||||
|
type DealNotificationData,
|
||||||
|
type SystemMessageData,
|
||||||
|
} from '../types/websocket';
|
||||||
|
import type { IncomingMessage } from 'http';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET!;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended WebSocket with user context
|
||||||
|
*/
|
||||||
|
interface AuthenticatedWebSocket extends WebSocket {
|
||||||
|
userId?: string;
|
||||||
|
isAlive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT payload structure
|
||||||
|
*/
|
||||||
|
interface JWTPayload {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebSocketService {
|
||||||
|
private wss: WebSocketServer | null = null;
|
||||||
|
private clients: Map<string, Set<AuthenticatedWebSocket>> = new Map();
|
||||||
|
private pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(private logger: Logger) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the WebSocket server and attach it to an HTTP server
|
||||||
|
*/
|
||||||
|
initialize(server: HTTPServer): void {
|
||||||
|
this.wss = new WebSocketServer({
|
||||||
|
server,
|
||||||
|
path: '/ws',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.info('WebSocket server initialized on path /ws');
|
||||||
|
|
||||||
|
this.wss.on('connection', (ws: AuthenticatedWebSocket, request: IncomingMessage) => {
|
||||||
|
this.handleConnection(ws, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start heartbeat ping/pong to detect dead connections
|
||||||
|
this.startHeartbeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle new WebSocket connection
|
||||||
|
*/
|
||||||
|
private handleConnection(ws: AuthenticatedWebSocket, request: IncomingMessage): void {
|
||||||
|
const connectionLogger = this.logger.child({ context: 'ws-connection' });
|
||||||
|
|
||||||
|
// Extract JWT token from query string or cookie
|
||||||
|
const token = this.extractToken(request);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
connectionLogger.warn('WebSocket connection rejected: No token provided');
|
||||||
|
ws.close(1008, 'Authentication required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT token
|
||||||
|
let payload: JWTPayload;
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(token, JWT_SECRET) as JWTPayload;
|
||||||
|
} catch (error) {
|
||||||
|
connectionLogger.warn({ error }, 'WebSocket connection rejected: Invalid token');
|
||||||
|
ws.close(1008, 'Invalid token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach user ID to the WebSocket connection
|
||||||
|
ws.userId = payload.user_id;
|
||||||
|
ws.isAlive = true;
|
||||||
|
|
||||||
|
// Register the client
|
||||||
|
this.registerClient(ws);
|
||||||
|
|
||||||
|
connectionLogger.info(
|
||||||
|
{ userId: ws.userId },
|
||||||
|
`WebSocket client connected for user ${ws.userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send connection confirmation
|
||||||
|
const confirmationMessage = createWebSocketMessage.connectionEstablished({
|
||||||
|
user_id: ws.userId,
|
||||||
|
message: 'Connected to real-time notification service',
|
||||||
|
});
|
||||||
|
this.sendToClient(ws, confirmationMessage);
|
||||||
|
|
||||||
|
// Handle incoming messages
|
||||||
|
ws.on('message', (data: Buffer) => {
|
||||||
|
this.handleMessage(ws, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle pong responses (heartbeat)
|
||||||
|
ws.on('pong', () => {
|
||||||
|
ws.isAlive = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle disconnection
|
||||||
|
ws.on('close', () => {
|
||||||
|
this.unregisterClient(ws);
|
||||||
|
connectionLogger.info({ userId: ws.userId }, 'WebSocket client disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
ws.on('error', (error: Error) => {
|
||||||
|
connectionLogger.error({ error, userId: ws.userId }, 'WebSocket error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract JWT token from request (query string or cookie)
|
||||||
|
*/
|
||||||
|
private extractToken(request: IncomingMessage): string | null {
|
||||||
|
// Try to extract from query string (?token=xxx)
|
||||||
|
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||||
|
const tokenFromQuery = url.searchParams.get('token');
|
||||||
|
if (tokenFromQuery) {
|
||||||
|
return tokenFromQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from cookie
|
||||||
|
const cookieHeader = request.headers.cookie;
|
||||||
|
if (cookieHeader) {
|
||||||
|
const cookies = cookieHeader.split(';').reduce(
|
||||||
|
(acc, cookie) => {
|
||||||
|
const [key, value] = cookie.trim().split('=');
|
||||||
|
acc[key] = value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return cookies['accessToken'] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a WebSocket client
|
||||||
|
*/
|
||||||
|
private registerClient(ws: AuthenticatedWebSocket): void {
|
||||||
|
if (!ws.userId) return;
|
||||||
|
|
||||||
|
if (!this.clients.has(ws.userId)) {
|
||||||
|
this.clients.set(ws.userId, new Set());
|
||||||
|
}
|
||||||
|
this.clients.get(ws.userId)!.add(ws);
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
{ userId: ws.userId, totalConnections: this.clients.get(ws.userId)!.size },
|
||||||
|
'Client registered',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a WebSocket client
|
||||||
|
*/
|
||||||
|
private unregisterClient(ws: AuthenticatedWebSocket): void {
|
||||||
|
if (!ws.userId) return;
|
||||||
|
|
||||||
|
const userClients = this.clients.get(ws.userId);
|
||||||
|
if (userClients) {
|
||||||
|
userClients.delete(ws);
|
||||||
|
if (userClients.size === 0) {
|
||||||
|
this.clients.delete(ws.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming messages from clients
|
||||||
|
*/
|
||||||
|
private handleMessage(ws: AuthenticatedWebSocket, data: Buffer): void {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data.toString()) as WebSocketMessage;
|
||||||
|
|
||||||
|
// Handle ping messages
|
||||||
|
if (message.type === 'ping') {
|
||||||
|
const pongMessage = createWebSocketMessage.pong();
|
||||||
|
this.sendToClient(ws, pongMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log other message types for debugging
|
||||||
|
this.logger.debug(
|
||||||
|
{ userId: ws.userId, messageType: message.type },
|
||||||
|
'Received WebSocket message',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ error }, 'Failed to parse WebSocket message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a specific WebSocket client
|
||||||
|
*/
|
||||||
|
private sendToClient(ws: AuthenticatedWebSocket, message: WebSocketMessage): void {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a deal notification to a specific user
|
||||||
|
*/
|
||||||
|
broadcastDealNotification(userId: string, data: DealNotificationData): void {
|
||||||
|
const message = createWebSocketMessage.dealNotification(data);
|
||||||
|
this.broadcastToUser(userId, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a system message to a specific user
|
||||||
|
*/
|
||||||
|
broadcastSystemMessage(userId: string, data: SystemMessageData): void {
|
||||||
|
const message = createWebSocketMessage.systemMessage(data);
|
||||||
|
this.broadcastToUser(userId, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a message to all connections of a specific user
|
||||||
|
*/
|
||||||
|
private broadcastToUser(userId: string, message: WebSocketMessage): void {
|
||||||
|
const userClients = this.clients.get(userId);
|
||||||
|
if (!userClients || userClients.size === 0) {
|
||||||
|
this.logger.debug({ userId }, 'No active WebSocket connections for user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sentCount = 0;
|
||||||
|
userClients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
this.sendToClient(client, message);
|
||||||
|
sentCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
{ userId, messageType: message.type, sentCount, totalConnections: userClients.size },
|
||||||
|
'Broadcast message to user',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a system message to all connected clients
|
||||||
|
*/
|
||||||
|
broadcastToAll(data: SystemMessageData): void {
|
||||||
|
const message = createWebSocketMessage.systemMessage(data);
|
||||||
|
let sentCount = 0;
|
||||||
|
|
||||||
|
this.clients.forEach((userClients) => {
|
||||||
|
userClients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
this.sendToClient(client, message);
|
||||||
|
sentCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
{ messageType: message.type, sentCount, totalUsers: this.clients.size },
|
||||||
|
'Broadcast message to all users',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start heartbeat ping/pong to detect dead connections
|
||||||
|
*/
|
||||||
|
private startHeartbeat(): void {
|
||||||
|
this.pingInterval = setInterval(() => {
|
||||||
|
if (!this.wss) return;
|
||||||
|
|
||||||
|
this.wss.clients.forEach((ws) => {
|
||||||
|
const authWs = ws as AuthenticatedWebSocket;
|
||||||
|
|
||||||
|
if (authWs.isAlive === false) {
|
||||||
|
this.logger.debug({ userId: authWs.userId }, 'Terminating dead connection');
|
||||||
|
return authWs.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
authWs.isAlive = false;
|
||||||
|
authWs.ping();
|
||||||
|
});
|
||||||
|
}, 30000); // Ping every 30 seconds
|
||||||
|
|
||||||
|
this.logger.info('WebSocket heartbeat started (30s interval)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of active connections
|
||||||
|
*/
|
||||||
|
getConnectionStats(): { totalUsers: number; totalConnections: number } {
|
||||||
|
let totalConnections = 0;
|
||||||
|
this.clients.forEach((userClients) => {
|
||||||
|
totalConnections += userClients.size;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers: this.clients.size,
|
||||||
|
totalConnections,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown the WebSocket server gracefully
|
||||||
|
*/
|
||||||
|
shutdown(): void {
|
||||||
|
if (this.pingInterval) {
|
||||||
|
clearInterval(this.pingInterval);
|
||||||
|
this.pingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.wss) {
|
||||||
|
this.logger.info('Shutting down WebSocket server');
|
||||||
|
|
||||||
|
// Notify all clients about shutdown
|
||||||
|
this.broadcastToAll({
|
||||||
|
message: 'Server is shutting down. Please reconnect.',
|
||||||
|
severity: 'warning',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close all connections
|
||||||
|
this.wss.clients.forEach((client) => {
|
||||||
|
client.close(1001, 'Server shutting down');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.wss.close(() => {
|
||||||
|
this.logger.info('WebSocket server closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clients.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const websocketService = new WebSocketService(globalLogger);
|
||||||
@@ -126,7 +126,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
|||||||
.post('/api/receipts')
|
.post('/api/receipts')
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
.attach('receipt', testImageBuffer, 'test-receipt.png')
|
.attach('receipt', testImageBuffer, 'test-receipt.png')
|
||||||
.field('store_id', '1')
|
.field('store_location_id', '1')
|
||||||
.field('transaction_date', '2024-01-15');
|
.field('transaction_date', '2024-01-15');
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
@@ -263,13 +263,12 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
|||||||
postalCode: 'M5V 4A4',
|
postalCode: 'M5V 4A4',
|
||||||
});
|
});
|
||||||
createdStoreLocations.push(store);
|
createdStoreLocations.push(store);
|
||||||
const storeId = store.storeId;
|
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO public.receipts (user_id, receipt_image_url, status, store_id, total_amount_cents)
|
`INSERT INTO public.receipts (user_id, receipt_image_url, status, store_location_id, total_amount_cents)
|
||||||
VALUES ($1, $2, 'completed', $3, 9999)
|
VALUES ($1, $2, 'completed', $3, 9999)
|
||||||
RETURNING receipt_id`,
|
RETURNING receipt_id`,
|
||||||
[testUser.user.user_id, '/uploads/receipts/detail-test.jpg', storeId],
|
[testUser.user.user_id, '/uploads/receipts/detail-test.jpg', store.storeLocationId],
|
||||||
);
|
);
|
||||||
testReceiptId = result.rows[0].receipt_id;
|
testReceiptId = result.rows[0].receipt_id;
|
||||||
createdReceiptIds.push(testReceiptId);
|
createdReceiptIds.push(testReceiptId);
|
||||||
@@ -292,7 +291,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
|
|||||||
expect(response.body.success).toBe(true);
|
expect(response.body.success).toBe(true);
|
||||||
expect(response.body.data.receipt).toBeDefined();
|
expect(response.body.data.receipt).toBeDefined();
|
||||||
expect(response.body.data.receipt.receipt_id).toBe(testReceiptId);
|
expect(response.body.data.receipt.receipt_id).toBe(testReceiptId);
|
||||||
expect(response.body.data.receipt.store_id).toBeDefined();
|
expect(response.body.data.receipt.store_location_id).toBeDefined();
|
||||||
expect(response.body.data.items).toBeDefined();
|
expect(response.body.data.items).toBeDefined();
|
||||||
expect(response.body.data.items.length).toBe(2);
|
expect(response.body.data.items.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/services/db/store.db.test.ts
|
// src/tests/integration/store.db.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
import { getPool } from './connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import { StoreRepository } from './store.db';
|
import { StoreRepository } from '../../services/db/store.db';
|
||||||
import { pino } from 'pino';
|
import { pino } from 'pino';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
|
|
||||||
@@ -65,10 +65,10 @@ describe('StoreRepository', () => {
|
|||||||
it('should create a store with created_by user ID', async () => {
|
it('should create a store with created_by user ID', async () => {
|
||||||
// Create a test user first
|
// Create a test user first
|
||||||
const userResult = await pool.query(
|
const userResult = await pool.query(
|
||||||
`INSERT INTO public.users (email, password_hash, full_name)
|
`INSERT INTO public.users (email, password_hash)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2)
|
||||||
RETURNING user_id`,
|
RETURNING user_id`,
|
||||||
['test@example.com', 'hash', 'Test User'],
|
['test@example.com', 'hash'],
|
||||||
);
|
);
|
||||||
const userId = userResult.rows[0].user_id;
|
const userId = userResult.rows[0].user_id;
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// src/services/db/storeLocation.db.test.ts
|
// src/tests/integration/storeLocation.db.test.ts
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
import { getPool } from './connection.db';
|
import { getPool } from '../../services/db/connection.db';
|
||||||
import { StoreLocationRepository } from './storeLocation.db';
|
import { StoreLocationRepository } from '../../services/db/storeLocation.db';
|
||||||
import { StoreRepository } from './store.db';
|
import { StoreRepository } from '../../services/db/store.db';
|
||||||
import { AddressRepository } from './address.db';
|
import { AddressRepository } from '../../services/db/address.db';
|
||||||
import { pino } from 'pino';
|
import { pino } from 'pino';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
|
|
||||||
452
src/tests/integration/websocket.integration.test.ts
Normal file
452
src/tests/integration/websocket.integration.test.ts
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
// src/tests/integration/websocket.integration.test.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for WebSocket real-time notification system
|
||||||
|
* Tests the full flow from server to client including authentication
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import type { Server as HTTPServer } from 'http';
|
||||||
|
import express from 'express';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { WebSocketService } from '../../services/websocketService.server';
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
import type { WebSocketMessage, DealNotificationData } from '../../types/websocket';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'test-secret';
|
||||||
|
let TEST_PORT = 0; // Use dynamic port (0 = let OS assign)
|
||||||
|
|
||||||
|
describe('WebSocket Integration Tests', () => {
|
||||||
|
let app: express.Application;
|
||||||
|
let server: HTTPServer;
|
||||||
|
let wsService: WebSocketService;
|
||||||
|
let mockLogger: Logger;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create mock logger
|
||||||
|
mockLogger = {
|
||||||
|
info: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
error: () => {},
|
||||||
|
debug: () => {},
|
||||||
|
child: () => mockLogger,
|
||||||
|
} as unknown as Logger;
|
||||||
|
|
||||||
|
// Create Express app
|
||||||
|
app = express();
|
||||||
|
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||||
|
|
||||||
|
// Create HTTP server (use port 0 for dynamic allocation)
|
||||||
|
server = createServer(app);
|
||||||
|
|
||||||
|
// Start server and wait for it to be listening
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.listen(0, () => {
|
||||||
|
const addr = server.address();
|
||||||
|
if (addr && typeof addr === 'object') {
|
||||||
|
TEST_PORT = addr.port;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize WebSocket service
|
||||||
|
wsService = new WebSocketService(mockLogger);
|
||||||
|
wsService.initialize(server);
|
||||||
|
|
||||||
|
// Wait for WebSocket server to be ready
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Shutdown WebSocket service first
|
||||||
|
wsService.shutdown();
|
||||||
|
|
||||||
|
// Close HTTP server
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.close(() => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for cleanup
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WebSocket Connection', () => {
|
||||||
|
it('should reject connection without authentication token', async () => {
|
||||||
|
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/ws`);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
ws.on('close', (code, reason) => {
|
||||||
|
expect(code).toBe(1008); // Policy violation
|
||||||
|
expect(reason.toString()).toContain('Authentication required');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject connection with invalid token', async () => {
|
||||||
|
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=invalid-token`);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
ws.on('close', (code, reason) => {
|
||||||
|
expect(code).toBe(1008);
|
||||||
|
expect(reason.toString()).toContain('Invalid token');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept connection with valid JWT token', async () => {
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ user_id: 'test-user-1', email: 'test@example.com', role: 'user' },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token}`);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
ws.on('open', () => {
|
||||||
|
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||||
|
ws.close();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should receive connection-established message on successful connection', async () => {
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ user_id: 'test-user-2', email: 'test2@example.com', role: 'user' },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token}`);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
ws.on('message', (data: Buffer) => {
|
||||||
|
const message = JSON.parse(data.toString()) as WebSocketMessage;
|
||||||
|
expect(message.type).toBe('connection-established');
|
||||||
|
expect(message.data).toHaveProperty('user_id', 'test-user-2');
|
||||||
|
expect(message.data).toHaveProperty('message');
|
||||||
|
expect(message.timestamp).toBeDefined();
|
||||||
|
ws.close();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Deal Notifications', () => {
|
||||||
|
it('should broadcast deal notification to connected user', async () => {
|
||||||
|
const userId = 'test-user-3';
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ user_id: userId, email: 'test3@example.com', role: 'user' },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token}`);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let messageCount = 0;
|
||||||
|
|
||||||
|
ws.on('message', (data: Buffer) => {
|
||||||
|
const message = JSON.parse(data.toString()) as WebSocketMessage;
|
||||||
|
messageCount++;
|
||||||
|
|
||||||
|
// First message should be connection-established
|
||||||
|
if (messageCount === 1) {
|
||||||
|
expect(message.type).toBe('connection-established');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second message should be our deal notification
|
||||||
|
if (messageCount === 2) {
|
||||||
|
expect(message.type).toBe('deal-notification');
|
||||||
|
const dealData = message.data as DealNotificationData;
|
||||||
|
expect(dealData.user_id).toBe(userId);
|
||||||
|
expect(dealData.deals).toHaveLength(2);
|
||||||
|
expect(dealData.deals[0].item_name).toBe('Test Item 1');
|
||||||
|
expect(dealData.deals[0].best_price_in_cents).toBe(299);
|
||||||
|
expect(dealData.message).toContain('2 new deal');
|
||||||
|
ws.close();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
// Wait a bit for connection-established message
|
||||||
|
setTimeout(() => {
|
||||||
|
// Broadcast a deal notification
|
||||||
|
wsService.broadcastDealNotification(userId, {
|
||||||
|
user_id: userId,
|
||||||
|
deals: [
|
||||||
|
{
|
||||||
|
item_name: 'Test Item 1',
|
||||||
|
best_price_in_cents: 299,
|
||||||
|
store_name: 'Test Store',
|
||||||
|
store_id: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
item_name: 'Test Item 2',
|
||||||
|
best_price_in_cents: 499,
|
||||||
|
store_name: 'Test Store 2',
|
||||||
|
store_id: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: 'You have 2 new deal(s) on your watched items!',
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should broadcast to multiple connections of same user', async () => {
|
||||||
|
const userId = 'test-user-4';
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ user_id: userId, email: 'test4@example.com', role: 'user' },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open two WebSocket connections for the same user
|
||||||
|
const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token}`);
|
||||||
|
const ws2 = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token}`);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let ws1Ready = false;
|
||||||
|
let ws2Ready = false;
|
||||||
|
let ws1ReceivedDeal = false;
|
||||||
|
let ws2ReceivedDeal = false;
|
||||||
|
|
||||||
|
const checkComplete = () => {
|
||||||
|
if (ws1ReceivedDeal && ws2ReceivedDeal) {
|
||||||
|
ws1.close();
|
||||||
|
ws2.close();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws1.on('message', (data: Buffer) => {
|
||||||
|
const message = JSON.parse(data.toString()) as WebSocketMessage;
|
||||||
|
if (message.type === 'connection-established') {
|
||||||
|
ws1Ready = true;
|
||||||
|
} else if (message.type === 'deal-notification') {
|
||||||
|
ws1ReceivedDeal = true;
|
||||||
|
checkComplete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws2.on('message', (data: Buffer) => {
|
||||||
|
const message = JSON.parse(data.toString()) as WebSocketMessage;
|
||||||
|
if (message.type === 'connection-established') {
|
||||||
|
ws2Ready = true;
|
||||||
|
} else if (message.type === 'deal-notification') {
|
||||||
|
ws2ReceivedDeal = true;
|
||||||
|
checkComplete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws1.on('open', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ws1Ready && ws2Ready) {
|
||||||
|
wsService.broadcastDealNotification(userId, {
|
||||||
|
user_id: userId,
|
||||||
|
deals: [
|
||||||
|
{
|
||||||
|
item_name: 'Test Item',
|
||||||
|
best_price_in_cents: 199,
|
||||||
|
store_name: 'Store',
|
||||||
|
store_id: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: 'You have 1 new deal!',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws1.on('error', reject);
|
||||||
|
ws2.on('error', reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not send notification to different user', async () => {
|
||||||
|
const user1Id = 'test-user-5';
|
||||||
|
const user2Id = 'test-user-6';
|
||||||
|
|
||||||
|
const token1 = jwt.sign(
|
||||||
|
{ user_id: user1Id, email: 'test5@example.com', role: 'user' },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const token2 = jwt.sign(
|
||||||
|
{ user_id: user2Id, email: 'test6@example.com', role: 'user' },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token1}`);
|
||||||
|
const ws2 = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token2}`);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let ws1Ready = false;
|
||||||
|
let ws2Ready = false;
|
||||||
|
let ws2ReceivedUnexpectedMessage = false;
|
||||||
|
|
||||||
|
ws1.on('message', (data: Buffer) => {
|
||||||
|
const message = JSON.parse(data.toString()) as WebSocketMessage;
|
||||||
|
if (message.type === 'connection-established') {
|
||||||
|
ws1Ready = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws2.on('message', (data: Buffer) => {
|
||||||
|
const message = JSON.parse(data.toString()) as WebSocketMessage;
|
||||||
|
if (message.type === 'connection-established') {
|
||||||
|
ws2Ready = true;
|
||||||
|
} else if (message.type === 'deal-notification') {
|
||||||
|
// User 2 should NOT receive this message
|
||||||
|
ws2ReceivedUnexpectedMessage = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws1.on('open', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ws1Ready && ws2Ready) {
|
||||||
|
// Send notification only to user 1
|
||||||
|
wsService.broadcastDealNotification(user1Id, {
|
||||||
|
user_id: user1Id,
|
||||||
|
deals: [
|
||||||
|
{
|
||||||
|
item_name: 'Test Item',
|
||||||
|
best_price_in_cents: 199,
|
||||||
|
store_name: 'Store',
|
||||||
|
store_id: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: 'You have 1 new deal!',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait a bit to ensure user 2 doesn't receive it
|
||||||
|
setTimeout(() => {
|
||||||
|
expect(ws2ReceivedUnexpectedMessage).toBe(false);
|
||||||
|
ws1.close();
|
||||||
|
ws2.close();
|
||||||
|
resolve();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws1.on('error', reject);
|
||||||
|
ws2.on('error', reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('System Messages', () => {
|
||||||
|
it('should broadcast system message to specific user', async () => {
|
||||||
|
const userId = 'test-user-7';
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ user_id: userId, email: 'test7@example.com', role: 'user' },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token}`);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let messageCount = 0;
|
||||||
|
|
||||||
|
ws.on('message', (data: Buffer) => {
|
||||||
|
const message = JSON.parse(data.toString()) as WebSocketMessage;
|
||||||
|
messageCount++;
|
||||||
|
|
||||||
|
if (messageCount === 2) {
|
||||||
|
expect(message.type).toBe('system-message');
|
||||||
|
expect(message.data).toHaveProperty('message', 'Test system message');
|
||||||
|
expect(message.data).toHaveProperty('severity', 'info');
|
||||||
|
ws.close();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
wsService.broadcastSystemMessage(userId, {
|
||||||
|
message: 'Test system message',
|
||||||
|
severity: 'info',
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Connection Stats', () => {
|
||||||
|
it('should track connection statistics', async () => {
|
||||||
|
const token1 = jwt.sign(
|
||||||
|
{ user_id: 'stats-user-1', email: 'stats1@example.com', role: 'user' },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const token2 = jwt.sign(
|
||||||
|
{ user_id: 'stats-user-2', email: 'stats2@example.com', role: 'user' },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token1}`);
|
||||||
|
const ws2a = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token2}`);
|
||||||
|
const ws2b = new WebSocket(`ws://localhost:${TEST_PORT}/ws?token=${token2}`);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let openCount = 0;
|
||||||
|
|
||||||
|
const checkOpen = () => {
|
||||||
|
openCount++;
|
||||||
|
if (openCount === 3) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const stats = wsService.getConnectionStats();
|
||||||
|
// Should have 2 users (stats-user-1 and stats-user-2)
|
||||||
|
// and 3 total connections
|
||||||
|
expect(stats.totalUsers).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(stats.totalConnections).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
ws1.close();
|
||||||
|
ws2a.close();
|
||||||
|
ws2b.close();
|
||||||
|
resolve();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws1.on('open', checkOpen);
|
||||||
|
ws2a.on('open', checkOpen);
|
||||||
|
ws2b.on('open', checkOpen);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -907,7 +907,7 @@ export const createMockReceipt = (
|
|||||||
const defaultReceipt: Receipt = {
|
const defaultReceipt: Receipt = {
|
||||||
receipt_id: receiptId,
|
receipt_id: receiptId,
|
||||||
user_id: `user-${getNextId()}`,
|
user_id: `user-${getNextId()}`,
|
||||||
store_id: null,
|
store_location_id: null,
|
||||||
receipt_image_url: `/receipts/mock-receipt-${receiptId}.jpg`,
|
receipt_image_url: `/receipts/mock-receipt-${receiptId}.jpg`,
|
||||||
transaction_date: new Date().toISOString(),
|
transaction_date: new Date().toISOString(),
|
||||||
total_amount_cents: null,
|
total_amount_cents: null,
|
||||||
@@ -1167,7 +1167,7 @@ export const createMockUserSubmittedPrice = (
|
|||||||
user_submitted_price_id: getNextId(),
|
user_submitted_price_id: getNextId(),
|
||||||
user_id: `user-${getNextId()}`,
|
user_id: `user-${getNextId()}`,
|
||||||
master_item_id: getNextId(),
|
master_item_id: getNextId(),
|
||||||
store_id: getNextId(),
|
store_location_id: getNextId(),
|
||||||
price_in_cents: 299,
|
price_in_cents: 299,
|
||||||
photo_url: null,
|
photo_url: null,
|
||||||
upvotes: 0,
|
upvotes: 0,
|
||||||
|
|||||||
19
src/types.ts
19
src/types.ts
@@ -16,14 +16,25 @@ export interface Flyer {
|
|||||||
image_url: string;
|
image_url: string;
|
||||||
icon_url: string; // URL for the 64x64 icon version of the flyer
|
icon_url: string; // URL for the 64x64 icon version of the flyer
|
||||||
readonly checksum?: string;
|
readonly checksum?: string;
|
||||||
readonly store_id?: number;
|
readonly store_id?: number; // Legacy field - kept for backward compatibility
|
||||||
valid_from?: string | null;
|
valid_from?: string | null;
|
||||||
valid_to?: string | null;
|
valid_to?: string | null;
|
||||||
store_address?: string | null;
|
store_address?: string | null; // Legacy field - will be deprecated
|
||||||
status: FlyerStatus;
|
status: FlyerStatus;
|
||||||
item_count: number;
|
item_count: number;
|
||||||
readonly uploaded_by?: string | null; // UUID of the user who uploaded it, can be null for anonymous uploads
|
readonly uploaded_by?: string | null; // UUID of the user who uploaded it, can be null for anonymous uploads
|
||||||
|
|
||||||
|
// Store relationship (legacy - single store)
|
||||||
store?: Store;
|
store?: Store;
|
||||||
|
|
||||||
|
// Store locations relationship (many-to-many via flyer_locations table)
|
||||||
|
// This is the correct relationship - a flyer can be valid at multiple store locations
|
||||||
|
locations?: Array<{
|
||||||
|
store_location_id: number;
|
||||||
|
store: Store;
|
||||||
|
address: Address;
|
||||||
|
}>;
|
||||||
|
|
||||||
readonly created_at: string;
|
readonly created_at: string;
|
||||||
readonly updated_at: string;
|
readonly updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -260,7 +271,7 @@ export interface UserSubmittedPrice {
|
|||||||
readonly user_submitted_price_id: number;
|
readonly user_submitted_price_id: number;
|
||||||
readonly user_id: string; // UUID
|
readonly user_id: string; // UUID
|
||||||
readonly master_item_id: number;
|
readonly master_item_id: number;
|
||||||
readonly store_id: number;
|
readonly store_location_id: number; // Specific store location (provides geographic specificity)
|
||||||
price_in_cents: number;
|
price_in_cents: number;
|
||||||
photo_url?: string | null;
|
photo_url?: string | null;
|
||||||
readonly upvotes: number;
|
readonly upvotes: number;
|
||||||
@@ -649,7 +660,7 @@ export interface ShoppingTrip {
|
|||||||
export interface Receipt {
|
export interface Receipt {
|
||||||
readonly receipt_id: number;
|
readonly receipt_id: number;
|
||||||
readonly user_id: string; // UUID
|
readonly user_id: string; // UUID
|
||||||
store_id?: number | null;
|
store_location_id?: number | null; // Specific store location (nullable if not yet matched)
|
||||||
receipt_image_url: string;
|
receipt_image_url: string;
|
||||||
transaction_date?: string | null;
|
transaction_date?: string | null;
|
||||||
total_amount_cents?: number | null;
|
total_amount_cents?: number | null;
|
||||||
|
|||||||
110
src/types/websocket.test.ts
Normal file
110
src/types/websocket.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// src/types/websocket.test.ts
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createWebSocketMessage } from './websocket';
|
||||||
|
|
||||||
|
describe('WebSocket Message Creators', () => {
|
||||||
|
describe('createWebSocketMessage.dealNotification', () => {
|
||||||
|
it('should create a valid deal notification message', () => {
|
||||||
|
const message = createWebSocketMessage.dealNotification({
|
||||||
|
user_id: 'user-123',
|
||||||
|
deals: [
|
||||||
|
{
|
||||||
|
item_name: 'Milk',
|
||||||
|
best_price_in_cents: 299,
|
||||||
|
store_name: 'Test Store',
|
||||||
|
store_id: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: 'You have 1 new deal!',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(message.type).toBe('deal-notification');
|
||||||
|
expect(message.data.user_id).toBe('user-123');
|
||||||
|
expect(message.data.deals).toHaveLength(1);
|
||||||
|
expect(message.data.deals[0].item_name).toBe('Milk');
|
||||||
|
expect(message.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createWebSocketMessage.systemMessage', () => {
|
||||||
|
it('should create a valid system message', () => {
|
||||||
|
const message = createWebSocketMessage.systemMessage({
|
||||||
|
message: 'System maintenance scheduled',
|
||||||
|
severity: 'warning',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(message.type).toBe('system-message');
|
||||||
|
expect(message.data.message).toBe('System maintenance scheduled');
|
||||||
|
expect(message.data.severity).toBe('warning');
|
||||||
|
expect(message.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createWebSocketMessage.error', () => {
|
||||||
|
it('should create a valid error message', () => {
|
||||||
|
const message = createWebSocketMessage.error({
|
||||||
|
message: 'Something went wrong',
|
||||||
|
code: 'ERR_500',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(message.type).toBe('error');
|
||||||
|
expect(message.data.message).toBe('Something went wrong');
|
||||||
|
expect(message.data.code).toBe('ERR_500');
|
||||||
|
expect(message.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createWebSocketMessage.connectionEstablished', () => {
|
||||||
|
it('should create a valid connection established message', () => {
|
||||||
|
const message = createWebSocketMessage.connectionEstablished({
|
||||||
|
user_id: 'user-123',
|
||||||
|
message: 'Connected successfully',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(message.type).toBe('connection-established');
|
||||||
|
expect(message.data.user_id).toBe('user-123');
|
||||||
|
expect(message.data.message).toBe('Connected successfully');
|
||||||
|
expect(message.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createWebSocketMessage.ping', () => {
|
||||||
|
it('should create a valid ping message', () => {
|
||||||
|
const message = createWebSocketMessage.ping();
|
||||||
|
|
||||||
|
expect(message.type).toBe('ping');
|
||||||
|
expect(message.data).toEqual({});
|
||||||
|
expect(message.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createWebSocketMessage.pong', () => {
|
||||||
|
it('should create a valid pong message', () => {
|
||||||
|
const message = createWebSocketMessage.pong();
|
||||||
|
|
||||||
|
expect(message.type).toBe('pong');
|
||||||
|
expect(message.data).toEqual({});
|
||||||
|
expect(message.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('timestamp validation', () => {
|
||||||
|
it('should generate valid ISO timestamps', () => {
|
||||||
|
const message = createWebSocketMessage.ping();
|
||||||
|
const timestamp = new Date(message.timestamp);
|
||||||
|
|
||||||
|
expect(timestamp).toBeInstanceOf(Date);
|
||||||
|
expect(timestamp.toISOString()).toBe(message.timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate different timestamps for sequential calls', () => {
|
||||||
|
const message1 = createWebSocketMessage.ping();
|
||||||
|
const message2 = createWebSocketMessage.ping();
|
||||||
|
|
||||||
|
// Timestamps should be close but potentially different
|
||||||
|
expect(message1.timestamp).toBeDefined();
|
||||||
|
expect(message2.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
112
src/types/websocket.ts
Normal file
112
src/types/websocket.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// src/types/websocket.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket message types for real-time notifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deal information for real-time notifications
|
||||||
|
*/
|
||||||
|
export interface DealInfo {
|
||||||
|
item_name: string;
|
||||||
|
best_price_in_cents: number;
|
||||||
|
store_name: string;
|
||||||
|
store_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base WebSocket message structure
|
||||||
|
*/
|
||||||
|
export interface WebSocketMessage<T = unknown> {
|
||||||
|
type: WebSocketMessageType;
|
||||||
|
data: T;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available WebSocket message types
|
||||||
|
*/
|
||||||
|
export type WebSocketMessageType =
|
||||||
|
| 'deal-notification'
|
||||||
|
| 'system-message'
|
||||||
|
| 'ping'
|
||||||
|
| 'pong'
|
||||||
|
| 'error'
|
||||||
|
| 'connection-established';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deal notification message payload
|
||||||
|
*/
|
||||||
|
export interface DealNotificationData {
|
||||||
|
notification_id?: string;
|
||||||
|
deals: DealInfo[];
|
||||||
|
user_id: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System message payload
|
||||||
|
*/
|
||||||
|
export interface SystemMessageData {
|
||||||
|
message: string;
|
||||||
|
severity: 'info' | 'warning' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message payload
|
||||||
|
*/
|
||||||
|
export interface ErrorMessageData {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection established payload
|
||||||
|
*/
|
||||||
|
export interface ConnectionEstablishedData {
|
||||||
|
user_id: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe message creators
|
||||||
|
*/
|
||||||
|
export const createWebSocketMessage = {
|
||||||
|
dealNotification: (data: DealNotificationData): WebSocketMessage<DealNotificationData> => ({
|
||||||
|
type: 'deal-notification',
|
||||||
|
data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
systemMessage: (data: SystemMessageData): WebSocketMessage<SystemMessageData> => ({
|
||||||
|
type: 'system-message',
|
||||||
|
data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
error: (data: ErrorMessageData): WebSocketMessage<ErrorMessageData> => ({
|
||||||
|
type: 'error',
|
||||||
|
data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
connectionEstablished: (
|
||||||
|
data: ConnectionEstablishedData,
|
||||||
|
): WebSocketMessage<ConnectionEstablishedData> => ({
|
||||||
|
type: 'connection-established',
|
||||||
|
data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
ping: (): WebSocketMessage<Record<string, never>> => ({
|
||||||
|
type: 'ping',
|
||||||
|
data: {},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
pong: (): WebSocketMessage<Record<string, never>> => ({
|
||||||
|
type: 'pong',
|
||||||
|
data: {},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
20305
test-results-full.txt
20305
test-results-full.txt
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user