Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ SUPABASE_STORAGE_BUCKET=applypilot-assets

OPENAI_API_KEY=
OPENAI_MODEL=gpt-4.1-mini

# Optional paid job aggregation. Leave blank to disable resume-triggered search.
ADZUNA_APP_ID=
ADZUNA_APP_KEY=
ADZUNA_COUNTRY=sg
ADZUNA_BASE_URL=https://api.adzuna.com/v1/api

APPLYPILOT_LOCAL_STORE_PATH=
ENABLE_DEMO_DATA=false
VITE_DASHBOARD_URL=http://localhost:3000
Expand Down
31 changes: 30 additions & 1 deletion apps/web/src/app/api/jobs/score/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,44 @@
import type { JobPosting } from '@applypilot/domain';

import { withAuthenticatedRoute } from '../../_lib';
import { isAiConfigured } from '@/server/services/ai';
import { scoreJobForCandidate } from '@/server/services/app-service';
import { recordUsage } from '@/server/services/usage-meter';

export async function POST(request: Request) {
return withAuthenticatedRoute(request, async ({ candidateId }) => {
const body = (await request.json()) as Partial<JobPosting>;

return scoreJobForCandidate({
const result = await scoreJobForCandidate({
candidateId,
jobInput: body,
});

recordUsage({
candidateId,
eventType: 'job_score',
provider: 'openai',
aiCallCount: isAiConfigured() ? 1 : 0,
metadata: {
jobPostingId: result.job.id,
fallbackMode: !isAiConfigured(),
},
});

if (result.tailoredResume) {
recordUsage({
candidateId,
eventType: 'tailored_resume_generation',
provider: 'openai',
aiCallCount: isAiConfigured() ? 1 : 0,
metadata: {
jobPostingId: result.job.id,
resumeId: result.tailoredResume.baseResumeId,
fallbackMode: !isAiConfigured(),
},
});
}

return result;
});
}
20 changes: 20 additions & 0 deletions apps/web/src/app/api/jobs/search-from-resume/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { withAuthenticatedRoute } from '../../_lib';
import { searchJobsFromResume } from '@/server/services/resume-job-search';

export async function POST(request: Request) {
return withAuthenticatedRoute(request, async ({ candidateId, store }) => {
const body = (await request.json().catch(() => ({}))) as {
resumeId?: string;
limit?: number;
};

const search = await searchJobsFromResume({
candidateId,
resumeId: body.resumeId,
limit: body.limit,
store,
});

return { search };
});
}
13 changes: 13 additions & 0 deletions apps/web/src/app/api/profile/parse/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { withAuthenticatedRoute } from '../../_lib';
import { isAiConfigured } from '@/server/services/ai';
import { parseProfileFromResume } from '@/server/services/app-service';
import { recordUsage } from '@/server/services/usage-meter';

export async function POST(request: Request) {
return withAuthenticatedRoute(request, async ({ candidateId }) => {
Expand All @@ -14,6 +16,17 @@ export async function POST(request: Request) {
resumeId: body.resumeId,
});

recordUsage({
candidateId,
eventType: 'profile_parse',
provider: 'openai',
aiCallCount: isAiConfigured() ? 1 : 0,
metadata: {
resumeId: body.resumeId,
fallbackMode: !isAiConfigured(),
},
});

return { profile };
});
}
39 changes: 38 additions & 1 deletion apps/web/src/components/resume-upload-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,44 @@ export function ResumeUploadForm() {
return;
}

setStatus('Resume uploaded and profile parsed.');
setStatus('Searching matching jobs...');
const searchResponse = await fetch('/api/jobs/search-from-resume', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
resumeId: uploaded.resume.id,
}),
});

if (!searchResponse.ok) {
setStatus('Resume uploaded and profile parsed. Save preferences to search matching jobs.');
router.refresh();
return;
}

const searchResult = (await searchResponse.json()) as {
search: {
enabled: boolean;
savedCount: number;
disabledReason?: string;
};
};

if (!searchResult.search.enabled) {
setStatus(
'Resume uploaded and profile parsed. Job search is disabled until Adzuna API credentials are configured.',
);
router.refresh();
return;
}

setStatus(
searchResult.search.savedCount > 0
? `Resume uploaded and ${searchResult.search.savedCount} matching jobs added to the tracker.`
: 'Resume uploaded and parsed. No matching jobs returned yet.',
);
router.refresh();
};

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ export const hasSupabaseServiceRoleConfig = Boolean(
env.NEXT_PUBLIC_SUPABASE_URL &&
(env.SUPABASE_SECRET_KEY || env.SUPABASE_SERVICE_ROLE_KEY),
);

export const hasAdzunaConfig = Boolean(env.ADZUNA_APP_ID && env.ADZUNA_APP_KEY);
2 changes: 2 additions & 0 deletions apps/web/src/server/services/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const createOpenAIClient = () => {
});
};

export const isAiConfigured = () => Boolean(env.OPENAI_API_KEY);

