Initial project commit for Coolify staging
CI / Quality gate (push) Has been cancelled

This commit is contained in:
Andres Resiri
2026-08-03 10:45:41 -04:00
commit 70daeb214a
1199 changed files with 243503 additions and 0 deletions
+209
View File
@@ -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)