Tristen.

Pollify: Scaling a Learning Project to a Production-Ready Application

June 25, 2024 (5mo ago)

Pollify: Scaling a Learning Project to a Production-Ready Application

Pollify is a full-stack application that began as a learning project in Udacity's React course but quickly evolved into a robust, production-ready platform. The app allows users to create, vote on, and manage polls, offering real-time engagement and leaderboards. With authentication, global state management, and a solid CI/CD pipeline, Pollify is engineered for both scalability and maintainability.

Visit pollify.dev to see the project in action.

System Design Overview

Pollify was built with a clear focus on scalability, performance, and modern best practices. The architecture revolves around React, Node.js, and TypeScript on the front end and back end, with PostgreSQL as the primary data store. The app uses Redux for global state management and AWS for deployment.

landing page

Frontend and State Management

The front end is built with React and TypeScript, utilizing Material UI for a responsive, modern design. Redux handles state management, centralizing user data and poll information across the application. Here’s an overview of the key systems:

Leaderboard: Users are ranked based on poll creation and voting activity. The leaderboard is calculated using a memoized selector in Redux, which sorts users by their activity score. The leaderboard UI is responsive and uses Material UI components to provide a clean, accessible interface.

const selectSortedUsers = createSelector([(state: RootState) => state.users.users], (users) =>
  Object.values(users).sort(
    (a, b) => (b.pollCount ?? 0) + (b.voteCount ?? 0) - ((a.pollCount ?? 0) + (a.voteCount ?? 0)),
  ),
)

leaderboard component

This approach ensures that only users with updated scores trigger re-renders, optimizing performance for larger datasets.

Poll Creation: Users can create polls with two options, which are immediately stored in Redux and persisted to the backend. The form uses controlled inputs and local component state for handling form submissions.

const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
  e.preventDefault()
  dispatch(addNewPoll({ optionOne, optionTwo, userId: user.id }))
    .unwrap()
    .then(() => navigate('/'))
}

view and vote on poll

Poll Pagination

With an increasing number of polls, the frontend needed a way to handle large datasets without overwhelming the user. Pollify implements pagination to limit the number of polls shown on a single page, allowing users to browse through polls efficiently.

Poll pagination is achieved using React's local state for controlling page navigation, and Material UI’s Pagination component for intuitive navigation. The user can also control the number of items displayed per page via a dropdown, providing a flexible and dynamic experience.

const PollPagination: React.FC<PollListProps> = ({ polls }) => {
  const [currentPage, setCurrentPage] = useState(1)
  const [itemsPerPage, setItemsPerPage] = useState(5)

  const handlePageChange = (event: React.ChangeEvent<unknown>, page: number) => {
    setCurrentPage(page)
  }

  const handleItemsPerPageChange = (event: SelectChangeEvent<number>) => {
    setItemsPerPage(event.target.value as number)
    setCurrentPage(1)
  }

  const startIndex = (currentPage - 1) * itemsPerPage
  const endIndex = startIndex + itemsPerPage
  const currentPolls = polls.slice(startIndex, endIndex)

  return (
    <Box>
      <PollList polls={currentPolls} />
      <Pagination
        count={Math.ceil(polls.length / itemsPerPage)}
        page={currentPage}
        onChange={handlePageChange}
      />
    </Box>
  )
}

pagination

By leveraging client-side pagination, Pollify ensures that the user experience remains smooth even when the dataset grows, without requiring roundtrips to the server for every page change. This approach also optimizes performance and keeps the UI responsive.

User Settings and Profile Updates

Pollify provides users with the ability to update their personal information, including their name, avatar, and password, directly from the frontend. The UserSettings component allows users to make these updates, while also displaying their created polls with pagination. Redux is used to manage user state, ensuring that updates are reflected across the app in real-time.

Form validation is performed locally, and upon successful form submission, user data is updated in the global state and persisted to the backend via API calls.

const handleUpdate = async (event: React.FormEvent) => {
  event.preventDefault()
  const passwordError = validateField('password', password)

  if (passwordError) {
    setErrors({ password: passwordError })
    return
  }

  dispatch(updateUser({ id: user?.id, name, password, avatar_url: avatarUrl }))
    .unwrap()
    .then(() => alert('Settings updated successfully!'))
    .catch((error) => alert('Failed to update settings: ' + error.message))
}

The UserSettings component also allows users to delete their account, which triggers an irreversible action confirmed through a prompt. This feature is integrated with the Redux store and ensures that user deletion is cascaded correctly through the application.

const handleDeleteAccount = () => {
  if (
    window.confirm('Are you sure you want to delete your account? This action cannot be undone.')
  ) {
    dispatch(deleteUser(user?.id || ''))
      .unwrap()
      .then(() => {
        alert('Account deleted successfully')
        navigate('/home')
      })
      .catch((error) => alert('Failed to delete account: ' + error.message))
  }
}

