Adding Sentiment to Journal App
In this chapter, we explore a practical architectural pivot in our Journal Application. We will transition from automated sentiment analysis of user content to a user-defined, privacy-first sentiment model. We will walk through the implementation of a custom Java Enum, database mapping, a weekly scheduler that aggregates user sentiments, and an integration testing strategy to verify the system's behavior.
1. Why Do We Need This? (The Problem & Pivot)
In early iterations of the Journal Application, the architecture planned to use an automated SentimentAnalysisService to parse the text content of journal entries and programmatically determine the user's emotional state.
However, real-world production systems often face strict privacy, compliance, and security constraints:
- End-to-End (E2E) Encryption: To ensure absolute user privacy, journal content should be encrypted on the client side or stored in an encrypted format that the server cannot read.
- Inability to Parse Content: If the server cannot read the raw text of the journal entry, automated natural language processing (NLP) or sentiment analysis services cannot run.
- The Pivot: Instead of programmatically analyzing the text, we shift the responsibility to the user. When creating a journal entry, the user explicitly selects their current sentiment from a predefined list. This preserves privacy while still allowing the application to track emotional trends over time.
2. Core Mental Model & Architecture
The updated system relies on a clean, decoupled flow. The user selects a sentiment when writing a journal entry, which is stored directly in MongoDB. A background scheduler runs periodically to aggregate these sentiments and email a weekly summary to users who have opted into the service.
System Data Flow
3. Database Mapping & The Sentiment Enum
To prevent users from submitting arbitrary or invalid sentiment strings (e.g., "good", "bad", "superb"), we enforce strict type safety using a Java Enum.
Step 1: Define the Sentiment Enum
We create a Sentiment enum inside a dedicated enums package.
package com.example.journalApp.enums;
public enum Sentiment {
HAPPY,
SAD,
ANGRY,
ANXIOUS
}
Step 2: Update the JournalEntry Model
We replace any raw String fields for sentiment with our new Sentiment enum. Spring Data MongoDB automatically maps Java Enums to their corresponding String representations in the database.
package com.example.journalApp.entity;
import com.example.journalApp.enums.Sentiment;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
@Document(collection = "journal_entries")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JournalEntry {
@Id
private ObjectId id;
private String title;
private String content;
private LocalDateTime date;
private Sentiment sentiment; // Enforced type safety
}
Note on DB Mapping: When saving a
JournalEntrydocument to MongoDB, Spring Data converts theSentimentenum to a plain string (e.g.,"HAPPY"). When reading the document back, it deserializes the string back into the corresponding Enum constant.
4. The Weekly Scheduler Logic (UserScheduler)
The UserScheduler is a background worker responsible for scanning the database, aggregating weekly sentiments, and sending out summary emails.
Key Responsibilities:
- Filter Eligible Users: Fetch only users who have opted into sentiment analysis (
sentimentAnalysis = true) and have a registered email address. - Time-Window Filtering: Retrieve journal entries created within the last 7 days.
- Null-Safe Aggregation: Extract sentiments while safely ignoring any entries where the sentiment field is
null. - Frequency Calculation: Determine which sentiment occurred most frequently.
- Email Dispatch: Send the calculated sentiment to the user.
The Scheduler Implementation
package com.example.journalApp.scheduler;
import com.example.journalApp.entity.JournalEntry;
import com.example.journalApp.entity.User;
import com.example.journalApp.enums.Sentiment;
import com.example.journalApp.repository.UserRepository;
import com.example.journalApp.service.EmailService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Component
public class UserScheduler {
@Autowired
private UserRepository userRepository;
@Autowired
private EmailService emailService;
// Runs every Sunday at 9:00 AM
@Scheduled(cron = "0 0 9 * * SUN")
public void fetchUsersAndSendSentimentMail() {
// 1. Fetch users opted into sentiment analysis with valid emails
List<User> users = userRepository.findBySentimentAnalysisTrueAndEmailNotNull();
for (User user : users) {
List<JournalEntry> journalEntries = user.getJournalEntries();
// 2. Filter entries from the last 7 days and extract non-null sentiments
List<Sentiment> sentiments = journalEntries.stream()
.filter(entry -> entry.getDate().isAfter(LocalDateTime.now().minusDays(7)))
.map(JournalEntry::getSentiment)
.filter(Objects::nonNull)
.collect(Collectors.toList());
// 3. Find the most frequent sentiment
Sentiment mostFrequentSentiment = calculateMostFrequentSentiment(sentiments);
// 4. Send email if a sentiment trend exists
if (mostFrequentSentiment != null) {
emailService.sendEmail(
user.getEmail(),
"Your Weekly Sentiment Summary",
"Hello " + user.getUserName() + ",\n\nYour most frequent sentiment this week was: " + mostFrequentSentiment.name()
);
}
}
}
private Sentiment calculateMostFrequentSentiment(List<Sentiment> sentiments) {
if (sentiments.isEmpty()) {
return null;
}
Map<Sentiment, Integer> sentimentCounts = new HashMap<>();
for (Sentiment sentiment : sentiments) {
sentimentCounts.put(sentiment, sentimentCounts.getOrDefault(sentiment, 0) + 1);
}
Sentiment mostFrequent = null;
int maxCount = 0;
for (Map.Entry<Sentiment, Integer> entry : sentimentCounts.entrySet()) {
if (entry.getValue() > maxCount) {
maxCount = entry.getValue();
mostFrequent = entry.getKey();
}
}
return mostFrequent;
}
}
5. Frequency Aggregation Algorithm
The core algorithm counts occurrences of each sentiment using a frequency map.
Mathematical & Complexity Analysis
Let be the number of journal entries written by a single user within the last 7 days, and let be the number of unique sentiment types (where since our enum has 4 constants: HAPPY, SAD, ANGRY, ANXIOUS).
-
Time Complexity:
- Filtering and mapping the list takes time.
- Building the frequency map takes time.
- Finding the maximum entry in the map takes time.
- Total Time Complexity per user: . Since is a small constant (), this simplifies to .
-
Space Complexity:
- The frequency map stores at most keys.
- Total Auxiliary Space Complexity: auxiliary space.
Handling Ties and Null Values
- Null Values: If a user did not select a sentiment for a particular entry, the sentiment field is stored as
null. The stream pipeline filters these out using.filter(Objects::nonNull)to avoid throwing aNullPointerExceptionduring map insertion. - Tie-Breaker Behavior: If two sentiments have the same frequency (e.g., 1
SADand 1ANXIOUS), the sentiment selected as "most frequent" depends on the iteration order of theHashMap. Because standardHashMapdoes not guarantee insertion or natural ordering, the tie-breaker is non-deterministic.
6. Integration Testing with @SpringBootTest
Waiting for a cron trigger (e.g., every Sunday at 9:00 AM) to fire during development is highly impractical. To test the scheduler logic instantly, we write a Spring Boot integration test that autowires the scheduler and executes the method programmatically.
package com.example.journalApp.scheduler;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class UserSchedulerTest {
@Autowired
private UserScheduler userScheduler;
@Test
public void testFetchUsersAndSendSentimentMail() {
// Manually trigger the scheduler logic bypass cron trigger
userScheduler.fetchUsersAndSendSentimentMail();
}
}
7. Complete Flow Walkthrough (The Test Case)
To understand how the system behaves under different user configurations, let's analyze a database state containing three users: Vipul, Chintu, and Mintu.
Database State Summary
Step-by-Step Execution Flow
Summary and Key Takeaways
- Privacy-First Design: Transitioning from automated text parsing to user-selected enums respects end-to-end encryption constraints and ensures user data privacy.
- Type Safety with Enums: Using a Java
Enum(Sentiment) instead of raw strings prevents invalid data entry and simplifies database mapping. - Automatic MongoDB Mapping: Spring Data MongoDB seamlessly maps Java Enums to standard database strings and handles bidirectional conversion automatically.
- Time-Window Filtering: Java 8 Streams and
LocalDateTimemake it simple to filter database records dynamically (e.g.,entry.getDate().isAfter(LocalDateTime.now().minusDays(7))). - Null-Safe Aggregation: Always filter out
nullvalues in stream pipelines before performing frequency counts to prevent runtime exceptions. - Bypassing Cron for Testing: Using
@SpringBootTestto autowire and manually invoke scheduled methods is the standard industry pattern for testing cron-based business logic.