This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
-- ============================================================
|
||||
-- SAFE MIGRATION SCRIPT — meSEO schema updates
|
||||
-- ============================================================
|
||||
-- Generated: 2026-04-20
|
||||
-- Purpose: Apply pending schema changes WITHOUT dropping tables
|
||||
-- or columns that contain data.
|
||||
--
|
||||
-- HOW TO RUN:
|
||||
-- 1. Open Neon dashboard → SQL Editor (or psql CLI)
|
||||
-- 2. Paste this entire script
|
||||
-- 3. Execute
|
||||
-- 4. After success, run: npx prisma db pull
|
||||
-- to verify schema and database are in sync
|
||||
--
|
||||
-- WHAT THIS DOES:
|
||||
-- - Adds soft-delete columns to Brand (deactivatedAt, deactivatedBy, deactivationReason)
|
||||
-- - Adds SiteEvent index on (eventType, timestamp) for admin queries
|
||||
-- - Does NOT drop any tables or columns
|
||||
--
|
||||
-- WHAT THIS DOES NOT DO:
|
||||
-- - Does NOT touch VisitorProfile, HouseholdCluster, AdClickEvent, AttributionPath
|
||||
-- - Does NOT touch TrackedSession columns (channelGroup, fbclid, gclid, etc.)
|
||||
-- - Does NOT drop any indexes
|
||||
-- ============================================================
|
||||
|
||||
-- 1. Brand soft-delete columns (all nullable, no data loss)
|
||||
ALTER TABLE "Brand" ADD COLUMN IF NOT EXISTS "deactivatedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "Brand" ADD COLUMN IF NOT EXISTS "deactivatedBy" TEXT;
|
||||
ALTER TABLE "Brand" ADD COLUMN IF NOT EXISTS "deactivationReason" TEXT;
|
||||
|
||||
-- 2. AI Attribution tables (LLMO/GEO/AEO tracking)
|
||||
CREATE TABLE IF NOT EXISTS "AiAttribution" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
"brandId" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"channel" TEXT NOT NULL,
|
||||
"aiPlatform" TEXT,
|
||||
"referrerUrl" TEXT,
|
||||
"queryContext" TEXT,
|
||||
"landingPage" TEXT NOT NULL,
|
||||
"pagesViewed" INTEGER NOT NULL DEFAULT 1,
|
||||
"sessionDuration" INTEGER,
|
||||
"maxScrollDepth" DOUBLE PRECISION,
|
||||
"engagementScore" DOUBLE PRECISION,
|
||||
"converted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"conversionType" TEXT,
|
||||
"conversionPage" TEXT,
|
||||
"conversionId" TEXT,
|
||||
"conversionTimestamp" TIMESTAMP(3),
|
||||
"leadType" TEXT,
|
||||
"dealValue" DOUBLE PRECISION,
|
||||
"dealStatus" TEXT,
|
||||
"outcomeUpdatedAt" TIMESTAMP(3),
|
||||
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "AiAttribution_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "AiAttribution_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "AiAttribution_brandId_timestamp_idx" ON "AiAttribution"("brandId", "timestamp");
|
||||
CREATE INDEX IF NOT EXISTS "AiAttribution_brandId_channel_idx" ON "AiAttribution"("brandId", "channel");
|
||||
CREATE INDEX IF NOT EXISTS "AiAttribution_brandId_aiPlatform_idx" ON "AiAttribution"("brandId", "aiPlatform");
|
||||
CREATE INDEX IF NOT EXISTS "AiAttribution_brandId_converted_idx" ON "AiAttribution"("brandId", "converted");
|
||||
CREATE INDEX IF NOT EXISTS "AiAttribution_sessionId_idx" ON "AiAttribution"("sessionId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AiCitation" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
"brandId" TEXT NOT NULL,
|
||||
"keyword" TEXT NOT NULL,
|
||||
"searchEngine" TEXT NOT NULL DEFAULT 'google',
|
||||
"citationType" TEXT NOT NULL,
|
||||
"channel" TEXT NOT NULL,
|
||||
"brandCited" BOOLEAN NOT NULL DEFAULT false,
|
||||
"citedUrl" TEXT,
|
||||
"citedPosition" INTEGER,
|
||||
"citedSnippet" TEXT,
|
||||
"competitorsCited" JSONB,
|
||||
"checkedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "AiCitation_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "AiCitation_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "AiCitation_brandId_keyword_idx" ON "AiCitation"("brandId", "keyword");
|
||||
CREATE INDEX IF NOT EXISTS "AiCitation_brandId_checkedAt_idx" ON "AiCitation"("brandId", "checkedAt");
|
||||
CREATE INDEX IF NOT EXISTS "AiCitation_brandId_channel_idx" ON "AiCitation"("brandId", "channel");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "AiCitation_brandId_keyword_searchEngine_checkedAt_key"
|
||||
ON "AiCitation"("brandId", "keyword", "searchEngine", "checkedAt");
|
||||
|
||||
-- 3. SiteEvent index for cross-brand admin queries
|
||||
-- The existing index [brandId, eventType, timestamp] can't be used
|
||||
-- for admin queries that don't filter by brandId. This new index
|
||||
-- covers the admin signal overview query pattern.
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "SiteEvent_eventType_timestamp_idx"
|
||||
ON "SiteEvent" ("eventType", "timestamp");
|
||||
|
||||
-- 4. AI Recommendations table (Phase 4 — Strategy Engine)
|
||||
CREATE TABLE IF NOT EXISTS "AiRecommendation" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
"brandId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"priority" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"title" TEXT NOT NULL,
|
||||
"insight" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"impact" TEXT,
|
||||
"dataPoints" JSONB NOT NULL DEFAULT '{}',
|
||||
"relatedPages" JSONB,
|
||||
"relatedKeywords" JSONB,
|
||||
"contentBriefId" TEXT,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"dismissedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "AiRecommendation_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "AiRecommendation_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "AiRecommendation_brandId_status_idx" ON "AiRecommendation"("brandId", "status");
|
||||
CREATE INDEX IF NOT EXISTS "AiRecommendation_brandId_type_idx" ON "AiRecommendation"("brandId", "type");
|
||||
CREATE INDEX IF NOT EXISTS "AiRecommendation_brandId_priority_createdAt_idx" ON "AiRecommendation"("brandId", "priority", "createdAt");
|
||||
|
||||
-- ============================================================
|
||||
-- 5. CronLog table (admin cron status dashboard)
|
||||
CREATE TABLE IF NOT EXISTS "CronLog" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
"route" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"duration" INTEGER NOT NULL,
|
||||
"details" TEXT,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "CronLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "CronLog_route_startedAt_idx" ON "CronLog"("route", "startedAt");
|
||||
|
||||
-- 6. ErrorLog table (admin error viewer)
|
||||
CREATE TABLE IF NOT EXISTS "ErrorLog" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
"route" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"status" INTEGER NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"stack" TEXT,
|
||||
"brandId" TEXT,
|
||||
"userId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ErrorLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "ErrorLog_createdAt_idx" ON "ErrorLog"("createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "ErrorLog_route_idx" ON "ErrorLog"("route");
|
||||
|
||||
-- 7. SiteConversion.isDuplicateOf column (dedup architecture)
|
||||
ALTER TABLE "SiteConversion" ADD COLUMN IF NOT EXISTS "isDuplicateOf" TEXT;
|
||||
CREATE INDEX IF NOT EXISTS "SiteConversion_brandId_isDuplicateOf_idx" ON "SiteConversion"("brandId", "isDuplicateOf");
|
||||
ALTER TABLE "SiteConversion" ADD COLUMN IF NOT EXISTS "invalidatedReason" TEXT;
|
||||
CREATE INDEX IF NOT EXISTS "SiteConversion_brandId_invalidatedReason_idx" ON "SiteConversion"("brandId", "invalidatedReason");
|
||||
|
||||
-- 8b. SiteConversion channel classification columns
|
||||
ALTER TABLE "SiteConversion" ADD COLUMN IF NOT EXISTS "classifiedChannel" TEXT;
|
||||
ALTER TABLE "SiteConversion" ADD COLUMN IF NOT EXISTS "classificationReason" TEXT;
|
||||
ALTER TABLE "SiteConversion" ADD COLUMN IF NOT EXISTS "classificationEvidence" JSONB;
|
||||
CREATE INDEX IF NOT EXISTS "SiteConversion_brandId_classifiedChannel_idx" ON "SiteConversion"("brandId", "classifiedChannel");
|
||||
|
||||
-- 8. Brand verification columns
|
||||
ALTER TABLE "Brand" ADD COLUMN IF NOT EXISTS "verifiedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "Brand" ADD COLUMN IF NOT EXISTS "lastVerificationScore" DOUBLE PRECISION;
|
||||
ALTER TABLE "Brand" ADD COLUMN IF NOT EXISTS "lastVerificationIssues" JSONB;
|
||||
|
||||
-- 9. DashboardSnapshot table (precomputed dashboard KPI payloads)
|
||||
CREATE TABLE IF NOT EXISTS "DashboardSnapshot" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
"brandId" TEXT NOT NULL,
|
||||
"range" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL DEFAULT '{}',
|
||||
"computedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "DashboardSnapshot_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "DashboardSnapshot_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "DashboardSnapshot_brandId_range_key" ON "DashboardSnapshot"("brandId", "range");
|
||||
CREATE INDEX IF NOT EXISTS "DashboardSnapshot_brandId_range_idx" ON "DashboardSnapshot"("brandId", "range");
|
||||
|
||||
-- VERIFICATION QUERIES — run these after the migration
|
||||
-- ============================================================
|
||||
|
||||
-- Verify Brand columns were added:
|
||||
-- SELECT column_name, data_type, is_nullable
|
||||
-- FROM information_schema.columns
|
||||
-- WHERE table_name = 'Brand' AND column_name IN ('deactivatedAt', 'deactivatedBy', 'deactivationReason');
|
||||
-- Expected: 3 rows, all nullable
|
||||
|
||||
-- Verify SiteEvent index was created:
|
||||
-- SELECT indexname FROM pg_indexes WHERE tablename = 'SiteEvent' AND indexname = 'SiteEvent_eventType_timestamp_idx';
|
||||
-- Expected: 1 row
|
||||
|
||||
-- Verify AiRecommendation table was created:
|
||||
-- SELECT table_name FROM information_schema.tables WHERE table_name = 'AiRecommendation';
|
||||
-- Expected: 1 row
|
||||
|
||||
-- ── MetricSnapshot: add updatedAt for freshness tracking ──
|
||||
ALTER TABLE "MetricSnapshot" ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW();
|
||||
|
||||
-- Verify NO tables were dropped:
|
||||
-- SELECT table_name FROM information_schema.tables
|
||||
-- WHERE table_schema = 'public'
|
||||
-- AND table_name IN ('VisitorProfile', 'HouseholdCluster', 'AdClickEvent', 'AttributionPath');
|
||||
-- Expected: 4 rows (all tables still exist)
|
||||
|
||||
-- Verify TrackedSession columns still exist:
|
||||
-- SELECT column_name FROM information_schema.columns
|
||||
-- WHERE table_name = 'TrackedSession'
|
||||
-- AND column_name IN ('channelGroup', 'fbclid', 'gclid', 'gbraid', 'wbraid', 'utmContent', 'utmTerm');
|
||||
-- Expected: 7 rows (all columns still exist)
|
||||
Reference in New Issue
Block a user