mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 01:44:53 +10:00
use vite+
This commit is contained in:
+155
-155
@@ -12,167 +12,167 @@ import { aiCredentialsSchema, aiProviderSchema, aiService, fileInputSchema } fro
|
||||
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,
|
||||
model: z.string(),
|
||||
apiKey: z.string(),
|
||||
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 || error instanceof OllamaError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
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,
|
||||
model: z.string(),
|
||||
apiKey: z.string(),
|
||||
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 || error instanceof OllamaError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
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,
|
||||
}),
|
||||
)
|
||||
.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 (error instanceof AISDKError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
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,
|
||||
}),
|
||||
)
|
||||
.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 (error instanceof AISDKError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(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",
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.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 (error instanceof AISDKError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
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",
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.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 (error instanceof AISDKError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
|
||||
throw 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 });
|
||||
}
|
||||
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;
|
||||
}
|
||||
}),
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -2,35 +2,35 @@ import { protectedProcedure, publicProcedure } from "../context";
|
||||
import { authService, type ProviderList } 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, and custom OAuth. No authentication required.",
|
||||
successDescription: "A map of enabled authentication provider identifiers to their display names.",
|
||||
})
|
||||
.handler((): ProviderList => {
|
||||
return authService.providers.list();
|
||||
}),
|
||||
},
|
||||
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, 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 });
|
||||
}),
|
||||
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 });
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -4,22 +4,22 @@ import { publicProcedure } from "../context";
|
||||
import { type FeatureFlags, 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()),
|
||||
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()),
|
||||
};
|
||||
|
||||
@@ -7,11 +7,11 @@ import { statisticsRouter } from "./statistics";
|
||||
import { storageRouter } from "./storage";
|
||||
|
||||
export default {
|
||||
ai: aiRouter,
|
||||
auth: authRouter,
|
||||
flags: flagsRouter,
|
||||
resume: resumeRouter,
|
||||
storage: storageRouter,
|
||||
printer: printerRouter,
|
||||
statistics: statisticsRouter,
|
||||
ai: aiRouter,
|
||||
auth: authRouter,
|
||||
flags: flagsRouter,
|
||||
resume: resumeRouter,
|
||||
storage: storageRouter,
|
||||
printer: printerRouter,
|
||||
statistics: statisticsRouter,
|
||||
};
|
||||
|
||||
@@ -5,57 +5,57 @@ import { printerService } from "../services/printer";
|
||||
import { resumeService } from "../services/resume";
|
||||
|
||||
export const printerRouter = {
|
||||
printResumeAsPDF: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}/pdf",
|
||||
tags: ["Resume Export"],
|
||||
operationId: "exportResumePdf",
|
||||
summary: "Export resume as 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().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, userId: context.user?.id });
|
||||
const url = await printerService.printResumeAsPDF({ id, data, userId });
|
||||
printResumeAsPDF: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}/pdf",
|
||||
tags: ["Resume Export"],
|
||||
operationId: "exportResumePdf",
|
||||
summary: "Export resume as 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().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, userId: context.user?.id });
|
||||
const url = await printerService.printResumeAsPDF({ id, data, userId });
|
||||
|
||||
if (!context.user) {
|
||||
await resumeService.statistics.increment({ id: input.id, downloads: true });
|
||||
}
|
||||
if (!context.user) {
|
||||
await resumeService.statistics.increment({ id: input.id, downloads: true });
|
||||
}
|
||||
|
||||
return { url };
|
||||
}),
|
||||
return { url };
|
||||
}),
|
||||
|
||||
getResumeScreenshot: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}/screenshot",
|
||||
tags: ["Resume Export"],
|
||||
operationId: "getResumeScreenshot",
|
||||
summary: "Get resume screenshot",
|
||||
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().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 ({ context, input }) => {
|
||||
try {
|
||||
const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
});
|
||||
getResumeScreenshot: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}/screenshot",
|
||||
tags: ["Resume Export"],
|
||||
operationId: "getResumeScreenshot",
|
||||
summary: "Get resume screenshot",
|
||||
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().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 ({ context, input }) => {
|
||||
try {
|
||||
const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
});
|
||||
|
||||
const url = await printerService.getResumeScreenshot({ id, data, userId, updatedAt });
|
||||
const url = await printerService.getResumeScreenshot({ id, data, userId, updatedAt });
|
||||
|
||||
return { url };
|
||||
} catch {
|
||||
// ignore errors, as the screenshot is not critical
|
||||
}
|
||||
return { url };
|
||||
} catch {
|
||||
// ignore errors, as the screenshot is not critical
|
||||
}
|
||||
|
||||
return { url: null };
|
||||
}),
|
||||
return { url: null };
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -8,373 +8,373 @@ import { resumeDto } from "../dto/resume";
|
||||
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 await resumeService.tags.list({ userId: context.user.id });
|
||||
}),
|
||||
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 await 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 await resumeService.statistics.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
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 await resumeService.statistics.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
|
||||
increment: publicProcedure
|
||||
.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);
|
||||
}),
|
||||
increment: publicProcedure
|
||||
.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);
|
||||
}),
|
||||
};
|
||||
|
||||
export const resumeRouter = {
|
||||
tags: tagsRouter,
|
||||
statistics: statisticsRouter,
|
||||
tags: tagsRouter,
|
||||
statistics: statisticsRouter,
|
||||
|
||||
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 await resumeService.list({
|
||||
userId: context.user.id,
|
||||
tags: input.tags,
|
||||
sort: input.sort,
|
||||
});
|
||||
}),
|
||||
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 await 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 await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
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 await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
|
||||
getByIdForPrinter: serverOnlyProcedure
|
||||
.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 });
|
||||
}),
|
||||
getByIdForPrinter: serverOnlyProcedure
|
||||
.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 });
|
||||
}),
|
||||
|
||||
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 await resumeService.getBySlug({ ...input, currentUserId: 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 await resumeService.getBySlug({ ...input, 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)
|
||||
.output(resumeDto.create.output)
|
||||
.errors({
|
||||
RESUME_SLUG_ALREADY_EXISTS: {
|
||||
message: "A resume with this slug already exists.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.create({
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
tags: input.tags,
|
||||
locale: context.locale,
|
||||
userId: context.user.id,
|
||||
data: input.withSampleData ? sampleResumeData : undefined,
|
||||
});
|
||||
}),
|
||||
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)
|
||||
.output(resumeDto.create.output)
|
||||
.errors({
|
||||
RESUME_SLUG_ALREADY_EXISTS: {
|
||||
message: "A resume with this slug already exists.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.create({
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
tags: input.tags,
|
||||
locale: context.locale,
|
||||
userId: context.user.id,
|
||||
data: input.withSampleData ? sampleResumeData : undefined,
|
||||
});
|
||||
}),
|
||||
|
||||
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)
|
||||
.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);
|
||||
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)
|
||||
.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 await resumeService.create({
|
||||
name,
|
||||
slug,
|
||||
tags: [],
|
||||
data: input.data,
|
||||
locale: context.locale,
|
||||
userId: context.user.id,
|
||||
});
|
||||
}),
|
||||
return await 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)
|
||||
.output(resumeDto.update.output)
|
||||
.errors({
|
||||
RESUME_SLUG_ALREADY_EXISTS: {
|
||||
message: "A resume with this slug already exists.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.update({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
tags: input.tags,
|
||||
data: input.data,
|
||||
isPublic: input.isPublic,
|
||||
});
|
||||
}),
|
||||
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)
|
||||
.output(resumeDto.update.output)
|
||||
.errors({
|
||||
RESUME_SLUG_ALREADY_EXISTS: {
|
||||
message: "A resume with this slug already exists.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.update({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
tags: input.tags,
|
||||
data: input.data,
|
||||
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)
|
||||
.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 await resumeService.patch({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
operations: input.operations,
|
||||
});
|
||||
}),
|
||||
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)
|
||||
.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 await 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)
|
||||
.output(resumeDto.setLocked.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.setLocked({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
isLocked: input.isLocked,
|
||||
});
|
||||
}),
|
||||
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)
|
||||
.output(resumeDto.setLocked.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await 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)
|
||||
.output(resumeDto.setPassword.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.setPassword({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
password: input.password,
|
||||
});
|
||||
}),
|
||||
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)
|
||||
.output(resumeDto.setPassword.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await 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."),
|
||||
}),
|
||||
)
|
||||
.output(z.boolean())
|
||||
.handler(async ({ input }): Promise<boolean> => {
|
||||
return await resumeService.verifyPassword({
|
||||
username: input.username,
|
||||
slug: input.slug,
|
||||
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."),
|
||||
}),
|
||||
)
|
||||
.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)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.removePassword({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
});
|
||||
}),
|
||||
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)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await 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)
|
||||
.output(resumeDto.duplicate.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
const original = await resumeService.getById({ 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)
|
||||
.output(resumeDto.duplicate.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
const original = await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
|
||||
return await 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,
|
||||
});
|
||||
}),
|
||||
return await 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)
|
||||
.output(resumeDto.delete.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.delete({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
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)
|
||||
.output(resumeDto.delete.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.delete({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -4,61 +4,61 @@ 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();
|
||||
}),
|
||||
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();
|
||||
}),
|
||||
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();
|
||||
}),
|
||||
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,
|
||||
user: userRouter,
|
||||
resume: resumeRouter,
|
||||
github: githubRouter,
|
||||
};
|
||||
|
||||
@@ -9,100 +9,100 @@ 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."),
|
||||
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("/");
|
||||
return input.trim().replace(/^\/+/, "").split("/").filter(Boolean).join("/");
|
||||
}
|
||||
|
||||
function isUnsafeStorageKey(key: string): boolean {
|
||||
return key.split("/").some((segment) => segment === "." || segment === "..");
|
||||
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 WebP format. Maximum file size is 10MB. Requires authentication.",
|
||||
successDescription: "The file was uploaded successfully.",
|
||||
})
|
||||
.input(fileSchema)
|
||||
.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);
|
||||
uploadFile: protectedProcedure
|
||||
.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().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;
|
||||
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;
|
||||
}
|
||||
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",
|
||||
});
|
||||
const result = await uploadFile({
|
||||
userId: context.user.id,
|
||||
data,
|
||||
contentType,
|
||||
type: "picture",
|
||||
});
|
||||
|
||||
return {
|
||||
url: result.url,
|
||||
path: result.key,
|
||||
contentType,
|
||||
};
|
||||
}),
|
||||
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)
|
||||
.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}/`;
|
||||
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)
|
||||
.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");
|
||||
}
|
||||
if (isUnsafeStorageKey(key) || !key.startsWith(userPrefix)) {
|
||||
throw new ORPCError("FORBIDDEN");
|
||||
}
|
||||
|
||||
const deleted = await storageService.delete(key);
|
||||
const deleted = await storageService.delete(key);
|
||||
|
||||
if (!deleted) throw new ORPCError("NOT_FOUND");
|
||||
}),
|
||||
if (!deleted) throw new ORPCError("NOT_FOUND");
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user