Building Google Docs Like Collaboration Tool With Integrated Notification Service for Cross-User and Batched Notifications

Sanjeev Kumar
February 10, 2024
TABLE OF CONTENTS

Google Docs is a rich collaborative application used by millions worldwide. To foster collaboration, it used various product notifications such as digests, batching, cross-user updates, and more. Building such rich notifications functionality is hard, and that is where you can use SuprSend Node SDK to integrate all such notificaton functionalities in your collaboration tool. In this article we will create DoodleDocs, a Google Docs like application with integrated notification service and functionalities like cross-user notifications, batching and preference management all via API.

Technology Stack

  • Frontend - React.js, Bootstrap
  • Backend - Node.js
  • Database -MongoDB
  • Notification Service -SuprSend NodeSDK

File structure

Application features:

  • Login and Register on the site.
  • Three type of mail service:  DocCreated, Sharedoc, DocEdit
  • A user can turn off any of the notification category of his choice through preference center.
  • A user can select/deselect any notification type.
  • User can share the doc to any one and now the doc will be saved on their storage and they
  • Will be able to read and write it will not change the original document of the owner though.
  • User can create as many doc, can delete or edit any doc.
Deployed Application: Doodle Docs
Github: SuprSend-NotificationAPI/Suprsend-DoodleDocs: Doodle Docs (github.com)

App UI

Login Page

A screenshot of a login pageDescription automatically generated

Register Page

A screenshot of a computerDescription automatically generated

When Logged in

A screenshot of a computerDescription automatically generated

Change User-preference setting

If any user clicks the User Preference option, then the user will be redirected to the user-preference page where user can change their preferences. You can create a hosted preference management center with SuprSend.

A screenshot of a chatDescription automatically generated

A white text box with black linesDescription automatically generated

Now the company can set default options also

A screenshot of a computerDescription automatically generated

All Docs Page

Here it also shows whether you are the owner of the doc or not.

A screenshot of a computerDescription automatically generated

Single Doc Page

User can click on any doc and open it user will get three options if he is the owner of the doc. They can also delete, share and save.

Frontend Code

The frontend code is available on our Github - SuprSend-NotificationAPI/Suprsend-DoodleCalender: Doodle Calender

Let's setup the preferences, and doodledocs pages.

Preferences.js

  
      import { useState, useEffect } from "react";
      import Switch from "react-switch";
      import suprsend, { ChannelLevelPreferenceOptions, PreferenceOptions } from "@suprsend/web-sdk";

      const handleCategoryPreferenceChange = (data, subcategory, setPreferenceData) => {
          const resp = suprsend.user.preferences.update_category_preference(subcategory.category, data ? PreferenceOptions.OPT_IN : PreferenceOptions.OPT_OUT);
          if (!resp.error) setPreferenceData({ ...resp });
      };

      const handleChannelPreferenceInCategoryChange = (channel, subcategory, setPreferenceData) => {
          if (!channel.is_editable) return;
          const resp = suprsend.user.preferences.update_channel_preference_in_category(channel.channel, channel.preference === PreferenceOptions.OPT_IN ? PreferenceOptions.OPT_OUT : PreferenceOptions.OPT_IN, subcategory.category);
          if (!resp.error) setPreferenceData({ ...resp });
      };

      const NotificationCategoryPreferences = ({ preferenceData, setPreferenceData }) => {
          if (!preferenceData.sections) return null;
          return preferenceData.sections?.map((section, index) => (
              
{section?.name === "DoodleDocs" && (

{section.name}

{section.description}

)} {section.name === "DoodleDocs" && section?.subcategories?.map((subcategory, index) => (

{subcategory.name}

{subcategory.description}

handleCategoryPreferenceChange(data, subcategory, setPreferenceData)} uncheckedIcon={false} checkedIcon={false} height={20} width={40} onColor="#2563EB" checked={subcategory.preference === PreferenceOptions.OPT_IN} />
{subcategory?.channels.map((channel, index) => ( handleChannelPreferenceInCategoryChange(channel, subcategory, setPreferenceData)} /> ))}
))}
)); }; const handleOverallChannelPreferenceChange = (channel, status, setPreferenceData) => { const resp = suprsend.user.preferences.update_overall_channel_preference(channel.channel, status); if (!resp.error) setPreferenceData({ ...resp }); }; const ChannelLevelPreferences = ({ preferenceData, setPreferenceData }) => (

What notifications to allow for channel?

{preferenceData.channel_preferences ? ( preferenceData.channel_preferences?.map((channel, index) => ( )) ) : (

No Data

)}
); const ChannelLevelPreferencesItem = ({ channel, setPreferenceData }) => { const [isActive, setIsActive] = useState(false); const handleClick = () => setIsActive(!isActive); const handlePreferenceChange = (status) => handleOverallChannelPreferenceChange(channel, status, setPreferenceData); return (

{channel.channel}

{channel.is_restricted ? "Allow required notifications only" : "Allow all notifications"}

{isActive && (

{channel.channel} Preferences

handlePreferenceChange(ChannelLevelPreferenceOptions.ALL)} description="Allow All Notifications, except the ones that I have turned off" /> handlePreferenceChange(ChannelLevelPreferenceOptions.REQUIRED)} description="Allow only important notifications related to account and security settings" />
)}
); }; const RadioOption = ({ label, checked, onChange, description }) => (

{description}

); export default function Preferences() { const [preferenceData, setPreferenceData] = useState(); useEffect(() => { suprsend.user.preferences.get_preferences().then((resp) => { if (!resp.error) setPreferenceData(resp); }); suprsend.emitter.on("preferences_updated", (preferenceData) => { setPreferenceData({ ...preferenceData }); }); suprsend.emitter.on("preferences_error", (error) => { console.log("ERROR:", error); }); }, []); if (!preferenceData) return

Loading...

; return (

Notification Preferences

); }; function Checkbox({ title, value, onClick, disabled }) { const selected = value === PreferenceOptions.OPT_IN; return (

{title}

); } function Circle({ selected, disabled }) { const bgColor = selected ? (disabled ? "#BDCFF8" : "#2463EB") : (disabled ? "#D0CFCF" : "#FFF"); return
; }

