mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 23:07:01 +10:00
feat(applications): improve performance
This commit is contained in:
@@ -22,10 +22,14 @@ const applicationSchema = createSelectSchema(schema.application, {
|
||||
jobDescription: z.string().nullable(),
|
||||
matchScore: z.number().int().min(0).max(100).nullable(),
|
||||
aiMetadata: aiMetadataSchema.nullable(),
|
||||
campaign: z.string().trim().nullable(),
|
||||
notes: z.string().nullable(),
|
||||
// Rendered as an <a href>; only same-origin storage URLs are ever stored. Constrain to
|
||||
// http(s)/relative so a hand-crafted `update` can't smuggle a `javascript:` href.
|
||||
resumeFileUrl: z
|
||||
.string()
|
||||
.refine((value) => /^(https?:\/\/|\/)/.test(value), "Resume file URL must be http(s) or a relative path.")
|
||||
.nullable(),
|
||||
resumeFileName: z.string().nullable(),
|
||||
coverLetterUrl: z
|
||||
.string()
|
||||
.refine((value) => /^(https?:\/\/|\/)/.test(value), "Cover letter URL must be http(s) or a relative path.")
|
||||
@@ -52,8 +56,9 @@ const editableSchema = applicationSchema.pick({
|
||||
source: true,
|
||||
sourceUrl: true,
|
||||
jobDescription: true,
|
||||
campaign: true,
|
||||
notes: true,
|
||||
resumeFileUrl: true,
|
||||
resumeFileName: true,
|
||||
coverLetterUrl: true,
|
||||
coverLetterName: true,
|
||||
followUpAt: true,
|
||||
@@ -74,7 +79,6 @@ export const applicationDto = {
|
||||
input: z
|
||||
.object({
|
||||
status: applicationStatusSchema.optional(),
|
||||
campaign: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
includeArchived: z.boolean().optional().default(false),
|
||||
})
|
||||
@@ -135,7 +139,7 @@ export const applicationDto = {
|
||||
// Aggregates for the Insights view. Everything else (funnel, sankey, tiles) is derived
|
||||
// client-side from these raw counts via computeInsights().
|
||||
stats: {
|
||||
input: z.object({ campaign: z.string().optional() }).optional(),
|
||||
input: z.void(),
|
||||
output: z.object({
|
||||
total: z.number(),
|
||||
byStage: z.array(z.object({ status: applicationStatusSchema, count: z.number() })),
|
||||
@@ -143,12 +147,6 @@ export const applicationDto = {
|
||||
}),
|
||||
},
|
||||
|
||||
// Distinct campaign names for the sidebar/filter (Phase 2 uses the same shape).
|
||||
campaigns: {
|
||||
input: z.void(),
|
||||
output: z.array(z.object({ name: z.string(), count: z.number() })),
|
||||
},
|
||||
|
||||
tags: {
|
||||
input: z.void(),
|
||||
output: z.array(z.string()),
|
||||
|
||||
@@ -61,9 +61,15 @@ async function fetchJobPostingText(url: string): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
// Job boards 403 obvious bot user-agents, so present as a normal browser.
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { "user-agent": "ReactiveResumeBot/1.0" },
|
||||
headers: {
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
},
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new ORPCError("BAD_REQUEST", { message: `Couldn't fetch the posting (HTTP ${response.status}).` });
|
||||
|
||||
@@ -21,7 +21,6 @@ export const crudRouter = {
|
||||
return applicationService.list({
|
||||
userId: context.user.id,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.campaign ? { campaign: input.campaign } : {}),
|
||||
...(input.tags ? { tags: input.tags } : {}),
|
||||
includeArchived: input.includeArchived,
|
||||
});
|
||||
@@ -174,32 +173,13 @@ export const crudRouter = {
|
||||
tags: ["Applications"],
|
||||
operationId: "getApplicationStats",
|
||||
summary: "Application pipeline stats",
|
||||
description:
|
||||
"Returns aggregate counts (per stage, per source) for the Insights view. Optionally scoped to a campaign. Requires authentication.",
|
||||
description: "Returns aggregate counts (per stage, per source) for the Insights view. Requires authentication.",
|
||||
successDescription: "Aggregate application counts.",
|
||||
})
|
||||
.input(applicationDto.stats.input)
|
||||
.output(applicationDto.stats.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return applicationService.stats({
|
||||
userId: context.user.id,
|
||||
...(input?.campaign ? { campaign: input.campaign } : {}),
|
||||
});
|
||||
}),
|
||||
|
||||
campaigns: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/applications/campaigns",
|
||||
tags: ["Applications"],
|
||||
operationId: "listApplicationCampaigns",
|
||||
summary: "List campaigns",
|
||||
description: "Returns distinct campaign names with their application counts. Requires authentication.",
|
||||
successDescription: "Distinct campaigns with counts.",
|
||||
})
|
||||
.output(applicationDto.campaigns.output)
|
||||
.handler(async ({ context }) => {
|
||||
return applicationService.campaigns({ userId: context.user.id });
|
||||
return applicationService.stats({ userId: context.user.id });
|
||||
}),
|
||||
|
||||
tags: protectedProcedure
|
||||
|
||||
@@ -12,7 +12,6 @@ export const applicationsRouter = {
|
||||
bulkUpdate: crudRouter.bulkUpdate,
|
||||
bulkDelete: crudRouter.bulkDelete,
|
||||
stats: crudRouter.stats,
|
||||
campaigns: crudRouter.campaigns,
|
||||
tags: crudRouter.tags,
|
||||
ai: aiRouter,
|
||||
};
|
||||
|
||||
@@ -24,8 +24,9 @@ type EditableFields = {
|
||||
source?: string | null | undefined;
|
||||
sourceUrl?: string | null | undefined;
|
||||
jobDescription?: string | null | undefined;
|
||||
campaign?: string | null | undefined;
|
||||
notes?: string | null | undefined;
|
||||
resumeFileUrl?: string | null | undefined;
|
||||
resumeFileName?: string | null | undefined;
|
||||
coverLetterUrl?: string | null | undefined;
|
||||
coverLetterName?: string | null | undefined;
|
||||
followUpAt?: Date | null | undefined;
|
||||
@@ -51,13 +52,7 @@ const stripUserId = <T extends { userId: string }>(row: T) => {
|
||||
};
|
||||
|
||||
export const applicationService = {
|
||||
list: async (input: {
|
||||
userId: string;
|
||||
status?: ApplicationStatus;
|
||||
campaign?: string;
|
||||
tags?: string[];
|
||||
includeArchived?: boolean;
|
||||
}) => {
|
||||
list: async (input: { userId: string; status?: ApplicationStatus; tags?: string[]; includeArchived?: boolean }) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.application)
|
||||
@@ -65,7 +60,6 @@ export const applicationService = {
|
||||
and(
|
||||
eq(schema.application.userId, input.userId),
|
||||
input.status ? eq(schema.application.status, input.status) : undefined,
|
||||
input.campaign ? eq(schema.application.campaign, input.campaign) : undefined,
|
||||
input.tags && input.tags.length > 0 ? arrayContains(schema.application.tags, input.tags) : undefined,
|
||||
),
|
||||
)
|
||||
@@ -241,12 +235,8 @@ export const applicationService = {
|
||||
},
|
||||
|
||||
// Raw counts for Insights; funnel/sankey/tiles are derived client-side from these.
|
||||
stats: async (input: { userId: string; campaign?: string }) => {
|
||||
const scope = and(
|
||||
eq(schema.application.userId, input.userId),
|
||||
eq(schema.application.archived, false),
|
||||
input.campaign ? eq(schema.application.campaign, input.campaign) : undefined,
|
||||
);
|
||||
stats: async (input: { userId: string }) => {
|
||||
const scope = and(eq(schema.application.userId, input.userId), eq(schema.application.archived, false));
|
||||
|
||||
const byStage = await db
|
||||
.select({ status: schema.application.status, count: sql<number>`count(*)::int` })
|
||||
@@ -271,16 +261,6 @@ export const applicationService = {
|
||||
};
|
||||
},
|
||||
|
||||
campaigns: async (input: { userId: string }) => {
|
||||
const rows = await db
|
||||
.select({ name: schema.application.campaign, count: sql<number>`count(*)::int` })
|
||||
.from(schema.application)
|
||||
.where(and(eq(schema.application.userId, input.userId), eq(schema.application.archived, false)))
|
||||
.groupBy(schema.application.campaign);
|
||||
|
||||
return rows.filter((row): row is { name: string; count: number } => !!row.name).sort((a, b) => b.count - a.count);
|
||||
},
|
||||
|
||||
listTags: async (input: { userId: string }) => {
|
||||
const rows = await db
|
||||
.select({ tag: sql<string>`distinct unnest(${schema.application.tags})` })
|
||||
|
||||
@@ -35,9 +35,11 @@ export const application = pg.pgTable(
|
||||
jobDescription: pg.text("job_description"),
|
||||
matchScore: pg.integer("match_score"),
|
||||
aiMetadata: pg.jsonb("ai_metadata").$type<AiMetadata>(),
|
||||
// Reserves the deferred campaigns feature; no management UI this pass.
|
||||
campaign: pg.text("campaign"),
|
||||
notes: pg.text("notes"),
|
||||
// An uploaded resume file (PDF) sent with this application, distinct from the live
|
||||
// resumeId link. Stored as the storage URL + original filename for display.
|
||||
resumeFileUrl: pg.text("resume_file_url"),
|
||||
resumeFileName: pg.text("resume_file_name"),
|
||||
// A cover letter sent with this application (PDF/doc uploaded to storage). Stored as the
|
||||
// key returned by the storage upload plus the original filename for display.
|
||||
coverLetterUrl: pg.text("cover_letter_url"),
|
||||
|
||||
Reference in New Issue
Block a user