diff --git a/.drone.yml b/.drone.yml index 23a7ad7..41abe31 100644 --- a/.drone.yml +++ b/.drone.yml @@ -33,7 +33,7 @@ steps: - drone-test status: success settings: - host: 10.0.0.52 + host: vinlottis.schleppe username: root key: from_secret: ssh_key diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ff5448c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 100, + "quoteProps": "consistent", + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "useTabs": true +} diff --git a/api/controllers/vinmonopoletController.js b/api/controllers/vinmonopoletController.js index b68ec51..cf2e486 100644 --- a/api/controllers/vinmonopoletController.js +++ b/api/controllers/vinmonopoletController.js @@ -11,7 +11,15 @@ function searchWines(req, res) { page: page, success: true }) - ); + ) + .catch(error => { + const { statusCode, message } = error; + + return res.status(statusCode || 500).send({ + message: message || `Unexpected error occured trying to search for wine: ${name} at page: ${page}`, + success: false + }); + }); } function wineByEAN(req, res) { @@ -28,12 +36,20 @@ function wineByEAN(req, res) { function wineById(req, res) { const { id } = req.params; - return vinmonopoletRepository.wineById(id).then(wines => + return vinmonopoletRepository.wineById(id).then(wine => res.json({ - wine: wines[0], + wine: wine, success: true }) - ); + ) + .catch(error => { + const { statusCode, message } = error; + + return res.status(statusCode || 500).send({ + message: message || `Unexpected error occured trying to fetch wine with id: ${id}`, + success: false + }); + }); } function allStores(req, res) { diff --git a/api/history.js b/api/history.js index 46195ce..3542c3d 100644 --- a/api/history.js +++ b/api/history.js @@ -33,7 +33,6 @@ const addWinnerWithWine = async (winner, wine) => { wine: savedWine, color: winner.color }; - if (exisitingWinner == undefined) { const newWinner = new Winner({ name: winner.name, diff --git a/api/lottery.js b/api/lottery.js index 956e09f..c1625e6 100644 --- a/api/lottery.js +++ b/api/lottery.js @@ -5,10 +5,13 @@ const Attendee = require(path.join(__dirname, "/schemas/Attendee")); const PreLotteryWine = require(path.join(__dirname, "/schemas/PreLotteryWine")); const VirtualWinner = require(path.join(__dirname, "/schemas/VirtualWinner")); const Lottery = require(path.join(__dirname, "/schemas/Purchase")); +const { WineNotFound } = require(path.join(__dirname, "/vinlottisErrors")); const Message = require(path.join(__dirname, "/message")); const historyRepository = require(path.join(__dirname, "/history")); const wineRepository = require(path.join(__dirname, "/wine")); +const winnerRepository = require(path.join(__dirname, "/winner")); +const prelotteryWineRepository = require(path.join(__dirname, "/prelotteryWine")); const { WinnerNotFound, @@ -17,11 +20,36 @@ const { LotteryByDateNotFound } = require(path.join(__dirname, "/vinlottisErrors")); +const moveUnfoundPrelotteryWineToWines = async (error, tempWine) => { + if(!(error instanceof WineNotFound)) { + throw error + } + + if(!tempWine.winner) { + throw new WinnerNotFound() + } + + const prelotteryWine = await prelotteryWineRepository.wineById(tempWine._id); + const winner = await winnerRepository.winnerById(tempWine.winner.id, true); + + return wineRepository + .addWine(prelotteryWine) + .then(_ => prelotteryWineRepository.addWinnerToWine(prelotteryWine, winner)) // prelotteryWine.deleteById + .then(_ => historyRepository.addWinnerWithWine(winner, prelotteryWine)) + .then(_ => winnerRepository.setWinnerChosenById(winner.id)) +} + const archive = (date, raffles, stolen, wines) => { const { blue, red, yellow, green } = raffles; const bought = blue + red + yellow + green; - return Promise.all(wines.map(wine => wineRepository.findWine(wine))).then(resolvedWines => { + return Promise.all( + wines.map(wine => wineRepository + .findWine(wine) + .catch(error => moveUnfoundPrelotteryWineToWines(error, wine) + .then(_ => wineRepository.findWine(wine)) + )) + ).then(resolvedWines => { const lottery = new Lottery({ date, blue, diff --git a/api/person.js b/api/person.js deleted file mode 100644 index 03aee27..0000000 --- a/api/person.js +++ /dev/null @@ -1,35 +0,0 @@ -const path = require("path"); -const Highscore = require(path.join(__dirname, "/schemas/Highscore")); - -async function findSavePerson(foundWinner, wonWine, date) { - let person = await Highscore.findOne({ - name: foundWinner.name - }); - - if (person == undefined) { - let newPerson = new Highscore({ - name: foundWinner.name, - wins: [ - { - color: foundWinner.color, - date: date, - wine: wonWine - } - ] - }); - - await newPerson.save(); - } else { - person.wins.push({ - color: foundWinner.color, - date: date, - wine: wonWine - }); - person.markModified("wins"); - await person.save(); - } - - return person; -} - -module.exports.findSavePerson = findSavePerson; diff --git a/api/prelotteryWine.js b/api/prelotteryWine.js index cfbcfb5..34004fb 100644 --- a/api/prelotteryWine.js +++ b/api/prelotteryWine.js @@ -23,7 +23,7 @@ const addWines = wines => { country: wine.country, id: wine.id }); - + console.log(newPrelotteryWine) return newPrelotteryWine.save(); }); diff --git a/api/router.js b/api/router.js index a9cac58..1a21c9e 100644 --- a/api/router.js +++ b/api/router.js @@ -87,8 +87,14 @@ router.get("/chat/history", chatController.getAllHistory); router.delete("/chat/history", mustBeAuthenticated, chatController.deleteHistory); router.post("/login", userController.login); -router.post("/register", mustBeAuthenticated, userController.register); router.get("/logout", userController.logout); +if(process.env !== "production") { + // We don't want to hide registering behind a + // authentication-wall if we are in dev + router.post("/register", userController.register); +} else { + router.post("/register", mustBeAuthenticated, userController.register); +} // router.get("/", documentation.apiInfo); diff --git a/api/vinmonopolet.js b/api/vinmonopolet.js index 164e09f..310232b 100644 --- a/api/vinmonopolet.js +++ b/api/vinmonopolet.js @@ -1,6 +1,7 @@ const fetch = require("node-fetch"); const path = require("path"); const config = require(path.join(__dirname + "/../config/env/lottery.config")); +const vinmonopoletCache = require(path.join(__dirname, "vinmonopoletCache")); const convertToOurWineObject = wine => { if (wine.basic.ageLimit === "18") { @@ -18,6 +19,20 @@ const convertToOurWineObject = wine => { } }; +const convertVinmonopoletProductResponseToWineObject = wine => { + return { + name: wine.name, + vivinoLink: "https://www.vinmonopolet.no" + wine.url, + rating: null, + occurences: 0, + id: wine.code, + year: wine.year, + image: wine.images[1].url, + price: wine.price.value, + country: wine.main_country.name + } +}; + const convertToOurStoreObject = store => { return { id: store.storeId, @@ -26,37 +41,32 @@ const convertToOurStoreObject = store => { }; }; -const searchWinesByName = async (name, page = 1) => { - const pageSize = 15; - let url = new URL( - `https://apis.vinmonopolet.no/products/v0/details-normal?productShortNameContains=gato&maxResults=15` - ); - url.searchParams.set("maxResults", pageSize); - url.searchParams.set("start", pageSize * (page - 1)); - url.searchParams.set("productShortNameContains", name); +const searchWinesByName = (name, page = 1) => { + const pageSize = 25; - const vinmonopoletResponse = await fetch(url, { - headers: { - "Ocp-Apim-Subscription-Key": config.vinmonopoletToken - } - }) - .then(resp => resp.json()) - .catch(err => console.error(err)); + return vinmonopoletCache.wineByQueryName(name, page, pageSize) + .catch(_ => { + console.log(`No wines matching query: ${name} at page ${page} found in elastic index, searching vinmonopolet..`) - if (vinmonopoletResponse.errors != null) { - return vinmonopoletResponse.errors.map(error => { - if (error.type == "UnknownProductError") { - return res.status(404).json({ - message: error.message - }); - } else { - return next(); - } - }); - } - const winesConverted = vinmonopoletResponse.map(convertToOurWineObject).filter(Boolean); + const url = `https://www.vinmonopolet.no/api/search?q=${name}:relevance:visibleInSearch:true&searchType=product&pageSize=${pageSize}¤tPage=${page-1}` + const options = { + headers: { "Content-Type": 'application/json' } + }; - return winesConverted; + return fetch(url, options) + .then(resp => { + if (resp.ok == false) { + return Promise.reject({ + statusCode: 404, + message: `No wines matching query ${name} at page ${page} found in local cache or at vinmonopolet.`, + }) + } + + return resp.json() + .then(response => response?.productSearchResult?.products) + }) + }) + .then(wines => wines.map(convertVinmonopoletProductResponseToWineObject)) }; const wineByEAN = ean => { @@ -67,16 +77,30 @@ const wineByEAN = ean => { }; const wineById = id => { - const url = `https://apis.vinmonopolet.no/products/v0/details-normal?productId=${id}`; - const options = { - headers: { - "Ocp-Apim-Subscription-Key": config.vinmonopoletToken - } - }; + return vinmonopoletCache.wineById(id) + .catch(_ => { + console.log(`Wine id: ${id} not found in elastic index, searching vinmonopolet..`) - return fetch(url, options) - .then(resp => resp.json()) - .then(response => response.map(convertToOurWineObject)); + const url = `https://www.vinmonopolet.no/api/products/${id}?fields=FULL` + const options = { + headers: { + "Content-Type": 'application/json' + } + }; + + return fetch(url, options) + .then(resp => { + if (resp.ok == false) { + return Promise.reject({ + statusCode: 404, + message: `Wine with id ${id} not found in local cache or at vinmonopolet.`, + }) + } + + return resp.json() + }) + }) + .then(wine => convertVinmonopoletProductResponseToWineObject(wine)) }; const allStores = () => { diff --git a/api/vinmonopoletCache.js b/api/vinmonopoletCache.js new file mode 100644 index 0000000..9406c32 --- /dev/null +++ b/api/vinmonopoletCache.js @@ -0,0 +1,98 @@ +const fetch = require("node-fetch"); + +const ELASTIC_URL = 'http://localhost:9200'; +const INDEX_URL = `${ELASTIC_URL}/wines*`; + +const verifyAndUnpackElasticSearchResult = response => { + const searchHits = response?.hits?.hits; + + if (searchHits == null || searchHits.length == 0) { + return Promise.reject({ + statusCode: 404, + message: `Nothing found in vinmonopolet cache matching this.`, + }) + } + + return searchHits; +} + +const getWineObjectFromSearchHit = hit => { + const { wine } = hit?._source; + + if (wine == null) { + return Promise.reject({ + statusCode: 500, + message: `Found response, but it's missing a wine object. Unable to convert!`, + }) + } + + return wine; +} + +const wineById = id => { + const url = `${INDEX_URL}/_search` + const options = { + method: 'POST', + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + "size": 1, + "query": { + "match": { + "wine.code": id + } + }, + "_source": { + "includes": "wine" + }, + "sort": [ + { + "@timestamp": "desc" + } + ] + }) + } + + return fetch(url, options) + .then(resp => resp.json()) + .then(verifyAndUnpackElasticSearchResult) + .then(searchHits => getWineObjectFromSearchHit(searchHits[0])) +} + +const wineByQueryName = (name, page=1, size=25) => { + const url = `${INDEX_URL}/_search` + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json', }, + body: JSON.stringify({ + "from": page - 1, + "size": size, + "query": { + "multi_match" : { + "query" : name, + "fields": ["wine.name"], + "fuzziness": 2 + } + }, + "sort": [ + { + "_score": { + "order": "desc" + } + } + ], + "_source": { + "includes": "wine" + } + }) + }; + + return fetch(url, options) + .then(resp => resp.json()) + .then(verifyAndUnpackElasticSearchResult) + .then(searchHits => Promise.all(searchHits.map(getWineObjectFromSearchHit))) +} + +module.exports = { + wineById, + wineByQueryName +} \ No newline at end of file diff --git a/api/winner.js b/api/winner.js index 85514ed..c937efe 100644 --- a/api/winner.js +++ b/api/winner.js @@ -10,17 +10,19 @@ const redactWinnerInfoMapper = winner => { }; }; +const addWinner = winner => { + let newWinner = new VirtualWinner({ + name: winner.name, + color: winner.color, + timestamp_drawn: new Date().getTime() + }); + + return newWinner.save() +} + const addWinners = winners => { return Promise.all( - winners.map(winner => { - let newWinnerElement = new VirtualWinner({ - name: winner.name, - color: winner.color, - timestamp_drawn: new Date().getTime() - }); - - return newWinnerElement.save(); - }) + winners.map(winner => addWinner(winner)) ); }; @@ -49,6 +51,14 @@ const winnerById = (id, isAdmin = false) => { }); }; +const setWinnerChosenById = (id) => { + return VirtualWinner.findOne({id: id}).then(winner => { + winner.prize_selected = true + winner.markModified("wins") + return winner.save() + }) +} + const updateWinnerById = (id, updateModel) => { return VirtualWinner.findOne({ id: id }).then(winner => { if (winner == null) { @@ -86,10 +96,12 @@ const deleteWinners = () => { }; module.exports = { + addWinner, addWinners, allWinners, winnerById, updateWinnerById, deleteWinnerById, - deleteWinners + deleteWinners, + setWinnerChosenById }; diff --git a/config/env/lottery.config.example.js b/config/env/lottery.config.example.js index 0fa3784..1f47551 100644 --- a/config/env/lottery.config.example.js +++ b/config/env/lottery.config.example.js @@ -8,5 +8,6 @@ module.exports = { gatewayToken: undefined, vinmonopoletToken: undefined, googleanalytics_trackingId: undefined, - googleanalytics_cookieLifetime: 60 * 60 * 24 * 14 -}; \ No newline at end of file + googleanalytics_cookieLifetime: 60 * 60 * 24 * 14, + sites: [], +}; diff --git a/config/webpack.config.common.js b/config/webpack.config.common.js index d320d5f..fb5cb5f 100644 --- a/config/webpack.config.common.js +++ b/config/webpack.config.common.js @@ -11,15 +11,15 @@ const webpackConfig = function(isDev) { resolve: { extensions: [".js", ".vue"], alias: { - vue$: "vue/dist/vue.min.js", - "@": helpers.root("frontend") - } + "vue$": "vue/dist/vue.min.js", + "@": helpers.root("frontend"), + }, }, entry: { - vinlottis: helpers.root("frontend", "vinlottis-init") + vinlottis: helpers.root("frontend", "vinlottis-init"), }, externals: { - moment: 'moment' // comes with chart.js + moment: "moment", // comes with chart.js }, module: { rules: [ @@ -31,45 +31,45 @@ const webpackConfig = function(isDev) { options: { loaders: { scss: "vue-style-loader!css-loader!sass-loader", - sass: "vue-style-loader!css-loader!sass-loader?indentedSyntax" - } - } - } - ] + sass: "vue-style-loader!css-loader!sass-loader?indentedSyntax", + }, + }, + }, + ], }, { test: /\.js$/, - use: [ "babel-loader" ], - include: [helpers.root("frontend")] + use: ["babel-loader"], + include: [helpers.root("frontend")], }, { test: /\.css$/, use: [ MiniCSSExtractPlugin.loader, - { loader: "css-loader", options: { sourceMap: isDev } } - ] + { loader: "css-loader", options: { sourceMap: isDev } }, + ], }, { test: /\.scss$/, use: [ MiniCSSExtractPlugin.loader, { loader: "css-loader", options: { sourceMap: isDev } }, - { loader: "sass-loader", options: { sourceMap: isDev } } - ] + { loader: "sass-loader", options: { sourceMap: isDev } }, + ], }, { test: /\.woff(2)?(\?[a-z0-9]+)?$/, loader: "url-loader", options: { limit: 10000, - mimetype: "application/font-woff" - } + mimetype: "application/font-woff", + }, }, { test: /\.(ttf|eot|svg)(\?[a-z0-9]+)?$/, - loader: "file-loader" - } - ] + loader: "file-loader", + }, + ], }, plugins: [ new VueLoaderPlugin(), @@ -83,9 +83,10 @@ const webpackConfig = function(isDev) { __HOURS__: env.hours, __PUSHENABLED__: JSON.stringify(require("./defaults/push") != false), __GA_TRACKINGID__: JSON.stringify(env.googleanalytics_trackingId), - __GA_COOKIELIFETIME__: env.googleanalytics_cookieLifetime - }) - ] + __GA_COOKIELIFETIME__: env.googleanalytics_cookieLifetime, + __sites__: JSON.stringify(env.sites), + }), + ], }; }; diff --git a/config/webpack.config.dev.js b/config/webpack.config.dev.js index db53127..d2ae0d2 100644 --- a/config/webpack.config.dev.js +++ b/config/webpack.config.dev.js @@ -15,51 +15,52 @@ let webpackConfig = merge(commonConfig(true), { output: { path: helpers.root("dist"), publicPath: "/", - filename: "js/[name].bundle.js" + filename: "js/[name].bundle.js", }, optimization: { concatenateModules: true, splitChunks: { - chunks: "initial" - } + chunks: "initial", + }, }, plugins: [ new webpack.EnvironmentPlugin(environment), new FriendlyErrorsPlugin(), new MiniCSSExtractPlugin({ - filename: "css/[name].css" - }) + filename: "css/[name].css", + }), ], devServer: { compress: true, historyApiFallback: true, host: "0.0.0.0", + disableHostCheck: true, hot: true, overlay: true, stats: { - normal: true + normal: true, }, proxy: { "/api": { target: "http://localhost:30030", - changeOrigin: true + changeOrigin: true, }, "/socket.io": { target: "ws://localhost:30030", changeOrigin: false, - ws: true - } + ws: true, + }, }, - writeToDisk: false - } + writeToDisk: false, + }, }); webpackConfig = merge(webpackConfig, { plugins: [ new HtmlWebpackPlugin({ - template: "frontend/templates/Index.html" - }) - ] + template: "frontend/templates/Index.html", + }), + ], }); module.exports = webpackConfig; diff --git a/db/seedSingleDay.js b/db/seedSingleDay.js new file mode 100644 index 0000000..952a258 --- /dev/null +++ b/db/seedSingleDay.js @@ -0,0 +1,78 @@ + + +const session = require("express-session"); +const mongoose = require("mongoose"); +const MongoStore = require("connect-mongo")(session); +mongoose.promise = global.Promise; +mongoose + .connect("mongodb://localhost/vinlottis", { + useCreateIndex: true, + useNewUrlParser: true, + useUnifiedTopology: true, + serverSelectionTimeoutMS: 10000 // initial connection timeout + }) + .then(_ => console.log("Mongodb connection established!")) + .catch(err => { + console.log(err); + console.error("ERROR! Mongodb required to run."); + process.exit(1); + }); +mongoose.set("debug", false); + +const path = require("path") +const prelotteryWineRepository = require(path.join(__dirname, "../api/prelotteryWine")); +const attendeeRepository = require(path.join(__dirname, "../api/attendee")); + +async function add() { + const wines = [ + { + vivinoLink: 'https://www.vinmonopolet.no/Land/Frankrike/Devevey-Bourgogne-Hautes-C%C3%B4tes-de-Beaune-Rouge-2018/p/12351301', + name: 'Devevey Bourgogne Hautes-Côtes de Beaune Rouge 2018', + rating: 3, + id: '12351301', + year: 2018, + image: "https://bilder.vinmonopolet.no/cache/300x300-0/12351301-1.jpg", + price: '370', + country: "Frankrike" + }, + { + vivinoLink: 'https://www.vinmonopolet.no/Land/Frankrike/Devevey-Rully-La-Chaume-Rouge-2018/p/12351101', + name: 'Devevey Rully La Chaume Rouge 2018', + rating: 4, + id: '12351101', + year: 2018, + image: 'https://bilder.vinmonopolet.no/cache/300x300-0/12351101-1.jpg', + price: '372', + country: 'Frankrike' + } + ] + + const attendees = [ + { + name: "Kasper Rynning-Tønnesen", + red: 0, + blue: 10, + green: 0, + yellow: 0, + phoneNumber: 97777777, + winner: false + }, + { + name: "Kevin Midbøe", + red: 3, + blue: 3, + green: 3, + yellow: 3, + phoneNumber: 95012321, + winner: false + } + ] + + await prelotteryWineRepository.addWines(wines) + await Promise.all(attendees.map(attendee => attendeeRepository.addAttendee(attendee))) + + console.log("Added some wines, and 2 attendees to database.") + process.exit(1) +} + +add() \ No newline at end of file diff --git a/frontend/Vinlottis.vue b/frontend/Vinlottis.vue index 0f468fb..ec08492 100644 --- a/frontend/Vinlottis.vue +++ b/frontend/Vinlottis.vue @@ -25,33 +25,33 @@ export default { routes: [ { name: "Virtuelt lotteri", - route: "/lottery" + route: "/lottery", }, { name: "Dagens viner", - route: "/dagens/" + route: "/dagens/", }, { name: "Highscore", - route: "/highscore" + route: "/highscore", }, { name: "Historie", - route: "/history/" + route: "/history/", }, { name: "Foreslå vin", - route: "/request" + route: "/request", }, { name: "Foreslåtte viner", - route: "/requested-wines" + route: "/requested-wines", }, { name: "Login", - route: "/login" - } - ] + route: "/login", + }, + ], }; }, mounted() { @@ -73,7 +73,7 @@ export default { closeToast: function() { this.showToast = false; }, - } + }, }; diff --git a/frontend/components/AccessCodePage.vue b/frontend/components/AccessCodePage.vue new file mode 100644 index 0000000..83af4b2 --- /dev/null +++ b/frontend/components/AccessCodePage.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/frontend/components/AllRequestedWines.vue b/frontend/components/AllRequestedWines.vue index 8dbabd7..14bd84a 100644 --- a/frontend/components/AllRequestedWines.vue +++ b/frontend/components/AllRequestedWines.vue @@ -1,9 +1,17 @@