From 35700ca80a516ddf1f25c0fe2a2483c30c8eae78 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:27:41 -0500 Subject: [PATCH] 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 --- README.md | 4 + knip.jsonc | 2 - package.json | 2 +- src/client/lib/error-messages.ts | 3 +- src/middleware/ensureUser.ts | 25 +-- src/middleware/errorHandling.ts | 24 +++ src/routes/index.tsx | 5 +- src/server/lib/errors.ts | 2 +- src/server/lib/logger.ts | 68 -------- src/server/lib/serverFnErrorBoundary.ts | 51 ------ src/server/services/DomainService.ts | 7 +- src/server/services/KeywordResearchService.ts | 19 +- .../keyword-research/saved-keywords.ts | 13 +- src/server/services/keyword-research/serp.ts | 8 +- src/serverFunctions/audit.ts | 113 ++++-------- src/serverFunctions/domain.ts | 19 +- src/serverFunctions/keywords.ts | 165 +++++------------- src/serverFunctions/middleware.ts | 9 + src/serverFunctions/psi.ts | 48 +++-- 19 files changed, 158 insertions(+), 429 deletions(-) create mode 100644 src/middleware/errorHandling.ts delete mode 100644 src/server/lib/logger.ts delete mode 100644 src/server/lib/serverFnErrorBoundary.ts create mode 100644 src/serverFunctions/middleware.ts diff --git a/README.md b/README.md index 22da5fc..1af52e2 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/knip.jsonc b/knip.jsonc index 47b7ebe..dc80a9e 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -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", diff --git a/package.json b/package.json index 00cd2bb..f1b482c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client/lib/error-messages.ts b/src/client/lib/error-messages.ts index 6f3389a..06b460e 100644 --- a/src/client/lib/error-messages.ts +++ b/src/client/lib/error-messages.ts @@ -8,7 +8,8 @@ const STANDARD_MESSAGES: Record = { 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( diff --git a/src/middleware/ensureUser.ts b/src/middleware/ensureUser.ts index 8b96b2d..bd0152a 100644 --- a/src/middleware/ensureUser.ts +++ b/src/middleware/ensureUser.ts @@ -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"); - } + await db.insert(users).values({ + id: userId, + email: session.email, + }); } - const userEmail = user?.email || session.email; - return next({ context: { userId, - userEmail, + userEmail: user?.email || session.email, session, }, }); diff --git a/src/middleware/errorHandling.ts b/src/middleware/errorHandling.ts new file mode 100644 index 0000000..e110ac5 --- /dev/null +++ b/src/middleware/errorHandling.ts @@ -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); + } +}); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index f124c66..e54e36f 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -29,7 +29,10 @@ function IndexRedirect() { return (

- {getStandardErrorMessage(error, "Failed to load. Please try again.")} + {getStandardErrorMessage( + error, + "An unexpected error occurred. Please check server logs.", + )}

); diff --git a/src/server/lib/errors.ts b/src/server/lib/errors.ts index 6bd6475..be685d4 100644 --- a/src/server/lib/errors.ts +++ b/src/server/lib/errors.ts @@ -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"; } diff --git a/src/server/lib/logger.ts b/src/server/lib/logger.ts deleted file mode 100644 index 6381a03..0000000 --- a/src/server/lib/logger.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { toErrorCode } from "@/server/lib/errors"; - -type LogContext = Record; - -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 = {}; - 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), - }), - ); -} diff --git a/src/server/lib/serverFnErrorBoundary.ts b/src/server/lib/serverFnErrorBoundary.ts deleted file mode 100644 index fa77755..0000000 --- a/src/server/lib/serverFnErrorBoundary.ts +++ /dev/null @@ -1,51 +0,0 @@ -export class PublicServerError extends Error { - constructor(message: string) { - super(message); - this.name = "PublicServerError"; - } -} - -interface ErrorBoundaryOptions { - fallbackMessage?: string; - passThroughMessages?: string[]; - getLogContext?: (args: TArgs) => Record; -} - -export function withServerFnErrorBoundary( - operation: string, - handler: (args: TArgs) => Promise, - options: ErrorBoundaryOptions = {}, -) { - const passThroughMessages = new Set(options.passThroughMessages ?? []); - - return async (args: TArgs): Promise => { - 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.", - ); - } - }; -} diff --git a/src/server/services/DomainService.ts b/src/server/services/DomainService.ts index a19240e..ca23183 100644 --- a/src/server/services/DomainService.ts +++ b/src/server/services/DomainService.ts @@ -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); }, ); } diff --git a/src/server/services/KeywordResearchService.ts b/src/server/services/KeywordResearchService.ts index 0a4f20b..934c43b 100644 --- a/src/server/services/KeywordResearchService.ts +++ b/src/server/services/KeywordResearchService.ts @@ -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, })), }; diff --git a/src/server/services/keyword-research/saved-keywords.ts b/src/server/services/keyword-research/saved-keywords.ts index afd004d..b4113b1 100644 --- a/src/server/services/keyword-research/saved-keywords.ts +++ b/src/server/services/keyword-research/saved-keywords.ts @@ -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, })), }; diff --git a/src/server/services/keyword-research/serp.ts b/src/server/services/keyword-research/serp.ts index 392cabd..17d51ab 100644 --- a/src/server/services/keyword-research/serp.ts +++ b/src/server/services/keyword-research/serp.ts @@ -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); }); } diff --git a/src/serverFunctions/audit.ts b/src/serverFunctions/audit.ts index f5c523c..d6d2706 100644 --- a/src/serverFunctions/audit.ts +++ b/src/serverFunctions/audit.ts @@ -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({ - 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); - } - }); + .handler(async ({ data, context }) => + AuditService.startAudit({ + userId: context.userId, + projectId: data.projectId, + startUrl: data.startUrl, + maxPages: data.maxPages, + psiStrategy: data.psiStrategy, + psiApiKey: data.psiApiKey, + }), + ); 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); - } + await AuditService.remove(data.auditId, context.userId); + return { success: true }; }); diff --git a/src/serverFunctions/domain.ts b/src/serverFunctions/domain.ts index 54486a8..02231cb 100644 --- a/src/serverFunctions/domain.ts +++ b/src/serverFunctions/domain.ts @@ -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)); diff --git a/src/serverFunctions/keywords.ts b/src/serverFunctions/keywords.ts index f9ea893..acc1103 100644 --- a/src/serverFunctions/keywords.ts +++ b/src/serverFunctions/keywords.ts @@ -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, - ); - } catch (error) { - logServerError("keywords.saved.list", error, { - userId: context.userId, - projectId: data.projectId, - }); - throw toClientError(error); - } - }); + .handler(async ({ data, context }) => + KeywordResearchService.getSavedKeywords(context.userId, data), + ); 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, - ); - } catch (error) { - logServerError("keywords.saved.remove", error, { - userId: context.userId, - savedKeywordId: data.savedKeywordId, - }); - throw toClientError(error); - } - }); + .handler(async ({ data, context }) => + KeywordResearchService.removeSavedKeyword(context.userId, data), + ); export const getOrCreateDefaultProject = createServerFn({ method: "POST" }) - .middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) - .handler(async ({ context }) => { - try { - return await KeywordResearchService.getOrCreateDefaultProject( - context.userId, - ); - } catch (error) { - logServerError("projects.get-or-create-default", error, { - userId: context.userId, - }); - throw toClientError(error); - } - }); + .middleware(authenticatedServerFunctionMiddleware) + .handler(async ({ context }) => + KeywordResearchService.getOrCreateDefaultProject(context.userId), + ); 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, - ); - } catch (error) { - logServerError("projects.get", error, { - userId: context.userId, - projectId: data.projectId, - }); - throw toClientError(error); - } - }); + .handler(async ({ data, context }) => + KeywordResearchService.getProject(context.userId, data.projectId), + ); diff --git a/src/serverFunctions/middleware.ts b/src/serverFunctions/middleware.ts new file mode 100644 index 0000000..4ee03da --- /dev/null +++ b/src/serverFunctions/middleware.ts @@ -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; diff --git a/src/serverFunctions/psi.ts b/src/serverFunctions/psi.ts index 9b79fe1..f0bafa6 100644 --- a/src/serverFunctions/psi.ts +++ b/src/serverFunctions/psi.ts @@ -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);