Skip to content

Commit a53487f

Browse files
authored
Merge pull request #27 from shaaddev/agents/feat/issues
Resume Import for Specific Applications
2 parents 4e4c78f + 4430591 commit a53487f

14 files changed

Lines changed: 1104 additions & 9 deletions

File tree

app/(application-record)/actions.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { revalidatePath } from "next/cache";
44
import { z } from "zod";
55
import { getUser } from "@/lib/session";
66
import { applicationSchema, isStatus, type ApplicationInput } from "@/lib/applications";
7+
import { checkResumeFile, cleanFileName, hasPdfHeader } from "@/lib/resumes";
78
import { IMPORT_ROW_LIMIT, type ImportRowInput } from "@/lib/import/shared";
89
import {
910
deleteApplication as deleteRow,
11+
deleteResume as deleteResumeRow,
1012
importApplications as importRows,
1113
insertApplication,
1214
updateApplication as updateRow,
1315
updateApplicationStatus as updateStatusRow,
16+
upsertResume,
1417
} from "@/db/queries";
1518

1619
type Result = { ok: true } | { ok: false; error: string };
@@ -101,6 +104,52 @@ export async function deleteApplication(id: number): Promise<Result> {
101104
}
102105
}
103106

107+
/** Expects a multipart body with a single `file` field holding a PDF. Replaces any existing resume. */
108+
export async function uploadResume(id: number, formData: FormData): Promise<Result> {
109+
const user = await getUser();
110+
if (!user) return fail("Sign in to upload a resume.");
111+
if (!Number.isInteger(id)) return fail("Application not found.");
112+
113+
const file = formData.get("file");
114+
if (!(file instanceof File)) return fail("Choose a PDF to upload.");
115+
116+
const problem = checkResumeFile(file);
117+
if (problem) return fail(problem);
118+
119+
const data = Buffer.from(await file.arrayBuffer());
120+
if (!hasPdfHeader(data)) return fail("That file is not a PDF.");
121+
122+
try {
123+
const row = await upsertResume(user.id, id, {
124+
file_name: cleanFileName(file.name),
125+
content_type: "application/pdf",
126+
size: data.byteLength,
127+
data,
128+
});
129+
if (!row) return fail("Application not found.");
130+
revalidatePath(PATH);
131+
return { ok: true };
132+
} catch (error) {
133+
console.error("uploadResume", error);
134+
return fail("Could not upload the resume.");
135+
}
136+
}
137+
138+
export async function removeResume(id: number): Promise<Result> {
139+
const user = await getUser();
140+
if (!user) return fail("Sign in to remove a resume.");
141+
142+
try {
143+
const deleted = await deleteResumeRow(user.id, id);
144+
if (!deleted) return fail("No resume attached.");
145+
revalidatePath(PATH);
146+
return { ok: true };
147+
} catch (error) {
148+
console.error("removeResume", error);
149+
return fail("Could not remove the resume.");
150+
}
151+
}
152+
104153
export async function importApplications(rows: ImportRowInput[]): Promise<ImportResult> {
105154
const user = await getUser();
106155
if (!user) return { ok: false, error: "Sign in to import applications." };
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { getUser } from "@/lib/session";
2+
import { getResume } from "@/db/queries";
3+
4+
/** RFC 6266: an ASCII fallback plus the UTF-8 encoded original name. */
5+
function contentDisposition(fileName: string) {
6+
const ascii = fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
7+
return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
8+
}
9+
10+
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
11+
const user = await getUser();
12+
if (!user) return new Response("Unauthorized", { status: 401 });
13+
14+
const id = Number((await params).id);
15+
if (!Number.isInteger(id)) return new Response("Not found", { status: 404 });
16+
17+
const resume = await getResume(user.id, id);
18+
if (!resume) return new Response("Not found", { status: 404 });
19+
20+
return new Response(new Uint8Array(resume.data), {
21+
headers: {
22+
"Content-Type": resume.content_type,
23+
"Content-Length": String(resume.size),
24+
"Content-Disposition": contentDisposition(resume.file_name),
25+
"Cache-Control": "private, no-store",
26+
"X-Content-Type-Options": "nosniff",
27+
},
28+
});
29+
}

components/applications/application-card.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "@/components/ui/card";
1111
import { StatusMenu } from "./status-menu";
1212
import { RowActions } from "./row-actions";
13+
import { ResumeChip } from "./resume-control";
1314

1415
export function ApplicationCard({ application }: { application: Application }) {
1516
const applied = formatDate(application.date_applied);
@@ -57,6 +58,9 @@ export function ApplicationCard({ application }: { application: Application }) {
5758
</a>
5859
) : null}
5960
</dl>
61+
<div className="flex min-w-0 items-center">
62+
<ResumeChip application={application} />
63+
</div>
6064
</CardContent>
6165
</Card>
6266
);

