Merge branch 'main' into user-onboarding

This commit is contained in:
DaKheera47
2026-01-22 20:37:28 +00:00
10 changed files with 363 additions and 667 deletions
+38
View File
@@ -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",
+1
View File
@@ -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);
});
});
+5 -4
View File
@@ -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();
}
});
});
});
+2 -1
View File
@@ -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,