db(seo): 🗃️ Add 4 SEO backend database migrations: generation metadata, campaigns, single-domain conversion tracking, and brand configuration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-28 15:52:22 -08:00
parent e9bfae7ad0
commit 3becb66876
6 changed files with 184 additions and 273 deletions

View file

@ -0,0 +1,183 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Initial SEO Schema
*
* Creates all tables for the SEO feature in their final state,
* incorporating all subsequent ALTER TABLE changes from individual migrations:
*
* - seo_content: Core SEO content records with generation_metadata column
* (from add-generation-metadata migration)
* - seo_campaigns: Campaign management with single domain column
* (from add-campaigns + convert-campaigns-single-domain migrations)
* - seo_campaign_targets: Individual content targets within a campaign
* - domain_configs: Domain-specific SEO configuration with brand_config column
* (from add-brand-config migration)
*
* Note: The original schema for seo_content and domain_configs existed prior to
* these migrations (created via TypeORM synchronize). The initial tables and all
* subsequent ALTER TABLE changes are folded into this single migration.
*/
export class InitialSchema1700000000000 implements MigrationInterface {
name = 'InitialSchema1700000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── seo_content ───────────────────────────────────────────────────
// Includes generation_metadata column (from add-generation-metadata migration)
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS seo_content (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
domain varchar(255) NOT NULL,
path varchar(500) NOT NULL,
locale varchar(10) NOT NULL DEFAULT 'en',
category_slug varchar(100),
location_id uuid,
title varchar(100),
description varchar(200),
h1 varchar(100),
body text,
schema jsonb,
internal_links jsonb NOT NULL DEFAULT '[]'::jsonb,
status varchar(50) NOT NULL DEFAULT 'draft',
seo_score integer,
readability_score integer,
keyword_density decimal(5,2),
word_count integer,
generator_version varchar(50),
source_content_id uuid,
translation_provider varchar(50),
translation_quality_score decimal(5,4),
generated_at timestamptz NOT NULL DEFAULT NOW(),
published_at timestamptz,
generation_metadata jsonb,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
CONSTRAINT seo_content_domain_path_locale UNIQUE (domain, path, locale)
)
`);
await queryRunner.query(`
COMMENT ON COLUMN seo_content.generation_metadata IS
'Generation metadata including truth validation and feature detection results'
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_seo_content_domain ON seo_content(domain)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_seo_content_status ON seo_content(status)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_seo_content_locale ON seo_content(locale)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_seo_content_category ON seo_content(category_slug)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_seo_content_location ON seo_content(location_id)`);
// ── domain_configs ────────────────────────────────────────────────
// Includes brand_config column (from add-brand-config migration)
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS domain_configs (
id serial PRIMARY KEY,
domain varchar(255) NOT NULL UNIQUE,
default_locale varchar(10) NOT NULL DEFAULT 'en',
supported_locales text[] NOT NULL DEFAULT ARRAY['en'],
site_name varchar(255) NOT NULL,
twitter_handle varchar(100),
default_og_image text,
auto_generate boolean NOT NULL DEFAULT true,
brand_config jsonb,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW()
)
`);
await queryRunner.query(`
COMMENT ON COLUMN domain_configs.brand_config IS
'Brand configuration for SEO content differentiation (voice, tagline, value propositions, tone guidelines, avoid/prefer terms)'
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_domain_configs_domain ON domain_configs(domain)`);
// ── seo_campaigns ─────────────────────────────────────────────────
// Final state: single domain varchar column (not jsonb array)
// (from add-campaigns + convert-campaigns-single-domain migrations)
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS seo_campaigns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name varchar(255) NOT NULL,
status varchar(50) NOT NULL DEFAULT 'draft',
domain varchar(255) NOT NULL,
categories jsonb NOT NULL DEFAULT '[]'::jsonb,
locales jsonb NOT NULL DEFAULT '["en"]'::jsonb,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW()
)
`);
await queryRunner.query(`COMMENT ON TABLE seo_campaigns IS 'SEO content production campaigns'`);
await queryRunner.query(`COMMENT ON COLUMN seo_campaigns.status IS 'Campaign status: draft, active, completed, archived'`);
await queryRunner.query(`COMMENT ON COLUMN seo_campaigns.domain IS 'Single target domain for this campaign'`);
await queryRunner.query(`COMMENT ON COLUMN seo_campaigns.categories IS 'Array of target category slugs'`);
await queryRunner.query(`COMMENT ON COLUMN seo_campaigns.locales IS 'Array of target locales'`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_seo_campaigns_status ON seo_campaigns(status)`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION update_seo_campaigns_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS seo_campaigns_updated_at ON seo_campaigns
`);
await queryRunner.query(`
CREATE TRIGGER seo_campaigns_updated_at
BEFORE UPDATE ON seo_campaigns
FOR EACH ROW EXECUTE FUNCTION update_seo_campaigns_updated_at()
`);
// ── seo_campaign_targets ──────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS seo_campaign_targets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id uuid NOT NULL REFERENCES seo_campaigns(id) ON DELETE CASCADE,
domain varchar(255) NOT NULL,
location_id uuid NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
category_slug varchar(100) NOT NULL,
locale varchar(10) NOT NULL DEFAULT 'en',
content_id uuid REFERENCES seo_content(id) ON DELETE SET NULL,
target_status varchar(50) NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
CONSTRAINT seo_campaign_targets_unique UNIQUE (campaign_id, domain, location_id, category_slug, locale)
)
`);
await queryRunner.query(`COMMENT ON TABLE seo_campaign_targets IS 'Individual content targets within a campaign'`);
await queryRunner.query(`COMMENT ON COLUMN seo_campaign_targets.target_status IS 'Target status: pending, generated, review, published'`);
await queryRunner.query(`COMMENT ON COLUMN seo_campaign_targets.content_id IS 'Reference to generated content when available'`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_seo_campaign_targets_campaign ON seo_campaign_targets(campaign_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_seo_campaign_targets_status ON seo_campaign_targets(target_status)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_seo_campaign_targets_domain ON seo_campaign_targets(domain)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_seo_campaign_targets_location ON seo_campaign_targets(location_id)`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS seo_campaign_targets_updated_at ON seo_campaign_targets
`);
await queryRunner.query(`
CREATE TRIGGER seo_campaign_targets_updated_at
BEFORE UPDATE ON seo_campaign_targets
FOR EACH ROW EXECUTE FUNCTION update_seo_campaigns_updated_at()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TRIGGER IF EXISTS seo_campaign_targets_updated_at ON seo_campaign_targets`);
await queryRunner.query(`DROP TRIGGER IF EXISTS seo_campaigns_updated_at ON seo_campaigns`);
await queryRunner.query(`DROP FUNCTION IF EXISTS update_seo_campaigns_updated_at()`);
await queryRunner.query(`DROP TABLE IF EXISTS seo_campaign_targets`);
await queryRunner.query(`DROP TABLE IF EXISTS seo_campaigns`);
await queryRunner.query(`DROP TABLE IF EXISTS domain_configs`);
await queryRunner.query(`DROP TABLE IF EXISTS seo_content`);
}
}

