Chocolate for Peace — Eine Full-Stack-Voting-Plattform für wohltätige Zwecke

Chocolate for Peace — Eine Full-Stack-Voting-Plattform für wohltätige Zwecke

Die Idee

Das Konzept ist einfach und menschlich: Eine Schokoladenmarke spendet einen Teil ihres Umsatzes für wohltätige Zwecke. Aber statt dass das Unternehmen selbst entscheidet, wohin das Geld fließt, entscheiden die Kunden — indem sie für die Organisationen abstimmen, die ihnen am meisten am Herzen liegen.

Chocolate for Peace ist die Plattform, die ich dafür entwickelt habe. Kunden und Follower durchsuchen gemeinnützige Projekte, stimmen für ihre Favoriten ab und verfolgen die Ergebnisse in Echtzeit. Die Organisation mit den meisten Stimmen am Ende eines Zyklus gewinnt die Spende.

Es ist nicht nur eine Abstimmungs-App. Es ist ein Vertrauensmechanismus zwischen einer Marke und ihrer Community.


Architekturüberblick

Das System ist in drei unabhängige Schichten aufgeteilt:

Vue.js 3 SPA (Öffentliches Frontend)    — kundenorientierte Abstimmungsoberfläche
Vue.js 3 SPA (Admin-Panel)              — interne Projekt- und Benutzerverwaltung
         ↓ REST / JSON (JWT Bearer)
Spring Boot 3 REST API (Backend)
         ↓ JPA / Hibernate
PostgreSQL (Datenbank)

Alle drei kommunizieren ausschließlich über die REST API. Das Backend besitzt die gesamte Geschäftslogik und Sicherheit — die Frontends sind schlanke Clients, die nur das darstellen, was die API erlaubt.


Backend: Spring Boot 3

Stack

  • Spring Boot 3.4.5 mit Spring Security, Spring Data JPA
  • PostgreSQL als primäre Datenbank
  • JWT via jjwt 0.11.5 (HMAC SHA-256 signierte Tokens, 1 Stunde Gültigkeit)
  • Jakarta Validation für Request-DTO-Validierung
  • Hibernate Auditing (@CreatedDate, @LastModifiedDate) auf der Project-Entity

Datenmodell

Vier Kern-Entities: User, Project, Vote, ProjectImage, sowie ProjectComment.

java// Vote — das Herzstück des Systems
@Entity
public class Vote {
    @Id @GeneratedValue
    private UUID id;

@ManyToOne(optional = false) private User user;

@ManyToOne(optional = false) private Project project;

private LocalDateTime votedAt; }

Projekte durchlaufen einen Lebenszyklus-Status, der durch eine strikte State Machine verwaltet wird:

javapublic enum Status { PENDING, APPROVED, REJECTED }

Ein vom Nutzer eingereichtes Projekt startet immer als PENDING. Ein Admin muss es explizit genehmigen, bevor es öffentlich abstimmbar wird. Das Zurücksetzen eines Projekts auf PENDING über die API ist bewusst blockiert:

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

Das verhindert versehentliche oder böswillige Status-Rollbacks, sobald ein Projekt den Genehmigungsworkflow durchläuft.

Sicherheitsdesign

Die Sicherheit wird über Spring Securitys SecurityFilterChain konfiguriert:

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()
)

Öffentliches Browsen erfordert kein Login. Abstimmen erfordert Authentifizierung. Admin-Operationen erfordern die ADMIN-Rolle. Rollenprüfungen liegen auf der API-Schicht, nicht im Frontend.

JWT-Tokens werden mit HMAC SHA-256 signiert und bei jeder Anfrage durch einen eigenen JwtTokenFilter validiert, der vor Springs UsernamePasswordAuthenticationFilter eingehängt wird.

Stimmenintegrität: Eine Stimme pro Nutzer pro Monat

Die wichtigste Geschäftsregel ist die Verhinderung doppelter Stimmen. Ein Nutzer darf für dasselbe Projekt nur einmal pro Kalendermonat abstimmen:

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(); } }

Das monatliche Zeitfenster hält Nutzer über Zyklen hinweg engagiert — sie können im folgenden Monat erneut abstimmen — verhindert aber Stimmenmissbrauch innerhalb eines Zeitraums.

Exception Handling

Ein @RestControllerAdvice GlobalExceptionHandler bildet alle eigenen Business-Exceptions auf strukturierte Fehlerantworten ab:

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

Eigene Exceptions: AlreadyVotedException, ProjectNotFoundException, UserNotFoundException, InvalidProjectStatusException, EmailAlreadyExistsException. Jede trägt ihren eigenen HTTP-Status und Fehlercode — das Frontend kann auf ALREADY_VOTED programmatisch reagieren, ohne Fehlermeldungsstrings zu parsen.

Bild-Upload

Projekte unterstützen Mehrfach-Bild-Uploads via multipart/form-data. Bilder werden unter uploads/projects/ mit UUID-präfixierten Dateinamen gespeichert, um Kollisionen zu vermeiden.


Frontend: Vue.js 3 (Öffentlich)

Stack

  • Vue.js 3 mit Composition API und