You can check the codes for context/ document, src files on the Github repo. Let's go to creating and integrating the backend.

  
      // Middleware/fetchuser.js

      require("dotenv").config();
      var jwt = require("jsonwebtoken");
      const JWT_SECRET = process.env.JWT_SECRET;

      const fetchuser = (req, res, next) => {
          // Get the user from the jwt token and add id to request object
          const token = req.header("auth-token");
          if (!token) {
              res.status(401).send({ error: "Please authenticate using a valid token" });
          }
          try {
              const data = jwt.verify(token, JWT_SECRET);
              req.user = data.user;
              next();
          } catch (error) {
              res.status(401).send({ error: "Please authenticate using a valid token" });
          }
      };

      module.exports = fetchuser;


      // Models/user.js

      const mongoose = require('mongoose');
      const { Schema, model } = mongoose;

      const UserSchema = new Schema({
          email: { type: String },
          name: String,
          phone: { type: Number },
          password: String
      });

      const UserModel = model('User', UserSchema);

      module.exports = UserModel;


      // Models/doc.js

      const mongoose = require("mongoose");
      const { Schema } = mongoose;

      const collaboratorSchema = new mongoose.Schema({
          user: {
              type: mongoose.Schema.Types.ObjectId,
              ref: 'user',
              required: true,
          },
      });

      const DocSchema = new Schema({
          author: {
              type: mongoose.Schema.Types.ObjectId,
              ref: "user",
          },
          textfile: {
              type: String,
          },
          collaborators: [collaboratorSchema],
          date: {
              type: Date,
              default: Date.now,
          },
          updatedAt: {
              type: Date,
              default: Date.now,
          },
      });

      module.exports = mongoose.model("Docs", DocSchema);


      // Connection to database db.js

      require("dotenv").config();
      const mongoose = require("mongoose");

      const connectToMongo = async () => {
          const URI = process.env.MONGO_URI;
          mongoose.connect(URI, { useNewUrlParser: true });
      };

      module.exports = connectToMongo;                  
        
    

