mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 14:52:18 +10:00
refactor: ponytail audit
This commit is contained in:
@@ -88,7 +88,7 @@ export const publicProcedure = base.use(async ({ context, next }) => {
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = publicProcedure.use(async ({ context, next }) => {
|
||||
export const protectedProcedure = publicProcedure.use(({ context, next }) => {
|
||||
if (!context.user) throw new ORPCError("UNAUTHORIZED");
|
||||
|
||||
return next({
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("applicationDto sourceUrl", () => {
|
||||
role: "Engineer",
|
||||
sourceUrl: "javascript:alert(1)",
|
||||
}),
|
||||
).toThrow();
|
||||
).toThrow("URL must use http or https.");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,14 +22,7 @@ const applicationDocumentFileSchema = z
|
||||
const httpUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "URL must use http or https.");
|
||||
.pipe(z.url({ protocol: /^https?$/, error: "URL must use http or https." }));
|
||||
|
||||
const applicationSchema = createSelectSchema(schema.application, {
|
||||
id: z.string().describe("The ID of the application."),
|
||||
|
||||
@@ -14,7 +14,5 @@ export const actionsRouter = {
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await agentService.actions.revert({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context, input }) => agentService.actions.revert({ id: input.id, userId: context.user.id })),
|
||||
};
|
||||
|
||||
@@ -4,10 +4,6 @@ import { storageUploadRateLimit } from "../../middleware/rate-limit";
|
||||
import { mapAgentEnvironmentError } from "./routing";
|
||||
import { agentService } from "./service";
|
||||
|
||||
function base64ToUint8Array(value: string) {
|
||||
return Uint8Array.from(Buffer.from(value, "base64"));
|
||||
}
|
||||
|
||||
export const attachmentsRouter = {
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
@@ -27,15 +23,15 @@ export const attachmentsRouter = {
|
||||
)
|
||||
.use(storageUploadRateLimit)
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await agentService.attachments.create({
|
||||
.handler(({ context, input }) =>
|
||||
agentService.attachments.create({
|
||||
userId: context.user.id,
|
||||
threadId: input.threadId,
|
||||
filename: input.filename,
|
||||
mediaType: input.mediaType,
|
||||
data: base64ToUint8Array(input.data),
|
||||
});
|
||||
}),
|
||||
data: Uint8Array.from(Buffer.from(input.data, "base64")),
|
||||
}),
|
||||
),
|
||||
|
||||
delete: protectedProcedure
|
||||
.route({
|
||||
@@ -48,7 +44,5 @@ export const attachmentsRouter = {
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(async ({ context, input }) => {
|
||||
await agentService.attachments.delete({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context, input }) => agentService.attachments.delete({ id: input.id, userId: context.user.id })),
|
||||
};
|
||||
|
||||
@@ -23,14 +23,14 @@ export const messagesRouter = {
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await agentService.messages.send({
|
||||
.handler(({ context, input }) =>
|
||||
agentService.messages.send({
|
||||
userId: context.user.id,
|
||||
threadId: input.threadId,
|
||||
message: input.message,
|
||||
...(input.attachmentIds ? { attachmentIds: input.attachmentIds } : {}),
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
stop: protectedProcedure
|
||||
.route({
|
||||
@@ -48,13 +48,13 @@ export const messagesRouter = {
|
||||
)
|
||||
.output(z.void())
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(async ({ context, input }) => {
|
||||
await agentService.messages.stop({
|
||||
.handler(({ context, input }) =>
|
||||
agentService.messages.stop({
|
||||
userId: context.user.id,
|
||||
threadId: input.threadId,
|
||||
...(input.partialMessage ? { partialMessage: input.partialMessage } : {}),
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
resume: protectedProcedure
|
||||
.route({
|
||||
@@ -66,7 +66,7 @@ export const messagesRouter = {
|
||||
})
|
||||
.input(z.object({ threadId: z.string() }))
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await agentService.messages.resume({ userId: context.user.id, threadId: input.threadId });
|
||||
}),
|
||||
.handler(({ context, input }) =>
|
||||
agentService.messages.resume({ userId: context.user.id, threadId: input.threadId }),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -332,20 +332,11 @@ function uniqueAttachmentIds(ids: unknown) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Attachment IDs must be unique." });
|
||||
}
|
||||
|
||||
if (unique.size > MAX_ATTACHMENTS_PER_MESSAGE) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Too many attachments for one message." });
|
||||
}
|
||||
|
||||
return Array.from(unique);
|
||||
}
|
||||
|
||||
function normalizeAttachmentIds(ids: unknown) {
|
||||
const unique = uniqueAttachmentIds(ids);
|
||||
return unique;
|
||||
return [...unique];
|
||||
}
|
||||
|
||||
async function getUnlinkedMessageAttachments(input: { ids: unknown; threadId: string; userId: string }) {
|
||||
const ids = normalizeAttachmentIds(input.ids);
|
||||
const ids = uniqueAttachmentIds(input.ids);
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const attachments = await db
|
||||
@@ -406,9 +397,9 @@ async function linkAttachmentsToMessage(input: {
|
||||
}
|
||||
}
|
||||
|
||||
async function readAttachmentModelInputs(attachments: AgentAttachmentRecord[]): Promise<AttachmentModelInput[]> {
|
||||
function readAttachmentModelInputs(attachments: AgentAttachmentRecord[]): Promise<AttachmentModelInput[]> {
|
||||
const storage = getStorageService();
|
||||
const inputs = await Promise.all(
|
||||
return Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
const stored = await storage.read(attachment.storageKey);
|
||||
if (!stored) {
|
||||
@@ -418,8 +409,6 @@ async function readAttachmentModelInputs(attachments: AgentAttachmentRecord[]):
|
||||
return { attachment, data: stored.data };
|
||||
}),
|
||||
);
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
function attachModelPartsToLatestUserMessage(
|
||||
@@ -643,7 +632,7 @@ function buildThreadTitle(message: UIMessage, fallback: string) {
|
||||
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
||||
}
|
||||
|
||||
async function listThreadMessages(input: { threadId: string; userId: string }) {
|
||||
function listThreadMessages(input: { threadId: string; userId: string }) {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.agentMessage)
|
||||
|
||||
@@ -13,9 +13,7 @@ export const threadsRouter = {
|
||||
summary: "List agent threads",
|
||||
})
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(async ({ context }) => {
|
||||
return await agentService.threads.list({ userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context }) => agentService.threads.list({ userId: context.user.id })),
|
||||
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
@@ -27,14 +25,14 @@ export const threadsRouter = {
|
||||
})
|
||||
.input(z.object({ aiProviderId: z.string().optional(), sourceResumeId: z.string().optional() }))
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await agentService.threads.create({
|
||||
.handler(({ context, input }) =>
|
||||
agentService.threads.create({
|
||||
userId: context.user.id,
|
||||
locale: context.locale,
|
||||
...(input.aiProviderId ? { aiProviderId: input.aiProviderId } : {}),
|
||||
...(input.sourceResumeId ? { sourceResumeId: input.sourceResumeId } : {}),
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
getOrCreateForResume: protectedProcedure
|
||||
.route({
|
||||
@@ -46,13 +44,13 @@ export const threadsRouter = {
|
||||
})
|
||||
.input(z.object({ resumeId: z.string(), aiProviderId: z.string().optional() }))
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await agentService.threads.getOrCreateForResume({
|
||||
.handler(({ context, input }) =>
|
||||
agentService.threads.getOrCreateForResume({
|
||||
userId: context.user.id,
|
||||
resumeId: input.resumeId,
|
||||
...(input.aiProviderId ? { aiProviderId: input.aiProviderId } : {}),
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
get: protectedProcedure
|
||||
.route({
|
||||
@@ -64,9 +62,7 @@ export const threadsRouter = {
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await agentService.threads.get({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context, input }) => agentService.threads.get({ id: input.id, userId: context.user.id })),
|
||||
|
||||
archive: protectedProcedure
|
||||
.route({
|
||||
@@ -79,9 +75,7 @@ export const threadsRouter = {
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(async ({ context, input }) => {
|
||||
await agentService.threads.archive({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context, input }) => agentService.threads.archive({ id: input.id, userId: context.user.id })),
|
||||
|
||||
delete: protectedProcedure
|
||||
.route({
|
||||
@@ -94,7 +88,5 @@ export const threadsRouter = {
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.use(mapAgentEnvironmentError)
|
||||
.handler(async ({ context, input }) => {
|
||||
await agentService.threads.delete({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context, input }) => agentService.threads.delete({ id: input.id, userId: context.user.id })),
|
||||
};
|
||||
|
||||
@@ -7,16 +7,6 @@ import { aiRequestRateLimit } from "../../middleware/rate-limit";
|
||||
import { providerInput, updateProviderInput } from "./inputs";
|
||||
import { aiProvidersService } from "./service";
|
||||
|
||||
function isAgentEnvironmentUnavailable(error: unknown) {
|
||||
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
|
||||
}
|
||||
|
||||
function throwUnavailable(): never {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "AI agent workspace is unavailable because REDIS_URL or ENCRYPTION_SECRET is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
function isInvalidAiBaseUrl(error: unknown) {
|
||||
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
|
||||
}
|
||||
@@ -39,14 +29,7 @@ export const aiProvidersRouter = {
|
||||
.errors({
|
||||
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
|
||||
})
|
||||
.handler(async ({ context }) => {
|
||||
try {
|
||||
return await aiProvidersService.list({ userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
.handler(({ context }) => aiProvidersService.list({ userId: context.user.id })),
|
||||
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
@@ -74,7 +57,6 @@ export const aiProvidersRouter = {
|
||||
apiKey: input.apiKey,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
|
||||
throw error;
|
||||
}
|
||||
@@ -110,7 +92,6 @@ export const aiProvidersRouter = {
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
|
||||
throw error;
|
||||
}
|
||||
@@ -130,14 +111,7 @@ export const aiProvidersRouter = {
|
||||
.errors({
|
||||
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
await aiProvidersService.delete({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
.handler(({ context, input }) => aiProvidersService.delete({ id: input.id, userId: context.user.id })),
|
||||
|
||||
test: protectedProcedure
|
||||
.route({
|
||||
@@ -161,7 +135,6 @@ export const aiProvidersRouter = {
|
||||
try {
|
||||
return await aiProvidersService.test({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
|
||||
if (error instanceof ORPCError) throw error;
|
||||
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
|
||||
|
||||
@@ -86,10 +86,6 @@ function normalizeBaseUrl(input: { provider: AIProvider; baseURL?: string | null
|
||||
return resolveAiBaseUrl({ provider: input.provider, baseURL: trimmed });
|
||||
}
|
||||
|
||||
function orderByLastUsedAtDescNullsLast() {
|
||||
return desc(sql<Date>`coalesce(${schema.aiProvider.lastUsedAt}, '1970-01-01T00:00:00.000Z'::timestamptz)`);
|
||||
}
|
||||
|
||||
async function getOwnedProvider(input: { id: string; userId: string }) {
|
||||
const [provider] = await db
|
||||
.select()
|
||||
@@ -110,7 +106,10 @@ export const aiProvidersService = {
|
||||
.select()
|
||||
.from(schema.aiProvider)
|
||||
.where(eq(schema.aiProvider.userId, input.userId))
|
||||
.orderBy(orderByLastUsedAtDescNullsLast(), asc(schema.aiProvider.createdAt));
|
||||
.orderBy(
|
||||
desc(sql<Date>`coalesce(${schema.aiProvider.lastUsedAt}, '1970-01-01T00:00:00.000Z'::timestamptz)`),
|
||||
asc(schema.aiProvider.createdAt),
|
||||
);
|
||||
|
||||
return providers.map(toResponse);
|
||||
},
|
||||
@@ -250,7 +249,7 @@ export const aiProvidersService = {
|
||||
if (!updated) throw new ORPCError("NOT_FOUND");
|
||||
return toResponse(updated);
|
||||
} catch (error) {
|
||||
const [updated] = await db
|
||||
await db
|
||||
.update(schema.aiProvider)
|
||||
.set({
|
||||
enabled: false,
|
||||
@@ -258,10 +257,8 @@ export const aiProvidersService = {
|
||||
testError: error instanceof Error ? error.message : "Failed to test provider.",
|
||||
lastTestedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)))
|
||||
.returning();
|
||||
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)));
|
||||
|
||||
if (!updated) throw error;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -80,9 +80,7 @@ export function supportsOpenAIWebSearch(model: string) {
|
||||
|
||||
if (OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS.has(normalized)) return true;
|
||||
|
||||
return Array.from(OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS).some((modelId) =>
|
||||
isDateSnapshotForModel(normalized, modelId),
|
||||
);
|
||||
return [...OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS].some((modelId) => isDateSnapshotForModel(normalized, modelId));
|
||||
}
|
||||
|
||||
export function supportsProviderNativeWebSearch(provider: AiProviderCapabilityInput) {
|
||||
|
||||
@@ -15,7 +15,7 @@ afterEach(() => {
|
||||
function stubOpenAICompatibleResponse(response?: { content?: string; finishReason?: string }) {
|
||||
let requestBody: unknown;
|
||||
|
||||
const fetchMock = vi.fn(async (_input: unknown, init?: { body?: unknown }) => {
|
||||
const fetchMock = vi.fn((_input: unknown, init?: { body?: unknown }) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { max_tokens?: number };
|
||||
requestBody = body;
|
||||
const hasEnoughOutputTokens = (body.max_tokens ?? 0) >= 128;
|
||||
|
||||
@@ -361,7 +361,7 @@ async function chat(input: ChatInput) {
|
||||
"Return one or more cohesive resume change proposals. Each proposal must include a title, optional summary, and valid JSON Patch operations against the current resume data. The tool validates but does not apply changes.",
|
||||
inputSchema: resumePatchProposalToolInputSchema,
|
||||
outputSchema: resumePatchProposalToolOutputSchema,
|
||||
execute: async (toolInput) => {
|
||||
execute: (toolInput) => {
|
||||
const proposals = normalizeResumePatchProposals(toolInput, input.resumeUpdatedAt);
|
||||
|
||||
for (const proposal of proposals) {
|
||||
|
||||
@@ -348,7 +348,7 @@ async function fetchLinkedInJobPostingText(jobId: string): Promise<string> {
|
||||
// Best-effort fetch + strip of a job posting page. http(s) only, size/time capped.
|
||||
export async function fetchJobPostingText(url: string): Promise<string> {
|
||||
const jobId = linkedInJobId(url);
|
||||
if (jobId) return await fetchLinkedInJobPostingText(jobId);
|
||||
if (jobId) return fetchLinkedInJobPostingText(jobId);
|
||||
if (isLinkedInUrl(url)) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "The LinkedIn job URL must include a job posting ID." });
|
||||
}
|
||||
|
||||
@@ -17,14 +17,14 @@ export const crudRouter = {
|
||||
})
|
||||
.input(applicationDto.list.input)
|
||||
.output(applicationDto.list.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.list({
|
||||
.handler(({ input, context }) =>
|
||||
applicationService.list({
|
||||
userId: context.user.id,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.tags ? { tags: input.tags } : {}),
|
||||
includeArchived: input.includeArchived,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
getById: protectedProcedure
|
||||
.route({
|
||||
@@ -39,9 +39,7 @@ export const crudRouter = {
|
||||
})
|
||||
.input(applicationDto.getById.input)
|
||||
.output(applicationDto.getById.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ input, context }) => applicationService.getById({ id: input.id, userId: context.user.id })),
|
||||
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
@@ -57,9 +55,7 @@ export const crudRouter = {
|
||||
.input(applicationDto.create.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.create.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.create({ userId: context.user.id, ...input });
|
||||
}),
|
||||
.handler(({ input, context }) => applicationService.create({ userId: context.user.id, ...input })),
|
||||
|
||||
import: protectedProcedure
|
||||
.route({
|
||||
@@ -75,9 +71,7 @@ export const crudRouter = {
|
||||
.input(applicationDto.import.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.import.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.importMany({ userId: context.user.id, items: input.items });
|
||||
}),
|
||||
.handler(({ input, context }) => applicationService.importMany({ userId: context.user.id, items: input.items })),
|
||||
|
||||
update: protectedProcedure
|
||||
.route({
|
||||
@@ -93,9 +87,7 @@ export const crudRouter = {
|
||||
.input(applicationDto.update.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.update.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.update({ userId: context.user.id, ...input });
|
||||
}),
|
||||
.handler(({ input, context }) => applicationService.update({ userId: context.user.id, ...input })),
|
||||
|
||||
attachDocument: protectedProcedure
|
||||
.route({
|
||||
@@ -153,9 +145,9 @@ export const crudRouter = {
|
||||
.input(applicationDto.removeDocument.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.removeDocument.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.removeDocument({ id: input.id, userId: context.user.id, kind: input.kind });
|
||||
}),
|
||||
.handler(({ input, context }) =>
|
||||
applicationService.removeDocument({ id: input.id, userId: context.user.id, kind: input.kind }),
|
||||
),
|
||||
|
||||
addNote: protectedProcedure
|
||||
.route({
|
||||
@@ -170,9 +162,14 @@ export const crudRouter = {
|
||||
.input(applicationDto.addNote.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.addNote.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.addNote({ id: input.id, userId: context.user.id, text: input.text, date: input.date });
|
||||
}),
|
||||
.handler(({ input, context }) =>
|
||||
applicationService.addNote({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
text: input.text,
|
||||
date: input.date,
|
||||
}),
|
||||
),
|
||||
|
||||
updateTimelineEntry: protectedProcedure
|
||||
.route({
|
||||
@@ -187,9 +184,7 @@ export const crudRouter = {
|
||||
.input(applicationDto.updateTimelineEntry.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.updateTimelineEntry.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.updateTimelineEntry({ ...input, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ input, context }) => applicationService.updateTimelineEntry({ ...input, userId: context.user.id })),
|
||||
|
||||
deleteTimelineEntry: protectedProcedure
|
||||
.route({
|
||||
@@ -205,9 +200,7 @@ export const crudRouter = {
|
||||
.input(applicationDto.deleteTimelineEntry.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.deleteTimelineEntry.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.deleteTimelineEntry({ ...input, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ input, context }) => applicationService.deleteTimelineEntry({ ...input, userId: context.user.id })),
|
||||
|
||||
delete: protectedProcedure
|
||||
.route({
|
||||
@@ -222,9 +215,7 @@ export const crudRouter = {
|
||||
.input(applicationDto.delete.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.delete.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.delete({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ input, context }) => applicationService.delete({ id: input.id, userId: context.user.id })),
|
||||
|
||||
bulkUpdate: protectedProcedure
|
||||
.route({
|
||||
@@ -240,9 +231,7 @@ export const crudRouter = {
|
||||
.input(applicationDto.bulkUpdate.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.bulkUpdate.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.bulkUpdate({ userId: context.user.id, ...input });
|
||||
}),
|
||||
.handler(({ input, context }) => applicationService.bulkUpdate({ userId: context.user.id, ...input })),
|
||||
|
||||
bulkDelete: protectedProcedure
|
||||
.route({
|
||||
@@ -257,9 +246,7 @@ export const crudRouter = {
|
||||
.input(applicationDto.bulkDelete.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(applicationDto.bulkDelete.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.bulkDelete({ userId: context.user.id, ids: input.ids });
|
||||
}),
|
||||
.handler(({ input, context }) => applicationService.bulkDelete({ userId: context.user.id, ids: input.ids })),
|
||||
|
||||
stats: protectedProcedure
|
||||
.route({
|
||||
@@ -273,9 +260,7 @@ export const crudRouter = {
|
||||
})
|
||||
.input(applicationDto.stats.input)
|
||||
.output(applicationDto.stats.output)
|
||||
.handler(async ({ context }) => {
|
||||
return applicationService.stats({ userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context }) => applicationService.stats({ userId: context.user.id })),
|
||||
|
||||
tags: protectedProcedure
|
||||
.route({
|
||||
@@ -288,7 +273,5 @@ export const crudRouter = {
|
||||
successDescription: "Distinct tags.",
|
||||
})
|
||||
.output(applicationDto.tags.output)
|
||||
.handler(async ({ context }) => {
|
||||
return applicationService.listTags({ userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context }) => applicationService.listTags({ userId: context.user.id })),
|
||||
};
|
||||
|
||||
@@ -47,16 +47,12 @@ function noteEntry(text: string, date?: string): ApplicationTimelineEntry {
|
||||
return { id: generateId(), type: "note", text, at: date ? atFromDateString(date) : new Date() };
|
||||
}
|
||||
|
||||
function byNewest(a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) {
|
||||
return new Date(b.at).getTime() - new Date(a.at).getTime();
|
||||
}
|
||||
|
||||
function timelineDay(value: Date | string) {
|
||||
return timelineDate(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function sortTimeline(activity: ApplicationTimelineEntry[]): ApplicationTimelineEntry[] {
|
||||
return [...activity].sort(byNewest);
|
||||
return [...activity].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
|
||||
}
|
||||
|
||||
function currentStageAnchor(activity: ApplicationTimelineEntry[], status: ApplicationStatus) {
|
||||
@@ -423,7 +419,7 @@ export const applicationService = {
|
||||
return stripUserId(updated);
|
||||
},
|
||||
|
||||
updateTimelineEntry: async (input: {
|
||||
updateTimelineEntry: (input: {
|
||||
id: string;
|
||||
userId: string;
|
||||
entryId: string;
|
||||
@@ -474,7 +470,7 @@ export const applicationService = {
|
||||
});
|
||||
},
|
||||
|
||||
deleteTimelineEntry: async (input: { id: string; userId: string; entryId: string }) => {
|
||||
deleteTimelineEntry: (input: { id: string; userId: string; entryId: string }) => {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select 1 from ${schema.application}
|
||||
|
||||
@@ -15,9 +15,7 @@ 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 => {
|
||||
return authService.providers.list();
|
||||
}),
|
||||
.handler((): ProviderList => authService.providers.list()),
|
||||
},
|
||||
|
||||
exportData: protectedProcedure
|
||||
@@ -31,9 +29,7 @@ export const authRouter = {
|
||||
"Returns a JSON-serializable export of the authenticated user's data, including their public profile fields and all of their resumes. Secrets such as password hashes, tokens, and API keys are never included. Requires authentication.",
|
||||
successDescription: "The user's exported account data.",
|
||||
})
|
||||
.handler(async ({ context }) => {
|
||||
return await authService.exportData({ userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context }) => authService.exportData({ userId: context.user.id })),
|
||||
|
||||
deleteAccount: protectedProcedure
|
||||
.route({
|
||||
@@ -46,7 +42,5 @@ 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.",
|
||||
})
|
||||
.handler(async ({ context }): Promise<void> => {
|
||||
return await authService.deleteAccount({ userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context }) => authService.deleteAccount({ userId: context.user.id })),
|
||||
};
|
||||
|
||||
@@ -17,7 +17,5 @@ export const analysisRouter = {
|
||||
})
|
||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
|
||||
.output(storedResumeAnalysisSchema.nullable())
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.analysis.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context, input }) => resumeService.analysis.getById({ id: input.id, userId: context.user.id })),
|
||||
};
|
||||
|
||||
@@ -19,13 +19,13 @@ export const crudRouter = {
|
||||
})
|
||||
.input(resumeDto.list.input.optional().default({ tags: [], sort: "lastUpdatedAt" }))
|
||||
.output(resumeDto.list.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return resumeService.list({
|
||||
.handler(({ input, context }) =>
|
||||
resumeService.list({
|
||||
userId: context.user.id,
|
||||
tags: input.tags,
|
||||
sort: input.sort,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
getById: protectedProcedure
|
||||
.route({
|
||||
@@ -40,9 +40,7 @@ export const crudRouter = {
|
||||
})
|
||||
.input(resumeDto.getById.input)
|
||||
.output(resumeDto.getById.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context, input }) => resumeService.getById({ id: input.id, userId: context.user.id })),
|
||||
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
@@ -64,16 +62,16 @@ export const crudRouter = {
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.create({
|
||||
.handler(({ context, input }) =>
|
||||
resumeService.create({
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
tags: input.tags,
|
||||
locale: context.locale,
|
||||
userId: context.user.id,
|
||||
...(input.withSampleData ? { data: createSampleResumeData(input.name) } : {}),
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
import: protectedProcedure
|
||||
.route({
|
||||
@@ -139,8 +137,8 @@ export const crudRouter = {
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.update({
|
||||
.handler(({ context, input }) =>
|
||||
resumeService.update({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
@@ -148,8 +146,8 @@ export const crudRouter = {
|
||||
...(input.tags !== undefined ? { tags: input.tags } : {}),
|
||||
...(input.data !== undefined ? { data: input.data } : {}),
|
||||
...(input.isPublic !== undefined ? { isPublic: input.isPublic } : {}),
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
patch: protectedProcedure
|
||||
.route({
|
||||
@@ -175,14 +173,14 @@ export const crudRouter = {
|
||||
status: 409,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.patch({
|
||||
.handler(({ context, input }) =>
|
||||
resumeService.patch({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
operations: input.operations,
|
||||
...(input.expectedUpdatedAt ? { expectedUpdatedAt: input.expectedUpdatedAt } : {}),
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
setLocked: protectedProcedure
|
||||
.route({
|
||||
@@ -198,13 +196,13 @@ export const crudRouter = {
|
||||
.input(resumeDto.setLocked.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.setLocked.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.setLocked({
|
||||
.handler(({ context, input }) =>
|
||||
resumeService.setLocked({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
isLocked: input.isLocked,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
duplicate: protectedProcedure
|
||||
.route({
|
||||
@@ -247,7 +245,5 @@ export const crudRouter = {
|
||||
.input(resumeDto.delete.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.delete.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.delete({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context, input }) => resumeService.delete({ id: input.id, userId: context.user.id })),
|
||||
};
|
||||
|
||||
@@ -75,10 +75,10 @@ export const downloadResumePdfProcedure = protectedProcedure
|
||||
}),
|
||||
)
|
||||
.use(pdfExportRateLimit)
|
||||
.handler(async ({ context, input }) => {
|
||||
return createResumePdfDownload({
|
||||
.handler(({ context, input }) =>
|
||||
createResumePdfDownload({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
...(input.target ? { target: input.target } : {}),
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -165,10 +165,7 @@ const tags = {
|
||||
.from(schema.resume)
|
||||
.where(eq(schema.resume.userId, input.userId));
|
||||
|
||||
const uniqueTags = new Set(result.flatMap((tag) => tag.tags));
|
||||
const sortedTags = Array.from(uniqueTags).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return sortedTags;
|
||||
return [...new Set(result.flatMap((tag) => tag.tags))].sort((a, b) => a.localeCompare(b));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -422,8 +419,8 @@ export const resumeService = {
|
||||
},
|
||||
},
|
||||
|
||||
list: async (input: { userId: string; tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) => {
|
||||
return await db
|
||||
list: (input: { userId: string; tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) =>
|
||||
db
|
||||
.select({
|
||||
id: schema.resume.id,
|
||||
name: schema.resume.name,
|
||||
@@ -449,8 +446,7 @@ export const resumeService = {
|
||||
.with("createdAt", () => asc(schema.resume.createdAt))
|
||||
.with("name", () => asc(schema.resume.name))
|
||||
.exhaustive(),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
getById: async (input: { id: string; userId: string }) => {
|
||||
const [resume] = await db
|
||||
|
||||
@@ -18,13 +18,13 @@ export const sharingRouter = {
|
||||
})
|
||||
.input(resumeDto.getBySlug.input)
|
||||
.output(resumeDto.getBySlug.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return resumeService.getBySlug({
|
||||
.handler(({ input, context }) =>
|
||||
resumeService.getBySlug({
|
||||
...input,
|
||||
requestHeaders: context.reqHeaders,
|
||||
...(context.user?.id ? { currentUserId: context.user.id } : {}),
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
setPassword: protectedProcedure
|
||||
.route({
|
||||
@@ -40,13 +40,13 @@ export const sharingRouter = {
|
||||
.input(resumeDto.setPassword.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.setPassword.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.setPassword({
|
||||
.handler(({ context, input }) =>
|
||||
resumeService.setPassword({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
password: input.password,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
verifyPassword: publicProcedure
|
||||
.route({
|
||||
@@ -68,14 +68,15 @@ export const sharingRouter = {
|
||||
)
|
||||
.use(resumePasswordRateLimit)
|
||||
.output(z.boolean())
|
||||
.handler(async ({ context, input }): Promise<boolean> => {
|
||||
return resumeService.verifyPassword({
|
||||
username: input.username,
|
||||
slug: input.slug,
|
||||
password: input.password,
|
||||
...(context.resHeaders ? { responseHeaders: context.resHeaders } : {}),
|
||||
});
|
||||
}),
|
||||
.handler(
|
||||
({ context, input }): Promise<boolean> =>
|
||||
resumeService.verifyPassword({
|
||||
username: input.username,
|
||||
slug: input.slug,
|
||||
password: input.password,
|
||||
...(context.resHeaders ? { responseHeaders: context.resHeaders } : {}),
|
||||
}),
|
||||
),
|
||||
|
||||
removePassword: protectedProcedure
|
||||
.route({
|
||||
@@ -91,10 +92,10 @@ export const sharingRouter = {
|
||||
.input(resumeDto.removePassword.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.removePassword.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.removePassword({
|
||||
.handler(({ context, input }) =>
|
||||
resumeService.removePassword({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -24,9 +24,7 @@ export const resumeStatisticsRouter = {
|
||||
lastDownloadedAt: z.date().nullable().describe("Timestamp of the last download, or null if never downloaded."),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.statistics.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context, input }) => resumeService.statistics.getById({ id: input.id, userId: context.user.id })),
|
||||
|
||||
getDailyById: protectedProcedure
|
||||
.route({
|
||||
@@ -54,7 +52,11 @@ export const resumeStatisticsRouter = {
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.statistics.getDailySeries({ id: input.id, userId: context.user.id, days: input.days });
|
||||
}),
|
||||
.handler(({ context, input }) =>
|
||||
resumeService.statistics.getDailySeries({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
days: input.days,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -15,7 +15,5 @@ export const tagsRouter = {
|
||||
successDescription: "A sorted array of unique tag strings.",
|
||||
})
|
||||
.output(z.array(z.string()))
|
||||
.handler(async ({ context }) => {
|
||||
return resumeService.tags.list({ userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context }) => resumeService.tags.list({ userId: context.user.id })),
|
||||
};
|
||||
|
||||
@@ -17,9 +17,9 @@ export const versionsRouter = {
|
||||
})
|
||||
.input(resumeDto.listVersions.input)
|
||||
.output(resumeDto.listVersions.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.versions.list({ resumeId: input.resumeId, userId: context.user.id });
|
||||
}),
|
||||
.handler(({ context, input }) =>
|
||||
resumeService.versions.list({ resumeId: input.resumeId, userId: context.user.id }),
|
||||
),
|
||||
|
||||
restoreVersion: protectedProcedure
|
||||
.route({
|
||||
@@ -35,11 +35,11 @@ export const versionsRouter = {
|
||||
.input(resumeDto.restoreVersion.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.restoreVersion.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.versions.restore({
|
||||
.handler(({ context, input }) =>
|
||||
resumeService.versions.restore({
|
||||
resumeId: input.resumeId,
|
||||
versionId: input.versionId,
|
||||
userId: context.user.id,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -15,9 +15,7 @@ const userRouter = {
|
||||
successDescription: "The total number of registered users.",
|
||||
})
|
||||
.output(z.number().describe("The total number of registered users."))
|
||||
.handler(async (): Promise<number> => {
|
||||
return await statisticsService.user.getCount();
|
||||
}),
|
||||
.handler(() => statisticsService.user.getCount()),
|
||||
};
|
||||
|
||||
const resumeRouter = {
|
||||
@@ -33,9 +31,7 @@ const resumeRouter = {
|
||||
successDescription: "The total number of resumes created.",
|
||||
})
|
||||
.output(z.number().describe("The total number of resumes created."))
|
||||
.handler(async (): Promise<number> => {
|
||||
return await statisticsService.resume.getCount();
|
||||
}),
|
||||
.handler(() => statisticsService.resume.getCount()),
|
||||
};
|
||||
|
||||
const githubRouter = {
|
||||
@@ -51,9 +47,7 @@ const githubRouter = {
|
||||
successDescription: "The number of GitHub stars for the Reactive Resume repository.",
|
||||
})
|
||||
.output(z.number().describe("The number of GitHub stars."))
|
||||
.handler(async (): Promise<number> => {
|
||||
return await statisticsService.github.getStarCount();
|
||||
}),
|
||||
.handler(() => statisticsService.github.getStarCount()),
|
||||
};
|
||||
|
||||
export const statisticsRouter = {
|
||||
|
||||
@@ -321,20 +321,13 @@ class S3StorageService implements StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
function createStorageService(): StorageService {
|
||||
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) {
|
||||
return new S3StorageService();
|
||||
}
|
||||
|
||||
return new LocalStorageService();
|
||||
}
|
||||
|
||||
let cachedService: StorageService | null = null;
|
||||
|
||||
export function getStorageService(): StorageService {
|
||||
if (cachedService) return cachedService;
|
||||
|
||||
cachedService = createStorageService();
|
||||
cachedService ??=
|
||||
env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET
|
||||
? new S3StorageService()
|
||||
: new LocalStorageService();
|
||||
return cachedService;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,16 +28,16 @@ import { getTrustedOrigins } from "./trusted-origins";
|
||||
const authBaseUrl = env.APP_URL;
|
||||
const isRateLimitEnabled = process.env.NODE_ENV === "production" && !env.FLAG_DISABLE_API_RATE_LIMIT;
|
||||
|
||||
function getOAuthAudiences(): string[] {
|
||||
const base = authBaseUrl.replace(/\/$/, "");
|
||||
const oauthAudienceBase = authBaseUrl.replace(/\/$/, "");
|
||||
const OAUTH_AUDIENCES = [
|
||||
oauthAudienceBase,
|
||||
`${oauthAudienceBase}/`,
|
||||
`${oauthAudienceBase}/mcp`,
|
||||
`${oauthAudienceBase}/mcp/`,
|
||||
];
|
||||
|
||||
return [base, `${base}/`, `${base}/mcp`, `${base}/mcp/`];
|
||||
}
|
||||
|
||||
const OAUTH_AUDIENCES = getOAuthAudiences();
|
||||
|
||||
export async function verifyOAuthToken(token: string): Promise<JWTPayload> {
|
||||
return await verifyAccessToken(token, {
|
||||
export function verifyOAuthToken(token: string): Promise<JWTPayload> {
|
||||
return verifyAccessToken(token, {
|
||||
jwksUrl: `${authBaseUrl}/api/auth/jwks`,
|
||||
verifyOptions: {
|
||||
issuer: `${authBaseUrl}/api/auth`,
|
||||
@@ -105,6 +105,7 @@ const getAuthConfig = () => {
|
||||
},
|
||||
|
||||
hooks: {
|
||||
// biome-ignore lint/suspicious/useAwait: Better Auth requires middleware callbacks to return a Promise.
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
if (!ctx.path.includes("/oauth2/register")) return;
|
||||
|
||||
|
||||
@@ -11,5 +11,5 @@ export function getTrustedOrigins(appUrl: string): string[] {
|
||||
trustedOrigins.add(normalizeOrigin(configuredUrl.origin));
|
||||
}
|
||||
|
||||
return Array.from(trustedOrigins);
|
||||
return [...trustedOrigins];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { relations } from "./relations";
|
||||
|
||||
declare global {
|
||||
var __pool: Pool | undefined;
|
||||
var __drizzle: NodePgDatabase<typeof relations> | undefined;
|
||||
var __drizzle: ReturnType<typeof drizzle> | undefined;
|
||||
}
|
||||
|
||||
export function getPool() {
|
||||
@@ -31,5 +29,5 @@ export function getPool() {
|
||||
}
|
||||
|
||||
// ponytail: two private fns collapsed; getPool() is already a singleton, global cache preserved
|
||||
globalThis.__drizzle ??= drizzle({ client: getPool(), relations });
|
||||
globalThis.__drizzle ??= drizzle({ client: getPool() });
|
||||
export const db = globalThis.__drizzle;
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { defineRelations } from "drizzle-orm";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export const relations = defineRelations(schema, (r) => ({
|
||||
user: {
|
||||
sessions: r.many.session(),
|
||||
accounts: r.many.account(),
|
||||
twoFactors: r.many.twoFactor(),
|
||||
passkeys: r.many.passkey(),
|
||||
resumes: r.many.resume(),
|
||||
aiProviders: r.many.aiProvider(),
|
||||
agentThreads: r.many.agentThread(),
|
||||
agentMessages: r.many.agentMessage(),
|
||||
agentAttachments: r.many.agentAttachment(),
|
||||
agentActions: r.many.agentAction(),
|
||||
apiKeys: r.many.apikey(),
|
||||
oauthClients: r.many.oauthClient(),
|
||||
oauthRefreshTokens: r.many.oauthRefreshToken(),
|
||||
oauthAccessTokens: r.many.oauthAccessToken(),
|
||||
oauthConsents: r.many.oauthConsent(),
|
||||
},
|
||||
session: {
|
||||
user: r.one.user({
|
||||
from: r.session.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
oauthRefreshTokens: r.many.oauthRefreshToken({
|
||||
from: r.session.id,
|
||||
to: r.oauthRefreshToken.sessionId,
|
||||
}),
|
||||
oauthAccessTokens: r.many.oauthAccessToken({
|
||||
from: r.session.id,
|
||||
to: r.oauthAccessToken.sessionId,
|
||||
}),
|
||||
},
|
||||
account: {
|
||||
user: r.one.user({
|
||||
from: r.account.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
twoFactor: {
|
||||
user: r.one.user({
|
||||
from: r.twoFactor.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
passkey: {
|
||||
user: r.one.user({
|
||||
from: r.passkey.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
resume: {
|
||||
user: r.one.user({
|
||||
from: r.resume.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
statistics: r.one.resumeStatistics({
|
||||
from: r.resume.id,
|
||||
to: r.resumeStatistics.resumeId,
|
||||
}),
|
||||
analysis: r.one.resumeAnalysis({
|
||||
from: r.resume.id,
|
||||
to: r.resumeAnalysis.resumeId,
|
||||
}),
|
||||
sourceAgentThreads: r.many.agentThread({
|
||||
from: r.resume.id,
|
||||
to: r.agentThread.sourceResumeId,
|
||||
}),
|
||||
workingAgentThreads: r.many.agentThread({
|
||||
from: r.resume.id,
|
||||
to: r.agentThread.workingResumeId,
|
||||
}),
|
||||
agentActions: r.many.agentAction({
|
||||
from: r.resume.id,
|
||||
to: r.agentAction.resumeId,
|
||||
}),
|
||||
},
|
||||
resumeStatistics: {
|
||||
resume: r.one.resume({
|
||||
from: r.resumeStatistics.resumeId,
|
||||
to: r.resume.id,
|
||||
}),
|
||||
},
|
||||
resumeAnalysis: {
|
||||
resume: r.one.resume({
|
||||
from: r.resumeAnalysis.resumeId,
|
||||
to: r.resume.id,
|
||||
}),
|
||||
},
|
||||
aiProvider: {
|
||||
user: r.one.user({
|
||||
from: r.aiProvider.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
threads: r.many.agentThread({
|
||||
from: r.aiProvider.id,
|
||||
to: r.agentThread.aiProviderId,
|
||||
}),
|
||||
},
|
||||
agentThread: {
|
||||
user: r.one.user({
|
||||
from: r.agentThread.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
aiProvider: r.one.aiProvider({
|
||||
from: r.agentThread.aiProviderId,
|
||||
to: r.aiProvider.id,
|
||||
}),
|
||||
sourceResume: r.one.resume({
|
||||
from: r.agentThread.sourceResumeId,
|
||||
to: r.resume.id,
|
||||
}),
|
||||
workingResume: r.one.resume({
|
||||
from: r.agentThread.workingResumeId,
|
||||
to: r.resume.id,
|
||||
}),
|
||||
messages: r.many.agentMessage(),
|
||||
attachments: r.many.agentAttachment(),
|
||||
actions: r.many.agentAction(),
|
||||
},
|
||||
agentMessage: {
|
||||
user: r.one.user({
|
||||
from: r.agentMessage.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
thread: r.one.agentThread({
|
||||
from: r.agentMessage.threadId,
|
||||
to: r.agentThread.id,
|
||||
}),
|
||||
attachments: r.many.agentAttachment(),
|
||||
actions: r.many.agentAction(),
|
||||
},
|
||||
agentAttachment: {
|
||||
user: r.one.user({
|
||||
from: r.agentAttachment.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
thread: r.one.agentThread({
|
||||
from: r.agentAttachment.threadId,
|
||||
to: r.agentThread.id,
|
||||
}),
|
||||
message: r.one.agentMessage({
|
||||
from: r.agentAttachment.messageId,
|
||||
to: r.agentMessage.id,
|
||||
}),
|
||||
},
|
||||
agentAction: {
|
||||
user: r.one.user({
|
||||
from: r.agentAction.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
thread: r.one.agentThread({
|
||||
from: r.agentAction.threadId,
|
||||
to: r.agentThread.id,
|
||||
}),
|
||||
message: r.one.agentMessage({
|
||||
from: r.agentAction.messageId,
|
||||
to: r.agentMessage.id,
|
||||
}),
|
||||
resume: r.one.resume({
|
||||
from: r.agentAction.resumeId,
|
||||
to: r.resume.id,
|
||||
}),
|
||||
},
|
||||
apikey: {
|
||||
user: r.one.user({
|
||||
from: r.apikey.referenceId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
oauthClient: {
|
||||
user: r.one.user({
|
||||
from: r.oauthClient.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
oauthRefreshTokens: r.many.oauthRefreshToken({
|
||||
from: r.oauthClient.clientId,
|
||||
to: r.oauthRefreshToken.clientId,
|
||||
}),
|
||||
oauthAccessTokens: r.many.oauthAccessToken({
|
||||
from: r.oauthClient.clientId,
|
||||
to: r.oauthAccessToken.clientId,
|
||||
}),
|
||||
oauthConsents: r.many.oauthConsent({
|
||||
from: r.oauthClient.clientId,
|
||||
to: r.oauthConsent.clientId,
|
||||
}),
|
||||
},
|
||||
oauthRefreshToken: {
|
||||
user: r.one.user({
|
||||
from: r.oauthRefreshToken.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
session: r.one.session({
|
||||
from: r.oauthRefreshToken.sessionId,
|
||||
to: r.session.id,
|
||||
}),
|
||||
},
|
||||
oauthAccessToken: {
|
||||
user: r.one.user({
|
||||
from: r.oauthAccessToken.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
session: r.one.session({
|
||||
from: r.oauthAccessToken.sessionId,
|
||||
to: r.session.id,
|
||||
}),
|
||||
refreshToken: r.one.oauthRefreshToken({
|
||||
from: r.oauthAccessToken.refreshId,
|
||||
to: r.oauthRefreshToken.id,
|
||||
}),
|
||||
},
|
||||
oauthConsent: {
|
||||
user: r.one.user({
|
||||
from: r.oauthConsent.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -43,27 +43,9 @@ function ptToTwips(pt: number): number {
|
||||
return Math.round(pt * 20);
|
||||
}
|
||||
|
||||
// --- Page size constants (in mm) ---
|
||||
|
||||
interface PageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE: PageSize = { width: 210, height: 297 };
|
||||
|
||||
const PAGE_SIZES = {
|
||||
a4: DEFAULT_PAGE_SIZE,
|
||||
letter: { width: 215.9, height: 279.4 },
|
||||
} satisfies Record<string, PageSize>;
|
||||
|
||||
type DocxPageFormat = keyof typeof PAGE_SIZES;
|
||||
|
||||
const resolveDocxPageFormat = (format: ResumeData["metadata"]["page"]["format"]): DocxPageFormat => {
|
||||
if (format === "letter") return "letter";
|
||||
|
||||
return "a4";
|
||||
};
|
||||
// DOCX has fixed pages; free-form resumes intentionally fall back to A4.
|
||||
const A4_PAGE_SIZE = { width: 210, height: 297 };
|
||||
const LETTER_PAGE_SIZE = { width: 215.9, height: 279.4 };
|
||||
|
||||
// --- Invisible border preset for table cells ---
|
||||
|
||||
@@ -103,12 +85,6 @@ const TEMPLATE_CONFIGS: Record<Template, TemplateConfig> = {
|
||||
scizor: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
};
|
||||
|
||||
const DEFAULT_TEMPLATE_CONFIG: TemplateConfig = {
|
||||
sidebarSide: "left",
|
||||
sidebarBackground: "none",
|
||||
headerPosition: "full-width",
|
||||
};
|
||||
|
||||
/**
|
||||
* Blends a hex color toward white at the given opacity (0-1).
|
||||
* Used to approximate CSS `background-color: rgba(r,g,b, 0.2)` on a white background.
|
||||
@@ -376,7 +352,7 @@ export function buildDocument(data: ResumeData, resolveTitle?: SectionTitleResol
|
||||
const lineSpacing = Math.round(data.metadata.typography.body.lineHeight * 240);
|
||||
|
||||
const { page } = data.metadata;
|
||||
const pageSize = PAGE_SIZES[resolveDocxPageFormat(page.format)];
|
||||
const pageSize = page.format === "letter" ? LETTER_PAGE_SIZE : A4_PAGE_SIZE;
|
||||
// Margins and gaps are defined in points (pt), not mm
|
||||
const marginXTwips = ptToTwips(page.marginX);
|
||||
const marginYTwips = ptToTwips(page.marginY);
|
||||
@@ -385,7 +361,7 @@ export function buildDocument(data: ResumeData, resolveTitle?: SectionTitleResol
|
||||
const sidebarWidth = data.metadata.layout.sidebarWidth;
|
||||
|
||||
// Template-aware layout config
|
||||
const templateConfig = TEMPLATE_CONFIGS[data.metadata.template] ?? DEFAULT_TEMPLATE_CONFIG;
|
||||
const templateConfig = TEMPLATE_CONFIGS[data.metadata.template];
|
||||
|
||||
// Compute sidebar background shading hex
|
||||
let sidebarShadingHex: string | undefined;
|
||||
|
||||
@@ -20,4 +20,11 @@ describe("buildDocx", () => {
|
||||
const blob = await buildDocx(data);
|
||||
expect(blob.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns a rejected Promise when document building fails synchronously", async () => {
|
||||
const promise = buildDocx(undefined as never);
|
||||
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { buildDocument } from "./builder";
|
||||
* Builds a DOCX file from resume data and returns it as a Blob. Pass `resolveTitle` to fill in
|
||||
* locale-aware section headings (titles are stored empty and resolved at render time).
|
||||
*/
|
||||
// biome-ignore lint/suspicious/useAwait: keep synchronous renderer errors on the public Promise rejection path.
|
||||
export async function buildDocx(data: ResumeData, resolveTitle?: SectionTitleResolver): Promise<Blob> {
|
||||
const doc = buildDocument(data, resolveTitle);
|
||||
return Packer.toBlob(doc);
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { ResetPasswordEmail } from "./auth";
|
||||
|
||||
interface ResetPasswordTemplateProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const ResetPasswordTemplate = ({ url }: ResetPasswordTemplateProps) => {
|
||||
return <ResetPasswordEmail url={url} />;
|
||||
};
|
||||
|
||||
export default Object.assign(ResetPasswordTemplate, {
|
||||
export default Object.assign(ResetPasswordEmail, {
|
||||
PreviewProps: {
|
||||
url: "https://localhost:3000/auth/reset-password?token=example-token",
|
||||
} satisfies ResetPasswordTemplateProps,
|
||||
} satisfies Parameters<typeof ResetPasswordEmail>[0],
|
||||
});
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import { VerifyEmailChange } from "./auth";
|
||||
|
||||
interface VerifyEmailChangeTemplateProps {
|
||||
url: string;
|
||||
previousEmail: string;
|
||||
newEmail: string;
|
||||
}
|
||||
|
||||
const VerifyEmailChangeTemplate = ({ url, previousEmail, newEmail }: VerifyEmailChangeTemplateProps) => {
|
||||
return <VerifyEmailChange url={url} previousEmail={previousEmail} newEmail={newEmail} />;
|
||||
};
|
||||
|
||||
export default Object.assign(VerifyEmailChangeTemplate, {
|
||||
export default Object.assign(VerifyEmailChange, {
|
||||
PreviewProps: {
|
||||
url: "https://localhost:3000/auth/verify-email-change?token=example-token",
|
||||
previousEmail: "old@example.com",
|
||||
newEmail: "new@example.com",
|
||||
} satisfies VerifyEmailChangeTemplateProps,
|
||||
} satisfies Parameters<typeof VerifyEmailChange>[0],
|
||||
});
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { VerifyEmail } from "./auth";
|
||||
|
||||
interface VerifyEmailTemplateProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const VerifyEmailTemplate = ({ url }: VerifyEmailTemplateProps) => {
|
||||
return <VerifyEmail url={url} />;
|
||||
};
|
||||
|
||||
export default Object.assign(VerifyEmailTemplate, {
|
||||
export default Object.assign(VerifyEmail, {
|
||||
PreviewProps: {
|
||||
url: "https://localhost:3000/auth/verify-email?token=example-token",
|
||||
} satisfies VerifyEmailTemplateProps,
|
||||
} satisfies Parameters<typeof VerifyEmail>[0],
|
||||
});
|
||||
|
||||
@@ -13,17 +13,14 @@ type SendEmailOptions = {
|
||||
from?: string;
|
||||
};
|
||||
|
||||
const isSmtpEnabled = () => {
|
||||
return !!env.SMTP_HOST && !!env.SMTP_USER && !!env.SMTP_PASS && !!env.SMTP_FROM;
|
||||
};
|
||||
const isSmtpEnabled = () => !!env.SMTP_HOST && !!env.SMTP_USER && !!env.SMTP_PASS && !!env.SMTP_FROM;
|
||||
|
||||
let cachedTransport: Transporter | undefined;
|
||||
|
||||
const getTransport = () => {
|
||||
if (!isSmtpEnabled()) return;
|
||||
if (cachedTransport) return cachedTransport;
|
||||
|
||||
cachedTransport = nodemailer.createTransport({
|
||||
cachedTransport ??= nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: env.SMTP_PORT,
|
||||
secure: env.SMTP_SECURE,
|
||||
|
||||
@@ -91,13 +91,9 @@ export const standardFontList = standardPdfFontList.filter((font) => !webFontMap
|
||||
const fontMap = new Map<string, FontRecord>();
|
||||
const chinesePrioritySet = new Set<string>(preferredChineseFontFamilies);
|
||||
|
||||
function orderFonts(fonts: FontRecord[]) {
|
||||
return [...fonts].sort((a, b) => {
|
||||
return a.family.localeCompare(b.family, undefined, { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
export const fontList = orderFonts([...standardFontList, ...webFontList]);
|
||||
export const fontList = [...standardFontList, ...webFontList].sort((a, b) =>
|
||||
a.family.localeCompare(b.family, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
|
||||
for (const font of fontList) {
|
||||
fontMap.set(font.family, font);
|
||||
@@ -127,10 +123,6 @@ export function getFont(family: string) {
|
||||
return alias ? fontMap.get(alias) : undefined;
|
||||
}
|
||||
|
||||
function getFontCategory(family: string): FontCategory | null {
|
||||
return getFont(family)?.category ?? null;
|
||||
}
|
||||
|
||||
export function getFontDisplayName(family: string) {
|
||||
return fontDisplayNames[family] ?? family;
|
||||
}
|
||||
@@ -185,7 +177,7 @@ export function getPdfFallbackFontFamilies(
|
||||
family: string,
|
||||
options: { locale?: Locale; scripts?: Iterable<Script> } = {},
|
||||
): string[] {
|
||||
const category = getFontCategory(family);
|
||||
const category = getFont(family)?.category ?? null;
|
||||
|
||||
const ordered: Script[] = [];
|
||||
const localeScript = getLocaleScript(options.locale);
|
||||
|
||||
@@ -162,19 +162,6 @@ const jsonResumeSchema = z.looseObject({
|
||||
});
|
||||
|
||||
type JSONResume = z.infer<typeof jsonResumeSchema>;
|
||||
type JSONResumeLocation = z.infer<typeof locationSchema>;
|
||||
|
||||
// Helper function to format location object to string
|
||||
function formatLocation(location?: JSONResumeLocation): string {
|
||||
if (!location) return "";
|
||||
|
||||
const parts: string[] = [];
|
||||
if (location.city) parts.push(location.city);
|
||||
if (location.region) parts.push(location.region);
|
||||
if (location.countryCode) parts.push(location.countryCode);
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
// ponytail: stateless two-method class → two plain functions
|
||||
function convertJSONResume(jsonResume: JSONResume): ResumeData {
|
||||
@@ -190,7 +177,9 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
|
||||
headline: basics.label || "",
|
||||
email: basics.email || "",
|
||||
phone: basics.phone || "",
|
||||
location: basics.location ? formatLocation(basics.location) : "",
|
||||
location: [basics.location?.city, basics.location?.region, basics.location?.countryCode]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
website: createUrl(basics.url),
|
||||
customFields: [],
|
||||
};
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server-card": "./src/mcp-server-card.ts",
|
||||
"./tool-names": "./src/mcp-tool-names.ts",
|
||||
"./tools": "./src/tools.ts",
|
||||
"./prompts": "./src/prompts.ts",
|
||||
"./resources": "./src/resources.ts"
|
||||
"./server-card": "./src/mcp-server-card.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { buildMcpServerCard } from "./mcp-server-card";
|
||||
export { MCP_TOOL_NAME } from "./mcp-tool-names";
|
||||
export { registerPrompts } from "./prompts";
|
||||
export { registerResources } from "./resources";
|
||||
|
||||
@@ -98,9 +98,9 @@ describe("buildMcpServerCard", () => {
|
||||
true,
|
||||
);
|
||||
expect(autofill.safeParse({ sourceUrl: "http://example.com/job" }).success).toBe(true);
|
||||
expect(create.safeParse({ company: "Acme", role: "Engineer", sourceUrl: "ftp://example.com/job" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
const invalidUrl = create.safeParse({ company: "Acme", role: "Engineer", sourceUrl: "ftp://example.com/job" });
|
||||
expect(invalidUrl.success).toBe(false);
|
||||
if (!invalidUrl.success) expect(invalidUrl.error.issues[0]?.message).toBe("URL must use http or https.");
|
||||
expect(autofill.safeParse({ sourceUrl: "javascript:alert(1)" }).success).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export function registerPrompts(server: McpServer) {
|
||||
description: "Guide the user step-by-step through building a resume from scratch, section by section.",
|
||||
argsSchema: { id: resumeIdArg },
|
||||
},
|
||||
async ({ id }) => ({
|
||||
({ id }) => ({
|
||||
messages: [
|
||||
...resumeContext(id),
|
||||
{
|
||||
@@ -113,7 +113,7 @@ export function registerPrompts(server: McpServer) {
|
||||
description: "Review resume content and suggest concrete improvements to wording, impact, and structure.",
|
||||
argsSchema: { id: resumeIdArg },
|
||||
},
|
||||
async ({ id }) => ({
|
||||
({ id }) => ({
|
||||
messages: [
|
||||
...resumeContext(id),
|
||||
{
|
||||
@@ -158,7 +158,7 @@ export function registerPrompts(server: McpServer) {
|
||||
"Get a structured, professional critique with a scorecard and prioritized recommendations. Read-only — no changes are made.",
|
||||
argsSchema: { id: resumeIdArg },
|
||||
},
|
||||
async ({ id }) => ({
|
||||
({ id }) => ({
|
||||
messages: [
|
||||
...resumeContext(id),
|
||||
{
|
||||
|
||||
@@ -58,7 +58,7 @@ export function registerResources(server: McpServer, client: RouterClient<typeof
|
||||
"custom sections, and metadata (template, layout, typography, colors, CSS).",
|
||||
].join(" "),
|
||||
},
|
||||
async (uri: URL) => ({
|
||||
(uri: URL) => ({
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MCP_TOOL_NAME } from "./mcp-tool-names";
|
||||
import { TOOL_ANNOTATIONS } from "./tool-annotations";
|
||||
import { TOOL_META } from "./tool-meta";
|
||||
|
||||
describe("MCP_TOOL_NAME", () => {
|
||||
it("uses canonical unprefixed snake_case tool names", () => {
|
||||
@@ -29,10 +29,10 @@ describe("MCP_TOOL_NAME", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TOOL_ANNOTATIONS", () => {
|
||||
describe("tool annotations", () => {
|
||||
it("provides annotations for every registered tool", () => {
|
||||
for (const name of Object.values(MCP_TOOL_NAME)) {
|
||||
expect(TOOL_ANNOTATIONS[name]).toBeDefined();
|
||||
expect(TOOL_META[name].annotations).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("TOOL_ANNOTATIONS", () => {
|
||||
MCP_TOOL_NAME.getApplicationStats,
|
||||
];
|
||||
for (const name of readOnlyTools) {
|
||||
const annotations = TOOL_ANNOTATIONS[name];
|
||||
const annotations = TOOL_META[name].annotations;
|
||||
expect(annotations.readOnlyHint, name).toBe(true);
|
||||
expect(annotations.destructiveHint, name).toBe(false);
|
||||
expect(annotations.idempotentHint, name).toBe(true);
|
||||
@@ -57,14 +57,14 @@ describe("TOOL_ANNOTATIONS", () => {
|
||||
});
|
||||
|
||||
it("marks PDF download URL generation as read-only but non-idempotent", () => {
|
||||
const annotations = TOOL_ANNOTATIONS[MCP_TOOL_NAME.downloadResumePdf];
|
||||
const annotations = TOOL_META[MCP_TOOL_NAME.downloadResumePdf].annotations;
|
||||
expect(annotations.readOnlyHint).toBe(true);
|
||||
expect(annotations.idempotentHint).toBe(false);
|
||||
expect(annotations.destructiveHint).toBe(false);
|
||||
});
|
||||
|
||||
it("marks deleteResume as destructive (but still idempotent)", () => {
|
||||
const annotations = TOOL_ANNOTATIONS[MCP_TOOL_NAME.deleteResume];
|
||||
const annotations = TOOL_META[MCP_TOOL_NAME.deleteResume].annotations;
|
||||
expect(annotations.destructiveHint).toBe(true);
|
||||
expect(annotations.idempotentHint).toBe(true);
|
||||
expect(annotations.readOnlyHint).toBe(false);
|
||||
@@ -72,7 +72,7 @@ describe("TOOL_ANNOTATIONS", () => {
|
||||
|
||||
it("marks application delete tools as destructive", () => {
|
||||
for (const name of [MCP_TOOL_NAME.deleteApplication, MCP_TOOL_NAME.bulkDeleteApplications]) {
|
||||
const annotations = TOOL_ANNOTATIONS[name];
|
||||
const annotations = TOOL_META[name].annotations;
|
||||
expect(annotations.readOnlyHint, name).toBe(false);
|
||||
expect(annotations.destructiveHint, name).toBe(true);
|
||||
}
|
||||
@@ -86,7 +86,7 @@ describe("TOOL_ANNOTATIONS", () => {
|
||||
MCP_TOOL_NAME.patchResume,
|
||||
MCP_TOOL_NAME.updateResume,
|
||||
]) {
|
||||
const annotations = TOOL_ANNOTATIONS[name];
|
||||
const annotations = TOOL_META[name].annotations;
|
||||
expect(annotations.readOnlyHint, name).toBe(false);
|
||||
expect(annotations.idempotentHint, name).toBe(false);
|
||||
expect(annotations.destructiveHint, name).toBe(false);
|
||||
@@ -95,7 +95,7 @@ describe("TOOL_ANNOTATIONS", () => {
|
||||
|
||||
it("marks lockResume / unlockResume as idempotent and non-destructive", () => {
|
||||
for (const name of [MCP_TOOL_NAME.lockResume, MCP_TOOL_NAME.unlockResume]) {
|
||||
const annotations = TOOL_ANNOTATIONS[name];
|
||||
const annotations = TOOL_META[name].annotations;
|
||||
expect(annotations.idempotentHint, name).toBe(true);
|
||||
expect(annotations.destructiveHint, name).toBe(false);
|
||||
expect(annotations.readOnlyHint, name).toBe(false);
|
||||
@@ -103,11 +103,11 @@ describe("TOOL_ANNOTATIONS", () => {
|
||||
});
|
||||
|
||||
it("marks only job-posting autofill as open-world", () => {
|
||||
expect(TOOL_ANNOTATIONS[MCP_TOOL_NAME.autofillApplicationFromJob].openWorldHint).toBe(true);
|
||||
expect(TOOL_META[MCP_TOOL_NAME.autofillApplicationFromJob].annotations.openWorldHint).toBe(true);
|
||||
});
|
||||
|
||||
it("declares no tools as open-world by default", () => {
|
||||
for (const [name, annotations] of Object.entries(TOOL_ANNOTATIONS)) {
|
||||
for (const [name, { annotations }] of Object.entries(TOOL_META)) {
|
||||
if (name === MCP_TOOL_NAME.autofillApplicationFromJob) continue;
|
||||
expect(annotations.openWorldHint).toBe(false);
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { MCP_TOOL_NAME } from "./mcp-tool-names";
|
||||
|
||||
type McpRegisteredToolName = (typeof MCP_TOOL_NAME)[keyof typeof MCP_TOOL_NAME];
|
||||
|
||||
// ponytail: 5 distinct annotation shapes shared across 14 tools
|
||||
const READ_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const READ_NON_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const READ_OPEN_WORLD_NON_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
};
|
||||
const WRITE_NON_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const WRITE_DESTRUCTIVE: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const WRITE_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
|
||||
/** Tool behavior hints for MCP `tools/list` and the static server card. */
|
||||
export const TOOL_ANNOTATIONS: Record<McpRegisteredToolName, ToolAnnotations> = {
|
||||
[MCP_TOOL_NAME.listResumes]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.listResumeTags]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.getResume]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.getResumeAnalysis]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.downloadResumePdf]: READ_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.createResume]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.importResume]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.duplicateResume]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.patchResume]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.updateResume]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.deleteResume]: WRITE_DESTRUCTIVE,
|
||||
[MCP_TOOL_NAME.lockResume]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.unlockResume]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.getResumeStatistics]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.listApplications]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.readApplication]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.listApplicationTags]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.getApplicationStats]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.createApplication]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.updateApplication]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.addApplicationNote]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.updateApplicationTimelineEntry]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.deleteApplicationTimelineEntry]: WRITE_DESTRUCTIVE,
|
||||
[MCP_TOOL_NAME.deleteApplication]: WRITE_DESTRUCTIVE,
|
||||
[MCP_TOOL_NAME.bulkUpdateApplications]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.bulkDeleteApplications]: WRITE_DESTRUCTIVE,
|
||||
[MCP_TOOL_NAME.importApplications]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.attachApplicationDocument]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.removeApplicationDocument]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.autofillApplicationFromJob]: READ_OPEN_WORLD_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.scoreApplicationMatch]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.tailorResumeForApplication]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.draftApplicationMessage]: READ_NON_IDEMPOTENT,
|
||||
};
|
||||
@@ -2,13 +2,49 @@
|
||||
* Canonical tool metadata (title, description, inputSchema, annotations) declared once.
|
||||
* Consumed by both `registerTools` (raw Zod) and `buildMcpServerCard` (toJsonSchemaCompat).
|
||||
*/
|
||||
import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
||||
import z from "zod";
|
||||
import { resumePatchOperationsSchema } from "@reactive-resume/ai/tools/resume-tool-contracts";
|
||||
import { applicationStatusSchema, contactSchema } from "@reactive-resume/schema/applications/data";
|
||||
import { MCP_TOOL_NAME as T } from "./mcp-tool-names";
|
||||
import { TOOL_ANNOTATIONS } from "./tool-annotations";
|
||||
|
||||
const MAX_APPLICATION_DOCUMENT_BYTES = 10 * 1024 * 1024;
|
||||
const READ_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const READ_NON_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const READ_OPEN_WORLD_NON_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
};
|
||||
const WRITE_NON_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const WRITE_DESTRUCTIVE: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const WRITE_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
|
||||
// ponytail: shared schema fragment; exported so server-card can re-use without re-importing
|
||||
const resumeIdSchema = z.string().min(1).describe(`Resume ID. Use \`${T.listResumes}\` to find valid IDs.`);
|
||||
@@ -22,14 +58,7 @@ const timelineDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must us
|
||||
const httpUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "URL must use http or https.");
|
||||
.pipe(z.url({ protocol: /^https?$/, error: "URL must use http or https." }));
|
||||
const pdfBase64Schema = z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -98,7 +127,7 @@ export const TOOL_META = {
|
||||
.default("lastUpdatedAt")
|
||||
.describe("Sort order for results. Default: lastUpdatedAt."),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.listResumes],
|
||||
annotations: READ_IDEMPOTENT,
|
||||
},
|
||||
[T.listResumeTags]: {
|
||||
title: "List Resume Tags",
|
||||
@@ -107,7 +136,7 @@ export const TOOL_META = {
|
||||
"Useful for choosing tag filters when calling list tools or keeping naming consistent.",
|
||||
].join("\n"),
|
||||
inputSchema: z.object({}),
|
||||
annotations: TOOL_ANNOTATIONS[T.listResumeTags],
|
||||
annotations: READ_IDEMPOTENT,
|
||||
},
|
||||
[T.getResume]: {
|
||||
title: "Read Resume",
|
||||
@@ -122,7 +151,7 @@ export const TOOL_META = {
|
||||
"The `resume://_meta/schema` resource describes the full data structure for JSON Patch paths.",
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResume],
|
||||
annotations: READ_IDEMPOTENT,
|
||||
},
|
||||
[T.getResumeAnalysis]: {
|
||||
title: "Get Resume Analysis",
|
||||
@@ -132,7 +161,7 @@ export const TOOL_META = {
|
||||
`Returns JSON or a short message if none exists. Use \`${T.listResumes}\` to find resume IDs.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResumeAnalysis],
|
||||
annotations: READ_IDEMPOTENT,
|
||||
},
|
||||
[T.downloadResumePdf]: {
|
||||
title: "Download Resume PDF",
|
||||
@@ -143,7 +172,7 @@ export const TOOL_META = {
|
||||
`Use \`${T.listResumes}\` first to find valid IDs.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.downloadResumePdf],
|
||||
annotations: READ_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.createResume]: {
|
||||
title: "Create Resume",
|
||||
@@ -168,7 +197,7 @@ export const TOOL_META = {
|
||||
.describe("Tags to categorize the resume (e.g. ['tech', 'senior'])"),
|
||||
withSampleData: z.boolean().optional().default(false).describe("Pre-fill with sample data. Default: false."),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.createResume],
|
||||
annotations: WRITE_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.importResume]: {
|
||||
title: "Import Resume",
|
||||
@@ -183,7 +212,7 @@ export const TOOL_META = {
|
||||
.unknown()
|
||||
.describe("Complete ResumeData JSON (same shape as `read_resume` body or `resume://_meta/schema`)."),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.importResume],
|
||||
annotations: WRITE_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.duplicateResume]: {
|
||||
title: "Duplicate Resume",
|
||||
@@ -200,7 +229,7 @@ export const TOOL_META = {
|
||||
slug: z.string().min(1).max(64).describe("URL-friendly slug for the duplicate (must be unique)"),
|
||||
tags: z.array(z.string()).optional().default([]).describe("Tags for the duplicate"),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.duplicateResume],
|
||||
annotations: WRITE_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.patchResume]: {
|
||||
title: "Apply Resume Patch",
|
||||
@@ -231,7 +260,7 @@ export const TOOL_META = {
|
||||
id: resumeIdSchema,
|
||||
operations: resumePatchOperationsSchema,
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.patchResume],
|
||||
annotations: WRITE_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.updateResume]: {
|
||||
title: "Update Resume (metadata)",
|
||||
@@ -255,7 +284,7 @@ export const TOOL_META = {
|
||||
"When true, anyone with the link can view the public resume (subject to password if set in the app).",
|
||||
),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.updateResume],
|
||||
annotations: WRITE_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.deleteResume]: {
|
||||
title: "Delete Resume",
|
||||
@@ -266,7 +295,7 @@ export const TOOL_META = {
|
||||
`Consider using \`${T.duplicateResume}\` to create a backup before deleting.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.deleteResume],
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
[T.lockResume]: {
|
||||
title: "Lock Resume",
|
||||
@@ -278,13 +307,13 @@ export const TOOL_META = {
|
||||
`Use \`${T.unlockResume}\` to re-enable editing.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.lockResume],
|
||||
annotations: WRITE_IDEMPOTENT,
|
||||
},
|
||||
[T.unlockResume]: {
|
||||
title: "Unlock Resume",
|
||||
description: "Unlock a previously locked resume, re-enabling edits, patches, and deletion.",
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.unlockResume],
|
||||
annotations: WRITE_IDEMPOTENT,
|
||||
},
|
||||
[T.getResumeStatistics]: {
|
||||
title: "Get Resume Statistics",
|
||||
@@ -295,7 +324,7 @@ export const TOOL_META = {
|
||||
"lastViewedAt (timestamp or null), lastDownloadedAt (timestamp or null).",
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResumeStatistics],
|
||||
annotations: READ_IDEMPOTENT,
|
||||
},
|
||||
[T.listApplications]: {
|
||||
title: "List Applications",
|
||||
@@ -306,31 +335,31 @@ export const TOOL_META = {
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
includeArchived: z.boolean().optional().default(false),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.listApplications],
|
||||
annotations: READ_IDEMPOTENT,
|
||||
},
|
||||
[T.readApplication]: {
|
||||
title: "Read Application",
|
||||
description: "Read one full job application, including contacts, document URLs, follow-up details, and timeline.",
|
||||
inputSchema: z.object({ id: applicationIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.readApplication],
|
||||
annotations: READ_IDEMPOTENT,
|
||||
},
|
||||
[T.listApplicationTags]: {
|
||||
title: "List Application Tags",
|
||||
description: "Return every distinct tag used across job applications.",
|
||||
inputSchema: z.object({}),
|
||||
annotations: TOOL_ANNOTATIONS[T.listApplicationTags],
|
||||
annotations: READ_IDEMPOTENT,
|
||||
},
|
||||
[T.getApplicationStats]: {
|
||||
title: "Get Application Stats",
|
||||
description: "Return aggregate application counts by pipeline stage and source for insights.",
|
||||
inputSchema: z.object({}),
|
||||
annotations: TOOL_ANNOTATIONS[T.getApplicationStats],
|
||||
annotations: READ_IDEMPOTENT,
|
||||
},
|
||||
[T.createApplication]: {
|
||||
title: "Create Application",
|
||||
description: "Create a tracked job application. Company and role are required.",
|
||||
inputSchema: createApplicationSchema,
|
||||
annotations: TOOL_ANNOTATIONS[T.createApplication],
|
||||
annotations: WRITE_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.updateApplication]: {
|
||||
title: "Update Application",
|
||||
@@ -341,7 +370,7 @@ export const TOOL_META = {
|
||||
...applicationMutableFieldsSchema,
|
||||
archived: z.boolean().optional().describe("Whether the application is hidden from active views."),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.updateApplication],
|
||||
annotations: WRITE_IDEMPOTENT,
|
||||
},
|
||||
[T.addApplicationNote]: {
|
||||
title: "Add Application Note",
|
||||
@@ -351,7 +380,7 @@ export const TOOL_META = {
|
||||
text: z.string().min(1),
|
||||
date: timelineDateSchema.optional().describe("Optional note date in YYYY-MM-DD format."),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.addApplicationNote],
|
||||
annotations: WRITE_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.updateApplicationTimelineEntry]: {
|
||||
title: "Update Application Timeline Entry",
|
||||
@@ -364,19 +393,19 @@ export const TOOL_META = {
|
||||
text: z.string().min(1).optional().describe("Replacement note text. Only note entries can change text."),
|
||||
})
|
||||
.refine((value) => value.date !== undefined || value.text !== undefined, "Provide date or text to update."),
|
||||
annotations: TOOL_ANNOTATIONS[T.updateApplicationTimelineEntry],
|
||||
annotations: WRITE_IDEMPOTENT,
|
||||
},
|
||||
[T.deleteApplicationTimelineEntry]: {
|
||||
title: "Delete Application Timeline Entry",
|
||||
description: "Delete a note or older stage entry. The current stage entry cannot be deleted.",
|
||||
inputSchema: z.object({ id: applicationIdSchema, entryId: applicationTimelineEntryIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.deleteApplicationTimelineEntry],
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
[T.deleteApplication]: {
|
||||
title: "Delete Application",
|
||||
description: "Permanently delete one job application and its owned uploaded documents.",
|
||||
inputSchema: z.object({ id: applicationIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.deleteApplication],
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
[T.bulkUpdateApplications]: {
|
||||
title: "Bulk Update Applications",
|
||||
@@ -387,19 +416,19 @@ export const TOOL_META = {
|
||||
archived: z.boolean().optional(),
|
||||
addTags: z.array(z.string()).optional(),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.bulkUpdateApplications],
|
||||
annotations: WRITE_IDEMPOTENT,
|
||||
},
|
||||
[T.bulkDeleteApplications]: {
|
||||
title: "Bulk Delete Applications",
|
||||
description: "Permanently delete multiple applications.",
|
||||
inputSchema: z.object({ ids: z.array(z.string()).min(1) }),
|
||||
annotations: TOOL_ANNOTATIONS[T.bulkDeleteApplications],
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
[T.importApplications]: {
|
||||
title: "Import Applications",
|
||||
description: "Bulk-create application rows parsed from CSV or another source. Maximum 500 items.",
|
||||
inputSchema: z.object({ items: z.array(createApplicationSchema).min(1).max(500) }),
|
||||
annotations: TOOL_ANNOTATIONS[T.importApplications],
|
||||
annotations: WRITE_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.attachApplicationDocument]: {
|
||||
title: "Attach Application Document",
|
||||
@@ -411,13 +440,13 @@ export const TOOL_META = {
|
||||
contentType: z.literal("application/pdf"),
|
||||
dataBase64: pdfBase64Schema,
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.attachApplicationDocument],
|
||||
annotations: WRITE_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.removeApplicationDocument]: {
|
||||
title: "Remove Application Document",
|
||||
description: "Remove a sent resume or cover-letter PDF from an application.",
|
||||
inputSchema: z.object({ id: applicationIdSchema, kind: applicationDocumentKindSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.removeApplicationDocument],
|
||||
annotations: WRITE_IDEMPOTENT,
|
||||
},
|
||||
[T.autofillApplicationFromJob]: {
|
||||
title: "Autofill Application From Job",
|
||||
@@ -427,24 +456,24 @@ export const TOOL_META = {
|
||||
sourceUrl: httpUrlSchema.optional(),
|
||||
jobDescription: z.string().max(20_000).optional(),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.autofillApplicationFromJob],
|
||||
annotations: READ_OPEN_WORLD_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.scoreApplicationMatch]: {
|
||||
title: "Score Application Match",
|
||||
description: "Score the linked resume against the application's job description and persist match metadata.",
|
||||
inputSchema: z.object({ id: applicationIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.scoreApplicationMatch],
|
||||
annotations: WRITE_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.tailorResumeForApplication]: {
|
||||
title: "Tailor Resume For Application",
|
||||
description: "Create and link a tailored copy of the application's linked resume.",
|
||||
inputSchema: z.object({ id: applicationIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.tailorResumeForApplication],
|
||||
annotations: WRITE_NON_IDEMPOTENT,
|
||||
},
|
||||
[T.draftApplicationMessage]: {
|
||||
title: "Draft Application Message",
|
||||
description: "Draft either a cover letter or recruiter follow-up from application and resume context.",
|
||||
inputSchema: z.object({ id: applicationIdSchema, kind: z.enum(["cover-letter", "follow-up"]) }),
|
||||
annotations: TOOL_ANNOTATIONS[T.draftApplicationMessage],
|
||||
annotations: READ_NON_IDEMPOTENT,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -21,7 +21,8 @@ vi.mock("@reactive-resume/env/server", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { MCP_TOOL_NAME, registerTools } = await import("./tools");
|
||||
const { MCP_TOOL_NAME } = await import("./mcp-tool-names");
|
||||
const { registerTools } = await import("./tools");
|
||||
|
||||
type ToolHandler = (input: Record<string, unknown>) => Promise<{
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
|
||||
@@ -12,8 +12,6 @@ import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { MCP_TOOL_NAME } from "./mcp-tool-names";
|
||||
import { TOOL_META } from "./tool-meta";
|
||||
|
||||
export { MCP_TOOL_NAME } from "./mcp-tool-names";
|
||||
|
||||
type PatchOperation = z.infer<typeof resumePatchOperationsSchema>[number];
|
||||
|
||||
// ── Shared Helpers ───────────────���──────────────────────────────
|
||||
|
||||
@@ -44,4 +44,16 @@ describe("createResumePdfBlob", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a rejected Promise when the renderer fails synchronously", async () => {
|
||||
rendererMock.pdf.mockImplementationOnce(() => {
|
||||
throw new Error("renderer failed");
|
||||
});
|
||||
const { createResumePdfBlob } = await import("./browser");
|
||||
|
||||
const promise = createResumePdfBlob({ data: sampleResumeData });
|
||||
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
await expect(promise).rejects.toThrow("renderer failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ type CreateResumePdfBlobOptions = {
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: keep synchronous renderer errors on the public Promise rejection path.
|
||||
export const createResumePdfBlob = async ({
|
||||
data,
|
||||
template,
|
||||
|
||||
@@ -9,10 +9,14 @@ import { Document } from "#react-pdf-renderer";
|
||||
import { RenderProvider } from "./context";
|
||||
import { registerFonts, resumeContentContainsCJK, resumeContentScripts } from "./hooks/use-register-fonts";
|
||||
import { getTemplatePage } from "./templates";
|
||||
import { shouldShowResumeHeader } from "./templates/shared/cover-letter";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "./templates/shared/page-size";
|
||||
|
||||
export type TemplatePageProps = {
|
||||
page: LayoutPage;
|
||||
pageIndex: number;
|
||||
pageSize: ReturnType<typeof getTemplatePageSize>;
|
||||
pageMinHeightStyle: ReturnType<typeof getTemplatePageMinHeightStyle>;
|
||||
showHeader: boolean;
|
||||
};
|
||||
|
||||
export type TemplatePage = ComponentType<TemplatePageProps>;
|
||||
@@ -43,6 +47,9 @@ export const ResumeDocument = ({ data, template, renderOptions, resolveSectionTi
|
||||
// fallback (#2986); the cast carries that wider runtime value through
|
||||
// `ResumeData` without changing the public schema.
|
||||
const resumeData = useMemo(() => ({ ...data, metadata: { ...data.metadata, typography } }), [data, typography]);
|
||||
const pageSize = getTemplatePageSize(resumeData.metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(resumeData.metadata.page.format);
|
||||
const headerResumeData = renderOptions ? { ...resumeData, renderOptions } : resumeData;
|
||||
|
||||
return (
|
||||
<RenderProvider data={resumeData} resolveSectionTitle={resolveSectionTitle} renderOptions={renderOptions}>
|
||||
@@ -57,7 +64,13 @@ export const ResumeDocument = ({ data, template, renderOptions, resolveSectionTi
|
||||
language={resumeData.metadata.page.locale}
|
||||
>
|
||||
{resumeData.metadata.layout.pages.map((page, index) => (
|
||||
<TemplatePageComponent key={getLayoutPageKey(page, index)} page={page} pageIndex={index} />
|
||||
<TemplatePageComponent
|
||||
key={getLayoutPageKey(page, index)}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
pageMinHeightStyle={pageMinHeightStyle}
|
||||
showHeader={shouldShowResumeHeader(headerResumeData, index)}
|
||||
/>
|
||||
))}
|
||||
</Document>
|
||||
</RenderProvider>
|
||||
|
||||
@@ -106,17 +106,6 @@ const toFontWeight = (weight: number): FontWeight => {
|
||||
return "900";
|
||||
};
|
||||
|
||||
const collectFontRangeWeights = (ranges: FontWeightRange[]): number[] => {
|
||||
const weights = new Set<number>();
|
||||
|
||||
for (const range of ranges) {
|
||||
weights.add(range.lowest);
|
||||
weights.add(range.highest);
|
||||
}
|
||||
|
||||
return [...weights];
|
||||
};
|
||||
|
||||
// Resolves the user-stored family to the one we hand to Font.register:
|
||||
// direct match → legacy alias (#2989) → IBM Plex Serif fallback.
|
||||
const resolvePdfFontFamily = (family: string) => {
|
||||
@@ -277,7 +266,7 @@ export const registerFonts = (
|
||||
const headingFallbacks = getPdfFallbackFontFamilies(headingFontFamily, { locale, scripts: fallbackScripts });
|
||||
|
||||
const registerFallbacks = (families: string[], ranges: FontWeightRange[]) => {
|
||||
const weights = collectFontRangeWeights(ranges);
|
||||
const weights = new Set(ranges.flatMap(({ lowest, highest }) => [lowest, highest]));
|
||||
|
||||
for (const family of families) {
|
||||
for (const weight of weights) {
|
||||
|
||||
@@ -20,10 +20,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -58,14 +56,11 @@ const azurillFeatures = {
|
||||
sectionTimeline: true,
|
||||
} satisfies TemplateFeatures;
|
||||
|
||||
export const AzurillPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const AzurillPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles, featureStyles } = useAzurillTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
|
||||
@@ -139,22 +134,17 @@ const useAzurillTemplate = (): AzurillTemplate => {
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
page: {
|
||||
...base.page,
|
||||
flexDirection: "column",
|
||||
rowGap: metrics.headerGap,
|
||||
columnGap: metrics.columnGap,
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
sectionHeading: {
|
||||
color: primary,
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -70,14 +68,11 @@ const getBronzorSections = ({
|
||||
return sections;
|
||||
};
|
||||
|
||||
export const BronzorPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const BronzorPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { styles, colors } = useBronzorTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sections = getBronzorSections({ mainSections, sidebarSections, fullWidth: page.fullWidth });
|
||||
@@ -136,22 +131,17 @@ const useBronzorTemplate = (): BronzorTemplate => {
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
heading: { ...base.heading, fontWeight: metadata.typography.heading.fontWeights[0] ?? "500" },
|
||||
page: {
|
||||
...base.page,
|
||||
flexDirection: "column",
|
||||
rowGap: metrics.headerGap,
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
section: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -47,15 +45,12 @@ type ChikoritaHeaderProps = {
|
||||
styles: ChikoritaStyles;
|
||||
};
|
||||
|
||||
export const ChikoritaPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata, picture } = data;
|
||||
const { colors, styles } = useChikoritaTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
|
||||
@@ -153,19 +148,14 @@ const useChikoritaTemplate = (): ChikoritaTemplate => {
|
||||
};
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
inline: { ...base.inline, columnGap: metrics.gapX(0.25) },
|
||||
page: {
|
||||
...base.page,
|
||||
flexDirection: r.row,
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -27,11 +27,23 @@ const readTemplate = (file: string) => {
|
||||
};
|
||||
|
||||
describe("cover letter PDF layout", () => {
|
||||
it.each(pageFiles)("%s suppresses the resume header for cover-letter-only documents", (file) => {
|
||||
it("derives shared page props in the document renderer", () => {
|
||||
const source = readTemplate("../document.tsx");
|
||||
|
||||
expect(source).toContain('from "./templates/shared/cover-letter"');
|
||||
expect(source).toContain('from "./templates/shared/page-size"');
|
||||
expect(source).toContain("showHeader={shouldShowResumeHeader");
|
||||
expect(source).toContain("pageSize={pageSize}");
|
||||
expect(source).toContain("pageMinHeightStyle={pageMinHeightStyle}");
|
||||
});
|
||||
|
||||
it.each(pageFiles)("%s renders the shared page props", (file) => {
|
||||
const source = readTemplate(file);
|
||||
|
||||
expect(source, basename(file)).toContain('from "../shared/cover-letter"');
|
||||
expect(source, basename(file)).toContain("shouldShowResumeHeader(data, pageIndex)");
|
||||
expect(source, basename(file)).not.toContain("const showHeader = pageIndex === 0;");
|
||||
expect(source, basename(file)).toContain("pageSize, pageMinHeightStyle, showHeader");
|
||||
expect(source, basename(file)).toContain("<Page size={pageSize}");
|
||||
expect(source, basename(file)).toContain("showHeader &&");
|
||||
expect(source, basename(file)).not.toContain('from "../shared/cover-letter"');
|
||||
expect(source, basename(file)).not.toContain('from "../shared/page-size"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,11 +15,9 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { getFeaturedSummaryLayout } from "../shared/featured-summary";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -58,14 +56,11 @@ const ditgarFeatures = {
|
||||
mainItemHeaderBorder: true,
|
||||
} satisfies TemplateFeatures;
|
||||
|
||||
export const DitgarPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useDitgarTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const showSidebar = !page.fullWidth || showHeader;
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
@@ -189,18 +184,13 @@ const useDitgarTemplate = (): DitgarTemplate => {
|
||||
};
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
page: {
|
||||
...base.page,
|
||||
flexDirection: r.row,
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -52,15 +50,12 @@ type DittoHeaderProps = {
|
||||
styles: DittoStyles;
|
||||
};
|
||||
|
||||
export const DittoPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata, picture } = data;
|
||||
const { colors, styles } = useDittoTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
|
||||
@@ -149,7 +144,7 @@ const useDittoTemplate = (): DittoTemplate => {
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
@@ -161,13 +156,8 @@ const useDittoTemplate = (): DittoTemplate => {
|
||||
marginLeft: -picture.size / 2,
|
||||
},
|
||||
page: {
|
||||
...base.page,
|
||||
flexDirection: "column",
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -15,11 +15,9 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { getFeaturedSummaryLayout } from "../shared/featured-summary";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -53,14 +51,11 @@ type GengarHeaderProps = {
|
||||
colors: TemplateColorRoles;
|
||||
};
|
||||
|
||||
export const GengarPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const GengarPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useGengarTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const showSidebar = !page.fullWidth || showHeader;
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
@@ -185,18 +180,13 @@ const useGengarTemplate = (): GengarTemplate => {
|
||||
};
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
page: {
|
||||
...base.page,
|
||||
flexDirection: r.row,
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -15,10 +15,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -55,14 +53,11 @@ const glalieFeatures = {
|
||||
stackSidebarItemHeader: true,
|
||||
} satisfies TemplateFeatures;
|
||||
|
||||
export const GlaliePage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const GlaliePage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useGlalieTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const showSidebar = !page.fullWidth || showHeader;
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
@@ -150,18 +145,10 @@ const useGlalieTemplate = (): GlalieTemplate => {
|
||||
};
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
rowGap: metrics.gapY(0.25),
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -46,14 +44,11 @@ type KakunaHeaderProps = {
|
||||
styles: KakunaStyles;
|
||||
};
|
||||
|
||||
export const KakunaPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const KakunaPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useKakunaTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
|
||||
@@ -119,20 +114,15 @@ const useKakunaTemplate = (): KakunaTemplate => {
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
...base.page,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.sectionGap,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -45,14 +43,11 @@ type LaprasHeaderProps = {
|
||||
styles: LaprasStyles;
|
||||
};
|
||||
|
||||
export const LaprasPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const LaprasPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useLaprasTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
|
||||
@@ -121,20 +116,15 @@ const useLaprasTemplate = (): LaprasTemplate => {
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
...base.page,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.gapY(1.5),
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -15,10 +15,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -51,14 +49,11 @@ type LeafishHeaderProps = {
|
||||
styles: LeafishStyles;
|
||||
};
|
||||
|
||||
export const LeafishPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const LeafishPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useLeafishTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const mainSections = filterSections(page.main, data).filter((section) => section !== "summary");
|
||||
const sidebarSections = filterSections(page.sidebar, data).filter((section) => section !== "summary");
|
||||
|
||||
@@ -141,18 +136,10 @@ const useLeafishTemplate = (): LeafishTemplate => {
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
rowGap: metrics.gapY(0.25),
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -50,14 +48,11 @@ const meowthFeatures = {
|
||||
inlineItemHeader: true,
|
||||
} satisfies TemplateFeatures;
|
||||
|
||||
export const MeowthPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const MeowthPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useMeowthTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
|
||||
@@ -122,20 +117,15 @@ const useMeowthTemplate = (): MeowthTemplate => {
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
...base.page,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.sectionGap,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
inlineItemHeader: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -45,14 +43,11 @@ type OnyxHeaderProps = {
|
||||
styles: OnyxStyles;
|
||||
};
|
||||
|
||||
export const OnyxPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const OnyxPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useOnyxTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
|
||||
@@ -117,20 +112,15 @@ const useOnyxTemplate = (): OnyxTemplate => {
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
...base.page,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.sectionGap,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -55,14 +53,11 @@ const pikachuFeatures = {
|
||||
stackSidebarItemHeader: true,
|
||||
} satisfies TemplateFeatures;
|
||||
|
||||
export const PikachuPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const PikachuPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata, picture } = data;
|
||||
const { colors, styles } = usePikachuTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const showSidebar = !page.fullWidth;
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
@@ -171,19 +166,14 @@ const usePikachuTemplate = (): PikachuTemplate => {
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
...base.page,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -15,10 +15,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -48,14 +46,11 @@ type RhyhornHeaderProps = {
|
||||
styles: RhyhornStyles;
|
||||
};
|
||||
|
||||
export const RhyhornPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const RhyhornPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useRhyhornTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
|
||||
@@ -163,20 +158,15 @@ const useRhyhornTemplate = (): RhyhornTemplate => {
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const contactGap = metrics.gapX(0.5);
|
||||
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
...base.page,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.sectionGap,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
WebsiteContactItem,
|
||||
} from "../shared/contact-item";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { shouldShowResumeHeader } from "../shared/cover-letter";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Text } from "../shared/primitives";
|
||||
import { createRtlStyleHelpers } from "../shared/rtl";
|
||||
@@ -46,14 +44,11 @@ type ScizorHeaderProps = {
|
||||
styles: ScizorStyles;
|
||||
};
|
||||
|
||||
export const ScizorPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
export const ScizorPage = ({ page, pageSize, pageMinHeightStyle, showHeader }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useScizorTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format);
|
||||
const showHeader = shouldShowResumeHeader(data, pageIndex);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = page.fullWidth ? [] : filterSections(page.sidebar, data);
|
||||
const sections = [...mainSections, ...sidebarSections];
|
||||
@@ -111,22 +106,17 @@ const useScizorTemplate = (): ScizorTemplate => {
|
||||
const divider = "#D8DCE2";
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, r, metrics, picture });
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
...base,
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
...base.page,
|
||||
borderTopWidth: metrics.gapY(0.45),
|
||||
borderTopColor: primary,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.sectionGap,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
},
|
||||
heading: { ...base.heading, fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "700" },
|
||||
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "700", color: foreground },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
type BaseTemplateStylesInput = {
|
||||
metadata: ResumeData["metadata"];
|
||||
foreground: string;
|
||||
background: string;
|
||||
r: ReturnType<typeof createRtlStyleHelpers>;
|
||||
metrics: ReturnType<typeof getTemplateMetrics>;
|
||||
picture: Picture;
|
||||
@@ -20,7 +21,14 @@ type BaseTemplateStylesInput = {
|
||||
* ponytail: factory returns plain objects, not StyleSheet.create'd; each template does one
|
||||
* StyleSheet.create pass so the final resolved styles are identical to before.
|
||||
*/
|
||||
export function createBaseTemplateStyles({ metadata, foreground, r, metrics, picture }: BaseTemplateStylesInput) {
|
||||
export function createBaseTemplateStyles({
|
||||
metadata,
|
||||
foreground,
|
||||
background,
|
||||
r,
|
||||
metrics,
|
||||
picture,
|
||||
}: BaseTemplateStylesInput) {
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
@@ -31,6 +39,15 @@ export function createBaseTemplateStyles({ metadata, foreground, r, metrics, pic
|
||||
} satisfies Style;
|
||||
|
||||
return {
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: r.pageDirection,
|
||||
} satisfies Style,
|
||||
|
||||
/** The canonical body text style; alias for `text` in StyleSheet slots. */
|
||||
text: bodyText,
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ describe("ExperienceSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ItemTitle", () => {
|
||||
it("renders award titles without the bold style", () => {
|
||||
expect(source).toContain("const ItemTitle = ({ children, website, bold = true }: ItemTitleProps)");
|
||||
expect(source).toContain("const title = bold ? <Bold>{children}</Bold> : <Text>{children}</Text>;");
|
||||
expect(source).toContain("<ItemTitle website={item.website} bold={false}>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SectionShell", () => {
|
||||
it("keeps section and heading style rules when section heading icons are hidden", () => {
|
||||
expect(source).toContain("<View style={composeStyles(sectionStyle, sectionRuleStyle)} {...breakProps}>");
|
||||
|
||||
@@ -90,6 +90,7 @@ type ItemWebsite = {
|
||||
type ItemTitleProps = {
|
||||
children: ReactNode;
|
||||
website: ItemWebsite;
|
||||
bold?: boolean;
|
||||
};
|
||||
|
||||
type ItemWebsiteLinkProps = {
|
||||
@@ -383,9 +384,9 @@ const SectionItemHeader = ({ children }: SectionItemHeaderProps) => {
|
||||
return <View style={composeStyles(sectionItemHeaderStyle)}>{children}</View>;
|
||||
};
|
||||
|
||||
const ItemTitle = ({ children, website }: ItemTitleProps) => {
|
||||
const ItemTitle = ({ children, website, bold = true }: ItemTitleProps) => {
|
||||
const inlineWebsiteUrl = getInlineItemWebsiteUrl(website);
|
||||
const title = <Bold>{children}</Bold>;
|
||||
const title = bold ? <Bold>{children}</Bold> : <Text>{children}</Text>;
|
||||
|
||||
if (!inlineWebsiteUrl) return title;
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ export const applicationTimelineEntrySchema = z.discriminatedUnion("type", [
|
||||
]);
|
||||
|
||||
export type ApplicationTimelineEntry = z.infer<typeof applicationTimelineEntrySchema>;
|
||||
export type ActivityEvent = ApplicationTimelineEntry;
|
||||
|
||||
// Reserved for AI enrichment output (autofill / match-score). Free-form so the shape can
|
||||
// evolve without a migration. See the AI roadmap in the applications feature.
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("Input", () => {
|
||||
expect(screen.getByPlaceholderText("Enter name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("respects disabled state", async () => {
|
||||
it("respects disabled state", () => {
|
||||
render(<Input data-testid="x" disabled />);
|
||||
const input = screen.getByTestId("x");
|
||||
expect(input).toBeDisabled();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isIP } from "node:net";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
|
||||
function normalizeHostname(hostname: string) {
|
||||
return hostname.trim().toLowerCase();
|
||||
@@ -8,170 +8,62 @@ function stripIpv6Brackets(hostname: string): string {
|
||||
return hostname.replace(/^\[/, "").replace(/\]$/, "");
|
||||
}
|
||||
|
||||
function normalizeIpv4MappedIpv6(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
const mapped = normalized.match(/^::ffff:(?<address>.+)$/)?.groups?.address;
|
||||
if (!mapped) return normalized;
|
||||
|
||||
if (isIP(mapped) === 4) return mapped;
|
||||
|
||||
const hexMatch = mapped.match(/^(?<high>[0-9a-f]{1,4}):(?<low>[0-9a-f]{1,4})$/);
|
||||
if (!hexMatch?.groups) return normalized;
|
||||
|
||||
const { high: highHex, low: lowHex } = hexMatch.groups;
|
||||
if (!highHex || !lowHex) return normalized;
|
||||
|
||||
const high = Number.parseInt(highHex, 16);
|
||||
const low = Number.parseInt(lowHex, 16);
|
||||
if (Number.isNaN(high) || Number.isNaN(low) || high > 0xffff || low > 0xffff) return normalized;
|
||||
|
||||
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
|
||||
}
|
||||
|
||||
function isIpv4MappedIpv6(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
|
||||
return normalized.startsWith("::ffff:");
|
||||
}
|
||||
|
||||
const blockedIpv4Cidrs: Array<[number, number]> = [
|
||||
[knownIpv4ToNumber("0.0.0.0"), 8],
|
||||
[knownIpv4ToNumber("10.0.0.0"), 8],
|
||||
[knownIpv4ToNumber("100.64.0.0"), 10],
|
||||
[knownIpv4ToNumber("127.0.0.0"), 8],
|
||||
[knownIpv4ToNumber("169.254.0.0"), 16],
|
||||
[knownIpv4ToNumber("172.16.0.0"), 12],
|
||||
[knownIpv4ToNumber("192.0.0.0"), 24],
|
||||
[knownIpv4ToNumber("192.0.2.0"), 24],
|
||||
[knownIpv4ToNumber("192.88.99.0"), 24],
|
||||
[knownIpv4ToNumber("192.168.0.0"), 16],
|
||||
[knownIpv4ToNumber("198.18.0.0"), 15],
|
||||
[knownIpv4ToNumber("198.51.100.0"), 24],
|
||||
[knownIpv4ToNumber("203.0.113.0"), 24],
|
||||
[knownIpv4ToNumber("224.0.0.0"), 4],
|
||||
[knownIpv4ToNumber("240.0.0.0"), 4],
|
||||
const blockedIpv4Cidrs: Array<[string, number]> = [
|
||||
["0.0.0.0", 8],
|
||||
["10.0.0.0", 8],
|
||||
["100.64.0.0", 10],
|
||||
["127.0.0.0", 8],
|
||||
["169.254.0.0", 16],
|
||||
["172.16.0.0", 12],
|
||||
["192.0.0.0", 24],
|
||||
["192.0.2.0", 24],
|
||||
["192.88.99.0", 24],
|
||||
["192.168.0.0", 16],
|
||||
["198.18.0.0", 15],
|
||||
["198.51.100.0", 24],
|
||||
["203.0.113.0", 24],
|
||||
["224.0.0.0", 4],
|
||||
["240.0.0.0", 4],
|
||||
];
|
||||
|
||||
const blockedIpv6Cidrs: Array<[bigint, number]> = [
|
||||
[knownIpv6ToBigInt("::"), 128],
|
||||
[knownIpv6ToBigInt("::1"), 128],
|
||||
[knownIpv6ToBigInt("::ffff:0:0"), 96],
|
||||
[knownIpv6ToBigInt("64:ff9b::"), 96],
|
||||
[knownIpv6ToBigInt("64:ff9b:1::"), 48],
|
||||
[knownIpv6ToBigInt("100::"), 64],
|
||||
[knownIpv6ToBigInt("100:0:0:1::"), 64],
|
||||
[knownIpv6ToBigInt("2001::"), 23],
|
||||
[knownIpv6ToBigInt("2001:2::"), 48],
|
||||
[knownIpv6ToBigInt("2001:10::"), 28],
|
||||
[knownIpv6ToBigInt("2001:db8::"), 32],
|
||||
[knownIpv6ToBigInt("2002::"), 16],
|
||||
[knownIpv6ToBigInt("3fff::"), 20],
|
||||
[knownIpv6ToBigInt("5f00::"), 16],
|
||||
[knownIpv6ToBigInt("fc00::"), 7],
|
||||
[knownIpv6ToBigInt("fe80::"), 10],
|
||||
[knownIpv6ToBigInt("ff00::"), 8],
|
||||
const blockedIpv6Cidrs: Array<[string, number]> = [
|
||||
["::", 128],
|
||||
["::1", 128],
|
||||
["::ffff:0:0", 96],
|
||||
["64:ff9b::", 96],
|
||||
["64:ff9b:1::", 48],
|
||||
["100::", 64],
|
||||
["100:0:0:1::", 64],
|
||||
["2001::", 23],
|
||||
["2001:2::", 48],
|
||||
["2001:10::", 28],
|
||||
["2001:db8::", 32],
|
||||
["2002::", 16],
|
||||
["3fff::", 20],
|
||||
["5f00::", 16],
|
||||
["fc00::", 7],
|
||||
["fe80::", 10],
|
||||
["ff00::", 8],
|
||||
];
|
||||
|
||||
function ipv4ToNumber(hostname: string) {
|
||||
const octets = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (octets.length !== 4 || octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) return null;
|
||||
const blockedIpv4s = new BlockList();
|
||||
for (const [address, prefix] of blockedIpv4Cidrs) blockedIpv4s.addSubnet(address, prefix, "ipv4");
|
||||
|
||||
return (((octets[0] ?? 0) << 24) | ((octets[1] ?? 0) << 16) | ((octets[2] ?? 0) << 8) | (octets[3] ?? 0)) >>> 0;
|
||||
}
|
||||
|
||||
function knownIpv4ToNumber(hostname: string) {
|
||||
const value = ipv4ToNumber(hostname);
|
||||
if (value === null) throw new Error(`Invalid IPv4 CIDR base: ${hostname}`);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function isIpv4InCidr(address: number, base: number, prefix: number) {
|
||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
||||
|
||||
return (address & mask) === (base & mask);
|
||||
}
|
||||
|
||||
function expandIpv6(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
const [head = "", tail = ""] = normalized.split("::", 2);
|
||||
const headParts = head ? head.split(":") : [];
|
||||
const tailParts = tail ? tail.split(":") : [];
|
||||
const missing = 8 - headParts.length - tailParts.length;
|
||||
if (missing < 0) return null;
|
||||
|
||||
const parts = [...headParts, ...Array.from({ length: missing }, () => "0"), ...tailParts];
|
||||
if (parts.length !== 8) return null;
|
||||
|
||||
const hextets = parts.map((part) => {
|
||||
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
|
||||
return Number.parseInt(part, 16);
|
||||
});
|
||||
|
||||
return hextets.every((part) => part !== null && part >= 0 && part <= 0xffff) ? (hextets as number[]) : null;
|
||||
}
|
||||
|
||||
function ipv6ToBigInt(hostname: string) {
|
||||
const hextets = expandIpv6(hostname);
|
||||
if (!hextets) return null;
|
||||
|
||||
return hextets.reduce((value, hextet) => (value << 16n) | BigInt(hextet), 0n);
|
||||
}
|
||||
|
||||
function knownIpv6ToBigInt(hostname: string) {
|
||||
const value = ipv6ToBigInt(hostname);
|
||||
if (value === null) throw new Error(`Invalid IPv6 CIDR base: ${hostname}`);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function isIpv6InCidr(address: bigint, base: bigint, prefix: number) {
|
||||
const bits = 128n;
|
||||
const hostBits = bits - BigInt(prefix);
|
||||
const mask = prefix === 0 ? 0n : ((1n << bits) - 1n) ^ ((1n << hostBits) - 1n);
|
||||
|
||||
return (address & mask) === (base & mask);
|
||||
}
|
||||
|
||||
function isLoopbackOrLocalHostname(hostname: string) {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
return (
|
||||
normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || normalized.endsWith(".localhost")
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIPv4(hostname: string) {
|
||||
const address = ipv4ToNumber(hostname);
|
||||
if (address === null) return false;
|
||||
|
||||
return blockedIpv4Cidrs.some(([base, prefix]) => isIpv4InCidr(address, base ?? 0, prefix));
|
||||
}
|
||||
|
||||
function isPrivateIPv6(hostname: string) {
|
||||
const address = ipv6ToBigInt(hostname);
|
||||
if (address === null) return false;
|
||||
|
||||
return blockedIpv6Cidrs.some(([base, prefix]) => isIpv6InCidr(address, base, prefix));
|
||||
}
|
||||
const blockedIpv6s = new BlockList();
|
||||
for (const [address, prefix] of blockedIpv6Cidrs) blockedIpv6s.addSubnet(address, prefix, "ipv6");
|
||||
|
||||
export function isPrivateOrLoopbackHost(hostname: string) {
|
||||
if (isIpv4MappedIpv6(hostname)) return true;
|
||||
|
||||
const normalized = normalizeIpv4MappedIpv6(hostname);
|
||||
if (isLoopbackOrLocalHostname(normalized)) return true;
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
if (normalized.startsWith("::ffff:")) return true;
|
||||
if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".localhost")) return true;
|
||||
|
||||
const ipVersion = isIP(normalized);
|
||||
if (ipVersion === 4) return isPrivateIPv4(normalized);
|
||||
if (ipVersion === 6) return isPrivateIPv6(normalized);
|
||||
if (ipVersion === 4) return blockedIpv4s.check(normalized, "ipv4");
|
||||
if (ipVersion === 6) return blockedIpv6s.check(normalized, "ipv6");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isOAuthLoopbackRedirectHost(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||
}
|
||||
|
||||
export function parseUrl(input: string) {
|
||||
try {
|
||||
return new URL(input);
|
||||
@@ -192,9 +84,9 @@ export function isAllowedOAuthRedirectUri(input: string, trustedOrigins: string[
|
||||
if (parsed.hash) return false;
|
||||
|
||||
const origin = parsed.origin.toLowerCase();
|
||||
const hostname = normalizeHostname(parsed.hostname);
|
||||
const hostname = stripIpv6Brackets(normalizeHostname(parsed.hostname));
|
||||
|
||||
if (parsed.protocol === "http:") return isOAuthLoopbackRedirectHost(hostname);
|
||||
if (parsed.protocol === "http:") return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
if (isPrivateOrLoopbackHost(hostname)) return false;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user