diff --git a/.github/workflows/cv-generator.yml b/.github/workflows/cv-generator.yml new file mode 100644 index 0000000..41c539c --- /dev/null +++ b/.github/workflows/cv-generator.yml @@ -0,0 +1,60 @@ +name: CV Generator + +on: + push: + branches: + - cv-generator-v2 + paths: + - 'lib/cv/**' + - 'app/**/cv/**' + - 'messages/src/en/cv.json' + - 'scripts/cv.mjs' + - 'scripts/render-cv-pdf.tsx' + - 'tests/cv/**' + - '.github/workflows/cv-generator.yml' + workflow_dispatch: + +jobs: + validate-and-render: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Test CV variant data + run: pnpm vitest run tests/cv/cv-variants.test.ts + + - name: Test CV tailoring rules + run: pnpm vitest run tests/cv/cv-tailoring.test.ts + + - name: Test canonical CV PDF export + run: pnpm vitest run tests/cv/cv-pdf-export.test.tsx + + - name: Test tailored CV PDF export + run: pnpm vitest run tests/cv/cv-tailored-pdf-export.test.tsx + + - name: Render CV PDFs + run: node scripts/cv.mjs render-all + + - name: Upload CV PDFs + uses: actions/upload-artifact@v4 + with: + name: cv-pdfs + path: generated/cv/*.pdf + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 57ccb3e..0552ec3 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ build_out/ /data/social/*.json /data/social/*.log !/data/social/*.example.json + +# locally generated CV PDFs +/generated/cv/ diff --git a/app/api/cv/render/route.ts b/app/api/cv/render/route.ts new file mode 100644 index 0000000..d8fd6a0 --- /dev/null +++ b/app/api/cv/render/route.ts @@ -0,0 +1,189 @@ +import { createElement } from 'react'; +import { inflateRawSync } from 'node:zlib'; +import type { Readable } from 'node:stream'; +import { pdf } from '@react-pdf/renderer'; +import { CVDocument } from '@/app/[locale]/(standalone)/cv/CVDocument'; +import { resolveCvPdfAssets } from '@/lib/cv/cv-media'; +import { cvVariantIds, listCVVariants, resolveCVVariant, type CVVariantId } from '@/lib/cv/cv-variants'; +import { parseCVTailoringInput, resolveTailoredCV } from '@/lib/cv/cv-tailoring'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 30; + +const MAX_ENCODED_PAYLOAD_CHARS = 16_000; +const MAX_DECOMPRESSED_PAYLOAD_BYTES = 32_000; +const MAX_RENDER_REQUESTS_PER_MINUTE = 12; +const RATE_LIMIT_ENTRY_TTL_MS = 60_000; + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +const rateLimitEntries = new Map(); + +function getClientKey(request: Request) { + return ( + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + request.headers.get('x-real-ip') || + 'unknown' + ); +} + +function isRateLimited(request: Request) { + const now = Date.now(); + const key = getClientKey(request); + const current = rateLimitEntries.get(key); + + if (!current || current.resetAt <= now) { + rateLimitEntries.set(key, { count: 1, resetAt: now + RATE_LIMIT_ENTRY_TTL_MS }); + return false; + } + + current.count += 1; + if (rateLimitEntries.size > 200) { + for (const [entryKey, entry] of rateLimitEntries) { + if (entry.resetAt <= now) rateLimitEntries.delete(entryKey); + } + } + + return current.count > MAX_RENDER_REQUESTS_PER_MINUTE; +} + +function jsonResponse(body: unknown, status = 200) { + return Response.json(body, { + status, + headers: { + 'Cache-Control': 'no-store', + 'X-Robots-Tag': 'noindex, nofollow, noarchive', + }, + }); +} + +function decodeCompressedPayload(encoded: string) { + if (encoded.length > MAX_ENCODED_PAYLOAD_CHARS) { + throw new Error('Encoded CV payload is too large'); + } + + const compressed = Buffer.from(encoded, 'base64url'); + const inflated = inflateRawSync(compressed, { + maxOutputLength: MAX_DECOMPRESSED_PAYLOAD_BYTES, + }); + + return JSON.parse(inflated.toString('utf8')) as unknown; +} + +async function renderPdfBuffer(cv: ReturnType['cv']) { + const resolvedAssets = await resolveCvPdfAssets(); + const document = createElement(CVDocument, { cv, resolvedAssets }); + const stream = (await pdf(document as Parameters[0]).toBuffer()) as Readable; + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + + stream.on('data', (chunk: Buffer | Uint8Array | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + }); +} + +function sanitizeFilename(filename: string) { + return filename.replace(/["\r\n]/g, '').slice(0, 180); +} + +async function pdfResponse( + resolved: ReturnType, + options: { inline?: boolean; cache?: boolean } = {} +) { + const buffer = await renderPdfBuffer(resolved.cv); + const disposition = options.inline ? 'inline' : 'attachment'; + const filename = sanitizeFilename(resolved.filename); + + return new Response(new Uint8Array(buffer), { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `${disposition}; filename="${filename}"`, + 'Content-Length': String(buffer.byteLength), + 'Cache-Control': options.cache + ? 'public, max-age=0, s-maxage=86400, stale-while-revalidate=604800' + : 'private, no-store', + 'X-Robots-Tag': 'noindex, nofollow, noarchive', + }, + }); +} + +export async function GET(request: Request) { + if (isRateLimited(request)) { + return jsonResponse({ error: 'Too many CV render requests. Try again shortly.' }, 429); + } + + const url = new URL(request.url); + const payload = url.searchParams.get('payload'); + const variant = url.searchParams.get('variant'); + const inline = url.searchParams.get('inline') === '1'; + + try { + if (payload) { + const tailoredInput = parseCVTailoringInput(decodeCompressedPayload(payload)); + return pdfResponse(resolveTailoredCV(tailoredInput), { inline, cache: false }); + } + + if (variant) { + if (!cvVariantIds.includes(variant as CVVariantId)) { + return jsonResponse( + { error: `Unknown CV variant: ${variant}`, variants: listCVVariants() }, + 404 + ); + } + + return pdfResponse(resolveCVVariant(variant as CVVariantId), { inline, cache: true }); + } + + return jsonResponse({ + endpoint: '/api/cv/render', + variants: listCVVariants(), + usage: { + namedVariant: '/api/cv/render?variant=fullstack-healthcare', + inlineNamedVariant: '/api/cv/render?variant=product-frontend&inline=1', + tailoredGet: + 'Deflate-raw a JSON tailoring payload, base64url encode it, then pass it as ?payload=.', + tailoredPost: 'POST the same tailoring payload as application/json.', + }, + safeguards: [ + 'Company, role title, employment dates, duration, and location cannot be overridden.', + 'Payload sizes and text lengths are bounded.', + 'Render requests are rate-limited on a best-effort per-instance basis.', + ], + }); + } catch (error) { + return jsonResponse( + { error: error instanceof Error ? error.message : 'Invalid CV render request' }, + 400 + ); + } +} + +export async function POST(request: Request) { + if (isRateLimited(request)) { + return jsonResponse({ error: 'Too many CV render requests. Try again shortly.' }, 429); + } + + try { + const text = await request.text(); + if (Buffer.byteLength(text, 'utf8') > MAX_DECOMPRESSED_PAYLOAD_BYTES) { + return jsonResponse({ error: 'CV tailoring payload is too large' }, 413); + } + + const input = parseCVTailoringInput(JSON.parse(text) as unknown); + return pdfResponse(resolveTailoredCV(input), { cache: false }); + } catch (error) { + return jsonResponse( + { error: error instanceof Error ? error.message : 'Invalid CV render request' }, + 400 + ); + } +} diff --git a/docs/cv-generator.md b/docs/cv-generator.md new file mode 100644 index 0000000..3109ccf --- /dev/null +++ b/docs/cv-generator.md @@ -0,0 +1,111 @@ +# Tailored CV Generator + +The CV system is designed so that creating a new tailored CV does not require editing the PDF layout. + +## Source of truth + +- `messages/src/en/cv.json` contains the canonical public CV content. +- `lib/cv/cv-data.ts` normalizes that content into the render model. +- `lib/cv/cv-variants.ts` contains named tailoring configs. +- `app/[locale]/(standalone)/cv/CVDocument.tsx` owns the PDF layout. +- `scripts/render-cv-pdf.tsx` renders variants to PDF. +- `scripts/cv.mjs` is the cross-platform command wrapper. + +The public CV remains the `default` variant. Tailored variants are overlays on top of the canonical data rather than copied CV documents. + +## Everyday usage + +List available variants: + +```bash +node scripts/cv.mjs list +``` + +Render one variant: + +```bash +node scripts/cv.mjs render product-frontend +node scripts/cv.mjs render fullstack-healthcare +node scripts/cv.mjs render product-ai +``` + +Render every registered variant: + +```bash +node scripts/cv.mjs render-all +``` + +Use a temporary output directory while experimenting: + +```bash +node scripts/cv.mjs render product-ai --output ./tmp/cv-experiments +``` + +By default PDFs are written to `generated/cv/`, which is gitignored. + +## Adding a new tailored CV + +Add one config object in `lib/cv/cv-variants.ts` and add its ID to `cvVariantIds`. + +A variant can control: + +- PDF filename +- document title +- headline +- summary and meta description +- experience ordering +- experience descriptions and highlights +- skill-group ordering +- skill labels and items +- portfolio project ordering + +Do not copy `CVDocument.tsx` for a new role. The layout should remain shared. + +## Factual safety + +Variant overrides intentionally cannot change the identity fields of an employment record: + +- company +- role title +- dates +- duration +- location + +Those stay canonical. Tailoring is limited to emphasis, summaries, descriptions, highlights, ordering, and skill presentation. + +If a factual employment field needs correction, update the canonical CV source instead of hiding the correction inside one tailored variant. + +## Current targeting lanes + +The initial reusable variants are: + +| Variant | Intended use | +| --- | --- | +| `product-frontend` | Senior Product Engineer roles with strong React / Next.js / frontend ownership | +| `fullstack-healthcare` | Senior Full-Stack roles, especially healthcare, telemedicine, APIs, integrations, and regulated products | +| `product-ai` | Product-oriented AI / full-stack roles where AI is part of a shipped product rather than ML research or model infrastructure | + +All three preserve the complete employment history while changing the first-screen positioning and technical emphasis. + +## Tests + +Variant tests live in `tests/cv/cv-variants.test.ts`. + +Run them with: + +```bash +pnpm vitest run tests/cv/cv-variants.test.ts +``` + +The existing PDF export test remains in `tests/cv/cv-pdf-export.test.tsx` and continues to protect searchable text, page count, contact links, and PDF rendering. + +## Recommended workflow for a specific job + +1. Score the job before tailoring. Only spend time on roles that are genuinely strong matches. +2. Start from the closest reusable lane instead of creating a CV from scratch. +3. Add a new variant only when the job needs materially different emphasis. +4. Keep every claim factual. Do not invent metrics or technologies. +5. Render to a temporary directory while iterating. +6. Once the copy is final, render the named PDF and use that exact file for the application. + +This keeps the layout stable while making content experimentation cheap and reversible. diff --git a/lib/cv/cv-tailoring.ts b/lib/cv/cv-tailoring.ts new file mode 100644 index 0000000..fa186f1 --- /dev/null +++ b/lib/cv/cv-tailoring.ts @@ -0,0 +1,178 @@ +import { z } from 'zod'; +import { + experienceKeys, + portfolioProjectKeys, + skillKeys, + type CVData, + type CVExperienceKey, + type CVPortfolioProjectKey, + type CVSkillKey, +} from './cv-data'; +import { + cvVariantIds, + resolveCVVariant, + type CVVariantId, + type ResolvedCVVariant, +} from './cv-variants'; + +const shortText = z.string().trim().min(1).max(220); +const paragraphText = z.string().trim().min(1).max(1800); +const bulletText = z.string().trim().min(1).max(560); +const skillText = z.string().trim().min(1).max(100); + +const experienceOverrideSchema = z + .object({ + description: z.string().trim().max(1200).optional(), + highlights: z.array(bulletText).max(6).optional(), + }) + .strict(); + +const skillOverrideSchema = z + .object({ + category: z.string().trim().min(1).max(120).optional(), + items: z.array(skillText).max(14).optional(), + }) + .strict(); + +export const cvTailoringInputSchema = z + .object({ + baseVariant: z.enum(cvVariantIds).default('default'), + filename: z + .string() + .trim() + .min(5) + .max(180) + .refine(value => value.toLowerCase().endsWith('.pdf'), 'filename must end in .pdf') + .optional(), + label: shortText.optional(), + title: shortText.optional(), + headline: shortText.optional(), + summary: z + .object({ + text: paragraphText.optional(), + metaDescription: z.string().trim().min(1).max(420).optional(), + }) + .strict() + .optional(), + experienceOrder: z.array(z.enum(experienceKeys)).max(experienceKeys.length).optional(), + experienceOverrides: z.record(z.string(), experienceOverrideSchema).optional(), + skillOrder: z.array(z.enum(skillKeys)).max(skillKeys.length).optional(), + skillOverrides: z.record(z.string(), skillOverrideSchema).optional(), + portfolioProjectOrder: z + .array(z.enum(portfolioProjectKeys)) + .max(portfolioProjectKeys.length) + .optional(), + }) + .strict(); + +export type CVTailoringInput = z.infer; + +const experienceKeySet = new Set(experienceKeys); +const skillKeySet = new Set(skillKeys); + +function assertKnownOverrideKeys(input: CVTailoringInput) { + for (const key of Object.keys(input.experienceOverrides ?? {})) { + if (!experienceKeySet.has(key)) { + throw new Error(`Unknown CV experience key: ${key}`); + } + } + + for (const key of Object.keys(input.skillOverrides ?? {})) { + if (!skillKeySet.has(key)) { + throw new Error(`Unknown CV skill key: ${key}`); + } + } +} + +function orderByKey(items: readonly T[], order?: readonly K[]) { + if (!order) return [...items]; + + const unique = new Set(order); + if (unique.size !== order.length) { + throw new Error('CV tailoring order lists cannot contain duplicate keys'); + } + + const byKey = new Map(items.map(item => [item.key, item] as const)); + const ordered = order.map(key => { + const item = byKey.get(key); + if (!item) throw new Error(`Missing CV item for key: ${key}`); + return item; + }); + + for (const item of items) { + if (!unique.has(item.key)) ordered.push(item); + } + + return ordered; +} + +export function parseCVTailoringInput(value: unknown): CVTailoringInput { + const parsed = cvTailoringInputSchema.parse(value); + assertKnownOverrideKeys(parsed); + return parsed; +} + +export function resolveTailoredCV(rawInput: unknown): ResolvedCVVariant { + const input = parseCVTailoringInput(rawInput); + const base = resolveCVVariant(input.baseVariant as CVVariantId); + const recentKeys = new Set(base.cv.recentExperiences.map(item => item.key)); + const earlierKeys = new Set(base.cv.earlierExperiences.map(item => item.key)); + + const experiences = orderByKey( + base.cv.experiences.map(experience => { + const override = input.experienceOverrides?.[experience.key]; + if (!override) return experience; + + return { + ...experience, + description: override.description ?? experience.description, + highlights: override.highlights ? [...override.highlights] : [...experience.highlights], + }; + }), + input.experienceOrder as CVExperienceKey[] | undefined + ); + + const skills = orderByKey( + base.cv.skills.map(skill => { + const override = input.skillOverrides?.[skill.key]; + if (!override) return skill; + + return { + ...skill, + category: override.category ?? skill.category, + items: override.items ? [...override.items] : [...skill.items], + }; + }), + input.skillOrder as CVSkillKey[] | undefined + ); + + const projects = orderByKey( + base.cv.portfolio.projects, + input.portfolioProjectOrder as CVPortfolioProjectKey[] | undefined + ); + + const cv: CVData = { + ...base.cv, + title: input.title ?? base.cv.title, + headline: input.headline ?? base.cv.headline, + summary: { + ...base.cv.summary, + ...input.summary, + }, + experiences, + recentExperiences: experiences.filter(item => recentKeys.has(item.key)), + earlierExperiences: experiences.filter(item => earlierKeys.has(item.key)), + skills, + portfolio: { + ...base.cv.portfolio, + projects, + }, + }; + + return { + id: base.id, + label: input.label ?? `${base.label} tailored`, + filename: input.filename ?? base.filename, + cv, + }; +} diff --git a/lib/cv/cv-variants.ts b/lib/cv/cv-variants.ts new file mode 100644 index 0000000..20b0904 --- /dev/null +++ b/lib/cv/cv-variants.ts @@ -0,0 +1,337 @@ +import { + getEnglishCVData, + type CVData, + type CVExperienceItem, + type CVExperienceKey, + type CVPortfolioProjectKey, + type CVSkillGroup, + type CVSkillKey, +} from './cv-data'; + +export const cvVariantIds = [ + 'default', + 'product-frontend', + 'fullstack-healthcare', + 'product-ai', +] as const; + +export type CVVariantId = (typeof cvVariantIds)[number]; + +type ExperienceOverride = Partial>; +type SkillOverride = Partial>; + +export interface CVVariantConfig { + id: CVVariantId; + label: string; + filename: string; + title?: string; + headline?: string; + summary?: Partial; + experienceOrder?: readonly CVExperienceKey[]; + experienceOverrides?: Partial>; + skillOrder?: readonly CVSkillKey[]; + skillOverrides?: Partial>; + portfolioProjectOrder?: readonly CVPortfolioProjectKey[]; +} + +export interface ResolvedCVVariant { + id: CVVariantId; + label: string; + filename: string; + cv: CVData; +} + +const allExperienceKeys: readonly CVExperienceKey[] = [ + 'cartshift', + 'curalife', + 'paragonex', + 'ecommerce_venture', + 'hot', + 'leumi', + 'entrepreneurship', + 'elbit', + 'airforce', +]; + +const productFirstSkillOrder: readonly CVSkillKey[] = [ + 'productEngineering', + 'frontendFullStack', + 'commerceIntegrations', + 'cloudData', + 'aiAutomation', + 'legacyEnterprise', +]; + +const fullStackSkillOrder: readonly CVSkillKey[] = [ + 'frontendFullStack', + 'productEngineering', + 'cloudData', + 'commerceIntegrations', + 'aiAutomation', + 'legacyEnterprise', +]; + +const aiSkillOrder: readonly CVSkillKey[] = [ + 'aiAutomation', + 'productEngineering', + 'frontendFullStack', + 'cloudData', + 'commerceIntegrations', + 'legacyEnterprise', +]; + +const curalifeOwnershipHighlights = [ + 'Defined, architected, and built Curalife’s HIPAA-compliant telemedicine acquisition product end-to-end, spanning the patient-facing flow, backend logic, integrations, cloud infrastructure, and production operations.', + 'The telemedicine product became one of Curalife’s main customer-acquisition and revenue funnels.', + 'Built and evolved customer-facing healthcare experiences with Next.js, React, TypeScript, and Google Cloud Platform, integrating healthcare, commerce, and operational systems.', + 'Led modernization of frontend architecture and production workflows with a focus on privacy, reliability, and maintainability.', +]; + +export const cvVariants: Record = { + default: { + id: 'default', + label: 'Default public CV', + filename: 'yotam-faraggi-senior-product-engineer-cv.pdf', + }, + 'product-frontend': { + id: 'product-frontend', + label: 'Product / Frontend', + filename: 'Yotam Faraggi CV - Product Frontend - Premium 2026 V2.pdf', + title: 'Yotam Faraggi - Senior Product Engineer CV', + headline: 'Senior Product Engineer | React, Next.js & Full-Stack Web Products', + summary: { + text: 'Senior Product Engineer with 10+ years of experience building customer-facing web products across healthcare, e-commerce, fintech, and enterprise software. Strongest in React, Next.js, TypeScript, and full-stack product development, with hands-on Node.js, APIs, integrations, cloud infrastructure, and production ownership. At Curalife, defined, architected, and built the telemedicine acquisition product end-to-end, from patient-facing experience through backend systems, integrations, deployment, and ongoing operation.', + metaDescription: + 'Senior Product Engineer in Berlin with 10+ years of React, Next.js, TypeScript, full-stack product, API, integration, and production experience.', + }, + experienceOrder: allExperienceKeys, + experienceOverrides: { + curalife: { + description: + 'Owned major customer-facing product work across telemedicine and e-commerce, from product definition and architecture through implementation, integrations, cloud infrastructure, and production operation.', + highlights: curalifeOwnershipHighlights, + }, + }, + skillOrder: productFirstSkillOrder, + portfolioProjectOrder: ['starlinker', 'rightflow', 'cartshift', 'atlasIrwin'], + }, + 'fullstack-healthcare': { + id: 'fullstack-healthcare', + label: 'Full-Stack / Healthcare', + filename: 'Yotam Faraggi CV - FullStack Healthcare - Premium 2026 V2.pdf', + title: 'Yotam Faraggi - Senior Full-Stack Engineer CV', + headline: 'Senior Full-Stack Engineer | Healthcare, APIs & Product Systems', + summary: { + text: 'Senior Full-Stack Engineer with 10+ years of experience building web products, APIs, integrations, and production systems, including more than four years of hands-on healthcare and telemedicine work. At Curalife, defined, architected, and built a HIPAA-compliant telemedicine acquisition product end-to-end across React and Next.js frontend flows, backend logic, external integrations, Google Cloud infrastructure, and production operation. Experienced in translating complex product and operational requirements into reliable customer-facing systems.', + metaDescription: + 'Senior Full-Stack Engineer in Berlin with healthcare, telemedicine, React, Next.js, TypeScript, APIs, integrations, cloud, and production experience.', + }, + experienceOrder: allExperienceKeys, + experienceOverrides: { + curalife: { + description: + 'Built and owned customer-facing healthcare and e-commerce systems in a privacy-sensitive production environment, spanning frontend, backend, integrations, and cloud infrastructure.', + highlights: curalifeOwnershipHighlights, + }, + }, + skillOrder: fullStackSkillOrder, + skillOverrides: { + productEngineering: { + items: [ + 'System design', + 'API design', + 'Technical scoping', + 'Web application architecture', + 'Reliability', + 'Performance optimization', + 'Customer-facing product development', + ], + }, + cloudData: { + items: [ + 'Google Cloud Platform', + 'PostgreSQL', + 'Firebase', + 'Firestore', + 'Docker', + 'Vercel', + 'GitHub Actions', + ], + }, + }, + portfolioProjectOrder: ['rightflow', 'starlinker', 'cartshift', 'atlasIrwin'], + }, + 'product-ai': { + id: 'product-ai', + label: 'Product / AI', + filename: 'Yotam Faraggi CV - Product AI - Premium 2026 V2.pdf', + title: 'Yotam Faraggi - Senior Product Engineer, AI & Full-Stack CV', + headline: 'Senior Product Engineer | AI & Full-Stack Products', + summary: { + text: 'Senior Product Engineer with 10+ years of experience building and shipping customer-facing software across healthcare, e-commerce, fintech, and enterprise systems. Combines React, Next.js, TypeScript, Node.js, APIs, integrations, and cloud experience with hands-on development of AI-assisted product workflows and automation. Focused on applying AI inside useful production products rather than model research, with end-to-end ownership from product definition and architecture through implementation and deployment.', + metaDescription: + 'Senior Product Engineer in Berlin building AI-assisted and full-stack products with React, Next.js, TypeScript, Node.js, APIs, integrations, and cloud systems.', + }, + experienceOrder: allExperienceKeys, + experienceOverrides: { + cartshift: { + description: + 'Independent product and web development studio focused on full-stack products, e-commerce, workflow automation, and practical AI-assisted tools.', + highlights: [ + 'Build and ship full-stack web products using Next.js, React, TypeScript, APIs, and modern cloud tooling.', + 'Develop AI-assisted workflow tools and product experiments that use LLM APIs and automation to support real user tasks.', + 'Own projects from product definition and technical planning through implementation, deployment, iteration, and maintenance.', + ], + }, + curalife: { + description: + 'Owned major customer-facing product work across telemedicine and e-commerce, spanning product definition, architecture, implementation, integrations, cloud infrastructure, and production operation.', + highlights: curalifeOwnershipHighlights, + }, + }, + skillOrder: aiSkillOrder, + skillOverrides: { + aiAutomation: { + category: 'AI Product & Automation', + items: [ + 'OpenAI API', + 'Claude API', + 'LangChain', + 'LLM-assisted workflows', + 'Webhooks', + 'Puppeteer', + 'Playwright', + ], + }, + }, + portfolioProjectOrder: ['starlinker', 'rightflow', 'cartshift', 'atlasIrwin'], + }, +}; + +function assertKnownUniqueOrder( + name: string, + order: readonly T[] | undefined, + allowed: ReadonlySet +) { + if (!order) return; + + const seen = new Set(); + for (const key of order) { + if (!allowed.has(key)) { + throw new Error(`CV variant ${name} references unknown key: ${key}`); + } + if (seen.has(key)) { + throw new Error(`CV variant ${name} contains duplicate key: ${key}`); + } + seen.add(key); + } +} + +export function validateCVVariantConfig(config: CVVariantConfig) { + if (!config.filename.toLowerCase().endsWith('.pdf')) { + throw new Error(`CV variant ${config.id} filename must end in .pdf`); + } + + const base = getEnglishCVData(); + const experienceKeys = new Set(base.experiences.map(item => item.key)); + const skillKeys = new Set(base.skills.map(item => item.key)); + const portfolioKeys = new Set(base.portfolio.projects.map(item => item.key)); + + assertKnownUniqueOrder(config.id, config.experienceOrder, experienceKeys); + assertKnownUniqueOrder(config.id, config.skillOrder, skillKeys); + assertKnownUniqueOrder(config.id, config.portfolioProjectOrder, portfolioKeys); + + for (const key of Object.keys(config.experienceOverrides ?? {}) as CVExperienceKey[]) { + if (!experienceKeys.has(key)) { + throw new Error(`CV variant ${config.id} overrides unknown experience: ${key}`); + } + } + + for (const key of Object.keys(config.skillOverrides ?? {}) as CVSkillKey[]) { + if (!skillKeys.has(key)) { + throw new Error(`CV variant ${config.id} overrides unknown skill group: ${key}`); + } + } +} + +function orderByKey( + items: readonly T[], + order?: readonly K[] +): T[] { + if (!order) return [...items]; + const byKey = new Map(items.map(item => [item.key, item] as const)); + return order.map(key => { + const item = byKey.get(key); + if (!item) throw new Error(`Missing CV item for key: ${key}`); + return item; + }); +} + +export function resolveCVVariant(id: CVVariantId = 'default'): ResolvedCVVariant { + const config = cvVariants[id]; + if (!config) throw new Error(`Unknown CV variant: ${id}`); + validateCVVariantConfig(config); + + const base = getEnglishCVData(); + const recentKeys = new Set(base.recentExperiences.map(item => item.key)); + const earlierKeys = new Set(base.earlierExperiences.map(item => item.key)); + + const experiences = orderByKey( + base.experiences.map(experience => { + const override = config.experienceOverrides?.[experience.key]; + return { + ...experience, + ...override, + highlights: override?.highlights ? [...override.highlights] : [...experience.highlights], + }; + }), + config.experienceOrder + ); + + const skills = orderByKey( + base.skills.map(skill => { + const override = config.skillOverrides?.[skill.key]; + return { + ...skill, + ...override, + items: override?.items ? [...override.items] : [...skill.items], + }; + }), + config.skillOrder + ); + + const projects = orderByKey(base.portfolio.projects, config.portfolioProjectOrder); + + const cv: CVData = { + ...base, + title: config.title ?? base.title, + headline: config.headline ?? base.headline, + summary: { + ...base.summary, + ...config.summary, + }, + experiences, + recentExperiences: experiences.filter(item => recentKeys.has(item.key)), + earlierExperiences: experiences.filter(item => earlierKeys.has(item.key)), + skills, + portfolio: { + ...base.portfolio, + projects, + }, + }; + + return { + id, + label: config.label, + filename: config.filename, + cv, + }; +} + +export function listCVVariants() { + return cvVariantIds.map(id => ({ + id, + label: cvVariants[id].label, + filename: cvVariants[id].filename, + })); +} diff --git a/scripts/cv.mjs b/scripts/cv.mjs new file mode 100644 index 0000000..1f4974d --- /dev/null +++ b/scripts/cv.mjs @@ -0,0 +1,47 @@ +import { spawnSync } from 'node:child_process'; + +const [command, ...rest] = process.argv.slice(2); + +const usage = `CV generator\n\nCommands:\n node scripts/cv.mjs list\n node scripts/cv.mjs render [--output ]\n node scripts/cv.mjs render-all [--output ]\n\nExamples:\n node scripts/cv.mjs render product-frontend\n node scripts/cv.mjs render fullstack-healthcare --output ./tmp/cvs\n node scripts/cv.mjs render-all\n`; + +let renderArgs; + +switch (command) { + case 'list': + renderArgs = ['--list']; + break; + case 'render': + renderArgs = rest; + break; + case 'render-all': + renderArgs = ['--all', ...rest]; + break; + case 'help': + case '--help': + case '-h': + case undefined: + console.log(usage); + process.exit(0); + break; + default: + console.error(`Unknown CV command: ${command}\n`); + console.log(usage); + process.exit(1); +} + +const result = spawnSync( + 'pnpm', + ['exec', 'tsx', 'scripts/render-cv-pdf.tsx', ...renderArgs], + { + cwd: process.cwd(), + stdio: 'inherit', + shell: process.platform === 'win32', + } +); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/scripts/render-cv-pdf.tsx b/scripts/render-cv-pdf.tsx new file mode 100644 index 0000000..10583f5 --- /dev/null +++ b/scripts/render-cv-pdf.tsx @@ -0,0 +1,107 @@ +import { createWriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import type { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { pdf } from '@react-pdf/renderer'; +import { CVDocument } from '@/app/[locale]/(standalone)/cv/CVDocument'; +import { resolveCvPdfAssets } from '@/lib/cv/cv-media'; +import { + cvVariantIds, + listCVVariants, + resolveCVVariant, + type CVVariantId, +} from '@/lib/cv/cv-variants'; + +const DEFAULT_OUTPUT_DIR = 'generated/cv'; + +function printUsage() { + console.log(`CV generator\n\nUsage:\n pnpm cv:list\n pnpm cv:render [--output ]\n pnpm cv:render:all [--output ]\n\nExamples:\n pnpm cv:render product-frontend\n pnpm cv:render fullstack-healthcare --output ./tmp/cvs\n pnpm cv:render:all\n`); +} + +function getOutputDirectory(args: string[]) { + const outputIndex = args.indexOf('--output'); + if (outputIndex === -1) return resolve(process.cwd(), DEFAULT_OUTPUT_DIR); + + const value = args[outputIndex + 1]; + if (!value || value.startsWith('--')) { + throw new Error('--output requires a directory path'); + } + + return resolve(process.cwd(), value); +} + +function getPositionalArgs(args: string[]) { + const positional: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + if (arg === '--output') { + index += 1; + continue; + } + if (!arg.startsWith('--')) positional.push(arg); + } + + return positional; +} + +function getRequestedVariant(args: string[]): CVVariantId { + const [value] = getPositionalArgs(args); + + if (!value) { + throw new Error('Missing CV variant. Run `pnpm cv:list` to see available variants.'); + } + + if (!cvVariantIds.includes(value as CVVariantId)) { + throw new Error(`Unknown CV variant: ${value}. Run \`pnpm cv:list\` to see available variants.`); + } + + return value as CVVariantId; +} + +async function renderVariant(id: CVVariantId, outputDirectory: string) { + const { cv, filename, label } = resolveCVVariant(id); + const resolvedAssets = await resolveCvPdfAssets(); + const outputPath = resolve(outputDirectory, filename); + const stream = (await pdf( + + ).toBuffer()) as Readable; + + await mkdir(outputDirectory, { recursive: true }); + await pipeline(stream, createWriteStream(outputPath)); + + console.log(`✓ ${label}: ${outputPath}`); +} + +async function main() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + return; + } + + if (args.includes('--list')) { + for (const variant of listCVVariants()) { + console.log(`${variant.id.padEnd(22)} ${variant.label} -> ${variant.filename}`); + } + return; + } + + const outputDirectory = getOutputDirectory(args); + + if (args.includes('--all')) { + for (const id of cvVariantIds) { + await renderVariant(id, outputDirectory); + } + return; + } + + await renderVariant(getRequestedVariant(args), outputDirectory); +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/tests/cv/cv-pdf-export.test.tsx b/tests/cv/cv-pdf-export.test.tsx index 1df4dde..106bbc6 100644 --- a/tests/cv/cv-pdf-export.test.tsx +++ b/tests/cv/cv-pdf-export.test.tsx @@ -47,6 +47,10 @@ function expectPdfTextIncludes(text: string, expected: string) { expect(text.toLowerCase()).toContain(expected.toLowerCase()); } +function expectPdfTextExcludes(text: string, forbidden: string) { + expect(text.toLowerCase()).not.toContain(forbidden.toLowerCase()); +} + describe('CV PDF export', () => { it('generates a searchable two-page PDF with clickable recruiter contact links', async () => { const buffer = await renderPdfBuffer(); @@ -66,6 +70,7 @@ describe('CV PDF export', () => { [ 'Yotam Faraggi', 'Senior Product Engineer', + 'Full-Stack, APIs & Integrations', 'EU citizen', '+4915776211298', 'Professional Experience', @@ -73,20 +78,22 @@ describe('CV PDF export', () => { 'Portfolio: cart-shift.com/en/cv', 'CartShift Studio', 'Curalife', + 'telemedicine acquisition product end-to-end', + 'customer-acquisition and revenue funnels', 'ParagonEX', 'HOT', 'Leumi Bank', 'Elbit Systems', - 'Israeli Air Force / Mamram', - 'Military Service', - 'IDF School for Computer Professions', - 'Programming Course', + 'IDF / Mamram', + 'Basmach / Mamram', + 'Software Development Program', 'Bar-Ilan University', - 'LEGACY ENTERPRISE', + 'Enterprise Integration', 'WordPress', 'HubSpot', 'Web application architecture', 'Google Cloud Platform', + 'PostgreSQL', ].forEach(expected => expectPdfTextIncludes(text, expected)); [ @@ -97,9 +104,15 @@ describe('CV PDF export', () => { 'Page 1 of 2', 'Page 2 of 2', 'Live CV & Portfolio', + 'Israeli Air Force / Mamram', + 'Military Service', + 'military helicopter systems', + 'IDF School for Computer Professions', + 'Programming Course', + 'LEGACY ENTERPRISE', ].forEach(forbidden => { - expect(text).not.toContain(forbidden); - expect(raw).not.toContain(forbidden); + expectPdfTextExcludes(text, forbidden); + expect(raw.toLowerCase()).not.toContain(forbidden.toLowerCase()); }); expect(raw).not.toMatch(/Page\s+\d+\s+of\s+\d+/i); diff --git a/tests/cv/cv-tailored-pdf-export.test.tsx b/tests/cv/cv-tailored-pdf-export.test.tsx new file mode 100644 index 0000000..6460eed --- /dev/null +++ b/tests/cv/cv-tailored-pdf-export.test.tsx @@ -0,0 +1,41 @@ +import { pdf } from '@react-pdf/renderer'; +import type { Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { CVDocument } from '@/app/[locale]/(standalone)/cv/CVDocument'; +import { resolveCvPdfAssets } from '@/lib/cv/cv-media'; +import { resolveCVVariant, type CVVariantId } from '@/lib/cv/cv-variants'; + +const tailoredVariants: CVVariantId[] = [ + 'product-frontend', + 'fullstack-healthcare', + 'product-ai', +]; + +async function renderVariantBuffer(id: CVVariantId) { + const resolvedAssets = await resolveCvPdfAssets(); + const { cv } = resolveCVVariant(id); + const stream = (await pdf( + + ).toBuffer()) as Readable; + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + }); +} + +describe('tailored CV PDF export', () => { + for (const id of tailoredVariants) { + it(`${id} remains a two-page searchable PDF`, async () => { + const buffer = await renderVariantBuffer(id); + const raw = buffer.toString('latin1'); + + expect(raw.match(/\/Type\s*\/Page\b/g)).toHaveLength(2); + expect(raw).toContain('/URI (mailto:yotamon@gmail.com)'); + expect(raw).toContain('/URI (https://linkedin.com/in/yotam-faraggi)'); + expect(buffer.length).toBeGreaterThan(20_000); + }, 30_000); + } +}); diff --git a/tests/cv/cv-tailoring.test.ts b/tests/cv/cv-tailoring.test.ts new file mode 100644 index 0000000..c3d742d --- /dev/null +++ b/tests/cv/cv-tailoring.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCVVariant } from '@/lib/cv/cv-variants'; +import { parseCVTailoringInput, resolveTailoredCV } from '@/lib/cv/cv-tailoring'; + +describe('dynamic CV tailoring', () => { + it('starts from a named lane and only changes allowed emphasis fields', () => { + const base = resolveCVVariant('fullstack-healthcare'); + const tailored = resolveTailoredCV({ + baseVariant: 'fullstack-healthcare', + filename: 'Yotam Faraggi CV - Example Health.pdf', + headline: 'Senior Full-Stack Engineer | Healthcare Product Systems', + summary: { + text: 'Tailored summary for a healthcare product role.', + }, + experienceOverrides: { + curalife: { + description: 'Tailored Curalife emphasis without changing factual employment identity.', + highlights: ['Tailored, factual highlight.'], + }, + }, + }); + + expect(tailored.filename).toBe('Yotam Faraggi CV - Example Health.pdf'); + expect(tailored.cv.headline).toBe('Senior Full-Stack Engineer | Healthcare Product Systems'); + expect(tailored.cv.summary.text).toBe('Tailored summary for a healthcare product role.'); + + const baseCuralife = base.cv.experiences.find(item => item.key === 'curalife')!; + const tailoredCuralife = tailored.cv.experiences.find(item => item.key === 'curalife')!; + + expect(tailoredCuralife.company).toBe(baseCuralife.company); + expect(tailoredCuralife.title).toBe(baseCuralife.title); + expect(tailoredCuralife.duration).toBe(baseCuralife.duration); + expect(tailoredCuralife.durationYears).toBe(baseCuralife.durationYears); + expect(tailoredCuralife.location).toBe(baseCuralife.location); + expect(tailoredCuralife.description).toBe( + 'Tailored Curalife emphasis without changing factual employment identity.' + ); + }); + + it('rejects unknown override keys and oversized content', () => { + expect(() => + parseCVTailoringInput({ + baseVariant: 'product-frontend', + experienceOverrides: { + imaginary_job: { highlights: ['Not allowed'] }, + }, + }) + ).toThrow(/unknown cv experience key/i); + + expect(() => + parseCVTailoringInput({ + baseVariant: 'product-ai', + summary: { text: 'x'.repeat(1801) }, + }) + ).toThrow(); + }); + + it('keeps omitted items when a custom order only prioritizes a subset', () => { + const tailored = resolveTailoredCV({ + baseVariant: 'product-frontend', + skillOrder: ['frontendFullStack', 'productEngineering'], + portfolioProjectOrder: ['rightflow'], + }); + + expect(tailored.cv.skills).toHaveLength(resolveCVVariant('product-frontend').cv.skills.length); + expect(tailored.cv.skills[0]?.key).toBe('frontendFullStack'); + expect(tailored.cv.skills[1]?.key).toBe('productEngineering'); + expect(tailored.cv.portfolio.projects[0]?.key).toBe('rightflow'); + expect(tailored.cv.portfolio.projects).toHaveLength( + resolveCVVariant('product-frontend').cv.portfolio.projects.length + ); + }); +}); diff --git a/tests/cv/cv-variants.test.ts b/tests/cv/cv-variants.test.ts new file mode 100644 index 0000000..2a9184d --- /dev/null +++ b/tests/cv/cv-variants.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { getEnglishCVData } from '@/lib/cv/cv-data'; +import { + cvVariantIds, + cvVariants, + resolveCVVariant, + validateCVVariantConfig, +} from '@/lib/cv/cv-variants'; + +describe('CV variants', () => { + it('keeps the default variant identical to the canonical English CV', () => { + expect(resolveCVVariant('default').cv).toEqual(getEnglishCVData()); + }); + + it('validates every registered variant', () => { + for (const id of cvVariantIds) { + expect(() => validateCVVariantConfig(cvVariants[id])).not.toThrow(); + expect(cvVariants[id].filename.toLowerCase()).toMatch(/\.pdf$/); + } + }); + + it('preserves factual experience identity while allowing tailored emphasis', () => { + const base = getEnglishCVData(); + + for (const id of cvVariantIds) { + const resolved = resolveCVVariant(id).cv; + + for (const experience of resolved.experiences) { + const canonical = base.experiences.find(item => item.key === experience.key); + expect(canonical).toBeDefined(); + expect(experience.company).toBe(canonical?.company); + expect(experience.title).toBe(canonical?.title); + expect(experience.duration).toBe(canonical?.duration); + expect(experience.durationYears).toBe(canonical?.durationYears); + expect(experience.location).toBe(canonical?.location); + } + } + }); + + it('keeps Berlin and German work authorization prominent in every variant', () => { + for (const id of cvVariantIds) { + const cv = resolveCVVariant(id).cv; + expect(cv.location).toBe('Berlin, Germany'); + expect(cv.workAuthorization).toContain('EU citizen'); + expect(cv.workAuthorization).toContain('Authorized to work in Germany'); + } + }); + + it('keeps military experience factual without restoring military-heavy labeling', () => { + for (const id of cvVariantIds) { + const cv = resolveCVVariant(id).cv; + const service = cv.experiences.find(item => item.key === 'airforce'); + const serialized = JSON.stringify(cv); + + expect(service?.company).toBe('IDF / Mamram'); + expect(serialized).not.toContain('Israeli Air Force'); + expect(serialized).not.toContain('Military Service'); + expect(serialized).not.toContain('IDF School for Computer Professions'); + expect(serialized).not.toContain('Intensive military software development training'); + expect(serialized).not.toContain('military helicopter systems'); + } + }); + + it('surfaces end-to-end Curalife ownership in every tailored lane', () => { + for (const id of ['product-frontend', 'fullstack-healthcare', 'product-ai'] as const) { + const curalife = resolveCVVariant(id).cv.experiences.find(item => item.key === 'curalife'); + const combined = [curalife?.description, ...(curalife?.highlights ?? [])].join(' '); + + expect(combined).toContain('Defined, architected, and built'); + expect(combined).toContain('end-to-end'); + expect(combined).toContain('customer-acquisition and revenue funnels'); + } + }); + + it('reorders emphasis without dropping historical roles in the current tailored variants', () => { + const baseKeys = getEnglishCVData().experiences.map(item => item.key); + + for (const id of ['product-frontend', 'fullstack-healthcare', 'product-ai'] as const) { + expect(resolveCVVariant(id).cv.experiences.map(item => item.key)).toEqual(baseKeys); + } + }); +});