diff --git a/orchestrator/src/server/services/pdf-skills-validation.test.ts b/orchestrator/src/server/services/pdf-skills-validation.test.ts new file mode 100644 index 0000000..785e69c --- /dev/null +++ b/orchestrator/src/server/services/pdf-skills-validation.test.ts @@ -0,0 +1,159 @@ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { generatePdf } from './pdf.js'; + +// Define mock data in hoisted block +const { mocks, mockProfile } = vi.hoisted(() => { + const profile = { + sections: { + summary: { content: 'Original Summary' }, + skills: { + items: [ + { id: 's1', name: 'Existing Skill', visible: true, description: 'Existing Desc', level: 3, keywords: ['k1'] } + ] + }, + projects: { items: [] } + }, + basics: { headline: 'Original Headline' } + }; + + return { + mockProfile: profile, + mocks: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + } + }; +}); + +// Configure base mock implementations +mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile)); +mocks.writeFile.mockResolvedValue(undefined); + +vi.mock('fs/promises', async () => { + return { + default: mocks, + ...mocks + }; +}); + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + default: { existsSync: vi.fn().mockReturnValue(true) } +})); + +vi.mock('../repositories/settings.js', () => ({ + getSetting: vi.fn().mockResolvedValue(null), +})); + +vi.mock('./projectSelection.js', () => ({ + pickProjectIdsForJob: vi.fn().mockResolvedValue([]), +})); + +vi.mock('./resumeProjects.js', () => ({ + extractProjectsFromProfile: vi.fn().mockReturnValue({ catalog: [], selectionItems: [] }), + resolveResumeProjectsSettings: vi.fn().mockReturnValue({ + resumeProjects: { lockedProjectIds: [], aiSelectableProjectIds: [], maxProjects: 2 } + }) +})); + +vi.mock('child_process', () => ({ + spawn: vi.fn().mockImplementation(() => ({ + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn().mockImplementation((event, cb) => { + if (event === 'close') cb(0); + return {}; + }), + })), + default: { + spawn: vi.fn().mockImplementation(() => ({ + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn().mockImplementation((event, cb) => { + if (event === 'close') cb(0); + return {}; + }), + })) + } +})); + +describe('PDF Service Skills Validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile)); + }); + + it('should add required schema fields (visible, description) to new skills', async () => { + // AI often returns just name and keywords + const newSkills = [ + { name: 'New Skill', keywords: ['k2'] }, + { name: 'Existing Skill', keywords: ['k3', 'k4'] } // Should merge with s1 + ]; + + const tailoredContent = { skills: newSkills }; + + await generatePdf('job-skills-1', tailoredContent, 'Job Desc'); + + expect(mocks.writeFile).toHaveBeenCalled(); + const callArgs = mocks.writeFile.mock.calls[0]; + const savedResumeJson = JSON.parse(callArgs[1] as string); + + const skillItems = savedResumeJson.sections.skills.items; + + // Check "New Skill" + const newSkill = skillItems.find((s: any) => s.name === 'New Skill'); + expect(newSkill).toBeDefined(); + + // These are the validations failing in user report: + expect(newSkill.visible).toBe(true); // Should default to true + expect(typeof newSkill.description).toBe('string'); // Should default to "" + expect(newSkill.description).toBe(''); + // Optional but good to check + expect(newSkill.id).toBeDefined(); + expect(newSkill.level).toBe(1); + + // Check "Existing Skill" - should preserve existing fields if not overwritten? + // In the implementation, we look up existing. + // existing.visible => true, existing.description => 'Existing Desc', existing.level => 3 + const existingSkill = skillItems.find((s: any) => s.name === 'Existing Skill'); + expect(existingSkill.visible).toBe(true); + expect(existingSkill.description).toBe('Existing Desc'); + expect(existingSkill.level).toBe(3); + expect(existingSkill.keywords).toEqual(['k3', 'k4']); // Should use new keywords or existing? Implementation uses new || existing. + }); + + it('should sanitize base resume even if no skills are tailored', async () => { + // Mock profile has an invalid skill (missing visible/description in the raw json implied, + // though our mock above has them. Let's make a truly invalid one locally) + const invalidProfile = { + ...mockProfile, + sections: { + ...mockProfile.sections, + skills: { + items: [ + { name: 'Invalid Skill' } // Missing visible, description, id, level + ] + } + } + }; + mocks.readFile.mockResolvedValueOnce(JSON.stringify(invalidProfile)); + + // No tailoring, pass dummy path to bypass getProfile cache and use readFile mock + await generatePdf('job-no-tailor', {}, 'Job Desc', 'dummy.json'); + + expect(mocks.writeFile).toHaveBeenCalled(); + const callArgs = mocks.writeFile.mock.calls[0]; + const savedResumeJson = JSON.parse(callArgs[1] as string); + + const item = savedResumeJson.sections.skills.items[0]; + + // Ensure defaults are applied even if we didn't use the tailoring logic block + expect(item.visible).toBe(true); + expect(item.description).toBe(''); + expect(item.id).toBeDefined(); + }); +}); diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 41bdfc7..5049e17 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -64,6 +64,20 @@ export async function generatePdf( ? JSON.parse(await readFile(baseResumePath, 'utf-8')) : JSON.parse(JSON.stringify(await getProfile())); // Deep copy from cache + // Sanitize skills: Ensure all skills have required schema fields (visible, description, id, level, keywords) + // This fixes issues where the base JSON uses a shorthand format (missing required fields) + if (baseResume.sections?.skills?.items && Array.isArray(baseResume.sections.skills.items)) { + baseResume.sections.skills.items = baseResume.sections.skills.items.map((skill: any, index: number) => ({ + ...skill, + id: skill.id || `skill-${Date.now()}-${index}`, + visible: skill.visible ?? true, + // Zod schema requires string, default to empty string if missing + description: skill.description ?? '', + level: skill.level ?? 1, + keywords: skill.keywords || [], + })); + } + // Inject tailored summary if (tailoredContent.summary) { if (baseResume.sections?.summary) { @@ -91,7 +105,23 @@ export async function generatePdf( : null; if (newSkills && baseResume.sections?.skills) { - baseResume.sections.skills.items = newSkills; + // Ensure each skill item has required schema fields + const existingSkills = baseResume.sections.skills.items || []; + const skillsWithSchema = newSkills.map((newSkill: any, index: number) => { + // Try to find matching existing skill to preserve id and other fields + const existing = existingSkills.find((s: any) => s.name === newSkill.name); + + return { + id: newSkill.id || existing?.id || `skill-${Date.now()}-${index}`, + visible: newSkill.visible !== undefined ? newSkill.visible : (existing?.visible ?? true), + name: newSkill.name || existing?.name || '', + description: newSkill.description !== undefined ? newSkill.description : (existing?.description || ''), + level: newSkill.level !== undefined ? newSkill.level : (existing?.level ?? 1), + keywords: newSkill.keywords || existing?.keywords || [], + }; + }); + + baseResume.sections.skills.items = skillsWithSchema; } } diff --git a/orchestrator/src/shared/rxresume-schema.ts b/orchestrator/src/shared/rxresume-schema.ts index 086daa8..565ee21 100644 --- a/orchestrator/src/shared/rxresume-schema.ts +++ b/orchestrator/src/shared/rxresume-schema.ts @@ -1,258 +1,895 @@ +// combined types from: https://github.com/amruthpillai/reactive-resume/tree/v4.5.5/libs/schema/src + import { z } from "zod"; -/** - * Schema matching the JSON you pasted (the "visible"/"summary"/"date"/"href" format). - * This is intentionally permissive (passthrough) so small future additions won't break parsing. - */ +// --- Shared --- -export const hrefUrlSchema = z.object({ - href: z.string().default(""), - label: z.string().default(""), +export type FilterKeys = { + [Key in keyof T]: T[Key] extends Condition ? Key : never; +}[keyof T]; + +export const idSchema = z + .string() + .describe("Unique identifier for the item"); + +export const itemSchema = z.object({ + id: idSchema, + visible: z.boolean(), }); -export const pictureEffectsSchema = z.object({ - border: z.boolean().default(false), - hidden: z.boolean().default(false), - grayscale: z.boolean().default(false), +export type Item = z.infer; + +export const defaultItem: Item = { + id: "", + visible: true, +}; + +export const urlSchema = z.object({ + label: z.string(), + href: z.literal("").or(z.string().url()), }); -export const basicsPictureSchema = z.object({ - url: z.string().default(""), - size: z.number().default(120), - effects: pictureEffectsSchema, - aspectRatio: z.number().default(1), - borderRadius: z.number().default(0), +export type URL = z.infer; + +export const defaultUrl: URL = { + label: "", + href: "", +}; + +// --- Basics --- + +export const customFieldSchema = z.object({ + id: z.string().cuid2(), + icon: z.string(), + name: z.string(), + value: z.string(), }); -export const customFieldSchema = z - .object({ - id: z.string().optional(), - icon: z.string().optional(), - text: z.string().optional(), - }) - .passthrough(); +export type CustomField = z.infer; -export const basicsSchema = z - .object({ - url: hrefUrlSchema, - name: z.string(), - email: z.string().email().or(z.literal("")).default(""), - phone: z.string().default(""), - picture: basicsPictureSchema, - headline: z.string().default(""), - location: z.string().default(""), - customFields: z.array(customFieldSchema).default([]), - }) - .passthrough(); - -export const metadataCssSchema = z.object({ - value: z.string().default(""), - visible: z.boolean().default(false), +export const basicsSchema = z.object({ + name: z.string(), + headline: z.string(), + email: z.literal("").or(z.string().email()), + phone: z.string(), + location: z.string(), + url: urlSchema, + customFields: z.array(customFieldSchema), + picture: z.object({ + url: z.string(), + size: z.number().default(64), + aspectRatio: z.number().default(1), + borderRadius: z.number().default(0), + effects: z.object({ + hidden: z.boolean().default(false), + border: z.boolean().default(false), + grayscale: z.boolean().default(false), + }), + }), }); -export const metadataPageOptionsSchema = z.object({ - breakLine: z.boolean().default(false), - pageNumbers: z.boolean().default(false), -}); +export type Basics = z.infer; -export const metadataPageSchema = z.object({ - format: z.enum(["a4", "letter"]).default("a4"), - margin: z.number().default(34), - options: metadataPageOptionsSchema.default({ breakLine: false, pageNumbers: false }), -}); +export const defaultBasics: Basics = { + name: "", + headline: "", + email: "", + phone: "", + location: "", + url: defaultUrl, + customFields: [], + picture: { + url: "", + size: 64, + aspectRatio: 1, + borderRadius: 0, + effects: { + hidden: false, + border: false, + grayscale: false, + }, + }, +}; -export const metadataThemeSchema = z.object({ - text: z.string().default("#000000"), - primary: z.string().default("#475569"), - background: z.string().default("#ffffff"), -}); +// --- Metadata --- -/** - * Your "layout" is shaped like: - * [ - * [ - * [ "summary", "profiles", ... ], // main column ids - * [ "skills", "languages" ] // sidebar column ids - * ], - * ... - * ] - */ -export const metadataLayoutSchema = z.array( - z.tuple([z.array(z.string()), z.array(z.string())]) -); +export const defaultLayout = [ + [ + ["profiles", "summary", "experience", "education", "projects", "volunteer", "references"], + ["skills", "interests", "certifications", "awards", "publications", "languages"], + ], +]; -export const metadataTypographySchema = z - .object({ +export const metadataSchema = z.object({ + template: z.string().default("rhyhorn"), + layout: z.array(z.array(z.array(z.string()))).default(defaultLayout), // pages -> columns -> sections + css: z.object({ + value: z.string().default("* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}"), + visible: z.boolean().default(false), + }), + page: z.object({ + margin: z.number().default(18), + format: z.enum(["a4", "letter"]).default("a4"), + options: z.object({ + breakLine: z.boolean().default(true), + pageNumbers: z.boolean().default(true), + }), + }), + theme: z.object({ + background: z.string().default("#ffffff"), + text: z.string().default("#000000"), + primary: z.string().default("#dc2626"), + }), + typography: z.object({ font: z.object({ - size: z.number().default(13), - family: z.string().default("IBM Plex Sans"), + family: z.string().default("IBM Plex Serif"), subset: z.string().default("latin"), variants: z.array(z.string()).default(["regular"]), + size: z.number().default(14), }), + lineHeight: z.number().default(1.5), hideIcons: z.boolean().default(false), - lineHeight: z.number().default(1.75), underlineLinks: z.boolean().default(true), - }) - .passthrough(); - -export const metadataSchema = z - .object({ - css: metadataCssSchema, - page: metadataPageSchema, - notes: z.string().default(""), - theme: metadataThemeSchema, - layout: metadataLayoutSchema.default([]), - template: z.string().default("onyx"), - typography: metadataTypographySchema, - }) - .passthrough(); - -/** Common section container used by most sections in your JSON */ -export const baseSectionSchema = z - .object({ - id: z.string(), - name: z.string(), - columns: z.number().default(1), - visible: z.boolean().default(true), - separateLinks: z.boolean().default(true), - items: z.array(z.unknown()).default([]), - }) - .passthrough(); - -/** Item schemas (based on the items you included) */ -export const profileItemSchema = z - .object({ - id: z.string(), - url: hrefUrlSchema, - icon: z.string().default(""), - network: z.string(), - visible: z.boolean().default(true), - username: z.string().default(""), - }) - .passthrough(); - -export const skillItemSchema = z - .object({ - id: z.string(), - name: z.string(), - level: z.number().default(0), - visible: z.boolean().default(true), - keywords: z.array(z.string()).default([]), - description: z.string().default(""), - }) - .passthrough(); - -export const projectItemSchema = z - .object({ - id: z.string(), - url: hrefUrlSchema, - date: z.string().default(""), - name: z.string(), - summary: z.string().default(""), // HTML string in your data - visible: z.boolean().default(true), - keywords: z.array(z.string()).default([]), - description: z.string().default(""), - }) - .passthrough(); - -export const educationItemSchema = z - .object({ - id: z.string(), - url: hrefUrlSchema, - area: z.string().default(""), - date: z.string().default(""), - score: z.string().default(""), - summary: z.string().default(""), // HTML string - visible: z.boolean().default(true), - studyType: z.string().default(""), - institution: z.string().default(""), - }) - .passthrough(); - -export const experienceItemSchema = z - .object({ - id: z.string(), - url: hrefUrlSchema, - date: z.string().default(""), - company: z.string(), - summary: z.string().default(""), // HTML string - visible: z.boolean().default(true), - location: z.string().default(""), - position: z.string().default(""), - }) - .passthrough(); - -/** Section schemas with typed items */ -export const profilesSectionSchema = baseSectionSchema.extend({ - items: z.array(profileItemSchema).default([]), + }), + notes: z.string().default(""), }); -export const skillsSectionSchema = baseSectionSchema.extend({ - items: z.array(skillItemSchema).default([]), +export type Metadata = z.infer; + +export const defaultMetadata: Metadata = { + template: "rhyhorn", + layout: defaultLayout, + css: { + value: "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}", + visible: false, + }, + page: { + margin: 18, + format: "a4", + options: { + breakLine: true, + pageNumbers: true, + }, + }, + theme: { + background: "#ffffff", + text: "#000000", + primary: "#dc2626", + }, + typography: { + font: { + family: "IBM Plex Serif", + subset: "latin", + variants: ["regular", "italic", "600"], + size: 14, + }, + lineHeight: 1.5, + hideIcons: false, + underlineLinks: true, + }, + notes: "", +}; + +// --- Sections --- + +// Award +export const awardSchema = itemSchema.extend({ + title: z.string().min(1), + awarder: z.string(), + date: z.string(), + summary: z.string(), + url: urlSchema, }); -export const projectsSectionSchema = baseSectionSchema.extend({ - items: z.array(projectItemSchema).default([]), +export type Award = z.infer; + +export const defaultAward: Award = { + ...defaultItem, + title: "", + awarder: "", + date: "", + summary: "", + url: defaultUrl, +}; + +// Certification +export const certificationSchema = itemSchema.extend({ + name: z.string().min(1), + issuer: z.string(), + date: z.string(), + summary: z.string(), + url: urlSchema, }); -export const educationSectionSchema = baseSectionSchema.extend({ - items: z.array(educationItemSchema).default([]), +export type Certification = z.infer; + +export const defaultCertification: Certification = { + ...defaultItem, + name: "", + issuer: "", + date: "", + summary: "", + url: defaultUrl, +}; + +// Custom Section +export const customSectionSchema = itemSchema.extend({ + name: z.string(), + description: z.string(), + date: z.string(), + location: z.string(), + summary: z.string(), + keywords: z.array(z.string()).default([]), + url: urlSchema, }); -export const experienceSectionSchema = baseSectionSchema.extend({ - items: z.array(experienceItemSchema).default([]), +export type CustomSection = z.infer; + +export const defaultCustomSection: CustomSection = { + ...defaultItem, + name: "", + description: "", + date: "", + location: "", + summary: "", + keywords: [], + url: defaultUrl, +}; + +// Education +export const educationSchema = itemSchema.extend({ + institution: z.string().min(1), + studyType: z.string(), + area: z.string(), + score: z.string(), + date: z.string(), + summary: z.string(), + url: urlSchema, }); -/** - * Your "summary" section is not an items array; it carries "content". - * Keep it separate. - */ -export const summarySectionSchema = z - .object({ - id: z.string(), - name: z.string(), - columns: z.number().default(1), - content: z.string().default(""), // HTML string - visible: z.boolean().default(true), - separateLinks: z.boolean().default(true), - }) - .passthrough(); +export type Education = z.infer; -/** Empty-ish sections (you have them as items: []) */ -export const emptyItemsSectionSchema = baseSectionSchema.extend({ - items: z.array(z.unknown()).default([]), +export const defaultEducation: Education = { + ...defaultItem, + id: "", + institution: "", + studyType: "", + area: "", + score: "", + date: "", + summary: "", + url: defaultUrl, +}; + +// Experience +export const experienceSchema = itemSchema.extend({ + company: z.string().min(1), + position: z.string(), + location: z.string(), + date: z.string(), + summary: z.string(), + url: urlSchema, }); -/** - * Your "sections" object contains a fixed set of keys, plus `custom: {}`. - * `custom` is an object with no guaranteed structure in your sample, so passthrough. - */ -export const sectionsSchema = z - .object({ - awards: emptyItemsSectionSchema, - custom: z.object({}).passthrough().default({}), - skills: skillsSectionSchema, - summary: summarySectionSchema, - profiles: profilesSectionSchema, - projects: projectsSectionSchema, - education: educationSectionSchema, - interests: emptyItemsSectionSchema, - languages: emptyItemsSectionSchema, - volunteer: emptyItemsSectionSchema, - experience: experienceSectionSchema, - references: emptyItemsSectionSchema, - publications: emptyItemsSectionSchema, - certifications: emptyItemsSectionSchema, - }) - .passthrough(); +export type Experience = z.infer; -/** Top-level schema matching what you pasted */ -export const myResumeJsonSchema = z - .object({ - basics: basicsSchema, - metadata: metadataSchema, - sections: sectionsSchema, - }) - .passthrough(); +export const defaultExperience: Experience = { + ...defaultItem, + company: "", + position: "", + location: "", + date: "", + summary: "", + url: defaultUrl, +}; -export type MyResumeJson = z.infer; +// Interest +export const interestSchema = itemSchema.extend({ + name: z.string().min(1), + keywords: z.array(z.string()).default([]), +}); + +export type Interest = z.infer; + +export const defaultInterest: Interest = { + ...defaultItem, + name: "", + keywords: [], +}; + +// Language +export const languageSchema = itemSchema.extend({ + name: z.string().min(1), + description: z.string(), + level: z.coerce.number().min(0).max(5).default(1), +}); + +export type Language = z.infer; + +export const defaultLanguage: Language = { + ...defaultItem, + name: "", + description: "", + level: 1, +}; + +// Profile +export const profileSchema = itemSchema.extend({ + network: z.string().min(1), + username: z.string().min(1), + icon: z + .string() + .describe( + 'Slug for the icon from https://simpleicons.org. For example, "github", "linkedin", etc.', + ), + url: urlSchema, +}); + +export type Profile = z.infer; + +export const defaultProfile: Profile = { + ...defaultItem, + network: "", + username: "", + icon: "", + url: defaultUrl, +}; + +// Project +export const projectSchema = itemSchema.extend({ + name: z.string().min(1), + description: z.string(), + date: z.string(), + summary: z.string(), + keywords: z.array(z.string()).default([]), + url: urlSchema, +}); + +export type Project = z.infer; + +export const defaultProject: Project = { + ...defaultItem, + name: "", + description: "", + date: "", + summary: "", + keywords: [], + url: defaultUrl, +}; + +// Publication +export const publicationSchema = itemSchema.extend({ + name: z.string().min(1), + publisher: z.string(), + date: z.string(), + summary: z.string(), + url: urlSchema, +}); + +export type Publication = z.infer; + +export const defaultPublication: Publication = { + ...defaultItem, + name: "", + publisher: "", + date: "", + summary: "", + url: defaultUrl, +}; + +// Reference +export const referenceSchema = itemSchema.extend({ + name: z.string().min(1), + description: z.string(), + summary: z.string(), + url: urlSchema, +}); + +export type Reference = z.infer; + +export const defaultReference: Reference = { + ...defaultItem, + name: "", + description: "", + summary: "", + url: defaultUrl, +}; + +// Skill +export const skillSchema = itemSchema.extend({ + name: z.string(), + description: z.string(), + level: z.coerce.number().min(0).max(5).default(1), + keywords: z.array(z.string()).default([]), +}); + +export type Skill = z.infer; + +export const defaultSkill: Skill = { + ...defaultItem, + name: "", + description: "", + level: 1, + keywords: [], +}; + +// Volunteer +export const volunteerSchema = itemSchema.extend({ + organization: z.string().min(1), + position: z.string(), + location: z.string(), + date: z.string(), + summary: z.string(), + url: urlSchema, +}); + +export type Volunteer = z.infer; + +export const defaultVolunteer: Volunteer = { + ...defaultItem, + organization: "", + position: "", + location: "", + date: "", + summary: "", + url: defaultUrl, +}; + +// --- Aggregate Sections --- + +export const sectionSchema = z.object({ + name: z.string(), + columns: z.number().min(1).max(5).default(1), + separateLinks: z.boolean().default(true), + visible: z.boolean().default(true), +}); + +export const customSchema = sectionSchema.extend({ + id: idSchema, + items: z.array(customSectionSchema), +}); + +export const sectionsSchema = z.object({ + summary: sectionSchema.extend({ + id: z.literal("summary"), + content: z.string().default(""), + }), + awards: sectionSchema.extend({ + id: z.literal("awards"), + items: z.array(awardSchema), + }), + certifications: sectionSchema.extend({ + id: z.literal("certifications"), + items: z.array(certificationSchema), + }), + education: sectionSchema.extend({ + id: z.literal("education"), + items: z.array(educationSchema), + }), + experience: sectionSchema.extend({ + id: z.literal("experience"), + items: z.array(experienceSchema), + }), + volunteer: sectionSchema.extend({ + id: z.literal("volunteer"), + items: z.array(volunteerSchema), + }), + interests: sectionSchema.extend({ + id: z.literal("interests"), + items: z.array(interestSchema), + }), + languages: sectionSchema.extend({ + id: z.literal("languages"), + items: z.array(languageSchema), + }), + profiles: sectionSchema.extend({ + id: z.literal("profiles"), + items: z.array(profileSchema), + }), + projects: sectionSchema.extend({ + id: z.literal("projects"), + items: z.array(projectSchema), + }), + publications: sectionSchema.extend({ + id: z.literal("publications"), + items: z.array(publicationSchema), + }), + references: sectionSchema.extend({ + id: z.literal("references"), + items: z.array(referenceSchema), + }), + skills: sectionSchema.extend({ + id: z.literal("skills"), + items: z.array(skillSchema), + }), + custom: z.record(z.string(), customSchema), +}); + +export type Section = z.infer; +export type Sections = z.infer; + +export type SectionKey = "basics" | keyof Sections | `custom.${string}`; +export type SectionWithItem = Sections[FilterKeys]; +export type SectionItem = SectionWithItem["items"][number]; +export type CustomSectionGroup = z.infer; + +export const defaultSection: Section = { + name: "", + columns: 1, + separateLinks: true, + visible: true, +}; + +export const defaultSections: Sections = { + summary: { ...defaultSection, id: "summary", name: "Summary", content: "" }, + awards: { ...defaultSection, id: "awards", name: "Awards", items: [] }, + certifications: { ...defaultSection, id: "certifications", name: "Certifications", items: [] }, + education: { ...defaultSection, id: "education", name: "Education", items: [] }, + experience: { ...defaultSection, id: "experience", name: "Experience", items: [] }, + volunteer: { ...defaultSection, id: "volunteer", name: "Volunteering", items: [] }, + interests: { ...defaultSection, id: "interests", name: "Interests", items: [] }, + languages: { ...defaultSection, id: "languages", name: "Languages", items: [] }, + profiles: { ...defaultSection, id: "profiles", name: "Profiles", items: [] }, + projects: { ...defaultSection, id: "projects", name: "Projects", items: [] }, + publications: { ...defaultSection, id: "publications", name: "Publications", items: [] }, + references: { ...defaultSection, id: "references", name: "References", items: [] }, + skills: { ...defaultSection, id: "skills", name: "Skills", items: [] }, + custom: {}, +}; + +// --- Main Resume Data --- + +export const resumeDataSchema = z.object({ + basics: basicsSchema, + sections: sectionsSchema, + metadata: metadataSchema, +}); + +export type ResumeData = z.infer; + +export const defaultResumeData: ResumeData = { + basics: defaultBasics, + sections: defaultSections, + metadata: defaultMetadata, +}; + +// --- Sample Data --- + +export const sampleResume: ResumeData = { + basics: { + name: "John Doe", + headline: "Creative and Innovative Web Developer", + email: "john.doe@gmail.com", + phone: "(555) 123-4567", + location: "Pleasantville, CA 94588", + url: { + label: "", + href: "https://johndoe.me/", + }, + customFields: [], + picture: { + url: "https://i.imgur.com/HgwyOuJ.jpg", + size: 120, + aspectRatio: 1, + borderRadius: 0, + effects: { + hidden: false, + border: false, + grayscale: false, + }, + }, + }, + sections: { + summary: { + name: "Summary", + columns: 1, + separateLinks: true, + visible: true, + id: "summary", + content: + "

