346 lines
13 KiB
TypeScript
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,
|
|
),
|
|
],
|
|
);
|