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 @@ + + + + + + + + Chat App + + +

Chat App

+ +
+ + + + + +
+ +
+ + diff --git a/chat-app/frontend/index.html b/chat-app/frontend/index.html new file mode 100644 index 00000000..c9fde210 --- /dev/null +++ b/chat-app/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + + + Chat App + + +

Chat App

+ +
+ + + + + +
+ +
+ + diff --git a/chat-app/frontend/script-websocket.js b/chat-app/frontend/script-websocket.js new file mode 100644 index 00000000..0b67a0d5 --- /dev/null +++ b/chat-app/frontend/script-websocket.js @@ -0,0 +1,141 @@ +const API_URL = "iswanna-chat-app-backend.hosting.codeyourfuture.io"; +const API_BASE_URL = `https://${API_URL}`; + +// const API_URL = "localhost:3000"; +// const API_BASE_URL = `http://${API_URL}`; + +let lastIdSeen = -1; + +const wsUri = `wss://${API_URL}/messages?since=${lastIdSeen}`; +// const wsUri = `ws://${API_URL}/messages?since=${lastIdSeen}` + +const webSocket = new WebSocket(wsUri); + +webSocket.addEventListener("open", (event) => { + webSocket.send("Hello Server!"); +}); + +webSocket.addEventListener("message", (event) => { + const receivedMessage = JSON.parse(event.data); + + const command = receivedMessage.command; + + if (command === "new-message" || command === "update-counter") { + renderMessages([receivedMessage.payload]); + } +}); + +function renderMessages(messages) { + const messageContainer = document.getElementById("all-messages"); + + messages.forEach((message) => { + const elementId = "msg-" + message.id; + + const existingElement = document.getElementById(elementId); + + if (existingElement) { + // find the specific span that hold the likes + const likeSpan = document.getElementById("likes-count-" + message.id); + const dislikeSpan = document.getElementById( + "dislikes-count-" + message.id, + ); + + // update only that span + if (likeSpan) { + likeSpan.textContent = `(${message.likes} Likes) `; + } + + if (dislikeSpan) { + dislikeSpan.textContent = `(${message.dislikes} Dislikes) `; + } + } else { + const newElement = document.createElement("div"); + newElement.id = "msg-" + message.id; + + // layer 1: the text + const textSpan = document.createElement("span"); + textSpan.textContent = `${message.sender}: ${message.text} `; + + //Layer 2: the counter (this is the one we will update later) + const likeSpan = document.createElement("span"); + likeSpan.id = "likes-count-" + message.id; + likeSpan.textContent = `(${message.likes} Likes) `; + + //Layer 2a: the counter (this is the one we will update later) + const disLikeSpan = document.createElement("span"); + disLikeSpan.id = "dislikes-count-" + message.id; + disLikeSpan.textContent = `(${message.dislikes} Dislikes) `; + + // Layer 3: the like button + const likeButton = document.createElement("button"); + likeButton.textContent = "Like"; + + likeButton.addEventListener("click", async () => { + await fetch(`${API_BASE_URL}/messages/${message.id}/like`, { + method: "POST", + }); + }); + + // Layer 3: the dislike button + const disLikeButton = document.createElement("button"); + disLikeButton.textContent = "Dislike"; + + disLikeButton.addEventListener("click", async () => { + await fetch(`${API_BASE_URL}/messages/${message.id}/dislike`, { + method: "POST", + }); + }); + + // put it all together + newElement.appendChild(textSpan); + newElement.appendChild(likeSpan); + newElement.appendChild(likeButton); + newElement.appendChild(disLikeSpan); + newElement.appendChild(disLikeButton); + messageContainer.appendChild(newElement); + + lastIdSeen = message.id; + } + }); +} + +const formElement = document.getElementById("chat-form"); +const senderElement = document.getElementById("chat-sender"); +const messageElement = document.getElementById("chat-message"); + +formElement.addEventListener("submit", async (event) => { + event.preventDefault(); + + // Get the values and trim them + const senderValue = senderElement.value.trim(); + const messageValue = messageElement.value.trim(); + + // The validation + if (senderValue === "" || messageValue === "") { + alert("Please enter both a name and a message!"); + + return; + } + + try { + // Send the data + const response = await fetch(`${API_BASE_URL}/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sender: senderValue, + text: messageValue, + }), + }); + + if (response.ok) { + // clear the sender and message input values + senderElement.value = ""; + messageElement.value = ""; + } + } catch (error) { + console.error("Error sending message:", error); + } +}); diff --git a/chat-app/frontend/script.js b/chat-app/frontend/script.js new file mode 100644 index 00000000..1fcc9fc9 --- /dev/null +++ b/chat-app/frontend/script.js @@ -0,0 +1,145 @@ +const API_BASE_URL = + "https://iswanna-chat-app-backend.hosting.codeyourfuture.io"; +//const API_BASE_URL = "http://localhost:3000"; + +let lastIdSeen = -1; + +async function getAllMessages() { + try { + const response = await fetch( + `${API_BASE_URL}/messages?since=${lastIdSeen}`, + ); + + // check if the response is not ok + if (!response.ok) { + // Stop everything and jump to the catch block + throw new Error(`HTTP Error: ${response.status}`); + } + + // we only get here if the response was ok + const data = await response.json(); + + renderMessages(data); + } catch (error) { + console.error("Error fetching messages:", error); + } finally { + setTimeout(getAllMessages, 0); + } +} + +function renderMessages(messages) { + const messageContainer = document.getElementById("all-messages"); + + messages.forEach((message) => { + const elementId = "msg-" + message.id; + + const existingElement = document.getElementById(elementId); + + if (existingElement) { + // find the specific span that hold the likes + const likeSpan = document.getElementById("likes-count-" + message.id); + const dislikeSpan = document.getElementById( + "dislikes-count-" + message.id, + ); + + // update only that span + if (likeSpan) { + likeSpan.textContent = `(${message.likes} Likes) `; + } + + if (dislikeSpan) { + dislikeSpan.textContent = `(${message.dislikes} Dislikes) `; + } + } else { + const newElement = document.createElement("div"); + newElement.id = "msg-" + message.id; + + // layer 1: the text + const textSpan = document.createElement("span"); + textSpan.textContent = `${message.sender}: ${message.text} `; + + //Layer 2: the counter (this is the one we will update later) + const likeSpan = document.createElement("span"); + likeSpan.id = "likes-count-" + message.id; + likeSpan.textContent = `(${message.likes} Likes) `; + + //Layer 2a: the counter (this is the one we will update later) + const disLikeSpan = document.createElement("span"); + disLikeSpan.id = "dislikes-count-" + message.id; + disLikeSpan.textContent = `(${message.dislikes} Dislikes) `; + + // Layer 3: the like button + const likeButton = document.createElement("button"); + likeButton.textContent = "Like"; + + likeButton.addEventListener("click", async () => { + await fetch(`${API_BASE_URL}/messages/${message.id}/like`, { + method: "POST", + }); + }); + + // Layer 3: the dislike button + const disLikeButton = document.createElement("button"); + disLikeButton.textContent = "Dislike"; + + disLikeButton.addEventListener("click", async () => { + await fetch(`${API_BASE_URL}/messages/${message.id}/dislike`, { + method: "POST", + }); + }); + + // put it all together + newElement.appendChild(textSpan); + newElement.appendChild(likeSpan); + newElement.appendChild(likeButton); + newElement.appendChild(disLikeSpan); + newElement.appendChild(disLikeButton); + messageContainer.appendChild(newElement); + + lastIdSeen = message.id; + } + }); +} + +getAllMessages(); + +const formElement = document.getElementById("chat-form"); +const senderElement = document.getElementById("chat-sender"); +const messageElement = document.getElementById("chat-message"); + +formElement.addEventListener("submit", async (event) => { + event.preventDefault(); + + // Get the values and trim them + const senderValue = senderElement.value.trim(); + const messageValue = messageElement.value.trim(); + + // The validation + if (senderValue === "" || messageValue === "") { + alert("Please enter both a name and a message!"); + + return; + } + + try { + // Send the data + const response = await fetch(`${API_BASE_URL}/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sender: senderValue, + text: messageValue, + }), + }); + + if (response.ok) { + // clear the sender and message input values + senderElement.value = ""; + messageElement.value = ""; + } + } catch (error) { + console.error("Error sending message:", error); + } +}); diff --git a/chat-app/frontend/styles.css b/chat-app/frontend/styles.css new file mode 100644 index 00000000..e9ccb94e --- /dev/null +++ b/chat-app/frontend/styles.css @@ -0,0 +1,20 @@ +#all-messages div { + border: 1px solid #ccc; + padding: 10px; + margin: 10px 0; + border-radius: 8px; + background-color: #f9f9f9; +} + +#all-messages span { + margin-right: 10px; +} + +button { + cursor: pointer; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + padding: 5px 10px; +}