mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 09:24:54 +10:00
initial commit of v5
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createGateway, generateText, Output, streamText } from "ai";
|
||||
import { createOllama } from "ai-sdk-ollama";
|
||||
import { match } from "ts-pattern";
|
||||
import z, { flattenError, ZodError } from "zod";
|
||||
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 { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
|
||||
import { protectedProcedure } from "../context";
|
||||
|
||||
const aiProviderSchema = z.enum(["vercel-ai-gateway", "openai", "anthropic", "gemini", "ollama"]);
|
||||
|
||||
type AIProvider = z.infer<typeof aiProviderSchema>;
|
||||
|
||||
type GetModelInput = {
|
||||
provider: AIProvider;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
baseURL?: string;
|
||||
};
|
||||
|
||||
function getModel(input: GetModelInput) {
|
||||
const { provider, model, apiKey, baseURL } = input;
|
||||
|
||||
return match(provider)
|
||||
.with("vercel-ai-gateway", () => createGateway({ apiKey }).languageModel(model))
|
||||
.with("openai", () => createOpenAI({ apiKey }).languageModel(model))
|
||||
.with("anthropic", () => createAnthropic({ apiKey }).languageModel(model))
|
||||
.with("gemini", () => createGoogleGenerativeAI({ apiKey }).languageModel(model))
|
||||
.with("ollama", () => createOllama({ apiKey, baseURL }).languageModel(model))
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
const aiCredentialsSchema = z.object({
|
||||
provider: aiProviderSchema,
|
||||
model: z.string(),
|
||||
apiKey: z.string(),
|
||||
baseURL: z.string().optional(),
|
||||
});
|
||||
|
||||
const fileInputSchema = z.object({
|
||||
name: z.string(),
|
||||
data: z.string(), // base64 encoded
|
||||
});
|
||||
|
||||
export const aiRouter = {
|
||||
testConnection: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
provider: aiProviderSchema,
|
||||
model: z.string(),
|
||||
apiKey: z.string(),
|
||||
baseURL: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.handler(async function* ({ input }) {
|
||||
const stream = streamText({
|
||||
temperature: 0,
|
||||
model: getModel(input),
|
||||
messages: [{ role: "user", content: 'Respond with "1"' }],
|
||||
});
|
||||
|
||||
yield* stream.textStream;
|
||||
}),
|
||||
|
||||
parsePdf: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
...aiCredentialsSchema.shape,
|
||||
file: fileInputSchema,
|
||||
}),
|
||||
)
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
const model = getModel(input);
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
maxRetries: 0,
|
||||
output: Output.object({
|
||||
schema: resumeDataSchema.omit({ picture: true, metadata: true, customSections: true }),
|
||||
}),
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: pdfParserSystemPrompt,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: pdfParserUserPrompt },
|
||||
{
|
||||
type: "file",
|
||||
filename: input.file.name,
|
||||
mediaType: "application/pdf",
|
||||
data: input.file.data,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return resumeDataSchema.parse({
|
||||
...result.output,
|
||||
customSections: [],
|
||||
picture: defaultResumeData.picture,
|
||||
metadata: defaultResumeData.metadata,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const errors = flattenError(error);
|
||||
throw new Error(JSON.stringify(errors));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
parseDocx: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
...aiCredentialsSchema.shape,
|
||||
file: fileInputSchema,
|
||||
mediaType: z.enum([
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
const model = getModel(input);
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
maxRetries: 0,
|
||||
output: Output.object({
|
||||
schema: resumeDataSchema.omit({ picture: true, metadata: true, customSections: true }),
|
||||
}),
|
||||
messages: [
|
||||
{ role: "system", content: docxParserSystemPrompt },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: docxParserUserPrompt },
|
||||
{
|
||||
type: "file",
|
||||
filename: input.file.name,
|
||||
mediaType: input.mediaType,
|
||||
data: input.file.data,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return resumeDataSchema.parse({
|
||||
...result.output,
|
||||
customSections: [],
|
||||
picture: defaultResumeData.picture,
|
||||
metadata: defaultResumeData.metadata,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const errors = flattenError(error);
|
||||
throw new Error(JSON.stringify(errors));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import z from "zod";
|
||||
import { protectedProcedure, publicProcedure } from "../context";
|
||||
import { authService, type ProviderList } from "../services/auth";
|
||||
|
||||
export const authRouter = {
|
||||
providers: {
|
||||
list: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/auth/providers/list",
|
||||
tags: ["Authentication"],
|
||||
summary: "List all auth providers",
|
||||
description:
|
||||
"A list of all authentication providers, and their display names, supported by the instance of Reactive Resume.",
|
||||
})
|
||||
.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",
|
||||
tags: ["Authentication"],
|
||||
summary: "Delete user account",
|
||||
description: "Delete the authenticated user's account and all associated data.",
|
||||
})
|
||||
.handler(async ({ context }): Promise<void> => {
|
||||
return await authService.deleteAccount({ userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { aiRouter } from "./ai";
|
||||
import { authRouter } from "./auth";
|
||||
import { printerRouter } from "./printer";
|
||||
import { resumeRouter } from "./resume";
|
||||
import { statisticsRouter } from "./statistics";
|
||||
import { storageRouter } from "./storage";
|
||||
|
||||
export default {
|
||||
ai: aiRouter,
|
||||
auth: authRouter,
|
||||
resume: resumeRouter,
|
||||
storage: storageRouter,
|
||||
printer: printerRouter,
|
||||
statistics: statisticsRouter,
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import z from "zod";
|
||||
import { protectedProcedure, publicProcedure } from "../context";
|
||||
import { printerService } from "../services/printer";
|
||||
import { resumeService } from "../services/resume";
|
||||
|
||||
export const printerRouter = {
|
||||
printResumeAsPDF: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/printer/resume/{id}/pdf",
|
||||
tags: ["Resume", "Printer"],
|
||||
summary: "Export resume as PDF",
|
||||
description: "Export a resume as a PDF. Returns a URL to download the PDF.",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.object({ url: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
// Get resume to find the owner's userId for storage key
|
||||
const resume = await resumeService.getByIdForPrinter({ id: input.id });
|
||||
|
||||
const url = await printerService.printResumeAsPDF({
|
||||
id: input.id,
|
||||
userId: resume.userId,
|
||||
});
|
||||
|
||||
if (!context.user) {
|
||||
await resumeService.statistics.increment({ id: input.id, downloads: true });
|
||||
}
|
||||
|
||||
return { url };
|
||||
}),
|
||||
|
||||
getResumeScreenshot: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/printer/resume/{id}/screenshot",
|
||||
tags: ["Resume", "Printer"],
|
||||
summary: "Get resume screenshot",
|
||||
description: "Get a screenshot of a resume. Returns a URL to the screenshot image.",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.object({ url: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
const url = await printerService.getResumeScreenshot({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
});
|
||||
|
||||
return { url };
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,331 @@
|
||||
import z from "zod";
|
||||
import { resumeDataSchema, sampleResumeData } from "@/schema/resume/data";
|
||||
import { generateRandomName, slugify } from "@/utils/string";
|
||||
import { protectedProcedure, publicProcedure, serverOnlyProcedure } from "../context";
|
||||
import { resumeService } from "../services/resume";
|
||||
|
||||
const tagsRouter = {
|
||||
list: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resume/tags/list",
|
||||
tags: ["Resume"],
|
||||
summary: "List all resume tags",
|
||||
description: "List all tags for the authenticated user's resumes. Used to populate the filter in the dashboard.",
|
||||
})
|
||||
.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: "/resume/statistics/{id}",
|
||||
tags: ["Resume"],
|
||||
summary: "Get resume statistics",
|
||||
description: "Get the statistics for a resume, such as number of views and downloads.",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(
|
||||
z.object({
|
||||
isPublic: z.boolean(),
|
||||
views: z.number(),
|
||||
downloads: z.number(),
|
||||
lastViewedAt: z.date().nullable(),
|
||||
lastDownloadedAt: z.date().nullable(),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.statistics.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
|
||||
increment: publicProcedure
|
||||
.route({ tags: ["Internal"], 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,
|
||||
|
||||
list: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resume/list",
|
||||
tags: ["Resume"],
|
||||
summary: "List all resumes",
|
||||
description: "List of all the resumes for the authenticated user.",
|
||||
})
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).optional().default("lastUpdatedAt"),
|
||||
})
|
||||
.optional()
|
||||
.default({ tags: [], sort: "lastUpdatedAt" }),
|
||||
)
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
isPublic: z.boolean(),
|
||||
isLocked: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handler(async ({ input, context }) => {
|
||||
return await resumeService.list({
|
||||
userId: context.user.id,
|
||||
tags: input.tags,
|
||||
sort: input.sort,
|
||||
});
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resume/{id}",
|
||||
tags: ["Resume"],
|
||||
summary: "Get resume by ID",
|
||||
description: "Get a resume, along with its data, by its ID.",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
data: resumeDataSchema,
|
||||
isPublic: z.boolean(),
|
||||
isLocked: z.boolean(),
|
||||
hasPassword: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
|
||||
getByIdForPrinter: serverOnlyProcedure
|
||||
.route({ tags: ["Internal"], summary: "Get resume by ID for printer" })
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
return await resumeService.getByIdForPrinter({ id: input.id });
|
||||
}),
|
||||
|
||||
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.",
|
||||
})
|
||||
.input(z.object({ username: z.string(), slug: z.string() }))
|
||||
.output(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
data: resumeDataSchema,
|
||||
isPublic: z.boolean(),
|
||||
isLocked: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ input, context }) => {
|
||||
return await resumeService.getBySlug({ ...input, currentUserId: context.user?.id });
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/resume/create",
|
||||
tags: ["Resume"],
|
||||
summary: "Create a new resume",
|
||||
description: "Create a new resume, with the ability to initialize it with sample data.",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
slug: z.string().min(1).max(64),
|
||||
tags: z.array(z.string()),
|
||||
withSampleData: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.output(z.string().describe("The ID of the created resume."))
|
||||
.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: "/resume/import",
|
||||
tags: ["Resume"],
|
||||
summary: "Import a resume",
|
||||
description: "Import a resume from a file.",
|
||||
})
|
||||
.input(z.object({ data: resumeDataSchema }))
|
||||
.output(z.string().describe("The ID of the imported resume."))
|
||||
.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,
|
||||
});
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/resume/{id}",
|
||||
tags: ["Resume"],
|
||||
summary: "Update a resume",
|
||||
description: "Update a resume, along with its data, by its ID.",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
data: resumeDataSchema.optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.void())
|
||||
.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,
|
||||
});
|
||||
}),
|
||||
|
||||
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.",
|
||||
})
|
||||
.input(z.object({ id: z.string(), isLocked: z.boolean() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.setLocked({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
isLocked: input.isLocked,
|
||||
});
|
||||
}),
|
||||
|
||||
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.",
|
||||
})
|
||||
.input(z.object({ id: z.string(), password: z.string().min(6).max(64) }))
|
||||
.output(z.void())
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.setPassword({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
password: input.password,
|
||||
});
|
||||
}),
|
||||
|
||||
removePassword: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/resume/{id}/remove-password",
|
||||
tags: ["Resume"],
|
||||
summary: "Remove password from a resume",
|
||||
description: "Remove password protection from a resume.",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.removePassword({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
});
|
||||
}),
|
||||
|
||||
duplicate: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/resume/{id}/duplicate",
|
||||
tags: ["Resume"],
|
||||
summary: "Duplicate a resume",
|
||||
description: "Duplicate a resume, by its ID.",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.string().describe("The ID of the duplicated resume."))
|
||||
.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,
|
||||
});
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/resume/{id}",
|
||||
tags: ["Resume"],
|
||||
summary: "Delete a resume",
|
||||
description: "Delete a resume, by its ID.",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.delete({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import z from "zod";
|
||||
import { publicProcedure } from "../context";
|
||||
import { statisticsService } from "../services/statistics";
|
||||
|
||||
const userRouter = {
|
||||
getCount: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/statistics/user/count",
|
||||
tags: ["Statistics"],
|
||||
summary: "Get total number of users",
|
||||
description: "Get the total number of users for the Reactive Resume.",
|
||||
})
|
||||
.output(z.number())
|
||||
.handler(async (): Promise<number> => {
|
||||
return await statisticsService.user.getCount();
|
||||
}),
|
||||
};
|
||||
|
||||
const resumeRouter = {
|
||||
getCount: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/statistics/resume/count",
|
||||
tags: ["Statistics"],
|
||||
summary: "Get total number of resumes",
|
||||
description: "Get the total number of resumes for the Reactive Resume.",
|
||||
})
|
||||
.output(z.number())
|
||||
.handler(async (): Promise<number> => {
|
||||
return await statisticsService.resume.getCount();
|
||||
}),
|
||||
};
|
||||
|
||||
const githubRouter = {
|
||||
getStarCount: publicProcedure
|
||||
.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.",
|
||||
})
|
||||
.output(z.number())
|
||||
.handler(async (): Promise<number> => {
|
||||
return await statisticsService.github.getStarCount();
|
||||
}),
|
||||
};
|
||||
|
||||
export const statisticsRouter = {
|
||||
user: userRouter,
|
||||
resume: resumeRouter,
|
||||
github: githubRouter,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../context";
|
||||
import { getStorageService, isImageFile, processImageForUpload, uploadFile } from "../services/storage";
|
||||
|
||||
const storageService = getStorageService();
|
||||
|
||||
const fileSchema = z.file().max(10 * 1024 * 1024, "File size must be less than 10MB");
|
||||
|
||||
const filenameSchema = z.object({ filename: z.string().min(1) });
|
||||
|
||||
export const storageRouter = {
|
||||
uploadFile: protectedProcedure
|
||||
.route({ tags: ["Internal"], summary: "Upload a file" })
|
||||
.input(fileSchema)
|
||||
.output(
|
||||
z.object({
|
||||
url: z.string(),
|
||||
path: z.string(),
|
||||
contentType: z.string(),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ context, input: file }) => {
|
||||
const originalMimeType = file.type;
|
||||
const isImage = isImageFile(originalMimeType);
|
||||
|
||||
let data: Uint8Array;
|
||||
let contentType: string;
|
||||
|
||||
if (isImage) {
|
||||
const processed = await processImageForUpload(file);
|
||||
data = processed.data;
|
||||
contentType = processed.contentType;
|
||||
} else {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
data = new Uint8Array(fileBuffer);
|
||||
contentType = originalMimeType;
|
||||
}
|
||||
|
||||
const result = await uploadFile({
|
||||
userId: context.user.id,
|
||||
data,
|
||||
contentType,
|
||||
type: "picture",
|
||||
});
|
||||
|
||||
return {
|
||||
url: result.url,
|
||||
path: result.key,
|
||||
contentType,
|
||||
};
|
||||
}),
|
||||
|
||||
deleteFile: protectedProcedure
|
||||
.route({ tags: ["Internal"], summary: "Delete a file" })
|
||||
.input(filenameSchema)
|
||||
.output(z.void())
|
||||
.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
|
||||
const key = input.filename.startsWith("uploads/")
|
||||
? input.filename
|
||||
: `uploads/${context.user.id}/pictures/${input.filename}`;
|
||||
|
||||
const deleted = await storageService.delete(key);
|
||||
|
||||
if (!deleted) throw new ORPCError("NOT_FOUND");
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user