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) => {
-
@@ -127,6 +137,24 @@ const createTask = async (task) => {
Deadline set to {deadlineDateString}
+
+
+ {#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) => {