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), ], ); // 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), ], ); // 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)], );