From 2b81310b9834610dea2c799510017d3b2347df96 Mon Sep 17 00:00:00 2001 From: Juuso Lappalainen Date: Fri, 20 Sep 2019 09:56:27 +0300 Subject: [PATCH 01/21] Add filter for rated registrations --- .../AssignAttendeesPage.js | 22 +++++++++++++++---- .../AssignAttendeesPage.module.scss | 11 ++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventReview/AssignAttendeesPage.js b/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventReview/AssignAttendeesPage.js index 0c1b7152a..a4b30e9b7 100644 --- a/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventReview/AssignAttendeesPage.js +++ b/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventReview/AssignAttendeesPage.js @@ -1,7 +1,7 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import styles from './AssignAttendeesPage.module.scss'; -import { Button as AntButton, Modal, message } from 'antd'; +import { Button as AntButton, Modal, message, Switch } from 'antd'; import { connect } from 'react-redux'; import Divider from 'components/generic/Divider'; @@ -15,6 +15,7 @@ import RegistrationsService from 'services/registrations'; import BulkEditRegistrationDrawer from 'components/modals/BulkEditRegistrationDrawer'; const SearchAttendeesPage = ({ idToken, event, registrations = [], registrationsLoading, updateRegistrations }) => { + const [hideRated, setHideRated] = useState(false); const { slug } = event; const handleSelfAssign = () => { Modal.confirm({ @@ -42,11 +43,24 @@ const SearchAttendeesPage = ({ idToken, event, registrations = [], registrations }); }; + const filtered = useMemo(() => { + return registrations.filter(registration => { + if (hideRated) { + if (registration.rating) return false; + } + return true; + }); + }, [registrations, hideRated]); + return (
- {registrations.length} registrations + {filtered.length} registrations
+
+ Hide rated registrations + +
Assign random registrations @@ -54,7 +68,7 @@ const SearchAttendeesPage = ({ idToken, event, registrations = [], registrations r._id)} />
- +
); diff --git a/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventReview/AssignAttendeesPage.module.scss b/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventReview/AssignAttendeesPage.module.scss index 14e6227f4..df0249912 100644 --- a/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventReview/AssignAttendeesPage.module.scss +++ b/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventReview/AssignAttendeesPage.module.scss @@ -15,3 +15,14 @@ flex-wrap: wrap; justify-content: flex-end; } + +.toggle { + display: flex; + flex-direction: row; + align-items: center; + padding: 0 0.5rem; +} + +.toggleText { + padding-right: 1rem; +} From 8867ca32aabbc852311f333b5931f7f659aa52c6 Mon Sep 17 00:00:00 2001 From: Juuso Lappalainen Date: Fri, 20 Sep 2019 10:43:37 +0300 Subject: [PATCH 02/21] Improve stats page --- .../src/components/plots/ApplicationsCount.js | 15 +++ .../components/plots/ApplicationsLast24h.js | 15 +++ .../components/plots/ApplicationsOverTime.js | 8 +- frontend/src/components/plots/RatingsSplit.js | 9 +- .../src/components/plots/ReviewedAverage.js | 15 +++ .../src/components/plots/ReviewedPercent.js | 15 +++ .../src/components/plots/ReviewersList.js | 36 +++-- frontend/src/components/plots/TeamsCount.js | 15 +++ .../OrganiserEditEventStats/index.js | 125 +++++------------- frontend/src/redux/organiser/selectors.js | 96 ++++++++++++++ 10 files changed, 246 insertions(+), 103 deletions(-) create mode 100644 frontend/src/components/plots/ApplicationsCount.js create mode 100644 frontend/src/components/plots/ApplicationsLast24h.js create mode 100644 frontend/src/components/plots/ReviewedAverage.js create mode 100644 frontend/src/components/plots/ReviewedPercent.js create mode 100644 frontend/src/components/plots/TeamsCount.js diff --git a/frontend/src/components/plots/ApplicationsCount.js b/frontend/src/components/plots/ApplicationsCount.js new file mode 100644 index 000000000..a4b681a32 --- /dev/null +++ b/frontend/src/components/plots/ApplicationsCount.js @@ -0,0 +1,15 @@ +import React from 'react'; + +import { Statistic } from 'antd'; +import { connect } from 'react-redux'; +import * as OrganiserSelectors from 'redux/organiser/selectors'; + +const ApplicationsCount = ({ value }) => { + return ; +}; + +const mapState = state => ({ + value: OrganiserSelectors.registrationsCount(state) +}); + +export default connect(mapState)(ApplicationsCount); diff --git a/frontend/src/components/plots/ApplicationsLast24h.js b/frontend/src/components/plots/ApplicationsLast24h.js new file mode 100644 index 000000000..b11c0c566 --- /dev/null +++ b/frontend/src/components/plots/ApplicationsLast24h.js @@ -0,0 +1,15 @@ +import React from 'react'; + +import { Statistic } from 'antd'; +import { connect } from 'react-redux'; +import * as OrganiserSelectors from 'redux/organiser/selectors'; + +const ApplicationsLast24h = ({ value }) => { + return ; +}; + +const mapState = state => ({ + value: OrganiserSelectors.registrationsLast24h(state) +}); + +export default connect(mapState)(ApplicationsLast24h); diff --git a/frontend/src/components/plots/ApplicationsOverTime.js b/frontend/src/components/plots/ApplicationsOverTime.js index 58e00f78b..bbb3dfe54 100644 --- a/frontend/src/components/plots/ApplicationsOverTime.js +++ b/frontend/src/components/plots/ApplicationsOverTime.js @@ -1,7 +1,9 @@ import React, { useMemo } from 'react'; import { sortBy } from 'lodash-es'; +import { connect } from 'react-redux'; import { ResponsiveContainer, BarChart, XAxis, YAxis, Tooltip, Legend, CartesianGrid, Bar } from 'recharts'; +import * as OrganiserSelectors from 'redux/organiser/selectors'; const ApplicationsOverTime = ({ data }) => { const formattedData = useMemo(() => { @@ -39,4 +41,8 @@ const ApplicationsOverTime = ({ data }) => { ); }; -export default ApplicationsOverTime; +const mapState = state => ({ + data: OrganiserSelectors.registrationsByDay(state) +}); + +export default connect(mapState)(ApplicationsOverTime); diff --git a/frontend/src/components/plots/RatingsSplit.js b/frontend/src/components/plots/RatingsSplit.js index efa98e3ac..43fc69e96 100644 --- a/frontend/src/components/plots/RatingsSplit.js +++ b/frontend/src/components/plots/RatingsSplit.js @@ -1,5 +1,8 @@ import React, { useMemo } from 'react'; import { sortBy } from 'lodash-es'; +import { connect } from 'react-redux'; + +import * as OrganiserSelectors from 'redux/organiser/selectors'; import { ResponsiveContainer, BarChart, XAxis, YAxis, Tooltip, Legend, CartesianGrid, Bar } from 'recharts'; @@ -39,4 +42,8 @@ const RatingsSplit = ({ data }) => { ); }; -export default RatingsSplit; +const mapState = state => ({ + data: OrganiserSelectors.registrationsByRating(state) +}); + +export default connect(mapState)(RatingsSplit); diff --git a/frontend/src/components/plots/ReviewedAverage.js b/frontend/src/components/plots/ReviewedAverage.js new file mode 100644 index 000000000..15ebe312a --- /dev/null +++ b/frontend/src/components/plots/ReviewedAverage.js @@ -0,0 +1,15 @@ +import React from 'react'; + +import { Statistic, Icon } from 'antd'; +import { connect } from 'react-redux'; +import * as OrganiserSelectors from 'redux/organiser/selectors'; + +const ReviewedAverage = ({ value }) => { + return } />; +}; + +const mapState = state => ({ + value: OrganiserSelectors.averageRating(state) +}); + +export default connect(mapState)(ReviewedAverage); diff --git a/frontend/src/components/plots/ReviewedPercent.js b/frontend/src/components/plots/ReviewedPercent.js new file mode 100644 index 000000000..ec9e87db8 --- /dev/null +++ b/frontend/src/components/plots/ReviewedPercent.js @@ -0,0 +1,15 @@ +import React from 'react'; + +import { Statistic } from 'antd'; +import { connect } from 'react-redux'; +import * as OrganiserSelectors from 'redux/organiser/selectors'; + +const ReviewedPercent = ({ value }) => { + return ; +}; + +const mapState = state => ({ + value: OrganiserSelectors.percentReviewed(state) +}); + +export default connect(mapState)(ReviewedPercent); diff --git a/frontend/src/components/plots/ReviewersList.js b/frontend/src/components/plots/ReviewersList.js index e864a4e8b..72fc35ada 100644 --- a/frontend/src/components/plots/ReviewersList.js +++ b/frontend/src/components/plots/ReviewersList.js @@ -1,17 +1,27 @@ import React, { useMemo } from 'react'; import { List, Avatar } from 'antd'; -import { map, sortBy } from 'lodash-es'; +import { sortBy } from 'lodash-es'; +import { connect } from 'react-redux'; -const ReviewersList = ({ data, userProfilesMap = {} }) => { +import * as OrganiserSelectors from 'redux/organiser/selectors'; + +const ReviewersList = ({ data, userProfilesMap = {}, averagesMap = {} }) => { const formattedData = useMemo(() => { - const asArray = map(Object.keys(data), userId => ({ - user: userProfilesMap[userId] || {}, - ratings: data[userId] - })); + const arr = []; + Object.keys(data).forEach(userId => { + const user = userProfilesMap[userId]; + if (user) { + arr.push({ + user, + ratings: data[userId], + average: averagesMap[userId] + }); + } + }); - return sortBy(asArray, 'ratings'); - }, [data, userProfilesMap]); + return sortBy(arr, item => item.ratings * -1); + }, [data, userProfilesMap, averagesMap]); return ( { } title={`${item.user.firstName} ${item.user.lastName}`} - description={`${item.ratings} applications reviewed`} + description={`${item.ratings} reviews / ${item.average} average rating`} /> )} @@ -30,4 +40,10 @@ const ReviewersList = ({ data, userProfilesMap = {} }) => { ); }; -export default ReviewersList; +const mapState = state => ({ + data: OrganiserSelectors.registrationsByReviewer(state), + userProfilesMap: OrganiserSelectors.organisersMap(state), + averagesMap: OrganiserSelectors.reviewAverageByReviewer(state) +}); + +export default connect(mapState)(ReviewersList); diff --git a/frontend/src/components/plots/TeamsCount.js b/frontend/src/components/plots/TeamsCount.js new file mode 100644 index 000000000..d0f105de8 --- /dev/null +++ b/frontend/src/components/plots/TeamsCount.js @@ -0,0 +1,15 @@ +import React from 'react'; + +import { Statistic } from 'antd'; +import { connect } from 'react-redux'; +import * as OrganiserSelectors from 'redux/organiser/selectors'; + +const TeamsCount = ({ value }) => { + return ; +}; + +const mapState = state => ({ + value: OrganiserSelectors.teamsCount(state) +}); + +export default connect(mapState)(TeamsCount); diff --git a/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventStats/index.js b/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventStats/index.js index 606854993..750dd16d6 100644 --- a/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventStats/index.js +++ b/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventStats/index.js @@ -1,138 +1,79 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect } from 'react'; import styles from './OrganiserEditEventStats.module.scss'; -import { isEmpty } from 'lodash-es'; import { connect } from 'react-redux'; -import moment from 'moment'; -import { PageHeader, Row, Col, Statistic, Card, List, Empty, Icon, Rate } from 'antd'; +import { PageHeader, Row, Col, Card } from 'antd'; -import Divider from 'components/generic/Divider'; -import Button from 'components/generic/Button'; -import PageWrapper from 'components/PageWrapper'; import * as OrganiserSelectors from 'redux/organiser/selectors'; import * as OrganiserActions from 'redux/organiser/actions'; + +import Divider from 'components/generic/Divider'; +import PageWrapper from 'components/PageWrapper'; import ApplicationsOverTime from 'components/plots/ApplicationsOverTime'; import RatingsSplit from 'components/plots/RatingsSplit'; import ReviewersList from 'components/plots/ReviewersList'; -const OrganiserEditEventStats = ({ stats, statsLoading, slug, updateEventStats, organisersMap }) => { - const updateStats = useCallback(() => { - updateEventStats(slug); - }, [slug, updateEventStats]); +import ApplicationsCount from 'components/plots/ApplicationsCount'; +import TeamsCount from 'components/plots/TeamsCount'; +import ReviewedPercent from 'components/plots/ReviewedPercent'; +import ReviewedAverage from 'components/plots/ReviewedAverage'; +import ApplicationsLast24h from 'components/plots/ApplicationsLast24h'; - console.log(stats); +const OrganiserEditEventStats = ({ slug, loading, updateRegistrations, updateTeams }) => { + useEffect(() => { + updateRegistrations(slug); + updateTeams(slug); + }, [slug, updateRegistrations, updateTeams]); const renderContent = () => { - if (isEmpty(stats)) { - return ( - -
-

No data

-
- - } - /> - ); - } - return ( - + - + - + - } - /> + - + - + - + - - - - - - - ( - - - - )} - /> - - - - - - ( - - - - )} - /> + @@ -140,23 +81,25 @@ const OrganiserEditEventStats = ({ stats, statsLoading, slug, updateEventStats, }; return ( - + Key stats for the event

} footer={renderContent()} />
); }; -const mapStateToProps = state => ({ - stats: OrganiserSelectors.stats(state), - statsLoading: OrganiserSelectors.statsLoading(state), - organisersMap: OrganiserSelectors.organisersMap(state) +const mapState = state => ({ + loading: + OrganiserSelectors.registrationsLoading(state) || + OrganiserSelectors.teamsLoading(state) || + OrganiserSelectors.organisersLoading(state) }); -const mapDispatchToProps = dispatch => ({ - updateEventStats: slug => dispatch(OrganiserActions.updateEventStats(slug)) +const mapDispatch = dispatch => ({ + updateRegistrations: slug => dispatch(OrganiserActions.updateRegistrationsForEvent(slug)), + updateTeams: slug => dispatch(OrganiserActions.updateTeamsForEvent(slug)) }); export default connect( - mapStateToProps, - mapDispatchToProps + mapState, + mapDispatch )(OrganiserEditEventStats); diff --git a/frontend/src/redux/organiser/selectors.js b/frontend/src/redux/organiser/selectors.js index 02c9dacc2..706f47683 100644 --- a/frontend/src/redux/organiser/selectors.js +++ b/frontend/src/redux/organiser/selectors.js @@ -1,6 +1,8 @@ import { createSelector } from 'reselect'; +import { meanBy, countBy, groupBy, mapValues } from 'lodash-es'; import * as FilterUtils from 'utils/filters'; import * as AuthSelectors from 'redux/auth/selectors'; +import moment from 'moment'; export const event = state => state.organiser.event.data; export const eventLoading = state => state.organiser.event.loading; @@ -43,6 +45,15 @@ export const registrationsAssigned = createSelector( } ); +export const registrationsReviewed = createSelector( + registrations, + registrations => { + return registrations.filter(registration => { + return !!registration.rating; + }); + } +); + export const teams = state => state.organiser.teams.data; export const teamsLoading = state => state.organiser.teams.loading; export const teamsError = state => state.organiser.teams.error; @@ -61,3 +72,88 @@ export const teamsPopulated = createSelector( }); } ); + +/** Stats selectors */ +export const registrationsCount = createSelector( + registrations, + registrations => registrations.length +); + +export const teamsCount = createSelector( + teams, + teams => teams.length +); + +export const percentReviewed = createSelector( + registrations, + registrations => { + const { reviewed, total } = registrations.reduce( + (res, registration) => { + res.total += 1; + if (registration.rating) { + res.reviewed += 1; + } + return res; + }, + { + reviewed: 0, + total: 0 + } + ); + return (reviewed * 100) / total; + } +); + +export const averageRating = createSelector( + registrationsReviewed, + registrations => { + return meanBy(registrations, 'rating'); + } +); + +export const registrationsLast24h = createSelector( + registrations, + registrations => { + return registrations.filter(registration => { + return registration.createdAt > Date.now() - 1000 * 60 * 60 * 24; + }).length; + } +); + +export const registrationsByDay = createSelector( + registrations, + registrations => { + return countBy(registrations, r => moment(r.createdAt).format('YYYY-MM-DD')); + } +); + +export const registrationsByRating = createSelector( + registrationsReviewed, + registrations => { + return countBy(registrations, 'rating'); + } +); + +export const registrationsByReviewer = createSelector( + registrationsReviewed, + registrations => { + return countBy(registrations, 'ratedBy'); + } +); + +export const registrationsBySecretCode = createSelector( + registrations, + registrations => { + return countBy(registrations, 'answers.secretCode'); + } +); + +export const reviewAverageByReviewer = createSelector( + registrationsReviewed, + registrations => { + const grouped = groupBy(registrations, 'ratedBy'); + return mapValues(grouped, registrations => { + return meanBy(registrations, 'rating'); + }); + } +); From 2a01edf80dbcb0c904c603f7e48ee6249bd6aa24 Mon Sep 17 00:00:00 2001 From: Juuso Lappalainen Date: Fri, 20 Sep 2019 10:59:09 +0300 Subject: [PATCH 03/21] add rounding to avg rating --- frontend/src/components/plots/ReviewersList.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/plots/ReviewersList.js b/frontend/src/components/plots/ReviewersList.js index 72fc35ada..36b582807 100644 --- a/frontend/src/components/plots/ReviewersList.js +++ b/frontend/src/components/plots/ReviewersList.js @@ -32,7 +32,7 @@ const ReviewersList = ({ data, userProfilesMap = {}, averagesMap = {} }) => { } title={`${item.user.firstName} ${item.user.lastName}`} - description={`${item.ratings} reviews / ${item.average} average rating`} + description={`${item.ratings} reviews / ${item.average.toFixed(2)} average rating`} /> )} From 713c89721773585213979c121fdbab53d4adb142 Mon Sep 17 00:00:00 2001 From: Juuso Lappalainen Date: Fri, 20 Sep 2019 11:03:08 +0300 Subject: [PATCH 04/21] Fix self assign sorting --- backend/modules/registration/controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/modules/registration/controller.js b/backend/modules/registration/controller.js index c7ff08e54..b3de96b7a 100644 --- a/backend/modules/registration/controller.js +++ b/backend/modules/registration/controller.js @@ -75,7 +75,7 @@ controller.selfAssignRegistrationsForEvent = (eventId, userId) => { rating: null, assignedTo: null }) - .sort([['createdAt', -1]]) + .sort({ createdAt: 'asc' }) .limit(10) .then(registrations => { const updates = registrations.map(reg => { From eee1469cacc6cd979b4b44756dfdb4d239ddde52 Mon Sep 17 00:00:00 2001 From: Juuso Lappalainen Date: Fri, 20 Sep 2019 12:06:14 +0300 Subject: [PATCH 05/21] download size optimisation --- backend/modules/registration/controller.js | 38 +++++++++++++--------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/backend/modules/registration/controller.js b/backend/modules/registration/controller.js index b3de96b7a..e75ac85dc 100644 --- a/backend/modules/registration/controller.js +++ b/backend/modules/registration/controller.js @@ -47,22 +47,28 @@ controller.updateRegistration = (user, event, data) => { }; controller.getRegistrationsForEvent = eventId => { - return Registration.find( - { - event: eventId - } - // { - // user: 1, - // rating: 1, - // ratedBy: 1, - // assignedTo: 1, - // tags: 1, - // status: 1, - // 'answers.email': 1, - // 'answers.firstName': 1, - // 'answers.lastName': 1 - // } - ); + return Registration.find({ + event: eventId + }).then(registrations => { + /** Do some minor optimisation here to cut down on size */ + return registrations.map(reg => { + reg.answers = _.mapValues(reg.answers, answer => { + if (typeof answer === 'string' && answer.length > 50) { + return answer.slice(0, 10) + '...'; + } + if (typeof answer === 'object' && Object.keys(answer).length > 0) { + return _.mapValues(answer, subAnswer => { + if (typeof subAnswer === 'string' && subAnswer.length > 50) { + return subAnswer.slice(0, 10); + } + return subAnswer; + }); + } + return answer; + }); + return reg; + }); + }); }; controller.searchRegistrationsForEvent = (eventId, userId, params) => { From f63a12bbb8b5fc74a6435b319ef32ccd467c8a6d Mon Sep 17 00:00:00 2001 From: Juuso Lappalainen Date: Sat, 21 Sep 2019 18:52:49 +0300 Subject: [PATCH 06/21] Create email tasks on registration status changes --- .gitignore | 4 +- backend/misc/config.js | 7 +- backend/modules/devtools/routes.js | 35 ++++++ backend/modules/email-task/controller.js | 94 +++++++++----- backend/modules/email-task/model.js | 29 +++-- backend/modules/email-task/types.js | 3 +- backend/modules/event/routes.js | 15 --- backend/modules/registration/controller.js | 119 ++++-------------- backend/modules/registration/model.js | 34 ++++- backend/modules/registration/routes.js | 79 ++++-------- backend/modules/routes.js | 6 + .../OrganiserEditEventManage/index.js | 7 ++ .../OrganiserEditEventReview/AdminPage.js | 26 +++- frontend/src/services/events.js | 4 - frontend/src/services/registrations.js | 63 ++++++---- package.json | 6 +- 16 files changed, 284 insertions(+), 247 deletions(-) create mode 100644 backend/modules/devtools/routes.js diff --git a/.gitignore b/.gitignore index 61debd9ec..bfe0efc46 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ node_modules .DS_Store scripts/sync-production-to-local.sh -scripts/sync-staging-to-dev.sh -scripts/sync-staging-to-local.sh \ No newline at end of file +scripts/sync-production-to-dev.sh +scripts/sync-production-to-staging.sh \ No newline at end of file diff --git a/backend/misc/config.js b/backend/misc/config.js index baa5409b9..cb4eeb3ba 100644 --- a/backend/misc/config.js +++ b/backend/misc/config.js @@ -74,6 +74,10 @@ const settings = { ENVIRONMENT_TAG: { value: process.env.ENVIRONMENT_TAG, default: 'none' + }, + DEVTOOLS_ENABLED: { + value: process.env.DEVTOOLS_ENABLED === 'true' && process.env.NODE_ENV !== 'production', + default: false } }; @@ -81,7 +85,7 @@ const buildConfig = () => { const config = {}; _.forOwn(settings, (obj, key) => { if (!obj.value) { - if (obj.default) { + if (obj.default || obj.default === false) { config[key] = obj.default; } else { throw new Error( @@ -94,7 +98,6 @@ const buildConfig = () => { }); console.log('Running app with config', config); - console.log('Running app with env', process.env); return config; }; diff --git a/backend/modules/devtools/routes.js b/backend/modules/devtools/routes.js new file mode 100644 index 000000000..8b1dff554 --- /dev/null +++ b/backend/modules/devtools/routes.js @@ -0,0 +1,35 @@ +const express = require('express'); +const router = express.Router(); +const Registration = require('../registration/model'); + +router.route('/').get((req, res) => { + return res.status(200).send('DEVTOOLS HERE'); +}); + +router.route('/anonymize-registrations').get(async (req, res) => { + const registrations = await Registration.find({}); + + const updates = registrations.map(registration => { + return { + updateOne: { + filter: { + _id: registration._id + }, + update: { + $set: { + 'answers.firstName': 'Anonymous', + 'answers.lastName': 'Owl', + 'answers.email': + 'juuso.lappalainen+' + Math.floor(Math.random() * 1000000) + '@hackjunction.com' + } + } + } + }; + }); + + const result = await Registration.bulkWrite(updates); + + return res.status(200).json(result); +}); + +module.exports = router; diff --git a/backend/modules/email-task/controller.js b/backend/modules/email-task/controller.js index ff83fe1b8..1d7d4cc04 100644 --- a/backend/modules/email-task/controller.js +++ b/backend/modules/email-task/controller.js @@ -1,14 +1,24 @@ const EmailTask = require('./model'); const SendgridService = require('../../common/services/sendgrid'); const EmailTypes = require('./types'); +const EventController = require('../event/controller'); +const UserController = require('../user-profile/controller'); const controller = {}; -controller.createTask = (msg, taskParams) => { +controller.createTask = (userId, eventId, type, message, schedule) => { const task = new EmailTask({ - message: msg, - ...taskParams + user: userId, + event: eventId, + type: type }); + if (schedule) { + task.schedule = schedule; + } + + if (message) { + task.message = message; + } return task.save().catch(err => { if (err.code === 11000) { //The task already exists, so it's ok @@ -19,37 +29,61 @@ controller.createTask = (msg, taskParams) => { }); }; -controller.sendEmail = (msg, taskParams) => { - return SendgridService.send(msg).catch(message => { - /** If sending the email fails, save a task to the DB and we can retry later */ - return controller.createTask(message, taskParams); - }); +controller.createAcceptedTask = (userId, eventId) => { + return controller.createTask(userId, eventId, EmailTypes.registrationAccepted); }; -controller.sendAcceptanceEmail = (event, user) => { - const msgParams = { - event_name: event.name - }; - const msg = SendgridService.buildAcceptanceEmail(user.email, msgParams); - const taskParams = { - userId: user.userId, - eventId: event._id, - type: EmailTypes.registrationAccepted - }; - return controller.sendEmail(msg, taskParams); +controller.createRejectedTask = (userId, eventId) => { + return controller.createTask(userId, eventId, EmailTypes.registrationRejected); }; -controller.sendRejectionEmail = (event, user) => { - const msgParams = { - event_name: event.name - }; - const msg = SendgridService.buildRejectionEmail(user.email, msgParams); - const taskParams = { - userId: user.userId, - eventId: event._id, - type: EmailTypes.registrationRejected - }; - return controller.sendEmail(msg, taskParams); +controller.createRegisteredTask = (userId, eventId) => { + return controller.createTask(userId, eventId, EmailTypes.registrationReceived); }; +// controller.sendEmail = (msg, taskParams) => { +// return SendgridService.send(msg).catch(message => { +// /** If sending the email fails, save a task to the DB and we can retry later */ +// return controller.createTask(message, taskParams); +// }); +// }; + +// controller.sendAcceptanceEmail = async (eventId, userId) => { +// const event = await EventController.getEventById(eventId); +// const user = await UserController.getUserProfile(userId); + +// const msgParams = { +// event_name: event.name, +// event_logo: event.logo.url +// }; + +// const msg = SendgridService.buildAcceptanceEmail('juuso.lappalainen@hackjunction.com', msgParams); +// const taskParams = { +// userId: user.userId, +// eventId: event._id, +// type: EmailTypes.registrationAccepted +// }; +// return controller +// .sendEmail(msg, taskParams) +// .then(res => { +// console.log('SENT EMAIL', res); +// }) +// .catch(err => { +// console.log('ERR SENDING EMAIL', err); +// }); +// }; + +// controller.sendRejectionEmail = (event, user) => { +// const msgParams = { +// event_name: event.name +// }; +// const msg = SendgridService.buildRejectionEmail(user.email, msgParams); +// const taskParams = { +// userId: user.userId, +// eventId: event._id, +// type: EmailTypes.registrationRejected +// }; +// return controller.sendEmail(msg, taskParams); +// }; + module.exports = controller; diff --git a/backend/modules/email-task/model.js b/backend/modules/email-task/model.js index fd15ca675..7da3387fe 100644 --- a/backend/modules/email-task/model.js +++ b/backend/modules/email-task/model.js @@ -1,24 +1,37 @@ const mongoose = require('mongoose'); const EmailTaskSchema = new mongoose.Schema({ - message: mongoose.Mixed, - deliveredAt: { - type: Date + message: { + type: String, + default: null }, schedule: { type: Date, default: Date.now }, - eventId: String, - userId: String, - type: String + deliveredAt: { + type: Date, + default: null + }, + event: { + type: String, + required: true + }, + user: { + type: String, + required: true + }, + type: { + type: String, + required: true + } }); EmailTaskSchema.set('timestamps', true); EmailTaskSchema.index( { - eventId: 1, - userId: 1, + event: 1, + user: 1, type: 1 }, { diff --git a/backend/modules/email-task/types.js b/backend/modules/email-task/types.js index 6877e7b0f..4f8983f62 100644 --- a/backend/modules/email-task/types.js +++ b/backend/modules/email-task/types.js @@ -1,6 +1,7 @@ const EmailTypes = { registrationAccepted: 'registration-accepted', - registrationRejected: 'registration-rejected' + registrationRejected: 'registration-rejected', + registrationReceived: 'registration-received' }; module.exports = EmailTypes; diff --git a/backend/modules/event/routes.js b/backend/modules/event/routes.js index 2ce9ea0fd..c49a6a9e2 100644 --- a/backend/modules/event/routes.js +++ b/backend/modules/event/routes.js @@ -39,17 +39,6 @@ const updateEvent = asyncHandler(async (req, res) => { return res.status(200).json(updatedEvent); }); -const getEventStats = asyncHandler(async (req, res) => { - const eventId = req.event._id.toString(); - const registrationStats = await RegistrationController.getRegistrationStatsForEvent(eventId); - const teamStats = await TeamController.getTeamStatsForEvent(eventId); - - return res.status(200).json({ - ...registrationStats, - ...teamStats - }); -}); - const getEventAsOrganiser = asyncHandler(async (req, res) => { const event = await EventController.getEventBySlug(req.event.slug); return res.status(200).json(event); @@ -105,10 +94,6 @@ router .patch(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, updateEvent) .delete(hasToken, hasPermission(Auth.Permissions.DELETE_EVENT), isEventOwner, deleteEvent); -router - .route('/:slug/stats') - .get(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, getEventStats); - /** Get organisers for single event */ router.get('/organisers/:slug', hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOwner, getOrganisers); diff --git a/backend/modules/registration/controller.js b/backend/modules/registration/controller.js index e75ac85dc..e97b90bab 100644 --- a/backend/modules/registration/controller.js +++ b/backend/modules/registration/controller.js @@ -1,11 +1,10 @@ const _ = require('lodash'); -const moment = require('moment'); - +const Promise = require('bluebird'); +const { RegistrationStatuses } = require('@hackjunction/shared'); const Registration = require('./model'); const { NotFoundError } = require('../../common/errors/errors'); const UserProfileController = require('../user-profile/controller'); const RegistrationHelpers = require('./helpers'); -const { RegistrationStatuses } = require('@hackjunction/shared'); const controller = {}; @@ -71,11 +70,6 @@ controller.getRegistrationsForEvent = eventId => { }); }; -controller.searchRegistrationsForEvent = (eventId, userId, params) => { - const aggregationSteps = RegistrationHelpers.buildAggregation(eventId, userId, params); - return Registration.aggregate(aggregationSteps); -}; - controller.selfAssignRegistrationsForEvent = (eventId, userId) => { return Registration.find({ rating: null, @@ -84,24 +78,18 @@ controller.selfAssignRegistrationsForEvent = (eventId, userId) => { .sort({ createdAt: 'asc' }) .limit(10) .then(registrations => { - const updates = registrations.map(reg => { - return { - updateOne: { - filter: { - _id: reg._id - }, - update: { - assignedTo: userId - } + const registrationIds = registrations.map(r => r._id.toString()); + return Registration.updateMany( + { + event: eventId, + _id: { + $in: registrationIds } - }; - }); - - if (updates.length === 0) { - return 0; - } - - return Registration.bulkWrite(updates).then(data => { + }, + { + assignedTo: userId + } + ).then(data => { return data.nModified; }); }); @@ -137,14 +125,6 @@ controller.getFullRegistration = (eventId, registrationId) => { }); }; -controller.rateRegistration = (registrationId, event, user, rating) => { - return controller.getFullRegistration(event._id.toString(), registrationId).then(registration => { - registration.rating = rating; - registration.ratedBy = user.sub; - return registration.save(); - }); -}; - controller.editRegistration = (registrationId, event, data, user) => { return controller.getFullRegistration(event._id.toString(), registrationId).then(registration => { registration.status = data.status; @@ -156,73 +136,28 @@ controller.editRegistration = (registrationId, event, data, user) => { }); }; -controller.acceptRegistration = (registrationId, event) => { - return controller.getFullRegistration(event._id.toString(), registrationId).then(registration => { - registration.status = RegistrationStatuses.asObject.accepted.id; - return registration.save(); - }); -}; - -controller.rejectRegistration = (registrationId, event) => { - return controller.getFullRegistration(event._id.toString(), registrationId).then(registration => { - registration.status = RegistrationStatuses.asObject.rejected.id; - return registration.save(); - }); -}; - controller.getFullRegistrationsForEvent = eventId => { return Registration.find({ event: eventId }); }; -controller.getRegistrationStatsForEvent = async eventId => { - const registrations = await Registration.find({ event: eventId }, [ - 'answers.firstName', - 'answers.lastName', - 'answers.secretCode', - 'rating', - 'ratedBy', - 'createdAt' - ]); - - const registrationsByDay = _.countBy(registrations, r => moment(r.createdAt).format('YYYY-MM-DD')); - - const numRegistrations = registrations.length; - const numRegistrationsLastDay = registrations.filter(r => { - return Date.now() - r.createdAt < 24 * 60 * 60 * 1000; - }).length; - const reviewedRegistrations = registrations.filter(r => { - return !isNaN(r.rating); - }); - const registrationsByReviewer = _.countBy(reviewedRegistrations, r => r.ratedBy); - const numRegistrationsReviewed = reviewedRegistrations.length; - const registrationsLastFive = _.sortBy(registrations, r => r.createdAt).slice(-5); - const topSecretCodes = _.countBy(registrations, r => r.answers.secretCode); - const topSecretCodesArray = []; - _.forOwn(topSecretCodes, (count, code) => { - if (!_.isEmpty(code) && code !== 'undefined') { - topSecretCodesArray.push({ - code, - count - }); - } +controller.acceptSoftAccepted = async eventId => { + const users = await Registration.find({ event: eventId, status: RegistrationStatuses.asObject.accepted.id }); + const accepted = await Promise.each(users, user => { + user.status = RegistrationStatuses.asObject.softAccepted.id; + user.save(); }); + return accepted; +}; - const registrationsAvgRating = _.meanBy(reviewedRegistrations, 'rating'); - const registrationsSplit = _.countBy(reviewedRegistrations, 'rating'); - - return { - numRegistrations, - numRegistrationsLastDay, - numRegistrationsReviewed, - registrationsByDay, - registrationsByReviewer, - registrationsAvgRating, - registrationsLastFive, - registrationsSplit, - registrationsTopSecretCodes: _.sortBy(topSecretCodesArray, item => item.count * -1) - }; +controller.rejectSoftRejected = async eventId => { + const users = await Registration.find({ event: eventId, status: RegistrationStatuses.asObject.softRejected.id }); + const rejected = await Promise.each(users, user => { + user.status = RegistrationStatuses.asObject.rejected.id; + user.save(); + }); + return rejected; }; module.exports = controller; diff --git a/backend/modules/registration/model.js b/backend/modules/registration/model.js index e21f5b687..1be8601ce 100644 --- a/backend/modules/registration/model.js +++ b/backend/modules/registration/model.js @@ -1,7 +1,8 @@ const mongoose = require('mongoose'); const _ = require('lodash'); -const RegistrationStatuses = require('@hackjunction/shared'); +const { RegistrationStatuses } = require('@hackjunction/shared'); const updateAllowedPlugin = require('../../common/plugins/updateAllowed'); +const EmailTaskController = require('../email-task/controller'); const RegistrationSchema = new mongoose.Schema({ event: { @@ -14,7 +15,7 @@ const RegistrationSchema = new mongoose.Schema({ status: { type: String, enum: RegistrationStatuses.ids, - default: 'pending' + default: RegistrationStatuses.asObject.pending.id }, assignedTo: { type: String @@ -43,6 +44,35 @@ RegistrationSchema.plugin(updateAllowedPlugin, { blacklisted: ['__v', '_id', 'event', 'user', 'createdAt', 'updatedAt'] }); +RegistrationSchema.pre('save', function(next) { + this._wasNew = this.isNew; + this._previousStatus = this.status; + next(); +}); + +/** Trigger email sending on status changes etc. */ +RegistrationSchema.post('save', function(doc, next) { + const ACCEPTED = RegistrationStatuses.asObject.accepted.id; + const REJECTED = RegistrationStatuses.asObject.rejected.id; + + /** If a registration was just created, create an email notification about it */ + if (doc._wasNew) { + EmailTaskController.createRegisteredTask(doc.user, doc.event); + } + + /** If a registration is accepted, create an email notification about it */ + if (doc._previousStatus !== ACCEPTED && doc.status === ACCEPTED) { + EmailTaskController.createAcceptedTask(doc.user, doc.event); + } + + /** If a registration is rejected, create an email notification about it */ + if ( && doc._previousStatus !== REJECTED && doc.status === REJECTED) { + EmailTaskController.createRejectedTask(doc.user, doc.event); + } + + next(); +}); + RegistrationSchema.index({ event: 1, user: 1 }, { unique: true }); const Registration = mongoose.model('Registration', RegistrationSchema); diff --git a/backend/modules/registration/routes.js b/backend/modules/registration/routes.js index e0c3eb4a1..026bb682b 100644 --- a/backend/modules/registration/routes.js +++ b/backend/modules/registration/routes.js @@ -1,11 +1,9 @@ const express = require('express'); const router = express.Router(); const asyncHandler = require('express-async-handler'); -const { Auth } = require('@hackjunction/shared'); +const { Auth, RegistrationStatuses } = require('@hackjunction/shared'); const RegistrationController = require('./controller'); const EventController = require('../event/controller'); -const UserProfileController = require('../user-profile/controller'); -const EmailTaskController = require('../email-task/controller'); const { hasToken } = require('../../common/middleware/token'); const { hasPermission } = require('../../common/middleware/permissions'); @@ -42,44 +40,11 @@ const editRegistration = asyncHandler(async (req, res) => { return res.status(200).json(registration); }); -const rateRegistration = asyncHandler(async (req, res) => { - const registration = await RegistrationController.rateRegistration( - req.params.registrationId, - req.event, - req.user, - req.body.rating - ); - return res.status(200).json(registration); -}); - -const acceptRegistration = asyncHandler(async (req, res) => { - const registration = await RegistrationController.acceptRegistration(req.params.registrationId, req.event); - const user = await UserProfileController.getUserProfile(registration.user); - await EmailTaskController.sendAcceptanceEmail(req.event, user); - return res.status(200).json(registration); -}); - -const rejectRegistration = asyncHandler(async (req, res) => { - const registration = await RegistrationController.rejectRegistration(req.params.registrationId, req.event); - const user = await UserProfileController.getUserProfile(registration.user); - await EmailTaskController.sendAcceptanceEmail(req.event, user); - return res.status(200).json(registration); -}); - const getRegistrationsForEvent = asyncHandler(async (req, res) => { const registrations = await RegistrationController.getRegistrationsForEvent(req.event._id.toString()); return res.status(200).json(registrations); }); -const searchRegistrationsForEvent = asyncHandler(async (req, res) => { - const registrations = await RegistrationController.searchRegistrationsForEvent( - req.event._id.toString(), - req.user.sub, - req.query - ); - return res.status(200).json(registrations); -}); - const selfAssignRegistrationsForEvent = asyncHandler(async (req, res) => { const registrations = await RegistrationController.selfAssignRegistrationsForEvent( req.event._id.toString(), @@ -112,6 +77,18 @@ const bulkEditRegistrations = asyncHandler(async (req, res) => { return res.status(200).json([]); }); +const bulkAcceptRegistrations = asyncHandler(async (req, res) => { + const eventId = req.event._id.toString(); + const accepted = await RegistrationController.acceptSoftAccepted(eventId); + return res.status(200).json(accepted); +}); + +const bulkRejectRegistrations = asyncHandler(async (req, res) => { + const eventId = req.event._id.toString(); + const rejected = await RegistrationController.rejectSoftRejected(eventId); + return res.status(200).json(rejected); +}); + router.route('/').get(hasToken, getUserRegistrations); /** Get, create or update a registration */ @@ -130,15 +107,6 @@ router.get( getRegistrationsForEvent ); -/** Search registrations as organiser */ -router.get( - '/:slug/search', - hasToken, - hasPermission(Auth.Permissions.MANAGE_EVENT), - isEventOrganiser, - searchRegistrationsForEvent -); - router .route('/:slug/assign') .get(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, selfAssignRegistrationsForEvent) @@ -148,23 +116,18 @@ router .route('/:slug/bulk') .patch(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, bulkEditRegistrations); -/** Get or edit single registration as an organiser */ -router - .route('/:slug/:registrationId') - .get(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, getFullRegistration) - .patch(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, editRegistration); - -/** Rate a single registration */ router - .route('/:slug/:registrationId/rate') - .patch(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, rateRegistration); + .route('/:slug/bulk/accept') + .patch(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, bulkAcceptRegistrations); router - .route('/:slug/:registrationId/accept') - .patch(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, acceptRegistration); + .route('/:slug/bulk/reject') + .patch(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, bulkRejectRegistrations); +/** Get or edit single registration as an organiser */ router - .route('/:slug/:registrationId/reject') - .patch(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, rejectRegistration); + .route('/:slug/:registrationId') + .get(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, getFullRegistration) + .patch(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, editRegistration); module.exports = router; diff --git a/backend/modules/routes.js b/backend/modules/routes.js index 0c948788d..ec0979e0f 100644 --- a/backend/modules/routes.js +++ b/backend/modules/routes.js @@ -5,6 +5,7 @@ const userProfileRouter = require('./user-profile/routes'); const registrationRouter = require('./registration/routes'); const newsletterRouter = require('./newsletter/routes'); const teamRouter = require('./team/routes'); +const devToolsRouter = require('./devtools/routes'); module.exports = function(app) { app.get('/api', (req, res) => { @@ -21,4 +22,9 @@ module.exports = function(app) { app.use('/api/teams', teamRouter); app.use('/api/user-profiles', userProfileRouter); app.use('/api/registrations', registrationRouter); + + /** Admin tools (development only) */ + if (global.gConfig.DEVTOOLS_ENABLED) { + app.use('/api/devtools', devToolsRouter); + } }; diff --git a/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventManage/index.js b/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventManage/index.js index 231eed2cd..81934eaf8 100644 --- a/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventManage/index.js +++ b/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventManage/index.js @@ -68,6 +68,10 @@ const OrganiserEditEventManage = ({ }); } + const testEmail = () => { + const recipient = 'juuso.lappalainen@hackjunction.com'; + }; + return ( + )} /> diff --git a/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventReview/AdminPage.js b/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventReview/AdminPage.js index eab4a21a5..cdbf46411 100644 --- a/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventReview/AdminPage.js +++ b/frontend/src/pages/OrganiserDashboard/OrganiserEditEvent/OrganiserEditEventReview/AdminPage.js @@ -7,10 +7,12 @@ import { RegistrationStatuses } from '@hackjunction/shared'; import { Row, Col, Card, Statistic, Tag, List, Button as AntButton } from 'antd'; import Divider from 'components/generic/Divider'; import * as OrganiserSelectors from 'redux/organiser/selectors'; +import * as AuthSelectors from 'redux/auth/selectors'; +import RegistrationsService from 'services/registrations'; const STATUSES = RegistrationStatuses.asObject; -const AdminPage = ({ registrations }) => { +const AdminPage = ({ registrations, idToken, event }) => { const groupedByStatus = useMemo(() => { return groupBy(registrations, 'status'); }, [registrations]); @@ -24,6 +26,20 @@ const AdminPage = ({ registrations }) => { }, 0); }; + const handleBulkAccept = () => { + console.log('BULK ACCEPT BEGIN'); + RegistrationsService.bulkAcceptRegistrationsForEvent(idToken, event.slug) + .then(data => { + console.log('BULK ACCEPT DONE', data); + }) + .catch(err => { + console.log('BULK ACCEPT ERR', err); + }) + .finally(() => { + console.log('BULK ACCEPT FINALLY'); + }); + }; + const total = registrations.length; const rated = filter(registrations, reg => reg.rating).length; const ratedOrAssigned = filter(registrations, reg => reg.rating || reg.assignedTo).length; @@ -34,7 +50,7 @@ const AdminPage = ({ registrations }) => { description: 'Change the status of all Soft Accepted participants to Accepted, and notify them via email that they have been accepted to the event!', extra: ( - window.alert('Get permission from Juuso to do this ;--)')} type="link"> + Accept ) @@ -51,8 +67,6 @@ const AdminPage = ({ registrations }) => { } ]; - console.log('RATED', rated); - return ( @@ -152,6 +166,8 @@ const AdminPage = ({ registrations }) => { }; const mapState = state => ({ - registrations: OrganiserSelectors.registrations(state) + registrations: OrganiserSelectors.registrations(state), + event: OrganiserSelectors.event(state), + idToken: AuthSelectors.getIdToken(state) }); export default connect(mapState)(AdminPage); diff --git a/frontend/src/services/events.js b/frontend/src/services/events.js index 731df5576..d0ec8efab 100644 --- a/frontend/src/services/events.js +++ b/frontend/src/services/events.js @@ -20,10 +20,6 @@ EventsService.getEventsByOrganiser = idToken => { return _axios.get(`${BASE_ROUTE}`, config(idToken)); }; -EventsService.getEventStats = (idToken, slug) => { - return _axios.get(`${BASE_ROUTE}/${slug}/stats`, config(idToken)); -}; - EventsService.getPublicEvents = () => { return _axios.get(`${BASE_ROUTE}/public`); }; diff --git a/frontend/src/services/registrations.js b/frontend/src/services/registrations.js index 9f1bd1888..1e145261a 100644 --- a/frontend/src/services/registrations.js +++ b/frontend/src/services/registrations.js @@ -12,64 +12,77 @@ function config(idToken) { const BASE_ROUTE = '/registrations'; +/** Get all of your registrations as the logged in user + * GET / + */ RegistrationsService.getUserRegistrations = idToken => { return _axios.get(`${BASE_ROUTE}`, config(idToken)); }; +/** Get your registration for an event as the logged in user + * GET /:slug + */ RegistrationsService.getRegistration = (idToken, slug) => { return _axios.get(`${BASE_ROUTE}/${slug}`, config(idToken)); }; -RegistrationsService.createRegistration = (idToken, slug, data, subscribe = false) => { - return _axios.post(`${BASE_ROUTE}/${slug}/?subscribe=${subscribe}`, data, config(idToken)); +/** Create a registration for an event as the logged in user + * POST /:slug + */ +RegistrationsService.createRegistration = (idToken, slug, data) => { + return _axios.post(`${BASE_ROUTE}/${slug}`, data, config(idToken)); }; RegistrationsService.updateRegistration = (idToken, slug, data) => { return _axios.patch(`${BASE_ROUTE}/${slug}`, data, config(idToken)); }; - +/** Get all registrations for event + * GET /:slug/all + */ RegistrationsService.getRegistrationsForEvent = (idToken, slug) => { return _axios.get(`${BASE_ROUTE}/${slug}/all`, config(idToken)); }; +/** Edit registrations in bulk + * PATCH /:slug/bulk + */ RegistrationsService.bulkEditRegistrationsForEvent = (idToken, slug, registrationIds, edits) => { return _axios.patch(`${BASE_ROUTE}/${slug}/bulk`, { registrationIds, edits }, config(idToken)); }; -RegistrationsService.searchRegistrationsForEvent = (idToken, slug, filters) => { - const options = { - ...config(idToken), - params: filters - }; - return _axios.get(`${BASE_ROUTE}/${slug}/search`, options); +/** Accept all soft-accepted registrations + * PATCH /:slug/bulk/accept + */ +RegistrationsService.bulkAcceptRegistrationsForEvent = (idToken, slug) => { + return _axios.patch(`${BASE_ROUTE}/${slug}/bulk/accept`, {}, config(idToken)); }; -RegistrationsService.assignRandomRegistrations = (idToken, slug) => { - return _axios.get(`${BASE_ROUTE}/${slug}/assign`, config(idToken)); +/** Reject all soft-rejected registrations + * PATCH /:slug/bulk/reject + */ +RegistrationsService.bulkRejectRegistrationsForEvent = (idToken, slug) => { + return _axios.patch(`${BASE_ROUTE}/${slug}/bulk/reject`); }; -RegistrationsService.assignRegistration = (idToken, slug, registrationId, userId) => { - return _axios.patch(`${BASE_ROUTE}/${slug}/assign`, { registrationId, userId }, config(idToken)); +/** Assign 10 registrations to logged in user + * GET /:slug/assign + */ +RegistrationsService.assignRandomRegistrations = (idToken, slug) => { + return _axios.get(`${BASE_ROUTE}/${slug}/assign`, config(idToken)); }; +/** Get a single registration with all fields + * GET /:slug/:registrationId + */ RegistrationsService.getFullRegistration = (idToken, slug, registrationId) => { return _axios.get(`${BASE_ROUTE}/${slug}/${registrationId}`, config(idToken)); }; +/** Edit a single registration + * PATCH /:slug/:registrationId + */ RegistrationsService.editRegistration = (idToken, slug, registrationId, data) => { return _axios.patch(`${BASE_ROUTE}/${slug}/${registrationId}`, data, config(idToken)); }; -RegistrationsService.rateRegistration = (idToken, slug, registrationId, rating) => { - return _axios.patch(`${BASE_ROUTE}/${slug}/${registrationId}/rate`, { rating }, config(idToken)); -}; - -RegistrationsService.acceptRegistration = (idToken, slug, registrationId) => { - return _axios.patch(`${BASE_ROUTE}/${slug}/${registrationId}/accept`, {}, config(idToken)); -}; - -RegistrationsService.rejectRegistration = (idToken, slug, registrationId) => { - return _axios.patch(`${BASE_ROUTE}/${slug}/${registrationId}/reject`, {}, config(idToken)); -}; - export default RegistrationsService; diff --git a/package.json b/package.json index 9241a7250..045da08f0 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,9 @@ "setup": "better-npm-run setup", "dev:frontend": "cd frontend && npm start", "dev:backend": "cd backend && npm run dev", - "db-sync:prod-to-local": "sh ./scripts/sync-production-to-local.sh", - "db-sync:staging-to-dev": "sh ./scripts/sync-staging-to-dev.sh", - "db-sync:staging-to-local": "sh ./scripts/sync-staging-to-local.sh" + "db-sync:local": "sh ./scripts/sync-production-to-local.sh", + "db-sync:dev": "sh ./scripts/sync-production-to-dev.sh", + "db-sync:staging": "sh ./scripts/sync-production-to-staging.sh" }, "betterScripts": { "setup": "better-npm-run setup:backend && better-npm-run setup:frontend", From 4e8c2aa8444186b6469064c0803ca896166cbd84 Mon Sep 17 00:00:00 2001 From: Juuso Lappalainen Date: Sun, 22 Sep 2019 17:57:18 +0300 Subject: [PATCH 07/21] Bulk email to filtered participants feature --- backend/common/services/sendgrid.js | 25 ++- backend/misc/config.js | 4 + backend/modules/email-task/controller.js | 103 ++++----- backend/modules/email-task/routes.js | 21 ++ backend/modules/registration/controller.js | 29 ++- backend/modules/registration/model.js | 20 +- backend/modules/registration/routes.js | 14 ++ backend/modules/routes.js | 2 + .../NotificationBlock.module.scss | 67 +++--- .../generic/NotificationBlock/index.js | 37 ++-- .../BulkEditRegistrationDrawer/index.js | 7 +- .../BulkEmailDrawer.module.scss | 4 + .../modals/BulkEmailDrawer/index.js | 203 ++++++++++++++++++ .../pages/Account/AccountDashboard/index.js | 2 +- .../RegistrationStatusBlock.js | 168 +++++++++++++++ .../TeamStatusBlock.js | 100 +++++++++ .../EventDashboardHomeRegistration/index.js | 140 +----------- .../EventDashboardHome/index.js | 3 - .../SearchAttendeesPage.js | 9 +- frontend/src/services/email.js | 24 +++ shared/constants/registration-statuses.js | 8 + 21 files changed, 743 insertions(+), 247 deletions(-) create mode 100644 backend/modules/email-task/routes.js create mode 100644 frontend/src/components/modals/BulkEmailDrawer/BulkEmailDrawer.module.scss create mode 100644 frontend/src/components/modals/BulkEmailDrawer/index.js create mode 100644 frontend/src/pages/EventDashboard/EventDashboardHome/EventDashboardHomeRegistration/RegistrationStatusBlock.js create mode 100644 frontend/src/pages/EventDashboard/EventDashboardHome/EventDashboardHomeRegistration/TeamStatusBlock.js create mode 100644 frontend/src/services/email.js diff --git a/backend/common/services/sendgrid.js b/backend/common/services/sendgrid.js index 33256e379..bceb1e645 100644 --- a/backend/common/services/sendgrid.js +++ b/backend/common/services/sendgrid.js @@ -34,11 +34,30 @@ const sendgridAddRecipientsToList = (list_id, recipient_ids) => { }; const SendgridService = { - buildAcceptanceEmail: (to, { event_name }) => { - return SendgridService.buildTemplateMessage(to, global.gConfig.SENDGRID_ACCEPTED_TEMPLATE, { - event_name + sendAcceptanceEmail: (to, event, user) => { + const msg = SendgridService.buildTemplateMessage(to, global.gConfig.SENDGRID_ACCEPTED_TEMPLATE, { + event_name: event.name, + first_name: user.firstName, + dashboard_link: `${global.gConfig.FRONTEND_URL}/dashboard/${event.slug}`, + website_link: 'https://2019.hackjunction.com', + fb_event_link: 'https://facebook.com', + contact_email: 'participants@hackjunction.com' }); + return SendgridService.send(msg); }, + sendGenericEmail: (to, params) => { + const msg = SendgridService.buildTemplateMessage(to, global.gConfig.SENDGRID_GENERIC_TEMPLATE, { + subject: params.subject, + subtitle: params.subtitle, + header_image: params.header_image, + body: params.body, + cta_text: params.cta_text, + cta_link: params.cta_link + }); + + return SendgridService.send(msg); + }, + buildRejectionEmail: (to, { event_name }) => { return SendgridService.buildTemplateMessage(to, global.gConfig.SENDGRID_REJECTED_TEMPLATE, { event_name diff --git a/backend/misc/config.js b/backend/misc/config.js index cb4eeb3ba..05213d198 100644 --- a/backend/misc/config.js +++ b/backend/misc/config.js @@ -66,6 +66,10 @@ const settings = { value: process.env.SENDGRID_REJECTED_TEMPLATE, required: true }, + SENDGRID_GENERIC_TEMPLATE: { + value: process.env.SENDGRID_GENERIC_TEMPLATE, + required: true + }, FRONTEND_URL: { value: process.env.FRONTEND_URL, default: '', diff --git a/backend/modules/email-task/controller.js b/backend/modules/email-task/controller.js index 1d7d4cc04..b4d0580d3 100644 --- a/backend/modules/email-task/controller.js +++ b/backend/modules/email-task/controller.js @@ -25,65 +25,70 @@ controller.createTask = (userId, eventId, type, message, schedule) => { return Promise.resolve(); } // For other types of errors, we'll want to throw the error normally - return Promise.reject(); + return Promise.reject(err); }); }; -controller.createAcceptedTask = (userId, eventId) => { - return controller.createTask(userId, eventId, EmailTypes.registrationAccepted); +controller.createAcceptedTask = async (userId, eventId, deliverNow = false) => { + const task = await controller.createTask(userId, eventId, EmailTypes.registrationAccepted); + if (deliverNow) { + return controller.deliverEmailTask(task); + } + return task; }; -controller.createRejectedTask = (userId, eventId) => { - return controller.createTask(userId, eventId, EmailTypes.registrationRejected); +controller.createRejectedTask = async (userId, eventId, deliverNow = false) => { + const task = await controller.createTask(userId, eventId, EmailTypes.registrationRejected); + if (deliverNow) { + return controller.deliverEmailTask(task); + } + return task; }; -controller.createRegisteredTask = (userId, eventId) => { - return controller.createTask(userId, eventId, EmailTypes.registrationReceived); +controller.createRegisteredTask = async (userId, eventId, deliverNow = false) => { + const task = await controller.createTask(userId, eventId, EmailTypes.registrationReceived); + if (deliverNow) { + return controller.deliverEmailTask(task); + } + return task; }; -// controller.sendEmail = (msg, taskParams) => { -// return SendgridService.send(msg).catch(message => { -// /** If sending the email fails, save a task to the DB and we can retry later */ -// return controller.createTask(message, taskParams); -// }); -// }; - -// controller.sendAcceptanceEmail = async (eventId, userId) => { -// const event = await EventController.getEventById(eventId); -// const user = await UserController.getUserProfile(userId); - -// const msgParams = { -// event_name: event.name, -// event_logo: event.logo.url -// }; +controller.deliverEmailTask = async task => { + const [user, event] = await Promise.all([ + UserController.getUserProfile(task.user), + EventController.getEventById(task.event) + ]); + switch (task.type) { + case EmailTypes.registrationAccepted: { + await SendgridService.sendAcceptanceEmail('juuso.lappalainen@hackjunction.com', event, user); + break; + } + case EmailTypes.registrationRejected: { + console.log('PERFORM REG REJECTED EMAIL'); + break; + } + case EmailTypes.registrationReceived: { + console.log('PERFORM REG RECEIVED'); + break; + } + default: { + console.log('PERFORM GENERIC EMAIL!'); + break; + } + } -// const msg = SendgridService.buildAcceptanceEmail('juuso.lappalainen@hackjunction.com', msgParams); -// const taskParams = { -// userId: user.userId, -// eventId: event._id, -// type: EmailTypes.registrationAccepted -// }; -// return controller -// .sendEmail(msg, taskParams) -// .then(res => { -// console.log('SENT EMAIL', res); -// }) -// .catch(err => { -// console.log('ERR SENDING EMAIL', err); -// }); -// }; + /** Here we'll have success so we can set the task as delivered */ + task.deliveredAt = Date.now(); + return task.save(); +}; -// controller.sendRejectionEmail = (event, user) => { -// const msgParams = { -// event_name: event.name -// }; -// const msg = SendgridService.buildRejectionEmail(user.email, msgParams); -// const taskParams = { -// userId: user.userId, -// eventId: event._id, -// type: EmailTypes.registrationRejected -// }; -// return controller.sendEmail(msg, taskParams); -// }; +controller.sendPreviewEmail = async (to, msgParams) => { + console.log('SENDING TEST TO', to); + console.log('WITH PARAMS', msgParams); + return SendgridService.sendGenericEmail(to, msgParams).catch(err => { + console.log('DA ERR', err); + return; + }); +}; module.exports = controller; diff --git a/backend/modules/email-task/routes.js b/backend/modules/email-task/routes.js new file mode 100644 index 000000000..d7e53165f --- /dev/null +++ b/backend/modules/email-task/routes.js @@ -0,0 +1,21 @@ +const express = require('express'); +const router = express.Router(); +const asyncHandler = require('express-async-handler'); +const { Auth } = require('@hackjunction/shared'); + +const { hasToken } = require('../../common/middleware/token'); +const { hasPermission } = require('../../common/middleware/permissions'); +const { isEventOrganiser } = require('../../common/middleware/events'); + +const EmailTaskController = require('./controller'); + +const sendPreviewEmail = asyncHandler(async (req, res) => { + await EmailTaskController.sendPreviewEmail(req.body.to, req.body.params); + return res.status(200).json({}); +}); + +router + .route('/:slug/preview') + .post(hasToken, hasPermission(Auth.Permissions.MANAGE_EVENT), isEventOrganiser, sendPreviewEmail); + +module.exports = router; diff --git a/backend/modules/registration/controller.js b/backend/modules/registration/controller.js index e97b90bab..2e9fbbc99 100644 --- a/backend/modules/registration/controller.js +++ b/backend/modules/registration/controller.js @@ -2,10 +2,11 @@ const _ = require('lodash'); const Promise = require('bluebird'); const { RegistrationStatuses } = require('@hackjunction/shared'); const Registration = require('./model'); -const { NotFoundError } = require('../../common/errors/errors'); +const { NotFoundError, ForbiddenError } = require('../../common/errors/errors'); const UserProfileController = require('../user-profile/controller'); const RegistrationHelpers = require('./helpers'); +const STATUSES = RegistrationStatuses.asObject; const controller = {}; controller.getUserRegistrations = user => { @@ -45,6 +46,28 @@ controller.updateRegistration = (user, event, data) => { }); }; +controller.confirmRegistration = (user, event) => { + return controller.getRegistration(user.sub, event._id.toString()).then(registration => { + if (registration.status === STATUSES.accepted.id) { + registration.status = STATUSES.confirmed.id; + return registration.save(); + } + + throw new ForbiddenError('Only accepted registrations can be confirmed'); + }); +}; + +controller.cancelRegistration = (user, event) => { + return controller.getRegistration(user.sub, event._id.toString()).then(registration => { + if (registration.status === STATUSES.confirmed.id) { + registration.status = STATUSES.cancelled.id; + return registration.save(); + } + + throw new ForbiddenError('Only confirmed registrations can be cancelled'); + }); +}; + controller.getRegistrationsForEvent = eventId => { return Registration.find({ event: eventId @@ -143,9 +166,9 @@ controller.getFullRegistrationsForEvent = eventId => { }; controller.acceptSoftAccepted = async eventId => { - const users = await Registration.find({ event: eventId, status: RegistrationStatuses.asObject.accepted.id }); + const users = await Registration.find({ event: eventId, status: RegistrationStatuses.asObject.softAccepted.id }); const accepted = await Promise.each(users, user => { - user.status = RegistrationStatuses.asObject.softAccepted.id; + user.status = RegistrationStatuses.asObject.accepted.id; user.save(); }); return accepted; diff --git a/backend/modules/registration/model.js b/backend/modules/registration/model.js index 1be8601ce..c397cef3f 100644 --- a/backend/modules/registration/model.js +++ b/backend/modules/registration/model.js @@ -15,7 +15,11 @@ const RegistrationSchema = new mongoose.Schema({ status: { type: String, enum: RegistrationStatuses.ids, - default: RegistrationStatuses.asObject.pending.id + default: RegistrationStatuses.asObject.pending.id, + set: function(status) { + this._previousStatus = this.status; + return status; + } }, assignedTo: { type: String @@ -46,7 +50,6 @@ RegistrationSchema.plugin(updateAllowedPlugin, { RegistrationSchema.pre('save', function(next) { this._wasNew = this.isNew; - this._previousStatus = this.status; next(); }); @@ -54,20 +57,19 @@ RegistrationSchema.pre('save', function(next) { RegistrationSchema.post('save', function(doc, next) { const ACCEPTED = RegistrationStatuses.asObject.accepted.id; const REJECTED = RegistrationStatuses.asObject.rejected.id; - /** If a registration was just created, create an email notification about it */ - if (doc._wasNew) { - EmailTaskController.createRegisteredTask(doc.user, doc.event); + if (this._wasNew) { + EmailTaskController.createRegisteredTask(doc.user, doc.event, true); } /** If a registration is accepted, create an email notification about it */ - if (doc._previousStatus !== ACCEPTED && doc.status === ACCEPTED) { - EmailTaskController.createAcceptedTask(doc.user, doc.event); + if (this._previousStatus !== ACCEPTED && this.status === ACCEPTED) { + EmailTaskController.createAcceptedTask(doc.user, doc.event, true); } /** If a registration is rejected, create an email notification about it */ - if ( && doc._previousStatus !== REJECTED && doc.status === REJECTED) { - EmailTaskController.createRejectedTask(doc.user, doc.event); + if (this._previousStatus !== REJECTED && this.status === REJECTED) { + EmailTaskController.createRejectedTask(doc.user, doc.event, true); } next(); diff --git a/backend/modules/registration/routes.js b/backend/modules/registration/routes.js index 026bb682b..e625ba90e 100644 --- a/backend/modules/registration/routes.js +++ b/backend/modules/registration/routes.js @@ -30,6 +30,16 @@ const updateRegistration = asyncHandler(async (req, res) => { return res.status(200).json(registration); }); +const confirmRegistration = asyncHandler(async (req, res) => { + const registration = await RegistrationController.confirmRegistration(req.user, req.event); + return res.status(200).json(registration); +}); + +const cancelRegistration = asyncHandler(async (req, res) => { + const registration = await RegistrationController.cancelRegistration(req.user, req.event); + return res.status(200).json(registration); +}); + const editRegistration = asyncHandler(async (req, res) => { const registration = await RegistrationController.editRegistration( req.params.registrationId, @@ -98,6 +108,10 @@ router .post(hasToken, canRegisterToEvent, createRegistration) .patch(hasToken, canRegisterToEvent, updateRegistration); +router.route('/:slug/confirm').patch(hasToken, confirmRegistration); + +router.route('/:slug/cancel').patch(hasToken, cancelRegistration); + /** Get all registration as organiser */ router.get( '/:slug/all', diff --git a/backend/modules/routes.js b/backend/modules/routes.js index ec0979e0f..cdd954964 100644 --- a/backend/modules/routes.js +++ b/backend/modules/routes.js @@ -5,6 +5,7 @@ const userProfileRouter = require('./user-profile/routes'); const registrationRouter = require('./registration/routes'); const newsletterRouter = require('./newsletter/routes'); const teamRouter = require('./team/routes'); +const emailRouter = require('./email-task/routes'); const devToolsRouter = require('./devtools/routes'); module.exports = function(app) { @@ -16,6 +17,7 @@ module.exports = function(app) { app.use('/api/auth', authRouter); app.use('/api/upload', uploadRouter); app.use('/api/newsletter', newsletterRouter); + app.use('/api/email', emailRouter); /** Model related routes */ app.use('/api/events', eventRouter); diff --git a/frontend/src/components/generic/NotificationBlock/NotificationBlock.module.scss b/frontend/src/components/generic/NotificationBlock/NotificationBlock.module.scss index 2d1046e8c..236fe42cd 100644 --- a/frontend/src/components/generic/NotificationBlock/NotificationBlock.module.scss +++ b/frontend/src/components/generic/NotificationBlock/NotificationBlock.module.scss @@ -1,48 +1,63 @@ @import '~styles/variables'; .wrapper { - padding: 1rem; display: flex; flex-direction: column; - align-items: flex-start; + align-items: stretch; border-radius: 10px; box-shadow: 4px 6px 20px #f3f3f3; + overflow: hidden; - .title { - font-size: 18px; - text-transform: uppercase; - color: $black; + .iconWrapper { display: flex; - flex-direction: row; + flex-direction: column; align-items: center; - } - - .body { - font-size: 18px; - } + justify-content: center; + background: rgba($black, 0.5); + padding: 1rem; - .titleExtra { - padding-left: 5px; - } + &Warning { + background: rgba($junction-orange, 0.7); + } - .typeIcon { - margin-right: 10px; - font-size: 24px; + &Error { + background: rgba($color-error, 0.5); + } - &Info { - color: $green; + &Success { + background: $green; } - &Warning { - color: $junction-orange; + .icon { + color: white; + font-size: 32px; } + } - &Error { - color: $color-error; + .contentWrapper { + padding: 1rem; + display: flex; + flex-direction: column; + align-items: center; + background: $lightgrey; + + .title { + font-size: 18px; + text-transform: uppercase; + color: $black; + display: flex; + flex-direction: row; + align-items: center; + + &Extra { + padding-left: 5px; + font-weight: 900; + } } - &Success { - color: green; + .body { + font-size: 18px; + text-align: center; } } } diff --git a/frontend/src/components/generic/NotificationBlock/index.js b/frontend/src/components/generic/NotificationBlock/index.js index 699fd2538..3e6ce62e6 100644 --- a/frontend/src/components/generic/NotificationBlock/index.js +++ b/frontend/src/components/generic/NotificationBlock/index.js @@ -11,30 +11,39 @@ const NotificationBlock = ({ title, titleExtra, body, bottom, type }) => { if (!type) return null; switch (type) { case 'success': - return ; + return ; case 'error': - return ; + return ; case 'warning': - return ( - - ); + return ; case 'info': - return ; + return ; default: return null; } }; + const iconWrapperClass = classNames(styles.iconWrapper, { + [styles.iconWrapperSuccess]: type === 'success', + [styles.iconWrapperInfo]: type === 'info', + [styles.iconWrapperWarning]: type === 'warning', + [styles.iconWrapperError]: type === 'error' + }); + return (
- - {renderTypeIcon()} - {title} - {titleExtra && {titleExtra}} - - -

{body}

-
{bottom}
+
+
{renderTypeIcon()}
+
+
+ + {title} + {titleExtra && {titleExtra}} + + +

{body}

+
{bottom}
+
); }; diff --git a/frontend/src/components/modals/BulkEditRegistrationDrawer/index.js b/frontend/src/components/modals/BulkEditRegistrationDrawer/index.js index a88029476..acca46b96 100644 --- a/frontend/src/components/modals/BulkEditRegistrationDrawer/index.js +++ b/frontend/src/components/modals/BulkEditRegistrationDrawer/index.js @@ -273,12 +273,7 @@ const BulkEditRegistrationDrawer = ({ />
- + ); }; diff --git a/frontend/src/components/modals/BulkEmailDrawer/BulkEmailDrawer.module.scss b/frontend/src/components/modals/BulkEmailDrawer/BulkEmailDrawer.module.scss new file mode 100644 index 000000000..437085590 --- /dev/null +++ b/frontend/src/components/modals/BulkEmailDrawer/BulkEmailDrawer.module.scss @@ -0,0 +1,4 @@ +.label { + display: block; + font-weight: bold; +} diff --git a/frontend/src/components/modals/BulkEmailDrawer/index.js b/frontend/src/components/modals/BulkEmailDrawer/index.js new file mode 100644 index 000000000..ca2492573 --- /dev/null +++ b/frontend/src/components/modals/BulkEmailDrawer/index.js @@ -0,0 +1,203 @@ +import React, { useState, useCallback } from 'react'; +import styles from './BulkEmailDrawer.module.scss'; + +import { connect } from 'react-redux'; +import { Drawer, Button as AntButton, Divider as AntDivider, Input, Modal, notification, Popconfirm } from 'antd'; + +import Divider from 'components/generic/Divider'; +import * as AuthSelectors from 'redux/auth/selectors'; +import * as OrganiserSelectors from 'redux/organiser/selectors'; +import EmailService from 'services/email'; + +const BulkEmailDrawer = ({ registrationIds, buttonProps, user, idToken, event }) => { + const [visible, setVisible] = useState(false); + const [testEmail, setTestEmail] = useState(user.email); + const [testModalVisible, setTestModalVisible] = useState(false); + const [testModalLoading, setTestModalLoading] = useState(false); + + const [subject, setSubject] = useState(); + const [subtitle, setSubtitle] = useState(); + const [headerImage, setHeaderImage] = useState(); + const [body, setBody] = useState(); + const [ctaText, setCtaText] = useState(); + const [ctaLink, setCtaLink] = useState(); + const [messageId, setMessageId] = useState(); + + const params = { + subject: subject, + subtitle: subtitle, + header_image: headerImage, + body: body, + cta_text: ctaText, + cta_link: ctaLink + }; + + const handleClose = useCallback(() => { + setVisible(false); + }, []); + + const handleCloseModal = useCallback(() => { + setTestModalVisible(false); + }, []); + + const handleTestEmail = useCallback(() => { + setTestModalLoading(true); + EmailService.sendPreviewEmail(idToken, event.slug, testEmail, params) + .then(() => { + notification.success({ + message: 'Success', + description: 'Check your inbox!' + }); + }) + .catch(err => { + console.log(err); + notification.error({ + message: 'Something went wrong...', + description: 'Did you enter a valid email address?' + }); + }) + .finally(() => { + setTestModalLoading(false); + setTestModalVisible(false); + }); + return null; + }, [idToken, event.slug, testEmail, params]); + + const handleConfirm = useCallback(() => { + window.alert('Try again later :)'); + }, []); + + return ( + + + setTestEmail(e.target.value)} + /> + + + Bulk email +

+ Here you can send an email to all selected participants. Type in your own email address below to + test the email before sending it out to everyone! +

+ + setHeaderImage(e.target.value)} + type="text" + size="large" + placeholder="https://" + > + Url to a header image for your email + + + setSubject(e.target.value)} + type="text" + size="large" + placeholder="Subject" + > + The subject line of your message + + + setSubtitle(e.target.value)} + type="text" + size="large" + placeholder="Your subtitle" + > + A subtitle to be shown under the subject (optional) + + + setBody(e.target.value)} + autosize={{ + minRows: 10, + maxRows: 20 + }} + > + The content of your email + + + setMessageId(e.target.value)} + size="large" + placeholder="something-you-will-remember" + /> + + If you want, you can enter a unique message id here. Messages with the same id will only be sent + once to a given participant - this is useful if you want to avoid sending out the same message to a + participant who has already received it earlier. + + + + setCtaText(e.target.value)} + size="large" + placeholder="Click this button" + /> + + If your want a Call to Action button in your email, enter the text for the button here. + + + + setCtaLink(e.target.value)} + size="large" + placeholder="https://..." + /> + Enter the link where your Call to Action button should lead + + + Test email + + + + + Send to {registrationIds.length} recipients + + +
+ +
+ ); +}; + +const mapState = state => ({ + user: AuthSelectors.getCurrentUser(state), + event: OrganiserSelectors.event(state), + idToken: AuthSelectors.getIdToken(state) +}); + +export default connect(mapState)(BulkEmailDrawer); diff --git a/frontend/src/pages/Account/AccountDashboard/index.js b/frontend/src/pages/Account/AccountDashboard/index.js index dbcbd7833..7351aaed9 100644 --- a/frontend/src/pages/Account/AccountDashboard/index.js +++ b/frontend/src/pages/Account/AccountDashboard/index.js @@ -19,7 +19,7 @@ const AccountDashboard = ({ registrations, updateRegistrations }) => {

Your registrations

{registrations.map(registration => ( - + ))} diff --git a/frontend/src/pages/EventDashboard/EventDashboardHome/EventDashboardHomeRegistration/RegistrationStatusBlock.js b/frontend/src/pages/EventDashboard/EventDashboardHome/EventDashboardHomeRegistration/RegistrationStatusBlock.js new file mode 100644 index 000000000..0309583ba --- /dev/null +++ b/frontend/src/pages/EventDashboard/EventDashboardHome/EventDashboardHomeRegistration/RegistrationStatusBlock.js @@ -0,0 +1,168 @@ +import React from 'react'; + +import { connect } from 'react-redux'; +import { Col } from 'antd'; + +import { RegistrationStatuses } from '@hackjunction/shared'; +import NotificationBlock from 'components/generic/NotificationBlock'; +import Button from 'components/generic/Button'; +import Divider from 'components/generic/Divider'; + +import * as DashboardSelectors from 'redux/dashboard/selectors'; + +const STATUSES = RegistrationStatuses.asObject; + +const RegistrationStatusBlock = ({ event, registration }) => { + if (!registration || !event) return null; + + const PENDING_STATUSES = [STATUSES.pending.id, STATUSES.softAccepted.id, STATUSES.softRejected.id]; + + if (PENDING_STATUSES.indexOf(registration.status) !== -1) { + return ( + + + + } + /> + + ); + } + + if (registration.status === STATUSES.accepted.id) { + return ( + + + window.alert('Confirm this stuff!') }} + /> + } + /> + + ); + } + + if (registration.status === STATUSES.rejected.id) { + return ( + + + + } + /> + + ); + } + + if (registration.status === STATUSES.confirmed.id) { + return ( + + + + + + ); +}; + +const mapState = state => ({ + registration: DashboardSelectors.registration(state) +}); + +export default connect(mapState)(VisaInvitationDrawer); diff --git a/frontend/src/index.js b/frontend/src/index.js index b54b76f44..8c673a36e 100755 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -13,6 +13,14 @@ import config from 'constants/config'; const { store, persistor } = configureStore(); +/** Disable log statements in production */ +function noop() {} +if (process.env.NODE_ENV !== 'development') { + console.log = noop; + console.warn = noop; + console.error = noop; +} + ReactDOM.render( } persistor={persistor}> diff --git a/frontend/src/pages/EventDashboard/EventDashboardHome/EventDashboardHomeRegistration/RegistrationStatusBlock.js b/frontend/src/pages/EventDashboard/EventDashboardHome/EventDashboardHomeRegistration/RegistrationStatusBlock.js index 0309583ba..7ebf41984 100644 --- a/frontend/src/pages/EventDashboard/EventDashboardHome/EventDashboardHomeRegistration/RegistrationStatusBlock.js +++ b/frontend/src/pages/EventDashboard/EventDashboardHome/EventDashboardHomeRegistration/RegistrationStatusBlock.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Col } from 'antd'; +import { Col, Button as AntButton } from 'antd'; import { RegistrationStatuses } from '@hackjunction/shared'; import NotificationBlock from 'components/generic/NotificationBlock'; @@ -96,30 +96,20 @@ const RegistrationStatusBlock = ({ event, registration }) => { titleExtra="Confirmed" body={`Awesome, you've confirmed your participation! You should probably start making travel and other arrangements - see the links below to stay up-to-date on all of the information and announcements related to ${event.name}.`} bottom={ - -