Skip to content

Commit

Permalink
feat: add resume upload (#204)
Browse files Browse the repository at this point in the history
* feat: DH11 application and review tables

* fix: update logsnag project

* feat: add user to dh11 applications

* feat: use DH11 Applications

* fix: update routes to refer to dh11

* feat: prisma db migration

* feat: backend for uppy signed url upload

* fix: remove unnecessary validation, store at root

* fix: cleanup packages

* feat: basic upload component

* feat: add endpoint for getting resume files

* feat: handle uppy upload responses

* fix: handle empty string dates

* feat: connect uppy to react form

* fix: prettier formatting

* fix: add missing types

* fix: make form mobile friendly again

* fix: remove migration

* fix: add missing libraries

---------

Co-authored-by: Krish120003 <[email protected]>
  • Loading branch information
arian81 and Krish120003 authored Oct 24, 2024
1 parent c2e8df8 commit 591e105
Show file tree
Hide file tree
Showing 8 changed files with 1,781 additions and 20 deletions.
11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"format:check": "prettier --check \"**/*.{ts,tsx,md,mdx,json,js,mjs,cjs}\""
},
"dependencies": {
"@aws-sdk/client-s3": "^3.674.0",
"@aws-sdk/s3-request-presigner": "^3.674.0",
"@babel/plugin-transform-react-display-name": "^7.23.3",
"@formkit/auto-animate": "^0.8.1",
"@fullcalendar/bootstrap5": "^6.0.2",
Expand All @@ -33,7 +35,14 @@
"@trpc/next": "^10.38.5",
"@trpc/react-query": "^10.38.5",
"@trpc/server": "^10.38.5",
"@zxing/browser": "^0.1.1",
"@uppy/core": "^4.2.2",
"@uppy/dashboard": "^4.1.1",
"@uppy/drag-drop": "^4.0.3",
"@uppy/file-input": "^4.0.2",
"@uppy/progress-bar": "^4.0.0",
"@uppy/react": "^4.0.2",
"@uppy/xhr-upload": "^4.2.1",
"@zxing/browser": "^0.1.5",
"bootstrap": "^5.2.3",
"bootstrap-icons": "^1.10.3",
"class-variance-authority": "^0.7.0",
Expand Down
1,575 changes: 1,569 additions & 6 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/components/Drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const Drawer = ({
ref={drawer}
/>

<div className="drawer-content flex flex-col">
<div className="drawer-content w-screen flex flex-col">
<NavBar />
{children}
</div>
Expand Down
4 changes: 4 additions & 0 deletions src/env/schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export const serverSchema = z.object({
AZURE_AD_CLIENT_SECRET: z.string(),
AZURE_AD_TENANT_ID: z.string(),
LOGSNAG_TOKEN: z.string(),
R2_ACCOUNT_ID: z.string(),
R2_ACCESS_KEY_ID: z.string(),
R2_SECRET_KEY_ID: z.string(),
R2_BUCKET_NAME: z.string(),
});

/**
Expand Down
117 changes: 106 additions & 11 deletions src/pages/apply.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,15 @@ import {
orientations,
representation,
} from "../data/applicationSelectData";
import { useEffect } from "react";
import React, { useEffect, useId, useState } from "react";
import SocialLinksFormInput from "../components/SocialLinkFormInput";
import Uppy from "@uppy/core";
import { Dashboard } from "@uppy/react";
import "@uppy/core/dist/style.min.css";
import "@uppy/dashboard/dist/style.min.css";
import XHR from "@uppy/xhr-upload";
import { useSession } from "next-auth/react";
import { useTheme } from "next-themes";

export type InputsType = z.infer<typeof applicationSchema>;
const pt = applicationSchema.partial();
Expand Down Expand Up @@ -152,6 +159,76 @@ const FormTextArea: React.FC<
);
};

interface FormUploadProps {
uploadUrl: string;
objectId: string;
setUploadValue: (value: string) => void;
}

const FormUpload: React.FC<FormUploadProps> = ({
uploadUrl,
objectId,
setUploadValue,
}) => {
const { theme } = useTheme();

const id = useId();
const [uppy] = useState(() =>
new Uppy({
id: id,
allowMultipleUploadBatches: false,
restrictions: {
maxNumberOfFiles: 1,
maxFileSize: 1024 * 1024 * 5, // 5 MB
allowedFileTypes: [".pdf"],
},
locale: {
strings: {
done: "Reset",
},
pluralize: (n: number) => n,
},
}).use(XHR, {
endpoint: uploadUrl,
formData: false,
method: "PUT",
onAfterResponse: () => {
setUploadValue(objectId);
},
getResponseData: () => {
return { url: objectId };
},
})
);

if (!uploadUrl) {
return (
<div className="flex items-center justify-center">
<div>Loading...</div>
</div>
);
}

return (
<div className="flex flex-col flex-1 gap-2 pb-4">
<div className="text-black dark:text-white">
Resume{" "}
<span className="text-neutral-500 dark:text-neutral-400">
(Optional)
</span>
</div>
<Dashboard
uppy={uppy}
height={200}
doneButtonHandler={() => {
uppy.resetProgress();
}}
theme={theme === "dark" ? "dark" : "light"}
/>
</div>
);
};

const ApplyForm = ({
autofillData,
persistId,
Expand Down Expand Up @@ -196,6 +273,24 @@ const ApplyForm = ({
storage: localStorage,
});

const [uploadUrl, setUploadUrl] = useState<string | null>(null);

const { mutate, data } = trpc.file.getUploadUrl.useMutation({
onSuccess: (data) => {
setUploadUrl(data?.url);
},
});

const user = useSession();

const objectId = `${user.data?.user?.id}-dh11.pdf`;
useEffect(() => {
mutate({
filename: objectId,
contentType: "application/pdf",
});
}, []);

const onSubmit: SubmitHandler<InputsType> = async (data) => {
console.log(data);
console.log("validating");
Expand Down Expand Up @@ -265,14 +360,15 @@ const ApplyForm = ({
<span className="text-error">{errors.birthday.message}</span>
)}
</div>
<FormInput
label="Link to Resume"
id="linkToResume"
placeholder="https://example.com/resume.pdf"
errors={errors.linkToResume}
register={register}
optional
/>
{uploadUrl ? (
<FormUpload
uploadUrl={uploadUrl}
objectId={objectId}
setUploadValue={(v) => setValue("linkToResume", v)}
/>
) : (
<div></div>
)}
<FormDivider label="Education" />
<FormCheckbox
label="Are you currently enrolled in post-secondary education?"
Expand Down Expand Up @@ -583,7 +679,6 @@ const ApplyForm = ({
<span className="text-error">{errors.discoverdFrom.message}</span>
)}
</div>

<FormCheckbox
label="Do you already have a team?"
id="alreadyHaveTeam"
Expand Down Expand Up @@ -795,7 +890,7 @@ const Apply: NextPage<
<Drawer>
<div className="w-full">
<div className="max-w-4xl p-4 mx-auto text-black dark:text-white md:w-1/2 md:p-0">
<h1 className="py-8 text-4xl font-bold text-center text-black dark:text-white md:text-left">
<h1 className="py-8 text-3xl font-bold text-center text-black dark:text-white md:text-left">
Apply to DeltaHacks XI
</h1>

Expand Down
6 changes: 5 additions & 1 deletion src/schemas/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@ const dh11schema = z.object({
studyDegree: z.string().min(1).max(255).nullish(),
studyMajor: z.string().min(1).max(255).nullish(),
studyYearOfStudy: z.string().nullish(),
studyExpectedGraduation: z.coerce.date().nullish(),
studyExpectedGraduation: z.coerce
.date()
.or(z.string())
.transform((s) => (typeof s === "string" ? null : s)) // if the coerce.date fails, this value is null
.nullish(),
previousHackathonsCount: z.coerce.number().int().min(0),
longAnswerIncident: z
.string()
Expand Down
84 changes: 84 additions & 0 deletions src/server/router/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { z } from "zod";
import { publicProcedure, router } from "./trpc";
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { TRPCError } from "@trpc/server";
import { env } from "../../env/server.mjs";

const { R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_KEY_ID, R2_BUCKET_NAME } =
env;

const R2 = new S3Client({
region: "auto",
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_KEY_ID,
},
});

export const fileUploadRouter = router({
getUploadUrl: publicProcedure
.input(
z.object({
filename: z.string(),
contentType: z.string(),
})
)
.mutation(async ({ input }) => {
try {
const signedUrl = await getSignedUrl(
R2,
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: `${input.filename}`,
ContentType: input.contentType,
}),
{ expiresIn: 3600 }
);

return {
url: signedUrl,
method: "PUT" as const,
};
} catch (error) {
console.error("Error generating signed URL:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate upload URL",
});
}
}),
getDownloadUrl: publicProcedure
.input(
z.object({
key: z.string(),
})
)
.query(async ({ input }) => {
try {
const command = new GetObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: input.key,
});

const signedUrl = await getSignedUrl(R2, command, {
expiresIn: 3600, // URL expires in 1 hour
});

return {
url: signedUrl,
};
} catch (error) {
console.error("Error generating download URL:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate download URL",
});
}
}),
});
2 changes: 2 additions & 0 deletions src/server/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { eventsRouter } from "./events";
import { userRouter } from "./users";
import { router } from "./trpc";
import { adminRouter } from "./admin";
import { fileUploadRouter } from "./file";

export const appRouter = router({
application: applicationRouter,
reviewer: reviewerRouter,
user: userRouter,
admin: adminRouter,
file: fileUploadRouter,
// NOTE: Will be deprecated
food: foodRouter,
events: eventsRouter,
Expand Down

0 comments on commit 591e105

Please sign in to comment.