Creating realtime experiences in e-commerce with Fauna and Ably
This post refers to a previous version of FQL.
This post refers to a previous version of FQL (v4). For the most current version of the language, visit our FQL documentation.
This is a guest post authored by Tom Camp, DevRel Engineer at Ably.
As e-commerce has continued to mature, the level of service expected from consumers creates the need to build more reactive experiences. To meet those expectations, developers need to build websites that show accurate product and discount information in realtime. We’ll be using Fauna and Ably to build this site.
What we’ll be making is a simple website with the following features:
- Ability to browse all available items
- Each item can be viewed in further detail, with descriptions, price, etc., all being viewable
- Any changes to these details will be reflected in realtime to the customer
- Items can be purchased. There will be checks to ensure each purchase can be fulfilled, such as checking the existing stock to make sure users aren’t buying out-of-stock items
- The ability for a customer to browse their previous orders
Why Fauna?
Fauna is a distributed document-relational database delivered as a global API. It has a substantial free tierletting you start and not worry about cost for your website until your hit a certain scale. Its serverless nature ensures that you don't have to think about any database infrastructure or maintanance and it scales to match your traffic patterns - overall it lets you focus on shipping your website. Fauna delivers globally distributed ACID transactions by default and a document-relational data model, accommodating a global user base that has shifting access patterns and expects dynamic experiences from e-commerce websites.
Its document-relational attributes, advanced querying, and event streaming, made it a perfect fit for us to host our e-commerce products, customers, and orders.
Why Ably?
Ably is a serverless pub/sub solution, making use of realtime protocols such as WebSockets to provide seamless communication between devices. With a strong focus on reliability, with exactly-once message delivery and message ordering consistency, Ably is a powerful match with Fauna.
Why Ably + Fauna?
Both Ably and Fauna are serverless solutions, taking away the complexities of hosting and managing your own messaging and database solutions, respectively. Both are designed to be massively scalable at what they do. With Fauna allowing for scalable and fast data querying and storage, Ably is the perfect match for interfacing and distributing events to clients. Fauna’s Event Streaming allows for realtime events to be communicated to Ably for distribution, allowing for minimum latencies in updates, utilizing the strengths of both services.
Creating our e-commerce site
Setting up Ably and Fauna Accounts
The first step is to create our Fauna and Ably accounts.
Setup Fauna Account
Firstly, you will need a Fauna account to create a new Database. Populate it with the demo data by ticking the 'Use demo data' button during this process. Also, ensure the region is 'Classic (C).'
If you choose to have the database in a specific region, you'll need to state this region in the .env
file at the base of this directory. This will be either eu
or us
as the FAUNA_REGION
value.
Once you have a database, create an API key for it with admin privilege by going to the 'Security' tab in the sidebar and selecting '+ New key' in the new tab.
Add this key to a .env file within the base of this directory as FAUNADB_API_KEY
.
Finally, we need to create a new Index in Fauna, which we can use to ensure we don't get customers with duplicate usernames. Within the Fauna database's Shell tab, run the following query:
CreateIndex({
name: "users_by_username",
permissions: { read: "public"},
source: Collection("users"),
terms: [{field: ["data", "username"]}],
unique: true,
});
Setup Ably Account
Next, we need to set up an Ably account. Sign up for an Ably account if you don't have one, then go to the Ably App you intend to use for this.
Within the app, go to the API key tab, and copy the Default API key's value. Paste this into the .env file you created earlier, with the name ABLY_API_KEY
.
Finally, we need to create some Channel Rules, which will allow for the last message on a channel to be persisted for up to a year. This is useful for ensuring that data is always accessible to our customers. Within the Ably App, go to the 'Settings' tab and the 'Channel Rules' section. Select 'Add new Rule', set the “Namespace' to be 'app,” and tick the “Persist last message” box. Finally, click 'Create channel rule.’
Creating a server
With our Fauna and Ably accounts created, we now need to create a server, which we will use to host our website, handle authentication with Ably, and act as a means of consuming from Fauna to re-distribute to Ably.
We’ll be using Node.js for this, so ensure your system has it installed.
Once you have it, create a new folder, and run npm init
to create a new Node.js project. You can use all the default values, except set the entry point as server.js
.
With the project created, let’s install the npm modules we’ll use.
npm install ably faunadb express cookie-parser
The Ably and Fauna packages will allow us to interact with those systems, respectively, express.js allows us to host a server easily, and cookie-parser will make it easier for our server to check our customer’s cookies.
Now, create a file called server.js
. Within this, add the following:
/* Start the Express.js web server */
const express = require('express'),
app = express(),
cookieParser = require('cookie-parser');
app.use(cookieParser());
app.use(express.static(__dirname + '/public'));
app.listen(process.env.PORT || 3000);
Here we’ve created our express.js server, which should, by default, run on port 3000. We’ve declared that it’ll make a folder called public
available via this port. If you run npm run start
at the base of your project, you should be able to try and access the server. As we’ve not currently got anything in a public
folder to return, it’ll fail to get anything. Let’s rectify that by creating the skeleton for our home page.
Creating the main page
This page will be how users will browse items and buy them.
Create a new folder called public
, and within it, add index.html
. Add the following code to it:
<html>
<head>
<link href='<https://fonts.googleapis.com/css?family=Actor>' rel='stylesheet'>
<link rel="stylesheet" type="text/css" href="/css/index.css">
</head>
<body>
<header>
<a class="home" href="/">
<h1>💸 Buy Things Inc</h1>
</a>
<nav>
<a href="/orders" class="orders">Your orders</a>
<form class="login-form" action="/login" id="panel-anonymous">
<input type="text" name="username" placeholder="Enter your username" class="login-text">
<input type="submit" value="Login" class="login-submit">
</form>
<div id="panel-logged-in">
You are logged in. <a href="/logout">Log out</a>
</div>
</nav>
</header>
<ul id="products" class="items"></ul>
<main class="item">
<div id="loader" class="loader" style="display: none"></div>
<p id="intro-section">Select a product from the sidebar to see details</p>
<div id="item-details" class="item-details" style="display: none">
<img id="item-image" src="<http://placekitten.com/600/400>" class="item-img">
<div class="item-info">
<h2 id="title" class="item-title"></h2>
<h3 id="description" class="item-description"></h3>
<p class="item-price">£<span id="price"></span></p>
<button onclick="order()" id="buy-button" class="buy">
Buy now
</button>
<p class="item-stock">Stock: <span id="quantity"></span></p>
<p class="item-stock" id="login-button-hint">
Login to add items to your cart
</p>
</div>
</div>
</main>
<footer>
<p>Made with <a href="<https://www.ably.com>">Ably</a> and <a href="<https://www.fauna.com>">Fauna</a></p>
</footer>
</body>
</html>
This page will be bland, so let’s create a css file for it. In the public
folder, add another folder called css
, and in that, add index.css
. In that file, add the contents of this css file from GitHub.
With that done, we have a basic website made. However, we’re lacking any products to display at this stage. Within our Fauna database, we have all the products created for us automatically as part of the test data we instantiated it with, so we need to get that data available to our clients.
Adding Ably to the webpage
To do this, we’ll set up the clients to use Ably, and consume from Ably Channels. We’ll need the clients to know what products exist and specific product details when a customer tries to view a product.
To accomplish this, we can have one main channel called products
, which will contain the most recent list of product IDs and names, and then sub-channels in the pattern of product:1
, product:2
, etc. This will mean any client will only ever need to be subscribed to two channels at any time, the overall list of products and the currently viewed product.
As we’ve set up Ably to have the last message in a channel persisted within the app
namespace, we’ll want all channels we use to exist within that namespace. This means we just need to prepend all the channel names with app
, so app:products
and app:product:1
.
We’ll need to include the Ably library on the page to use Ably. In the head, add the following line:
<script src="<https://cdn.ably.io/lib/ably.min-1.js>" type="text/javascript"></script>
Next, add the following code just below the body:
let productId;
// Connect to Ably
const realtime = new Ably.Realtime({ authUrl: '/auth' });
// Instantiate the products channel, specifying rewind=1 so that we will as part of our subscription get the last state on the channel
const productsChannel = realtime.channels.get('app:products', { params: { rewind: '1' } });
let products;
productsChannel.subscribe((msg) => {
const productsContainer = document.getElementById('products');
productsContainer.innerHTML = '';
products = msg.data;
for (let productID in products) {
let activeText = '';
if (productID == productId) activeText = 'active';
let item = document.createElement("li");
item.innerHTML = `<span id="products-${productID}" class="${activeText}" onclick="loadProduct('${productID}')">${products[productID]}</span>`;
productsContainer.appendChild(item);
}
});
let loggedIn = document.cookie.indexOf('username') >= 0;
document.getElementById('panel-anonymous').setAttribute('style', "display: " + (loggedIn ? 'none' : 'flex'));
document.getElementById('panel-logged-in').setAttribute('style', "display: " + (loggedIn ? 'inline' : 'none'));
document.getElementById('buy-button').setAttribute('style', "display: " + (loggedIn ? 'flex' : 'none'));
document.getElementById('login-button-hint').setAttribute('style', "display: " + (loggedIn ? 'none' : 'block'));
Here we instantiate Ably, specifying that we intend to authenticate via a url (more on this in a moment). We then declare a new Ably Channel object, which will connect to the channel app:products
. We also specify in its parameters that it should use [rewind](https://ably.com/docs/realtime/channels/channel-parameters/rewind)=1
, a feature of Ably that allows for the last message on the channel to be sent to a subscription seamlessly with any new messages and updates that may come through.
With the channel declared, we then subscribe to any new messages which will be published on it. We take those messages and extract the data we want to display in the sidebar.
Authentication with Ably
If you were to load the page at this stage, you should see the error Ably: Auth.requestToken(): token request signing call returned error
. This is as we’ve not implemented an authentication method for Ably yet.
For Ably, two main authentication methods are available: API keys and Tokens. API keys are the keys you can find on your app page on the Ably site.
They will exist until you choose to delete them and are useful for trusted devices such as a server for accessing Ably. They’re not great for non-trusted clients like customers; however, for that reason, there’s no way to control who’s using them.
For non-trusted clients, tokens are ideal. These are short-lived, can be revoked easily, and generally, you’d have a service selectively generate them with limited permissions.
Here, for the customer’s browser, we’ll be using tokens. Within the Ably declaration, you’ll see we used authUrl: ‘/auth’
. This states that the client library will attempt to get a token from that url (so localhost:3000/auth in our case). As we’ve yet to define an auth endpoint for our server, it’s coming back with nothing.
Let’s create this endpoint now. Firstly, we’ll need to define an Ably API key for our server to use to generate tokens. Create a .env
file in the project's base, and add ABLY_API_KEY=your api key
, replacing the ‘your api key’ bit with the API key you got during the Setup Ably Account
section.
Next, let’s start using Ably on the server. Add the following to the top of the server.js
file:
require('dotenv').config();
const Ably = require("ably");
const realtime = new Ably.Realtime(process.env.ABLY_API_KEY);
With Ably declared, let’s use it to create an auth endpoint, which will return a token. We’ll want there to be two states of users, logged-in users who can make orders and anonymous users who just want to browse. We can create different tokens to only provide the needed permissions for both cases. Add the following to the bottom of the server.js
file:
app.get('/auth', function (req, res) {
var tokenParams;
if (req.cookies.username) {
tokenParams = {
'capability': {
'app:product:*': ['subscribe'],
'app:products': ['subscribe'],
'app:submit_order': ['publish'],
},
'clientId': req.cookies.username
};
} else {
/* Issue a token request with only subscribe privileges for products */
tokenParams = {
'capability': { 'app:product:*': ['subscribe'], 'app:products': ['subscribe'] }
};
}
console.log("Sending signed token request:", JSON.stringify(tokenParams));
realtime.auth.createTokenRequest(tokenParams, function(err, tokenRequest) {
if (err) {
res.status(500).send('Error requesting token: ' + JSON.stringify(err));
} else {
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(tokenRequest));
}
});
});
Here we’re just using the customer’s username, stored in a cookie, to identify them. This is not a secure way to authenticate a user by any means, but for this tutorial with suffice. If you were ever to create a project like this for real, you would want to implement a robust authentication system to validate who a user is.
Now, if you run everything again with npm run start
, you should be able to load up localhost:3000
and not see any errors in the console! However, our sidebar is still empty as we’re yet to put any data into Ably.
We can send some data via a curl to check everything is working. Within a terminal, send the following message, replacing API_KEY
with your Ably API key:
curl -X POST <https://rest.ably.io/channels/app%3Aproducts/messages> -u "API_KEY" -H "Content-Type: application/json" --data '{ "name": "products", "data": {"101": "Pineapple" } }'
You should see this instantly reflected in the sidebar with the product ‘Pineapple’ appearing.
With that done, let’s now let users log in and out so they can be given either an anonymous user token or a logged-in user token. Add the following functions to the server.js
file:
app.get('/login', async function (req, res) {
if (req.query['username']) {
res.cookie('username', req.query['username']);
res.redirect('back');
} else {
res.status(500).send('Username is required to login');
}
});
/* Clear the cookie when the user logs outs */
app.get('/logout', function (req, res) {
res.clearCookie('username');
res.redirect('/');
});
If you run the server again, and try to login in the top right with a username, you should hopefully see from the server logs Sending signed token request: {"capability":{"app:product:*":["subscribe"],"app:products":["subscribe"],"app:submit_order":["publish"]},"clientId":"Tom C"}
.
Getting the initial product state from Fauna
Now that we have our products page connected to Ably, it’s time to get the data within Ably properly matched up to the data in Fauna. We’ll need to make use of two mechanics to do this:
- We will need to do an initial check of what products exist with a query
- We will need to check for the state of these objects and check for any changes to them over time
To get started on this, let’s create a folder in the base of our project called fauna
, and add a file called faunaHandler.js
. Here, let’s set up our Ably and Fauna objects and create an Ably Channel object for the products channel we’re using. We will also need to add a new environment variable to our .env
file, FAUNADB_API_KEY
. Set this to the Fauna API key you got during the Setup Fauna Account
section.
If you’ve set up your Fauna database in a specific region, you will need to create a FAUNA_REGION
environment variable, set to eu
or us
, respectively.
const faunadb = require('faunadb');
const Ably = require('ably');
const ablyClient = new Ably.Realtime(process.env.ABLY_API_KEY);
const productsChannel = ablyClient.channels.get('app:products');
/* Setup Fauna client */
const q = faunadb.query;
let domainRegion = "";
if (process.env.FAUNA_REGION) domainRegion = `${process.env.FAUNA_REGION}.`;
client = new faunadb.Client({
secret: process.env.FAUNADB_API_KEY,
domain: `db.${domainRegion}fauna.com`,
scheme: 'https',
});
With these made, we can create our query to Fauna for all the Products. As we’ll be wanting both the product IDs and names, we’ll need to query for all products, and then get all their values:
/* Product functions */
let allProductsQuery = q.Map(
q.Paginate(q.Documents(q.Collection('products'))),
q.Lambda(x => q.Get(x))
);
With the query made, let’s execute it, and publish the output into Ably:
const allProducts = {};
client.query(allProductsQuery)
.then((products) => {
for (const product of products.data) {
const id = product.ref.value.id;
allProducts[id] = product.data.name;
ablyClient.channels
.get(`app:product:${id}`)
.publish('update', product.data);
}
productsChannel.publish('products', allProducts);
})
.catch((err) => console.error(
'Error: [%s] %s',
err.name,
err.message
));
This code should allow us to initially populate the Ably app:products
channel with an object of product IDs and their names. It will also post the specific product’s details into a channel for that product, matching the pattern app:product:${productID}
. Let’s require our faunaHandler file in the server.js file and then run everything again. Add the following to the top of the server.js
file, just below the require(‘dotenv’).require()
line:
require('./fauna/faunaHandler.js');
Start the server with npm run start
, and go to localhost:3000
. You should see the products from your Fauna database in the sidebar!
Realtime product updates
With the above code, we can get the initial state of our products. However, we’re not currently able to see when something changes to the products, such as a new one being added or an existing product being removed. We could query Fauna every X seconds to see if there have been any changes compared to the previous state and then publish an updated object to Ably if there have been any changes.
We can avoid unnecessarily polling when there have been no changes and avoid delays in changes being reflected by clients by using Fauna’s Event Streaming. This allows our server to be sent updates on specific Product and Collection changes whenever they occur. We can use the Set Streaming option to see whenever a document in a collection or index is created or removed. We could stream the Collection of Product documents, however, we’d only receive creation and removal events, which means we wouldn’t see when a specific Product changes, say in quantity.
Instead, we can create an Index that will check for changes in our Product timestamps. This will mean the Index will emit a delete event when the ‘old’ document is deleted and then a ‘create’ event when the document is created again with the new timestamp. This will allow us to receive updates whenever our products are updated.
To create this Index, go to your Fauna Dashboard, and within the database you made for this project, go to the ‘Shell’ tab on the left side of the screen. Within the shell, input the following:
CreateIndex({
name: "products_by_ts",
source: Collection("products"),
values: [{ field: "ts" }]
})
Run the command, and a new Index will have been created called products_by_ts
. This may take a few minutes to populate but should eventually be visible within the Indexes tab.
Now we’ve created this Index, we can try listening to it for changes. Add the following to your faunaHandler.js
file:
function listenForProductChanges () {
const ref = q.Match(q.Index("products_by_ts"));
let stream = client.stream(ref).on('set', (set) => {
let productId = set.document.ref.value.id;
if (set.action == 'add') {
client.query(
q.Get(q.Ref(q.Collection('products'), `${productId}`))
)
.then((product) => {
allProducts[productId] = product.data.name;
productsChannel.publish('products', allProducts);
ablyClient.channels
.get(`app:product:${productId}`)
.publish('update', product.data);
});
} else {
delete allProducts[productId];
productsChannel.publish('products', allProducts);
}
})
.on('error', (error) => {
console.log('Error:', error);
stream.close();
setTimeout(() => { listenForProductChanges() }, 1000);
}).start();
return stream;
}
listenForProductChanges();
With this, we have a stream that listens for any changes, be it adding, removing, or changing Products. When a change occurs, we get an update as a set
event and can react accordingly.
We can try this out at this stage to see if changes in Fauna are reflected in the browser while the server is running. Run npm run start
, and open up localhost:3000
. Within the Fauna Dashboard for the database, try deleting or adding a new Product to the collection, and it should be updated within the browser immediately. The same should for updating an existing Product’s name.
(WARN_UNRECOGNIZED_ELEMENT: PAGE_BREAK)
Displaying the product details in the browser
At this stage, when the server is run, each product should now have an event stream feeding into its own Ably channel, for example, app:product:201
. We now need to handle this from the HTML page to subscribe to these channels when we select a product in the sidebar and render the results on the page.
Within the index.html
file, add the following code to the bottom of the <script>
we’ve been making:
let productChannel;
// Load a new product to display from Ably, and subscribe to any changes
function loadProduct(productID) {
document.getElementById("intro-section").style.display = "none";
document.getElementById("loader").style.display = "flex";
document.getElementById("item-details").style.display = "none";
if (productChannel != null) {
productChannel.detach();
}
productId = productID;
// Update the url to the new item, so if we refresh the page this item loads again
if (products) {
window.history
.pushState('page2', products[productID], '?product=' + productID);
const activeSidebarItem = document.getElementsByClassName('active');
for (let i=0; i < activeSidebarItem.length; i++) {
activeSidebarItem[i].className =
activeSidebarItem[0].className.replace("active", "");
}
const newActiveItem = document.getElementById(`products-${productID}`);
newActiveItem.className += "active";
}
productChannel = realtime.channels.get(`app:product:${productID}`, { params: { rewind: '1' } });
productChannel.subscribe((msg) => {
document.getElementById("loader").style.display = "none";
document.getElementById("item-details").style.display = "flex";
document.getElementById("title").textContent = msg.data.name;
document.getElementById("description").textContent = msg.data.description;
document.getElementById("quantity").textContent = msg.data.quantity;
document.getElementById("price").textContent = msg.data.price;
if (msg.data.image) {
document.getElementById("item-image").src = msg.data.image;
} else {
document.getElementById("item-image").src =
'<http://placekitten.com/600/400>';
}
});
}
// If there is a param for the product to load in the URL, load it
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('product')) {
let productId = urlParams.get('product');
loadProduct(productId);
}
Here we’ve added the addProduct
function, which is called when an item in the sidebar is clicked on. This’ll connect us to the product’s channel, and the last message on the channel will be used to render the product. It’ll remain subscribed to the product’s channel, meaning it’ll keep updating the UI with any changes to the product.
To make the page a bit more user-friendly for sharing specific products with others, we are changing a product
parameter in the URL, and if there’s a product parameter when loading the page, it’ll load up the product straight away.
Finally, an image
field can be included in your products stored in Fauna, which isn’t set by default. This should be a URL to an image that is hosted somewhere. A cute cat picture will be shown instead if the image field isn’t set for a product.
Purchasing items
Users can now see what products are available and view specific product details, all able to update in real-time. However, logged-in users are not yet able to buy the items they’re viewing.
Within your Fauna database, assuming you initialized it with test data, there should be a Function called submit_order
. This function will take an order from a customer and checks that each item they want has enough stock remaining to allow for the purchase to happen. It will subtract the order from each item’s stock and create a new order collection if there is enough stock.
We can make a call to using the Fauna client library, but we will want to make sure only a client who is logged in as a specific customer can make orders for themself. To do this, we will need to:
- Create a Customer document in Fauna for each account that signs in
- Identify what users are requesting to buy products, to then request
submit_order
Customer link to logged-in users
Let’s first link up Customers in Fauna with logged-in users. In the Setup Fauna Account
section, we already made it so that Customer documents cannot have duplicate username
fields, so we can trust that in creating new Customers, we won’t be able to have customers with duplicate usernames.
Due to this, we can create a new customer document with the user’s username when a client tries to place an order, and if it says a customer already exists with that username, we can use their ID in our request to make an order. Add the following to the end of the faunaHandler.js
file:
/* Customer functions */
async function createOrFindCustomer(username) {
/* We're hard-coding the address and other details, but for an actual
solution we'd get these details from the user */
let newCustomerObject = {
"username": username,
"address": {
"street": "0 Fake Street",
"city": "Washington",
"state": "DC",
"zipCode": "00000"
},
"telephone": "208-346-0715",
"creditCard": {
"network": "Visa",
"number": "000000000000"
}
};
// Try to create a new customer
return await new Promise(r => {
createP = client.query(
q.Create(q.Collection('customers'), { data: newCustomerObject })
)
.then(function(response) {
r(response.ref.value.id);
})
.catch(async () => {
// If an existing user with this username exists, throws an error due to
// uniqueness requirement defined within Fauna. We can find it with our username.
let customerId = await getCustomerIdByUsername(username);
r(customerId);
});
});
}
async function getCustomerIdByUsername(username) {
// Uniqueness requirement should mean there's only 1 user with the username
return await new Promise(r=> {
client.query(
q.Get(q.Match(q.Index('customers_by_username'), username))
).then(function(response) {
r(response.ref.value.id);
}).catch(function(err) {
console.log(err);
});
});
}
In this, we’re making of the Fauna library to try and create a new Customer using a passed-in username. If that fails, due to having a customer already with this username, we instead find that customer by the username and return their ID.
Now, we can use this to convert usernames to user IDs. When we create Ably Tokens for clients in the Authentication with Ably
section, we assign them a clientId
, which can be used to identify users. As this is assigned by the server to them, we can trust that users with this attached to them in Ably are those users.
We can make use of this clientId to thus identify which user is making an order request and thus attach their customerId to the order we submit to Fauna. Let’s make it so that this clientId is the customerId. First, let’s export the username function so our server can use it. Add the following to the very bottom of the faunaHandler.js
file:
module.exports = createOrFindCustomer;
Next, in the server.js
file, replace the currently existing require(‘./fauna/faunaHandler.js);
line with:
const createOrFindCustomer = require('./fauna/faunaHandler.js');
Where the clientId is assigned in the auth token generation in the server.js
file, instead set it to be the following:
app.get('/auth', async function (req, res) {
let tokenParams;
const customerId = await createOrFindCustomer(req.query['username']);
if (req.cookies.username) {
tokenParams = {
'capability': {
'app:product:*': ['subscribe'],
'app:products': ['subscribe'],
'app:submit_order': ['publish'],
},
'clientId': customerId
};
} else {
/* Issue a token request with only subscribe privileges for products */
tokenParams = {
'capability': {
'app:product:*': ['subscribe'],
'app:products': ['subscribe']
}
};
}
// ...
With each Ably client having their Customer ID attached to their connection, we can now listen to any messages they may try to send for orders. In the faunaHandler.js
file, add the following to the bottom, just above the module.exports…
we added:
const ordersChannel = ablyClient.channels.get('app:submit_order');
/* Listen for new order requests from clients */
ordersChannel.subscribe((msg) => {
submitOrder(msg.clientId, msg.data);
});
const publicOrderConversion = (({ customer, cart, status, creationDate, deliveryAddress }) =>
({
customer, cart, status, creationDate, deliveryAddress
}));
async function submitOrder(customerId, orders) {
client.query(
q.Call('submit_order', customerId, orders)
)
.then((ret) => {
const publicOrder = publicOrderConversion(ret.data);
let userId = ret.data.customer.value.id;
let orderId = ret.ref.value.id;
ablyClient.channels
.get(`app:order:${userId}:${orderId}`)
.publish('order', publicOrder);
if (!allOrders[customerId]) allOrders[customerId] = [];
allOrders[customerId].push(orderId);
ablyClient.channels
.get(`app:orders:${customerId}`)
.publish(`order`, allOrders[customerId]);
})
.catch((err) => {
console.log(err);
console.error(
'Error: [%s] %s: %s',
err.name,
err.message,
err.errors()[0].description,
)});
}
Note: In this, we’ve also added a publish to an orders channel in Ably, which will be mentioned in more detail in the ‘Viewing orders’ section.
With this, we’re subscribed to the order details sent by clients through the app:submit_order
Ably Channel. The data of the order will be sent to the submitOrder
function, which takes the user’s client ID (which is their customer ID), and submits the order to Fauna.
We now just need to make it so that the ‘Buy now’ button on our index.html
file functions. Add the following to the index.html
file’s script:
const orderChannel = realtime.channels.get('app:submit_order');
function order() {
// customer Id, order
orderChannel.publish("order", [
{
"productId": productId,
"quantity": 1
}
]);
}
The ‘Buy now’ button should now be fully functional! Start up the server again with npm run start
, load up localhost:3000
, and try going to a product and buying it. If it works you should see the stock go down for the item in the browser, and get a console log in your terminal with the details of the order placed!
Viewing orders
Now that customers can place orders, it makes sense for us to add the ability for customers to view their orders. We should be able to do this using the same mechanisms as before:
- We can have a sidebar with a list of a customer’s orders
- The customer will be able to click on any order in the sidebar to view its details
The main difference between what we did for products and this is that the orders should only be accessible to the specific user who placed them. To ensure this, we can break down orders in Ably Channels that match up to specific customerIDs.
For example, the customer ‘101’ should have the list of all their orders placed for the sidebar in app:orders:101
. Likewise, the details of each order will be in a channel of the structure (for order ID 20) app:order:101:20
. When creating Ably Tokens for users, we can only provide them access to channels that match the pattern app:orders:101
and app:order:101:*
, ensuring they only get access to their orders.
Client order viewing functionality
Let’s add these permissions to the existing Ably Token generator in the server.js
file. Change the token part of the auth endpoint to be the following:
let tokenParams;
if (req.cookies.username) {
const customerId = await createOrFindCustomer(req.cookies['username']);
const ordersPattern = `app:orders:${customerId}`;
const orderPattern = `app:order:${customerId}:*`;
tokenParams = {
'capability': {
'app:product:*': ['subscribe'],
'app:products': ['subscribe'],
'app:submit_order': ['publish'],
},
'clientId': customerId
};
tokenParams.capability[orderPattern] = ['subscribe'];
tokenParams.capability[ordersPattern] = ['subscribe'];
} else {
/* Issue a token request with only subscribe privileges for products */
tokenParams = {
'capability': {
'app:product:*': ['subscribe'],
'app:products': ['subscribe']
}
};
}
// ...
Now that customers, once signed in, will have access to the order channels matching their customer ID, we need to use it from the customer side. Let’s add new endpoint customers can use to view their orders, /orders
. Add the following to the server.js
file:
app.get('/orders', (req, res) => {
res.sendFile(__dirname + '/public/orders.html');
});
Next, in the public
folder, add a file called orders.html
. In it, add the following code:
<html>
<head>
<script src="<https://cdn.ably.io/lib/ably.min-1.js>" type="text/javascript"></script>
<link href='<https://fonts.googleapis.com/css?family=Actor>' rel='stylesheet'>
<link rel="stylesheet" type="text/css" href="/css/index.css">
</head>
<body>
<header>
<a class="home" href="/">
<h1>💸 Buy Things Inc</h1>
</a>
<nav>
<a class="orders" href="/orders">Your orders</a>
<form class="login-form" action="/login" id="panel-anonymous">
<input type="text" name="username" placeholder="Enter your username" class="login-text">
<input type="submit" value="Login" class="login-submit">
</form>
<div id="panel-logged-in">
You are logged in. <a href="/logout">Log out</a>
</div>
</nav>
</header>
<ul id="orders" class="items"></ul>
<main class="item">
<div id="loader" class="loader" style="display: none"></div>
<p id="intro-section">Select an order from the sidebar to see details</p>
<div id="item-details" class="item-details" style="display: none">
<div class="item-info">
<h2 class="item-title">Order <span id="title"></span></h2>
<h3>Time of purchase: <span id="date"></span></h3>
<h3>Delivery Address</h3>
<p id="delivery-address"></p>
<h3>Ordered items</h3>
<ul id="cart"></ul>
<p>Total price: £<span id="total-price"></span></p>
</div>
</div>
</main>
<footer>
<p>Made with <a href="<https://www.ably.com>">Ably</a> and <a href="<https://www.fauna.com>">Fauna</a></p>
</footer>
</body>
<script type="text/javascript">
let orderId;
let orderChannel;
let orders;
let customerId;
const realtime = new Ably.Realtime({ authUrl: '/auth' });
realtime.connection.on('connected', () => {
customerId = realtime.auth.clientId;
console.log(`app:orders:${customerId}`);
const ordersChannel = realtime.channels.get(`app:orders:${customerId}`, { params: { rewind: '1' } });
ordersChannel.subscribe((msg) => {
const ordersContainer = document.getElementById('orders');
ordersContainer.innerHTML = '';
orders = msg.data;
for (let orderID of orders) {
let activeText = '';
if (orderId == orderID) activeText = 'active';
let item = document.createElement("li");
item.innerHTML = ``;
ordersContainer.appendChild(item);
}
});
});
function loadOrder(orderID) {
document.getElementById("intro-section").style.display = "none";
document.getElementById("loader").style.display = "flex";
document.getElementById("item-details").style.display = "none";
if (orderChannel != null) {
orderChannel.detach();
}
orderId = orderID;
if (orders) {
const activeSidebarItem = document.getElementsByClassName('active');
for (let i=0; i < activeSidebarItem.length; i++) {
activeSidebarItem[i].className = activeSidebarItem[0].className.replace("active", "");
}
const newActiveItem = document.getElementById(`orders-${orderID}`);
newActiveItem.className += "active";
}
orderChannel = realtime.channels.get(`app:order:${customerId}:${orderId}`, { params: { rewind: '1' } });
orderChannel.subscribe((msg) => {
document.getElementById("loader").style.display = "none";
document.getElementById("item-details").style.display = "flex";
document.getElementById("title").textContent = orderId;
document.getElementById("date").textContent = new Date(msg.data.creationDate['@ts']).toLocaleString();
const city = msg.data.deliveryAddress.city;
const state = msg.data.deliveryAddress.state;
const street = msg.data.deliveryAddress.street;
const zipCode = msg.data.deliveryAddress.zipCode;
document.getElementById("delivery-address").textContent = `${street} \\n ${city} \\n ${state} \\n ${zipCode}`;
let list = document.getElementById('cart');
list.innerHTML = "";
const cart = msg.data.cart;
let totalPrice = 0;
for (let i = 0; i < cart.length; i++) {
let item = cart[i];
let entry = document.createElement('li');
totalPrice += item.price;
let productName = item.name || item.product['@ref'].id;
entry.appendChild(document.createTextNode(`product: ${productName} - price: ${item.price} - quantity: ${item.quantity}`));
list.appendChild(entry);
}
document.getElementById("total-price").textContent = totalPrice;
});
}
/* Hide or show the logged in / anonymous panels based on the session cookie */
let loggedIn = document.cookie.indexOf('username') >= 0;
document.getElementById('panel-anonymous').setAttribute('style', "display: " + (loggedIn ? 'none' : 'flex'));
document.getElementById('panel-logged-in').setAttribute('style', "display: " + (loggedIn ? 'inline' : 'none'));
</script>
</html>
In terms of functionality, this is effectively the same as the index.html
code. We populate the sidebar based on the contents of the channel app:orders:CUSTOMERID
. Then, when an order is selected, subscribe to the channel app:order:CUSTOMERID:ORDERID
and populate the fields in the right part of the page.
Fauna to Ably order functionality
With that setup, we just have to get the data into those Ably channels. Again, this work will be very similar to the existing work we made for mapping Fauna to Ably for Products. We will also need to consider the attached customer ID on the order when constructing the Ably Channel name.
In the faunaHandler.js
file, let’s do the same actions as before for the product updates. We’ll do an initial fetch to see what orders exist and then update Ably channels with the current states of the list of orders per customer ID and each order. Add the following to the faunaHandler.js
file just above the module.exports
bit:
/* Order functions */
const allOrders = {};
let allOrdersQuery = q.Map(
q.Paginate(q.Documents(q.Collection('orders'))),
q.Lambda(x => q.Get(x))
);
client.query(allOrdersQuery)
.then((orders) => {
for (const order of orders.data) {
const orderId = order.ref.value.id;
const customerId = order.data.customer.id;
if (!allOrders[customerId]) allOrders[customerId] = [];
allOrders[customerId].push(orderId);
const publicOrder = publicOrderConversion(order.data);
ablyClient.channels.get(`app:order:${customerId}:${orderId}`).publish('update', publicOrder);
}
for (const [customerId, orders] of Object.entries(allOrders)) {
ablyClient.channels.get(`app:orders:${customerId}`).publish(`order`, orders);
}
})
.catch((err) => console.error(
'Error: [%s] %s',
err.name,
err.message
));
Try running the server again with npm run start
, load up localhost:3000
, and try buying a few items. Click the orders
button in the nav bar, and hopefully you should see all your orders populated in the sidebar!
Conclusion
We now have a website where users can log in, browse products, see realtime updates on new products being added and changed, and purchase them. Customers can also view the orders they’ve made in realtime - with Fauna’s ABAC you can be confident that customers can only view the orders for which they have access.
There’s so much more that can be done with a site like this, but this give syou the core building blocks to develop a realtime e-commerce website. As seen by the repetition between the product and order updates, many of the same patterns can be used to seamlessly replicate and interact between clients and systems such as Fauna for the user.
You can find all the code from this blog on GitHub. If you have any Ably questions, you can reach out to them on their Discord, or you can reach out to the Fauna team on Discord or here.
If you enjoyed our blog, and want to work on systems and challenges related to globally distributed systems, and serverless databases, Fauna is hiring
Subscribe to Fauna's newsletter
Get latest blog posts, development tips & tricks, and latest learning material delivered right to your inbox.