Chocolate for Peace — A Full-Stack Voting Platform for Charity

Chocolate for Peace — A Full-Stack Voting Platform for Charity

The Idea

The concept is simple and human: a chocolate brand donates a portion of its revenue to charity. But instead of the company deciding where the money goes, the customers decide — by voting for the organizations they care about most.

Chocolate for Peace is the platform I built to make that possible. Customers and followers browse charity projects, vote for their favourites, and watch the results in real time. The organization with the most votes at the end of a cycle wins the donation.

It is not just a voting app. It is a trust mechanism between a brand and its community.


Architecture Overview

The system is split into three independent layers:

Vue.js 3 SPA (Public Frontend)         — customer-facing voting interface
Vue.js 3 SPA (Admin Panel)             — internal project and user management
         ↓ REST / JSON (JWT Bearer)
Spring Boot 3 REST API (Backend)
         ↓ JPA / Hibernate
PostgreSQL (Database)

All three communicate exclusively through the REST API. The backend owns all business logic and security — the frontends are thin clients that render what the API permits.


Backend: Spring Boot 3

Stack

  • Spring Boot 3.4.5 with Spring Security, Spring Data JPA
  • PostgreSQL as the primary database
  • JWT via jjwt 0.11.5 (HMAC SHA-256 signed tokens, 1-hour expiry)
  • Jakarta Validation for request DTO validation
  • Hibernate Auditing (@CreatedDate, @LastModifiedDate) on the Project entity

Data Model

Four core entities: User, Project, Vote, ProjectImage, plus ProjectComment.

java// Vote — the heart of the system
@Entity
public class Vote {
    @Id @GeneratedValue
    private UUID id;

@ManyToOne(optional = false) private User user;

@ManyToOne(optional = false) private Project project;

private LocalDateTime votedAt; }

Projects carry a lifecycle status managed through a strict state machine:

javapublic enum Status { PENDING, APPROVED, REJECTED }

A project submitted by a user always starts as PENDING. An admin must explicitly approve it before it becomes publicly voteable. Setting a project back to PENDING via the API is explicitly blocked:

javaif (newStatus == Status.PENDING) {
    throw new SettingStatusToPendingNotAllowedException();
}

This prevents accidental or malicious state rollbacks once a project enters the approval workflow.

Security Design

Security is configured via Spring Security's SecurityFilterChain:

java.authorizeHttpRequests(auth -> auth
    .requestMatchers("/auth/**").permitAll()
    .requestMatchers(HttpMethod.GET, "/api/projects/**").permitAll()
    .requestMatchers(HttpMethod.GET, "/api/votes/count/**").permitAll()
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .requestMatchers(HttpMethod.PUT, "/api/projects/*/approval").hasRole("ADMIN")
    .requestMatchers(HttpMethod.POST, "/api/votes/**").authenticated()
    .anyRequest().authenticated()
)

Public browsing requires no login. Voting requires authentication. Admin operations require the ADMIN role. Role checks live at the API layer, not in the frontend.

JWT tokens are signed with HMAC SHA-256 and validated on every request by a custom JwtTokenFilter inserted before Spring's UsernamePasswordAuthenticationFilter.

Vote Integrity: One Vote Per User Per Month

The most critical business rule is duplicate vote prevention. A user may vote for the same project only once per calendar month:

javaprivate void checkIfUserHasVotedThisMonth(User user, Project project) {
    LocalDateTime startOfMonth = LocalDateTime.now()
        .withDayOfMonth(1).with(LocalTime.MIN);
    LocalDateTime endOfMonth = LocalDateTime.now()
        .withDayOfMonth(LocalDateTime.now().toLocalDate().lengthOfMonth())
        .with(LocalTime.MAX);

if (voteRepository.existsByUserAndProjectAndVotedAtBetween( user, project, startOfMonth, endOfMonth)) { throw new AlreadyVotedException(); } }

The monthly window keeps users engaged across cycles — they can vote again the following month — while preventing ballot stuffing within a single period.

Exception Handling

A @RestControllerAdvice GlobalExceptionHandler maps all custom business exceptions to structured error responses:

java@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponseDTO> handleBusinessException(BusinessException ex) {
    return ResponseEntity
        .status(ex.getStatus())
        .body(new ErrorResponseDTO(ex.getCode(), ex.getMessage()));
}

Custom exceptions include: AlreadyVotedException, ProjectNotFoundException, UserNotFoundException, InvalidProjectStatusException, EmailAlreadyExistsException. Each carries its own HTTP status and error code — the frontend reacts programmatically to ALREADY_VOTED without parsing message strings.

Image Upload

Projects support multi-image uploads via multipart/form-data. Images are stored to disk under uploads/projects/ with UUID-prefixed filenames to prevent collisions.


Frontend: Vue.js 3 (Public)

Stack

  • Vue.js 3 with Composition API and