',
+ created_at: '2023-09-01T00:00:00Z',
+ updated_at: '2023-09-01T00:00:00Z'
+ }
+]
+
+describe('Experience Queries', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ describe('experienceQueryOptions', () => {
+ it('should have correct query key', () => {
+ const options = experienceQueryOptions
+
+ expect(options.queryKey).toEqual(EXPERIENCE_KEYS)
+ })
+
+ it('should have correct query function', () => {
+ const options = experienceQueryOptions
+
+ expect(options.queryFn).toBeInstanceOf(Function)
+ })
+
+ it('should call getExperiencesFromDB with "base" when query function is executed', async () => {
+ mockGetExperiencesFromDB.mockResolvedValue(mockExperiences)
+
+ const mockContext = {
+ queryKey: EXPERIENCE_KEYS,
+ client: {} as any,
+ signal: {} as AbortSignal,
+ meta: undefined
+ }
+
+ const result = await experienceQueryOptions.queryFn!(mockContext)
+
+ expect(mockGetExperiencesFromDB).toHaveBeenCalledTimes(1)
+ expect(mockGetExperiencesFromDB).toHaveBeenCalledWith('base')
+ expect(result).toEqual(mockExperiences)
+ })
+
+ it('should handle query function errors', async () => {
+ const error = new Error('API Error')
+ mockGetExperiencesFromDB.mockRejectedValue(error)
+
+ const mockContext = {
+ queryKey: EXPERIENCE_KEYS,
+ client: {} as any,
+ signal: {} as AbortSignal,
+ meta: undefined
+ }
+
+ await expect(experienceQueryOptions.queryFn!(mockContext)).rejects.toThrow('API Error')
+ expect(mockGetExperiencesFromDB).toHaveBeenCalledWith('base')
+ })
+ })
+
+ describe('useCurrentExperiences', () => {
+ it('should use the correct query options', () => {
+ const hook = useCurrentExperiences
+
+ // Test that the hook is a function
+ expect(typeof hook).toBe('function')
+ })
+
+ it('should be available for import', () => {
+ expect(useCurrentExperiences).toBeDefined()
+ expect(typeof useCurrentExperiences).toBe('function')
+ })
+ })
+})
diff --git a/lib/experience/actions.ts b/lib/experience/actions.ts
index 9ba73d63..b1d42f92 100644
--- a/lib/experience/actions.ts
+++ b/lib/experience/actions.ts
@@ -11,10 +11,13 @@ import {handleErrors} from '@/lib/misc/error-handler'
* @returns {Promise} - The updated experience object
* @throws {Error} If validation, authentication, or API request fails.
*/
-export const addExperienceToDB = async (experienceValues: ExperienceMutation): Promise => {
+export const addExperienceToDB = async (
+ experienceValues: ExperienceMutation,
+ resumeId: string = 'base'
+): Promise => {
try {
const params = ExperienceMutationSchema.parse(experienceValues)
- const data = await api.post('/resume/base/experience/', params)
+ const data = await api.post(`/resume/${resumeId}/experience/`, params)
return ExperienceSchema.parse(data)
} catch (error) {
return handleErrors(error, 'add experience')
diff --git a/lib/experience/mutations.ts b/lib/experience/mutations.ts
index bf0ad7d5..f9fd0dea 100644
--- a/lib/experience/mutations.ts
+++ b/lib/experience/mutations.ts
@@ -1,18 +1,24 @@
import {Experience, ExperienceMutation} from '@/lib/experience/types'
import {MutationOptions, useMutation} from '@tanstack/react-query'
-import {deleteExperienceFromDB, addExperienceToDB, updateExperienceInDB} from '@/lib/experience/actions'
+import {addExperienceToDB, deleteExperienceFromDB, updateExperienceInDB} from '@/lib/experience/actions'
-export const useAddUserExperienceMutation = (options?: MutationOptions) => useMutation({
- mutationFn: addExperienceToDB,
+export const useAddUserExperienceMutation = (options?: MutationOptions) => useMutation({
+ mutationFn: ({data, resumeId}) => {
+ return addExperienceToDB(data, resumeId || 'base')
+ },
...options
})
-export const useUpdateExperienceMutation = (options?: MutationOptions}>) => useMutation({
- mutationFn: ({id, data}) => updateExperienceInDB(id, data),
+export const useUpdateExperienceMutation = (options?: MutationOptions, resumeId?: string}>) => useMutation({
+ mutationFn: ({id, data, resumeId}) => {
+ return updateExperienceInDB(id, data, resumeId || 'base')
+ },
...options
})
-export const useDeleteExperienceMutation = (options?:MutationOptions) => useMutation({
- mutationFn: (experienceId) => deleteExperienceFromDB(experienceId, 'base'),
+export const useDeleteExperienceMutation = (options?:MutationOptions) => useMutation({
+ mutationFn: ({id, resumeId}) => {
+ return deleteExperienceFromDB(id, resumeId || 'base')
+ },
...options
})
diff --git a/lib/experience/queries.ts b/lib/experience/queries.ts
index bf29c5ba..af71ce68 100644
--- a/lib/experience/queries.ts
+++ b/lib/experience/queries.ts
@@ -1,11 +1,13 @@
import {queryOptions, useQuery} from '@tanstack/react-query'
-import {EXPERIENCE_KEYS} from './keys'
-import {getExperiencesFromDB} from './actions'
+import {EXPERIENCE_KEYS} from '@/lib/experience/keys'
+import {getExperiencesFromDB} from '@/lib/experience/actions'
-export const experienceQueryOptions = queryOptions({
- queryKey: EXPERIENCE_KEYS,
- queryFn: () => getExperiencesFromDB('base')
+export const experienceQueryOptions = (resumeId: string = 'base') => queryOptions({
+ queryKey: [...EXPERIENCE_KEYS, resumeId],
+ queryFn: () => getExperiencesFromDB(resumeId)
})
-export const useCurrentExperiences = () => useQuery(experienceQueryOptions)
+export const useCurrentExperiences = (resumeId?: string) => {
+ return useQuery(experienceQueryOptions(resumeId))
+}
diff --git a/lib/job/types.ts b/lib/job/types.ts
index aeef529c..1daaf00f 100644
--- a/lib/job/types.ts
+++ b/lib/job/types.ts
@@ -4,18 +4,18 @@ import {z} from 'zod'
* Base schema for Job
* Check https://outline.letraz.app/api-reference/job-object/get-job-by-job-id for more information
*/
+const stringOrStringArray = z.union([z.string(), z.array(z.string())]).transform((val) => Array.isArray(val) ? val.join('\n') : val)
+
export const JobSchema = z.object({
job_url: z.string().describe('The URL of the job posting.'),
title: z.string().describe('The title of the job position.'),
company_name: z.string().describe('The name of the company offering the job.'),
- location: z.string().describe('The location where the job is based.'),
- currency: z.string().describe('The currency in which the salary is paid.'),
- salary_max: z.number().nullable().describe('The maximum salary for the job position. Nullable if not specified.'),
- salary_min: z.number().nullable().describe('The minimum salary for the job position. Nullable if not specified.'),
- requirements: z.array(z.string().nullable().describe('The requirements for the job position. Nullable if not specified.')),
- description: z.string().describe('The description of the job position.'),
- responsibilities: z.array(z.string().nullable().describe('The responsibilities associated with the job position. Nullable if not specified.')),
- benefits: z.array(z.string().nullable().describe('The benefits provided by the company for the job position. Nullable if not specified.'))
+ location: z.string().nullable().describe('The location where the job is based.'),
+ requirements: stringOrStringArray.nullable().describe('The requirements for the job position. Nullable if not specified.'),
+ description: stringOrStringArray.nullable().describe('The description of the job position.'),
+ responsibilities: stringOrStringArray.nullable().describe('The responsibilities associated with the job position. Nullable if not specified.'),
+ benefits: stringOrStringArray.nullable().describe('The benefits provided by the company for the job position. Nullable if not specified.'),
+ status: z.string().describe('Indicates if the job is currently being processed.').nullable().optional()
})
// Infer TypeScript types from the schema
diff --git a/lib/misc/__tests__/error-handler.test.ts b/lib/misc/__tests__/error-handler.test.ts
new file mode 100644
index 00000000..c5394921
--- /dev/null
+++ b/lib/misc/__tests__/error-handler.test.ts
@@ -0,0 +1,94 @@
+import {describe, expect, it} from 'vitest'
+import {ZodError} from 'zod'
+import {handleErrors} from '@/lib/misc/error-handler'
+
+describe('handleErrors', () => {
+ it('should handle ZodError with formatted message', () => {
+ const zodError = new ZodError([
+ {
+ code: 'invalid_type',
+ expected: 'string',
+ received: 'number',
+ path: ['name'],
+ message: 'Expected string, received number'
+ },
+ {
+ code: 'too_small',
+ minimum: 2,
+ type: 'string',
+ inclusive: true,
+ exact: false,
+ path: ['email'],
+ message: 'String must contain at least 2 character(s)'
+ }
+ ])
+
+ expect(() => handleErrors(zodError, 'test validation')).toThrow(
+ 'Validation failed for test validation: Expected string, received number, String must contain at least 2 character(s)'
+ )
+ })
+
+ it('should handle single ZodError', () => {
+ const zodError = new ZodError([
+ {
+ code: 'invalid_type',
+ expected: 'string',
+ received: 'number',
+ path: ['name'],
+ message: 'Expected string, received number'
+ }
+ ])
+
+ expect(() => handleErrors(zodError, 'single validation')).toThrow(
+ 'Validation failed for single validation: Expected string, received number'
+ )
+ })
+
+ it('should handle generic Error', () => {
+ const genericError = new Error('Something went wrong')
+
+ expect(() => handleErrors(genericError, 'generic operation')).toThrow(
+ 'Failed to generic operation: Something went wrong'
+ )
+ })
+
+ it('should handle unknown error types', () => {
+ const unknownError = 'string error'
+
+ expect(() => handleErrors(unknownError, 'unknown operation')).toThrow(
+ 'An unknown error occurred while trying to unknown operation'
+ )
+ })
+
+ it('should handle null error', () => {
+ expect(() => handleErrors(null, 'null operation')).toThrow(
+ 'An unknown error occurred while trying to null operation'
+ )
+ })
+
+ it('should handle undefined error', () => {
+ expect(() => handleErrors(undefined, 'undefined operation')).toThrow(
+ 'An unknown error occurred while trying to undefined operation'
+ )
+ })
+
+ it('should handle object error', () => {
+ const objectError = {message: 'Custom error object'}
+
+ expect(() => handleErrors(objectError, 'object operation')).toThrow(
+ 'An unknown error occurred while trying to object operation'
+ )
+ })
+
+ it('should always throw and never return', () => {
+ let errorThrown = false
+
+ try {
+ handleErrors(new Error('test'), 'test context')
+ } catch (error) {
+ errorThrown = true
+ }
+
+ expect(errorThrown).toBe(true)
+ })
+})
diff --git a/lib/onboarding/__tests__/actions.test.ts b/lib/onboarding/__tests__/actions.test.ts
new file mode 100644
index 00000000..6d8e681a
--- /dev/null
+++ b/lib/onboarding/__tests__/actions.test.ts
@@ -0,0 +1,586 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
+import {
+ completeOnboarding,
+ getOnboardingState,
+ progressToNextStep,
+ resetOnboarding,
+ updateOnboardingStep
+} from '@/lib/onboarding/actions'
+import {auth, clerkClient} from '@clerk/nextjs/server'
+import {ONBOARDING_STEPS, OnboardingMetadata, OnboardingStep} from '@/lib/onboarding/types'
+
+// Mock dependencies
+vi.mock('@clerk/nextjs/server')
+vi.mock('../types', async () => {
+ const actual = await vi.importActual('../types')
+ return {
+ ...actual,
+ getNextStep: vi.fn()
+ }
+})
+
+const mockAuth = vi.mocked(auth)
+const mockClerkClient = vi.mocked(clerkClient)
+const mockGetNextStep = vi.mocked((await import('../types')).getNextStep)
+
+// Mock Clerk client methods
+const mockClient = {
+ users: {
+ getUser: vi.fn(),
+ updateUser: vi.fn()
+ }
+}
+
+// Helper function to create proper Auth objects for mocking
+const createMockAuthObject = (userId: string | null) => {
+ if (userId) {
+ return {
+ userId,
+ sessionClaims: {},
+ sessionId: 'session_123',
+ sessionStatus: 'active' as const,
+ actor: null,
+ orgId: null,
+ orgRole: null,
+ orgSlug: null,
+ orgPermissions: null,
+ has: vi.fn(),
+ redirectToSignIn: vi.fn(),
+ protect: vi.fn(),
+ getToken: vi.fn(),
+ factorVerificationAge: null,
+ debug: vi.fn()
+ } as any
+ } else {
+ return {
+ userId: null,
+ sessionClaims: null,
+ sessionId: null,
+ sessionStatus: 'unauthenticated' as const,
+ actor: null,
+ orgId: null,
+ orgRole: null,
+ orgSlug: null,
+ orgPermissions: null,
+ has: vi.fn(),
+ redirectToSignIn: vi.fn(),
+ protect: vi.fn(),
+ getToken: vi.fn(),
+ factorVerificationAge: null,
+ debug: vi.fn()
+ } as any
+ }
+}
+
+describe('Onboarding Actions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockClerkClient.mockResolvedValue(mockClient as any)
+ })
+
+ afterEach(() => {
+ vi.resetAllMocks()
+ })
+
+ describe('updateOnboardingStep', () => {
+ const mockUser = {
+ id: 'user_123',
+ publicMetadata: {
+ onboardingComplete: false,
+ currentOnboardingStep: 'welcome' as OnboardingStep,
+ completedSteps: ['welcome'] as OnboardingStep[]
+ }
+ }
+
+ it('should update onboarding step successfully', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(mockUser)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ const result = await updateOnboardingStep('about')
+
+ expect(mockAuth).toHaveBeenCalled()
+ expect(mockClient.users.getUser).toHaveBeenCalledWith('user_123')
+ expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', {
+ publicMetadata: {
+ onboardingComplete: false,
+ currentOnboardingStep: 'about',
+ completedSteps: ['welcome', 'about']
+ }
+ })
+ expect(result).toEqual({
+ onboardingComplete: false,
+ currentOnboardingStep: 'about',
+ completedSteps: ['welcome', 'about']
+ })
+ })
+
+ it('should not duplicate steps in completedSteps', async () => {
+ const userWithDuplicateStep = {
+ ...mockUser,
+ publicMetadata: {
+ ...mockUser.publicMetadata,
+ completedSteps: ['welcome', 'about'] as OnboardingStep[]
+ }
+ }
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(userWithDuplicateStep)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ const result = await updateOnboardingStep('about')
+
+ expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', {
+ publicMetadata: {
+ onboardingComplete: false,
+ currentOnboardingStep: 'about',
+ completedSteps: ['welcome', 'about']
+ }
+ })
+ expect(result.completedSteps).toEqual(['welcome', 'about'])
+ })
+
+ it('should handle empty publicMetadata', async () => {
+ const userWithEmptyMetadata = {
+ id: 'user_123',
+ publicMetadata: {}
+ }
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(userWithEmptyMetadata)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ const result = await updateOnboardingStep('welcome')
+
+ expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', {
+ publicMetadata: {
+ currentOnboardingStep: 'welcome',
+ completedSteps: ['welcome']
+ }
+ })
+ expect(result.completedSteps).toEqual(['welcome'])
+ })
+
+ it('should handle null publicMetadata', async () => {
+ const userWithNullMetadata = {
+ id: 'user_123',
+ publicMetadata: null
+ }
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(userWithNullMetadata)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ const result = await updateOnboardingStep('welcome')
+
+ expect(result.completedSteps).toEqual(['welcome'])
+ })
+
+ it('should throw error when user is not authenticated', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject(null))
+
+ await expect(updateOnboardingStep('about')).rejects.toThrow('User not authenticated')
+ expect(mockClient.users.getUser).not.toHaveBeenCalled()
+ })
+
+ it('should handle all onboarding steps', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(mockUser)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ const steps: OnboardingStep[] = ['welcome', 'about', 'personal-details', 'education', 'experience', 'resume']
+
+ for (const step of steps) {
+ const result = await updateOnboardingStep(step)
+ expect(result.currentOnboardingStep).toBe(step)
+ expect(result.completedSteps).toContain(step)
+ }
+ })
+
+ it('should preserve existing metadata properties', async () => {
+ const userWithExtraMetadata = {
+ id: 'user_123',
+ publicMetadata: {
+ onboardingComplete: false,
+ currentOnboardingStep: 'welcome' as OnboardingStep,
+ completedSteps: ['welcome'] as OnboardingStep[]
+ }
+ }
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(userWithExtraMetadata)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ const result = await updateOnboardingStep('about')
+
+ expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', {
+ publicMetadata: {
+ onboardingComplete: false,
+ currentOnboardingStep: 'about',
+ completedSteps: ['welcome', 'about']
+ }
+ })
+ })
+ })
+
+ describe('progressToNextStep', () => {
+ const mockUser = {
+ id: 'user_123',
+ publicMetadata: {
+ onboardingComplete: false,
+ currentOnboardingStep: 'welcome' as OnboardingStep,
+ completedSteps: ['welcome'] as OnboardingStep[]
+ }
+ }
+
+ it('should progress to next step successfully', async () => {
+ mockGetNextStep.mockReturnValue('about' as OnboardingStep)
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(mockUser)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ const result = await progressToNextStep('welcome')
+
+ expect(mockGetNextStep).toHaveBeenCalledWith('welcome')
+ expect(result.currentOnboardingStep).toBe('about')
+ })
+
+ it('should complete onboarding when no next step exists', async () => {
+ mockGetNextStep.mockReturnValue(null)
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(mockUser)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ const result = await progressToNextStep('resume')
+
+ expect(mockGetNextStep).toHaveBeenCalledWith('resume')
+ expect(result.onboardingComplete).toBe(true)
+ expect(result.currentOnboardingStep).toBe('resume')
+ expect(result.completedSteps).toEqual(ONBOARDING_STEPS)
+ })
+
+ it('should handle progression through all steps', async () => {
+ const stepProgression = [
+ {current: 'welcome', next: 'about'},
+ {current: 'about', next: 'personal-details'},
+ {current: 'personal-details', next: 'education'},
+ {current: 'education', next: 'experience'},
+ {current: 'experience', next: 'resume'},
+ {current: 'resume', next: null}
+ ]
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(mockUser)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ for (const {current, next} of stepProgression) {
+ mockGetNextStep.mockReturnValue(next as OnboardingStep)
+
+ const result = await progressToNextStep(current as OnboardingStep)
+
+ if (next) {
+ expect(result.currentOnboardingStep).toBe(next)
+ expect(result.onboardingComplete).toBeFalsy()
+ } else {
+ expect(result.onboardingComplete).toBe(true)
+ }
+ }
+ })
+ })
+
+ describe('completeOnboarding', () => {
+ const mockUser = {
+ id: 'user_123',
+ publicMetadata: {
+ onboardingComplete: false,
+ currentOnboardingStep: 'experience' as OnboardingStep,
+ completedSteps: ['welcome', 'about', 'personal-details', 'education', 'experience'] as OnboardingStep[]
+ }
+ }
+
+ it('should complete onboarding successfully', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(mockUser)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ const result = await completeOnboarding()
+
+ expect(mockAuth).toHaveBeenCalled()
+ expect(mockClient.users.getUser).toHaveBeenCalledWith('user_123')
+ expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', {
+ publicMetadata: {
+ onboardingComplete: true,
+ currentOnboardingStep: 'resume',
+ completedSteps: ONBOARDING_STEPS
+ }
+ })
+ expect(result).toEqual({
+ onboardingComplete: true,
+ currentOnboardingStep: 'resume',
+ completedSteps: ONBOARDING_STEPS
+ })
+ })
+
+ it('should handle empty publicMetadata', async () => {
+ const userWithEmptyMetadata = {
+ id: 'user_123',
+ publicMetadata: {}
+ }
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(userWithEmptyMetadata)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ const result = await completeOnboarding()
+
+ expect(result.onboardingComplete).toBe(true)
+ expect(result.currentOnboardingStep).toBe('resume')
+ expect(result.completedSteps).toEqual(ONBOARDING_STEPS)
+ })
+
+ it('should preserve existing metadata properties', async () => {
+ const userWithExtraMetadata = {
+ id: 'user_123',
+ publicMetadata: {
+ ...mockUser.publicMetadata,
+ customProperty: 'custom_value'
+ }
+ }
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(userWithExtraMetadata)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ await completeOnboarding()
+
+ expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', {
+ publicMetadata: {
+ customProperty: 'custom_value',
+ onboardingComplete: true,
+ currentOnboardingStep: 'resume',
+ completedSteps: ONBOARDING_STEPS
+ }
+ })
+ })
+
+ it('should throw error when user is not authenticated', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject(null))
+
+ await expect(completeOnboarding()).rejects.toThrow('User not authenticated')
+ expect(mockClient.users.getUser).not.toHaveBeenCalled()
+ })
+
+ it('should handle already completed onboarding', async () => {
+ const completedUser = {
+ id: 'user_123',
+ publicMetadata: {
+ onboardingComplete: true,
+ currentOnboardingStep: 'resume' as OnboardingStep,
+ completedSteps: ONBOARDING_STEPS
+ }
+ }
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(completedUser)
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ const result = await completeOnboarding()
+
+ expect(result.onboardingComplete).toBe(true)
+ expect(result.completedSteps).toEqual(ONBOARDING_STEPS)
+ })
+ })
+
+ describe('getOnboardingState', () => {
+ const mockOnboardingMetadata: OnboardingMetadata = {
+ onboardingComplete: false,
+ currentOnboardingStep: 'about',
+ completedSteps: ['welcome', 'about']
+ }
+
+ const mockUser = {
+ id: 'user_123',
+ publicMetadata: mockOnboardingMetadata
+ }
+
+ it('should get onboarding state successfully', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(mockUser)
+
+ const result = await getOnboardingState()
+
+ expect(mockAuth).toHaveBeenCalled()
+ expect(mockClient.users.getUser).toHaveBeenCalledWith('user_123')
+ expect(result).toEqual(mockOnboardingMetadata)
+ })
+
+ it('should handle empty publicMetadata', async () => {
+ const userWithEmptyMetadata = {
+ id: 'user_123',
+ publicMetadata: {}
+ }
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(userWithEmptyMetadata)
+
+ const result = await getOnboardingState()
+
+ expect(result).toEqual({})
+ })
+
+ it('should handle null publicMetadata', async () => {
+ const userWithNullMetadata = {
+ id: 'user_123',
+ publicMetadata: null
+ }
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(userWithNullMetadata)
+
+ const result = await getOnboardingState()
+
+ expect(result).toBeNull()
+ })
+
+ it('should throw error when user is not authenticated', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject(null))
+
+ await expect(getOnboardingState()).rejects.toThrow('User not authenticated')
+ expect(mockClient.users.getUser).not.toHaveBeenCalled()
+ })
+
+ it('should handle completed onboarding state', async () => {
+ const completedMetadata: OnboardingMetadata = {
+ onboardingComplete: true,
+ currentOnboardingStep: 'resume',
+ completedSteps: ONBOARDING_STEPS
+ }
+
+ const completedUser = {
+ id: 'user_123',
+ publicMetadata: completedMetadata
+ }
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(completedUser)
+
+ const result = await getOnboardingState()
+
+ expect(result).toEqual(completedMetadata)
+ expect(result.onboardingComplete).toBe(true)
+ expect(result.completedSteps).toHaveLength(6)
+ })
+
+ it('should handle partial onboarding state', async () => {
+ const partialMetadata: OnboardingMetadata = {
+ currentOnboardingStep: 'education',
+ completedSteps: ['welcome', 'about', 'personal-details']
+ }
+
+ const partialUser = {
+ id: 'user_123',
+ publicMetadata: partialMetadata
+ }
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(partialUser)
+
+ const result = await getOnboardingState()
+
+ expect(result).toEqual(partialMetadata)
+ expect(result.onboardingComplete).toBeUndefined()
+ expect(result.completedSteps).toHaveLength(3)
+ })
+ })
+
+ describe('resetOnboarding', () => {
+ it('should reset onboarding successfully', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ await resetOnboarding()
+
+ expect(mockAuth).toHaveBeenCalled()
+ expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', {
+ publicMetadata: {
+ onboardingComplete: false,
+ currentOnboardingStep: 'welcome',
+ completedSteps: []
+ }
+ })
+ })
+
+ it('should throw error when user is not authenticated', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject(null))
+
+ await expect(resetOnboarding()).rejects.toThrow('User not authenticated')
+ expect(mockClient.users.updateUser).not.toHaveBeenCalled()
+ })
+
+ it('should reset from any onboarding state', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.updateUser.mockResolvedValue({})
+
+ // Test multiple calls to ensure consistent reset
+ await resetOnboarding()
+ await resetOnboarding()
+
+ expect(mockClient.users.updateUser).toHaveBeenCalledTimes(2)
+ mockClient.users.updateUser.mock.calls.forEach(call => {
+ expect(call[1]).toEqual({
+ publicMetadata: {
+ onboardingComplete: false,
+ currentOnboardingStep: 'welcome',
+ completedSteps: []
+ }
+ })
+ })
+ })
+
+ it('should handle Clerk client errors', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.updateUser.mockRejectedValue(new Error('Clerk API Error'))
+
+ await expect(resetOnboarding()).rejects.toThrow('Clerk API Error')
+ })
+ })
+
+ describe('Error Handling', () => {
+ it('should handle Clerk authentication errors', async () => {
+ mockAuth.mockRejectedValue(new Error('Auth service unavailable'))
+
+ await expect(updateOnboardingStep('about')).rejects.toThrow('Auth service unavailable')
+ })
+
+ it('should handle Clerk client initialization errors', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClerkClient.mockRejectedValue(new Error('Clerk client initialization failed'))
+
+ await expect(updateOnboardingStep('about')).rejects.toThrow('Clerk client initialization failed')
+ })
+
+ it('should handle user fetch errors', async () => {
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockRejectedValue(new Error('User not found'))
+
+ await expect(updateOnboardingStep('about')).rejects.toThrow('User not found')
+ })
+
+ it('should handle user update errors', async () => {
+ const mockUser = {
+ id: 'user_123',
+ publicMetadata: {
+ onboardingComplete: false,
+ currentOnboardingStep: 'welcome' as OnboardingStep,
+ completedSteps: ['welcome'] as OnboardingStep[]
+ }
+ }
+
+ mockAuth.mockResolvedValue(createMockAuthObject('user_123'))
+ mockClient.users.getUser.mockResolvedValue(mockUser)
+ mockClient.users.updateUser.mockRejectedValue(new Error('Update failed'))
+
+ await expect(updateOnboardingStep('about')).rejects.toThrow('Update failed')
+ })
+ })
+})
diff --git a/lib/onboarding/actions.ts b/lib/onboarding/actions.ts
new file mode 100644
index 00000000..fc67aac2
--- /dev/null
+++ b/lib/onboarding/actions.ts
@@ -0,0 +1,124 @@
+'use server'
+
+import {auth, clerkClient} from '@clerk/nextjs/server'
+import {
+ getNextStep,
+ ONBOARDING_STEPS,
+ OnboardingMetadata,
+ OnboardingMetadataSchema,
+ OnboardingStep
+} from '@/lib/onboarding/types'
+
+/**
+ * Updates the user's current onboarding step
+ */
+export const updateOnboardingStep = async (step: OnboardingStep): Promise => {
+ const {userId} = await auth()
+
+ if (!userId) {
+ throw new Error('User not authenticated')
+ }
+
+ const client = await clerkClient()
+ const user = await client.users.getUser(userId)
+
+ const currentMetadata = OnboardingMetadataSchema.parse(user.publicMetadata || {})
+ const completedSteps = currentMetadata.completedSteps || []
+
+ // Add current step to completed steps if not already there
+ const updatedCompletedSteps = completedSteps.includes(step)
+ ? completedSteps
+ : [...completedSteps, step]
+
+ const updatedMetadata: OnboardingMetadata = {
+ ...currentMetadata,
+ currentOnboardingStep: step,
+ completedSteps: updatedCompletedSteps
+ }
+
+ await client.users.updateUser(userId, {
+ publicMetadata: updatedMetadata
+ })
+
+ return updatedMetadata
+}
+
+/**
+ * Progresses to the next onboarding step
+ */
+export const progressToNextStep = async (currentStep: OnboardingStep): Promise => {
+ const nextStep = getNextStep(currentStep)
+
+ if (!nextStep) {
+ // If no next step, complete onboarding
+ return completeOnboarding()
+ }
+
+ return updateOnboardingStep(nextStep)
+}
+
+/**
+ * Marks onboarding as complete
+ */
+export const completeOnboarding = async (): Promise => {
+ const {userId} = await auth()
+
+ if (!userId) {
+ throw new Error('User not authenticated')
+ }
+
+ const client = await clerkClient()
+ const user = await client.users.getUser(userId)
+
+ const currentMetadata = user.publicMetadata as OnboardingMetadata
+
+ const completedMetadata: OnboardingMetadata = {
+ ...currentMetadata,
+ onboardingComplete: true,
+ currentOnboardingStep: 'resume', // Final step
+ completedSteps: ONBOARDING_STEPS // Mark all steps as completed
+ }
+
+ await client.users.updateUser(userId, {
+ publicMetadata: completedMetadata
+ })
+
+ return completedMetadata
+}
+
+/**
+ * Gets the current onboarding state for the user
+ */
+export const getOnboardingState = async (): Promise => {
+ const {userId} = await auth()
+
+ if (!userId) {
+ throw new Error('User not authenticated')
+ }
+
+ const client = await clerkClient()
+ const user = await client.users.getUser(userId)
+
+ return user.publicMetadata as OnboardingMetadata
+}
+
+/**
+ * Resets onboarding state (useful for testing)
+ */
+export const resetOnboarding = async (): Promise => {
+ const {userId} = await auth()
+
+ if (!userId) {
+ throw new Error('User not authenticated')
+ }
+
+ const client = await clerkClient()
+
+ await client.users.updateUser(userId, {
+ publicMetadata: {
+ onboardingComplete: false,
+ currentOnboardingStep: 'welcome',
+ completedSteps: []
+ }
+ })
+}
diff --git a/lib/onboarding/globals.d.ts b/lib/onboarding/globals.d.ts
new file mode 100644
index 00000000..15e10fef
--- /dev/null
+++ b/lib/onboarding/globals.d.ts
@@ -0,0 +1,9 @@
+import {OnboardingMetadata} from '@/lib/onboarding/types'
+
+declare global {
+ interface CustomJwtSessionClaims {
+ metadata: OnboardingMetadata
+ }
+
+ interface UserPublicMetadata extends OnboardingMetadata {}
+}
diff --git a/lib/onboarding/hooks.ts b/lib/onboarding/hooks.ts
new file mode 100644
index 00000000..ffcbd95e
--- /dev/null
+++ b/lib/onboarding/hooks.ts
@@ -0,0 +1,56 @@
+'use client'
+
+import {useUser} from '@clerk/nextjs'
+import {OnboardingMetadata, OnboardingStep} from '@/lib/onboarding/types'
+import {ONBOARDING_STEPS} from '@/app/app/onboarding/types'
+
+/**
+ * Hook to get the current onboarding state from the user's metadata
+ */
+export const useOnboardingState = () => {
+ const {user} = useUser()
+
+ const metadata = user?.publicMetadata as OnboardingMetadata
+
+ return {
+ onboardingComplete: metadata?.onboardingComplete ?? false,
+ currentStep: metadata?.currentOnboardingStep ?? 'welcome',
+ completedSteps: metadata?.completedSteps ?? [],
+ isLoading: !user
+ }
+}
+
+/**
+ * Hook to check if onboarding is complete
+ */
+export const useOnboardingComplete = () => {
+ const {onboardingComplete} = useOnboardingState()
+ return onboardingComplete
+}
+
+/**
+ * Hook to get the current onboarding step
+ */
+export const useCurrentOnboardingStep = () => {
+ const {currentStep} = useOnboardingState()
+ return currentStep
+}
+
+/**
+ * Hook to check if a specific step is completed
+ */
+export const useIsStepCompleted = (step: OnboardingStep) => {
+ const {completedSteps} = useOnboardingState()
+ return completedSteps.includes(step)
+}
+
+/**
+ * Hook to get onboarding progress percentage
+ */
+export const useOnboardingProgress = () => {
+ const {completedSteps} = useOnboardingState()
+ const totalSteps = ONBOARDING_STEPS.length // welcome, about, personal-details, education, experience, resume
+ const completedCount = completedSteps.length
+
+ return Math.round((completedCount / totalSteps) * 100)
+}
diff --git a/lib/onboarding/types.ts b/lib/onboarding/types.ts
new file mode 100644
index 00000000..97fbc782
--- /dev/null
+++ b/lib/onboarding/types.ts
@@ -0,0 +1,52 @@
+import {z} from 'zod'
+
+// Define the available onboarding steps
+export const OnboardingStepSchema = z.enum([
+ 'welcome',
+ 'about',
+ 'personal-details',
+ 'education',
+ 'experience',
+ 'resume'
+])
+
+export type OnboardingStep = z.infer
+
+// Schema for onboarding metadata
+export const OnboardingMetadataSchema = z.object({
+ onboardingComplete: z.boolean().optional(),
+ currentOnboardingStep: OnboardingStepSchema.optional(),
+ completedSteps: z.array(OnboardingStepSchema).optional()
+})
+
+export type OnboardingMetadata = z.infer
+
+// Step progression mapping
+export const ONBOARDING_STEPS: OnboardingStep[] = [
+ 'welcome',
+ 'about',
+ 'personal-details',
+ 'education',
+ 'experience',
+ 'resume'
+]
+
+// Helper to get next step
+export const getNextStep = (currentStep: OnboardingStep): OnboardingStep | null => {
+ const currentIndex = ONBOARDING_STEPS.indexOf(currentStep)
+ if (currentIndex === -1) {
+ throw new Error(`Invalid onboarding step: ${currentStep}`)
+ }
+ const nextIndex = currentIndex + 1
+ return nextIndex < ONBOARDING_STEPS.length ? ONBOARDING_STEPS[nextIndex] : null
+}
+
+// Helper to get previous step
+export const getPreviousStep = (currentStep: OnboardingStep): OnboardingStep | null => {
+ const currentIndex = ONBOARDING_STEPS.indexOf(currentStep)
+ if (currentIndex === -1) {
+ throw new Error(`Invalid onboarding step: ${currentStep}`)
+ }
+ const previousIndex = currentIndex - 1
+ return previousIndex >= 0 ? ONBOARDING_STEPS[previousIndex] : null
+}
diff --git a/lib/project/actions.ts b/lib/project/actions.ts
new file mode 100644
index 00000000..3cd8553f
--- /dev/null
+++ b/lib/project/actions.ts
@@ -0,0 +1,103 @@
+'use server'
+
+import {z} from 'zod'
+import {
+ GlobalSkill,
+ GlobalSkillSchema,
+ Project,
+ ProjectMutation,
+ ProjectMutationSchema,
+ ProjectSchema
+} from '@/lib/project/types'
+import {api} from '@/lib/config/api-client'
+import {handleErrors} from '@/lib/misc/error-handler'
+
+/**
+ * Gets all projects from the database for a given resume
+ * @param {string} resumeId - The resume id, default is 'base'
+ * @returns {Promise} - Array of projects
+ * @throws {Error} If authentication or API request fails.
+ */
+export const getProjectsFromDB = async (resumeId: string = 'base'): Promise => {
+ try {
+ const data = await api.get(`/resume/${resumeId}/project/`)
+
+ return z.array(ProjectSchema).parse(data)
+ } catch (error) {
+ return handleErrors(error, 'fetch projects')
+ }
+}
+
+/**
+ * Fetches all global skills available in the database
+ * @returns {Promise} - Array of skills
+ * @throws {Error} If API request fails.
+ */
+export const getGlobalSkills = async (): Promise => {
+ try {
+ const data = await api.get('/skills/')
+ return z.array(GlobalSkillSchema).parse(data)
+ } catch (error) {
+ return handleErrors(error, 'fetch global skills')
+ }
+}
+
+/**
+ * Adds a new project in the database
+ * @param {ProjectMutation} projectValues - The project information
+ * @param {string} resumeId - The resume id, default is 'base'
+ * @returns {Promise} - The new project object
+ * @throws {Error} If validation, authentication, or API request fails.
+ */
+export const addProjectToDB = async (projectValues: ProjectMutation, resumeId: string = 'base'): Promise => {
+ try {
+ const params = ProjectMutationSchema.parse(projectValues)
+ const data = await api.post(`/resume/${resumeId}/project/`, params)
+ return ProjectSchema.parse(data)
+ } catch (error) {
+ return handleErrors(error, 'add project')
+ }
+}
+
+/**
+ * Updates an existing project in the database
+ * @param {string} projectId - The project id
+ * @param {Partial} projectValues - The updated project information
+ * @param {string} resumeId - The resume id, default is 'base'
+ * @returns {Promise} - The updated project object
+ * @throws {Error} If validation, authentication, or API request fails.
+ */
+export const updateProjectInDB = async (
+ projectId: string,
+ projectValues: Partial,
+ resumeId: string = 'base'
+): Promise => {
+ try {
+ const params = ProjectMutationSchema.partial().parse(projectValues)
+ const data = await api.patch(
+ `/resume/${resumeId}/project/${projectId}/`,
+ params
+ )
+ return ProjectSchema.parse(data)
+ } catch (error) {
+ return handleErrors(error, 'update project')
+ }
+}
+
+/**
+ * Deletes a project from the database
+ * @param {string} projectId - The project id
+ * @param {string} resumeId - The resume id, default is 'base'
+ * @returns {Promise}
+ * @throws {Error} If authentication or API request fails.
+ */
+export const deleteProjectFromDB = async (
+ projectId: string,
+ resumeId: string = 'base'
+): Promise => {
+ try {
+ await api.delete(`/resume/${resumeId}/project/${projectId}/`)
+ } catch (error) {
+ return handleErrors(error, 'delete project')
+ }
+}
diff --git a/lib/project/keys.ts b/lib/project/keys.ts
new file mode 100644
index 00000000..506b39d8
--- /dev/null
+++ b/lib/project/keys.ts
@@ -0,0 +1,4 @@
+/**
+ * React Query keys for project-related queries
+ */
+export const PROJECT_KEYS = ['projects']
diff --git a/lib/project/mutations.ts b/lib/project/mutations.ts
new file mode 100644
index 00000000..6250e109
--- /dev/null
+++ b/lib/project/mutations.ts
@@ -0,0 +1,18 @@
+import {Project, ProjectMutation} from '@/lib/project/types'
+import {MutationOptions, useMutation} from '@tanstack/react-query'
+import {addProjectToDB, deleteProjectFromDB, updateProjectInDB} from '@/lib/project/actions'
+
+export const useAddProjectMutation = (options?: MutationOptions) => useMutation({
+ mutationFn: ({data, resumeId}) => addProjectToDB(data, resumeId || 'base'),
+ ...options
+})
+
+export const useUpdateProjectMutation = (options?: MutationOptions, resumeId?: string}>) => useMutation({
+ mutationFn: ({id, data, resumeId}) => updateProjectInDB(id, data, resumeId || 'base'),
+ ...options
+})
+
+export const useDeleteProjectMutation = (options?: MutationOptions) => useMutation({
+ mutationFn: ({id, resumeId}) => deleteProjectFromDB(id, resumeId || 'base'),
+ ...options
+})
diff --git a/lib/project/queries.ts b/lib/project/queries.ts
new file mode 100644
index 00000000..5f027086
--- /dev/null
+++ b/lib/project/queries.ts
@@ -0,0 +1,19 @@
+import {queryOptions, useQuery} from '@tanstack/react-query'
+import {getGlobalSkills, getProjectsFromDB} from './actions'
+import {PROJECT_KEYS} from './keys'
+
+export const projectQueryOptions = (resumeId: string) => queryOptions({
+ queryKey: [...PROJECT_KEYS, resumeId],
+ queryFn: () => getProjectsFromDB(resumeId)
+})
+
+export const globalSkillsQueryOptions = queryOptions({
+ queryKey: [...PROJECT_KEYS, 'globalSkills'],
+ queryFn: getGlobalSkills
+})
+
+export const useCurrentProjects = (resumeId: string) => {
+ return useQuery(projectQueryOptions(resumeId))
+}
+
+export const useGlobalSkills = () => useQuery(globalSkillsQueryOptions)
diff --git a/lib/project/types.ts b/lib/project/types.ts
new file mode 100644
index 00000000..aa0c4759
--- /dev/null
+++ b/lib/project/types.ts
@@ -0,0 +1,92 @@
+import {z} from 'zod'
+
+/**
+ * Project Schema for API interactions
+ */
+export const ProjectSchema = z.object({
+ id: z.string().uuid().describe('Unique identifier for the project').readonly(),
+ name: z.string().max(255).describe('Name of the project.'),
+ category: z.string().max(255).nullable().describe('Category or type of the project (e.g., Web Development, Mobile App).'),
+ description: z.string().nullable().optional().describe('Detailed description of the project and its objectives.'),
+ role: z.string().max(255).nullable().describe('Your role or position in this project (e.g., Lead Developer, UI Designer).'),
+ github_url: z.string().max(200).nullable().describe('Link to the project\'s GitHub repository.'),
+ live_url: z.string().max(200).nullable().describe('Link to the live/deployed version of the project.'),
+ started_from_month: z.number().int().min(1).max(12).nullable().describe('Month when the project started (1-12).'),
+ started_from_year: z.number().int().min(1900).max(2100).nullable().describe('Year when the project started (YYYY).'),
+ finished_at_month: z.number().int().min(1).max(12).nullable().describe('Month when the project was completed (1-12).'),
+ finished_at_year: z.number().int().min(1900).max(2100).nullable().describe('Year when the project was completed (YYYY).'),
+ current: z.boolean().nullable().describe('Indicates if this is a current/ongoing project.'),
+ user: z.string().describe('The user who owns this project').readonly(),
+ created_at: z.string().describe('Timestamp when the project was first created.').readonly(),
+ updated_at: z.string().describe('Timestamp when the project was last updated.').readonly(),
+ skills_used: z.array(z.object({
+ id: z.string().uuid().describe('The unique identifier for the skill entry.').readonly(),
+ category: z.string().max(50).nullable().describe('The category of the skill. (optional)'),
+ name: z.string().max(250).describe('The name of the skill.'),
+ preferred: z.boolean().optional().describe('Whether this is a preferred skill'),
+ alias: z.array(z.object({
+ id: z.string().describe('The unique identifier for the alias entry.').readonly(),
+ category: z.string().max(50).nullable().describe('The category of the alias. (optional)'),
+ name: z.string().max(250).describe('The name of the alias.'),
+ preferred: z.boolean().optional().describe('Whether this is a preferred alias'),
+ created_at: z.string().describe('The date and time the alias entry was created.').readonly(),
+ updated_at: z.string().describe('The date and time the alias entry was last updated.').readonly()
+ })).describe('Alternative names for the skill'),
+ updated_at: z.string().describe('The date and time the skill entry was last updated.').readonly(),
+ created_at: z.string().describe('The date and time the skill entry was created.').readonly()
+ })).describe('Skills used in this project'),
+ resume_section: z.string().uuid().describe('The unique identifier for the resume section entry.').readonly()
+})
+
+/**
+ * Schema for project creation/modification
+ * Derived by omitting read-only fields from ProjectSchema
+ */
+export const ProjectMutationSchema = ProjectSchema.omit({
+ id: true,
+ user: true,
+ created_at: true,
+ updated_at: true,
+ resume_section: true,
+ skills_used: true,
+ started_from_month: true,
+ started_from_year: true,
+ finished_at_month: true,
+ finished_at_year: true
+}).extend({
+ name: z.string().min(1, {message: 'Required'}).max(255).describe('Name of the project.'),
+ started_from_month: z.string().nullish(),
+ started_from_year: z.string().nullish(),
+ finished_at_month: z.string().nullish(),
+ finished_at_year: z.string().nullish(),
+ skills_used: z.array(z.object({
+ name: z.string().describe('The name of the skill.'),
+ category: z.string().nullable().describe('The category of the skill. (optional)')
+ })).describe('Skills used in this project')
+})
+
+// Infer TypeScript types from the schema
+export type Project = z.infer
+export type ProjectMutation = z.infer
+
+/**
+ * Schema for global skills
+ */
+export const GlobalSkillSchema = z.object({
+ id: z.string().describe('The unique identifier for the skill entry.').readonly(),
+ name: z.string().max(250).describe('The name of the skill.'),
+ category: z.string().max(50).nullable().describe('The category of the skill. (optional)'),
+ preferred: z.boolean().optional().describe('Whether this is a preferred skill'),
+ alias: z.array(z.object({
+ id: z.string().describe('The unique identifier for the alias entry.').readonly(),
+ category: z.string().max(50).nullable().describe('The category of the alias. (optional)'),
+ name: z.string().max(250).describe('The name of the alias.'),
+ preferred: z.boolean().optional().describe('Whether this is a preferred alias'),
+ created_at: z.string().describe('The date and time the alias entry was created.').readonly(),
+ updated_at: z.string().describe('The date and time the alias entry was last updated.').readonly()
+ })).describe('Alternative names for the skill'),
+ created_at: z.string().describe('The date and time the skill entry was created.').readonly(),
+ updated_at: z.string().describe('The date and time the skill entry was last updated.').readonly()
+})
+
+export type GlobalSkill = z.infer
diff --git a/lib/resume/__tests__/actions.test.ts b/lib/resume/__tests__/actions.test.ts
new file mode 100644
index 00000000..110b26aa
--- /dev/null
+++ b/lib/resume/__tests__/actions.test.ts
@@ -0,0 +1,436 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
+import {getResumeFromDB, rearrangeResumeSections} from '@/lib/resume/actions'
+import {api} from '@/lib/config/api-client'
+import {handleErrors} from '@/lib/misc/error-handler'
+import {Resume} from '@/lib/resume/types'
+
+// Mock dependencies
+vi.mock('@/lib/config/api-client')
+vi.mock('@/lib/misc/error-handler')
+
+const mockApi = vi.mocked(api)
+const mockHandleErrors = vi.mocked(handleErrors)
+
+describe('Resume Actions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.resetAllMocks()
+ })
+
+ describe('getResumeFromDB', () => {
+ const mockResume: Resume = {
+ id: 'resume_123',
+ base: true,
+ status: 'Success',
+ user: {
+ id: 'user_123',
+ title: null,
+ first_name: 'John',
+ last_name: 'Doe',
+ email: 'john.doe@example.com',
+ phone: '+1-555-0123',
+ dob: null,
+ nationality: null,
+ address: null,
+ city: null,
+ postal: null,
+ country: null,
+ website: 'https://johndoe.dev',
+ profile_text: null,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z'
+ },
+ job: {
+ job_url: 'https://example.com/job/1',
+ title: 'Senior Software Engineer',
+ company_name: 'Tech Corp',
+ location: 'San Francisco, CA',
+ requirements: 'React, TypeScript, Node.js',
+ description: 'We are looking for a senior software engineer.',
+ responsibilities: 'Lead development projects, Mentor junior developers',
+ benefits: 'Health insurance, Remote work, 401k matching',
+ status: 'Success'
+ },
+ sections: [
+ {
+ id: '123e4567-e89b-12d3-a456-426614174001',
+ resume: 'resume_123',
+ index: 0,
+ type: 'Experience',
+ data: {
+ id: '123e4567-e89b-12d3-a456-426614174010',
+ user: 'user_123',
+ resume_section: '123e4567-e89b-12d3-a456-426614174001',
+ company_name: 'Tech Corp',
+ job_title: 'Senior Software Engineer',
+ country: {
+ code: 'USA',
+ name: 'United States'
+ },
+ city: 'San Francisco',
+ employment_type: 'flt',
+ started_from_month: 1,
+ started_from_year: 2022,
+ finished_at_month: 12,
+ finished_at_year: 2023,
+ current: false,
+ description: 'Led development of web applications.',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z'
+ }
+ },
+ {
+ id: '123e4567-e89b-12d3-a456-426614174002',
+ resume: 'resume_123',
+ index: 1,
+ type: 'Education',
+ data: {
+ id: '123e4567-e89b-12d3-a456-426614174020',
+ user: 'user_123',
+ resume_section: '123e4567-e89b-12d3-a456-426614174002',
+ institution_name: 'University of Technology',
+ degree: 'Bachelor of Science',
+ field_of_study: 'Computer Science',
+ country: {
+ code: 'USA',
+ name: 'United States'
+ },
+ started_from_month: 9,
+ started_from_year: 2018,
+ finished_at_month: 5,
+ finished_at_year: 2022,
+ current: false,
+ description: 'Magna Cum Laude, Dean\'s List',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z'
+ }
+ }
+ ]
+ }
+
+ it('should fetch resume with default resumeId', async () => {
+ mockApi.get.mockResolvedValue(mockResume)
+
+ const result = await getResumeFromDB()
+
+ expect(mockApi.get).toHaveBeenCalledWith('/resume/base/')
+ expect(result).toEqual(mockResume)
+ })
+
+ it('should fetch resume with custom resumeId', async () => {
+ mockApi.get.mockResolvedValue(mockResume)
+
+ const result = await getResumeFromDB('custom-resume-123')
+
+ expect(mockApi.get).toHaveBeenCalledWith('/resume/custom-resume-123/')
+ expect(result).toEqual(mockResume)
+ })
+
+ it('should handle base resumeId explicitly', async () => {
+ mockApi.get.mockResolvedValue(mockResume)
+
+ const result = await getResumeFromDB('base')
+
+ expect(mockApi.get).toHaveBeenCalledWith('/resume/base/')
+ expect(result).toEqual(mockResume)
+ })
+
+ it('should validate response data with schema', async () => {
+ mockApi.get.mockResolvedValue(mockResume)
+
+ const result = await getResumeFromDB()
+
+ expect(result).toMatchObject({
+ id: expect.any(String),
+ base: expect.any(Boolean),
+ user: expect.objectContaining({
+ id: expect.any(String),
+ first_name: expect.any(String),
+ last_name: expect.any(String),
+ email: expect.any(String)
+ }),
+ job: expect.objectContaining({
+ job_url: expect.any(String),
+ title: expect.any(String),
+ company_name: expect.any(String)
+ }),
+ sections: expect.any(Array)
+ })
+ })
+
+ it('should handle API errors', async () => {
+ const error = new Error('API Error')
+ mockApi.get.mockRejectedValue(error)
+ vi.mocked(handleErrors).mockImplementation(() => {
+ throw new Error('Failed to fetch resume: API Error')
+ })
+
+ await expect(getResumeFromDB()).rejects.toThrow('Failed to fetch resume: API Error')
+ expect(mockHandleErrors).toHaveBeenCalledWith(error, 'fetch resume')
+ })
+
+ it('should return resume with all sections', async () => {
+ mockApi.get.mockResolvedValue(mockResume)
+
+ const result = await getResumeFromDB()
+
+ expect(result.sections).toHaveLength(2)
+ expect(result.sections[0].type).toBe('Experience')
+ expect(result.sections[1].type).toBe('Education')
+ })
+
+ it('should handle resume with no sections', async () => {
+ const resumeWithNoSections: Resume = {
+ ...mockResume,
+ sections: []
+ }
+
+ mockApi.get.mockResolvedValue(resumeWithNoSections)
+
+ const result = await getResumeFromDB()
+
+ expect(result.sections).toEqual([])
+ })
+
+ it('should handle resume with null job values', async () => {
+ const resumeWithNullJobValues: Resume = {
+ ...mockResume,
+ job: {
+ ...mockResume.job,
+ requirements: null,
+ responsibilities: null,
+ benefits: null
+ }
+ }
+
+ mockApi.get.mockResolvedValue(resumeWithNullJobValues)
+
+ const result = await getResumeFromDB()
+
+ expect(result.job.requirements).toBeNull()
+ expect(result.job.responsibilities).toBeNull()
+ expect(result.job.benefits).toBeNull()
+ })
+ })
+
+ describe('rearrangeResumeSections', () => {
+ const mockUpdatedResume: Resume = {
+ id: 'resume_123',
+ base: true,
+ status: 'Success',
+ user: {
+ id: 'user_123',
+ title: null,
+ first_name: 'John',
+ last_name: 'Doe',
+ email: 'john.doe@example.com',
+ phone: '+1-555-0123',
+ dob: null,
+ nationality: null,
+ address: null,
+ city: null,
+ postal: null,
+ country: null,
+ website: 'https://johndoe.dev',
+ profile_text: null,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z'
+ },
+ job: {
+ job_url: 'https://example.com/job/1',
+ title: 'Senior Software Engineer',
+ company_name: 'Tech Corp',
+ location: 'San Francisco, CA',
+ requirements: 'React, TypeScript, Node.js',
+ description: 'We are looking for a senior software engineer.',
+ responsibilities: 'Lead development projects, Mentor junior developers',
+ benefits: 'Health insurance, Remote work, 401k matching',
+ status: 'Success'
+ },
+ sections: [
+ {
+ id: '123e4567-e89b-12d3-a456-426614174002',
+ resume: 'resume_123',
+ index: 0,
+ type: 'Education',
+ data: {
+ id: '123e4567-e89b-12d3-a456-426614174020',
+ user: 'user_123',
+ resume_section: '123e4567-e89b-12d3-a456-426614174002',
+ institution_name: 'University of Technology',
+ degree: 'Bachelor of Science',
+ field_of_study: 'Computer Science',
+ country: {
+ code: 'USA',
+ name: 'United States'
+ },
+ started_from_month: 9,
+ started_from_year: 2018,
+ finished_at_month: 5,
+ finished_at_year: 2022,
+ current: false,
+ description: 'Magna Cum Laude, Dean\'s List',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z'
+ }
+ },
+ {
+ id: '123e4567-e89b-12d3-a456-426614174001',
+ resume: 'resume_123',
+ index: 1,
+ type: 'Experience',
+ data: {
+ id: '123e4567-e89b-12d3-a456-426614174010',
+ user: 'user_123',
+ resume_section: '123e4567-e89b-12d3-a456-426614174001',
+ company_name: 'Tech Corp',
+ job_title: 'Senior Software Engineer',
+ country: {
+ code: 'USA',
+ name: 'United States'
+ },
+ city: 'San Francisco',
+ employment_type: 'flt',
+ started_from_month: 1,
+ started_from_year: 2022,
+ finished_at_month: 12,
+ finished_at_year: 2023,
+ current: false,
+ description: 'Led development of web applications.',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z'
+ }
+ }
+ ]
+ }
+
+ it('should rearrange resume sections successfully', async () => {
+ mockApi.put.mockResolvedValue(mockUpdatedResume)
+
+ const sectionIds = ['123e4567-e89b-12d3-a456-426614174002', '123e4567-e89b-12d3-a456-426614174001']
+ const result = await rearrangeResumeSections('resume_123', sectionIds)
+
+ expect(mockApi.put).toHaveBeenCalledWith('/resume/resume_123/sections/rearrange/', {
+ section_ids: sectionIds
+ })
+ expect(result).toEqual(mockUpdatedResume)
+ })
+
+ it('should handle empty section IDs array', async () => {
+ mockApi.put.mockResolvedValue(mockUpdatedResume)
+
+ const sectionIds: string[] = []
+ const result = await rearrangeResumeSections('resume_123', sectionIds)
+
+ expect(mockApi.put).toHaveBeenCalledWith('/resume/resume_123/sections/rearrange/', {
+ section_ids: []
+ })
+ expect(result).toEqual(mockUpdatedResume)
+ })
+
+ it('should handle single section ID', async () => {
+ mockApi.put.mockResolvedValue(mockUpdatedResume)
+
+ const sectionIds = ['123e4567-e89b-12d3-a456-426614174001']
+ await rearrangeResumeSections('resume_123', sectionIds)
+
+ expect(mockApi.put).toHaveBeenCalledWith('/resume/resume_123/sections/rearrange/', {
+ section_ids: ['123e4567-e89b-12d3-a456-426614174001']
+ })
+ })
+
+ it('should handle multiple section IDs', async () => {
+ mockApi.put.mockResolvedValue(mockUpdatedResume)
+
+ const sectionIds = ['123e4567-e89b-12d3-a456-426614174003', '123e4567-e89b-12d3-a456-426614174001', '123e4567-e89b-12d3-a456-426614174002', '123e4567-e89b-12d3-a456-426614174004']
+ await rearrangeResumeSections('resume_123', sectionIds)
+
+ expect(mockApi.put).toHaveBeenCalledWith('/resume/resume_123/sections/rearrange/', {
+ section_ids: sectionIds
+ })
+ })
+
+ it('should validate response data with schema', async () => {
+ mockApi.put.mockResolvedValue(mockUpdatedResume)
+
+ const result = await rearrangeResumeSections('resume_123', ['123e4567-e89b-12d3-a456-426614174002', '123e4567-e89b-12d3-a456-426614174001'])
+
+ expect(result).toMatchObject({
+ id: expect.any(String),
+ base: expect.any(Boolean),
+ user: expect.objectContaining({
+ id: expect.any(String),
+ first_name: expect.any(String),
+ last_name: expect.any(String)
+ }),
+ job: expect.objectContaining({
+ job_url: expect.any(String),
+ title: expect.any(String),
+ company_name: expect.any(String)
+ }),
+ sections: expect.any(Array)
+ })
+ })
+
+ it('should handle API errors', async () => {
+ const error = new Error('API Error')
+ mockApi.put.mockRejectedValue(error)
+ vi.mocked(handleErrors).mockImplementation(() => {
+ throw new Error('Failed to rearrange resume sections: API Error')
+ })
+
+ await expect(rearrangeResumeSections('resume_123', ['123e4567-e89b-12d3-a456-426614174001']))
+ .rejects.toThrow('Failed to rearrange resume sections: API Error')
+ expect(mockHandleErrors).toHaveBeenCalledWith(error, 'rearrange resume sections')
+ })
+
+ it('should preserve section order in response', async () => {
+ mockApi.put.mockResolvedValue(mockUpdatedResume)
+
+ const result = await rearrangeResumeSections('resume_123', ['123e4567-e89b-12d3-a456-426614174002', '123e4567-e89b-12d3-a456-426614174001'])
+
+ // Verify that the returned resume has sections in the new order
+ expect(result.sections[0].id).toBe('123e4567-e89b-12d3-a456-426614174002')
+ expect(result.sections[0].index).toBe(0)
+ expect(result.sections[1].id).toBe('123e4567-e89b-12d3-a456-426614174001')
+ expect(result.sections[1].index).toBe(1)
+ })
+
+ it('should handle different resume IDs', async () => {
+ mockApi.put.mockResolvedValue(mockUpdatedResume)
+
+ await rearrangeResumeSections('different-resume-456', ['123e4567-e89b-12d3-a456-426614174001'])
+
+ expect(mockApi.put).toHaveBeenCalledWith('/resume/different-resume-456/sections/rearrange/', {
+ section_ids: ['123e4567-e89b-12d3-a456-426614174001']
+ })
+ })
+
+ it('should handle 404 errors gracefully', async () => {
+ const notFoundError = new Error('Resume not found')
+ mockApi.put.mockRejectedValue(notFoundError)
+ vi.mocked(handleErrors).mockImplementation(() => {
+ throw new Error('Failed to rearrange resume sections: Resume not found')
+ })
+
+ await expect(rearrangeResumeSections('non-existent-resume', ['123e4567-e89b-12d3-a456-426614174001']))
+ .rejects.toThrow('Failed to rearrange resume sections: Resume not found')
+ expect(mockHandleErrors).toHaveBeenCalledWith(notFoundError, 'rearrange resume sections')
+ })
+
+ it('should handle validation errors', async () => {
+ const validationError = new Error('Invalid section IDs')
+ mockApi.put.mockRejectedValue(validationError)
+ vi.mocked(handleErrors).mockImplementation(() => {
+ throw new Error('Failed to rearrange resume sections: Invalid section IDs')
+ })
+
+ await expect(rearrangeResumeSections('resume_123', ['invalid-section']))
+ .rejects.toThrow('Failed to rearrange resume sections: Invalid section IDs')
+ expect(mockHandleErrors).toHaveBeenCalledWith(validationError, 'rearrange resume sections')
+ })
+ })
+})
diff --git a/lib/resume/__tests__/mutations.test.ts b/lib/resume/__tests__/mutations.test.ts
new file mode 100644
index 00000000..c7fbb03c
--- /dev/null
+++ b/lib/resume/__tests__/mutations.test.ts
@@ -0,0 +1,71 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
+
+// Mock the mutations module
+vi.mock('@/lib/resume/mutations', () => ({
+ useRearrangeResumeSectionsMutation: vi.fn()
+}))
+
+// Mock the actions
+vi.mock('@/lib/resume/actions')
+
+// Mock sonner toast
+vi.mock('sonner', () => ({
+ toast: {
+ error: vi.fn()
+ }
+}))
+
+describe('Resume Mutations', () => {
+ beforeEach(async () => {
+ vi.clearAllMocks()
+
+ // Setup default mock implementations
+ const mutations = vi.mocked(await import('@/lib/resume/mutations'))
+
+ mutations.useRearrangeResumeSectionsMutation.mockReturnValue({
+ mutateAsync: vi.fn(() => Promise.resolve()),
+ isPending: false,
+ isError: false,
+ error: null
+ } as any)
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ describe('useRearrangeResumeSectionsMutation', () => {
+ it('should be a function', async () => {
+ const {useRearrangeResumeSectionsMutation} = await import('@/lib/resume/mutations')
+ expect(typeof useRearrangeResumeSectionsMutation).toBe('function')
+ })
+
+ it('should be mockable', async () => {
+ const {useRearrangeResumeSectionsMutation} = vi.mocked(await import('@/lib/resume/mutations'))
+
+ expect(useRearrangeResumeSectionsMutation).toHaveBeenCalledTimes(0)
+
+ // Call the mocked function
+ const result = useRearrangeResumeSectionsMutation()
+
+ expect(useRearrangeResumeSectionsMutation).toHaveBeenCalledTimes(1)
+ expect(result).toBeDefined()
+ expect(result.mutateAsync).toBeDefined()
+ expect(result.isPending).toBe(false)
+ })
+
+ it('should return mutation with proper structure', async () => {
+ const {useRearrangeResumeSectionsMutation} = vi.mocked(await import('@/lib/resume/mutations'))
+
+ const result = useRearrangeResumeSectionsMutation()
+
+ expect(result).toHaveProperty('mutateAsync')
+ expect(result).toHaveProperty('isPending')
+ expect(result).toHaveProperty('isError')
+ expect(result).toHaveProperty('error')
+ expect(typeof result.mutateAsync).toBe('function')
+ expect(typeof result.isPending).toBe('boolean')
+ expect(typeof result.isError).toBe('boolean')
+ })
+ })
+})
diff --git a/lib/resume/__tests__/queries.test.ts b/lib/resume/__tests__/queries.test.ts
new file mode 100644
index 00000000..853c5d40
--- /dev/null
+++ b/lib/resume/__tests__/queries.test.ts
@@ -0,0 +1,120 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
+import {baseResumeQueryOptions, useBaseResume} from '@/lib/resume/queries'
+import {getResumeFromDB} from '@/lib/resume/actions'
+import {BASE_RESUME_KEYS} from '@/lib/resume/key'
+import {Resume} from '@/lib/resume/types'
+
+// Mock the actions
+vi.mock('../actions')
+
+const mockGetResumeFromDB = vi.mocked(getResumeFromDB)
+
+// Mock data
+const mockResume: Resume = {
+ id: 'resume-123',
+ base: true,
+ status: 'Success',
+ user: {
+ id: 'user-123',
+ title: 'Mr.',
+ first_name: 'John',
+ last_name: 'Doe',
+ email: 'john@example.com',
+ phone: '+1234567890',
+ dob: null,
+ address: '123 Main St',
+ city: 'New York',
+ postal: '10001',
+ country: {
+ code: 'US',
+ name: 'United States'
+ },
+ nationality: 'American',
+ website: 'https://johndoe.com',
+ profile_text: 'Software engineer',
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-01T00:00:00Z'
+ },
+ job: {
+ job_url: 'https://example.com/job',
+ title: 'Software Engineer',
+ company_name: 'Tech Corp',
+ location: 'San Francisco, CA',
+ requirements: 'JavaScript, React, TypeScript',
+ description: 'We are looking for a talented software engineer',
+ responsibilities: 'Develop web applications, Write clean code',
+ benefits: 'Health insurance, 401k, Remote work',
+ status: 'Success'
+ },
+ sections: []
+}
+
+describe('Resume Queries', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ describe('baseResumeQueryOptions', () => {
+ it('should have correct query key', () => {
+ const options = baseResumeQueryOptions
+
+ expect(options.queryKey).toEqual(BASE_RESUME_KEYS)
+ })
+
+ it('should have correct query function', () => {
+ const options = baseResumeQueryOptions
+
+ expect(options.queryFn).toBeInstanceOf(Function)
+ })
+
+ it('should call getResumeFromDB with "base" when query function is executed', async () => {
+ mockGetResumeFromDB.mockResolvedValue(mockResume)
+
+ const mockContext = {
+ queryKey: BASE_RESUME_KEYS,
+ client: {} as any,
+ signal: {} as AbortSignal,
+ meta: undefined
+ }
+
+ const result = await baseResumeQueryOptions.queryFn!(mockContext)
+
+ expect(mockGetResumeFromDB).toHaveBeenCalledTimes(1)
+ expect(mockGetResumeFromDB).toHaveBeenCalledWith('base')
+ expect(result).toEqual(mockResume)
+ })
+
+ it('should handle query function errors', async () => {
+ const error = new Error('API Error')
+ mockGetResumeFromDB.mockRejectedValue(error)
+
+ const mockContext = {
+ queryKey: BASE_RESUME_KEYS,
+ client: {} as any,
+ signal: {} as AbortSignal,
+ meta: undefined
+ }
+
+ await expect(baseResumeQueryOptions.queryFn!(mockContext)).rejects.toThrow('API Error')
+ expect(mockGetResumeFromDB).toHaveBeenCalledWith('base')
+ })
+ })
+
+ describe('useBaseResume', () => {
+ it('should use the correct query options', () => {
+ const hook = useBaseResume
+
+ // Test that the hook is a function
+ expect(typeof hook).toBe('function')
+ })
+
+ it('should be available for import', () => {
+ expect(useBaseResume).toBeDefined()
+ expect(typeof useBaseResume).toBe('function')
+ })
+ })
+})
diff --git a/lib/resume/accept.ts b/lib/resume/accept.ts
new file mode 100644
index 00000000..34b34805
--- /dev/null
+++ b/lib/resume/accept.ts
@@ -0,0 +1,25 @@
+export const ACCEPTED_FILE_EXTENSIONS = ['.pdf', '.txt', '.doc', '.docx', '.rtf', '.odt'] as const
+
+export const ACCEPTED_MIME_TYPES = [
+ 'application/pdf',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'text/plain',
+ 'application/rtf',
+ 'application/vnd.oasis.opendocument.text'
+] as const
+
+export const ACCEPT_ATTRIBUTE = [...ACCEPTED_FILE_EXTENSIONS, ...ACCEPTED_MIME_TYPES].join(',')
+
+export const ACCEPTED_LABEL = ACCEPTED_FILE_EXTENSIONS.join(', ')
+
+export const isAcceptedByName = (fileName: string): boolean => {
+ const lower = fileName.toLowerCase()
+ return ACCEPTED_FILE_EXTENSIONS.some(ext => lower.endsWith(ext))
+}
+
+export const isAcceptedFile = (file: File): boolean => {
+ return isAcceptedByName(file.name) || ACCEPTED_MIME_TYPES.includes(file.type as typeof ACCEPTED_MIME_TYPES[number])
+}
+
+
diff --git a/lib/resume/actions.ts b/lib/resume/actions.ts
index 7cd3cd46..621b5b87 100644
--- a/lib/resume/actions.ts
+++ b/lib/resume/actions.ts
@@ -1,24 +1,46 @@
'use server'
-import {auth} from '@clerk/nextjs/server'
-import {Resume} from '@/lib/resume/types'
+import {
+ Resume,
+ ResumeListItem,
+ ResumeListItemSchema,
+ ResumeMutation,
+ ResumeMutationSchema,
+ ResumeSchema,
+ TailorResumeResponse,
+ TailorResumeResponseSchema
+} from '@/lib/resume/types'
+import {parseResume} from '@/lib/resume/parser'
+import {api} from '@/lib/config/api-client'
+import {handleErrors} from '@/lib/misc/error-handler'
+import {ACCEPTED_MIME_TYPES, isAcceptedByName} from '@/lib/resume/accept'
/**
- * Retrieves a single experience object from the database by its ID
- * @param {string} [resumeId] - The ID of the resume to retrieve experience entries from. Defaults to 'base'.
- * @returns {Promise} - The retrieved experience objects
+ * Retrieves a single resume object from the database by its ID
+ * @param {string} [resumeId] - The ID of the resume to retrieve. Defaults to 'base'.
+ * @returns {Promise} - The retrieved resume object
+ * @throws {Error} If authentication or API request fails.
*/
export const getResumeFromDB = async (resumeId?: string | 'base'): Promise => {
- const session = await auth()
- const token = await session.getToken()
-
- const response = await fetch(`${process.env.API_URL}/resume/${resumeId ?? 'base'}/`, {
- headers: {
- Authorization: `Bearer ${token}`
- }
- })
+ try {
+ const data = await api.get(`/resume/${resumeId ?? 'base'}/`)
+ return ResumeSchema.parse(data)
+ } catch (error) {
+ return handleErrors(error, 'fetch resume')
+ }
+}
- return await response.json()
+/**
+ * Retrieves all resumes for the current user
+ * GET /resume/
+ */
+export const listResumesForUser = async (): Promise => {
+ try {
+ const data = await api.get('/resume/')
+ return (data || []).map(item => ResumeListItemSchema.parse(item))
+ } catch (error) {
+ return handleErrors(error, 'list resumes')
+ }
}
/**
@@ -26,25 +48,78 @@ export const getResumeFromDB = async (resumeId?: string | 'base'): Promise} - The updated resume object
+ * @throws {Error} If validation, authentication, or API request fails.
*/
export const rearrangeResumeSections = async (resumeId: string, sectionIds: string[]): Promise => {
- const session = await auth()
- const token = await session.getToken()
-
- const response = await fetch(`${process.env.API_URL}/resume/${resumeId}/sections/rearrange/`, {
- method: 'PUT',
- headers: {
- Authorization: `Bearer ${token}`,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
+ try {
+ const data = await api.put(`/resume/${resumeId}/sections/rearrange/`, {
section_ids: sectionIds
})
- })
+ return ResumeSchema.parse(data)
+ } catch (error) {
+ return handleErrors(error, 'rearrange resume sections')
+ }
+}
+
+/**
+ * Initiates tailoring of a new resume for a job
+ * Accepts either a job URL or a raw job description
+ */
+export const tailorResumeInDB = async (
+ payload: { target: string }
+): Promise => {
+ try {
+ const data = await api.post('/resume/tailor/', payload)
+ return TailorResumeResponseSchema.parse(data)
+ } catch (error) {
+ return handleErrors(error, 'tailor resume')
+ }
+}
+
+/**
+ * Parses an uploaded resume file using the self-hosted parser
+ * Runs on the server and accepts a FormData containing the file
+ */
+export const parseUploadedResume = async (
+ formData: FormData,
+ format: 'proprietary' | 'generic' = 'proprietary'
+): Promise => {
+ const file = formData.get('file')
+ if (!file || !(file instanceof File)) {
+ throw new Error('No file provided in form data under key "file"')
+ }
- if (!response.ok) {
- throw new Error('Failed to rearrange resume sections')
+ // Basic validation: size and type
+ const maxBytes = 10 * 1024 * 1024 // 10MB
+ if (file.size === 0) {
+ throw new Error('Uploaded file is empty')
+ }
+ if (file.size > maxBytes) {
+ throw new Error('File too large. Maximum allowed size is 10MB')
}
+ const mimeOk = (file.type ? (ACCEPTED_MIME_TYPES as readonly string[]).includes(file.type) : false)
+ const nameOk = isAcceptedByName((file.name ?? '').toLowerCase())
+ if (!mimeOk && !nameOk) {
+ throw new Error('Unsupported file type. Please upload a PDF, DOC, DOCX, RTF, ODT, or TXT file')
+ }
+
+ const result = await parseResume(file, format)
+ return result
+}
- return await response.json()
+/**
+ * Replaces a resume with provided sections payload
+ * PUT /resume/{id}/ (or /resume/base/)
+ */
+export const replaceResume = async (
+ payload: ResumeMutation,
+ resumeId: string = 'base'
+): Promise => {
+ try {
+ const parsed = ResumeMutationSchema.parse(payload)
+ const data = await api.put(`/resume/${resumeId ?? 'base'}/`, parsed)
+ return ResumeSchema.parse(data)
+ } catch (error) {
+ return handleErrors(error, 'replace resume')
+ }
}
diff --git a/lib/resume/key.ts b/lib/resume/key.ts
index e6a7b8cb..3a3e8fc6 100644
--- a/lib/resume/key.ts
+++ b/lib/resume/key.ts
@@ -1 +1,2 @@
export const BASE_RESUME_KEYS = ['base-resume']
+export const RESUMES_KEYS = ['resumes']
diff --git a/lib/resume/mutations.ts b/lib/resume/mutations.ts
index f7ce0e44..b589843d 100644
--- a/lib/resume/mutations.ts
+++ b/lib/resume/mutations.ts
@@ -1,7 +1,9 @@
-import {useMutation, useQueryClient} from '@tanstack/react-query'
-import {rearrangeResumeSections} from '@/lib/resume/actions'
+import {MutationOptions, useMutation, useQueryClient} from '@tanstack/react-query'
+import {parseUploadedResume, rearrangeResumeSections, replaceResume, tailorResumeInDB} from '@/lib/resume/actions'
import {BASE_RESUME_KEYS} from '@/lib/resume/key'
import {toast} from 'sonner'
+import {Resume, ResumeMutation} from '@/lib/resume/types'
+import type {TailorResumeResponse} from '@/lib/resume/types'
export const useRearrangeResumeSectionsMutation = () => {
const queryClient = useQueryClient()
@@ -16,3 +18,39 @@ export const useRearrangeResumeSectionsMutation = () => {
}
})
}
+
+export const useTailorResumeMutation = (
+ options?: MutationOptions
+) => useMutation({
+ mutationFn: (payload) => tailorResumeInDB(payload),
+ ...options
+})
+
+type ParseResumeParams = {
+ formData: FormData
+ format?: 'proprietary' | 'generic'
+}
+
+export const useParseResumeMutation = () => {
+ return useMutation({
+ mutationFn: async ({formData, format = 'proprietary'}) => parseUploadedResume(formData, format)
+ })
+}
+
+type ReplaceResumeParams = {
+ payload: ResumeMutation
+ resumeId?: string | 'base'
+}
+
+export const useReplaceResumeMutation = () => {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async ({payload, resumeId = 'base'}) => replaceResume(payload, resumeId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({queryKey: BASE_RESUME_KEYS})
+ },
+ onError: () => {
+ toast.error('Failed to replace resume')
+ }
+ })
+}
diff --git a/lib/resume/parser.ts b/lib/resume/parser.ts
new file mode 100644
index 00000000..cbfc90ca
--- /dev/null
+++ b/lib/resume/parser.ts
@@ -0,0 +1,272 @@
+'use server'
+
+import {generateObject} from 'ai'
+import {google} from '@ai-sdk/google'
+import {z} from 'zod'
+import {ResumeMutation, ResumeMutationSchema} from '@/lib/resume/types'
+
+/**
+ * Ensure that HTML produced for Tiptap includes our expected classes.
+ * - paragraph -> text-node
+ * - heading (h1..h6) -> heading-node
+ * - blockquote -> block-node
+ * - ul/ol -> list-node
+ * - code (inline) -> inline
+ * If the input is plain text, wrap it in a paragraph with class text-node.
+ */
+const ALLOWED_TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'ul', 'ol', 'code'] as const
+type AllowedTag = typeof ALLOWED_TAGS[number]
+
+const ensureClassOnTag = (html: string, tag: AllowedTag, className: string): string => {
+ if (!ALLOWED_TAGS.includes(tag)) {
+ throw new Error(`Tag "${tag}" is not allowed`)
+ }
+ const regex = new RegExp(`<${tag}\\b([^>]*)>`, 'gi')
+ return html.replace(regex, (match, attrs: string) => {
+ if (/class\s*=/.test(attrs)) {
+ return match.replace(/class\s*=\s*(["'])(.*?)\1/i, (_m, quote: string, classes: string) => {
+ const classList = classes.trim().split(/\s+/)
+ if (classList.includes(className)) return `class=${quote}${classes}${quote}`
+ const updated = classes ? `${classes} ${className}` : className
+ return `class=${quote}${updated}${quote}`
+ })
+ }
+ const space = attrs?.length ? attrs : ''
+ return `<${tag}${space} class="${className}">`
+ })
+}
+
+// Normalize certification issue_date to YYYY-MM-DD when possible
+const normalizeCertificationDate = (date: unknown): string | null | undefined => {
+ if (date === null || date === undefined) return date as null | undefined
+
+ if (date instanceof Date) {
+ return date.toISOString().slice(0, 10)
+ }
+
+ if (typeof date === 'string') {
+ if (/^\d{4}-\d{2}-\d{2}$/.test(date)) return date
+ if (/^\d{4}-\d{2}-\d{2}T/.test(date)) return date.slice(0, 10)
+
+ const parsed = new Date(date)
+ if (!Number.isNaN(parsed.getTime())) {
+ return parsed.toISOString().slice(0, 10)
+ }
+ }
+
+ return undefined
+}
+
+const isLikelyHtml = (input: string): boolean => /<\w+[\s\S]*>/.test(input)
+
+const normalizeDescriptionToTiptapHTML = (input: string | null | undefined): string | null | undefined => {
+ if (input == null || input === '') return input
+
+ let html = String(input).trim()
+
+ // If not HTML, convert to a simple paragraph
+ if (!isLikelyHtml(html)) {
+ // Preserve basic newlines by splitting into paragraphs
+ const paragraphs = html
+ .split(/\n{2,}/)
+ .map(p => p.trim())
+ .filter(Boolean)
+ .map(p => `
${p.replace(/\n/g, ' ')}
`) // single newlines become
+ html = paragraphs.length ? paragraphs.join('') : ''
+ }
+
+ // Enforce classes on common nodes
+ html = ensureClassOnTag(html, 'p', 'text-node')
+ html = ensureClassOnTag(html, 'h1', 'heading-node')
+ html = ensureClassOnTag(html, 'h2', 'heading-node')
+ html = ensureClassOnTag(html, 'h3', 'heading-node')
+ html = ensureClassOnTag(html, 'h4', 'heading-node')
+ html = ensureClassOnTag(html, 'h5', 'heading-node')
+ html = ensureClassOnTag(html, 'h6', 'heading-node')
+ html = ensureClassOnTag(html, 'blockquote', 'block-node')
+ html = ensureClassOnTag(html, 'ul', 'list-node')
+ html = ensureClassOnTag(html, 'ol', 'list-node')
+ html = ensureClassOnTag(html, 'code', 'inline')
+
+ return html
+}
+
+// Generic schema for non-proprietary format
+const GenericResumeSchema = z.object({
+ personalInfo: z.object({
+ name: z.string(),
+ email: z.string().optional(),
+ phone: z.string().optional(),
+ location: z.string().optional(),
+ summary: z.string().optional()
+ }),
+ education: z.array(z.object({
+ institution: z.string(),
+ degree: z.string().optional(),
+ field: z.string().optional(),
+ startDate: z.string().describe('Use blank string if not found'),
+ endDate: z.string().describe('Use blank string if not found'),
+ gpa: z.string().optional()
+ })),
+ experience: z.array(z.object({
+ company: z.string(),
+ position: z.string(),
+ startDate: z.string().describe('Use blank string if not found'),
+ endDate: z.string().describe('Use blank string if not found'),
+ description: z.string().describe('In a short paragraph, what did the user do in their experience and their impact'),
+ location: z.string().optional()
+ })),
+ skills: z.array(z.string()),
+ certifications: z.array(z.object({
+ name: z.string(),
+ issuer: z.string().optional(),
+ date: z.string().optional()
+ })),
+ projects: z.array(z.object({
+ name: z.string(),
+ description: z.string(),
+ technologies: z.array(z.string()).optional()
+ }))
+})
+
+export const parseResume = async (
+ file: File,
+ format: 'proprietary' | 'generic' = 'proprietary'
+): Promise => {
+ // Validate input
+ if (!file || !(file instanceof File)) {
+ throw new Error('Uploaded resume is not a file')
+ }
+
+ if (file.size === 0) {
+ throw new Error('Uploaded resume is an empty file')
+ }
+
+ // For proprietary format, output exactly our internal ResumeMutation schema
+ const schema = format === 'proprietary' ? ResumeMutationSchema : GenericResumeSchema
+
+ const currentYear = new Date().getFullYear()
+ const prompt = format === 'proprietary'
+ ? `You are a strict JSON generator. Return ONLY JSON matching this schema, no prose:
+{
+ "sections": [
+ { "type": "Education", "data": { "institution_name": string, "field_of_study": string, "degree": string|null, "country": string(ISO3), "started_from_month": string(1-12)|null|undefined, "started_from_year": string(YYYY)|null|undefined, "finished_at_month": string(1-12)|null|undefined, "finished_at_year": string(YYYY)|null|undefined, "current": boolean, "description": string|null } },
+ { "type": "Experience", "data": { "company_name": string, "job_title": string, "country": string(ISO3), "city": string|null, "employment_type": "flt"|"prt"|"con"|"int"|"fre"|"sel"|"vol"|"tra", "started_from_month": string(1-12)|null|undefined, "started_from_year": string(YYYY)|null|undefined, "finished_at_month": string(1-12)|null|undefined, "finished_at_year": string(YYYY)|null|undefined, "current": boolean, "description": string|null } },
+ { "type": "Skill", "data": { "skills": [ { "name": string, "level": "BEG"|"INT"|"ADV"|"EXP"|null, "category": string|null|undefined } ] } },
+ { "type": "Project", "data": { "name": string, "category": string|null, "description": string|null, "role": string|null, "github_url": string|null, "live_url": string|null, "started_from_month": string(1-12)|null|undefined, "started_from_year": string(YYYY)|null|undefined, "finished_at_month": string(1-12)|null|undefined, "finished_at_year": string(YYYY)|null|undefined, "current": boolean|null, "skills_used": [ { "name": string, "category": string|null } ] } },
+ { "type": "Certification", "data": { "name": string, "issuing_organization": string|undefined, "issue_date": string(YYYY-MM-DD) | null | undefined, "credential_url": string|null } }
+ ]
+}
+
+Rules:
+- Use null for unknown optional values where allowed; otherwise use empty string for required strings when unknown.
+- Months MUST be numeric strings from "1" to "12" (do not use names like "Jul").
+- Years MUST be 4-digit numeric strings like "2021".
+- Certification issue_date MUST be a date-only string in the exact format YYYY-MM-DD (e.g., "2024-03-01").
+- Use ISO3 country codes (e.g., USA, IND) when inferring countries.
+- employment_type must be one of: flt, prt, con, int, fre, sel, vol, tra.
+- level must be one of: BEG, INT, ADV, EXP, or null.
+- The current calendar year is ${currentYear}. Do NOT output any future years. If you encounter a year greater than ${currentYear} in the source resume:
+ - For that date field, set the associated month and year fields to null, and
+ - Set the "current" flag to true for that section when available (Education, Experience, Project).
+- For every "description" field (Education, Experience, Project), return a Tiptap-compatible HTML string and PREFER BULLETED LISTS:
+ - Default to unordered lists for multi-point content:
…
…
+ - Use a single paragraph only when the content is one succinct sentence:
…
+ - Allowed classes: paragraphs -> "text-node"; headings h1–h6 -> "heading-node"; blockquotes -> "block-node"; ul/ol -> "list-node"; inline code -> "inline".
+ - Do NOT return Markdown; return HTML only with the classes above. Keep list items concise, one idea per