Improve error handling + local setup (#4)
* Add unified Docker self-host stack for OpenSEO and Gateway * Fix self-host migration flow and env wiring * Track latest gateway release by default and harden migrations * Fix gateway static asset serving in self-host runtime * Run gateway self-host from source with .env-based Vite runtime * Harden self-host env mode and remove apt dependency from gateway build * Avoid gateway build ENOSPC by installing deps at runtime * Revert temporary ENOSPC workaround for gateway Docker setup * Use Cloudflare selfhost env precedence for Docker runtime * Add selfhost wrangler environment and tighten Docker docs * Stabilize selfhost Docker runtime and persistence Use full Node base images for reliable TLS trust, simplify selfhost startup/env handling, and persist local gateway wrangler state to avoid resets between restarts. * Simplify Docker selfhost: rename scripts to docker:selfhost:*, remove GATEWAY_RELEASE_REPO, default to latest gateway * Remove selfhost wrangler env, use .env.local everywhere, simplify docker script names * Simplify selfhost compose: remove D1 preflight, conditional migration, and healthcheck * Extract inline Node.js from gateway Dockerfile into standalone scripts * Save * Move OpenSEO dependency install from container startup to image build step * Move self-host docker scripts and compose into dedicated folder * save * disable TanStack devtools in Docker self-host * Make edits * clarify self-hosting env setup docs * save * save * centralize server-function error middleware and simplify client-safe messaging * remove local migration-specific error remapping Keep auth middleware simple by letting DB/schema failures flow through the centralized error handler and remove the unused LOCAL_DB_MIGRATION_REQUIRED client/server code path. * remove custom error logging in favor of plain console.error * standardize PSI error codes and sanitize non-Error throws
This commit is contained in:
parent
4350b12c67
commit
35700ca80a
@ -170,6 +170,8 @@ cp .env.example .env.local
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
# Initialize local DB schema (required on a fresh machine)
|
||||
pnpm run db:migrate:local
|
||||
# This runs in BYPASS_GATEWAY mode so that you don't need to set up the Every App gateway. This is fine for local use.
|
||||
pnpm dev:agents
|
||||
```
|
||||
@ -184,6 +186,8 @@ Running locally is the fastest way to test core flows. In the future, local mode
|
||||
# This log file make it easier for your coding agent to debug.
|
||||
mkdir .logs
|
||||
touch .logs/dev-server.log
|
||||
# Run once per fresh local DB
|
||||
pnpm run db:migrate:local
|
||||
# terminal 1: start once and keep running
|
||||
pnpm dev:agents
|
||||
```
|
||||
|
||||
@ -20,7 +20,6 @@
|
||||
"src/server.ts",
|
||||
"src/server/lib/audit/progress-kv.ts",
|
||||
"src/server/lib/audit/types.ts",
|
||||
"src/server/lib/errors.ts",
|
||||
"src/server/services/PsiIssuesService.ts",
|
||||
"src/server/workflows/SiteAuditWorkflow.ts",
|
||||
"src/serverFunctions/keywords.ts",
|
||||
@ -29,7 +28,6 @@
|
||||
"src/types/schemas/psi.ts",
|
||||
],
|
||||
"ignoreFiles": [
|
||||
"src/server/lib/serverFnErrorBoundary.ts",
|
||||
"src/server/services/keyword-research/helpers.ts",
|
||||
"src/server/services/keyword-research/projects.ts",
|
||||
"src/server/services/keyword-research/research-data.ts",
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:agents": "BYPASS_GATEWAY_LOCAL_ONLY=true vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"dev:agents": "mkdir -p .logs && BYPASS_GATEWAY_LOCAL_ONLY=true vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"lint": "oxlint .",
|
||||
"lint:fix": "oxlint . --fix",
|
||||
|
||||
@ -8,7 +8,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
|
||||
CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.",
|
||||
RATE_LIMITED: "Too many requests. Please wait and try again.",
|
||||
CONFLICT: "This request conflicts with existing data.",
|
||||
INTERNAL_ERROR: "Something went wrong. Please try again.",
|
||||
INTERNAL_ERROR:
|
||||
"An unexpected error occurred. Please check server logs and try again.",
|
||||
};
|
||||
|
||||
export function getStandardErrorMessage(
|
||||
|
||||
@ -7,22 +7,16 @@ import {
|
||||
getAuthConfig,
|
||||
} from "@every-app/sdk/tanstack/server";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { logServerError } from "@/server/lib/logger";
|
||||
|
||||
export const ensureUserMiddleware = createMiddleware({
|
||||
type: "function",
|
||||
}).server(async (c) => {
|
||||
const { next } = c;
|
||||
|
||||
const authConfig = getAuthConfig();
|
||||
|
||||
const session = await authenticateRequest(authConfig);
|
||||
|
||||
if (!session) {
|
||||
throw new AppError("UNAUTHENTICATED");
|
||||
}
|
||||
|
||||
if (!session.email) {
|
||||
if (!session || !session.email) {
|
||||
throw new AppError("UNAUTHENTICATED");
|
||||
}
|
||||
|
||||
@ -34,23 +28,16 @@ export const ensureUserMiddleware = createMiddleware({
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
try {
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
email: session.email,
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("auth.ensure-user.create", error, { userId });
|
||||
throw new AppError("INTERNAL_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
const userEmail = user?.email || session.email;
|
||||
|
||||
return next({
|
||||
context: {
|
||||
userId,
|
||||
userEmail,
|
||||
userEmail: user?.email || session.email,
|
||||
session,
|
||||
},
|
||||
});
|
||||
|
||||
24
src/middleware/errorHandling.ts
Normal file
24
src/middleware/errorHandling.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { createMiddleware } from "@tanstack/react-start";
|
||||
import { asAppError, toClientError } from "@/server/lib/errors";
|
||||
|
||||
export const errorHandlingMiddleware = createMiddleware({
|
||||
type: "function",
|
||||
}).server(async (c) => {
|
||||
const { next } = c;
|
||||
|
||||
try {
|
||||
return await next();
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw new Error("INTERNAL_ERROR", { cause: error });
|
||||
}
|
||||
|
||||
const appError = asAppError(error);
|
||||
|
||||
if (appError?.code !== "UNAUTHENTICATED") {
|
||||
console.error("server.function error:", error);
|
||||
}
|
||||
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
@ -29,7 +29,10 @@ function IndexRedirect() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-error">
|
||||
{getStandardErrorMessage(error, "Failed to load. Please try again.")}
|
||||
{getStandardErrorMessage(
|
||||
error,
|
||||
"An unexpected error occurred. Please check server logs.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -18,7 +18,7 @@ export function asAppError(error: unknown): AppError | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toErrorCode(error: unknown): ErrorCode {
|
||||
function toErrorCode(error: unknown): ErrorCode {
|
||||
return asAppError(error)?.code ?? "INTERNAL_ERROR";
|
||||
}
|
||||
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
import { toErrorCode } from "@/server/lib/errors";
|
||||
|
||||
type LogContext = Record<string, unknown>;
|
||||
|
||||
const SENSITIVE_KEY_PATTERN = /token|secret|password|key|email|authorization/i;
|
||||
|
||||
function sanitizeValue(key: string, value: unknown): unknown {
|
||||
if (SENSITIVE_KEY_PATTERN.test(key)) return "[REDACTED]";
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value.length > 300 ? `${value.slice(0, 300)}...` : value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 10).map((item) => sanitizeValue(key, item));
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
output[k] = sanitizeValue(k, v);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function sanitizeContext(context: LogContext): LogContext {
|
||||
const safe: LogContext = {};
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
safe[key] = sanitizeValue(key, value);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
export function logServerError(
|
||||
operation: string,
|
||||
error: unknown,
|
||||
context: LogContext = {},
|
||||
): void {
|
||||
const code = toErrorCode(error);
|
||||
const safeErrorMessage =
|
||||
error instanceof Error
|
||||
? sanitizeValue("message", error.message)
|
||||
: "unknown";
|
||||
const safeStack =
|
||||
error instanceof Error && typeof error.stack === "string"
|
||||
? sanitizeValue("stack", error.stack)
|
||||
: undefined;
|
||||
const safeCause =
|
||||
error instanceof Error && "cause" in error
|
||||
? sanitizeValue("cause", (error as { cause?: unknown }).cause)
|
||||
: undefined;
|
||||
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
level: "error",
|
||||
operation,
|
||||
code,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
message: safeErrorMessage,
|
||||
stack: safeStack,
|
||||
cause: safeCause,
|
||||
context: sanitizeContext(context),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
export class PublicServerError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PublicServerError";
|
||||
}
|
||||
}
|
||||
|
||||
interface ErrorBoundaryOptions<TArgs> {
|
||||
fallbackMessage?: string;
|
||||
passThroughMessages?: string[];
|
||||
getLogContext?: (args: TArgs) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function withServerFnErrorBoundary<TArgs, TResult>(
|
||||
operation: string,
|
||||
handler: (args: TArgs) => Promise<TResult>,
|
||||
options: ErrorBoundaryOptions<TArgs> = {},
|
||||
) {
|
||||
const passThroughMessages = new Set(options.passThroughMessages ?? []);
|
||||
|
||||
return async (args: TArgs): Promise<TResult> => {
|
||||
try {
|
||||
return await handler(args);
|
||||
} catch (error) {
|
||||
if (error instanceof PublicServerError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof Error && passThroughMessages.has(error.message)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const cause =
|
||||
error instanceof Error && "cause" in error
|
||||
? (error as { cause?: unknown }).cause
|
||||
: undefined;
|
||||
const logContext = options.getLogContext?.(args);
|
||||
console.error(`${operation} failed`, {
|
||||
message,
|
||||
cause,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
...logContext,
|
||||
});
|
||||
|
||||
throw new PublicServerError(
|
||||
options.fallbackMessage ?? "Something went wrong. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -7,7 +7,6 @@ import {
|
||||
} from "@/server/lib/dataforseo";
|
||||
import { sortBy } from "remeda";
|
||||
import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
|
||||
import { logServerError } from "@/server/lib/logger";
|
||||
|
||||
/** Domain overview data is refreshed every 12 hours. */
|
||||
const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60;
|
||||
@ -111,11 +110,7 @@ async function getOverview(input: {
|
||||
if (result.hasData) {
|
||||
void setCached(cacheKey, result, DOMAIN_OVERVIEW_TTL_SECONDS).catch(
|
||||
(error) => {
|
||||
logServerError("domain.overview.cache-write", error, {
|
||||
domain,
|
||||
locationCode: input.locationCode,
|
||||
languageCode: input.languageCode,
|
||||
});
|
||||
console.error("domain.overview.cache-write failed:", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,7 +28,6 @@ import {
|
||||
} from "@/server/lib/kv-cache";
|
||||
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { logServerError } from "@/server/lib/logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@ -64,15 +63,12 @@ type EnrichedKeyword = {
|
||||
|
||||
type KeywordSource = "related" | "suggestions" | "ideas";
|
||||
|
||||
function parseMonthlySearches(
|
||||
payload: string | null,
|
||||
context: { keyword: string; projectId: string },
|
||||
): MonthlySearch[] {
|
||||
function parseMonthlySearches(payload: string | null): MonthlySearch[] {
|
||||
if (!payload) return [];
|
||||
try {
|
||||
return JSON.parse(payload) as MonthlySearch[];
|
||||
} catch (error) {
|
||||
logServerError("keywords.saved.parse-monthly-searches", error, context);
|
||||
console.error("keywords.saved.parse-monthly-searches failed:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -302,11 +298,7 @@ async function research(
|
||||
}),
|
||||
),
|
||||
).catch((error) => {
|
||||
logServerError("keywords.research.persist-metrics", error, {
|
||||
locationCode: input.locationCode,
|
||||
languageCode: input.languageCode,
|
||||
rowCount: rows.length,
|
||||
});
|
||||
console.error("keywords.research.persist-metrics failed:", error);
|
||||
});
|
||||
|
||||
return { rows, source, usedFallback };
|
||||
@ -428,10 +420,7 @@ async function getSavedKeywords(
|
||||
competition: metric?.competition ?? null,
|
||||
keywordDifficulty: metric?.keywordDifficulty ?? null,
|
||||
intent: metric?.intent ?? null,
|
||||
monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null, {
|
||||
keyword: row.keyword,
|
||||
projectId: row.projectId,
|
||||
}),
|
||||
monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null),
|
||||
fetchedAt: metric?.fetchedAt ?? null,
|
||||
})),
|
||||
};
|
||||
|
||||
@ -7,17 +7,13 @@ import type {
|
||||
} from "@/types/schemas/keywords";
|
||||
import type { MonthlySearch, SavedKeywordRow } from "@/types/keywords";
|
||||
import { normalizeKeyword } from "./helpers";
|
||||
import { logServerError } from "@/server/lib/logger";
|
||||
|
||||
function parseMonthlySearches(
|
||||
payload: string | null,
|
||||
context: { keyword: string; projectId: string },
|
||||
): MonthlySearch[] {
|
||||
function parseMonthlySearches(payload: string | null): MonthlySearch[] {
|
||||
if (!payload) return [];
|
||||
try {
|
||||
return JSON.parse(payload) as MonthlySearch[];
|
||||
} catch (error) {
|
||||
logServerError("keywords.saved.parse-monthly-searches", error, context);
|
||||
console.error("keywords.saved.parse-monthly-searches failed:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -76,10 +72,7 @@ export async function getSavedKeywords(
|
||||
competition: metric?.competition ?? null,
|
||||
keywordDifficulty: metric?.keywordDifficulty ?? null,
|
||||
intent: metric?.intent ?? null,
|
||||
monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null, {
|
||||
keyword: row.keyword,
|
||||
projectId: row.projectId,
|
||||
}),
|
||||
monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null),
|
||||
fetchedAt: metric?.fetchedAt ?? null,
|
||||
})),
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { fetchHistoricalSerpsRaw } from "@/server/lib/dataforseo";
|
||||
import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
|
||||
import { logServerError } from "@/server/lib/logger";
|
||||
|
||||
import type { SerpResultItem } from "@/types/keywords";
|
||||
import { normalizeKeyword } from "./helpers";
|
||||
|
||||
@ -57,11 +57,7 @@ export async function getSerpAnalysis(input: {
|
||||
|
||||
if (items.length > 0) {
|
||||
void setCached(cacheKey, result, SERP_CACHE_TTL_SECONDS).catch((error) => {
|
||||
logServerError("keywords.serp.cache-write", error, {
|
||||
keyword,
|
||||
locationCode: input.locationCode,
|
||||
languageCode: input.languageCode,
|
||||
});
|
||||
console.error("keywords.serp.cache-write failed:", error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { ensureUserMiddleware } from "@/middleware/ensureUser";
|
||||
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
|
||||
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
|
||||
import {
|
||||
startAuditSchema,
|
||||
getAuditStatusSchema,
|
||||
@ -10,103 +9,53 @@ import {
|
||||
getCrawlProgressSchema,
|
||||
} from "@/types/schemas/audit";
|
||||
import { AuditService } from "@/server/services/AuditService";
|
||||
import { logServerError } from "@/server/lib/logger";
|
||||
import { toClientError } from "@/server/lib/errors";
|
||||
|
||||
export const startAudit = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => startAuditSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await AuditService.startAudit({
|
||||
.handler(async ({ data, context }) =>
|
||||
AuditService.startAudit({
|
||||
userId: context.userId,
|
||||
projectId: data.projectId,
|
||||
startUrl: data.startUrl,
|
||||
maxPages: data.maxPages,
|
||||
psiStrategy: data.psiStrategy,
|
||||
psiApiKey: data.psiApiKey,
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("audit.start", error, {
|
||||
userId: context.userId,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
export const getAuditStatus = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => getAuditStatusSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await AuditService.getStatus(data.auditId, context.userId);
|
||||
} catch (error) {
|
||||
logServerError("audit.status", error, {
|
||||
userId: context.userId,
|
||||
auditId: data.auditId,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
.handler(async ({ data, context }) =>
|
||||
AuditService.getStatus(data.auditId, context.userId),
|
||||
);
|
||||
|
||||
export const getAuditResults = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => getAuditResultsSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await AuditService.getResults(data.auditId, context.userId);
|
||||
} catch (error) {
|
||||
logServerError("audit.results", error, {
|
||||
userId: context.userId,
|
||||
auditId: data.auditId,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
.handler(async ({ data, context }) =>
|
||||
AuditService.getResults(data.auditId, context.userId),
|
||||
);
|
||||
|
||||
export const getAuditHistory = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => getAuditHistorySchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await AuditService.getHistory(data.projectId, context.userId);
|
||||
} catch (error) {
|
||||
logServerError("audit.history", error, {
|
||||
userId: context.userId,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
.handler(async ({ data, context }) =>
|
||||
AuditService.getHistory(data.projectId, context.userId),
|
||||
);
|
||||
|
||||
export const getCrawlProgress = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => getCrawlProgressSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await AuditService.getCrawlProgress(data.auditId, context.userId);
|
||||
} catch (error) {
|
||||
logServerError("audit.crawl-progress", error, {
|
||||
userId: context.userId,
|
||||
auditId: data.auditId,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
.handler(async ({ data, context }) =>
|
||||
AuditService.getCrawlProgress(data.auditId, context.userId),
|
||||
);
|
||||
|
||||
export const deleteAudit = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => deleteAuditSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
await AuditService.remove(data.auditId, context.userId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logServerError("audit.delete", error, {
|
||||
userId: context.userId,
|
||||
auditId: data.auditId,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,22 +1,9 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { ensureUserMiddleware } from "@/middleware/ensureUser";
|
||||
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
|
||||
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
|
||||
import { domainOverviewSchema } from "@/types/schemas/domain";
|
||||
import { DomainService } from "@/server/services/DomainService";
|
||||
import { logServerError } from "@/server/lib/logger";
|
||||
import { toClientError } from "@/server/lib/errors";
|
||||
|
||||
export const getDomainOverview = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => domainOverviewSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await DomainService.getOverview(data);
|
||||
} catch (error) {
|
||||
logServerError("domain.overview", error, {
|
||||
userId: context.userId,
|
||||
domain: data.domain,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
.handler(async ({ data }) => DomainService.getOverview(data));
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { z } from "zod";
|
||||
import { ensureUserMiddleware } from "@/middleware/ensureUser";
|
||||
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
|
||||
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
|
||||
import {
|
||||
researchKeywordsSchema,
|
||||
createProjectSchema,
|
||||
@ -12,157 +11,73 @@ import {
|
||||
serpAnalysisSchema,
|
||||
} from "@/types/schemas/keywords";
|
||||
import { KeywordResearchService } from "@/server/services/KeywordResearchService";
|
||||
import { logServerError } from "@/server/lib/logger";
|
||||
import { toClientError } from "@/server/lib/errors";
|
||||
|
||||
export const researchKeywords = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => researchKeywordsSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await KeywordResearchService.research(context.userId, data);
|
||||
} catch (error) {
|
||||
logServerError("keywords.research", error, { userId: context.userId });
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
.handler(async ({ data, context }) =>
|
||||
KeywordResearchService.research(context.userId, data),
|
||||
);
|
||||
|
||||
export const listProjects = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.handler(async ({ context }) => {
|
||||
try {
|
||||
return await KeywordResearchService.listProjects(context.userId);
|
||||
} catch (error) {
|
||||
logServerError("projects.list", error, { userId: context.userId });
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.handler(async ({ context }) =>
|
||||
KeywordResearchService.listProjects(context.userId),
|
||||
);
|
||||
|
||||
export const createProject = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => createProjectSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await KeywordResearchService.createProject(context.userId, data);
|
||||
} catch (error) {
|
||||
logServerError("projects.create", error, { userId: context.userId });
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
.handler(async ({ data, context }) =>
|
||||
KeywordResearchService.createProject(context.userId, data),
|
||||
);
|
||||
|
||||
export const deleteProject = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => deleteProjectSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await KeywordResearchService.deleteProject(context.userId, data);
|
||||
} catch (error) {
|
||||
logServerError("projects.delete", error, {
|
||||
userId: context.userId,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
.handler(async ({ data, context }) =>
|
||||
KeywordResearchService.deleteProject(context.userId, data),
|
||||
);
|
||||
|
||||
export const saveKeywords = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => saveKeywordsSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await KeywordResearchService.saveKeywords(context.userId, data);
|
||||
} catch (error) {
|
||||
logServerError("keywords.save", error, {
|
||||
userId: context.userId,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
.handler(async ({ data, context }) =>
|
||||
KeywordResearchService.saveKeywords(context.userId, data),
|
||||
);
|
||||
|
||||
export const getSavedKeywords = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => getSavedKeywordsSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await KeywordResearchService.getSavedKeywords(
|
||||
context.userId,
|
||||
data,
|
||||
.handler(async ({ data, context }) =>
|
||||
KeywordResearchService.getSavedKeywords(context.userId, data),
|
||||
);
|
||||
} catch (error) {
|
||||
logServerError("keywords.saved.list", error, {
|
||||
userId: context.userId,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
|
||||
export const removeSavedKeyword = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => removeSavedKeywordSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await KeywordResearchService.removeSavedKeyword(
|
||||
context.userId,
|
||||
data,
|
||||
.handler(async ({ data, context }) =>
|
||||
KeywordResearchService.removeSavedKeyword(context.userId, data),
|
||||
);
|
||||
} catch (error) {
|
||||
logServerError("keywords.saved.remove", error, {
|
||||
userId: context.userId,
|
||||
savedKeywordId: data.savedKeywordId,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
|
||||
export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.handler(async ({ context }) => {
|
||||
try {
|
||||
return await KeywordResearchService.getOrCreateDefaultProject(
|
||||
context.userId,
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.handler(async ({ context }) =>
|
||||
KeywordResearchService.getOrCreateDefaultProject(context.userId),
|
||||
);
|
||||
} catch (error) {
|
||||
logServerError("projects.get-or-create-default", error, {
|
||||
userId: context.userId,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
|
||||
export const getSerpAnalysis = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => serpAnalysisSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await KeywordResearchService.getSerpAnalysis(data);
|
||||
} catch (error) {
|
||||
logServerError("keywords.serp-analysis", error, {
|
||||
userId: context.userId,
|
||||
keyword: data.keyword,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
.handler(async ({ data }) => KeywordResearchService.getSerpAnalysis(data));
|
||||
|
||||
const getProjectSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const getProject = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => getProjectSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
try {
|
||||
return await KeywordResearchService.getProject(
|
||||
context.userId,
|
||||
data.projectId,
|
||||
.handler(async ({ data, context }) =>
|
||||
KeywordResearchService.getProject(context.userId, data.projectId),
|
||||
);
|
||||
} catch (error) {
|
||||
logServerError("projects.get", error, {
|
||||
userId: context.userId,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
throw toClientError(error);
|
||||
}
|
||||
});
|
||||
|
||||
9
src/serverFunctions/middleware.ts
Normal file
9
src/serverFunctions/middleware.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
|
||||
import { errorHandlingMiddleware } from "@/middleware/errorHandling";
|
||||
import { ensureUserMiddleware } from "@/middleware/ensureUser";
|
||||
|
||||
export const authenticatedServerFunctionMiddleware = [
|
||||
errorHandlingMiddleware,
|
||||
useSessionTokenClientMiddleware,
|
||||
ensureUserMiddleware,
|
||||
] as const;
|
||||
@ -1,6 +1,6 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { ensureUserMiddleware } from "@/middleware/ensureUser";
|
||||
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
|
||||
import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import {
|
||||
psiAuditSchema,
|
||||
psiAuditListSchema,
|
||||
@ -33,7 +33,7 @@ async function resolvePsiSource(input: {
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
throw new Error("Audit not found");
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
return {
|
||||
@ -52,7 +52,7 @@ async function resolvePsiSource(input: {
|
||||
});
|
||||
|
||||
if (!site) {
|
||||
throw new Error("Audit not found");
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
return {
|
||||
@ -65,7 +65,7 @@ async function resolvePsiSource(input: {
|
||||
}
|
||||
|
||||
export const runPsiAudit = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => psiAuditSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
const apiKey = await KeywordResearchRepository.getProjectPsiApiKey(
|
||||
@ -74,9 +74,7 @@ export const runPsiAudit = createServerFn({ method: "POST" })
|
||||
);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"PSI API key is not set for this project. Save a key first.",
|
||||
);
|
||||
throw new AppError("VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
const auditId = crypto.randomUUID();
|
||||
@ -146,7 +144,7 @@ export const runPsiAudit = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const getProjectPsiApiKey = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => psiProjectSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
// This PSI key is intentionally treated as low-sensitivity operational config
|
||||
@ -159,7 +157,7 @@ export const getProjectPsiApiKey = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const saveProjectPsiApiKey = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => psiProjectKeySchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
// Same tradeoff: persisted for convenience across PSI + Site Audit flows.
|
||||
@ -172,7 +170,7 @@ export const saveProjectPsiApiKey = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const clearProjectPsiApiKey = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => psiProjectSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
await KeywordResearchRepository.clearProjectPsiApiKey(
|
||||
@ -183,7 +181,7 @@ export const clearProjectPsiApiKey = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const listProjectPsiAudits = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => psiAuditListSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
const rows = await PsiAuditRepository.listAuditResults({
|
||||
@ -219,7 +217,7 @@ export const listProjectPsiAudits = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const getProjectPsiAuditRaw = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => psiAuditDetailsSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
const row = await PsiAuditRepository.getAuditResult({
|
||||
@ -229,11 +227,11 @@ export const getProjectPsiAuditRaw = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
throw new Error("Audit not found");
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
if (!row.r2Key) {
|
||||
throw new Error("Audit payload not available");
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
const payloadJson = await getJsonFromR2(row.r2Key);
|
||||
@ -247,7 +245,7 @@ export const getProjectPsiAuditRaw = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const getProjectPsiAuditIssues = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => psiIssueFilterSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
const row = await PsiAuditRepository.getAuditResult({
|
||||
@ -257,11 +255,11 @@ export const getProjectPsiAuditIssues = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
throw new Error("Audit not found");
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
if (!row.r2Key) {
|
||||
throw new Error("Audit payload not available");
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
const payloadJson = await getJsonFromR2(row.r2Key);
|
||||
@ -277,7 +275,7 @@ export const getProjectPsiAuditIssues = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const exportProjectPsiAudit = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => psiExportSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
const row = await PsiAuditRepository.getAuditResult({
|
||||
@ -287,11 +285,11 @@ export const exportProjectPsiAudit = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
throw new Error("Audit not found");
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
if (!row.r2Key) {
|
||||
throw new Error("Audit payload not available");
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
const payloadJson = await getJsonFromR2(row.r2Key);
|
||||
@ -329,7 +327,7 @@ export const exportProjectPsiAudit = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const getPsiIssuesBySource = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => psiUnifiedIssueSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
const target = await resolvePsiSource({
|
||||
@ -340,7 +338,7 @@ export const getPsiIssuesBySource = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
if (!target.r2Key) {
|
||||
throw new Error("Audit payload not available");
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
const payloadJson = await getJsonFromR2(target.r2Key);
|
||||
@ -356,7 +354,7 @@ export const getPsiIssuesBySource = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const exportPsiBySource = createServerFn({ method: "POST" })
|
||||
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware])
|
||||
.middleware(authenticatedServerFunctionMiddleware)
|
||||
.inputValidator((data: unknown) => psiUnifiedExportSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
const target = await resolvePsiSource({
|
||||
@ -367,7 +365,7 @@ export const exportPsiBySource = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
if (!target.r2Key) {
|
||||
throw new Error("Audit payload not available");
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
const payloadJson = await getJsonFromR2(target.r2Key);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user