Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
6dbd6e0
add nonfunctional resume upload field
anna-sage Dec 14, 2024
2395cbd
validate resumeUpload input is FileList type
anna-sage Dec 17, 2024
86bca3c
validate number of files in file list
anna-sage Dec 17, 2024
28c2152
validate resume file is pdf
anna-sage Dec 17, 2024
3fbd84c
refactor zod refinements to single superrefine
anna-sage Dec 19, 2024
6e6b318
validate file size doesn't exceed 5MB
anna-sage Dec 19, 2024
eb7fdfd
validate type of FileList object
anna-sage Dec 19, 2024
3232d44
remove testing console log
anna-sage Dec 23, 2024
2704a89
install minio client library
anna-sage Dec 23, 2024
3475a13
install aws sdk dependencies
anna-sage Dec 23, 2024
9271c56
create empty upload api route file
anna-sage Dec 23, 2024
52e22e5
instantiate minio client (not working)
anna-sage Dec 24, 2024
9c6dde9
add skeleton code for new resume upload route
anna-sage Dec 29, 2024
79d6963
add resume upload router to tRPC routers
anna-sage Dec 29, 2024
f74086b
removed unused file
anna-sage Dec 29, 2024
d5b900b
add minio version to blade dependencies
anna-sage Dec 29, 2024
2ea642d
write initial resume upload frontend logic
anna-sage Dec 29, 2024
63fc81a
add initial resume upload router logic
anna-sage Dec 29, 2024
02d261b
update .env.example with MinIO access variables
anna-sage Dec 29, 2024
e9db6a2
update env module with MinIO access variables
anna-sage Dec 29, 2024
199cbed
add tRPC route for resumes (progress)
anna-sage Dec 29, 2024
2a0da03
auto-fix issue with lock file
anna-sage Jan 1, 2025
407f786
resolve type error
anna-sage Jan 1, 2025
3c17dff
convert file to base64 string for client to server transmission
anna-sage Jan 1, 2025
8b35359
add typechecking for base 64 data
anna-sage Jan 2, 2025
736166e
fix lint issues
anna-sage Jan 2, 2025
bda4b20
fix typecheck issue
anna-sage Jan 2, 2025
16f900c
run prettier on everything
anna-sage Jan 2, 2025
5199388
reorder dependencies
anna-sage Jan 2, 2025
838b729
return path to resume instead of resume url (to generate presigned url)
anna-sage Jan 2, 2025
b483307
configure ssl
anna-sage Jan 2, 2025
f1b0331
overwrite old resumes before uploading
anna-sage Jan 3, 2025
cc40a49
run prettier again
anna-sage Jan 3, 2025
548e907
resolve comments
Lewin-B Jan 4, 2025
09adefa
Add tanstack table to blade
Lewin-B Jan 4, 2025
da3be3c
Add grad date to the member form
Lewin-B Jan 4, 2025
7df93bb
Add grad date to member profile
Lewin-B Jan 4, 2025
0f6099c
styling changes
Lewin-B Jan 4, 2025
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: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,9 @@ DISCORD_WEATHER_API_KEY=""

# Google
GOOGLE_PRIVATE_KEY_B64=""
GOOGLE_CLIENT_EMAIL=""
GOOGLE_CLIENT_EMAIL=""

# Minio
MINIO_ENDPOINT=""
MINIO_ACCESS_KEY=""
MINIO_SECRET_KEY=""
4 changes: 4 additions & 0 deletions apps/blade/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.717.0",
"@aws-sdk/s3-request-presigner": "^3.717.0",
"@forge/api": "workspace:*",
"@forge/auth": "workspace:*",
"@forge/consts": "workspace:*",
Expand All @@ -25,13 +27,15 @@
"@stripe/stripe-js": "^5.2.0",
"@t3-oss/env-nextjs": "^0.11.1",
"@tanstack/react-query": "catalog:",
"@tanstack/react-table": "^8.20.6",
"@trpc/client": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"geist": "^1.3.1",
"google-auth-library": "^9.15.0",
"googleapis": "^144.0.0",
"lucide-react": "^0.469.0",
"minio": "^8.0.3",
"next": "^14.2.15",
"react": "catalog:react18",
"react-dom": "catalog:react18",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { z } from "zod";

import {
GENDERS,
KNIGHTHACKS_MAX_RESUME_SIZE,
LEVELS_OF_STUDY,
RACES_OR_ETHNICITIES,
SCHOOLS,
Expand Down Expand Up @@ -48,6 +49,16 @@ export function MemberApplicationForm() {
toast.error("Oops! Something went wrong. Please try again later.");
},
});

