diff --git a/.gitignore b/.gitignore index 27fb5f91..b384ed9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ __coverage__ .build-info .sass-cache -dist +#dist node_modules _auto_doc_ .vscode diff --git a/__tests__/__snapshots__/index.js.snap b/__tests__/__snapshots__/index.js.snap index 31e05077..33482195 100644 --- a/__tests__/__snapshots__/index.js.snap +++ b/__tests__/__snapshots__/index.js.snap @@ -106,6 +106,18 @@ Object { "getUserSrmDone": [Function], "getUserSrmInit": [Function], }, + "notifications": Object { + "dismissChallengeNotificationsDone": [Function], + "dismissChallengeNotificationsInit": [Function], + "getNotificationsDone": [Function], + "getNotificationsInit": [Function], + "markAllNotificationAsReadDone": [Function], + "markAllNotificationAsReadInit": [Function], + "markAllNotificationAsSeenDone": [Function], + "markAllNotificationAsSeenInit": [Function], + "markNotificationAsReadDone": [Function], + "markNotificationAsReadInit": [Function], + }, "profile": Object { "addSkillDone": [Function], "addSkillInit": [Function], @@ -255,6 +267,7 @@ Object { "memberTasks": [Function], "members": [Function], "mySubmissionsManagement": [Function], + "notifications": [Function], "profile": [Function], "reviewOpportunity": [Function], "settings": [Function], @@ -312,6 +325,10 @@ Object { "default": undefined, "getService": [Function], }, + "notifications": Object { + "default": undefined, + "getService": [Function], + }, "reviewOpportunities": Object { "default": undefined, "getReviewOpportunitiesService": [Function], diff --git a/src/actions/index.js b/src/actions/index.js index 09deaca7..b053ae50 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -14,6 +14,7 @@ import lookupActions from './lookup'; import settingsActions from './settings'; import lookerActions from './looker'; import memberSearchActions from './member-search'; +import notificationActions from './notifications'; export const actions = { auth: authActions.auth, @@ -32,6 +33,7 @@ export const actions = { settings: settingsActions.settings, looker: lookerActions.looker, memberSearch: memberSearchActions.memberSearch, + notifications: notificationActions.notifications, }; export default undefined; diff --git a/src/actions/notifications.js b/src/actions/notifications.js new file mode 100644 index 00000000..d7730ef3 --- /dev/null +++ b/src/actions/notifications.js @@ -0,0 +1,173 @@ +/** + * @module "actions.notifications" + * @desc Actions related to notifications data. + */ + +import _ from 'lodash'; +import { createActions } from 'redux-actions'; +import { getService } from '../services/notifications'; + +/** + * TODO: We will need to change this based on API and + * frontend mapping we need later. + */ +function processData(data) { + const retData = _.map(data, (item) => { + const object = {}; + object.id = item.id; + object.sourceId = item.contents.id; + object.sourceName = item.contents.name || item.contents.title; + object.eventType = item.type; + object.isRead = item.read; + object.isSeen = item.seen; + object.contents = item.contents.message || item.contents.title; + object.version = item.version; + object.date = item.createdAt; + return object; + }); + return retData; +} + +/** + * @static + * @desc Creates an action that signals beginning of notifications + * loading. + * @return {Action} + */ +function getNotificationsInit() { + return { }; +} + +/** + * @static + * @desc Creates an action that loads member achievements. + * @param {String} tokenV3 v3 auth token. + * @return {Action} + */ +async function getNotificationsDone(tokenV3) { + let data; + try { + data = await getService(tokenV3).getNotifications(); + } catch (e) { + data = []; + } + return processData(data.items || []); +} + +/** + * @static + * @desc Creates an action that signals beginning of mark notification as read + * loading. + * @return {Action} + */ +function markNotificationAsReadInit() { + return { }; +} + +/** + * @static + * @desc Creates an action that marks notification as read. + * @param {String} tokenV3 v3 auth token. + * @return {Action} + */ +async function markNotificationAsReadDone(item, tokenV3) { + try { + await getService(tokenV3).markNotificationAsRead(item.id); + } catch (e) { + return e; + } + return item; +} + +/** + * @static + * @desc Creates an action that signals beginning of mark all notification as read + * loading. + * @return {Action} + */ +function markAllNotificationAsReadInit() { + return { }; +} + +/** + * @static + * @desc Creates an action that marks all notification as read. + * @param {String} tokenV3 v3 auth token. + * @return {Action} + */ +async function markAllNotificationAsReadDone(tokenV3) { + try { + await getService(tokenV3).markAllNotificationAsRead(); + } catch (e) { + return e; + } + return true; +} + + +/** + * @static + * @desc Creates an action that signals beginning of mark all notification as seen + * loading. + * @return {Action} + */ +function markAllNotificationAsSeenInit() { + return { }; +} + +/** + * @static + * @desc Creates an action that marks all notification as seen. + * @param {String} tokenV3 v3 auth token. + * @return {Action} + */ +async function markAllNotificationAsSeenDone(items, tokenV3) { + try { + await getService(tokenV3).markAllNotificationAsSeen(items); + } catch (e) { + return e; + } + return items; +} + + +/** + * @static + * @desc Creates an action that signals beginning of dismiss all challenge notifications + * loading. + * @return {Action} + */ +function dismissChallengeNotificationsInit() { + return { }; +} + +/** + * @static + * @desc Creates an action that dismisses all challenge notifications + * @param {String} tokenV3 v3 auth token. + * @return {Action} + */ +async function dismissChallengeNotificationsDone(challengeId, tokenV3) { + try { + await getService(tokenV3).dismissChallengeNotifications(challengeId); + } catch (e) { + return e; + } + return true; +} + + +export default createActions({ + NOTIFICATIONS: { + GET_NOTIFICATIONS_INIT: getNotificationsInit, + GET_NOTIFICATIONS_DONE: getNotificationsDone, + MARK_NOTIFICATION_AS_READ_INIT: markNotificationAsReadInit, + MARK_NOTIFICATION_AS_READ_DONE: markNotificationAsReadDone, + MARK_ALL_NOTIFICATION_AS_READ_INIT: markAllNotificationAsReadInit, + MARK_ALL_NOTIFICATION_AS_READ_DONE: markAllNotificationAsReadDone, + MARK_ALL_NOTIFICATION_AS_SEEN_INIT: markAllNotificationAsSeenInit, + MARK_ALL_NOTIFICATION_AS_SEEN_DONE: markAllNotificationAsSeenDone, + DISMISS_CHALLENGE_NOTIFICATIONS_INIT: dismissChallengeNotificationsInit, + DISMISS_CHALLENGE_NOTIFICATIONS_DONE: dismissChallengeNotificationsDone, + }, +}); diff --git a/src/reducers/index.js b/src/reducers/index.js index 7f7e3604..e016d5b0 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -12,6 +12,7 @@ import errors, { factory as errorsFactory } from './errors'; import challenge, { factory as challengeFactory } from './challenge'; import profile, { factory as profileFactory } from './profile'; import members, { factory as membersFactory } from './members'; +import notifications, { factory as notificationsFactory } from './notifications'; import lookup, { factory as lookupFactory } from './lookup'; import memberTasks, { factory as memberTasksFactory } from './member-tasks'; import reviewOpportunity, { factory as reviewOpportunityFactory } @@ -42,6 +43,7 @@ export function factory(options) { settings: settingsFactory(options), looker: lookerFactory(options), memberSearch: memberSearchFactory(options), + notifications: notificationsFactory(options), }); } @@ -62,4 +64,5 @@ export default ({ settings, looker, memberSearch, + notifications, }); diff --git a/src/reducers/notifications.js b/src/reducers/notifications.js new file mode 100644 index 00000000..bb1ecaa8 --- /dev/null +++ b/src/reducers/notifications.js @@ -0,0 +1,254 @@ +/** + * @module "reducers.notifications" + * @desc Reducer for {@link module:actions.notifications} actions. + * + * State segment managed by this reducer has the following strcuture: + * @param {Array} authenticating=true `true` if authentication is still in + * progress; `false` if it has already completed or failed. + * @param {Object} profile=null Topcoder user profile. + * @param {String} tokenV2='' Topcoder v2 auth token. + * @param {String} tokenV3='' Topcoder v3 auth token. + * @param {Object} user=null Topcoder user object (user information stored in + * v3 auth token). + */ + + +import { handleActions } from 'redux-actions'; +import actions from '../actions/notifications'; +import logger from '../utils/logger'; +import { fireErrorMessage } from '../utils/errors'; + + +/** + * Handles NOTIFICATIONS/GET_NOTIFICATIONS_INIT action. + * @param {Object} state + * @return {Object} New state + */ +function onGetNotificationsInit(state) { + return { ...state }; +} + +/** + * Handles NOTIFICATIONS/GET_NOTIFICATIONS_DONE action. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onGetNotificationsDone(state, { error, payload }) { + if (error) { + logger.error('Failed to get notifications!', payload); + fireErrorMessage( + 'ERROR: Failed to load the notifications', + 'Please, try again a bit later', + ); + return { + ...state, + fetchNotificationsFailure: true, + items: [], + }; + } + + return { + ...state, + items: payload, + fetchNotificationsFailure: false, + }; +} + +/** + * Handles NOTIFICATIONS/MARK_NOTIFICATION_AS_INIT action. + * @param {Object} state + * @return {Object} New state + */ +function onMarkNotificationAsReadInit(state) { + return { ...state }; +} + +/** + * Handles NOTIFICATIONS/MARK_NOTIFICATION_AS_DONE action. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onMarkNotificationAsReadDone(state, { error, payload }) { + if (error) { + logger.error('Failed to mark notification as read!', payload); + fireErrorMessage( + 'ERROR: Failed to mark the notification as read', + 'Please, try again a bit later', + ); + return { + ...state, + fetchNotificationsFailure: true, + }; + } + + const notifications = state.items; + const itemIndex = state.items.findIndex(item => item.id === payload.id); + notifications[itemIndex].isRead = true; + + return { + ...state, + fetchNotificationsFailure: false, + items: notifications, + }; +} + + +/** + * Handles NOTIFICATIONS/MARK_ALL_NOTIFICATION_AS_READ_INIT action. + * @param {Object} state + * @return {Object} New state + */ +function onMarkAllNotificationAsReadInit(state) { + return { ...state }; +} + +/** + * Handles NOTIFICATIONS/MARK_ALL_NOTIFICATION_AS_READ_DONE action. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onMarkAllNotificationAsReadDone(state, { error, payload }) { + if (error) { + logger.error('Failed to mark notification as read!', payload); + fireErrorMessage( + 'ERROR: Failed to mark the notification as read', + 'Please, try again a bit later', + ); + return { + ...state, + fetchNotificationsFailure: true, + }; + } + + const notifications = state.items; + notifications.forEach((item, index) => { + notifications[index].isRead = true; + }); + + return { + ...state, + fetchNotificationsFailure: true, + items: notifications, + }; +} + +/** + * Handles NOTIFICATIONS/MARK_ALL_NOTIFICATION_AS_SEEN_INIT action. + * @param {Object} state + * @return {Object} New state + */ +function onMarkAllNotificationAsSeenInit(state) { + return { ...state }; +} + +/** + * Handles NOTIFICATIONS/MARK_ALL_NOTIFICATION_AS_SEEN_DONE action. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onMarkAllNotificationAsSeenDone(state, { error, payload }) { + if (error) { + logger.error('Failed to mark notification as seen!', payload); + fireErrorMessage( + 'ERROR: Failed to mark the notification as seen', + 'Please, try again a bit later', + ); + return { + ...state, + fetchNotificationsFailure: true, + }; + } + + const items = payload.split('-'); + const notifications = state.items; + state.items.forEach((item, index) => { + if (items.includes(String(item.id))) { + notifications[index].isSeen = true; + } + }); + + return { + ...state, + fetchNotificationsFailure: false, + items: notifications, + }; +} + +/** + * Handles NOTIFICATIONS/DISMISS_CHALLENGE_NOTIFICATIONS_INIT action. + * @param {Object} state + * @return {Object} New state + */ +function onDismissChallengeNotificationsInit(state) { + return { ...state }; +} + +/** + * Handles NOTIFICATIONS/DISMISS_CHALLENGE_NOTIFICATIONS_DONE action. + * @param {Object} state + * @param {Object} action + * @return {Object} New state. + */ +function onDismissChallengeNotificationsDone(state, { error, payload }) { + if (error) { + logger.error('Failed to dismiss notification!', payload); + fireErrorMessage( + 'ERROR: Failed to dismiss the notification', + 'Please, try again a bit later', + ); + return { + ...state, + fetchNotificationsFailure: true, + items: [], + }; + } + + return { + ...state, + fetchNotificationsFailure: false, + }; +} + + +/** + * Creates a new Members reducer with the specified initial state. + * @param {Object} initialState Optional. Initial state. + * @return {Function} Members reducer. + */ +function create(initialState = {}) { + const a = actions.notifications; + return handleActions({ + [a.getNotificationsInit]: onGetNotificationsInit, + [a.getNotificationsDone]: onGetNotificationsDone, + [a.markNotificationAsReadInit]: onMarkNotificationAsReadInit, + [a.markNotificationAsReadDone]: onMarkNotificationAsReadDone, + [a.markAllNotificationAsReadInit]: onMarkAllNotificationAsReadInit, + [a.markAllNotificationAsReadDone]: onMarkAllNotificationAsReadDone, + [a.markAllNotificationAsSeenInit]: onMarkAllNotificationAsSeenInit, + [a.markAllNotificationAsSeenDone]: onMarkAllNotificationAsSeenDone, + [a.dismissChallengeNotificationsInit]: onDismissChallengeNotificationsInit, + [a.dismissChallengeNotificationsDone]: onDismissChallengeNotificationsDone, + }, initialState); +} + +/** + * Factory which creates a new reducer with its initial state tailored to the + * given options object, if specified (for server-side rendering). If options + * object is not specified, it creates just the default reducer. Accepted options are: + * @return {Promise} + * @resolves {Function(state, action): state} New reducer. + */ +export function factory() { + return Promise.resolve(create()); +} + +/** + * @static + * @member default + * @desc Reducer with default initial state. + */ +export default create(); diff --git a/src/services/index.js b/src/services/index.js index d6b5993b..76e2c457 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -16,6 +16,7 @@ import * as lookup from './lookup'; import * as userTraits from './user-traits'; import * as submissions from './submissions'; import * as memberSearch from './member-search'; +import * as notifications from './notifications'; export const services = { api, @@ -33,6 +34,7 @@ export const services = { userTraits, submissions, memberSearch, + notifications, }; export default undefined; diff --git a/src/services/notifications.js b/src/services/notifications.js new file mode 100644 index 00000000..94d3ec9d --- /dev/null +++ b/src/services/notifications.js @@ -0,0 +1,83 @@ +/** + * @module "services.notifications" + * @desc This module provides a service for searching for Topcoder + * notifications. + */ + +import { getApi } from './api'; + +/** + * Service class for Notifications. + */ +class NotificationService { + /** + * @param {String} tokenV3 Optional. Auth token for Topcoder API v5. + */ + constructor(tokenV3) { + this.private = { + apiV5: getApi('V5', tokenV3), + tokenV3, + }; + } + + /** + * Gets member's notification information. + * @return {Promise} Resolves to the notification information object. + */ + async getNotifications() { + return this.private.apiV5.get('/notifications/?platform=community&limit=20') + .then(res => (res.ok ? res.json() : new Error(res.statusText))); + } + + /** + * Marks given notification as read. + * @return {Promise} Resolves to the notification information object. + */ + async markNotificationAsRead(item) { + return this.private.apiV5.put(`/notifications/${item}/read`) + .then(res => (res.ok ? null : Promise.reject(new Error(res.statusText)))); + } + + /** + * Marks all notification as read. + * @return {Promise} Resolves to the notification information object. + */ + async markAllNotificationAsRead() { + return this.private.apiV5.put('/notifications/read') + .then(res => (res.ok ? null : Promise.reject(new Error(res.statusText)))); + } + + /** + * Marks all notification as seen. + * @return {Promise} Resolves to the notification information object. + */ + async markAllNotificationAsSeen(items) { + return this.private.apiV5.put(`/notifications/${items}/seen`) + .then(res => (res.ok ? null : Promise.reject(new Error(res.statusText)))); + } + + /** + * Dismiss challenge notifications. + * @return {Promise} Resolves to the notification information object. + */ + async dismissChallengeNotifications(challengeID) { + return this.private.apiV5.put(`/notifications/${challengeID}/dismiss`) + .then(res => (res.ok ? null : Promise.reject(new Error(res.statusText)))); + } +} + +let lastInstance = null; +/** + * Returns a new or existing notifications service. + * @param {String} tokenV3 Optional. Auth token for Topcoder API v3. + * @return {NotificationService} Notification service object + */ +export function getService(tokenV3) { + if (!lastInstance || tokenV3 !== lastInstance.private.tokenV3) { + lastInstance = new NotificationService(tokenV3); + } + return lastInstance; +} + +/* Using default export would be confusing in this case. */ +export default undefined;