From b6304e294fbe87fb4f7d591b6f538d831475548b Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Fri, 26 May 2017 22:50:22 +1000 Subject: [PATCH 01/20] Don't show '% ABV' unless we know the value in BreweryBeers --- client/src/components/breweries/brewery-beers.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/breweries/brewery-beers.js b/client/src/components/breweries/brewery-beers.js index d599057..0c1346a 100644 --- a/client/src/components/breweries/brewery-beers.js +++ b/client/src/components/breweries/brewery-beers.js @@ -20,13 +20,13 @@ const BreweryBeers = props => ( {props.Beers.map(beer => (
- {beer.name} + {beer.name}
{beer.variety}
- {beer.abv}% ABV + {beer.abv && `${beer.abv}% ABV`}
))} From 75bbf29bc3639860ed21facb7e6b1e4d160841c3 Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Fri, 26 May 2017 22:52:49 +1000 Subject: [PATCH 02/20] /ping endpoint --- TODO.md | 2 ++ server/routes/api.js | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index bb54646..4819879 100644 --- a/TODO.md +++ b/TODO.md @@ -4,6 +4,8 @@ ## bugs, issues - there's no 404 page +- probably need to ping the /whoami endpoint every x minutes to ensure you're still logged in + - currently cached in the store forever, even after your session dies. ## new features, improvements diff --git a/server/routes/api.js b/server/routes/api.js index 8940fcf..4749e0b 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -92,6 +92,11 @@ function logAndSendError(err, res) { } +// ping, does nothing. +function ping(req, res) { + return res.status(200).send({ ping: 'pong' }); +} + // maybe consider moving these to submodules // if we end up with too many of them... function getOnTap(req, res) { @@ -638,7 +643,7 @@ function simulateCommonCodeInternet(req, res, next) { } router.use(simulateCommonCodeInternet); - +router.get('/ping', ping); router.get('/ontap', getOnTap); router.get('/kegs', getAllKegs); router.get('/kegs/new', getNewKegs); // todo - is this a bad url pattern? From 3751ec52fdee4781c96a42abbea27d92d5f9be51 Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Sat, 27 May 2017 08:36:35 +1000 Subject: [PATCH 03/20] Dummy API endpoint for testing received touches --- server/routes/api.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/routes/api.js b/server/routes/api.js index 4749e0b..144daa2 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -617,6 +617,16 @@ function createBrewery(req, res) { }); } +// receive Touches from TapOnTap +function receiveTouches(req, res) { + const touches = req.body; + + log.info('Received Touches:'); + log.info(touches); + + return res.status(200).send({ success: true }); +} + // auth middleware. From c6d0b7993ed919be34c686a40c34ecbd326463c3 Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Sun, 28 May 2017 21:02:28 +1000 Subject: [PATCH 04/20] Card & Touch models --- server/lib/db.js | 9 +++++++++ server/models/card.js | 33 +++++++++++++++++++++++++++++++++ server/models/touch.js | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 server/models/card.js create mode 100644 server/models/touch.js diff --git a/server/lib/db.js b/server/lib/db.js index 40a3b30..b3edf87 100644 --- a/server/lib/db.js +++ b/server/lib/db.js @@ -64,6 +64,15 @@ db.Beer.belongsTo(db.Brewery, { foreignKey: 'breweryId', }); +// A Card has a User +db.User.hasMany(db.Card, { + foreignKey: 'userId', + onDelete: 'CASCADE', // delete user => delete cards +}); +db.Card.belongsTo(db.User, { + foreignKey: 'userId', +}); + // A Beer is added by a User db.User.hasOne(db.Beer, { foreignKey: 'addedBy', diff --git a/server/models/card.js b/server/models/card.js new file mode 100644 index 0000000..b7f60a1 --- /dev/null +++ b/server/models/card.js @@ -0,0 +1,33 @@ +/** + * Card model. + * An NFC token belonging to a User. + */ + +module.exports = (sequelize, DataTypes) => sequelize.define('Card', { + id: { + type: DataTypes.INTEGER, + unique: true, + primaryKey: true, + autoIncrement: true, + }, + uid: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + userId: { + type: DataTypes.INTEGER, + references: { + model: 'Users', + key: 'id', + }, + allowNull: false, + onDelete: 'CASCADE', // delete user => delete card + onUpdate: 'CASCADE', // change user.id => update card + }, + name: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: '', + }, +}); diff --git a/server/models/touch.js b/server/models/touch.js new file mode 100644 index 0000000..ba7f951 --- /dev/null +++ b/server/models/touch.js @@ -0,0 +1,37 @@ +/** + * Touch model. + * Sent from TapOnTap when someone taps their Card on the reader. + * + * kegId, cardId & cheersId aren't proper FKs! + * They can all point to nothing. + * At this stage, we'll just ignore that. In the future we *might* delete them. + */ + +module.exports = (sequelize, DataTypes) => sequelize.define('Touch', { + id: { + type: DataTypes.INTEGER, + unique: true, + primaryKey: true, + autoIncrement: true, + }, + cardUid: { + type: DataTypes.STRING, + allowNull: false, + }, + timestamp: { + type: DataTypes.DATE, + allowNull: false, + }, + kegId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + cardId: { + type: DataTypes.INTEGER, + allowNull: true, + }, + cheersId: { + type: DataTypes.INTEGER, + allowNull: true, + }, +}); From af961671cdf27ac89e178ad0d1f7c54374778925 Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Sun, 28 May 2017 21:02:59 +1000 Subject: [PATCH 05/20] lib/touches first pass - getCard(), checkKeg(), createTouch() --- server/lib/touches.js | 101 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 server/lib/touches.js diff --git a/server/lib/touches.js b/server/lib/touches.js new file mode 100644 index 0000000..198bff7 --- /dev/null +++ b/server/lib/touches.js @@ -0,0 +1,101 @@ +/** + * lib/touches + * + * utilities for handing touches from tapontap + */ + +const db = require('lib/db'); +const log = require('lib/logger'); + + +/** + * Get a Card given its uid + * @param {String} uid + * @return {Promise} resolves to Card or null + */ +function getCard(uid) { + return db.Card.findOne({ + where: { + uid, + }, + }); +} + +/** + * Return whether a Keg with matching id exists + * @param {Number} id + * @return {Promise} resolves to boolean + */ +function checkKeg(id) { + return db.Keg.findOne({ + where: { + id, + }, + }) + .then(kegOrNull => !!kegOrNull); +} + + +/** + * Creates a Touch, given properties sent from TapOnTap + * @param {Object} props + * @return {Promise} resolves to created model + */ +function createTouch(props) { + // check if the card is known + // check whether the kegId is legit + + const { cardUid, kegId, timestamp } = props; + + db.sequelize.transaction((transaction) => { + return getCard(cardUid) + .then((card) => { + log.debug(`card is ${card ? card.id : null}`); + return checkKeg(kegId) + .then((kegExists) => { + log.debug(`kegExists ${kegExists}`); + if (card && kegExists) { + const { userId } = card; + return db.Cheers.create({ + kegId, + userId, + timestamp, + }, { + transaction, + }); + } + + return null; + }) + .then((cheers) => { + const cardId = card ? card.id : null; + const cheersId = cheers ? cheers.id : null; + + return db.Touch.create({ + cardUid, + cardId, + kegId, + timestamp, + cheersId, + }, { + transaction, + }); + }); + }); + }) + .then(() => { + console.log('success'); + // transaction succeeded. + }) + .catch(err => { + console.log('explosion'); + console.error(err); + // transaction failed. + }) +} + +module.exports = { + getCard, + checkKeg, + createTouch, +}; From affb93ca5985031419406a229ba3f27bd48ef91b Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Sun, 28 May 2017 21:03:11 +1000 Subject: [PATCH 06/20] Extra API endpoints for debugging and testing --- server/routes/api.js | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/server/routes/api.js b/server/routes/api.js index 144daa2..9fd1f7f 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -8,6 +8,7 @@ const _ = require('lodash'); const db = require('lib/db'); const log = require('lib/logger'); +const touchlib = require('lib/touches'); const router = new Router(); router.use(bodyParser.json()); @@ -621,12 +622,25 @@ function createBrewery(req, res) { function receiveTouches(req, res) { const touches = req.body; - log.info('Received Touches:'); - log.info(touches); + Promise.all(touches.map(touch => touchlib.createTouch(touch))) + .then(() => { + res.status(200).send({ success: true }); + }) + .catch(error => logAndSendError(error, res)); +} + - return res.status(200).send({ success: true }); +function getAllTouches(req, res) { + db.Touch.findAll() + .then(touches => res.send(touches)) + .catch(error => logAndSendError(error, res)); } +function getAllCards(req, res) { + db.Card.findAll() + .then(cards => res.send(cards)) + .catch(error => logAndSendError(error, res)); +} // auth middleware. @@ -668,6 +682,9 @@ router.get('/profile', getProfile); router.get('/breweries', getAllBreweries); router.get('/breweries/:id', getBreweryById); +// todo - don't leave this open +router.post('/touches', receiveTouches); + // guests can't use endpoints below this middleware router.use(usersOnly); @@ -695,5 +712,7 @@ router.delete('/users/:id', deleteUser); router.put('/breweries/:id', updateBrewery); router.delete('/breweries/:id', deleteBrewery); router.post('/breweries', createBrewery); +router.get('/touches', getAllTouches); +router.get('/cards', getAllCards); module.exports = router; From 934faa2f712709d678b65cb413570d84b2430774 Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Mon, 29 May 2017 13:47:36 +1000 Subject: [PATCH 07/20] kegs/fetchCheers() action; call every 3 seconds in Keg List view for quick'n'dirty realtime Cheers updates --- client/src/actions/kegs.js | 22 ++++++++++++++++++++++ client/src/components/kegs/keg-list.js | 12 +++++++++++- client/src/stores/kegs.js | 1 + 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/client/src/actions/kegs.js b/client/src/actions/kegs.js index dea903f..27da23a 100644 --- a/client/src/actions/kegs.js +++ b/client/src/actions/kegs.js @@ -29,6 +29,28 @@ export function fetchKegs() { }); } +// fetch the kegs to get the cheerses. +// same as fetchKegs() but doesn't dispatch +// REQUEST_FETCH_KEGS so you can't see it happening. +// polling this will update the cheerses on the +// keg list view in real time (ish). +// todo - websockets! +export function fetchCheers() { + dispatcher.dispatch({ + type: 'REQUEST_FETCH_KEGS_CHEERS', + }); + + return fetcher('/api/v1/kegs') + .then(data => dispatcher.dispatch({ + type: 'RECEIVE_FETCH_KEGS_CHEERS', + data, + })) + .catch(error => dispatcher.dispatch({ + type: 'RECEIVE_FETCH_KEGS_CHEERS', + error, + })); +} + // fetch one keg export function fetchKeg(id) { dispatcher.dispatch({ diff --git a/client/src/components/kegs/keg-list.js b/client/src/components/kegs/keg-list.js index af7450d..4e1194d 100644 --- a/client/src/components/kegs/keg-list.js +++ b/client/src/components/kegs/keg-list.js @@ -9,7 +9,7 @@ import React from 'react'; import reactPropTypes from 'prop-types'; import { Container } from 'flux/utils'; -import { fetchKegs } from '../../actions/kegs'; +import { fetchKegs, fetchCheers } from '../../actions/kegs'; import kegsStore from '../../stores/kegs'; import * as propTypes from '../../proptypes/'; @@ -103,6 +103,8 @@ KegList.propTypes = { }; +let interval; + class KegListContainer extends React.Component { static getStores() { return [kegsStore]; @@ -116,6 +118,14 @@ class KegListContainer extends React.Component { componentWillMount() { fetchKegs(); + interval = setInterval(() => { + console.log('fetching cheers...'); // eslint-disable-line no-console + fetchCheers (); + }, 5000); + } + + componentWillUnmount() { + clearInterval(interval); } render() { diff --git a/client/src/stores/kegs.js b/client/src/stores/kegs.js index 54ab10e..fd22f8b 100644 --- a/client/src/stores/kegs.js +++ b/client/src/stores/kegs.js @@ -46,6 +46,7 @@ class KegMapStore extends ReduceStore { })).set('kegs', new Immutable.Map()); case 'RECEIVE_FETCH_KEGS': + case 'RECEIVE_FETCH_KEGS_CHEERS': return state.set('sync', new Immutable.Map({ fetching: false, fetched: true, From eccc4b4fa6f51fb463cbd358e2922861318def53 Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Tue, 30 May 2017 01:02:16 +1000 Subject: [PATCH 08/20] update tests --- package.json | 4 +- server/lib/touches.js | 42 ++++++++---- server/seed/seed.js | 102 ++++++++++++++++------------ server/test/api.js | 2 +- server/test/touches.js | 150 +++++++++++++++++++++++++++++++++++++++++ yarn.lock | 10 +-- 6 files changed, 243 insertions(+), 67 deletions(-) create mode 100644 server/test/touches.js diff --git a/package.json b/package.json index 845c689..bd8d42f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "lint": "eslint client/src", "start": "node server/index", "test": "mocha --reporter dot server/test", + "test:watch": "mocha --reporter dot -w server/test", "watch": "pack -r -m -w --src client/src --main main.js -d client/dist --proxy 4000", "migrate": "sequelize db:migrate", "migrate:undo": "sequelize db:migrate:undo", @@ -50,13 +51,14 @@ "recompose": "^0.20.2", "sequelize": "^3.24.3", "sequelize-cli": "^2.5.1", - "sqlite3": "^3.1.6", + "sqlite3": "^3.1.8", "winston": "^2.2.0" }, "devDependencies": { "babel-eslint": "^7.2.2", "babel-plugin-transform-decorators-legacy": "^1.3.4", "chai": "^3.5.0", + "chai-as-promised": "^6.0.0", "eslint": "^3.6.1", "eslint-config-airbnb": "^12.0.0", "eslint-plugin-import": "^1.16.0", diff --git a/server/lib/touches.js b/server/lib/touches.js index 198bff7..192d55a 100644 --- a/server/lib/touches.js +++ b/server/lib/touches.js @@ -1,7 +1,7 @@ /** * lib/touches * - * utilities for handing touches from tapontap + * utilities for handing touches from TapOnTap */ const db = require('lib/db'); @@ -37,24 +37,30 @@ function checkKeg(id) { /** - * Creates a Touch, given properties sent from TapOnTap + * Inserts a Touch, given properties sent from TapOnTap. + * If .cardUid and .kegId resolve to a Card and Keg respectively, + * also insert a Cheers and reference it from Touch.cheersId * @param {Object} props * @return {Promise} resolves to created model */ function createTouch(props) { - // check if the card is known - // check whether the kegId is legit - const { cardUid, kegId, timestamp } = props; - db.sequelize.transaction((transaction) => { + // inserting a couple of rows here so we'll wrap it in a transaction + return db.sequelize.transaction((transaction) => { + // do we have a Card with the uid provided by TapOnTap? return getCard(cardUid) .then((card) => { log.debug(`card is ${card ? card.id : null}`); + + // is the kegId valid? return checkKeg(kegId) .then((kegExists) => { log.debug(`kegExists ${kegExists}`); + if (card && kegExists) { + // we can reconcile the Card and the Keg, which is enough + // to create a Cheers, so let's do it. const { userId } = card; return db.Cheers.create({ kegId, @@ -65,12 +71,16 @@ function createTouch(props) { }); } + // can't reconcile the Card or the Keg so we skip + // creating a Cheers. if someone registers the Card later, + // we'll do it then. return null; }) .then((cheers) => { const cardId = card ? card.id : null; const cheersId = cheers ? cheers.id : null; + // create the Touch row return db.Touch.create({ cardUid, cardId, @@ -83,15 +93,21 @@ function createTouch(props) { }); }); }) - .then(() => { - console.log('success'); + .then((touch) => { // transaction succeeded. + log.debug('Inserted a Touch:'); + log.debug(touch.get()); }) - .catch(err => { - console.log('explosion'); - console.error(err); - // transaction failed. - }) + .catch((error) => { + // something failed, log and rethrow. + log.error(error); + throw error; + }); +} + + +function reconcileTouch(touch) { + } module.exports = { diff --git a/server/seed/seed.js b/server/seed/seed.js index 1feb963..1ec615c 100644 --- a/server/seed/seed.js +++ b/server/seed/seed.js @@ -1,55 +1,69 @@ /** - * seed data + * Seed data. + * Primarily for tests, but you could use it to populate an instance. */ const db = require('lib/db'); +// you need an empty DB before running the seeds +// because we specify the ids here. -// three taps -const TAPS = [{ - name: 'Left Tap', -}, { - name: 'Middle Tap', -}, { - name: 'Right Tap', -}]; - -// three kegs -const KEGS = [{ - beerName: 'Love Tap', - breweryName: 'Moon Dog', - abv: 5, - notes: 'A delicious lager.', -}, { - beerName: 'Pilsner', - breweryName: 'Hawkers', - abv: 5, - notes: 'Excellent pilsner, very pilsnery.', -}, { - beerName: 'Brown Ale', - breweryName: 'Cavalier Brewing', - abv: 5.5, - notes: 'The perfect mix of chocolate and toasty caramel flavours. With the added complexity of aromas from classic American hops, subtle citrus notes reveal something new in every sip.', -}]; +const user = { + name: 'Test User', + email: 'test@mail.com', + googleProfileId: 'foobarqux', + id: 1, +}; +const brewery = { + id: 1, + name: 'Test Brewery', + location: 'Collingwood', + web: 'testbrewery.com', + description: 'Just a test brewery.', + adminNOtes: 'Just test admin notes', + canBuy: false, + +}; + +const beer = { + id: 1, + name: 'Test Beer', + breweryId: 1, + abv: 5.0, + ibu: 30, + variety: 'Pale Ale', + notes: 'A test pale ale', + canBuy: false, + addedBy: 1, +}; + +const keg = { + id: 1, + notes: 'A test keg', + beerId: 1, +}; + +const card = { + id: 1, + name: 'Test Card', + uid: 'foobarqux', + userId: 1, +}; function seed() { - // create the taps - return Promise.all(TAPS.map(tap => db.Tap.create(tap))) - .then((taps) => { - // create the kegs - return Promise.all(KEGS.map(keg => db.Keg.create(keg))) - .then((kegs) => { - - return Promise.all([ - taps[0].setKeg(kegs[0]), - taps[1].setKeg(kegs[1]) - ]); - - }); - }) - .then(() => console.log('done')) - .catch(err => console.log(err)); + return db.User.create(user) + .then(() => db.Brewery.create(brewery)) + .then(() => db.Beer.create(beer)) + .then(() => db.Keg.create(keg)) + .then(() => db.Card.create(card)); } -module.exports = seed; +module.exports = { + seed, + user, + brewery, + beer, + keg, + card, +}; diff --git a/server/test/api.js b/server/test/api.js index 3d3e5be..58b9f45 100644 --- a/server/test/api.js +++ b/server/test/api.js @@ -26,7 +26,7 @@ const testapp = express(); testapp.use('/', apiRoutes); const api = supertest(testapp); -describe('/api/v1', () => { +xdescribe('/api/v1', () => { describe('/helloworld', () => { it('responds', (done) => { api.get('/helloworld') diff --git a/server/test/touches.js b/server/test/touches.js new file mode 100644 index 0000000..98e9f6d --- /dev/null +++ b/server/test/touches.js @@ -0,0 +1,150 @@ +/* eslint-disable no-console, padded-blocks */ + +/** + * test/touches + * + * test suite for lib/touches + */ + +require('dotenv-safe').load(); +require('app-module-path').addPath(`${__dirname}/../`); + +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); + +// enable should syntax and .eventually async test helper +chai.should(); +chai.use(chaiAsPromised); + +// use an in memory db +process.env.DB_STORAGE = ':memory:'; +process.env.DB_DIALECT = 'sqlite'; + +const seeds = require('seed/seed'); +const db = require('lib/db'); +const touches = require('lib/touches'); + +// sync and seed before each test. +// if this becomes unperformant, use transactions intead. +beforeEach(() => db.sequelize.sync({ force: true }) + .then(() => seeds.seed() +)); + +describe('lib/touches', () => { + + describe('getCard()', () => { + it('returns a card by uid', () => + touches.getCard(seeds.card.uid).should.eventually.be.an('object') + ); + + it('returns null for a bad uid', () => + touches.getCard('invalid uid').should.eventually.be.null + ); + }); + + describe('checkKeg()', () => { + it('returns a boolean for keg existence', () => + touches.checkKeg(1).should.eventually.equal(true) + .then(() => + touches.checkKeg(999).should.eventually.equal(false)) + ); + }); + + describe('createTouch()', () => { + it('creates a Touch and Cheers when cardUid and kegId can resolve', () => { + // both point to valid models + const cardUid = 'foobarqux'; + const kegId = 1; + const timestamp = new Date(); + + return touches.createTouch({ + cardUid, + kegId, + timestamp, + }) + .then(() => db.Touch.findOne({ + where: { + timestamp, + }, + })) + .then(touch => touch.get()).should.eventually.be.an('object') + .which.includes({ + cardUid, + kegId, + cardId: 1, // sets cardId + // timestamp, + }) + .then(touch => db.Cheers.findById(touch.cheersId)) // sets cheersId + .should.eventually.be.an('object') + .which.includes({ + kegId, + userId: 1, // this user owns card #1 + // timestamp, + }); + }); + + it('doesn\'t create Cheers if cardUid doesn\'t resolve', () => { + const cardUid = 'invalid card uid'; + const kegId = 1; // valid + const timestamp = new Date(); + + let cheersCount; + return db.Cheers.findAll().then((cheers) => { + cheersCount = cheers.length; + }) + .then(() => touches.createTouch({ + cardUid, + kegId, + timestamp, + }) + .then(() => db.Touch.findOne({ + where: { + timestamp, + }, + })) + .then(touch => touch.get()).should.eventually.be.an('object') + .which.includes({ + cardUid, // gets set even though it can't resolve + kegId, + cardId: null, // card doesn't resolve yet, + cheersId: null, + }) + // shouldn't be any new cheers in the db + .then(() => db.Cheers.findAll().should.eventually.be.an('array').of.length(cheersCount))); + }); + + it('doesn\'t create a Cheers if kegId doesn\'t resolve', () => { + const cardUid = 'foobarqux'; // valid + const kegId = 99; // invalid + const timestamp = new Date(); + + let cheersCount; + return db.Cheers.findAll().then((cheers) => { + cheersCount = cheers.length; + }) + .then(() => touches.createTouch({ + cardUid, + kegId, + timestamp, + }) + .then(() => db.Touch.findOne({ + where: { + timestamp, + }, + })) + .then(touch => touch.get()) + .should.eventually.be.an('object') + .which.includes({ + cardUid, + kegId, + cardId: 1, // Card should resolve + cheersId: null, // no cheersId though + }) + .then(() => db.Cheers.findAll().should.eventually.be.an('array').of.length(cheersCount))); + }); + + it('uses transaction so a failure prevents either model from being written', () => { + // no idea how to do this actually... + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 4ff6ac6..55ab109 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5422,19 +5422,13 @@ promise@^7.0.1, promise@^7.1.1: dependencies: asap "~2.0.3" -prop-types@^15.5.10: +prop-types@^15.5.10, prop-types@^15.5.4: version "15.5.10" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154" dependencies: fbjs "^0.8.9" loose-envify "^1.3.1" -prop-types@^15.5.4: - version "15.5.8" - resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.8.tgz#6b7b2e141083be38c8595aa51fc55775c7199394" - dependencies: - fbjs "^0.8.9" - proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -6408,7 +6402,7 @@ sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" -sqlite3@^3.1.6: +sqlite3@^3.1.8: version "3.1.8" resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-3.1.8.tgz#4cbcf965d8b901d1b1015cbc7fc415aae157dfaa" dependencies: From 609a2f52797dbca97a9dc69d2c7b4abb34889721 Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Tue, 30 May 2017 21:21:06 +1000 Subject: [PATCH 09/20] Associate Cheers to Touch (non-FK constrained) --- server/lib/db.js | 9 +++++++++ server/models/cheers.js | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/server/lib/db.js b/server/lib/db.js index b3edf87..a9739d6 100644 --- a/server/lib/db.js +++ b/server/lib/db.js @@ -101,4 +101,13 @@ db.Cheers.belongsTo(db.User, { foreignKey: 'userId', }); + +// Touches have a Card and a Cheers +// Note! These aren't FK constrained. +// Sequelize just lets us act like they are. +db.Touch.belongsTo(db.Cheers, { + foreignKey: 'cheersId', +}); + + module.exports = db; diff --git a/server/models/cheers.js b/server/models/cheers.js index 5fe131c..bbbe2a7 100644 --- a/server/models/cheers.js +++ b/server/models/cheers.js @@ -35,4 +35,10 @@ module.exports = (sequelize, DataTypes) => sequelize.define('Cheers', { allowNull: false, defaultValue: DataTypes.NOW, }, +}, { + // Sequelize gets a bit confused without this... + name: { + singular: 'Cheers', + plural: 'Cheers', + }, }); From 10b1299eaa4469e8af8907e197122d2cb7a5d281 Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Wed, 31 May 2017 22:11:14 +1000 Subject: [PATCH 10/20] Add non-FK constrained associations to Touch model --- server/lib/db.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/server/lib/db.js b/server/lib/db.js index a9739d6..8315480 100644 --- a/server/lib/db.js +++ b/server/lib/db.js @@ -102,11 +102,22 @@ db.Cheers.belongsTo(db.User, { }); -// Touches have a Card and a Cheers -// Note! These aren't FK constrained. +// Touches have Card, Cheers and Keg. +// Note constraints: false means they're not FKs in the schema, // Sequelize just lets us act like they are. db.Touch.belongsTo(db.Cheers, { foreignKey: 'cheersId', + constraints: false, +}); + +db.Touch.belongsTo(db.Keg, { + foreignKey: 'kegId', + constraints: false, +}); + +db.Touch.belongsTo(db.Card, { + foreignKey: 'cardId', + constraints: false, }); From eb664af72baf9175c654055af64b1b5de51ef1e4 Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Wed, 31 May 2017 22:11:44 +1000 Subject: [PATCH 11/20] lib/touches and test suite --- server/lib/touches.js | 248 +++++++++++++++++------ server/test/touches.js | 450 +++++++++++++++++++++++++++++++++++------ 2 files changed, 574 insertions(+), 124 deletions(-) diff --git a/server/lib/touches.js b/server/lib/touches.js index 192d55a..7d37cbf 100644 --- a/server/lib/touches.js +++ b/server/lib/touches.js @@ -9,30 +9,96 @@ const log = require('lib/logger'); /** - * Get a Card given its uid + * Get a Card by uid * @param {String} uid - * @return {Promise} resolves to Card or null + * @return {Promise.Card|null} */ function getCard(uid) { return db.Card.findOne({ where: { uid, }, + }) + .then((card) => { // eslint-disable-line arrow-body-style + return card ? card.get() : null; }); } /** * Return whether a Keg with matching id exists * @param {Number} id - * @return {Promise} resolves to boolean + * @return {Promise.boolean} */ -function checkKeg(id) { +function getKeg(id) { return db.Keg.findOne({ where: { id, }, }) - .then(kegOrNull => !!kegOrNull); + .then((keg) => { // eslint-disable-line arrow-body-style + return keg ? keg.get() : null; + }); +} + +/** + * Attempt to reconcile touch.cardUid and touch.kegId + * to a Card and Keg, returning them in an object. + * @param {Object} touch + * @return {Object} + * @return {Object|null} return.card + * @return {Object|null} return.keg + */ +function reconcileCardAndKeg(touch) { + const { cardUid, kegId } = touch; + + return Promise.resolve() + .then(() => getCard(cardUid)) + .then(card => getKeg(kegId) + .then(keg => ({ + card, + keg, + })) + ); +} + +/** + * Attempt to create a Cheers from a Touch. + * @param {Object} touch + * @param {Sequelize.Transaction} [transaction=null] + * @return {Promise.Object} + * @return {Object|null} return.cheers + * @return {Object|null} return.card + * @return {Object|null} return.keg + */ +function createCheersFromTouch(touch, transaction = null) { + return reconcileCardAndKeg(touch) + .then(({ card, keg }) => { + if (card && keg) { + // card and keg resolve, create a Cheers. + const { userId } = card; + const { timestamp, kegId } = touch; + return db.Cheers.create({ + kegId, + userId, + timestamp, + }, { + transaction, + }) + .then(cheers => cheers.get()) + .then(cheers => ({ + cheers, + card, + keg, + })); + } + // card or keg couldn't resolve, cheers is null. + const cheers = null; + return { + cheers, + card, + keg, + }; + }); } @@ -41,77 +107,133 @@ function checkKeg(id) { * If .cardUid and .kegId resolve to a Card and Keg respectively, * also insert a Cheers and reference it from Touch.cheersId * @param {Object} props - * @return {Promise} resolves to created model + * @return {Promise.Touch} */ function createTouch(props) { const { cardUid, kegId, timestamp } = props; - // inserting a couple of rows here so we'll wrap it in a transaction - return db.sequelize.transaction((transaction) => { - // do we have a Card with the uid provided by TapOnTap? - return getCard(cardUid) - .then((card) => { - log.debug(`card is ${card ? card.id : null}`); - - // is the kegId valid? - return checkKeg(kegId) - .then((kegExists) => { - log.debug(`kegExists ${kegExists}`); - - if (card && kegExists) { - // we can reconcile the Card and the Keg, which is enough - // to create a Cheers, so let's do it. - const { userId } = card; - return db.Cheers.create({ - kegId, - userId, - timestamp, - }, { - transaction, - }); - } - - // can't reconcile the Card or the Keg so we skip - // creating a Cheers. if someone registers the Card later, - // we'll do it then. - return null; - }) - .then((cheers) => { - const cardId = card ? card.id : null; - const cheersId = cheers ? cheers.id : null; - - // create the Touch row - return db.Touch.create({ - cardUid, - cardId, - kegId, - timestamp, - cheersId, - }, { - transaction, - }); + // transact this so if one fails neither get written + return db.sequelize.transaction(transaction => + createCheersFromTouch(props, transaction) + .then(({ card, cheers }) => { + const cardId = card ? card.id : null; + const cheersId = cheers ? cheers.id : null; + + // create the Touch row + return db.Touch.create({ + cardUid, + cardId, + kegId, + timestamp, + cheersId, + }, { + transaction, }); - }); - }) - .then((touch) => { - // transaction succeeded. - log.debug('Inserted a Touch:'); - log.debug(touch.get()); - }) - .catch((error) => { - // something failed, log and rethrow. - log.error(error); - throw error; - }); + }) + .then((touch) => { + // transaction succeeded. + log.debug('Inserted a Touch:'); + log.debug(touch.get()); + return touch.get(); + }) + .catch((error) => { + // something failed, log and rethrow. + log.error(error); + throw error; + })); +} + + +/** + * Wraps createTouch() to process an array of touches in sequence. + * todo - a single failure will stop them all. think about how to handle. + * @param {Array} touches + * @return {Promise.Touch[]} array of touches + */ +function createTouches(touches) { + return touches.reduce((promise, props) => + promise.then(returnArray => + createTouch(props) + .then(touch => [...returnArray, touch]) + ) + , Promise.resolve([])); } +/** + * Attempt to reconcile Card and Keg from an existing Touch, + * updating the Touch row with the results. + * @param {Object} touch + * @return {Touch} + */ function reconcileTouch(touch) { + return Promise.resolve() + .then(() => { + if (touch.cheersId || touch.cardId) throw new Error('ALREADY_RECONCILED'); + }) + .then(() => db.sequelize.transaction(transaction => + createCheersFromTouch(touch, transaction) + .then(({ card, cheers }) => { + const cardId = card ? card.id : null; + const cheersId = cheers ? cheers.id : null; + + return db.Touch.update({ + cardId, + cheersId, + }, { + where: { + id: touch.id, + }, + transaction, + }); + }))) + .then((numRowsUpdated) => { + if (!numRowsUpdated) throw new Error('TOUCH_NOT_FOUND'); + return db.Touch.findById(touch.id).then(updatedTouch => updatedTouch.get()); + }) + .catch((error) => { + // log and rethrow + log.error(error); + throw error; + }); +} + + +/** + * Find all Touches where .kegId doesn't resolve to a Keg. + * @return {Promise.Object[]} + */ +function getOrphanTouches() { + return db.Touch.findAll({ + where: { + '$Keg.id$': null, + }, + include: [{ + model: db.Keg, + }], + }); +} +/** + * Find all Touches that don't have .cardId + * @return {Promise.Object[]} + */ +function getUnreconciledTouches() { + return db.Touch.findAll({ + where: { + cardId: null, + }, + }); } module.exports = { getCard, - checkKeg, + getKeg, + createCheersFromTouch, createTouch, + createTouches, + reconcileTouch, + reconcileCardAndKeg, + getOrphanTouches, + getUnreconciledTouches, }; diff --git a/server/test/touches.js b/server/test/touches.js index 98e9f6d..20dd8db 100644 --- a/server/test/touches.js +++ b/server/test/touches.js @@ -11,8 +11,10 @@ require('app-module-path').addPath(`${__dirname}/../`); const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); +const omit = require('lodash/omit'); // enable should syntax and .eventually async test helper +const { expect } = chai; chai.should(); chai.use(chaiAsPromised); @@ -24,37 +26,182 @@ const seeds = require('seed/seed'); const db = require('lib/db'); const touches = require('lib/touches'); + +// return an object with timestamp keys omitted. +// equality assertions fail because of them. +// todo - figure out why. +function stripTimestamps(obj, extraKeys = []) { + return omit(obj, ['updatedAt', 'createdAt', 'timestamp', ...extraKeys]); +} + +// reloads and gets values from a model. +// the test below explains why it's needed. +function getValues(model) { + return model.reload().then(reloadedModel => reloadedModel.get()); +} + // sync and seed before each test. // if this becomes unperformant, use transactions intead. beforeEach(() => db.sequelize.sync({ force: true }) .then(() => seeds.seed() )); +describe('test/touches', () => { + // for some reason model.create() doesn't return any columns that + // should have a value of null. + // pipe it through getValues to return all columns. + describe('getValues()', () => { + it('ensures columns that default to null are returned', () => + db.Touch.create({ + cardUid: 'something', + timestamp: new Date(), + kegId: 1, + cheersId: null, + }) + .then((touch) => { + // cardId and cheersId are both nullable with no defaultValue + // so they don't get returned here. + touch.should.not.include({ + cardId: null, + cheersId: null, + }); + return touch; + }) + .then(getValues) + .then((touch) => { + // and now they do + touch.should.include({ + cardId: null, + cheersId: null, + }); + }) + ); + }); +}); + describe('lib/touches', () => { describe('getCard()', () => { - it('returns a card by uid', () => - touches.getCard(seeds.card.uid).should.eventually.be.an('object') - ); - - it('returns null for a bad uid', () => - touches.getCard('invalid uid').should.eventually.be.null - ); + it('returns a card by uid, fallback to null', () => + touches.getCard(seeds.card.uid).should.eventually.be.an('object').that.includes(seeds.card) + .then(() => + touches.getCard(99999).should.eventually.equal(null))); }); - describe('checkKeg()', () => { - it('returns a boolean for keg existence', () => - touches.checkKeg(1).should.eventually.equal(true) + describe('getKeg()', () => { + it('returns a keg by id, fallback to null', () => + touches.getKeg(seeds.keg.id).should.eventually.be.an('object').that.includes(seeds.keg) .then(() => - touches.checkKeg(999).should.eventually.equal(false)) + touches.getKeg(999).should.eventually.equal(null)) ); }); + describe('reconcileCardAndKeg()', () => { + it('returns models if reconciled', () => + touches.reconcileCardAndKeg({ + cardUid: seeds.card.uid, + kegId: seeds.keg.id, + }) + .then((obj) => { + obj.card.should.be.an('object').that.includes(seeds.card); + obj.keg.should.be.an('object').that.includes(seeds.keg); + })); + + it('returns nulls if not reconciled', () => + touches.reconcileCardAndKeg({ + cardUid: 999999, + kegId: 999999, + }) + .should.eventually.be.an('object').that.includes({ + card: null, + keg: null, + })); + }); + + + describe('createCheersFromTouch', () => { + it('creates a Cheers if cardUid and kegId can be reconciled', () => { + const validTouch = { + cardUid: seeds.card.uid, + kegId: seeds.keg.id, + timestamp: new Date(), + }; + + return db.Cheers.count() + .then(numCheers => touches.createCheersFromTouch(validTouch) + .then((response) => { + response.card.should.be.an('object').that.includes(seeds.card); + response.keg.should.be.an('object').that.includes(seeds.keg); + response.cheers.should.be.an('object').that.includes({ + kegId: seeds.keg.id, + userId: seeds.card.userId, + }); + }) + .then(() => db.Cheers.count()) + .then(numCheersNow => numCheersNow.should.equal(numCheers + 1)) + ); + }); + + it('doesn\'t create a Cheers if cardUid can\'t be reconciled', () => { + const badCardUid = { + cardUid: 'invalid card uid', + kegId: seeds.keg.id, + timestamp: new Date(), + }; + + return db.Cheers.count() + .then(numCheers => touches.createCheersFromTouch(badCardUid) + .then(response => response.should.include({ + cheers: null, + })) + .then(() => db.Cheers.count()) + .then(numCheersNow => numCheersNow.should.equal(numCheers)) + ); + }); + + it('doesn\'t create a Cheers if kegId can\'t be reconciled', () => { + const badKegId = { + cardUid: seeds.card.uid, + kegId: 9999999, + timestamp: new Date(), + }; + + return db.Cheers.count() + .then(numCheers => touches.createCheersFromTouch(badKegId) + .then(response => response.should.include({ + cheers: null, + })) + .then(() => db.Cheers.count()) + .then(numCheersNow => numCheersNow.should.equal(numCheers)) + ); + }); + + it('doesn\'t create a Cheers if kegId and cardUid can\'t be reconciled', () => { + const invalid = { + cardUid: 'invalid card uid', + kegId: 9999999, + timestamp: new Date(), + }; + + return db.Cheers.count() + .then(numCheers => touches.createCheersFromTouch(invalid) + .then(response => response.should.include({ + cheers: null, + })) + .then(() => db.Cheers.count()) + .then(numCheersNow => numCheersNow.should.equal(numCheers)) + ); + }); + + xit('supports transactions', () => { + // todo - figure this out + }); + }); + describe('createTouch()', () => { - it('creates a Touch and Cheers when cardUid and kegId can resolve', () => { - // both point to valid models - const cardUid = 'foobarqux'; - const kegId = 1; + it('creates a Touch and when cardUid and kegId can resolve', () => { + const cardUid = seeds.card.uid; + const kegId = seeds.keg.id; const timestamp = new Date(); return touches.createTouch({ @@ -71,80 +218,261 @@ describe('lib/touches', () => { .which.includes({ cardUid, kegId, - cardId: 1, // sets cardId - // timestamp, + cardId: seeds.card.id, }) - .then(touch => db.Cheers.findById(touch.cheersId)) // sets cheersId + // this is tested above by createCheersFromTouch but hey why not + .then(touch => db.Cheers.findById(touch.cheersId)) .should.eventually.be.an('object') .which.includes({ kegId, - userId: 1, // this user owns card #1 - // timestamp, + userId: seeds.card.userId, }); }); - it('doesn\'t create Cheers if cardUid doesn\'t resolve', () => { - const cardUid = 'invalid card uid'; - const kegId = 1; // valid + xit('uses transaction so a failure prevents either model from being written', () => { + // todo - figure out how to test this? + // make the Touch insert fail and check the Cheers wasn't inserted. + }); + }); + + describe('createTouches()', () => { + it('processes an array of touches through createTouch()', () => { + + const cardUid = seeds.card.uid; + const kegId = seeds.keg.id; const timestamp = new Date(); - let cheersCount; - return db.Cheers.findAll().then((cheers) => { - cheersCount = cheers.length; - }) - .then(() => touches.createTouch({ + const t1 = { cardUid, kegId, timestamp, - }) - .then(() => db.Touch.findOne({ - where: { - timestamp, - }, - })) - .then(touch => touch.get()).should.eventually.be.an('object') - .which.includes({ - cardUid, // gets set even though it can't resolve + }; + + const t2 = { + cardUid: 'different card uid who cares', kegId, - cardId: null, // card doesn't resolve yet, - cheersId: null, - }) - // shouldn't be any new cheers in the db - .then(() => db.Cheers.findAll().should.eventually.be.an('array').of.length(cheersCount))); + timestamp, + }; + + const t3 = { + cardUid, + kegId: 9999, + timestamp, + }; + + const propsArray = [t1, t2, t3]; + + // count current touches + return db.Touch.count() + .then(numTouches => + touches.createTouches(propsArray) + // returns an array with 3 elements + .should.eventually.be.an('array').of.length('3') + .then((touchArray) => { + // same order we put them in, with the right props + touchArray.forEach((touch, i) => { + touch.should.be.an('object').that.includes(propsArray[i]); + }); + return touchArray; + }) + // and now there's +3 in the db + .then(() => db.Touch.count()) + .should.eventually.equal(numTouches + 3) + ); }); + }); - it('doesn\'t create a Cheers if kegId doesn\'t resolve', () => { - const cardUid = 'foobarqux'; // valid - const kegId = 99; // invalid - const timestamp = new Date(); - let cheersCount; - return db.Cheers.findAll().then((cheers) => { - cheersCount = cheers.length; + describe('reconcileTouch()', () => { + it('throws if the Touch is already reconciled', () => + // createTouch will reconcile by itself + touches.createTouch({ + cardUid: seeds.card.uid, + kegId: seeds.keg.id, + timestamp: new Date(), + }) + .then(touch => touches.reconcileTouch(touch)) + .should.be.rejectedWith(Error, 'ALREADY_RECONCILED')); + + it('does nothing if Card can\'t be reconciled', () => + db.Touch.create({ + cardUid: 'INVALID CARD UID', + kegId: seeds.keg.id, + timestamp: new Date(), }) - .then(() => touches.createTouch({ + .then(touch => touch.get()) + .then(touch => touches.reconcileTouch(touch) + .then((reconciledTouch) => { + // should all be the same + reconciledTouch.should.include(stripTimestamps(touch)); + }) + )); + + it('sets Touch.cardId but doesn\'t create Cheers if kegId is bad', () => + db.Cheers.count() + .then(numCheers => + db.Touch.create({ + cardUid: seeds.card.uid, + kegId: 99999, + timestamp: new Date(), + }) + .then(touch => touch.get()) + .then(touch => touches.reconcileTouch(touch) + .then((reconciledTouch) => { + reconciledTouch.should.include(Object.assign(stripTimestamps(touch), { + cardId: seeds.card.id, + })); + }) + ) + .then(() => db.Cheers.count()) + .then(numCheersNow => numCheersNow.should.equal(numCheers)) + ) + ); + + it('creates a Cheers if Card and Keg are both reconciled', () => + db.Cheers.count() + .then(numCheers => + db.Touch.create({ + cardUid: seeds.card.uid, + kegId: seeds.keg.id, + timestamp: new Date(), + }) + .then(getValues) + .then((touch) => { + // no cardId, cheersId + expect(touch.cardId).to.equal(null); + expect(touch.cheersId).to.equal(null); + return touch; + }) + .then(touch => touches.reconcileTouch(touch) + .then((reconciledTouch) => { + // both were reconciled so we've got a cheers + reconciledTouch.cardId.should.equal(seeds.card.id); + reconciledTouch.cheersId.should.not.equal(null); + + // and we created a cheers + return db.Cheers.findById(reconciledTouch.cheersId); + }) + ) + .should.eventually.be.an('object').that.includes({ + userId: seeds.card.userId, + kegId: seeds.keg.id, + }) + .then(() => db.Cheers.count()) + .then(cheersCountNow => cheersCountNow.should.equal(numCheers + 1)) + ) + ); + + }); + + describe('a user can touch before registering a card and then claim the cheers', () => { + // I think technically this all covered above, but since it's the + // whole point of the thing we'll go through it from start to finish... + + const cardUid = 'a fake card uid'; + const kegId = 1; + const timestamp = new Date(); + const randomUser = { + name: 'Some random', + googleProfileId: 'fakeProfileId', + email: 'some@random.com', + }; + + let touchId; + + it('works', () => + // get a touch from some random with an unknown cardUid + touches.createTouch({ cardUid, kegId, timestamp, }) - .then(() => db.Touch.findOne({ + .then((touch) => { + touchId = touch.id; + }) + // now the random registers an account + .then(() => db.User.create(randomUser)) + // and claims the card + .then(user => db.Card.create({ + userId: user.id, + uid: cardUid, + name: 'My security token', + })) + // now we reconcile the touch... + .then(() => db.Touch.findById(touchId)) + .then(touch => touches.reconcileTouch(touch)) + // and look up our random user + .then(() => db.User.findOne({ where: { - timestamp, + email: randomUser.email, }, + include: db.Cheers, })) - .then(touch => touch.get()) - .should.eventually.be.an('object') - .which.includes({ + // and he's got a cheers! + .then((user) => { + user.Cheers.should.be.an('array').of.length(1); + user.Cheers[0].kegId.should.equal(kegId); + }) + ); + + }); + + describe('getOrphanTouches()', () => { + it('returns Touches where .kegId doesn\'t reconcile', () => { + const cardUid = seeds.card.uid; + const kegId = seeds.keg.id; + const timestamp = new Date(); + + // 4 touches, 2 with bad kegId + return touches.createTouches([{ cardUid, kegId, - cardId: 1, // Card should resolve - cheersId: null, // no cheersId though - }) - .then(() => db.Cheers.findAll().should.eventually.be.an('array').of.length(cheersCount))); + timestamp, + }, { + cardUid: 'invalid card uid', + kegId, + timestamp, + }, { + cardUid: 'invalid card uid', + kegId: 50000, + timestamp, + }, { + cardUid, + kegId: 50000, + timestamp, + }]) + .then(() => touches.getOrphanTouches()) + .should.eventually.be.an('array').of.length(2); }); + }); + + describe('getUnreconciledTouches()', () => { + it('returns Touches without a .cardId', () => { + const cardUid = seeds.card.uid; + const kegId = seeds.keg.id; + const timestamp = new Date(); - it('uses transaction so a failure prevents either model from being written', () => { - // no idea how to do this actually... + // 4 touches, 2 with bad cardId + return touches.createTouches([{ + cardUid, + kegId, + timestamp, + }, { + cardUid: 'invalid card uid', + kegId, + timestamp, + }, { + cardUid, + kegId: 9999, + timestamp, + }, { + cardUid: 'invalid card uid', + kegId: 9999, + timestamp, + }]) + .then(() => touches.getUnreconciledTouches()) + .should.eventually.be.an('array').of.length(2); }); }); + }); From 825e47e7a165dc4a99de4c6993ba442a887836c9 Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Thu, 1 Jun 2017 15:04:55 +1000 Subject: [PATCH 12/20] lib/tapontap --- package.json | 5 +++-- server/lib/tapontap.js | 40 ++++++++++++++++++++++++++++++++++++++++ yarn.lock | 7 +++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 server/lib/tapontap.js diff --git a/package.json b/package.json index bd8d42f..ce5d44d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "scripts": { "build": "pack -r -m --src client/src --main main.js -d client/dist", "db-cli": "node server/bin/db", - "dev": "nodemon server/index --ignore client", + "dev": "nodemon --ignore client -- --inspect server/index", "lint": "eslint client/src", "start": "node server/index", "test": "mocha --reporter dot server/test", @@ -23,7 +23,7 @@ "type": "git", "url": "git+https://github.com/commoncode/ontap.git" }, - "author": "Common Code", + "author": "Mark Reid", "license": "MIT", "bugs": { "url": "https://github.com/commoncode/ontap/issues" @@ -43,6 +43,7 @@ "immutable": "^3.8.1", "lodash": "^4.17.4", "morgan": "^1.7.0", + "node-fetch": "^1.7.0", "normalize.css": "^4.2.0", "pack-cli": "^1.4.5", "passport": "^0.3.2", diff --git a/server/lib/tapontap.js b/server/lib/tapontap.js new file mode 100644 index 0000000..07bbcce --- /dev/null +++ b/server/lib/tapontap.js @@ -0,0 +1,40 @@ +/** + * lib/tapontap + * + * methods for interfacing with a tapontap instance + */ + +const fetch = require('node-fetch'); + +const { TAPONTAP_INSTANCE } = process.env; + + +/** + * Proxy a request to tapontap/status to fetch the uid + * of the card currently tapped onto the reader. + * @return {String} Card uid + */ +function getCurrentCard() { + if (!TAPONTAP_INSTANCE) throw new Error('No TapOnTap instance registered'); + + return fetch(`${TAPONTAP_INSTANCE}/api/v1/status`) + .catch(() => { + // fetch only throws for network errors + throw new Error('TapOnTap instance unreachable'); + }) + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error('Unable to parse TapOnTap response'); + }) + .then((status) => { + console.log(status); + return status.card ? status.card.uid : null; + }); +} + + +module.exports = { + getCurrentCard, +}; diff --git a/yarn.lock b/yarn.lock index 55ab109..6d12613 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4503,6 +4503,13 @@ node-fetch@^1.0.1: encoding "^0.1.11" is-stream "^1.0.1" +node-fetch@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.0.tgz#3ff6c56544f9b7fb00682338bb55ee6f54a8a0ef" + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + node-gyp@^3.3.1: version "3.5.0" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.5.0.tgz#a8fe5e611d079ec16348a3eb960e78e11c85274a" From 31abd37b9f080a5bbf8ea27c426f37feeea046de Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Thu, 1 Jun 2017 15:05:43 +1000 Subject: [PATCH 13/20] Cleanup API routes --- server/routes/api.js | 46 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/server/routes/api.js b/server/routes/api.js index 9fd1f7f..70b45e3 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -9,6 +9,7 @@ const _ = require('lodash'); const db = require('lib/db'); const log = require('lib/logger'); const touchlib = require('lib/touches'); +const tapontap = require('lib/tapontap'); const router = new Router(); router.use(bodyParser.json()); @@ -618,18 +619,17 @@ function createBrewery(req, res) { }); } -// receive Touches from TapOnTap +// receives an array of Touches from a TapOnTap instance function receiveTouches(req, res) { const touches = req.body; - Promise.all(touches.map(touch => touchlib.createTouch(touch))) - .then(() => { - res.status(200).send({ success: true }); - }) + // todo - validate + + touchlib.createTouches(touches) + .then(() => res.status(200).send({ success: true })) .catch(error => logAndSendError(error, res)); } - function getAllTouches(req, res) { db.Touch.findAll() .then(touches => res.send(touches)) @@ -637,11 +637,38 @@ function getAllTouches(req, res) { } function getAllCards(req, res) { - db.Card.findAll() + db.Card.findAll({ + include: userInclude, + }) .then(cards => res.send(cards)) .catch(error => logAndSendError(error, res)); } +// fetch the uid of the card currently tapped onto the +// reader of our TapOnTap instance +function getCardUid(req, res) { + tapontap.getCurrentCard() + .then(cardUid => res.send({ cardUid })) + .catch(err => logAndSendError(err, res)); +} + +// register a Card to a User +function registerCard(req, res) { + const { uid } = req.body; + if (!uid) return res.status(400).send({ error: 'uid required' }); + + const name = req.body.name || 'NFC Token'; + const userId = req.user.id; + + return db.Card.create({ + uid, + userId, + name, + }) + .then(card => res.status(201).send(card.get())) + .catch(error => logAndSendError(error, res)); +} + // auth middleware. // prevent guests from hitting endpoints @@ -682,7 +709,7 @@ router.get('/profile', getProfile); router.get('/breweries', getAllBreweries); router.get('/breweries/:id', getBreweryById); -// todo - don't leave this open +// todo - these need to be locked down router.post('/touches', receiveTouches); @@ -694,7 +721,8 @@ router.post('/beers', createBeer); router.get('/profile/cheers', getProfileCheers); router.put('/profile', updateProfile); router.delete('/profile', deleteProfile); - +router.get('/cards/register', getCardUid); +router.post('/cards/register', registerCard); // admins only for all endpoints below this middleware router.use(adminsOnly); From c0af83e93df56df85e9e967a2d60a5c41d29ced1 Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Thu, 1 Jun 2017 17:53:50 +1000 Subject: [PATCH 14/20] reconcileTouches() for handling in bulk; getUnreconciledTouches() takes an optional cardUid; add some basic documentation --- server/lib/touches.js | 46 +++++++++++++++++-- server/test/touches.js | 101 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 130 insertions(+), 17 deletions(-) diff --git a/server/lib/touches.js b/server/lib/touches.js index 7d37cbf..696a2ac 100644 --- a/server/lib/touches.js +++ b/server/lib/touches.js @@ -1,7 +1,17 @@ /** * lib/touches * - * utilities for handing touches from TapOnTap + * This looks a bit complicated, but basically what it does is: + * + * TapOnTap sends touches, which get passed to createTouches(). + * This creates the Touches and tries to "reconcile" them against an + * existing Card (.cardUid) and Keg (.kegId). + * If a Touch is successfully reconciled, it creates a Cheers. + * If a Touch can't be reconciled, it can be tried again in the future, + * which lets users tap a card *before* registering the card to their account. + * To do this we pipe getUnreconciledTouches() to reconcileTouches(). + * + * Eeeeeasy. */ const db = require('lib/db'); @@ -112,6 +122,8 @@ function createCheersFromTouch(touch, transaction = null) { function createTouch(props) { const { cardUid, kegId, timestamp } = props; + log.info(`creating touch with cardUid ${cardUid} and kegId ${kegId}`); + // transact this so if one fails neither get written return db.sequelize.transaction(transaction => createCheersFromTouch(props, transaction) @@ -198,6 +210,24 @@ function reconcileTouch(touch) { }); } +/** + * Wrap reconcileTouch() to process an array of touches in sequence. + * Returns an array of only the Touches that were successfully reconciled. + * @param {Object[]} touches + * @return {Promise.Object[]} + */ +function reconcileTouches(touches) { + return touches.reduce((promise, props) => + promise.then(returnArray => + reconcileTouch(props) + .then((touch) => { // eslint-disable-line arrow-body-style + // only return touches that got reconciled (ie, have a cheers) + return touch.cheersId ? [...returnArray, touch] : returnArray; + }) + ) + , Promise.resolve([])); +} + /** * Find all Touches where .kegId doesn't resolve to a Keg. @@ -216,13 +246,18 @@ function getOrphanTouches() { /** * Find all Touches that don't have .cardId + * Optionally pass a cardUid to narrow it down. * @return {Promise.Object[]} */ -function getUnreconciledTouches() { +function getUnreconciledTouches(cardUid) { + const where = { + cardId: null, + }; + if (cardUid) { + where.cardUid = cardUid; + } return db.Touch.findAll({ - where: { - cardId: null, - }, + where, }); } @@ -233,6 +268,7 @@ module.exports = { createTouch, createTouches, reconcileTouch, + reconcileTouches, reconcileCardAndKeg, getOrphanTouches, getUnreconciledTouches, diff --git a/server/test/touches.js b/server/test/touches.js index 20dd8db..8ddb602 100644 --- a/server/test/touches.js +++ b/server/test/touches.js @@ -365,6 +365,58 @@ describe('lib/touches', () => { }); + describe('reconcileTouches()', () => { + it('Processes an array of Touches and returns reconciled Touches', () => { + + // - a valid touch + // - touch with unknown cardUid + // - touch with unknown kegId + // - a valid touch + // we should get 2 back, and it should create 2 cheers. + + return db.Cheers.count() + .then((numCheers) => { + const touchArray = []; + + return db.Touch.create({ + cardUid: seeds.card.uid, + kegId: seeds.keg.id, + timestamp: new Date(), + }) + .then(touch => touchArray.push(touch)) + .then(() => db.Touch.create({ + cardUid: 'invalid card uid', + kegId: seeds.keg.id, + timestamp: new Date(), + })) + .then(touch => touchArray.push(touch)) + .then(() => db.Touch.create({ + cardUid: seeds.card.uid, + kegId: 99999, + timestamp: new Date(), + })) + .then(touch => touchArray.push(touch)) + .then(() => db.Touch.create({ + cardUid: seeds.card.uid, + kegId: seeds.keg.id, + timestamp: new Date(), + })) + .then(touch => touchArray.push(touch)) + .then(() => touches.reconcileTouches(touchArray)) + .should.eventually.be.an('array').of.length(2) + .then(response => response[0].should.be.an('object').and.include({ + cardUid: seeds.card.uid, + cardId: seeds.card.id, + kegId: seeds.keg.id, + })) + .then(() => db.Cheers.count()) + .then(cheersCountNow => cheersCountNow.should.equal(numCheers + 2)); + }); + + + }); + }); + describe('a user can touch before registering a card and then claim the cheers', () => { // I think technically this all covered above, but since it's the // whole point of the thing we'll go through it from start to finish... @@ -378,18 +430,17 @@ describe('lib/touches', () => { email: 'some@random.com', }; - let touchId; - it('works', () => - // get a touch from some random with an unknown cardUid - touches.createTouch({ + // create touches from some random with an unknown cardUid + touches.createTouches([{ cardUid, kegId, timestamp, - }) - .then((touch) => { - touchId = touch.id; - }) + }, { + cardUid, + kegId, + timestamp, + }]) // now the random registers an account .then(() => db.User.create(randomUser)) // and claims the card @@ -399,8 +450,8 @@ describe('lib/touches', () => { name: 'My security token', })) // now we reconcile the touch... - .then(() => db.Touch.findById(touchId)) - .then(touch => touches.reconcileTouch(touch)) + .then(() => touches.getUnreconciledTouches(cardUid)) + .then(unreconciledTouches => touches.reconcileTouches(unreconciledTouches)) // and look up our random user .then(() => db.User.findOne({ where: { @@ -408,9 +459,9 @@ describe('lib/touches', () => { }, include: db.Cheers, })) - // and he's got a cheers! + // and he's got 2 cheers! .then((user) => { - user.Cheers.should.be.an('array').of.length(1); + user.Cheers.should.be.an('array').of.length(2); user.Cheers[0].kegId.should.equal(kegId); }) ); @@ -473,6 +524,32 @@ describe('lib/touches', () => { .then(() => touches.getUnreconciledTouches()) .should.eventually.be.an('array').of.length(2); }); + + it('optionally takes a cardUid argument to filter by', () => { + const kegId = seeds.keg.id; + const timestamp = new Date(); + + // create 2 unreconcileable touches + return touches.createTouches([{ + cardUid: 'abc', + kegId, + timestamp, + }, { + cardUid: 'abc', + kegId, + timestamp, + }, { + cardUid: 'def', + kegId, + timestamp, + }, { + cardUid: 'def', + kegId, + timestamp, + }]) + .then(() => touches.getUnreconciledTouches('abc')) + .should.eventually.be.an('array').of.length(2); + }); }); }); From 7746316c65e0494617d318220df4074a801ea850 Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Sun, 4 Jun 2017 16:19:28 +1000 Subject: [PATCH 15/20] Card registration workflow --- TODO.md | 13 ++ client/src/actions/card-register.js | 69 +++++++ .../components/cards/card-register-steps.js | 178 ++++++++++++++++++ client/src/components/cards/card-register.js | 50 +++++ client/src/components/error.js | 9 +- client/src/components/router.js | 7 + client/src/components/users/user-cheers.js | 2 +- client/src/proptypes/index.js | 7 + client/src/scss/main.scss | 64 ++++++- client/src/stores/card-register.js | 135 +++++++++++++ server/lib/tapontap.js | 7 +- server/routes/api.js | 49 ++++- 12 files changed, 579 insertions(+), 11 deletions(-) create mode 100644 client/src/actions/card-register.js create mode 100644 client/src/components/cards/card-register-steps.js create mode 100644 client/src/components/cards/card-register.js create mode 100644 client/src/stores/card-register.js diff --git a/TODO.md b/TODO.md index 4819879..0e82720 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,16 @@ # todo +## TapOnTap integration + +- Bearer token or API key auth for the post-touches endpoint +- Remove the extra button press in the registration flow +- List, edit and delete tokens in Profile +- Delete Cheers +- API endpoint that proxies a ping to TapOnTap so you can test it +- Cleanup classname inconsistency in the steps components + + + ## bugs, issues @@ -9,6 +20,8 @@ ## new features, improvements +- Redirect to last URL on login +- ladda - Link to the Keg Detail from the Keg List view? - UI needs to be more helpful distinguishing/explaining Kegs vs Beers - Searchable ModelSelect diff --git a/client/src/actions/card-register.js b/client/src/actions/card-register.js new file mode 100644 index 0000000..782892f --- /dev/null +++ b/client/src/actions/card-register.js @@ -0,0 +1,69 @@ +/** + * CardRegister Actions. + + */ + +import dispatcher from '../dispatcher'; +import { fetcher } from './util'; + + +// reset the store to intial values +export function reset() { + dispatcher.dispatch({ + type: 'CARD_REGISTER_RESET', + }); +} + +// Transition to a different step +export function stepTo(step) { + dispatcher.dispatch({ + type: 'CARD_REGISTER_STEP_TO', + step, + }); +} + +// retry with a different token +export function retry() { + dispatcher.dispatch({ + type: 'CARD_REGISTER_RETRY', + }); +} + +// fetch card uid from TapOnTap +export function fetchCard() { + dispatcher.dispatch({ + type: 'REQUEST_FETCH_CARD', + }); + + return fetcher('/api/v1/cards/register') + .then(({ cardUid }) => dispatcher.dispatch({ + type: 'RECEIVE_FETCH_CARD', + cardUid, + })) + .catch(error => dispatcher.dispatch({ + type: 'RECEIVE_FETCH_CARD', + error, + })); +} + +// register the card to the current user +export function registerCard(uid) { + dispatcher.dispatch({ + type: 'REQUEST_REGISTER_CARD', + }); + + return fetcher('/api/v1/cards/register', { + method: 'POST', + body: JSON.stringify({ + uid, + }), + }) + .then(data => dispatcher.dispatch({ + type: 'RECEIVE_REGISTER_CARD', + data, + })) + .catch(error => dispatcher.dispatch({ + type: 'RECEIVE_REGISTER_CARD', + error, + })); +} diff --git a/client/src/components/cards/card-register-steps.js b/client/src/components/cards/card-register-steps.js new file mode 100644 index 0000000..8627696 --- /dev/null +++ b/client/src/components/cards/card-register-steps.js @@ -0,0 +1,178 @@ +import React from 'react'; +import reactPropTypes from 'prop-types'; + +import { stepTo, fetchCard, registerCard, retry } from '../../actions/card-register'; +import { userModel, cardModel, cheersModel } from '../../proptypes'; + +import { UserCheersItem } from '../users/user-cheers'; + + +function register(uid) { + stepTo(3); + registerCard(uid); +} + +// explain how it works +const Intro = () => ( +
+