Innovative Web Developer with 5 years of experience in building impactful and user-friendly websites and applications. Specializes in front-end technologies and passionate about modern web standards and cutting-edge development techniques. Proven track record of leading successful projects from concept to deployment.

", + }, + awards: { + name: "Awards", + columns: 1, + separateLinks: true, + visible: true, + id: "awards", + items: [], + }, + certifications: { + name: "Certifications", + columns: 1, + separateLinks: true, + visible: true, + id: "certifications", + items: [ + { + id: "spdhh9rrqi1gvj0yqnbqunlo", + visible: true, + name: "Full-Stack Web Development", + issuer: "CodeAcademy", + date: "2020", + summary: "", + url: { + label: "", + href: "", + }, + }, + { + id: "n838rddyqv47zexn6cxauwqp", + visible: true, + name: "AWS Certified Developer", + issuer: "Amazon Web Services", + date: "2019", + summary: "", + url: { + label: "", + href: "", + }, + }, + ], + }, + education: { + name: "Education", + columns: 1, + separateLinks: true, + visible: true, + id: "education", + items: [ + { + id: "yo3p200zo45c6cdqc6a2vtt3", + visible: true, + institution: "University of California", + studyType: "Bachelor's in Computer Science", + area: "Berkeley, CA", + score: "", + date: "August 2012 to May 2016", + summary: "", + url: { + label: "", + href: "", + }, + }, + ], + }, + experience: { + name: "Experience", + columns: 1, + separateLinks: true, + visible: true, + id: "experience", + items: [ + { + id: "lhw25d7gf32wgdfpsktf6e0x", + visible: true, + company: "Creative Solutions Inc.", + position: "Senior Web Developer", + location: "San Francisco, CA", + date: "January 2019 to Present", + summary: + "
  • Spearheaded the redesign of the main product website, resulting in a 40% increase in user engagement.

  • Developed and implemented a new responsive framework, improving cross-device compatibility.

  • Mentored a team of four junior developers, fostering a culture of technical excellence.

