feat: SEO template library, discovery pipeline, and orchestrator enhancements

## SEO Template Library
- Add complete template library with 7 page types (state, city, category, brand, product, search, regeneration)
- Add Template Library tab in SEO Orchestrator with accordion-based editors
- Add template preview, validation, and variable injection engine
- Add API endpoints: /api/seo/templates, preview, validate, generate, regenerate

## Discovery Pipeline
- Add promotion.ts for discovery location validation and promotion
- Add discover-all-states.ts script for multi-state discovery
- Add promotion log migration (067)
- Enhance discovery routes and types

## Orchestrator & Admin
- Add crawl_enabled filter to stores page
- Add API permissions page
- Add job queue management
- Add price analytics routes
- Add markets and intelligence routes
- Enhance dashboard and worker monitoring

## Infrastructure
- Add migrations for worker definitions, SEO settings, field alignment
- Add canonical pipeline for scraper v2
- Update hydration and sync orchestrator
- Enhance multi-state query service

🤖 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-09 00:05:34 -07:00
parent 9711d594db
commit 2f483b3084
83 changed files with 16700 additions and 1277 deletions

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env npx tsx
/**
* Retry resolving platform IDs for Dutchie stores that have menu_url but no platform_dispensary_id
*
* Usage:
* npx tsx src/scripts/retry-platform-ids.ts
*/
import { Pool } from 'pg';
import dotenv from 'dotenv';
import { resolveDispensaryIdWithDetails } from '../platforms/dutchie/queries';
dotenv.config();
const pool = new Pool({
connectionString: process.env.DATABASE_URL ||
`postgresql://${process.env.CANNAIQ_DB_USER || 'dutchie'}:${process.env.CANNAIQ_DB_PASS || 'dutchie_local_pass'}@${process.env.CANNAIQ_DB_HOST || 'localhost'}:${process.env.CANNAIQ_DB_PORT || '54320'}/${process.env.CANNAIQ_DB_NAME || 'dutchie_menus'}`
});
interface DispensaryRow {
id: number;
name: string;
menu_url: string;
}
function extractSlugFromUrl(menuUrl: string): string | null {
// Extract slug from Dutchie URLs like:
// https://dutchie.com/stores/Nirvana-North-Phoenix
// https://dutchie.com/dispensary/curaleaf-dispensary-peoria
// https://dutchie.com/embedded-menu/some-slug
const patterns = [
/dutchie\.com\/stores\/([^/?]+)/i,
/dutchie\.com\/dispensary\/([^/?]+)/i,
/dutchie\.com\/embedded-menu\/([^/?]+)/i,
];
for (const pattern of patterns) {
const match = menuUrl.match(pattern);
if (match) {
return match[1];
}
}
return null;
}
async function main() {
console.log('='.repeat(60));
console.log('Retry Platform ID Resolution');
console.log('='.repeat(60));
console.log('');
// Get Dutchie dispensaries with menu_url but no platform_dispensary_id
const result = await pool.query<DispensaryRow>(`
SELECT id, name, menu_url
FROM dispensaries
WHERE menu_type = 'dutchie'
AND menu_url IS NOT NULL AND menu_url != ''
AND (platform_dispensary_id IS NULL OR platform_dispensary_id = '')
ORDER BY name
`);
console.log(`Found ${result.rows.length} stores to retry\n`);
if (result.rows.length === 0) {
console.log('No stores need platform ID resolution.');
await pool.end();
return;
}
const successes: { id: number; name: string; platformId: string }[] = [];
const failures: { id: number; name: string; slug: string | null; error: string }[] = [];
for (const row of result.rows) {
console.log(`\n[${row.id}] ${row.name}`);
console.log(` URL: ${row.menu_url}`);
const slug = extractSlugFromUrl(row.menu_url);
if (!slug) {
console.log(` ❌ Could not extract slug from URL`);
failures.push({ id: row.id, name: row.name, slug: null, error: 'Could not extract slug' });
continue;
}
console.log(` Slug: ${slug}`);
try {
const resolveResult = await resolveDispensaryIdWithDetails(slug);
if (resolveResult.dispensaryId) {
console.log(` ✅ Resolved: ${resolveResult.dispensaryId}`);
// Update database
await pool.query(
'UPDATE dispensaries SET platform_dispensary_id = $1 WHERE id = $2',
[resolveResult.dispensaryId, row.id]
);
console.log(` 💾 Updated database`);
successes.push({ id: row.id, name: row.name, platformId: resolveResult.dispensaryId });
} else {
const errorMsg = resolveResult.error || 'Unknown error';
console.log(` ❌ Failed: ${errorMsg}`);
failures.push({ id: row.id, name: row.name, slug, error: errorMsg });
}
} catch (error: any) {
console.log(` ❌ Error: ${error.message}`);
failures.push({ id: row.id, name: row.name, slug, error: error.message });
}
// Small delay between requests
await new Promise(r => setTimeout(r, 500));
}
console.log('\n' + '='.repeat(60));
console.log('SUMMARY');
console.log('='.repeat(60));
console.log(`\n✅ Successes (${successes.length}):`);
for (const s of successes) {
console.log(` [${s.id}] ${s.name} -> ${s.platformId}`);
}
console.log(`\n❌ Failures (${failures.length}):`);
for (const f of failures) {
console.log(` [${f.id}] ${f.name} (slug: ${f.slug || 'N/A'})`);
console.log(` ${f.error}`);
}
await pool.end();
}
main().catch(e => {
console.error('Fatal error:', e);
process.exit(1);
});