├── README.md ├── auth_providers ├── anon-user.json ├── api-key.json └── local-userpass.json ├── fts.json ├── functions ├── addReview │ ├── config.json │ └── source.js ├── backInStock │ ├── config.json │ └── source.js ├── cancelOrder │ ├── config.json │ └── source.js ├── emailStockNotification │ ├── config.json │ └── source.js ├── getCountriesList │ ├── config.json │ └── source.js ├── outOfStock │ ├── config.json │ └── source.js ├── placeOrder │ ├── config.json │ └── source.js ├── productInStock │ ├── config.json │ └── source.js ├── stripeCreateCheckoutSession │ ├── config.json │ └── source.js ├── textStockNotification │ ├── config.json │ └── source.js └── updateStockLevel │ ├── config.json │ └── source.js ├── services ├── AWS-personal │ ├── config.json │ └── rules │ │ └── SES.json ├── AWS2 │ ├── config.json │ └── rules │ │ ├── S3.json │ │ └── SES.json ├── Stripe │ ├── config.json │ ├── incoming_webhooks │ │ └── stripePaymentSuccess │ │ │ ├── config.json │ │ │ └── source.js │ └── rules │ │ ├── get.json │ │ └── post.json ├── TwilioText │ ├── config.json │ └── rules │ │ └── sendText.json ├── http │ ├── config.json │ └── rules │ │ └── countries.json └── mongodb-atlas │ ├── config.json │ └── rules │ ├── ecommerce.customers.json │ ├── ecommerce.deliveries.json │ ├── ecommerce.discounts.json │ ├── ecommerce.meta.json │ ├── ecommerce.metaData.json │ ├── ecommerce.orderOverflow.json │ ├── ecommerce.products.json │ ├── ecommerce.reviewOverflow.json │ └── ecommerce.salesTax.json ├── stitch.json ├── triggers ├── backInStock.json ├── outOfStock.json └── productInStock.json └── values ├── AWS_private_key.json ├── MongoDBProductImage.json ├── productImagePrefix.json ├── productImageSuffix.json ├── productURLPrefix.json ├── s3MugBucket.json ├── sourceEmail.json ├── stripePublicKey.json ├── stripePurchaseCancelURL.json ├── stripePurchaseSuccessURL.json ├── stripeSecretKey.json └── twilioNumber.json /README.md: -------------------------------------------------------------------------------- 1 | # MongoDB eCommerce reference app – backend 2 | 3 | This is an example of how to build an eCommerce app using MongoDB Atlas and MongoDB Stitch/Realm. This repo contains the code and data for the backend part of the application; the [am-MongoDB-eCommerce](https://github.com/am-MongoDB/eCommerce) contains the reference frontend web app. 4 | 5 | [Try out the app](https://ecommerce-iukkg.mongodbstitch.com/#/) before recreating it for yourself. 6 | 7 | ## Stack 8 | 9 | This application backend doesn't require an application or web server. 10 | 11 | The database is MongoDB Atlas, a fully managed cloud database. 12 | 13 | Access to Atlas and other services is through MongoDB Stitch/Realm – the serverless platform from MongoDB. 14 | 15 | The application frontend uses these technologies: 16 | 17 | - Vue.js 18 | - Bulmer 19 | - Buefy 20 | - SaaS 21 | 22 | The frontend code can be hosted on Stich/Realm static hosting. 23 | 24 | ## Application setup 25 | 26 | This backend application stores data in MongoDB Atlas and uses MongoDB Stitch as a serverless platform for all of the backend functionality. 27 | 28 | ### Configure database and load sample data 29 | If you don't already have a [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) cluster, you'll need to [create one](https://www.mongodb.com/cloud/atlas/register) – a free M0 cluster will work. 30 | 31 | To setup the collection indexes and sample product catalog (13K+ products): 32 | 33 | 1. Add your IP address to your [Atlas whitelist](https://docs.atlas.mongodb.com/security-whitelist/) 34 | 2. [Create Atlas database user](https://docs.atlas.mongodb.com/security-add-mongodb-users/) 35 | 3. Download the [`dump`](https://github.com/am-MongoDB/eCommerce/tree/master/dump) folder 36 | 4. Restore the data: 37 | ``` 38 | mongorestore --uri="mongodb+srv://your-username:your-password@your-cluster-name.mongodb.net/ecommerce" dump/ 39 | ``` 40 | 5. Enable full-text-search using the configuration from [fts.json](https://github.com/am-MongoDB/eCommerce-Realm/blob/master/fts.json) and name the FTS index "Titles and descriptions". 41 | 42 | ### Create the Stitch app 43 | 1. Download this repo 44 | ``` 45 | git clone https://github.com/am-MongoDB/eCommerce-Realm.git 46 | ``` 47 | 2. Use the [Stitch CLI](https://docs.mongodb.com/stitch/deploy/stitch-cli-reference/) to install the Stitch app – **the first attempt will fail**: 48 | 49 | ``` 50 | cd eCommerce-Realm-master 51 | stitch-cli import --strategy=replace 52 | ``` 53 | 3. Add Stitch secrets (you get these from your other cloud service providers): 54 | ``` 55 | stitch-cli secrets add --name=AWS_private_key --value="my-secret-key" 56 | stitch-cli secrets add --name=stripeSecretKey --value="my-secret-key" 57 | stitch-cli secrets add --name=TwilioAuthToken --value="my-secret-key" 58 | stitch-cli secrets add --name=AWS-personal-private-key --value="my-secret-key" 59 | 60 | stitch-cli import --strategy=replace 61 | ``` 62 | 4. Configure the Stitch App (through the Stitch UI) 63 | - Link Stitch app to your Atlas cluster. In the Atlas UI, select `clusters` from the left-side, then click on `mongodb-atlas` and then select your Atlas cluster from the dropdown and save. 64 | - Set your own values for each of the `values` (and `secrets` if you didn't use the real values when importing the app) through the Stitch UI. 65 | 66 | 5. Add frontend app to Stitch static hosting 67 | Build the [frontend app](https://github.com/am-MongoDB/eCommerce) and drag the files onto the `Hosting` panel in the Stitch UI. 68 | 6. Copy the link from the Hosting panel and browse to that page 69 | -------------------------------------------------------------------------------- /auth_providers/anon-user.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "anon-user", 3 | "type": "anon-user", 4 | "disabled": false 5 | } 6 | -------------------------------------------------------------------------------- /auth_providers/api-key.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-key", 3 | "type": "api-key", 4 | "disabled": true 5 | } 6 | -------------------------------------------------------------------------------- /auth_providers/local-userpass.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "local-userpass", 3 | "type": "local-userpass", 4 | "config": { 5 | "autoConfirm": true, 6 | "confirmEmailSubject": "eCommerce account created", 7 | "emailConfirmationUrl": "https://ecommerce-iukkg.mongodbstitch.com/#/confirm", 8 | "resetPasswordSubject": "eCommerce password reset", 9 | "resetPasswordUrl": "https://ecommerce-iukkg.mongodbstitch.com/#/reset" 10 | }, 11 | "disabled": false 12 | } 13 | -------------------------------------------------------------------------------- /fts.json: -------------------------------------------------------------------------------- 1 | { 2 | "mappings": { 3 | "dynamic": false, 4 | "fields": { 5 | "category": { 6 | "analyzer": "lucene.standard", 7 | "type": "string" 8 | }, 9 | "description": { 10 | "analyzer": "lucene.standard", 11 | "type": "string" 12 | }, 13 | "productName": { 14 | "analyzer": "lucene.standard", 15 | "type": "string" 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /functions/addReview/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "addReview", 3 | "private": false, 4 | "run_as_system": true, 5 | "can_evaluate": {} 6 | } 7 | -------------------------------------------------------------------------------- /functions/addReview/source.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} ReviewStats 3 | * @property {number} averageReviewScore 4 | * @property {number} numberOfReviews 5 | */ 6 | 7 | /** 8 | * Adds the new review to the `products` document. If there are more than 10 reviews in the `products` 9 | * document then archive the oldest review to its own document in the `reviewOverflow` document. 10 | * @param {string} productID – Uniquely identifies which document the review is for 11 | * @param {string} comment – The text of the comment 12 | * @param {number} stars - How the user rates the product (out of 5) 13 | * @returns {ReviewStats} - The average review score and total number of reviews submitted (including this one) 14 | */ 15 | exports = function(productID, comment, stars) { 16 | const productsCollection = context.services.get("mongodb-atlas").db("ecommerce").collection("products"); 17 | const reviewsCollection = context.services.get("mongodb-atlas").db("ecommerce").collection("reviewOverflow"); 18 | let reviewToArchive = null; 19 | let averageReviewScore = 0; 20 | let numberOfReviews = 0; 21 | return productsCollection.findOne({productID: productID}) 22 | .then ((product) => { 23 | product.reviews.numberOfReviews++; 24 | numberOfReviews = product.reviews.numberOfReviews; 25 | product.reviews.totalReviewScore += stars; 26 | product.reviews.averageReviewScore = product.reviews.totalReviewScore / product.reviews.numberOfReviews; 27 | averageReviewScore = product.reviews.averageReviewScore; 28 | const newReview = { 29 | 'review': comment, 30 | 'score': stars 31 | }; 32 | let newReviewArray = [newReview]; 33 | product.reviews.recentReviews = newReviewArray.concat(product.reviews.recentReviews); 34 | if (product.reviews.recentReviews.length > 10) { 35 | reviewToArchive = product.reviews.recentReviews.pop(); 36 | reviewToArchive.productID = productID; 37 | product.reviews.overflow = true; 38 | } 39 | return productsCollection.updateOne( 40 | {productID: productID}, 41 | {$set: {reviews: product.reviews}} 42 | ); 43 | }) 44 | .then (() => { 45 | if (reviewToArchive) { 46 | return reviewsCollection.insertOne(reviewToArchive); 47 | } else { 48 | return { 49 | averageReviewScore: averageReviewScore, 50 | numberOfReviews: numberOfReviews 51 | }; 52 | } 53 | }) 54 | .then (() => { 55 | return { 56 | averageReviewScore: averageReviewScore, 57 | numberOfReviews: numberOfReviews 58 | }; 59 | }, 60 | (error) => { 61 | console.error(`Failed to store the new review for ${productID}: ${error.message}`); 62 | }); 63 | }; -------------------------------------------------------------------------------- /functions/backInStock/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backInStock", 3 | "private": true, 4 | "can_evaluate": {} 5 | } 6 | -------------------------------------------------------------------------------- /functions/backInStock/source.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Updates the product to indicate that it's no longer out of stock. Delegates sending 3 | * notifications to registered users to `productInStock` 4 | * @param {object} changeEvent - https://docs.mongodb.com/stitch/triggers/database-triggers/#database-change-events 5 | */ 6 | exports = function(changeEvent) { 7 | const products = context.services.get('mongodb-atlas').db("ecommerce").collection("products"); 8 | products.updateOne( 9 | {productID: changeEvent.fullDocument.productID}, 10 | {$set: {"internal.outOfStock": false}} 11 | ) 12 | .then (() => { 13 | context.functions.execute("productInStock", changeEvent); 14 | }) 15 | .catch ((error) => { 16 | console.log(error); 17 | }); 18 | }; -------------------------------------------------------------------------------- /functions/cancelOrder/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cancelOrder", 3 | "private": false 4 | } 5 | -------------------------------------------------------------------------------- /functions/cancelOrder/source.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} Result 3 | * @property {boolean} result – Indicates success/failure 4 | * @property {string} error - If failure, contains the error message 5 | */ 6 | /** 7 | * Removes the order from the customer's document, and adjusts up the stock levels 8 | * for all products that were part of the order. 9 | * @param {string} orderID - Uniquely identifies the order 10 | * @returns {Result} 11 | */ 12 | exports = function(orderID) { 13 | const customerCollection = context.services.get("mongodb-atlas").db("ecommerce").collection("customers"); 14 | let query = {owner_id: context.user.id}; 15 | let orderToDelete = null; 16 | 17 | return customerCollection.findOne(query) 18 | .then ((customer) => { 19 | let existingIndex = -1; 20 | if (customer.orders.some(function(item, index) { 21 | existingIndex = index; 22 | return item.orderID === orderID; 23 | })) { 24 | orderToDelete = customer.orders[existingIndex]; 25 | // Create a by-value copy and remove the order from the derived position 26 | let newOrders = customer.orders.slice(); 27 | newOrders.splice(existingIndex, 1); 28 | customer.orders = newOrders; 29 | return customerCollection.updateOne( 30 | {"contact.email": customer.contact.email}, 31 | {$set: {orders: newOrders}} 32 | ); 33 | } else { 34 | console.log(`Unable to find orderID: ${orderID}`); 35 | } 36 | }) 37 | .then (() => { 38 | // Update stock levels for every product in the order 39 | return Promise.all(orderToDelete.items.map((item) => { 40 | item.quantity = 0 - item.quantity; 41 | return context.functions.execute('updateStockLevel', item); 42 | })); 43 | }) 44 | .then(() => { 45 | return {result: true}; 46 | }, 47 | (error) => { 48 | const errorSring = `Failed to delete the order: ${error.message}`; 49 | console.error(errorSring); 50 | return {result: false, error: errorSring}; 51 | }); 52 | }; -------------------------------------------------------------------------------- /functions/emailStockNotification/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "emailStockNotification", 3 | "private": true, 4 | "run_as_system": true 5 | } 6 | -------------------------------------------------------------------------------- /functions/emailStockNotification/source.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Uses the Amazon Simple Email Service (SES) to send an email to a single user to 3 | * indicate that the product they were watching is now back in stock. 4 | * @param {string} destinationEmailAddress - Address to send the email to 5 | * @param {string} productName - Short product name 6 | * @param {string} productID - Uniquely identfies the product 7 | * @param {boolean} emailAllowed – Whether the customer has consented to receive email notifications 8 | * @returns {Promise} 9 | */ 10 | exports = function(destinationEmailAddress, productName, productID, emailAllowed){ 11 | console.log('email function'); 12 | const urlPrefix = context.values.get("productURLPrefix"); 13 | const imagePrefix = context.values.get("productImagePrefix"); 14 | const imageSuffix = context.values.get("productImageSuffix"); 15 | const sourceAddress = context.values.get("sourceEmail"); 16 | 17 | if(!emailAllowed) {return} 18 | 19 | const body = ` 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | Product in stock 29 | 34 | 35 | 80 | 81 | 82 |
83 | 88 | 89 | 90 | 166 | 167 |
91 | 92 | 93 | 131 | 132 |
94 | 95 | 96 | 129 | 130 |
97 | 98 | 99 | 100 | 109 | 110 |
101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |
111 | 112 | 113 | 114 | 126 | 127 |
115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 |

