From 9ed80c2b28f78de1341e19dc256496684ef92eb2 Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:21:13 +0200 Subject: [PATCH 01/19] feat(cv): add typed tailored CV variants --- lib/cv/cv-variants.ts | 337 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 lib/cv/cv-variants.ts 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, + })); +} From 41d4483d0c4c8d48f46d9c737d8641706cb77dc2 Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:21:51 +0200 Subject: [PATCH 02/19] feat(cv): add reusable PDF render CLI --- scripts/render-cv-pdf.tsx | 93 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 scripts/render-cv-pdf.tsx diff --git a/scripts/render-cv-pdf.tsx b/scripts/render-cv-pdf.tsx new file mode 100644 index 0000000..494376f --- /dev/null +++ b/scripts/render-cv-pdf.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +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 getRequestedVariant(args: string[]): CVVariantId { + const value = args.find(arg => !arg.startsWith('--') && arg !== args[args.indexOf('--output') + 1]); + + 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; +}); From 02fa51279ecff5ec484a6880000b043d2f75129f Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:23:02 +0200 Subject: [PATCH 03/19] fix(cv): make variant CLI parsing deterministic --- scripts/render-cv-pdf.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/render-cv-pdf.tsx b/scripts/render-cv-pdf.tsx index 494376f..3cb7743 100644 --- a/scripts/render-cv-pdf.tsx +++ b/scripts/render-cv-pdf.tsx @@ -32,8 +32,23 @@ function getOutputDirectory(args: string[]) { 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 = args.find(arg => !arg.startsWith('--') && arg !== args[args.indexOf('--output') + 1]); + const [value] = getPositionalArgs(args); if (!value) { throw new Error('Missing CV variant. Run `pnpm cv:list` to see available variants.'); From 464dc3844a0a7b942351b349702b39773f55cc1b Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:23:25 +0200 Subject: [PATCH 04/19] test(cv): cover tailored variant resolution --- tests/cv/cv-variants.test.ts | 58 ++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/cv/cv-variants.test.ts diff --git a/tests/cv/cv-variants.test.ts b/tests/cv/cv-variants.test.ts new file mode 100644 index 0000000..d86e353 --- /dev/null +++ b/tests/cv/cv-variants.test.ts @@ -0,0 +1,58 @@ +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('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); + } + }); +}); From a91f9f70a51e1e608b7b7ce4727e8a490cb6b4eb Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:24:16 +0200 Subject: [PATCH 05/19] feat(cv): add cross-platform CV generator command --- scripts/cv.mjs | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 scripts/cv.mjs 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); From 4385ae6aea34cad867381f92a8b5c60d93a96e25 Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:24:33 +0200 Subject: [PATCH 06/19] chore(cv): ignore generated CV PDFs --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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/ From 7d5e8c82291a4bf66123650d26cbce181a1e37a7 Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:25:07 +0200 Subject: [PATCH 07/19] docs(cv): document repeatable tailored CV workflow --- docs/cv-generator.md | 111 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/cv-generator.md 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. From be2e9b6efac4321b857e7a8292f6ba18bf654581 Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:26:26 +0200 Subject: [PATCH 08/19] test(cv): enforce two-page tailored PDF output --- tests/cv/cv-tailored-pdf-export.test.tsx | 41 ++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/cv/cv-tailored-pdf-export.test.tsx 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); + } +}); From 9739e9f523d1fb5b2d90170cc87aefa8c7ca9a5d Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:26:44 +0200 Subject: [PATCH 09/19] ci(cv): validate and render tailored CVs --- .github/workflows/cv-generator.yml | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/cv-generator.yml diff --git a/.github/workflows/cv-generator.yml b/.github/workflows/cv-generator.yml new file mode 100644 index 0000000..88905a5 --- /dev/null +++ b/.github/workflows/cv-generator.yml @@ -0,0 +1,51 @@ +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 variants + run: pnpm vitest run tests/cv/cv-variants.test.ts tests/cv/cv-pdf-export.test.tsx 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 From 9e7fee9614559065dc1cc86338b701ce39df8e9b Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:54:59 +0200 Subject: [PATCH 10/19] fix(cv): keep CLI compatible with production typecheck --- scripts/render-cv-pdf.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/render-cv-pdf.tsx b/scripts/render-cv-pdf.tsx index 3cb7743..10583f5 100644 --- a/scripts/render-cv-pdf.tsx +++ b/scripts/render-cv-pdf.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { createWriteStream } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import { resolve } from 'node:path'; From 723be899dd2423f1bb22b73cd9068f13f9b1b0f6 Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:55:39 +0200 Subject: [PATCH 11/19] feat(cv): add validated one-off tailoring payloads --- lib/cv/cv-tailoring.ts | 178 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 lib/cv/cv-tailoring.ts 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, + }; +} From f07eaa7c9219d3128e6fb7c221455df61333b14d Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:56:47 +0200 Subject: [PATCH 12/19] feat(cv): expose production PDF render API --- app/api/cv/render/route.ts | 189 +++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 app/api/cv/render/route.ts diff --git a/app/api/cv/render/route.ts b/app/api/cv/render/route.ts new file mode 100644 index 0000000..8436db8 --- /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).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 + ); + } +} From e3b3769bfdb78e6069f266092f9983e01ecfac7a Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:57:44 +0200 Subject: [PATCH 13/19] test(cv): cover dynamic tailoring safeguards --- tests/cv/cv-tailoring.test.ts | 73 +++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/cv/cv-tailoring.test.ts 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 + ); + }); +}); From 382636613259ad1a96a7ab87e9acf7875c6ba31d Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 15:58:13 +0200 Subject: [PATCH 14/19] test(cv): include dynamic tailoring in generator CI --- .github/workflows/cv-generator.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cv-generator.yml b/.github/workflows/cv-generator.yml index 88905a5..709187e 100644 --- a/.github/workflows/cv-generator.yml +++ b/.github/workflows/cv-generator.yml @@ -38,7 +38,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Test CV variants - run: pnpm vitest run tests/cv/cv-variants.test.ts tests/cv/cv-pdf-export.test.tsx tests/cv/cv-tailored-pdf-export.test.tsx + run: pnpm vitest run tests/cv/cv-variants.test.ts tests/cv/cv-tailoring.test.ts tests/cv/cv-pdf-export.test.tsx tests/cv/cv-tailored-pdf-export.test.tsx - name: Render CV PDFs run: node scripts/cv.mjs render-all From 345608c8510ec3a70dfb1412bae63426862d6996 Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 16:00:47 +0200 Subject: [PATCH 15/19] fix(cv): satisfy react-pdf route render typing --- app/api/cv/render/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/cv/render/route.ts b/app/api/cv/render/route.ts index 8436db8..d8fd6a0 100644 --- a/app/api/cv/render/route.ts +++ b/app/api/cv/render/route.ts @@ -77,7 +77,7 @@ function decodeCompressedPayload(encoded: string) { async function renderPdfBuffer(cv: ReturnType['cv']) { const resolvedAssets = await resolveCvPdfAssets(); const document = createElement(CVDocument, { cv, resolvedAssets }); - const stream = (await pdf(document).toBuffer()) as Readable; + const stream = (await pdf(document as Parameters[0]).toBuffer()) as Readable; return new Promise((resolve, reject) => { const chunks: Buffer[] = []; From 5a6ef86be4d913a1a5eb635a1c0c702800c1dc96 Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 16:18:08 +0200 Subject: [PATCH 16/19] Align tailored CV generator with canonical CV --- messages/src/en/cv.json | 86 +++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/messages/src/en/cv.json b/messages/src/en/cv.json index fa6d0a7..24d7a6f 100644 --- a/messages/src/en/cv.json +++ b/messages/src/en/cv.json @@ -2,7 +2,7 @@ "cv": { "title": "Yotam Faraggi - Senior Product Engineer CV", "name": "Yotam Faraggi", - "subtitle": "Senior Product Engineer | Full-Stack Web Products", + "subtitle": "Senior Product Engineer | Full-Stack, APIs & Integrations", "location": "Berlin, Germany", "workAuthorization": "EU citizen · Authorized to work in Germany", "phone": "+4915776211298", @@ -30,7 +30,7 @@ "education": "Education" }, "portfolio": { - "intro": "Selected product and web projects that show hands-on work across commerce, workflow tools, artist websites, and document-heavy applications.", + "intro": "Selected product and web projects showing hands-on ownership across AI-assisted workflows, commerce, document-heavy applications, and customer-facing web products.", "liveLabel": "Live project", "featuredLabel": "Founder-led", "visitProject": "Visit project", @@ -64,8 +64,8 @@ } }, "summary": { - "text": "Senior full-stack developer with more than 10 years of experience building web products and internal systems across e-commerce, healthcare, fintech, and enterprise software. Experience spans frontend, backend, integrations, and infrastructure, with a focus on turning practical product needs into reliable software. Recent work includes customer-facing telemedicine and e-commerce flows, Shopify and HubSpot integrations, modern React and Next.js applications, and AI-assisted workflow tools.", - "metaDescription": "Senior Product Engineer and full-stack developer in Berlin building web products, integrations, e-commerce systems, and AI-assisted workflow tools." + "text": "Berlin-based Senior Product Engineer and full-stack developer with 10+ years of experience building and operating production software across healthcare, fintech, e-commerce, and enterprise systems. Strong across React, Next.js, TypeScript, Node.js, APIs, integrations, cloud infrastructure, and PostgreSQL, with end-to-end ownership from technical scoping and architecture through deployment and iteration. At Curalife, defined, architected, and built a HIPAA-compliant telemedicine acquisition product that became one of the company’s primary customer-acquisition and revenue funnels. Recent work includes AI-assisted workflow tools, commerce systems, and integration-heavy product development.", + "metaDescription": "Berlin-based Senior Product Engineer with 10+ years of full-stack experience across React, Next.js, TypeScript, Node.js, APIs, integrations, cloud systems, healthcare, fintech, and AI-assisted products." }, "experience": { "cartshift": { @@ -74,11 +74,11 @@ "duration": "November 2025 - Present", "durationYears": "Current", "location": "Berlin, Germany", - "description": "Independent product and web development studio focused on e-commerce, web platforms, and workflow automation for international clients.", + "description": "Independent product and web development studio focused on production web applications, e-commerce, integrations, and workflow automation.", "highlights": [ - "Build Shopify storefronts and headless e-commerce experiences using Next.js, React, TypeScript, and modern web tooling.", - "Develop AI-assisted workflow tools and internal utilities for operational tasks.", - "Handle projects from requirements and technical planning through implementation, deployment, and ongoing improvements." + "Build and ship full-stack web products using Next.js, React, TypeScript, Node.js, third-party APIs, and modern cloud tooling.", + "Develop AI-assisted workflow tools and automations that integrate LLM APIs with external services and operational processes.", + "Own projects from product definition and technical planning through implementation, deployment, iteration, and maintenance." ] }, "curalife": { @@ -86,13 +86,13 @@ "title": "Full Stack Developer & R&D Lead", "duration": "July 2021 - November 2025", "durationYears": "4 years 5 months", - "location": "Tel Aviv, Israel", - "description": "Worked across customer-facing telemedicine and e-commerce systems, integrations, frontend modernization, and cloud infrastructure.", + "location": "Tel Aviv", + "description": "Owned major customer-facing product and platform work across telemedicine and e-commerce, spanning product definition, architecture, frontend, backend, integrations, cloud infrastructure, and production operation.", "highlights": [ - "Built and maintained a HIPAA-compliant telemedicine funnel using Next.js, React, TypeScript, and Google Cloud Platform.", - "Worked on Shopify and HubSpot integrations supporting customer and operational workflows.", - "Led migration work toward a modern frontend stack and improved key customer-facing flows.", - "Built and deployed cloud infrastructure with a focus on privacy, reliability, and maintainability." + "Defined, architected, and built Curalife’s HIPAA-compliant telemedicine acquisition product end-to-end, spanning the patient-facing flow, backend logic, external integrations, Google Cloud infrastructure, and production operations.", + "The telemedicine product became one of Curalife’s primary customer-acquisition and revenue funnels.", + "Built and evolved customer-facing healthcare and commerce experiences with Next.js, React, TypeScript, Shopify, HubSpot, and third-party APIs.", + "Led frontend modernization and production workflow improvements with a focus on privacy, reliability, and maintainability." ] }, "paragonex": { @@ -100,12 +100,12 @@ "title": "Software & Integration Developer", "duration": "August 2019 - July 2021", "durationYears": "2 years", - "location": "Tel Aviv Area, Israel", - "description": "Built and maintained web experiences and integrations for fintech affiliate operations.", + "location": "Tel Aviv Area", + "description": "Built and maintained production integrations and high-traffic web systems for fintech affiliate operations.", "highlights": [ - "Developed and optimized WordPress solutions using Sage and Laravel for high-traffic websites.", - "Built integrations between affiliate systems and core platform services.", - "Worked on frontend build optimization and loading performance improvements." + "Built integrations between affiliate systems and core fintech platform services.", + "Developed and optimized high-traffic web applications using Laravel, Sage, and WordPress with a focus on performance and maintainability.", + "Improved frontend build and page-loading performance across production web properties." ] }, "ecommerce_venture": { @@ -114,8 +114,8 @@ "duration": "April 2018 - July 2019", "durationYears": "1 year 4 months", "highlights": [ - "Built and operated direct-to-consumer e-commerce experiments across storefront setup, operations, payments, fulfillment, analytics, and conversion work.", - "Gained hands-on experience with Shopify, WordPress, customer acquisition, and day-to-day commerce operations." + "Built and operated direct-to-consumer systems spanning storefronts, payments, fulfillment, analytics, integrations, and day-to-day operations.", + "Gained hands-on product and commercial experience across Shopify, WordPress, customer acquisition, conversion, and e-commerce operations." ] }, "hot": { @@ -125,7 +125,8 @@ "durationYears": "1 year 2 months", "location": "Israel", "highlights": [ - "Built enterprise integrations and high-throughput web services using Oracle OSB, WebLogic, IBM DataPower, and JMS queues." + "Built enterprise integrations and high-throughput web services using Oracle OSB, WebLogic, IBM DataPower, and JMS queues.", + "Used asynchronous messaging to connect production services and operational systems with a focus on reliability." ] }, "leumi": { @@ -133,9 +134,10 @@ "title": "Integration Developer", "duration": "March 2016 - December 2016", "durationYears": "10 months", - "location": "Lod, Israel", + "location": "Lod", "highlights": [ - "Developed and maintained banking integrations and high-availability services using IBM WebSphere ESB, IBM Integration Bus, and DataPower." + "Developed and maintained high-availability banking integrations using IBM WebSphere ESB, IBM Integration Bus, and IBM DataPower.", + "Worked on production services connecting banking systems with a focus on reliability and operational continuity." ] }, "entrepreneurship": { @@ -144,7 +146,7 @@ "duration": "October 2014 - February 2016", "durationYears": "1 year 5 months", "highlights": [ - "Delivered freelance web and integration projects for small businesses while building independent product ideas from requirements through launch." + "Delivered freelance web and integration projects for small businesses while taking independent product ideas from requirements through launch." ] }, "elbit": { @@ -153,16 +155,16 @@ "duration": "December 2013 - September 2014", "durationYears": "10 months", "highlights": [ - "Contributed to real-time command-and-control software for military helicopter systems using C++, COM, and VB." + "Contributed to real-time command-and-control software for helicopter systems using C++, COM, and VB." ] }, "airforce": { - "company": "Israeli Air Force / Mamram (Military Service)", + "company": "IDF / Mamram", "title": "Software Programmer", "duration": "March 2011 - September 2014", "durationYears": "3 years 7 months", "highlights": [ - "Worked on integration systems connecting aircraft and ground-control infrastructure, including modernization of legacy services." + "Worked on integration systems connecting airborne and ground-control infrastructure, including modernization of legacy services." ] } }, @@ -173,10 +175,10 @@ "Technical scoping", "System design", "API design", - "Performance optimization", - "Reliability", + "Product ownership", "Web application architecture", - "Customer-facing product development" + "Reliability", + "Performance optimization" ] }, "frontendFullStack": { @@ -192,35 +194,35 @@ ] }, "commerceIntegrations": { - "category": "E-Commerce & Integrations", + "category": "APIs, Commerce & Integrations", "items": [ + "REST APIs", + "GraphQL", "Shopify", "Shopify APIs", "HubSpot", "Stripe", - "WordPress", - "GraphQL", - "REST APIs" + "WordPress" ] }, "aiAutomation": { "category": "AI & Automation", - "items": ["OpenAI API", "Claude API", "LangChain", "Webhooks", "Puppeteer", "Playwright"] + "items": ["OpenAI API", "Claude API", "LangChain", "LLM-assisted workflows", "Webhooks", "Puppeteer", "Playwright"] }, "cloudData": { "category": "Cloud & Data", "items": [ "Google Cloud Platform", + "PostgreSQL", "Firebase", "Firestore", - "PostgreSQL", - "Vercel", "Docker", + "Vercel", "GitHub Actions" ] }, "legacyEnterprise": { - "category": "Legacy Enterprise", + "category": "Enterprise Integration", "items": [ "Oracle OSB", "WebLogic", @@ -235,9 +237,9 @@ "education": { "entries": [ { - "institution": "IDF School for Computer Professions (Basmach)", - "program": "Programming Course (Mamram)", - "description": "Intensive military software development training.", + "institution": "Basmach / Mamram", + "program": "Software Development Program", + "description": "Intensive software development training.", "years": "2011" }, { From ba30a651280a926eb5c096a17d9b14fa9bc44f9c Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 16:19:00 +0200 Subject: [PATCH 17/19] Guard canonical CV identity and positioning --- tests/cv/cv-variants.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/cv/cv-variants.test.ts b/tests/cv/cv-variants.test.ts index d86e353..2a9184d 100644 --- a/tests/cv/cv-variants.test.ts +++ b/tests/cv/cv-variants.test.ts @@ -37,6 +37,30 @@ describe('CV variants', () => { } }); + 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'); From adaa66680c560ac97f4ddd502d50da454054eb61 Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 16:27:23 +0200 Subject: [PATCH 18/19] ci(cv): isolate generator validation stages --- .github/workflows/cv-generator.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cv-generator.yml b/.github/workflows/cv-generator.yml index 709187e..41c539c 100644 --- a/.github/workflows/cv-generator.yml +++ b/.github/workflows/cv-generator.yml @@ -37,8 +37,17 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Test CV variants - run: pnpm vitest run tests/cv/cv-variants.test.ts tests/cv/cv-tailoring.test.ts tests/cv/cv-pdf-export.test.tsx tests/cv/cv-tailored-pdf-export.test.tsx + - 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 From cf379238601663e7dafe39461fc61b8ce2d46ce3 Mon Sep 17 00:00:00 2001 From: Yotam Faraggi Date: Thu, 3 Sep 2026 16:36:20 +0200 Subject: [PATCH 19/19] test(cv): align PDF assertions with canonical positioning --- tests/cv/cv-pdf-export.test.tsx | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) 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);