From 58f594953e3e6dfadbc9ff9922880fb8941cdb05 Mon Sep 17 00:00:00 2001 From: Michael Bailly Date: Thu, 26 Nov 2020 00:03:14 +0100 Subject: [PATCH] Add Notification Read only --- .../core/commands/user-notification.js | 38 +++++ src/backend/core/events/email/share.js | 29 +++- src/backend/core/events/index.js | 2 + src/backend/core/events/task.js | 13 ++ src/backend/core/events/user-notification.js | 35 +++++ src/components/Modal.svelte | 6 +- src/components/Nav.svelte | 2 + src/libs/modal/modalService.js | 8 +- src/libs/notificationProvider.js | 62 ++++++++ src/libs/tasks/tasksProvider.js | 136 ++++++++++++++++++ src/libs/users.js | 6 +- src/routes/_components/ClickOutside.svelte | 36 +++++ .../EmailView/EmailViewActionButton.svelte | 36 +++-- .../EmailView/EmailViewShare.svelte | 3 +- src/routes/_components/LeftMenu.svelte | 7 +- .../Notification/Activity/Activity.svelte | 14 ++ .../Activity/ActivityTemplate.svelte | 40 ++++++ .../Notification/Activity/EmailShared.svelte | 18 +++ .../Activity/EmailTaskCreated.svelte | 18 +++ .../_components/Notification/NavButton.svelte | 60 ++++++++ src/routes/_components/Task/CreateForm.svelte | 62 +++++--- .../_components/Task/HumanDeadline.svelte | 10 ++ src/routes/api/notifications.js | 67 +++++++++ src/routes/api/sse/[clientId].js | 10 ++ src/routes/api/tasks/[emailId]/index.js | 4 +- src/routes/api/tasks/index.js | 2 +- src/routes/inbox/[folder]/index.svelte | 4 - .../inbox/labels/[labelName]/index.svelte | 4 - .../tasks/_components/TaskBox.svelte | 7 +- src/routes/tasks/_layout.svelte | 46 ++++++ src/routes/{inbox => }/tasks/index.svelte | 19 ++- src/shared/activity.js | 15 ++ src/shared/email-head.js | 89 ++++++++++++ src/shared/task.js | 8 ++ src/shared/user-notification.js | 43 ++++++ 35 files changed, 897 insertions(+), 62 deletions(-) create mode 100644 src/backend/core/commands/user-notification.js create mode 100644 src/backend/core/events/user-notification.js create mode 100644 src/libs/notificationProvider.js create mode 100644 src/libs/tasks/tasksProvider.js create mode 100644 src/routes/_components/ClickOutside.svelte create mode 100644 src/routes/_components/Notification/Activity/Activity.svelte create mode 100644 src/routes/_components/Notification/Activity/ActivityTemplate.svelte create mode 100644 src/routes/_components/Notification/Activity/EmailShared.svelte create mode 100644 src/routes/_components/Notification/Activity/EmailTaskCreated.svelte create mode 100644 src/routes/_components/Notification/NavButton.svelte create mode 100644 src/routes/_components/Task/HumanDeadline.svelte create mode 100644 src/routes/api/notifications.js rename src/routes/{inbox => }/tasks/_components/TaskBox.svelte (86%) create mode 100644 src/routes/tasks/_layout.svelte rename src/routes/{inbox => }/tasks/index.svelte (84%) create mode 100644 src/shared/activity.js create mode 100644 src/shared/email-head.js create mode 100644 src/shared/user-notification.js diff --git a/src/backend/core/commands/user-notification.js b/src/backend/core/commands/user-notification.js new file mode 100644 index 0000000..da9a0d3 --- /dev/null +++ b/src/backend/core/commands/user-notification.js @@ -0,0 +1,38 @@ +import { ObjectId } from 'bson'; +import UserNotification from '../../../shared/user-notification'; +import sendEvent from '../../kafka/events/producer'; +import KafkaMessage from '../../kafka/kafka-message'; +import logger from '../logger'; +/** + * A user notification is associated with an activity + * + * + * Should have: + * - activity: Object notification activity + * - user: Actor notification user target + * + * may have: + * - emailId(String) and email(EmailHead) + * + */ + +const debug = logger.extend('commands:user-notification'); + +export async function createUserNotification(notification, sender) { + let userNotification; + const _id = new ObjectId(); + try { + userNotification = UserNotification.fromObject({ ...notification, _id }); + } catch (e) { + debug('Bad parameter: %O', e.message); + throw e; + } + + const kafkaMessage = KafkaMessage.fromObject(userNotification.user._id, { + event: 'user:notification:create', + sender, + payload: userNotification, + }); + + await sendEvent(kafkaMessage); +} diff --git a/src/backend/core/events/email/share.js b/src/backend/core/events/email/share.js index 8e5458c..ec8f8d2 100644 --- a/src/backend/core/events/email/share.js +++ b/src/backend/core/events/email/share.js @@ -2,8 +2,12 @@ import sendNotification from '../../../kafka/notifications/producer'; import { dbCol } from '../../../mongodb'; import logger from '../../logger'; import EmailShareActivity from '../../../../shared/email-share-activity'; +import EmailHead from '../../../../shared/email-head'; import Actor from '../../../../shared/actor'; import { getEmailIfAllowed } from '../../../api-middleware/email-permission'; +import KafkaMessage from '../../../kafka/kafka-message'; +import UserNotification from '../../../../shared/user-notification'; +import { createUserNotification } from '../../commands/user-notification'; const debug = logger.extend('events:email:share'); @@ -32,6 +36,25 @@ export async function emailShareReceiver(kafkaMessage) { return false; } + const updated = await updateEmailDocument(collection, email, activity, target); + if (!updated) { + return; + } + const notificationMessage = kafkaMessage.setEvent(NOTIFICATION_NAME); + await sendNotification(notificationMessage); + + const userNotification = UserNotification.fromObject({ + activity, + user: target, + seen: false, + email: EmailHead.fromEmail(email.email), + emailId: email._id, + }); + + await createUserNotification(userNotification, kafkaMessage.sender()); +} + +const updateEmailDocument = async (collection, email, activity, target) => { try { const { modifiedCount } = await collection.updateOne( { _id: email._id }, @@ -51,10 +74,8 @@ export async function emailShareReceiver(kafkaMessage) { debug('MongoDB document update failed: %s %s', e.message, e.stack); return false; } - - const notificationMessage = kafkaMessage.setEvent(NOTIFICATION_NAME); - sendNotification(notificationMessage); -} + return true; +}; export const EVENTS = { 'email:share': emailShareReceiver, diff --git a/src/backend/core/events/index.js b/src/backend/core/events/index.js index e6ca046..e13d8da 100644 --- a/src/backend/core/events/index.js +++ b/src/backend/core/events/index.js @@ -10,6 +10,7 @@ import { EVENTS as EVENTS9 } from './email/label-add'; import { EVENTS as EVENTS10 } from './chat-message/last-seen-pointer-update'; import { EVENTS as EVENTS11 } from './task'; import { EVENTS as EVENTS12 } from './email/user-state'; +import { EVENTS as EVENTS13 } from './user-notification'; export const eventsListeners = {}; @@ -25,3 +26,4 @@ Object.keys(EVENTS9).forEach((k) => (eventsListeners[k] = EVENTS9[k])); Object.keys(EVENTS10).forEach((k) => (eventsListeners[k] = EVENTS10[k])); Object.keys(EVENTS11).forEach((k) => (eventsListeners[k] = EVENTS11[k])); Object.keys(EVENTS12).forEach((k) => (eventsListeners[k] = EVENTS12[k])); +Object.keys(EVENTS13).forEach((k) => (eventsListeners[k] = EVENTS13[k])); diff --git a/src/backend/core/events/task.js b/src/backend/core/events/task.js index 6c0b3cf..e9651ca 100644 --- a/src/backend/core/events/task.js +++ b/src/backend/core/events/task.js @@ -2,8 +2,10 @@ import { ObjectId } from 'bson'; import Task from '../../../shared/task'; import TaskCreatedActivity from '../../../shared/task-created-activity'; import TaskDoneStatusUpdatedActivity from '../../../shared/task-done-status-updated-activity'; +import UserNotification from '../../../shared/user-notification'; import { dbCol } from '../../mongodb'; import { recordActivity } from '../activity'; +import { createUserNotification } from '../commands/user-notification'; import logger from '../logger'; const debugF = logger.extend('events:task'); @@ -35,6 +37,17 @@ export async function taskCreateReceiver(kafkaMessage) { const activity = TaskCreatedActivity.fromKafkaMessage(kafkaMessage); await recordActivity(activity, task.emailId, true); + + if (activity.actor._id !== activity.target._id) { + debug('creating user notification for user %s, task %s', activity.target.email, task.description); + const userNotification = UserNotification.fromObject({ + activity, + user: activity.target, + seen: false, + emailId: task.emailId, + }); + await createUserNotification(userNotification, kafkaMessage.sender()); + } } export async function taskDoneStatusUpdateReceiver(kafkaMessage) { diff --git a/src/backend/core/events/user-notification.js b/src/backend/core/events/user-notification.js new file mode 100644 index 0000000..6acdd37 --- /dev/null +++ b/src/backend/core/events/user-notification.js @@ -0,0 +1,35 @@ +import logger from '../logger'; +import UserNotification from '../../../shared/user-notification'; +import { dbCol } from '../../mongodb'; +import { ObjectId } from 'bson'; +import sendNotification from '../../kafka/notifications/producer'; + +const debug = logger.extend('events:user-notifications'); + +export async function userNotificationCreateReceiver(kafkaMessage) { + let userNotification; + try { + userNotification = UserNotification.fromObject(kafkaMessage.payload()); + } catch (e) { + debug('Invalid payload %s: %O', e.message, kafkaMessage.payload()); + } + + try { + const collection = await dbCol('userNotifications'); + const { insertedCount } = await collection.insertOne({ ...userNotification, _id: new ObjectId(userNotification._id) }); + if (insertedCount !== 1) { + throw new Error('No document have been inserted in the datastore.'); + } + } catch (e) { + debug('Can not insert new user notification: %s', e.message); + return; + } + + const notification = kafkaMessage.setEvent('user:notification:created'); + + await sendNotification(notification); +} + +export const EVENTS = { + 'user:notification:create': userNotificationCreateReceiver, +}; diff --git a/src/components/Modal.svelte b/src/components/Modal.svelte index f2ec715..757b008 100644 --- a/src/components/Modal.svelte +++ b/src/components/Modal.svelte @@ -120,8 +120,12 @@ } }; + const isOpened = () => { + return !!Component; + } + setContext(key, { open, close }); - registerModal(open, close); + registerModal(open, close, isOpened); diff --git a/src/routes/_components/Notification/Activity/EmailShared.svelte b/src/routes/_components/Notification/Activity/EmailShared.svelte new file mode 100644 index 0000000..eef01aa --- /dev/null +++ b/src/routes/_components/Notification/Activity/EmailShared.svelte @@ -0,0 +1,18 @@ + + + + + shared the email {userNotification.email.subject} with you. + + + + + + New shared email {userNotification.email.subject} + + diff --git a/src/routes/_components/Notification/Activity/EmailTaskCreated.svelte b/src/routes/_components/Notification/Activity/EmailTaskCreated.svelte new file mode 100644 index 0000000..3f47791 --- /dev/null +++ b/src/routes/_components/Notification/Activity/EmailTaskCreated.svelte @@ -0,0 +1,18 @@ + + + + + created a task on email {userNotification.activity.task.email.subject} and assigned it to you. + + + + + + New task {userNotification.activity.task.description} + + diff --git a/src/routes/_components/Notification/NavButton.svelte b/src/routes/_components/Notification/NavButton.svelte new file mode 100644 index 0000000..99ad66e --- /dev/null +++ b/src/routes/_components/Notification/NavButton.svelte @@ -0,0 +1,60 @@ + + + + + + + diff --git a/src/routes/_components/Task/CreateForm.svelte b/src/routes/_components/Task/CreateForm.svelte index 050c10e..1885816 100644 --- a/src/routes/_components/Task/CreateForm.svelte +++ b/src/routes/_components/Task/CreateForm.svelte @@ -16,6 +16,7 @@ export let description = ''; let selectedValue = { ...selectedUser, dn: getDisplayName(selectedUser) } ; + // ------------------ SELECT SPECIFIC VARIABLES const optionIdentifier = '_id'; @@ -37,7 +38,9 @@ const loadOptions = async (q) => { // --------------------------------------------- let deadlineDateString; +let isRecording = false; +$: assigneeIsInMail = email.users.concat(email.usersShared).some((id) => id === selectedValue._id); $: deadlineDate = add(new Date(), { weeks: deadlineScale === 'week' && deadlineCount || 0, days: deadlineScale === 'day' && deadlineCount || 0, @@ -48,10 +51,29 @@ $: deadlineDateString = format(deadlineDate, 'PPPP \'at\' HH\'h\''); $: canBeRecorded = selectedValue && selectedValue._id && description && description.length; const create = async () => { - if (!canBeRecorded) { + if (!canBeRecorded || isRecording) { return; } + isRecording = true; + + if (!assigneeIsInMail) { + try { + const response = await post(`/api/emails/${email._id}/share`, { + userIds: [selectedValue._id], + }); + if (!response || response.error) { + throw new Error(`Bad server response: ${JSON.stringify(response)}`); + } + } catch(e) { + console.log('Unable to share email: ', e); + isRecording = false; + return; + } + + await new Promise(resolve => setTimeout(resolve, 2000)); + } + await createTask({ assignee: selectedValue, deadline: { @@ -61,13 +83,14 @@ const create = async () => { }, description, }); - + isRecording = false; return onCreate(); } const createTask = async (task) => { let id; try { + const response = await post(`/api/tasks/${email._id}`, task); id = response._id; } catch(e) { @@ -83,19 +106,6 @@ const createTask = async (task) => {
-
-
- - -
-
-
- +
+
+
+ {#if !assigneeIsInMail} +

Email {email.email.subject} will be shared with {selectedValue.dn} first.

+ {/if} +
@@ -136,10 +164,10 @@ const createTask = async (task) => {
- +
- +
diff --git a/src/routes/_components/Task/HumanDeadline.svelte b/src/routes/_components/Task/HumanDeadline.svelte new file mode 100644 index 0000000..f7de5d3 --- /dev/null +++ b/src/routes/_components/Task/HumanDeadline.svelte @@ -0,0 +1,10 @@ + + +{human} diff --git a/src/routes/api/notifications.js b/src/routes/api/notifications.js new file mode 100644 index 0000000..c7fde66 --- /dev/null +++ b/src/routes/api/notifications.js @@ -0,0 +1,67 @@ +import { requireUser } from '../../backend/api-middleware/user'; +import { dbCol } from '../../backend/mongodb'; + +/** + * Get notifications for the current user + * + * @param {express.Request} req express.js request + * @param {express.Response} res express.js response + */ +export async function get(req, res) { + const currentUser = await requireUser(req, res); + if (!currentUser) { + return; + } + + try { + const collection = await dbCol('userNotifications'); + const notifications = await collection + .find({ 'user._id': currentUser._id, seen: false }) + .sort({ 'activity.date': -1 }) + .toArray(); + + return res.status(200).json(notifications); + } catch (e) { + return res.status(500).json({ error: e.message, stack: e.stack }); + } +} + +/** + * Update notification read status for the current user + * + * body JSON param: + * + * ```javascript + * { markReadUntil: String (Date.toIsoString) } + * ``` + * + * @param {express.Request} req express.js request + * @param {express.Response} res express.js response + */ +export async function post(req, res) { + const currentUser = await requireUser(req, res); + if (!currentUser) { + return; + } + + if (!req.body || !req.body.markReadUntil) { + return res.status(400).json({ error: 'Body should contain a markReadUntil property' }); + } + + const date = new Date(req.body.markReadUntil); + + if (isNaN(date.getTime())) { + return res.status(400).json({ error: 'Body markReadUntil property should be a valid date' }); + } + + try { + const collection = await dbCol('userNotifications'); + const { updatedCount } = collection.updateMany({ 'activity.date': { $lte: date } }, { + $set: { seen: true }, + }); + + return res.status(200).json({ updated: updatedCount }); + } catch (e) { + return res.status(500).json({ error: e.message, stack: e.stack }); + } +} diff --git a/src/routes/api/sse/[clientId].js b/src/routes/api/sse/[clientId].js index 8879f54..65d73d2 100644 --- a/src/routes/api/sse/[clientId].js +++ b/src/routes/api/sse/[clientId].js @@ -78,6 +78,8 @@ export async function get(req, res) { emailUserStateSeenUpdatedEvent(kafkaMessage, eventCallbackArgs); } else if (kafkaMessage.event() === 'email:task:done-status:updated') { emailTaskDoneStatusUpdatedEvent(kafkaMessage, eventCallbackArgs); + } else if (kafkaMessage.event() === 'user:notification:created') { + userNotificationCreatedEvent(kafkaMessage, eventCallbackArgs); } }); @@ -133,6 +135,13 @@ const emailDeliveredEvent = async (kafkaMessage, { user, send, debug }) => { send(kafkaMessage.event(), payload); }; +const sendToUser = async (kafkaMessage, { userId, send }) => { + const notification = kafkaMessage.payload(); + if (userId === notification.user._id) { + send(kafkaMessage.event(), notification); + } +}; + const emailSharedEvent = sendIfUserIsInEmail; const chatMessagePostedEvent = sendIfUserIsInEmail; const chatStartedEvent = sendIfUserIsInEmail; @@ -145,3 +154,4 @@ const taskCreatedEvent = sendIfUserIsInEmail; const emailUserAddedEvent = sendIfUserIsInEmail; const emailUserStateSeenUpdatedEvent = sendOnlyToSender; const emailTaskDoneStatusUpdatedEvent = sendIfUserIsInEmail; +const userNotificationCreatedEvent = sendToUser; diff --git a/src/routes/api/tasks/[emailId]/index.js b/src/routes/api/tasks/[emailId]/index.js index e1ec190..d53d65a 100644 --- a/src/routes/api/tasks/[emailId]/index.js +++ b/src/routes/api/tasks/[emailId]/index.js @@ -2,6 +2,7 @@ import { ObjectId } from 'mongodb'; import { getEmailIfAllowed } from '../../../../backend/api-middleware/email-permission'; import { createTask } from '../../../../backend/core/commands/task'; import Actor from '../../../../shared/actor'; +import EmailHead from '../../../../shared/email-head'; export async function post(req, res) { if (!req.session.user) { @@ -10,7 +11,7 @@ export async function post(req, res) { const currentUser = req.session.user; const emailId = req.params.emailId; - const email = getEmailIfAllowed(currentUser, emailId, res); + const email = await getEmailIfAllowed(currentUser, emailId, res); if (!email) { return; } @@ -30,6 +31,7 @@ export async function post(req, res) { deadline: req.body.deadline, description: req.body.description, emailId, + email: EmailHead.fromEmail(email.email), done: false, date: new Date(), }; diff --git a/src/routes/api/tasks/index.js b/src/routes/api/tasks/index.js index d46e327..0231105 100644 --- a/src/routes/api/tasks/index.js +++ b/src/routes/api/tasks/index.js @@ -63,7 +63,7 @@ export async function get(req, res) { } let done = TASK_DONE.both; - if (req.query.done) { + if ('done' in req.query) { if (!(req.query.done in TASK_DONE)) { return res.status(400).json({ error: `done query string parameter should be in [${Object.keys(TASK_DONE).join(', ')}]` }); } diff --git a/src/routes/inbox/[folder]/index.svelte b/src/routes/inbox/[folder]/index.svelte index 76827c6..5017280 100644 --- a/src/routes/inbox/[folder]/index.svelte +++ b/src/routes/inbox/[folder]/index.svelte @@ -67,8 +67,4 @@ $: { align-items: center; width: 100%; } - -activity-container { - -} diff --git a/src/routes/inbox/labels/[labelName]/index.svelte b/src/routes/inbox/labels/[labelName]/index.svelte index 49d83ca..2d5b71d 100644 --- a/src/routes/inbox/labels/[labelName]/index.svelte +++ b/src/routes/inbox/labels/[labelName]/index.svelte @@ -65,8 +65,4 @@ $: { align-items: center; width: 100%; } - -activity-container { - -} diff --git a/src/routes/inbox/tasks/_components/TaskBox.svelte b/src/routes/tasks/_components/TaskBox.svelte similarity index 86% rename from src/routes/inbox/tasks/_components/TaskBox.svelte rename to src/routes/tasks/_components/TaskBox.svelte index a4ef243..453b1ca 100644 --- a/src/routes/inbox/tasks/_components/TaskBox.svelte +++ b/src/routes/tasks/_components/TaskBox.svelte @@ -1,6 +1,7 @@ + + + + + Your tasks - SoBox + + +{#await Promise.all([loadLabels(), fetchEmails()])} +Loading your inbox... +{:then foo} +
+ + In layout +
+{/await} + + + diff --git a/src/routes/inbox/tasks/index.svelte b/src/routes/tasks/index.svelte similarity index 84% rename from src/routes/inbox/tasks/index.svelte rename to src/routes/tasks/index.svelte index 29d0f26..f73de7e 100644 --- a/src/routes/inbox/tasks/index.svelte +++ b/src/routes/tasks/index.svelte @@ -1,10 +1,10 @@
+ {#if $lateTasks.length} +
+
Late tasks
+
+ {#each $lateTasks as task} + + {/each} +
+
+ {/if} +
This week
{#if thisWeek.length} @@ -95,6 +105,7 @@ const formatDate = (date) => {