+ Cheers a beer by tapping an NFC token on the reader at the bar. +

+

+ You can Cheers now and register your token later - your Cheers will be added to your profile. +

+ + +
+); + +// make sure the user's logged in +const Whoami = props => ( +
+ {props.profile.id && ( +
+

+ You're logged in as {props.profile.name}. +

+ + Logout +
+ )} + + {!props.profile.id && ( +
+

To begin, please login or create an OnTap account.

+ Login with Google +
+ )} +
+); + +Whoami.propTypes = { + profile: reactPropTypes.shape(userModel), +}; + +// check the card, display any errors +const CheckCard = (props) => { + const { cardUid, badCard, error } = props; + + return ( +
+ {!cardUid && ( +
+

Please place and hold your NFC token on the reader.

+

While holding your token on the reader, tap below.

+ +
+ )} + + {badCard && ( +
+

+ Unable to detect an NFC token. Is the reader light green? +

+

+ Not all tokens are compatible. Ask your Beer Baron if you're not sure. +

+
+ )} + + {error && ( +
+

{ error }

+
+ )} +
+ ); +}; + +CheckCard.propTypes = { + cardUid: reactPropTypes.string, + error: reactPropTypes.string, + badCard: reactPropTypes.bool, +}; + +// got card from tapontap; attempt to register, display any errors +const ConfirmRegistration = props => ( +
+ + {!props.duplicateCard && !props.registeredCard && ( +
+

Great, we've detected a compatible NFC token on the reader.

+

Tap below to link this token to your OnTap account.

+ +
+ )} + + {props.duplicateCard && ( +
+
+

That token is already registered.

+
+

Please try another NFC token.

+ +
+ )} + + {props.registeredCard && ( +
+

Success! Your NFC token has been linked to your account.

+

Manage your tokens in My Profile.

+ + {props.registeredCard.cheers.length ? ( +
+

You've got {props.registeredCard.cheers.length} new Cheers:

+
+ {props.registeredCard.cheers.map(cheers => + + )} +
+ +
+ ) : ( +
+

How to use your token

+

Tap once on the reader to Cheers the beer on tap. You'll hear a beep and see a green light.

+

Cheers a beer as many times as you like. Whenever it hits the spot!

+

Cheers!

+
+ )} + +
+ )} + + {props.error && ( +
+

{props.error}

+
+ )} + + +
+); + +ConfirmRegistration.propTypes = { + cardUid: reactPropTypes.string, + error: reactPropTypes.string, + duplicateCard: reactPropTypes.bool, + registeredCard: reactPropTypes.shape({ + // card: reactPropTypes.shape(cardModel), + cheers: reactPropTypes.arrayOf(reactPropTypes.shape(cheersModel)), + }), +}; + +const stepsInOrder = [Intro, Whoami, CheckCard, ConfirmRegistration]; + +const CurrentStep = (props) => { + const StepComponent = stepsInOrder[props.step]; + return ; +}; + +CurrentStep.propTypes = { + step: reactPropTypes.number, +}; + +export default CurrentStep; diff --git a/client/src/components/cards/card-register.js b/client/src/components/cards/card-register.js new file mode 100644 index 0000000..7a0b639 --- /dev/null +++ b/client/src/components/cards/card-register.js @@ -0,0 +1,50 @@ +/** + * CardRegister + * + * Users can register an NFC Card for use with TapOnTap. + * + */ + +import React from 'react'; +import { Container } from 'flux/utils'; + +import profileStore from '../../stores/profile'; +import cardRegisterStore from '../../stores/card-register'; +import { reset } from '../../actions/card-register'; + +import CurrentStep from './card-register-steps'; + +const CardRegister = props => ( +
+
+

+ Tap On Tap +

+
+ +
+); + +class CardRegisterContainer extends React.Component { + + static getStores() { + return [cardRegisterStore, profileStore]; + } + + static calculateState() { + return { + ...cardRegisterStore.getState(), + profile: profileStore.getState().data, + }; + } + + componentWillMount() { + reset(); // reset the state store + } + + render() { + return ; + } +} + +export default Container.create(CardRegisterContainer); diff --git a/client/src/components/error.js b/client/src/components/error.js index c37ecbc..360f93e 100644 --- a/client/src/components/error.js +++ b/client/src/components/error.js @@ -4,8 +4,9 @@ */ import React from 'react'; +import propTypes from 'prop-types'; -export default (props) => { +const Error = (props) => { if (!props) return null; return ( @@ -14,3 +15,9 @@ export default (props) => { ); }; + +Error.propTypes = { + message: propTypes.string, +}; + +export default Error; diff --git a/client/src/components/router.js b/client/src/components/router.js index 8281598..f1ce44b 100644 --- a/client/src/components/router.js +++ b/client/src/components/router.js @@ -21,6 +21,7 @@ import BeerDetail from './beers/beer-detail'; import BreweryList from './breweries/brewery-list'; import BreweryDetail from './breweries/brewery-detail'; import BreweryAdd from './breweries/brewery-add'; +import CardRegister from './cards/card-register'; // define your routes. @@ -130,6 +131,12 @@ const routes = { profile: props.profile.data, }), }, + '/tapon': { + component: CardRegister, + props: props => ({ + profile: props.profile.data, + }), + }, }; // set a default route diff --git a/client/src/components/users/user-cheers.js b/client/src/components/users/user-cheers.js index 605ec7e..51be8a5 100644 --- a/client/src/components/users/user-cheers.js +++ b/client/src/components/users/user-cheers.js @@ -5,7 +5,7 @@ import moment from 'moment'; import * as propTypes from '../../proptypes'; -const UserCheersItem = (props) => { +export const UserCheersItem = (props) => { const { Keg } = props; const { Beer } = Keg; const { Brewery } = Beer; diff --git a/client/src/proptypes/index.js b/client/src/proptypes/index.js index 31ec085..18284ed 100644 --- a/client/src/proptypes/index.js +++ b/client/src/proptypes/index.js @@ -71,6 +71,13 @@ export const tapModel = { Keg: propTypes.shape(kegModel), }; +export const cardModel = { + id: propTypes.number, + uid: propTypes.string, + userId: propTypes.number, + name: propTypes.string, +}; + // notification export const notificationModel = propTypes.shape({ message: propTypes.string, diff --git a/client/src/scss/main.scss b/client/src/scss/main.scss index f790471..db6bbf8 100644 --- a/client/src/scss/main.scss +++ b/client/src/scss/main.scss @@ -17,6 +17,7 @@ $nearlyWhite: #FAFAFA; $brown: #59596E; $khaki: #6D7340; $highlight: orange; +$warning: #C00; @import '~normalize.css/normalize.css'; @@ -652,6 +653,7 @@ icon.icon-canbuy { font-weight: 400; color: $lightBlue; font-size: 1rem; + text-align: center; cursor: pointer; padding: 0.5em 1em; @@ -662,11 +664,11 @@ icon.icon-canbuy { text-decoration: inherit; } - // + // alternate button &.alt { background: $lightBlue; color: white; - border: 1px solid $darkerBlue; + // border: 1px solid $darkerBlue; &:hover { background: $darkBlue; @@ -1065,6 +1067,10 @@ icon.icon-canbuy { } } +.error { + color: $warning; +} + // tap views .tap { @@ -1102,6 +1108,51 @@ icon.icon-canbuy { } +.card-register { + p { + color: $darkBlue; + font-size: 1.2rem; + margin: 0 0 1em; + + &.important { + font-weight: 600; + color: $darkerBlue; + } + + } + + .error { + margin: 1em 0; + + p { + font-weight: 400; + color: $warning; + } + + p + p { + margin: 0 0 1em; + } + + } + + h3 { + font-size: 1.4rem; + color: $darkerBlue; + margin: 0 0 1em; + } + + .no-cheers, .found-cheers { + margin: 2em 0 0; + } + + .icon.emoji-beers { + vertical-align: bottom; + margin-left: 8px; + width: 44px; + height: 44px; + } + +} // responsive styles for mobile $mobilePadding: 12px; @@ -1346,6 +1397,15 @@ $mobilePadding: 12px; } } + .card-register { + .btn { + width: 100%; + font-size: 1.2rem; + display: block; + margin: 0 0 0.5em; + } + } + } diff --git a/client/src/stores/card-register.js b/client/src/stores/card-register.js new file mode 100644 index 0000000..a26e561 --- /dev/null +++ b/client/src/stores/card-register.js @@ -0,0 +1,135 @@ +/** + * Card Register store. + * Basically a state machine for the CardRegister view. + */ + +import { ReduceStore } from 'flux/utils'; + +import dispatcher from '../dispatcher'; + +// parse http responses into human-readable messages for the client +function parseFetchError(error) { + if (error.isClean) { + switch (error.code) { + case 504: + return 'Network Error: Can\'t reach TapOnTap instance.'; + case 502: + return 'Bad gateway: Got a bad response from TapOnTap instance.'; + default: + return error.message; + } + } + return error.toString(); +} + +const initialState = { + step: 0, + cardUid: null, // uid sent from tapontap. + error: null, + loading: false, + badCard: false, + duplicateCard: false, + registeredCard: null, +}; + +class CardRegisterStore extends ReduceStore { + getInitialState() { + return initialState; + } + + reduce(state, action) { // eslint-disable-line class-methods-use-this + const { type, error } = action; + + switch (type) { + + case 'CARD_REGISTER_RESET': + return initialState; + + case 'CARD_REGISTER_STEP_TO': + return { + step: action.step, + }; + + case 'REQUEST_FETCH_CARD': + return { + cardUid: null, + error: null, + loading: true, + badCard: false, + }; + + case 'RECEIVE_FETCH_CARD': { + // error + if (error) { + return { + cardUid: null, + error: parseFetchError(error), + loading: false, + }; + } + + // no card or invalid card on the reader + if (action.cardUid === null) { + return { + cardUid: null, + badCard: true, + error: null, + loading: false, + }; + } + + // success, save carduid and push to next step + return { + step: 3, + cardUid: action.cardUid, + error: null, + loading: false, + badCard: false, + }; + } + + case 'REQUEST_REGISTER_CARD': + return { + error: null, + loading: true, + }; + + case 'RECEIVE_REGISTER_CARD': + // duplicate card + if (error && error.code === 409) { + return { + loading: false, + duplicateCard: true, + error: null, + }; + } + + // some other error + if (error) { + return { + loading: false, + error: parseFetchError(error), + }; + } + + // no problem + return { + error: null, + loading: false, + registeredCard: action.data, + }; + + case 'CARD_REGISTER_RETRY': + // go back to step 2 + return { + ...initialState, + step: 2, + }; + + default: + return state; + } + } +} + +export default new CardRegisterStore(dispatcher); diff --git a/server/lib/tapontap.js b/server/lib/tapontap.js index 07bbcce..e7385ee 100644 --- a/server/lib/tapontap.js +++ b/server/lib/tapontap.js @@ -20,16 +20,15 @@ function getCurrentCard() { return fetch(`${TAPONTAP_INSTANCE}/api/v1/status`) .catch(() => { // fetch only throws for network errors - throw new Error('TapOnTap instance unreachable'); + throw new Error('NETWORK_ERROR'); }) .then((response) => { if (response.ok) { return response.json(); } - throw new Error('Unable to parse TapOnTap response'); + throw new Error('BAD_RESPONSE'); }) - .then((status) => { - console.log(status); + .then((status) => { // eslint-disable-line arrow-body-style return status.card ? status.card.uid : null; }); } diff --git a/server/routes/api.js b/server/routes/api.js index 70b45e3..3cf412b 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -625,6 +625,8 @@ function receiveTouches(req, res) { // todo - validate + log.info(`received ${touches.length} ${touches.length === 1 ? 'touch' : 'touches'} from TapOnTap instance`); + touchlib.createTouches(touches) .then(() => res.status(200).send({ success: true })) .catch(error => logAndSendError(error, res)); @@ -649,7 +651,21 @@ function getAllCards(req, res) { function getCardUid(req, res) { tapontap.getCurrentCard() .then(cardUid => res.send({ cardUid })) - .catch(err => logAndSendError(err, res)); + .catch((error) => { + log.error('getCardUid()', error); + switch (error.message) { + case 'NETWORK_ERROR': + res.status(504); + break; + case 'BAD_RESPONSE': + res.status(502); + break; + default: + res.status(500); + break; + } + res.send({ error: error.message }); + }); } // register a Card to a User @@ -665,8 +681,35 @@ function registerCard(req, res) { userId, name, }) - .then(card => res.status(201).send(card.get())) - .catch(error => logAndSendError(error, res)); + .then(card => + // Card's been registered. Try to reconcile any Touches + // made with it and return their corresponding Cheers. + touchlib.getUnreconciledTouches(uid) + .then(touches => touchlib.reconcileTouches(touches)) + .then((reconciledTouches) => { + const cheersIds = reconciledTouches.map(touch => touch.cheersId); + return db.Cheers.findAll({ + where: { + id: { + $in: cheersIds, + }, + }, + attributes: cheersAttributesPublic, + include: [kegWithBeerInclude], + }); + }).then(cheers => res.status(201).send({ + card, + cheers, + })) + ) + .catch((error) => { + // explicitly handle a conflicting uid + if (error.name === 'SequelizeUniqueConstraintError') { + res.status(409).send({ error: 'CARD_EXISTS' }); + } else { + logAndSendError(error, res); + } + }); } // auth middleware. From 180e78d99142ed40a65ecc35825888e91697db2b Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Sun, 4 Jun 2017 22:47:26 +1000 Subject: [PATCH 16/20] Primitive implementation of bearer token from env var for the /touches endpoint --- .env.example | 4 ++++ client/src/components/router.js | 2 +- server/routes/api.js | 21 +++++++++++++-------- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index ab599c4..fd5747e 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,7 @@ GOOGLE_AUTH_CALLBACK_URL= # debugging SIMULATE_USER_ID=0 + +# tapontap +TAPONTAP_INSTANCE=http://localhost:5000 +AUTH_TOKEN=foobarqux diff --git a/client/src/components/router.js b/client/src/components/router.js index f1ce44b..476f564 100644 --- a/client/src/components/router.js +++ b/client/src/components/router.js @@ -113,7 +113,7 @@ const routes = { }, '/breweries/add': { component: BreweryAdd, - props: (props, params) => ({ + props: props => ({ profile: props.profile.data, }), }, diff --git a/server/routes/api.js b/server/routes/api.js index 3cf412b..ca2de30 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -14,6 +14,13 @@ const tapontap = require('lib/tapontap'); const router = new Router(); router.use(bodyParser.json()); +const { AUTH_TOKEN } = process.env; + +// validate that we were sent the correct auth token +// currently we just load it from an env var +function validateAuthToken(request) { + return request.get('authorization') === `Bearer: ${AUTH_TOKEN}`; +} // default attributes to send over the wire const userAttributesPublic = ['id', 'name', 'avatar', 'admin']; @@ -28,7 +35,6 @@ const cheersAttributesPublic = ['id', 'kegId', 'userId', 'timestamp']; // include declarations for returning nested models - const breweryInclude = { model: db.Brewery, attributes: breweryAttributesPublic, @@ -621,13 +627,15 @@ function createBrewery(req, res) { // receives an array of Touches from a TapOnTap instance function receiveTouches(req, res) { - const touches = req.body; - - // todo - validate + // need a valid auth token to proceed + if (!validateAuthToken(req)) { + return res.status(401).send(); + } + const touches = req.body; log.info(`received ${touches.length} ${touches.length === 1 ? 'touch' : 'touches'} from TapOnTap instance`); - touchlib.createTouches(touches) + return touchlib.createTouches(touches) .then(() => res.status(200).send({ success: true })) .catch(error => logAndSendError(error, res)); } @@ -751,11 +759,8 @@ router.get('/beers/:id', getBeerById); router.get('/profile', getProfile); router.get('/breweries', getAllBreweries); router.get('/breweries/:id', getBreweryById); - -// todo - these need to be locked down router.post('/touches', receiveTouches); - // guests can't use endpoints below this middleware router.use(usersOnly); From e51431acc167c6484ec72bfe8279797e0d7bdb7f Mon Sep 17 00:00:00 2001 From: Mark Reid Date: Mon, 5 Jun 2017 10:52:55 +1000 Subject: [PATCH 17/20] Display Cards in User Profile; allow deletion by self or admin. Add /pingtapontap endpoint --- .env.example | 6 +- TODO.md | 6 +- client/src/actions/cards.js | 35 +++++++++ client/src/actions/profile.js | 13 ++-- client/src/components/app/index.js | 8 ++- .../src/components/profile/profile-detail.js | 11 ++- client/src/components/profile/profile-menu.js | 12 +++- client/src/components/users/user-cards.js | 72 +++++++++++++++++++ client/src/scss/main.scss | 32 +++++++-- client/src/stores/profile.js | 21 ++++-- package.json | 3 +- server/lib/tapontap.js | 20 +++++- server/routes/api.js | 64 ++++++++++++++--- 13 files changed, 261 insertions(+), 42 deletions(-) create mode 100644 client/src/actions/cards.js create mode 100644 client/src/components/users/user-cards.js diff --git a/.env.example b/.env.example index fd5747e..e9cfdd5 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,6 @@ GOOGLE_AUTH_CALLBACK_URL= # debugging SIMULATE_USER_ID=0 -# tapontap -TAPONTAP_INSTANCE=http://localhost:5000 -AUTH_TOKEN=foobarqux +# tapontap - optional +# TAPONTAP_INSTANCE=http://localhost:5000 +# AUTH_TOKEN=foobarqux diff --git a/TODO.md b/TODO.md index 0e82720..ee69296 100644 --- a/TODO.md +++ b/TODO.md @@ -2,12 +2,9 @@ ## TapOnTap integration -- Bearer token or API key auth for the post-touches endpoint - Remove the extra button press in the registration flow -- List, edit and delete tokens in Profile - Delete Cheers -- API endpoint that proxies a ping to TapOnTap so you can test it -- Cleanup classname inconsistency in the steps components +- Cleanup classname inconsistency in the CardRegister steps components @@ -22,6 +19,7 @@ - Redirect to last URL on login - ladda + - also a component for the button + confirm UI pattern - Link to the Keg Detail from the Keg List view? - UI needs to be more helpful distinguishing/explaining Kegs vs Beers - Searchable ModelSelect diff --git a/client/src/actions/cards.js b/client/src/actions/cards.js new file mode 100644 index 0000000..7cb74ca --- /dev/null +++ b/client/src/actions/cards.js @@ -0,0 +1,35 @@ +/** + * Card Actions + */ + +import dispatcher from '../dispatcher'; +import { fetcher } from './util'; +import { addNotification } from './notifications'; + + +// delete a card +export function deleteCard(id) { // eslint-disable-line import/prefer-default-export + dispatcher.dispatch({ + type: 'REQUEST_DELETE_CARD', + id, + }); + + return fetcher(`/api/v1/cards/${id}`, { + method: 'DELETE', + }) + .then(() => { + dispatcher.dispatch({ + type: 'RECEIVE_DELETE_CARD', + id, + }); + addNotification('Removed your NFC Token.'); + }) + .catch((error) => { + dispatcher.dispatch({ + type: 'RECEIVE_DELETE_CARD', + error, + }); + + throw error; // rethrow for action caller + }); +} diff --git a/client/src/actions/profile.js b/client/src/actions/profile.js index 6d21e88..96ad3cc 100644 --- a/client/src/actions/profile.js +++ b/client/src/actions/profile.js @@ -24,19 +24,20 @@ export function fetchProfile() { })); } -// fetch the Cheers for our profile. -export function fetchProfileCheers() { +// fetch the extra data for the profile view; +// Cheers, Cards. +export function fetchProfileFull() { dispatcher.dispatch({ - type: 'REQUEST_FETCH_PROFILE_CHEERS', + type: 'REQUEST_FETCH_PROFILE_FULL', }); - return fetcher('/api/v1/profile/cheers') + return fetcher('/api/v1/profile/full') .then(data => dispatcher.dispatch({ - type: 'RECEIVE_FETCH_PROFILE_CHEERS', + type: 'RECEIVE_FETCH_PROFILE_FULL', data, })) .catch(error => dispatcher.dispatch({ - type: 'RECEIVE_FETCH_PROFILE_CHEERS', + type: 'RECEIVE_FETCH_PROFILE_FULL', error, })); } diff --git a/client/src/components/app/index.js b/client/src/components/app/index.js index 63ec7e7..27cf81e 100644 --- a/client/src/components/app/index.js +++ b/client/src/components/app/index.js @@ -40,6 +40,12 @@ class AppComponent extends React.Component { }); } + hideMenu() { + this.setState({ + showMenu: false, + }); + } + render() { const { props } = this; const isAdmin = props.profile && props.profile.data.admin; @@ -66,7 +72,7 @@ class AppComponent extends React.Component { >☰ -