Skip to content

Commit

Permalink
Allow sponsors to download resumes (#823)
Browse files Browse the repository at this point in the history
  • Loading branch information
leonm1 committed Oct 22, 2021
1 parent 98c3a64 commit cff43f1
Show file tree
Hide file tree
Showing 13 changed files with 186 additions and 12 deletions.
61 changes: 59 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 41 additions & 1 deletion scripts/populateDb.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -59,6 +72,33 @@ const addHackers = async (): Promise<void> => {
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);
};

Expand Down
4 changes: 2 additions & 2 deletions src/client/assets/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions src/client/assets/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
1 change: 0 additions & 1 deletion src/client/routes/dashboard/OrganizerDash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,6 @@ function getGuaranteedHackerInfo(
export const OrganizerDash: FC<Props> = ({ 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();
Expand Down
19 changes: 18 additions & 1 deletion src/client/routes/manage/HackerTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -237,6 +241,8 @@ const HackerTable: FC<HackerTableProps> = ({
const deselect = useRef<DeselectElement>(null);
const [updateStatus] = useHackerStatusMutation();
const [updateStatuses] = useHackerStatusesMutation();
const resumeDumpUrlQuery = useResumeDumpUrlQuery();
const { data: { resumeDumpUrl = '' } = {} } = resumeDumpUrlQuery || {};

const {
selectAll,
Expand Down Expand Up @@ -295,6 +301,11 @@ const HackerTable: FC<HackerTableProps> = ({
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 = (
Expand Down Expand Up @@ -398,7 +409,7 @@ const HackerTable: FC<HackerTableProps> = ({
</FlexRow>
))}
</FlexColumn>
<Count>
<Count style={{ margin: '20px' }}>
<h3>Num Shown:</h3>
<p>{sortedData.length}</p>
{selectedRowsIds.length > 0 ? (
Expand All @@ -408,6 +419,12 @@ const HackerTable: FC<HackerTableProps> = ({
</>
) : null}
</Count>
{viewResumes &&
(isResumeDumpReady ? (
<Button linkTo={resumeDumpUrl}>Download Resumes</Button>
) : (
<Spinner />
))}
<CSVLink style={{ margin: '20px' }} data={sortedData} filename="exportedData.csv">
Export
</CSVLink>
Expand Down
10 changes: 9 additions & 1 deletion src/client/routes/manage/SponsorHackerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/client/routes/manage/hackers.graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export default gql`
}
}
query resumeDumpUrl {
resumeDumpUrl
}
mutation hackerStatus($input: HackerStatusInput!) {
hackerStatus(input: $input) {
id
Expand Down
5 changes: 4 additions & 1 deletion src/common/constants.ts
Original file line number Diff line number Diff line change
@@ -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();
1 change: 1 addition & 0 deletions src/common/schema.graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
10 changes: 9 additions & 1 deletion src/server/resolvers/QueryResolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -34,6 +34,14 @@ export const Query: QueryResolvers<Context> = {

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,
Expand Down
33 changes: 33 additions & 0 deletions src/server/storage/gcp.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -14,6 +17,9 @@ export const getSignedUploadUrl = async (filename: string): Promise<string> => {
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',
Expand Down Expand Up @@ -43,3 +49,30 @@ export const getSignedReadUrl = async (filename: string): Promise<string> => {

return url;
};

export const getResumeDumpUrl = async (): Promise<string> => {
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);
};

0 comments on commit cff43f1

Please sign in to comment.