Setting backend with Routes

  
      // Require statements
      require("dotenv").config();
      const express = require("express");
      const jwt = require("jsonwebtoken");
      const cors = require('cors');
      const { Suprsend, Event } = require("@suprsend/node-sdk");
      const connectToMongo = require("./db");
      const fetchUser = require("./middleware/fetchUser");
      const User = require("./models/user");
      const Docs = require("./models/doc");

      // Connect to MongoDB
      connectToMongo();

      // Initialize Express app
      const app = express();
      const port = 4000;

      // Suprsend client setup
      const supr_client = new Suprsend(process.env.WKEY, process.env.WSECRET);

      // Middleware setup
      app.use(cors());
      app.use(express.json());

      // Register route
      app.post("/register", async (req, res) => {
          let success = false;
          try {
              const user = await User.create({
                  email: req.body.email,
                  name: req.body.name,
                  phone: req.body.phone,
                  password: req.body.password
              });
              const data = { user: { id: user.id } };
              const authtoken = jwt.sign(data, process.env.JWT_SECRET);
              success = true;
              const distinct_id = user.email;
              const user1 = supr_client.user.get_instance(distinct_id);
              user1.add_email(user.email);
              user1.add_sms("+" + user.phone);
              user1.add_whatsapp("+" + user.phone);
              const response = user1.save();
              response.then((res) => console.log("response", res));
              res.json({ success, authtoken });
          } catch (error) {
              console.error(error.message);
              res.status(500).send("Some error occurred");
          }
      });

      // Login route
      app.post("/login", async (req, res) => {
          const { email, password } = req.body;
          let success = false;
          try {
              let user = await User.findOne({ email: email });
              if (!user) return res.status(400).json({ success, message: "User does not exist" });
              if (password != user.password) return res.status(400).json({ success, message: "Incorrect password" });
              const data = { user: { id: user.id } };
              const authtoken = jwt.sign(data, process.env.JWT_SECRET);
              success = true;
              res.json({ success, authtoken });
          } catch (error) {
              console.error(error.message);
              res.status(500).send("Some error occurred");
          }
      });

      // Fetch all docs route
      app.get("/fetchalldocs", fetchUser, async (req, res) => {
          try {
              const docs = await Docs.find({ collaborators: { $elemMatch: { user: req.user.id } } }).sort({ updatedAt: -1 });
              res.json(docs);
          } catch (error) {
              console.error(error.message);
              res.status(500).send("Some error occurred");
          }
      });

      // Add docs route
      app.post("/adddoc", fetchUser, async (req, res) => {
          try {
              const currentDate = new Date();
              const { textfile } = req.body;
              const doc = new Docs({
                  author: req.user.id,
                  textfile: textfile,
                  collaborators: [{ user: req.user.id }],
                  updatedAt: currentDate,
              });
              const savedDoc = await doc.save();
              res.json(savedDoc);
          } catch (error) {
              console.error(error.message);
              res.status(500).send("Some error occurred");
          }
      });

      // Edit docs route
      app.put("/updatedoc/:id", async (req, res) => {
          try {
              let doc = await Docs.findById(req.params.id);
              if (!doc) return res.status(404).send("Not Found");
              doc = await Docs.findByIdAndUpdate(req.params.id, { textfile: req.body.textfile, updatedAt: new Date() }, { new: true });
              for (const collaborator of doc.collaborators) {
                  const collaboratorId = collaborator.user;
                  const user = await User.findById(collaboratorId);
                  const distinct_id = user.email;
                  const event_name = "DOCEDIT";
                  const properties = { "name": req.params.id };
                  const event = new Event(distinct_id, event_name, properties);
                  const response = supr_client.track_event(event);
                  response.then((res) => console.log("response", res));
              }
              res.json({ doc });
          } catch (error) {
              console.error(error.message);
              res.status(500).send("Some error occurred");
          }
      });

      // Start server
      app.listen(port, () => {
          console.log(`Server is running on port ${port}`);
      });               
        
    

Nows let's create the code for getting the document owner, with id, deleting document and sharing.

  
      // Get doc owner route
      app.post("/getdocowner", async (req, res) => {
          try {
              const user = await User.findById(req.body.id);
              res.json(user);
          } catch (error) {
              console.error(error.message);
              res.status(500).send("Some error occurred");
          }
      });

      // Delete a doc route
      app.delete("/deletedoc/:id", async (req, res) => {
          try {
              let doc = await Docs.findById(req.params.id);
              if (!doc) return res.status(404).send("Not Found");
              doc = await Docs.findByIdAndDelete(req.params.id, { textfile: req.textfile });
              res.json({ doc });
          } catch (error) {
              console.error(error.message);
              res.status(500).send("Some error occurred");
          }
      });

      // Get doc with id route
      app.get("/getdoc/:id", async (req, res) => {
          try {
              let doc = await Docs.findById(req.params.id);
              if (!doc) return res.status(404).send("Not found");
              res.json({ doc });
          } catch (error) {
              console.error(error.message);
              res.status(500).send("Some error occurred");
          }
      });

      // Share a doc route
      app.post("/sharedoc", fetchUser, async (req, res) => {
          try {
              let success = false;
              const { share, docid } = req.body;
              let user = await User.findOne({ email: share });
              if (!user) return res.status(404).json({ success, message: "No such user exists" });
              let user2 = await User.findById(req.user.id);
              let doc = await Docs.findById(docid);
              if (!doc) return res.status(404).json({ success, error: 'Document not found' });
              doc.collaborators.push({ user: user._id });
              await doc.save();
              success = true;
              const distinct_id = user.email;
              const event_name = "DOCSHARE";
              const properties = { "recep": user.name, "owner": user2.name };
              const event = new Event(distinct_id, event_name, properties);
              const response = supr_client.track_event(event);
              response.then((res) => console.log("response", res));
              return res.json({ success, doc });
          } catch (error) {
              console.error(error.message);
              return res.status(500).send("Some error occurred");
          }
      });

      // Start server
      app.listen(port, () => {
          console.log("Server started on port 4000");
      });        
        
    

Now you can create templates and workflows directly using SuprSend API's and trigger it when someone used your DoodleDocs.

Here's a sample batched event.

Written by:
Sanjeev Kumar
Engineering, SuprSend
Implement a powerful stack for your notifications
By clicking “Accept All Cookies”, you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy for more information.