diff --git a/.env.example b/.env.example index 229b17d..ea9c8a3 100644 --- a/.env.example +++ b/.env.example @@ -11,13 +11,18 @@ SECRET= SERVER_URL= PORT= +# Upload Settings +MAX_FILE_SIZE= +MAX_FILES= + # Banana Mailer Settings BANANA_SERVICE= BANANA_EMAIL= BANANA_PASS= # Google Service Settings -GOOGLE_CREDS= +GOOGLE_PRIVATE_KEY= +GOOGLE_CLIENT_EMAIL= GOOGLE_FOLDER_ID= # Bcrypt Settings diff --git a/package.json b/package.json index e98071d..8f1d5da 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,15 @@ "main": "index.js", "license": "MIT", "dependencies": { - "apollo-server-express": "^2.1.0", + "apollo-server-express": "^2.3.1", "axios": "^0.18.0", - "backpack-core": "^0.7.0", + "backpack-core": "^0.8.3", "banana-mail": "^1.0.0", "bcrypt": "^3.0.2", "dotenv": "^6.1.0", - "eslint-config-airbnb": "^17.1.0", "express": "^4.16.4", "express-jwt": "^5.3.1", + "googleapis": "^35.0.0", "graphql": "^14.0.2", "jsonwebtoken": "^8.3.0", "mongoose": "^5.3.13" @@ -26,6 +26,7 @@ }, "devDependencies": { "eslint": "^5.8.0", + "eslint-config-airbnb": "^17.1.0", "eslint-plugin-import": "^2.14.0", "eslint-plugin-jsx-a11y": "^6.1.2", "eslint-plugin-react": "^7.11.1" diff --git a/src/database/index.js b/src/database/index.js index 6ed076a..1e94650 100644 --- a/src/database/index.js +++ b/src/database/index.js @@ -7,6 +7,8 @@ const { DB_USER, DB_PASSWORD, DB_HOST, DB_NAME, } = process.env; +const mongoURL = `${DB_HOST}/${DB_NAME}`; + const options = { user: DB_USER, pass: DB_PASSWORD, @@ -14,14 +16,12 @@ const options = { useCreateIndex: true, }; -const db = () => Promise.resolve( - mongoose.connect( - `${DB_HOST}/${DB_NAME}`, - options, - ), +const initMongoose = async () => mongoose.connect( + mongoURL, + options, ); -db() - .then(() => console.log('> DB Connected')) - .catch(e => console.log(e.message)); +initMongoose() + .then(() => console.log(`> Connected to Mongo instance at ${mongoURL}`)) + .catch(err => console.error('Error connecting to Mongo instance:', err)); diff --git a/src/graphql/apollo.js b/src/graphql/apollo.js new file mode 100644 index 0000000..d57dfd7 --- /dev/null +++ b/src/graphql/apollo.js @@ -0,0 +1,20 @@ +import { ApolloServer } from 'apollo-server-express'; + +import typeDefs from './schema.gql'; +import resolvers from './resolvers'; + +const apollo = new ApolloServer({ + typeDefs, + resolvers, + formatError(err) { + console.error(err); + const { message, extensions: { code } } = err; + return { message, extensions: { code } }; + }, + context(ctx) { + const { req } = ctx; + return req.user; + }, +}); + +export default apollo; diff --git a/src/graphql/resolvers/index.js b/src/graphql/resolvers/index.js index b2206dd..da934c6 100644 --- a/src/graphql/resolvers/index.js +++ b/src/graphql/resolvers/index.js @@ -1,8 +1,8 @@ +import user from './queries/user'; + import { signUp, logIn, verify } from './mutations/user'; import { updateApplication, submitApplication } from './mutations/application'; -import user from './queries/user'; - const resolvers = { Query: { user, diff --git a/src/graphql/resolvers/mutations/application.js b/src/graphql/resolvers/mutations/application.js index 4e61c15..e7efcea 100644 --- a/src/graphql/resolvers/mutations/application.js +++ b/src/graphql/resolvers/mutations/application.js @@ -1,4 +1,3 @@ -import userService from '../../../services/user'; import applicationService from '../../../services/application'; const updateApplication = async (root, args, context) => { @@ -14,8 +13,7 @@ const updateApplication = async (root, args, context) => { const submitApplication = async (root, args, context) => { try { const { id } = context; - const application = await applicationService.update(id, args); - await userService.updateStatus(id, 'SUBMITTED'); + const application = await applicationService.submit(id, args); return application; } catch (err) { throw err; diff --git a/src/graphql/resolvers/queries/user.js b/src/graphql/resolvers/queries/user.js index 6fb4e01..990d97e 100644 --- a/src/graphql/resolvers/queries/user.js +++ b/src/graphql/resolvers/queries/user.js @@ -1,13 +1,9 @@ -import { AuthenticationError } from 'apollo-server-express'; -import User from '../../../models'; +import userService from '../../../services/user'; const user = async (root, args, context) => { try { const { id, level } = context; - if (level !== 'ADMIN' && id.toString() !== args.id) { - throw new AuthenticationError('Not allowed to fetch this user'); - } - const requestedUser = await User.findById(args.id); + const requestedUser = await userService.find(id, level, args.id); return requestedUser; } catch (err) { throw err; diff --git a/src/graphql/schema.gql b/src/graphql/schema.gql index 1eefbb7..be444a3 100644 --- a/src/graphql/schema.gql +++ b/src/graphql/schema.gql @@ -12,14 +12,21 @@ type Team { members: [User!] } +type File { + name: String! + path: String! +} + type Application { id: ID! firstName: String lastName: String levelOfStudy: LevelOfStudy + school: String major: String shirtSize: ShirtSize gender: Gender + resume: File } type Query { @@ -40,17 +47,21 @@ type Mutation { firstName: String lastName: String levelOfStudy: LevelOfStudy + school: String major: String shirtSize: ShirtSize gender: Gender + resume: Upload ): Application! submitApplication( firstName: String! lastName: String! levelOfStudy: LevelOfStudy! + school: String! major: String! shirtSize: ShirtSize! gender: Gender! + resume: Upload ): Application! } @@ -84,6 +95,8 @@ enum LevelOfStudy { JUNIOR SENIOR SENIORPLUS + GRADUATE + OTHER } enum Gender { diff --git a/src/index.js b/src/index.js index ced0c0d..756405c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,33 +1,17 @@ import './env'; +import './database'; import express from 'express'; -import jwt from 'express-jwt'; -import { ApolloServer } from 'apollo-server-express'; +import expressJWT from 'express-jwt'; -import typeDefs from './graphql/schema.gql'; -import resolvers from './graphql/resolvers'; -import './database'; +import apollo from './graphql/apollo'; const { PORT, SECRET, SERVER_URL } = process.env; const app = express(); -const server = new ApolloServer({ - typeDefs, - resolvers, - formatError(err) { - console.error(err); - const { message, extensions: { code } } = err; - return { message, extensions: { code } }; - }, - context(ctx) { - const { req } = ctx; - return req.user; - }, -}); - -app.use(jwt({ secret: SECRET, credentialsRequired: false })); +app.use(expressJWT({ secret: SECRET, credentialsRequired: false })); -server.applyMiddleware({ app }); +apollo.applyMiddleware({ app }); -app.listen({ port: PORT }, () => console.log(`🍑 Server up on ${SERVER_URL}:${PORT}${server.graphqlPath}`)); +app.listen({ port: PORT }, () => console.log(`🍑 Server up on ${SERVER_URL}:${PORT}${apollo.graphqlPath}`)); diff --git a/src/models/application.js b/src/models/application.js index 6cadcd0..a327d69 100644 --- a/src/models/application.js +++ b/src/models/application.js @@ -1,10 +1,12 @@ -const ApplicationSchema = Mongoose => new Mongoose.Schema({ +const ApplicationSchema = (mongoose, Resume) => new mongoose.Schema({ firstName: String, lastName: String, levelOfStudy: String, major: String, shirtSize: String, gender: String, + school: String, + resume: Resume, }); export default ApplicationSchema; diff --git a/src/models/index.js b/src/models/index.js index b27ed5d..b9c58e9 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -1,8 +1,12 @@ import mongoose from 'mongoose'; + +import ResumeSchema from './resume'; import ApplicationSchema from './application'; -import UserSchema from './user'; -const Application = ApplicationSchema(mongoose); -const User = UserSchema(mongoose, Application); +import UserModel from './user'; + +const Resume = ResumeSchema(mongoose); +const Application = ApplicationSchema(mongoose, Resume); +const User = UserModel(mongoose, Application); export default User; diff --git a/src/models/resume.js b/src/models/resume.js new file mode 100644 index 0000000..a88b5c2 --- /dev/null +++ b/src/models/resume.js @@ -0,0 +1,6 @@ +const ResumeSchema = Mongoose => new Mongoose.Schema({ + name: String, + path: String, +}); + +export default ResumeSchema; diff --git a/src/models/user.js b/src/models/user.js index 1a033f7..ddc5c3f 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -1,9 +1,9 @@ -const UserSchema = (Mongoose, application) => Mongoose.model('users', new Mongoose.Schema({ +const UserModel = (Mongoose, Application) => Mongoose.model('users', new Mongoose.Schema({ email: { type: String, required: true, unique: true }, password: { type: String, required: true }, level: { type: String, required: true }, status: { type: String, required: true }, - application, + application: Application, })); -export default UserSchema; +export default UserModel; diff --git a/src/services/application.js b/src/services/application.js index a36ca62..1a74fa7 100644 --- a/src/services/application.js +++ b/src/services/application.js @@ -1,17 +1,21 @@ -import { ForbiddenError } from 'apollo-server-express'; +import { ForbiddenError, ValidationError } from 'apollo-server-express'; + import User from '../models'; +import userService from './user'; +import driveService from './drive'; + /** * Updates the given user's application with the given arguments. - * @param {number} userId The user's ID. + * @param {string} userId The user's ID. * @param {Object} args The arguments with which to update the application. */ -const update = async (userId, options) => { +const update = async (userId, args) => { try { if (!userId) { throw new ForbiddenError('User is not logged in.'); } - const { status, level } = await User.findById(userId); + const { email, status, level } = await User.findById(userId); if (level !== 'HACKER') { throw new ForbiddenError('User is not a HACKER.'); } @@ -19,7 +23,30 @@ const update = async (userId, options) => { throw new ForbiddenError('User has already submitted an application.'); } - const user = await User.findByIdAndUpdate(userId, { application: options }, { new: true }); + const { + firstName, lastName, levelOfStudy, gender, major, shirtSize, resume, school, + } = args; + + let path; + let name; + if (resume) { + const { filename, mimetype, createReadStream } = await resume; + const stream = createReadStream(); + name = filename; + path = await driveService.upload({ filename: email, mimetype, stream }); + } + const user = await User.findByIdAndUpdate(userId, { + application: { + firstName, + lastName, + levelOfStudy, + gender, + major, + shirtSize, + school, + resume: path ? { name, path } : null, + }, + }, { new: true }); const { application } = user; return application; } catch (err) { @@ -27,4 +54,31 @@ const update = async (userId, options) => { } }; -export default { update }; +/** + * Returns whether or not an application is valid, i.e. completed. + * @param {Object} application The application to validate. + */ +const validate = application => ( + Object.keys(application).reduce((valid, key) => application[key] != null && valid, true) +); + +/** + * Submits a user's completed application. + * @param {string} userId The user's ID. + * @param {Object} args The arguments with which to submit the application. + */ +const submit = async (userId, args) => { + try { + const application = await update(userId, args); + const valid = validate(application); + if (!valid) { + throw new ValidationError('Application incomplete.'); + } + await userService.updateStatus(userId, 'SUBMITTED'); + return application; + } catch (err) { + throw err; + } +}; + +export default { update, submit }; diff --git a/src/services/drive.js b/src/services/drive.js new file mode 100644 index 0000000..4ccf0f3 --- /dev/null +++ b/src/services/drive.js @@ -0,0 +1,51 @@ +import { google } from 'googleapis'; + +const { GOOGLE_CLIENT_EMAIL, GOOGLE_PRIVATE_KEY, GOOGLE_FOLDER_ID } = process.env; + +const driveScope = 'https://www.googleapis.com/auth/drive'; +const createFieldSelector = 'webViewLink'; + +const auth = new google.auth.JWT(GOOGLE_CLIENT_EMAIL, null, GOOGLE_PRIVATE_KEY, [driveScope]); + +const drive = google.drive('v3'); + + +/** + * Deletes files having the given fields. + * @param {Object} params + */ +const deleteFiles = async (params) => { + const { filename } = params; + try { + await auth.authorize(); + const ids = drive.files.list({ auth, q: `name = ${filename}` }); + console.log(ids); + } catch (err) { + throw err; + } +}; + +/** + * Uploads a given file to Google Drive. + * @param {Object} file The file to be uploaded. + * @param {string} file.filename The name of the file. + * @param {string} file.mimetype The mimetype of the file. + * @param {ReadStream} file.stream The readStream body. + */ +const upload = async (file) => { + const { filename, mimetype, stream } = file; + const media = { mimeType: mimetype, body: stream }; + const parents = [GOOGLE_FOLDER_ID]; + try { + await auth.authorize(); + await deleteFiles({ filename }); + const { data: { webViewLink } } = await drive.files.create({ + auth, media, requestBody: { name: filename, parents }, fields: createFieldSelector, + }); + return webViewLink; + } catch (err) { + throw err; + } +}; + +export default { upload }; diff --git a/src/services/user.js b/src/services/user.js index 66909e1..3e84640 100644 --- a/src/services/user.js +++ b/src/services/user.js @@ -96,6 +96,25 @@ const verify = async (verificationToken) => { } }; +/** + * Finds the user with the given userId if the requester has appropriate permissions. + * @param {*} requesterId The ID of the user requesting the information. + * @param {*} requesterLevel The level of the user requesting the information. + * @param {*} userId The ID of the user whose information is being requested. + */ +const find = async (requesterId, requesterLevel, userId) => { + try { + const isAuthorized = requesterLevel === 'ADMIN' || userId === requesterId; + if (!requesterId || !isAuthorized) { + throw new AuthenticationError('Not allowed to fetch this user'); + } + const user = await User.findById(userId); + return user; + } catch (err) { + throw err; + } +}; + export default { - updateStatus, signUp, logIn, verify, + updateStatus, signUp, logIn, verify, find, }; diff --git a/test.png b/test.png new file mode 100644 index 0000000..7e0e2e2 Binary files /dev/null and b/test.png differ