* chore(release): v5.1.0

* feat: implement resume thumbnails

* fix: remove unused mcp tools

* docs: fix formatting of docs
This commit is contained in:
Amruth Pillai
2026-05-07 15:12:33 +02:00
committed by GitHub
parent 51c366310e
commit 50ba37a27f
1015 changed files with 106087 additions and 141872 deletions
+34
View File
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@reactive-resume/api",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./context": "./src/context.ts",
"./routers": "./src/routers/index.ts",
"./services/*": "./src/services/*.ts",
"./helpers/*": "./src/helpers/*.ts",
"./middleware/rate-limit": "./src/middleware/rate-limit/index.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.75",
"@ai-sdk/google": "^3.0.68",
"@ai-sdk/openai": "^3.0.62",
"@ai-sdk/openai-compatible": "^2.0.46",
"@aws-sdk/client-s3": "^3.1044.0",
"@orpc/client": "^1.14.2",
"@orpc/experimental-ratelimit": "^1.14.2",
"@orpc/server": "^1.14.2",
"@reactive-resume/ai": "workspace:*",
"@reactive-resume/auth": "workspace:*",
"@reactive-resume/db": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tanstack/react-start": "^1.167.65",
"ai": "^6.0.175",
"bcrypt": "^6.0.0",
"better-auth": "1.6.9",
"drizzle-orm": "1.0.0-beta.22",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.46.1",
"fast-json-patch": "^3.1.1",
"ollama-ai-provider-v2": "^3.5.0",
"react": "^19.2.6",
"sharp": "^0.34.5",
"ts-pattern": "^5.9.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@typescript/native-preview": "7.0.0-dev.20260507.1",
"typescript": "^6.0.3"
}
}
+99
View File
@@ -0,0 +1,99 @@
import type { Locale } from "@reactive-resume/utils/locale";
import type { User } from "better-auth";
import { ORPCError, os } from "@orpc/server";
import { eq } from "drizzle-orm";
import { auth, verifyOAuthToken } from "@reactive-resume/auth/config";
import { db } from "@reactive-resume/db/client";
import { user } from "@reactive-resume/db/schema";
interface ORPCContext {
locale: Locale;
reqHeaders?: Headers;
}
async function getUserFromBearerToken(headers: Headers): Promise<User | null> {
try {
const authHeader = headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) return null;
const payload = await verifyOAuthToken(authHeader.slice(7));
if (!payload?.sub) return null;
const [userResult] = await db.select().from(user).where(eq(user.id, payload.sub)).limit(1);
return userResult ?? null;
} catch (error) {
console.warn("Bearer token verification failed:", error);
return null;
}
}
async function getUserFromHeaders(headers: Headers): Promise<User | null> {
try {
const result = await auth.api.getSession({ headers });
if (!result?.user) return null;
return result.user;
} catch (error) {
console.warn("Session verification failed:", error);
return null;
}
}
async function getUserFromApiKey(apiKey: string): Promise<User | null> {
try {
const result = await auth.api.verifyApiKey({ body: { key: apiKey } });
if (!result.key || !result.valid) return null;
const [userResult] = await db.select().from(user).where(eq(user.id, result.key.referenceId)).limit(1);
if (!userResult) return null;
return userResult;
} catch (error) {
console.warn("API key verification failed:", error);
return null;
}
}
/**
* Resolve the authenticated user from the same headers oRPC uses (`x-api-key`,
* `Authorization: Bearer`, or session cookies). Tries each auth method in
* priority order and returns the first valid identity. Used directly by
* oRPC's `publicProcedure` and by callers outside oRPC handlers (e.g. MCP
* tools) where `context.user` is not in scope.
*/
export async function resolveUserFromRequestHeaders(headers: Headers): Promise<User | null> {
const apiKey = headers.get("x-api-key");
if (apiKey) {
const apiKeyUser = await getUserFromApiKey(apiKey);
if (apiKeyUser) return apiKeyUser;
} else {
const bearerUser = await getUserFromBearerToken(headers);
if (bearerUser) return bearerUser;
}
return getUserFromHeaders(headers);
}
const base = os.$context<ORPCContext>();
export const publicProcedure = base.use(async ({ context, next }) => {
const user = await resolveUserFromRequestHeaders(context.reqHeaders ?? new Headers());
return next({
context: {
...context,
user,
},
});
});
export const protectedProcedure = publicProcedure.use(async ({ context, next }) => {
if (!context.user) throw new ORPCError("UNAUTHORIZED");
return next({
context: {
...context,
user: context.user,
},
});
});
+104
View File
@@ -0,0 +1,104 @@
import { createSelectSchema } from "drizzle-zod";
import z from "zod";
import * as schema from "@reactive-resume/db/schema";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { jsonPatchOperationSchema } from "@reactive-resume/utils/resume/patch";
const resumeSchema = createSelectSchema(schema.resume, {
id: z.string().describe("The ID of the resume."),
name: z.string().min(1).describe("The name of the resume."),
slug: z.string().min(1).describe("The slug of the resume."),
tags: z.array(z.string()).describe("The tags of the resume."),
isPublic: z.boolean().describe("Whether the resume is public."),
isLocked: z.boolean().describe("Whether the resume is locked."),
password: z.string().min(6).nullable().describe("The password of the resume, if any."),
data: resumeDataSchema,
userId: z.string().describe("The ID of the user who owns the resume."),
createdAt: z.date().describe("The date and time the resume was created."),
updatedAt: z.date().describe("The date and time the resume was last updated."),
});
export const resumeDto = {
list: {
input: z
.object({
tags: z.array(z.string()).optional().default([]),
sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).optional().default("lastUpdatedAt"),
})
.optional()
.default({ tags: [], sort: "lastUpdatedAt" }),
output: z.array(resumeSchema.omit({ data: true, password: true, userId: true })),
},
getById: {
input: resumeSchema.pick({ id: true }),
output: resumeSchema
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
.extend({ hasPassword: z.boolean() }),
},
getBySlug: {
input: z.object({ username: z.string(), slug: z.string() }),
output: resumeSchema.omit({ password: true, userId: true, createdAt: true, updatedAt: true }),
},
create: {
input: resumeSchema
.pick({ name: true, slug: true, tags: true })
.extend({ withSampleData: z.boolean().default(false) }),
output: z.string().describe("The ID of the created resume."),
},
import: {
input: resumeSchema.pick({ data: true }),
output: z.string().describe("The ID of the imported resume."),
},
update: {
input: resumeSchema
.pick({ name: true, slug: true, tags: true, data: true, isPublic: true })
.partial()
.extend({ id: z.string() }),
output: resumeSchema
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
.extend({ hasPassword: z.boolean() }),
},
setLocked: {
input: resumeSchema.pick({ id: true, isLocked: true }),
output: z.void(),
},
setPassword: {
input: resumeSchema.pick({ id: true }).extend({ password: z.string().min(6).max(64) }),
output: z.void(),
},
removePassword: {
input: resumeSchema.pick({ id: true }),
output: z.void(),
},
patch: {
input: z.object({
id: z.string().describe("The ID of the resume to patch."),
operations: z
.array(jsonPatchOperationSchema)
.min(1)
.describe("An array of JSON Patch (RFC 6902) operations to apply to the resume data."),
}),
output: resumeSchema
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
.extend({ hasPassword: z.boolean() }),
},
duplicate: {
input: resumeSchema.pick({ id: true, name: true, slug: true, tags: true }),
output: z.string().describe("The ID of the duplicated resume."),
},
delete: {
input: resumeSchema.pick({ id: true }),
output: z.void(),
},
};
+36
View File
@@ -0,0 +1,36 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { env } from "@reactive-resume/env/server";
const RESUME_ACCESS_COOKIE_PREFIX = "resume_access";
const RESUME_ACCESS_TTL_SECONDS = 60 * 10; // 10 minutes
const getResumeAccessCookieName = (resumeId: string) => `${RESUME_ACCESS_COOKIE_PREFIX}_${resumeId}`;
const signResumeAccessToken = (resumeId: string, passwordHash: string): string =>
createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
const safeEquals = (value: string, expected: string) => {
const valueBuffer = Buffer.from(value);
const expectedBuffer = Buffer.from(expected);
if (valueBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(valueBuffer, expectedBuffer);
};
export const hasResumeAccess = (resumeId: string, passwordHash: string | null) => {
if (!passwordHash) return false;
const cookieName = getResumeAccessCookieName(resumeId);
const cookieValue = getCookie(cookieName);
if (!cookieValue) return false;
const expected = signResumeAccessToken(resumeId, passwordHash);
return safeEquals(cookieValue, expected);
};
export const grantResumeAccess = (resumeId: string, passwordHash: string) =>
setCookie(getResumeAccessCookieName(resumeId), signResumeAccessToken(resumeId, passwordHash), {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: RESUME_ACCESS_TTL_SECONDS,
secure: env.APP_URL.startsWith("https"),
});
@@ -0,0 +1,118 @@
import { createRatelimitMiddleware } from "@orpc/experimental-ratelimit";
import { MemoryRatelimiter } from "@orpc/experimental-ratelimit/memory";
import { rateLimitConfig, TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
const isRateLimitEnabled = process.env.NODE_ENV === "production";
type ContextWithHeaders = {
reqHeaders?: Headers;
user?: { id: string } | null;
};
function getTrustedIp(headers?: Headers): string | null {
if (!headers) return null;
for (const headerName of TRUSTED_IP_HEADERS) {
const raw = headers.get(headerName)?.trim();
if (!raw) continue;
// Some proxies provide a comma-delimited chain; first item is the original client.
const ip = raw.split(",")[0]?.trim();
if (!ip) continue;
return ip;
}
return null;
}
function getClientKey(headers?: Headers): string {
const trustedIp = getTrustedIp(headers);
if (trustedIp) return `ip:${trustedIp}`;
const userAgent = headers?.get("user-agent")?.trim() ?? "unknown";
const language = headers?.get("accept-language")?.split(",")[0]?.trim() ?? "none";
return `fp:${userAgent.slice(0, 64)}:${language.slice(0, 16)}`;
}
function getUserKey(context: ContextWithHeaders): string {
return context.user?.id ?? "anon";
}
function getInputKeyPart(input: unknown): string {
if (!input || typeof input !== "object") return "no-input";
const inputRecord = input as Record<string, unknown>;
const id = inputRecord.id;
if (typeof id === "string" && id.trim()) return id;
const username = inputRecord.username;
const slug = inputRecord.slug;
if (typeof username === "string" && typeof slug === "string") return `${username}:${slug}`;
return "no-id";
}
const resumePasswordLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.resumePassword);
const pdfLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.pdfExport);
const aiLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.aiRequest);
const jobsSearchLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.jobsSearch);
const jobsTestConnectionLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.jobsTestConnection);
const storageUploadLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.storageUpload);
const storageDeleteLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.storageDelete);
const resumeMutationLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.resumeMutations);
const disabledLimiter = {
limit: async () => ({
success: true,
remaining: Number.POSITIVE_INFINITY,
reset: Date.now(),
}),
};
const productionLimiter = (limiter: MemoryRatelimiter) => (isRateLimitEnabled ? limiter : disabledLimiter);
export const resumePasswordRateLimit = createRatelimitMiddleware<
ContextWithHeaders,
{ username: string; slug: string }
>({
limiter: productionLimiter(resumePasswordLimiter),
key: ({ context }, input) => `resume-password:${input.username}:${input.slug}:${getClientKey(context.reqHeaders)}`,
});
export const pdfExportRateLimit = createRatelimitMiddleware<ContextWithHeaders, { id: string }>({
limiter: productionLimiter(pdfLimiter),
key: ({ context }, input) => `pdf-export:${getUserKey(context)}:${input.id}`,
});
export const aiRequestRateLimit = createRatelimitMiddleware<ContextWithHeaders, { provider: string }>({
limiter: productionLimiter(aiLimiter),
key: ({ context }, input) => `ai-request:${getUserKey(context)}:${input.provider}`,
});
export const jobsSearchRateLimit = createRatelimitMiddleware<ContextWithHeaders, { params: { query: string } }>({
limiter: productionLimiter(jobsSearchLimiter),
key: ({ context }, input) => `jobs-search:${getUserKey(context)}:${input.params.query.trim().toLowerCase()}`,
});
export const jobsTestConnectionRateLimit = createRatelimitMiddleware<ContextWithHeaders, { apiKey: string }>({
limiter: productionLimiter(jobsTestConnectionLimiter),
key: ({ context }) => `jobs-test-connection:${getUserKey(context)}`,
});
export const storageUploadRateLimit = createRatelimitMiddleware<ContextWithHeaders, unknown>({
limiter: productionLimiter(storageUploadLimiter),
key: ({ context }) => `storage-upload:${getUserKey(context)}`,
});
export const storageDeleteRateLimit = createRatelimitMiddleware<ContextWithHeaders, { filename: string }>({
limiter: productionLimiter(storageDeleteLimiter),
key: ({ context }, input) => `storage-delete:${getUserKey(context)}:${input.filename}`,
});
export const resumeMutationRateLimit = createRatelimitMiddleware<ContextWithHeaders, unknown>({
limiter: productionLimiter(resumeMutationLimiter),
key: ({ context }, input) => `resume-mutation:${getUserKey(context)}:${getInputKeyPart(input)}`,
});
+218
View File
@@ -0,0 +1,218 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { UIMessage } from "ai";
import { ORPCError } from "@orpc/client";
import { type } from "@orpc/server";
import { AISDKError } from "ai";
import { flattenError, ZodError, z } from "zod";
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { protectedProcedure } from "../context";
import { aiRequestRateLimit } from "../middleware/rate-limit";
import { aiCredentialsSchema, aiService, fileInputSchema } from "../services/ai";
import { resumeService } from "../services/resume";
type AIProvider = z.infer<typeof aiCredentialsSchema.shape.provider>;
function isInvalidAiBaseUrlError(error: unknown): boolean {
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
}
function isAiProviderGatewayError(error: unknown): boolean {
return error instanceof AISDKError;
}
function throwAiProviderGatewayError(): never {
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
}
function throwAiProviderConfigError(): never {
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
}
function throwResumeStructureError(error: ZodError): never {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume data structure",
cause: flattenError(error),
});
}
export const aiRouter = {
testConnection: protectedProcedure
.route({
method: "POST",
path: "/ai/test-connection",
tags: ["AI"],
operationId: "testAiConnection",
summary: "Test AI provider connection",
description:
"Validates the connection to an AI provider by sending a simple test prompt. Requires the provider type, model name, API key, and an optional base URL. Supported providers: OpenAI, Anthropic, Google Gemini, Ollama, OpenRouter, and Vercel AI Gateway. Requires authentication.",
successDescription: "The AI provider connection was successful.",
})
.input(z.object({ ...aiCredentialsSchema.shape }))
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
})
.handler(async ({ input }) => {
try {
return await aiService.testConnection(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
throw error;
}
}),
parsePdf: protectedProcedure
.route({
method: "POST",
path: "/ai/parse-pdf",
tags: ["AI"],
operationId: "parseResumePdf",
summary: "Parse a PDF file into resume data",
description:
"Extracts structured resume data from a PDF file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials. Returns a complete ResumeData object. Requires authentication.",
successDescription: "The PDF was successfully parsed into structured resume data.",
})
.input(z.object({ ...aiCredentialsSchema.shape, file: fileInputSchema }))
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.handler(async ({ input }): Promise<ResumeData> => {
try {
return await aiService.parsePdf(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
}
}),
parseDocx: protectedProcedure
.route({
method: "POST",
path: "/ai/parse-docx",
tags: ["AI"],
operationId: "parseResumeDocx",
summary: "Parse a DOCX file into resume data",
description:
"Extracts structured resume data from a DOCX or DOC file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials and the document's media type. Returns a complete ResumeData object. Requires authentication.",
successDescription: "The DOCX was successfully parsed into structured resume data.",
})
.input(
z.object({
...aiCredentialsSchema.shape,
file: fileInputSchema,
mediaType: z.enum([
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]),
}),
)
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.handler(async ({ input }) => {
try {
return await aiService.parseDocx(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
}
}),
chat: protectedProcedure
.route({
method: "POST",
path: "/ai/chat",
tags: ["AI"],
operationId: "aiChat",
summary: "Chat with AI to modify resume",
description:
"Streams a chat response from the configured AI provider. The LLM can call the patch_resume tool to generate JSON Patch operations that modify the resume. Requires authentication and AI provider credentials.",
})
.input(
type<{
provider: AIProvider;
model: string;
apiKey: string;
baseURL: string;
messages: UIMessage[];
resumeData: ResumeData;
}>(),
)
.use(aiRequestRateLimit)
.handler(async ({ input }) => {
try {
return await aiService.chat(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
throw error;
}
}),
analyzeResume: protectedProcedure
.route({
method: "POST",
path: "/ai/analyze-resume",
tags: ["AI"],
operationId: "analyzeResume",
summary: "Analyze resume and persist latest analysis",
description:
"Uses AI to analyze the current resume and returns a structured analysis with scorecard, strengths, and improvement suggestions. The latest analysis is persisted and can be fetched later. Requires authentication and AI credentials.",
successDescription: "Structured resume analysis returned and persisted successfully.",
})
.input(
z.object({
...aiCredentialsSchema.shape,
resumeId: z.string(),
resumeData: resumeDataSchema,
}),
)
.use(aiRequestRateLimit)
.output(storedResumeAnalysisSchema)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.handler(async ({ context, input }) => {
try {
const analysis = await aiService.analyzeResume({
provider: input.provider,
model: input.model,
apiKey: input.apiKey,
baseURL: input.baseURL,
resumeData: input.resumeData,
});
return await resumeService.analysis.upsert({
id: input.resumeId,
userId: context.user.id,
analysis: {
...analysis,
updatedAt: new Date(),
modelMeta: { provider: input.provider, model: input.model },
},
});
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume analysis structure",
cause: flattenError(error),
});
}
throw error;
}
}),
};
+37
View File
@@ -0,0 +1,37 @@
import type { ProviderList } from "../services/auth";
import { protectedProcedure, publicProcedure } from "../context";
import { authService } from "../services/auth";
export const authRouter = {
providers: {
list: publicProcedure
.route({
method: "GET",
path: "/auth/providers",
tags: ["Authentication"],
operationId: "listAuthProviders",
summary: "List authentication providers",
description:
"Returns a list of all authentication providers enabled on this Reactive Resume instance, along with their display names. Possible providers include password-based credentials, Google, GitHub, LinkedIn, and custom OAuth. No authentication required.",
successDescription: "A map of enabled authentication provider identifiers to their display names.",
})
.handler((): ProviderList => {
return authService.providers.list();
}),
},
deleteAccount: protectedProcedure
.route({
method: "DELETE",
path: "/auth/account",
tags: ["Authentication"],
operationId: "deleteAccount",
summary: "Delete user account",
description:
"Permanently deletes the authenticated user's account, including all resumes, uploaded files (profile pictures, screenshots, PDFs), and associated data. This action is irreversible. Requires authentication.",
successDescription: "The user account and all associated data have been successfully deleted.",
})
.handler(async ({ context }): Promise<void> => {
return await authService.deleteAccount({ userId: context.user.id });
}),
};
+25
View File
@@ -0,0 +1,25 @@
import type { FeatureFlags } from "../services/flags";
import z from "zod";
import { publicProcedure } from "../context";
import { flagsService } from "../services/flags";
export const flagsRouter = {
get: publicProcedure
.route({
method: "GET",
path: "/flags",
tags: ["Feature Flags"],
operationId: "getFeatureFlags",
summary: "Get feature flags",
description:
"Returns the current feature flags for this Reactive Resume instance. Feature flags control instance-wide settings such as whether new user signups or email-based authentication are disabled. No authentication required.",
successDescription: "The current feature flags for this instance.",
})
.output(
z.object({
disableSignups: z.boolean().describe("Whether new user signups are disabled on this instance."),
disableEmailAuth: z.boolean().describe("Whether email-based authentication is disabled on this instance."),
}),
)
.handler((): FeatureFlags => flagsService.getFlags()),
};
+15
View File
@@ -0,0 +1,15 @@
import { aiRouter } from "./ai";
import { authRouter } from "./auth";
import { flagsRouter } from "./flags";
import { resumeRouter } from "./resume";
import { statisticsRouter } from "./statistics";
import { storageRouter } from "./storage";
export default {
ai: aiRouter,
auth: authRouter,
flags: flagsRouter,
resume: resumeRouter,
statistics: statisticsRouter,
storage: storageRouter,
};
+399
View File
@@ -0,0 +1,399 @@
import z from "zod";
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { generateRandomName, slugify } from "@reactive-resume/utils/string";
import { protectedProcedure, publicProcedure } from "../context";
import { resumeDto } from "../dto/resume";
import { resumeMutationRateLimit, resumePasswordRateLimit } from "../middleware/rate-limit";
import { resumeService } from "../services/resume";
const tagsRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/resumes/tags",
tags: ["Resumes"],
operationId: "listResumeTags",
summary: "List all resume tags",
description:
"Returns a sorted list of all unique tags across the authenticated user's resumes. Useful for populating tag filters in the dashboard. Requires authentication.",
successDescription: "A sorted array of unique tag strings.",
})
.output(z.array(z.string()))
.handler(async ({ context }) => {
return resumeService.tags.list({ userId: context.user.id });
}),
};
const statisticsRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/statistics",
tags: ["Resume Statistics"],
operationId: "getResumeStatistics",
summary: "Get resume statistics",
description:
"Returns view and download statistics for the specified resume, including total counts and the timestamps of the last view and download. Requires authentication.",
successDescription: "The resume's view and download statistics.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(
z.object({
isPublic: z.boolean().describe("Whether the resume is currently public."),
views: z.number().describe("Total number of times the resume has been viewed."),
downloads: z.number().describe("Total number of times the resume has been downloaded."),
lastViewedAt: z.date().nullable().describe("Timestamp of the last view, or null if never viewed."),
lastDownloadedAt: z.date().nullable().describe("Timestamp of the last download, or null if never downloaded."),
}),
)
.handler(async ({ context, input }) => {
return resumeService.statistics.getById({ id: input.id, userId: context.user.id });
}),
};
const analysisRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/analysis",
tags: ["Resume Analysis"],
operationId: "getResumeAnalysis",
summary: "Get latest resume analysis",
description:
"Returns the latest persisted AI analysis for the specified resume, if one exists. Requires authentication.",
successDescription: "The latest persisted resume analysis, or null if no analysis has been saved yet.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(storedResumeAnalysisSchema.nullable())
.handler(async ({ context, input }) => {
return resumeService.analysis.getById({ id: input.id, userId: context.user.id });
}),
};
export const resumeRouter = {
tags: tagsRouter,
statistics: statisticsRouter,
analysis: analysisRouter,
list: protectedProcedure
.route({
method: "GET",
path: "/resumes",
tags: ["Resumes"],
operationId: "listResumes",
summary: "List all resumes",
description:
"Returns a list of all resumes belonging to the authenticated user. Results can be filtered by tags and sorted by last updated date, creation date, or name. Resume data is not included in the response for performance; use the get endpoint to fetch full resume data. Requires authentication.",
successDescription: "A list of resumes with their metadata (without full resume data).",
})
.input(resumeDto.list.input.optional().default({ tags: [], sort: "lastUpdatedAt" }))
.output(resumeDto.list.output)
.handler(async ({ input, context }) => {
return resumeService.list({
userId: context.user.id,
tags: input.tags,
sort: input.sort,
});
}),
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "getResume",
summary: "Get resume by ID",
description:
"Returns a single resume with its full data, identified by its unique ID. Only resumes belonging to the authenticated user can be retrieved. Requires authentication.",
successDescription: "The resume with its full data.",
})
.input(resumeDto.getById.input)
.output(resumeDto.getById.output)
.handler(async ({ context, input }) => {
return resumeService.getById({ id: input.id, userId: context.user.id });
}),
getBySlug: publicProcedure
.route({
method: "GET",
path: "/resumes/{username}/{slug}",
tags: ["Resume Sharing"],
operationId: "getResumeBySlug",
summary: "Get public resume by username and slug",
description:
"Returns a publicly shared resume identified by the owner's username and the resume's slug. If the resume is password-protected and the viewer has not yet verified the password, a 401 error with code NEED_PASSWORD is returned. No authentication required for public resumes; if authenticated as the owner, private resumes are also accessible.",
successDescription: "The public resume with its full data.",
})
.input(resumeDto.getBySlug.input)
.output(resumeDto.getBySlug.output)
.handler(async ({ input, context }) => {
return resumeService.getBySlug({
...input,
...(context.user?.id ? { currentUserId: context.user.id } : {}),
});
}),
create: protectedProcedure
.route({
method: "POST",
path: "/resumes",
tags: ["Resumes"],
operationId: "createResume",
summary: "Create a new resume",
description:
"Creates a new resume with the given name, slug, and tags. Optionally initializes the resume with sample data by setting withSampleData to true. The slug must be unique across the user's resumes. Returns the ID of the newly created resume. Requires authentication.",
successDescription: "The ID of the newly created resume.",
})
.input(resumeDto.create.input)
.use(resumeMutationRateLimit)
.output(resumeDto.create.output)
.errors({
RESUME_SLUG_ALREADY_EXISTS: {
message: "A resume with this slug already exists.",
status: 400,
},
})
.handler(async ({ context, input }) => {
return resumeService.create({
name: input.name,
slug: input.slug,
tags: input.tags,
locale: context.locale,
userId: context.user.id,
...(input.withSampleData ? { data: sampleResumeData } : {}),
});
}),
import: protectedProcedure
.route({
method: "POST",
path: "/resumes/import",
tags: ["Resumes"],
operationId: "importResume",
summary: "Import a resume",
description:
"Creates a new resume from an existing ResumeData object (e.g. from a previously exported JSON file). A random name and slug are generated automatically. Returns the ID of the imported resume. Requires authentication.",
successDescription: "The ID of the imported resume.",
})
.input(resumeDto.import.input)
.use(resumeMutationRateLimit)
.output(resumeDto.import.output)
.errors({
RESUME_SLUG_ALREADY_EXISTS: {
message: "A resume with this slug already exists.",
status: 400,
},
})
.handler(async ({ context, input }) => {
const name = generateRandomName();
const slug = slugify(name);
return resumeService.create({
name,
slug,
tags: [],
data: input.data,
locale: context.locale,
userId: context.user.id,
});
}),
update: protectedProcedure
.route({
method: "PUT",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "updateResume",
summary: "Update a resume",
description:
"Updates one or more fields of a resume identified by its ID. All fields are optional; only provided fields will be updated. Locked resumes cannot be updated. Requires authentication.",
successDescription: "The updated resume with its full data.",
})
.input(resumeDto.update.input)
.use(resumeMutationRateLimit)
.output(resumeDto.update.output)
.errors({
RESUME_SLUG_ALREADY_EXISTS: {
message: "A resume with this slug already exists.",
status: 400,
},
})
.handler(async ({ context, input }) => {
return resumeService.update({
id: input.id,
userId: context.user.id,
...(input.name !== undefined ? { name: input.name } : {}),
...(input.slug !== undefined ? { slug: input.slug } : {}),
...(input.tags !== undefined ? { tags: input.tags } : {}),
...(input.data !== undefined ? { data: input.data } : {}),
...(input.isPublic !== undefined ? { isPublic: input.isPublic } : {}),
});
}),
patch: protectedProcedure
.route({
method: "PATCH",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "patchResume",
summary: "Patch resume data",
description:
"Applies JSON Patch (RFC 6902) operations to partially update a resume's data. This allows small, targeted changes (e.g. updating a single field) without sending the entire resume object. Locked resumes cannot be patched. Requires authentication.",
successDescription: "The patched resume with its full data.",
})
.input(resumeDto.patch.input)
.use(resumeMutationRateLimit)
.output(resumeDto.patch.output)
.errors({
INVALID_PATCH_OPERATIONS: {
message: "The patch operations are invalid or produced an invalid resume.",
status: 400,
},
})
.handler(async ({ context, input }) => {
return resumeService.patch({
id: input.id,
userId: context.user.id,
operations: input.operations,
});
}),
setLocked: protectedProcedure
.route({
method: "POST",
path: "/resumes/{id}/lock",
tags: ["Resumes"],
operationId: "setResumeLocked",
summary: "Set resume lock status",
description:
"Toggles the locked status of a resume. When locked, a resume cannot be updated, patched, or deleted. Useful for protecting finalized resumes from accidental edits. Requires authentication.",
successDescription: "The resume lock status was updated successfully.",
})
.input(resumeDto.setLocked.input)
.use(resumeMutationRateLimit)
.output(resumeDto.setLocked.output)
.handler(async ({ context, input }) => {
return resumeService.setLocked({
id: input.id,
userId: context.user.id,
isLocked: input.isLocked,
});
}),
setPassword: protectedProcedure
.route({
method: "PUT",
path: "/resumes/{id}/password",
tags: ["Resume Sharing"],
operationId: "setResumePassword",
summary: "Set resume password",
description:
"Sets or updates a password on a resume. When a password is set, viewers of the public resume must enter the password before the resume data is revealed. The password must be between 6 and 64 characters. Requires authentication.",
successDescription: "The resume password was set successfully.",
})
.input(resumeDto.setPassword.input)
.use(resumeMutationRateLimit)
.output(resumeDto.setPassword.output)
.handler(async ({ context, input }) => {
return resumeService.setPassword({
id: input.id,
userId: context.user.id,
password: input.password,
});
}),
verifyPassword: publicProcedure
.route({
method: "POST",
path: "/resumes/{username}/{slug}/password/verify",
tags: ["Resume Sharing"],
operationId: "verifyResumePassword",
summary: "Verify resume password",
description:
"Verifies a password for a password-protected public resume. On success, the viewer is granted access to view the resume data for the duration of their session. No authentication required.",
successDescription: "The password was verified successfully and access has been granted.",
})
.input(
z.object({
username: z.string().min(1).describe("The username of the resume owner."),
slug: z.string().min(1).describe("The slug of the resume."),
password: z.string().min(1).describe("The password to verify."),
}),
)
.use(resumePasswordRateLimit)
.output(z.boolean())
.handler(async ({ input }): Promise<boolean> => {
return resumeService.verifyPassword({
username: input.username,
slug: input.slug,
password: input.password,
});
}),
removePassword: protectedProcedure
.route({
method: "DELETE",
path: "/resumes/{id}/password",
tags: ["Resume Sharing"],
operationId: "removeResumePassword",
summary: "Remove resume password",
description:
"Removes password protection from a resume. After removal, the resume (if public) can be viewed without entering a password. Requires authentication.",
successDescription: "The resume password was removed successfully.",
})
.input(resumeDto.removePassword.input)
.use(resumeMutationRateLimit)
.output(resumeDto.removePassword.output)
.handler(async ({ context, input }) => {
return resumeService.removePassword({
id: input.id,
userId: context.user.id,
});
}),
duplicate: protectedProcedure
.route({
method: "POST",
path: "/resumes/{id}/duplicate",
tags: ["Resumes"],
operationId: "duplicateResume",
summary: "Duplicate a resume",
description:
"Creates a copy of an existing resume with the same data. Optionally override the name, slug, and tags for the duplicate. If not provided, the original resume's name, slug, and tags are used. Returns the ID of the duplicated resume. Requires authentication.",
successDescription: "The ID of the duplicated resume.",
})
.input(resumeDto.duplicate.input)
.use(resumeMutationRateLimit)
.output(resumeDto.duplicate.output)
.handler(async ({ context, input }) => {
const original = await resumeService.getById({ id: input.id, userId: context.user.id });
return resumeService.create({
userId: context.user.id,
name: input.name ?? original.name,
slug: input.slug ?? original.slug,
tags: input.tags ?? original.tags,
locale: context.locale,
data: original.data,
});
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "deleteResume",
summary: "Delete a resume",
description:
"Permanently deletes a resume and its associated files (screenshots, PDFs) from storage. Locked resumes cannot be deleted; unlock the resume first. Requires authentication.",
successDescription: "The resume and its associated files were deleted successfully.",
})
.input(resumeDto.delete.input)
.use(resumeMutationRateLimit)
.output(resumeDto.delete.output)
.handler(async ({ context, input }) => {
return resumeService.delete({ id: input.id, userId: context.user.id });
}),
};
+63
View File
@@ -0,0 +1,63 @@
import z from "zod";
import { publicProcedure } from "../context";
import { statisticsService } from "../services/statistics";
const userRouter = {
getCount: publicProcedure
.route({
method: "GET",
path: "/statistics/users",
tags: ["Platform Statistics"],
operationId: "getUserCount",
summary: "Get total number of users",
description:
"Returns the total number of registered users on this Reactive Resume instance. The count is cached for up to 6 hours for performance. No authentication required.",
successDescription: "The total number of registered users.",
})
.output(z.number().describe("The total number of registered users."))
.handler(async (): Promise<number> => {
return await statisticsService.user.getCount();
}),
};
const resumeRouter = {
getCount: publicProcedure
.route({
method: "GET",
path: "/statistics/resumes",
tags: ["Platform Statistics"],
operationId: "getResumeCount",
summary: "Get total number of resumes",
description:
"Returns the total number of resumes created on this Reactive Resume instance. The count is cached for up to 6 hours for performance. No authentication required.",
successDescription: "The total number of resumes created.",
})
.output(z.number().describe("The total number of resumes created."))
.handler(async (): Promise<number> => {
return await statisticsService.resume.getCount();
}),
};
const githubRouter = {
getStarCount: publicProcedure
.route({
method: "GET",
path: "/statistics/github/stars",
tags: ["Platform Statistics"],
operationId: "getGitHubStarCount",
summary: "Get GitHub star count",
description:
"Returns the number of GitHub stars for the Reactive Resume repository. The count is cached for up to 6 hours and falls back to a last-known value if the GitHub API is unavailable. No authentication required.",
successDescription: "The number of GitHub stars for the Reactive Resume repository.",
})
.output(z.number().describe("The number of GitHub stars."))
.handler(async (): Promise<number> => {
return await statisticsService.github.getStarCount();
}),
};
export const statisticsRouter = {
user: userRouter,
resume: resumeRouter,
github: githubRouter,
};
+110
View File
@@ -0,0 +1,110 @@
import { ORPCError } from "@orpc/server";
import z from "zod";
import { protectedProcedure } from "../context";
import { storageDeleteRateLimit, storageUploadRateLimit } from "../middleware/rate-limit";
import { getStorageService, isImageFile, processImageForUpload, uploadFile } from "../services/storage";
const storageService = getStorageService();
const fileSchema = z.file().max(10 * 1024 * 1024, "File size must be less than 10MB");
const filenameSchema = z.object({
filename: z.string().min(1).describe("The path or filename of the file to delete."),
});
function normalizeKey(input: string): string {
return input.trim().replace(/^\/+/, "").split("/").filter(Boolean).join("/");
}
function isUnsafeStorageKey(key: string): boolean {
return key.split("/").some((segment) => segment === "." || segment === "..");
}
export const storageRouter = {
uploadFile: protectedProcedure
.route({
tags: ["Internal"],
operationId: "uploadFile",
summary: "Upload a file",
description:
"Uploads a file to storage. Images are automatically resized and converted to JPEG format. Maximum file size is 10MB. Requires authentication.",
successDescription: "The file was uploaded successfully.",
})
.input(fileSchema)
.use(storageUploadRateLimit)
.output(
z.object({
url: z.string().describe("The public URL to access the uploaded file."),
path: z.string().describe("The storage path of the uploaded file."),
contentType: z.string().describe("The MIME type of the uploaded file."),
}),
)
.handler(async ({ context, input: file }) => {
const originalMimeType = file.type;
const isImage = isImageFile(originalMimeType);
let data: Uint8Array;
let contentType: string;
if (isImage) {
const processed = await processImageForUpload(file);
data = processed.data;
contentType = processed.contentType;
} else {
const fileBuffer = await file.arrayBuffer();
data = new Uint8Array(fileBuffer);
contentType = originalMimeType;
}
const result = await uploadFile({
userId: context.user.id,
data,
contentType,
type: "picture",
});
return {
url: result.url,
path: result.key,
contentType,
};
}),
deleteFile: protectedProcedure
.route({
tags: ["Internal"],
operationId: "deleteFile",
summary: "Delete a file",
description:
"Deletes a file from storage by its filename or path. If the filename does not start with 'uploads/', the user's picture directory is assumed. Requires authentication.",
successDescription: "The file was deleted successfully.",
})
.input(filenameSchema)
.use(storageDeleteRateLimit)
.output(z.void())
.errors({
NOT_FOUND: {
message: "The specified file was not found in storage.",
status: 404,
},
FORBIDDEN: {
message: "You do not have permission to delete this file.",
status: 403,
},
})
.handler(async ({ context, input }): Promise<void> => {
const requestedKey = normalizeKey(input.filename);
const key = requestedKey.startsWith("uploads/")
? requestedKey
: normalizeKey(`uploads/${context.user.id}/pictures/${requestedKey}`);
const userPrefix = `uploads/${context.user.id}/`;
if (isUnsafeStorageKey(key) || !key.startsWith(userPrefix)) {
throw new ORPCError("FORBIDDEN");
}
const deleted = await storageService.delete(key);
if (!deleted) throw new ORPCError("NOT_FOUND");
}),
};
+273
View File
@@ -0,0 +1,273 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import type { ResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ModelMessage, UIMessage } from "ai";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { streamToEventIterator } from "@orpc/server";
import { convertToModelMessages, createGateway, generateText, Output, stepCountIs, streamText, tool } from "ai";
import { createOllama } from "ollama-ai-provider-v2";
import { match } from "ts-pattern";
import { z } from "zod";
import {
analyzeResumeSystemPrompt as analyzeResumeSystemPromptTemplate,
chatSystemPromptTemplate,
docxParserSystemPrompt,
docxParserUserPrompt,
pdfParserSystemPrompt,
pdfParserUserPrompt,
} from "@reactive-resume/ai/prompts";
import { buildAiExtractionTemplate } from "@reactive-resume/ai/resume/extraction-template";
import { sanitizeAndParseResumeJson } from "@reactive-resume/ai/resume/sanitize";
import {
executePatchResume,
patchResumeDescription,
patchResumeInputSchema,
} from "@reactive-resume/ai/tools/patch-resume";
import { AI_PROVIDER_DEFAULT_BASE_URLS, aiProviderSchema } from "@reactive-resume/ai/types";
import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { isPrivateOrLoopbackHost, parseUrl } from "@reactive-resume/utils/url-security";
const aiExtractionTemplate = buildAiExtractionTemplate();
function logAndRethrow(context: string, error: unknown): never {
if (error instanceof Error) {
console.error(`${context}:`, error);
throw error;
}
console.error(`${context}:`, error);
throw new Error(`An unknown error occurred during ${context}.`);
}
function parseAndValidateResumeJson(resultText: string): ResumeData {
const { data, diagnostics } = sanitizeAndParseResumeJson(resultText);
if (diagnostics.coercions.length === 0 && diagnostics.droppedSectionItems.length === 0) return data;
const droppedBySection = diagnostics.droppedSectionItems.reduce<Record<string, number>>((acc, item) => {
acc[item.section] = (acc[item.section] ?? 0) + 1;
return acc;
}, {});
console.info("AI resume sanitization diagnostics", {
coercions: diagnostics.coercions.length,
droppedBySection,
salvageApplied: diagnostics.salvageApplied,
});
return data;
}
type GetModelInput = {
provider: AIProvider;
model: string;
apiKey: string;
baseURL?: string;
};
const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB
const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4;
function resolveBaseUrl(input: GetModelInput): string {
const baseURL = input.baseURL?.trim() || AI_PROVIDER_DEFAULT_BASE_URLS[input.provider];
if (!baseURL) throw new Error("INVALID_AI_BASE_URL");
const parsedBaseURL = parseUrl(baseURL);
if (!parsedBaseURL) throw new Error("INVALID_AI_BASE_URL");
if (parsedBaseURL.protocol !== "https:") throw new Error("INVALID_AI_BASE_URL");
if (parsedBaseURL.username || parsedBaseURL.password) throw new Error("INVALID_AI_BASE_URL");
if (isPrivateOrLoopbackHost(parsedBaseURL.hostname)) throw new Error("INVALID_AI_BASE_URL");
return parsedBaseURL.toString();
}
function getModel(input: GetModelInput) {
const { provider, model, apiKey } = input;
const baseURL = resolveBaseUrl(input);
return match(provider)
.with("openai", () => createOpenAI({ apiKey, baseURL }).chat(model))
.with("anthropic", () => createAnthropic({ apiKey, baseURL }).languageModel(model))
.with("gemini", () => createGoogleGenerativeAI({ apiKey, baseURL }).languageModel(model))
.with("vercel-ai-gateway", () => createGateway({ apiKey, baseURL }).languageModel(model))
.with("openrouter", () => createOpenAICompatible({ name: "openrouter", apiKey, baseURL }).languageModel(model))
.with("ollama", () => {
const ollama = createOllama({
name: "ollama",
baseURL,
...(apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}),
});
return ollama.languageModel(model);
})
.exhaustive();
}
export const aiCredentialsSchema = z.object({
provider: aiProviderSchema,
model: z.string().trim().min(1),
apiKey: z.string().trim().min(1),
baseURL: z.string().optional().default(""),
});
export const fileInputSchema = z.object({
name: z.string(),
data: z.string().max(MAX_AI_FILE_BASE64_CHARS, "File is too large. Maximum size is 10MB."),
});
type TestConnectionInput = z.infer<typeof aiCredentialsSchema>;
async function testConnection(input: TestConnectionInput): Promise<boolean> {
const RESPONSE_OK = "1";
const result = await generateText({
model: getModel(input),
output: Output.choice({ options: [RESPONSE_OK] }),
messages: [{ role: "user", content: `Respond only with JSON Object: { "result": "${RESPONSE_OK}" }` }],
});
return result.output === RESPONSE_OK;
}
type ParsePdfInput = z.infer<typeof aiCredentialsSchema> & {
file: z.infer<typeof fileInputSchema>;
};
type BuildResumeParsingMessagesInput = {
systemPrompt: string;
userPrompt: string;
file: z.infer<typeof fileInputSchema>;
mediaType: string;
};
function buildResumeParsingMessages({
systemPrompt,
userPrompt,
file,
mediaType,
}: BuildResumeParsingMessagesInput): ModelMessage[] {
return [
{
role: "system",
content: `${systemPrompt}\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n${JSON.stringify(aiExtractionTemplate, null, 2)}`,
},
{
role: "user",
content: [
{ type: "text", text: userPrompt },
{ type: "file", data: file.data, mediaType, filename: file.name },
],
},
];
}
async function parsePdf(input: ParsePdfInput): Promise<ResumeData> {
const model = getModel(input);
const result = await generateText({
model,
messages: buildResumeParsingMessages({
systemPrompt: pdfParserSystemPrompt,
userPrompt: pdfParserUserPrompt,
file: input.file,
mediaType: "application/pdf",
}),
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
return parseAndValidateResumeJson(result.text);
}
type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
file: z.infer<typeof fileInputSchema>;
mediaType: "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
};
async function parseDocx(input: ParseDocxInput): Promise<ResumeData> {
const model = getModel(input);
const result = await generateText({
model,
messages: buildResumeParsingMessages({
systemPrompt: docxParserSystemPrompt,
userPrompt: docxParserUserPrompt,
file: input.file,
mediaType: input.mediaType,
}),
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
return parseAndValidateResumeJson(result.text);
}
function buildChatSystemPrompt(resumeData: ResumeData): string {
return chatSystemPromptTemplate.replace("{{RESUME_DATA}}", JSON.stringify(resumeData, null, 2));
}
type ChatInput = z.infer<typeof aiCredentialsSchema> & {
messages: UIMessage[];
resumeData: ResumeData;
};
async function chat(input: ChatInput) {
const model = getModel(input);
const systemPrompt = buildChatSystemPrompt(input.resumeData);
const result = streamText({
model,
system: systemPrompt,
messages: await convertToModelMessages(input.messages),
tools: {
patch_resume: tool({
description: patchResumeDescription,
inputSchema: patchResumeInputSchema,
execute: async ({ operations }) => executePatchResume(input.resumeData, operations),
}),
},
stopWhen: stepCountIs(3),
});
return streamToEventIterator(result.toUIMessageStream());
}
type AnalyzeResumeInput = z.infer<typeof aiCredentialsSchema> & {
resumeData: ResumeData;
};
function buildAnalyzeResumeSystemPrompt(resumeData: ResumeData): string {
return `${analyzeResumeSystemPromptTemplate}\n\n## Resume Data\n\n${JSON.stringify(resumeData, null, 2)}`;
}
async function analyzeResume(input: AnalyzeResumeInput): Promise<ResumeAnalysis> {
const model = getModel(input);
const systemPrompt = buildAnalyzeResumeSystemPrompt(input.resumeData);
const result = await generateText({
model,
output: Output.object({ schema: resumeAnalysisOutputSchema }),
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content:
"Analyze this resume and return a structured report with scorecard, overall score, strengths, and actionable suggestions.",
},
],
});
if (result.output == null) {
throw new Error("AI returned no structured analysis output.");
}
return resumeAnalysisSchema.parse(result.output);
}
export const aiService = {
analyzeResume,
chat,
parseDocx,
parsePdf,
testConnection,
};
+48
View File
@@ -0,0 +1,48 @@
import type { AuthProvider } from "@reactive-resume/auth/types";
import { ORPCError } from "@orpc/client";
import { eq } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { env } from "@reactive-resume/env/server";
import { getStorageService } from "./storage";
export type ProviderList = Partial<Record<AuthProvider, string>>;
const providers = {
list: (): ProviderList => {
const providers: ProviderList = { credential: "Password", passkey: "Passkey" };
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) providers.google = "Google";
if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) providers.github = "GitHub";
if (env.LINKEDIN_CLIENT_ID && env.LINKEDIN_CLIENT_SECRET) providers.linkedin = "LinkedIn";
if (env.OAUTH_CLIENT_ID && env.OAUTH_CLIENT_SECRET) providers.custom = env.OAUTH_PROVIDER_NAME ?? "Custom OAuth";
return providers;
},
};
export const authService = {
providers,
deleteAccount: async (input: { userId: string }): Promise<void> => {
if (!input.userId || input.userId.length === 0) return;
const storageService = getStorageService();
// Delete all user files in one call (pictures, screenshots, pdfs)
// The storage service delete method supports recursive deletion via prefix
try {
await storageService.delete(`uploads/${input.userId}`);
} catch {
// Ignore error and proceed with deleting user
}
try {
await db.delete(schema.user).where(eq(schema.user.id, input.userId));
} catch (err) {
console.error("Failed to delete user record:", err);
throw new ORPCError("INTERNAL_SERVER_ERROR");
}
},
};
+13
View File
@@ -0,0 +1,13 @@
import { env } from "@reactive-resume/env/server";
export type FeatureFlags = {
disableSignups: boolean;
disableEmailAuth: boolean;
};
export const flagsService = {
getFlags: (): FeatureFlags => ({
disableSignups: env.FLAG_DISABLE_SIGNUPS,
disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH,
}),
};
@@ -0,0 +1,78 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { ORPCError } from "@orpc/client";
/**
* Single source of truth for "who can view/edit a resume" and "what fields
* leak to non-owners on the public view path."
*
* Owner-only mutation methods (update / patch / delete / setPassword / …)
* intentionally do **not** call this module — their `WHERE userId = :owner`
* clauses already enforce ownership at the query level. The policy here
* documents that contract and gates the dual-role read path (`getBySlug`)
* where a non-owner viewer can legitimately read a public resume.
*/
type Resume = {
userId: string;
isPublic: boolean;
};
type Viewer = { id: string } | null;
export function isOwner(resume: Resume, viewer: Viewer): boolean {
return viewer !== null && viewer.id === resume.userId;
}
/**
* Throws `NOT_FOUND` (not `FORBIDDEN`) when the viewer is not allowed to see
* the resume — same response as a nonexistent resume so the API does not
* disclose existence of private resumes by id/slug.
*/
export function assertCanView(resume: Resume, viewer: Viewer): void {
if (isOwner(resume, viewer)) return;
if (resume.isPublic) return;
throw new ORPCError("NOT_FOUND");
}
/**
* Redact owner-only fields before serializing a resume to a non-owner viewer.
*
* Stripped on public view:
* - `resume.name` — the dashboard title chosen by the owner (often
* contains personal context like "Senior Eng @ Foo — final draft").
* - `resume.data.metadata.notes` — explicitly documented as "only visible
* to the author when editing" in the resume schema.
*
* Everything else (including `data.basics.name`, the person's name on the
* resume itself) is part of the public payload and is returned unchanged.
*
* Owner views pass through untouched.
*/
export function redactResumeForViewer<T extends { name: string; data: ResumeData }>(
resume: T,
viewerIsOwner: boolean,
): T {
if (viewerIsOwner) return resume;
return {
...resume,
name: "",
data: {
...resume.data,
metadata: {
...resume.data.metadata,
notes: "",
},
},
};
}
/**
* Owner self-views/downloads do not count toward the public statistics —
* the dashboard would otherwise inflate metrics every time the author
* previewed their own resume. Call sites that increment `views` /
* `downloads` should gate on this helper.
*/
export function shouldCountForStatistics(resume: Resume, viewer: Viewer): boolean {
return !isOwner(resume, viewer);
}
+451
View File
@@ -0,0 +1,451 @@
import type { StoredResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Locale } from "@reactive-resume/utils/locale";
import type { Operation } from "fast-json-patch";
import { ORPCError } from "@orpc/client";
import { compare, hash } from "bcrypt";
import { and, arrayContains, asc, desc, eq, isNotNull, sql } from "drizzle-orm";
import { get } from "es-toolkit/compat";
import { match } from "ts-pattern";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { applyResumePatches, ResumePatchError } from "@reactive-resume/utils/resume/patch";
import { generateId } from "@reactive-resume/utils/string";
import { grantResumeAccess, hasResumeAccess } from "../helpers/resume-access";
import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./resume-access-policy";
import { getStorageService } from "./storage";
const tags = {
list: async (input: { userId: string }) => {
const result = await db
.select({ tags: schema.resume.tags })
.from(schema.resume)
.where(eq(schema.resume.userId, input.userId));
const uniqueTags = new Set(result.flatMap((tag) => tag.tags));
const sortedTags = Array.from(uniqueTags).sort((a, b) => a.localeCompare(b));
return sortedTags;
},
};
const statistics = {
getById: async (input: { id: string; userId: string }) => {
const [statistics] = await db
.select({
isPublic: schema.resume.isPublic,
views: schema.resumeStatistics.views,
downloads: schema.resumeStatistics.downloads,
lastViewedAt: schema.resumeStatistics.lastViewedAt,
lastDownloadedAt: schema.resumeStatistics.lastDownloadedAt,
})
.from(schema.resumeStatistics)
.rightJoin(schema.resume, eq(schema.resumeStatistics.resumeId, schema.resume.id))
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!statistics) throw new ORPCError("NOT_FOUND");
return {
isPublic: statistics.isPublic,
views: statistics.views ?? 0,
downloads: statistics.downloads ?? 0,
lastViewedAt: statistics.lastViewedAt,
lastDownloadedAt: statistics.lastDownloadedAt,
};
},
increment: async (input: { id: string; views?: boolean; downloads?: boolean }) => {
const views = input.views ? 1 : 0;
const downloads = input.downloads ? 1 : 0;
const lastViewedAt = input.views ? sql`now()` : undefined;
const lastDownloadedAt = input.downloads ? sql`now()` : undefined;
await db
.insert(schema.resumeStatistics)
.values({
resumeId: input.id,
views,
downloads,
lastViewedAt,
lastDownloadedAt,
})
.onConflictDoUpdate({
target: [schema.resumeStatistics.resumeId],
set: {
views: sql`${schema.resumeStatistics.views} + ${views}`,
downloads: sql`${schema.resumeStatistics.downloads} + ${downloads}`,
lastViewedAt,
lastDownloadedAt,
},
});
},
};
const analysis = {
getById: async (input: { id: string; userId: string }) => {
const [result] = await db
.select({ analysis: schema.resumeAnalysis.analysis })
.from(schema.resume)
.leftJoin(schema.resumeAnalysis, eq(schema.resumeAnalysis.resumeId, schema.resume.id))
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!result) throw new ORPCError("NOT_FOUND");
return result.analysis ?? null;
},
upsert: async (input: { id: string; userId: string; analysis: StoredResumeAnalysis }) => {
const [resume] = await db
.select({ id: schema.resume.id })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
await db
.insert(schema.resumeAnalysis)
.values({
resumeId: input.id,
analysis: input.analysis,
})
.onConflictDoUpdate({
target: [schema.resumeAnalysis.resumeId],
set: {
analysis: input.analysis,
},
});
return input.analysis;
},
};
function toSharedResumeResponse<TPassword extends boolean>(
resume: {
id: string;
name: string;
slug: string;
tags: string[];
data: ResumeData;
isPublic: boolean;
isLocked: boolean;
},
hasPassword: TPassword,
) {
return {
id: resume.id,
name: resume.name,
slug: resume.slug,
tags: resume.tags,
data: resume.data,
isPublic: resume.isPublic,
isLocked: resume.isLocked,
hasPassword,
};
}
export const resumeService = {
tags,
statistics,
analysis,
list: async (input: { userId: string; tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) => {
return await db
.select({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
createdAt: schema.resume.createdAt,
updatedAt: schema.resume.updatedAt,
})
.from(schema.resume)
.where(
and(
eq(schema.resume.userId, input.userId),
match(input.tags.length)
.with(0, () => undefined)
.otherwise(() => arrayContains(schema.resume.tags, input.tags)),
),
)
.orderBy(
match(input.sort)
.with("lastUpdatedAt", () => desc(schema.resume.updatedAt))
.with("createdAt", () => asc(schema.resume.createdAt))
.with("name", () => asc(schema.resume.name))
.exhaustive(),
);
},
getById: async (input: { id: string; userId: string }) => {
const [resume] = await db
.select({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
})
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
return resume;
},
getBySlug: async (input: { username: string; slug: string; currentUserId?: string }) => {
const [resume] = await db
.select({
id: schema.resume.id,
userId: schema.resume.userId,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
passwordHash: schema.resume.password,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
})
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(and(eq(schema.resume.slug, input.slug), eq(schema.user.username, input.username)));
if (!resume) throw new ORPCError("NOT_FOUND");
const viewer = input.currentUserId ? { id: input.currentUserId } : null;
assertCanView(resume, viewer);
if (resume.hasPassword && !hasResumeAccess(resume.id, resume.passwordHash)) {
throw new ORPCError("NEED_PASSWORD", {
status: 401,
data: { username: input.username, slug: input.slug },
});
}
if (shouldCountForStatistics(resume, viewer)) {
await resumeService.statistics.increment({ id: resume.id, views: true });
}
return toSharedResumeResponse(redactResumeForViewer(resume, isOwner(resume, viewer)), resume.hasPassword);
},
create: async (input: {
userId: string;
name: string;
slug: string;
tags: string[];
locale: Locale;
data?: ResumeData;
}) => {
const id = generateId();
const data = input.data ?? defaultResumeData;
data.metadata.page.locale = input.locale;
try {
await db.insert(schema.resume).values({
id,
name: input.name,
slug: input.slug,
tags: input.tags,
userId: input.userId,
data,
});
return id;
} catch (error) {
const constraint = get(error, "cause.constraint") as string | undefined;
if (constraint === "resume_slug_user_id_unique") {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
}
console.error("Failed to create resume:", error);
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to create resume" });
}
},
update: async (input: {
id: string;
userId: string;
name?: string;
slug?: string;
tags?: string[];
data?: ResumeData;
isPublic?: boolean;
}) => {
const [resume] = await db
.select({ isLocked: schema.resume.isLocked })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (resume?.isLocked) throw new ORPCError("RESUME_LOCKED");
const updateData: Partial<typeof schema.resume.$inferSelect> = {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.slug !== undefined ? { slug: input.slug } : {}),
...(input.tags !== undefined ? { tags: input.tags } : {}),
...(input.data !== undefined ? { data: input.data } : {}),
...(input.isPublic !== undefined ? { isPublic: input.isPublic } : {}),
};
try {
const [resume] = await db
.update(schema.resume)
.set(updateData)
.where(
and(
eq(schema.resume.id, input.id),
eq(schema.resume.isLocked, false),
eq(schema.resume.userId, input.userId),
),
)
.returning({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
});
if (!resume) throw new ORPCError("NOT_FOUND");
return resume;
} catch (error) {
if (get(error, "cause.constraint") === "resume_slug_user_id_unique") {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
}
console.error("Failed to update resume:", error);
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to update resume" });
}
},
patch: async (input: { id: string; userId: string; operations: Operation[] }) => {
const [existing] = await db
.select({ data: schema.resume.data, isLocked: schema.resume.isLocked })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!existing) throw new ORPCError("NOT_FOUND");
if (existing.isLocked) throw new ORPCError("RESUME_LOCKED");
let patchedData: ResumeData;
try {
patchedData = applyResumePatches(existing.data, input.operations);
} catch (error) {
if (error instanceof ResumePatchError) {
throw new ORPCError("INVALID_PATCH_OPERATIONS", {
status: 400,
message: error.message,
data: { code: error.code, index: error.index, operation: error.operation },
});
}
throw new ORPCError("INVALID_PATCH_OPERATIONS", {
status: 400,
message: error instanceof Error ? error.message : "Failed to apply patch operations",
});
}
const [resume] = await db
.update(schema.resume)
.set({ data: patchedData })
.where(
and(eq(schema.resume.id, input.id), eq(schema.resume.isLocked, false), eq(schema.resume.userId, input.userId)),
)
.returning({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
});
if (!resume) throw new ORPCError("NOT_FOUND");
return resume;
},
setLocked: async (input: { id: string; userId: string; isLocked: boolean }) => {
await db
.update(schema.resume)
.set({ isLocked: input.isLocked })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
},
setPassword: async (input: { id: string; userId: string; password: string }) => {
const hashedPassword = await hash(input.password, 10);
await db
.update(schema.resume)
.set({ password: hashedPassword })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
},
verifyPassword: async (input: { slug: string; username: string; password: string }) => {
const [resume] = await db
.select({ id: schema.resume.id, password: schema.resume.password })
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(
and(
isNotNull(schema.resume.password),
eq(schema.resume.slug, input.slug),
eq(schema.user.username, input.username),
),
);
if (!resume) throw new ORPCError("INVALID_PASSWORD", { status: 401 });
const passwordHash = resume.password as string;
const isValid = await compare(input.password, passwordHash);
if (!isValid) throw new ORPCError("INVALID_PASSWORD", { status: 401 });
grantResumeAccess(resume.id, passwordHash);
return true;
},
removePassword: async (input: { id: string; userId: string }) => {
await db
.update(schema.resume)
.set({ password: null })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
},
delete: async (input: { id: string; userId: string }) => {
await db.transaction(async (tx) => {
const [resume] = await tx
.select({ isLocked: schema.resume.isLocked })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
if (resume.isLocked) throw new ORPCError("RESUME_LOCKED");
await tx.delete(schema.resume).where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
});
// Clean up storage files after the DB transaction succeeds
const storageService = getStorageService();
await Promise.allSettled([
storageService.delete(`uploads/${input.userId}/screenshots/${input.id}`),
storageService.delete(`uploads/${input.userId}/pdfs/${input.id}`),
]);
},
};
+127
View File
@@ -0,0 +1,127 @@
import fs from "node:fs/promises";
import { dirname, join } from "node:path";
import { count } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
const CACHE_DURATION_MS = 6 * 60 * 60 * 1000; // 6 hours
const GITHUB_API_URL = "https://api.github.com/repos/amruthpillai/reactive-resume";
const GITHUB_REQUEST_TIMEOUT_MS = 5_000;
const GITHUB_REQUEST_MAX_ATTEMPTS = 2;
const LAST_KNOWN = {
users: 978_528,
resumes: 1_336_307,
stars: 34_073,
} as const;
const getCachePath = (key: string) => join(process.cwd(), "data", "statistics", `${key}.txt`);
const readCache = async (key: string): Promise<number | null> => {
try {
const filePath = getCachePath(key);
const [stats, contents] = await Promise.all([fs.stat(filePath), fs.readFile(filePath, "utf-8")]);
if (stats.mtimeMs < Date.now() - CACHE_DURATION_MS) return null;
const value = Number.parseInt(contents, 10);
return Number.isFinite(value) && value >= 0 ? value : null;
} catch {
return null;
}
};
const writeCache = async (key: string, value: number) => {
try {
const filePath = getCachePath(key);
await fs.mkdir(dirname(filePath), { recursive: true });
const contents = String(value);
try {
if ((await fs.readFile(filePath, "utf-8")) === contents) return;
} catch {
// Cache file does not exist yet.
}
await fs.writeFile(filePath, contents, "utf-8");
} catch {
// Ignore errors, cache is not critical
}
};
const getCachedCount = async (
key: string,
lastKnown: number,
fetcher: () => Promise<number | null>,
): Promise<number> => {
const cached = await readCache(key);
if (cached !== null) return cached;
try {
const value = await fetcher();
if (value !== null) {
await writeCache(key, value);
return value;
}
} catch {
// Ignore errors, use last known value
}
return lastKnown;
};
const getCountFromDatabase = async (table: typeof schema.user | typeof schema.resume): Promise<number | null> => {
const [result] = await db.select({ count: count() }).from(table);
if (!result) return null;
return result.count;
};
const fetchGitHubStarsOnce = async (): Promise<number | null> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), GITHUB_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(GITHUB_API_URL, {
signal: controller.signal,
headers: {
Accept: "application/vnd.github+json",
},
});
if (!response.ok) return null;
const data = (await response.json()) as { stargazers_count?: unknown };
const stars = Number(data.stargazers_count);
return Number.isFinite(stars) && stars > 0 ? stars : null;
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
};
const getGitHubStars = async (): Promise<number | null> => {
for (let attempt = 0; attempt < GITHUB_REQUEST_MAX_ATTEMPTS; attempt++) {
const stars = await fetchGitHubStarsOnce();
if (stars !== null) return stars;
}
return null;
};
export const statisticsService = {
user: {
getCount: () => {
return getCachedCount("users", LAST_KNOWN.users, () => getCountFromDatabase(schema.user));
},
},
resume: {
getCount: () => {
return getCachedCount("resumes", LAST_KNOWN.resumes, () => getCountFromDatabase(schema.resume));
},
},
github: {
getStarCount: () => {
return getCachedCount("stars", LAST_KNOWN.stars, getGitHubStars);
},
},
};
+396
View File
@@ -0,0 +1,396 @@
import fs from "node:fs/promises";
import { dirname, extname, join } from "node:path";
import {
DeleteObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import sharp from "sharp";
import { env } from "@reactive-resume/env/server";
interface StorageWriteInput {
key: string;
data: Uint8Array;
contentType: string;
}
interface StorageReadResult {
data: Uint8Array;
size: number;
etag?: string;
lastModified?: Date;
contentType?: string;
}
interface StorageService {
list(prefix: string): Promise<string[]>;
write(input: StorageWriteInput): Promise<void>;
read(key: string): Promise<StorageReadResult | null>;
delete(key: string): Promise<boolean>;
healthcheck(): Promise<StorageHealthResult>;
}
interface StorageHealthResult {
status: "healthy" | "unhealthy";
type: "local" | "s3";
message: string;
error?: string;
}
const CONTENT_TYPE_MAP: Record<string, string> = {
".webp": "image/webp",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".svg": "image/svg+xml",
".pdf": "application/pdf",
};
const DEFAULT_CONTENT_TYPE = "application/octet-stream";
const IMAGE_MIME_TYPES = ["image/gif", "image/png", "image/jpeg", "image/webp"];
// Key builders for different upload types
function buildPictureKey(userId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/pictures/${timestamp}.jpeg`;
}
function buildScreenshotKey(userId: string, resumeId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/screenshots/${resumeId}/${timestamp}.jpeg`;
}
function buildPdfKey(userId: string, resumeId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/pdfs/${resumeId}/${timestamp}.pdf`;
}
function buildPublicUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const apiPath = normalizedPath.startsWith("/api/") ? normalizedPath : `/api${normalizedPath}`;
return new URL(apiPath, env.APP_URL).toString();
}
export function inferContentType(filename: string): string {
const extension = extname(filename).toLowerCase();
return CONTENT_TYPE_MAP[extension] ?? DEFAULT_CONTENT_TYPE;
}
export function isImageFile(mimeType: string): boolean {
return IMAGE_MIME_TYPES.includes(mimeType);
}
interface ProcessedImage {
data: Uint8Array;
contentType: string;
}
export async function processImageForUpload(file: File): Promise<ProcessedImage> {
const fileBuffer = await file.arrayBuffer();
if (env.FLAG_DISABLE_IMAGE_PROCESSING) {
return {
data: new Uint8Array(fileBuffer),
contentType: file.type,
};
}
const processedBuffer = await sharp(fileBuffer)
.resize(800, 800, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
return {
data: new Uint8Array(processedBuffer),
contentType: "image/jpeg",
};
}
class LocalStorageService implements StorageService {
private rootDirectory: string;
constructor() {
this.rootDirectory = join(process.cwd(), "data");
}
async list(prefix: string): Promise<string[]> {
const fullPath = this.resolvePath(prefix);
try {
const files = await fs.readdir(fullPath, { recursive: true });
return files.map((file) => join(prefix, file));
} catch (error: unknown) {
// If directory doesn't exist, return empty array
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return [];
}
throw error;
}
}
async write({ key, data }: StorageWriteInput): Promise<void> {
const fullPath = this.resolvePath(key);
await fs.mkdir(dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, data);
}
async read(key: string): Promise<StorageReadResult | null> {
const fullPath = this.resolvePath(key);
try {
const [arrayBuffer, stats] = await Promise.all([fs.readFile(fullPath), fs.stat(fullPath)]);
return {
data: arrayBuffer,
size: stats.size,
etag: `"${stats.size}-${stats.mtime.getTime()}"`,
lastModified: stats.mtime,
contentType: inferContentType(key),
};
} catch (error: unknown) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return null;
}
throw error;
}
}
async delete(key: string): Promise<boolean> {
const fullPath = this.resolvePath(key);
// Check if the path exists and whether it's a file or folder
try {
const stats = await fs.stat(fullPath);
if (stats.isDirectory()) {
// Delete the directory and its contents recursively
await fs.rm(fullPath, { recursive: true });
return true;
}
await fs.unlink(fullPath);
return true;
} catch {
// Path does not exist
return false;
}
}
async healthcheck(): Promise<StorageHealthResult> {
try {
await fs.mkdir(this.rootDirectory, { recursive: true });
await fs.access(this.rootDirectory, fs.constants.R_OK | fs.constants.W_OK);
return {
type: "local",
status: "healthy",
message: "Local filesystem storage is accessible and has read/write permission.",
};
} catch (error: unknown) {
return {
type: "local",
status: "unhealthy",
message: "Local filesystem storage is not accessible or lacks sufficient permissions.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
private resolvePath(key: string): string {
const normalizedKey = key.replace(/^\/*/, "");
const segments = normalizedKey
.split(/[/\\]+/)
.filter((segment) => segment.length > 0 && segment !== "." && segment !== "..");
if (segments.length === 0) throw new Error("Invalid storage key");
return join(this.rootDirectory, ...segments);
}
}
class S3StorageService implements StorageService {
private readonly bucket: string;
private readonly accessKeyId: string;
private readonly secretAccessKey: string;
private readonly endpoint: string | undefined;
private readonly clientPromise: Promise<S3Client>;
constructor() {
if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY || !env.S3_BUCKET) {
throw new Error("S3 credentials are not set");
}
this.bucket = env.S3_BUCKET;
this.accessKeyId = env.S3_ACCESS_KEY_ID;
this.secretAccessKey = env.S3_SECRET_ACCESS_KEY;
this.endpoint = env.S3_ENDPOINT;
this.clientPromise = this.createClient();
}
private async createClient(): Promise<S3Client> {
return new S3Client({
region: env.S3_REGION,
forcePathStyle: env.S3_FORCE_PATH_STYLE,
...(this.endpoint ? { endpoint: this.endpoint } : {}),
credentials: {
accessKeyId: this.accessKeyId,
secretAccessKey: this.secretAccessKey,
},
});
}
private async getClient(): Promise<S3Client> {
return this.clientPromise;
}
async list(prefix: string): Promise<string[]> {
const client = await this.getClient();
const command = new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix });
const response = await client.send(command);
if (!response.Contents) return [];
return response.Contents.map((object) => object.Key ?? "");
}
async write({ key, data, contentType }: StorageWriteInput): Promise<void> {
const client = await this.getClient();
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: data,
ACL: "public-read",
ContentType: contentType,
});
await client.send(command);
}
async read(key: string): Promise<StorageReadResult | null> {
try {
const client = await this.getClient();
const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
const response = await client.send(command);
if (!response.Body) return null;
const arrayBuffer = await response.Body.transformToByteArray();
return {
data: arrayBuffer,
size: response.ContentLength ?? 0,
contentType: response.ContentType ?? inferContentType(key),
...(response.ETag !== undefined ? { etag: response.ETag } : {}),
...(response.LastModified !== undefined ? { lastModified: response.LastModified } : {}),
};
} catch {
return null;
}
}
async delete(keyOrPrefix: string): Promise<boolean> {
const client = await this.getClient();
// Use list to find all matching keys (handles both single file and folder/prefix)
const keys = await this.list(keyOrPrefix);
if (keys.length === 0) return false;
// Delete all matching keys using Promise.allSettled
const deleteCommands = keys.map((k) => new DeleteObjectCommand({ Bucket: this.bucket, Key: k }));
const results = await Promise.allSettled(deleteCommands.map((c) => client.send(c)));
// Return true if at least one deletion succeeded
return results.some((r) => r.status === "fulfilled");
}
async healthcheck(): Promise<StorageHealthResult> {
try {
const client = await this.getClient();
const putCommand = new PutObjectCommand({ Bucket: this.bucket, Key: "healthcheck", Body: "OK" });
await client.send(putCommand);
const deleteCommand = new DeleteObjectCommand({ Bucket: this.bucket, Key: "healthcheck" });
await client.send(deleteCommand);
return {
type: "s3",
status: "healthy",
message: "S3 storage is accessible and credentials are valid.",
};
} catch (error: unknown) {
return {
type: "s3",
status: "unhealthy",
message: "Failed to connect to S3 storage or invalid credentials.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
}
function createStorageService(): StorageService {
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) {
return new S3StorageService();
}
return new LocalStorageService();
}
let cachedService: StorageService | null = null;
export function getStorageService(): StorageService {
if (cachedService) return cachedService;
cachedService = createStorageService();
return cachedService;
}
// High-level upload types
type UploadType = "picture" | "screenshot" | "pdf";
interface UploadFileInput {
userId: string;
data: Uint8Array;
contentType: string;
type: UploadType;
resumeId?: string;
}
interface UploadFileResult {
url: string;
key: string;
}
export async function uploadFile(input: UploadFileInput): Promise<UploadFileResult> {
const storageService = getStorageService();
let key: string;
switch (input.type) {
case "picture":
key = buildPictureKey(input.userId);
break;
case "screenshot":
if (!input.resumeId) throw new Error("resumeId is required for screenshot uploads");
key = buildScreenshotKey(input.userId, input.resumeId);
break;
case "pdf":
if (!input.resumeId) throw new Error("resumeId is required for pdf uploads");
key = buildPdfKey(input.userId, input.resumeId);
break;
}
await storageService.write({
key,
data: input.data,
contentType: input.contentType,
});
return {
key,
url: buildPublicUrl(key),
};
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "@reactive-resume/config/tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["ESNext", "DOM"]
}
}
+7
View File
@@ -0,0 +1,7 @@
import { fileURLToPath } from "node:url";
import { createVitestProjectConfig } from "../../vitest.shared";
export default createVitestProjectConfig({
name: "@reactive-resume/api",
dirname: fileURLToPath(new URL(".", import.meta.url)),
});