const parseJsonContent = <T>(value: string | null | undefined, fallback: T) => {
if (!value) {
return fallback;
Expand Down
198 changes: 198 additions & 0 deletions apps/web/src/server/services/job-aggregator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { z } from 'zod';

import type { JobPosting } from '@applypilot/domain';

import { env, hasAdzunaConfig } from '@/lib/env';
import { slugify } from '@/lib/utils';

export type JobAggregatorConfig = {
appId?: string | null;
appKey?: string | null;
country?: string | null;
baseUrl?: string | null;
fetchImpl?: typeof fetch;
};

type AdzunaSearchInput = {
query: string;
location?: string | null;
limit?: number;
page?: number;
config?: JobAggregatorConfig;
};

const adzunaCompanySchema = z.object({
display_name: z.string().optional(),
});

const adzunaLocationSchema = z.object({
display_name: z.string().optional(),
});

const adzunaJobSchema = z.object({
id: z.union([z.string(), z.number()]),
title: z.string().optional(),
company: adzunaCompanySchema.optional(),
location: adzunaLocationSchema.optional(),
redirect_url: z.string().url().optional(),
description: z.string().optional(),
contract_time: z.string().nullable().optional(),
contract_type: z.string().nullable().optional(),
salary_min: z.number().nullable().optional(),
salary_max: z.number().nullable().optional(),
created: z.string().optional(),
});

const adzunaSearchResponseSchema = z.object({
count: z.number().default(0),
results: z.array(adzunaJobSchema).default([]),
});

const clampLimit = (limit: number | undefined) =>
Math.max(1, Math.min(50, Math.floor(limit ?? 20)));

const cleanDescription = (value: string | undefined) =>
(value ?? '')
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/\s+/g, ' ')
.trim();

const normalizeTimestamp = (value: string | undefined) => {
const parsed = value ? new Date(value) : null;

return parsed && !Number.isNaN(parsed.getTime())
? parsed.toISOString()
: new Date().toISOString();
};

const formatSalaryText = ({
min,
max,
}: {
min?: number | null;
max?: number | null;
}) => {
if (typeof min !== 'number' && typeof max !== 'number') {
return null;
}

if (typeof min === 'number' && typeof max === 'number') {
return `Estimated salary ${Math.round(min)} - ${Math.round(max)}`;
}

return `Estimated salary ${Math.round((min ?? max) as number)}`;
};

const formatEmploymentType = ({
contractTime,
contractType,
}: {
contractTime?: string | null;
contractType?: string | null;
}) =>
[contractTime, contractType]
.filter((value): value is string => Boolean(value?.trim()))
.map((value) => value.replace(/_/g, ' '))
.join(', ') || null;

const resolveAdzunaConfig = (config?: JobAggregatorConfig) => ({
appId: config?.appId ?? env.ADZUNA_APP_ID ?? null,
appKey: config?.appKey ?? env.ADZUNA_APP_KEY ?? null,
country: (config?.country ?? env.ADZUNA_COUNTRY).toLowerCase(),
baseUrl: (config?.baseUrl ?? env.ADZUNA_BASE_URL).replace(/\/+$/, ''),
fetchImpl: config?.fetchImpl ?? fetch,
});

export const isAdzunaSearchConfigured = (config?: JobAggregatorConfig) => {
if (config) {
return Boolean(config.appId && config.appKey);
}

return hasAdzunaConfig;
};

export const mapAdzunaJobToPosting = (
job: z.infer<typeof adzunaJobSchema>,
): JobPosting => {
const externalJobId = String(job.id);

return {
id: `adzuna_${slugify(externalJobId)}`,
source: 'adzuna',
externalJobId,
title: job.title?.trim() || 'Untitled role',
company: job.company?.display_name?.trim() || 'Unknown company',
location: job.location?.display_name?.trim() || '',
salaryText: formatSalaryText({
min: job.salary_min,
max: job.salary_max,
}),
employmentType: formatEmploymentType({
contractTime: job.contract_time,
contractType: job.contract_type,
}),
url: job.redirect_url ?? 'https://www.adzuna.com/',
description: cleanDescription(job.description),
easyApply: false,
detectedQuestions: [],
scrapedAt: normalizeTimestamp(job.created),
};
};

export const searchAdzunaJobs = async ({
query,
location,
limit,
page = 1,
config,
}: AdzunaSearchInput) => {
const resolved = resolveAdzunaConfig(config);

if (!isAdzunaSearchConfigured(config)) {
return {
enabled: false as const,
provider: 'adzuna' as const,
attribution: 'Jobs by Adzuna',
disabledReason: 'missing_api_key' as const,
jobs: [],
totalCount: 0,
};
}

const url = new URL(
`${resolved.baseUrl}/jobs/${resolved.country}/search/${Math.max(1, page)}`,
);
url.searchParams.set('app_id', resolved.appId ?? '');
url.searchParams.set('app_key', resolved.appKey ?? '');
url.searchParams.set('what', query);
url.searchParams.set('results_per_page', String(clampLimit(limit)));
url.searchParams.set('content-type', 'application/json');

if (location?.trim()) {
url.searchParams.set('where', location.trim());
}

const response = await resolved.fetchImpl(url, {
headers: {
Accept: 'application/json',
},
});

if (!response.ok) {
throw new Error(`Adzuna search failed with HTTP ${response.status}.`);
}

const payload = adzunaSearchResponseSchema.parse(await response.json());

return {
enabled: true as const,
provider: 'adzuna' as const,
attribution: 'Jobs by Adzuna',
jobs: payload.results.map(mapAdzunaJobToPosting),
totalCount: payload.count,
};
};
Loading
Loading