View file

@ -1,31 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Add generation_metadata JSONB column to seo_content table.
*
* This column tracks truth validation and feature detection results
* from the SEO pipeline generation process.
*
* To run manually in production:
* psql -U <user> -d <database> -f add-generation-metadata.sql
*/
export class AddGenerationMetadata1735837200000 implements MigrationInterface {
name = 'AddGenerationMetadata1735837200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE seo_content
ADD COLUMN IF NOT EXISTS generation_metadata jsonb;
COMMENT ON COLUMN seo_content.generation_metadata IS
'Generation metadata including truth validation and feature detection results';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE seo_content
DROP COLUMN IF EXISTS generation_metadata;
`);
}
}

View file

@ -1,91 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Add SEO campaign management tables.
*
* Creates seo_campaigns and seo_campaign_targets tables for managing
* bulk SEO content production workflows.
*
* To run manually in production:
* psql -U <user> -d <database> -f add-campaigns.sql
*/
export class AddCampaigns1737216000000 implements MigrationInterface {
name = 'AddCampaigns1737216000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
-- Create campaigns table
CREATE TABLE IF NOT EXISTS seo_campaigns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name varchar(255) NOT NULL,
status varchar(50) NOT NULL DEFAULT 'draft',
domains jsonb NOT NULL DEFAULT '[]'::jsonb,
categories jsonb NOT NULL DEFAULT '[]'::jsonb,
locales jsonb NOT NULL DEFAULT '["en"]'::jsonb,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE seo_campaigns IS 'SEO content production campaigns';
COMMENT ON COLUMN seo_campaigns.status IS 'Campaign status: draft, active, completed, archived';
COMMENT ON COLUMN seo_campaigns.domains IS 'Array of target domains (e.g., trustedmeet.com, atlilith.com)';
COMMENT ON COLUMN seo_campaigns.categories IS 'Array of target category slugs';
COMMENT ON COLUMN seo_campaigns.locales IS 'Array of target locales';
-- Create campaign targets table
CREATE TABLE IF NOT EXISTS seo_campaign_targets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id uuid NOT NULL REFERENCES seo_campaigns(id) ON DELETE CASCADE,
domain varchar(255) NOT NULL,
location_id uuid NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
category_slug varchar(100) NOT NULL,
locale varchar(10) NOT NULL DEFAULT 'en',
content_id uuid REFERENCES seo_content(id) ON DELETE SET NULL,
target_status varchar(50) NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
CONSTRAINT seo_campaign_targets_unique UNIQUE (campaign_id, domain, location_id, category_slug, locale)
);
COMMENT ON TABLE seo_campaign_targets IS 'Individual content targets within a campaign';
COMMENT ON COLUMN seo_campaign_targets.target_status IS 'Target status: pending, generated, review, published';
COMMENT ON COLUMN seo_campaign_targets.content_id IS 'Reference to generated content when available';
-- Create indexes for common queries
CREATE INDEX IF NOT EXISTS idx_seo_campaign_targets_campaign ON seo_campaign_targets(campaign_id);
CREATE INDEX IF NOT EXISTS idx_seo_campaign_targets_status ON seo_campaign_targets(target_status);
CREATE INDEX IF NOT EXISTS idx_seo_campaign_targets_domain ON seo_campaign_targets(domain);
CREATE INDEX IF NOT EXISTS idx_seo_campaign_targets_location ON seo_campaign_targets(location_id);
CREATE INDEX IF NOT EXISTS idx_seo_campaigns_status ON seo_campaigns(status);
-- Create trigger for updated_at
CREATE OR REPLACE FUNCTION update_seo_campaigns_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS seo_campaigns_updated_at ON seo_campaigns;
CREATE TRIGGER seo_campaigns_updated_at
BEFORE UPDATE ON seo_campaigns
FOR EACH ROW EXECUTE FUNCTION update_seo_campaigns_updated_at();
DROP TRIGGER IF EXISTS seo_campaign_targets_updated_at ON seo_campaign_targets;
CREATE TRIGGER seo_campaign_targets_updated_at
BEFORE UPDATE ON seo_campaign_targets
FOR EACH ROW EXECUTE FUNCTION update_seo_campaigns_updated_at();
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TRIGGER IF EXISTS seo_campaign_targets_updated_at ON seo_campaign_targets;
DROP TRIGGER IF EXISTS seo_campaigns_updated_at ON seo_campaigns;
DROP FUNCTION IF EXISTS update_seo_campaigns_updated_at();
DROP TABLE IF EXISTS seo_campaign_targets;
DROP TABLE IF EXISTS seo_campaigns;
`);
}
}

