Merge branch 'main' into user-onboarding
This commit is contained in:
Generated
+38
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@paralleldrive/cuid2": "^3.0.6",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
@@ -1480,6 +1481,17 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -1518,6 +1530,19 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-3.0.6.tgz",
|
||||
"integrity": "sha512-ujtxTTvr4fwPrzuQT7o6VLKs5BzdWetR9+/zRQ0SyK9hVIwZQllEccxgcHYXN6I3Z429y1yg3F6+uiVxMDPrLQ==",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"bignumber.js": "^9.3.1",
|
||||
"error-causes": "^3.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"cuid2": "bin/cuid2.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@petamoriken/float16": {
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
|
||||
@@ -3834,6 +3859,14 @@
|
||||
"prebuild-install": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -4763,6 +4796,11 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/error-causes": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/error-causes/-/error-causes-3.0.2.tgz",
|
||||
"integrity": "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@paralleldrive/cuid2": "^3.0.6",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
|
||||
@@ -75,6 +75,7 @@ export async function callOpenRouter<T>(
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
@@ -83,6 +84,7 @@ export async function callOpenRouter<T>(
|
||||
schema: jsonSchema.schema,
|
||||
},
|
||||
},
|
||||
plugins: [{ id: 'response-healing' }],
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('PDF Service Skills Validation', () => {
|
||||
});
|
||||
|
||||
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,
|
||||
// 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,
|
||||
@@ -157,4 +157,101 @@ describe('PDF Service Skills Validation', () => {
|
||||
expect(item.description).toBe('');
|
||||
expect(item.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should generate CUID2-compatible IDs for skills without IDs', async () => {
|
||||
// Profile with skills missing IDs (common when AI generates them)
|
||||
const profileWithoutIds = {
|
||||
...mockProfile,
|
||||
sections: {
|
||||
...mockProfile.sections,
|
||||
skills: {
|
||||
items: [
|
||||
{ name: 'Skill 1', keywords: ['a'] },
|
||||
{ name: 'Skill 2', keywords: ['b'] },
|
||||
{ name: 'Skill 3', keywords: ['c'] }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithoutIds));
|
||||
|
||||
await generatePdf('job-cuid2-test', {}, 'Job Desc', 'dummy.json');
|
||||
|
||||
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;
|
||||
|
||||
// All skills should have IDs
|
||||
skillItems.forEach((skill: any, index: number) => {
|
||||
expect(skill.id).toBeDefined();
|
||||
expect(typeof skill.id).toBe('string');
|
||||
expect(skill.id.length).toBeGreaterThanOrEqual(20);
|
||||
|
||||
// CUID2 format: starts with a letter, lowercase alphanumeric
|
||||
expect(skill.id).toMatch(/^[a-z][a-z0-9]+$/);
|
||||
});
|
||||
|
||||
// IDs should be unique
|
||||
const ids = skillItems.map((s: any) => s.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('should NOT generate IDs like "skill-0" which are invalid CUID2', async () => {
|
||||
const profileWithoutIds = {
|
||||
...mockProfile,
|
||||
sections: {
|
||||
...mockProfile.sections,
|
||||
skills: {
|
||||
items: [
|
||||
{ name: 'Skill Without ID', keywords: ['test'] }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithoutIds));
|
||||
|
||||
await generatePdf('job-no-skill-prefix', {}, 'Job Desc', 'dummy.json');
|
||||
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
const callArgs = mocks.writeFile.mock.calls[0];
|
||||
const savedResumeJson = JSON.parse(callArgs[1] as string);
|
||||
|
||||
const skill = savedResumeJson.sections.skills.items[0];
|
||||
|
||||
// ID should NOT be in the old invalid format
|
||||
expect(skill.id).not.toMatch(/^skill-\d+$/);
|
||||
|
||||
// Should be valid CUID2 format
|
||||
expect(skill.id).toMatch(/^[a-z][a-z0-9]+$/);
|
||||
});
|
||||
|
||||
it('should preserve existing valid IDs and not regenerate them', async () => {
|
||||
const validCuid2Id = 'ck9w4ygzq0000xmn5h0jt7l5c';
|
||||
const profileWithValidId = {
|
||||
...mockProfile,
|
||||
sections: {
|
||||
...mockProfile.sections,
|
||||
skills: {
|
||||
items: [
|
||||
{ id: validCuid2Id, name: 'Skill With Valid ID', keywords: ['test'], visible: true, description: '', level: 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithValidId));
|
||||
|
||||
await generatePdf('job-preserve-id', {}, 'Job Desc', 'dummy.json');
|
||||
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
const callArgs = mocks.writeFile.mock.calls[0];
|
||||
const savedResumeJson = JSON.parse(callArgs[1] as string);
|
||||
|
||||
const skill = savedResumeJson.sections.skills.items[0];
|
||||
|
||||
// Should preserve the original valid ID
|
||||
expect(skill.id).toBe(validCuid2Id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readFile, writeFile, mkdir, access, unlink } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import { pickProjectIdsForJob } from './projectSelection.js';
|
||||
@@ -67,9 +68,9 @@ export async function generatePdf(
|
||||
// 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) => ({
|
||||
baseResume.sections.skills.items = baseResume.sections.skills.items.map((skill: any) => ({
|
||||
...skill,
|
||||
id: skill.id || `skill-${index}`,
|
||||
id: skill.id || createId(),
|
||||
visible: skill.visible ?? true,
|
||||
// Zod schema requires string, default to empty string if missing
|
||||
description: skill.description ?? '',
|
||||
@@ -107,12 +108,12 @@ export async function generatePdf(
|
||||
if (newSkills && baseResume.sections?.skills) {
|
||||
// Ensure each skill item has required schema fields
|
||||
const existingSkills = baseResume.sections.skills.items || [];
|
||||
const skillsWithSchema = newSkills.map((newSkill: any, index: number) => {
|
||||
const skillsWithSchema = newSkills.map((newSkill: any) => {
|
||||
// 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-${index}`,
|
||||
id: newSkill.id || existing?.id || createId(),
|
||||
visible: newSkill.visible !== undefined ? newSkill.visible : (existing?.visible ?? true),
|
||||
name: newSkill.name || existing?.name || '',
|
||||
description: newSkill.description !== undefined ? newSkill.description : (existing?.description || ''),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { idSchema, skillSchema, resumeDataSchema } from './rxresume-schema.js';
|
||||
|
||||
describe('RxResume Schema Validation', () => {
|
||||
describe('idSchema (CUID2)', () => {
|
||||
it('should accept valid CUID2 IDs generated by the library', () => {
|
||||
// Generate real CUID2 IDs using the official library
|
||||
const validIds = [
|
||||
createId(),
|
||||
createId(),
|
||||
createId(),
|
||||
];
|
||||
|
||||
validIds.forEach(id => {
|
||||
const result = idSchema.safeParse(id);
|
||||
expect(result.success, `ID "${id}" should be valid`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid IDs like "skill-0"', () => {
|
||||
const invalidIds = [
|
||||
'skill-0', // contains hyphen
|
||||
'skill-1', // contains hyphen
|
||||
'skill-123', // contains hyphen
|
||||
'item_1', // contains underscore
|
||||
'ABC123', // uppercase letters
|
||||
'', // empty
|
||||
];
|
||||
|
||||
invalidIds.forEach(id => {
|
||||
const result = idSchema.safeParse(id);
|
||||
expect(result.success, `ID "${id}" should be invalid`).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('skillSchema', () => {
|
||||
it('should accept valid skill with CUID2 ID', () => {
|
||||
const validSkill = {
|
||||
id: createId(),
|
||||
visible: true,
|
||||
name: 'JavaScript',
|
||||
description: '',
|
||||
level: 3,
|
||||
keywords: ['ES6', 'TypeScript'],
|
||||
};
|
||||
|
||||
const result = skillSchema.safeParse(validSkill);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject skill with invalid ID format', () => {
|
||||
const invalidSkill = {
|
||||
id: 'skill-0', // Invalid CUID2
|
||||
visible: true,
|
||||
name: 'JavaScript',
|
||||
description: '',
|
||||
level: 3,
|
||||
keywords: ['ES6'],
|
||||
};
|
||||
|
||||
const result = skillSchema.safeParse(invalidSkill);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain('id');
|
||||
expect(result.error.issues[0].message).toContain('cuid2');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resumeDataSchema', () => {
|
||||
it('should reject resume with invalid skill IDs', () => {
|
||||
const resumeWithInvalidIds = {
|
||||
basics: {
|
||||
name: 'John Doe',
|
||||
headline: 'Developer',
|
||||
email: 'john@example.com',
|
||||
phone: '',
|
||||
location: '',
|
||||
url: { label: '', href: '' },
|
||||
customFields: [],
|
||||
picture: {
|
||||
url: '',
|
||||
size: 64,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 0,
|
||||
effects: { hidden: false, border: false, grayscale: false },
|
||||
},
|
||||
},
|
||||
sections: {
|
||||
summary: { id: 'summary', name: 'Summary', columns: 1, separateLinks: true, visible: true, content: '' },
|
||||
skills: {
|
||||
id: 'skills',
|
||||
name: 'Skills',
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
items: [
|
||||
{
|
||||
id: 'skill-0', // Invalid!
|
||||
visible: true,
|
||||
name: 'JavaScript',
|
||||
description: '',
|
||||
level: 1,
|
||||
keywords: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
// Minimal required sections
|
||||
awards: { id: 'awards', name: 'Awards', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
certifications: { id: 'certifications', name: 'Certifications', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
education: { id: 'education', name: 'Education', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
experience: { id: 'experience', name: 'Experience', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
volunteer: { id: 'volunteer', name: 'Volunteer', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
interests: { id: 'interests', name: 'Interests', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
languages: { id: 'languages', name: 'Languages', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
profiles: { id: 'profiles', name: 'Profiles', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
projects: { id: 'projects', name: 'Projects', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
publications: { id: 'publications', name: 'Publications', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
references: { id: 'references', name: 'References', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
custom: {},
|
||||
},
|
||||
metadata: {
|
||||
template: 'rhyhorn',
|
||||
layout: [[['summary'], ['skills']]],
|
||||
css: { value: '', 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'], size: 14 },
|
||||
lineHeight: 1.5,
|
||||
hideIcons: false,
|
||||
underlineLinks: true,
|
||||
},
|
||||
notes: '',
|
||||
},
|
||||
};
|
||||
|
||||
const result = resumeDataSchema.safeParse(resumeWithInvalidIds);
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (!result.success) {
|
||||
// Should have error about the skill ID
|
||||
const idError = result.error.issues.find(
|
||||
issue => issue.path.join('.').includes('skills.items') && issue.path.includes('id')
|
||||
);
|
||||
expect(idError).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,8 @@ export type FilterKeys<T, Condition> = {
|
||||
|
||||
export const idSchema = z
|
||||
.string()
|
||||
.describe("Unique identifier for the item");
|
||||
.cuid2()
|
||||
.describe("Unique identifier for the item (CUID2 format)");
|
||||
|
||||
export const itemSchema = z.object({
|
||||
id: idSchema,
|
||||
|
||||
Reference in New Issue
Block a user