Update User Settings

Backend Architecture

The backend of Pollify is built using Node.js and Express, structured around the MVC (Model-View-Controller) architecture to ensure clear separation of concerns, maintainability, and scalability. The backend handles user authentication, poll management, and voting logic, all interacting with a PostgreSQL database via Sequelize, an ORM that simplifies database operations.

Models and Database

Pollify’s data is stored in PostgreSQL, managed by Sequelize. Each major entity—such as User, Poll, and Vote—has its own model, defined in Sequelize. These models define the schema, relationships, and behaviors of data entities, ensuring consistency and integrity within the database.

For example, a User can create multiple Polls and cast Votes on various polls. This many-to-many relationship is modeled through the User, Poll, and Vote entities in the database.

// models/user.js
export const User = sequelize.define('user', {
  id: { type: DataTypes.UUID, primaryKey: true, allowNull: false },
  username: { type: DataTypes.STRING, allowNull: false },
  password: { type: DataTypes.STRING, allowNull: false },
})

The database schema supports scalable growth, with indexes and optimized queries for performance. PostgreSQL’s support for complex queries, transactions, and migrations makes it the ideal choice for handling Pollify’s relational data.

JWT-Based Authentication

User authentication in Pollify is stateless, relying on JSON Web Tokens (JWT) to secure user sessions. When users log in or register, the server generates a JWT that contains a signed payload with the user’s ID and other relevant details. This token is then sent to the client and used for subsequent authenticated requests, such as creating polls or voting.

Here’s a simplified example of the login controller, which authenticates a user and issues a JWT upon successful authentication:

import jwt from 'jsonwebtoken'
import bcrypt from 'bcrypt'
import { User } from '../models/user'

export const login = async (req, res) => {
  const { username, password } = req.body
  const user = await User.findOne({ where: { username } })

  if (!user || !bcrypt.compareSync(password, user.password)) {
    return res.status(401).json({ error: 'Invalid credentials' })
  }

  const token = jwt.sign({ id: user.id, username: user.username }, process.env.JWT_SECRET, {
    expiresIn: '1h',
  })

  res.json({ token })
}

The JWT ensures stateless authentication, allowing the backend to scale horizontally. Since the token is self-contained, there’s no need to store session data in memory, reducing overhead and simplifying the architecture.

MVC Pattern and Controllers

The MVC (Model-View-Controller) pattern organizes the codebase into three main parts:

  • Models: Represent the application’s data and business logic (e.g., User, Poll, Vote).
  • Views: In this API-centric application, views are minimal, as most of the data is consumed by the front end. However, API responses act as the "views" that the client consumes.
  • Controllers: Handle incoming HTTP requests, interact with models, and return data to the client (or handle errors). The controllers contain business logic for creating polls, registering users, handling votes, etc.

Each controller serves a specific purpose. For example, the PollController handles CRUD operations for polls, interfacing with the Poll model and the database. Here's a sample method for creating a new poll:

// controllers/pollController.js
import { Poll } from '../models/poll'
import { User } from '../models/user'

export const createPoll = async (req, res) => {
  const { optionOne, optionTwo } = req.body
  const userId = req.user.id // Extracted from JWT

  try {
    const poll = await Poll.create({
      optionOne,
      optionTwo,
      userId,
    })

    res.status(201).json(poll)
  } catch (error) {
    res.status(500).json({ error: 'Failed to create poll' })
  }
}

The controller structure helps keep the logic modular and testable. Each controller focuses solely on handling a specific piece of logic related to its model, which also makes it easier to maintain as the application grows.

Middleware and Validation

In addition to controllers, the backend employs middleware to handle tasks such as JWT verification, input validation, and error handling. Middleware functions ensure that requests are properly authenticated and validated before reaching the controller logic, adding a layer of security and integrity to the system.

For example, a middleware function verifies JWT tokens before allowing access to protected routes:

// middleware/auth.js
import jwt from 'jsonwebtoken'

export const authenticateJWT = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1]

  if (!token) {
    return res.status(401).json({ error: 'No token provided' })
  }

  jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
    if (err) {
      return res.status(401).json({ error: 'Invalid token' })
    }

    req.user = decoded
    next()
  })
}

By ensuring that only authenticated users can access certain routes, Pollify protects sensitive operations like creating polls, voting, and viewing user-specific data.

The backend of Pollify is designed with scalability, security, and maintainability in mind. By leveraging the power of PostgreSQL for data management, JWT for stateless authentication, and the MVC pattern for organized code structure, the backend is equipped to handle increasing traffic and feature expansion with ease. The modular architecture also facilitates testing and ensures that the system can grow over time without becoming unmanageable.

CI/CD and Deployment