", + url: { + label: "", + href: "https://creativesolutions.inc/", + }, + }, + { + id: "r6543lil53ntrxmvel53gbtm", + visible: true, + company: "TechAdvancers", + position: "Web Developer", + location: "San Jose, CA", + date: "June 2016 to December 2018", + summary: + "
  • Collaborated in a team of 10 to develop high-quality web applications using React.js and Node.js.

  • Managed the integration of third-party services such as Stripe for payments and Twilio for SMS services.

  • Optimized application performance, achieving a 30% reduction in load times.

", + url: { + label: "", + href: "https://techadvancers.com/", + }, + }, + ], + }, + volunteer: { + name: "Volunteering", + columns: 1, + separateLinks: true, + visible: true, + id: "volunteer", + items: [], + }, + interests: { + name: "Interests", + columns: 1, + separateLinks: true, + visible: true, + id: "interests", + items: [], + }, + languages: { + name: "Languages", + columns: 1, + separateLinks: true, + visible: true, + id: "languages", + items: [], + }, + profiles: { + name: "Profiles", + columns: 1, + separateLinks: true, + visible: true, + id: "profiles", + items: [ + { + id: "cnbk5f0aeqvhx69ebk7hktwd", + visible: true, + network: "LinkedIn", + username: "johndoe", + icon: "linkedin", + url: { + label: "", + href: "https://linkedin.com/in/johndoe", + }, + }, + { + id: "ukl0uecvzkgm27mlye0wazlb", + visible: true, + network: "GitHub", + username: "johndoe", + icon: "github", + url: { + label: "", + href: "https://github.com/johndoe", + }, + }, + ], + }, + projects: { + name: "Projects", + columns: 1, + separateLinks: true, + visible: true, + id: "projects", + items: [ + { + id: "yw843emozcth8s1ubi1ubvlf", + visible: true, + name: "E-Commerce Platform", + description: "Project Lead", + date: "", + summary: + "

