feat: AZ dispensary harmonization with Dutchie source of truth

Major changes:
- Add harmonize-az-dispensaries.ts script to sync dispensaries with Dutchie API
- Add migration 057 for crawl_enabled and dutchie_verified fields
- Remove legacy dutchie-az module (replaced by platforms/dutchie)
- Clean up deprecated crawlers, scrapers, and orchestrator code
- Update location-discovery to not fallback to slug when ID is missing
- Add crawl-rotator service for proxy rotation
- Add types/index.ts for shared type definitions
- Add woodpecker-agent k8s manifest

Harmonization script:
- Queries ConsumerDispensaries API for all 32 AZ cities
- Matches dispensaries by platform_dispensary_id (not slug)
- Updates existing records with full Dutchie data
- Creates new records for unmatched Dutchie dispensaries
- Disables dispensaries not found in Dutchie

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-08 10:19:49 -07:00
parent 948a732dd5
commit b7cfec0770
112 changed files with 3163 additions and 34694 deletions

View File

@@ -9,7 +9,6 @@
import { Router, Request, Response, NextFunction } from 'express';
import { pool } from '../db/pool';
import { query as dutchieAzQuery } from '../dutchie-az/db/connection';
import ipaddr from 'ipaddr.js';
import {
ApiScope,
@@ -140,7 +139,7 @@ async function validatePublicApiKey(
try {
// Query WordPress permissions table with store info
const result = await pool.query<ApiKeyPermission>(`
const result = await pool.query(`
SELECT
p.id,
p.user_name,
@@ -198,7 +197,7 @@ async function validatePublicApiKey(
// Resolve the dutchie_az store for wordpress keys
if (permission.key_type === 'wordpress' && permission.store_name) {
const storeResult = await dutchieAzQuery<{ id: number }>(`
const storeResult = await pool.query(`
SELECT id FROM dispensaries
WHERE LOWER(TRIM(name)) = LOWER(TRIM($1))
OR LOWER(TRIM(name)) LIKE LOWER(TRIM($1)) || '%'
@@ -439,7 +438,7 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
// Query products with latest snapshot data
// Note: Price filters use HAVING clause since they reference the snapshot subquery
const { rows: products } = await dutchieAzQuery(`
const { rows: products } = await pool.query(`
SELECT
p.id,
p.dispensary_id,
@@ -482,7 +481,7 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
`, params);
// Get total count for pagination (include price filters if specified)
const { rows: countRows } = await dutchieAzQuery(`
const { rows: countRows } = await pool.query(`
SELECT COUNT(*) as total FROM dutchie_products p
LEFT JOIN LATERAL (
SELECT rec_min_price_cents, special FROM dutchie_product_snapshots
@@ -567,7 +566,7 @@ router.get('/products/:id', async (req: PublicApiRequest, res: Response) => {
const { id } = req.params;
// Get product (without dispensary filter to check access afterward)
const { rows: products } = await dutchieAzQuery(`
const { rows: products } = await pool.query(`
SELECT
p.*,
s.rec_min_price_cents,
@@ -677,7 +676,7 @@ router.get('/categories', async (req: PublicApiRequest, res: Response) => {
});
}
const { rows: categories } = await dutchieAzQuery(`
const { rows: categories } = await pool.query(`
SELECT
type as category,
subcategory,
@@ -733,7 +732,7 @@ router.get('/brands', async (req: PublicApiRequest, res: Response) => {
});
}
const { rows: brands } = await dutchieAzQuery(`
const { rows: brands } = await pool.query(`
SELECT
brand_name as brand,
COUNT(*) as product_count,
@@ -796,7 +795,7 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => {
params.push(limitNum, offsetNum);
const { rows: products } = await dutchieAzQuery(`
const { rows: products } = await pool.query(`
SELECT
p.id,
p.dispensary_id,
@@ -828,7 +827,7 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => {
// Get total count
const countParams = params.slice(0, -2);
const { rows: countRows } = await dutchieAzQuery(`
const { rows: countRows } = await pool.query(`
SELECT COUNT(*) as total
FROM dutchie_products p
INNER JOIN LATERAL (
@@ -906,7 +905,7 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
}
// Get single dispensary for wordpress key
const { rows: dispensaries } = await dutchieAzQuery(`
const { rows: dispensaries } = await pool.query(`
SELECT
d.id,
d.name,
@@ -1013,7 +1012,7 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
const limitNum = Math.min(parseInt(limit as string, 10) || 100, 500);
const offsetNum = parseInt(offset as string, 10) || 0;
const { rows: dispensaries } = await dutchieAzQuery(`
const { rows: dispensaries } = await pool.query(`
SELECT
d.id,
d.name,
@@ -1051,7 +1050,7 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`, [...params, limitNum, offsetNum]);
const { rows: countRows } = await dutchieAzQuery(`
const { rows: countRows } = await pool.query(`
SELECT COUNT(*) as total
FROM dispensaries d
LEFT JOIN LATERAL (
@@ -1178,7 +1177,7 @@ router.get('/search', async (req: PublicApiRequest, res: Response) => {
params.push(limitNum, offsetNum);
const { rows: products } = await dutchieAzQuery(`
const { rows: products } = await pool.query(`
SELECT
p.id,
p.dispensary_id,
@@ -1221,7 +1220,7 @@ router.get('/search', async (req: PublicApiRequest, res: Response) => {
// Count query (without relevance param)
const countParams = params.slice(0, paramIndex - 3); // Remove relevance, limit, offset
const { rows: countRows } = await dutchieAzQuery(`
const { rows: countRows } = await pool.query(`
SELECT COUNT(*) as total
FROM dutchie_products p
${whereClause}
@@ -1302,7 +1301,7 @@ router.get('/menu', async (req: PublicApiRequest, res: Response) => {
}
// Get counts by category
const { rows: categoryCounts } = await dutchieAzQuery(`
const { rows: categoryCounts } = await pool.query(`
SELECT
type as category,
COUNT(*) as total,
@@ -1314,7 +1313,7 @@ router.get('/menu', async (req: PublicApiRequest, res: Response) => {
`, params);
// Get overall stats
const { rows: stats } = await dutchieAzQuery(`
const { rows: stats } = await pool.query(`
SELECT
COUNT(*) as total_products,
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count,
@@ -1326,7 +1325,7 @@ router.get('/menu', async (req: PublicApiRequest, res: Response) => {
`, params);
// Get specials count
const { rows: specialsCount } = await dutchieAzQuery(`
const { rows: specialsCount } = await pool.query(`
SELECT COUNT(*) as count
FROM dutchie_products p
INNER JOIN LATERAL (