From 5e4e642240cd2af6482146eff3013545212f8512 Mon Sep 17 00:00:00 2001 From: Matt Leon Date: Fri, 22 Oct 2021 14:10:24 -0400 Subject: [PATCH] Allow sponsors to download resumes (#823) --- package.json | 1 + scripts/populateDb.ts | 42 ++++++++++++++++++- src/client/assets/application.js | 4 +- src/client/assets/strings.json | 7 +++- src/client/routes/dashboard/OrganizerDash.tsx | 1 - src/client/routes/manage/HackerTable.tsx | 19 ++++++++- .../routes/manage/SponsorHackerView.tsx | 10 ++++- src/client/routes/manage/hackers.graphql.ts | 4 ++ src/common/constants.ts | 5 ++- src/common/schema.graphql.ts | 1 + src/server/resolvers/QueryResolvers/index.ts | 10 ++++- src/server/storage/gcp.ts | 33 +++++++++++++++ 12 files changed, 127 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 919b9e0f3..9ce0893d1 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "helmet": "^4.6.0", "immer": "^9.0.6", "jsdom": "^16.4.0", + "jszip": "^3.6.0", "mongodb": "^3.6.3", "mongodb-memory-server": "^6.9.2", "node-fetch": "^2.6.5", diff --git a/scripts/populateDb.ts b/scripts/populateDb.ts index 9bd86b866..6bbff3ae4 100644 --- a/scripts/populateDb.ts +++ b/scripts/populateDb.ts @@ -1,6 +1,7 @@ import { ObjectID } from 'mongodb'; import faker from 'faker'; import { config as dotenvConfig } from 'dotenv'; +import { Storage } from '@google-cloud/storage'; import institutions from '../src/client/assets/data/institutions.json'; import { ApplicationStatus, @@ -12,10 +13,22 @@ import { HackerDbObject, } from '../src/server/generated/graphql'; import DB from '../src/server/models'; +import { RESUME_DUMP_NAME } from '../src/client/assets/strings.json'; dotenvConfig(); -const NUM_HACKERS = 800; +const printUsage = (): void => { + void console.log('Usage: INCLUDE_RESUMES=[true | false] ts-node ./scripts/downloadResumes.ts'); +}; + +const { INCLUDE_RESUMES } = process.env; +if (!INCLUDE_RESUMES) { + printUsage(); + process.exit(1); +} +const includeResumes = INCLUDE_RESUMES === 'true'; + +const NUM_HACKERS = 200; const generateHacker: () => HackerDbObject = () => { const fn = faker.name.firstName(); @@ -59,6 +72,33 @@ const addHackers = async (): Promise => { console.log(`Adding the hackers to the DB...`); const { insertedCount } = await models.Hackers.insertMany(newHackers); console.log(`Inserted ${insertedCount} new hackers`); + if (includeResumes) { + console.log('Uploading resumes...'); + const bucket = new Storage(JSON.parse(process.env.GCP_STORAGE_SERVICE_ACCOUNT ?? '')).bucket( + process.env.BUCKET_NAME ?? '' + ); + + await bucket.file(RESUME_DUMP_NAME).delete({ ignoreNotFound: true }); + + await Promise.all( + newHackers.map(async hacker => { + const id = hacker._id.toHexString(); + try { + const contents = `Filler resume for ${hacker.firstName} ${hacker.lastName}.`; + await bucket.file(id).save(contents, { + resumable: false, + validation: false, + }); + console.log(contents); + } catch (e) { + console.group('Error:'); + console.error(e); + console.info('Hacker ID:', hacker._id); + console.groupEnd(); + } + }) + ); + } process.exit(0); }; diff --git a/src/client/assets/application.js b/src/client/assets/application.js index 6563d07f5..ceb1e7c50 100644 --- a/src/client/assets/application.js +++ b/src/client/assets/application.js @@ -158,8 +158,8 @@ export const questions = [ { Component: FileInput, fieldName: 'resume', - note: 'Your résumé will be shared with sponsors', - title: 'Résumé', + note: '(pdf only) Your resume will be shared with sponsors', + title: 'Resume', }, { Component: CheckboxSansTitleCase, diff --git a/src/client/assets/strings.json b/src/client/assets/strings.json index 2bcc840bf..766e11361 100644 --- a/src/client/assets/strings.json +++ b/src/client/assets/strings.json @@ -38,6 +38,9 @@ "INPUT_MAX_LENGTH": 100, "NO_EVENTS_MESSAGE": "There are no current events.", "PERMISSIONS_HACKER_TABLE": "hackertable", - "PERMISSIONS_RESUME": "resume", - "PERMISSIONS_NFC": "nfc" + "PERMISSIONS_RESUME_BEFORE": "resumebefore", + "PERMISSIONS_RESUME_DURING": "resumeduring", + "PERMISSIONS_RESUME_AFTER": "resumeafter", + "PERMISSIONS_NFC": "nfc", + "RESUME_DUMP_NAME": "vandyhacks_8_hacker_resumes.zip" } diff --git a/src/client/routes/dashboard/OrganizerDash.tsx b/src/client/routes/dashboard/OrganizerDash.tsx index eb0f5b0ac..19d64bf57 100644 --- a/src/client/routes/dashboard/OrganizerDash.tsx +++ b/src/client/routes/dashboard/OrganizerDash.tsx @@ -231,7 +231,6 @@ function getGuaranteedHackerInfo( export const OrganizerDash: FC = ({ disableAnimations }): JSX.Element => { // TODO(leonm1/tangck): Fix queries to show real data. Should also clean up imports when done. // Currently the { loading: true } will stop this component from causing errors in prod. - // eslint-disable-next-line @typescript-eslint/no-explicit-any // const { loading, error, data } = { data: {} as any, error: 'Not Implemented', loading: true }; const { loading, error, data } = useHackersQuery(); diff --git a/src/client/routes/manage/HackerTable.tsx b/src/client/routes/manage/HackerTable.tsx index d337fa670..22b44439e 100644 --- a/src/client/routes/manage/HackerTable.tsx +++ b/src/client/routes/manage/HackerTable.tsx @@ -6,7 +6,9 @@ import Select from 'react-select'; import { ValueType } from 'react-select/src/types'; import { SelectableGroup, SelectAll, DeselectAll } from 'react-selectable-fast'; import { CSVLink } from 'react-csv'; +import { useHistory } from 'react-router-dom'; +import { use } from 'passport'; import { ToggleSwitch } from '../../components/Buttons/ToggleSwitch'; import { Button } from '../../components/Buttons/Button'; import { SearchBox } from '../../components/Input/SearchBox'; @@ -19,9 +21,11 @@ import { useEventsQuery, ApplicationStatus, useHackerStatusesMutation, + useResumeDumpUrlQuery, } from '../../generated/graphql'; import RemoveButton from '../../assets/img/remove_button.svg'; import AddButton from '../../assets/img/add_button.svg'; +import { Spinner } from '../../components/Loading/Spinner'; import { HackerTableRows } from './HackerTableRows'; import { DeselectElement, SliderInput } from './SliderInput'; @@ -237,6 +241,8 @@ const HackerTable: FC = ({ const deselect = useRef(null); const [updateStatus] = useHackerStatusMutation(); const [updateStatuses] = useHackerStatusesMutation(); + const resumeDumpUrlQuery = useResumeDumpUrlQuery(); + const { data: { resumeDumpUrl = '' } = {} } = resumeDumpUrlQuery || {}; const { selectAll, @@ -295,6 +301,11 @@ const HackerTable: FC = ({ setSortedData(filteredData); }, [data, sortBy, sortDirection, searchCriteria, eventIds]); + const [isResumeDumpReady, setIsResumeDumpReady] = useState(false); + useEffect(() => { + setIsResumeDumpReady(resumeDumpUrl !== ''); + }, [resumeDumpUrl]); + // handles the text or regex search and sets the sortedData state with the updated row list // floating button that onClick toggles between selecting all or none of the rows const SelectAllButton = ( @@ -398,7 +409,7 @@ const HackerTable: FC = ({ ))} - +

Num Shown:

{sortedData.length}

{selectedRowsIds.length > 0 ? ( @@ -408,6 +419,12 @@ const HackerTable: FC = ({ ) : null}
+ {viewResumes && + (isResumeDumpReady ? ( + + ) : ( + + ))} Export diff --git a/src/client/routes/manage/SponsorHackerView.tsx b/src/client/routes/manage/SponsorHackerView.tsx index 4a2072e91..f6e7addc3 100644 --- a/src/client/routes/manage/SponsorHackerView.tsx +++ b/src/client/routes/manage/SponsorHackerView.tsx @@ -5,6 +5,7 @@ import FloatingPopup from '../../components/Containers/FloatingPopup'; import { Spinner } from '../../components/Loading/Spinner'; import { GraphQLErrorMessage } from '../../components/Text/ErrorMessage'; import STRINGS from '../../assets/strings.json'; +import { HACKATHON_START, HACKATHON_END } from '../../../common/constants'; import { HackerView } from './HackerView'; import HackerTable from './HackerTable'; import { defaultTableState, TableContext } from '../../contexts/TableContext'; @@ -14,9 +15,16 @@ export const SponsorHackerView: FC = () => { const { loading, error, data } = useHackersQuery(); const [tableState, updateTableState] = useImmer(defaultTableState); const sponsor = useMeSponsorQuery(); + const now = Date.now(); const viewResumes = sponsor.data?.me?.__typename === 'Sponsor' && - sponsor.data?.me?.company?.tier?.permissions?.includes(STRINGS.PERMISSIONS_RESUME); + ((sponsor.data?.me?.company?.tier?.permissions?.includes(STRINGS.PERMISSIONS_RESUME_BEFORE) && + now < HACKATHON_START) || + (sponsor.data?.me?.company?.tier?.permissions?.includes(STRINGS.PERMISSIONS_RESUME_DURING) && + now > HACKATHON_START && + now < HACKATHON_END) || + (sponsor.data?.me?.company?.tier?.permissions?.includes(STRINGS.PERMISSIONS_RESUME_AFTER) && + now > HACKATHON_END)); if (sponsor.error) { console.error(sponsor.error); diff --git a/src/client/routes/manage/hackers.graphql.ts b/src/client/routes/manage/hackers.graphql.ts index fe5962bd2..ffbd64d53 100644 --- a/src/client/routes/manage/hackers.graphql.ts +++ b/src/client/routes/manage/hackers.graphql.ts @@ -49,6 +49,10 @@ export default gql` } } + query resumeDumpUrl { + resumeDumpUrl + } + mutation hackerStatus($input: HackerStatusInput!) { hackerStatus(input: $input) { id diff --git a/src/common/constants.ts b/src/common/constants.ts index f5cf9542e..78221ee3f 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,2 +1,5 @@ -export const DEADLINE_TIMESTAMP = 1601679600000; +export const DEADLINE_TIMESTAMP = 1633323540000; export const MAX_TEAM_SIZE = 4; + +export const HACKATHON_START = new Date('October 8, 2021 17:00:00').getTime(); +export const HACKATHON_END = new Date('October 10, 2021 15:00:00').getTime(); diff --git a/src/common/schema.graphql.ts b/src/common/schema.graphql.ts index 12970986c..c11f77898 100644 --- a/src/common/schema.graphql.ts +++ b/src/common/schema.graphql.ts @@ -299,6 +299,7 @@ export default gql` mentor(id: ID!): Mentor! mentors(sortDirection: SortDirection): [Mentor!]! signedReadUrl(input: ID!): String! + resumeDumpUrl: String! team(id: ID!): Team! teams(sortDirection: SortDirection): [Team!]! tier(id: ID!): Tier! diff --git a/src/server/resolvers/QueryResolvers/index.ts b/src/server/resolvers/QueryResolvers/index.ts index e9d38eda0..4d30e3200 100644 --- a/src/server/resolvers/QueryResolvers/index.ts +++ b/src/server/resolvers/QueryResolvers/index.ts @@ -2,7 +2,7 @@ import { AuthenticationError } from 'apollo-server-express'; import { QueryResolvers, UserType } from '../../generated/graphql'; import Context from '../../context'; import { checkIsAuthorized, fetchUser } from '../helpers'; -import { getSignedReadUrl } from '../../storage/gcp'; +import { getResumeDumpUrl, getSignedReadUrl } from '../../storage/gcp'; import { EventQuery } from './EventQueryResolvers'; import { CompanyQuery } from './CompanyQueryResolvers'; import { HackerQuery } from './HackerQueryResolvers'; @@ -34,6 +34,14 @@ export const Query: QueryResolvers = { return getSignedReadUrl(input); }, + resumeDumpUrl: async (_, __, { user }) => { + if (!user) throw new AuthenticationError(`cannot get resumes: user not logged in`); + + // Only organizers and sponsors can get + checkIsAuthorized([UserType.Organizer, UserType.Sponsor], user); + + return getResumeDumpUrl(); + }, ...SponsorQuery, ...TeamQuery, ...TierQuery, diff --git a/src/server/storage/gcp.ts b/src/server/storage/gcp.ts index e3610bec0..70e556c58 100644 --- a/src/server/storage/gcp.ts +++ b/src/server/storage/gcp.ts @@ -1,4 +1,7 @@ import { Storage, GetSignedUrlConfig } from '@google-cloud/storage'; +import JSZip from 'jszip'; +import DB from '../models'; +import { RESUME_DUMP_NAME } from '../../client/assets/strings.json'; const { BUCKET_NAME, GCP_STORAGE_SERVICE_ACCOUNT } = process.env; @@ -14,6 +17,9 @@ export const getSignedUploadUrl = async (filename: string): Promise => { const credentials = JSON.parse(GCP_STORAGE_SERVICE_ACCOUNT); const storage = new Storage({ credentials }); + // Check for resume dump. Remove if exists. + await storage.bucket(BUCKET_NAME).file(RESUME_DUMP_NAME).delete({ ignoreNotFound: true }); + const options: GetSignedUrlConfig = { action: 'write' as const, contentType: 'application/pdf', @@ -43,3 +49,30 @@ export const getSignedReadUrl = async (filename: string): Promise => { return url; }; + +export const getResumeDumpUrl = async (): Promise => { + const credentials = JSON.parse(GCP_STORAGE_SERVICE_ACCOUNT); + const bucket = new Storage({ credentials }).bucket(BUCKET_NAME); + const models = await new DB().collections; + if (!(await bucket.file(RESUME_DUMP_NAME).exists())[0]) { + const zip = JSZip(); + + await Promise.all( + await models.Hackers.find({ + status: { $in: ['ACCEPTED', 'SUBMITTED', 'CONFIRMED'] }, + }) + .map(async hacker => { + const storedFilename = hacker._id.toHexString(); + const fileContents = (await bucket.file(storedFilename).download())[0]; + const readableFilename = `${hacker.lastName}, ${hacker.firstName} (${hacker.school}).pdf`; + zip.file(readableFilename, fileContents); + }) + .toArray() + ); + + const dump = await zip.generateAsync({ type: 'nodebuffer' }); + await bucket.file(RESUME_DUMP_NAME).save(dump, { resumable: false }); + } + + return getSignedReadUrl(RESUME_DUMP_NAME); +};