diff --git a/features/seo/backend-api/src/database/migrations/1700000000000-InitialSchema.ts b/features/seo/backend-api/src/database/migrations/1700000000000-InitialSchema.ts new file mode 100644 index 000000000..d103eca53 --- /dev/null +++ b/features/seo/backend-api/src/database/migrations/1700000000000-InitialSchema.ts @@ -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 { + // ── 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 { + 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`); + } +} diff --git a/features/seo/backend-api/src/database/migrations/1735837200000-add-generation-metadata.ts b/features/seo/backend-api/src/database/migrations/1735837200000-add-generation-metadata.ts deleted file mode 100755 index 5aa350f4b..000000000 --- a/features/seo/backend-api/src/database/migrations/1735837200000-add-generation-metadata.ts +++ /dev/null @@ -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 -d -f add-generation-metadata.sql - */ -export class AddGenerationMetadata1735837200000 implements MigrationInterface { - name = 'AddGenerationMetadata1735837200000'; - - public async up(queryRunner: QueryRunner): Promise { - 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 { - await queryRunner.query(` - ALTER TABLE seo_content - DROP COLUMN IF EXISTS generation_metadata; - `); - } -} diff --git a/features/seo/backend-api/src/database/migrations/1737216000000-add-campaigns.ts b/features/seo/backend-api/src/database/migrations/1737216000000-add-campaigns.ts deleted file mode 100644 index b0288d4ad..000000000 --- a/features/seo/backend-api/src/database/migrations/1737216000000-add-campaigns.ts +++ /dev/null @@ -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 -d -f add-campaigns.sql - */ -export class AddCampaigns1737216000000 implements MigrationInterface { - name = 'AddCampaigns1737216000000'; - - public async up(queryRunner: QueryRunner): Promise { - 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 { - 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; - `); - } -} diff --git a/features/seo/backend-api/src/database/migrations/1737302400000-convert-campaigns-single-domain.ts b/features/seo/backend-api/src/database/migrations/1737302400000-convert-campaigns-single-domain.ts deleted file mode 100644 index 94cb02369..000000000 --- a/features/seo/backend-api/src/database/migrations/1737302400000-convert-campaigns-single-domain.ts +++ /dev/null @@ -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 -d -f convert-campaigns-single-domain.sql - */ -export class ConvertCampaignsSingleDomain1737302400000 implements MigrationInterface { - name = 'ConvertCampaignsSingleDomain1737302400000'; - - public async up(queryRunner: QueryRunner): Promise { - 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 { - 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)'; - `); - } -} diff --git a/features/seo/backend-api/src/database/migrations/1737388800000-add-brand-config.ts b/features/seo/backend-api/src/database/migrations/1737388800000-add-brand-config.ts deleted file mode 100644 index f0f907e78..000000000 --- a/features/seo/backend-api/src/database/migrations/1737388800000-add-brand-config.ts +++ /dev/null @@ -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 { - // 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 { - await queryRunner.query(` - ALTER TABLE domain_configs - DROP COLUMN IF EXISTS brand_config; - `); - } -} diff --git a/features/seo/backend-api/src/database/migrations/index.ts b/features/seo/backend-api/src/database/migrations/index.ts new file mode 100644 index 000000000..ee1b12480 --- /dev/null +++ b/features/seo/backend-api/src/database/migrations/index.ts @@ -0,0 +1 @@ +export { InitialSchema1700000000000 } from './1700000000000-InitialSchema';