components/applications/applications-view.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { NewApplicationButton } from "./application-dialog";
6161
import { ImportButton } from "./import-dialog";
6262
import { ExportButton } from "./export-button";
6363
import { ApplicationCard } from "./application-card";
64+
import { ResumeCell } from "./resume-control";
6465
import { StatusMenu } from "./status-menu";
6566
import { RowActions } from "./row-actions";
6667
import { statusClasses } from "./status-badge";
@@ -115,6 +116,12 @@ const columns: ColumnDef<Application>[] = [
115116
);
116117
},
117118
},
119+
{
120+
id: "resume",
121+
accessorFn: (row) => (row.resume ? 1 : 0),
122+
header: "Resume",
123+
cell: ({ row }) => <ResumeCell application={row.original} />,
124+
},
118125
{
119126
id: "link",
120127
header: () => <span className="sr-only">Posting</span>,
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"use client";
2+
3+
import { type ChangeEvent, useRef, useTransition } from "react";
4+
import { FilePdfIcon, UploadSimpleIcon } from "@phosphor-icons/react";
5+
import { cn } from "@/lib/utils";
6+
import type { Application } from "@/lib/applications";
7+
import { RESUME_ACCEPT, checkResumeFile, formatBytes, resumeUrl } from "@/lib/resumes";
8+
import { removeResume, uploadResume } from "@/app/(application-record)/actions";
9+
import { toast } from "@/components/ui/toast";
10+
import { Button, buttonVariants } from "@/components/ui/button";
11+
import { Spinner } from "@/components/ui/spinner";
12+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
13+
14+
/**
15+
* Owns the hidden file input and the upload/remove transitions. Render
16+
* `input` somewhere in the tree, then call `pick()` from any button or menu item.
17+
*/
18+
export function useResumeUpload(application: Application) {
19+
const inputRef = useRef<HTMLInputElement>(null);
20+
const [pending, startTransition] = useTransition();
21+
const attached = application.resume !== null;
22+
23+
const pick = () => inputRef.current?.click();
24+
25+
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
26+
const file = event.target.files?.[0];
27+
event.target.value = "";
28+
if (!file) return;
29+
30+
const problem = checkResumeFile(file);
31+
if (problem) {
32+
toast.add({ type: "error", title: "Resume not uploaded", description: problem });
33+
return;
34+
}
35+
36+
startTransition(async () => {
37+
const body = new FormData();
38+
body.append("file", file);
39+
const result = await uploadResume(application.id, body);
40+
if (!result.ok) {
41+
toast.add({ type: "error", title: "Resume not uploaded", description: result.error });
42+
return;
43+
}
44+
toast.add({
45+
type: "success",
46+
title: attached ? "Resume replaced" : "Resume attached",
47+
description: file.name,
48+
});
49+
});
50+
};
51+
52+
const remove = () => {
53+
startTransition(async () => {
54+
const result = await removeResume(application.id);
55+
if (!result.ok) {
56+
toast.add({ type: "error", title: "Resume not removed", description: result.error });
57+
return;
58+
}
59+
toast.add({ type: "success", title: "Resume removed" });
60+
});
61+
};
62+
63+
const input = (
64+
<input
65+
ref={inputRef}
66+
type="file"
67+
accept={RESUME_ACCEPT}
68+
className="hidden"
69+
tabIndex={-1}
70+
aria-hidden="true"
71+
onChange={onChange}
72+
/>
73+
);
74+
75+
return { attached, pending, pick, remove, input };
76+
}
77+
78+
/** Card view: a chip that opens the PDF, or a quiet "Add resume" button. */
79+
export function ResumeChip({ application }: { application: Application }) {
80+
const { pending, pick, input } = useResumeUpload(application);
81+
const resume = application.resume;
82+
83+
return (
84+
<>
85+
{input}
86+
{resume ? (
87+
<a
88+
href={resumeUrl(application.id)}
89+
target="_blank"
90+
rel="noreferrer"
91+
title={`${resume.file_name} (${formatBytes(resume.size)})`}
92+
aria-label={`Open resume ${resume.file_name}`}
93+
className={cn(
94+
"inline-flex h-6 max-w-full items-center gap-1.5 rounded-2xl bg-brand/15 pr-2.5 pl-2 text-xs font-medium text-brand-ink transition-colors outline-none hover:bg-brand/25 focus-visible:ring-3 focus-visible:ring-ring/30",
95+
pending && "opacity-60",
96+
)}
97+
>
98+
{pending ? (
99+
<Spinner className="size-3.5" />
100+
) : (
101+
<FilePdfIcon weight="fill" className="size-3.5 shrink-0" aria-hidden="true" />
102+
)}
103+
<span className="truncate">{resume.file_name}</span>
104+
</a>
105+
) : (
106+
<Button
107+
variant="ghost"
108+
size="xs"
109+
className="-ml-2 text-muted-foreground hover:text-foreground"
110+
onClick={pick}
111+
disabled={pending}
112+
>
113+
{pending ? (
114+
<Spinner data-icon="inline-start" />
115+
) : (
116+
<UploadSimpleIcon data-icon="inline-start" weight="bold" />
117+
)}
118+
Add resume
119+
</Button>
120+
)}
121+
</>
122+
);
123+
}
124+
125+
/** Table view: one icon that either opens the PDF or starts an upload. */
126+
export function ResumeCell({ application }: { application: Application }) {
127+
const { pending, pick, input } = useResumeUpload(application);
128+
const resume = application.resume;
129+
130+
if (pending) {
131+
return (
132+
<span className="inline-flex size-7 items-center justify-center">
133+
<Spinner className="text-muted-foreground" />
134+
</span>
135+
);
136+
}
137+
138+
return (
139+
<>
140+
{input}
141+
{resume ? (
142+
<Tooltip>
143+
<TooltipTrigger
144+
render={
145+
<a
146+
href={resumeUrl(application.id)}
147+
target="_blank"
148+
rel="noreferrer"
149+
aria-label={`Open resume ${resume.file_name}`}
150+
/>
151+
}
152+
className={cn(
153+
buttonVariants({ variant: "ghost", size: "icon-sm" }),
154+
"text-brand-ink hover:text-brand-ink",
155+
)}
156+
>
157+
<FilePdfIcon weight="fill" />
158+
</TooltipTrigger>
159+
<TooltipContent>
160+
{resume.file_name}
161+
<span className="opacity-60">{formatBytes(resume.size)}</span>
162+
</TooltipContent>
163+
</Tooltip>
164+
) : (
165+
<Tooltip>
166+
<TooltipTrigger
167+
render={<Button variant="ghost" size="icon-sm" aria-label="Upload resume" />}
168+
className="text-muted-foreground/60 hover:text-foreground"
169+
onClick={pick}
170+
>
171+
<UploadSimpleIcon />
172+
</TooltipTrigger>
173+
<TooltipContent>Upload resume</TooltipContent>
174+
</Tooltip>
175+
)}
176+
</>
177+
);
178+
}