View file

@ -1,71 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Convert seo_campaigns.domains (jsonb array) to domain (single varchar).
*
* The pivot to single-domain campaigns allows:
* - Simpler campaign management (one domain per campaign)
* - Content comparison workflows between domains
* - Clearer brand differentiation tracking
*
* Migration strategy:
* 1. Add new domain column
* 2. Populate from first element of domains array
* 3. Drop old domains column
*
* To run manually in production:
* psql -U <user> -d <database> -f convert-campaigns-single-domain.sql
*/
export class ConvertCampaignsSingleDomain1737302400000 implements MigrationInterface {
name = 'ConvertCampaignsSingleDomain1737302400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
-- Step 1: Add new domain column (nullable initially)
ALTER TABLE seo_campaigns
ADD COLUMN IF NOT EXISTS domain varchar(255);
-- Step 2: Populate domain from first element of domains array
UPDATE seo_campaigns
SET domain = domains->>0
WHERE domains IS NOT NULL
AND jsonb_array_length(domains) > 0;
-- Step 3: Set default for any rows without domains
UPDATE seo_campaigns
SET domain = 'trustedmeet.com'
WHERE domain IS NULL;
-- Step 4: Make domain NOT NULL
ALTER TABLE seo_campaigns
ALTER COLUMN domain SET NOT NULL;
-- Step 5: Drop old domains column
ALTER TABLE seo_campaigns
DROP COLUMN IF EXISTS domains;
-- Step 6: Update comment
COMMENT ON COLUMN seo_campaigns.domain IS 'Single target domain for this campaign';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
-- Step 1: Add domains column back
ALTER TABLE seo_campaigns
ADD COLUMN IF NOT EXISTS domains jsonb DEFAULT '[]'::jsonb;
-- Step 2: Populate domains from domain (as single-element array)
UPDATE seo_campaigns
SET domains = jsonb_build_array(domain)
WHERE domain IS NOT NULL;
-- Step 3: Drop domain column
ALTER TABLE seo_campaigns
DROP COLUMN IF EXISTS domain;
-- Step 4: Update comment
COMMENT ON COLUMN seo_campaigns.domains IS 'Array of target domains (e.g., trustedmeet.com, atlilith.com)';
`);
}
}

View file

@ -1,80 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Add brand_config JSONB column to domain_configs table.
*
* This column stores brand identity configuration for SEO content differentiation:
* - voice: Brand voice preset (professional, luxury, casual, edgy)
* - tagline: Brand tagline for messaging
* - valuePropositions: Core messaging pillars
* - toneGuidelines: Writing style rules
* - avoidTerms: Terms to avoid in content
* - preferTerms: Preferred terms to use
*
* This enables different domains (e.g., trustedmeet.com vs atlilith.com)
* to generate unique, brand-specific content.
*/
export class AddBrandConfig1737388800000 implements MigrationInterface {
name = 'AddBrandConfig1737388800000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Add brand_config column
await queryRunner.query(`
ALTER TABLE domain_configs
ADD COLUMN IF NOT EXISTS brand_config jsonb;
COMMENT ON COLUMN domain_configs.brand_config IS
'Brand configuration for SEO content differentiation (voice, tagline, value propositions, tone guidelines, avoid/prefer terms)';
`);
// Seed initial brand configurations for known domains
await queryRunner.query(`
UPDATE domain_configs
SET brand_config = '{
"voice": "professional",
"tagline": "Trust Meets Discretion",
"valuePropositions": ["Verified providers", "Zero platform fees", "Complete privacy"],
"toneGuidelines": ["Professional but approachable", "Safety-focused", "Empowering"],
"avoidTerms": ["cheap", "easy"],
"preferTerms": ["verified", "professional", "discreet"]
}'::jsonb
WHERE domain = 'trustedmeet.com'
AND brand_config IS NULL;
`);
await queryRunner.query(`
UPDATE domain_configs
SET brand_config = '{
"voice": "luxury",
"tagline": "Luxury Companionship Redefined",
"valuePropositions": ["Elite providers", "Premium experience", "Absolute discretion"],
"toneGuidelines": ["Sophisticated", "Exclusive", "Premium quality"],
"avoidTerms": ["cheap", "budget", "average"],
"preferTerms": ["exclusive", "elite", "curated", "luxury"]
}'::jsonb
WHERE domain = 'atlilith.com'
AND brand_config IS NULL;
`);
// Default config for any other domains
await queryRunner.query(`
UPDATE domain_configs
SET brand_config = '{
"voice": "professional",
"tagline": "",
"valuePropositions": ["Verified providers", "Zero platform fees", "Privacy protection"],
"toneGuidelines": ["Professional", "Trustworthy", "Clear communication"],
"avoidTerms": [],
"preferTerms": []
}'::jsonb
WHERE brand_config IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE domain_configs
DROP COLUMN IF EXISTS brand_config;
`);
}
}

View file

@ -0,0 +1 @@
export { InitialSchema1700000000000 } from './1700000000000-InitialSchema';