MongoDB Store –

A product you are watching is now back in stock

128 |
133 | 134 | 135 | 164 | 165 |
136 | 137 | 138 | 162 | 163 |
139 | 140 | 141 | 142 | 148 | 149 |
143 | 144 | 145 | 146 | 147 |
150 | 151 | 152 | 153 | 159 | 160 |
154 | 155 | 156 | 157 | 158 |


The "${productName}" you are watching is now back in stock – click here to order.

161 |
168 |
169 |
170 | 171 | 172 | `; 173 | const ses = context.services.get('AWS-personal').ses("us-east-1"); 174 | return ses.SendEmail({ 175 | Source: sourceAddress, 176 | Destination: { ToAddresses: [destinationEmailAddress]}, 177 | Message: { 178 | Body: { 179 | Html: { 180 | Charset: "UTF-8", 181 | Data: body 182 | } 183 | }, 184 | Subject: { 185 | Charset: "UTF-8", 186 | Data: "Product back in stock" 187 | } 188 | } 189 | }); 190 | }; -------------------------------------------------------------------------------- /functions/getCountriesList/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "getCountriesList", 3 | "private": false 4 | } 5 | -------------------------------------------------------------------------------- /functions/getCountriesList/source.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Use the Stitch http service to retrieve the latest list of countries from 3 | * a public REST API. 4 | * TODO - Could cache this list in the `meta` collection and have a scheduled 5 | * trigger refresh it daily. 6 | */ 7 | exports = function(){ 8 | const http = context.services.get("http"); 9 | const countries =[ 10 | {name: 'United Kingdom'}, 11 | {name: 'United States'} 12 | ]; 13 | 14 | return http.get({url: "https://restcountries.eu/rest/v2/all?fields=name"}) 15 | .then (response => { 16 | try { 17 | let countryList = (EJSON.parse(response.body.text())); 18 | console.log (`countryList: ${countryList}`); 19 | console.log (`First country: ${countryList[0].name}`); 20 | 21 | return countryList; 22 | } 23 | catch (error) { 24 | console.error(`Error parsing country list: ${error.message}`); 25 | return countries; 26 | } 27 | }, 28 | (error) => { 29 | console.error(`Failed to fetch country list ${error.message}`); 30 | return countries; 31 | }); 32 | }; -------------------------------------------------------------------------------- /functions/outOfStock/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "outOfStock", 3 | "private": true, 4 | "can_evaluate": {} 5 | } 6 | -------------------------------------------------------------------------------- /functions/outOfStock/source.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Marks product as being out of stock (using an internal field not known to the 3 | * legacy stock management system) 4 | * @param {object} changeEvent - https://docs.mongodb.com/stitch/triggers/database-triggers/#database-change-events 5 | */ 6 | exports = function(changeEvent) { 7 | const products = context.services.get('mongodb-atlas').db("ecommerce").collection("products"); 8 | products.updateOne( 9 | {productID: changeEvent.fullDocument.productID}, 10 | {$set: {"internal.outOfStock": true}} 11 | ) 12 | .catch ((error) => { 13 | console.log(error); 14 | }); 15 | }; -------------------------------------------------------------------------------- /functions/placeOrder/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "placeOrder", 3 | "private": false 4 | } 5 | -------------------------------------------------------------------------------- /functions/placeOrder/source.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} Result 3 | * @property {boolean} result – Indicates success/failure 4 | * @property {string} error - If failure, contains the error message 5 | */ 6 | /** 7 | * Creates an order using the contents of the customer's shopping basket (as stored in the 8 | * `customer` document.) Stores the new order in the `customer` document and (if there are 9 | * now more than 10 orders) archives the oldest to its own document in the `orderOverflow` 10 | * collection. 11 | * @param {string} paymentMethod - Stored as part of the order 12 | * @param {email} - Uniquely identifies the customer 13 | * @returns {Result} 14 | */ 15 | exports = function(paymentMethod, email) { 16 | const customerCollection = context.services.get("mongodb-atlas").db("ecommerce").collection("customers"); 17 | const orderCollection = context.services.get("mongodb-atlas").db("ecommerce").collection("orderOverflow"); 18 | let orderToArchive = null; 19 | let thisOrderItems = null; 20 | let query = {}; 21 | if (email) { 22 | query = {"contact.email": email}; 23 | } else { 24 | query = {owner_id: context.user.id}; 25 | } 26 | return customerCollection.findOne(query) 27 | .then ((customer) => { 28 | const totalPrice = customer.shoppingBasket.reduce((total, item) => 29 | { 30 | return total + (item.quantity * item.price); 31 | }, 0); 32 | let newOrder = { 33 | orderID: `${customer.contact.email}-${Date.now()}`, 34 | status: 'pending', 35 | orderDate: new Date(), 36 | deliveryDate: null, 37 | deliveryAddress: customer.contact.deliveryAddress, 38 | totalPrice: totalPrice, 39 | paymentMethod: paymentMethod, 40 | items: customer.shoppingBasket 41 | }; 42 | thisOrderItems = customer.shoppingBasket; 43 | customer.orders = [newOrder].concat(customer.orders); 44 | if (customer.orders.length > 10) { 45 | console.log('Need to archive older order'); 46 | orderToArchive = customer.orders.pop(); 47 | orderToArchive.customerEmail = customer.contact.email; 48 | orderToArchive.owner_id = context.user.id; 49 | } 50 | return customerCollection.updateOne( 51 | query, 52 | {$set: { 53 | orders: customer.orders, 54 | orderOverflow: true 55 | }} 56 | ); 57 | }) 58 | .then (() => { 59 | if (orderToArchive) { 60 | return orderCollection.insertOne(orderToArchive); 61 | } else { 62 | return {result: true}; 63 | } 64 | }) 65 | .then (() => { 66 | // Update stock levels for every product in the order 67 | return Promise.all(thisOrderItems.map((item) => { 68 | return context.functions.execute('updateStockLevel', item); 69 | })); 70 | }) 71 | .then(() => { 72 | return {result: true}; 73 | }, 74 | (error) => { 75 | const errorSring = `Failed to store the new order: ${error.message}`; 76 | console.error(errorSring); 77 | return {result: false, error: errorSring}; 78 | }); 79 | }; -------------------------------------------------------------------------------- /functions/productInStock/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "productInStock", 3 | "private": true, 4 | "run_as_system": true 5 | } 6 | -------------------------------------------------------------------------------- /functions/productInStock/source.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Called once a product is back in stock. For each customer that's waiting on 3 | * the product send them a text and/or email notification that it's back in 4 | * stock and remove it from the customer's waiting list 5 | * @param {object} event - Change event: https://docs.mongodb.com/stitch/triggers/database-triggers/#database-change-events 6 | * @returns {Promise} 7 | */ 8 | exports = function(event) { 9 | const db = context.services.get("mongodb-atlas").db("ecommerce"); 10 | const customers = db.collection("customers"); 11 | const product = event.fullDocument; 12 | const notifyCustomer = (customer) => { 13 | return context.functions.execute( 14 | "emailStockNotification", 15 | customer.contact.email, 16 | product.productName, 17 | product.productID, 18 | customer.marketingPreferences.email 19 | ) 20 | .then(() => { 21 | return context.functions.execute( 22 | "textStockNotification", 23 | customer.contact.phone, 24 | product.productName, 25 | product.productID, 26 | customer.marketingPreferences.sms 27 | ); 28 | }) 29 | .then(() => { 30 | let waitingOnProducts = [...customer.waitingOnProducts]; 31 | let index = waitingOnProducts.indexOf(product.productID); 32 | waitingOnProducts.splice(index, 1); 33 | return customers.updateOne( 34 | {'contact.email': customer.contact.email}, 35 | {$set: {waitingOnProducts: waitingOnProducts}} 36 | ); 37 | }) 38 | .catch((error) => { 39 | console.log(error); 40 | }); 41 | }; 42 | return customers.find({waitingOnProducts: product.productID}).toArray().then((customers) => { 43 | return Promise.all(customers.map((customer) => { 44 | return notifyCustomer(customer); 45 | })); 46 | }); 47 | }; -------------------------------------------------------------------------------- /functions/stripeCreateCheckoutSession/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stripeCreateCheckoutSession", 3 | "private": false 4 | } 5 | -------------------------------------------------------------------------------- /functions/stripeCreateCheckoutSession/source.js: -------------------------------------------------------------------------------- 1 | /** 2 | * To process a payment through Stripe Checkout, this function creates a context for the payment; 3 | * the resulting `id` can then be returned to the frontend app when redirecting to stripe. 4 | * @param {number} amount - The amount to charge to the customer's card, in $USD 5 | * @returns {string} - a unique ID associated with this Stripe session that the frontend can use 6 | * to redirect to the Stripe Checkout page to take the payment 7 | */ 8 | 9 | exports = function(amount) { 10 | const amountInCents = Math.round(amount * 100); 11 | const http = context.services.get("Stripe"); 12 | const secretKey = context.values.get("stripeSecretKey"); 13 | const productImage = context.values.get("MongoDBProductImage"); 14 | const successURL = context.values.get("stripePurchaseSuccessURL"); 15 | const cancelURL = context.values.get("stripePurchaseCancelURL"); 16 | const customers = context.services.get("mongodb-atlas").db("ecommerce").collection("customers"); 17 | 18 | return customers.findOne({owner_id: context.user.id}, {"contact.email": 1}) 19 | .then ((customer) => { 20 | const formData = { 21 | "payment_method_types[]": ["card"], 22 | "line_items[][name]": "MongoDB eCommerce order", 23 | "line_items[][description]": "Your order from MongoDB", 24 | "line_items[][images][]": productImage, 25 | "line_items[][amount]": amountInCents, 26 | "line_items[][currency]": "usd", 27 | "line_items[][quantity]": 1, 28 | "customer_email": customer.contact.email, 29 | success_url: successURL, 30 | cancel_url: cancelURL 31 | }; 32 | 33 | // Need to url-encode the form object (as that's the format required by the 34 | // Stripe API) 35 | let formBody = []; 36 | for (let property in formData) { 37 | const encodedKey = encodeURIComponent(property); 38 | const encodedValue = encodeURIComponent(formData[property]); 39 | formBody.push(encodedKey + "=" + encodedValue); 40 | } 41 | formBody = formBody.join("&"); 42 | return http.post({ 43 | url: "https://api.stripe.com/v1/checkout/sessions", 44 | body: formBody, 45 | headers: { 46 | "Authorization": [ `Bearer ${secretKey}` ], 47 | "Content-Type": [ "application/x-www-form-urlencoded" ] 48 | } 49 | }); 50 | }) 51 | .then(response => { 52 | // The response body is encoded as raw BSON.Binary. Parse it to JSON. 53 | const ejson_body = EJSON.parse(response.body.text()); 54 | return ejson_body.id; 55 | }, 56 | (error) => { 57 | console.log(`Failed to create session: ${error}`); 58 | }); 59 | }; -------------------------------------------------------------------------------- /functions/textStockNotification/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "textStockNotification", 3 | "private": true, 4 | "run_as_system": true 5 | } 6 | -------------------------------------------------------------------------------- /functions/textStockNotification/source.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sends a text message (using the Twilio service) to the user to tell them 3 | * that their watched product is now back in stock 4 | * @param {string} destinationPhone - Phone number to send the text to 5 | * @param {string} productName - Product name to include in the text message 6 | * @param {string} productID - Uniquely identifies the product 7 | * @param {boolean} textAllowed - Whether the customer has agreed to receive text notifiactions 8 | * @returns {Promise} 9 | */ 10 | exports = function(destinationPhone, productName, productID, textAllowed){ 11 | if (!textAllowed || !destinationPhone.mobile) {return} 12 | const twilio = context.services.get("TwilioText"); 13 | const twilioNumber = context.values.get('twilioNumber'); 14 | const productURL = `${context.values.get('productURLPrefix')}${productID}`; 15 | const body = `"${productName}" is now back in stock: ${productURL}`; 16 | return twilio.send({ 17 | to: destinationPhone.mobile, 18 | from: twilioNumber, 19 | body: body 20 | }); 21 | }; -------------------------------------------------------------------------------- /functions/updateStockLevel/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "updateStockLevel", 3 | "private": true, 4 | "run_as_system": true 5 | } 6 | -------------------------------------------------------------------------------- /functions/updateStockLevel/source.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} LineItem 3 | * @property {string} productID - Uniquely identifies the product 4 | * @property {number} quantity - Number of this product included in the order 5 | */ 6 | /** 7 | * Reduce the stock level of the product to reflect this line item from an order. 8 | * @param {LineItem} lineItem: Line item from an order 9 | * @returns {Promise} 10 | */ 11 | exports = function (lineItem) { 12 | // This is implemented as a seperate, private Stitch Function so that it can run as 13 | // System to update the product documents. 14 | const productsCollection = context.services.get("mongodb-atlas").db("ecommerce").collection("products"); 15 | return productsCollection.findOne({productID: lineItem.productID}) 16 | .then ((productDoc) => { 17 | const newStockLevel = Math.max (0, productDoc.stockLevel - lineItem.quantity); 18 | return productsCollection.updateOne( 19 | {productID: lineItem.productID}, 20 | {$set: {stockLevel: newStockLevel}} 21 | ); 22 | }, 23 | (error) => { 24 | const errorSring = `Failed to update existing product document: ${error.message}`; 25 | console.error(errorSring); 26 | }); 27 | }; -------------------------------------------------------------------------------- /services/AWS-personal/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "AWS-personal", 3 | "type": "aws", 4 | "config": { 5 | "accessKeyId": "AKIAIF2IIZ5B37QIDHZA" 6 | }, 7 | "secret_config": { 8 | "secretAccessKey": "AWS-personal-private-key" 9 | }, 10 | "version": 1 11 | } 12 | -------------------------------------------------------------------------------- /services/AWS-personal/rules/SES.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SES", 3 | "actions": [ 4 | "ses:SendEmail" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /services/AWS2/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "AWS2", 3 | "type": "aws", 4 | "config": { 5 | "accessKeyId": "AKIA2QKJSCXBY426BJM3" 6 | }, 7 | "secret_config": { 8 | "secretAccessKey": "AWS_private_key" 9 | }, 10 | "version": 1 11 | } 12 | -------------------------------------------------------------------------------- /services/AWS2/rules/S3.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "S3", 3 | "actions": [ 4 | "s3:PutObject" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /services/AWS2/rules/SES.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SES", 3 | "actions": [ 4 | "ses:SendEmail" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /services/Stripe/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Stripe", 3 | "type": "http", 4 | "config": {}, 5 | "version": 1 6 | } 7 | -------------------------------------------------------------------------------- /services/Stripe/incoming_webhooks/stripePaymentSuccess/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stripePaymentSuccess", 3 | "run_as_authed_user": false, 4 | "run_as_user_id": "", 5 | "run_as_user_id_script_source": "", 6 | "options": { 7 | "httpMethod": "POST", 8 | "validationMethod": "NO_VALIDATION" 9 | }, 10 | "respond_result": true 11 | } 12 | -------------------------------------------------------------------------------- /services/Stripe/incoming_webhooks/stripePaymentSuccess/source.js: -------------------------------------------------------------------------------- 1 | // This function is the webhook's request handler. 2 | exports = function(payload, response) { 3 | // This is a binary object that can be accessed as a string using .text() 4 | const body = EJSON.parse(payload.body.text()); 5 | console.log(`Customer email: ${body.data.object.customer_email}`); 6 | const customers = context.services.get("mongodb-atlas").db("ecommerce").collection("customers"); 7 | return context.functions.execute('placeOrder', 'Credit or debit card', body.data.object.customer_email) 8 | .then (() => { 9 | customers.updateOne({"contact.email": body.data.object.customer_email}, {$set: {shoppingBasket: []}}); 10 | return "Order processed successfully"; 11 | }, 12 | () => { 13 | return "Problem with the order"; 14 | }); 15 | }; -------------------------------------------------------------------------------- /services/Stripe/rules/get.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "get", 3 | "actions": [ 4 | "get" 5 | ], 6 | "when": { 7 | "%%args.url.host": { 8 | "%in": [ 9 | "api.stripe.com" 10 | ] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /services/Stripe/rules/post.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "post", 3 | "actions": [ 4 | "post" 5 | ], 6 | "when": { 7 | "%%args.url.host": { 8 | "%in": [ 9 | "api.stripe.com" 10 | ] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /services/TwilioText/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TwilioText", 3 | "type": "twilio", 4 | "config": { 5 | "sid": "AC417992716e63e127f80fb78bb4bbc0b4" 6 | }, 7 | "secret_config": { 8 | "auth_token": "TwilioAuthToken" 9 | }, 10 | "version": 1 11 | } 12 | -------------------------------------------------------------------------------- /services/TwilioText/rules/sendText.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sendText", 3 | "actions": [ 4 | "send" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /services/http/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http", 3 | "type": "http", 4 | "config": {}, 5 | "version": 1 6 | } 7 | -------------------------------------------------------------------------------- /services/http/rules/countries.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "countries", 3 | "actions": [ 4 | "get" 5 | ], 6 | "when": { 7 | "%%args.url.host": { 8 | "%in": [ 9 | "restcountries.eu" 10 | ] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /services/mongodb-atlas/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongodb-atlas", 3 | "type": "mongodb-atlas", 4 | "config": { 5 | "readPreference": "primary", 6 | "wireProtocolEnabled": false 7 | }, 8 | "version": 1 9 | } 10 | -------------------------------------------------------------------------------- /services/mongodb-atlas/rules/ecommerce.customers.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "ecommerce", 3 | "collection": "customers", 4 | "roles": [ 5 | { 6 | "name": "owner", 7 | "apply_when": { 8 | "owner_id": "%%user.id" 9 | }, 10 | "fields": { 11 | "wishLists": {} 12 | }, 13 | "read": true, 14 | "write": true, 15 | "insert": true, 16 | "delete": true, 17 | "additional_fields": {} 18 | }, 19 | { 20 | "name": "shared", 21 | "apply_when": { 22 | "friends": "%%user.id" 23 | }, 24 | "fields": { 25 | "wishLists": { 26 | "fields": { 27 | "quantity": { 28 | "write": true, 29 | "read": true 30 | } 31 | }, 32 | "write": false, 33 | "read": true 34 | } 35 | }, 36 | "write": false, 37 | "insert": true, 38 | "delete": false, 39 | "additional_fields": {} 40 | }, 41 | { 42 | "name": "anyone", 43 | "apply_when": {}, 44 | "write": { 45 | "%%prevRoot": { 46 | "$exists": false 47 | } 48 | }, 49 | "insert": true, 50 | "delete": false, 51 | "additional_fields": {} 52 | } 53 | ], 54 | "schema": { 55 | "properties": { 56 | "_id": { 57 | "bsonType": "objectId" 58 | }, 59 | "friends": { 60 | "bsonType": "array", 61 | "items": { 62 | "bsonType": "string" 63 | } 64 | }, 65 | "owner_id": { 66 | "bsonType": "string" 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /services/mongodb-atlas/rules/ecommerce.deliveries.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "ecommerce", 3 | "collection": "deliveries", 4 | "roles": [ 5 | { 6 | "name": "owner", 7 | "apply_when": { 8 | "owner_id": "%%user.id" 9 | }, 10 | "read": true, 11 | "insert": true, 12 | "delete": true, 13 | "additional_fields": {} 14 | } 15 | ], 16 | "schema": { 17 | "properties": { 18 | "_id": { 19 | "bsonType": "objectId" 20 | }, 21 | "owner_id": { 22 | "bsonType": "string" 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /services/mongodb-atlas/rules/ecommerce.discounts.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "ecommerce", 3 | "collection": "discounts", 4 | "roles": [ 5 | { 6 | "name": "default", 7 | "apply_when": {}, 8 | "read": true, 9 | "write": false, 10 | "insert": false, 11 | "delete": false, 12 | "additional_fields": {} 13 | } 14 | ], 15 | "schema": { 16 | "properties": { 17 | "_id": { 18 | "bsonType": "objectId" 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /services/mongodb-atlas/rules/ecommerce.meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "ecommerce", 3 | "collection": "meta", 4 | "roles": [ 5 | { 6 | "name": "default", 7 | "apply_when": {}, 8 | "read": true, 9 | "write": false, 10 | "insert": false, 11 | "delete": false, 12 | "additional_fields": {} 13 | } 14 | ], 15 | "schema": { 16 | "properties": { 17 | "_id": { 18 | "bsonType": "objectId" 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /services/mongodb-atlas/rules/ecommerce.metaData.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "ecommerce", 3 | "collection": "metaData", 4 | "roles": [ 5 | { 6 | "name": "default", 7 | "apply_when": {}, 8 | "read": true, 9 | "write": false, 10 | "insert": false, 11 | "delete": false, 12 | "additional_fields": {} 13 | } 14 | ], 15 | "schema": { 16 | "properties": { 17 | "_id": { 18 | "bsonType": "objectId" 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /services/mongodb-atlas/rules/ecommerce.orderOverflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "ecommerce", 3 | "collection": "orderOverflow", 4 | "roles": [ 5 | { 6 | "name": "owner", 7 | "apply_when": { 8 | "owner_id": "%%user.id" 9 | }, 10 | "read": true, 11 | "write": true, 12 | "insert": true, 13 | "delete": true, 14 | "additional_fields": {} 15 | } 16 | ], 17 | "schema": { 18 | "properties": { 19 | "_id": { 20 | "bsonType": "objectId" 21 | }, 22 | "owner_id": { 23 | "bsonType": "string" 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /services/mongodb-atlas/rules/ecommerce.products.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "ecommerce", 3 | "collection": "products", 4 | "roles": [ 5 | { 6 | "name": "default", 7 | "apply_when": {}, 8 | "fields": { 9 | "internal": {}, 10 | "reviews": { 11 | "write": true 12 | } 13 | }, 14 | "read": true, 15 | "insert": false, 16 | "delete": false, 17 | "additional_fields": {} 18 | } 19 | ], 20 | "schema": { 21 | "properties": { 22 | "_id": { 23 | "bsonType": "objectId" 24 | }, 25 | "owner_id": { 26 | "bsonType": "string" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /services/mongodb-atlas/rules/ecommerce.reviewOverflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "ecommerce", 3 | "collection": "reviewOverflow", 4 | "roles": [ 5 | { 6 | "name": "owner", 7 | "apply_when": { 8 | "owner_id": "%%user.id" 9 | }, 10 | "read": true, 11 | "write": true, 12 | "insert": true, 13 | "delete": true, 14 | "additional_fields": {} 15 | }, 16 | { 17 | "name": "non-owner", 18 | "apply_when": {}, 19 | "read": true, 20 | "write": false, 21 | "insert": false, 22 | "delete": false, 23 | "additional_fields": {} 24 | } 25 | ], 26 | "schema": { 27 | "properties": { 28 | "_id": { 29 | "bsonType": "objectId" 30 | }, 31 | "owner_id": { 32 | "bsonType": "string" 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /services/mongodb-atlas/rules/ecommerce.salesTax.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "ecommerce", 3 | "collection": "salesTax", 4 | "roles": [ 5 | { 6 | "name": "default", 7 | "apply_when": {}, 8 | "read": true, 9 | "write": false, 10 | "insert": false, 11 | "delete": false, 12 | "additional_fields": {} 13 | } 14 | ], 15 | "schema": { 16 | "properties": { 17 | "_id": { 18 | "bsonType": "objectId" 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stitch.json: -------------------------------------------------------------------------------- 1 | { 2 | "config_version": 20180301, 3 | "security": {} 4 | } 5 | -------------------------------------------------------------------------------- /triggers/backInStock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backInStock", 3 | "type": "DATABASE", 4 | "config": { 5 | "operation_types": [ 6 | "UPDATE", 7 | "REPLACE" 8 | ], 9 | "database": "ecommerce", 10 | "collection": "products", 11 | "service_name": "mongodb-atlas", 12 | "match": { 13 | "fullDocument.internal.outOfStock": true, 14 | "fullDocument.stockLevel": { 15 | "$gt": { 16 | "$numberInt": "0" 17 | } 18 | } 19 | }, 20 | "full_document": true, 21 | "unordered": true 22 | }, 23 | "function_name": "backInStock", 24 | "disabled": false 25 | } 26 | -------------------------------------------------------------------------------- /triggers/outOfStock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "outOfStock", 3 | "type": "DATABASE", 4 | "config": { 5 | "operation_types": [ 6 | "UPDATE", 7 | "REPLACE" 8 | ], 9 | "database": "ecommerce", 10 | "collection": "products", 11 | "service_name": "mongodb-atlas", 12 | "match": { 13 | "fullDocument.internal.outOfStock": false, 14 | "fullDocument.stockLevel": { 15 | "$eq": { 16 | "$numberInt": "0" 17 | } 18 | } 19 | }, 20 | "full_document": true, 21 | "unordered": true 22 | }, 23 | "function_name": "outOfStock", 24 | "disabled": false 25 | } 26 | -------------------------------------------------------------------------------- /triggers/productInStock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "productInStock", 3 | "type": "DATABASE", 4 | "config": { 5 | "operation_types": [ 6 | "UPDATE" 7 | ], 8 | "database": "ecommerce", 9 | "collection": "products", 10 | "service_name": "mongodb-atlas", 11 | "match": { 12 | "updateDescription.updatedFields.stockLevel": { 13 | "$gt": { 14 | "$numberInt": "0" 15 | } 16 | } 17 | }, 18 | "full_document": true, 19 | "unordered": true 20 | }, 21 | "function_name": "productInStock", 22 | "disabled": true 23 | } 24 | -------------------------------------------------------------------------------- /values/AWS_private_key.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "AWS_private_key", 3 | "value": "AWS_private_key", 4 | "from_secret": true 5 | } 6 | -------------------------------------------------------------------------------- /values/MongoDBProductImage.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MongoDBProductImage", 3 | "value": "https://webassets.mongodb.com/_com_assets/cms/MongoDB_Logo_FullColorBlack_RGB-4td3yuxzjs.png", 4 | "from_secret": false 5 | } 6 | -------------------------------------------------------------------------------- /values/productImagePrefix.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "productImagePrefix", 3 | "value": "https://ecommerce-mongodb-products.s3.amazonaws.com/", 4 | "from_secret": false 5 | } 6 | -------------------------------------------------------------------------------- /values/productImageSuffix.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "productImageSuffix", 3 | "value": "-0.jpg", 4 | "from_secret": false 5 | } 6 | -------------------------------------------------------------------------------- /values/productURLPrefix.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "productURLPrefix", 3 | "value": "https://.mongodbstitch.com/#/product?productID=", 4 | "from_secret": false 5 | } 6 | -------------------------------------------------------------------------------- /values/s3MugBucket.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "s3MugBucket", 3 | "value": "https://.s3.amazonaws.com/", 4 | "from_secret": false 5 | } 6 | -------------------------------------------------------------------------------- /values/sourceEmail.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sourceEmail", 3 | "value": "", 4 | "from_secret": false 5 | } 6 | -------------------------------------------------------------------------------- /values/stripePurchaseCancelURL.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stripePurchaseCancelURL", 3 | "value": "https://.mongodbstitch.com/#/basket", 4 | "from_secret": false 5 | } 6 | -------------------------------------------------------------------------------- /values/stripePurchaseSuccessURL.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stripePurchaseSuccessURL", 3 | "value": "https://.mongodbstitch.com/#/checkedout?session_id={CHECKOUT_SESSION_ID}", 4 | "from_secret": false 5 | } 6 | -------------------------------------------------------------------------------- /values/stripeSecretKey.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stripeSecretKey", 3 | "value": "stripeSecretKey", 4 | "from_secret": true 5 | } 6 | -------------------------------------------------------------------------------- /values/twilioNumber.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twilioNumber", 3 | "value": "", 4 | "from_secret": false 5 | } 6 | --------------------------------------------------------------------------------