Pollify is deployed on AWS using Elastic Beanstalk for the backend and S3/CloudFront for the frontend, ensuring fast global delivery and scalability. The PostgreSQL database is hosted on Amazon RDS for reliability and automated backups.

A CircleCI pipeline automates the build, test, and deployment process. This includes:

  • Build and Test: The pipeline runs the Jest and Cypress test suites, ensuring both unit and end-to-end tests pass before deployment. These tests cover key user flows, such as poll creation and voting, ensuring functionality across releases.
version: 2.1

orbs:
node: circleci/node@5.0.2
eb: circleci/aws-elastic-beanstalk@2.0.1
aws-cli: circleci/aws-cli@3.1.1

jobs:
build:
  docker:
    - image: 'cimg/node:20.12'
  steps:
    - node/install:
        node-version: '20.12'
    - checkout
    - restore_cache:
        keys:
          - v1-dependencies-{{ checksum "package-lock.json" }}
          - v1-dependencies-
    - run:
        name: Install Root Dependencies
        command: |
          npm install
          echo "Root dependencies installed."
    - run:
        name: Install Front-End Dependencies
        command: |
          echo "NODE --version" 
          echo $(node --version)
          echo "NPM --version" 
          echo $(npm --version)
          npm run install-client
    - run:
        name: Install Server Dependencies
        command: |
          npm run install-server
          echo "Server dependencies installed."
    - save_cache:
        paths:
          - node_modules
        key: v1-dependencies-{{ checksum "package-lock.json" }}
    - run:
        name: Lint
        command: |
          npm run lint
    - run:
        name: Run Tests
        command: |
          npm run test-server
          npm run test-client
          echo "Tests completed successfully."
    - run:
        name: Front-End Build
        command: |
          REACT_APP_API_URL=$REACT_APP_API_URL npm run build-client
          echo "Frontend built successfully."
    - run:
        name: Server Build
        command: |
          npm run build-server
          echo "Backend built successfully."
    - persist_to_workspace:
        root: .
        paths:
          - frontend/build
          - server/dist
          - server/dist.zip

deploy:
  docker:
    - image: 'cimg/base:stable'
  steps:
    - node/install:
        node-version: '20.12'
    - checkout
    - aws-cli/setup
    - eb/setup
    - attach_workspace:
        at: .
    - run:
        name: Set Up AWS Credentials
        command: |
          echo "Setting up AWS credentials..."
          aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
          aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY
          aws configure set default.region $AWS_DEFAULT_REGION
    - run:
        name: Deploy Frontend to S3
        command: |
          echo "Deploying Frontend to S3..."
          aws s3 sync frontend/build s3://$BUCKET_NAME --delete
    - run:
        name: Deploy Backend to Elastic Beanstalk
        command: |
          cd server
          eb init $EB_APP_NAME -r $AWS_DEFAULT_REGION -p node.js
          eb setenv JWT_SECRET=$JWT_SECRET NODE_ENV=production POSTGRES_HOST=$POSTGRES_HOST POSTGRES_NAME=$POSTGRES_NAME POSTGRES_PASSWORD=$POSTGRES_PASSWORD POSTGRES_PORT=$POSTGRES_PORT POSTGRES_USER=$POSTGRES_USER
          echo "Deploying Backend to Elastic Beanstalk..."
          eb deploy $EB_ENV
          echo "Deployment completed."

workflows:
employee-polling:
  jobs:
    - build
    - hold:
        type: approval
        requires:
          - build
        filters:
          branches:
            only:
              - main
    - deploy:
        requires:
          - hold
  • Deployment: The pipeline deploys the frontend to an S3 bucket, and the backend to Elastic Beanstalk. This fully automated process ensures that every commit to the main branch results in a tested and deployed version of the application.

Testing Strategy

Testing in Pollify is comprehensive, with both end-to-end tests via Cypress and unit tests using Jest. Cypress is used to verify critical user flows, such as logging in, creating polls, and viewing results. Jest focuses on testing the Redux logic, including actions like fetching polls and creating new ones.

describe('Pollify End-to-End Tests', () => {
  it('Creates a new poll and verifies it on the dashboard', () => {
    cy.visit('/create')
    cy.get('input[id="optionOneText"]').type('go scuba diving')
    cy.get('input[id="optionTwoText"]').type('skydiving')
    cy.get('button[type="submit"]').click()
    cy.contains('Would you rather go scuba diving or skydiving?')
  })
})

This ensures the app's core functionality is robust and ready for production environments.

Conclusion: A Scalable and Robust Polling Platform

Pollify's transformation from a simple Udacity project to a fully-featured polling platform illustrates the importance of system design, testing, and scalability. The combination of React, Redux, Node.js, PostgreSQL, and AWS provides a solid foundation for a production-ready application. By leveraging modern tools and practices, Pollify delivers a seamless user experience while being flexible enough to handle growth and evolving requirements.

Visit pollify.dev to see the final product in action.