Led the development of a full-stack e-commerce platform, improving sales conversion by 25%.

", + keywords: [], + url: { + label: "", + href: "", + }, + }, + { + id: "ncxgdjjky54gh59iz2t1xi1v", + visible: true, + name: "Interactive Dashboard", + description: "Frontend Developer", + date: "", + summary: + "

Created an interactive analytics dashboard for a SaaS application, enhancing data visualization for clients.

", + keywords: [], + url: { + label: "", + href: "", + }, + }, + ], + }, + publications: { + name: "Publications", + columns: 1, + separateLinks: true, + visible: true, + id: "publications", + items: [], + }, + references: { + name: "References", + columns: 1, + separateLinks: true, + visible: false, + id: "references", + items: [ + { + id: "f2sv5z0cce6ztjl87yuk8fak", + visible: true, + name: "Available upon request", + description: "", + summary: "", + url: { + label: "", + href: "", + }, + }, + ], + }, + skills: { + name: "Skills", + columns: 1, + separateLinks: true, + visible: true, + id: "skills", + items: [ + { + id: "hn0keriukh6c0ojktl9gsgjm", + visible: true, + name: "Web Technologies", + description: "Advanced", + level: 0, + keywords: ["HTML5", "JavaScript", "PHP", "Python"], + }, + { + id: "r8c3y47vykausqrgmzwg5pur", + visible: true, + name: "Web Frameworks", + description: "Intermediate", + level: 0, + keywords: ["React.js", "Angular", "Vue.js", "Laravel", "Django"], + }, + { + id: "b5l75aseexqv17quvqgh73fe", + visible: true, + name: "Tools", + description: "Intermediate", + level: 0, + keywords: ["Webpack", "Git", "Jenkins", "Docker", "JIRA"], + }, + ], + }, + custom: {}, + }, + metadata: { + template: "glalie", + layout: [ + [ + ["summary", "experience", "education", "projects", "references"], + [ + "profiles", + "skills", + "certifications", + "interests", + "languages", + "awards", + "volunteer", + "publications", + ], + ], + ], + css: { + value: "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}", + visible: false, + }, + page: { + margin: 14, + format: "a4", + options: { + breakLine: true, + pageNumbers: true, + }, + }, + theme: { + background: "#ffffff", + text: "#000000", + primary: "#ca8a04", + }, + typography: { + font: { + family: "Merriweather", + subset: "latin", + variants: ["regular"], + size: 13, + }, + lineHeight: 1.75, + hideIcons: false, + underlineLinks: true, + }, + notes: "", + }, +};