diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c35aab4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +package-lock.json +.env +.DS_Store +*.log +dist/ +build/ +.vscode/ \ No newline at end of file diff --git a/chat-app/README.md b/chat-app/README.md index d26b1a13..23865aae 100644 --- a/chat-app/README.md +++ b/chat-app/README.md @@ -1,34 +1,79 @@ -# Write and Deploy Chat Application Frontend and Backend +## Changes Made -### Link to the coursework +### Backend Implementation (`backend/`) -https://sdc.codeyourfuture.io/decomposition/sprints/2/prep/ +#### `server.js` (New) -You must complete and deploy a chat application. You have two weeks to complete this. +- Set up Express server with CORS middleware +- Implemented **POST `/messages`** endpoint with input validation + - Validates sender name and message text + - Creates message objects with id, sender, text, likes, and dislikes + - Returns 201 status on success, 400 on validation error + - Triggers long polling callbacks to notify waiting clients +- Implemented **GET `/messages`** endpoint with long polling + - Accepts `?since=` query parameter for incremental message loading + - Returns only messages with id > sinceId + - Holds client requests until new messages arrive (long polling) + - Handles edge case where since=0 correctly +- Implemented **POST `/messages/:id/like`** endpoint + - Increments like count for specified message + - Notifies all waiting clients via long polling + - Returns 200 on success, 404 if message not found -It must support at least the following requirements: -* As a user, I can send add a message to the chat. -* As a user, when I open the chat I see the messages that have been sent by any user. -* As a user, when someone sends a message, it gets added to what I see. +#### `package.json` (New) -It must also support at least one additional feature. +- Configured as ES module project (`"type": "module"`) +- Added Express 5.2.1 dependency +- Added CORS 2.8.6 dependency -### Why are we doing this? +### Frontend Implementation (`frontend/`) -Learning about deploying multiple pieces of software that interact. +#### `index.html` (New) -Designing and implementing working software that users can use. +- Semantic HTML structure with proper meta tags +- Chat form with sender name and message inputs +- Message container div for displaying chat history +- Deferred JavaScript execution for proper DOM loading -Exploring and understanding different ways of sending information between a client and server. +#### `script.js` (New) -### Maximum time in hours +- **`getAllMessages()`** async function + - Fetches messages from backend with `?since=` parameter + - Implements incremental message loading via `lastIdSeen` tracking + - Updates existing message likes without re-rendering + - Creates DOM elements for new messages with text, like count, and like button + - Uses long polling with `setTimeout(getAllMessages, 0)` for real-time updates + - Includes error handling with automatic retry on fetch failure -16 +- **Form submission handler** + - Validates sender name and message text + - Sends POST request with JSON payload + - Clears input fields after successful submission + - Includes try-catch error handling -### How to submit +- **Like button functionality** + - Sends POST request to `/messages/:id/like` endpoint + - Extracts current like count from DOM + - Provides immediate UI feedback (optimistic update) + - Updates display instantly without waiting for server response -* Fork the Module-Decomposition repository -* Develop and deploy your chat app -* Create a pull request back into the original Module-Decomposition repo, including: - * A link to the deployed frontend on the CYF hosting environment - * A link to the deployed backend on the CYF hosting environment +#### `styles.css` (New) + +- Styled message containers with border, padding, and rounded corners +- Light gray background (#f9f9f9) for message boxes +- Styled like buttons with blue background (#007bff) and white text +- Proper spacing and cursor pointer for better UX + +## Testing Instructions + +### Setup + +```bash +# Install backend dependencies +cd backend +npm install + +# Start backend server +node server.js + +``` diff --git a/chat-app/backend/package.json b/chat-app/backend/package.json new file mode 100644 index 00000000..a090286d --- /dev/null +++ b/chat-app/backend/package.json @@ -0,0 +1,9 @@ +{ + "type": "module", + "dependencies": { + "cors": "^2.8.6", + "express": "^5.2.1", + "http": "^0.0.1-security", + "websocket": "^1.0.35" + } +} diff --git a/chat-app/backend/server.js b/chat-app/backend/server.js new file mode 100644 index 00000000..fe8488a8 --- /dev/null +++ b/chat-app/backend/server.js @@ -0,0 +1,216 @@ +import express from "express"; +import cors from "cors"; +import { server as WebSocketServer } from "websocket"; +import http from "http"; + +const app = express(); +const server = http.createServer(app); +const webSocketServer = new WebSocketServer({ httpServer: server }); + +const port = process.env.PORT || 3000; + +const messages = []; +const callBacksForNewMessages = []; +let activeConnections = []; + +// Enable CORS for all routes +app.use(cors()); + +// Middleware to parse JSON request body +app.use(express.json()); + +webSocketServer.on("request", (request) => { + const connection = request.accept(null, request.origin); + + activeConnections.push(connection); + + console.log("A user connected"); + + const sinceId = Number(request.resourceURL.query.since); + + if (!isNaN(sinceId)) { + const messagesSineId = messages.filter((message) => message.id > sinceId); + + messagesSineId.forEach((message) => { + const messageSinceIdObject = { + command: "new-message", + payload: message, + }; + connection.sendUTF(JSON.stringify(messageSinceIdObject)); + }); + } + + connection.on("close", () => { + activeConnections = activeConnections.filter( + (connectionToRemove) => connection !== connectionToRemove, + ); + }); +}); + +function isValidMessage(req, res) { + // Check if request body exists at all + if (!req.body) { + res.status(400).send("No body provided"); + return false; + } + + const { text, sender } = req.body; + + // Check if the inputs are strings + if (typeof text !== "string" || typeof sender !== "string") { + res.status(400).send("Inputs must be strings"); + return false; + } + + // Check if the inputs are not a falsy value + if (!text.trim() || !sender.trim()) { + res.status(400).send("Please provide both text and a sender name."); + return false; + } + + return { + text: text.trim(), + sender: sender.trim(), + }; +} + +app.post("/messages", (req, res) => { + const requestBody = isValidMessage(req, res); + + if (!requestBody) { + return; + } + + // Create the message object + const newMessage = { + id: messages.length, + sender: requestBody.sender, + text: requestBody.text, + likes: 0, + dislikes: 0, + }; + + // Add the new message to the messages array (the storage) + messages.push(newMessage); + + // Websocket Broadcast + activeConnections.forEach((connection) => { + // Turn new message object to a string + const newMessageString = { + command: "new-message", + payload: newMessage, + }; + + // Send the string message to the connection + connection.sendUTF(JSON.stringify(newMessageString)); + }); + + // long polling Broadcast + while (callBacksForNewMessages.length > 0) { + const callBack = callBacksForNewMessages.pop(); + + callBack([newMessage]); + } + + // Finally, respond to the person who actually sent the POST request + res.status(201).send(newMessage); +}); + +app.get("/messages", (req, res) => { + const sinceValue = req.query.since; + + let sinceId; + // We check if the value exists at all. + // If it's "0", this check is true, and we use 0. + if (sinceValue !== undefined) { + sinceId = Number(sinceValue); + } else { + sinceId = -1; + } + + const messagesSinceId = messages.filter((message) => message.id > sinceId); + + if (messagesSinceId.length === 0) { + callBacksForNewMessages.push((value) => res.send(value)); + } else { + res.send(messagesSinceId); + } +}); + +function broadcastCounterUpdate(data) { + activeConnections.forEach((connection) => { + // Turn new message object to a string + const updateMessageString = { + command: "update-counter", + payload: data, + }; + + // Send the string message to the connection + connection.sendUTF(JSON.stringify(updateMessageString)); + }); + + while (callBacksForNewMessages.length > 0) { + const callBack = callBacksForNewMessages.pop(); + + callBack([data]); + } +} + +function findMessageOrError(req, res) { + // Get the id from the URL + const idFromUrl = req.params.id; + + //convert to number + const idAsNumber = Number(idFromUrl); + + const messageWithIdAsNumber = messages[idAsNumber]; + + if (!messageWithIdAsNumber) { + res.status(404).send("Message not found"); + return null; + } + + return messageWithIdAsNumber; +} +app.post("/messages/:id/like", (req, res) => { + const messageWithIdAsNumber = findMessageOrError(req, res); + + if (!messageWithIdAsNumber) { + return; + } + messageWithIdAsNumber.likes += 1; + + const dataToSendToClient = { + id: messageWithIdAsNumber.id, + likes: messageWithIdAsNumber.likes, + dislikes: messageWithIdAsNumber.dislikes, + }; + + broadcastCounterUpdate(dataToSendToClient); + + res.status(200).send(dataToSendToClient); +}); + +app.post("/messages/:id/dislike", (req, res) => { + const messageWithIdAsNumber = findMessageOrError(req, res); + + if (!messageWithIdAsNumber) { + return; + } + messageWithIdAsNumber.dislikes += 1; + + const dataToSendToClient = { + id: messageWithIdAsNumber.id, + likes: messageWithIdAsNumber.likes, + dislikes: messageWithIdAsNumber.dislikes, + }; + + broadcastCounterUpdate(dataToSendToClient); + + res.status(200).send(dataToSendToClient); +}); + +// Start the server +server.listen(port, () => { + console.log(`Chat app listening on port ${port}`); +}); diff --git a/chat-app/frontend/index-websocket.html b/chat-app/frontend/index-websocket.html new file mode 100644 index 00000000..9812583d --- /dev/null +++ b/chat-app/frontend/index-websocket.html @@ -0,0 +1,23 @@ + + +
+ + + + +