Files
cannaiq/backend/src/db/migrate.ts
Kelly 5415cac2f3 feat(seo): Add SEO tables to migration and ingress config
- Add seo_pages and seo_page_contents tables to migrate.ts for
  automatic creation on deployment
- Update Home.tsx with minor formatting
- Add ingress configuration updates

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 12:58:38 -07:00

439 lines
15 KiB
TypeScript
Executable File

/**
* Database Migration Script (CLI-ONLY)
*
* This file is for running migrations via CLI only:
* npx tsx src/db/migrate.ts
*
* DO NOT import this file from runtime code.
* Runtime code should import from src/db/pool.ts instead.
*/
import { Pool } from 'pg';
import dotenv from 'dotenv';
// Load .env BEFORE any env var access
dotenv.config();
/**
* Get the database connection string from environment variables.
* Strict validation - will throw if required vars are missing.
*/
function getConnectionString(): string {
// Priority 1: Full connection URL
if (process.env.CANNAIQ_DB_URL) {
return process.env.CANNAIQ_DB_URL;
}
// Priority 2: Build from individual env vars (all required)
const required = ['CANNAIQ_DB_HOST', 'CANNAIQ_DB_PORT', 'CANNAIQ_DB_NAME', 'CANNAIQ_DB_USER', 'CANNAIQ_DB_PASS'];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
throw new Error(
`[Migrate] Missing required environment variables: ${missing.join(', ')}\n` +
`Either set CANNAIQ_DB_URL or all of: CANNAIQ_DB_HOST, CANNAIQ_DB_PORT, CANNAIQ_DB_NAME, CANNAIQ_DB_USER, CANNAIQ_DB_PASS`
);
}
const host = process.env.CANNAIQ_DB_HOST!;
const port = process.env.CANNAIQ_DB_PORT!;
const name = process.env.CANNAIQ_DB_NAME!;
const user = process.env.CANNAIQ_DB_USER!;
const pass = process.env.CANNAIQ_DB_PASS!;
return `postgresql://${user}:${pass}@${host}:${port}/${name}`;
}
/**
* Run all database migrations
*/
async function runMigrations() {
// Create pool only when migrations are actually run
const pool = new Pool({
connectionString: getConnectionString(),
});
const client = await pool.connect();
try {
await client.query('BEGIN');
// Users table
await client.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'admin',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Stores table
await client.query(`
CREATE TABLE IF NOT EXISTS stores (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
dutchie_url TEXT NOT NULL,
active BOOLEAN DEFAULT true,
scrape_enabled BOOLEAN DEFAULT true,
last_scraped_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Categories table (shop, brands, specials)
await client.query(`
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
dutchie_url TEXT NOT NULL,
scrape_enabled BOOLEAN DEFAULT true,
last_scraped_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(store_id, slug)
);
`);
// Products table
await client.query(`
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
dutchie_product_id VARCHAR(255),
name VARCHAR(500) NOT NULL,
slug VARCHAR(500),
description TEXT,
price DECIMAL(10, 2),
original_price DECIMAL(10, 2),
strain_type VARCHAR(100),
thc_percentage DECIMAL(5, 2),
cbd_percentage DECIMAL(5, 2),
brand VARCHAR(255),
weight VARCHAR(100),
image_url TEXT,
local_image_path TEXT,
dutchie_url TEXT NOT NULL,
in_stock BOOLEAN DEFAULT true,
is_special BOOLEAN DEFAULT false,
metadata JSONB,
first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(store_id, dutchie_product_id)
);
`);
// Campaigns table
await client.query(`
CREATE TABLE IF NOT EXISTS campaigns (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
display_style VARCHAR(50) DEFAULT 'grid',
active BOOLEAN DEFAULT true,
start_date TIMESTAMP,
end_date TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Add variant column to products table (for different sizes/options of same product)
await client.query(`
ALTER TABLE products ADD COLUMN IF NOT EXISTS variant VARCHAR(255);
`);
// Add special tracking columns (DEPRECATED - not used with new approach)
await client.query(`
ALTER TABLE products ADD COLUMN IF NOT EXISTS special_ends_at TIMESTAMP;
ALTER TABLE products ADD COLUMN IF NOT EXISTS special_text TEXT;
ALTER TABLE products ADD COLUMN IF NOT EXISTS special_type VARCHAR(100);
`);
// ====== NEW SCHEMA ADDITIONS ======
// Add array columns for product attributes
await client.query(`
ALTER TABLE products ADD COLUMN IF NOT EXISTS terpenes TEXT[];
ALTER TABLE products ADD COLUMN IF NOT EXISTS effects TEXT[];
ALTER TABLE products ADD COLUMN IF NOT EXISTS flavors TEXT[];
`);
// Add new price columns (regular_price = market price, sale_price = discount price)
await client.query(`
ALTER TABLE products ADD COLUMN IF NOT EXISTS regular_price DECIMAL(10, 2);
ALTER TABLE products ADD COLUMN IF NOT EXISTS sale_price DECIMAL(10, 2);
`);
// Migrate existing price data
await client.query(`
UPDATE products
SET regular_price = original_price
WHERE regular_price IS NULL AND original_price IS NOT NULL;
`);
await client.query(`
UPDATE products
SET sale_price = price
WHERE sale_price IS NULL AND price IS NOT NULL AND original_price IS NOT NULL AND price < original_price;
`);
// Make slug NOT NULL and add unique constraint
await client.query(`
UPDATE products SET slug = dutchie_product_id WHERE slug IS NULL;
ALTER TABLE products ALTER COLUMN slug SET NOT NULL;
`);
// Drop old unique constraint and add new one on slug
await client.query(`
ALTER TABLE products DROP CONSTRAINT IF EXISTS products_store_id_dutchie_product_id_key;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'products_store_id_slug_unique') THEN
ALTER TABLE products ADD CONSTRAINT products_store_id_slug_unique UNIQUE (store_id, slug);
END IF;
END$$;
`);
// Product Categories (many-to-many) - products can appear in multiple categories
await client.query(`
CREATE TABLE IF NOT EXISTS product_categories (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
category_slug VARCHAR(255) NOT NULL,
first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(product_id, category_slug)
);
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_product_categories_slug ON product_categories(category_slug, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_product_categories_product ON product_categories(product_id);
`);
// Price History - track regular and sale price changes over time
await client.query(`
CREATE TABLE IF NOT EXISTS price_history (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
regular_price DECIMAL(10, 2),
sale_price DECIMAL(10, 2),
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_price_history_product ON price_history(product_id, recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_price_history_recorded ON price_history(recorded_at DESC);
`);
// Batch History - track cannabinoid/terpene changes (different batches)
await client.query(`
CREATE TABLE IF NOT EXISTS batch_history (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
thc_percentage DECIMAL(5, 2),
cbd_percentage DECIMAL(5, 2),
terpenes TEXT[],
strain_type VARCHAR(100),
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_batch_history_product ON batch_history(product_id, recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_batch_history_recorded ON batch_history(recorded_at DESC);
`);
// Campaign products (many-to-many with ordering)
await client.query(`
CREATE TABLE IF NOT EXISTS campaign_products (
id SERIAL PRIMARY KEY,
campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE,
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
display_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(campaign_id, product_id)
);
`);
// Click tracking
await client.query(`
CREATE TABLE IF NOT EXISTS clicks (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
campaign_id INTEGER REFERENCES campaigns(id) ON DELETE SET NULL,
ip_address VARCHAR(45),
user_agent TEXT,
referrer TEXT,
clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Create index on clicked_at for analytics queries
await client.query(`
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
CREATE INDEX IF NOT EXISTS idx_clicks_product_id ON clicks(product_id);
CREATE INDEX IF NOT EXISTS idx_clicks_campaign_id ON clicks(campaign_id);
`);
// Proxies table
await client.query(`
CREATE TABLE IF NOT EXISTS proxies (
id SERIAL PRIMARY KEY,
host VARCHAR(255) NOT NULL,
port INTEGER NOT NULL,
protocol VARCHAR(10) NOT NULL,
username VARCHAR(255),
password VARCHAR(255),
active BOOLEAN DEFAULT true,
is_anonymous BOOLEAN DEFAULT false,
last_tested_at TIMESTAMP,
test_result VARCHAR(50),
response_time_ms INTEGER,
failure_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(host, port, protocol)
);
`);
// Add failure_count column if it doesn't exist
await client.query(`
ALTER TABLE proxies ADD COLUMN IF NOT EXISTS failure_count INTEGER DEFAULT 0;
`);
// Failed proxies table
await client.query(`
CREATE TABLE IF NOT EXISTS failed_proxies (
id SERIAL PRIMARY KEY,
host VARCHAR(255) NOT NULL,
port INTEGER NOT NULL,
protocol VARCHAR(10) NOT NULL,
username VARCHAR(255),
password VARCHAR(255),
failure_count INTEGER NOT NULL,
last_error TEXT,
failed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(host, port, protocol)
);
`);
// Proxy test jobs table
await client.query(`
CREATE TABLE IF NOT EXISTS proxy_test_jobs (
id SERIAL PRIMARY KEY,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
total_proxies INTEGER NOT NULL DEFAULT 0,
tested_proxies INTEGER NOT NULL DEFAULT 0,
passed_proxies INTEGER NOT NULL DEFAULT 0,
failed_proxies INTEGER NOT NULL DEFAULT 0,
started_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_proxy_test_jobs_status ON proxy_test_jobs(status);
CREATE INDEX IF NOT EXISTS idx_proxy_test_jobs_created_at ON proxy_test_jobs(created_at DESC);
`);
// Settings table
await client.query(`
CREATE TABLE IF NOT EXISTS settings (
key VARCHAR(255) PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Insert default settings
await client.query(`
INSERT INTO settings (key, value, description) VALUES
('scrape_interval_hours', '4', 'How often to scrape stores (in hours)'),
('scrape_specials_time', '00:01', 'Time to scrape specials daily (HH:MM in 24h format)'),
('analytics_retention_days', '365', 'How many days to keep analytics data'),
('proxy_timeout_ms', '3000', 'Proxy timeout in milliseconds'),
('proxy_test_url', 'https://httpbin.org/ip', 'URL to test proxies against')
ON CONFLICT (key) DO NOTHING;
`);
// SEO Pages table
await client.query(`
CREATE TABLE IF NOT EXISTS seo_pages (
id SERIAL PRIMARY KEY,
type VARCHAR(50) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
page_key VARCHAR(255) NOT NULL,
primary_keyword VARCHAR(255),
status VARCHAR(50) DEFAULT 'pending_generation',
data_source VARCHAR(100),
meta_title VARCHAR(255),
meta_description TEXT,
last_generated_at TIMESTAMPTZ,
last_reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_seo_pages_type ON seo_pages(type);
CREATE INDEX IF NOT EXISTS idx_seo_pages_status ON seo_pages(status);
CREATE INDEX IF NOT EXISTS idx_seo_pages_slug ON seo_pages(slug);
`);
// SEO Page Contents table
await client.query(`
CREATE TABLE IF NOT EXISTS seo_page_contents (
id SERIAL PRIMARY KEY,
page_id INTEGER NOT NULL REFERENCES seo_pages(id) ON DELETE CASCADE,
version INTEGER DEFAULT 1,
blocks JSONB NOT NULL DEFAULT '[]',
meta JSONB NOT NULL DEFAULT '{}',
meta_title VARCHAR(255),
meta_description TEXT,
h1 VARCHAR(255),
canonical_url TEXT,
og_title VARCHAR(255),
og_description TEXT,
og_image_url TEXT,
generated_by VARCHAR(50) DEFAULT 'claude',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(page_id, version)
);
CREATE INDEX IF NOT EXISTS idx_seo_page_contents_page ON seo_page_contents(page_id);
`);
await client.query('COMMIT');
console.log('✅ Migrations completed successfully');
} catch (error) {
await client.query('ROLLBACK');
console.error('❌ Migration failed:', error);
throw error;
} finally {
client.release();
await pool.end();
}
}
// Only run when executed directly (CLI mode)
// DO NOT export pool - runtime code must use src/db/pool.ts
if (require.main === module) {
runMigrations()
.then(() => process.exit(0))
.catch(() => process.exit(1));
}