mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 23:02:17 +10:00
Squashed commit of the following:
commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:13:38 2026 +0200
chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies
commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:08:04 2026 +0200
fix: remove timestamp conflict guard
commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 12:11:51 2026 +0200
chore(release): v5.1.5
commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:35 2026 +0200
revert: compose.yml
commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:08 2026 +0200
refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086
commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:10:41 2026 +0200
refactor(pdf): simplify sidebar section filtering and update summary feature logic
commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:53:37 2026 +0200
chore: update pnpm-lock.yaml and turbo.json
commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:38:30 2026 +0200
fix(polyfill): add tested polyfill for Map Upsert methods
commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:23:29 2026 +0200
Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration
# Conflicts:
# packages/api/src/services/agent-url.ts
# packages/runtime-externals/package.json
commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:22:12 2026 +0200
chore: preserve branch changes before main sync
commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Mon May 18 07:50:28 2026 +0200
chore: lot of fixes for monorepo migration
commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 13:57:17 2026 +0200
chore: update knip version and refine web app routing with new SEO endpoints
commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 12:10:06 2026 +0200
refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint
commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:54:29 2026 +0200
chore: update dependencies and enhance PWA metadata in web app
commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:12:35 2026 +0200
docs: revise manifest-only pwa testing scope
commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:11:33 2026 +0200
docs: add manifest-only pwa design
commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:05:04 2026 +0200
chore(dev): simplify server proxy config
commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:50 2026 +0200
docs: add unsafe oauth redirect plan
commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:34 2026 +0200
feat(auth): add unsafe oauth redirect flag
commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:55:02 2026 +0200
docs: design unsafe oauth redirect flag
commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:22:04 2026 +0200
chore: update translation source paths
commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:09:25 2026 +0200
refactor(arch): react spa + hono migration
commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 01:10:47 2026 +0200
docs: add docker nightly tagging design
commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Thu May 14 20:05:44 2026 +0200
feat: migrate to hono spa server
This commit is contained in:
@@ -1,287 +0,0 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../context";
|
||||
import { aiRequestRateLimit, storageUploadRateLimit } from "../middleware/rate-limit";
|
||||
import { agentService } from "../services/agent";
|
||||
|
||||
function isAgentEnvironmentUnavailable(error: unknown) {
|
||||
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
|
||||
}
|
||||
|
||||
function throwUnavailable(): never {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "AI agent workspace is unavailable because REDIS_URL or ENCRYPTION_SECRET is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
function base64ToUint8Array(value: string) {
|
||||
return Uint8Array.from(Buffer.from(value, "base64"));
|
||||
}
|
||||
|
||||
function isUiMessage(value: unknown): value is UIMessage {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
|
||||
const message = value as Partial<UIMessage>;
|
||||
return (
|
||||
typeof message.id === "string" &&
|
||||
(message.role === "system" || message.role === "user" || message.role === "assistant") &&
|
||||
Array.isArray(message.parts)
|
||||
);
|
||||
}
|
||||
|
||||
const threadsRouter = {
|
||||
list: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/agent/threads",
|
||||
tags: ["Agent"],
|
||||
operationId: "listAgentThreads",
|
||||
summary: "List agent threads",
|
||||
})
|
||||
.handler(async ({ context }) => {
|
||||
try {
|
||||
return await agentService.threads.list({ userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/agent/threads",
|
||||
tags: ["Agent"],
|
||||
operationId: "createAgentThread",
|
||||
summary: "Create agent thread",
|
||||
})
|
||||
.input(z.object({ aiProviderId: z.string().optional(), sourceResumeId: z.string().optional() }))
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await agentService.threads.create({
|
||||
userId: context.user.id,
|
||||
locale: context.locale,
|
||||
...(input.aiProviderId ? { aiProviderId: input.aiProviderId } : {}),
|
||||
...(input.sourceResumeId ? { sourceResumeId: input.sourceResumeId } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/agent/threads/{id}",
|
||||
tags: ["Agent"],
|
||||
operationId: "getAgentThread",
|
||||
summary: "Get agent thread",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await agentService.threads.get({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
archive: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/agent/threads/{id}/archive",
|
||||
tags: ["Agent"],
|
||||
operationId: "archiveAgentThread",
|
||||
summary: "Archive agent thread",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
await agentService.threads.archive({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/agent/threads/{id}",
|
||||
tags: ["Agent"],
|
||||
operationId: "deleteAgentThread",
|
||||
summary: "Delete agent thread",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
await agentService.threads.delete({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
const messagesRouter = {
|
||||
send: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/agent/messages/send",
|
||||
tags: ["Agent"],
|
||||
operationId: "sendAgentMessage",
|
||||
summary: "Send agent message",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
threadId: z.string(),
|
||||
message: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }),
|
||||
attachmentIds: z.array(z.string().trim().min(1)).max(10).optional(),
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await agentService.messages.send({
|
||||
userId: context.user.id,
|
||||
threadId: input.threadId,
|
||||
message: input.message,
|
||||
...(input.attachmentIds ? { attachmentIds: input.attachmentIds } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
stop: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/agent/messages/stop",
|
||||
tags: ["Agent"],
|
||||
operationId: "stopAgentMessage",
|
||||
summary: "Stop active agent run",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
threadId: z.string(),
|
||||
partialMessage: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.void())
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
await agentService.messages.stop({
|
||||
userId: context.user.id,
|
||||
threadId: input.threadId,
|
||||
...(input.partialMessage ? { partialMessage: input.partialMessage } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
resume: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/agent/messages/resume",
|
||||
tags: ["Agent"],
|
||||
operationId: "resumeAgentMessages",
|
||||
summary: "Resume agent message stream",
|
||||
})
|
||||
.input(z.object({ threadId: z.string() }))
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await agentService.messages.resume({ userId: context.user.id, threadId: input.threadId });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
const attachmentsRouter = {
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/agent/attachments",
|
||||
tags: ["Agent"],
|
||||
operationId: "createAgentAttachment",
|
||||
summary: "Create agent attachment",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
threadId: z.string(),
|
||||
filename: z.string().trim().min(1),
|
||||
mediaType: z.string().trim().min(1),
|
||||
data: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.use(storageUploadRateLimit)
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await agentService.attachments.create({
|
||||
userId: context.user.id,
|
||||
threadId: input.threadId,
|
||||
filename: input.filename,
|
||||
mediaType: input.mediaType,
|
||||
data: base64ToUint8Array(input.data),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/agent/attachments/{id}",
|
||||
tags: ["Agent"],
|
||||
operationId: "deleteAgentAttachment",
|
||||
summary: "Delete agent attachment",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
await agentService.attachments.delete({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
const actionsRouter = {
|
||||
revert: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/agent/actions/{id}/revert",
|
||||
tags: ["Agent"],
|
||||
operationId: "revertAgentAction",
|
||||
summary: "Revert agent action",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await agentService.actions.revert({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
export const agentRouter = {
|
||||
threads: threadsRouter,
|
||||
messages: messagesRouter,
|
||||
attachments: attachmentsRouter,
|
||||
actions: actionsRouter,
|
||||
};
|
||||
@@ -1,185 +0,0 @@
|
||||
import type { AiProviderResponse } from "../services/ai-providers";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { type } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { aiProviderSchema } from "@reactive-resume/ai/types";
|
||||
import { protectedProcedure } from "../context";
|
||||
import { aiRequestRateLimit } from "../middleware/rate-limit";
|
||||
import { aiProvidersService } from "../services/ai-providers";
|
||||
|
||||
const providerInput = z.object({
|
||||
label: z.string().trim().min(1),
|
||||
provider: aiProviderSchema,
|
||||
model: z.string().trim().min(1),
|
||||
baseURL: z.string().trim().optional().default(""),
|
||||
apiKey: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const updateProviderInput = providerInput
|
||||
.partial()
|
||||
.extend({ id: z.string(), enabled: z.boolean().optional() })
|
||||
.refine((input) => Object.keys(input).some((key) => key !== "id"), {
|
||||
message: "At least one field must be provided.",
|
||||
});
|
||||
|
||||
function isAgentEnvironmentUnavailable(error: unknown) {
|
||||
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
|
||||
}
|
||||
|
||||
function throwUnavailable(): never {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "AI agent workspace is unavailable because REDIS_URL or ENCRYPTION_SECRET is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
function isInvalidAiBaseUrl(error: unknown) {
|
||||
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
|
||||
}
|
||||
|
||||
function throwInvalidProviderConfig(): never {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
|
||||
}
|
||||
|
||||
export const aiProvidersRouter = {
|
||||
list: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/ai-providers",
|
||||
tags: ["AI Providers"],
|
||||
operationId: "listAiProviders",
|
||||
summary: "List saved AI providers",
|
||||
description: "Lists saved provider/model/API key combinations for the authenticated user. API keys are redacted.",
|
||||
})
|
||||
.output(type<AiProviderResponse[]>())
|
||||
.errors({
|
||||
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
|
||||
})
|
||||
.handler(async ({ context }) => {
|
||||
try {
|
||||
return await aiProvidersService.list({ userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/ai-providers",
|
||||
tags: ["AI Providers"],
|
||||
operationId: "createAiProvider",
|
||||
summary: "Create saved AI provider",
|
||||
description: "Stores an encrypted provider/model/API key combination. The key is never returned.",
|
||||
})
|
||||
.input(providerInput)
|
||||
.output(type<AiProviderResponse>())
|
||||
.errors({
|
||||
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
|
||||
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await aiProvidersService.create({
|
||||
userId: context.user.id,
|
||||
label: input.label,
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
baseURL: input.baseURL,
|
||||
apiKey: input.apiKey,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.route({
|
||||
method: "PATCH",
|
||||
path: "/ai-providers/{id}",
|
||||
tags: ["AI Providers"],
|
||||
operationId: "updateAiProvider",
|
||||
summary: "Update saved AI provider",
|
||||
description:
|
||||
"Updates a saved provider/model/API key combination. Updating the key requires retesting before use.",
|
||||
})
|
||||
.input(updateProviderInput)
|
||||
.output(type<AiProviderResponse>())
|
||||
.errors({
|
||||
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
|
||||
NOT_FOUND: { message: "AI provider was not found.", status: 404 },
|
||||
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await aiProvidersService.update({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
...(input.label !== undefined ? { label: input.label } : {}),
|
||||
...(input.provider !== undefined ? { provider: input.provider } : {}),
|
||||
...(input.model !== undefined ? { model: input.model } : {}),
|
||||
...(input.baseURL !== undefined ? { baseURL: input.baseURL } : {}),
|
||||
...(input.apiKey !== undefined ? { apiKey: input.apiKey } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/ai-providers/{id}",
|
||||
tags: ["AI Providers"],
|
||||
operationId: "deleteAiProvider",
|
||||
summary: "Delete saved AI provider",
|
||||
description: "Deletes a saved provider/model/API key combination.",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.errors({
|
||||
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
await aiProvidersService.delete({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
test: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/ai-providers/{id}/test",
|
||||
tags: ["AI Providers"],
|
||||
operationId: "testAiProvider",
|
||||
summary: "Test saved AI provider",
|
||||
description: "Decrypts the saved API key server-side and validates the provider/model connection.",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(type<AiProviderResponse>())
|
||||
.use(aiRequestRateLimit)
|
||||
.errors({
|
||||
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
NOT_FOUND: { message: "AI provider was not found.", status: 404 },
|
||||
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await aiProvidersService.test({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
|
||||
if (error instanceof ORPCError) throw error;
|
||||
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -1,241 +0,0 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { type } from "@orpc/server";
|
||||
import { AISDKError } from "ai";
|
||||
import { flattenError, ZodError, z } from "zod";
|
||||
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
|
||||
import { protectedProcedure } from "../context";
|
||||
import { aiRequestRateLimit } from "../middleware/rate-limit";
|
||||
import { aiService, fileInputSchema } from "../services/ai";
|
||||
import { aiProvidersService } from "../services/ai-providers";
|
||||
import { resumeService } from "../services/resume";
|
||||
|
||||
function isInvalidAiBaseUrlError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
|
||||
}
|
||||
|
||||
function isAiProviderGatewayError(error: unknown): boolean {
|
||||
return error instanceof AISDKError;
|
||||
}
|
||||
|
||||
function isCredentialEncryptionUnavailable(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE";
|
||||
}
|
||||
|
||||
function throwAiProviderGatewayError(): never {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
|
||||
}
|
||||
|
||||
function throwAiProviderConfigError(): never {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
|
||||
}
|
||||
|
||||
function throwCredentialEncryptionUnavailable(): never {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "AI providers are unavailable because ENCRYPTION_SECRET is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
function throwResumeStructureError(error: ZodError): never {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
|
||||
async function getRunnableProvider(userId: string, aiProviderId?: string) {
|
||||
const provider = aiProviderId
|
||||
? await aiProvidersService.getRunnableById({ id: aiProviderId, userId })
|
||||
: await aiProvidersService.getDefaultRunnable({ userId });
|
||||
|
||||
if (!provider) throw new ORPCError("BAD_REQUEST", { message: "No tested AI provider is available." });
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
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({ aiProviderId: z.string().optional(), file: fileInputSchema }))
|
||||
.use(aiRequestRateLimit)
|
||||
.errors({
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
|
||||
})
|
||||
.handler(async ({ context, input }): Promise<ResumeData> => {
|
||||
try {
|
||||
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
|
||||
return await aiService.parsePdf({
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseURL ?? "",
|
||||
file: input.file,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
if (error instanceof ZodError) throwResumeStructureError(error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
parseDocx: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/ai/parse-docx",
|
||||
tags: ["AI"],
|
||||
operationId: "parseResumeDocx",
|
||||
summary: "Parse a DOCX file into resume data",
|
||||
description:
|
||||
"Extracts structured resume data from a DOCX or DOC file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials and the document's media type. Returns a complete ResumeData object. Requires authentication.",
|
||||
successDescription: "The DOCX was successfully parsed into structured resume data.",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
aiProviderId: z.string().optional(),
|
||||
file: fileInputSchema,
|
||||
mediaType: z.enum([
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.errors({
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
|
||||
return await aiService.parseDocx({
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseURL ?? "",
|
||||
mediaType: input.mediaType,
|
||||
file: input.file,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
if (error instanceof ZodError) throwResumeStructureError(error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
chat: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/ai/chat",
|
||||
tags: ["AI"],
|
||||
operationId: "aiChat",
|
||||
summary: "Chat with AI to modify resume",
|
||||
description:
|
||||
"Streams a chat response from the configured AI provider. The LLM can call the propose_resume_patches tool to generate JSON Patch proposals for explicit user approval. Requires authentication and AI provider credentials.",
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
aiProviderId?: string;
|
||||
messages: UIMessage[];
|
||||
resumeId: string;
|
||||
}>(),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const [provider, resume] = await Promise.all([
|
||||
getRunnableProvider(context.user.id, input.aiProviderId),
|
||||
resumeService.getById({ id: input.resumeId, userId: context.user.id }),
|
||||
]);
|
||||
|
||||
return await aiService.chat({
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseURL ?? "",
|
||||
messages: input.messages,
|
||||
resumeData: resume.data,
|
||||
resumeUpdatedAt: resume.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
analyzeResume: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/ai/analyze-resume",
|
||||
tags: ["AI"],
|
||||
operationId: "analyzeResume",
|
||||
summary: "Analyze resume and persist latest analysis",
|
||||
description:
|
||||
"Uses AI to analyze the current resume and returns a structured analysis with scorecard, strengths, and improvement suggestions. The latest analysis is persisted and can be fetched later. Requires authentication and AI credentials.",
|
||||
successDescription: "Structured resume analysis returned and persisted successfully.",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
aiProviderId: z.string().optional(),
|
||||
resumeId: z.string(),
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.output(storedResumeAnalysisSchema)
|
||||
.errors({
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const [provider, resume] = await Promise.all([
|
||||
getRunnableProvider(context.user.id, input.aiProviderId),
|
||||
resumeService.getById({ id: input.resumeId, userId: context.user.id }),
|
||||
]);
|
||||
const analysis = await aiService.analyzeResume({
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseURL ?? "",
|
||||
resumeData: resume.data,
|
||||
});
|
||||
|
||||
return await resumeService.analysis.upsert({
|
||||
id: input.resumeId,
|
||||
userId: context.user.id,
|
||||
analysis: {
|
||||
...analysis,
|
||||
updatedAt: new Date(),
|
||||
modelMeta: { provider: provider.provider, model: provider.model },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume analysis structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { ProviderList } from "../services/auth";
|
||||
import { protectedProcedure, publicProcedure } from "../context";
|
||||
import { authService } from "../services/auth";
|
||||
|
||||
export const authRouter = {
|
||||
providers: {
|
||||
list: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/auth/providers",
|
||||
tags: ["Authentication"],
|
||||
operationId: "listAuthProviders",
|
||||
summary: "List authentication providers",
|
||||
description:
|
||||
"Returns a list of all authentication providers enabled on this Reactive Resume instance, along with their display names. Possible providers include password-based credentials, Google, GitHub, LinkedIn, and custom OAuth. No authentication required.",
|
||||
successDescription: "A map of enabled authentication provider identifiers to their display names.",
|
||||
})
|
||||
.handler((): ProviderList => {
|
||||
return authService.providers.list();
|
||||
}),
|
||||
},
|
||||
|
||||
deleteAccount: protectedProcedure
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/auth/account",
|
||||
tags: ["Authentication"],
|
||||
operationId: "deleteAccount",
|
||||
summary: "Delete user account",
|
||||
description:
|
||||
"Permanently deletes the authenticated user's account, including all resumes, uploaded files (profile pictures, screenshots, PDFs), and associated data. This action is irreversible. Requires authentication.",
|
||||
successDescription: "The user account and all associated data have been successfully deleted.",
|
||||
})
|
||||
.handler(async ({ context }): Promise<void> => {
|
||||
return await authService.deleteAccount({ userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { FeatureFlags } from "../services/flags";
|
||||
import z from "zod";
|
||||
import { publicProcedure } from "../context";
|
||||
import { flagsService } from "../services/flags";
|
||||
|
||||
export const flagsRouter = {
|
||||
get: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/flags",
|
||||
tags: ["Feature Flags"],
|
||||
operationId: "getFeatureFlags",
|
||||
summary: "Get feature flags",
|
||||
description:
|
||||
"Returns the current feature flags for this Reactive Resume instance. Feature flags control instance-wide settings such as whether new user signups or email-based authentication are disabled. No authentication required.",
|
||||
successDescription: "The current feature flags for this instance.",
|
||||
})
|
||||
.output(
|
||||
z.object({
|
||||
disableSignups: z.boolean().describe("Whether new user signups are disabled on this instance."),
|
||||
disableEmailAuth: z.boolean().describe("Whether email-based authentication is disabled on this instance."),
|
||||
}),
|
||||
)
|
||||
.handler((): FeatureFlags => flagsService.getFlags()),
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import { agentRouter } from "./agent";
|
||||
import { aiRouter } from "./ai";
|
||||
import { aiProvidersRouter } from "./ai-providers";
|
||||
import { authRouter } from "./auth";
|
||||
import { flagsRouter } from "./flags";
|
||||
import { resumeRouter } from "./resume";
|
||||
import { statisticsRouter } from "./statistics";
|
||||
import { storageRouter } from "./storage";
|
||||
import { agentRouter } from "../features/agent/router";
|
||||
import { aiRouter } from "../features/ai/router";
|
||||
import { aiProvidersRouter } from "../features/ai-providers/router";
|
||||
import { authRouter } from "../features/auth/router";
|
||||
import { flagsRouter } from "../features/flags/router";
|
||||
import { resumeRouter } from "../features/resume/router";
|
||||
import { statisticsRouter } from "../features/statistics/router";
|
||||
import { storageRouter } from "../features/storage/router";
|
||||
|
||||
export default {
|
||||
ai: aiRouter,
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
import z from "zod";
|
||||
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { generateRandomName, slugify } from "@reactive-resume/utils/string";
|
||||
import { protectedProcedure, publicProcedure } from "../context";
|
||||
import { resumeDto } from "../dto/resume";
|
||||
import { resumeMutationRateLimit, resumePasswordRateLimit } from "../middleware/rate-limit";
|
||||
import { resumeService } from "../services/resume";
|
||||
import { subscribeResumeUpdated } from "../services/resume-events";
|
||||
|
||||
const tagsRouter = {
|
||||
list: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/tags",
|
||||
tags: ["Resumes"],
|
||||
operationId: "listResumeTags",
|
||||
summary: "List all resume tags",
|
||||
description:
|
||||
"Returns a sorted list of all unique tags across the authenticated user's resumes. Useful for populating tag filters in the dashboard. Requires authentication.",
|
||||
successDescription: "A sorted array of unique tag strings.",
|
||||
})
|
||||
.output(z.array(z.string()))
|
||||
.handler(async ({ context }) => {
|
||||
return resumeService.tags.list({ userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
|
||||
const statisticsRouter = {
|
||||
getById: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}/statistics",
|
||||
tags: ["Resume Statistics"],
|
||||
operationId: "getResumeStatistics",
|
||||
summary: "Get resume statistics",
|
||||
description:
|
||||
"Returns view and download statistics for the specified resume, including total counts and the timestamps of the last view and download. Requires authentication.",
|
||||
successDescription: "The resume's view and download statistics.",
|
||||
})
|
||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
|
||||
.output(
|
||||
z.object({
|
||||
isPublic: z.boolean().describe("Whether the resume is currently public."),
|
||||
views: z.number().describe("Total number of times the resume has been viewed."),
|
||||
downloads: z.number().describe("Total number of times the resume has been downloaded."),
|
||||
lastViewedAt: z.date().nullable().describe("Timestamp of the last view, or null if never viewed."),
|
||||
lastDownloadedAt: z.date().nullable().describe("Timestamp of the last download, or null if never downloaded."),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.statistics.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
|
||||
const analysisRouter = {
|
||||
getById: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}/analysis",
|
||||
tags: ["Resume Analysis"],
|
||||
operationId: "getResumeAnalysis",
|
||||
summary: "Get latest resume analysis",
|
||||
description:
|
||||
"Returns the latest persisted AI analysis for the specified resume, if one exists. Requires authentication.",
|
||||
successDescription: "The latest persisted resume analysis, or null if no analysis has been saved yet.",
|
||||
})
|
||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
|
||||
.output(storedResumeAnalysisSchema.nullable())
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.analysis.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
|
||||
const updatesRouter = {
|
||||
subscribe: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}/updates",
|
||||
tags: ["Resumes"],
|
||||
operationId: "subscribeResumeUpdates",
|
||||
summary: "Subscribe to resume updates",
|
||||
description:
|
||||
"Streams lightweight invalidation events when the specified resume changes. The event payload contains metadata only; clients should refetch the resume for canonical data.",
|
||||
successDescription: "A stream of resume update invalidation events.",
|
||||
})
|
||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
|
||||
.handler(async function* ({ context, input, signal }) {
|
||||
const resume = await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
|
||||
yield {
|
||||
type: "resume.updated" as const,
|
||||
resumeId: input.id,
|
||||
userId: context.user.id,
|
||||
updatedAt: resume.updatedAt.toISOString(),
|
||||
mutation: "sync" as const,
|
||||
};
|
||||
|
||||
yield* subscribeResumeUpdated({
|
||||
resumeId: input.id,
|
||||
userId: context.user.id,
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
export const resumeRouter = {
|
||||
tags: tagsRouter,
|
||||
statistics: statisticsRouter,
|
||||
analysis: analysisRouter,
|
||||
updates: updatesRouter,
|
||||
|
||||
list: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes",
|
||||
tags: ["Resumes"],
|
||||
operationId: "listResumes",
|
||||
summary: "List all resumes",
|
||||
description:
|
||||
"Returns a list of all resumes belonging to the authenticated user. Results can be filtered by tags and sorted by last updated date, creation date, or name. Resume data is not included in the response for performance; use the get endpoint to fetch full resume data. Requires authentication.",
|
||||
successDescription: "A list of resumes with their metadata (without full resume data).",
|
||||
})
|
||||
.input(resumeDto.list.input.optional().default({ tags: [], sort: "lastUpdatedAt" }))
|
||||
.output(resumeDto.list.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return resumeService.list({
|
||||
userId: context.user.id,
|
||||
tags: input.tags,
|
||||
sort: input.sort,
|
||||
});
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}",
|
||||
tags: ["Resumes"],
|
||||
operationId: "getResume",
|
||||
summary: "Get resume by ID",
|
||||
description:
|
||||
"Returns a single resume with its full data, identified by its unique ID. Only resumes belonging to the authenticated user can be retrieved. Requires authentication.",
|
||||
successDescription: "The resume with its full data.",
|
||||
})
|
||||
.input(resumeDto.getById.input)
|
||||
.output(resumeDto.getById.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
|
||||
getBySlug: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{username}/{slug}",
|
||||
tags: ["Resume Sharing"],
|
||||
operationId: "getResumeBySlug",
|
||||
summary: "Get public resume by username and slug",
|
||||
description:
|
||||
"Returns a publicly shared resume identified by the owner's username and the resume's slug. If the resume is password-protected and the viewer has not yet verified the password, a 401 error with code NEED_PASSWORD is returned. No authentication required for public resumes; if authenticated as the owner, private resumes are also accessible.",
|
||||
successDescription: "The public resume with its full data.",
|
||||
})
|
||||
.input(resumeDto.getBySlug.input)
|
||||
.output(resumeDto.getBySlug.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return resumeService.getBySlug({
|
||||
...input,
|
||||
...(context.user?.id ? { currentUserId: context.user.id } : {}),
|
||||
});
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/resumes",
|
||||
tags: ["Resumes"],
|
||||
operationId: "createResume",
|
||||
summary: "Create a new resume",
|
||||
description:
|
||||
"Creates a new resume with the given name, slug, and tags. Optionally initializes the resume with sample data by setting withSampleData to true. The slug must be unique across the user's resumes. Returns the ID of the newly created resume. Requires authentication.",
|
||||
successDescription: "The ID of the newly created resume.",
|
||||
})
|
||||
.input(resumeDto.create.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.create.output)
|
||||
.errors({
|
||||
RESUME_SLUG_ALREADY_EXISTS: {
|
||||
message: "A resume with this slug already exists.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.create({
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
tags: input.tags,
|
||||
locale: context.locale,
|
||||
userId: context.user.id,
|
||||
...(input.withSampleData ? { data: sampleResumeData } : {}),
|
||||
});
|
||||
}),
|
||||
|
||||
import: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/resumes/import",
|
||||
tags: ["Resumes"],
|
||||
operationId: "importResume",
|
||||
summary: "Import a resume",
|
||||
description:
|
||||
"Creates a new resume from an existing ResumeData object (e.g. from a previously exported JSON file). A random name and slug are generated automatically. Returns the ID of the imported resume. Requires authentication.",
|
||||
successDescription: "The ID of the imported resume.",
|
||||
})
|
||||
.input(resumeDto.import.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.import.output)
|
||||
.errors({
|
||||
RESUME_SLUG_ALREADY_EXISTS: {
|
||||
message: "A resume with this slug already exists.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
const name = generateRandomName();
|
||||
const slug = slugify(name);
|
||||
|
||||
return resumeService.create({
|
||||
name,
|
||||
slug,
|
||||
tags: [],
|
||||
data: input.data,
|
||||
locale: context.locale,
|
||||
userId: context.user.id,
|
||||
});
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/resumes/{id}",
|
||||
tags: ["Resumes"],
|
||||
operationId: "updateResume",
|
||||
summary: "Update a resume",
|
||||
description:
|
||||
"Updates one or more fields of a resume identified by its ID. All fields are optional; only provided fields will be updated. Locked resumes cannot be updated. Requires authentication.",
|
||||
successDescription: "The updated resume with its full data.",
|
||||
})
|
||||
.input(resumeDto.update.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.update.output)
|
||||
.errors({
|
||||
RESUME_SLUG_ALREADY_EXISTS: {
|
||||
message: "A resume with this slug already exists.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.update({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.slug !== undefined ? { slug: input.slug } : {}),
|
||||
...(input.tags !== undefined ? { tags: input.tags } : {}),
|
||||
...(input.data !== undefined ? { data: input.data } : {}),
|
||||
...(input.isPublic !== undefined ? { isPublic: input.isPublic } : {}),
|
||||
});
|
||||
}),
|
||||
|
||||
patch: protectedProcedure
|
||||
.route({
|
||||
method: "PATCH",
|
||||
path: "/resumes/{id}",
|
||||
tags: ["Resumes"],
|
||||
operationId: "patchResume",
|
||||
summary: "Patch resume data",
|
||||
description:
|
||||
"Applies JSON Patch (RFC 6902) operations to partially update a resume's data. This allows small, targeted changes (e.g. updating a single field) without sending the entire resume object. Locked resumes cannot be patched. Requires authentication.",
|
||||
successDescription: "The patched resume with its full data.",
|
||||
})
|
||||
.input(resumeDto.patch.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.patch.output)
|
||||
.errors({
|
||||
INVALID_PATCH_OPERATIONS: {
|
||||
message: "The patch operations are invalid or produced an invalid resume.",
|
||||
status: 400,
|
||||
},
|
||||
RESUME_VERSION_CONFLICT: {
|
||||
message: "The resume changed after this patch was generated.",
|
||||
status: 409,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.patch({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
operations: input.operations,
|
||||
...(input.expectedUpdatedAt ? { expectedUpdatedAt: input.expectedUpdatedAt } : {}),
|
||||
});
|
||||
}),
|
||||
|
||||
setLocked: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/resumes/{id}/lock",
|
||||
tags: ["Resumes"],
|
||||
operationId: "setResumeLocked",
|
||||
summary: "Set resume lock status",
|
||||
description:
|
||||
"Toggles the locked status of a resume. When locked, a resume cannot be updated, patched, or deleted. Useful for protecting finalized resumes from accidental edits. Requires authentication.",
|
||||
successDescription: "The resume lock status was updated successfully.",
|
||||
})
|
||||
.input(resumeDto.setLocked.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.setLocked.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.setLocked({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
isLocked: input.isLocked,
|
||||
});
|
||||
}),
|
||||
|
||||
setPassword: protectedProcedure
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/resumes/{id}/password",
|
||||
tags: ["Resume Sharing"],
|
||||
operationId: "setResumePassword",
|
||||
summary: "Set resume password",
|
||||
description:
|
||||
"Sets or updates a password on a resume. When a password is set, viewers of the public resume must enter the password before the resume data is revealed. The password must be between 6 and 64 characters. Requires authentication.",
|
||||
successDescription: "The resume password was set successfully.",
|
||||
})
|
||||
.input(resumeDto.setPassword.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.setPassword.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.setPassword({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
password: input.password,
|
||||
});
|
||||
}),
|
||||
|
||||
verifyPassword: publicProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/resumes/{username}/{slug}/password/verify",
|
||||
tags: ["Resume Sharing"],
|
||||
operationId: "verifyResumePassword",
|
||||
summary: "Verify resume password",
|
||||
description:
|
||||
"Verifies a password for a password-protected public resume. On success, the viewer is granted access to view the resume data for the duration of their session. No authentication required.",
|
||||
successDescription: "The password was verified successfully and access has been granted.",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
username: z.string().min(1).describe("The username of the resume owner."),
|
||||
slug: z.string().min(1).describe("The slug of the resume."),
|
||||
password: z.string().min(1).describe("The password to verify."),
|
||||
}),
|
||||
)
|
||||
.use(resumePasswordRateLimit)
|
||||
.output(z.boolean())
|
||||
.handler(async ({ input }): Promise<boolean> => {
|
||||
return resumeService.verifyPassword({
|
||||
username: input.username,
|
||||
slug: input.slug,
|
||||
password: input.password,
|
||||
});
|
||||
}),
|
||||
|
||||
removePassword: protectedProcedure
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/resumes/{id}/password",
|
||||
tags: ["Resume Sharing"],
|
||||
operationId: "removeResumePassword",
|
||||
summary: "Remove resume password",
|
||||
description:
|
||||
"Removes password protection from a resume. After removal, the resume (if public) can be viewed without entering a password. Requires authentication.",
|
||||
successDescription: "The resume password was removed successfully.",
|
||||
})
|
||||
.input(resumeDto.removePassword.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.removePassword.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.removePassword({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
});
|
||||
}),
|
||||
|
||||
duplicate: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/resumes/{id}/duplicate",
|
||||
tags: ["Resumes"],
|
||||
operationId: "duplicateResume",
|
||||
summary: "Duplicate a resume",
|
||||
description:
|
||||
"Creates a copy of an existing resume with the same data. Optionally override the name, slug, and tags for the duplicate. If not provided, the original resume's name, slug, and tags are used. Returns the ID of the duplicated resume. Requires authentication.",
|
||||
successDescription: "The ID of the duplicated resume.",
|
||||
})
|
||||
.input(resumeDto.duplicate.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.duplicate.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
const original = await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
|
||||
return resumeService.create({
|
||||
userId: context.user.id,
|
||||
name: input.name ?? original.name,
|
||||
slug: input.slug ?? original.slug,
|
||||
tags: input.tags ?? original.tags,
|
||||
locale: context.locale,
|
||||
data: original.data,
|
||||
});
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/resumes/{id}",
|
||||
tags: ["Resumes"],
|
||||
operationId: "deleteResume",
|
||||
summary: "Delete a resume",
|
||||
description:
|
||||
"Permanently deletes a resume and its associated files (screenshots, PDFs) from storage. Locked resumes cannot be deleted; unlock the resume first. Requires authentication.",
|
||||
successDescription: "The resume and its associated files were deleted successfully.",
|
||||
})
|
||||
.input(resumeDto.delete.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.delete.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.delete({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import z from "zod";
|
||||
import { publicProcedure } from "../context";
|
||||
import { statisticsService } from "../services/statistics";
|
||||
|
||||
const userRouter = {
|
||||
getCount: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/statistics/users",
|
||||
tags: ["Platform Statistics"],
|
||||
operationId: "getUserCount",
|
||||
summary: "Get total number of users",
|
||||
description:
|
||||
"Returns the total number of registered users on this Reactive Resume instance. The count is cached for up to 6 hours for performance. No authentication required.",
|
||||
successDescription: "The total number of registered users.",
|
||||
})
|
||||
.output(z.number().describe("The total number of registered users."))
|
||||
.handler(async (): Promise<number> => {
|
||||
return await statisticsService.user.getCount();
|
||||
}),
|
||||
};
|
||||
|
||||
const resumeRouter = {
|
||||
getCount: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/statistics/resumes",
|
||||
tags: ["Platform Statistics"],
|
||||
operationId: "getResumeCount",
|
||||
summary: "Get total number of resumes",
|
||||
description:
|
||||
"Returns the total number of resumes created on this Reactive Resume instance. The count is cached for up to 6 hours for performance. No authentication required.",
|
||||
successDescription: "The total number of resumes created.",
|
||||
})
|
||||
.output(z.number().describe("The total number of resumes created."))
|
||||
.handler(async (): Promise<number> => {
|
||||
return await statisticsService.resume.getCount();
|
||||
}),
|
||||
};
|
||||
|
||||
const githubRouter = {
|
||||
getStarCount: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/statistics/github/stars",
|
||||
tags: ["Platform Statistics"],
|
||||
operationId: "getGitHubStarCount",
|
||||
summary: "Get GitHub star count",
|
||||
description:
|
||||
"Returns the number of GitHub stars for the Reactive Resume repository. The count is cached for up to 6 hours and falls back to a last-known value if the GitHub API is unavailable. No authentication required.",
|
||||
successDescription: "The number of GitHub stars for the Reactive Resume repository.",
|
||||
})
|
||||
.output(z.number().describe("The number of GitHub stars."))
|
||||
.handler(async (): Promise<number> => {
|
||||
return await statisticsService.github.getStarCount();
|
||||
}),
|
||||
};
|
||||
|
||||
export const statisticsRouter = {
|
||||
user: userRouter,
|
||||
resume: resumeRouter,
|
||||
github: githubRouter,
|
||||
};
|
||||
@@ -1,110 +0,0 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../context";
|
||||
import { storageDeleteRateLimit, storageUploadRateLimit } from "../middleware/rate-limit";
|
||||
import { getStorageService, isImageFile, processImageForUpload, uploadFile } from "../services/storage";
|
||||
|
||||
const storageService = getStorageService();
|
||||
|
||||
const fileSchema = z.file().max(10 * 1024 * 1024, "File size must be less than 10MB");
|
||||
|
||||
const filenameSchema = z.object({
|
||||
filename: z.string().min(1).describe("The path or filename of the file to delete."),
|
||||
});
|
||||
|
||||
function normalizeKey(input: string): string {
|
||||
return input.trim().replace(/^\/+/, "").split("/").filter(Boolean).join("/");
|
||||
}
|
||||
|
||||
function isUnsafeStorageKey(key: string): boolean {
|
||||
return key.split("/").some((segment) => segment === "." || segment === "..");
|
||||
}
|
||||
|
||||
export const storageRouter = {
|
||||
uploadFile: protectedProcedure
|
||||
.route({
|
||||
tags: ["Internal"],
|
||||
operationId: "uploadFile",
|
||||
summary: "Upload a file",
|
||||
description:
|
||||
"Uploads a file to storage. Images are automatically resized and converted to JPEG format. Maximum file size is 10MB. Requires authentication.",
|
||||
successDescription: "The file was uploaded successfully.",
|
||||
})
|
||||
.input(fileSchema)
|
||||
.use(storageUploadRateLimit)
|
||||
.output(
|
||||
z.object({
|
||||
url: z.string().describe("The public URL to access the uploaded file."),
|
||||
path: z.string().describe("The storage path of the uploaded file."),
|
||||
contentType: z.string().describe("The MIME type of the uploaded file."),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ context, input: file }) => {
|
||||
const originalMimeType = file.type;
|
||||
const isImage = isImageFile(originalMimeType);
|
||||
|
||||
let data: Uint8Array;
|
||||
let contentType: string;
|
||||
|
||||
if (isImage) {
|
||||
const processed = await processImageForUpload(file);
|
||||
data = processed.data;
|
||||
contentType = processed.contentType;
|
||||
} else {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
data = new Uint8Array(fileBuffer);
|
||||
contentType = originalMimeType;
|
||||
}
|
||||
|
||||
const result = await uploadFile({
|
||||
userId: context.user.id,
|
||||
data,
|
||||
contentType,
|
||||
type: "picture",
|
||||
});
|
||||
|
||||
return {
|
||||
url: result.url,
|
||||
path: result.key,
|
||||
contentType,
|
||||
};
|
||||
}),
|
||||
|
||||
deleteFile: protectedProcedure
|
||||
.route({
|
||||
tags: ["Internal"],
|
||||
operationId: "deleteFile",
|
||||
summary: "Delete a file",
|
||||
description:
|
||||
"Deletes a file from storage by its filename or path. If the filename does not start with 'uploads/', the user's picture directory is assumed. Requires authentication.",
|
||||
successDescription: "The file was deleted successfully.",
|
||||
})
|
||||
.input(filenameSchema)
|
||||
.use(storageDeleteRateLimit)
|
||||
.output(z.void())
|
||||
.errors({
|
||||
NOT_FOUND: {
|
||||
message: "The specified file was not found in storage.",
|
||||
status: 404,
|
||||
},
|
||||
FORBIDDEN: {
|
||||
message: "You do not have permission to delete this file.",
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }): Promise<void> => {
|
||||
const requestedKey = normalizeKey(input.filename);
|
||||
const key = requestedKey.startsWith("uploads/")
|
||||
? requestedKey
|
||||
: normalizeKey(`uploads/${context.user.id}/pictures/${requestedKey}`);
|
||||
const userPrefix = `uploads/${context.user.id}/`;
|
||||
|
||||
if (isUnsafeStorageKey(key) || !key.startsWith(userPrefix)) {
|
||||
throw new ORPCError("FORBIDDEN");
|
||||
}
|
||||
|
||||
const deleted = await storageService.delete(key);
|
||||
|
||||
if (!deleted) throw new ORPCError("NOT_FOUND");
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user