mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
refactor(api): drop hand-rolled helpers the platform or one caller already covers
This commit is contained in:
@@ -80,13 +80,5 @@ export async function handleHealth() {
|
||||
console.warn("[Healthcheck]", { route: "/api/health", database, storage });
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
const body = JSON.stringify(checks);
|
||||
headers.set("Content-Type", "application/json; charset=UTF-8");
|
||||
headers.set("Content-Length", Buffer.byteLength(body, "utf-8").toString());
|
||||
|
||||
return new Response(body, {
|
||||
headers,
|
||||
status: checks.status === "unhealthy" ? 503 : 200,
|
||||
});
|
||||
return Response.json(checks, { status: checks.status === "unhealthy" ? 503 : 200 });
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const envMock = vi.hoisted(() => ({
|
||||
S3_SECRET_ACCESS_KEY: "test-secret-key",
|
||||
S3_REGION: "us-east-1",
|
||||
S3_ENDPOINT: "",
|
||||
STORAGE_BACKEND: "s3",
|
||||
S3_BUCKET: "test-bucket",
|
||||
S3_FORCE_PATH_STYLE: true,
|
||||
}));
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"better-auth": "1.7.6",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "^1.52.0",
|
||||
"fflate": "^0.8.3",
|
||||
"ioredis": "^6.0.0",
|
||||
"jsonrepair": "^3.15.0",
|
||||
"ollama-ai-provider-v2": "^4.0.1",
|
||||
@@ -71,6 +71,7 @@
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"es-toolkit": "^1.52.0",
|
||||
"pg": "^8.23.0",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ const resumeServiceMock = {
|
||||
getById: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
patchInTransaction: vi.fn(),
|
||||
notifyResumePatched: vi.fn(),
|
||||
};
|
||||
|
||||
const aiProvidersServiceMock = {
|
||||
|
||||
@@ -59,10 +59,6 @@ vi.mock("../ai/credentials", () => ({
|
||||
assertCredentialEncryptionConfigured: vi.fn(),
|
||||
decryptCredential: vi.fn(() => "decrypted-key"),
|
||||
encryptCredential: vi.fn(),
|
||||
redactEncryptedCredential: vi.fn(() => ({
|
||||
apiKeyFingerprint: "fingerprint",
|
||||
apiKeyPreview: "sk-...test",
|
||||
})),
|
||||
}));
|
||||
vi.mock("../ai/service", () => ({ testConnection: vi.fn() }));
|
||||
vi.mock("../ai/url-policy", () => ({ resolveAiBaseUrl: vi.fn() }));
|
||||
|
||||
@@ -4,12 +4,7 @@ import { and, asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { aiProviderSchema } from "@reactive-resume/ai/types";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
import {
|
||||
assertCredentialEncryptionConfigured,
|
||||
decryptCredential,
|
||||
encryptCredential,
|
||||
redactEncryptedCredential,
|
||||
} from "../ai/credentials";
|
||||
import { assertCredentialEncryptionConfigured, decryptCredential, encryptCredential } from "../ai/credentials";
|
||||
import { testConnection } from "../ai/service";
|
||||
import { resolveAiBaseUrl } from "../ai/url-policy";
|
||||
|
||||
@@ -54,12 +49,6 @@ type UpdateAiProviderInput = {
|
||||
|
||||
function toResponse(row: AiProviderRecord): AiProviderResponse {
|
||||
const provider = aiProviderSchema.parse(row.provider);
|
||||
const { apiKeyFingerprint, apiKeyPreview } = redactEncryptedCredential({
|
||||
encryptedApiKey: row.encryptedApiKey,
|
||||
apiKeySalt: row.apiKeySalt,
|
||||
apiKeyHash: row.apiKeyHash,
|
||||
apiKeyPreview: row.apiKeyPreview,
|
||||
});
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -70,8 +59,9 @@ function toResponse(row: AiProviderRecord): AiProviderResponse {
|
||||
enabled: row.enabled,
|
||||
testStatus: row.testStatus,
|
||||
testError: row.testError,
|
||||
apiKeyPreview,
|
||||
apiKeyFingerprint,
|
||||
apiKeyPreview: row.apiKeyPreview,
|
||||
// The hash identifies the key without revealing it; the ciphertext and salt never leave the server.
|
||||
apiKeyFingerprint: row.apiKeyHash,
|
||||
lastTestedAt: row.lastTestedAt,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
createdAt: row.createdAt,
|
||||
|
||||
@@ -7,14 +7,9 @@ const envMock = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
|
||||
const {
|
||||
assertAgentEnvironment,
|
||||
decryptCredential,
|
||||
encryptCredential,
|
||||
fingerprintCredential,
|
||||
isAgentEnvironmentConfigured,
|
||||
redactEncryptedCredential,
|
||||
} = await import("./credentials");
|
||||
const { assertAgentEnvironment, decryptCredential, encryptCredential, fingerprintCredential } = await import(
|
||||
"./credentials"
|
||||
);
|
||||
|
||||
describe("AI credential encryption", () => {
|
||||
it("encrypts and decrypts provider API keys without storing plaintext", () => {
|
||||
@@ -34,36 +29,16 @@ describe("AI credential encryption", () => {
|
||||
expect(first).not.toBe(differentSalt);
|
||||
expect(first).not.toContain("sk-test-secret");
|
||||
});
|
||||
|
||||
it("redacts stored encrypted credential fields from API responses", () => {
|
||||
const encrypted = encryptCredential("sk-test-secret");
|
||||
|
||||
const redacted = redactEncryptedCredential({
|
||||
encryptedApiKey: encrypted.encryptedApiKey,
|
||||
apiKeySalt: encrypted.apiKeySalt,
|
||||
apiKeyHash: encrypted.apiKeyHash,
|
||||
apiKeyPreview: encrypted.apiKeyPreview,
|
||||
});
|
||||
|
||||
expect(redacted).toEqual({
|
||||
apiKeyFingerprint: encrypted.apiKeyHash,
|
||||
apiKeyPreview: encrypted.apiKeyPreview,
|
||||
});
|
||||
expect(JSON.stringify(redacted)).not.toContain(encrypted.encryptedApiKey);
|
||||
expect(JSON.stringify(redacted)).not.toContain(encrypted.apiKeySalt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI agent environment", () => {
|
||||
it("needs the encryption secret, and works without Redis", () => {
|
||||
expect(isAgentEnvironmentConfigured()).toBe(true);
|
||||
expect(() => assertAgentEnvironment()).not.toThrow();
|
||||
|
||||
envMock.REDIS_URL = "";
|
||||
expect(isAgentEnvironmentConfigured()).toBe(true);
|
||||
expect(() => assertAgentEnvironment()).not.toThrow();
|
||||
|
||||
envMock.ENCRYPTION_SECRET = "";
|
||||
expect(isAgentEnvironmentConfigured()).toBe(false);
|
||||
expect(() => assertAgentEnvironment()).toThrow("AGENT_ENVIRONMENT_UNAVAILABLE");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,11 +13,6 @@ type StoredCredentialFields = {
|
||||
apiKeyPreview: string;
|
||||
};
|
||||
|
||||
type RedactedCredentialFields = {
|
||||
apiKeyFingerprint: string;
|
||||
apiKeyPreview: string;
|
||||
};
|
||||
|
||||
function getEncryptionSecret() {
|
||||
return env.ENCRYPTION_SECRET?.trim() ?? "";
|
||||
}
|
||||
@@ -77,27 +72,16 @@ export function decryptCredential(payload: string) {
|
||||
return Buffer.concat([decipher.update(decode(encodedCiphertext)), decipher.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
export function redactEncryptedCredential(fields: StoredCredentialFields): RedactedCredentialFields {
|
||||
return {
|
||||
apiKeyFingerprint: fields.apiKeyHash,
|
||||
apiKeyPreview: fields.apiKeyPreview,
|
||||
};
|
||||
}
|
||||
|
||||
function isCredentialEncryptionConfigured() {
|
||||
return !!getEncryptionSecret();
|
||||
}
|
||||
|
||||
// The assistant needs stored keys, so ENCRYPTION_SECRET. Redis is optional: with it, replies survive a reload
|
||||
// and Stop reaches a run on another server; without it, both work within one server.
|
||||
export function isAgentEnvironmentConfigured() {
|
||||
return isCredentialEncryptionConfigured();
|
||||
}
|
||||
|
||||
export function assertCredentialEncryptionConfigured() {
|
||||
if (!isCredentialEncryptionConfigured()) throw new Error("AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE");
|
||||
}
|
||||
|
||||
// The assistant needs stored keys, so ENCRYPTION_SECRET. Redis is optional: with it, replies survive a reload
|
||||
// and Stop reaches a run on another server; without it, both work within one server.
|
||||
export function assertAgentEnvironment() {
|
||||
if (!isAgentEnvironmentConfigured()) throw new Error("AGENT_ENVIRONMENT_UNAVAILABLE");
|
||||
if (!isCredentialEncryptionConfigured()) throw new Error("AGENT_ENVIRONMENT_UNAVAILABLE");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AIProvider } from "@reactive-resume/ai/types";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { ModelMessage } from "ai";
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
import { createCerebras } from "@ai-sdk/cerebras";
|
||||
import { createCohere } from "@ai-sdk/cohere";
|
||||
@@ -16,6 +15,7 @@ import { createPerplexity } from "@ai-sdk/perplexity";
|
||||
import { createTogetherAI } from "@ai-sdk/togetherai";
|
||||
import { createXai } from "@ai-sdk/xai";
|
||||
import { APICallError, createGateway, generateText, LoadAPIKeyError, NoSuchModelError } from "ai";
|
||||
import { strFromU8, unzipSync } from "fflate";
|
||||
import { createOllama } from "ollama-ai-provider-v2";
|
||||
import { match } from "ts-pattern";
|
||||
import { z } from "zod";
|
||||
@@ -86,11 +86,6 @@ const TEST_CONNECTION_TIMEOUT_MS = z.coerce
|
||||
.catch(30_000)
|
||||
.parse(process.env.AI_TEST_TIMEOUT_MS?.trim() || undefined);
|
||||
const DOCX_DOCUMENT_XML_PATH = "word/document.xml";
|
||||
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
||||
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
||||
const ZIP_STORED_METHOD = 0;
|
||||
const ZIP_DEFLATED_METHOD = 8;
|
||||
|
||||
export function getModel(input: GetModelInput) {
|
||||
const { provider, model, apiKey } = input;
|
||||
@@ -338,68 +333,16 @@ type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
|
||||
mediaType: "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
};
|
||||
|
||||
function assertZipRange(buffer: Buffer, offset: number, length: number) {
|
||||
if (offset < 0 || length < 0 || offset + length > buffer.length) throw new Error("Invalid DOCX archive.");
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(buffer: Buffer): number {
|
||||
const minOffset = Math.max(0, buffer.length - 0xffff - 22);
|
||||
|
||||
for (let offset = buffer.length - 22; offset >= minOffset; offset--) {
|
||||
if (buffer.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) return offset;
|
||||
function readDocumentXml(data: string): string {
|
||||
let entries: Record<string, Uint8Array>;
|
||||
try {
|
||||
entries = unzipSync(Buffer.from(data, "base64"), { filter: (file) => file.name === DOCX_DOCUMENT_XML_PATH });
|
||||
} catch {
|
||||
throw new Error("Invalid DOCX archive.");
|
||||
}
|
||||
|
||||
throw new Error("Invalid DOCX archive.");
|
||||
}
|
||||
|
||||
function readZipEntry(buffer: Buffer, entryName: string): Buffer {
|
||||
const eocdOffset = findEndOfCentralDirectory(buffer);
|
||||
assertZipRange(buffer, eocdOffset, 22);
|
||||
|
||||
const centralDirectorySize = buffer.readUInt32LE(eocdOffset + 12);
|
||||
const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16);
|
||||
assertZipRange(buffer, centralDirectoryOffset, centralDirectorySize);
|
||||
|
||||
let offset = centralDirectoryOffset;
|
||||
const endOffset = centralDirectoryOffset + centralDirectorySize;
|
||||
|
||||
while (offset < endOffset) {
|
||||
assertZipRange(buffer, offset, 46);
|
||||
if (buffer.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) throw new Error("Invalid DOCX archive.");
|
||||
|
||||
const compressionMethod = buffer.readUInt16LE(offset + 10);
|
||||
const compressedSize = buffer.readUInt32LE(offset + 20);
|
||||
const fileNameLength = buffer.readUInt16LE(offset + 28);
|
||||
const extraFieldLength = buffer.readUInt16LE(offset + 30);
|
||||
const commentLength = buffer.readUInt16LE(offset + 32);
|
||||
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
|
||||
const fileNameOffset = offset + 46;
|
||||
assertZipRange(buffer, fileNameOffset, fileNameLength);
|
||||
|
||||
const fileName = buffer.toString("utf8", fileNameOffset, fileNameOffset + fileNameLength);
|
||||
|
||||
if (fileName === entryName) {
|
||||
assertZipRange(buffer, localHeaderOffset, 30);
|
||||
if (buffer.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
|
||||
throw new Error("Invalid DOCX archive.");
|
||||
}
|
||||
|
||||
const localFileNameLength = buffer.readUInt16LE(localHeaderOffset + 26);
|
||||
const localExtraFieldLength = buffer.readUInt16LE(localHeaderOffset + 28);
|
||||
const dataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraFieldLength;
|
||||
assertZipRange(buffer, dataOffset, compressedSize);
|
||||
|
||||
const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize);
|
||||
if (compressionMethod === ZIP_STORED_METHOD) return compressed;
|
||||
if (compressionMethod === ZIP_DEFLATED_METHOD) return inflateRawSync(compressed);
|
||||
|
||||
throw new Error("Unsupported DOCX archive compression.");
|
||||
}
|
||||
|
||||
offset = fileNameOffset + fileNameLength + extraFieldLength + commentLength;
|
||||
}
|
||||
|
||||
throw new Error("DOCX document content not found.");
|
||||
const xml = entries[DOCX_DOCUMENT_XML_PATH];
|
||||
if (!xml) throw new Error("DOCX document content not found.");
|
||||
return strFromU8(xml);
|
||||
}
|
||||
|
||||
function decodeXmlEntities(value: string): string {
|
||||
@@ -416,7 +359,7 @@ function decodeXmlEntities(value: string): string {
|
||||
}
|
||||
|
||||
function extractDocxText(file: z.infer<typeof fileInputSchema>): string {
|
||||
const documentXml = readZipEntry(Buffer.from(file.data, "base64"), DOCX_DOCUMENT_XML_PATH).toString("utf8");
|
||||
const documentXml = readDocumentXml(file.data);
|
||||
// ponytail: minimal OOXML body-text extraction; add a DOCX parser dependency if tracked changes matter.
|
||||
const text = decodeXmlEntities(
|
||||
documentXml
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { db } from "@reactive-resume/db/client";
|
||||
import type { DbOrTx } from "@reactive-resume/db/client";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
@@ -7,8 +7,6 @@ import { coverLetterSchema } from "@reactive-resume/schema/cover-letter/data";
|
||||
import { sanitizeCoverLetterHtml } from "./html";
|
||||
import { writeLetterVersion } from "./versions";
|
||||
|
||||
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
/**
|
||||
* Letters are documents of their own. Older app versions, API clients, imported files and restored versions can
|
||||
* still hand the server a resume with cover-letter sections; each resume write passes through here first, so those
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DbOrTx } from "@reactive-resume/db/client";
|
||||
import type { CoverLetterVersionData, CoverLetterVersionKind } from "@reactive-resume/db/schema";
|
||||
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
@@ -5,8 +6,6 @@ import { and, desc, eq, inArray, lt, notInArray } from "drizzle-orm";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
|
||||
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
// The same retention as resumes (see resume/version-history.ts): sessions refresh their autosave at most every two
|
||||
// minutes; autosaves and restore markers last 90 days; at most 500 autosaves per letter.
|
||||
const SESSION_REFRESH_MS = 2 * 60 * 1000;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DbOrTx } from "@reactive-resume/db/client";
|
||||
import type { JsonPatchOperation } from "@reactive-resume/resume/patch";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
@@ -5,7 +6,6 @@ import type { ResumeUpdatedEvent } from "./events";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { compare, hash } from "bcrypt";
|
||||
import { and, arrayContains, asc, desc, eq, gte, isNotNull, isNull, sql } from "drizzle-orm";
|
||||
import { get } from "es-toolkit/compat";
|
||||
import { match } from "ts-pattern";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
@@ -30,8 +30,6 @@ import {
|
||||
} from "./version-history";
|
||||
import { clientKeyFromHeaders, shouldCountView } from "./view-dedup";
|
||||
|
||||
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
function resumeVersionConflict(updatedAt: Date) {
|
||||
return new ORPCError("RESUME_VERSION_CONFLICT", {
|
||||
status: 409,
|
||||
@@ -40,14 +38,13 @@ function resumeVersionConflict(updatedAt: Date) {
|
||||
});
|
||||
}
|
||||
|
||||
function invalidPatchOperation(message: string, index?: number, operation?: JsonPatchOperation) {
|
||||
if (index !== undefined && operation !== undefined) {
|
||||
return new ORPCError("INVALID_PATCH_OPERATIONS", { status: 400, message, data: { index, operation } });
|
||||
}
|
||||
|
||||
return new ORPCError("INVALID_PATCH_OPERATIONS", { status: 400, message });
|
||||
function invalidPatchOperation(message: string, index: number, operation: JsonPatchOperation) {
|
||||
return new ORPCError("INVALID_PATCH_OPERATIONS", { status: 400, message, data: { index, operation } });
|
||||
}
|
||||
|
||||
/** The unique constraint a Postgres insert or update broke, if that's why it failed. */
|
||||
const uniqueConstraint = (error: unknown) => (error as { cause?: { constraint?: string } } | null)?.cause?.constraint;
|
||||
|
||||
function isValidJsonPointer(pointer: string): boolean {
|
||||
if (pointer === "") return true;
|
||||
if (!pointer.startsWith("/")) return false;
|
||||
@@ -423,9 +420,7 @@ export const resumeService = {
|
||||
and(
|
||||
eq(schema.resume.userId, input.userId),
|
||||
isNull(schema.resume.trashedAt),
|
||||
match(input.tags.length)
|
||||
.with(0, () => undefined)
|
||||
.otherwise(() => arrayContains(schema.resume.tags, input.tags)),
|
||||
input.tags.length > 0 ? arrayContains(schema.resume.tags, input.tags) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
@@ -568,7 +563,7 @@ export const resumeService = {
|
||||
|
||||
return id;
|
||||
} catch (error) {
|
||||
const constraint = get(error, "cause.constraint") as string | undefined;
|
||||
const constraint = uniqueConstraint(error);
|
||||
|
||||
if (constraint === "resume_slug_user_id_unique") {
|
||||
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
|
||||
@@ -675,7 +670,7 @@ export const resumeService = {
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof ORPCError) throw error;
|
||||
|
||||
if (get(error, "cause.constraint") === "resume_slug_user_id_unique") {
|
||||
if (uniqueConstraint(error) === "resume_slug_user_id_unique") {
|
||||
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
|
||||
}
|
||||
|
||||
@@ -720,16 +715,6 @@ export const resumeService = {
|
||||
|
||||
patchInTransaction: applyResumePatchTx,
|
||||
|
||||
notifyResumePatched: async (input: { resumeId: string; userId: string; updatedAt: Date }) => {
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
resumeId: input.resumeId,
|
||||
userId: input.userId,
|
||||
updatedAt: input.updatedAt.toISOString(),
|
||||
mutation: "patch",
|
||||
});
|
||||
},
|
||||
|
||||
setLocked: async (input: { id: string; userId: string; isLocked: boolean }) => {
|
||||
const [resume] = await db
|
||||
.update(schema.resume)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { DbOrTx } from "@reactive-resume/db/client";
|
||||
import { and, eq, gt, inArray, like, or, sql } from "drizzle-orm";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
import { slugify } from "@reactive-resume/utils/string";
|
||||
|
||||
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
/** Lowercase letters and numbers, in groups joined by single dashes. Only new and changed slugs must match. */
|
||||
export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DbOrTx } from "@reactive-resume/db/client";
|
||||
import type { ResumeVersionKind } from "@reactive-resume/db/schema";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
@@ -6,8 +7,6 @@ import { db } from "@reactive-resume/db/client";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
import { parseStoredResumeData, parseWritableResumeData } from "./resume-data-validation";
|
||||
|
||||
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
// An editing session's autosave is refreshed at most this often.
|
||||
const SESSION_REFRESH_MS = 2 * 60 * 1000;
|
||||
// Autosaves, AI edits and restore markers are kept this long. Named, sent, created, imported and
|
||||
|
||||
@@ -52,17 +52,13 @@ const getCachedCount = async (
|
||||
|
||||
const getCountFromDatabase = async (table: typeof schema.user | typeof schema.resume): Promise<number | null> => {
|
||||
const [result] = await db.select({ count: count() }).from(table);
|
||||
if (!result) return null;
|
||||
return result.count;
|
||||
return result?.count ?? null;
|
||||
};
|
||||
|
||||
const fetchGitHubStarsOnce = async (): Promise<number | null> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), GITHUB_REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(GITHUB_API_URL, {
|
||||
signal: controller.signal,
|
||||
signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS),
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
@@ -74,8 +70,6 @@ const fetchGitHubStarsOnce = async (): Promise<number | null> => {
|
||||
return Number.isFinite(stars) && stars > 0 ? stars : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -330,8 +330,7 @@ export function getStorageService(): StorageService {
|
||||
cachedService ??=
|
||||
env.STORAGE_BACKEND === "blob"
|
||||
? new BlobStorageService()
|
||||
: env.STORAGE_BACKEND === "s3" ||
|
||||
(!env.STORAGE_BACKEND && env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET)
|
||||
: env.STORAGE_BACKEND === "s3"
|
||||
? new S3StorageService()
|
||||
: new LocalStorageService();
|
||||
return cachedService;
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
export function getTrustedOrigins(appUrl: string): string[] {
|
||||
const normalizeOrigin = (origin: string): string => origin.replace(/\/$/, "");
|
||||
const trustedOrigins = new Set<string>(["http://localhost:3000", "http://127.0.0.1:3000"]);
|
||||
|
||||
const configuredUrl = new URL(appUrl);
|
||||
trustedOrigins.add(normalizeOrigin(configuredUrl.origin));
|
||||
trustedOrigins.add(configuredUrl.origin);
|
||||
|
||||
if (configuredUrl.hostname === "localhost" || configuredUrl.hostname === "127.0.0.1") {
|
||||
const loopbackAlias = configuredUrl.hostname === "localhost" ? "127.0.0.1" : "localhost";
|
||||
configuredUrl.hostname = loopbackAlias;
|
||||
trustedOrigins.add(normalizeOrigin(configuredUrl.origin));
|
||||
trustedOrigins.add(configuredUrl.origin);
|
||||
}
|
||||
|
||||
return [...trustedOrigins];
|
||||
|
||||
@@ -36,3 +36,6 @@ export function getPool() {
|
||||
// ponytail: two private fns collapsed; getPool() is already a singleton, global cache preserved
|
||||
globalThis.__drizzle ??= drizzle({ client: getPool() });
|
||||
export const db = globalThis.__drizzle;
|
||||
|
||||
/** The client, or a transaction on it: helpers that write take either, so callers choose the transaction. */
|
||||
export type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
@@ -24,6 +24,8 @@ vi.mock("react-email", () => ({
|
||||
|
||||
const { sendEmail } = await import("./transport");
|
||||
|
||||
const fakeReact = { $$typeof: Symbol.for("react.element") } as unknown as React.ReactElement;
|
||||
|
||||
const resetEnv = () => {
|
||||
envMock.SMTP_HOST = undefined;
|
||||
envMock.SMTP_USER = undefined;
|
||||
@@ -34,16 +36,10 @@ const resetEnv = () => {
|
||||
};
|
||||
|
||||
describe("sendEmail", () => {
|
||||
it("does nothing when neither text nor html is provided", async () => {
|
||||
resetEnv();
|
||||
await sendEmail({ to: "a@b.com", subject: "hi" });
|
||||
expect(sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips sending and logs when SMTP is not configured (no host)", async () => {
|
||||
resetEnv();
|
||||
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
await sendEmail({ to: "a@b.com", subject: "hi", text: "body" });
|
||||
await sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact });
|
||||
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
"SMTP not configured; skipping email send.",
|
||||
@@ -60,7 +56,7 @@ describe("sendEmail", () => {
|
||||
envMock.SMTP_PASS = "pass";
|
||||
envMock.SMTP_FROM = "noreply@example.com";
|
||||
|
||||
await sendEmail({ to: "a@b.com", subject: "hi", text: "body" });
|
||||
await sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact });
|
||||
|
||||
expect(createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -75,24 +71,11 @@ describe("sendEmail", () => {
|
||||
to: "a@b.com",
|
||||
from: "noreply@example.com",
|
||||
subject: "hi",
|
||||
text: "body",
|
||||
text: "plain text body",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to a default 'noreply@localhost' from address when SMTP_FROM is unset", async () => {
|
||||
resetEnv();
|
||||
envMock.SMTP_HOST = "smtp.example.com";
|
||||
envMock.SMTP_USER = "user";
|
||||
envMock.SMTP_PASS = "pass";
|
||||
|
||||
// SMTP not "enabled" without SMTP_FROM — skipping branch — but options.from is used.
|
||||
// Manually provide from in options instead.
|
||||
await sendEmail({ to: "a@b.com", from: "explicit@x.com", subject: "hi", text: "body" });
|
||||
// SMTP isn't enabled, so sendMail isn't called — confirm the info-log branch instead.
|
||||
expect(sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders react element into both html and plain-text bodies", async () => {
|
||||
resetEnv();
|
||||
envMock.SMTP_HOST = "smtp.example.com";
|
||||
@@ -100,7 +83,6 @@ describe("sendEmail", () => {
|
||||
envMock.SMTP_PASS = "pass";
|
||||
envMock.SMTP_FROM = "noreply@example.com";
|
||||
|
||||
const fakeReact = { $$typeof: Symbol.for("react.element") } as unknown as React.ReactElement;
|
||||
await sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact });
|
||||
|
||||
expect(sendMail).toHaveBeenCalledWith(
|
||||
@@ -120,7 +102,7 @@ describe("sendEmail", () => {
|
||||
sendMail.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await expect(sendEmail({ to: "a@b.com", subject: "hi", text: "body" })).resolves.toBeUndefined();
|
||||
await expect(sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact })).resolves.toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -4,14 +4,7 @@ import nodemailer from "nodemailer";
|
||||
import { render } from "react-email";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
type SendEmailOptions = {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
react?: ReactElement;
|
||||
from?: string;
|
||||
};
|
||||
type SendEmailOptions = { to: string; subject: string; react: ReactElement };
|
||||
|
||||
let cachedTransport: Transporter | undefined;
|
||||
|
||||
@@ -29,24 +22,16 @@ const getTransport = () => {
|
||||
return cachedTransport;
|
||||
};
|
||||
|
||||
export const sendEmail = async (options: SendEmailOptions) => {
|
||||
export const sendEmail = async ({ to, subject, react }: SendEmailOptions) => {
|
||||
const transport = getTransport();
|
||||
const from = options.from ?? env.SMTP_FROM ?? "Reactive Resume <noreply@localhost>";
|
||||
const payload: SendMailOptions = {
|
||||
to: options.to,
|
||||
from,
|
||||
subject: options.subject,
|
||||
...(options.text === undefined ? {} : { text: options.text }),
|
||||
...(options.html === undefined ? {} : { html: options.html }),
|
||||
to,
|
||||
from: env.SMTP_FROM,
|
||||
subject,
|
||||
html: await render(react),
|
||||
text: await render(react, { plainText: true }),
|
||||
};
|
||||
|
||||
if (options.react) {
|
||||
payload.html = await render(options.react);
|
||||
payload.text = options.text ?? (await render(options.react, { plainText: true }));
|
||||
}
|
||||
|
||||
if (!payload.text && !payload.html) return;
|
||||
|
||||
if (!transport) {
|
||||
console.info("SMTP not configured; skipping email send.", {
|
||||
to: payload.to,
|
||||
|
||||
+12
-18
@@ -131,7 +131,7 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
|
||||
if (resumes.length === 0) return text(`No resumes found. Use \`${T.createResume}\` to create one.`);
|
||||
|
||||
return text(JSON.stringify(resumes, null, 2));
|
||||
return json(resumes);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -145,7 +145,7 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
|
||||
if (tags.length === 0) return text("No tags in use yet. Add tags when creating or updating a resume.");
|
||||
|
||||
return text(JSON.stringify(tags, null, 2));
|
||||
return json(tags);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -156,7 +156,7 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
withErrorHandling("getting resume", async ({ id }: { id: string }) => {
|
||||
const resume = await client.resume.getById({ id });
|
||||
|
||||
return text(JSON.stringify(resume.data, null, 2));
|
||||
return json(resume.data);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -171,20 +171,14 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
|
||||
const signedUrl = createResumePdfDownloadUrl({ resumeId: id, userId: user.id });
|
||||
|
||||
return text(
|
||||
JSON.stringify(
|
||||
{
|
||||
resumeId: id,
|
||||
name: resume.name,
|
||||
downloadUrl: signedUrl.url,
|
||||
expiresAt: signedUrl.expiresAt,
|
||||
expiresInSeconds: signedUrl.expiresInSeconds,
|
||||
contentType: "application/pdf",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return json({
|
||||
resumeId: id,
|
||||
name: resume.name,
|
||||
downloadUrl: signedUrl.url,
|
||||
expiresAt: signedUrl.expiresAt,
|
||||
expiresInSeconds: signedUrl.expiresInSeconds,
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -360,7 +354,7 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
withErrorHandling("getting resume statistics", async ({ id }: { id: string }) => {
|
||||
const stats = await client.resume.statistics.getById({ id });
|
||||
|
||||
return text(JSON.stringify(stats, null, 2));
|
||||
return json(stats);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Generated
+6
-3
@@ -914,9 +914,9 @@ importers:
|
||||
drizzle-zod:
|
||||
specifier: 1.0.0-beta.14-a36c63d
|
||||
version: 1.0.0-beta.14-a36c63d(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.0)(zod@4.6.5))(zod@4.6.5)
|
||||
es-toolkit:
|
||||
specifier: ^1.52.0
|
||||
version: 1.52.0
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
ioredis:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0(supports-color@7.2.0)
|
||||
@@ -960,6 +960,9 @@ importers:
|
||||
'@typescript/native-preview':
|
||||
specifier: 7.0.0-dev.20260707.2
|
||||
version: 7.0.0-dev.20260707.2
|
||||
es-toolkit:
|
||||
specifier: ^1.52.0
|
||||
version: 1.52.0
|
||||
pg:
|
||||
specifier: ^8.23.0
|
||||
version: 8.23.0
|
||||
|
||||
Reference in New Issue
Block a user