Files
cannaiq/backend/src/routes/consumer-saved-searches.ts
Kelly 3bc0effa33 feat: Responsive admin UI, SEO pages, and click analytics
## Responsive Admin UI
- Layout.tsx: Mobile sidebar drawer with hamburger menu
- Dashboard.tsx: 2-col grid on mobile, responsive stats cards
- OrchestratorDashboard.tsx: Responsive table with hidden columns
- PagesTab.tsx: Responsive filters and table

## SEO Pages
- New /admin/seo section with state landing pages
- SEO page generation and management
- State page content with dispensary/product counts

## Click Analytics
- Product click tracking infrastructure
- Click analytics dashboard

## Other Changes
- Consumer features scaffolding (alerts, deals, favorites)
- Health panel component
- Workers dashboard improvements
- Legacy DutchieAZ pages removed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 22:48:21 -07:00

390 lines
12 KiB
TypeScript

/**
* Consumer Saved Searches API Routes
* Handles saved searches for findagram.co (products) and findadispo.com (dispensaries)
*/
import { Router, Request, Response } from 'express';
import { pool } from '../db/pool';
import { authenticateConsumer } from './consumer-auth';
const router = Router();
// All routes require authentication
router.use(authenticateConsumer);
/**
* GET /api/consumer/saved-searches
* Get user's saved searches
*/
router.get('/', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
if (domain === 'findagram.co') {
const result = await pool.query(
`SELECT * FROM findagram_saved_searches
WHERE user_id = $1
ORDER BY created_at DESC`,
[userId]
);
res.json({
savedSearches: result.rows.map(row => ({
id: row.id,
name: row.name,
query: row.query,
category: row.category,
brand: row.brand,
strainType: row.strain_type,
minPrice: row.min_price,
maxPrice: row.max_price,
minThc: row.min_thc,
maxThc: row.max_thc,
city: row.city,
state: row.state,
notifyOnNew: row.notify_on_new,
notifyOnPriceDrop: row.notify_on_price_drop,
createdAt: row.created_at,
updatedAt: row.updated_at
}))
});
} else if (domain === 'findadispo.com') {
const result = await pool.query(
`SELECT * FROM findadispo_saved_searches
WHERE user_id = $1
ORDER BY created_at DESC`,
[userId]
);
res.json({
savedSearches: result.rows.map(row => ({
id: row.id,
name: row.name,
query: row.query,
city: row.city,
state: row.state,
minRating: row.min_rating,
maxDistance: row.max_distance,
amenities: row.amenities || [],
notifyOnNewDispensary: row.notify_on_new_dispensary,
notifyOnDeals: row.notify_on_deals,
createdAt: row.created_at,
updatedAt: row.updated_at
}))
});
} else {
res.status(400).json({ error: 'Invalid domain' });
}
} catch (error) {
console.error('[Consumer Saved Searches] Get error:', error);
res.status(500).json({ error: 'Failed to get saved searches' });
}
});
/**
* POST /api/consumer/saved-searches
* Create a saved search
*/
router.post('/', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
if (domain === 'findagram.co') {
const {
name,
query,
category,
brand,
strainType,
minPrice,
maxPrice,
minThc,
maxThc,
city,
state,
notifyOnNew = false,
notifyOnPriceDrop = false
} = req.body;
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
const result = await pool.query(
`INSERT INTO findagram_saved_searches
(user_id, name, query, category, brand, strain_type, min_price, max_price,
min_thc, max_thc, city, state, notify_on_new, notify_on_price_drop)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id`,
[
userId, name, query || null, category || null, brand || null,
strainType || null, minPrice || null, maxPrice || null,
minThc || null, maxThc || null, city || null, state || null,
notifyOnNew, notifyOnPriceDrop
]
);
res.status(201).json({
success: true,
savedSearchId: result.rows[0].id,
message: 'Search saved'
});
} else if (domain === 'findadispo.com') {
const {
name,
query,
city,
state,
minRating,
maxDistance,
amenities,
notifyOnNewDispensary = false,
notifyOnDeals = false
} = req.body;
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
const result = await pool.query(
`INSERT INTO findadispo_saved_searches
(user_id, name, query, city, state, min_rating, max_distance, amenities,
notify_on_new_dispensary, notify_on_deals)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
[
userId, name, query || null, city || null, state || null,
minRating || null, maxDistance || null, amenities || null,
notifyOnNewDispensary, notifyOnDeals
]
);
res.status(201).json({
success: true,
savedSearchId: result.rows[0].id,
message: 'Search saved'
});
} else {
res.status(400).json({ error: 'Invalid domain' });
}
} catch (error) {
console.error('[Consumer Saved Searches] Create error:', error);
res.status(500).json({ error: 'Failed to save search' });
}
});
/**
* PUT /api/consumer/saved-searches/:id
* Update a saved search
*/
router.put('/:id', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const searchId = parseInt(req.params.id);
if (isNaN(searchId)) {
return res.status(400).json({ error: 'Invalid search ID' });
}
if (domain === 'findagram.co') {
const {
name,
query,
category,
brand,
strainType,
minPrice,
maxPrice,
minThc,
maxThc,
city,
state,
notifyOnNew,
notifyOnPriceDrop
} = req.body;
const result = await pool.query(
`UPDATE findagram_saved_searches SET
name = COALESCE($1, name),
query = COALESCE($2, query),
category = COALESCE($3, category),
brand = COALESCE($4, brand),
strain_type = COALESCE($5, strain_type),
min_price = COALESCE($6, min_price),
max_price = COALESCE($7, max_price),
min_thc = COALESCE($8, min_thc),
max_thc = COALESCE($9, max_thc),
city = COALESCE($10, city),
state = COALESCE($11, state),
notify_on_new = COALESCE($12, notify_on_new),
notify_on_price_drop = COALESCE($13, notify_on_price_drop),
updated_at = NOW()
WHERE id = $14 AND user_id = $15
RETURNING id`,
[
name, query, category, brand, strainType, minPrice, maxPrice,
minThc, maxThc, city, state, notifyOnNew, notifyOnPriceDrop,
searchId, userId
]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Saved search not found' });
}
res.json({ success: true, message: 'Search updated' });
} else if (domain === 'findadispo.com') {
const {
name,
query,
city,
state,
minRating,
maxDistance,
amenities,
notifyOnNewDispensary,
notifyOnDeals
} = req.body;
const result = await pool.query(
`UPDATE findadispo_saved_searches SET
name = COALESCE($1, name),
query = COALESCE($2, query),
city = COALESCE($3, city),
state = COALESCE($4, state),
min_rating = COALESCE($5, min_rating),
max_distance = COALESCE($6, max_distance),
amenities = COALESCE($7, amenities),
notify_on_new_dispensary = COALESCE($8, notify_on_new_dispensary),
notify_on_deals = COALESCE($9, notify_on_deals),
updated_at = NOW()
WHERE id = $10 AND user_id = $11
RETURNING id`,
[
name, query, city, state, minRating, maxDistance,
amenities, notifyOnNewDispensary, notifyOnDeals,
searchId, userId
]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Saved search not found' });
}
res.json({ success: true, message: 'Search updated' });
} else {
res.status(400).json({ error: 'Invalid domain' });
}
} catch (error) {
console.error('[Consumer Saved Searches] Update error:', error);
res.status(500).json({ error: 'Failed to update search' });
}
});
/**
* DELETE /api/consumer/saved-searches/:id
* Delete a saved search
*/
router.delete('/:id', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const searchId = parseInt(req.params.id);
if (isNaN(searchId)) {
return res.status(400).json({ error: 'Invalid search ID' });
}
const table = domain === 'findagram.co' ? 'findagram_saved_searches' : 'findadispo_saved_searches';
const result = await pool.query(
`DELETE FROM ${table} WHERE id = $1 AND user_id = $2 RETURNING id`,
[searchId, userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Saved search not found' });
}
res.json({ success: true, message: 'Search deleted' });
} catch (error) {
console.error('[Consumer Saved Searches] Delete error:', error);
res.status(500).json({ error: 'Failed to delete search' });
}
});
/**
* POST /api/consumer/saved-searches/:id/run
* Execute a saved search and return results
* This builds the search URL/params that the frontend can use
*/
router.post('/:id/run', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const searchId = parseInt(req.params.id);
if (isNaN(searchId)) {
return res.status(400).json({ error: 'Invalid search ID' });
}
const table = domain === 'findagram.co' ? 'findagram_saved_searches' : 'findadispo_saved_searches';
const result = await pool.query(
`SELECT * FROM ${table} WHERE id = $1 AND user_id = $2`,
[searchId, userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Saved search not found' });
}
const search = result.rows[0];
// Build search parameters for frontend to use
if (domain === 'findagram.co') {
const params: Record<string, any> = {};
if (search.query) params.q = search.query;
if (search.category) params.category = search.category;
if (search.brand) params.brand = search.brand;
if (search.strain_type) params.strainType = search.strain_type;
if (search.min_price) params.minPrice = search.min_price;
if (search.max_price) params.maxPrice = search.max_price;
if (search.min_thc) params.minThc = search.min_thc;
if (search.max_thc) params.maxThc = search.max_thc;
if (search.city) params.city = search.city;
if (search.state) params.state = search.state;
res.json({
searchParams: params,
searchUrl: `/products?${new URLSearchParams(params as any).toString()}`
});
} else {
const params: Record<string, any> = {};
if (search.query) params.q = search.query;
if (search.city) params.city = search.city;
if (search.state) params.state = search.state;
if (search.min_rating) params.minRating = search.min_rating;
if (search.max_distance) params.maxDistance = search.max_distance;
if (search.amenities?.length) params.amenities = search.amenities.join(',');
res.json({
searchParams: params,
searchUrl: `/?${new URLSearchParams(params as any).toString()}`
});
}
} catch (error) {
console.error('[Consumer Saved Searches] Run error:', error);
res.status(500).json({ error: 'Failed to run search' });
}
});
export default router;