This commit is contained in:
Amruth Pillai
2026-02-09 01:50:31 +01:00
committed by GitHub
parent 2b8fa9c7e8
commit 90c34ca572
95 changed files with 3615 additions and 450 deletions
@@ -0,0 +1,65 @@
You are an expert resume writer and a specialist in JSON Patch (RFC 6902) operations. Your role is to help the user improve and modify their resume through natural conversation.
## Your Capabilities
- You can read and understand the user's current resume data (provided below).
- You can modify the resume by calling the `patch_resume` tool with JSON Patch operations.
- You can advise on resume best practices, wording, and structure.
## Rules
1. **Always use the `patch_resume` tool** to make changes. Never output raw JSON or patch operations in your text response.
2. **Generate the minimal set of operations** needed for each change. Do not replace entire objects when only a single field needs updating.
3. **Preserve existing data** unless the user explicitly asks to remove or replace it.
4. **Confirm before destructive actions** like removing sections or clearing large amounts of content.
5. **Stay on topic.** Only discuss resume-related content. Politely decline off-topic requests.
6. **Do not fabricate content.** Only add information the user provides or explicitly asks you to generate. If generating content (e.g. a summary), make it clear you are drafting and ask for approval.
7. **HTML content fields** (description, summary content, cover letter content) must use valid HTML. Use `<p>` for paragraphs, `<ul>`/`<li>` for lists, `<strong>` for bold, `<em>` for italic.
8. **IDs for new items** must be valid UUIDs (use the format `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`).
## Resume Data Structure
The resume data is a JSON object with these top-level keys:
- `basics` — name, headline, email, phone, location, website, customFields
- `summary` — title, content (HTML), columns, hidden
- `picture` — url, size, rotation, aspectRatio, border/shadow settings
- `sections` — built-in sections, each with title, columns, hidden, items[]
- `profiles` — items: { network, username, icon, website }
- `experience` — items: { company, position, location, period, website, description }
- `education` — items: { school, degree, area, grade, location, period, website, description }
- `projects` — items: { name, period, website, description }
- `skills` — items: { name, proficiency, level, keywords[], icon }
- `languages` — items: { language, fluency, level }
- `interests` — items: { name, keywords[], icon }
- `awards` — items: { title, awarder, date, website, description }
- `certifications` — items: { title, issuer, date, website, description }
- `publications` — items: { title, publisher, date, website, description }
- `volunteer` — items: { organization, location, period, website, description }
- `references` — items: { name, position, website, phone, description }
- `customSections` — array of user-created sections with id, type, title, items[]
- `metadata` — template, layout, css, page, design, typography, notes
Every item in a section has: `id` (UUID), `hidden` (boolean), and optionally `options`.
Every `website` field is an object: `{ url: string, label: string }`.
## JSON Patch Path Examples
| Action | Operation |
|--------|-----------|
| Change name | `{ "op": "replace", "path": "/basics/name", "value": "Jane Doe" }` |
| Update headline | `{ "op": "replace", "path": "/basics/headline", "value": "Senior Engineer" }` |
| Replace summary content | `{ "op": "replace", "path": "/summary/content", "value": "<p>Experienced engineer...</p>" }` |
| Add experience item | `{ "op": "add", "path": "/sections/experience/items/-", "value": { ...full item object } }` |
| Remove skill at index 2 | `{ "op": "remove", "path": "/sections/skills/items/2" }` |
| Update a specific item field | `{ "op": "replace", "path": "/sections/experience/items/0/company", "value": "New Corp" }` |
| Change template | `{ "op": "replace", "path": "/metadata/template", "value": "bronzor" }` |
| Change primary color | `{ "op": "replace", "path": "/metadata/design/colors/primary", "value": "rgba(37, 99, 235, 1)" }` |
| Hide a section | `{ "op": "replace", "path": "/sections/interests/hidden", "value": true }` |
| Rename a section title | `{ "op": "replace", "path": "/sections/experience/title", "value": "Work History" }` |
## Current Resume Data
```json
{{RESUME_DATA}}
```
+22
View File
@@ -0,0 +1,22 @@
import type { Operation } from "fast-json-patch";
import z from "zod";
import type { ResumeData } from "@/schema/resume/data";
import { applyResumePatches, jsonPatchOperationSchema } from "@/utils/resume/patch";
export const patchResumeInputSchema = z.object({
operations: z
.array(jsonPatchOperationSchema)
.min(1)
.describe("Array of JSON Patch (RFC 6902) operations to apply to the resume"),
});
export const patchResumeDescription = `Apply JSON Patch (RFC 6902) operations to modify the user's resume data.
Use this tool whenever the user asks to change, add, or remove content from their resume.
Always generate the minimal set of operations needed. Prefer "replace" for updates, "add" for new content, "remove" for deletions.
Use the special "-" index to append to arrays (e.g. "/sections/experience/items/-").`;
export function executePatchResume(resumeData: ResumeData, operations: Operation[]) {
// Validates operations structurally and against the schema; throws on invalid
applyResumePatches(resumeData, operations);
return { success: true as const, appliedOperations: operations };
}
+2 -1
View File
@@ -2,7 +2,7 @@ import { BetterAuthError } from "@better-auth/core/error";
import { passkey } from "@better-auth/passkey";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { betterAuth } from "better-auth/minimal";
import { apiKey, type GenericOAuthConfig, genericOAuth, twoFactor } from "better-auth/plugins";
import { apiKey, type GenericOAuthConfig, genericOAuth, openAPI, twoFactor } from "better-auth/plugins";
import { username } from "better-auth/plugins/username";
import { tanstackStartCookies } from "better-auth/tanstack-start";
import { and, eq, or } from "drizzle-orm";
@@ -211,6 +211,7 @@ const getAuthConfig = () => {
},
plugins: [
openAPI(),
apiKey({
enableSessionForAPIKeys: true,
rateLimit: {
+21 -6
View File
@@ -1,6 +1,6 @@
import { createORPCClient } from "@orpc/client";
import { createORPCClient, onError } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { BatchLinkPlugin } from "@orpc/client/plugins";
import { BatchLinkPlugin, SimpleCsrfProtectionLinkPlugin } from "@orpc/client/plugins";
import { createRouterClient, type InferRouterInputs, type InferRouterOutputs, type RouterClient } from "@orpc/server";
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import { createIsomorphicFn } from "@tanstack/react-start";
@@ -11,6 +11,11 @@ import { getLocale } from "@/utils/locale";
export const getORPCClient = createIsomorphicFn()
.server((): RouterClient<typeof router> => {
return createRouterClient(router, {
interceptors: [
onError((error) => {
console.error(`ERROR [oRPC]: ${error}`);
}),
],
context: async () => {
const locale = await getLocale();
const reqHeaders = getRequestHeaders();
@@ -25,10 +30,20 @@ export const getORPCClient = createIsomorphicFn()
.client((): RouterClient<typeof router> => {
const link = new RPCLink({
url: `${window.location.origin}/api/rpc`,
plugins: [new BatchLinkPlugin({ groups: [{ condition: () => true, context: {} }] })],
fetch: (request, init) => {
return fetch(request, { ...init, credentials: "include" });
},
fetch: (request, init) => fetch(request, { ...init, credentials: "include" }),
plugins: [
new SimpleCsrfProtectionLinkPlugin(),
new BatchLinkPlugin({
mode: typeof window === "undefined" ? "buffered" : "streaming",
groups: [{ condition: () => true, context: {} }],
}),
],
interceptors: [
onError((error) => {
if (error instanceof DOMException && error.name === "AbortError") return;
console.error(`ERROR [oRPC]: ${error}`);
}),
],
});
return createORPCClient(link);
+88 -3
View File
@@ -1,11 +1,26 @@
import { ORPCError } from "@orpc/client";
import { AISDKError } from "ai";
import { type } from "@orpc/server";
import { AISDKError, type UIMessage } from "ai";
import { OllamaError } from "ai-sdk-ollama";
import z, { ZodError } from "zod";
import type { ResumeData } from "@/schema/resume/data";
import { protectedProcedure } from "../context";
import { aiCredentialsSchema, aiProviderSchema, aiService, fileInputSchema, formatZodError } from "../services/ai";
type AIProvider = z.infer<typeof aiProviderSchema>;
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, and Vercel AI Gateway. Requires authentication.",
successDescription: "The AI provider connection was successful.",
})
.input(
z.object({
provider: aiProviderSchema,
@@ -14,11 +29,17 @@ export const aiRouter = {
baseURL: z.string(),
}),
)
.errors({
BAD_GATEWAY: {
message: "The AI provider returned an error or is unreachable.",
status: 502,
},
})
.handler(async ({ input }) => {
try {
return await aiService.testConnection(input);
} catch (error) {
if (error instanceof AISDKError) {
if (error instanceof AISDKError || error instanceof OllamaError) {
throw new ORPCError("BAD_GATEWAY", { message: error.message });
}
@@ -27,13 +48,29 @@ export const aiRouter = {
}),
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,
}),
)
.handler(async ({ input }) => {
.errors({
BAD_GATEWAY: {
message: "The AI provider returned an error or is unreachable.",
status: 502,
},
})
.handler(async ({ input }): Promise<ResumeData> => {
try {
return await aiService.parsePdf(input);
} catch (error) {
@@ -49,6 +86,16 @@ export const aiRouter = {
}),
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,
@@ -59,6 +106,12 @@ export const aiRouter = {
]),
}),
)
.errors({
BAD_GATEWAY: {
message: "The AI provider returned an error or is unreachable.",
status: 502,
},
})
.handler(async ({ input }) => {
try {
return await aiService.parseDocx(input);
@@ -71,6 +124,38 @@ export const aiRouter = {
throw new Error(formatZodError(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;
}>(),
)
.handler(async ({ input }) => {
try {
return await aiService.chat(input);
} catch (error) {
if (error instanceof AISDKError || error instanceof OllamaError) {
throw new ORPCError("BAD_GATEWAY", { message: error.message });
}
throw error;
}
}),
+10 -30
View File
@@ -1,4 +1,3 @@
import z from "zod";
import { protectedProcedure, publicProcedure } from "../context";
import { authService, type ProviderList } from "../services/auth";
@@ -7,48 +6,29 @@ export const authRouter = {
list: publicProcedure
.route({
method: "GET",
path: "/auth/providers/list",
path: "/auth/providers",
tags: ["Authentication"],
summary: "List all auth providers",
operationId: "listAuthProviders",
summary: "List authentication providers",
description:
"A list of all authentication providers, and their display names, supported by the instance of Reactive Resume.",
"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, and custom OAuth. No authentication required.",
successDescription: "A map of enabled authentication provider identifiers to their display names.",
})
.handler((): ProviderList => {
return authService.providers.list();
}),
},
verifyResumePassword: publicProcedure
.route({
method: "POST",
path: "/auth/verify-resume-password",
tags: ["Authentication", "Resume"],
summary: "Verify resume password",
description: "Verify a resume password, to grant access to the locked resume.",
})
.input(
z.object({
slug: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
}),
)
.output(z.boolean())
.handler(async ({ input }): Promise<boolean> => {
return await authService.verifyResumePassword({
slug: input.slug,
username: input.username,
password: input.password,
});
}),
deleteAccount: protectedProcedure
.route({
method: "DELETE",
path: "/auth/delete-account",
path: "/auth/account",
tags: ["Authentication"],
operationId: "deleteAccount",
summary: "Delete user account",
description: "Delete the authenticated user's account and all associated data.",
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 });
+11 -1
View File
@@ -1,3 +1,4 @@
import z from "zod";
import { publicProcedure } from "../context";
import { type FeatureFlags, flagsService } from "../services/flags";
@@ -7,8 +8,17 @@ export const flagsRouter = {
method: "GET",
path: "/flags",
tags: ["Feature Flags"],
operationId: "getFeatureFlags",
summary: "Get feature flags",
description: "Returns the current feature flags for this instance.",
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()),
};
+16 -10
View File
@@ -7,13 +7,16 @@ export const printerRouter = {
printResumeAsPDF: publicProcedure
.route({
method: "GET",
path: "/printer/resume/{id}/pdf",
tags: ["Resume", "Printer"],
path: "/resumes/{id}/pdf",
tags: ["Resume Export"],
operationId: "exportResumePdf",
summary: "Export resume as PDF",
description: "Export a resume as a PDF. Returns a URL to download the PDF.",
description:
"Generates a PDF from the specified resume and uploads it to storage. Returns a URL to download the generated PDF file. If the request is made by an unauthenticated user (e.g. via a public share link), the resume's download count is incremented. Authentication is optional.",
successDescription: "The PDF was generated successfully. Returns a URL to download the file.",
})
.input(z.object({ id: z.string() }))
.output(z.object({ url: z.string() }))
.input(z.object({ id: z.string().describe("The unique identifier of the resume to export.") }))
.output(z.object({ url: z.string().describe("The URL to download the generated PDF file.") }))
.handler(async ({ input, context }) => {
const { id, data, userId } = await resumeService.getByIdForPrinter({ id: input.id });
const url = await printerService.printResumeAsPDF({ id, data, userId });
@@ -28,13 +31,16 @@ export const printerRouter = {
getResumeScreenshot: protectedProcedure
.route({
method: "GET",
path: "/printer/resume/{id}/screenshot",
tags: ["Resume", "Printer"],
path: "/resumes/{id}/screenshot",
tags: ["Resume Export"],
operationId: "getResumeScreenshot",
summary: "Get resume screenshot",
description: "Get a screenshot of a resume. Returns a URL to the screenshot image.",
description:
"Returns a URL to a screenshot image of the first page of the specified resume. Screenshots are cached for up to 6 hours and regenerated automatically when the resume is updated. Returns null if the screenshot cannot be generated. Requires authentication.",
successDescription: "The screenshot URL, or null if the screenshot could not be generated.",
})
.input(z.object({ id: z.string() }))
.output(z.object({ url: z.string().nullable() }))
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(z.object({ url: z.string().nullable().describe("The URL to the screenshot image, or null.") }))
.handler(async ({ input }) => {
try {
const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter({ id: input.id });
+125 -57
View File
@@ -9,10 +9,13 @@ const tagsRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/resume/tags/list",
tags: ["Resume"],
path: "/resumes/tags",
tags: ["Resumes"],
operationId: "listResumeTags",
summary: "List all resume tags",
description: "List all tags for the authenticated user's resumes. Used to populate the filter in the dashboard.",
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 }) => {
@@ -24,19 +27,22 @@ const statisticsRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resume/statistics/{id}",
tags: ["Resume"],
path: "/resumes/{id}/statistics",
tags: ["Resume Statistics"],
operationId: "getResumeStatistics",
summary: "Get resume statistics",
description: "Get the statistics for a resume, such as number of views and downloads.",
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() }))
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(
z.object({
isPublic: z.boolean(),
views: z.number(),
downloads: z.number(),
lastViewedAt: z.date().nullable(),
lastDownloadedAt: z.date().nullable(),
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 }) => {
@@ -44,7 +50,7 @@ const statisticsRouter = {
}),
increment: publicProcedure
.route({ tags: ["Internal"], summary: "Increment resume statistics" })
.route({ tags: ["Internal"], operationId: "incrementResumeStatistics", summary: "Increment resume statistics" })
.input(z.object({ id: z.string(), views: z.boolean().default(false), downloads: z.boolean().default(false) }))
.handler(async ({ input }) => {
return await resumeService.statistics.increment(input);
@@ -58,10 +64,13 @@ export const resumeRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/resume/list",
tags: ["Resume"],
path: "/resumes",
tags: ["Resumes"],
operationId: "listResumes",
summary: "List all resumes",
description: "List of all the resumes for the authenticated user.",
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)
@@ -76,10 +85,13 @@ export const resumeRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resume/{id}",
tags: ["Resume"],
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "getResume",
summary: "Get resume by ID",
description: "Get a resume, along with its data, by its 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)
@@ -88,7 +100,7 @@ export const resumeRouter = {
}),
getByIdForPrinter: serverOnlyProcedure
.route({ tags: ["Internal"], summary: "Get resume by ID for printer" })
.route({ tags: ["Internal"], operationId: "getResumeForPrinter", summary: "Get resume by ID for printer" })
.input(resumeDto.getById.input)
.handler(async ({ input }) => {
return await resumeService.getByIdForPrinter({ id: input.id });
@@ -97,10 +109,13 @@ export const resumeRouter = {
getBySlug: publicProcedure
.route({
method: "GET",
path: "/resume/{username}/{slug}",
tags: ["Resume"],
summary: "Get resume by username and slug",
description: "Get a resume, along with its data, by its username and slug.",
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)
@@ -111,10 +126,13 @@ export const resumeRouter = {
create: protectedProcedure
.route({
method: "POST",
path: "/resume/create",
tags: ["Resume"],
path: "/resumes",
tags: ["Resumes"],
operationId: "createResume",
summary: "Create a new resume",
description: "Create a new resume, with the ability to initialize it with sample data.",
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)
.output(resumeDto.create.output)
@@ -138,10 +156,13 @@ export const resumeRouter = {
import: protectedProcedure
.route({
method: "POST",
path: "/resume/import",
tags: ["Resume"],
path: "/resumes/import",
tags: ["Resumes"],
operationId: "importResume",
summary: "Import a resume",
description: "Import a resume from a file.",
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)
.output(resumeDto.import.output)
@@ -168,10 +189,13 @@ export const resumeRouter = {
update: protectedProcedure
.route({
method: "PUT",
path: "/resume/{id}",
tags: ["Resume"],
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "updateResume",
summary: "Update a resume",
description: "Update a resume, along with its data, by its ID.",
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)
.output(resumeDto.update.output)
@@ -196,11 +220,13 @@ export const resumeRouter = {
patch: protectedProcedure
.route({
method: "PATCH",
path: "/resume/{id}",
tags: ["Resume"],
summary: "Patch a resume",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "patchResume",
summary: "Patch resume data",
description:
"Apply JSON Patch (RFC 6902) operations to partially update a resume's data. This allows you to make small, targeted changes without sending the entire resume object.",
"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)
.output(resumeDto.patch.output)
@@ -221,10 +247,13 @@ export const resumeRouter = {
setLocked: protectedProcedure
.route({
method: "POST",
path: "/resume/{id}/set-locked",
tags: ["Resume"],
summary: "Set resume locked status",
description: "Toggle the locked status of a resume, by its ID.",
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)
.output(resumeDto.setLocked.output)
@@ -238,11 +267,14 @@ export const resumeRouter = {
setPassword: protectedProcedure
.route({
method: "POST",
path: "/resume/{id}/set-password",
tags: ["Resume"],
summary: "Set password on a resume",
description: "Set a password on a resume to protect it from unauthorized access when shared publicly.",
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)
.output(resumeDto.setPassword.output)
@@ -254,13 +286,43 @@ export const resumeRouter = {
});
}),
removePassword: protectedProcedure
verifyPassword: publicProcedure
.route({
method: "POST",
path: "/resume/{id}/remove-password",
tags: ["Resume"],
summary: "Remove password from a resume",
description: "Remove password protection from a resume.",
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."),
}),
)
.output(z.boolean())
.handler(async ({ input }): Promise<boolean> => {
return await 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)
.output(resumeDto.removePassword.output)
@@ -274,10 +336,13 @@ export const resumeRouter = {
duplicate: protectedProcedure
.route({
method: "POST",
path: "/resume/{id}/duplicate",
tags: ["Resume"],
path: "/resumes/{id}/duplicate",
tags: ["Resumes"],
operationId: "duplicateResume",
summary: "Duplicate a resume",
description: "Duplicate a resume, by its ID.",
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)
.output(resumeDto.duplicate.output)
@@ -297,10 +362,13 @@ export const resumeRouter = {
delete: protectedProcedure
.route({
method: "DELETE",
path: "/resume/{id}",
tags: ["Resume"],
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "deleteResume",
summary: "Delete a resume",
description: "Delete a resume, by its ID.",
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)
.output(resumeDto.delete.output)
+21 -12
View File
@@ -6,12 +6,15 @@ const userRouter = {
getCount: publicProcedure
.route({
method: "GET",
path: "/statistics/user/count",
tags: ["Statistics"],
path: "/statistics/users",
tags: ["Platform Statistics"],
operationId: "getUserCount",
summary: "Get total number of users",
description: "Get the total number of users for the Reactive Resume.",
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())
.output(z.number().describe("The total number of registered users."))
.handler(async (): Promise<number> => {
return await statisticsService.user.getCount();
}),
@@ -21,12 +24,15 @@ const resumeRouter = {
getCount: publicProcedure
.route({
method: "GET",
path: "/statistics/resume/count",
tags: ["Statistics"],
path: "/statistics/resumes",
tags: ["Platform Statistics"],
operationId: "getResumeCount",
summary: "Get total number of resumes",
description: "Get the total number of resumes for the Reactive Resume.",
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())
.output(z.number().describe("The total number of resumes created."))
.handler(async (): Promise<number> => {
return await statisticsService.resume.getCount();
}),
@@ -37,11 +43,14 @@ const githubRouter = {
.route({
method: "GET",
path: "/statistics/github/stars",
tags: ["Statistics"],
summary: "Get GitHub Repository stargazers count",
description: "Get the stargazers count for the Reactive Resume GitHub repository, at the time of writing.",
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())
.output(z.number().describe("The number of GitHub stars."))
.handler(async (): Promise<number> => {
return await statisticsService.github.getStarCount();
}),
+28 -6
View File
@@ -7,17 +7,26 @@ 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) });
const filenameSchema = z.object({
filename: z.string().min(1).describe("The path or filename of the file to delete."),
});
export const storageRouter = {
uploadFile: protectedProcedure
.route({ tags: ["Internal"], summary: "Upload a file" })
.route({
tags: ["Internal"],
operationId: "uploadFile",
summary: "Upload a file",
description:
"Uploads a file to storage. Images are automatically resized and converted to WebP format. Maximum file size is 10MB. Requires authentication.",
successDescription: "The file was uploaded successfully.",
})
.input(fileSchema)
.output(
z.object({
url: z.string(),
path: z.string(),
contentType: z.string(),
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 }) => {
@@ -52,9 +61,22 @@ export const storageRouter = {
}),
deleteFile: protectedProcedure
.route({ tags: ["Internal"], summary: "Delete a file" })
.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)
.output(z.void())
.errors({
NOT_FOUND: {
message: "The specified file was not found in storage.",
status: 404,
},
})
.handler(async ({ context, input }): Promise<void> => {
// The filename is now the full path from the URL (e.g., "uploads/userId/pictures/timestamp.webp")
// We need to extract just the path portion that matches the storage key
+48 -1
View File
@@ -1,15 +1,31 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { createGateway, generateText, Output } from "ai";
import { streamToEventIterator } from "@orpc/server";
import {
convertToModelMessages,
createGateway,
generateText,
Output,
stepCountIs,
streamText,
tool,
type UIMessage,
} from "ai";
import { createOllama } from "ai-sdk-ollama";
import { match } from "ts-pattern";
import type { ZodError } from "zod";
import z, { flattenError } from "zod";
import chatSystemPromptTemplate from "@/integrations/ai/prompts/chat-system.md?raw";
import docxParserSystemPrompt from "@/integrations/ai/prompts/docx-parser-system.md?raw";
import docxParserUserPrompt from "@/integrations/ai/prompts/docx-parser-user.md?raw";
import pdfParserSystemPrompt from "@/integrations/ai/prompts/pdf-parser-system.md?raw";
import pdfParserUserPrompt from "@/integrations/ai/prompts/pdf-parser-user.md?raw";
import {
executePatchResume,
patchResumeDescription,
patchResumeInputSchema,
} from "@/integrations/ai/tools/patch-resume";
import type { ResumeData } from "@/schema/resume/data";
import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
@@ -141,8 +157,39 @@ export function formatZodError(error: ZodError): string {
return JSON.stringify(flattenError(error));
}
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());
}
export const aiService = {
testConnection,
parsePdf,
parseDocx,
chat,
};
+1 -28
View File
@@ -1,11 +1,9 @@
import { ORPCError } from "@orpc/client";
import { and, eq, isNotNull } from "drizzle-orm";
import { eq } from "drizzle-orm";
import type { AuthProvider } from "@/integrations/auth/types";
import { schema } from "@/integrations/drizzle";
import { db } from "@/integrations/drizzle/client";
import { env } from "@/utils/env";
import { verifyPassword } from "@/utils/password";
import { grantResumeAccess } from "../helpers/resume-access";
import { getStorageService } from "./storage";
export type ProviderList = Partial<Record<AuthProvider, string>>;
@@ -25,31 +23,6 @@ const providers = {
export const authService = {
providers,
verifyResumePassword: async (input: { slug: string; username: string; password: string }): Promise<boolean> => {
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("NOT_FOUND");
const passwordHash = resume.password as string;
const isValid = await verifyPassword(input.password, passwordHash);
if (!isValid) throw new ORPCError("INVALID_PASSWORD");
grantResumeAccess(resume.id, passwordHash);
return true;
},
deleteAccount: async (input: { userId: string }): Promise<void> => {
if (!input.userId || input.userId.length === 0) return;
+28 -3
View File
@@ -1,5 +1,5 @@
import { ORPCError } from "@orpc/client";
import { and, arrayContains, asc, desc, eq, sql } from "drizzle-orm";
import { and, arrayContains, asc, desc, eq, isNotNull, sql } from "drizzle-orm";
import { get } from "es-toolkit/compat";
import type { Operation } from "fast-json-patch";
import { match } from "ts-pattern";
@@ -9,10 +9,10 @@ import type { ResumeData } from "@/schema/resume/data";
import { defaultResumeData } from "@/schema/resume/data";
import { env } from "@/utils/env";
import type { Locale } from "@/utils/locale";
import { hashPassword } from "@/utils/password";
import { hashPassword, verifyPassword } from "@/utils/password";
import { applyResumePatches, ResumePatchError } from "@/utils/resume/patch";
import { generateId } from "@/utils/string";
import { hasResumeAccess } from "../helpers/resume-access";
import { grantResumeAccess, hasResumeAccess } from "../helpers/resume-access";
import { getStorageService } from "./storage";
const tags = {
@@ -383,6 +383,31 @@ export const resumeService = {
.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("NOT_FOUND");
const passwordHash = resume.password as string;
const isValid = await verifyPassword(input.password, passwordHash);
if (!isValid) throw new ORPCError("INVALID_PASSWORD");
grantResumeAccess(resume.id, passwordHash);
return true;
},
removePassword: async (input: { id: string; userId: string }) => {
await db
.update(schema.resume)