309 lines
9.1 KiB
TypeScript
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;
|