mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
feat(api): add REST endpoints for imports, exports, checks and pagination
Resource-oriented REST aliases cover file imports (PDF text layer, JSON Resume, Reactive Resume v4/current, LinkedIn), PDF/DOCX/Markdown/JSON exports for resumes and cover letters, editor content checks, job-term matching and a public PDF readability checker. Collection endpoints accept limit/offset with X-Total-Count headers, and PATCH routes cover partial updates. Existing routes and response shapes stay unchanged. The OpenAPI spec marks public operations with `security: []`, the REST surface gets a 40 MiB body limit, and Better Auth publishes its own OpenAPI schema. Cookie sessions are rejected for cross-origin requests. DOCX export parses HTML with node-html-parser so it runs on the server, and PDF line extraction moves to @reactive-resume/import for reuse.
This commit is contained in:
@@ -62,6 +62,7 @@
|
||||
"better-auth": "catalog:",
|
||||
"cjk-regex": "^3.5.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"docx": "^9.8.1",
|
||||
"drizzle-orm": "catalog:",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "catalog:",
|
||||
@@ -75,6 +76,7 @@
|
||||
"node-html-parser": "^9.0.4",
|
||||
"nodemailer": "^10.0.13",
|
||||
"ollama-ai-provider-v2": "^4.0.1",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"pg": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-email": "^6.11.0",
|
||||
|
||||
@@ -239,3 +239,16 @@ it.each(["/auth/consent", "/auth/consent/", "/auth/login"])("prevents framing or
|
||||
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("rejects oversized REST requests before parsing multipart uploads", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const response = await createApp().request("http://localhost:3000/api/openapi/files", {
|
||||
method: "POST",
|
||||
headers: { "Content-Length": String(40 * 1024 * 1024 + 1) },
|
||||
body: "x",
|
||||
});
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toMatchObject({ code: "PAYLOAD_TOO_LARGE", status: 413 });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(mocks.handleOpenApi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Context } from "hono";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { compress } from "hono/compress";
|
||||
import { prepareStagedBody, withStagedBody } from "@reactive-resume/api/features/storage/transport";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
@@ -73,6 +74,17 @@ export function createApp(options: AppOptions = {}) {
|
||||
c.header("Cache-Control", "no-store");
|
||||
});
|
||||
|
||||
app.use(
|
||||
"/api/openapi/*",
|
||||
bodyLimit({
|
||||
maxSize: 40 * 1024 * 1024,
|
||||
onError: (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ defined: false, code: "PAYLOAD_TOO_LARGE", status: 413, message: "Payload too large" }, 413);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.post("/api/storage/stage", (c) => prepareStagedBody(c.req.raw));
|
||||
app.all("/api/rpc", (c) => withStagedBody(c.req.raw, (request) => handleRpc(request, client(c))));
|
||||
app.all("/api/rpc/*", (c) => withStagedBody(c.req.raw, (request) => handleRpc(request, client(c))));
|
||||
|
||||
@@ -91,3 +91,55 @@ describe("generateOpenApiSpec", () => {
|
||||
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("documents anonymous routes, all supported credentials, and additive PATCH routes", async () => {
|
||||
const spec = await generateSpec();
|
||||
expect(spec.paths?.["/flags"]?.get?.security).toEqual([]);
|
||||
expect(spec.paths?.["/resumes/{username}/{slug}"]?.get?.security).toEqual([]);
|
||||
expect(spec.paths?.["/resumes"]?.get?.security).toEqual([{ apiKey: [] }, { bearerAuth: [] }, { cookieAuth: [] }]);
|
||||
for (const path of ["/applications/{id}", "/cover-letters/{id}"]) {
|
||||
expect(spec.paths?.[path]?.put?.requestBody).toBeDefined();
|
||||
expect(spec.paths?.[path]?.patch?.requestBody).toBeDefined();
|
||||
}
|
||||
expect(spec.paths?.["/files"]?.post?.requestBody).toHaveProperty("content.multipart/form-data");
|
||||
expect(spec.paths?.["/files"]?.post?.requestBody).not.toHaveProperty("content.application/json");
|
||||
expect(spec.paths?.["/resumes"]?.get?.parameters).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "limit", in: "query" }),
|
||||
expect.objectContaining({ name: "offset", in: "query" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("gives every published app operation an explicit route and input/output contracts", async () => {
|
||||
const { openAPIRouter } = await import("./generator");
|
||||
const visit = (node: unknown, path: string) => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if ("~orpc" in node) {
|
||||
const definition = node["~orpc"] as {
|
||||
route: { tags?: string[]; method?: string; path?: string };
|
||||
inputSchema?: unknown;
|
||||
outputSchema?: unknown;
|
||||
};
|
||||
if (definition.route.tags?.includes("Internal")) return;
|
||||
expect(definition.route.method, path).toBeDefined();
|
||||
expect(definition.route.path, path).toBeDefined();
|
||||
expect(definition.inputSchema, path).toBeDefined();
|
||||
expect(definition.outputSchema, path).toBeDefined();
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) visit(value, `${path}.${key}`);
|
||||
};
|
||||
visit(openAPIRouter, "api");
|
||||
});
|
||||
|
||||
it("describes empty responses and common errors without impossible payloads", async () => {
|
||||
const spec = await generateSpec();
|
||||
const deleted = spec.paths?.["/files"]?.delete?.responses?.["200"];
|
||||
expect(deleted).toBeDefined();
|
||||
expect(deleted).not.toHaveProperty("content");
|
||||
expect(spec.paths?.["/resumes"]?.get?.responses?.default).toHaveProperty(
|
||||
"content.application/json.schema.properties.code",
|
||||
);
|
||||
expect(spec.paths?.["/resumes"]?.get?.responses?.["200"]).toHaveProperty("headers.X-Total-Count");
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenAPI } from "@orpc/openapi";
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { JSON_SCHEMA_INPUT_REGISTRY, ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||
import { restAliases } from "@reactive-resume/api/rest";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
@@ -9,6 +10,7 @@ import { writableResumeDataSchema } from "@reactive-resume/schema/resume/write";
|
||||
|
||||
export const openAPIRouter = {
|
||||
...router,
|
||||
rest: restAliases,
|
||||
resume: {
|
||||
...router.resume,
|
||||
downloadPdf: downloadResumePdfProcedure,
|
||||
@@ -79,11 +81,12 @@ const healthResponseSchema = {
|
||||
} satisfies OpenAPI.SchemaObject;
|
||||
|
||||
export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSpecOptions) {
|
||||
return await openAPIGenerator.generate(openAPIRouter, {
|
||||
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
||||
info: {
|
||||
title: "Reactive Resume",
|
||||
version,
|
||||
description: "Reactive Resume API",
|
||||
description:
|
||||
"Reactive Resume API. Mutations do not support Idempotency-Key. Do not automatically retry POST, PUT, PATCH or DELETE requests after a timeout: first read the resource to determine whether the operation succeeded. Existing enum values and response shapes are retained for compatibility.",
|
||||
license: { name: "MIT", url: "https://github.com/reactive-resume/reactive-resume/blob/main/LICENSE" },
|
||||
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||
},
|
||||
@@ -116,6 +119,19 @@ export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSp
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
description: "An OAuth access token issued by this instance for its API/MCP resource.",
|
||||
},
|
||||
cookieAuth: {
|
||||
type: "apiKey",
|
||||
in: "cookie",
|
||||
name: "better-auth.session_token",
|
||||
description:
|
||||
"Browser session (secure deployments use the __Secure- prefix). Cookie requests must originate from this instance.",
|
||||
},
|
||||
apiKey: {
|
||||
type: "apiKey",
|
||||
name: "x-api-key",
|
||||
@@ -124,7 +140,66 @@ export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSp
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ apiKey: [] }],
|
||||
security: [{ apiKey: [] }, { bearerAuth: [] }, { cookieAuth: [] }],
|
||||
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||
});
|
||||
// Void results have no HTTP body; Zod's impossible JSON schema is not a response payload.
|
||||
const isVoid = (schema: unknown): boolean => {
|
||||
if (!schema || typeof schema !== "object") return false;
|
||||
const value = schema as { not?: object; anyOf?: unknown[] };
|
||||
return (
|
||||
(value.not !== undefined && Object.keys(value.not).length === 0) ||
|
||||
(Array.isArray(value.anyOf) && value.anyOf.every(isVoid))
|
||||
);
|
||||
};
|
||||
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
||||
if (!item || path === "/api/health") continue;
|
||||
for (const method of ["get", "post", "put", "patch", "delete"] as const) {
|
||||
const operation = item[method];
|
||||
if (!operation) continue;
|
||||
const body = operation.requestBody;
|
||||
if (body && !("$ref" in body) && body.content["multipart/form-data"]) delete body.content["application/json"];
|
||||
operation.responses ??= {};
|
||||
operation.responses.default = {
|
||||
description: "Structured API error. See status and code; do not branch on message text.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["defined", "code", "status", "message"],
|
||||
properties: {
|
||||
defined: { type: "boolean" },
|
||||
code: { type: "string" },
|
||||
status: { type: "integer" },
|
||||
message: { type: "string" },
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
for (const response of Object.values(operation.responses)) {
|
||||
if ("$ref" in response) continue;
|
||||
const schema = response.content?.["application/json"]?.schema;
|
||||
if (isVoid(schema)) delete response.content;
|
||||
if (
|
||||
schema &&
|
||||
"type" in schema &&
|
||||
schema.type === "array" &&
|
||||
operation.parameters?.some((parameter) => "name" in parameter && parameter.name === "limit")
|
||||
) {
|
||||
response.headers = {
|
||||
...response.headers,
|
||||
"X-Total-Count": { description: "Total matching results before pagination.", schema: { type: "integer" } },
|
||||
"X-Limit": { description: "Page size, when pagination is requested.", schema: { type: "integer" } },
|
||||
"X-Offset": {
|
||||
description: "Zero-based offset, when pagination is requested.",
|
||||
schema: { type: "integer" },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: {
|
||||
api: { getSession: vi.fn().mockResolvedValue(null), verifyApiKey: vi.fn().mockResolvedValue({ valid: false }) },
|
||||
},
|
||||
verifyOAuthToken: vi.fn().mockRejectedValue(new Error("invalid token")),
|
||||
isCustomOAuthProviderEnabled: () => false,
|
||||
}));
|
||||
const { handleOpenApi } = await import("./handler");
|
||||
const request = (path: string, init?: RequestInit) =>
|
||||
handleOpenApi(new Request(`http://localhost:3000/api/openapi${path}`, init), "127.0.0.1");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("REST boundary", () => {
|
||||
it("serves public configuration without authentication and marks it uncacheable", async () => {
|
||||
const response = await request("/flags");
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toHaveProperty("disableSignups");
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
});
|
||||
it.each(["/resume/getRoot", "/storage/uploadFile", "/storage/deleteFile", "/missing"])(
|
||||
"does not expose internal or unknown routes: %s",
|
||||
async (path) => {
|
||||
const response = await request(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toMatchObject({ code: "NOT_FOUND", status: 404 });
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
"/resumes",
|
||||
"/applications",
|
||||
"/documents",
|
||||
"/ai-providers",
|
||||
"/agent/threads",
|
||||
"/cover-letters",
|
||||
"/resumes/private/exports/json",
|
||||
"/cover-letters/private/exports/pdf",
|
||||
])("rejects unauthenticated private reads: %s", async (path) => {
|
||||
const response = await request(path);
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toMatchObject({ code: "UNAUTHORIZED", status: 401 });
|
||||
});
|
||||
it("decodes multipart public checks and returns structured errors for invalid PDF content", async () => {
|
||||
const form = new FormData();
|
||||
form.set("file", new File(["not a PDF"], "resume.pdf", { type: "application/pdf" }));
|
||||
const response = await request("/pdf-checks", { method: "POST", body: form });
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Provide a PDF no larger than 25 MB.",
|
||||
});
|
||||
});
|
||||
it("returns the same JSON error envelope for malformed JSON", async () => {
|
||||
const response = await request("/resumes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({ code: "BAD_REQUEST", status: 400 });
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { onError } from "@orpc/server";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import { RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { appVersion } from "../app-version";
|
||||
@@ -10,8 +10,8 @@ import { getRequestLocale } from "../rpc/locale";
|
||||
import { generateOpenApiSpec, openAPIRouter } from "./generator";
|
||||
|
||||
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||
plugins: [
|
||||
new BatchHandlerPlugin(),
|
||||
new RequestHeadersPlugin(),
|
||||
new StrictGetMethodPlugin(),
|
||||
new SmartCoercionPlugin({
|
||||
@@ -26,7 +26,8 @@ const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||
});
|
||||
|
||||
export async function handleOpenApi(request: Request, trustedClient: string) {
|
||||
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
||||
const pathname = new URL(request.url).pathname;
|
||||
if (request.method === "GET" && ["/api/openapi/spec.json", "/api/openapi/spec"].includes(pathname)) {
|
||||
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
|
||||
}
|
||||
|
||||
@@ -36,6 +37,12 @@ export async function handleOpenApi(request: Request, trustedClient: string) {
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders, trustedClient },
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
resHeaders.set("Cache-Control", "no-store");
|
||||
resHeaders.set("X-Content-Type-Options", "nosniff");
|
||||
if (!response)
|
||||
return Response.json(
|
||||
{ defined: false, code: "NOT_FOUND", status: 404, message: "Not found" },
|
||||
{ status: 404, headers: resHeaders },
|
||||
);
|
||||
return mergeResponseHeaders(response, resHeaders);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
|
||||
type StoredObject = { data: Buffer; contentType: string };
|
||||
type StorageRequest = { method: string; path: string; headers: IncomingHttpHeaders };
|
||||
const resolveUser = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@reactive-resume/api/context", () => ({ resolveUserFromRequestHeaders: resolveUser }));
|
||||
|
||||
const objects = new Map<string, StoredObject>();
|
||||
const requests: StorageRequest[] = [];
|
||||
|
||||
@@ -62,6 +65,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resolveUser.mockReset();
|
||||
objects.clear();
|
||||
requests.length = 0;
|
||||
});
|
||||
@@ -102,8 +106,31 @@ it("stores private attachments without ACLs while keeping them outside the publi
|
||||
it.each(["text/html", "image/svg+xml"])("downloads stored %s uploads instead of rendering them", async (type) => {
|
||||
const key = "uploads/user-1/pictures/upload.bin";
|
||||
await storage.write({ key, data: new TextEncoder().encode("<script>alert(1)</script>"), contentType: type });
|
||||
resolveUser.mockResolvedValue({ id: "user-1" });
|
||||
const response = await handleUpload(new Request(`${envMock.APP_URL}/api/${key}`));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/octet-stream");
|
||||
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="upload.bin"');
|
||||
});
|
||||
|
||||
it("requires the upload owner before serving application PDFs, including conditional requests", async () => {
|
||||
const key = "uploads/user-1/pictures/application.pdf";
|
||||
await storage.write({
|
||||
key,
|
||||
data: new TextEncoder().encode("private application PDF"),
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
for (const viewer of [null, { id: "other-user" }]) {
|
||||
resolveUser.mockResolvedValue(viewer);
|
||||
const response = await handleUpload(
|
||||
new Request(`${envMock.APP_URL}/api/${key}`, { headers: { "If-None-Match": '"test-etag"' } }),
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).not.toContain("private application PDF");
|
||||
}
|
||||
resolveUser.mockResolvedValue({ id: "user-1" });
|
||||
const response = await handleUpload(new Request(`${envMock.APP_URL}/api/${key}`));
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("private application PDF");
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
});
|
||||
|
||||
@@ -21,9 +21,16 @@ export async function handleUpload(request: Request) {
|
||||
|
||||
const filename = filePath.split("/").pop() ?? filePath;
|
||||
const contentType = storedFile.contentType ?? inferContentType(filename);
|
||||
const isPublicPicture = filePath.startsWith("pictures/") && INLINE_CONTENT_TYPES.has(contentType);
|
||||
if (!isPublicPicture) {
|
||||
const { resolveUserFromRequestHeaders } = await import("@reactive-resume/api/context");
|
||||
const user = await resolveUserFromRequestHeaders(request.headers).catch(() => null);
|
||||
if (user?.id !== userId)
|
||||
return new Response("Not Found", { status: 404, headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
const etag = createEtag(storedFile);
|
||||
|
||||
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
|
||||
if (isPublicPicture && isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
|
||||
|
||||
const shouldForceDownload = !INLINE_CONTENT_TYPES.has(contentType);
|
||||
|
||||
@@ -35,7 +42,7 @@ export async function handleUpload(request: Request) {
|
||||
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
|
||||
}
|
||||
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
headers.set("Cache-Control", isPublicPicture ? "public, max-age=31536000, immutable" : "private, no-store");
|
||||
headers.set("ETag", etag);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
|
||||
@@ -41,6 +41,7 @@ const bundledInteropPackages = new Set([
|
||||
"source-map-js",
|
||||
"launder",
|
||||
"dayjs",
|
||||
"wink-porter2-stemmer",
|
||||
]);
|
||||
|
||||
const packageNameOf = (id: string) =>
|
||||
|
||||
@@ -1,81 +1,8 @@
|
||||
import type { ExtractedDocument } from "@reactive-resume/resume/ats-pdf";
|
||||
import { documentToLines } from "@reactive-resume/import/pdf-lines";
|
||||
import { buildExtractedDocument } from "@reactive-resume/resume/ats-pdf";
|
||||
import { extractPdf } from "@/features/ats-checker/extract-client";
|
||||
|
||||
/**
|
||||
* Turns a PDF into the plain lines `parseResumeText` reads.
|
||||
*
|
||||
* The extraction itself is the ATS checker's — same worker configuration, same size and password
|
||||
* guards, same font-relative line clustering and column-gutter detection. Only the joining differs:
|
||||
* the ATS text is prose, while the importer splits header rows on runs of two or more spaces, so a
|
||||
* tabbed "Company Role Location" has to survive as three fields rather than one phrase.
|
||||
*/
|
||||
|
||||
/** Horizontal gap, relative to font size, above which two spans are separate header fields. */
|
||||
const FIELD_GAP_RATIO = 1.5;
|
||||
/** Narrower gaps are still a word break; matches the ratio the ATS extractor joins prose with. */
|
||||
const SPACE_GAP_RATIO = 0.25;
|
||||
|
||||
/** The evidence the ATS layout rules require before calling a page two-column. */
|
||||
const GUTTER_COVERAGE = 0.85;
|
||||
const GUTTER_SPLIT_RATIO = 0.25;
|
||||
|
||||
type PageGeometry = ExtractedDocument["pages"][number];
|
||||
type TextSpan = PageGeometry["lines"][number]["spans"][number];
|
||||
|
||||
function joinSpans(spans: readonly TextSpan[]): string {
|
||||
let text = "";
|
||||
let previous: TextSpan | null = null;
|
||||
|
||||
for (const span of spans) {
|
||||
if (previous) {
|
||||
const gap = span.x - (previous.x + previous.width);
|
||||
const fontSize = Math.max(previous.fontSize, span.fontSize, 1);
|
||||
|
||||
if (gap > FIELD_GAP_RATIO * fontSize) text += " ";
|
||||
else if (gap > SPACE_GAP_RATIO * fontSize && !/\s$/.test(text) && !/^\s/.test(span.text)) text += " ";
|
||||
}
|
||||
|
||||
text += span.text;
|
||||
previous = span;
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
/** Mid-point of a convincing gutter, or null on a page that reads as one column. */
|
||||
function columnSplitX(page: PageGeometry): number | null {
|
||||
const gutter = page.gutter;
|
||||
if (!gutter || gutter.coverage < GUTTER_COVERAGE || gutter.splitRatio < GUTTER_SPLIT_RATIO) return null;
|
||||
|
||||
return gutter.x + gutter.width / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines grouped by baseline alone interleave a sidebar with the body, which drops a whole work
|
||||
* history into the skills section. On a two-column page each column is read out in full instead.
|
||||
*/
|
||||
function pageToLines(page: PageGeometry): string[] {
|
||||
const splitX = columnSplitX(page);
|
||||
if (splitX === null) return page.lines.map((line) => joinSpans(line.spans));
|
||||
|
||||
const left: string[] = [];
|
||||
const right: string[] = [];
|
||||
|
||||
for (const line of page.lines) {
|
||||
const leftSpans = line.spans.filter((span) => span.x + span.width / 2 < splitX);
|
||||
if (leftSpans.length > 0) left.push(joinSpans(leftSpans));
|
||||
|
||||
const rightSpans = line.spans.filter((span) => span.x + span.width / 2 >= splitX);
|
||||
if (rightSpans.length > 0) right.push(joinSpans(rightSpans));
|
||||
}
|
||||
|
||||
return [...left, ...right];
|
||||
}
|
||||
|
||||
export function documentToLines(doc: ExtractedDocument): string[] {
|
||||
return doc.pages.flatMap(pageToLines).filter((line) => line.length > 0);
|
||||
}
|
||||
export { documentToLines } from "@reactive-resume/import/pdf-lines";
|
||||
|
||||
export async function extractPdfLines(file: File): Promise<string[]> {
|
||||
return documentToLines(buildExtractedDocument(await extractPdf(file, { operatorBudgetMs: 0 })));
|
||||
|
||||
@@ -112,8 +112,52 @@ Every endpoint, with its parameters, request body and responses, is listed in th
|
||||
| AI | `/ai-providers`, `/ai`, `/agent` | Manage your AI providers, run AI tools such as resume import from PDF, work with assistant conversations |
|
||||
| Account | `/auth/account/export`, `/auth/account` | Export your account data, delete your account |
|
||||
|
||||
Additional app workflows have REST endpoints:
|
||||
|
||||
| Workflow | Endpoint |
|
||||
| --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| PDF, JSON Resume, Reactive Resume v4/current, LinkedIn imports | `POST /resumes/file-imports` |
|
||||
| PDF, Word, Markdown and JSON exports | `GET /resumes/{id}/exports/{format}`, `GET /cover-letters/{id}/exports/{format}` |
|
||||
| Editor content checks and job-term matching | `GET /resumes/{id}/checks`, `POST /resumes/{id}/job-matches` |
|
||||
| Public PDF readability checker | `POST /pdf-checks` |
|
||||
| File uploads and deletion | `POST /files`, `DELETE /files` |
|
||||
| Web search integration settings and connection tests | `/integrations/web-access` |
|
||||
| Sign-in, sessions, profile, passwords, two-factor authentication, passkeys and API keys | `/api/auth/*` (outside `/api/openapi`) |
|
||||
|
||||
Authentication workflows use Better Auth's native HTTP API and its own error contract. Your instance publishes their specification at `/api/auth/open-api/generate-schema`; use that specification for the enabled authentication methods. Browser interactions such as zoom, print dialogs and local undo do not require separate server endpoints: use document changes, versions and exports for the corresponding data workflows.
|
||||
|
||||
`GET /api/health` (outside `/api/openapi`) reports whether the instance and its database and storage are healthy. It needs no key.
|
||||
|
||||
## Imports and exports
|
||||
|
||||
File imports accept a multipart form. The format is one of `PDF`, `REACTIVE_RESUME`, `REACTIVE_RESUME_V4`, `JSON_RESUME` or `LINKEDIN`; the maximum file size is 10 MiB. PDF imports read the text layer without AI. AI-assisted PDF and Word parsing remain available under `/ai/pdf-parses` and `/ai/docx-parses`; save the resulting data through `/resumes/imports`.
|
||||
|
||||
```bash
|
||||
curl "https://rxresu.me/api/openapi/resumes/file-imports" \
|
||||
-H "x-api-key: YOUR_API_KEY" \
|
||||
-F "format=JSON_RESUME" -F "file=@resume.json"
|
||||
|
||||
curl "https://rxresu.me/api/openapi/resumes/RESUME_ID/exports/docx" \
|
||||
-H "x-api-key: YOUR_API_KEY" -o resume.docx
|
||||
```
|
||||
|
||||
Exports accept `pdf`, `docx`, `md` or `json` and return a downloadable file. They enforce document ownership. The public checker accepts a multipart `file` (PDF, maximum 25 MB) and optional `jobDescription` text. It stores nothing, examines at most 30 pages and reports truncation or skipped checks. Parsing has a 45-second deadline. Hosting-provider request limits still apply.
|
||||
|
||||
## Pagination and partial updates
|
||||
|
||||
Collection endpoints support `limit` (1–100) and `offset` (zero-based). For existing array responses, omitting both preserves the complete array for compatibility. Supplying only `offset` selects a page size of 20. The `X-Total-Count` header reports the total before slicing; paginated responses also include `X-Limit` and `X-Offset`. The cover-letter list retains its existing `{ items, total }` response and default pagination.
|
||||
|
||||
```bash
|
||||
curl "https://rxresu.me/api/openapi/resumes?limit=20&offset=20" \
|
||||
-H "x-api-key: YOUR_API_KEY" -i
|
||||
```
|
||||
|
||||
Use `PATCH` for partial application, interview, timeline-entry and cover-letter updates. Resume content uses JSON Patch at `/resumes/{id}`; metadata uses `PATCH /resumes/{id}/metadata`. Resource-oriented paths such as `/resumes/{id}/copies` and `/documents/{type}/{id}/trash` are preferred for new integrations. Existing verb-based routes, partial `PUT` routes, response shapes and enum values remain supported.
|
||||
|
||||
## Retries
|
||||
|
||||
State-changing operations do not implement `Idempotency-Key`. Treat mutations as unsafe to retry automatically, including requests that timed out: the change might already have happened. Read the current resource before deciding whether to repeat a write. Repeating create, copy, import or AI operations can create duplicates or incur provider charges.
|
||||
|
||||
## Authentication methods
|
||||
|
||||
The API accepts three kinds of credentials:
|
||||
@@ -122,9 +166,11 @@ The API accepts three kinds of credentials:
|
||||
2. `Authorization: Bearer <token>`: an OAuth access token, which MCP clients get when you connect them with OAuth. See [Using the MCP server](/guides/using-the-mcp-server).
|
||||
3. The session cookie of a signed-in browser.
|
||||
|
||||
Send one of them. If a request has an `x-api-key` header, the API ignores any bearer token, so a wrong key isn't rescued by a valid token.
|
||||
Send one credential. When several are present, the server tries API key, bearer token, then session cookie, using the first valid credential. Cookie-authenticated requests with a foreign `Origin` or cross-site fetch metadata are rejected; explicit API keys and bearer tokens do not rely on browser cookies.
|
||||
|
||||
A request without valid credentials gets `401` with the code `UNAUTHORIZED`.
|
||||
A private request without valid credentials gets `401` with the code `UNAUTHORIZED`. Public operations are marked with `security: []` in the OpenAPI specification. Public resumes still enforce visibility, trash status and sharing passwords, and redact private data.
|
||||
|
||||
REST data responses use `Cache-Control: no-store`. Uploaded profile images are public; other uploaded files require the owner's credentials, including conditional download requests. Assistant attachments are not exposed through the uploads URL. Unknown and unauthorized file URLs return `404`.
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -133,10 +179,12 @@ A request without valid credentials gets `401` with the code `UNAUTHORIZED`.
|
||||
| Requests per API key | 1,000 per hour |
|
||||
| Changes to one resume, document or application (create, update, patch, lock, duplicate, delete) | 300 per minute |
|
||||
| PDF downloads of one resume (`GET /resumes/{id}/pdf`) | 5 per minute |
|
||||
| Public PDF checks per client address | 5 per minute |
|
||||
| REST request body size (before endpoint-specific limits) | 40 MiB |
|
||||
| AI requests | 20 per minute |
|
||||
| Request body size on Vercel installations | 4.5 MB (see [Large RPC requests](/guides/large-rpc-requests)) |
|
||||
|
||||
When you hit a limit, the API responds with `429`. On a self-hosted installation, `FLAG_DISABLE_API_RATE_LIMIT` turns off the per-key limit together with the sign-in limits; the other limits stay on (see [Environment variables](/self-hosting/environment-variables)).
|
||||
Procedure limits apply in production. When you hit a limit, the API responds with `429`. On a self-hosted installation, `FLAG_DISABLE_API_RATE_LIMIT` turns off the per-key limit together with the sign-in limits; the procedure limits also turn off (see [Environment variables](/self-hosting/environment-variables)).
|
||||
|
||||
## Errors
|
||||
|
||||
@@ -146,7 +194,7 @@ Errors come back as JSON with a machine-readable `code`, the HTTP `status` and a
|
||||
{ "defined": false, "code": "NOT_FOUND", "status": 404, "message": "Not Found" }
|
||||
```
|
||||
|
||||
Branch on `code` rather than on the message text.
|
||||
Branch on `code` rather than on the message text. Invalid input uses `400`, missing authentication `401`, forbidden actions `403`, missing or inaccessible resources `404`, conflicts `409`, oversized requests `413`, rate limits `429`, and unexpected failures `500`. Unexpected failures do not expose internal exception details.
|
||||
|
||||
## Revoke a key
|
||||
|
||||
|
||||
+52454
-383
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@
|
||||
"bcryptjs",
|
||||
"cjk-regex",
|
||||
"css-tree",
|
||||
"docx",
|
||||
"drizzle-zod",
|
||||
"fast-json-patch",
|
||||
"fast-png",
|
||||
@@ -54,6 +55,7 @@
|
||||
"node-html-parser",
|
||||
"nodemailer",
|
||||
"ollama-ai-provider-v2",
|
||||
"pdfjs-dist",
|
||||
"react-email",
|
||||
"react-reconciler",
|
||||
"resumable-stream",
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
"./features/resume/public-pdf": "./src/features/resume/public-pdf.ts",
|
||||
"./features/resume/social-meta": "./src/features/resume/social-meta.ts",
|
||||
"./features/storage": "./src/features/storage/index.ts",
|
||||
"./routers": "./src/routers/index.ts",
|
||||
"./features/storage/transport": "./src/features/storage/transport.ts"
|
||||
"./features/storage/transport": "./src/features/storage/transport.ts",
|
||||
"./rest": "./src/routers/rest.ts",
|
||||
"./routers": "./src/routers/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -44,7 +45,9 @@
|
||||
"@reactive-resume/ai": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
"@reactive-resume/db": "workspace:*",
|
||||
"@reactive-resume/docx": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@reactive-resume/import": "workspace:*",
|
||||
"@reactive-resume/pdf": "workspace:*",
|
||||
"@reactive-resume/resume": "workspace:*",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
@@ -59,6 +62,7 @@
|
||||
"ioredis": "catalog:",
|
||||
"jsonrepair": "catalog:",
|
||||
"ollama-ai-provider-v2": "^4.0.1",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"resumable-stream": "^2.2.13",
|
||||
"sanitize-html": "^2.18.0",
|
||||
"sharp": "^0.35.5",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
api: {
|
||||
@@ -75,3 +76,31 @@ describe("resolveUserFromRequestHeaders", () => {
|
||||
expect(user).toMatchObject({ id: "user-bearer", name: "Bob" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cookie request origins", () => {
|
||||
it.each([{ origin: "https://attacker.example" }, { origin: "null" }, { "sec-fetch-site": "cross-site" }])(
|
||||
"rejects ambient credentials from another origin: %j",
|
||||
async (originHeaders) => {
|
||||
reset();
|
||||
authMock.api.getSession.mockResolvedValue({ user: { id: "owner" } });
|
||||
const headers = new Headers({ cookie: "session=valid", ...originHeaders });
|
||||
await expect(resolveUserFromRequestHeaders(headers)).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(authMock.api.getSession).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts same-origin cookies and explicit bearer credentials from other origins", async () => {
|
||||
reset();
|
||||
authMock.api.getSession.mockResolvedValue({ user: { id: "session-user" } });
|
||||
await expect(
|
||||
resolveUserFromRequestHeaders(new Headers({ cookie: "session=valid", origin: new URL(env.APP_URL).origin })),
|
||||
).resolves.toMatchObject({ id: "session-user" });
|
||||
verifyOAuthTokenMock.mockResolvedValueOnce({ sub: "token-user" });
|
||||
setupDbResolves({ id: "token-user" });
|
||||
await expect(
|
||||
resolveUserFromRequestHeaders(
|
||||
new Headers({ authorization: "Bearer token", cookie: "session=valid", origin: "https://client.example" }),
|
||||
),
|
||||
).resolves.toMatchObject({ id: "token-user" });
|
||||
});
|
||||
});
|
||||
|
||||
+32
-16
@@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
|
||||
import { auth, verifyOAuthToken } from "@reactive-resume/auth/config";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import { user } from "@reactive-resume/db/schema";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
interface ORPCContext {
|
||||
locale: Locale;
|
||||
@@ -72,29 +73,44 @@ export async function resolveUserFromRequestHeaders(headers: Headers): Promise<U
|
||||
const bearerUser = await getUserFromBearerToken(headers);
|
||||
if (bearerUser) return bearerUser;
|
||||
|
||||
// Cookie credentials are ambient. Never use them for a request from another origin,
|
||||
// including multipart uploads that browsers can submit without a CORS preflight.
|
||||
const origin = headers.get("origin");
|
||||
if (
|
||||
headers.has("cookie") &&
|
||||
((origin && origin !== new URL(env.APP_URL).origin) || headers.get("sec-fetch-site") === "cross-site")
|
||||
) {
|
||||
throw new ORPCError("FORBIDDEN", { message: "Cross-origin session requests are not allowed." });
|
||||
}
|
||||
return getUserFromHeaders(headers);
|
||||
}
|
||||
|
||||
const base = os.$context<ORPCContext>();
|
||||
|
||||
export const publicProcedure = base.use(async ({ context, next }) => {
|
||||
const user = await resolveUserFromRequestHeaders(context.reqHeaders);
|
||||
export const publicProcedure = base
|
||||
.route({ spec: (operation) => ({ ...operation, security: [] }) })
|
||||
.use(async ({ context, next }) => {
|
||||
const user = await resolveUserFromRequestHeaders(context.reqHeaders);
|
||||
|
||||
return next({
|
||||
context: {
|
||||
...context,
|
||||
user,
|
||||
},
|
||||
return next({
|
||||
context: {
|
||||
...context,
|
||||
user,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = publicProcedure.use(({ context, next }) => {
|
||||
if (!context.user) throw new ORPCError("UNAUTHORIZED");
|
||||
export const protectedProcedure = publicProcedure
|
||||
.route({
|
||||
spec: (operation) => ({ ...operation, security: [{ apiKey: [] }, { bearerAuth: [] }, { cookieAuth: [] }] }),
|
||||
})
|
||||
.use(({ context, next }) => {
|
||||
if (!context.user) throw new ORPCError("UNAUTHORIZED");
|
||||
|
||||
return next({
|
||||
context: {
|
||||
...context,
|
||||
user: context.user,
|
||||
},
|
||||
return next({
|
||||
context: {
|
||||
...context,
|
||||
user: context.user,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import z from "zod";
|
||||
|
||||
// AI SDK message parts are an extensible protocol (including dynamic tools and provider metadata).
|
||||
// Preserve their fields while documenting the stable envelope.
|
||||
export const uiMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
parts: z.array(z.looseObject({ type: z.string() })),
|
||||
metadata: z.unknown().optional(),
|
||||
}) as z.ZodType<UIMessage>;
|
||||
|
||||
export const agentThreadSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
status: z.string(),
|
||||
sourceResumeId: z.string().nullable(),
|
||||
workingResumeId: z.string().nullable(),
|
||||
coverLetterId: z.string().nullable(),
|
||||
aiProviderId: z.string().nullable(),
|
||||
resumeName: z.string().nullable(),
|
||||
coverLetterName: z.string().nullable(),
|
||||
providerLabel: z.string().nullable(),
|
||||
editsProposed: z.number(),
|
||||
editsAccepted: z.number(),
|
||||
activeRunId: z.string().nullable(),
|
||||
lastMessageAt: z.date(),
|
||||
archivedAt: z.date().nullable(),
|
||||
deletedAt: z.date().nullable(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
export const agentAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
threadId: z.string(),
|
||||
messageId: z.string().nullable(),
|
||||
filename: z.string(),
|
||||
mediaType: z.string(),
|
||||
size: z.number(),
|
||||
createdAt: z.date(),
|
||||
});
|
||||
export const agentConversationSchema = z.object({
|
||||
thread: agentThreadSchema,
|
||||
messages: z.array(uiMessageSchema),
|
||||
attachments: z.array(agentAttachmentSchema),
|
||||
document: z
|
||||
.object({ kind: z.enum(["resume", "letter"]), id: z.string(), name: z.string(), locked: z.boolean() })
|
||||
.nullable(),
|
||||
isReadOnly: z.boolean(),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import z from "zod";
|
||||
import { aiProviderSchema } from "@reactive-resume/ai/types";
|
||||
|
||||
export const aiProviderResponseSchema = z.object({
|
||||
managed: z.boolean(),
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
provider: aiProviderSchema,
|
||||
model: z.string(),
|
||||
baseURL: z.string().nullable(),
|
||||
enabled: z.boolean(),
|
||||
testStatus: z.string(),
|
||||
testError: z.string().nullable(),
|
||||
apiKeyPreview: z.string(),
|
||||
apiKeyFingerprint: z.string(),
|
||||
lastTestedAt: z.date().nullable(),
|
||||
lastUsedAt: z.date().nullable(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
interviewKindSchema,
|
||||
postingSourceSchema,
|
||||
} from "@reactive-resume/schema/applications/data";
|
||||
import { paginationShape } from "../pagination";
|
||||
|
||||
const MAX_APPLICATION_JOB_DESCRIPTION_CHARS = 20_000;
|
||||
const MAX_APPLICATION_DOCUMENT_BYTES = 10 * 1024 * 1024;
|
||||
@@ -119,6 +120,7 @@ export const applicationDto = {
|
||||
list: {
|
||||
input: z
|
||||
.object({
|
||||
...paginationShape,
|
||||
status: applicationStatusSchema.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
coverLetterStyleSchema,
|
||||
} from "@reactive-resume/schema/cover-letter/data";
|
||||
import { templateSchema } from "@reactive-resume/schema/templates";
|
||||
import { paginationShape } from "../pagination";
|
||||
|
||||
const idSchema = z.object({ id: z.string().min(1) });
|
||||
const revisionSchema = idSchema.extend({ expectedRevision: z.number().int().min(1) });
|
||||
@@ -96,7 +97,7 @@ export const coverLetterDto = {
|
||||
}),
|
||||
output: coverLetterSchema,
|
||||
},
|
||||
listVersions: { input: idSchema, output: z.array(letterVersionSummarySchema) },
|
||||
listVersions: { input: idSchema.extend(paginationShape), output: z.array(letterVersionSummarySchema) },
|
||||
getVersion: { input: versionRefSchema, output: letterVersionSchema },
|
||||
createVersion: { input: idSchema.extend({ name: versionNameSchema }), output: letterVersionSummarySchema },
|
||||
renameVersion: { input: versionRefSchema.extend({ name: versionNameSchema }), output: letterVersionSummarySchema },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import z from "zod";
|
||||
import { paginationShape } from "../pagination";
|
||||
|
||||
const documentTypeSchema = z.enum(["resume", "letter"]).describe("resume, or letter (a saved cover letter).");
|
||||
const documentRefSchema = z.object({
|
||||
@@ -26,7 +27,10 @@ export type DocumentSummary = z.infer<typeof documentSchema>;
|
||||
export const documentsDto = {
|
||||
list: {
|
||||
input: z
|
||||
.object({ trashed: z.boolean().default(false).describe("List the documents in Trash instead.") })
|
||||
.object({
|
||||
...paginationShape,
|
||||
trashed: z.boolean().default(false).describe("List the documents in Trash instead."),
|
||||
})
|
||||
.default({ trashed: false }),
|
||||
output: z.array(documentSchema),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import z from "zod";
|
||||
import { PDF_ATS_RULE_CODES } from "@reactive-resume/resume/ats-pdf";
|
||||
const severity = z.enum(["blocker", "warning", "tip"]);
|
||||
const category = z.enum(["parseability", "layout", "sections", "contact", "dates", "content"]);
|
||||
const code = z.enum(PDF_ATS_RULE_CODES);
|
||||
const finding = z.object({
|
||||
code,
|
||||
severity,
|
||||
category,
|
||||
params: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
|
||||
evidence: z
|
||||
.object({
|
||||
snippet: z.string().optional(),
|
||||
page: z.number().optional(),
|
||||
box: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export const jobTermSchema = z.object({
|
||||
term: z.string(),
|
||||
jdCount: z.number(),
|
||||
resumeCount: z.number(),
|
||||
weight: z.number(),
|
||||
});
|
||||
const jd = z.object({
|
||||
terms: z.array(jobTermSchema).readonly(),
|
||||
matchedTerms: z.array(z.string()).readonly(),
|
||||
missingTerms: z.array(z.string()).readonly(),
|
||||
totalTerms: z.number(),
|
||||
matchedCount: z.number(),
|
||||
weightedCoverage: z.number(),
|
||||
stuffedTerms: z.array(z.string()).readonly(),
|
||||
documentHasHiddenText: z.boolean(),
|
||||
});
|
||||
export const pdfCheckSchema = z.object({
|
||||
version: z.literal(1),
|
||||
score: z.number(),
|
||||
cappedBy: z.array(code).readonly(),
|
||||
categories: z
|
||||
.array(
|
||||
z.object({
|
||||
category: category.exclude(["content"]),
|
||||
score: z.number(),
|
||||
weight: z.number(),
|
||||
applicableChecks: z.number(),
|
||||
passedChecks: z.number(),
|
||||
skippedChecks: z.number(),
|
||||
}),
|
||||
)
|
||||
.readonly(),
|
||||
checks: z
|
||||
.array(
|
||||
z.object({
|
||||
code,
|
||||
category,
|
||||
severity,
|
||||
status: z.enum(["pass", "skip", "fail"]),
|
||||
skipReason: z
|
||||
.enum(["no-text", "no-operators", "not-english", "not-applicable", "encrypted", "insufficient-data"])
|
||||
.optional(),
|
||||
findings: z.array(finding).readonly(),
|
||||
}),
|
||||
)
|
||||
.readonly(),
|
||||
findings: z.array(finding).readonly(),
|
||||
tips: z.array(finding).readonly(),
|
||||
counts: z.object({ blocker: z.number(), warning: z.number(), tip: z.number() }),
|
||||
applicableChecks: z.number(),
|
||||
passedChecks: z.number(),
|
||||
skippedChecks: z.number(),
|
||||
jd: jd.nullable(),
|
||||
file: z.object({ name: z.string(), sizeBytes: z.number(), magicBytesOk: z.boolean() }),
|
||||
document: z.object({
|
||||
pageCount: z.number(),
|
||||
truncated: z.boolean(),
|
||||
wordCount: z.number(),
|
||||
operatorsAvailable: z.boolean(),
|
||||
}),
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import * as schema from "@reactive-resume/db/schema";
|
||||
import { jsonPatchOperationSchema } from "@reactive-resume/resume/patch";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { writableResumeDataSchema } from "@reactive-resume/schema/resume/write";
|
||||
import { paginationShape } from "../pagination";
|
||||
|
||||
const resumeSchema = createSelectSchema(schema.resume, {
|
||||
id: z.string().describe("The ID of the resume."),
|
||||
@@ -38,6 +39,7 @@ export const resumeDto = {
|
||||
list: {
|
||||
input: z
|
||||
.object({
|
||||
...paginationShape,
|
||||
tags: z
|
||||
.union([z.string().transform((tag) => [tag]), z.array(z.string())])
|
||||
.optional()
|
||||
@@ -168,7 +170,10 @@ export const resumeDto = {
|
||||
},
|
||||
|
||||
listVersions: {
|
||||
input: z.object({ resumeId: z.string().describe("The ID of the resume whose version history to list.") }),
|
||||
input: z.object({
|
||||
...paginationShape,
|
||||
resumeId: z.string().describe("The ID of the resume whose version history to list."),
|
||||
}),
|
||||
output: z.array(versionSchema),
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { agentAttachmentSchema } from "../../dto/agent";
|
||||
import { storageUploadRateLimit } from "../../middleware/rate-limit";
|
||||
import { mapAgentEnvironmentError } from "./routing";
|
||||
import { agentService } from "./service";
|
||||
@@ -18,9 +19,13 @@ export const attachmentsRouter = {
|
||||
threadId: z.string(),
|
||||
filename: z.string().trim().min(1),
|
||||
mediaType: z.string().trim().min(1),
|
||||
data: z.string().min(1),
|
||||
data: z
|
||||
.base64()
|
||||
.min(1)
|
||||
.max(4 * Math.ceil((25 * 1024 * 1024) / 3)),
|
||||
}),
|
||||
)
|
||||
.output(agentAttachmentSchema)
|
||||
.use(storageUploadRateLimit)
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(({ context, input }) =>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { eventIterator } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { uiMessageSchema } from "../../dto/agent";
|
||||
import { aiRequestRateLimit } from "../../middleware/rate-limit";
|
||||
import { isUiMessage, mapAgentEnvironmentError } from "./routing";
|
||||
import { mapAgentEnvironmentError } from "./routing";
|
||||
import { agentService } from "./service";
|
||||
|
||||
export const messagesRouter = {
|
||||
@@ -17,7 +18,7 @@ export const messagesRouter = {
|
||||
.input(
|
||||
z.object({
|
||||
threadId: z.string(),
|
||||
message: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }),
|
||||
message: uiMessageSchema,
|
||||
attachmentIds: z.array(z.string().trim().min(1)).max(10).optional(),
|
||||
// The context chips: leaving one out keeps it out of what's sent.
|
||||
context: z
|
||||
@@ -31,6 +32,7 @@ export const messagesRouter = {
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.use(mapAgentEnvironmentError)
|
||||
.output(eventIterator(z.string()))
|
||||
.handler(({ context, input }) =>
|
||||
agentService.messages.send({
|
||||
userId: context.user.id,
|
||||
@@ -54,7 +56,7 @@ export const messagesRouter = {
|
||||
threadId: z.string(),
|
||||
// Deprecated and ignored: partial content now persists server-side via the run's
|
||||
// abort path. Kept in the schema for one release so mid-deploy clients still parse.
|
||||
partialMessage: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }).optional(),
|
||||
partialMessage: uiMessageSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.void())
|
||||
@@ -76,6 +78,7 @@ export const messagesRouter = {
|
||||
})
|
||||
.input(z.object({ threadId: z.string() }))
|
||||
.use(mapAgentEnvironmentError)
|
||||
.output(eventIterator(z.string()))
|
||||
.handler(({ context, input }) =>
|
||||
agentService.messages.resume({ userId: context.user.id, threadId: input.threadId }),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AnyMiddleware } from "@orpc/server";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
|
||||
function isAgentEnvironmentUnavailable(error: unknown) {
|
||||
@@ -12,17 +11,6 @@ function throwUnavailable(): never {
|
||||
});
|
||||
}
|
||||
|
||||
export 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)
|
||||
);
|
||||
}
|
||||
|
||||
// ponytail: single middleware replaces 12 near-identical try/catch blocks across agent route handlers
|
||||
export const mapAgentEnvironmentError: AnyMiddleware = async ({ next }) => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { agentThreadSchema, agentConversationSchema } from "../../dto/agent";
|
||||
import { paginate, paginationShape } from "../../pagination";
|
||||
import { mapAgentEnvironmentError } from "./routing";
|
||||
import { agentService } from "./service";
|
||||
|
||||
@@ -13,7 +15,11 @@ export const threadsRouter = {
|
||||
summary: "List agent threads",
|
||||
})
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(({ context }) => agentService.threads.list({ userId: context.user.id })),
|
||||
.output(z.array(agentThreadSchema))
|
||||
.input(z.object(paginationShape).default({}))
|
||||
.handler(async ({ context, input }) =>
|
||||
paginate(await agentService.threads.list({ userId: context.user.id }), input, context.resHeaders),
|
||||
),
|
||||
|
||||
start: protectedProcedure
|
||||
.route({
|
||||
@@ -37,6 +43,7 @@ export const threadsRouter = {
|
||||
}),
|
||||
)
|
||||
.use(mapAgentEnvironmentError)
|
||||
.output(agentThreadSchema)
|
||||
.handler(({ context, input }) => agentService.threads.start({ userId: context.user.id, ...input })),
|
||||
|
||||
get: protectedProcedure
|
||||
@@ -49,6 +56,7 @@ export const threadsRouter = {
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.use(mapAgentEnvironmentError)
|
||||
.output(agentConversationSchema)
|
||||
.handler(({ context, input }) => agentService.threads.get({ id: input.id, userId: context.user.id })),
|
||||
|
||||
update: protectedProcedure
|
||||
@@ -61,6 +69,7 @@ export const threadsRouter = {
|
||||
})
|
||||
.input(z.object({ id: z.string(), aiProviderId: z.string().min(1) }))
|
||||
.use(mapAgentEnvironmentError)
|
||||
.output(agentThreadSchema)
|
||||
.handler(({ context, input }) =>
|
||||
agentService.threads.update({ id: input.id, userId: context.user.id, aiProviderId: input.aiProviderId }),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { AiProviderResponse } from "./service";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { type } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { aiProviderResponseSchema } from "../../dto/ai-provider";
|
||||
import { aiRequestRateLimit } from "../../middleware/rate-limit";
|
||||
import { paginate, paginationShape } from "../../pagination";
|
||||
import { providerInput, updateProviderInput } from "./inputs";
|
||||
import { aiProvidersService } from "./service";
|
||||
|
||||
@@ -25,11 +25,14 @@ export const aiProvidersRouter = {
|
||||
summary: "List saved AI providers",
|
||||
description: "Lists saved provider/model/API key combinations for the authenticated user. API keys are redacted.",
|
||||
})
|
||||
.output(type<AiProviderResponse[]>())
|
||||
.output(z.array(aiProviderResponseSchema))
|
||||
.errors({
|
||||
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
|
||||
})
|
||||
.handler(({ context }) => aiProvidersService.list({ userId: context.user.id })),
|
||||
.input(z.object(paginationShape).default({}))
|
||||
.handler(async ({ context, input }) =>
|
||||
paginate(await aiProvidersService.list({ userId: context.user.id }), input, context.resHeaders),
|
||||
),
|
||||
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
@@ -41,7 +44,7 @@ export const aiProvidersRouter = {
|
||||
description: "Stores an encrypted provider/model/API key combination. The key is never returned.",
|
||||
})
|
||||
.input(providerInput)
|
||||
.output(type<AiProviderResponse>())
|
||||
.output(aiProviderResponseSchema)
|
||||
.errors({
|
||||
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
|
||||
FORBIDDEN: { message: "AI is managed by the server.", status: 403 },
|
||||
@@ -74,7 +77,7 @@ export const aiProvidersRouter = {
|
||||
"Updates a saved provider/model/API key combination. Updating the key requires retesting before use.",
|
||||
})
|
||||
.input(updateProviderInput)
|
||||
.output(type<AiProviderResponse>())
|
||||
.output(aiProviderResponseSchema)
|
||||
.errors({
|
||||
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
|
||||
FORBIDDEN: { message: "AI is managed by the server.", status: 403 },
|
||||
@@ -126,7 +129,7 @@ export const aiProvidersRouter = {
|
||||
description: "Decrypts the saved API key server-side and validates the provider/model connection.",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(type<AiProviderResponse>())
|
||||
.output(aiProviderResponseSchema)
|
||||
.use(aiRequestRateLimit)
|
||||
.errors({
|
||||
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { AISDKError } from "ai";
|
||||
import { flattenError, ZodError, z } from "zod";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { aiRequestRateLimit } from "../../middleware/rate-limit";
|
||||
import { aiProvidersService } from "../ai-providers/service";
|
||||
@@ -57,7 +57,8 @@ export const aiRouter = {
|
||||
.input(z.object({ aiProviderId: z.string().optional(), file: fileInputSchema }))
|
||||
.use(aiRequestRateLimit)
|
||||
.errors(aiErrors)
|
||||
.handler(async ({ context, input }): Promise<ResumeData> => {
|
||||
.output(resumeDataSchema)
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
|
||||
return await aiService.parsePdf({
|
||||
@@ -95,6 +96,7 @@ export const aiRouter = {
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.errors(aiErrors)
|
||||
.output(resumeDataSchema)
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { applicationDto } from "../../dto/application";
|
||||
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
|
||||
import { paginate, paginationShape } from "../../pagination";
|
||||
import { applicationService } from "./service";
|
||||
|
||||
export const crudRouter = {
|
||||
@@ -17,12 +19,16 @@ export const crudRouter = {
|
||||
})
|
||||
.input(applicationDto.list.input)
|
||||
.output(applicationDto.list.output)
|
||||
.handler(({ input, context }) =>
|
||||
applicationService.list({
|
||||
userId: context.user.id,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.tags ? { tags: input.tags } : {}),
|
||||
}),
|
||||
.handler(async ({ input, context }) =>
|
||||
paginate(
|
||||
await applicationService.list({
|
||||
userId: context.user.id,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.tags ? { tags: input.tags } : {}),
|
||||
}),
|
||||
input,
|
||||
context.resHeaders,
|
||||
),
|
||||
),
|
||||
|
||||
getById: protectedProcedure
|
||||
@@ -305,5 +311,8 @@ export const crudRouter = {
|
||||
successDescription: "Distinct tags.",
|
||||
})
|
||||
.output(applicationDto.tags.output)
|
||||
.handler(({ context }) => applicationService.listTags({ userId: context.user.id })),
|
||||
.input(z.object(paginationShape).default({}))
|
||||
.handler(async ({ context, input }) =>
|
||||
paginate(await applicationService.listTags({ userId: context.user.id }), input, context.resHeaders),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { ProviderList } from "./service";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import z from "zod";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
import { coverLetterSchema } from "@reactive-resume/schema/cover-letter/data";
|
||||
import { protectedProcedure, publicProcedure } from "../../context";
|
||||
import { applicationDto } from "../../dto/application";
|
||||
import { resumeDto } from "../../dto/resume";
|
||||
import { authService } from "./service";
|
||||
|
||||
export const authRouter = {
|
||||
@@ -15,7 +20,9 @@ export const authRouter = {
|
||||
"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 => authService.providers.list()),
|
||||
.input(z.object({}).optional())
|
||||
.output(z.partialRecord(z.enum(["credential", "passkey", "google", "github", "linkedin", "custom"]), z.string()))
|
||||
.handler(() => authService.providers.list()),
|
||||
},
|
||||
|
||||
exportData: protectedProcedure
|
||||
@@ -29,6 +36,26 @@ export const authRouter = {
|
||||
"Returns a JSON-serializable export of the authenticated user's data, including their public profile fields, resumes, independent cover letters and job applications. Images remain URL references. Secrets such as password hashes, tokens, and API keys are never included. Requires authentication.",
|
||||
successDescription: "The user's exported account data.",
|
||||
})
|
||||
.input(z.object({}).optional())
|
||||
.output(
|
||||
z.object({
|
||||
exportedAt: z.string(),
|
||||
user: createSelectSchema(schema.user).pick({
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
username: true,
|
||||
displayUsername: true,
|
||||
image: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}),
|
||||
resumes: z.array(resumeDto.getById.output.omit({ hasPassword: true, applicationId: true })),
|
||||
coverLetters: z.array(coverLetterSchema),
|
||||
applications: z.array(applicationDto.getById.output),
|
||||
}),
|
||||
)
|
||||
.handler(({ context }) => authService.exportData({ userId: context.user.id })),
|
||||
|
||||
deleteAccount: protectedProcedure
|
||||
@@ -42,5 +69,7 @@ export const authRouter = {
|
||||
"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.",
|
||||
})
|
||||
.input(z.object({}).optional())
|
||||
.output(z.void())
|
||||
.handler(({ context }) => authService.deleteAccount({ userId: context.user.id })),
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { env } from "@reactive-resume/env/server";
|
||||
import { coverLetterService } from "../cover-letters/service";
|
||||
import { getStorageService } from "../storage/service";
|
||||
|
||||
export type ProviderList = Partial<Record<AuthProvider, string>>;
|
||||
type ProviderList = Partial<Record<AuthProvider, string>>;
|
||||
|
||||
const providers = {
|
||||
list: (): ProviderList => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { eventIterator } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { coverLetterDto } from "../../dto/cover-letter";
|
||||
import { aiRequestRateLimit } from "../../middleware/rate-limit";
|
||||
import { aiRequestRateLimit, resumeMutationRateLimit } from "../../middleware/rate-limit";
|
||||
import { paginate } from "../../pagination";
|
||||
import { draftLetterBody } from "./draft";
|
||||
import { coverLetterService } from "./service";
|
||||
import { deleteLetterVersion, getLetterVersion, listLetterVersions, renameLetterVersion } from "./versions";
|
||||
@@ -46,6 +47,7 @@ export const coverLettersRouter = {
|
||||
successDescription: "The newly created cover letter.",
|
||||
})
|
||||
.input(coverLetterDto.create.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(coverLetterDto.create.output)
|
||||
.handler(({ context, input }) => coverLetterService.create({ ...input, userId: context.user.id })),
|
||||
update: protectedProcedure
|
||||
@@ -59,6 +61,7 @@ export const coverLettersRouter = {
|
||||
successDescription: "The updated cover letter.",
|
||||
})
|
||||
.input(coverLetterDto.update.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(coverLetterDto.update.output)
|
||||
.handler(({ context, input }) => coverLetterService.update({ ...input, userId: context.user.id })),
|
||||
refreshStyle: protectedProcedure
|
||||
@@ -72,6 +75,7 @@ export const coverLettersRouter = {
|
||||
successDescription: "The cover letter with refreshed style.",
|
||||
})
|
||||
.input(coverLetterDto.refreshStyle.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(coverLetterDto.refreshStyle.output)
|
||||
.handler(({ context, input }) => coverLetterService.refreshStyle({ ...input, userId: context.user.id })),
|
||||
duplicate: protectedProcedure
|
||||
@@ -85,6 +89,7 @@ export const coverLettersRouter = {
|
||||
successDescription: "The duplicated cover letter.",
|
||||
})
|
||||
.input(coverLetterDto.duplicate.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(coverLetterDto.duplicate.output)
|
||||
.handler(({ context, input }) => coverLetterService.duplicate({ ...input, userId: context.user.id })),
|
||||
delete: protectedProcedure
|
||||
@@ -99,6 +104,7 @@ export const coverLettersRouter = {
|
||||
successDescription: "The cover letter is in Trash.",
|
||||
})
|
||||
.input(coverLetterDto.delete.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(coverLetterDto.delete.output)
|
||||
.handler(({ context, input }) => coverLetterService.delete({ ...input, userId: context.user.id })),
|
||||
export: protectedProcedure
|
||||
@@ -125,6 +131,7 @@ export const coverLettersRouter = {
|
||||
successDescription: "The imported cover letter.",
|
||||
})
|
||||
.input(coverLetterDto.import.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(coverLetterDto.import.output)
|
||||
.handler(({ context, input }) => coverLetterService.import({ ...input, userId: context.user.id })),
|
||||
listVersions: protectedProcedure
|
||||
@@ -140,7 +147,13 @@ export const coverLettersRouter = {
|
||||
})
|
||||
.input(coverLetterDto.listVersions.input)
|
||||
.output(coverLetterDto.listVersions.output)
|
||||
.handler(({ context, input }) => listLetterVersions({ coverLetterId: input.id, userId: context.user.id })),
|
||||
.handler(async ({ context, input }) =>
|
||||
paginate(
|
||||
await listLetterVersions({ coverLetterId: input.id, userId: context.user.id }),
|
||||
input,
|
||||
context.resHeaders,
|
||||
),
|
||||
),
|
||||
getVersion: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
@@ -167,6 +180,7 @@ export const coverLettersRouter = {
|
||||
successDescription: "The new version.",
|
||||
})
|
||||
.input(coverLetterDto.createVersion.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(coverLetterDto.createVersion.output)
|
||||
.handler(({ context, input }) => coverLetterService.createVersion({ ...input, userId: context.user.id })),
|
||||
renameVersion: protectedProcedure
|
||||
@@ -180,6 +194,7 @@ export const coverLettersRouter = {
|
||||
successDescription: "The renamed version.",
|
||||
})
|
||||
.input(coverLetterDto.renameVersion.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(coverLetterDto.renameVersion.output)
|
||||
.handler(({ context, input }) =>
|
||||
renameLetterVersion({
|
||||
@@ -200,6 +215,7 @@ export const coverLettersRouter = {
|
||||
successDescription: "The version was deleted.",
|
||||
})
|
||||
.input(coverLetterDto.deleteVersion.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(coverLetterDto.deleteVersion.output)
|
||||
.handler(({ context, input }) =>
|
||||
deleteLetterVersion({ coverLetterId: input.id, userId: context.user.id, versionId: input.versionId }),
|
||||
@@ -216,6 +232,7 @@ export const coverLettersRouter = {
|
||||
successDescription: "The restored letter.",
|
||||
})
|
||||
.input(coverLetterDto.restoreVersion.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(coverLetterDto.restoreVersion.output)
|
||||
.handler(({ context, input }) => coverLetterService.restoreVersion({ ...input, userId: context.user.id })),
|
||||
draft: protectedProcedure
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { call } from "@orpc/server";
|
||||
import { unzipSync, strFromU8 } from "fflate";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
const getResume = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../resume/service", () => ({ resumeService: { getById: getResume } }));
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: { api: { getSession: vi.fn().mockResolvedValue({ user: { id: "owner" } }) } },
|
||||
verifyOAuthToken: vi.fn(),
|
||||
}));
|
||||
const { documentExports } = await import("./exports");
|
||||
const context = { locale: "en-US" as const, reqHeaders: new Headers() };
|
||||
beforeEach(() => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.basics.name = "Ada Lovelace";
|
||||
data.summary.content = "<p>Builds analytical engines.</p>";
|
||||
getResume.mockResolvedValue({ id: "resume-1", name: "Ada", data });
|
||||
});
|
||||
|
||||
it("exports saved data as round-trippable JSON, readable Markdown and a Word document", async () => {
|
||||
const json = await call(documentExports.resume, { id: "resume-1", format: "json" }, { context });
|
||||
expect(JSON.parse(await json.body.text())).toMatchObject({ basics: { name: "Ada Lovelace" } });
|
||||
expect(json.headers["content-disposition"]).toContain(".json");
|
||||
const markdown = await call(documentExports.resume, { id: "resume-1", format: "md" }, { context });
|
||||
expect(await markdown.body.text()).toContain("Builds analytical engines.");
|
||||
const docx = await call(documentExports.resume, { id: "resume-1", format: "docx" }, { context });
|
||||
const files = unzipSync(new Uint8Array(await docx.body.arrayBuffer()));
|
||||
const xml = files["word/document.xml"];
|
||||
expect(xml).toBeDefined();
|
||||
if (!xml) throw new Error("Missing Word document");
|
||||
expect(strFromU8(xml)).toContain("Ada Lovelace");
|
||||
expect(getResume).toHaveBeenCalledWith({ id: "resume-1", userId: "owner" });
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { composeCoverLetter, createCoverLetterResumeData } from "@reactive-resume/resume/cover-letter";
|
||||
import { getResumeExportData } from "@reactive-resume/resume/export-sections";
|
||||
import { buildMarkdown } from "@reactive-resume/resume/markdown";
|
||||
import { generateFilename } from "@reactive-resume/utils/file";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { pdfExportRateLimit } from "../../middleware/rate-limit";
|
||||
import { coverLetterService } from "../cover-letters/service";
|
||||
import { resumeService } from "../resume/service";
|
||||
|
||||
const exportInput = z.object({
|
||||
id: z.string().min(1),
|
||||
format: z.enum(["pdf", "docx", "md", "json"]),
|
||||
});
|
||||
const exportOutput = z.object({ headers: z.object({ "content-disposition": z.string() }), body: z.file() });
|
||||
|
||||
async function fileResponse(
|
||||
data: ResumeData,
|
||||
name: string,
|
||||
format: z.infer<typeof exportInput>["format"],
|
||||
json: unknown,
|
||||
includeCoverLetterHeader = false,
|
||||
) {
|
||||
const filename = generateFilename(name, format);
|
||||
let body: File;
|
||||
try {
|
||||
if (format === "json") body = new File([JSON.stringify(json, null, 2)], filename, { type: "application/json" });
|
||||
else if (format === "pdf") {
|
||||
const { createResumePdfFile } = await import("@reactive-resume/pdf/server");
|
||||
body = await createResumePdfFile({
|
||||
data,
|
||||
filename,
|
||||
uploadOrigin: env.APP_URL,
|
||||
renderOptions: { includeCoverLetterHeader },
|
||||
});
|
||||
} else {
|
||||
const { getResumeSectionTitle } = await import("@reactive-resume/pdf/section-title");
|
||||
const resolveTitle = (id: string) => getResumeSectionTitle(data, id);
|
||||
if (format === "md") body = new File([buildMarkdown(data, resolveTitle)], filename, { type: "text/markdown" });
|
||||
else {
|
||||
const { buildDocx } = await import("@reactive-resume/docx");
|
||||
const blob = await buildDocx(data, resolveTitle);
|
||||
body = new File([blob], filename, { type: blob.type });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[document export] Rendering failed", error);
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Could not generate the document." });
|
||||
}
|
||||
return { headers: { "content-disposition": `attachment; filename="${filename}"` }, body };
|
||||
}
|
||||
|
||||
export const documentExports = {
|
||||
resume: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}/exports/{format}",
|
||||
tags: ["Resumes"],
|
||||
operationId: "exportResumeFile",
|
||||
summary: "Download a resume in PDF, DOCX, Markdown or JSON",
|
||||
outputStructure: "detailed",
|
||||
})
|
||||
.input(exportInput)
|
||||
.output(exportOutput)
|
||||
.use(pdfExportRateLimit)
|
||||
.handler(async ({ context, input }) => {
|
||||
const resume = await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
return fileResponse(getResumeExportData(resume.data, "resume"), resume.name, input.format, resume.data);
|
||||
}),
|
||||
letter: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/cover-letters/{id}/exports/{format}",
|
||||
tags: ["Cover Letters"],
|
||||
operationId: "exportCoverLetterFile",
|
||||
summary: "Download a cover letter in PDF, DOCX, Markdown or JSON",
|
||||
description:
|
||||
"Uses live linked sender/design details. Structured letter greetings default to English; supply words to localize them. Dates use the document locale.",
|
||||
outputStructure: "detailed",
|
||||
})
|
||||
.input(
|
||||
exportInput.extend({
|
||||
words: z
|
||||
.object({
|
||||
greeting: z.string().max(500).describe("Use {name} for the recipient."),
|
||||
teamGreeting: z.string().max(500),
|
||||
hiringTeam: z.string().max(500),
|
||||
signOff: z.string().max(500),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.output(exportOutput)
|
||||
.use(pdfExportRateLimit)
|
||||
.handler(async ({ context, input }) => {
|
||||
const letter = await coverLetterService.getById({ id: input.id, userId: context.user.id });
|
||||
const words = input.words ?? {
|
||||
greeting: "Dear {name},",
|
||||
teamGreeting: "Dear hiring team,",
|
||||
hiringTeam: "Hiring team",
|
||||
signOff: "Kind regards,",
|
||||
};
|
||||
const composed = composeCoverLetter(letter, {
|
||||
...words,
|
||||
greeting: (name) => words.greeting.replace("{name}", name),
|
||||
formatDate: (date) =>
|
||||
new Date(`${date}T12:00:00Z`).toLocaleDateString(letter.style.metadata.page.locale, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}),
|
||||
});
|
||||
const json =
|
||||
input.format === "json" ? await coverLetterService.export({ id: input.id, userId: context.user.id }) : null;
|
||||
return fileResponse(
|
||||
createCoverLetterResumeData({ ...letter, ...composed }),
|
||||
letter.name,
|
||||
input.format,
|
||||
json,
|
||||
true,
|
||||
);
|
||||
}),
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { documentsDto } from "../../dto/documents";
|
||||
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
|
||||
import { paginate } from "../../pagination";
|
||||
import { documentsService } from "./service";
|
||||
|
||||
const route = (
|
||||
@@ -32,7 +34,13 @@ export const documentsRouter = {
|
||||
)
|
||||
.input(documentsDto.list.input)
|
||||
.output(documentsDto.list.output)
|
||||
.handler(({ context, input }) => documentsService.list({ userId: context.user.id, trashed: input.trashed })),
|
||||
.handler(async ({ context, input }) =>
|
||||
paginate(
|
||||
await documentsService.list({ userId: context.user.id, trashed: input.trashed }),
|
||||
input,
|
||||
context.resHeaders,
|
||||
),
|
||||
),
|
||||
|
||||
counts: protectedProcedure
|
||||
.route(
|
||||
@@ -44,6 +52,7 @@ export const documentsRouter = {
|
||||
"Counts live resumes and letters, and everything in Trash.",
|
||||
),
|
||||
)
|
||||
.input(z.object({}).optional())
|
||||
.output(documentsDto.counts.output)
|
||||
.handler(({ context }) => documentsService.counts({ userId: context.user.id })),
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export const flagsRouter = {
|
||||
"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.",
|
||||
})
|
||||
.input(z.object({}).optional())
|
||||
.output(
|
||||
z.object({
|
||||
disableSignups: z.boolean().describe("Whether new user signups are disabled on this instance."),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import z from "zod";
|
||||
import { ATS_CATEGORIES, ATS_RULE_CODES, lintResumeForAts } from "@reactive-resume/resume/ats";
|
||||
import {
|
||||
analyzePdfResume,
|
||||
buildExtractedDocument,
|
||||
matchJobDescription,
|
||||
surfaceFormsOf,
|
||||
} from "@reactive-resume/resume/ats-pdf";
|
||||
import { buildMarkdown } from "@reactive-resume/resume/markdown";
|
||||
import { publicProcedure, protectedProcedure } from "../../context";
|
||||
import { jobTermSchema, pdfCheckSchema } from "../../dto/pdf-check";
|
||||
import { pdfAnalysisRateLimit } from "../../middleware/rate-limit";
|
||||
import { extractPdf } from "./pdf-analysis";
|
||||
import { resumeService } from "./service";
|
||||
|
||||
const finding = z.object({
|
||||
code: z.enum(ATS_RULE_CODES),
|
||||
severity: z.enum(["error", "warning", "info"]),
|
||||
pointer: z.string(),
|
||||
key: z.string(),
|
||||
params: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
|
||||
});
|
||||
export const checkResume = protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}/checks",
|
||||
tags: ["Resumes"],
|
||||
operationId: "getResumeChecks",
|
||||
summary: "Check resume content for ATS issues",
|
||||
description:
|
||||
"Runs the editor's deterministic contact, date, layout, heading and writing checks. Respects ignored findings saved on the resume.",
|
||||
})
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.output(
|
||||
z.object({
|
||||
findings: z.array(finding).readonly(),
|
||||
ignored: z.array(finding).readonly(),
|
||||
counts: z.object({ error: z.number(), warning: z.number(), info: z.number() }),
|
||||
totalRules: z.number(),
|
||||
passedRules: z.number(),
|
||||
score: z.number(),
|
||||
categories: z.record(z.enum(ATS_CATEGORIES), z.object({ total: z.number(), passed: z.number() })),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ input, context }) =>
|
||||
lintResumeForAts((await resumeService.getById({ id: input.id, userId: context.user.id })).data),
|
||||
);
|
||||
|
||||
export const checkPdf = publicProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/pdf-checks",
|
||||
tags: ["Checks"],
|
||||
operationId: "createPdfCheck",
|
||||
summary: "Check a PDF for ATS readability",
|
||||
description:
|
||||
"Multipart PDF upload, up to 25 MB. Processes at most 30 pages, reports truncation and skipped checks, and stores nothing. Rate limited per client address. No authentication required.",
|
||||
})
|
||||
.input(z.object({ file: z.file().max(25_000_000), jobDescription: z.string().max(20_000).optional() }))
|
||||
.output(
|
||||
pdfCheckSchema.extend({
|
||||
fullText: z.string().describe("Extracted text in reading order, for the optional AI review."),
|
||||
}),
|
||||
)
|
||||
.use(pdfAnalysisRateLimit)
|
||||
.handler(async ({ input, signal }) => {
|
||||
const raw = await extractPdf(input.file, signal);
|
||||
return {
|
||||
...analyzePdfResume(raw, input.jobDescription ? { jobDescription: input.jobDescription } : {}),
|
||||
fullText: buildExtractedDocument(raw).fullText,
|
||||
};
|
||||
});
|
||||
|
||||
export const matchResume = protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/resumes/{id}/job-matches",
|
||||
tags: ["Checks"],
|
||||
operationId: "createResumeJobMatch",
|
||||
summary: "Match resume terms to a job posting",
|
||||
description:
|
||||
"Runs the editor's deterministic term matching and respects terms hidden on this resume. No AI provider is needed.",
|
||||
})
|
||||
.input(z.object({ id: z.string().min(1), jobDescription: z.string().trim().min(1).max(20_000) }))
|
||||
.output(
|
||||
z.object({
|
||||
found: z.array(jobTermSchema.extend({ label: z.string() })),
|
||||
missing: z.array(jobTermSchema.extend({ label: z.string() })),
|
||||
total: z.number(),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { data } = await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
const hidden = new Set(data.metadata.check?.hiddenTerms ?? []);
|
||||
const terms = matchJobDescription({ jobDescription: input.jobDescription, resumeText: buildMarkdown(data) })
|
||||
.terms.filter((term) => !hidden.has(term.term))
|
||||
.map((term) => ({
|
||||
...term,
|
||||
label:
|
||||
surfaceFormsOf(term.term)
|
||||
.map((form) => input.jobDescription.match(new RegExp(RegExp.escape(form), "i"))?.[0])
|
||||
.find(Boolean) ?? term.term,
|
||||
}));
|
||||
return {
|
||||
found: terms.filter((term) => term.resumeCount > 0),
|
||||
missing: terms.filter((term) => term.resumeCount === 0),
|
||||
total: terms.length,
|
||||
};
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { generateId, generateRandomName } from "@reactive-resume/utils/string";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { resumeDto } from "../../dto/resume";
|
||||
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
|
||||
import { paginate } from "../../pagination";
|
||||
import { documentsService } from "../documents/service";
|
||||
import { createResumeData } from "./initial-data";
|
||||
import { parseStoredResumeData } from "./resume-data-validation";
|
||||
@@ -21,12 +22,16 @@ export const crudRouter = {
|
||||
})
|
||||
.input(resumeDto.list.input.optional().default({ tags: [], sort: "lastUpdatedAt" }))
|
||||
.output(resumeDto.list.output)
|
||||
.handler(({ input, context }) =>
|
||||
resumeService.list({
|
||||
userId: context.user.id,
|
||||
tags: input.tags,
|
||||
sort: input.sort,
|
||||
}),
|
||||
.handler(async ({ input, context }) =>
|
||||
paginate(
|
||||
await resumeService.list({
|
||||
userId: context.user.id,
|
||||
tags: input.tags,
|
||||
sort: input.sort,
|
||||
}),
|
||||
input,
|
||||
context.resHeaders,
|
||||
),
|
||||
),
|
||||
|
||||
getById: protectedProcedure
|
||||
@@ -144,6 +149,7 @@ export const crudRouter = {
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
|
||||
...(input.slug !== undefined ? { slug: input.slug } : {}),
|
||||
...(input.tags !== undefined ? { tags: input.tags } : {}),
|
||||
...(input.data !== undefined ? { data: input.data } : {}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eventIterator } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { subscribeResumeUpdated } from "./events";
|
||||
@@ -16,6 +17,17 @@ export const updatesRouter = {
|
||||
successDescription: "A stream of resume update invalidation events.",
|
||||
})
|
||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
|
||||
.output(
|
||||
eventIterator(
|
||||
z.object({
|
||||
type: z.literal("resume.updated"),
|
||||
resumeId: z.string(),
|
||||
userId: z.string(),
|
||||
updatedAt: z.string(),
|
||||
mutation: z.enum(["sync", "create", "update", "patch", "lock", "password", "delete"]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handler(async function* ({ context, input, signal }) {
|
||||
const resume = await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { call } from "@orpc/server";
|
||||
const create = vi.hoisted(() => vi.fn());
|
||||
vi.mock("./service", () => ({ resumeService: { create } }));
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: { api: { getSession: vi.fn().mockResolvedValue({ user: { id: "owner" } }) } },
|
||||
verifyOAuthToken: vi.fn(),
|
||||
}));
|
||||
const { importResumeFile } = await import("./imports");
|
||||
|
||||
it("imports JSON Resume into the authenticated account and rejects invalid files before writing", async () => {
|
||||
const context = { locale: "en-US" as const, reqHeaders: new Headers() };
|
||||
const id = await call(
|
||||
importResumeFile,
|
||||
{ file: new File([JSON.stringify({ basics: { name: "Ada Lovelace" } })], "resume.json"), format: "JSON_RESUME" },
|
||||
{ context },
|
||||
);
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id,
|
||||
userId: "owner",
|
||||
name: "Ada Lovelace",
|
||||
data: expect.objectContaining({ basics: expect.objectContaining({ name: "Ada Lovelace" }) }),
|
||||
}),
|
||||
);
|
||||
create.mockClear();
|
||||
await expect(
|
||||
call(importResumeFile, { file: new File(["bad json"], "resume.json"), format: "JSON_RESUME" }, { context }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { call, ORPCError } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { storageUploadRateLimit } from "../../middleware/rate-limit";
|
||||
import { crudRouter } from "./crud";
|
||||
|
||||
export const importResumeFile = protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/resumes/file-imports",
|
||||
tags: ["Resumes"],
|
||||
operationId: "importResumeFile",
|
||||
summary: "Import a PDF, resume JSON or LinkedIn archive",
|
||||
description:
|
||||
"Multipart form with file and format fields. PDF imports read the text layer without AI. For AI-assisted PDF and Word imports, use the AI parsing endpoints then create a resume import.",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
file: z.file().max(10 * 1024 * 1024),
|
||||
format: z.enum(["REACTIVE_RESUME", "REACTIVE_RESUME_V4", "JSON_RESUME", "LINKEDIN", "PDF"]),
|
||||
}),
|
||||
)
|
||||
.output(z.string().describe("The imported resume ID."))
|
||||
.use(storageUploadRateLimit)
|
||||
.handler(async ({ input, context, signal }) => {
|
||||
let data;
|
||||
try {
|
||||
if (input.format === "PDF") {
|
||||
const [{ extractPdf }, { documentToLines }, { buildExtractedDocument }, { parseResumeText }] =
|
||||
await Promise.all([
|
||||
import("./pdf-analysis"),
|
||||
import("@reactive-resume/import/pdf-lines"),
|
||||
import("@reactive-resume/resume/ats-pdf"),
|
||||
import("@reactive-resume/import/plain-text"),
|
||||
]);
|
||||
const raw = await extractPdf(input.file, signal, 0);
|
||||
if (raw.truncated) throw new Error("The PDF has too many pages to import completely.");
|
||||
const lines = documentToLines(buildExtractedDocument(raw));
|
||||
if (lines.length === 0) throw new Error("The PDF has no readable text layer.");
|
||||
data = parseResumeText(lines.join("\n"));
|
||||
} else if (input.format === "LINKEDIN") {
|
||||
const { parseLinkedInExport } = await import("@reactive-resume/import/linkedin");
|
||||
data = parseLinkedInExport(new Uint8Array(await input.file.arrayBuffer()));
|
||||
} else {
|
||||
const text = await input.file.text();
|
||||
if (input.format === "JSON_RESUME") {
|
||||
const { parseJSONResume } = await import("@reactive-resume/import/json-resume");
|
||||
data = parseJSONResume(text);
|
||||
} else if (input.format === "REACTIVE_RESUME_V4") {
|
||||
const { parseReactiveResumeV4JSON } = await import("@reactive-resume/import/reactive-resume-v4-json");
|
||||
data = parseReactiveResumeV4JSON(text);
|
||||
} else {
|
||||
const { parseReactiveResumeJSON } = await import("@reactive-resume/import/reactive-resume-json");
|
||||
data = parseReactiveResumeJSON(text);
|
||||
}
|
||||
}
|
||||
const { convertLegacyStylesheet, needsLegacyStyleConversion } =
|
||||
await import("@reactive-resume/pdf/semantic-legacy");
|
||||
if (needsLegacyStyleConversion(data.metadata)) data.metadata.stylesheet = convertLegacyStylesheet(data);
|
||||
} catch (error) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "The file is not a valid resume in the selected format.",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return call(crudRouter.import, { data }, { context });
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { call } from "@orpc/server";
|
||||
const list = vi.hoisted(() => vi.fn());
|
||||
vi.mock("./service", () => ({ resumeService: { list } }));
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: { api: { getSession: vi.fn().mockResolvedValue({ user: { id: "owner" } }) } },
|
||||
verifyOAuthToken: vi.fn(),
|
||||
}));
|
||||
const { crudRouter } = await import("./crud");
|
||||
|
||||
it("paginates the owner's filtered list without changing legacy array responses", async () => {
|
||||
const rows = ["a", "b", "c"].map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
slug: id,
|
||||
tags: ["engineering"],
|
||||
isPublic: false,
|
||||
isLocked: false,
|
||||
showDownloadButtons: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
list.mockResolvedValue(rows);
|
||||
const resHeaders = new Headers();
|
||||
const context = { locale: "en-US" as const, reqHeaders: new Headers(), resHeaders };
|
||||
const result = await call(crudRouter.list, { tags: ["engineering"], limit: 1, offset: 1 }, { context });
|
||||
expect(result.map((row) => row.id)).toEqual(["b"]);
|
||||
expect(resHeaders.get("X-Total-Count")).toBe("3");
|
||||
expect(list).toHaveBeenCalledWith({ userId: "owner", tags: ["engineering"], sort: "lastUpdatedAt" });
|
||||
expect(await call(crudRouter.list, {}, { context })).toEqual(rows);
|
||||
await expect(call(crudRouter.list, { limit: 101 }, { context })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { call } from "@orpc/server";
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: { api: { getSession: vi.fn().mockResolvedValue(null) } },
|
||||
verifyOAuthToken: vi.fn(),
|
||||
}));
|
||||
const { checkPdf } = await import("./checks");
|
||||
|
||||
// A complete, tiny one-page PDF; no network fonts, renderer, database or mocked PDF reader.
|
||||
function pdfFile() {
|
||||
const stream = "BT /F1 12 Tf 50 700 Td (Ada Lovelace analytical engines) Tj ET";
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
let text = "%PDF-1.4\n";
|
||||
const offsets = objects.map((object, index) => {
|
||||
const offset = text.length;
|
||||
text += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
return offset;
|
||||
});
|
||||
const xref = text.length;
|
||||
text += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`;
|
||||
return new File([text], "resume.pdf", { type: "application/pdf" });
|
||||
}
|
||||
|
||||
it("checks an anonymous PDF upload with the real reader and rejects non-PDF bytes", async () => {
|
||||
const context = { locale: "en-US" as const, reqHeaders: new Headers(), trustedClient: "127.0.0.1" };
|
||||
const report = await call(checkPdf, { file: pdfFile() }, { context });
|
||||
expect(report.document).toMatchObject({ pageCount: 1, truncated: false, wordCount: 4 });
|
||||
expect(report.file.magicBytesOk).toBe(true);
|
||||
expect(report.fullText).toContain("Ada Lovelace analytical engines");
|
||||
await expect(call(checkPdf, { file: new File(["not a PDF"], "resume.pdf") }, { context })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { harvestPdfDocument } from "@reactive-resume/resume/ats-pdf";
|
||||
|
||||
/** Server adapter for the same bounded extraction used by the browser's checker and importer. */
|
||||
export async function extractPdf(file: File, signal?: AbortSignal, operatorBudgetMs = 30_000) {
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
if (file.size > 25_000_000 || ![0x25, 0x50, 0x44, 0x46].every((byte, index) => data[index] === byte)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Provide a PDF no larger than 25 MB." });
|
||||
}
|
||||
const { getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const task = getDocument({ data, fontExtraProperties: true, useSystemFonts: false });
|
||||
const timeout = AbortSignal.timeout(45_000);
|
||||
const aborted = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
const cancel = () => {
|
||||
void task.destroy().catch(() => {});
|
||||
};
|
||||
aborted.addEventListener("abort", cancel, { once: true });
|
||||
try {
|
||||
aborted.throwIfAborted();
|
||||
const document = await task.promise;
|
||||
return await harvestPdfDocument(document, {
|
||||
file: { name: file.name, sizeBytes: file.size, magicBytesOk: true },
|
||||
signal: aborted,
|
||||
operatorBudgetMs,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "The PDF could not be read. Use an unencrypted PDF with a readable text layer.",
|
||||
cause,
|
||||
});
|
||||
} finally {
|
||||
aborted.removeEventListener("abort", cancel);
|
||||
await task.destroy().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export const sharingRouter = {
|
||||
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."),
|
||||
password: z.string().min(1).max(64).describe("The password to verify."),
|
||||
}),
|
||||
)
|
||||
.use(resumePasswordRateLimit)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { paginate, paginationShape } from "../../pagination";
|
||||
import { resumeService } from "./service";
|
||||
|
||||
export const tagsRouter = {
|
||||
@@ -15,5 +16,8 @@ export const tagsRouter = {
|
||||
successDescription: "A sorted array of unique tag strings.",
|
||||
})
|
||||
.output(z.array(z.string()))
|
||||
.handler(({ context }) => resumeService.tags.list({ userId: context.user.id })),
|
||||
.input(z.object(paginationShape).default({}))
|
||||
.handler(async ({ context, input }) =>
|
||||
paginate(await resumeService.tags.list({ userId: context.user.id }), input, context.resHeaders),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { resumeDto } from "../../dto/resume";
|
||||
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
|
||||
import { paginate } from "../../pagination";
|
||||
import { resumeService } from "./service";
|
||||
|
||||
export const versionsRouter = {
|
||||
@@ -17,8 +18,12 @@ export const versionsRouter = {
|
||||
})
|
||||
.input(resumeDto.listVersions.input)
|
||||
.output(resumeDto.listVersions.output)
|
||||
.handler(({ context, input }) =>
|
||||
resumeService.versions.list({ resumeId: input.resumeId, userId: context.user.id }),
|
||||
.handler(async ({ context, input }) =>
|
||||
paginate(
|
||||
await resumeService.versions.list({ resumeId: input.resumeId, userId: context.user.id }),
|
||||
input,
|
||||
context.resHeaders,
|
||||
),
|
||||
),
|
||||
|
||||
getVersion: protectedProcedure
|
||||
|
||||
@@ -14,6 +14,7 @@ const githubRouter = {
|
||||
"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.",
|
||||
})
|
||||
.input(z.object({}).optional())
|
||||
.output(z.number().describe("The number of GitHub stars."))
|
||||
.handler(() => statisticsService.github.getStarCount()),
|
||||
};
|
||||
@@ -27,6 +28,7 @@ export const statisticsRouter = {
|
||||
operationId: "getStatisticsTotals",
|
||||
summary: "Get user and resume totals with their cache timestamp",
|
||||
})
|
||||
.input(z.object({}).optional())
|
||||
.output(
|
||||
z.object({
|
||||
users: z.number(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { count } from "drizzle-orm";
|
||||
import z from "zod";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
|
||||
@@ -62,9 +63,8 @@ const fetchGitHubStarsOnce = async (): Promise<number | null> => {
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = (await response.json()) as { stargazers_count?: unknown };
|
||||
const stars = Number(data.stargazers_count);
|
||||
return Number.isFinite(stars) && stars > 0 ? stars : null;
|
||||
const data = z.object({ stargazers_count: z.number().int().nonnegative() }).safeParse(await response.json());
|
||||
return data.success ? data.data.stargazers_count : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ function isUnsafeStorageKey(key: string): boolean {
|
||||
return key.split("/").some((segment) => segment === "." || segment === "..");
|
||||
}
|
||||
|
||||
export const uploadFileOutputSchema = z.object({
|
||||
url: z.string().describe("The download URL. Profile images are public; other files require owner authentication."),
|
||||
path: z.string().describe("The storage path of the uploaded file."),
|
||||
contentType: z.string().describe("The MIME type of the uploaded file."),
|
||||
});
|
||||
|
||||
export const storageRouter = {
|
||||
uploadFile: protectedProcedure
|
||||
.route({
|
||||
@@ -32,13 +38,7 @@ export const storageRouter = {
|
||||
})
|
||||
.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."),
|
||||
}),
|
||||
)
|
||||
.output(uploadFileOutputSchema)
|
||||
.handler(async ({ context, input: file }) => {
|
||||
const originalMimeType = file.type;
|
||||
const isImage = isImageFile(originalMimeType);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { webAccessDto } from "../../dto/web-access";
|
||||
import { WebAccessError } from "./contracts";
|
||||
@@ -20,6 +21,7 @@ export const webAccessRouter = {
|
||||
operationId: "getWebAccessStatus",
|
||||
summary: "Get search and reading availability",
|
||||
})
|
||||
.input(z.object({}).optional())
|
||||
.output(webAccessDto.status.output)
|
||||
.handler(({ context }) => webAccessService.status(context.user.id)),
|
||||
save: protectedProcedure
|
||||
@@ -43,6 +45,7 @@ export const webAccessRouter = {
|
||||
summary: "Remove the personal web connection",
|
||||
})
|
||||
.errors(errors)
|
||||
.input(z.object({}).optional())
|
||||
.output(webAccessDto.delete.output)
|
||||
.handler(({ context }) => webAccessService.delete(context.user.id)),
|
||||
test: protectedProcedure
|
||||
@@ -57,6 +60,7 @@ export const webAccessRouter = {
|
||||
PRECONDITION_FAILED: { message: "No external web connection is configured.", status: 412 },
|
||||
RATE_LIMIT_EXCEEDED: { message: "Too many web requests. Try again later.", status: 429 },
|
||||
})
|
||||
.input(z.object({}).optional())
|
||||
.output(webAccessDto.test.output)
|
||||
.handler(async ({ context, signal }) => {
|
||||
const connection = await webAccessService.resolve(context.user.id);
|
||||
|
||||
@@ -101,3 +101,9 @@ export const resumeMutationRateLimit = createRatelimitMiddleware<ContextWithHead
|
||||
limiter: productionLimiter(resumeMutationLimiter),
|
||||
key: ({ context }, input) => `resume-mutation:${getUserKey(context)}:${getInputKeyPart(input)}`,
|
||||
});
|
||||
|
||||
const pdfAnalysisLimiter = createRateLimiter("pdfAnalysisLimiter", rateLimitConfig.orpc.pdfExport);
|
||||
export const pdfAnalysisRateLimit = createRatelimitMiddleware<ContextWithHeaders, unknown>({
|
||||
limiter: productionLimiter(pdfAnalysisLimiter),
|
||||
key: ({ context }) => `pdf-analysis:${getClientKey(context.trustedClient)}`,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import z from "zod";
|
||||
|
||||
// Opt-in pagination keeps existing array responses and clients that load the full library compatible.
|
||||
export const paginationShape = {
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe("Maximum results (1–100). Omit both pagination fields for the legacy complete list."),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.optional()
|
||||
.describe("Zero-based offset. Defaults to 0; limit defaults to 20 when only offset is given."),
|
||||
};
|
||||
|
||||
export function paginate<T>(
|
||||
items: T[],
|
||||
input: { limit?: number | undefined; offset?: number | undefined },
|
||||
headers?: Headers,
|
||||
): T[] {
|
||||
headers?.set("X-Total-Count", String(items.length));
|
||||
if (input.limit === undefined && input.offset === undefined) return items;
|
||||
const limit = input.limit ?? 20;
|
||||
const offset = input.offset ?? 0;
|
||||
headers?.set("X-Limit", String(limit));
|
||||
headers?.set("X-Offset", String(offset));
|
||||
// ponytail: slice the existing per-user library in memory; move pagination into SQL if large libraries dominate memory.
|
||||
return items.slice(offset, offset + limit);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { call } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../context";
|
||||
import { documentExports } from "../features/documents/exports";
|
||||
import { checkResume, checkPdf, matchResume } from "../features/resume/checks";
|
||||
import { importResumeFile } from "../features/resume/imports";
|
||||
import { uploadFileOutputSchema } from "../features/storage/router";
|
||||
import router from "./index";
|
||||
|
||||
// Add resource-oriented aliases without changing the routes existing clients use.
|
||||
export const restAliases = {
|
||||
documentExports,
|
||||
checkResume,
|
||||
checkPdf,
|
||||
matchResume,
|
||||
importResumeFile,
|
||||
updateCoverLetter: router.coverLetters.update.route({ method: "PATCH", operationId: "patchCoverLetter" }),
|
||||
updateApplication: router.applications.update.route({ method: "PATCH", operationId: "patchApplication" }),
|
||||
updateInterview: router.applications.updateInterview.route({
|
||||
method: "PATCH",
|
||||
operationId: "patchApplicationInterview",
|
||||
}),
|
||||
updateTimelineEntry: router.applications.updateTimelineEntry.route({
|
||||
method: "PATCH",
|
||||
operationId: "patchApplicationTimelineEntry",
|
||||
}),
|
||||
updateResume: router.resume.update.route({
|
||||
method: "PATCH",
|
||||
path: "/resumes/{id}/metadata",
|
||||
operationId: "patchResumeMetadata",
|
||||
}),
|
||||
resumeCopy: router.resume.duplicate.route({ path: "/resumes/{id}/copies", operationId: "createResumeCopy" }),
|
||||
resumeImport: router.resume.import.route({ path: "/resumes/imports", operationId: "createResumeImport" }),
|
||||
resumeLock: router.resume.setLocked.route({
|
||||
method: "PUT",
|
||||
path: "/resumes/{id}/lock",
|
||||
operationId: "putResumeLock",
|
||||
}),
|
||||
resumeRestoration: router.resume.restoreVersion.route({
|
||||
path: "/resumes/{resumeId}/versions/{versionId}/restorations",
|
||||
operationId: "createResumeRestoration",
|
||||
}),
|
||||
resumePasswordVerification: router.resume.verifyPassword.route({
|
||||
path: "/resumes/{username}/{slug}/password-verifications",
|
||||
operationId: "createResumePasswordVerification",
|
||||
}),
|
||||
resumeSlugAvailability: router.resume.checkSlug.route({
|
||||
path: "/resumes/{resumeId}/slug-availability",
|
||||
operationId: "getResumeSlugAvailability",
|
||||
}),
|
||||
resumeDownload: router.resume.statistics.recordDownload.route({
|
||||
path: "/resumes/{username}/{slug}/statistics/downloads",
|
||||
operationId: "createResumeDownload",
|
||||
}),
|
||||
letterCopy: router.coverLetters.duplicate.route({
|
||||
path: "/cover-letters/{id}/copies",
|
||||
operationId: "createCoverLetterCopy",
|
||||
}),
|
||||
letterImport: router.coverLetters.import.route({
|
||||
path: "/cover-letters/imports",
|
||||
operationId: "createCoverLetterImport",
|
||||
}),
|
||||
letterStyle: router.coverLetters.refreshStyle.route({
|
||||
path: "/cover-letters/{id}/style-refreshes",
|
||||
operationId: "createCoverLetterStyleRefresh",
|
||||
}),
|
||||
letterDraft: router.coverLetters.draft.route({
|
||||
path: "/cover-letters/{id}/drafts",
|
||||
operationId: "createCoverLetterDraft",
|
||||
}),
|
||||
letterRestoration: router.coverLetters.restoreVersion.route({
|
||||
path: "/cover-letters/{id}/versions/{versionId}/restorations",
|
||||
operationId: "createCoverLetterRestoration",
|
||||
}),
|
||||
documentName: router.documents.rename.route({
|
||||
method: "PATCH",
|
||||
path: "/documents/{type}/{id}/name",
|
||||
operationId: "patchDocumentName",
|
||||
}),
|
||||
documentTags: router.documents.setTags.route({
|
||||
method: "PUT",
|
||||
path: "/documents/{type}/{id}/tags",
|
||||
operationId: "putDocumentTags",
|
||||
}),
|
||||
documentLock: router.documents.setLocked.route({
|
||||
method: "PUT",
|
||||
path: "/documents/{type}/{id}/lock",
|
||||
operationId: "putDocumentLock",
|
||||
}),
|
||||
documentApplication: router.documents.linkApplication.route({
|
||||
method: "PUT",
|
||||
path: "/documents/{type}/{id}/application",
|
||||
operationId: "putDocumentApplication",
|
||||
}),
|
||||
documentTrash: router.documents.trash.route({
|
||||
method: "PUT",
|
||||
path: "/documents/{type}/{id}/trash",
|
||||
operationId: "putDocumentTrash",
|
||||
}),
|
||||
documentRestore: router.documents.restore.route({
|
||||
method: "DELETE",
|
||||
path: "/documents/{type}/{id}/trash",
|
||||
operationId: "deleteDocumentTrash",
|
||||
}),
|
||||
documentPurge: router.documents.purge.route({
|
||||
method: "DELETE",
|
||||
path: "/documents/{type}/{id}",
|
||||
operationId: "deleteDocumentPermanently",
|
||||
}),
|
||||
documentCopy: router.documents.copyForJob.route({ path: "/documents/copies", operationId: "createDocumentCopy" }),
|
||||
postingSearch: router.applications.ai.searchPostings.route({
|
||||
path: "/applications/ai/posting-searches",
|
||||
operationId: "createPostingSearch",
|
||||
}),
|
||||
postingParse: router.applications.ai.parsePosting.route({
|
||||
path: "/applications/ai/posting-parses",
|
||||
operationId: "createPostingParse",
|
||||
}),
|
||||
applicationAutofill: router.applications.ai.autofill.route({
|
||||
path: "/applications/ai/autofills",
|
||||
operationId: "createApplicationAutofill",
|
||||
}),
|
||||
applicationMatch: router.applications.ai.matchScore.route({
|
||||
path: "/applications/{id}/ai/match-scores",
|
||||
operationId: "createApplicationMatchScore",
|
||||
}),
|
||||
applicationMessage: router.applications.ai.draftMessage.route({
|
||||
path: "/applications/{id}/ai/message-drafts",
|
||||
operationId: "createApplicationMessageDraft",
|
||||
}),
|
||||
applicationResume: router.applications.ai.tailorResume.route({
|
||||
path: "/applications/{id}/ai/tailored-resumes",
|
||||
operationId: "createTailoredResume",
|
||||
}),
|
||||
applicationImport: router.applications.import.route({
|
||||
path: "/applications/imports",
|
||||
operationId: "createApplicationImport",
|
||||
}),
|
||||
applicationBulkUpdate: router.applications.bulkUpdate.route({
|
||||
method: "PATCH",
|
||||
path: "/applications",
|
||||
operationId: "patchApplications",
|
||||
}),
|
||||
applicationBulkDelete: router.applications.bulkDelete.route({
|
||||
method: "DELETE",
|
||||
path: "/applications",
|
||||
operationId: "deleteApplications",
|
||||
}),
|
||||
providerTest: router.aiProviders.test.route({
|
||||
path: "/ai-providers/{id}/tests",
|
||||
operationId: "createAiProviderTest",
|
||||
}),
|
||||
webAccessTest: router.webAccess.test.route({
|
||||
path: "/integrations/web-access/tests",
|
||||
operationId: "createWebAccessTest",
|
||||
}),
|
||||
message: router.agent.messages.send.route({
|
||||
path: "/agent/threads/{threadId}/messages",
|
||||
operationId: "createAgentMessage",
|
||||
}),
|
||||
messageStop: router.agent.messages.stop.route({
|
||||
method: "DELETE",
|
||||
path: "/agent/threads/{threadId}/run",
|
||||
operationId: "deleteAgentRun",
|
||||
}),
|
||||
messageStream: router.agent.messages.resume.route({
|
||||
path: "/agent/threads/{threadId}/stream",
|
||||
operationId: "getAgentStream",
|
||||
}),
|
||||
messageEdits: router.agent.messages.setEditStatus.route({
|
||||
method: "PATCH",
|
||||
path: "/agent/threads/{threadId}/messages/{messageId}/edits",
|
||||
operationId: "patchAgentMessageEdits",
|
||||
}),
|
||||
pdfParsing: router.ai.parsePdf.route({ path: "/ai/pdf-parses", operationId: "createPdfParse" }),
|
||||
docxParsing: router.ai.parseDocx.route({ path: "/ai/docx-parses", operationId: "createDocxParse" }),
|
||||
atsReview: router.ai.atsReview.route({ path: "/ai/ats-reviews", operationId: "createAtsReview" }),
|
||||
improvement: router.ai.improve.route({ path: "/ai/improvements", operationId: "createImprovement" }),
|
||||
fileUpload: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/files",
|
||||
tags: ["Files"],
|
||||
operationId: "createFile",
|
||||
summary: "Upload a file",
|
||||
description:
|
||||
"Send a multipart form with a file field (maximum 10 MiB). Images become public profile pictures; other files remain owner-only. Requires authentication.",
|
||||
})
|
||||
.input(z.object({ file: z.file().max(10 * 1024 * 1024) }))
|
||||
.output(uploadFileOutputSchema)
|
||||
.handler(({ input, context }) => call(router.storage.uploadFile, input.file, { context })),
|
||||
fileDelete: router.storage.deleteFile.route({
|
||||
method: "DELETE",
|
||||
path: "/files",
|
||||
tags: ["Files"],
|
||||
operationId: "deleteStoredFile",
|
||||
}),
|
||||
};
|
||||
@@ -21,3 +21,12 @@ describe("session freshness", () => {
|
||||
expect(auth.options.session?.freshAge).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes the configured native authentication HTTP contract", async () => {
|
||||
const response = await auth.handler(new Request(`${env.APP_URL}/api/auth/open-api/generate-schema`));
|
||||
expect(response.status).toBe(200);
|
||||
const spec = await response.json();
|
||||
expect(spec.paths).toHaveProperty("/sign-in/email");
|
||||
expect(spec.paths).toHaveProperty("/get-session");
|
||||
expect(spec.paths).toHaveProperty("/api-key/create");
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { compare, hash } from "bcryptjs";
|
||||
import { APIError, betterAuth } from "better-auth";
|
||||
import { createAuthMiddleware } from "better-auth/api";
|
||||
import { verifyBearerToken } from "better-auth/oauth2";
|
||||
import { admin, jwt } from "better-auth/plugins";
|
||||
import { admin, jwt, openAPI } from "better-auth/plugins";
|
||||
import { genericOAuth } from "better-auth/plugins/generic-oauth";
|
||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||
import { username } from "better-auth/plugins/username";
|
||||
@@ -303,6 +303,7 @@ const getAuthConfig = () => {
|
||||
},
|
||||
|
||||
plugins: [
|
||||
openAPI({ disableDefaultReference: true }),
|
||||
jwt(),
|
||||
admin(),
|
||||
passkey(),
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"dependencies": {
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"docx": "^9.8.1"
|
||||
"docx": "^9.8.1",
|
||||
"node-html-parser": "^9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { IShadingAttributesProperties } from "docx";
|
||||
import type { HTMLElement, Node } from "node-html-parser";
|
||||
import { ExternalHyperlink, HeadingLevel, Paragraph, TextRun } from "docx";
|
||||
import { parse, NodeType } from "node-html-parser";
|
||||
import { isDarkColor, parseColorString } from "@reactive-resume/utils/color";
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
|
||||
@@ -24,11 +26,11 @@ interface InlineStyle {
|
||||
type InlineChild = TextRun | ExternalHyperlink;
|
||||
|
||||
const preservesWhitespace = (node: Node) => {
|
||||
let ancestor = node.parentElement;
|
||||
let ancestor = node.parentNode;
|
||||
while (ancestor) {
|
||||
if (/^(P|H[1-6])$/.test(ancestor.tagName) && ancestor.getAttribute("data-resume-whitespace") === "preserve")
|
||||
return true;
|
||||
ancestor = ancestor.parentElement;
|
||||
ancestor = ancestor.parentNode;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -52,6 +54,18 @@ function toDocxColorValue(value: string) {
|
||||
return [rgba.r, rgba.g, rgba.b].map((channel) => channel.toString(16).padStart(2, "0").toUpperCase()).join("");
|
||||
}
|
||||
|
||||
function styleProperty(element: HTMLElement | undefined, property: string): string | undefined {
|
||||
return element
|
||||
?.getAttribute("style")
|
||||
?.split(";")
|
||||
.reverse()
|
||||
.map((declaration) => {
|
||||
const colon = declaration.indexOf(":");
|
||||
return [declaration.slice(0, colon).trim().toLowerCase(), declaration.slice(colon + 1).trim()];
|
||||
})
|
||||
.find(([name]) => name === property)?.[1];
|
||||
}
|
||||
|
||||
function mergeStyle(parent: InlineStyle, tag: string, element?: HTMLElement): InlineStyle {
|
||||
const next = { ...parent };
|
||||
|
||||
@@ -75,7 +89,7 @@ function mergeStyle(parent: InlineStyle, tag: string, element?: HTMLElement): In
|
||||
next.font = "Courier New";
|
||||
break;
|
||||
case "MARK": {
|
||||
const bgColor = (element as HTMLElement | undefined)?.style.backgroundColor;
|
||||
const bgColor = styleProperty(element, "background-color");
|
||||
const fill = bgColor ? toDocxColorValue(bgColor) : null;
|
||||
next.shading = { fill: fill ?? "FFFF00" };
|
||||
if (bgColor && isDarkColor(bgColor)) next.color = "FFFFFF";
|
||||
@@ -83,7 +97,7 @@ function mergeStyle(parent: InlineStyle, tag: string, element?: HTMLElement): In
|
||||
}
|
||||
}
|
||||
|
||||
const colorValue = (element as HTMLElement | undefined)?.style.color;
|
||||
const colorValue = styleProperty(element, "color");
|
||||
if (colorValue) {
|
||||
const color = toDocxColorValue(colorValue);
|
||||
if (color) next.color = color;
|
||||
@@ -96,7 +110,7 @@ function collectInlineChildren(node: Node, style: InlineStyle): InlineChild[] {
|
||||
const children: InlineChild[] = [];
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
if (child.nodeType === NodeType.TEXT_NODE) {
|
||||
const text = (child.textContent ?? "").replace(/\t/g, preservesWhitespace(child) ? " " : "\t");
|
||||
if (text) {
|
||||
children.push(new TextRun({ text, ...style }));
|
||||
@@ -104,7 +118,7 @@ function collectInlineChildren(node: Node, style: InlineStyle): InlineChild[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
if (child.nodeType !== NodeType.ELEMENT_NODE) continue;
|
||||
|
||||
const el = child as HTMLElement;
|
||||
const tag = el.tagName;
|
||||
@@ -184,7 +198,7 @@ function processBlockElement(
|
||||
|
||||
if (hasNestedBlocks) {
|
||||
for (const liChild of li.childNodes) {
|
||||
if (liChild.nodeType === Node.TEXT_NODE) {
|
||||
if (liChild.nodeType === NodeType.TEXT_NODE) {
|
||||
const text = (liChild.textContent ?? "").trim();
|
||||
if (text) {
|
||||
paragraphs.push(
|
||||
@@ -195,7 +209,7 @@ function processBlockElement(
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (liChild.nodeType === Node.ELEMENT_NODE) {
|
||||
} else if (liChild.nodeType === NodeType.ELEMENT_NODE) {
|
||||
processBlockElement(liChild as HTMLElement, mergedStyle, paragraphs, level, quoteIndent);
|
||||
}
|
||||
}
|
||||
@@ -217,16 +231,16 @@ function processBlockElement(
|
||||
|
||||
if (tag === "BLOCKQUOTE") {
|
||||
const quoteStyle = { ...mergedStyle, italics: true };
|
||||
const inline = el.ownerDocument.createElement("p");
|
||||
const inline = parse("<p></p>").firstChild as HTMLElement;
|
||||
const flushInline = () => {
|
||||
if (!inline.hasChildNodes()) return;
|
||||
if (inline.childNodes.length === 0) return;
|
||||
processBlockElement(inline, quoteStyle, paragraphs, listLevel, quoteIndent + 720);
|
||||
inline.replaceChildren();
|
||||
inline.set_content([]);
|
||||
};
|
||||
// Keep each semantic paragraph's own offset and preserve adjacent inline
|
||||
// content as one paragraph. Nested quotes add their existing 720-twip inset.
|
||||
for (const child of el.childNodes) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
if (child.nodeType === NodeType.ELEMENT_NODE) {
|
||||
const element = child as HTMLElement;
|
||||
if (HEADING_MAP[element.tagName] || /^(P|DIV|UL|OL|BLOCKQUOTE|PRE|HR)$/.test(element.tagName)) {
|
||||
flushInline();
|
||||
@@ -234,8 +248,9 @@ function processBlockElement(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (child.nodeType === Node.TEXT_NODE && !child.textContent?.trim() && !inline.hasChildNodes()) continue;
|
||||
inline.appendChild(child.cloneNode(true));
|
||||
if (child.nodeType === NodeType.TEXT_NODE && !child.textContent?.trim() && inline.childNodes.length === 0)
|
||||
continue;
|
||||
inline.appendChild(child.clone());
|
||||
}
|
||||
flushInline();
|
||||
return;
|
||||
@@ -281,7 +296,7 @@ function processBlockElement(
|
||||
|
||||
/**
|
||||
* Converts an HTML string (from TipTap rich text editor) into an array of docx Paragraphs.
|
||||
* Uses the browser's DOMParser to parse HTML, then walks the DOM tree to produce
|
||||
* Parses HTML without browser globals, then walks the tree to produce
|
||||
* structured docx content with proper formatting (bold, italic, lists, links, etc.).
|
||||
*
|
||||
* @param html - The HTML string to convert
|
||||
@@ -297,14 +312,13 @@ export function htmlToParagraphs(html: string, styleConfig?: HtmlStyleConfig): P
|
||||
if (styleConfig?.size) baseStyle.size = styleConfig.size;
|
||||
if (styleConfig?.color) baseStyle.color = styleConfig.color;
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const doc = parse(html);
|
||||
// Script and style text is code, not content: drop it before any walk below prints it.
|
||||
for (const element of doc.body.querySelectorAll("script, style")) element.remove();
|
||||
for (const element of doc.querySelectorAll("script, style")) element.remove();
|
||||
const paragraphs: Paragraph[] = [];
|
||||
|
||||
for (const child of doc.body.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
for (const child of doc.childNodes) {
|
||||
if (child.nodeType === NodeType.TEXT_NODE) {
|
||||
const text = (child.textContent ?? "").trim();
|
||||
if (text) {
|
||||
paragraphs.push(new Paragraph({ children: [new TextRun({ text, ...baseStyle })] }));
|
||||
@@ -312,7 +326,7 @@ export function htmlToParagraphs(html: string, styleConfig?: HtmlStyleConfig): P
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
if (child.nodeType === NodeType.ELEMENT_NODE) {
|
||||
processBlockElement(child as HTMLElement, baseStyle, paragraphs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"exports": {
|
||||
"./json-resume": "./src/json-resume.tsx",
|
||||
"./linkedin": "./src/linkedin.ts",
|
||||
"./pdf-lines": "./src/pdf-lines.ts",
|
||||
"./plain-text": "./src/plain-text.ts",
|
||||
"./reactive-resume-json": "./src/reactive-resume-json.tsx",
|
||||
"./reactive-resume-v4-json": "./src/reactive-resume-v4-json.tsx"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ExtractedDocument } from "@reactive-resume/resume/ats-pdf";
|
||||
|
||||
/**
|
||||
* Turns a PDF into the plain lines `parseResumeText` reads.
|
||||
*
|
||||
* The extraction itself is the ATS checker's — same worker configuration, same size and password
|
||||
* guards, same font-relative line clustering and column-gutter detection. Only the joining differs:
|
||||
* the ATS text is prose, while the importer splits header rows on runs of two or more spaces, so a
|
||||
* tabbed "Company Role Location" has to survive as three fields rather than one phrase.
|
||||
*/
|
||||
|
||||
/** Horizontal gap, relative to font size, above which two spans are separate header fields. */
|
||||
const FIELD_GAP_RATIO = 1.5;
|
||||
/** Narrower gaps are still a word break; matches the ratio the ATS extractor joins prose with. */
|
||||
const SPACE_GAP_RATIO = 0.25;
|
||||
|
||||
/** The evidence the ATS layout rules require before calling a page two-column. */
|
||||
const GUTTER_COVERAGE = 0.85;
|
||||
const GUTTER_SPLIT_RATIO = 0.25;
|
||||
|
||||
type PageGeometry = ExtractedDocument["pages"][number];
|
||||
type TextSpan = PageGeometry["lines"][number]["spans"][number];
|
||||
|
||||
function joinSpans(spans: readonly TextSpan[]): string {
|
||||
let text = "";
|
||||
let previous: TextSpan | null = null;
|
||||
|
||||
for (const span of spans) {
|
||||
if (previous) {
|
||||
const gap = span.x - (previous.x + previous.width);
|
||||
const fontSize = Math.max(previous.fontSize, span.fontSize, 1);
|
||||
|
||||
if (gap > FIELD_GAP_RATIO * fontSize) text += " ";
|
||||
else if (gap > SPACE_GAP_RATIO * fontSize && !/\s$/.test(text) && !/^\s/.test(span.text)) text += " ";
|
||||
}
|
||||
|
||||
text += span.text;
|
||||
previous = span;
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
/** Mid-point of a convincing gutter, or null on a page that reads as one column. */
|
||||
function columnSplitX(page: PageGeometry): number | null {
|
||||
const gutter = page.gutter;
|
||||
if (!gutter || gutter.coverage < GUTTER_COVERAGE || gutter.splitRatio < GUTTER_SPLIT_RATIO) return null;
|
||||
|
||||
return gutter.x + gutter.width / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines grouped by baseline alone interleave a sidebar with the body, which drops a whole work
|
||||
* history into the skills section. On a two-column page each column is read out in full instead.
|
||||
*/
|
||||
function pageToLines(page: PageGeometry): string[] {
|
||||
const splitX = columnSplitX(page);
|
||||
if (splitX === null) return page.lines.map((line) => joinSpans(line.spans));
|
||||
|
||||
const left: string[] = [];
|
||||
const right: string[] = [];
|
||||
|
||||
for (const line of page.lines) {
|
||||
const leftSpans = line.spans.filter((span) => span.x + span.width / 2 < splitX);
|
||||
if (leftSpans.length > 0) left.push(joinSpans(leftSpans));
|
||||
|
||||
const rightSpans = line.spans.filter((span) => span.x + span.width / 2 >= splitX);
|
||||
if (rightSpans.length > 0) right.push(joinSpans(rightSpans));
|
||||
}
|
||||
|
||||
return [...left, ...right];
|
||||
}
|
||||
|
||||
export function documentToLines(doc: ExtractedDocument): string[] {
|
||||
return doc.pages.flatMap(pageToLines).filter((line) => line.length > 0);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
|
||||
export type RasterizedPdfPage = {
|
||||
@@ -7,40 +7,40 @@ export type RasterizedPdfPage = {
|
||||
data: Uint8Array;
|
||||
};
|
||||
|
||||
type RasterPage = {
|
||||
getViewport(input: { scale: number }): { width: number; height: number };
|
||||
render(input: unknown): { promise: Promise<unknown> };
|
||||
cleanup(): void;
|
||||
type RasterCanvas = {
|
||||
canvas: HTMLCanvasElement;
|
||||
context: CanvasRenderingContext2D;
|
||||
};
|
||||
|
||||
type RasterDocument = {
|
||||
numPages: number;
|
||||
getPage(pageNumber: number): Promise<RasterPage>;
|
||||
};
|
||||
|
||||
type RasterLoadingTask = {
|
||||
promise: Promise<RasterDocument>;
|
||||
destroy(): Promise<void>;
|
||||
type RasterCanvasFactory = {
|
||||
create(width: number, height: number): RasterCanvas;
|
||||
destroy(canvas: RasterCanvas): void;
|
||||
};
|
||||
|
||||
export async function rasterizePdf(bytes: Uint8Array): Promise<readonly RasterizedPdfPage[]> {
|
||||
const loadingTask = getDocument({ data: bytes }) as unknown as RasterLoadingTask;
|
||||
const loadingTask = getDocument({
|
||||
data: bytes,
|
||||
standardFontDataUrl: fileURLToPath(new URL("standard_fonts/", import.meta.resolve("pdfjs-dist/package.json"))),
|
||||
});
|
||||
const pages: RasterizedPdfPage[] = [];
|
||||
|
||||
try {
|
||||
const document = await loadingTask.promise;
|
||||
// Use PDF.js's canvas implementation: a separately resolved native canvas can reject its Path2D objects.
|
||||
const canvasFactory = document.canvasFactory as RasterCanvasFactory;
|
||||
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
|
||||
const page = await document.getPage(pageNumber);
|
||||
let surface: RasterCanvas | undefined;
|
||||
try {
|
||||
const viewport = page.getViewport({ scale: 1.5 });
|
||||
const width = Math.ceil(viewport.width);
|
||||
const height = Math.ceil(viewport.height);
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
surface = canvasFactory.create(width, height);
|
||||
const { canvas, context } = surface;
|
||||
|
||||
await page.render({
|
||||
canvas: canvas as unknown as HTMLCanvasElement,
|
||||
canvasContext: context as unknown as CanvasRenderingContext2D,
|
||||
canvas,
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
}).promise;
|
||||
pages.push({
|
||||
@@ -49,6 +49,7 @@ export async function rasterizePdf(bytes: Uint8Array): Promise<readonly Rasteriz
|
||||
data: Uint8Array.from(context.getImageData(0, 0, width, height).data),
|
||||
});
|
||||
} finally {
|
||||
if (surface) canvasFactory.destroy(surface);
|
||||
page.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ResumeRenderOptions } from "./context";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
@@ -10,6 +11,7 @@ export type CreateResumePdfFileOptions = {
|
||||
data: ResumeData;
|
||||
filename: string;
|
||||
template?: Template | undefined;
|
||||
renderOptions?: ResumeRenderOptions | undefined;
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
/** Operator-configured app origin; only its public picture upload paths may use private addresses. */
|
||||
uploadOrigin?: string | undefined;
|
||||
|
||||
Generated
+18
-3
@@ -466,6 +466,9 @@ importers:
|
||||
css-tree:
|
||||
specifier: ^3.2.1
|
||||
version: 3.2.1
|
||||
docx:
|
||||
specifier: ^9.8.1
|
||||
version: 9.8.1
|
||||
drizzle-orm:
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.5)
|
||||
@@ -505,6 +508,9 @@ importers:
|
||||
ollama-ai-provider-v2:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1(ai@7.0.124(zod@4.6.5))(zod@4.6.5)
|
||||
pdfjs-dist:
|
||||
specifier: 6.3.289
|
||||
version: 6.3.289
|
||||
pg:
|
||||
specifier: 'catalog:'
|
||||
version: 8.23.1
|
||||
@@ -798,9 +804,6 @@ importers:
|
||||
'@babel/core':
|
||||
specifier: ^7.29.7
|
||||
version: 7.29.7(supports-color@7.2.0)
|
||||
'@lingui/babel-plugin-lingui-macro':
|
||||
specifier: ^6.9.0
|
||||
version: 6.9.0(@babel/core@7.29.7(supports-color@7.2.0))
|
||||
'@lingui/cli':
|
||||
specifier: ^6.9.0
|
||||
version: 6.9.0(babel-plugin-macros@3.1.0)(esbuild@0.28.2)(rolldown@1.2.12)(supports-color@7.2.0)
|
||||
@@ -952,9 +955,15 @@ importers:
|
||||
'@reactive-resume/db':
|
||||
specifier: workspace:*
|
||||
version: link:../db
|
||||
'@reactive-resume/docx':
|
||||
specifier: workspace:*
|
||||
version: link:../docx
|
||||
'@reactive-resume/env':
|
||||
specifier: workspace:*
|
||||
version: link:../env
|
||||
'@reactive-resume/import':
|
||||
specifier: workspace:*
|
||||
version: link:../import
|
||||
'@reactive-resume/pdf':
|
||||
specifier: workspace:*
|
||||
version: link:../pdf
|
||||
@@ -997,6 +1006,9 @@ importers:
|
||||
ollama-ai-provider-v2:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1(ai@7.0.124(zod@4.6.5))(zod@4.6.5)
|
||||
pdfjs-dist:
|
||||
specifier: 6.3.289
|
||||
version: 6.3.289
|
||||
resumable-stream:
|
||||
specifier: ^2.2.13
|
||||
version: 2.2.13
|
||||
@@ -1146,6 +1158,9 @@ importers:
|
||||
docx:
|
||||
specifier: ^9.8.1
|
||||
version: 9.8.1
|
||||
node-html-parser:
|
||||
specifier: ^9.0.4
|
||||
version: 9.0.4
|
||||
devDependencies:
|
||||
'@reactive-resume/config':
|
||||
specifier: workspace:*
|
||||
|
||||
Reference in New Issue
Block a user