metatron-open-seo/src/db/audit.schema.ts

204 lines
7.2 KiB
TypeScript

import {
sqliteTable,
text,
integer,
real,
index,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
import { projects } from "./app.schema";
// ============================================================================
// 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),
// Indexability/canonical signals from response headers
xRobotsTag: text("x_robots_tag"),
headerCanonicalUrl: text("header_canonical_url"),
// Crawl metadata
// null depth = not reached via links (e.g. sitemap-seeded)
crawlDepth: integer("crawl_depth"),
inSitemap: integer("in_sitemap", { mode: "boolean" })
.notNull()
.default(false),
// SHA-256 of the visible body text, for duplicate-content grouping
contentHash: text("content_hash"),
// How the fetch resolved: ok | blocked (WAF/bot challenge) | error
fetchClass: text("fetch_class", { enum: ["ok", "blocked", "error"] })
.notNull()
.default("ok"),
// Performance
responseTimeMs: integer("response_time_ms"),
},
(table) => [index("audit_pages_audit_url_idx").on(table.auditId, table.url)],
);
// One row per unique (source page, target URL) link edge. Currently only
// internal edges are stored (see AuditRepository); isInternal stays so
// external-link checks can start writing rows without a migration.
export const auditLinks = sqliteTable(
"audit_links",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
sourcePageId: text("source_page_id")
.notNull()
.references(() => auditPages.id, { onDelete: "cascade" }),
sourceUrl: text("source_url").notNull(),
targetUrl: text("target_url").notNull(),
anchor: text("anchor"),
isInternal: integer("is_internal", { mode: "boolean" })
.notNull()
.default(true),
isNofollow: integer("is_nofollow", { mode: "boolean" })
.notNull()
.default(false),
},
(table) => [
index("audit_links_audit_target_idx").on(table.auditId, table.targetUrl),
// Cascade path from audit_pages deletes; without this every page delete
// seq-scans the (large) links table.
index("audit_links_source_page_id_idx").on(table.sourcePageId),
],
);
// One row per (issue type, affected page)
export const auditIssues = sqliteTable(
"audit_issues",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
pageId: text("page_id").references(() => auditPages.id, {
onDelete: "cascade",
}),
pageUrl: text("page_url").notNull(),
issueType: text("issue_type").notNull(),
severity: text("severity", { enum: ["critical", "warning", "info"] })
.notNull()
.default("info"),
// JSON details specific to the issue type (e.g. broken link target)
detailsJson: text("details_json"),
},
(table) => [
index("audit_issues_audit_type_idx").on(table.auditId, table.issueType),
index("audit_issues_page_id_idx").on(table.pageId),
],
);
// 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),
index("audit_lighthouse_results_page_id_idx").on(table.pageId),
],
);