components/applications/row-actions.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import { useState } from "react";
44
import {
55
ArrowSquareOutIcon,
66
DotsThreeVerticalIcon,
7+
FilePdfIcon,
8+
FileXIcon,
79
PencilSimpleIcon,
810
TrashIcon,
11+
UploadSimpleIcon,
912
} from "@phosphor-icons/react";
1013
import type { Application } from "@/lib/applications";
14+
import { resumeUrl } from "@/lib/resumes";
1115
import { Button } from "@/components/ui/button";
1216
import {
1317
DropdownMenu,
@@ -19,21 +23,24 @@ import {
1923
} from "@/components/ui/dropdown-menu";
2024
import { ApplicationDialog } from "./application-dialog";
2125
import { DeleteDialog } from "./delete-dialog";
26+
import { useResumeUpload } from "./resume-control";
2227

2328
export function RowActions({ application }: { application: Application }) {
2429
const [editOpen, setEditOpen] = useState(false);
2530
const [deleteOpen, setDeleteOpen] = useState(false);
31+
const resume = useResumeUpload(application);
2632

2733
return (
2834
<>
35+
{resume.input}
2936
<DropdownMenu>
3037
<DropdownMenuTrigger
3138
render={<Button variant="ghost" size="icon-sm" />}
3239
aria-label={`Actions for ${application.company_name}`}
3340
>
3441
<DotsThreeVerticalIcon weight="bold" />
3542
</DropdownMenuTrigger>
36-
<DropdownMenuContent align="end" className="min-w-40">
43+
<DropdownMenuContent align="end" className="min-w-44">
3744
<DropdownMenuGroup>
3845
<DropdownMenuItem onClick={() => setEditOpen(true)}>
3946
<PencilSimpleIcon />
@@ -56,6 +63,39 @@ export function RowActions({ application }: { application: Application }) {
5663
) : null}
5764
</DropdownMenuGroup>
5865
<DropdownMenuSeparator />
66+
<DropdownMenuGroup>
67+
{resume.attached ? (
68+
<>
69+
<DropdownMenuItem
70+
render={
71+
<a
72+
href={resumeUrl(application.id)}
73+
target="_blank"
74+
rel="noreferrer"
75+
aria-label="Open resume"
76+
/>
77+
}
78+
>
79+
<FilePdfIcon />
80+
Open resume
81+
</DropdownMenuItem>
82+
<DropdownMenuItem onClick={resume.pick} disabled={resume.pending}>
83+
<UploadSimpleIcon />
84+
Replace resume
85+
</DropdownMenuItem>
86+
<DropdownMenuItem onClick={resume.remove} disabled={resume.pending}>
87+
<FileXIcon />
88+
Remove resume
89+
</DropdownMenuItem>
90+
</>
91+
) : (
92+
<DropdownMenuItem onClick={resume.pick} disabled={resume.pending}>
93+
<UploadSimpleIcon />
94+
Upload resume
95+
</DropdownMenuItem>
96+
)}
97+
</DropdownMenuGroup>
98+
<DropdownMenuSeparator />
5999
<DropdownMenuGroup>
60100
<DropdownMenuItem variant="destructive" onClick={() => setDeleteOpen(true)}>
61101
<TrashIcon />

0 commit comments

Comments
 (0)