406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
import { createServerFn } from "@tanstack/react-start";
|
|
import { ensureUserMiddleware } from "@/middleware/ensureUser";
|
|
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
|
|
import {
|
|
psiAuditSchema,
|
|
psiAuditListSchema,
|
|
psiAuditDetailsSchema,
|
|
psiIssueFilterSchema,
|
|
psiExportSchema,
|
|
psiUnifiedIssueSchema,
|
|
psiUnifiedExportSchema,
|
|
psiProjectKeySchema,
|
|
psiProjectSchema,
|
|
} from "@/types/schemas/psi";
|
|
import { PsiService } from "@/server/services/PsiService";
|
|
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository";
|
|
import { PsiAuditRepository } from "@/server/repositories/PsiAuditRepository";
|
|
import { AuditRepository } from "@/server/repositories/AuditRepository";
|
|
import { getJsonFromR2, putJsonToR2 } from "@/server/lib/r2";
|
|
import { PsiIssuesService } from "@/server/services/PsiIssuesService";
|
|
|
|
async function resolvePsiSource(input: {
|
|
projectId: string;
|
|
userId: string;
|
|
source: "single" | "site";
|
|
resultId: string;
|
|
}) {
|
|
if (input.source === "single") {
|
|
const row = await PsiAuditRepository.getAuditResult({
|
|
auditId: input.resultId,
|
|
projectId: input.projectId,
|
|
userId: input.userId,
|
|
});
|
|
|
|
if (!row) {
|
|
throw new Error("Audit not found");
|
|
}
|
|
|
|
return {
|
|
id: row.id,
|
|
strategy: row.strategy,
|
|
finalUrl: row.finalUrl,
|
|
createdAt: row.createdAt,
|
|
r2Key: row.r2Key,
|
|
};
|
|
}
|
|
|
|
const site = await AuditRepository.getPsiResultById({
|
|
psiResultId: input.resultId,
|
|
projectId: input.projectId,
|
|
userId: input.userId,
|
|
});
|
|
|
|
if (!site) {
|
|
throw new Error("Audit not found");
|
|
}
|
|
|
|
return {
|
|
id: site.psi.id,
|
|
strategy: site.psi.strategy,
|
|
finalUrl: site.page?.url ?? "",
|
|
createdAt: site.audit.startedAt,
|
|
r2Key: site.psi.r2Key,
|
|
};
|
|
}
|
|
|
|
export const runPsiAudit = createServerFn({ method: "POST" })
|
|
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
|
.inputValidator((data: unknown) => psiAuditSchema.parse(data))
|
|
.handler(async ({ data, context }) => {
|
|
const apiKey = await KeywordResearchRepository.getProjectPsiApiKey(
|
|
data.projectId,
|
|
context.userId,
|
|
);
|
|
|
|
if (!apiKey) {
|
|
throw new Error(
|
|
"PSI API key is not set for this project. Save a key first.",
|
|
);
|
|
}
|
|
|
|
const auditId = crypto.randomUUID();
|
|
try {
|
|
const result = await PsiService.runAudit({
|
|
url: data.url,
|
|
strategy: data.strategy,
|
|
apiKey,
|
|
});
|
|
|
|
const now = new Date();
|
|
const datePrefix = now.toISOString().slice(0, 10);
|
|
const key = `psi/${data.projectId}/${datePrefix}/${auditId}.json`;
|
|
const uploaded = await putJsonToR2(key, result.rawPayload);
|
|
|
|
await PsiAuditRepository.createAuditResult({
|
|
id: auditId,
|
|
projectId: data.projectId,
|
|
requestedUrl: result.requestedUrl,
|
|
finalUrl: result.finalUrl,
|
|
strategy: result.strategy,
|
|
status: "completed",
|
|
performanceScore: result.scores.performance,
|
|
accessibilityScore: result.scores.accessibility,
|
|
bestPracticesScore: result.scores["best-practices"],
|
|
seoScore: result.scores.seo,
|
|
firstContentfulPaint: result.metrics.firstContentfulPaint.displayValue,
|
|
largestContentfulPaint:
|
|
result.metrics.largestContentfulPaint.displayValue,
|
|
totalBlockingTime: result.metrics.totalBlockingTime.displayValue,
|
|
cumulativeLayoutShift:
|
|
result.metrics.cumulativeLayoutShift.displayValue,
|
|
speedIndex: result.metrics.speedIndex.displayValue,
|
|
timeToInteractive: result.metrics.timeToInteractive.displayValue,
|
|
lighthouseVersion: result.lighthouseVersion,
|
|
r2Key: uploaded.key,
|
|
payloadSizeBytes: uploaded.sizeBytes,
|
|
});
|
|
|
|
return {
|
|
auditId,
|
|
requestedUrl: result.requestedUrl,
|
|
finalUrl: result.finalUrl,
|
|
strategy: result.strategy,
|
|
fetchedAt: result.fetchedAt,
|
|
lighthouseVersion: result.lighthouseVersion,
|
|
scores: result.scores,
|
|
metrics: result.metrics,
|
|
};
|
|
} catch (error) {
|
|
const requestedUrl = data.url.trim();
|
|
const message =
|
|
error instanceof Error ? error.message : "PSI request failed";
|
|
|
|
await PsiAuditRepository.createAuditResult({
|
|
id: auditId,
|
|
projectId: data.projectId,
|
|
requestedUrl,
|
|
finalUrl: requestedUrl,
|
|
strategy: data.strategy,
|
|
status: "failed",
|
|
errorMessage: message,
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
export const getProjectPsiApiKey = createServerFn({ method: "POST" })
|
|
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
|
.inputValidator((data: unknown) => psiProjectSchema.parse(data))
|
|
.handler(async ({ data, context }) => {
|
|
// This PSI key is intentionally treated as low-sensitivity operational config
|
|
// (Google abuse-control), not a direct billing secret.
|
|
const apiKey = await KeywordResearchRepository.getProjectPsiApiKey(
|
|
data.projectId,
|
|
context.userId,
|
|
);
|
|
return { apiKey };
|
|
});
|
|
|
|
export const saveProjectPsiApiKey = createServerFn({ method: "POST" })
|
|
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
|
.inputValidator((data: unknown) => psiProjectKeySchema.parse(data))
|
|
.handler(async ({ data, context }) => {
|
|
// Same tradeoff: persisted for convenience across PSI + Site Audit flows.
|
|
await KeywordResearchRepository.setProjectPsiApiKey(
|
|
data.projectId,
|
|
context.userId,
|
|
data.apiKey.trim(),
|
|
);
|
|
return { success: true };
|
|
});
|
|
|
|
export const clearProjectPsiApiKey = createServerFn({ method: "POST" })
|
|
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
|
.inputValidator((data: unknown) => psiProjectSchema.parse(data))
|
|
.handler(async ({ data, context }) => {
|
|
await KeywordResearchRepository.clearProjectPsiApiKey(
|
|
data.projectId,
|
|
context.userId,
|
|
);
|
|
return { success: true };
|
|
});
|
|
|
|
export const listProjectPsiAudits = createServerFn({ method: "POST" })
|
|
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
|
.inputValidator((data: unknown) => psiAuditListSchema.parse(data))
|
|
.handler(async ({ data, context }) => {
|
|
const rows = await PsiAuditRepository.listAuditResults({
|
|
projectId: data.projectId,
|
|
userId: context.userId,
|
|
strategy: data.strategy,
|
|
limit: data.limit,
|
|
});
|
|
|
|
return {
|
|
rows: rows.map((row) => ({
|
|
id: row.id,
|
|
requestedUrl: row.requestedUrl,
|
|
finalUrl: row.finalUrl,
|
|
strategy: row.strategy,
|
|
status: row.status,
|
|
performanceScore: row.performanceScore,
|
|
accessibilityScore: row.accessibilityScore,
|
|
bestPracticesScore: row.bestPracticesScore,
|
|
seoScore: row.seoScore,
|
|
firstContentfulPaint: row.firstContentfulPaint,
|
|
largestContentfulPaint: row.largestContentfulPaint,
|
|
totalBlockingTime: row.totalBlockingTime,
|
|
cumulativeLayoutShift: row.cumulativeLayoutShift,
|
|
speedIndex: row.speedIndex,
|
|
timeToInteractive: row.timeToInteractive,
|
|
lighthouseVersion: row.lighthouseVersion,
|
|
errorMessage: row.errorMessage,
|
|
payloadSizeBytes: row.payloadSizeBytes,
|
|
createdAt: row.createdAt,
|
|
})),
|
|
};
|
|
});
|
|
|
|
export const getProjectPsiAuditRaw = createServerFn({ method: "POST" })
|
|
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
|
.inputValidator((data: unknown) => psiAuditDetailsSchema.parse(data))
|
|
.handler(async ({ data, context }) => {
|
|
const row = await PsiAuditRepository.getAuditResult({
|
|
auditId: data.auditId,
|
|
projectId: data.projectId,
|
|
userId: context.userId,
|
|
});
|
|
|
|
if (!row) {
|
|
throw new Error("Audit not found");
|
|
}
|
|
|
|
if (!row.r2Key) {
|
|
throw new Error("Audit payload not available");
|
|
}
|
|
|
|
const payloadJson = await getJsonFromR2(row.r2Key);
|
|
return {
|
|
id: row.id,
|
|
strategy: row.strategy,
|
|
finalUrl: row.finalUrl,
|
|
createdAt: row.createdAt,
|
|
payloadJson,
|
|
};
|
|
});
|
|
|
|
export const getProjectPsiAuditIssues = createServerFn({ method: "POST" })
|
|
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
|
.inputValidator((data: unknown) => psiIssueFilterSchema.parse(data))
|
|
.handler(async ({ data, context }) => {
|
|
const row = await PsiAuditRepository.getAuditResult({
|
|
auditId: data.auditId,
|
|
projectId: data.projectId,
|
|
userId: context.userId,
|
|
});
|
|
|
|
if (!row) {
|
|
throw new Error("Audit not found");
|
|
}
|
|
|
|
if (!row.r2Key) {
|
|
throw new Error("Audit payload not available");
|
|
}
|
|
|
|
const payloadJson = await getJsonFromR2(row.r2Key);
|
|
const issues = PsiIssuesService.parseIssues(payloadJson, data.category);
|
|
|
|
return {
|
|
id: row.id,
|
|
finalUrl: row.finalUrl,
|
|
strategy: row.strategy,
|
|
createdAt: row.createdAt,
|
|
issues,
|
|
};
|
|
});
|
|
|
|
export const exportProjectPsiAudit = createServerFn({ method: "POST" })
|
|
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
|
.inputValidator((data: unknown) => psiExportSchema.parse(data))
|
|
.handler(async ({ data, context }) => {
|
|
const row = await PsiAuditRepository.getAuditResult({
|
|
auditId: data.auditId,
|
|
projectId: data.projectId,
|
|
userId: context.userId,
|
|
});
|
|
|
|
if (!row) {
|
|
throw new Error("Audit not found");
|
|
}
|
|
|
|
if (!row.r2Key) {
|
|
throw new Error("Audit payload not available");
|
|
}
|
|
|
|
const payloadJson = await getJsonFromR2(row.r2Key);
|
|
const safeDate = row.createdAt.replace(/[:.]/g, "-");
|
|
const baseName = `psi-${row.strategy}-${safeDate}`;
|
|
|
|
if (data.mode === "full") {
|
|
return {
|
|
filename: `${baseName}-full.json`,
|
|
content: payloadJson,
|
|
};
|
|
}
|
|
|
|
const category = data.mode === "category" ? data.category : undefined;
|
|
const issues = PsiIssuesService.parseIssues(payloadJson, category);
|
|
|
|
return {
|
|
filename:
|
|
data.mode === "category" && category
|
|
? `${baseName}-${category}-issues.json`
|
|
: `${baseName}-issues.json`,
|
|
content: JSON.stringify(
|
|
{
|
|
auditId: row.id,
|
|
finalUrl: row.finalUrl,
|
|
strategy: row.strategy,
|
|
createdAt: row.createdAt,
|
|
category: category ?? "all",
|
|
issues,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
};
|
|
});
|
|
|
|
export const getPsiIssuesBySource = createServerFn({ method: "POST" })
|
|
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
|
.inputValidator((data: unknown) => psiUnifiedIssueSchema.parse(data))
|
|
.handler(async ({ data, context }) => {
|
|
const target = await resolvePsiSource({
|
|
projectId: data.projectId,
|
|
userId: context.userId,
|
|
source: data.source,
|
|
resultId: data.resultId,
|
|
});
|
|
|
|
if (!target.r2Key) {
|
|
throw new Error("Audit payload not available");
|
|
}
|
|
|
|
const payloadJson = await getJsonFromR2(target.r2Key);
|
|
const issues = PsiIssuesService.parseIssues(payloadJson, data.category);
|
|
|
|
return {
|
|
id: target.id,
|
|
finalUrl: target.finalUrl,
|
|
strategy: target.strategy,
|
|
createdAt: target.createdAt,
|
|
issues,
|
|
};
|
|
});
|
|
|
|
export const exportPsiBySource = createServerFn({ method: "POST" })
|
|
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
|
.inputValidator((data: unknown) => psiUnifiedExportSchema.parse(data))
|
|
.handler(async ({ data, context }) => {
|
|
const target = await resolvePsiSource({
|
|
projectId: data.projectId,
|
|
userId: context.userId,
|
|
source: data.source,
|
|
resultId: data.resultId,
|
|
});
|
|
|
|
if (!target.r2Key) {
|
|
throw new Error("Audit payload not available");
|
|
}
|
|
|
|
const payloadJson = await getJsonFromR2(target.r2Key);
|
|
const safeDate = target.createdAt.replace(/[:.]/g, "-");
|
|
const baseName = `psi-${target.strategy}-${safeDate}`;
|
|
|
|
if (data.mode === "full") {
|
|
return {
|
|
filename: `${baseName}-full.json`,
|
|
content: payloadJson,
|
|
};
|
|
}
|
|
|
|
const category = data.mode === "category" ? data.category : undefined;
|
|
const issues = PsiIssuesService.parseIssues(payloadJson, category);
|
|
|
|
return {
|
|
filename:
|
|
data.mode === "category" && category
|
|
? `${baseName}-${category}-issues.json`
|
|
: `${baseName}-issues.json`,
|
|
content: JSON.stringify(
|
|
{
|
|
resultId: target.id,
|
|
finalUrl: target.finalUrl,
|
|
strategy: target.strategy,
|
|
createdAt: target.createdAt,
|
|
category: category ?? "all",
|
|
issues,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
};
|
|
});
|