From 0cd13119cbc7dbc219c5c29c005ef6c48adbc741 Mon Sep 17 00:00:00 2001 From: Evan Dean Date: Tue, 23 Jul 2019 12:45:34 -0400 Subject: [PATCH] Update to v1.0.3 (#4) * Removed the default user account. * Improved documentation. * Added some logging of Plaid API requests to store request identifiers (to align with the [Plaid docs][plaid-docs]). * Removed the Plaid item id from the client (to align with the [Plaid docs][plaid-docs]). * Removed the Beta label. [plaid-docs]: https://plaid.com/docs/#storing-plaid-api-identifiers --- README.md | 23 +++- client/README.md | 18 ++- client/package-lock.json | 2 +- client/package.json | 2 +- client/src/App.css | 19 +-- client/src/components/ItemCard.jsx | 12 +- client/src/components/ItemList.jsx | 2 +- client/src/components/Landing.jsx | 5 +- client/src/components/Sockets.jsx | 37 +++--- client/src/services/api.js | 41 +++--- client/src/services/link.js | 49 ++++++-- client/src/services/webhooks.js | 4 +- database/README.md | 62 ++++++++- database/init/create.sql | 58 +++++++-- database/init/insert.sql | 2 - docker-compose.yml | 6 +- server/README.md | 8 +- server/db/queries/accounts.js | 27 ++-- server/db/queries/index.js | 30 ++--- server/db/queries/items.js | 88 ++++++------- server/db/queries/linkEvents.js | 47 +++++++ server/db/queries/plaidApiEvents.js | 54 ++++++++ server/db/queries/transactions.js | 6 +- server/db/queries/users.js | 39 ++---- server/index.js | 2 + server/package-lock.json | 2 +- server/package.json | 2 +- server/plaid.js | 118 ++++++++++++++++-- server/routes/index.js | 2 + server/routes/items.js | 27 ++-- server/routes/linkEvents.js | 30 +++++ server/routes/users.js | 14 +-- server/util.js | 1 - server/webhookHandlers/handleItemWebhook.js | 28 ++--- .../handleTransactionsWebhook.js | 33 ++--- 35 files changed, 623 insertions(+), 277 deletions(-) delete mode 100644 database/init/insert.sql create mode 100644 server/db/queries/linkEvents.js create mode 100644 server/db/queries/plaidApiEvents.js create mode 100644 server/routes/linkEvents.js diff --git a/README.md b/README.md index cc4d13da..dadb8455 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Plaid Pattern (Beta) +# Plaid Pattern ![Plaid Pattern client][client-img] @@ -8,7 +8,7 @@ This is a reference application demonstrating an end-to-end [Plaid][plaid] integ ## Requirements -- [Docker][docker] Version 2.0.0.3 (31259) or higher, installed and running +- [Docker][docker] Version 2.0.0.3 (31259) or higher, installed, running, and signed in - [Plaid API keys][plaid-keys] - [sign up][plaid-signup] for a free Sandbox account if you don't already have one ## Getting Started @@ -38,8 +38,16 @@ This is a reference application demonstrating an end-to-end [Plaid][plaid] integ All available commands can be seen by calling `make help`. ## Architecture +As a modern full-stack application, Pattern consists of multiple services handling different segments of the stack: -For more information about the individual services, see the readmes for the [client][client-readme], [database][database-readme], and [server][server-readme]. +- [`database`][database-readme] runs a [PostgreSQL][postgres] database +- [`server`][server-readme] runs an application back-end server using [NodeJS] and [Express] +- [`client`][client-readme] runs a [React]-based single-page web frontend +- [`ngrok`][ngrok-readme] exposes a [ngrok] tunnel from your local machine to the Internet to receive webhooks + +We use [Docker Compose][docker-compose] to orchestrate these services. As such, each individual service has its own Dockerfile, which Docker Compose reads when bringing up the services. + +For more information about the individual services, see their readmes, linked in the list above. ## Troubleshooting @@ -59,7 +67,12 @@ See [`docs/troubleshooting.md`][troubleshooting]. [client-img]: docs/pattern_screenshot.png [client-readme]: client/README.md [database-readme]: database/README.md -[docker]: https://www.docker.com/products/docker-desktop +[docker]: https://docs.docker.com/ +[docker-compose]: https://docs.docker.com/compose/ +[express]: https://expressjs.com/ +[ngrok]: https://ngrok.com/ +[ngrok-readme]: ngrok/README.md +[nodejs]: https://nodejs.org/en/ [plaid]: https://plaid.com [plaid-docs]: https://plaid.com/docs/ [plaid-help]: https://support.plaid.com/hc/en-us @@ -67,5 +80,7 @@ See [`docs/troubleshooting.md`][troubleshooting]. [plaid-quickstart]: https://plaid.com/docs/quickstart/ [plaid-signup]: https://dashboard.plaid.com/signup [plaid-support-ticket]: https://dashboard.plaid.com/support/new +[postgres]: https://www.postgresql.org/ +[react]: http://reactjs.org/ [server-readme]: server/README.md [troubleshooting]: docs/troubleshooting.md diff --git a/client/README.md b/client/README.md index f8486a3f..8640fb36 100644 --- a/client/README.md +++ b/client/README.md @@ -1,8 +1,18 @@ # Plaid Pattern - Client -The client-side code demonstrates a [Plaid Link](https://plaid.com/docs/#integrating-with-link) integration. It is written using [React](https://reactjs.org/) (bootstrapped with [Create React App](https://github.com/facebook/create-react-app)). The app runs on port 3000 by default, although you can modify this in [docker-compose.yml](../docker-compose.yml). +The Pattern web client is written in JavaScript using [React]. It presents a basic [Link][plaid-link] workflow to the user, as well as a simple dashboard displaying linked accounts and transactions. The app runs on port 3000 by default, although you can modify this in [docker-compose.yml](../docker-compose.yml). -## Learn More +## Key concepts -- [React documentation](https://reactjs.org/) -- [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started) +### Communicating with the server +Aside from websocket listeners (see below), all HTTP calls to the Pattern server are defined in `src/services/api.js`. + +### Instantiating Link +You'll notice that we don't create a Link instance until the user clicks the Link button. This is because we need information about which user or item to set up Link for before we can create the instance, both for the purposes of setting the necessary callbacks and for letting Plaid know whether we're adding a new item or updating an existing one. Note also that we maintain each individual Link instance indefinitely after creation, so we don't need to repeatedly recreate the same instances for the same users and items. This has a minimal memory footprint relative to the initial load of the Link SDK. + +### Webhooks and Websockets +The Pattern server is configured to send a message over a websocket whenever it receives a webhook from Plaid. On the client side have websocket listeners defined in `src/components/Sockets.jsx` that wait for these messages and update data in real time accordingly. + +[cra]: https://github.com/facebook/create-react-app +[plaid-link]: https://plaid.com/docs/#integrating-with-link +[react]: https://reactjs.org/ diff --git a/client/package-lock.json b/client/package-lock.json index a705d03d..d86a64c9 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,6 +1,6 @@ { "name": "client", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/client/package.json b/client/package.json index 7610d364..5c693467 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "client", - "version": "1.0.2", + "version": "1.0.3", "private": true, "dependencies": { "axios": "^0.18.0", diff --git a/client/src/App.css b/client/src/App.css index de648af7..f58bf482 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -235,23 +235,24 @@ Item overview } .item-card__column-1 { - width: 17%; - float: left; - margin-top: 8px; -} - -.bank-name { width: 20%; display: flex; + float: left; align-items: center; } -.item-card__column { - width: 43%; +.item-card__column-2 { + width: 45%; float: left; + margin-top: 8px; } -.item-card__column-2 { +.item-card__column-3 { + width: 15%; + float: left; +} + +.item-card__column-4 { width: 20%; float: left; } diff --git a/client/src/components/ItemCard.jsx b/client/src/components/ItemCard.jsx index 9f809b4e..4c56aaa0 100644 --- a/client/src/components/ItemCard.jsx +++ b/client/src/components/ItemCard.jsx @@ -30,7 +30,7 @@ const ItemCard = ({ item, userId }) => { formatLogoSrc, } = useInstitutions(); - const { id, plaid_institution_id, plaid_item_id, status } = item; + const { id, plaid_institution_id, status } = item; const isSandbox = PLAID_ENV === 'sandbox'; const isGoodState = status === 'good'; @@ -60,7 +60,7 @@ const ItemCard = ({ item, userId }) => { className="item-card__clickable" onClick={() => setShowAccounts(current => !current)} > -
+
{ />

{institution && institution.name}

-
+
{isGoodState ? (
Updated
) : (
Login Required
)}
-
+

ITEM_ID

-

{plaid_item_id}

+

{id}

-
+

LAST_UPDATED

{diffBetweenCurrentTime(item.updated_at)}

diff --git a/client/src/components/ItemList.jsx b/client/src/components/ItemList.jsx index 0e47e66e..bb81cb58 100644 --- a/client/src/components/ItemList.jsx +++ b/client/src/components/ItemList.jsx @@ -69,7 +69,7 @@ const ItemList = ({ match }) => { > items - . Click on an item, to view the accounts within. + . Click on an item to view its associated accounts.

)}
diff --git a/client/src/components/Landing.jsx b/client/src/components/Landing.jsx index 21a74576..afbf7f94 100644 --- a/client/src/components/Landing.jsx +++ b/client/src/components/Landing.jsx @@ -20,14 +20,13 @@ export default function Landing({ users }) {

STEP 1

- Select or add a user from the list below, and click the Link an item - button below to connect{' '} + Add a user, and click the "Link an Item" button below to connect{' '} - items + Items {' '} from the user.

diff --git a/client/src/components/Sockets.jsx b/client/src/components/Sockets.jsx index 7e20d95f..958e974f 100644 --- a/client/src/components/Sockets.jsx +++ b/client/src/components/Sockets.jsx @@ -15,30 +15,37 @@ export default function Sockets() { useEffect(() => { socket.current = io(`localhost:${REACT_APP_SERVER_PORT}`); - socket.current.on('DEFAULT_UPDATE', ({ message, itemId } = {}) => - console.log(message, itemId) - ); + socket.current.on('DEFAULT_UPDATE', ({ itemId } = {}) => { + const msg = `New Webhook Event: Item ${itemId}: New Transactions Received`; + console.log(msg); + toast(msg); + }); - socket.current.on('TRANSACTIONS_REMOVED', ({ message, itemId } = {}) => - console.log(message, itemId) - ); + socket.current.on('TRANSACTIONS_REMOVED', ({ itemId } = {}) => { + const msg = `New Webhook Event: Item ${itemId}: Transactions Removed`; + console.log(msg); + toast(msg); + }); - socket.current.on('INITIAL_UPDATE', ({ message, itemId } = {}) => { - console.log(message); - toast('New Webhook Event:\nInitial Transactions Received'); + socket.current.on('INITIAL_UPDATE', ({ itemId } = {}) => { + const msg = `New Webhook Event: Item ${itemId}: Initial Transactions Received`; + console.log(msg); + toast(msg); getAccountsByItem(itemId); getTransactionsByItem(itemId); }); - socket.current.on('HISTORICAL_UPDATE', ({ message, itemId } = {}) => { - console.log(message); - toast('New Webhook Event:\nHistorical Transactions Received'); + socket.current.on('HISTORICAL_UPDATE', ({ itemId } = {}) => { + const msg = `New Webhook Event: Item ${itemId}: Historical Transactions Received`; + console.log(msg); + toast(msg); getTransactionsByItem(itemId, true); }); - socket.current.on('ERROR', ({ message, itemId, errorCode } = {}) => { - console.log(message); - toast.error(`New Webhook Event:\nItem Error ${errorCode}`); + socket.current.on('ERROR', ({ itemId, errorCode } = {}) => { + const msg = `New Webhook Event: Item ${itemId}: Item Error ${errorCode}`; + console.error(msg); + toast.error(msg); getItemById(itemId, true); }); diff --git a/client/src/services/api.js b/client/src/services/api.js index 12199c41..b82efc47 100644 --- a/client/src/services/api.js +++ b/client/src/services/api.js @@ -26,7 +26,7 @@ export const getItemsByUser = userId => api.get(`/users/${userId}/items`); export const deleteItemById = id => api.delete(`/items/${id}`); export const setItemState = (itemId, status) => api.put(`items/${itemId}`, { status }); -// This endoint is only availble in the sandbox enviornment +// This endpoint is only availble in the sandbox enviornment export const setItemToBadState = itemId => api.post('/items/sandbox/item/reset_login', { itemId }); @@ -37,8 +37,6 @@ export const getAccountsByItem = itemId => api.get(`/items/${itemId}/accounts`); export const getAccountsByUser = userId => api.get(`/users/${userId}/accounts`); // transactions -// export const getTransactionsByAccount = accountId => -// api.get(`/accounts/${accountId}/transactions`); export const getTransactionsByAccount = accountId => api.get(`/accounts/${accountId}/transactions`); export const getTransactionsByItem = itemId => @@ -50,10 +48,11 @@ export const getTransactionsByUser = userId => export const getInstitutionById = instId => api.get(`/institutions/${instId}`); // misc +export const postLinkEvent = event => api.post(`/link-event`, event); export const getWebhookUrl = async () => { try { - const res = await fetch('/services/ngrok'); - const { url: urlBase } = await res.json(); + const { data } = await api.get('/services/ngrok'); + const { url: urlBase } = data; return { data: urlBase ? `${urlBase}/services/webhook` : '', @@ -70,30 +69,20 @@ export const exchangeToken = async ( accounts, userId ) => { - const res = await fetch('/items', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + try { + const { data } = await api.post('/items', { publicToken, institutionId: institution.institution_id, userId, accounts, - }), - }); - - const resJson = await res.json(); - - if (res.status === 409) { - const errorMsg = `${institution.name} already linked.`; - console.error(errorMsg); - toast.error(errorMsg); - } else if (resJson.error) { - console.error(resJson.error); - toast.error(`Error linking ${institution.name}`); + }); + return data; + } catch (err) { + const { response } = err; + if (response && response.status === 409) { + toast.error(`${institution.name} already linked.`); + } else { + toast.error(`Error linking ${institution.name}`); + } } - - return resJson; }; diff --git a/client/src/services/link.js b/client/src/services/link.js index 85eaa19b..37d780fb 100644 --- a/client/src/services/link.js +++ b/client/src/services/link.js @@ -1,6 +1,5 @@ import React, { useContext, - useState, useMemo, useEffect, useCallback, @@ -8,7 +7,12 @@ import React, { useRef, } from 'react'; -import { exchangeToken, getPublicToken, setItemState } from './api'; +import { + exchangeToken, + getPublicToken, + setItemState, + postLinkEvent, +} from './api'; import { useWebhooks, useItems } from '.'; const PLAID_ENV = process.env.REACT_APP_PLAID_ENV; @@ -31,7 +35,6 @@ const types = { * and fetch instances of Link. */ export function LinkProvider(props) { - const [webhook, setWebhook] = useState(); const [linkHandlers, dispatch] = useReducer(reducer, { byUser: {}, byItem: {}, @@ -52,10 +55,6 @@ export function LinkProvider(props) { getWebhooksUrl(); }, [getWebhooksUrl]); - useEffect(() => { - setWebhook(webhooksUrl || null); - }, [webhooksUrl]); - /** * @desc Creates a new instance of Link for a given User or Item. For more details on * the configuration object see https://plaid.com/docs/#parameter-reference @@ -88,9 +87,16 @@ export function LinkProvider(props) { dispatch([types.LINK_LOADED, { id: userId }]); } }, - onSuccess: async (publicToken, { institution, accounts }) => { - logEvent('onSuccess', { institution, accounts }); - + onSuccess: async ( + publicToken, + { institution, accounts, link_session_id } + ) => { + logEvent('onSuccess', { institution, accounts, link_session_id }); + await postLinkEvent({ + userId, + link_session_id, + type: 'success', + }); if (isUpdate) { await setItemState(itemId, 'good'); getItemById(itemId, true); @@ -103,9 +109,28 @@ export function LinkProvider(props) { window.location.href = `/user/${userId}/items`; } }, + onExit: async ( + error, + { institution, link_session_id, request_id } + ) => { + logEvent('onExit', { + error, + institution, + link_session_id, + request_id, + }); + const eventError = error || {}; + await postLinkEvent({ + userId, + link_session_id, + request_id, + type: 'exit', + ...eventError, + }); + }, product: ['transactions'], // Add the webhook parameter only if the endpoint is available - ...(webhook && { webhook }), + ...(webhooksUrl && { webhook: webhooksUrl }), ...(token && { token }), }); @@ -116,7 +141,7 @@ export function LinkProvider(props) { } } }, - [webhook, getItemsByUser, getItemById, webhooksFetched] + [webhooksUrl, getItemsByUser, getItemById, webhooksFetched] ); /** diff --git a/client/src/services/webhooks.js b/client/src/services/webhooks.js index 565792ab..818ec641 100644 --- a/client/src/services/webhooks.js +++ b/client/src/services/webhooks.js @@ -20,8 +20,8 @@ export function WebhooksProvider(props) { if (!pendingRequest.current && (refresh || !hasBeenFetched.current)) { pendingRequest.current = true; const { data } = await apiGetWebhooksUrl(); - await setWebhooksUrl(data); - await setFetchedAsState(true); + setWebhooksUrl(data); + setFetchedAsState(true); pendingRequest.current = false; hasBeenFetched.current = true; } diff --git a/database/README.md b/database/README.md index 11877aa4..35614212 100644 --- a/database/README.md +++ b/database/README.md @@ -1,9 +1,65 @@ # Plaid Pattern - Database -The database is a [PostgreSQL](https://www.postgresql.org/) instance running inside a Docker container. +The database is a [PostgreSQL][postgres] instance running inside a Docker container. Port 5432 is exposed to the Docker host, so you can connect to the DB using the tool of your choice. -Username and password can be found in [docker-compose.yml](../docker-compose.yml). +Username and password can be found in [docker-compose.yml][docker-compose]. + +## Key Concepts + +### Plaid API & Link Identifiers + +API and Link Identifiers are crucial for maintaining a scalable and stable integration. +Occasionally, an Institution error may occur due to a bank issue, or a live product pull may fail on request. +To resolve these types of issues, Plaid Identifiers are required to [open a Support ticket in the Dashboard][plaid-new-support-ticket]. + +`access_tokens` and `item_ids` are the core identifiers that map end-users to their financial institutions. +As such, we are storing them in the database associated with our application users. +**These identifiers should never be exposed client-side.** + +Plaid returns a unique `request_id` in all server-side responses and Link callbacks. +A `link_session_id` is also returned in Link callbacks. +These values can be used for identifying the specific network request or Link session for a user, and associating that request or session with other events in your application. +We store these identifiers in database tables used for logging Plaid API requests, as they can be useful for troubleshooting. + +For more information, see the docs page on [storing Plaid API identifiers][plaid-docs-api-identifiers]. + +## Tables + +The `*.sql` scripts in the `init` directory are used to initialize the database if the data directory is empty (i.e. on first run, after manually clearing the db by running `make clear-db`, or after modifying the scripts in the `init` directory). + +See the [create.sql][create-script] initialization script to see some brief notes for and the schemas of the tables used in this application. +While most of them are fairly self-explanitory, we've added some additional notes for some of the tables below. + +### link_events_table + +This table stores responses from the Plaid API for client requests to the Plaid Link client. + +User flows that this table captures (based on the client implementation, which hooks into the `onExit` and `onSuccess` Link callbacks): + +* User opens Link, closes without trying to connect an account. + This will have type `exit` but no request_id, error_type, or error_code. +* User tries to connect an account, fails, and closes link. + This will have type `exit` and will have a request_id, error_type, and error_code. +* User successfully connects an account. + This will have type `success` but no request_id, error_type, or error_code. + +### plaid_api_events_table + +This table stores responses from the Plaid API for server requests to the Plaid client. +The server stores the responses for all of the requests it makes to the Plaid API. +Where applicable, it also maps the response to an item. +If the request returned an error, the error_type and error_code columns will be populated. + +In a real-world application, you might want to map some of these requests to a user or a session, if they were initiated in response to a client request. +Since this demo app doesn't have the concept of a session, we did not incorporate that into these logs. ## Learn More -- [PostgreSQL documentation](https://www.postgresql.org/docs/) +- [PostgreSQL documentation][postgres-docs] + +[create-script]: init/create.sql +[docker-compose]: ../docker-compose.yml +[plaid-docs-api-identifiers]: https://plaid.com/docs/#storing-plaid-api-identifiers +[plaid-new-support-ticket]: https://dashboard.plaid.com/support/new +[postgres]: https://www.postgresql.org/ +[postgres-docs]: https://www.postgresql.org/docs/ diff --git a/database/init/create.sql b/database/init/create.sql index 030d5494..24e1481f 100644 --- a/database/init/create.sql +++ b/database/init/create.sql @@ -1,4 +1,5 @@ --- updates the updated_at timestamp +-- This trigger updates the value in the updated_at column. It is used in the tables below to log +-- when a row was last updated. CREATE OR REPLACE FUNCTION trigger_set_timestamp() RETURNS TRIGGER AS $$ @@ -8,7 +9,10 @@ BEGIN END; $$ LANGUAGE plpgsql; --- Users + +-- USERS +-- This table is used to store the users of our application. The view returns the same data as the +-- table, we're just creating it to follow the pattern used in other tables. CREATE TABLE users_table ( @@ -34,8 +38,10 @@ AS users_table; --- Items --- https://plaid.com/docs/#item-schema +-- ITEMS +-- This table is used to store the items associated with each user. The view returns the same data +-- as the table, we're just using both to maintain consistency with our other tables. For more info +-- on the Plaid Item schema, see the docs page: https://plaid.com/docs/#item-schema CREATE TABLE items_table ( @@ -69,8 +75,10 @@ AS items_table; --- Accounts --- https://plaid.com/docs/#account-schema +-- ACCOUNTS +-- This table is used to store the accounts associated with each item. The view returns all the +-- data from the accounts table and some data from the items view. For more info on the Plaid +-- Accounts schema, see the docs page: https://plaid.com/docs/#account-schema CREATE TABLE accounts_table ( @@ -119,8 +127,10 @@ AS LEFT JOIN items i ON i.id = a.item_id; --- Transactions --- https://plaid.com/docs/#transaction-schema +-- TRANSACTIONS +-- This table is used to store the transactions associated with each account. The view returns all +-- the data from the transactions table and some data from the accounts view. For more info on the +-- Plaid Transactions schema, see the docs page: https://plaid.com/docs/#transaction-schema CREATE TABLE transactions_table ( @@ -172,3 +182,35 @@ AS FROM transactions_table t LEFT JOIN accounts a ON t.account_id = a.id; + + +-- The link_events_table is used to log responses from the Plaid API for client requests to the +-- Plaid Link client. This information is useful for troubleshooting. + +CREATE TABLE link_events_table +( + id SERIAL PRIMARY KEY, + type text NOT NULL, + user_id integer, + link_session_id text NOT NULL, + request_id text UNIQUE, + error_type text, + error_code text, + created_at timestamptz default now() +); + + +-- The plaid_api_events_table is used to log responses from the Plaid API for server requests to +-- the Plaid client. This information is useful for troubleshooting. + +CREATE TABLE plaid_api_events_table +( + id SERIAL PRIMARY KEY, + item_id integer, + plaid_method text NOT NULL, + arguments text, + request_id text UNIQUE, + error_type text, + error_code text, + created_at timestamptz default now() +); diff --git a/database/init/insert.sql b/database/init/insert.sql deleted file mode 100644 index a2da279d..00000000 --- a/database/init/insert.sql +++ /dev/null @@ -1,2 +0,0 @@ --- seed initial user -INSERT INTO users (username) VALUES ('pplatypus'); diff --git a/docker-compose.yml b/docker-compose.yml index 1c0399bc..564b831a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: server: build: ./server - image: plaidinc/pattern-server:1.0.2 + image: plaidinc/pattern-server:1.0.3 ports: - 5000:5000 environment: @@ -38,7 +38,7 @@ services: ngrok: build: ./ngrok - image: plaidinc/pattern-ngrok:1.0.2 + image: plaidinc/pattern-ngrok:1.0.3 command: ["ngrok", "http", "server:5000"] ports: - 4040:4040 @@ -47,7 +47,7 @@ services: client: build: ./client - image: plaidinc/pattern-client:1.0.2 + image: plaidinc/pattern-client:1.0.3 ports: - 3000:3000 environment: diff --git a/server/README.md b/server/README.md index fe52c5cb..2ad9290b 100644 --- a/server/README.md +++ b/server/README.md @@ -1,18 +1,18 @@ # Plaid Pattern - Server -The application server is written in javascript using [Node.js][nodejs] and [Express][expressjs]. It interacts with the Plaid API via the [Plaid Node SDK][plaid-node], and with the [database][database-readme] using [`node-postgres`][node-pg]. While we've used Node for the reference implementation, the concepts shown here will apply no matter what language your backend is written in. +The application server is written in JavaScript using [Node.js][nodejs] and [Express][expressjs]. It interacts with the Plaid API via the [Plaid Node SDK][plaid-node], and with the [database][database-readme] using [`node-postgres`][node-pg]. While we've used Node for the reference implementation, the concepts shown here will apply no matter what language your backend is written in. ## Key Concepts -### Associating users with Plaid items and access tokens. +### Associating users with Plaid items and access tokens Plaid does not have a user data object for tying multiple items together, so it is up to application developers to define that relationship. For an example of this, see the [root items route][items-routes] (used to store new items) and the [users routes][users-routes]. -### Preventing item duplication. +### Preventing item duplication Plaid does not prevent item duplication. It is entirely possible for a user to create multiple items linked to the same financial institution. In practice, you probably want to prevent this. The easiest way to do this is to check the institution id of a newly created item before performing the token exchange and storing the item. For an example of this, see the [root items route][items-routes]. -### Using webhooks to update transaction data. +### Using webhooks to update transaction data Plaid uses [webhooks][transactions-webhooks] to notify you whenever there are new transactions associated with an item. This allows you to make a call to Plaid's transactions endpoint only when there are new transactions available, rather than polling for them. For an example of this, see the [transactions webhook handler][transactions-handler]. diff --git a/server/db/queries/accounts.js b/server/db/queries/accounts.js index a825f283..236d0ae1 100644 --- a/server/db/queries/accounts.js +++ b/server/db/queries/accounts.js @@ -2,7 +2,7 @@ * @file Defines the queries for the accounts table/view. */ -const { retrieveItemId } = require('./items'); +const { retrieveItemByPlaidItemId } = require('./items'); const db = require('../'); /** @@ -10,9 +10,10 @@ const db = require('../'); * * @param {string} plaidItemId the Plaid ID of the item. * @param {Object[]} accounts an array of accounts. + * @returns {Object[]} an array of new accounts. */ const createAccounts = async (plaidItemId, accounts) => { - const itemId = await retrieveItemId(plaidItemId); + const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); const pendingQueries = accounts.map(async account => { const { account_id: aid, @@ -29,6 +30,7 @@ const createAccounts = async (plaidItemId, accounts) => { type, } = account; const query = { + // RETURNING is a Postgres-specific clause that returns a list of the inserted items. text: ` INSERT INTO accounts_table ( @@ -51,6 +53,8 @@ const createAccounts = async (plaidItemId, accounts) => { DO UPDATE SET current_balance = $6, available_balance = $7 + RETURNING + * `, values: [ itemId, @@ -66,26 +70,27 @@ const createAccounts = async (plaidItemId, accounts) => { subtype, ], }; - await db.query(query); + const { rows } = await db.query(query); + return rows[0]; }); - await Promise.all(pendingQueries); + const newAccounts = await Promise.all(pendingQueries); + return newAccounts; }; /** - * Retrieves the account ID for a single account. + * Retrieves the account associated with a Plaid account ID. * * @param {string} plaidAccountId the Plaid ID of the account. - * @returns {number} the account ID. + * @returns {Object} a single account. */ -const retrieveAccountId = async plaidAccountId => { +const retrieveAccountByPlaidAccountId = async plaidAccountId => { const query = { - text: 'SELECT (id) FROM accounts WHERE plaid_account_id = $1', + text: 'SELECT * FROM accounts WHERE plaid_account_id = $1', values: [plaidAccountId], }; const { rows } = await db.query(query); // since Plaid account IDs are unique, this query will never return more than one row. - const accountId = rows[0].id; - return accountId; + return rows[0]; }; /** @@ -120,7 +125,7 @@ const retrieveAccountsByUserId = async userId => { module.exports = { createAccounts, - retrieveAccountId, + retrieveAccountByPlaidAccountId, retrieveAccountsByItemId, retrieveAccountsByUserId, }; diff --git a/server/db/queries/index.js b/server/db/queries/index.js index f75a876d..fe88d9de 100644 --- a/server/db/queries/index.js +++ b/server/db/queries/index.js @@ -4,21 +4,21 @@ const { createAccounts, - retrieveAccountId, + retrieveAccountByPlaidAccountId, retrieveAccountsByItemId, retrieveAccountsByUserId, } = require('./accounts'); const { createItem, deleteItem, - retrieveAccessTokenByPlaidItemId, - retrieveAccessTokenByItemId, + retrieveItemById, + retrieveItemByPlaidAccessToken, retrieveItemByPlaidInstitutionId, - retrieveItemId, - retrieveItemsById, + retrieveItemByPlaidItemId, retrieveItemsByUser, updateItemStatus, } = require('./items'); +const { createPlaidApiEvent } = require('./plaidApiEvents'); const { createTransactions, retrieveTransactionsByAccountId, @@ -31,27 +31,28 @@ const { createUser, deleteUsers, retrieveUsers, - retrieveAccessTokensByUserId, - retrieveUserByUserId, + retrieveUserById, retrieveUserByUsername, } = require('./users'); +const { createLinkEvent } = require('./linkEvents'); module.exports = { // accounts createAccounts, - retrieveAccountId, + retrieveAccountByPlaidAccountId, retrieveAccountsByItemId, retrieveAccountsByUserId, // items createItem, deleteItem, - retrieveAccessTokenByPlaidItemId, - retrieveAccessTokenByItemId, + retrieveItemById, + retrieveItemByPlaidAccessToken, retrieveItemByPlaidInstitutionId, - retrieveItemId, - retrieveItemsById, + retrieveItemByPlaidItemId, retrieveItemsByUser, updateItemStatus, + // plaid api events + createPlaidApiEvent, // transactions createTransactions, retrieveTransactionsByAccountId, @@ -62,8 +63,9 @@ module.exports = { // users createUser, deleteUsers, - retrieveUserByUserId, + retrieveUserById, retrieveUserByUsername, - retrieveAccessTokensByUserId, retrieveUsers, + // link events + createLinkEvent, }; diff --git a/server/db/queries/items.js b/server/db/queries/items.js index 97b8b3ef..580327e2 100644 --- a/server/db/queries/items.js +++ b/server/db/queries/items.js @@ -11,6 +11,7 @@ const db = require('../'); * @param {string} plaidAccessToken the Plaid access token of the item. * @param {string} plaidItemId the Plaid ID of the item. * @param {number} userId the ID of the user. + * @returns {Object} the new item. */ const createItem = async ( plaidInstitutionId, @@ -22,49 +23,51 @@ const createItem = async ( // We know the status is good. const status = 'good'; const query = { + // RETURNING is a Postgres-specific clause that returns a list of the inserted items. text: ` INSERT INTO items_table (user_id, plaid_access_token, plaid_item_id, plaid_institution_id, status) VALUES - ($1, $2, $3, $4, $5); + ($1, $2, $3, $4, $5) + RETURNING + *; `, values: [userId, plaidAccessToken, plaidItemId, plaidInstitutionId, status], }; - await db.query(query); + const { rows } = await db.query(query); + return rows[0]; }; /** - * Retrieves the Plaid access token for an item. + * Retrieves a single item. * - * @param {string} plaidItemId the Plaid ID of the item. - * @returns {string} the Plaid access token. + * @param {number} itemId the ID of the item. + * @returns {Object} an item. */ -const retrieveAccessTokenByPlaidItemId = async plaidItemId => { +const retrieveItemById = async itemId => { const query = { - text: 'SELECT plaid_access_token FROM items WHERE plaid_item_id = $1;', - values: [plaidItemId], + text: 'SELECT * FROM items WHERE id = $1', + values: [itemId], }; const { rows } = await db.query(query); - // since Plaid item IDs are unique, this query will never return more than one row. - const accessToken = rows[0].plaid_access_token; - return accessToken; + // since item IDs are unique, this query will never return more than one row. + return rows[0]; }; /** - * Retrieves the Plaid access token for an item. + * Retrieves a single item. * - * @param {string} itemId of the item. - * @returns {string} the Plaid access token. + * @param {string} accessToken the Plaid access token of the item. + * @returns {Object} the item. */ -const retrieveAccessTokenByItemId = async itemId => { +const retrieveItemByPlaidAccessToken = async accessToken => { const query = { - text: 'SELECT plaid_access_token FROM items WHERE id = $1;', - values: [itemId], + text: 'SELECT * FROM items WHERE plaid_access_token = $1', + values: [accessToken], }; - const { rows } = await db.query(query); - // since Plaid item IDs are unique, this query will never return more than one row. - const accessToken = rows[0].plaid_access_token; - return accessToken; + const { rows: existingItems } = await db.query(query); + // Access tokens are unique, so this will return at most one item. + return existingItems[0]; }; /** @@ -85,32 +88,19 @@ const retrieveItemByPlaidInstitutionId = async (plaidInstitutionId, userId) => { }; /** - * Retrieves the item ID for a single item. + * Retrieves a single item. * * @param {string} plaidItemId the Plaid ID of the item. - * @returns {number} the item ID. - */ -const retrieveItemId = async plaidItemId => { - const query = 'SELECT (id) FROM items WHERE plaid_item_id = $1'; - const { rows } = await db.query(query, [plaidItemId]); - // since Plaid item IDs are unique, this query will never return more than one row. - const itemId = rows[0].id; - return itemId; -}; - -/** - * Retrieves all stored items for a given item ID. - * - * @param {number} itemId the ID of the item. - * @returns {Object[]} Array of 1 item. + * @returns {Object} an item. */ -const retrieveItemsById = async itemId => { +const retrieveItemByPlaidItemId = async plaidItemId => { const query = { - text: 'SELECT * FROM items WHERE id = $1', - values: [itemId], + text: 'SELECT * FROM items WHERE plaid_item_id = $1', + values: [plaidItemId], }; - const { rows: items } = await db.query(query); - return items; + const { rows } = await db.query(query); + // since Plaid item IDs are unique, this query will never return more than one row. + return rows[0]; }; /** @@ -143,12 +133,13 @@ const updateItemStatus = async (itemId, status) => { }; /** - * Removes item, related accounts and transactions. - * @param {string[]} itemId the Plaid IDs of the transactions. + * Removes a single item. The database will also remove related accounts and transactions. + * + * @param {string} itemId the id of the item. */ const deleteItem = async itemId => { const query = { - text: `DELETE FROM items_table i WHERE i.id = $1`, + text: `DELETE FROM items_table WHERE id = $1`, values: [itemId], }; await db.query(query); @@ -157,11 +148,10 @@ const deleteItem = async itemId => { module.exports = { createItem, deleteItem, - retrieveAccessTokenByPlaidItemId, - retrieveAccessTokenByItemId, + retrieveItemById, + retrieveItemByPlaidAccessToken, retrieveItemByPlaidInstitutionId, - retrieveItemId, - retrieveItemsById, + retrieveItemByPlaidItemId, retrieveItemsByUser, updateItemStatus, }; diff --git a/server/db/queries/linkEvents.js b/server/db/queries/linkEvents.js new file mode 100644 index 00000000..f3f8e79d --- /dev/null +++ b/server/db/queries/linkEvents.js @@ -0,0 +1,47 @@ +/** + * @file Defines the queries for link events. + */ + +const db = require('..'); + +/** + * Creates a link event. + * + * @param {Object} event the link event. + * @param {string} event.userId the ID of the user. + * @param {string} event.type displayed as 'success' or 'exit' based on the callback. + * @param {string} event.link_session_id the session ID created when connecting with link. + * @param {string} event.request_id the request ID created only on error when connecting with link. + * @param {string} event.error_type a broad categorization of the error. + * @param {string} event.error_code a specific code that is a subset of the error_type. + */ + +const createLinkEvent = async ({ + type, + userId, + link_session_id: linkSessionId, + request_id: requestId, + error_type: errorType, + error_code: errorCode, +}) => { + const query = { + text: ` + INSERT INTO link_events_table + ( + type, + user_id, + link_session_id, + request_id, + error_type, + error_code + ) + VALUES ($1, $2, $3, $4, $5, $6); + `, + values: [type, userId, linkSessionId, requestId, errorType, errorCode], + }; + await db.query(query); +}; + +module.exports = { + createLinkEvent, +}; diff --git a/server/db/queries/plaidApiEvents.js b/server/db/queries/plaidApiEvents.js new file mode 100644 index 00000000..240cd58d --- /dev/null +++ b/server/db/queries/plaidApiEvents.js @@ -0,0 +1,54 @@ +/** + * @file Defines the queries for the plaid_api_events table. + */ + +const db = require('../'); + +/** + * Creates a single Plaid api event log entry. + * + * @param {string} itemId the item id in the request. + * @param {string} plaidMethod the Plaid client method called. + * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. + * @param {Object} response the Plaid api response object. + */ +const createPlaidApiEvent = async ( + itemId, + plaidMethod, + clientMethodArgs, + response +) => { + const { + error_code: errorCode, + error_type: errorType, + request_id: requestId, + } = response; + const query = { + text: ` + INSERT INTO plaid_api_events_table + ( + item_id, + plaid_method, + arguments, + request_id, + error_type, + error_code + ) + VALUES + ($1, $2, $3, $4, $5, $6); + `, + values: [ + itemId, + plaidMethod, + JSON.stringify(clientMethodArgs), + requestId, + errorType, + errorCode, + ], + }; + await db.query(query); +}; + +module.exports = { + createPlaidApiEvent, +}; diff --git a/server/db/queries/transactions.js b/server/db/queries/transactions.js index 146c69a2..28f95d15 100644 --- a/server/db/queries/transactions.js +++ b/server/db/queries/transactions.js @@ -2,7 +2,7 @@ * @file Defines the queries for the transactions table. */ -const { retrieveAccountId } = require('./accounts'); +const { retrieveAccountByPlaidAccountId } = require('./accounts'); const db = require('../'); /** @@ -26,7 +26,9 @@ const createTransactions = async transactions => { pending, account_owner: accountOwner, } = transaction; - const accountId = await retrieveAccountId(plaidAccountId); + const { id: accountId } = await retrieveAccountByPlaidAccountId( + plaidAccountId + ); const [category, subcategory] = categories; try { const query = { diff --git a/server/db/queries/users.js b/server/db/queries/users.js index 57809244..946bdfbe 100644 --- a/server/db/queries/users.js +++ b/server/db/queries/users.js @@ -8,13 +8,16 @@ const db = require('../'); * Creates a single user. * * @param {string} username the username of the user. + * @returns {Object} the new user. */ const createUser = async username => { const query = { - text: 'INSERT INTO users_table (username) VALUES ($1);', + // RETURNING is a Postgres-specific clause that returns a list of the inserted items. + text: 'INSERT INTO users_table (username) VALUES ($1) RETURNING *;', values: [username], }; - await db.query(query); + const { rows } = await db.query(query); + return rows[0]; }; /** @@ -33,23 +36,23 @@ const deleteUsers = async userId => { }; /** - * Retrieves users with a given user ID. + * Retrieves a single user. * * @param {number} userId the ID of the user. - * @returns {Object[]} an array of users. + * @returns {Object} a user. */ -const retrieveUserByUserId = async userId => { +const retrieveUserById = async userId => { const query = { text: 'SELECT * FROM users WHERE id = $1', values: [userId], }; - const { rows: user } = await db.query(query); - return user; + const { rows } = await db.query(query); + // since the user IDs are unique, this query will return at most one result. + return rows[0]; }; /** - * Retrieves a single user by username. - * + * Retrieves a single user. * * @param {string} username the username to search for. * @returns {Object} a single user. @@ -77,26 +80,10 @@ const retrieveUsers = async () => { return users; }; -/** - * Retrieves the Plaid access tokens for an item. - * - * @param {number} userId the userId to get plaid access tokens for. - * @returns {string[]} an array of Plaid access tokens. - */ -const retrieveAccessTokensByUserId = async userId => { - const query = { - text: 'SELECT plaid_access_token FROM items WHERE user_id = $1;', - values: [userId], - }; - const { rows } = await db.query(query); - return rows.map(r => r.plaid_access_token); -}; - module.exports = { createUser, deleteUsers, - retrieveUserByUserId, + retrieveUserById, retrieveUserByUsername, retrieveUsers, - retrieveAccessTokensByUserId, }; diff --git a/server/index.js b/server/index.js index 12dfce8d..ef76e156 100644 --- a/server/index.js +++ b/server/index.js @@ -15,6 +15,7 @@ const { accountsRouter, institutionsRouter, serviceRouter, + linkEventsRouter, unhandledRouter, } = require('./routes'); @@ -56,6 +57,7 @@ app.use('/items', itemsRouter); app.use('/accounts', accountsRouter); app.use('/institutions', institutionsRouter); app.use('/services', serviceRouter); +app.use('/link-event', linkEventsRouter); app.use('*', unhandledRouter); // Error handling has to sit at the bottom of the stack. diff --git a/server/package-lock.json b/server/package-lock.json index 40bee83d..f0e528e9 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/server/package.json b/server/package.json index 510c4b70..fc0f764e 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "1.0.2", + "version": "1.0.3", "private": true, "scripts": { "start": "node index.js", diff --git a/server/plaid.js b/server/plaid.js index 25db9e2c..87e6833d 100644 --- a/server/plaid.js +++ b/server/plaid.js @@ -2,8 +2,14 @@ * @file Defines the connection to the Plaid client. */ +const forEach = require('lodash/forEach'); const plaid = require('plaid'); +const { + createPlaidApiEvent, + retrieveItemByPlaidAccessToken, +} = require('./db/queries'); + // Your Plaid API keys and secrets are loaded as environment variables. // They are set in your `.env` file. See the repo README for more info. const { @@ -14,26 +20,114 @@ const { PLAID_SECRET_SANDBOX, } = process.env; -// The Plaid secret is unique per environment. +// The Plaid secret is unique per environment. Note that there is also a separate production key, +// though we do not account for that here. const PLAID_SECRET = PLAID_ENV === 'development' ? PLAID_SECRET_DEVELOPMENT : PLAID_SECRET_SANDBOX; const OPTIONS = { clientApp: 'Plaid-Pattern' }; +// We want to log requests to / responses from the Plaid API (via the Plaid client), as this data +// can be useful for troubleshooting. + /** - * Initializes a new Plaid Client using your Plaid keys. + * Logging function for Plaid client methods that use an access_token as an argument. Associates + * the Plaid API event log entry with the item the request is for. * - * @returns initialized plaid client. + * @param {string} clientMethod the name of the Plaid client method called. + * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. + * @param {Object} response the response from the Plaid client. */ -const initPlaidClient = () => { - const plaidClient = new plaid.Client( - PLAID_CLIENT_ID, - PLAID_SECRET, - PLAID_PUBLIC_KEY, - plaid.environments[PLAID_ENV], - OPTIONS +const defaultLogger = async (clientMethod, clientMethodArgs, response) => { + const accessToken = clientMethodArgs[0]; + const { id: itemId } = await retrieveItemByPlaidAccessToken(accessToken); + await createPlaidApiEvent(itemId, clientMethod, clientMethodArgs, response); +}; + +/** + * Logging function for Plaid client methods that do not use access_token as an argument. These + * Plaid API event log entries will not be associated with an item. + * + * @param {string} clientMethod the name of the Plaid client method called. + * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. + * @param {Object} response the response from the Plaid client. + */ +const noAccessTokenLogger = async ( + clientMethod, + clientMethodArgs, + response +) => { + await createPlaidApiEvent( + undefined, + clientMethod, + clientMethodArgs, + response ); - return plaidClient; }; -module.exports = initPlaidClient(); +// All available Plaid client methods as of v4.1.0 mapped to their appropriate logging functions. +const clientMethodLoggingFns = { + createPublicToken: defaultLogger, + exchangePublicToken: noAccessTokenLogger, + createProcessorToken: defaultLogger, + invalidateAccessToken: defaultLogger, + updateAccessTokenVersion: defaultLogger, + removeItem: defaultLogger, + getItem: defaultLogger, + updateItemWebhook: defaultLogger, + getAccounts: defaultLogger, + getBalance: defaultLogger, + getAuth: defaultLogger, + getIdentity: defaultLogger, + getIncome: defaultLogger, + getCreditDetails: defaultLogger, + getLiabilities: defaultLogger, + getHoldings: defaultLogger, + getInvestmentTransactions: defaultLogger, + getTransactions: defaultLogger, + getAllTransactions: defaultLogger, + createStripeToken: defaultLogger, + getInstitutions: noAccessTokenLogger, + getInstitutionById: noAccessTokenLogger, + searchInstitutionsByName: noAccessTokenLogger, + getCategories: noAccessTokenLogger, + // remaining methods are only available in the sandbox environment + resetLogin: defaultLogger, + sandboxItemFireWebhook: defaultLogger, + sandboxPublicTokenCreate: noAccessTokenLogger, +}; + +// Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests. +class PlaidClientWrapper { + constructor() { + // Initialize the Plaid client. + this.client = new plaid.Client( + PLAID_CLIENT_ID, + PLAID_SECRET, + PLAID_PUBLIC_KEY, + plaid.environments[PLAID_ENV], + OPTIONS + ); + + // Wrap the Plaid client methods to add a logging function. + forEach(clientMethodLoggingFns, (logFn, method) => { + this[method] = this.createWrappedClientMethod(method, logFn); + }); + } + + // Allows us to log API request data for troubleshooting purposes. + createWrappedClientMethod(clientMethod, log) { + return async (...args) => { + try { + const res = await this.client[clientMethod](...args); + await log(clientMethod, args, res); + return res; + } catch (err) { + await log(clientMethod, args, err); + throw err; + } + }; + } +} + +module.exports = new PlaidClientWrapper(); diff --git a/server/routes/index.js b/server/routes/index.js index b0d074ed..87538b22 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -7,6 +7,7 @@ const itemsRouter = require('./items'); const accountsRouter = require('./accounts'); const institutionsRouter = require('./institutions'); const serviceRouter = require('./services'); +const linkEventsRouter = require('./linkEvents'); const unhandledRouter = require('./unhandled'); module.exports = { @@ -15,5 +16,6 @@ module.exports = { accountsRouter, institutionsRouter, serviceRouter, + linkEventsRouter, unhandledRouter, }; diff --git a/server/routes/items.js b/server/routes/items.js index d01de14f..9be61827 100644 --- a/server/routes/items.js +++ b/server/routes/items.js @@ -5,8 +5,7 @@ const express = require('express'); const Boom = require('@hapi/boom'); const { - retrieveAccessTokenByItemId, - retrieveItemsById, + retrieveItemById, retrieveItemByPlaidInstitutionId, retrieveAccountsByItemId, retrieveTransactionsByItemId, @@ -54,8 +53,13 @@ router.post( item_id: itemId, access_token: accessToken, } = await plaid.exchangePublicToken(publicToken); - await createItem(institutionId, accessToken, itemId, userId); - res.json({}); + const newItem = await createItem( + institutionId, + accessToken, + itemId, + userId + ); + res.json(sanitizeItems(newItem)); }) ); @@ -69,8 +73,8 @@ router.get( '/:itemId', asyncWrapper(async (req, res) => { const { itemId } = req.params; - const items = await retrieveItemsById(itemId); - res.json(sanitizeItems(items)); + const item = await retrieveItemById(itemId); + res.json(sanitizeItems(item)); }) ); @@ -97,8 +101,8 @@ router.put( ); } await updateItemStatus(itemId, status); - const items = await retrieveItemsById(itemId); - res.json(sanitizeItems(items)); + const item = await retrieveItemById(itemId); + res.json(sanitizeItems(item)); } else { throw new Boom('You must provide updated item information.', { statusCode: 400, @@ -120,7 +124,7 @@ router.delete( '/:itemId', asyncWrapper(async (req, res) => { const { itemId } = req.params; - const accessToken = await retrieveAccessTokenByItemId(itemId); + const { plaid_access_token: accessToken } = await retrieveItemById(itemId); /* eslint-disable camelcase */ const { removed, status_code } = await plaid.removeItem(accessToken); @@ -177,8 +181,7 @@ router.post( '/sandbox/item/reset_login', asyncWrapper(async (req, res) => { const { itemId } = req.body; - const items = await retrieveItemsById(itemId); - const { plaid_access_token: accessToken } = items[0]; + const { plaid_access_token: accessToken } = await retrieveItemById(itemId); const resetResponse = await plaid.resetLogin(accessToken); res.json(resetResponse); }) @@ -194,7 +197,7 @@ router.post( '/:itemId/public_token', asyncWrapper(async (req, res) => { const { itemId } = req.params; - const accessToken = await retrieveAccessTokenByItemId(itemId); + const { plaid_access_token: accessToken } = await retrieveItemById(itemId); const publicToken = await plaid.createPublicToken(accessToken); res.send(publicToken); }) diff --git a/server/routes/linkEvents.js b/server/routes/linkEvents.js new file mode 100644 index 00000000..c040676c --- /dev/null +++ b/server/routes/linkEvents.js @@ -0,0 +1,30 @@ +/** + * @file Defines all routes for Link Events. + */ + +const express = require('express'); + +const { createLinkEvent } = require('../db/queries'); +const { asyncWrapper } = require('../middleware'); + +const router = express.Router(); + +/** + * Creates a new link event . + * + * @param {string} userId the ID of the user. + * @param {string} type displayed as 'success' or 'exit' based on the callback. + * @param {string} link_session_id the session ID created when connecting with link. + * @param {string} request_id the request ID created only on error when connecting with link. + * @param {string} error_type a broad categorization of the error. + * @param {string} error_code a specific code that is a subset of the error_type. + */ +router.post( + '/', + asyncWrapper(async (req, res) => { + await createLinkEvent(req.body); + res.sendStatus(200); + }) +); + +module.exports = router; diff --git a/server/routes/users.js b/server/routes/users.js index 3c29c272..da799a8f 100644 --- a/server/routes/users.js +++ b/server/routes/users.js @@ -12,8 +12,7 @@ const { deleteUsers, retrieveItemsByUser, retrieveTransactionsByUserId, - retrieveUserByUserId, - retrieveAccessTokensByUserId, + retrieveUserById, } = require('../db/queries'); const { asyncWrapper } = require('../middleware'); const { @@ -56,8 +55,7 @@ router.post( // prevent duplicates if (usernameExists) throw new Boom('Username already exists', { statusCode: 409 }); - await createUser(username); - const newUser = await retrieveUserByUsername(username); + const newUser = await createUser(username); res.json(sanitizeUsers(newUser)); }) ); @@ -72,7 +70,7 @@ router.get( '/:userId', asyncWrapper(async (req, res) => { const { userId } = req.params; - const user = await retrieveUserByUserId(userId); + const user = await retrieveUserById(userId); res.json(sanitizeUsers(user)); }) ); @@ -137,8 +135,10 @@ router.delete( // access any data that was associated with the Item. // @TODO wrap promise in a try catch block once proper error handling introduced - const plaidAccessTokens = await retrieveAccessTokensByUserId(userId); - await Promise.all(plaidAccessTokens.map(t => plaid.removeItem(t))); + const items = await retrieveItemsByUser(userId); + await Promise.all( + items.map(({ plaid_access_token: token }) => plaid.removeItem(token)) + ); // delete from the db await deleteUsers(userId); diff --git a/server/util.js b/server/util.js index c5289480..b3c701c5 100644 --- a/server/util.js +++ b/server/util.js @@ -50,7 +50,6 @@ const sanitizeItems = items => sanitizeWith(items, [ 'id', 'user_id', - 'plaid_item_id', 'plaid_institution_id', 'status', 'created_at', diff --git a/server/webhookHandlers/handleItemWebhook.js b/server/webhookHandlers/handleItemWebhook.js index fc63a1d1..d2a2fd92 100644 --- a/server/webhookHandlers/handleItemWebhook.js +++ b/server/webhookHandlers/handleItemWebhook.js @@ -3,7 +3,10 @@ * https://plaid.com/docs/#item-webhooks */ -const { updateItemStatus, retrieveItemId } = require('../db/queries'); +const { + updateItemStatus, + retrieveItemByPlaidItemId, +} = require('../db/queries'); /** * Handles Item errors received from item webhooks. When an error is received @@ -17,7 +20,7 @@ const itemErrorHandler = async (plaidItemId, error) => { const { error_code: errorCode } = error; switch (errorCode) { case 'ITEM_LOGIN_REQUIRED': { - const itemId = await retrieveItemId(plaidItemId); + const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); await updateItemStatus(itemId, 'bad'); break; } @@ -56,21 +59,12 @@ const itemsHandler = async (requestBody, io) => { error, } = requestBody; - const message = (additionalInfo, type = 'WEBHOOK') => - `${type}: ITEMS: ${webhookCode}: Plaid item id ${plaidItemId}: ${additionalInfo}`; - - // websocket is emitted to the client-side as a webhook is received from Plaid - const emitSocket = (additionalInfo, itemId, errorCode) => { - io.emit(webhookCode, { - message: message(additionalInfo, 'WEBSOCKETS'), - itemId, - errorCode, - }); - }; - const serverLogAndEmitSocket = (additionalInfo, itemId, errorCode) => { - console.log(message(additionalInfo)); - if (webhookCode) emitSocket(additionalInfo, itemId, errorCode); + console.log( + `WEBHOOK: ITEMS: ${webhookCode}: Plaid item id ${plaidItemId}: ${additionalInfo}` + ); + // use websocket to notify the client that a webhook has been received and handled + if (webhookCode) io.emit(webhookCode, { itemId, errorCode }); }; switch (webhookCode) { @@ -79,7 +73,7 @@ const itemsHandler = async (requestBody, io) => { break; case 'ERROR': { itemErrorHandler(plaidItemId, error); - const itemId = await retrieveItemId(plaidItemId); + const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); serverLogAndEmitSocket( `ERROR: ${error.error_code}: ${error.error_message}`, itemId, diff --git a/server/webhookHandlers/handleTransactionsWebhook.js b/server/webhookHandlers/handleTransactionsWebhook.js index dd715e2f..01d69fa0 100644 --- a/server/webhookHandlers/handleTransactionsWebhook.js +++ b/server/webhookHandlers/handleTransactionsWebhook.js @@ -6,8 +6,7 @@ const moment = require('moment'); const plaid = require('../plaid'); const { - retrieveAccessTokenByPlaidItemId, - retrieveItemId, + retrieveItemByPlaidItemId, createAccounts, createTransactions, deleteTransactions, @@ -28,7 +27,9 @@ const fetchTransactions = async (plaidItemId, startDate, endDate) => { try { // get the access token based on the plaid item id - const accessToken = await retrieveAccessTokenByPlaidItemId(plaidItemId); + const { plaid_access_token: accessToken } = await retrieveItemByPlaidItemId( + plaidItemId + ); let offset = 0; let transactionsToFetch = true; @@ -139,20 +140,12 @@ const handleTransactionsWebhook = async (requestBody, io) => { removed_transactions: removedTransactions, } = requestBody; - const message = (additionalInfo, type = 'WEBHOOK') => - `${type}: TRANSACTIONS: ${webhookCode}: Plaid item id ${plaidItemId}: ${additionalInfo}`; - - // websocket is emitted to the client-side as a webhook is received from Plaid - const emitSocket = (additionalInfo, itemId) => { - io.emit(webhookCode, { - message: message(additionalInfo, 'WEBSOCKETS'), - itemId, - }); - }; - const serverLogAndEmitSocket = (additionalInfo, itemId) => { - console.log(message(additionalInfo)); - if (webhookCode) emitSocket(additionalInfo, itemId); + console.log( + `WEBHOOK: TRANSACTIONS: ${webhookCode}: Plaid_item_id ${plaidItemId}: ${additionalInfo}` + ); + // use websocket to notify the client that a webhook has been received and handled + if (webhookCode) io.emit(webhookCode, { itemId }); }; switch (webhookCode) { @@ -164,7 +157,7 @@ const handleTransactionsWebhook = async (requestBody, io) => { .format('YYYY-MM-DD'); const endDate = moment().format('YYYY-MM-DD'); await handleTransactionsUpdate(plaidItemId, startDate, endDate); - const itemId = await retrieveItemId(plaidItemId); + const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); serverLogAndEmitSocket(`${newTransactions} transactions to add.`, itemId); break; } @@ -176,7 +169,7 @@ const handleTransactionsWebhook = async (requestBody, io) => { .format('YYYY-MM-DD'); const endDate = moment().format('YYYY-MM-DD'); await handleTransactionsUpdate(plaidItemId, startDate, endDate); - const itemId = await retrieveItemId(plaidItemId); + const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); serverLogAndEmitSocket(`${newTransactions} transactions to add.`, itemId); break; } @@ -189,7 +182,7 @@ const handleTransactionsWebhook = async (requestBody, io) => { .format('YYYY-MM-DD'); const endDate = moment().format('YYYY-MM-DD'); await handleTransactionsUpdate(plaidItemId, startDate, endDate); - const itemId = await retrieveItemId(plaidItemId); + const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); serverLogAndEmitSocket(`${newTransactions} transactions to add.`, itemId); break; } @@ -197,7 +190,7 @@ const handleTransactionsWebhook = async (requestBody, io) => { // Fired when posted transaction(s) for an Item are deleted. The deleted transaction IDs // are included in the webhook payload. await deleteTransactions(removedTransactions); - const itemId = await retrieveItemId(plaidItemId); + const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); serverLogAndEmitSocket( `${removedTransactions.length} transactions to remove.`, itemId