From 1f1cf1aab28a17447499a3614024deeb5b139c49 Mon Sep 17 00:00:00 2001 From: Bruno Sousa Date: Mon, 12 Feb 2024 14:27:33 -0300 Subject: [PATCH] realtime voting system --- package-lock.json | 30 ++++++++++++++++++++++++++++++ package.json | 1 + src/http/routes/vote-on-poll.ts | 19 +++++++++++++++++-- src/http/server.ts | 7 +++++++ src/http/ws/poll-results.ts | 21 +++++++++++++++++++++ src/utils/voting-pub-sub.ts | 26 ++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 src/http/ws/poll-results.ts create mode 100644 src/utils/voting-pub-sub.ts diff --git a/package-lock.json b/package-lock.json index 7c37b4a..d78c8cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@fastify/cookie": "^9.3.1", + "@fastify/websocket": "^8.3.1", "@prisma/client": "^5.9.1", "fastify": "^4.26.0", "ioredis": "^5.3.2", @@ -430,6 +431,15 @@ "fast-deep-equal": "^3.1.3" } }, + "node_modules/@fastify/websocket": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-8.3.1.tgz", + "integrity": "sha512-hsQYHHJme/kvP3ZS4v/WMUznPBVeeQHHwAoMy1LiN6m/HuPfbdXq1MBJ4Nt8qX1YI+eVbog4MnOsU7MTozkwYA==", + "dependencies": { + "fastify-plugin": "^4.0.0", + "ws": "^8.0.0" + } + }, "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", @@ -1296,6 +1306,26 @@ "punycode": "^2.1.0" } }, + "node_modules/ws": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index b3ef82f..d224a5b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@fastify/cookie": "^9.3.1", + "@fastify/websocket": "^8.3.1", "@prisma/client": "^5.9.1", "fastify": "^4.26.0", "ioredis": "^5.3.2", diff --git a/src/http/routes/vote-on-poll.ts b/src/http/routes/vote-on-poll.ts index 90a1346..ca614e4 100644 --- a/src/http/routes/vote-on-poll.ts +++ b/src/http/routes/vote-on-poll.ts @@ -3,6 +3,7 @@ import z from "zod"; import { randomUUID } from "node:crypto"; import { prisma } from "../../lib/prisma"; import { redis } from "../../lib/redis"; +import { voting } from "../../utils/voting-pub-sub"; export async function voteOnPoll(app: FastifyInstance) { app.post( @@ -41,7 +42,16 @@ export async function voteOnPoll(app: FastifyInstance) { }, }); - await redis.zincrby(pollId, -1, userPreviousVoteOnPoll.pollOptionId); + const votes = await redis.zincrby( + pollId, + -1, + userPreviousVoteOnPoll.pollOptionId + ); + + voting.publish(pollId, { + pollOptionId: userPreviousVoteOnPoll.pollOptionId, + votes: Number(votes), + }); } else if (userPreviousVoteOnPoll) { return reply .status(400) @@ -68,7 +78,12 @@ export async function voteOnPoll(app: FastifyInstance) { }, }); - await redis.zincrby(pollId, 1, pollOptionId); + const votes = await redis.zincrby(pollId, 1, pollOptionId); + + voting.publish(pollId, { + pollOptionId, + votes: Number(votes), + }); return reply.status(201); } diff --git a/src/http/server.ts b/src/http/server.ts index d85e805..facaa7f 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -1,8 +1,11 @@ import fastify from "fastify"; import cookie from "@fastify/cookie"; +import websocket from "@fastify/websocket"; + import { createPoll } from "./routes/create-poll"; import { getPoll } from "./routes/get-poll"; import { voteOnPoll } from "./routes/vote-on-poll"; +import { pollResults } from "./ws/poll-results"; const app = fastify(); @@ -12,10 +15,14 @@ app.register(cookie, { parseOptions: {}, }); +app.register(websocket); + app.register(createPoll); app.register(getPoll); app.register(voteOnPoll); +app.register(pollResults); + app.listen({ port: 3333 }).then(() => { console.log("HTTP server running!"); }); diff --git a/src/http/ws/poll-results.ts b/src/http/ws/poll-results.ts new file mode 100644 index 0000000..c64dee7 --- /dev/null +++ b/src/http/ws/poll-results.ts @@ -0,0 +1,21 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { voting } from "../../utils/voting-pub-sub"; + +export async function pollResults(app: FastifyInstance) { + app.get( + "/polls/:pollId/results", + { websocket: true }, + (connection, request) => { + const getPollParams = z.object({ + pollId: z.string().uuid(), + }); + + const { pollId } = getPollParams.parse(request.params); + + voting.subscribe(pollId, (message) => { + connection.socket.send(JSON.stringify(message)); + }); + } + ); +} diff --git a/src/utils/voting-pub-sub.ts b/src/utils/voting-pub-sub.ts new file mode 100644 index 0000000..e06510d --- /dev/null +++ b/src/utils/voting-pub-sub.ts @@ -0,0 +1,26 @@ +type Message = { pollOptionId: string; votes: number }; +type Subscriber = (message: Message) => void; + +class VotingPubSub { + private channels: Record = {}; + + subscribe(pollId: string, subscriber: Subscriber) { + if (!this.channels[pollId]) { + this.channels[pollId] = []; + } + + this.channels[pollId].push(subscriber); + } + + publish(pollId: string, message: Message) { + if (!this.channels[pollId]) { + return; + } + + for (const subscriber of this.channels[pollId]) { + subscriber(message); + } + } +} + +export const voting = new VotingPubSub();