diff --git a/package-lock.json b/package-lock.json index d7d278df0..9fdb97721 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21599,6 +21599,11 @@ "integrity": "sha512-LKJlnKmXFzDdh6IZtXTyBxXcCLTAkwgKYS+NMiPXiXVnlTLjQC8fq7U89laUSgHtypJB3TdMMDK4ecG5NI/Cgw==", "dev": true }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" + }, "immer": { "version": "9.0.6", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.6.tgz", @@ -23424,6 +23429,46 @@ "object.assign": "^4.1.0" } }, + "jszip": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.7.1.tgz", + "integrity": "sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg==", + "requires": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "set-immediate-shim": "~1.0.1" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, "junk": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz", @@ -23604,6 +23649,14 @@ "type-check": "~0.3.2" } }, + "lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "requires": { + "immediate": "~3.0.5" + } + }, "lines-and-columns": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", @@ -25648,8 +25701,7 @@ "pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, "parallel-transform": { "version": "1.2.0", @@ -29010,6 +29062,11 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" }, + "set-immediate-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", + "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=" + }, "set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", 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); +};