const uploadResume = api.resume.uploadResume.useMutation({
onSuccess() {
toast.success("Resume successfully uploaded!");
},
onError() {
toast.error("There was a problem storing your resume, please try again!");
},
});

const form = useForm({
schema: InsertMemberSchema.extend({
// userId will be derived from the user's session on the server
Expand All @@ -66,6 +77,10 @@ export function MemberApplicationForm() {
.string()
.pipe(z.coerce.date())
.transform((date) => date.toISOString()),
gradDate: z
.string()
.pipe(z.coerce.date())
.transform((date) => date.toISOString()),
githubProfileUrl: z
.string()
.regex(/^https:\/\/.+/, "Invalid URL: Please try again with https://")
Expand Down Expand Up @@ -95,26 +110,112 @@ export function MemberApplicationForm() {
.url({ message: "Invalid URL" })
.optional()
.or(z.literal("")),
resumeUpload: z
.instanceof(FileList)
.superRefine((fileList, ctx) => {
// Validate number of files is 0 or 1
if (fileList.length !== 0 && fileList.length !== 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Only 0 or 1 files allowed",
});
}

if (fileList.length === 1) {
// Validate type of object in FileList is File
if (fileList[0] instanceof File) {
// Validate file extension is PDF
const fileExtension = fileList[0].name.split(".").pop();
if (fileExtension !== "pdf") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Resume must be a PDF",
});
}

// Validate file size is <= 5MB
if (fileList[0].size > KNIGHTHACKS_MAX_RESUME_SIZE) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
type: "number",
maximum: KNIGHTHACKS_MAX_RESUME_SIZE,
inclusive: true,
exact: false,
message: "File too large: maximum 5MB",
});
}
} else {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Object in FileList is undefined",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this and other zod issues/errors/not user-facing strings can have more consistent punctuation

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you give an example?

});
}
}
})
.optional(),
}),
defaultValues: {
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
dob: "",
gradDate: "",
githubProfileUrl: "",
linkedinProfileUrl: "",
websiteUrl: "",
},
});

const fileRef = form.register("resumeUpload");

// Convert a resume to base64 for client-server transmission
const fileToBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// Check type before resolving as string
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(
new Error(
"Failed to convert file to Base64: Unexpected result type",
),
);
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});

