metatron-open-seo/src/db/pg/app.schema.ts
2026-07-10 15:45:02 -04:00

346 lines
13 KiB
TypeScript

import { sql } from "drizzle-orm";
import {
boolean,
index,
integer,
pgTable,
real,
serial,
text,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { organization, user } from "./better-auth-schema";
// Timestamps are stored as *text* (same column shape as the SQLite schema).
// Postgres `timestamptz` would be parsed back into a JS Date by postgres-js
// (even with drizzle `mode:"string"`), silently breaking the lexicographic
// string comparisons the app does on timestamps. `isoNow` matches the format of
// `new Date().toISOString()`, so within a Postgres deployment DB-defaulted and
// app-written values sort together.
//
// NOTE: this is NOT byte-for-byte equal to the SQLite default, which uses
// `current_timestamp` (`YYYY-MM-DD HH:MM:SS`, space-separated, no millis/Z).
// Each backend is internally consistent, but a one-time D1→Postgres data
// migration MUST rewrite legacy timestamp text into this ISO format (tracked as
// the deferred timestamp backfill).
const isoNow = sql`to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
const timestampColumn = (name: string) => text(name);
export const userOnboardingAnswers = pgTable(
"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: timestampColumn("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: timestampColumn("gsc_nudge_dismissed_at"),
createdAt: timestampColumn("created_at").notNull().default(isoNow),
updatedAt: timestampColumn("updated_at").notNull().default(isoNow),
},
(table) => [
index("user_onboarding_answers_organization_idx").on(table.organizationId),
],
);
// Projects for keyword research
export const projects = pgTable(
"projects",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(),
domain: text("domain"),
// Default DataForSEO location/language for the project, set during
// onboarding and reused by every project-scoped data call.
locationCode: integer("location_code").notNull().default(2840),
languageCode: text("language_code").notNull().default("en"),
createdAt: timestampColumn("created_at").notNull().default(isoNow),
// Soft delete: archived projects are hidden everywhere but their data
// (keywords, rank tracking, audits) is preserved.
archivedAt: timestampColumn("archived_at"),
},
(table) => [
// Only the auto-created Default/null-domain project is a singleton. This
// guards the get-or-create race when several requests enter a new
// organization at once (mirrors the SQLite schema). `tryCreateDefaultProject`
// relies on this partial unique index for its onConflictDoNothing().
uniqueIndex("projects_one_default_per_organization_idx")
.on(table.organizationId)
.where(
sql`${table.name} = 'Default' AND ${table.domain} IS NULL AND ${table.archivedAt} IS NULL`,
),
// Every project listing filters by organization; the partial-unique index
// above only covers the Default-project row, so without this the org-scoped
// list queries seq-scan. Per-org row counts are small, so the archived/
// created_at ordering sorts cheaply on top of this single-column lookup.
index("projects_organization_id_idx").on(table.organizationId),
],
);
// User-saved keywords within a project. This is the canonical saved list.
export const savedKeywords = pgTable(
"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: timestampColumn("created_at").notNull().default(isoNow),
},
(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 = pgTable(
"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: timestampColumn("created_at").notNull().default(isoNow),
},
(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 = pgTable(
"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: timestampColumn("created_at").notNull().default(isoNow),
},
(table) => [
uniqueIndex("saved_keyword_tag_assignments_unique_idx").on(
table.savedKeywordId,
table.tagId,
),
// No standalone index on savedKeywordId — the unique index above has it as
// its leftmost column, so it already serves savedKeywordId lookups.
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 = pgTable(
"keyword_metrics",
{
id: serial("id").primaryKey(),
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: timestampColumn("fetched_at").notNull().default(isoNow),
},
(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 = pgTable(
"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", "monthly", "manual"],
})
.notNull()
.default("weekly"),
locationName: text("location_name"),
isActive: boolean("is_active").notNull().default(true),
lastCheckedAt: timestampColumn("last_checked_at"),
nextCheckAt: timestampColumn("next_check_at"),
lastSkipReason: text("last_skip_reason"),
createdAt: timestampColumn("created_at").notNull().default(isoNow),
},
(table) => [
index("rank_tracking_configs_project_active_created_idx").on(
table.projectId,
table.isActive,
table.createdAt,
),
uniqueIndex("rank_tracking_configs_national_idx")
.on(table.projectId, table.domain, table.locationCode)
.where(sql`${table.locationName} IS NULL`),
uniqueIndex("rank_tracking_configs_local_idx")
.on(table.projectId, table.domain, table.locationCode, table.locationName)
.where(sql`${table.locationName} IS NOT NULL`),
],
);
// Keywords tracked per domain config
export const rankTrackingKeywords = pgTable(
"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: timestampColumn("metrics_fetched_at"),
createdAt: timestampColumn("created_at").notNull().default(isoNow),
},
(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 = pgTable(
"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: boolean("is_subset_run").notNull().default(false),
errorMessage: text("error_message"),
startedAt: timestampColumn("started_at").notNull().default(isoNow),
completedAt: timestampColumn("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 = pgTable(
"rank_snapshots",
{
id: serial("id").primaryKey(),
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: timestampColumn("checked_at").notNull().default(isoNow),
},
(table) => [
// No standalone index on runId — the unique index below has it as its
// leftmost column, so it already serves runId lookups.
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,
),
],
);