node-mongo-demo
node.js and mongodb demo
git clone https://9o.is/git/node-mongo-demo.git
commit eaf9e286a8ec7cc51258b8844ec052b04bce787d parent c4302aa68c759963a6cdff52682c2d42447c9201 Author: Jul <jul@9o.is> Date: Tue, 28 Jan 2025 04:47:22 -0500 refactor Diffstat:
| M | backend/package-lock.json | | | 50 | ++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | backend/package.json | | | 1 | + |
| M | backend/scripts/populate_database.js | | | 12 | ++++++------ |
| D | backend/src/api/lucky7-bet-events.js | | | 83 | ------------------------------------------------------------------------------- |
| D | backend/src/api/lucky7-bets-create.js | | | 65 | ----------------------------------------------------------------- |
| A | backend/src/api/lucky7-bets.js | | | 57 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | backend/src/api/lucky7-leaderboard.js | | | 136 | +++++++++---------------------------------------------------------------------- |
| D | backend/src/api/lucky7-sessions-create.js | | | 14 | -------------- |
| A | backend/src/api/lucky7-sessions.js | | | 17 | +++++++++++++++++ |
| M | backend/src/api/lucky7.js | | | 21 | +++++++++++---------- |
| D | backend/src/core/lucky7.js | | | 17 | ----------------- |
| D | backend/src/core/lucky7.test.js | | | 22 | ---------------------- |
| A | backend/src/events/lucky7-bets.js | | | 67 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | backend/src/models/lucky7-bet.js | | | 45 | +++++++++++++++++++++++++++++++++++++-------- |
| M | backend/src/models/lucky7-session.js | | | 47 | ++++++++++++++++++++++++++++++++++++++++++----- |
| A | backend/src/queries/lucky7-leaderboard.js | | | 105 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| D | backend/src/services/lucky7-session.js | | | 58 | ---------------------------------------------------------- |
| A | backend/src/utils/timer.js | | | 19 | +++++++++++++++++++ |
| M | frontend/src/api/index.js | | | 2 | +- |
| M | frontend/src/components/Lucky7.js | | | 4 | ++-- |
20 files changed, 429 insertions(+), 413 deletions(-)
diff --git a/backend/package-lock.json b/backend/package-lock.json @@ -15,6 +15,7 @@ "cors": "^2.8.5", "dotenv": "^16.3.2", "express": "^4.18.2", + "joi": "^17.13.3", "jsonwebtoken": "^9.0.2", "mongoose": "^8.1.0" }, @@ -610,6 +611,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -991,6 +1007,27 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3423,6 +3460,19 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/backend/package.json b/backend/package.json @@ -18,6 +18,7 @@ "cors": "^2.8.5", "dotenv": "^16.3.2", "express": "^4.18.2", + "joi": "^17.13.3", "jsonwebtoken": "^9.0.2", "mongoose": "^8.1.0" }, diff --git a/backend/scripts/populate_database.js b/backend/scripts/populate_database.js @@ -1,10 +1,10 @@ -import mongoose from "mongoose"; -import bcrypt from "bcryptjs"; -import User from "../src/models/user.js"; -import Lucky7Session from "../src/models/lucky7-session.js"; -import Lucky7Bet from "../src/models/lucky7-bet.js"; +import mongoose from 'mongoose'; +import bcrypt from 'bcryptjs'; +import User from '../src/models/user.js'; +import Lucky7Session from '../src/models/lucky7-session.js'; +import Lucky7Bet from '../src/models/lucky7-bet.js'; -const startDate = new Date("2024-01-01") +const startDate = new Date('2024-01-01') const roll = () => Math.round(Math.random()) ? { win: true, lucky: true, roll: [3,4] } diff --git a/backend/src/api/lucky7-bet-events.js b/backend/src/api/lucky7-bet-events.js @@ -1,83 +0,0 @@ -import Lucky7Bet from "../models/lucky7-bet.js"; -import EventStream from "../utils/event-stream.js"; -import { dateDifferenceInSeconds } from "../services/lucky7-session.js"; - -const users = new Map(); -const bets = new Map(); - -Lucky7Bet.watch().on('change', data => { - const { - operationType, - documentKey: { _id } - } = data; - const id = _id.toString(); - - if (operationType === 'insert') { - const bet = data.fullDocument; - const { userId, rollAt, lucky, roll, win } = bet; - const user = users.get(userId.toString()); - const timeout = 1000 * dateDifferenceInSeconds(Date.now(), rollAt); - - user.nextBetTimeoutId = setTimeout(async () => { - user.stream.writeJson({ - operationType: 'update', - bet: { - id, - roll, - win, - state: 'finished', - }, - }); - - bets.delete(id); - delete user['nextBetTimeoutId']; - }, timeout) - - if (user) { - bets.set(id, bet); - user.stream.writeJson({ - operationType, - bet: { - id, - rollAt, - lucky, - state: 'pending', - }, - }) - } - - } else if (operationType === 'update') { - const bet = bets.get(id); - const userId = bet?.userId.toString(); - const user = userId ? users.get(userId) : undefined; - - if (bet && user) { - const { lucky, win } = data.updateDescription.updatedFields; - user.stream.writeJson({ - operationType, - bet: { - id, - lucky, - state: 'pending', - }, - }); - } - } -}); - -const lucky7BetEvents = async (req, res) => { - const { userId } = req; - const stream = new EventStream(req, res); - - users.set(userId, { stream }); - - stream.onClose(() => { - const user = users.get(userId) - if (user?.nextGameTimeout) { - clearTimeout(user.nextGameTimeout); - } - users.delete(userId); - }); -}; - -export default lucky7BetEvents; diff --git a/backend/src/api/lucky7-bets-create.js b/backend/src/api/lucky7-bets-create.js @@ -1,65 +0,0 @@ -import { randomInt } from "crypto"; -import lucky7Session, { nextGameTime, findLucky7SessionByUserId } from "../services/lucky7-session.js"; -import Lucky7Bet from "../models/lucky7-bet.js"; - -const roll = () => [randomInt(1,7), randomInt(1,7)]; - -const outcome = (roll, lucky) => { - const seven = 7 === roll[0] + roll[1]; - return (lucky && seven) || (!lucky && !seven); -}; - -const lucky7BetsCreate = async (req, res) => { - const { userId } = req; - const { lucky } = req.body; - - if (typeof lucky !== 'boolean') { - return res.status(400).json({ message: 'invalid input' }) - } - - try { - const session = await findLucky7SessionByUserId(userId); - - if (!session) { - return res.status(400).json({ code: "SESSION_EXPIRED", message: "Session has expired" }) - } - - if (session.nextGameInSeconds <= 5) { - return res.status(400).json({ code: "TIMER", message: '5 seconds or less remaining for next dice roll' }) - } - - const rollAt = session.nextGameTime; - - if (!rollAt) { - return res.status(400).json({ code: "SESSION_EXPIRING", message: "No more games for current session" }); - } - - const existingBet = await Lucky7Bet.findOne({ - userId, - rollAt, - }); - - const bet = existingBet || - new Lucky7Bet({ userId, rollAt }); - - if (!bet.roll.length) { - bet.roll = roll(); - } - - bet.lucky = lucky; - bet.win = outcome(bet.roll, lucky); - await bet.save() - - res.status(200).json({ - id: bet._id, - lucky: bet.lucky, - rollAt: bet.rollAt, - state: 'pending', - }); - } catch (error) { - console.error(error); - res.status(500).json({ message: "Something went wrong" }); - } -}; - -export default lucky7BetsCreate; diff --git a/backend/src/api/lucky7-bets.js b/backend/src/api/lucky7-bets.js @@ -0,0 +1,57 @@ +import Joi from 'joi'; +import * as betEvents from '../events/lucky7-bets.js'; +import Lucky7Session from '../models/lucky7-session.js'; +import Lucky7Bet from '../models/lucky7-bet.js'; +import EventStream from '../utils/event-stream.js'; + +const schema = Joi.object({ + lucky: Joi.boolean().required(), +}); + +const errors = { + 'SESSION_EXPIRED': { code: 'SESSION_EXPIRED', error: 'Session has expired' }, + 'SESSION_TIMER': { code: 'SESSION_TIMER', error: 'Less than 5 seconds remaining' }, + 'SESSION_EXPIRING': { code: 'SESSION_EXPIRING', error: 'Session has no more games' }, +}; + +Lucky7Bet.watch().on('change', betEvents.onChange); + +export const getEvents = async (req, res) => { + const { userId } = req; + const stream = new EventStream(req, res); + betEvents.subscribe(userId, stream); +}; + +export const create = async (req, res) => { + const { userId } = req; + const { + value: { lucky }, + error + } = schema.validate(req.body); + + if (error) { + return res.status(400).json({ error }) + } + + try { + const session = await Lucky7Session.findByUserId(userId); + const rollAt = session?.nextIntervalAt(); + + if (!session) return res.status(400).json(errors['SESSION_EXPIRED']); + if (!session.isPlayable()) return res.status(400).json(errors['SESSION_TIMER']); + if (!session.nextIntervalAt()) return res.status(400).json(errors['SESSION_EXPIRING']); + + const bet = await Lucky7Bet.findOne({ userId, rollAt }) || new Lucky7Bet({ userId, rollAt }); + bet.lucky = lucky; + + if (!bet.played()) { + bet.play(); + } + + await bet.save(); + res.status(200).json(bet.toRedactedObject()); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Something went wrong' }); + } +}; diff --git a/backend/src/api/lucky7-leaderboard.js b/backend/src/api/lucky7-leaderboard.js @@ -1,124 +1,16 @@ -import Lucky7Bet from "../models/lucky7-bet.js"; +import Lucky7Bet from '../models/lucky7-bet.js'; +import * as queries from '../queries/lucky7-leaderboard.js'; -const lucky7Leaderboard = async (req, res) => { - try { - const streaks = await getTopWinStreaks(); - res.status(200).json(streaks.map(({ streak, users }) => ({ - streak, - name: users[0].name, - }))); - } catch (error) { - console.error(error); - res.status(500).json({ message: "Something went wrong" }); - } -}; - -function getTopWinStreaks() { - return Lucky7Bet.aggregate([{ - $sort: { - userId: 1, - rollAt: -1, - } - }, - { - $group: { - _id: "$userId", - bets: { - $push: "$$ROOT" - } - } - }, - { - $project: { - result: { - $reduce: { - input: "$bets", - initialValue: { - streaks: [], - currentStreak: 0 - }, - in: { - streaks: { - $cond: [{ - $eq: ["$$this.win", true] - }, - "$$value.streaks", - { - $cond: [{ - $gt: [ - "$$value.currentStreak", - 0 - ] - }, - { - $concatArrays: [ - "$$value.streaks", - [ - "$$value.currentStreak" - ] - ] - }, - "$$value.streaks" - ] - } - ] - }, - currentStreak: { - $cond: [{ - $eq: ["$$this.win", true] - }, - { - $add: [ - "$$value.currentStreak", - 1 - ] - }, - 0 - ] - } - } - } - } - } - }, - { - $project: { - streak: { - $cond: [{ - $gt: ["$result.currentStreak", 0] - }, - { - $concatArrays: [ - "$result.streaks", - ["$result.currentStreak"] - ] - }, - "$result.streaks" - ] - } - } - }, - { - $unwind: "$streak" - }, - { - $sort: { - streak: -1 - } - }, - { - $limit: 10 - }, - { - $lookup: { - from: "users", - localField: "_id", - foreignField: "_id", - as: "users" - } - }, - ]); -} - -export default lucky7Leaderboard; +export const getStreaks = async (req, res) => { + try { + const streaks = await Lucky7Bet.aggregate(queries.TOP_10_STREAKS); + res.status(200).json(streaks.map(({ streak, users }) => ({ + streak, + name: users[0].name, + }))); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Something went wrong' }); + } +}; diff --git a/backend/src/api/lucky7-sessions-create.js b/backend/src/api/lucky7-sessions-create.js @@ -1,14 +0,0 @@ -import lucky7Session from "../services/lucky7-session.js"; - -const lucky7SessionsCreate = async (req, res) => { - const { userId } = req; - - try { - res.status(200).json(await lucky7Session(userId)); - } catch (error) { - console.error(error); - res.status(500).json({ message: "Something went wrong" }); - } -}; - -export default lucky7SessionsCreate; diff --git a/backend/src/api/lucky7-sessions.js b/backend/src/api/lucky7-sessions.js @@ -0,0 +1,17 @@ +import Lucky7Session from '../models/lucky7-session.js'; + +export const create = async (req, res) => { + const { userId } = req; + + try { + const { id, createdAt } = await Lucky7Session.findByUserIdOrCreate(userId); + + res.status(201).json({ + id, + createdAt, + }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Something went wrong' }); + } +}; diff --git a/backend/src/api/lucky7.js b/backend/src/api/lucky7.js @@ -1,15 +1,16 @@ -import express from "express"; -import lucky7BetsCreate from "./lucky7-bets-create.js"; -import lucky7Leaderboard from "./lucky7-leaderboard.js"; -import lucky7BetEvents from "./lucky7-bet-events.js"; -import lucky7SessionsCreate from "./lucky7-sessions-create.js"; -import auth from "../utils/auth.js"; +import express from 'express'; +import auth from '../utils/auth.js'; +import * as lucky7Sessions from './lucky7-sessions.js'; +import * as lucky7Bets from './lucky7-bets.js'; +import * as lucky7Leaderboard from './lucky7-leaderboard.js'; const router = express.Router(); -router.post("/sessions", auth, lucky7SessionsCreate); -router.post("/bets", auth, lucky7BetsCreate); -router.get("/betEvents", auth, lucky7BetEvents); -router.get("/leaderboard", lucky7Leaderboard); +router.post('/sessions', auth, lucky7Sessions.create); + +router.get('/bets/events', auth, lucky7Bets.getEvents); +router.post('/bets', auth, lucky7Bets.create); + +router.get('/leaderboard/streaks', lucky7Leaderboard.getStreaks); export default router; diff --git a/backend/src/core/lucky7.js b/backend/src/core/lucky7.js @@ -1,17 +0,0 @@ -const startSession = (userId) => { - return { - state: () => { - return { userId } - }, - }; -} - -const createInstance = () => { - return { - startSession, - }; -}; - -export default { - createInstance, -}; diff --git a/backend/src/core/lucky7.test.js b/backend/src/core/lucky7.test.js @@ -1,22 +0,0 @@ -import lucky7 from './lucky7'; - -describe('lucky7', () => { - test('initial state', () => { - const session = createSession('A'); - expect(session.state()).toHaveProperty('userId', 'A') - }); -}); - -function createSession(userId, options) { - const { rolls, onGameFinished } = Object.assign({}, options, { - rolls: [], - onGameFinished: (game) => Promise.resolve(game), - }); - - const instance = lucky7.createInstance({ - diceRoll: () => rolls.shift(), - onGameFinished: onGameFinished, - }); - - return instance.startSession(userId); -} diff --git a/backend/src/events/lucky7-bets.js b/backend/src/events/lucky7-bets.js @@ -0,0 +1,67 @@ +import Lucky7Bet from '../models/lucky7-bet.js'; + +const users = new Map(); +const bets = new Map(); + +export const subscribe = (userId, stream) => { + users.set(userId, { stream }); + + stream.onClose(() => { + const user = users.get(userId) + if (user?.nextBetTimeoutId) { + clearTimeout(user.nextBetTimeoutId); + } + users.delete(userId); + }); +}; + +const handleInsert = data => { + const bet = new Lucky7Bet(data.fullDocument); + const user = users.get(bet.userId.toString()); + + if (user) { + const id = bet.id.toString(); + bets.set(id, bet); + + user.stream.writeJson({ + operationType: 'insert', + bet: bet.toRedactedObject(), + }); + + user.nextBetTimeoutId = setTimeout(() => { + user.stream.writeJson({ + operationType: 'update', + bet: bet.toRedactedObject(), + }); + + bets.delete(id); + delete user['nextBetTimeoutId']; + }, bet.rollAt - Date.now()); + } +}; + +const handleUpdate = data => { + const id = data.documentKey._id.toString(); + const bet = bets.get(id); + const userId = bet?.userId.toString(); + const user = userId ? users.get(userId) : undefined; + + if (bet && user) { + const { lucky, win } = data.updateDescription.updatedFields; + user.stream.writeJson({ + operationType, + bet: { + id, + lucky, + state: 'pending', + }, + }); + } +}; + +export const onChange = data => { + const { operationType } = data; + + if (operationType === 'insert') handleInsert(data); + if (operationType === 'update') handleUpdate(data); +}; diff --git a/backend/src/models/lucky7-bet.js b/backend/src/models/lucky7-bet.js @@ -1,16 +1,45 @@ -import mongoose from "mongoose"; +import mongoose from 'mongoose'; +import { randomInt } from 'crypto'; const lucky7BetSchema = mongoose.Schema({ - userId: { type: 'ObjectId', ref: 'User', required: true }, - rollAt: { type: Date, required: true }, - lucky: { type: Boolean, required: true }, - roll: { type: [Number], required: true }, - win: { type: Boolean, required: true }, + userId: { type: 'ObjectId', ref: 'User', required: true }, + rollAt: { type: Date, required: true }, + lucky: { type: Boolean, required: true }, + roll: { type: [Number], required: true }, + win: { type: Boolean, required: true }, }, { - optimisticConcurrency: true, + optimisticConcurrency: true, + virtuals: { + state: { + get() { + return Date.now() < this.rollAt ? 'pending' : 'finished'; + }, + }, + }, + methods: { + played() { + return Boolean(this.roll?.length === 2); + }, + play() { + this.roll = [randomInt(1,7), randomInt(1,7)]; + const seven = 7 === this.roll[0] + this.roll[1]; + this.win = (this.lucky && seven) || (!this.lucky && !seven); + }, + toRedactedObject() { + const { id, rollAt, lucky, roll, win, state } = this; + return { + id, + state, + rollAt, + lucky, + roll: state === 'finished' ? roll : undefined, + win: state === 'finished' ? win : undefined, + }; + }, + }, }); lucky7BetSchema.index({ userId: 1, rollAt: -1 }, { unique: true }); -export default mongoose.model("Lucky7Bet", lucky7BetSchema); +export default mongoose.model('Lucky7Bet', lucky7BetSchema); diff --git a/backend/src/models/lucky7-session.js b/backend/src/models/lucky7-session.js @@ -1,10 +1,47 @@ -import mongoose from "mongoose"; +import mongoose from 'mongoose'; +import timer from '../utils/timer.js'; -export const EXPIRES_IN_SECONDS = 86400; // 1 day +const GAME_INTERVAL_IN_SECONDS = 15; +const GAME_UNPLAYABLE_IN_SECONDS = 5; +const EXPIRES_IN_SECONDS = 86400; // 1 day const lucky7SessionSchema = mongoose.Schema({ - userId: { type: 'ObjectId', ref: 'User', required: true, unique: true }, - createdAt: { type: Date, expires: EXPIRES_IN_SECONDS, default: Date.now }, + userId: { type: 'ObjectId', ref: 'User', required: true, unique: true }, + createdAt: { type: Date, expires: EXPIRES_IN_SECONDS, default: Date.now }, +}, { + virtuals: { + expiresAt: { + get() { + const millis = EXPIRES_IN_SECONDS * 1000; + return new Date(this.createdAt.getTime() + millis); + }, + }, + gameIntervalInSeconds: { + get() { + return GAME_INTERVAL_IN_SECONDS; + }, + }, + }, + methods: { + nextIntervalAt() { + return timer(this).nextIntervalAt; + }, + isPlayable() { + const { nextIntervalAt, nextIntervalInMillis } = timer(this); + return nextIntervalInMillis >= GAME_UNPLAYABLE_IN_SECONDS * 1000; + }, + }, + statics: { + findByUserId(userId) { + return this.findOne({ userId }); + }, + findByUserIdOrCreate(userId) { + return this.findOneAndUpdate({ userId }, {}, { + upsert: true, + new: true, + }); + }, + }, }); -export default mongoose.model("Lucky7Session", lucky7SessionSchema); +export default mongoose.model('Lucky7Session', lucky7SessionSchema); diff --git a/backend/src/queries/lucky7-leaderboard.js b/backend/src/queries/lucky7-leaderboard.js @@ -0,0 +1,105 @@ +export const TOP_10_STREAKS = [{ + $sort: { + userId: 1, + rollAt: -1, + } +}, + { + $group: { + _id: '$userId', + bets: { + $push: '$$ROOT' + } + } + }, + { + $project: { + result: { + $reduce: { + input: '$bets', + initialValue: { + streaks: [], + currentStreak: 0 + }, + in: { + streaks: { + $cond: [{ + $eq: ['$$this.win', true] + }, + '$$value.streaks', + { + $cond: [{ + $gt: [ + '$$value.currentStreak', + 0 + ] + }, + { + $concatArrays: [ + '$$value.streaks', + [ + '$$value.currentStreak' + ] + ] + }, + '$$value.streaks' + ] + } + ] + }, + currentStreak: { + $cond: [{ + $eq: ['$$this.win', true] + }, + { + $add: [ + '$$value.currentStreak', + 1 + ] + }, + 0 + ] + } + } + } + } + } + }, + { + $project: { + streak: { + $cond: [{ + $gt: ['$result.currentStreak', 0] + }, + { + $concatArrays: [ + '$result.streaks', + ['$result.currentStreak'] + ] + }, + '$result.streaks' + ] + } + } + }, + { + $unwind: '$streak' + }, + { + $sort: { + streak: -1 + } + }, + { + $limit: 10 + }, + { + $lookup: { + from: 'users', + localField: '_id', + foreignField: '_id', + as: 'users' + } + }, +]; + diff --git a/backend/src/services/lucky7-session.js b/backend/src/services/lucky7-session.js @@ -1,58 +0,0 @@ -import Lucky7Session, { EXPIRES_IN_SECONDS } from "../models/lucky7-session.js"; - -export const dateDifferenceInSeconds = (date1, date2) => { - return Math.floor((date2 - date1) / 1000); -}; - -export const nextGameTime = (startDateTime) => { - const currentSeconds = Math.ceil(new Date() / 1000); - const startSeconds = Math.floor(startDateTime.getTime() / 1000); - const difference = currentSeconds - startSeconds; - - const nextChunk = Math.ceil(difference / 15) * 15; - const result = new Date((startSeconds + nextChunk) * 1000); - const expiration = new Date(startDateTime.getTime() + (EXPIRES_IN_SECONDS * 1000)); - - if (result > expiration) return undefined; - return result; -}; - -const lucky7Session = async (userId) => { - const session = await Lucky7Session.findOneAndUpdate({ - userId, - }, {}, { - upsert: true, - new: true, - }); - - const { _id, createdAt } = session.toObject(); - const next = nextGameTime(createdAt); - - return { - id: _id, - createdAt, - nextGameTime: next, - nextGameInSeconds: next ? dateDifferenceInSeconds(new Date(), next) : undefined, - }; -}; - -export const findLucky7SessionByUserId = async userId => { - const session = await Lucky7Session.findOne({ - userId, - }); - - if (!session) return undefined; - - const { _id, createdAt } = session.toObject(); - const next = nextGameTime(createdAt); - - return { - id: _id, - createdAt, - nextGameTime: next, - nextGameInSeconds: next ? dateDifferenceInSeconds(new Date(), next) : undefined, - }; - -}; - -export default lucky7Session; diff --git a/backend/src/utils/timer.js b/backend/src/utils/timer.js @@ -0,0 +1,19 @@ +const nextInterval = (start, interval) => { + const currentSeconds = Math.ceil(new Date() / 1000); + const startSeconds = Math.floor(new Date(start).getTime() / 1000); + const next = Math.ceil((currentSeconds - startSeconds) / interval) * interval; + return new Date((startSeconds + next) * 1000); +}; + +const timer = session => { + const { createdAt, gameIntervalInSeconds } = session + const nextIntervalAt = nextInterval(createdAt, gameIntervalInSeconds); + const expiring = nextIntervalAt > session.expiresAt; + + return expiring ? {} : { + nextIntervalAt, + nextIntervalInMillis: nextIntervalAt - Date.now(), + }; +}; + +export default timer; diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js @@ -18,7 +18,7 @@ export const changePassword = (formData) => export const lucky7Leaderboard = () => API.post("/api/lucky7/leaderboard") export const lucky7Session = () => API.post("/api/lucky7/sessions") export const lucky7Bet = lucky => API.post("/api/lucky7/bets", { lucky }); -export const lucky7BetEvents = () => API.get(`/api/lucky7/betEvents`, { +export const lucky7BetEvents = () => API.get(`/api/lucky7/bets/events`, { headers: { 'Accept': 'text/event-stream', }, diff --git a/frontend/src/components/Lucky7.js b/frontend/src/components/Lucky7.js @@ -108,8 +108,8 @@ const Lucky7 = user => { await api.lucky7Bet(lucky); setError(undefined); } catch (error) { - if (error.status === 400 && error.response.data.code === "TIMER") { - setError(error.response.data.message); + if (error.status === 400 && error.response.data.code === "SESSION_TIMER") { + setError(error.response.data.error); } else { console.error(error) }