return (
<Form {...form}>
<form
className="space-y-4"
className="mx-auto flex h-full w-full flex-col space-y-3 overflow-y-auto rounded-md border p-4 md:w-1/2"
noValidate
onSubmit={form.handleSubmit((values) => {
createMember.mutate(values);
onSubmit={form.handleSubmit(async (values) => {
try {
let resumeUrl = "";
if (values.resumeUpload?.length && values.resumeUpload[0]) {
const file = values.resumeUpload[0];
const base64File = await fileToBase64(file);
resumeUrl = await uploadResume.mutateAsync({
fileName: file.name,
fileContent: base64File,
});
}

createMember.mutate({
...values,
resumeUrl, // Include uploaded resume URL
});
} catch (error) {
console.error("Error uploading resume or creating member:", error);
toast.error(
"Something went wrong while processing your application.",
);
}
})}
>
<h1 className="text-2xl font-bold">Application Form</h1>
Expand Down Expand Up @@ -291,6 +392,19 @@ export function MemberApplicationForm() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="gradDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Graduation Date</FormLabel>
<FormControl>
<Input type="month" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="shirtSize"
Expand Down Expand Up @@ -365,6 +479,26 @@ export function MemberApplicationForm() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="resumeUpload"
render={({ field }) => (
<FormItem>
<FormLabel>Resume</FormLabel>
<FormControl>
<Input
type="file"
placeholder=""
{...fileRef}
onChange={(event) => {
field.onChange(event.target.files?.[0] ?? undefined);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<Button type="submit">Submit</Button>
</form>
Expand Down
18 changes: 18 additions & 0 deletions apps/blade/src/app/settings/member-profile-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export function MemberProfileForm({
.string()
.pipe(z.coerce.date())
.transform((date) => date.toISOString()),
gradDate: z
.string()
.pipe(z.coerce.date())
.transform((date) => date.toISOString()),
githubProfileUrl: z
.string()
.regex(/^https:\/\/.+/, "Invalid URL: Please try again with https://")
Expand Down Expand Up @@ -110,6 +114,7 @@ export function MemberProfileForm({
email: member?.email,
phoneNumber: member?.phoneNumber,
dob: member?.dob,
gradDate: member?.gradDate,
githubProfileUrl: member?.githubProfileUrl ?? "",
linkedinProfileUrl: member?.linkedinProfileUrl ?? "",
websiteUrl: member?.websiteUrl ?? "",
Expand Down Expand Up @@ -363,6 +368,19 @@ export function MemberProfileForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="gradDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Graduation Date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="!mt-10">
<h3 className="text-lg font-medium">URLs</h3>
<p className="text-sm text-muted-foreground">
Expand Down
3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,5 @@
"overrides": {
"@types/node": "^22.10.1"
}
},
"dependencies": {
"@tanstack/react-table": "^8.20.6"
}
}
3 changes: 3 additions & 0 deletions packages/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export const env = createEnv({
GOOGLE_PRIVATE_KEY_B64: z.string(),
NODE_ENV: z.enum(["development", "production"]).optional(),
STRIPE_SECRET_WEBHOOK_KEY: z.string(),
MINIO_ENDPOINT: z.string(),
MINIO_ACCESS_KEY: z.string(),
MINIO_SECRET_KEY: z.string(),
},
experimental__runtimeEnv: {},
skipValidation:
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { authRouter } from "./routers/auth";
import { duesPaymentRouter } from "./routers/dues-payment";
import { eventRouter } from "./routers/event";
import { memberRouter } from "./routers/member";
import { resumeUploadRouter } from "./routers/resume-upload";
import { userRouter } from "./routers/user";
import { createTRPCRouter } from "./trpc";

Expand All @@ -11,6 +12,7 @@ export const appRouter = createTRPCRouter({
member: memberRouter,
event: eventRouter,
user: userRouter,
resume: resumeUploadRouter,
});

// export type definition of API
Expand Down
74 changes: 74 additions & 0 deletions packages/api/src/routers/resume-upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { ItemBucketMetadata } from "minio";
import { TRPCError } from "@trpc/server";
import { Client } from "minio";
import { z } from "zod";

import { KNIGHTHACKS_S3_BUCKET_REGION } from "@forge/consts/knight-hacks";

import { env } from "../env";
import { protectedProcedure } from "../trpc";

const s3Client = new Client({
endPoint: env.MINIO_ENDPOINT,
useSSL: true,
accessKey: env.MINIO_ACCESS_KEY,
secretKey: env.MINIO_SECRET_KEY,
});

export const resumeUploadRouter = {
uploadResume: protectedProcedure
.input(
z.object({
fileName: z.string(),
fileContent: z.string(), // Base-64 encoded
}),
)
.mutation(async ({ input, ctx }) => {
const { fileName, fileContent } = input;

// Decode Base64 to Buffer
const base64Data = fileContent.split(",")[1]; // Remove metadata prefix
if (base64Data) {
const fileBuffer = Buffer.from(base64Data, "base64");

const bucketName = "member-resumes";
const userDirectory = `${ctx.session.user.id}/`;
const filePath = `${userDirectory}${fileName}`;

// Ensure bucket exists
const bucketExists = await s3Client.bucketExists(bucketName);
if (!bucketExists) {
await s3Client.makeBucket(bucketName, KNIGHTHACKS_S3_BUCKET_REGION);
}

// Overwrite any existing resume associated with the user
const existingResumes = [];
const objectStream = s3Client.listObjects(
bucketName,
userDirectory,
true,
);
for await (const obj of objectStream as AsyncIterable<ItemBucketMetadata>) {
existingResumes.push(obj.name);
}

if (existingResumes.length > 0) {
await Promise.all(
existingResumes.map(async (objectName: string) => {
await s3Client.removeObject(bucketName, objectName);
}),
);
}

await s3Client.putObject(bucketName, filePath, fileBuffer);

// Path to the resume within the bucket
return filePath;
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Base64 data is missing or invalid",
});
}
}),
};
3 changes: 3 additions & 0 deletions packages/consts/src/knight-hacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export const EVENT_POINTS: Record<EventTag, number> = {
Hackathon: 1,
} as const;

export const KNIGHTHACKS_S3_BUCKET_REGION = "us-east-1";
export const KNIGHTHACKS_MAX_RESUME_SIZE = 5 * 1000000; // 5MB

export const KNIGHTHACKS_MEMBERSHIP_PRICE = 2500;

export const PROD_DISCORD_ADMIN_ROLE_ID = "1319413082258411652";
Expand Down
Loading
Loading