2026-02-27 14:09:33 -05:00

309 lines
9.1 KiB
TypeScript

/**
* Data access layer for site audit tables.
* All D1 interactions for audits, audit_pages, and audit_psi_results.
*/
import { db } from "@/db";
import { audits, auditPages, auditPsiResults, projects } from "@/db/schema";
import { and, eq, desc } from "drizzle-orm";
import type { PsiResult, AuditConfig } from "@/server/lib/audit/types";
// ─── Create ──────────────────────────────────────────────────────────────────
async function createAudit(data: {
id: string;
projectId: string;
userId: string;
startUrl: string;
workflowInstanceId: string;
config: AuditConfig;
}) {
await db.insert(audits).values({
id: data.id,
projectId: data.projectId,
userId: data.userId,
startUrl: data.startUrl,
workflowInstanceId: data.workflowInstanceId,
config: JSON.stringify(data.config),
status: "running",
});
}
// ─── Update ──────────────────────────────────────────────────────────────────
async function updateAuditProgress(
auditId: string,
data: {
pagesCrawled?: number;
pagesTotal?: number;
psiTotal?: number;
psiCompleted?: number;
psiFailed?: number;
currentPhase?: string;
},
) {
await db.update(audits).set(data).where(eq(audits.id, auditId));
}
async function completeAudit(
auditId: string,
data: {
pagesCrawled: number;
pagesTotal: number;
},
) {
await db
.update(audits)
.set({
status: "completed",
completedAt: new Date().toISOString(),
currentPhase: "completed",
...data,
})
.where(eq(audits.id, auditId));
}
async function failAudit(auditId: string) {
await db
.update(audits)
.set({
status: "failed",
completedAt: new Date().toISOString(),
currentPhase: "failed",
})
.where(eq(audits.id, auditId));
}
// ─── Batch write results (finalize step) ─────────────────────────────────────
/**
* Use db.batch() to send individual INSERT statements in a single round-trip.
* D1's batch API supports up to 100 *statements* per call — each statement
* has its own bind params, so there's no per-statement param limit issue.
*/
async function batchWriteResults(
auditId: string,
pages: Array<{
id: string;
url: string;
statusCode: number;
redirectUrl: string | null;
title: string;
metaDescription: string;
canonicalUrl: string | null;
robotsMeta: string | null;
ogTitle: string | null;
ogDescription: string | null;
ogImage: string | null;
h1Count: number;
h2Count: number;
h3Count: number;
h4Count: number;
h5Count: number;
h6Count: number;
headingOrder: number[];
wordCount: number;
imagesTotal: number;
imagesMissingAlt: number;
images: Array<{ src: string | null; alt: string | null }>;
internalLinks: string[];
externalLinks: string[];
hasStructuredData: boolean;
hreflangTags: string[];
isIndexable: boolean;
responseTimeMs: number;
}>,
psiResults: PsiResult[],
) {
const BATCH_SIZE = 100; // D1 max statements per batch() call
// ── Pages ──────────────────────────────────────────────────────────
const pageStatements = pages.map((p) =>
db.insert(auditPages).values({
id: p.id,
auditId,
url: p.url,
statusCode: p.statusCode,
redirectUrl: p.redirectUrl,
// Metadata
title: p.title,
metaDescription: p.metaDescription,
canonicalUrl: p.canonicalUrl,
robotsMeta: p.robotsMeta,
// Open Graph
ogTitle: p.ogTitle,
ogDescription: p.ogDescription,
ogImage: p.ogImage,
// Headings
h1Count: p.h1Count,
h2Count: p.h2Count,
h3Count: p.h3Count,
h4Count: p.h4Count,
h5Count: p.h5Count,
h6Count: p.h6Count,
headingOrderJson: JSON.stringify(p.headingOrder),
// Content
wordCount: p.wordCount,
// Images
imagesTotal: p.imagesTotal,
imagesMissingAlt: p.imagesMissingAlt,
imagesJson: JSON.stringify(p.images),
// Links
internalLinkCount: p.internalLinks.length,
externalLinkCount: p.externalLinks.length,
// Structured data
hasStructuredData: p.hasStructuredData,
// Hreflang
hreflangTagsJson: JSON.stringify(p.hreflangTags),
// Indexability
isIndexable: p.isIndexable,
// Performance
responseTimeMs: p.responseTimeMs,
}),
);
for (let i = 0; i < pageStatements.length; i += BATCH_SIZE) {
const chunk = pageStatements.slice(i, i + BATCH_SIZE);
const [first, ...rest] = chunk;
await db.batch([first, ...rest]);
}
// ── PSI results ────────────────────────────────────────────────────
if (psiResults.length > 0) {
const psiStatements = psiResults.map((r) =>
db.insert(auditPsiResults).values({
id: crypto.randomUUID(),
auditId,
pageId: r.pageId,
strategy: r.strategy,
performanceScore: r.performanceScore,
accessibilityScore: r.accessibilityScore,
bestPracticesScore: r.bestPracticesScore,
seoScore: r.seoScore,
lcpMs: r.lcpMs,
cls: r.cls,
inpMs: r.inpMs,
ttfbMs: r.ttfbMs,
errorMessage: r.errorMessage ?? null,
r2Key: r.r2Key ?? null,
payloadSizeBytes: r.payloadSizeBytes ?? null,
}),
);
for (let i = 0; i < psiStatements.length; i += BATCH_SIZE) {
const chunk = psiStatements.slice(i, i + BATCH_SIZE);
const [first, ...rest] = chunk;
await db.batch([first, ...rest]);
}
}
}
// ─── Read ────────────────────────────────────────────────────────────────────
async function isProjectOwnedByUser(projectId: string, userId: string) {
const project = await db.query.projects.findFirst({
where: and(eq(projects.id, projectId), eq(projects.userId, userId)),
});
return Boolean(project);
}
async function getAuditForUser(auditId: string, userId: string) {
return db.query.audits.findFirst({
where: and(eq(audits.id, auditId), eq(audits.userId, userId)),
});
}
async function getAuditsByProjectForUser(projectId: string, userId: string) {
return db.query.audits.findMany({
where: and(eq(audits.projectId, projectId), eq(audits.userId, userId)),
orderBy: [desc(audits.startedAt)],
});
}
async function getAuditResultsForUser(auditId: string, userId: string) {
const audit = await getAuditForUser(auditId, userId);
if (!audit) {
return { audit: null, pages: [], psi: [] };
}
const [pages, psi] = await Promise.all([
db.query.auditPages.findMany({
where: eq(auditPages.auditId, auditId),
}),
db.query.auditPsiResults.findMany({
where: eq(auditPsiResults.auditId, auditId),
}),
]);
return { audit, pages, psi };
}
async function getPsiResultById(input: {
psiResultId: string;
projectId: string;
userId: string;
}) {
const project = await db.query.projects.findFirst({
where: and(
eq(projects.id, input.projectId),
eq(projects.userId, input.userId),
),
});
if (!project) {
throw new Error("Project not found");
}
const psi = await db.query.auditPsiResults.findFirst({
where: eq(auditPsiResults.id, input.psiResultId),
});
if (!psi) return null;
const parentAudit = await db.query.audits.findFirst({
where: and(
eq(audits.id, psi.auditId),
eq(audits.projectId, input.projectId),
eq(audits.userId, input.userId),
),
});
if (!parentAudit) {
throw new Error("Audit not found");
}
const page = await db.query.auditPages.findFirst({
where: eq(auditPages.id, psi.pageId),
});
return {
psi,
page,
audit: parentAudit,
};
}
// ─── Delete ──────────────────────────────────────────────────────────────────
async function deleteAuditForUser(auditId: string, userId: string) {
// Cascading deletes handle child tables
await db
.delete(audits)
.where(and(eq(audits.id, auditId), eq(audits.userId, userId)));
}
// ─── Export ──────────────────────────────────────────────────────────────────
export const AuditRepository = {
createAudit,
updateAuditProgress,
completeAudit,
failAudit,
batchWriteResults,
isProjectOwnedByUser,
getAuditForUser,
getAuditsByProjectForUser,
getAuditResultsForUser,
getPsiResultById,
deleteAuditForUser,
} as const;