metatron-open-seo/src/db/app.schema.ts
2026-06-04 18:06:16 -04:00

450 lines
15 KiB
TypeScript

import {
sqliteTable,
text,
integer,
real,
uniqueIndex,
index,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
import { organization, user } from "./better-auth-schema";
export const userOnboardingAnswers = sqliteTable(
"user_onboarding_answers",
{
userId: text("user_id")
.primaryKey()
.references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
interestedFeatures: text("interested_features").notNull().default("[]"),
workFor: text("work_for"),
clientWebsiteCount: text("client_website_count"),
foundVia: text("found_via"),
mcpSetupIntent: text("mcp_setup_intent"),
completedAt: text("completed_at"),
// Set when the user dismisses (or acts on) the one-time "connect Search
// Console" nudge shown to people who finished onboarding before the GSC
// step existed. Null = never shown/dismissed.
gscNudgeDismissedAt: text("gsc_nudge_dismissed_at"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
index("user_onboarding_answers_organization_idx").on(table.organizationId),
],
);
// Projects for keyword research
export const projects = sqliteTable(
"projects",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(),
domain: text("domain"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
// Only the auto-created Default/null-domain project is a singleton. This
// guards the get-or-create race that can happen when several requests enter
// a new organization at once, without forbidding users from manually
// creating multiple projects with the same name or domain later.
uniqueIndex("projects_one_default_per_organization_idx")
.on(table.organizationId)
.where(sql`${table.name} = 'Default' AND ${table.domain} IS NULL`),
],
);
// User-saved keywords within a project. This is the canonical saved list.
export const savedKeywords = sqliteTable(
"saved_keywords",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
keyword: text("keyword").notNull(),
locationCode: integer("location_code").notNull().default(2840),
languageCode: text("language_code").notNull().default("en"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("saved_keywords_unique_project_keyword_location_language").on(
table.projectId,
table.keyword,
table.locationCode,
table.languageCode,
),
index("saved_keywords_project_created_idx").on(
table.projectId,
table.createdAt,
),
],
);
export const savedKeywordTags = sqliteTable(
"saved_keyword_tags",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
normalizedName: text("normalized_name").notNull(),
// Palette key (e.g. "blue", "rose"). Null = derive a stable color from the
// tag id at render time. See src/shared/tag-colors.ts.
color: text("color"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("saved_keyword_tags_project_normalized_name_idx").on(
table.projectId,
table.normalizedName,
),
index("saved_keyword_tags_project_name_idx").on(
table.projectId,
table.name,
),
],
);
export const savedKeywordTagAssignments = sqliteTable(
"saved_keyword_tag_assignments",
{
savedKeywordId: text("saved_keyword_id")
.notNull()
.references(() => savedKeywords.id, { onDelete: "cascade" }),
tagId: text("tag_id")
.notNull()
.references(() => savedKeywordTags.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("saved_keyword_tag_assignments_unique_idx").on(
table.savedKeywordId,
table.tagId,
),
index("saved_keyword_tag_assignments_keyword_idx").on(table.savedKeywordId),
index("saved_keyword_tag_assignments_tag_idx").on(table.tagId),
],
);
// Latest cached metrics for a keyword within a project.
// This is joined onto savedKeywords when rendering the saved keyword list.
export const keywordMetrics = sqliteTable(
"keyword_metrics",
{
id: integer("id").primaryKey({ autoIncrement: true }),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
keyword: text("keyword").notNull(),
locationCode: integer("location_code").notNull(),
languageCode: text("language_code").notNull().default("en"),
searchVolume: integer("search_volume"),
cpc: real("cpc"),
competition: real("competition"),
keywordDifficulty: integer("keyword_difficulty"),
intent: text("intent"),
monthlySearches: text("monthly_searches"),
fetchedAt: text("fetched_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("keyword_metrics_unique_project_keyword_location_language").on(
table.projectId,
table.keyword,
table.locationCode,
table.languageCode,
),
index("keyword_metrics_lookup_idx").on(
table.projectId,
table.keyword,
table.locationCode,
table.languageCode,
table.fetchedAt,
),
],
);
// ============================================================================
// Rank Tracking tables
// ============================================================================
// One configuration per project+domain — defines what domain to track and how
export const rankTrackingConfigs = sqliteTable(
"rank_tracking_configs",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
domain: text("domain").notNull(),
locationCode: integer("location_code").notNull().default(2840),
languageCode: text("language_code").notNull().default("en"),
devices: text("devices", {
enum: ["both", "desktop", "mobile"],
})
.notNull()
.default("both"),
serpDepth: integer("serp_depth").notNull(),
scheduleInterval: text("schedule_interval", {
enum: ["daily", "weekly", "manual"],
})
.notNull()
.default("weekly"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastCheckedAt: text("last_checked_at"),
nextCheckAt: text("next_check_at"),
lastSkipReason: text("last_skip_reason"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("rank_tracking_configs_project_domain_location_idx").on(
table.projectId,
table.domain,
table.locationCode,
),
],
);
// Keywords tracked per domain config
export const rankTrackingKeywords = sqliteTable(
"rank_tracking_keywords",
{
id: text("id").primaryKey(),
configId: text("config_id")
.notNull()
.references(() => rankTrackingConfigs.id, { onDelete: "cascade" }),
keyword: text("keyword").notNull(),
searchVolume: integer("search_volume"),
keywordDifficulty: integer("keyword_difficulty"),
cpc: real("cpc"),
metricsFetchedAt: text("metrics_fetched_at"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("rank_tracking_keywords_config_keyword_idx").on(
table.configId,
table.keyword,
),
],
);
// One row per check execution (manual or scheduled).
// A partial unique index on `config_id WHERE status IN ('pending','running')`
// enforces at most one in-flight run per config at the DB level, which is how
// duplicate-trigger protection is implemented — INSERT of a second pending run
// for the same config fails with a unique-constraint violation.
export const rankCheckRuns = sqliteTable(
"rank_check_runs",
{
id: text("id").primaryKey(),
configId: text("config_id")
.notNull()
.references(() => rankTrackingConfigs.id, { onDelete: "cascade" }),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
status: text("status", {
enum: ["pending", "running", "completed", "failed"],
})
.notNull()
.default("pending"),
keywordsTotal: integer("keywords_total").notNull().default(0),
keywordsChecked: integer("keywords_checked").notNull().default(0),
isSubsetRun: integer("is_subset_run", { mode: "boolean" })
.notNull()
.default(false),
errorMessage: text("error_message"),
startedAt: text("started_at")
.notNull()
.default(sql`(current_timestamp)`),
completedAt: text("completed_at"),
},
(table) => [
index("rank_check_runs_config_idx").on(table.configId, table.startedAt),
index("rank_check_runs_project_idx").on(table.projectId, table.startedAt),
uniqueIndex("rank_check_runs_one_active_per_config_idx")
.on(table.configId)
.where(sql`${table.status} IN ('pending', 'running')`),
],
);
// One row per keyword per device per check run
export const rankSnapshots = sqliteTable(
"rank_snapshots",
{
id: integer("id").primaryKey({ autoIncrement: true }),
runId: text("run_id")
.notNull()
.references(() => rankCheckRuns.id, { onDelete: "cascade" }),
// No FK to rankTrackingKeywords — intentional. Historical snapshots are
// preserved after a keyword is removed from tracking so users can still
// see past position data for deleted keywords.
trackingKeywordId: text("tracking_keyword_id").notNull(),
keyword: text("keyword").notNull(),
device: text("device", { enum: ["desktop", "mobile"] }).notNull(),
position: integer("position"), // null = not found in top 20
url: text("url"),
serpFeatures: text("serp_features"), // JSON array of feature type strings
checkedAt: text("checked_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
index("rank_snapshots_run_idx").on(table.runId),
index("rank_snapshots_keyword_device_idx").on(
table.trackingKeywordId,
table.device,
table.checkedAt,
),
uniqueIndex("rank_snapshots_run_keyword_device_idx").on(
table.runId,
table.trackingKeywordId,
table.device,
),
],
);
// ============================================================================
// Site Audit tables
// ============================================================================
// One row per audit run
export const audits = sqliteTable(
"audits",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
startedByUserId: text("started_by_user_id").notNull(),
startUrl: text("start_url").notNull(),
status: text("status", {
enum: ["running", "completed", "failed"],
})
.notNull()
.default("running"),
workflowInstanceId: text("workflow_instance_id"),
// JSON config: { maxPages, lighthouseStrategy }
config: text("config").notNull().default("{}"),
// Progress & summary
pagesCrawled: integer("pages_crawled").notNull().default(0),
pagesTotal: integer("pages_total").notNull().default(0),
lighthouseTotal: integer("lighthouse_total").notNull().default(0),
lighthouseCompleted: integer("lighthouse_completed").notNull().default(0),
lighthouseFailed: integer("lighthouse_failed").notNull().default(0),
currentPhase: text("current_phase").default("discovery"),
startedAt: text("started_at")
.notNull()
.default(sql`(current_timestamp)`),
completedAt: text("completed_at"),
},
(table) => [
index("audits_project_id_idx").on(table.projectId),
index("audits_started_by_user_id_idx").on(table.startedByUserId),
],
);
// One row per crawled page
export const auditPages = sqliteTable(
"audit_pages",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
url: text("url").notNull(),
statusCode: integer("status_code"),
redirectUrl: text("redirect_url"),
// Metadata
title: text("title"),
metaDescription: text("meta_description"),
canonicalUrl: text("canonical_url"),
robotsMeta: text("robots_meta"),
// Open Graph
ogTitle: text("og_title"),
ogDescription: text("og_description"),
ogImage: text("og_image"),
// Headings
h1Count: integer("h1_count").notNull().default(0),
h2Count: integer("h2_count").notNull().default(0),
h3Count: integer("h3_count").notNull().default(0),
h4Count: integer("h4_count").notNull().default(0),
h5Count: integer("h5_count").notNull().default(0),
h6Count: integer("h6_count").notNull().default(0),
headingOrderJson: text("heading_order_json"),
// Content
wordCount: integer("word_count").notNull().default(0),
// Images
imagesTotal: integer("images_total").notNull().default(0),
imagesMissingAlt: integer("images_missing_alt").notNull().default(0),
imagesJson: text("images_json"),
// Links
internalLinkCount: integer("internal_link_count").notNull().default(0),
externalLinkCount: integer("external_link_count").notNull().default(0),
// Structured data
hasStructuredData: integer("has_structured_data", { mode: "boolean" })
.notNull()
.default(false),
// Hreflang
hreflangTagsJson: text("hreflang_tags_json"),
// Indexability
isIndexable: integer("is_indexable", { mode: "boolean" })
.notNull()
.default(true),
// Performance
responseTimeMs: integer("response_time_ms"),
},
(table) => [index("audit_pages_audit_id_idx").on(table.auditId)],
);
// One row per Lighthouse test (mobile + desktop per page).
export const auditLighthouseResults = sqliteTable(
"audit_lighthouse_results",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
pageId: text("page_id")
.notNull()
.references(() => auditPages.id, { onDelete: "cascade" }),
strategy: text("strategy", { enum: ["mobile", "desktop"] }).notNull(),
performanceScore: integer("performance_score"),
accessibilityScore: integer("accessibility_score"),
bestPracticesScore: integer("best_practices_score"),
seoScore: integer("seo_score"),
lcpMs: real("lcp_ms"),
cls: real("cls"),
inpMs: real("inp_ms"),
ttfbMs: real("ttfb_ms"),
errorMessage: text("error_message"),
r2Key: text("r2_key"),
payloadSizeBytes: integer("payload_size_bytes"),
},
(table) => [index("audit_lighthouse_results_audit_id_idx").on(table.auditId)],
);